1package diffview
2
3import (
4 "fmt"
5 "image/color"
6 "strconv"
7 "strings"
8
9 "github.com/alecthomas/chroma/v2"
10 "github.com/alecthomas/chroma/v2/lexers"
11 "github.com/aymanbagabas/go-udiff"
12 "github.com/aymanbagabas/go-udiff/myers"
13 "github.com/charmbracelet/lipgloss/v2"
14 "github.com/charmbracelet/x/ansi"
15)
16
17const (
18 leadingSymbolsSize = 2
19 lineNumPadding = 1
20)
21
22type file struct {
23 path string
24 content string
25}
26
27type layout int
28
29const (
30 layoutUnified layout = iota + 1
31 layoutSplit
32)
33
34// DiffView represents a view for displaying differences between two files.
35type DiffView struct {
36 layout layout
37 before file
38 after file
39 contextLines int
40 lineNumbers bool
41 height int
42 width int
43 xOffset int
44 yOffset int
45 style Style
46 tabWidth int
47 chromaStyle *chroma.Style
48
49 isComputed bool
50 err error
51 unified udiff.UnifiedDiff
52 edits []udiff.Edit
53
54 splitHunks []splitHunk
55
56 codeWidth int
57 fullCodeWidth int // with leading symbols
58 extraColOnAfter bool // add extra column on after panel
59 beforeNumDigits int
60 afterNumDigits int
61}
62
63// New creates a new DiffView with default settings.
64func New() *DiffView {
65 dv := &DiffView{
66 layout: layoutUnified,
67 contextLines: udiff.DefaultContextLines,
68 lineNumbers: true,
69 tabWidth: 8,
70 }
71 dv.style = DefaultDarkStyle()
72 return dv
73}
74
75// Unified sets the layout of the DiffView to unified.
76func (dv *DiffView) Unified() *DiffView {
77 dv.layout = layoutUnified
78 return dv
79}
80
81// Split sets the layout of the DiffView to split (side-by-side).
82func (dv *DiffView) Split() *DiffView {
83 dv.layout = layoutSplit
84 return dv
85}
86
87// Before sets the "before" file for the DiffView.
88func (dv *DiffView) Before(path, content string) *DiffView {
89 dv.before = file{path: path, content: content}
90 return dv
91}
92
93// After sets the "after" file for the DiffView.
94func (dv *DiffView) After(path, content string) *DiffView {
95 dv.after = file{path: path, content: content}
96 return dv
97}
98
99// ContextLines sets the number of context lines for the DiffView.
100func (dv *DiffView) ContextLines(contextLines int) *DiffView {
101 dv.contextLines = contextLines
102 return dv
103}
104
105// Style sets the style for the DiffView.
106func (dv *DiffView) Style(style Style) *DiffView {
107 dv.style = style
108 return dv
109}
110
111// LineNumbers sets whether to display line numbers in the DiffView.
112func (dv *DiffView) LineNumbers(lineNumbers bool) *DiffView {
113 dv.lineNumbers = lineNumbers
114 return dv
115}
116
117// Height sets the height of the DiffView.
118func (dv *DiffView) Height(height int) *DiffView {
119 dv.height = height
120 return dv
121}
122
123// Width sets the width of the DiffView.
124func (dv *DiffView) Width(width int) *DiffView {
125 dv.width = width
126 return dv
127}
128
129// XOffset sets the horizontal offset for the DiffView.
130func (dv *DiffView) XOffset(xOffset int) *DiffView {
131 dv.xOffset = xOffset
132 return dv
133}
134
135// YOffset sets the vertical offset for the DiffView.
136func (dv *DiffView) YOffset(yOffset int) *DiffView {
137 dv.yOffset = yOffset
138 return dv
139}
140
141// TabWidth sets the tab width. Only relevant for code that contains tabs, like
142// Go code.
143func (dv *DiffView) TabWidth(tabWidth int) *DiffView {
144 dv.tabWidth = tabWidth
145 return dv
146}
147
148// ChromaStyle sets the chroma style for syntax highlighting.
149// If nil, no syntax highlighting will be applied.
150func (dv *DiffView) ChromaStyle(style *chroma.Style) *DiffView {
151 dv.chromaStyle = style
152 return dv
153}
154
155// String returns the string representation of the DiffView.
156func (dv *DiffView) String() string {
157 dv.replaceTabs()
158 if err := dv.computeDiff(); err != nil {
159 return err.Error()
160 }
161 dv.convertDiffToSplit()
162 dv.adjustStyles()
163 dv.detectNumDigits()
164
165 if dv.width <= 0 {
166 dv.detectCodeWidth()
167 } else {
168 dv.resizeCodeWidth()
169 }
170
171 style := lipgloss.NewStyle()
172 if dv.width > 0 {
173 style = style.MaxWidth(dv.width)
174 }
175 if dv.height > 0 {
176 style = style.MaxHeight(dv.height)
177 }
178
179 switch dv.layout {
180 case layoutUnified:
181 return style.Render(strings.TrimSuffix(dv.renderUnified(), "\n"))
182 case layoutSplit:
183 return style.Render(strings.TrimSuffix(dv.renderSplit(), "\n"))
184 default:
185 panic("unknown diffview layout")
186 }
187}
188
189// replaceTabs replaces tabs in the before and after file contents with spaces
190// according to the specified tab width.
191func (dv *DiffView) replaceTabs() {
192 spaces := strings.Repeat(" ", dv.tabWidth)
193 dv.before.content = strings.ReplaceAll(dv.before.content, "\t", spaces)
194 dv.after.content = strings.ReplaceAll(dv.after.content, "\t", spaces)
195}
196
197// computeDiff computes the differences between the "before" and "after" files.
198func (dv *DiffView) computeDiff() error {
199 if dv.isComputed {
200 return dv.err
201 }
202 dv.isComputed = true
203 dv.edits = myers.ComputeEdits( //nolint:staticcheck
204 dv.before.content,
205 dv.after.content,
206 )
207 dv.unified, dv.err = udiff.ToUnifiedDiff(
208 dv.before.path,
209 dv.after.path,
210 dv.before.content,
211 dv.edits,
212 dv.contextLines,
213 )
214 return dv.err
215}
216
217// convertDiffToSplit converts the unified diff to a split diff if the layout is
218// set to split.
219func (dv *DiffView) convertDiffToSplit() {
220 if dv.layout != layoutSplit {
221 return
222 }
223
224 dv.splitHunks = make([]splitHunk, len(dv.unified.Hunks))
225 for i, h := range dv.unified.Hunks {
226 dv.splitHunks[i] = hunkToSplit(h)
227 }
228}
229
230// adjustStyles adjusts adds padding and alignment to the styles.
231func (dv *DiffView) adjustStyles() {
232 setPadding := func(s lipgloss.Style) lipgloss.Style {
233 return s.Padding(0, lineNumPadding).Align(lipgloss.Right)
234 }
235 dv.style.MissingLine.LineNumber = setPadding(dv.style.MissingLine.LineNumber)
236 dv.style.DividerLine.LineNumber = setPadding(dv.style.DividerLine.LineNumber)
237 dv.style.EqualLine.LineNumber = setPadding(dv.style.EqualLine.LineNumber)
238 dv.style.InsertLine.LineNumber = setPadding(dv.style.InsertLine.LineNumber)
239 dv.style.DeleteLine.LineNumber = setPadding(dv.style.DeleteLine.LineNumber)
240}
241
242// detectNumDigits calculates the maximum number of digits needed for before and
243// after line numbers.
244func (dv *DiffView) detectNumDigits() {
245 dv.beforeNumDigits = 0
246 dv.afterNumDigits = 0
247
248 for _, h := range dv.unified.Hunks {
249 dv.beforeNumDigits = max(dv.beforeNumDigits, len(strconv.Itoa(h.FromLine+len(h.Lines))))
250 dv.afterNumDigits = max(dv.afterNumDigits, len(strconv.Itoa(h.ToLine+len(h.Lines))))
251 }
252}
253
254// detectCodeWidth calculates the maximum width of code lines in the diff view.
255func (dv *DiffView) detectCodeWidth() {
256 switch dv.layout {
257 case layoutUnified:
258 dv.detectUnifiedCodeWidth()
259 case layoutSplit:
260 dv.detectSplitCodeWidth()
261 }
262 dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
263}
264
265// detectUnifiedCodeWidth calculates the maximum width of code lines in a
266// unified diff.
267func (dv *DiffView) detectUnifiedCodeWidth() {
268 dv.codeWidth = 0
269
270 for _, h := range dv.unified.Hunks {
271 shownLines := ansi.StringWidth(dv.hunkLineFor(h))
272
273 for _, l := range h.Lines {
274 lineWidth := ansi.StringWidth(strings.TrimSuffix(l.Content, "\n")) + 1
275 dv.codeWidth = max(dv.codeWidth, lineWidth, shownLines)
276 }
277 }
278}
279
280// detectSplitCodeWidth calculates the maximum width of code lines in a
281// split diff.
282func (dv *DiffView) detectSplitCodeWidth() {
283 dv.codeWidth = 0
284
285 for i, h := range dv.splitHunks {
286 shownLines := ansi.StringWidth(dv.hunkLineFor(dv.unified.Hunks[i]))
287
288 for _, l := range h.lines {
289 if l.before != nil {
290 codeWidth := ansi.StringWidth(strings.TrimSuffix(l.before.Content, "\n")) + 1
291 dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
292 }
293 if l.after != nil {
294 codeWidth := ansi.StringWidth(strings.TrimSuffix(l.after.Content, "\n")) + 1
295 dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
296 }
297 }
298 }
299}
300
301// resizeCodeWidth resizes the code width to fit within the specified width.
302func (dv *DiffView) resizeCodeWidth() {
303 fullNumWidth := dv.beforeNumDigits + dv.afterNumDigits
304 fullNumWidth += lineNumPadding * 4 // left and right padding for both line numbers
305
306 switch dv.layout {
307 case layoutUnified:
308 dv.codeWidth = dv.width - fullNumWidth - leadingSymbolsSize
309 case layoutSplit:
310 remainingWidth := dv.width - fullNumWidth - leadingSymbolsSize*2
311 dv.codeWidth = remainingWidth / 2
312 dv.extraColOnAfter = isOdd(remainingWidth)
313 }
314
315 dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
316}
317
318// renderUnified renders the unified diff view as a string.
319func (dv *DiffView) renderUnified() string {
320 var b strings.Builder
321
322 fullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
323 printedLines := -dv.yOffset
324
325 write := func(s string) {
326 if printedLines >= 0 {
327 b.WriteString(s)
328 }
329 }
330
331outer:
332 for i, h := range dv.unified.Hunks {
333 ls := dv.style.DividerLine
334 if dv.lineNumbers {
335 write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
336 write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
337 }
338 content := ansi.Truncate(dv.hunkLineFor(h), dv.fullCodeWidth, "…")
339 write(ls.Code.Width(dv.fullCodeWidth).Render(content))
340 write("\n")
341 printedLines++
342
343 beforeLine := h.FromLine
344 afterLine := h.ToLine
345
346 for j, l := range h.Lines {
347 // print ellipis if we don't have enough space to print the rest of the diff
348 hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
349 isLastHunk := i+1 == len(dv.unified.Hunks)
350 isLastLine := j+1 == len(h.Lines)
351 if hasReachedHeight && (!isLastHunk || !isLastLine) {
352 ls := dv.lineStyleForType(l.Kind)
353 if dv.lineNumbers {
354 write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
355 write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
356 }
357 write(fullContentStyle.Render(
358 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
359 ))
360 write("\n")
361 break outer
362 }
363
364 getContent := func(ls LineStyle) string {
365 content := strings.TrimSuffix(l.Content, "\n")
366 content = dv.hightlightCode(content, ls.Code.GetBackground())
367 content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
368 content = ansi.Truncate(content, dv.codeWidth, "…")
369 return content
370 }
371
372 leadingEllipsis := dv.xOffset > 0 && strings.TrimSpace(content) != ""
373
374 switch l.Kind {
375 case udiff.Equal:
376 ls := dv.style.EqualLine
377 content := getContent(ls)
378 if dv.lineNumbers {
379 write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
380 write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
381 }
382 write(fullContentStyle.Render(
383 ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
384 ))
385 beforeLine++
386 afterLine++
387 case udiff.Insert:
388 ls := dv.style.InsertLine
389 content := getContent(ls)
390 if dv.lineNumbers {
391 write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
392 write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
393 }
394 write(fullContentStyle.Render(
395 ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
396 ls.Code.Width(dv.codeWidth).Render(content),
397 ))
398 afterLine++
399 case udiff.Delete:
400 ls := dv.style.DeleteLine
401 content := getContent(ls)
402 if dv.lineNumbers {
403 write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
404 write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
405 }
406 write(fullContentStyle.Render(
407 ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
408 ls.Code.Width(dv.codeWidth).Render(content),
409 ))
410 beforeLine++
411 }
412 write("\n")
413
414 printedLines++
415 }
416 }
417
418 for printedLines < dv.height {
419 ls := dv.style.MissingLine
420 if dv.lineNumbers {
421 write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
422 write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
423 }
424 write(ls.Code.Width(dv.fullCodeWidth).Render(" "))
425 write("\n")
426 printedLines++
427 }
428
429 return b.String()
430}
431
432// renderSplit renders the split (side-by-side) diff view as a string.
433func (dv *DiffView) renderSplit() string {
434 var b strings.Builder
435
436 beforeFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
437 afterFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth + btoi(dv.extraColOnAfter))
438 printedLines := -dv.yOffset
439
440 write := func(s string) {
441 if printedLines >= 0 {
442 b.WriteString(s)
443 }
444 }
445
446outer:
447 for i, h := range dv.splitHunks {
448 ls := dv.style.DividerLine
449 if dv.lineNumbers {
450 write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
451 }
452 content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
453 write(ls.Code.Width(dv.fullCodeWidth).Render(content))
454 if dv.lineNumbers {
455 write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
456 }
457 write(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
458 write("\n")
459 printedLines++
460
461 beforeLine := h.fromLine
462 afterLine := h.toLine
463
464 for j, l := range h.lines {
465 // print ellipis if we don't have enough space to print the rest of the diff
466 hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
467 isLastHunk := i+1 == len(dv.unified.Hunks)
468 isLastLine := j+1 == len(h.lines)
469 if hasReachedHeight && (!isLastHunk || !isLastLine) {
470 ls := dv.style.MissingLine
471 if l.before != nil {
472 ls = dv.lineStyleForType(l.before.Kind)
473 }
474 if dv.lineNumbers {
475 write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
476 }
477 write(beforeFullContentStyle.Render(
478 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
479 ))
480 ls = dv.style.MissingLine
481 if l.after != nil {
482 ls = dv.lineStyleForType(l.after.Kind)
483 }
484 if dv.lineNumbers {
485 write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
486 }
487 write(afterFullContentStyle.Render(
488 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
489 ))
490 write("\n")
491 break outer
492 }
493
494 getContent := func(content string, ls LineStyle) string {
495 content = strings.TrimSuffix(content, "\n")
496 content = dv.hightlightCode(content, ls.Code.GetBackground())
497 content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
498 content = ansi.Truncate(content, dv.codeWidth, "…")
499 return content
500 }
501 getLeadingEllipsis := func(content string) bool {
502 return dv.xOffset > 0 && strings.TrimSpace(content) != ""
503 }
504
505 switch {
506 case l.before == nil:
507 ls := dv.style.MissingLine
508 if dv.lineNumbers {
509 write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
510 }
511 write(beforeFullContentStyle.Render(
512 ls.Code.Width(dv.fullCodeWidth).Render(" "),
513 ))
514 case l.before.Kind == udiff.Equal:
515 ls := dv.style.EqualLine
516 content := getContent(l.before.Content, ls)
517 leadingEllipsis := getLeadingEllipsis(content)
518 if dv.lineNumbers {
519 write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
520 }
521 write(beforeFullContentStyle.Render(
522 ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
523 ))
524 beforeLine++
525 case l.before.Kind == udiff.Delete:
526 ls := dv.style.DeleteLine
527 content := getContent(l.before.Content, ls)
528 leadingEllipsis := getLeadingEllipsis(content)
529 if dv.lineNumbers {
530 write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
531 }
532 write(beforeFullContentStyle.Render(
533 ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
534 ls.Code.Width(dv.codeWidth).Render(content),
535 ))
536 beforeLine++
537 }
538
539 switch {
540 case l.after == nil:
541 ls := dv.style.MissingLine
542 if dv.lineNumbers {
543 write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
544 }
545 write(afterFullContentStyle.Render(
546 ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "),
547 ))
548 case l.after.Kind == udiff.Equal:
549 ls := dv.style.EqualLine
550 content := getContent(l.after.Content, ls)
551 leadingEllipsis := getLeadingEllipsis(content)
552 if dv.lineNumbers {
553 write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
554 }
555 write(afterFullContentStyle.Render(
556 ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(ternary(leadingEllipsis, " …", " ") + content),
557 ))
558 afterLine++
559 case l.after.Kind == udiff.Insert:
560 ls := dv.style.InsertLine
561 content := getContent(l.after.Content, ls)
562 leadingEllipsis := getLeadingEllipsis(content)
563 if dv.lineNumbers {
564 write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
565 }
566 write(afterFullContentStyle.Render(
567 ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
568 ls.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(content),
569 ))
570 afterLine++
571 }
572
573 write("\n")
574
575 printedLines++
576 }
577 }
578
579 for printedLines < dv.height {
580 ls := dv.style.MissingLine
581 if dv.lineNumbers {
582 write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
583 }
584 write(ls.Code.Width(dv.fullCodeWidth).Render(" "))
585 if dv.lineNumbers {
586 write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
587 }
588 write(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
589 write("\n")
590 printedLines++
591 }
592
593 return b.String()
594}
595
596// hunkLineFor formats the header line for a hunk in the unified diff view.
597func (dv *DiffView) hunkLineFor(h *udiff.Hunk) string {
598 beforeShownLines, afterShownLines := dv.hunkShownLines(h)
599
600 return fmt.Sprintf(
601 " @@ -%d,%d +%d,%d @@ ",
602 h.FromLine,
603 beforeShownLines,
604 h.ToLine,
605 afterShownLines,
606 )
607}
608
609// hunkShownLines calculates the number of lines shown in a hunk for both before
610// and after versions.
611func (dv *DiffView) hunkShownLines(h *udiff.Hunk) (before, after int) {
612 for _, l := range h.Lines {
613 switch l.Kind {
614 case udiff.Equal:
615 before++
616 after++
617 case udiff.Insert:
618 after++
619 case udiff.Delete:
620 before++
621 }
622 }
623 return
624}
625
626func (dv *DiffView) lineStyleForType(t udiff.OpKind) LineStyle {
627 switch t {
628 case udiff.Equal:
629 return dv.style.EqualLine
630 case udiff.Insert:
631 return dv.style.InsertLine
632 case udiff.Delete:
633 return dv.style.DeleteLine
634 default:
635 return dv.style.MissingLine
636 }
637}
638
639func (dv *DiffView) hightlightCode(source string, bgColor color.Color) string {
640 if dv.chromaStyle == nil {
641 return source
642 }
643
644 l := dv.getChromaLexer(source)
645 f := dv.getChromaFormatter(bgColor)
646
647 it, err := l.Tokenise(nil, source)
648 if err != nil {
649 return source
650 }
651
652 var b strings.Builder
653 if err := f.Format(&b, dv.chromaStyle, it); err != nil {
654 return source
655 }
656 return b.String()
657}
658
659func (dv *DiffView) getChromaLexer(source string) chroma.Lexer {
660 l := lexers.Match(dv.before.path)
661 if l == nil {
662 l = lexers.Analyse(source)
663 }
664 if l == nil {
665 l = lexers.Fallback
666 }
667 return chroma.Coalesce(l)
668}
669
670func (dv *DiffView) getChromaFormatter(gbColor color.Color) chroma.Formatter {
671 return chromaFormatter{
672 bgColor: gbColor,
673 }
674}