diffview.go

  1package diffview
  2
  3import (
  4	"fmt"
  5	"image/color"
  6	"strconv"
  7	"strings"
  8
  9	"github.com/alecthomas/chroma/v2"
 10	"github.com/alecthomas/chroma/v2/lexers"
 11	"github.com/aymanbagabas/go-udiff"
 12	"github.com/aymanbagabas/go-udiff/myers"
 13	"github.com/charmbracelet/lipgloss/v2"
 14	"github.com/charmbracelet/x/ansi"
 15)
 16
 17const (
 18	leadingSymbolsSize = 2
 19	lineNumPadding     = 1
 20)
 21
 22type file struct {
 23	path    string
 24	content string
 25}
 26
 27type layout int
 28
 29const (
 30	layoutUnified layout = iota + 1
 31	layoutSplit
 32)
 33
 34// DiffView represents a view for displaying differences between two files.
 35type DiffView struct {
 36	layout       layout
 37	before       file
 38	after        file
 39	contextLines int
 40	lineNumbers  bool
 41	highlight    bool
 42	height       int
 43	width        int
 44	xOffset      int
 45	yOffset      int
 46	style        Style
 47	tabWidth     int
 48	chromaStyle  *chroma.Style
 49
 50	isComputed bool
 51	err        error
 52	unified    udiff.UnifiedDiff
 53	edits      []udiff.Edit
 54
 55	splitHunks []splitHunk
 56
 57	codeWidth       int
 58	fullCodeWidth   int  // with leading symbols
 59	extraColOnAfter bool // add extra column on after panel
 60	beforeNumDigits int
 61	afterNumDigits  int
 62}
 63
 64// New creates a new DiffView with default settings.
 65func New() *DiffView {
 66	dv := &DiffView{
 67		layout:       layoutUnified,
 68		contextLines: udiff.DefaultContextLines,
 69		lineNumbers:  true,
 70		tabWidth:     8,
 71	}
 72	dv.style = DefaultDarkStyle()
 73	return dv
 74}
 75
 76// Unified sets the layout of the DiffView to unified.
 77func (dv *DiffView) Unified() *DiffView {
 78	dv.layout = layoutUnified
 79	return dv
 80}
 81
 82// Split sets the layout of the DiffView to split (side-by-side).
 83func (dv *DiffView) Split() *DiffView {
 84	dv.layout = layoutSplit
 85	return dv
 86}
 87
 88// Before sets the "before" file for the DiffView.
 89func (dv *DiffView) Before(path, content string) *DiffView {
 90	dv.before = file{path: path, content: content}
 91	return dv
 92}
 93
 94// After sets the "after" file for the DiffView.
 95func (dv *DiffView) After(path, content string) *DiffView {
 96	dv.after = file{path: path, content: content}
 97	return dv
 98}
 99
100// ContextLines sets the number of context lines for the DiffView.
101func (dv *DiffView) ContextLines(contextLines int) *DiffView {
102	dv.contextLines = contextLines
103	return dv
104}
105
106// Style sets the style for the DiffView.
107func (dv *DiffView) Style(style Style) *DiffView {
108	dv.style = style
109	return dv
110}
111
112// LineNumbers sets whether to display line numbers in the DiffView.
113func (dv *DiffView) LineNumbers(lineNumbers bool) *DiffView {
114	dv.lineNumbers = lineNumbers
115	return dv
116}
117
118// SyntaxHightlight sets whether to enable syntax highlighting in the DiffView.
119func (dv *DiffView) SyntaxHightlight(highlight bool) *DiffView {
120	dv.highlight = highlight
121	return dv
122}
123
124// Height sets the height of the DiffView.
125func (dv *DiffView) Height(height int) *DiffView {
126	dv.height = height
127	return dv
128}
129
130// Width sets the width of the DiffView.
131func (dv *DiffView) Width(width int) *DiffView {
132	dv.width = width
133	return dv
134}
135
136// XOffset sets the horizontal offset for the DiffView.
137func (dv *DiffView) XOffset(xOffset int) *DiffView {
138	dv.xOffset = xOffset
139	return dv
140}
141
142// YOffset sets the vertical offset for the DiffView.
143func (dv *DiffView) YOffset(yOffset int) *DiffView {
144	dv.yOffset = yOffset
145	return dv
146}
147
148// TabWidth sets the tab width. Only relevant for code that contains tabs, like
149// Go code.
150func (dv *DiffView) TabWidth(tabWidth int) *DiffView {
151	dv.tabWidth = tabWidth
152	return dv
153}
154
155// ChromaStyle sets the chroma style for syntax highlighting.
156// If nil, no syntax highlighting will be applied.
157func (dv *DiffView) ChromaStyle(style *chroma.Style) *DiffView {
158	dv.chromaStyle = style
159	return dv
160}
161
162// String returns the string representation of the DiffView.
163func (dv *DiffView) String() string {
164	dv.replaceTabs()
165	if err := dv.computeDiff(); err != nil {
166		return err.Error()
167	}
168	dv.convertDiffToSplit()
169	dv.adjustStyles()
170	dv.detectNumDigits()
171
172	if dv.width <= 0 {
173		dv.detectCodeWidth()
174	} else {
175		dv.resizeCodeWidth()
176	}
177
178	style := lipgloss.NewStyle()
179	if dv.width > 0 {
180		style = style.MaxWidth(dv.width)
181	}
182	if dv.height > 0 {
183		style = style.MaxHeight(dv.height)
184	}
185
186	switch dv.layout {
187	case layoutUnified:
188		return style.Render(strings.TrimSuffix(dv.renderUnified(), "\n"))
189	case layoutSplit:
190		return style.Render(strings.TrimSuffix(dv.renderSplit(), "\n"))
191	default:
192		panic("unknown diffview layout")
193	}
194}
195
196// replaceTabs replaces tabs in the before and after file contents with spaces
197// according to the specified tab width.
198func (dv *DiffView) replaceTabs() {
199	spaces := strings.Repeat(" ", dv.tabWidth)
200	dv.before.content = strings.ReplaceAll(dv.before.content, "\t", spaces)
201	dv.after.content = strings.ReplaceAll(dv.after.content, "\t", spaces)
202}
203
204// computeDiff computes the differences between the "before" and "after" files.
205func (dv *DiffView) computeDiff() error {
206	if dv.isComputed {
207		return dv.err
208	}
209	dv.isComputed = true
210	dv.edits = myers.ComputeEdits( //nolint:staticcheck
211		dv.before.content,
212		dv.after.content,
213	)
214	dv.unified, dv.err = udiff.ToUnifiedDiff(
215		dv.before.path,
216		dv.after.path,
217		dv.before.content,
218		dv.edits,
219		dv.contextLines,
220	)
221	return dv.err
222}
223
224// convertDiffToSplit converts the unified diff to a split diff if the layout is
225// set to split.
226func (dv *DiffView) convertDiffToSplit() {
227	if dv.layout != layoutSplit {
228		return
229	}
230
231	dv.splitHunks = make([]splitHunk, len(dv.unified.Hunks))
232	for i, h := range dv.unified.Hunks {
233		dv.splitHunks[i] = hunkToSplit(h)
234	}
235}
236
237// adjustStyles adjusts adds padding and alignment to the styles.
238func (dv *DiffView) adjustStyles() {
239	setPadding := func(s lipgloss.Style) lipgloss.Style {
240		return s.Padding(0, lineNumPadding).Align(lipgloss.Right)
241	}
242	dv.style.MissingLine.LineNumber = setPadding(dv.style.MissingLine.LineNumber)
243	dv.style.DividerLine.LineNumber = setPadding(dv.style.DividerLine.LineNumber)
244	dv.style.EqualLine.LineNumber = setPadding(dv.style.EqualLine.LineNumber)
245	dv.style.InsertLine.LineNumber = setPadding(dv.style.InsertLine.LineNumber)
246	dv.style.DeleteLine.LineNumber = setPadding(dv.style.DeleteLine.LineNumber)
247}
248
249// detectNumDigits calculates the maximum number of digits needed for before and
250// after line numbers.
251func (dv *DiffView) detectNumDigits() {
252	dv.beforeNumDigits = 0
253	dv.afterNumDigits = 0
254
255	for _, h := range dv.unified.Hunks {
256		dv.beforeNumDigits = max(dv.beforeNumDigits, len(strconv.Itoa(h.FromLine+len(h.Lines))))
257		dv.afterNumDigits = max(dv.afterNumDigits, len(strconv.Itoa(h.ToLine+len(h.Lines))))
258	}
259}
260
261// detectCodeWidth calculates the maximum width of code lines in the diff view.
262func (dv *DiffView) detectCodeWidth() {
263	switch dv.layout {
264	case layoutUnified:
265		dv.detectUnifiedCodeWidth()
266	case layoutSplit:
267		dv.detectSplitCodeWidth()
268	}
269	dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
270}
271
272// detectUnifiedCodeWidth calculates the maximum width of code lines in a
273// unified diff.
274func (dv *DiffView) detectUnifiedCodeWidth() {
275	dv.codeWidth = 0
276
277	for _, h := range dv.unified.Hunks {
278		shownLines := ansi.StringWidth(dv.hunkLineFor(h))
279
280		for _, l := range h.Lines {
281			lineWidth := ansi.StringWidth(strings.TrimSuffix(l.Content, "\n")) + 1
282			dv.codeWidth = max(dv.codeWidth, lineWidth, shownLines)
283		}
284	}
285}
286
287// detectSplitCodeWidth calculates the maximum width of code lines in a
288// split diff.
289func (dv *DiffView) detectSplitCodeWidth() {
290	dv.codeWidth = 0
291
292	for i, h := range dv.splitHunks {
293		shownLines := ansi.StringWidth(dv.hunkLineFor(dv.unified.Hunks[i]))
294
295		for _, l := range h.lines {
296			if l.before != nil {
297				codeWidth := ansi.StringWidth(strings.TrimSuffix(l.before.Content, "\n")) + 1
298				dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
299			}
300			if l.after != nil {
301				codeWidth := ansi.StringWidth(strings.TrimSuffix(l.after.Content, "\n")) + 1
302				dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
303			}
304		}
305	}
306}
307
308// resizeCodeWidth resizes the code width to fit within the specified width.
309func (dv *DiffView) resizeCodeWidth() {
310	fullNumWidth := dv.beforeNumDigits + dv.afterNumDigits
311	fullNumWidth += lineNumPadding * 4 // left and right padding for both line numbers
312
313	switch dv.layout {
314	case layoutUnified:
315		dv.codeWidth = dv.width - fullNumWidth - leadingSymbolsSize
316	case layoutSplit:
317		remainingWidth := dv.width - fullNumWidth - leadingSymbolsSize*2
318		dv.codeWidth = remainingWidth / 2
319		dv.extraColOnAfter = isOdd(remainingWidth)
320	}
321
322	dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
323}
324
325// renderUnified renders the unified diff view as a string.
326func (dv *DiffView) renderUnified() string {
327	var b strings.Builder
328
329	fullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
330	printedLines := -dv.yOffset
331
332	write := func(s string) {
333		if printedLines >= 0 {
334			b.WriteString(s)
335		}
336	}
337
338outer:
339	for i, h := range dv.unified.Hunks {
340		ls := dv.style.DividerLine
341		if dv.lineNumbers {
342			write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
343			write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
344		}
345		content := ansi.Truncate(dv.hunkLineFor(h), dv.fullCodeWidth, "…")
346		write(ls.Code.Width(dv.fullCodeWidth).Render(content))
347		write("\n")
348		printedLines++
349
350		beforeLine := h.FromLine
351		afterLine := h.ToLine
352
353		for j, l := range h.Lines {
354			// print ellipis if we don't have enough space to print the rest of the diff
355			hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
356			isLastHunk := i+1 == len(dv.unified.Hunks)
357			isLastLine := j+1 == len(h.Lines)
358			if hasReachedHeight && (!isLastHunk || !isLastLine) {
359				ls := dv.lineStyleForType(l.Kind)
360				if dv.lineNumbers {
361					write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
362					write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
363				}
364				write(fullContentStyle.Render(
365					ls.Code.Width(dv.fullCodeWidth).Render("  …"),
366				))
367				write("\n")
368				break outer
369			}
370
371			getContent := func(ls LineStyle) string {
372				content := strings.TrimSuffix(l.Content, "\n")
373				content = dv.hightlightCode(content, ls.Code.GetBackground())
374				content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
375				content = ansi.Truncate(content, dv.codeWidth, "…")
376				return content
377			}
378
379			leadingEllipsis := dv.xOffset > 0 && strings.TrimSpace(content) != ""
380
381			switch l.Kind {
382			case udiff.Equal:
383				ls := dv.style.EqualLine
384				content := getContent(ls)
385				if dv.lineNumbers {
386					write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
387					write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
388				}
389				write(fullContentStyle.Render(
390					ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", "  ") + content),
391				))
392				beforeLine++
393				afterLine++
394			case udiff.Insert:
395				ls := dv.style.InsertLine
396				content := getContent(ls)
397				if dv.lineNumbers {
398					write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
399					write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
400				}
401				write(fullContentStyle.Render(
402					ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
403						ls.Code.Width(dv.codeWidth).Render(content),
404				))
405				afterLine++
406			case udiff.Delete:
407				ls := dv.style.DeleteLine
408				content := getContent(ls)
409				if dv.lineNumbers {
410					write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
411					write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
412				}
413				write(fullContentStyle.Render(
414					ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
415						ls.Code.Width(dv.codeWidth).Render(content),
416				))
417				beforeLine++
418			}
419			write("\n")
420
421			printedLines++
422		}
423	}
424
425	for printedLines < dv.height {
426		ls := dv.style.MissingLine
427		if dv.lineNumbers {
428			write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
429			write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
430		}
431		write(ls.Code.Width(dv.fullCodeWidth).Render("  "))
432		write("\n")
433		printedLines++
434	}
435
436	return b.String()
437}
438
439// renderSplit renders the split (side-by-side) diff view as a string.
440func (dv *DiffView) renderSplit() string {
441	var b strings.Builder
442
443	beforeFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
444	afterFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth + btoi(dv.extraColOnAfter))
445	printedLines := -dv.yOffset
446
447	write := func(s string) {
448		if printedLines >= 0 {
449			b.WriteString(s)
450		}
451	}
452
453outer:
454	for i, h := range dv.splitHunks {
455		ls := dv.style.DividerLine
456		if dv.lineNumbers {
457			write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
458		}
459		content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
460		write(ls.Code.Width(dv.fullCodeWidth).Render(content))
461		if dv.lineNumbers {
462			write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
463		}
464		write(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
465		write("\n")
466		printedLines++
467
468		beforeLine := h.fromLine
469		afterLine := h.toLine
470
471		for j, l := range h.lines {
472			// print ellipis if we don't have enough space to print the rest of the diff
473			hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
474			isLastHunk := i+1 == len(dv.unified.Hunks)
475			isLastLine := j+1 == len(h.lines)
476			if hasReachedHeight && (!isLastHunk || !isLastLine) {
477				ls := dv.style.MissingLine
478				if l.before != nil {
479					ls = dv.lineStyleForType(l.before.Kind)
480				}
481				if dv.lineNumbers {
482					write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
483				}
484				write(beforeFullContentStyle.Render(
485					ls.Code.Width(dv.fullCodeWidth).Render("  …"),
486				))
487				ls = dv.style.MissingLine
488				if l.after != nil {
489					ls = dv.lineStyleForType(l.after.Kind)
490				}
491				if dv.lineNumbers {
492					write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
493				}
494				write(afterFullContentStyle.Render(
495					ls.Code.Width(dv.fullCodeWidth).Render("  …"),
496				))
497				write("\n")
498				break outer
499			}
500
501			getContent := func(content string, ls LineStyle) string {
502				content = strings.TrimSuffix(content, "\n")
503				content = dv.hightlightCode(content, ls.Code.GetBackground())
504				content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
505				content = ansi.Truncate(content, dv.codeWidth, "…")
506				return content
507			}
508			getLeadingEllipsis := func(content string) bool {
509				return dv.xOffset > 0 && strings.TrimSpace(content) != ""
510			}
511
512			switch {
513			case l.before == nil:
514				ls := dv.style.MissingLine
515				if dv.lineNumbers {
516					write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
517				}
518				write(beforeFullContentStyle.Render(
519					ls.Code.Width(dv.fullCodeWidth).Render("  "),
520				))
521			case l.before.Kind == udiff.Equal:
522				ls := dv.style.EqualLine
523				content := getContent(l.before.Content, ls)
524				leadingEllipsis := getLeadingEllipsis(content)
525				if dv.lineNumbers {
526					write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
527				}
528				write(beforeFullContentStyle.Render(
529					ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", "  ") + content),
530				))
531				beforeLine++
532			case l.before.Kind == udiff.Delete:
533				ls := dv.style.DeleteLine
534				content := getContent(l.before.Content, ls)
535				leadingEllipsis := getLeadingEllipsis(content)
536				if dv.lineNumbers {
537					write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
538				}
539				write(beforeFullContentStyle.Render(
540					ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
541						ls.Code.Width(dv.codeWidth).Render(content),
542				))
543				beforeLine++
544			}
545
546			switch {
547			case l.after == nil:
548				ls := dv.style.MissingLine
549				if dv.lineNumbers {
550					write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
551				}
552				write(afterFullContentStyle.Render(
553					ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render("  "),
554				))
555			case l.after.Kind == udiff.Equal:
556				ls := dv.style.EqualLine
557				content := getContent(l.after.Content, ls)
558				leadingEllipsis := getLeadingEllipsis(content)
559				if dv.lineNumbers {
560					write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
561				}
562				write(afterFullContentStyle.Render(
563					ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(ternary(leadingEllipsis, " …", "  ") + content),
564				))
565				afterLine++
566			case l.after.Kind == udiff.Insert:
567				ls := dv.style.InsertLine
568				content := getContent(l.after.Content, ls)
569				leadingEllipsis := getLeadingEllipsis(content)
570				if dv.lineNumbers {
571					write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
572				}
573				write(afterFullContentStyle.Render(
574					ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
575						ls.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(content),
576				))
577				afterLine++
578			}
579
580			write("\n")
581
582			printedLines++
583		}
584	}
585
586	for printedLines < dv.height {
587		ls := dv.style.MissingLine
588		if dv.lineNumbers {
589			write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
590		}
591		write(ls.Code.Width(dv.fullCodeWidth).Render(" "))
592		if dv.lineNumbers {
593			write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
594		}
595		write(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
596		write("\n")
597		printedLines++
598	}
599
600	return b.String()
601}
602
603// hunkLineFor formats the header line for a hunk in the unified diff view.
604func (dv *DiffView) hunkLineFor(h *udiff.Hunk) string {
605	beforeShownLines, afterShownLines := dv.hunkShownLines(h)
606
607	return fmt.Sprintf(
608		"  @@ -%d,%d +%d,%d @@ ",
609		h.FromLine,
610		beforeShownLines,
611		h.ToLine,
612		afterShownLines,
613	)
614}
615
616// hunkShownLines calculates the number of lines shown in a hunk for both before
617// and after versions.
618func (dv *DiffView) hunkShownLines(h *udiff.Hunk) (before, after int) {
619	for _, l := range h.Lines {
620		switch l.Kind {
621		case udiff.Equal:
622			before++
623			after++
624		case udiff.Insert:
625			after++
626		case udiff.Delete:
627			before++
628		}
629	}
630	return
631}
632
633func (dv *DiffView) lineStyleForType(t udiff.OpKind) LineStyle {
634	switch t {
635	case udiff.Equal:
636		return dv.style.EqualLine
637	case udiff.Insert:
638		return dv.style.InsertLine
639	case udiff.Delete:
640		return dv.style.DeleteLine
641	default:
642		return dv.style.MissingLine
643	}
644}
645
646func (dv *DiffView) hightlightCode(source string, bgColor color.Color) string {
647	if dv.chromaStyle == nil {
648		return source
649	}
650
651	l := dv.getChromaLexer(source)
652	f := dv.getChromaFormatter(bgColor)
653
654	it, err := l.Tokenise(nil, source)
655	if err != nil {
656		return source
657	}
658
659	var b strings.Builder
660	if err := f.Format(&b, dv.chromaStyle, it); err != nil {
661		return source
662	}
663	return b.String()
664}
665
666func (dv *DiffView) getChromaLexer(source string) chroma.Lexer {
667	l := lexers.Match(dv.before.path)
668	if l == nil {
669		l = lexers.Analyse(source)
670	}
671	if l == nil {
672		l = lexers.Fallback
673	}
674	return chroma.Coalesce(l)
675}
676
677func (dv *DiffView) getChromaFormatter(gbColor color.Color) chroma.Formatter {
678	return chromaFormatter{
679		bgColor: gbColor,
680	}
681}