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