1package diff
2
3import (
4 "fmt"
5 "image/color"
6 "regexp"
7 "strconv"
8 "strings"
9
10 "github.com/aymanbagabas/go-udiff"
11 "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/highlight"
13 "github.com/charmbracelet/crush/internal/tui/styles"
14 "github.com/charmbracelet/lipgloss/v2"
15 "github.com/charmbracelet/x/ansi"
16 "github.com/sergi/go-diff/diffmatchpatch"
17)
18
19// -------------------------------------------------------------------------
20// Core Types
21// -------------------------------------------------------------------------
22
23// LineType represents the kind of line in a diff.
24type LineType int
25
26const (
27 LineContext LineType = iota // Line exists in both files
28 LineAdded // Line added in the new file
29 LineRemoved // Line removed from the old file
30)
31
32// Segment represents a portion of a line for intra-line highlighting
33type Segment struct {
34 Start int
35 End int
36 Type LineType
37 Text string
38}
39
40// DiffLine represents a single line in a diff
41type DiffLine struct {
42 OldLineNo int // Line number in old file (0 for added lines)
43 NewLineNo int // Line number in new file (0 for removed lines)
44 Kind LineType // Type of line (added, removed, context)
45 Content string // Content of the line
46 Segments []Segment // Segments for intraline highlighting
47}
48
49// Hunk represents a section of changes in a diff
50type Hunk struct {
51 Header string
52 Lines []DiffLine
53}
54
55// DiffResult contains the parsed result of a diff
56type DiffResult struct {
57 OldFile string
58 NewFile string
59 Hunks []Hunk
60}
61
62// linePair represents a pair of lines for side-by-side display
63type linePair struct {
64 left *DiffLine
65 right *DiffLine
66}
67
68// -------------------------------------------------------------------------
69// Parse Configuration
70// -------------------------------------------------------------------------
71
72// ParseConfig configures the behavior of diff parsing
73type ParseConfig struct {
74 ContextSize int // Number of context lines to include
75}
76
77// ParseOption modifies a ParseConfig
78type ParseOption func(*ParseConfig)
79
80// WithContextSize sets the number of context lines to include
81func WithContextSize(size int) ParseOption {
82 return func(p *ParseConfig) {
83 if size >= 0 {
84 p.ContextSize = size
85 }
86 }
87}
88
89// -------------------------------------------------------------------------
90// Side-by-Side Configuration
91// -------------------------------------------------------------------------
92
93// SideBySideConfig configures the rendering of side-by-side diffs
94type SideBySideConfig struct {
95 TotalWidth int
96}
97
98// SideBySideOption modifies a SideBySideConfig
99type SideBySideOption func(*SideBySideConfig)
100
101// NewSideBySideConfig creates a SideBySideConfig with default values
102func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig {
103 config := SideBySideConfig{
104 TotalWidth: 160, // Default width for side-by-side view
105 }
106
107 for _, opt := range opts {
108 opt(&config)
109 }
110
111 return config
112}
113
114// WithTotalWidth sets the total width for side-by-side view
115func WithTotalWidth(width int) SideBySideOption {
116 return func(s *SideBySideConfig) {
117 if width > 0 {
118 s.TotalWidth = width
119 }
120 }
121}
122
123// -------------------------------------------------------------------------
124// Diff Parsing
125// -------------------------------------------------------------------------
126
127// ParseUnifiedDiff parses a unified diff format string into structured data
128func ParseUnifiedDiff(diff string) (DiffResult, error) {
129 var result DiffResult
130 var currentHunk *Hunk
131
132 hunkHeaderRe := regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`)
133 lines := strings.Split(diff, "\n")
134
135 var oldLine, newLine int
136 inFileHeader := true
137
138 for _, line := range lines {
139 // Parse file headers
140 if inFileHeader {
141 if strings.HasPrefix(line, "--- a/") {
142 result.OldFile = strings.TrimPrefix(line, "--- a/")
143 continue
144 }
145 if strings.HasPrefix(line, "+++ b/") {
146 result.NewFile = strings.TrimPrefix(line, "+++ b/")
147 inFileHeader = false
148 continue
149 }
150 }
151
152 // Parse hunk headers
153 if matches := hunkHeaderRe.FindStringSubmatch(line); matches != nil {
154 if currentHunk != nil {
155 result.Hunks = append(result.Hunks, *currentHunk)
156 }
157 currentHunk = &Hunk{
158 Header: line,
159 Lines: []DiffLine{},
160 }
161
162 oldStart, _ := strconv.Atoi(matches[1])
163 newStart, _ := strconv.Atoi(matches[3])
164 oldLine = oldStart
165 newLine = newStart
166 continue
167 }
168
169 // Ignore "No newline at end of file" markers
170 if strings.HasPrefix(line, "\\ No newline at end of file") {
171 continue
172 }
173
174 if currentHunk == nil {
175 continue
176 }
177
178 // Process the line based on its prefix
179 if len(line) > 0 {
180 switch line[0] {
181 case '+':
182 currentHunk.Lines = append(currentHunk.Lines, DiffLine{
183 OldLineNo: 0,
184 NewLineNo: newLine,
185 Kind: LineAdded,
186 Content: line[1:],
187 })
188 newLine++
189 case '-':
190 currentHunk.Lines = append(currentHunk.Lines, DiffLine{
191 OldLineNo: oldLine,
192 NewLineNo: 0,
193 Kind: LineRemoved,
194 Content: line[1:],
195 })
196 oldLine++
197 default:
198 currentHunk.Lines = append(currentHunk.Lines, DiffLine{
199 OldLineNo: oldLine,
200 NewLineNo: newLine,
201 Kind: LineContext,
202 Content: line,
203 })
204 oldLine++
205 newLine++
206 }
207 } else {
208 // Handle empty lines
209 currentHunk.Lines = append(currentHunk.Lines, DiffLine{
210 OldLineNo: oldLine,
211 NewLineNo: newLine,
212 Kind: LineContext,
213 Content: "",
214 })
215 oldLine++
216 newLine++
217 }
218 }
219
220 // Add the last hunk if there is one
221 if currentHunk != nil {
222 result.Hunks = append(result.Hunks, *currentHunk)
223 }
224
225 return result, nil
226}
227
228// HighlightIntralineChanges updates lines in a hunk to show character-level differences
229func HighlightIntralineChanges(h *Hunk) {
230 var updated []DiffLine
231 dmp := diffmatchpatch.New()
232
233 for i := 0; i < len(h.Lines); i++ {
234 // Look for removed line followed by added line
235 if i+1 < len(h.Lines) && h.Lines[i].Kind == LineRemoved && h.Lines[i+1].Kind == LineAdded {
236 oldLine := h.Lines[i]
237 newLine := h.Lines[i+1]
238
239 // Find character-level differences
240 patches := dmp.DiffMain(oldLine.Content, newLine.Content, false)
241 patches = dmp.DiffCleanupSemantic(patches)
242 patches = dmp.DiffCleanupMerge(patches)
243 patches = dmp.DiffCleanupEfficiency(patches)
244
245 segments := make([]Segment, 0)
246
247 removeStart := 0
248 addStart := 0
249 for _, patch := range patches {
250 switch patch.Type {
251 case diffmatchpatch.DiffDelete:
252 segments = append(segments, Segment{
253 Start: removeStart,
254 End: removeStart + len(patch.Text),
255 Type: LineRemoved,
256 Text: patch.Text,
257 })
258 removeStart += len(patch.Text)
259 case diffmatchpatch.DiffInsert:
260 segments = append(segments, Segment{
261 Start: addStart,
262 End: addStart + len(patch.Text),
263 Type: LineAdded,
264 Text: patch.Text,
265 })
266 addStart += len(patch.Text)
267 default:
268 // Context text, no highlighting needed
269 removeStart += len(patch.Text)
270 addStart += len(patch.Text)
271 }
272 }
273 oldLine.Segments = segments
274 newLine.Segments = segments
275
276 updated = append(updated, oldLine, newLine)
277 i++ // Skip the next line as we've already processed it
278 } else {
279 updated = append(updated, h.Lines[i])
280 }
281 }
282
283 h.Lines = updated
284}
285
286// pairLines converts a flat list of diff lines to pairs for side-by-side display
287func pairLines(lines []DiffLine) []linePair {
288 var pairs []linePair
289 i := 0
290
291 for i < len(lines) {
292 switch lines[i].Kind {
293 case LineRemoved:
294 // Check if the next line is an addition, if so pair them
295 if i+1 < len(lines) && lines[i+1].Kind == LineAdded {
296 pairs = append(pairs, linePair{left: &lines[i], right: &lines[i+1]})
297 i += 2
298 } else {
299 pairs = append(pairs, linePair{left: &lines[i], right: nil})
300 i++
301 }
302 case LineAdded:
303 pairs = append(pairs, linePair{left: nil, right: &lines[i]})
304 i++
305 case LineContext:
306 pairs = append(pairs, linePair{left: &lines[i], right: &lines[i]})
307 i++
308 }
309 }
310
311 return pairs
312}
313
314// -------------------------------------------------------------------------
315// Syntax Highlighting
316// -------------------------------------------------------------------------
317func getColor(c color.Color) string {
318 rgba := color.RGBAModel.Convert(c).(color.RGBA)
319 return fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
320}
321
322// highlightLine applies syntax highlighting to a single line
323func highlightLine(fileName string, line string, bg color.Color) string {
324 highlighted, err := highlight.SyntaxHighlight(line, fileName, bg)
325 if err != nil {
326 return line
327 }
328 return highlighted
329}
330
331// createStyles generates the lipgloss styles needed for rendering diffs
332func createStyles(t *styles.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
333 removedLineStyle = lipgloss.NewStyle().Background(t.S().Diff.RemovedBg)
334 addedLineStyle = lipgloss.NewStyle().Background(t.S().Diff.AddedBg)
335 contextLineStyle = lipgloss.NewStyle().Background(t.S().Diff.ContextBg)
336 lineNumberStyle = lipgloss.NewStyle().Foreground(t.S().Diff.LineNumber)
337 return
338}
339
340// -------------------------------------------------------------------------
341// Rendering Functions
342// -------------------------------------------------------------------------
343
344// applyHighlighting applies intra-line highlighting to a piece of text
345func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg color.Color) string {
346 // Find all ANSI sequences in the content
347 ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
348 ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
349
350 // Build a mapping of visible character positions to their actual indices
351 visibleIdx := 0
352 ansiSequences := make(map[int]string)
353 lastAnsiSeq := "\x1b[0m" // Default reset sequence
354
355 for i := 0; i < len(content); {
356 isAnsi := false
357 for _, match := range ansiMatches {
358 if match[0] == i {
359 ansiSequences[visibleIdx] = content[match[0]:match[1]]
360 lastAnsiSeq = content[match[0]:match[1]]
361 i = match[1]
362 isAnsi = true
363 break
364 }
365 }
366 if isAnsi {
367 continue
368 }
369
370 // For non-ANSI positions, store the last ANSI sequence
371 if _, exists := ansiSequences[visibleIdx]; !exists {
372 ansiSequences[visibleIdx] = lastAnsiSeq
373 }
374 visibleIdx++
375 i++
376 }
377
378 // Apply highlighting
379 var sb strings.Builder
380 inSelection := false
381 currentPos := 0
382
383 // Get the appropriate color based on terminal background
384 bgColor := lipgloss.Color(getColor(highlightBg))
385 // fgColor := lipgloss.Color(getColor(theme.CurrentTheme().Background()))
386
387 for i := 0; i < len(content); {
388 // Check if we're at an ANSI sequence
389 isAnsi := false
390 for _, match := range ansiMatches {
391 if match[0] == i {
392 sb.WriteString(content[match[0]:match[1]]) // Preserve ANSI sequence
393 i = match[1]
394 isAnsi = true
395 break
396 }
397 }
398 if isAnsi {
399 continue
400 }
401
402 // Check for segment boundaries
403 for _, seg := range segments {
404 if seg.Type == segmentType {
405 if currentPos == seg.Start {
406 inSelection = true
407 }
408 if currentPos == seg.End {
409 inSelection = false
410 }
411 }
412 }
413
414 // Get current character
415 char := string(content[i])
416
417 if inSelection {
418 // Get the current styling
419 currentStyle := ansiSequences[currentPos]
420
421 // Apply foreground and background highlight
422 // sb.WriteString("\x1b[38;2;")
423 // r, g, b, _ := fgColor.RGBA()
424 // sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
425 sb.WriteString("\x1b[48;2;")
426 r, g, b, _ := bgColor.RGBA()
427 sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
428 sb.WriteString(char)
429 // Reset foreground and background
430 // sb.WriteString("\x1b[39m")
431
432 // Reapply the original ANSI sequence
433 sb.WriteString(currentStyle)
434 } else {
435 // Not in selection, just copy the character
436 sb.WriteString(char)
437 }
438
439 currentPos++
440 i++
441 }
442
443 return sb.String()
444}
445
446// renderLeftColumn formats the left side of a side-by-side diff
447func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
448 t := styles.CurrentTheme()
449
450 if dl == nil {
451 contextLineStyle := t.S().Base.Background(t.S().Diff.ContextBg)
452 return contextLineStyle.Width(colWidth).Render("")
453 }
454
455 removedLineStyle, _, contextLineStyle, lineNumberStyle := createStyles(t)
456
457 // Determine line style based on line type
458 var marker string
459 var bgStyle lipgloss.Style
460 switch dl.Kind {
461 case LineRemoved:
462 marker = removedLineStyle.Foreground(t.S().Diff.Removed).Render("-")
463 bgStyle = removedLineStyle
464 lineNumberStyle = lineNumberStyle.Foreground(t.S().Diff.Removed).Background(t.S().Diff.RemovedLineNumberBg)
465 case LineAdded:
466 marker = "?"
467 bgStyle = contextLineStyle
468 case LineContext:
469 marker = contextLineStyle.Render(" ")
470 bgStyle = contextLineStyle
471 }
472
473 // Format line number
474 lineNum := ""
475 if dl.OldLineNo > 0 {
476 lineNum = fmt.Sprintf("%6d", dl.OldLineNo)
477 }
478
479 // Create the line prefix
480 prefix := lineNumberStyle.Render(lineNum + " " + marker)
481
482 // Apply syntax highlighting
483 content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
484
485 // Apply intra-line highlighting for removed lines
486 if dl.Kind == LineRemoved && len(dl.Segments) > 0 {
487 content = applyHighlighting(content, dl.Segments, LineRemoved, t.S().Diff.HighlightRemoved)
488 }
489
490 // Add a padding space for removed lines
491 if dl.Kind == LineRemoved {
492 content = bgStyle.Render(" ") + content
493 }
494
495 // Create the final line and truncate if needed
496 lineText := prefix + content
497 return bgStyle.MaxHeight(1).Width(colWidth).Render(
498 ansi.Truncate(
499 lineText,
500 colWidth,
501 lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.FgMuted).Render("..."),
502 ),
503 )
504}
505
506// renderRightColumn formats the right side of a side-by-side diff
507func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
508 t := styles.CurrentTheme()
509
510 if dl == nil {
511 contextLineStyle := lipgloss.NewStyle().Background(t.S().Diff.ContextBg)
512 return contextLineStyle.Width(colWidth).Render("")
513 }
514
515 _, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t)
516
517 // Determine line style based on line type
518 var marker string
519 var bgStyle lipgloss.Style
520 switch dl.Kind {
521 case LineAdded:
522 marker = addedLineStyle.Foreground(t.S().Diff.Added).Render("+")
523 bgStyle = addedLineStyle
524 lineNumberStyle = lineNumberStyle.Foreground(t.S().Diff.Added).Background(t.S().Diff.AddedLineNumberBg)
525 case LineRemoved:
526 marker = "?"
527 bgStyle = contextLineStyle
528 case LineContext:
529 marker = contextLineStyle.Render(" ")
530 bgStyle = contextLineStyle
531 }
532
533 // Format line number
534 lineNum := ""
535 if dl.NewLineNo > 0 {
536 lineNum = fmt.Sprintf("%6d", dl.NewLineNo)
537 }
538
539 // Create the line prefix
540 prefix := lineNumberStyle.Render(lineNum + " " + marker)
541
542 // Apply syntax highlighting
543 content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
544
545 // Apply intra-line highlighting for added lines
546 if dl.Kind == LineAdded && len(dl.Segments) > 0 {
547 content = applyHighlighting(content, dl.Segments, LineAdded, t.S().Diff.HighlightAdded)
548 }
549
550 // Add a padding space for added lines
551 if dl.Kind == LineAdded {
552 content = bgStyle.Render(" ") + content
553 }
554
555 // Create the final line and truncate if needed
556 lineText := prefix + content
557 return bgStyle.MaxHeight(1).Width(colWidth).Render(
558 ansi.Truncate(
559 lineText,
560 colWidth,
561 lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.FgMuted).Render("..."),
562 ),
563 )
564}
565
566// -------------------------------------------------------------------------
567// Public API
568// -------------------------------------------------------------------------
569
570// RenderSideBySideHunk formats a hunk for side-by-side display
571func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
572 // Apply options to create the configuration
573 config := NewSideBySideConfig(opts...)
574
575 // Make a copy of the hunk so we don't modify the original
576 hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
577 copy(hunkCopy.Lines, h.Lines)
578
579 // Highlight changes within lines
580 HighlightIntralineChanges(&hunkCopy)
581
582 // Pair lines for side-by-side display
583 pairs := pairLines(hunkCopy.Lines)
584
585 // Calculate column width
586 colWidth := config.TotalWidth / 2
587
588 leftWidth := colWidth
589 rightWidth := config.TotalWidth - colWidth
590 var sb strings.Builder
591 for _, p := range pairs {
592 leftStr := renderLeftColumn(fileName, p.left, leftWidth)
593 rightStr := renderRightColumn(fileName, p.right, rightWidth)
594 sb.WriteString(leftStr + rightStr + "\n")
595 }
596
597 return sb.String()
598}
599
600// FormatDiff creates a side-by-side formatted view of a diff
601func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
602 diffResult, err := ParseUnifiedDiff(diffText)
603 if err != nil {
604 return "", err
605 }
606
607 var sb strings.Builder
608 for _, h := range diffResult.Hunks {
609 sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
610 }
611
612 return sb.String(), nil
613}
614
615// GenerateDiff creates a unified diff from two file contents
616func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, int) {
617 // remove the cwd prefix and ensure consistent path format
618 // this prevents issues with absolute paths in different environments
619 cwd := config.WorkingDirectory()
620 fileName = strings.TrimPrefix(fileName, cwd)
621 fileName = strings.TrimPrefix(fileName, "/")
622
623 var (
624 unified = udiff.Unified("a/"+fileName, "b/"+fileName, beforeContent, afterContent)
625 additions = 0
626 removals = 0
627 )
628
629 lines := strings.SplitSeq(unified, "\n")
630 for line := range lines {
631 if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") {
632 additions++
633 } else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") {
634 removals++
635 }
636 }
637
638 return unified, additions, removals
639}