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