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
152// computeDiff computes the differences between the "before" and "after" files.
153func (dv *DiffView) computeDiff() error {
154	if dv.isComputed {
155		return dv.err
156	}
157	dv.isComputed = true
158	dv.edits = myers.ComputeEdits( //nolint:staticcheck
159		dv.before.content,
160		dv.after.content,
161	)
162	dv.unified, dv.err = udiff.ToUnifiedDiff(
163		dv.before.path,
164		dv.after.path,
165		dv.before.content,
166		dv.edits,
167		dv.contextLines,
168	)
169	return dv.err
170}
171
172// convertDiffToSplit converts the unified diff to a split diff if the layout is
173// set to split.
174func (dv *DiffView) convertDiffToSplit() {
175	if dv.layout != layoutSplit {
176		return
177	}
178
179	dv.splitHunks = make([]splitHunk, len(dv.unified.Hunks))
180	for i, h := range dv.unified.Hunks {
181		dv.splitHunks[i] = hunkToSplit(h)
182	}
183}
184
185// adjustStyles adjusts adds padding and alignment to the styles.
186func (dv *DiffView) adjustStyles() {
187	dv.style.MissingLine.LineNumber = setPadding(dv.style.MissingLine.LineNumber)
188	dv.style.DividerLine.LineNumber = setPadding(dv.style.DividerLine.LineNumber)
189	dv.style.EqualLine.LineNumber = setPadding(dv.style.EqualLine.LineNumber)
190	dv.style.InsertLine.LineNumber = setPadding(dv.style.InsertLine.LineNumber)
191	dv.style.DeleteLine.LineNumber = setPadding(dv.style.DeleteLine.LineNumber)
192}
193
194// detectNumDigits calculates the maximum number of digits needed for before and
195// after line numbers.
196func (dv *DiffView) detectNumDigits() {
197	dv.beforeNumDigits = 0
198	dv.afterNumDigits = 0
199
200	for _, h := range dv.unified.Hunks {
201		dv.beforeNumDigits = max(dv.beforeNumDigits, len(strconv.Itoa(h.FromLine+len(h.Lines))))
202		dv.afterNumDigits = max(dv.afterNumDigits, len(strconv.Itoa(h.ToLine+len(h.Lines))))
203	}
204}
205
206func setPadding(s lipgloss.Style) lipgloss.Style {
207	return s.Padding(0, lineNumPadding).Align(lipgloss.Right)
208}
209
210// detectCodeWidth calculates the maximum width of code lines in the diff view.
211func (dv *DiffView) detectCodeWidth() {
212	switch dv.layout {
213	case layoutUnified:
214		dv.detectUnifiedCodeWidth()
215	case layoutSplit:
216		dv.detectSplitCodeWidth()
217	}
218	dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
219}
220
221// detectUnifiedCodeWidth calculates the maximum width of code lines in a
222// unified diff.
223func (dv *DiffView) detectUnifiedCodeWidth() {
224	dv.codeWidth = 0
225
226	for _, h := range dv.unified.Hunks {
227		shownLines := ansi.StringWidth(dv.hunkLineFor(h))
228
229		for _, l := range h.Lines {
230			lineWidth := ansi.StringWidth(strings.TrimSuffix(l.Content, "\n")) + 1
231			dv.codeWidth = max(dv.codeWidth, lineWidth, shownLines)
232		}
233	}
234}
235
236// detectSplitCodeWidth calculates the maximum width of code lines in a
237// split diff.
238func (dv *DiffView) detectSplitCodeWidth() {
239	dv.codeWidth = 0
240
241	for i, h := range dv.splitHunks {
242		shownLines := ansi.StringWidth(dv.hunkLineFor(dv.unified.Hunks[i]))
243
244		for _, l := range h.lines {
245			if l.before != nil {
246				codeWidth := ansi.StringWidth(strings.TrimSuffix(l.before.Content, "\n")) + 1
247				dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
248			}
249			if l.after != nil {
250				codeWidth := ansi.StringWidth(strings.TrimSuffix(l.after.Content, "\n")) + 1
251				dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
252			}
253		}
254	}
255}
256
257// renderUnified renders the unified diff view as a string.
258func (dv *DiffView) renderUnified() string {
259	var b strings.Builder
260
261	for _, h := range dv.unified.Hunks {
262		if dv.lineNumbers {
263			b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", dv.beforeNumDigits)))
264			b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", dv.afterNumDigits)))
265		}
266		b.WriteString(dv.style.DividerLine.Code.Width(dv.fullCodeWidth).Render(dv.hunkLineFor(h)))
267		b.WriteRune('\n')
268
269		beforeLine := h.FromLine
270		afterLine := h.ToLine
271
272		for _, l := range h.Lines {
273			content := strings.TrimSuffix(l.Content, "\n")
274
275			switch l.Kind {
276			case udiff.Equal:
277				if dv.lineNumbers {
278					b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
279					b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
280				}
281				b.WriteString(dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render("  " + content))
282				beforeLine++
283				afterLine++
284			case udiff.Insert:
285				if dv.lineNumbers {
286					b.WriteString(dv.style.InsertLine.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
287					b.WriteString(dv.style.InsertLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
288				}
289				b.WriteString(dv.style.InsertLine.Symbol.Render("+ "))
290				b.WriteString(dv.style.InsertLine.Code.Width(dv.codeWidth).Render(content))
291				afterLine++
292			case udiff.Delete:
293				if dv.lineNumbers {
294					b.WriteString(dv.style.DeleteLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
295					b.WriteString(dv.style.DeleteLine.LineNumber.Render(pad(" ", dv.afterNumDigits)))
296				}
297				b.WriteString(dv.style.DeleteLine.Symbol.Render("- "))
298				b.WriteString(dv.style.DeleteLine.Code.Width(dv.codeWidth).Render(content))
299				beforeLine++
300			}
301			b.WriteRune('\n')
302		}
303	}
304
305	return b.String()
306}
307
308// renderSplit renders the split (side-by-side) diff view as a string.
309func (dv *DiffView) renderSplit() string {
310	var b strings.Builder
311
312	for i, h := range dv.splitHunks {
313		if dv.lineNumbers {
314			b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", dv.beforeNumDigits)))
315		}
316		b.WriteString(dv.style.DividerLine.Code.Width(dv.fullCodeWidth).Render(dv.hunkLineFor(dv.unified.Hunks[i])))
317		if dv.lineNumbers {
318			b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", dv.afterNumDigits)))
319		}
320		b.WriteString(dv.style.DividerLine.Code.Width(dv.fullCodeWidth).Render(" "))
321		b.WriteRune('\n')
322
323		beforeLine := h.fromLine
324		afterLine := h.toLine
325
326		for _, l := range h.lines {
327			var beforeContent string
328			var afterContent string
329			if l.before != nil {
330				beforeContent = strings.TrimSuffix(l.before.Content, "\n")
331			}
332			if l.after != nil {
333				afterContent = strings.TrimSuffix(l.after.Content, "\n")
334			}
335
336			switch {
337			case l.before == nil:
338				if dv.lineNumbers {
339					b.WriteString(dv.style.MissingLine.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
340				}
341				b.WriteString(dv.style.MissingLine.Code.Width(dv.fullCodeWidth).Render("  "))
342			case l.before.Kind == udiff.Equal:
343				if dv.lineNumbers {
344					b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
345				}
346				b.WriteString(dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render("  " + beforeContent))
347				beforeLine++
348			case l.before.Kind == udiff.Delete:
349				if dv.lineNumbers {
350					b.WriteString(dv.style.DeleteLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
351				}
352				b.WriteString(dv.style.DeleteLine.Symbol.Render("- "))
353				b.WriteString(dv.style.DeleteLine.Code.Width(dv.codeWidth).Render(beforeContent))
354				beforeLine++
355			}
356
357			switch {
358			case l.after == nil:
359				if dv.lineNumbers {
360					b.WriteString(dv.style.MissingLine.LineNumber.Render(pad(" ", dv.afterNumDigits)))
361				}
362				b.WriteString(dv.style.MissingLine.Code.Width(dv.fullCodeWidth).Render("  "))
363			case l.after.Kind == udiff.Equal:
364				if dv.lineNumbers {
365					b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
366				}
367				b.WriteString(dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render("  " + afterContent))
368				afterLine++
369			case l.after.Kind == udiff.Insert:
370				if dv.lineNumbers {
371					b.WriteString(dv.style.InsertLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
372				}
373				b.WriteString(dv.style.InsertLine.Symbol.Render("+ "))
374				b.WriteString(dv.style.InsertLine.Code.Width(dv.codeWidth).Render(afterContent))
375				afterLine++
376			}
377
378			b.WriteRune('\n')
379		}
380	}
381
382	return b.String()
383}
384
385// hunkLineFor formats the header line for a hunk in the unified diff view.
386func (dv *DiffView) hunkLineFor(h *udiff.Hunk) string {
387	beforeShownLines, afterShownLines := dv.hunkShownLines(h)
388
389	return fmt.Sprintf(
390		"  @@ -%d,%d +%d,%d @@ ",
391		h.FromLine,
392		beforeShownLines,
393		h.ToLine,
394		afterShownLines,
395	)
396}
397
398// hunkShownLines calculates the number of lines shown in a hunk for both before
399// and after versions.
400func (dv *DiffView) hunkShownLines(h *udiff.Hunk) (before, after int) {
401	for _, l := range h.Lines {
402		switch l.Kind {
403		case udiff.Equal:
404			before++
405			after++
406		case udiff.Insert:
407			after++
408		case udiff.Delete:
409			before++
410		}
411	}
412	return
413}