on d8a4fa0 help sign in
crush/internal/exp/diffview/diffview.go
681 lines 19 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 highlight bool
42 height int
43 width int
44 xOffset int
45 yOffset int
46 style Style
47 tabWidth int
48 chromaStyle *chroma.Style
50 isComputed bool
51 err error
52 unified udiff.UnifiedDiff
53 edits []udiff.Edit
55 splitHunks []splitHunk
57 codeWidth int
58 fullCodeWidth int // with leading symbols
59 extraColOnAfter bool // add extra column on after panel
60 beforeNumDigits int
61 afterNumDigits int
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
76// Unified sets the layout of the DiffView to unified.
77func (dv *DiffView) Unified() *DiffView {
78 dv.layout = layoutUnified
79 return dv
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
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
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
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
106// Style sets the style for the DiffView.
107func (dv *DiffView) Style(style Style) *DiffView {
108 dv.style = style
109 return dv
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
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
124// Height sets the height of the DiffView.
125func (dv *DiffView) Height(height int) *DiffView {
126 dv.height = height
127 return dv
130// Width sets the width of the DiffView.
131func (dv *DiffView) Width(width int) *DiffView {
132 dv.width = width
133 return dv
136// XOffset sets the horizontal offset for the DiffView.
137func (dv *DiffView) XOffset(xOffset int) *DiffView {
138 dv.xOffset = xOffset
139 return dv
142// YOffset sets the vertical offset for the DiffView.
143func (dv *DiffView) YOffset(yOffset int) *DiffView {
144 dv.yOffset = yOffset
145 return dv
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
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
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()
172 if dv.width <= 0 {
173 dv.detectCodeWidth()
174 } else {
175 dv.resizeCodeWidth()
176 }
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 }
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 }
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)
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
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 }
231 dv.splitHunks = make([]splitHunk, len(dv.unified.Hunks))
232 for i, h := range dv.unified.Hunks {
233 dv.splitHunks[i] = hunkToSplit(h)
234 }
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)
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
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 }
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
272// detectUnifiedCodeWidth calculates the maximum width of code lines in a
273// unified diff.
274func (dv *DiffView) detectUnifiedCodeWidth() {
275 dv.codeWidth = 0
277 for _, h := range dv.unified.Hunks {
278 shownLines := ansi.StringWidth(dv.hunkLineFor(h))
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 }
287// detectSplitCodeWidth calculates the maximum width of code lines in a
288// split diff.
289func (dv *DiffView) detectSplitCodeWidth() {
290 dv.codeWidth = 0
292 for i, h := range dv.splitHunks {
293 shownLines := ansi.StringWidth(dv.hunkLineFor(dv.unified.Hunks[i]))
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 }
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
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 }
322 dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
325// renderUnified renders the unified diff view as a string.
326func (dv *DiffView) renderUnified() string {
327 var b strings.Builder
329 fullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
330 printedLines := -dv.yOffset
332 write := func(s string) {
333 if printedLines >= 0 {
334 b.WriteString(s)
335 }
336 }
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++
350 beforeLine := h.FromLine
351 afterLine := h.ToLine
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 }
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 }
379 leadingEllipsis := dv.xOffset > 0 && strings.TrimSpace(content) != ""
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")
421 printedLines++
422 }
423 }
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 }
436 return b.String()
439// renderSplit renders the split (side-by-side) diff view as a string.
440func (dv *DiffView) renderSplit() string {
441 var b strings.Builder
443 beforeFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
444 afterFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth + btoi(dv.extraColOnAfter))
445 printedLines := -dv.yOffset
447 write := func(s string) {
448 if printedLines >= 0 {
449 b.WriteString(s)
450 }
451 }
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++
468 beforeLine := h.fromLine
469 afterLine := h.toLine
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 }
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 }
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 }
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 }
580 write("\n")
582 printedLines++
583 }
584 }
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 }
600 return b.String()
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)
607 return fmt.Sprintf(
608 " @@ -%d,%d +%d,%d @@ ",
609 h.FromLine,
610 beforeShownLines,
611 h.ToLine,
612 afterShownLines,
613 )
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
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 }
646func (dv *DiffView) hightlightCode(source string, bgColor color.Color) string {
647 if dv.chromaStyle == nil {
648 return source
649 }
651 l := dv.getChromaLexer(source)
652 f := dv.getChromaFormatter(bgColor)
654 it, err := l.Tokenise(nil, source)
655 if err != nil {
656 return source
657 }
659 var b strings.Builder
660 if err := f.Format(&b, dv.chromaStyle, it); err != nil {
661 return source
662 }
663 return b.String()
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)
677func (dv *DiffView) getChromaFormatter(gbColor color.Color) chroma.Formatter {
678 return chromaFormatter{
679 bgColor: gbColor,
680 }