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