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
152// computeDiff computes the differences between the "before" and "after" files.
153func (dv *DiffView) computeDiff() error {
154 if dv.isComputed {
155 return dv.err
156 }
157 dv.isComputed = true
158 dv.edits = myers.ComputeEdits( //nolint:staticcheck
159 dv.before.content,
160 dv.after.content,
161 )
162 dv.unified, dv.err = udiff.ToUnifiedDiff(
163 dv.before.path,
164 dv.after.path,
165 dv.before.content,
166 dv.edits,
167 dv.contextLines,
168 )
169 return dv.err
170}
171
172// convertDiffToSplit converts the unified diff to a split diff if the layout is
173// set to split.
174func (dv *DiffView) convertDiffToSplit() {
175 if dv.layout != layoutSplit {
176 return
177 }
178
179 dv.splitHunks = make([]splitHunk, len(dv.unified.Hunks))
180 for i, h := range dv.unified.Hunks {
181 dv.splitHunks[i] = hunkToSplit(h)
182 }
183}
184
185// adjustStyles adjusts adds padding and alignment to the styles.
186func (dv *DiffView) adjustStyles() {
187 dv.style.MissingLine.LineNumber = setPadding(dv.style.MissingLine.LineNumber)
188 dv.style.DividerLine.LineNumber = setPadding(dv.style.DividerLine.LineNumber)
189 dv.style.EqualLine.LineNumber = setPadding(dv.style.EqualLine.LineNumber)
190 dv.style.InsertLine.LineNumber = setPadding(dv.style.InsertLine.LineNumber)
191 dv.style.DeleteLine.LineNumber = setPadding(dv.style.DeleteLine.LineNumber)
192}
193
194// detectNumDigits calculates the maximum number of digits needed for before and
195// after line numbers.
196func (dv *DiffView) detectNumDigits() {
197 dv.beforeNumDigits = 0
198 dv.afterNumDigits = 0
199
200 for _, h := range dv.unified.Hunks {
201 dv.beforeNumDigits = max(dv.beforeNumDigits, len(strconv.Itoa(h.FromLine+len(h.Lines))))
202 dv.afterNumDigits = max(dv.afterNumDigits, len(strconv.Itoa(h.ToLine+len(h.Lines))))
203 }
204}
205
206func setPadding(s lipgloss.Style) lipgloss.Style {
207 return s.Padding(0, lineNumPadding).Align(lipgloss.Right)
208}
209
210// detectCodeWidth calculates the maximum width of code lines in the diff view.
211func (dv *DiffView) detectCodeWidth() {
212 switch dv.layout {
213 case layoutUnified:
214 dv.detectUnifiedCodeWidth()
215 case layoutSplit:
216 dv.detectSplitCodeWidth()
217 }
218 dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
219}
220
221// detectUnifiedCodeWidth calculates the maximum width of code lines in a
222// unified diff.
223func (dv *DiffView) detectUnifiedCodeWidth() {
224 dv.codeWidth = 0
225
226 for _, h := range dv.unified.Hunks {
227 shownLines := ansi.StringWidth(dv.hunkLineFor(h))
228
229 for _, l := range h.Lines {
230 lineWidth := ansi.StringWidth(strings.TrimSuffix(l.Content, "\n")) + 1
231 dv.codeWidth = max(dv.codeWidth, lineWidth, shownLines)
232 }
233 }
234}
235
236// detectSplitCodeWidth calculates the maximum width of code lines in a
237// split diff.
238func (dv *DiffView) detectSplitCodeWidth() {
239 dv.codeWidth = 0
240
241 for i, h := range dv.splitHunks {
242 shownLines := ansi.StringWidth(dv.hunkLineFor(dv.unified.Hunks[i]))
243
244 for _, l := range h.lines {
245 if l.before != nil {
246 codeWidth := ansi.StringWidth(strings.TrimSuffix(l.before.Content, "\n")) + 1
247 dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
248 }
249 if l.after != nil {
250 codeWidth := ansi.StringWidth(strings.TrimSuffix(l.after.Content, "\n")) + 1
251 dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
252 }
253 }
254 }
255}
256
257// renderUnified renders the unified diff view as a string.
258func (dv *DiffView) renderUnified() string {
259 var b strings.Builder
260
261 for _, h := range dv.unified.Hunks {
262 if dv.lineNumbers {
263 b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", dv.beforeNumDigits)))
264 b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", dv.afterNumDigits)))
265 }
266 b.WriteString(dv.style.DividerLine.Code.Width(dv.fullCodeWidth).Render(dv.hunkLineFor(h)))
267 b.WriteRune('\n')
268
269 beforeLine := h.FromLine
270 afterLine := h.ToLine
271
272 for _, l := range h.Lines {
273 content := strings.TrimSuffix(l.Content, "\n")
274
275 switch l.Kind {
276 case udiff.Equal:
277 if dv.lineNumbers {
278 b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
279 b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
280 }
281 b.WriteString(dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render(" " + content))
282 beforeLine++
283 afterLine++
284 case udiff.Insert:
285 if dv.lineNumbers {
286 b.WriteString(dv.style.InsertLine.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
287 b.WriteString(dv.style.InsertLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
288 }
289 b.WriteString(dv.style.InsertLine.Symbol.Render("+ "))
290 b.WriteString(dv.style.InsertLine.Code.Width(dv.codeWidth).Render(content))
291 afterLine++
292 case udiff.Delete:
293 if dv.lineNumbers {
294 b.WriteString(dv.style.DeleteLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
295 b.WriteString(dv.style.DeleteLine.LineNumber.Render(pad(" ", dv.afterNumDigits)))
296 }
297 b.WriteString(dv.style.DeleteLine.Symbol.Render("- "))
298 b.WriteString(dv.style.DeleteLine.Code.Width(dv.codeWidth).Render(content))
299 beforeLine++
300 }
301 b.WriteRune('\n')
302 }
303 }
304
305 return b.String()
306}
307
308// renderSplit renders the split (side-by-side) diff view as a string.
309func (dv *DiffView) renderSplit() string {
310 var b strings.Builder
311
312 for i, h := range dv.splitHunks {
313 if dv.lineNumbers {
314 b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", dv.beforeNumDigits)))
315 }
316 b.WriteString(dv.style.DividerLine.Code.Width(dv.fullCodeWidth).Render(dv.hunkLineFor(dv.unified.Hunks[i])))
317 if dv.lineNumbers {
318 b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", dv.afterNumDigits)))
319 }
320 b.WriteString(dv.style.DividerLine.Code.Width(dv.fullCodeWidth).Render(" "))
321 b.WriteRune('\n')
322
323 beforeLine := h.fromLine
324 afterLine := h.toLine
325
326 for _, l := range h.lines {
327 var beforeContent string
328 var afterContent string
329 if l.before != nil {
330 beforeContent = strings.TrimSuffix(l.before.Content, "\n")
331 }
332 if l.after != nil {
333 afterContent = strings.TrimSuffix(l.after.Content, "\n")
334 }
335
336 switch {
337 case l.before == nil:
338 if dv.lineNumbers {
339 b.WriteString(dv.style.MissingLine.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
340 }
341 b.WriteString(dv.style.MissingLine.Code.Width(dv.fullCodeWidth).Render(" "))
342 case l.before.Kind == udiff.Equal:
343 if dv.lineNumbers {
344 b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
345 }
346 b.WriteString(dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render(" " + beforeContent))
347 beforeLine++
348 case l.before.Kind == udiff.Delete:
349 if dv.lineNumbers {
350 b.WriteString(dv.style.DeleteLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
351 }
352 b.WriteString(dv.style.DeleteLine.Symbol.Render("- "))
353 b.WriteString(dv.style.DeleteLine.Code.Width(dv.codeWidth).Render(beforeContent))
354 beforeLine++
355 }
356
357 switch {
358 case l.after == nil:
359 if dv.lineNumbers {
360 b.WriteString(dv.style.MissingLine.LineNumber.Render(pad(" ", dv.afterNumDigits)))
361 }
362 b.WriteString(dv.style.MissingLine.Code.Width(dv.fullCodeWidth).Render(" "))
363 case l.after.Kind == udiff.Equal:
364 if dv.lineNumbers {
365 b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
366 }
367 b.WriteString(dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render(" " + afterContent))
368 afterLine++
369 case l.after.Kind == udiff.Insert:
370 if dv.lineNumbers {
371 b.WriteString(dv.style.InsertLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
372 }
373 b.WriteString(dv.style.InsertLine.Symbol.Render("+ "))
374 b.WriteString(dv.style.InsertLine.Code.Width(dv.codeWidth).Render(afterContent))
375 afterLine++
376 }
377
378 b.WriteRune('\n')
379 }
380 }
381
382 return b.String()
383}
384
385// hunkLineFor formats the header line for a hunk in the unified diff view.
386func (dv *DiffView) hunkLineFor(h *udiff.Hunk) string {
387 beforeShownLines, afterShownLines := dv.hunkShownLines(h)
388
389 return fmt.Sprintf(
390 " @@ -%d,%d +%d,%d @@ ",
391 h.FromLine,
392 beforeShownLines,
393 h.ToLine,
394 afterShownLines,
395 )
396}
397
398// hunkShownLines calculates the number of lines shown in a hunk for both before
399// and after versions.
400func (dv *DiffView) hunkShownLines(h *udiff.Hunk) (before, after int) {
401 for _, l := range h.Lines {
402 switch l.Kind {
403 case udiff.Equal:
404 before++
405 after++
406 case udiff.Insert:
407 after++
408 case udiff.Delete:
409 before++
410 }
411 }
412 return
413}