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