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}