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	fullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
290
291	for _, h := range dv.unified.Hunks {
292		if dv.lineNumbers {
293			b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", dv.beforeNumDigits)))
294			b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", dv.afterNumDigits)))
295		}
296		content := ansi.Truncate(dv.hunkLineFor(h), dv.fullCodeWidth, "…")
297		b.WriteString(dv.style.DividerLine.Code.Width(dv.fullCodeWidth).Render(content))
298		b.WriteRune('\n')
299
300		beforeLine := h.FromLine
301		afterLine := h.ToLine
302
303		for _, l := range h.Lines {
304			content := strings.TrimSuffix(l.Content, "\n")
305			content = ansi.Truncate(content, dv.codeWidth, "…")
306
307			switch l.Kind {
308			case udiff.Equal:
309				if dv.lineNumbers {
310					b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
311					b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
312				}
313				b.WriteString(fullContentStyle.Render(
314					dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render("  " + content),
315				))
316				beforeLine++
317				afterLine++
318			case udiff.Insert:
319				if dv.lineNumbers {
320					b.WriteString(dv.style.InsertLine.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
321					b.WriteString(dv.style.InsertLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
322				}
323				b.WriteString(fullContentStyle.Render(
324					dv.style.InsertLine.Symbol.Render("+ ") +
325						dv.style.InsertLine.Code.Width(dv.codeWidth).Render(content),
326				))
327				afterLine++
328			case udiff.Delete:
329				if dv.lineNumbers {
330					b.WriteString(dv.style.DeleteLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
331					b.WriteString(dv.style.DeleteLine.LineNumber.Render(pad(" ", dv.afterNumDigits)))
332				}
333				b.WriteString(fullContentStyle.Render(
334					dv.style.DeleteLine.Symbol.Render("- ") +
335						dv.style.DeleteLine.Code.Width(dv.codeWidth).Render(content),
336				))
337				beforeLine++
338			}
339			b.WriteRune('\n')
340		}
341	}
342
343	return b.String()
344}
345
346// renderSplit renders the split (side-by-side) diff view as a string.
347func (dv *DiffView) renderSplit() string {
348	var b strings.Builder
349
350	beforeFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
351	afterFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth + btoi(dv.extraColOnAfter))
352
353	for i, h := range dv.splitHunks {
354		if dv.lineNumbers {
355			b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", dv.beforeNumDigits)))
356		}
357		content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
358		b.WriteString(dv.style.DividerLine.Code.Width(dv.fullCodeWidth).Render(content))
359		if dv.lineNumbers {
360			b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", dv.afterNumDigits)))
361		}
362		b.WriteString(dv.style.DividerLine.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
363		b.WriteRune('\n')
364
365		beforeLine := h.fromLine
366		afterLine := h.toLine
367
368		for _, l := range h.lines {
369			var beforeContent string
370			var afterContent string
371			if l.before != nil {
372				beforeContent = strings.TrimSuffix(l.before.Content, "\n")
373				beforeContent = ansi.Truncate(beforeContent, dv.codeWidth, "…")
374			}
375			if l.after != nil {
376				afterContent = strings.TrimSuffix(l.after.Content, "\n")
377				afterContent = ansi.Truncate(afterContent, dv.codeWidth+btoi(dv.extraColOnAfter), "…")
378			}
379
380			switch {
381			case l.before == nil:
382				if dv.lineNumbers {
383					b.WriteString(dv.style.MissingLine.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
384				}
385				b.WriteString(beforeFullContentStyle.Render(
386					dv.style.MissingLine.Code.Width(dv.fullCodeWidth).Render("  "),
387				))
388			case l.before.Kind == udiff.Equal:
389				if dv.lineNumbers {
390					b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
391				}
392				b.WriteString(beforeFullContentStyle.Render(
393					dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render("  " + beforeContent),
394				))
395				beforeLine++
396			case l.before.Kind == udiff.Delete:
397				if dv.lineNumbers {
398					b.WriteString(dv.style.DeleteLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
399				}
400				b.WriteString(beforeFullContentStyle.Render(
401					dv.style.DeleteLine.Symbol.Render("- ") +
402						dv.style.DeleteLine.Code.Width(dv.codeWidth).Render(beforeContent),
403				))
404				beforeLine++
405			}
406
407			switch {
408			case l.after == nil:
409				if dv.lineNumbers {
410					b.WriteString(dv.style.MissingLine.LineNumber.Render(pad(" ", dv.afterNumDigits)))
411				}
412				b.WriteString(afterFullContentStyle.Render(
413					dv.style.MissingLine.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render("  "),
414				))
415			case l.after.Kind == udiff.Equal:
416				if dv.lineNumbers {
417					b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
418				}
419				b.WriteString(afterFullContentStyle.Render(
420					dv.style.EqualLine.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render("  " + afterContent),
421				))
422				afterLine++
423			case l.after.Kind == udiff.Insert:
424				if dv.lineNumbers {
425					b.WriteString(dv.style.InsertLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
426				}
427				b.WriteString(afterFullContentStyle.Render(
428					dv.style.InsertLine.Symbol.Render("+ ") +
429						dv.style.InsertLine.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(afterContent),
430				))
431				afterLine++
432			}
433
434			b.WriteRune('\n')
435		}
436	}
437
438	return b.String()
439}
440
441// hunkLineFor formats the header line for a hunk in the unified diff view.
442func (dv *DiffView) hunkLineFor(h *udiff.Hunk) string {
443	beforeShownLines, afterShownLines := dv.hunkShownLines(h)
444
445	return fmt.Sprintf(
446		"  @@ -%d,%d +%d,%d @@ ",
447		h.FromLine,
448		beforeShownLines,
449		h.ToLine,
450		afterShownLines,
451	)
452}
453
454// hunkShownLines calculates the number of lines shown in a hunk for both before
455// and after versions.
456func (dv *DiffView) hunkShownLines(h *udiff.Hunk) (before, after int) {
457	for _, l := range h.Lines {
458		switch l.Kind {
459		case udiff.Equal:
460			before++
461			after++
462		case udiff.Insert:
463			after++
464		case udiff.Delete:
465			before++
466		}
467	}
468	return
469}