diff.go

  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}