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