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				if dv.lineNumbers {
335					write(dv.style.EqualLine.LineNumber.Render(pad("…", dv.beforeNumDigits)))
336					write(dv.style.EqualLine.LineNumber.Render(pad("…", dv.afterNumDigits)))
337				}
338				write(fullContentStyle.Render(
339					dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render("  …"),
340				))
341				write("\n")
342				break outer
343			}
344
345			content := strings.TrimSuffix(l.Content, "\n")
346			content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
347			content = ansi.Truncate(content, dv.codeWidth, "…")
348
349			leadingEllipsis := dv.xOffset > 0 && strings.TrimSpace(content) != ""
350
351			switch l.Kind {
352			case udiff.Equal:
353				if dv.lineNumbers {
354					write(dv.style.EqualLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
355					write(dv.style.EqualLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
356				}
357				write(fullContentStyle.Render(
358					dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", "  ") + content),
359				))
360				beforeLine++
361				afterLine++
362			case udiff.Insert:
363				if dv.lineNumbers {
364					write(dv.style.InsertLine.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
365					write(dv.style.InsertLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
366				}
367				write(fullContentStyle.Render(
368					dv.style.InsertLine.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
369						dv.style.InsertLine.Code.Width(dv.codeWidth).Render(content),
370				))
371				afterLine++
372			case udiff.Delete:
373				if dv.lineNumbers {
374					write(dv.style.DeleteLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
375					write(dv.style.DeleteLine.LineNumber.Render(pad(" ", dv.afterNumDigits)))
376				}
377				write(fullContentStyle.Render(
378					dv.style.DeleteLine.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
379						dv.style.DeleteLine.Code.Width(dv.codeWidth).Render(content),
380				))
381				beforeLine++
382			}
383			write("\n")
384
385			printedLines++
386		}
387	}
388
389	for printedLines < dv.height {
390		if dv.lineNumbers {
391			write(dv.style.MissingLine.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
392			write(dv.style.MissingLine.LineNumber.Render(pad(" ", dv.afterNumDigits)))
393		}
394		write(dv.style.MissingLine.Code.Width(dv.fullCodeWidth).Render("  "))
395		write("\n")
396		printedLines++
397	}
398
399	return b.String()
400}
401
402// renderSplit renders the split (side-by-side) diff view as a string.
403func (dv *DiffView) renderSplit() string {
404	var b strings.Builder
405
406	beforeFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
407	afterFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth + btoi(dv.extraColOnAfter))
408	printedLines := -dv.yOffset
409
410	write := func(s string) {
411		if printedLines >= 0 {
412			b.WriteString(s)
413		}
414	}
415
416outer:
417	for i, h := range dv.splitHunks {
418		if dv.lineNumbers {
419			write(dv.style.DividerLine.LineNumber.Render(pad("…", dv.beforeNumDigits)))
420		}
421		content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
422		write(dv.style.DividerLine.Code.Width(dv.fullCodeWidth).Render(content))
423		if dv.lineNumbers {
424			write(dv.style.DividerLine.LineNumber.Render(pad("…", dv.afterNumDigits)))
425		}
426		write(dv.style.DividerLine.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
427		write("\n")
428		printedLines++
429
430		beforeLine := h.fromLine
431		afterLine := h.toLine
432
433		for j, l := range h.lines {
434			// print ellipis if we don't have enough space to print the rest of the diff
435			hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
436			isLastHunk := i+1 == len(dv.unified.Hunks)
437			isLastLine := j+1 == len(h.lines)
438			if hasReachedHeight && (!isLastHunk || !isLastLine) {
439				if dv.lineNumbers {
440					write(dv.style.EqualLine.LineNumber.Render(pad("…", dv.beforeNumDigits)))
441				}
442				write(beforeFullContentStyle.Render(
443					dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render("  …"),
444				))
445				if dv.lineNumbers {
446					write(dv.style.EqualLine.LineNumber.Render(pad("…", dv.afterNumDigits)))
447				}
448				write(afterFullContentStyle.Render(
449					dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render("  …"),
450				))
451				write("\n")
452				break outer
453			}
454
455			var beforeContent string
456			var afterContent string
457			if l.before != nil {
458				beforeContent = strings.TrimSuffix(l.before.Content, "\n")
459				beforeContent = ansi.GraphemeWidth.Cut(beforeContent, dv.xOffset, len(beforeContent))
460				beforeContent = ansi.Truncate(beforeContent, dv.codeWidth, "…")
461			}
462			if l.after != nil {
463				afterContent = strings.TrimSuffix(l.after.Content, "\n")
464				afterContent = ansi.GraphemeWidth.Cut(afterContent, dv.xOffset, len(afterContent))
465				afterContent = ansi.Truncate(afterContent, dv.codeWidth+btoi(dv.extraColOnAfter), "…")
466			}
467
468			leadingBeforeEllipsis := dv.xOffset > 0 && strings.TrimSpace(beforeContent) != ""
469			leadingAfterEllipsis := dv.xOffset > 0 && strings.TrimSpace(afterContent) != ""
470
471			switch {
472			case l.before == nil:
473				if dv.lineNumbers {
474					write(dv.style.MissingLine.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
475				}
476				write(beforeFullContentStyle.Render(
477					dv.style.MissingLine.Code.Width(dv.fullCodeWidth).Render("  "),
478				))
479			case l.before.Kind == udiff.Equal:
480				if dv.lineNumbers {
481					write(dv.style.EqualLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
482				}
483				write(beforeFullContentStyle.Render(
484					dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render(ternary(leadingBeforeEllipsis, " …", "  ") + beforeContent),
485				))
486				beforeLine++
487			case l.before.Kind == udiff.Delete:
488				if dv.lineNumbers {
489					write(dv.style.DeleteLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
490				}
491				write(beforeFullContentStyle.Render(
492					dv.style.DeleteLine.Symbol.Render(ternary(leadingBeforeEllipsis, "-…", "- ")) +
493						dv.style.DeleteLine.Code.Width(dv.codeWidth).Render(beforeContent),
494				))
495				beforeLine++
496			}
497
498			switch {
499			case l.after == nil:
500				if dv.lineNumbers {
501					write(dv.style.MissingLine.LineNumber.Render(pad(" ", dv.afterNumDigits)))
502				}
503				write(afterFullContentStyle.Render(
504					dv.style.MissingLine.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render("  "),
505				))
506			case l.after.Kind == udiff.Equal:
507				if dv.lineNumbers {
508					write(dv.style.EqualLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
509				}
510				write(afterFullContentStyle.Render(
511					dv.style.EqualLine.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(ternary(leadingAfterEllipsis, " …", "  ") + afterContent),
512				))
513				afterLine++
514			case l.after.Kind == udiff.Insert:
515				if dv.lineNumbers {
516					write(dv.style.InsertLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
517				}
518				write(afterFullContentStyle.Render(
519					dv.style.InsertLine.Symbol.Render(ternary(leadingAfterEllipsis, "+…", "+ ")) +
520						dv.style.InsertLine.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(afterContent),
521				))
522				afterLine++
523			}
524
525			write("\n")
526
527			printedLines++
528		}
529	}
530
531	for printedLines < dv.height {
532		if dv.lineNumbers {
533			write(dv.style.MissingLine.LineNumber.Render(pad("…", dv.beforeNumDigits)))
534		}
535		write(dv.style.MissingLine.Code.Width(dv.fullCodeWidth).Render(" "))
536		if dv.lineNumbers {
537			write(dv.style.MissingLine.LineNumber.Render(pad("…", dv.afterNumDigits)))
538		}
539		write(dv.style.MissingLine.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
540		write("\n")
541		printedLines++
542	}
543
544	return b.String()
545}
546
547// hunkLineFor formats the header line for a hunk in the unified diff view.
548func (dv *DiffView) hunkLineFor(h *udiff.Hunk) string {
549	beforeShownLines, afterShownLines := dv.hunkShownLines(h)
550
551	return fmt.Sprintf(
552		"  @@ -%d,%d +%d,%d @@ ",
553		h.FromLine,
554		beforeShownLines,
555		h.ToLine,
556		afterShownLines,
557	)
558}
559
560// hunkShownLines calculates the number of lines shown in a hunk for both before
561// and after versions.
562func (dv *DiffView) hunkShownLines(h *udiff.Hunk) (before, after int) {
563	for _, l := range h.Lines {
564		switch l.Kind {
565		case udiff.Equal:
566			before++
567			after++
568		case udiff.Insert:
569			after++
570		case udiff.Delete:
571			before++
572		}
573	}
574	return
575}