on ac33d35 help sign in
crush/internal/exp/diffview/diffview.go
413 lines 11 kB history raw
1package diffview
3import (
4 "fmt"
5 "os"
6 "strconv"
7 "strings"
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"
15const (
16 leadingSymbolsSize = 2
17 lineNumPadding = 1
20type file struct {
21 path string
22 content string
25type layout int
27const (
28 layoutUnified layout = iota + 1
29 layoutSplit
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
44 isComputed bool
45 err error
46 unified udiff.UnifiedDiff
47 edits []udiff.Edit
49 splitHunks []splitHunk
51 codeWidth int
52 fullCodeWidth int // with leading symbols
53 beforeNumDigits int
54 afterNumDigits int
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
72// Unified sets the layout of the DiffView to unified.
73func (dv *DiffView) Unified() *DiffView {
74 dv.layout = layoutUnified
75 return dv
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
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
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
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
102// Style sets the style for the DiffView.
103func (dv *DiffView) Style(style Style) *DiffView {
104 dv.style = style
105 return dv
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
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
120// Height sets the height of the DiffView.
121func (dv *DiffView) Height(height int) *DiffView {
122 dv.height = height
123 return dv
126// Width sets the width of the DiffView.
127func (dv *DiffView) Width(width int) *DiffView {
128 dv.width = width
129 return dv
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()
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 }
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
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 }
179 dv.splitHunks = make([]splitHunk, len(dv.unified.Hunks))
180 for i, h := range dv.unified.Hunks {
181 dv.splitHunks[i] = hunkToSplit(h)
182 }
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)
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
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 }
206func setPadding(s lipgloss.Style) lipgloss.Style {
207 return s.Padding(0, lineNumPadding).Align(lipgloss.Right)
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
221// detectUnifiedCodeWidth calculates the maximum width of code lines in a
222// unified diff.
223func (dv *DiffView) detectUnifiedCodeWidth() {
224 dv.codeWidth = 0
226 for _, h := range dv.unified.Hunks {
227 shownLines := ansi.StringWidth(dv.hunkLineFor(h))
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 }
236// detectSplitCodeWidth calculates the maximum width of code lines in a
237// split diff.
238func (dv *DiffView) detectSplitCodeWidth() {
239 dv.codeWidth = 0
241 for i, h := range dv.splitHunks {
242 shownLines := ansi.StringWidth(dv.hunkLineFor(dv.unified.Hunks[i]))
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 }
257// renderUnified renders the unified diff view as a string.
258func (dv *DiffView) renderUnified() string {
259 var b strings.Builder
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')
269 beforeLine := h.FromLine
270 afterLine := h.ToLine
272 for _, l := range h.Lines {
273 content := strings.TrimSuffix(l.Content, "\n")
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 }
305 return b.String()
308// renderSplit renders the split (side-by-side) diff view as a string.
309func (dv *DiffView) renderSplit() string {
310 var b strings.Builder
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')
323 beforeLine := h.fromLine
324 afterLine := h.toLine
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 }
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 }
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 }
378 b.WriteRune('\n')
379 }
380 }
382 return b.String()
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)
389 return fmt.Sprintf(
390 " @@ -%d,%d +%d,%d @@ ",
391 h.FromLine,
392 beforeShownLines,
393 h.ToLine,
394 afterShownLines,
395 )
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