on 76d90bc help sign in
crush/internal/exp/diffview/diffview.go
674 lines 18 kB history raw
1package diffview
3import (
4 "fmt"
5 "image/color"
6 "strconv"
7 "strings"
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"
17const (
18 leadingSymbolsSize = 2
19 lineNumPadding = 1
22type file struct {
23 path string
24 content string
27type layout int
29const (
30 layoutUnified layout = iota + 1
31 layoutSplit
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
49 isComputed bool
50 err error
51 unified udiff.UnifiedDiff
52 edits []udiff.Edit
54 splitHunks []splitHunk
56 codeWidth int
57 fullCodeWidth int // with leading symbols
58 extraColOnAfter bool // add extra column on after panel
59 beforeNumDigits int
60 afterNumDigits int
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
75// Unified sets the layout of the DiffView to unified.
76func (dv *DiffView) Unified() *DiffView {
77 dv.layout = layoutUnified
78 return dv
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
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
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
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
105// Style sets the style for the DiffView.
106func (dv *DiffView) Style(style Style) *DiffView {
107 dv.style = style
108 return dv
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
117// Height sets the height of the DiffView.
118func (dv *DiffView) Height(height int) *DiffView {
119 dv.height = height
120 return dv
123// Width sets the width of the DiffView.
124func (dv *DiffView) Width(width int) *DiffView {
125 dv.width = width
126 return dv
129// XOffset sets the horizontal offset for the DiffView.
130func (dv *DiffView) XOffset(xOffset int) *DiffView {
131 dv.xOffset = xOffset
132 return dv
135// YOffset sets the vertical offset for the DiffView.
136func (dv *DiffView) YOffset(yOffset int) *DiffView {
137 dv.yOffset = yOffset
138 return dv
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
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
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()
165 if dv.width <= 0 {
166 dv.detectCodeWidth()
167 } else {
168 dv.resizeCodeWidth()
169 }
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 }
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 }
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)
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
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 }
224 dv.splitHunks = make([]splitHunk, len(dv.unified.Hunks))
225 for i, h := range dv.unified.Hunks {
226 dv.splitHunks[i] = hunkToSplit(h)
227 }
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)
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
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 }
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
265// detectUnifiedCodeWidth calculates the maximum width of code lines in a
266// unified diff.
267func (dv *DiffView) detectUnifiedCodeWidth() {
268 dv.codeWidth = 0
270 for _, h := range dv.unified.Hunks {
271 shownLines := ansi.StringWidth(dv.hunkLineFor(h))
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 }
280// detectSplitCodeWidth calculates the maximum width of code lines in a
281// split diff.
282func (dv *DiffView) detectSplitCodeWidth() {
283 dv.codeWidth = 0
285 for i, h := range dv.splitHunks {
286 shownLines := ansi.StringWidth(dv.hunkLineFor(dv.unified.Hunks[i]))
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 }
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
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 }
315 dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
318// renderUnified renders the unified diff view as a string.
319func (dv *DiffView) renderUnified() string {
320 var b strings.Builder
322 fullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
323 printedLines := -dv.yOffset
325 write := func(s string) {
326 if printedLines >= 0 {
327 b.WriteString(s)
328 }
329 }
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++
343 beforeLine := h.FromLine
344 afterLine := h.ToLine
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 }
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 }
372 leadingEllipsis := dv.xOffset > 0 && strings.TrimSpace(content) != ""
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")
414 printedLines++
415 }
416 }
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 }
429 return b.String()
432// renderSplit renders the split (side-by-side) diff view as a string.
433func (dv *DiffView) renderSplit() string {
434 var b strings.Builder
436 beforeFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
437 afterFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth + btoi(dv.extraColOnAfter))
438 printedLines := -dv.yOffset
440 write := func(s string) {
441 if printedLines >= 0 {
442 b.WriteString(s)
443 }
444 }
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++
461 beforeLine := h.fromLine
462 afterLine := h.toLine
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 }
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 }
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 }
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 }
573 write("\n")
575 printedLines++
576 }
577 }
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 }
593 return b.String()
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)
600 return fmt.Sprintf(
601 " @@ -%d,%d +%d,%d @@ ",
602 h.FromLine,
603 beforeShownLines,
604 h.ToLine,
605 afterShownLines,
606 )
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
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 }
639func (dv *DiffView) hightlightCode(source string, bgColor color.Color) string {
640 if dv.chromaStyle == nil {
641 return source
642 }
644 l := dv.getChromaLexer(source)
645 f := dv.getChromaFormatter(bgColor)
647 it, err := l.Tokenise(nil, source)
648 if err != nil {
649 return source
650 }
652 var b strings.Builder
653 if err := f.Format(&b, dv.chromaStyle, it); err != nil {
654 return source
655 }
656 return b.String()
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)
670func (dv *DiffView) getChromaFormatter(gbColor color.Color) chroma.Formatter {
671 return chromaFormatter{
672 bgColor: gbColor,
673 }