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 leadingSymbolsSize = 2
16
17type file struct {
18 path string
19 content string
20}
21
22type layout int
23
24const (
25 layoutUnified layout = iota + 1
26 layoutSplit
27)
28
29// DiffView represents a view for displaying differences between two files.
30type DiffView struct {
31 layout layout
32 before file
33 after file
34 contextLines int
35 lineNumbers bool
36 highlight bool
37 height int
38 width int
39 style Style
40
41 isComputed bool
42 err error
43 unified udiff.UnifiedDiff
44 edits []udiff.Edit
45
46 splitHunks []splitHunk
47
48 codeWidth int
49 fullCodeWidth int // with leading symbols
50 beforeNumDigits int
51 afterNumDigits int
52}
53
54// New creates a new DiffView with default settings.
55func New() *DiffView {
56 dv := &DiffView{
57 layout: layoutUnified,
58 contextLines: udiff.DefaultContextLines,
59 lineNumbers: true,
60 }
61 if lipgloss.HasDarkBackground(os.Stdin, os.Stdout) {
62 dv.style = DefaultDarkStyle
63 } else {
64 dv.style = DefaultLightStyle
65 }
66 return dv
67}
68
69// Unified sets the layout of the DiffView to unified.
70func (dv *DiffView) Unified() *DiffView {
71 dv.layout = layoutUnified
72 return dv
73}
74
75// Split sets the layout of the DiffView to split (side-by-side).
76func (dv *DiffView) Split() *DiffView {
77 dv.layout = layoutSplit
78 return dv
79}
80
81// Before sets the "before" file for the DiffView.
82func (dv *DiffView) Before(path, content string) *DiffView {
83 dv.before = file{path: path, content: content}
84 return dv
85}
86
87// After sets the "after" file for the DiffView.
88func (dv *DiffView) After(path, content string) *DiffView {
89 dv.after = file{path: path, content: content}
90 return dv
91}
92
93// ContextLines sets the number of context lines for the DiffView.
94func (dv *DiffView) ContextLines(contextLines int) *DiffView {
95 dv.contextLines = contextLines
96 return dv
97}
98
99// Style sets the style for the DiffView.
100func (dv *DiffView) Style(style Style) *DiffView {
101 dv.style = style
102 return dv
103}
104
105// LineNumbers sets whether to display line numbers in the DiffView.
106func (dv *DiffView) LineNumbers(lineNumbers bool) *DiffView {
107 dv.lineNumbers = lineNumbers
108 return dv
109}
110
111// SyntaxHightlight sets whether to enable syntax highlighting in the DiffView.
112func (dv *DiffView) SyntaxHightlight(highlight bool) *DiffView {
113 dv.highlight = highlight
114 return dv
115}
116
117// Height sets the height of the DiffView.
118func (dv *DiffView) Height(height int) *DiffView {
119 dv.height = height
120 return dv
121}
122
123// Width sets the width of the DiffView.
124func (dv *DiffView) Width(width int) *DiffView {
125 dv.width = width
126 return dv
127}
128
129// String returns the string representation of the DiffView.
130func (dv *DiffView) String() string {
131 if err := dv.computeDiff(); err != nil {
132 return err.Error()
133 }
134 dv.convertDiffToSplit()
135 dv.detectNumDigits()
136 dv.detectCodeWidth()
137
138 switch dv.layout {
139 case layoutUnified:
140 return dv.renderUnified()
141 case layoutSplit:
142 return dv.renderSplit()
143 default:
144 panic("unknown diffview layout")
145 }
146}
147
148// renderUnified renders the unified diff view as a string.
149func (dv *DiffView) renderUnified() string {
150 var b strings.Builder
151
152 for _, h := range dv.unified.Hunks {
153 if dv.lineNumbers {
154 b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", dv.beforeNumDigits)))
155 b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", dv.afterNumDigits)))
156 }
157 b.WriteString(dv.style.DividerLine.Code.Width(dv.fullCodeWidth).Render(dv.hunkLineFor(h)))
158 b.WriteRune('\n')
159
160 beforeLine := h.FromLine
161 afterLine := h.ToLine
162
163 for _, l := range h.Lines {
164 content := strings.TrimSuffix(l.Content, "\n")
165
166 switch l.Kind {
167 case udiff.Equal:
168 if dv.lineNumbers {
169 b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
170 b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
171 }
172 b.WriteString(dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render(" " + content))
173 beforeLine++
174 afterLine++
175 case udiff.Insert:
176 if dv.lineNumbers {
177 b.WriteString(dv.style.InsertLine.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
178 b.WriteString(dv.style.InsertLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
179 }
180 b.WriteString(dv.style.InsertLine.Symbol.Render("+ "))
181 b.WriteString(dv.style.InsertLine.Code.Width(dv.codeWidth).Render(content))
182 afterLine++
183 case udiff.Delete:
184 if dv.lineNumbers {
185 b.WriteString(dv.style.DeleteLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
186 b.WriteString(dv.style.DeleteLine.LineNumber.Render(pad(" ", dv.afterNumDigits)))
187 }
188 b.WriteString(dv.style.DeleteLine.Symbol.Render("- "))
189 b.WriteString(dv.style.DeleteLine.Code.Width(dv.codeWidth).Render(content))
190 beforeLine++
191 }
192 b.WriteRune('\n')
193 }
194 }
195
196 return b.String()
197}
198
199// renderSplit renders the split (side-by-side) diff view as a string.
200func (dv *DiffView) renderSplit() string {
201 var b strings.Builder
202
203 for i, h := range dv.splitHunks {
204 if dv.lineNumbers {
205 b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", dv.beforeNumDigits)))
206 }
207 b.WriteString(dv.style.DividerLine.Code.Width(dv.fullCodeWidth).Render(dv.hunkLineFor(dv.unified.Hunks[i])))
208 if dv.lineNumbers {
209 b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", dv.afterNumDigits)))
210 }
211 b.WriteString(dv.style.DividerLine.Code.Width(dv.fullCodeWidth).Render(" "))
212 b.WriteRune('\n')
213
214 beforeLine := h.fromLine
215 afterLine := h.toLine
216
217 for _, l := range h.lines {
218 var beforeContent string
219 var afterContent string
220 if l.before != nil {
221 beforeContent = strings.TrimSuffix(l.before.Content, "\n")
222 }
223 if l.after != nil {
224 afterContent = strings.TrimSuffix(l.after.Content, "\n")
225 }
226
227 switch {
228 case l.before == nil:
229 if dv.lineNumbers {
230 b.WriteString(dv.style.MissingLine.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
231 }
232 b.WriteString(dv.style.MissingLine.Code.Width(dv.fullCodeWidth).Render(" "))
233 case l.before.Kind == udiff.Equal:
234 if dv.lineNumbers {
235 b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
236 }
237 b.WriteString(dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render(" " + beforeContent))
238 beforeLine++
239 case l.before.Kind == udiff.Delete:
240 if dv.lineNumbers {
241 b.WriteString(dv.style.DeleteLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
242 }
243 b.WriteString(dv.style.DeleteLine.Symbol.Render("- "))
244 b.WriteString(dv.style.DeleteLine.Code.Width(dv.codeWidth).Render(beforeContent))
245 beforeLine++
246 }
247
248 switch {
249 case l.after == nil:
250 if dv.lineNumbers {
251 b.WriteString(dv.style.MissingLine.LineNumber.Render(pad(" ", dv.afterNumDigits)))
252 }
253 b.WriteString(dv.style.MissingLine.Code.Width(dv.fullCodeWidth).Render(" "))
254 case l.after.Kind == udiff.Equal:
255 if dv.lineNumbers {
256 b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
257 }
258 b.WriteString(dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render(" " + afterContent))
259 afterLine++
260 case l.after.Kind == udiff.Insert:
261 if dv.lineNumbers {
262 b.WriteString(dv.style.InsertLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
263 }
264 b.WriteString(dv.style.InsertLine.Symbol.Render("+ "))
265 b.WriteString(dv.style.InsertLine.Code.Width(dv.codeWidth).Render(afterContent))
266 afterLine++
267 }
268
269 b.WriteRune('\n')
270 }
271 }
272
273 return b.String()
274}
275
276func (dv *DiffView) computeDiff() error {
277 if dv.isComputed {
278 return dv.err
279 }
280 dv.isComputed = true
281 dv.edits = myers.ComputeEdits( //nolint:staticcheck
282 dv.before.content,
283 dv.after.content,
284 )
285 dv.unified, dv.err = udiff.ToUnifiedDiff(
286 dv.before.path,
287 dv.after.path,
288 dv.before.content,
289 dv.edits,
290 dv.contextLines,
291 )
292 return dv.err
293}
294
295// detectNumDigits calculates the maximum number of digits needed for before and
296// after line numbers.
297func (dv *DiffView) detectNumDigits() {
298 dv.beforeNumDigits = 0
299 dv.afterNumDigits = 0
300
301 for _, h := range dv.unified.Hunks {
302 dv.beforeNumDigits = max(dv.beforeNumDigits, len(strconv.Itoa(h.FromLine+len(h.Lines))))
303 dv.afterNumDigits = max(dv.afterNumDigits, len(strconv.Itoa(h.ToLine+len(h.Lines))))
304 }
305}
306
307func (dv *DiffView) hunkLineFor(h *udiff.Hunk) string {
308 beforeShownLines, afterShownLines := dv.hunkShownLines(h)
309
310 return fmt.Sprintf(
311 " @@ -%d,%d +%d,%d @@ ",
312 h.FromLine,
313 beforeShownLines,
314 h.ToLine,
315 afterShownLines,
316 )
317}
318
319// hunkShownLines calculates the number of lines shown in a hunk for both before
320// and after versions.
321func (dv *DiffView) hunkShownLines(h *udiff.Hunk) (before, after int) {
322 for _, l := range h.Lines {
323 switch l.Kind {
324 case udiff.Equal:
325 before++
326 after++
327 case udiff.Insert:
328 after++
329 case udiff.Delete:
330 before++
331 }
332 }
333 return
334}
335
336func (dv *DiffView) detectCodeWidth() {
337 switch dv.layout {
338 case layoutUnified:
339 dv.detectUnifiedCodeWidth()
340 case layoutSplit:
341 dv.detectSplitCodeWidth()
342 }
343 dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
344}
345
346func (dv *DiffView) detectUnifiedCodeWidth() {
347 dv.codeWidth = 0
348
349 for _, h := range dv.unified.Hunks {
350 shownLines := ansi.StringWidth(dv.hunkLineFor(h))
351
352 for _, l := range h.Lines {
353 lineWidth := ansi.StringWidth(strings.TrimSuffix(l.Content, "\n")) + 1
354 dv.codeWidth = max(dv.codeWidth, lineWidth, shownLines)
355 }
356 }
357}
358
359func (dv *DiffView) convertDiffToSplit() {
360 if dv.layout != layoutSplit {
361 return
362 }
363
364 dv.splitHunks = make([]splitHunk, len(dv.unified.Hunks))
365 for i, h := range dv.unified.Hunks {
366 dv.splitHunks[i] = hunkToSplit(h)
367 }
368}
369
370func (dv *DiffView) detectSplitCodeWidth() {
371 dv.codeWidth = 0
372
373 for i, h := range dv.splitHunks {
374 shownLines := ansi.StringWidth(dv.hunkLineFor(dv.unified.Hunks[i]))
375
376 for _, l := range h.lines {
377 if l.before != nil {
378 codeWidth := ansi.StringWidth(strings.TrimSuffix(l.before.Content, "\n")) + 1
379 dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
380 }
381 if l.after != nil {
382 codeWidth := ansi.StringWidth(strings.TrimSuffix(l.after.Content, "\n")) + 1
383 dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
384 }
385 }
386 }
387}