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