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/lipgloss/v2"
 12	"github.com/charmbracelet/x/ansi"
 13	"github.com/opencode-ai/opencode/internal/config"
 14	"github.com/opencode-ai/opencode/internal/highlight"
 15	"github.com/opencode-ai/opencode/internal/tui/theme"
 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) &&
236			h.Lines[i].Kind == LineRemoved &&
237			h.Lines[i+1].Kind == LineAdded {
238
239			oldLine := h.Lines[i]
240			newLine := h.Lines[i+1]
241
242			// Find character-level differences
243			patches := dmp.DiffMain(oldLine.Content, newLine.Content, false)
244			patches = dmp.DiffCleanupSemantic(patches)
245			patches = dmp.DiffCleanupMerge(patches)
246			patches = dmp.DiffCleanupEfficiency(patches)
247
248			segments := make([]Segment, 0)
249
250			removeStart := 0
251			addStart := 0
252			for _, patch := range patches {
253				switch patch.Type {
254				case diffmatchpatch.DiffDelete:
255					segments = append(segments, Segment{
256						Start: removeStart,
257						End:   removeStart + len(patch.Text),
258						Type:  LineRemoved,
259						Text:  patch.Text,
260					})
261					removeStart += len(patch.Text)
262				case diffmatchpatch.DiffInsert:
263					segments = append(segments, Segment{
264						Start: addStart,
265						End:   addStart + len(patch.Text),
266						Type:  LineAdded,
267						Text:  patch.Text,
268					})
269					addStart += len(patch.Text)
270				default:
271					// Context text, no highlighting needed
272					removeStart += len(patch.Text)
273					addStart += len(patch.Text)
274				}
275			}
276			oldLine.Segments = segments
277			newLine.Segments = segments
278
279			updated = append(updated, oldLine, newLine)
280			i++ // Skip the next line as we've already processed it
281		} else {
282			updated = append(updated, h.Lines[i])
283		}
284	}
285
286	h.Lines = updated
287}
288
289// pairLines converts a flat list of diff lines to pairs for side-by-side display
290func pairLines(lines []DiffLine) []linePair {
291	var pairs []linePair
292	i := 0
293
294	for i < len(lines) {
295		switch lines[i].Kind {
296		case LineRemoved:
297			// Check if the next line is an addition, if so pair them
298			if i+1 < len(lines) && lines[i+1].Kind == LineAdded {
299				pairs = append(pairs, linePair{left: &lines[i], right: &lines[i+1]})
300				i += 2
301			} else {
302				pairs = append(pairs, linePair{left: &lines[i], right: nil})
303				i++
304			}
305		case LineAdded:
306			pairs = append(pairs, linePair{left: nil, right: &lines[i]})
307			i++
308		case LineContext:
309			pairs = append(pairs, linePair{left: &lines[i], right: &lines[i]})
310			i++
311		}
312	}
313
314	return pairs
315}
316
317// -------------------------------------------------------------------------
318// Syntax Highlighting
319// -------------------------------------------------------------------------
320func getColor(c color.Color) string {
321	rgba := color.RGBAModel.Convert(c).(color.RGBA)
322	return fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
323}
324
325// highlightLine applies syntax highlighting to a single line
326func highlightLine(fileName string, line string, bg color.Color) string {
327	highlighted, err := highlight.SyntaxHighlight(line, fileName, bg)
328	if err != nil {
329		return line
330	}
331	return highlighted
332}
333
334// createStyles generates the lipgloss styles needed for rendering diffs
335func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
336	removedLineStyle = lipgloss.NewStyle().Background(t.DiffRemovedBg())
337	addedLineStyle = lipgloss.NewStyle().Background(t.DiffAddedBg())
338	contextLineStyle = lipgloss.NewStyle().Background(t.DiffContextBg())
339	lineNumberStyle = lipgloss.NewStyle().Foreground(t.DiffLineNumber())
340
341	return
342}
343
344// -------------------------------------------------------------------------
345// Rendering Functions
346// -------------------------------------------------------------------------
347
348// applyHighlighting applies intra-line highlighting to a piece of text
349func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg color.Color) string {
350	// Find all ANSI sequences in the content
351	ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
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 := theme.CurrentTheme()
453
454	if dl == nil {
455		contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
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.DiffRemoved()).Render("-")
467		bgStyle = removedLineStyle
468		lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
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.DiffHighlightRemoved())
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.TextMuted()).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 := theme.CurrentTheme()
513
514	if dl == nil {
515		contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
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.DiffAdded()).Render("+")
527		bgStyle = addedLineStyle
528		lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
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.DiffHighlightAdded())
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.TextMuted()).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}