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// -------------------------------------------------------------------------
 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}