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