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