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 leadingSymbolsSize = 2
 16
 17type file struct {
 18	path    string
 19	content string
 20}
 21
 22type layout int
 23
 24const (
 25	layoutUnified layout = iota + 1
 26	layoutSplit
 27)
 28
 29// DiffView represents a view for displaying differences between two files.
 30type DiffView struct {
 31	layout       layout
 32	before       file
 33	after        file
 34	contextLines int
 35	lineNumbers  bool
 36	highlight    bool
 37	height       int
 38	width        int
 39	style        Style
 40
 41	isComputed bool
 42	err        error
 43	unified    udiff.UnifiedDiff
 44	edits      []udiff.Edit
 45
 46	splitHunks []splitHunk
 47
 48	codeWidth     int
 49	fullCodeWidth int // with leading symbols
 50}
 51
 52// New creates a new DiffView with default settings.
 53func New() *DiffView {
 54	dv := &DiffView{
 55		layout:       layoutUnified,
 56		contextLines: udiff.DefaultContextLines,
 57		lineNumbers:  true,
 58	}
 59	if lipgloss.HasDarkBackground(os.Stdin, os.Stdout) {
 60		dv.style = DefaultDarkStyle
 61	} else {
 62		dv.style = DefaultLightStyle
 63	}
 64	return dv
 65}
 66
 67// Unified sets the layout of the DiffView to unified.
 68func (dv *DiffView) Unified() *DiffView {
 69	dv.layout = layoutUnified
 70	return dv
 71}
 72
 73// Split sets the layout of the DiffView to split (side-by-side).
 74func (dv *DiffView) Split() *DiffView {
 75	dv.layout = layoutSplit
 76	return dv
 77}
 78
 79// Before sets the "before" file for the DiffView.
 80func (dv *DiffView) Before(path, content string) *DiffView {
 81	dv.before = file{path: path, content: content}
 82	return dv
 83}
 84
 85// After sets the "after" file for the DiffView.
 86func (dv *DiffView) After(path, content string) *DiffView {
 87	dv.after = file{path: path, content: content}
 88	return dv
 89}
 90
 91// ContextLines sets the number of context lines for the DiffView.
 92func (dv *DiffView) ContextLines(contextLines int) *DiffView {
 93	dv.contextLines = contextLines
 94	return dv
 95}
 96
 97// Style sets the style for the DiffView.
 98func (dv *DiffView) Style(style Style) *DiffView {
 99	dv.style = style
100	return dv
101}
102
103// LineNumbers sets whether to display line numbers in the DiffView.
104func (dv *DiffView) LineNumbers(lineNumbers bool) *DiffView {
105	dv.lineNumbers = lineNumbers
106	return dv
107}
108
109// SyntaxHightlight sets whether to enable syntax highlighting in the DiffView.
110func (dv *DiffView) SyntaxHightlight(highlight bool) *DiffView {
111	dv.highlight = highlight
112	return dv
113}
114
115// Height sets the height of the DiffView.
116func (dv *DiffView) Height(height int) *DiffView {
117	dv.height = height
118	return dv
119}
120
121// Width sets the width of the DiffView.
122func (dv *DiffView) Width(width int) *DiffView {
123	dv.width = width
124	return dv
125}
126
127// String returns the string representation of the DiffView.
128func (dv *DiffView) String() string {
129	if err := dv.computeDiff(); err != nil {
130		return err.Error()
131	}
132	dv.convertDiffToSplit()
133	dv.detectCodeWidth()
134
135	switch dv.layout {
136	case layoutUnified:
137		return dv.renderUnified()
138	case layoutSplit:
139		return dv.renderSplit()
140	default:
141		panic("unknown diffview layout")
142	}
143}
144
145// renderUnified renders the unified diff view as a string.
146func (dv *DiffView) renderUnified() string {
147	beforeNumDigits, afterNumDigits := dv.lineNumberDigits()
148
149	var b strings.Builder
150
151	for _, h := range dv.unified.Hunks {
152		if dv.lineNumbers {
153			b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", beforeNumDigits)))
154			b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", afterNumDigits)))
155		}
156		b.WriteString(dv.style.DividerLine.Code.Width(dv.fullCodeWidth).Render(dv.hunkLineFor(h)))
157		b.WriteRune('\n')
158
159		beforeLine := h.FromLine
160		afterLine := h.ToLine
161
162		for _, l := range h.Lines {
163			content := strings.TrimSuffix(l.Content, "\n")
164
165			switch l.Kind {
166			case udiff.Equal:
167				if dv.lineNumbers {
168					b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(beforeLine, beforeNumDigits)))
169					b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(afterLine, afterNumDigits)))
170				}
171				b.WriteString(dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render("  " + content))
172				beforeLine++
173				afterLine++
174			case udiff.Insert:
175				if dv.lineNumbers {
176					b.WriteString(dv.style.InsertLine.LineNumber.Render(pad(" ", beforeNumDigits)))
177					b.WriteString(dv.style.InsertLine.LineNumber.Render(pad(afterLine, afterNumDigits)))
178				}
179				b.WriteString(dv.style.InsertLine.Symbol.Render("+ "))
180				b.WriteString(dv.style.InsertLine.Code.Width(dv.codeWidth).Render(content))
181				afterLine++
182			case udiff.Delete:
183				if dv.lineNumbers {
184					b.WriteString(dv.style.DeleteLine.LineNumber.Render(pad(beforeLine, beforeNumDigits)))
185					b.WriteString(dv.style.DeleteLine.LineNumber.Render(pad(" ", afterNumDigits)))
186				}
187				b.WriteString(dv.style.DeleteLine.Symbol.Render("- "))
188				b.WriteString(dv.style.DeleteLine.Code.Width(dv.codeWidth).Render(content))
189				beforeLine++
190			}
191			b.WriteRune('\n')
192		}
193	}
194
195	return b.String()
196}
197
198// renderSplit renders the split (side-by-side) diff view as a string.
199func (dv *DiffView) renderSplit() string {
200	beforeNumDigits, afterNumDigits := dv.lineNumberDigits()
201
202	var b strings.Builder
203
204	for i, h := range dv.splitHunks {
205		if dv.lineNumbers {
206			b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", beforeNumDigits)))
207		}
208		b.WriteString(dv.style.DividerLine.Code.Width(dv.fullCodeWidth).Render(dv.hunkLineFor(dv.unified.Hunks[i])))
209		if dv.lineNumbers {
210			b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", afterNumDigits)))
211		}
212		b.WriteString(dv.style.DividerLine.Code.Width(dv.fullCodeWidth).Render(" "))
213		b.WriteRune('\n')
214
215		beforeLine := h.fromLine
216		afterLine := h.toLine
217
218		for _, l := range h.lines {
219			var beforeContent string
220			var afterContent string
221			if l.before != nil {
222				beforeContent = strings.TrimSuffix(l.before.Content, "\n")
223			}
224			if l.after != nil {
225				afterContent = strings.TrimSuffix(l.after.Content, "\n")
226			}
227
228			switch {
229			case l.before == nil:
230				if dv.lineNumbers {
231					b.WriteString(dv.style.MissingLine.LineNumber.Render(pad(" ", beforeNumDigits)))
232				}
233				b.WriteString(dv.style.MissingLine.Code.Width(dv.fullCodeWidth).Render("  "))
234			case l.before.Kind == udiff.Equal:
235				if dv.lineNumbers {
236					b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(beforeLine, beforeNumDigits)))
237				}
238				b.WriteString(dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render("  " + beforeContent))
239				beforeLine++
240			case l.before.Kind == udiff.Delete:
241				if dv.lineNumbers {
242					b.WriteString(dv.style.DeleteLine.LineNumber.Render(pad(beforeLine, beforeNumDigits)))
243				}
244				b.WriteString(dv.style.DeleteLine.Symbol.Render("- "))
245				b.WriteString(dv.style.DeleteLine.Code.Width(dv.codeWidth).Render(beforeContent))
246				beforeLine++
247			}
248
249			switch {
250			case l.after == nil:
251				if dv.lineNumbers {
252					b.WriteString(dv.style.MissingLine.LineNumber.Render(pad(" ", afterNumDigits)))
253				}
254				b.WriteString(dv.style.MissingLine.Code.Width(dv.fullCodeWidth).Render("  "))
255			case l.after.Kind == udiff.Equal:
256				if dv.lineNumbers {
257					b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(afterLine, afterNumDigits)))
258				}
259				b.WriteString(dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render("  " + afterContent))
260				afterLine++
261			case l.after.Kind == udiff.Insert:
262				if dv.lineNumbers {
263					b.WriteString(dv.style.InsertLine.LineNumber.Render(pad(afterLine, afterNumDigits)))
264				}
265				b.WriteString(dv.style.InsertLine.Symbol.Render("+ "))
266				b.WriteString(dv.style.InsertLine.Code.Width(dv.codeWidth).Render(afterContent))
267				afterLine++
268			}
269
270			b.WriteRune('\n')
271		}
272	}
273
274	return b.String()
275}
276
277func (dv *DiffView) computeDiff() error {
278	if dv.isComputed {
279		return dv.err
280	}
281	dv.isComputed = true
282	dv.edits = myers.ComputeEdits( //nolint:staticcheck
283		dv.before.content,
284		dv.after.content,
285	)
286	dv.unified, dv.err = udiff.ToUnifiedDiff(
287		dv.before.path,
288		dv.after.path,
289		dv.before.content,
290		dv.edits,
291		dv.contextLines,
292	)
293	return dv.err
294}
295
296// lineNumberDigits calculates the maximum number of digits needed for before and
297// after line numbers.
298func (dv *DiffView) lineNumberDigits() (maxBefore, maxAfter int) {
299	for _, h := range dv.unified.Hunks {
300		maxBefore = max(maxBefore, len(strconv.Itoa(h.FromLine+len(h.Lines))))
301		maxAfter = max(maxAfter, len(strconv.Itoa(h.ToLine+len(h.Lines))))
302	}
303	return
304}
305
306func (dv *DiffView) hunkLineFor(h *udiff.Hunk) string {
307	beforeShownLines, afterShownLines := dv.hunkShownLines(h)
308
309	return fmt.Sprintf(
310		"  @@ -%d,%d +%d,%d @@ ",
311		h.FromLine,
312		beforeShownLines,
313		h.ToLine,
314		afterShownLines,
315	)
316}
317
318// hunkShownLines calculates the number of lines shown in a hunk for both before
319// and after versions.
320func (dv *DiffView) hunkShownLines(h *udiff.Hunk) (before, after int) {
321	for _, l := range h.Lines {
322		switch l.Kind {
323		case udiff.Equal:
324			before++
325			after++
326		case udiff.Insert:
327			after++
328		case udiff.Delete:
329			before++
330		}
331	}
332	return
333}
334
335func (dv *DiffView) detectCodeWidth() {
336	switch dv.layout {
337	case layoutUnified:
338		dv.detectUnifiedCodeWidth()
339	case layoutSplit:
340		dv.detectSplitCodeWidth()
341	}
342	dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
343}
344
345func (dv *DiffView) detectUnifiedCodeWidth() {
346	dv.codeWidth = 0
347
348	for _, h := range dv.unified.Hunks {
349		shownLines := ansi.StringWidth(dv.hunkLineFor(h))
350
351		for _, l := range h.Lines {
352			lineWidth := ansi.StringWidth(strings.TrimSuffix(l.Content, "\n")) + 1
353			dv.codeWidth = max(dv.codeWidth, lineWidth, shownLines)
354		}
355	}
356}
357
358func (dv *DiffView) convertDiffToSplit() {
359	if dv.layout != layoutSplit {
360		return
361	}
362
363	dv.splitHunks = make([]splitHunk, len(dv.unified.Hunks))
364	for i, h := range dv.unified.Hunks {
365		dv.splitHunks[i] = hunkToSplit(h)
366	}
367}
368
369func (dv *DiffView) detectSplitCodeWidth() {
370	dv.codeWidth = 0
371
372	for i, h := range dv.splitHunks {
373		shownLines := ansi.StringWidth(dv.hunkLineFor(dv.unified.Hunks[i]))
374
375		for _, l := range h.lines {
376			if l.before != nil {
377				codeWidth := ansi.StringWidth(strings.TrimSuffix(l.before.Content, "\n")) + 1
378				dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
379			}
380			if l.after != nil {
381				codeWidth := ansi.StringWidth(strings.TrimSuffix(l.after.Content, "\n")) + 1
382				dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
383			}
384		}
385	}
386}