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