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