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