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