diffview.go

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