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