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 if dv.lineNumbers {
335 write(dv.style.EqualLine.LineNumber.Render(pad("…", dv.beforeNumDigits)))
336 write(dv.style.EqualLine.LineNumber.Render(pad("…", dv.afterNumDigits)))
337 }
338 write(fullContentStyle.Render(
339 dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render(" …"),
340 ))
341 write("\n")
342 break outer
343 }
344
345 content := strings.TrimSuffix(l.Content, "\n")
346 content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
347 content = ansi.Truncate(content, dv.codeWidth, "…")
348
349 leadingEllipsis := dv.xOffset > 0 && strings.TrimSpace(content) != ""
350
351 switch l.Kind {
352 case udiff.Equal:
353 if dv.lineNumbers {
354 write(dv.style.EqualLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
355 write(dv.style.EqualLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
356 }
357 write(fullContentStyle.Render(
358 dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
359 ))
360 beforeLine++
361 afterLine++
362 case udiff.Insert:
363 if dv.lineNumbers {
364 write(dv.style.InsertLine.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
365 write(dv.style.InsertLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
366 }
367 write(fullContentStyle.Render(
368 dv.style.InsertLine.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
369 dv.style.InsertLine.Code.Width(dv.codeWidth).Render(content),
370 ))
371 afterLine++
372 case udiff.Delete:
373 if dv.lineNumbers {
374 write(dv.style.DeleteLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
375 write(dv.style.DeleteLine.LineNumber.Render(pad(" ", dv.afterNumDigits)))
376 }
377 write(fullContentStyle.Render(
378 dv.style.DeleteLine.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
379 dv.style.DeleteLine.Code.Width(dv.codeWidth).Render(content),
380 ))
381 beforeLine++
382 }
383 write("\n")
384
385 printedLines++
386 }
387 }
388
389 for printedLines < dv.height {
390 if dv.lineNumbers {
391 write(dv.style.MissingLine.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
392 write(dv.style.MissingLine.LineNumber.Render(pad(" ", dv.afterNumDigits)))
393 }
394 write(dv.style.MissingLine.Code.Width(dv.fullCodeWidth).Render(" "))
395 write("\n")
396 printedLines++
397 }
398
399 return b.String()
400}
401
402// renderSplit renders the split (side-by-side) diff view as a string.
403func (dv *DiffView) renderSplit() string {
404 var b strings.Builder
405
406 beforeFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
407 afterFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth + btoi(dv.extraColOnAfter))
408 printedLines := -dv.yOffset
409
410 write := func(s string) {
411 if printedLines >= 0 {
412 b.WriteString(s)
413 }
414 }
415
416outer:
417 for i, h := range dv.splitHunks {
418 if dv.lineNumbers {
419 write(dv.style.DividerLine.LineNumber.Render(pad("…", dv.beforeNumDigits)))
420 }
421 content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
422 write(dv.style.DividerLine.Code.Width(dv.fullCodeWidth).Render(content))
423 if dv.lineNumbers {
424 write(dv.style.DividerLine.LineNumber.Render(pad("…", dv.afterNumDigits)))
425 }
426 write(dv.style.DividerLine.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
427 write("\n")
428 printedLines++
429
430 beforeLine := h.fromLine
431 afterLine := h.toLine
432
433 for j, l := range h.lines {
434 // print ellipis if we don't have enough space to print the rest of the diff
435 hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
436 isLastHunk := i+1 == len(dv.unified.Hunks)
437 isLastLine := j+1 == len(h.lines)
438 if hasReachedHeight && (!isLastHunk || !isLastLine) {
439 if dv.lineNumbers {
440 write(dv.style.EqualLine.LineNumber.Render(pad("…", dv.beforeNumDigits)))
441 }
442 write(beforeFullContentStyle.Render(
443 dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render(" …"),
444 ))
445 if dv.lineNumbers {
446 write(dv.style.EqualLine.LineNumber.Render(pad("…", dv.afterNumDigits)))
447 }
448 write(afterFullContentStyle.Render(
449 dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render(" …"),
450 ))
451 write("\n")
452 break outer
453 }
454
455 var beforeContent string
456 var afterContent string
457 if l.before != nil {
458 beforeContent = strings.TrimSuffix(l.before.Content, "\n")
459 beforeContent = ansi.GraphemeWidth.Cut(beforeContent, dv.xOffset, len(beforeContent))
460 beforeContent = ansi.Truncate(beforeContent, dv.codeWidth, "…")
461 }
462 if l.after != nil {
463 afterContent = strings.TrimSuffix(l.after.Content, "\n")
464 afterContent = ansi.GraphemeWidth.Cut(afterContent, dv.xOffset, len(afterContent))
465 afterContent = ansi.Truncate(afterContent, dv.codeWidth+btoi(dv.extraColOnAfter), "…")
466 }
467
468 leadingBeforeEllipsis := dv.xOffset > 0 && strings.TrimSpace(beforeContent) != ""
469 leadingAfterEllipsis := dv.xOffset > 0 && strings.TrimSpace(afterContent) != ""
470
471 switch {
472 case l.before == nil:
473 if dv.lineNumbers {
474 write(dv.style.MissingLine.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
475 }
476 write(beforeFullContentStyle.Render(
477 dv.style.MissingLine.Code.Width(dv.fullCodeWidth).Render(" "),
478 ))
479 case l.before.Kind == udiff.Equal:
480 if dv.lineNumbers {
481 write(dv.style.EqualLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
482 }
483 write(beforeFullContentStyle.Render(
484 dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render(ternary(leadingBeforeEllipsis, " …", " ") + beforeContent),
485 ))
486 beforeLine++
487 case l.before.Kind == udiff.Delete:
488 if dv.lineNumbers {
489 write(dv.style.DeleteLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
490 }
491 write(beforeFullContentStyle.Render(
492 dv.style.DeleteLine.Symbol.Render(ternary(leadingBeforeEllipsis, "-…", "- ")) +
493 dv.style.DeleteLine.Code.Width(dv.codeWidth).Render(beforeContent),
494 ))
495 beforeLine++
496 }
497
498 switch {
499 case l.after == nil:
500 if dv.lineNumbers {
501 write(dv.style.MissingLine.LineNumber.Render(pad(" ", dv.afterNumDigits)))
502 }
503 write(afterFullContentStyle.Render(
504 dv.style.MissingLine.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "),
505 ))
506 case l.after.Kind == udiff.Equal:
507 if dv.lineNumbers {
508 write(dv.style.EqualLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
509 }
510 write(afterFullContentStyle.Render(
511 dv.style.EqualLine.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(ternary(leadingAfterEllipsis, " …", " ") + afterContent),
512 ))
513 afterLine++
514 case l.after.Kind == udiff.Insert:
515 if dv.lineNumbers {
516 write(dv.style.InsertLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
517 }
518 write(afterFullContentStyle.Render(
519 dv.style.InsertLine.Symbol.Render(ternary(leadingAfterEllipsis, "+…", "+ ")) +
520 dv.style.InsertLine.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(afterContent),
521 ))
522 afterLine++
523 }
524
525 write("\n")
526
527 printedLines++
528 }
529 }
530
531 for printedLines < dv.height {
532 if dv.lineNumbers {
533 write(dv.style.MissingLine.LineNumber.Render(pad("…", dv.beforeNumDigits)))
534 }
535 write(dv.style.MissingLine.Code.Width(dv.fullCodeWidth).Render(" "))
536 if dv.lineNumbers {
537 write(dv.style.MissingLine.LineNumber.Render(pad("…", dv.afterNumDigits)))
538 }
539 write(dv.style.MissingLine.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
540 write("\n")
541 printedLines++
542 }
543
544 return b.String()
545}
546
547// hunkLineFor formats the header line for a hunk in the unified diff view.
548func (dv *DiffView) hunkLineFor(h *udiff.Hunk) string {
549 beforeShownLines, afterShownLines := dv.hunkShownLines(h)
550
551 return fmt.Sprintf(
552 " @@ -%d,%d +%d,%d @@ ",
553 h.FromLine,
554 beforeShownLines,
555 h.ToLine,
556 afterShownLines,
557 )
558}
559
560// hunkShownLines calculates the number of lines shown in a hunk for both before
561// and after versions.
562func (dv *DiffView) hunkShownLines(h *udiff.Hunk) (before, after int) {
563 for _, l := range h.Lines {
564 switch l.Kind {
565 case udiff.Equal:
566 before++
567 after++
568 case udiff.Insert:
569 after++
570 case udiff.Delete:
571 before++
572 }
573 }
574 return
575}