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