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}