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