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