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) && 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 theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
333	removedLineStyle = lipgloss.NewStyle().Background(t.DiffRemovedBg())
334	addedLineStyle = lipgloss.NewStyle().Background(t.DiffAddedBg())
335	contextLineStyle = lipgloss.NewStyle().Background(t.DiffContextBg())
336	lineNumberStyle = lipgloss.NewStyle().Foreground(t.DiffLineNumber())
337
338	return
339}
340
341// -------------------------------------------------------------------------
342// Rendering Functions
343// -------------------------------------------------------------------------
344
345// applyHighlighting applies intra-line highlighting to a piece of text
346func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg color.Color) string {
347	// Find all ANSI sequences in the content
348	ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
349	ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
350
351	// Build a mapping of visible character positions to their actual indices
352	visibleIdx := 0
353	ansiSequences := make(map[int]string)
354	lastAnsiSeq := "\x1b[0m" // Default reset sequence
355
356	for i := 0; i < len(content); {
357		isAnsi := false
358		for _, match := range ansiMatches {
359			if match[0] == i {
360				ansiSequences[visibleIdx] = content[match[0]:match[1]]
361				lastAnsiSeq = content[match[0]:match[1]]
362				i = match[1]
363				isAnsi = true
364				break
365			}
366		}
367		if isAnsi {
368			continue
369		}
370
371		// For non-ANSI positions, store the last ANSI sequence
372		if _, exists := ansiSequences[visibleIdx]; !exists {
373			ansiSequences[visibleIdx] = lastAnsiSeq
374		}
375		visibleIdx++
376		i++
377	}
378
379	// Apply highlighting
380	var sb strings.Builder
381	inSelection := false
382	currentPos := 0
383
384	// Get the appropriate color based on terminal background
385	bgColor := lipgloss.Color(getColor(highlightBg))
386	// fgColor := lipgloss.Color(getColor(theme.CurrentTheme().Background()))
387
388	for i := 0; i < len(content); {
389		// Check if we're at an ANSI sequence
390		isAnsi := false
391		for _, match := range ansiMatches {
392			if match[0] == i {
393				sb.WriteString(content[match[0]:match[1]]) // Preserve ANSI sequence
394				i = match[1]
395				isAnsi = true
396				break
397			}
398		}
399		if isAnsi {
400			continue
401		}
402
403		// Check for segment boundaries
404		for _, seg := range segments {
405			if seg.Type == segmentType {
406				if currentPos == seg.Start {
407					inSelection = true
408				}
409				if currentPos == seg.End {
410					inSelection = false
411				}
412			}
413		}
414
415		// Get current character
416		char := string(content[i])
417
418		if inSelection {
419			// Get the current styling
420			currentStyle := ansiSequences[currentPos]
421
422			// Apply foreground and background highlight
423			// sb.WriteString("\x1b[38;2;")
424			// r, g, b, _ := fgColor.RGBA()
425			// sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
426			sb.WriteString("\x1b[48;2;")
427			r, g, b, _ := bgColor.RGBA()
428			sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
429			sb.WriteString(char)
430			// Reset foreground and background
431			// sb.WriteString("\x1b[39m")
432
433			// Reapply the original ANSI sequence
434			sb.WriteString(currentStyle)
435		} else {
436			// Not in selection, just copy the character
437			sb.WriteString(char)
438		}
439
440		currentPos++
441		i++
442	}
443
444	return sb.String()
445}
446
447// renderLeftColumn formats the left side of a side-by-side diff
448func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
449	t := theme.CurrentTheme()
450
451	if dl == nil {
452		contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
453		return contextLineStyle.Width(colWidth).Render("")
454	}
455
456	removedLineStyle, _, contextLineStyle, lineNumberStyle := createStyles(t)
457
458	// Determine line style based on line type
459	var marker string
460	var bgStyle lipgloss.Style
461	switch dl.Kind {
462	case LineRemoved:
463		marker = removedLineStyle.Foreground(t.DiffRemoved()).Render("-")
464		bgStyle = removedLineStyle
465		lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
466	case LineAdded:
467		marker = "?"
468		bgStyle = contextLineStyle
469	case LineContext:
470		marker = contextLineStyle.Render(" ")
471		bgStyle = contextLineStyle
472	}
473
474	// Format line number
475	lineNum := ""
476	if dl.OldLineNo > 0 {
477		lineNum = fmt.Sprintf("%6d", dl.OldLineNo)
478	}
479
480	// Create the line prefix
481	prefix := lineNumberStyle.Render(lineNum + " " + marker)
482
483	// Apply syntax highlighting
484	content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
485
486	// Apply intra-line highlighting for removed lines
487	if dl.Kind == LineRemoved && len(dl.Segments) > 0 {
488		content = applyHighlighting(content, dl.Segments, LineRemoved, t.DiffHighlightRemoved())
489	}
490
491	// Add a padding space for removed lines
492	if dl.Kind == LineRemoved {
493		content = bgStyle.Render(" ") + content
494	}
495
496	// Create the final line and truncate if needed
497	lineText := prefix + content
498	return bgStyle.MaxHeight(1).Width(colWidth).Render(
499		ansi.Truncate(
500			lineText,
501			colWidth,
502			lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
503		),
504	)
505}
506
507// renderRightColumn formats the right side of a side-by-side diff
508func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
509	t := theme.CurrentTheme()
510
511	if dl == nil {
512		contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
513		return contextLineStyle.Width(colWidth).Render("")
514	}
515
516	_, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t)
517
518	// Determine line style based on line type
519	var marker string
520	var bgStyle lipgloss.Style
521	switch dl.Kind {
522	case LineAdded:
523		marker = addedLineStyle.Foreground(t.DiffAdded()).Render("+")
524		bgStyle = addedLineStyle
525		lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
526	case LineRemoved:
527		marker = "?"
528		bgStyle = contextLineStyle
529	case LineContext:
530		marker = contextLineStyle.Render(" ")
531		bgStyle = contextLineStyle
532	}
533
534	// Format line number
535	lineNum := ""
536	if dl.NewLineNo > 0 {
537		lineNum = fmt.Sprintf("%6d", dl.NewLineNo)
538	}
539
540	// Create the line prefix
541	prefix := lineNumberStyle.Render(lineNum + " " + marker)
542
543	// Apply syntax highlighting
544	content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
545
546	// Apply intra-line highlighting for added lines
547	if dl.Kind == LineAdded && len(dl.Segments) > 0 {
548		content = applyHighlighting(content, dl.Segments, LineAdded, t.DiffHighlightAdded())
549	}
550
551	// Add a padding space for added lines
552	if dl.Kind == LineAdded {
553		content = bgStyle.Render(" ") + content
554	}
555
556	// Create the final line and truncate if needed
557	lineText := prefix + content
558	return bgStyle.MaxHeight(1).Width(colWidth).Render(
559		ansi.Truncate(
560			lineText,
561			colWidth,
562			lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
563		),
564	)
565}
566
567// -------------------------------------------------------------------------
568// Public API
569// -------------------------------------------------------------------------
570
571// RenderSideBySideHunk formats a hunk for side-by-side display
572func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
573	// Apply options to create the configuration
574	config := NewSideBySideConfig(opts...)
575
576	// Make a copy of the hunk so we don't modify the original
577	hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
578	copy(hunkCopy.Lines, h.Lines)
579
580	// Highlight changes within lines
581	HighlightIntralineChanges(&hunkCopy)
582
583	// Pair lines for side-by-side display
584	pairs := pairLines(hunkCopy.Lines)
585
586	// Calculate column width
587	colWidth := config.TotalWidth / 2
588
589	leftWidth := colWidth
590	rightWidth := config.TotalWidth - colWidth
591	var sb strings.Builder
592	for _, p := range pairs {
593		leftStr := renderLeftColumn(fileName, p.left, leftWidth)
594		rightStr := renderRightColumn(fileName, p.right, rightWidth)
595		sb.WriteString(leftStr + rightStr + "\n")
596	}
597
598	return sb.String()
599}
600
601// FormatDiff creates a side-by-side formatted view of a diff
602func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
603	diffResult, err := ParseUnifiedDiff(diffText)
604	if err != nil {
605		return "", err
606	}
607
608	var sb strings.Builder
609	for _, h := range diffResult.Hunks {
610		sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
611	}
612
613	return sb.String(), nil
614}
615
616// GenerateDiff creates a unified diff from two file contents
617func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, int) {
618	// remove the cwd prefix and ensure consistent path format
619	// this prevents issues with absolute paths in different environments
620	cwd := config.WorkingDirectory()
621	fileName = strings.TrimPrefix(fileName, cwd)
622	fileName = strings.TrimPrefix(fileName, "/")
623
624	var (
625		unified   = udiff.Unified("a/"+fileName, "b/"+fileName, beforeContent, afterContent)
626		additions = 0
627		removals  = 0
628	)
629
630	lines := strings.SplitSeq(unified, "\n")
631	for line := range lines {
632		if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") {
633			additions++
634		} else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") {
635			removals++
636		}
637	}
638
639	return unified, additions, removals
640}