diff.go

  1package diff
  2
  3import (
  4	"bytes"
  5	"fmt"
  6	"io"
  7	"regexp"
  8	"strconv"
  9	"strings"
 10
 11	"github.com/alecthomas/chroma/v2"
 12	"github.com/alecthomas/chroma/v2/formatters"
 13	"github.com/alecthomas/chroma/v2/lexers"
 14	"github.com/alecthomas/chroma/v2/styles"
 15	"github.com/aymanbagabas/go-udiff"
 16	"github.com/charmbracelet/lipgloss"
 17	"github.com/charmbracelet/x/ansi"
 18	"github.com/opencode-ai/opencode/internal/config"
 19	"github.com/sergi/go-diff/diffmatchpatch"
 20)
 21
 22// -------------------------------------------------------------------------
 23// Core Types
 24// -------------------------------------------------------------------------
 25
 26// LineType represents the kind of line in a diff.
 27type LineType int
 28
 29const (
 30	LineContext LineType = iota // Line exists in both files
 31	LineAdded                   // Line added in the new file
 32	LineRemoved                 // Line removed from the old file
 33)
 34
 35// Segment represents a portion of a line for intra-line highlighting
 36type Segment struct {
 37	Start int
 38	End   int
 39	Type  LineType
 40	Text  string
 41}
 42
 43// DiffLine represents a single line in a diff
 44type DiffLine struct {
 45	OldLineNo int       // Line number in old file (0 for added lines)
 46	NewLineNo int       // Line number in new file (0 for removed lines)
 47	Kind      LineType  // Type of line (added, removed, context)
 48	Content   string    // Content of the line
 49	Segments  []Segment // Segments for intraline highlighting
 50}
 51
 52// Hunk represents a section of changes in a diff
 53type Hunk struct {
 54	Header string
 55	Lines  []DiffLine
 56}
 57
 58// DiffResult contains the parsed result of a diff
 59type DiffResult struct {
 60	OldFile string
 61	NewFile string
 62	Hunks   []Hunk
 63}
 64
 65// linePair represents a pair of lines for side-by-side display
 66type linePair struct {
 67	left  *DiffLine
 68	right *DiffLine
 69}
 70
 71// -------------------------------------------------------------------------
 72// Style Configuration
 73// -------------------------------------------------------------------------
 74
 75// StyleConfig defines styling for diff rendering
 76type StyleConfig struct {
 77	ShowHeader     bool
 78	ShowHunkHeader bool
 79	FileNameFg     lipgloss.Color
 80	// Background colors
 81	RemovedLineBg       lipgloss.Color
 82	AddedLineBg         lipgloss.Color
 83	ContextLineBg       lipgloss.Color
 84	HunkLineBg          lipgloss.Color
 85	RemovedLineNumberBg lipgloss.Color
 86	AddedLineNamerBg    lipgloss.Color
 87
 88	// Foreground colors
 89	HunkLineFg         lipgloss.Color
 90	RemovedFg          lipgloss.Color
 91	AddedFg            lipgloss.Color
 92	LineNumberFg       lipgloss.Color
 93	RemovedHighlightFg lipgloss.Color
 94	AddedHighlightFg   lipgloss.Color
 95
 96	// Highlight settings
 97	HighlightStyle     string
 98	RemovedHighlightBg lipgloss.Color
 99	AddedHighlightBg   lipgloss.Color
100}
101
102// StyleOption is a function that modifies a StyleConfig
103type StyleOption func(*StyleConfig)
104
105// NewStyleConfig creates a StyleConfig with default values
106func NewStyleConfig(opts ...StyleOption) StyleConfig {
107	// Default color scheme
108	config := StyleConfig{
109		ShowHeader:          true,
110		ShowHunkHeader:      true,
111		FileNameFg:          lipgloss.Color("#a0a0a0"),
112		RemovedLineBg:       lipgloss.Color("#3A3030"),
113		AddedLineBg:         lipgloss.Color("#303A30"),
114		ContextLineBg:       lipgloss.Color("#212121"),
115		HunkLineBg:          lipgloss.Color("#212121"),
116		HunkLineFg:          lipgloss.Color("#a0a0a0"),
117		RemovedFg:           lipgloss.Color("#7C4444"),
118		AddedFg:             lipgloss.Color("#478247"),
119		LineNumberFg:        lipgloss.Color("#888888"),
120		HighlightStyle:      "dracula",
121		RemovedHighlightBg:  lipgloss.Color("#612726"),
122		AddedHighlightBg:    lipgloss.Color("#256125"),
123		RemovedLineNumberBg: lipgloss.Color("#332929"),
124		AddedLineNamerBg:    lipgloss.Color("#293229"),
125		RemovedHighlightFg:  lipgloss.Color("#FADADD"),
126		AddedHighlightFg:    lipgloss.Color("#DAFADA"),
127	}
128
129	// Apply all provided options
130	for _, opt := range opts {
131		opt(&config)
132	}
133
134	return config
135}
136
137// Style option functions
138func WithFileNameFg(color lipgloss.Color) StyleOption {
139	return func(s *StyleConfig) { s.FileNameFg = color }
140}
141
142func WithRemovedLineBg(color lipgloss.Color) StyleOption {
143	return func(s *StyleConfig) { s.RemovedLineBg = color }
144}
145
146func WithAddedLineBg(color lipgloss.Color) StyleOption {
147	return func(s *StyleConfig) { s.AddedLineBg = color }
148}
149
150func WithContextLineBg(color lipgloss.Color) StyleOption {
151	return func(s *StyleConfig) { s.ContextLineBg = color }
152}
153
154func WithRemovedFg(color lipgloss.Color) StyleOption {
155	return func(s *StyleConfig) { s.RemovedFg = color }
156}
157
158func WithAddedFg(color lipgloss.Color) StyleOption {
159	return func(s *StyleConfig) { s.AddedFg = color }
160}
161
162func WithLineNumberFg(color lipgloss.Color) StyleOption {
163	return func(s *StyleConfig) { s.LineNumberFg = color }
164}
165
166func WithHighlightStyle(style string) StyleOption {
167	return func(s *StyleConfig) { s.HighlightStyle = style }
168}
169
170func WithRemovedHighlightColors(bg, fg lipgloss.Color) StyleOption {
171	return func(s *StyleConfig) {
172		s.RemovedHighlightBg = bg
173		s.RemovedHighlightFg = fg
174	}
175}
176
177func WithAddedHighlightColors(bg, fg lipgloss.Color) StyleOption {
178	return func(s *StyleConfig) {
179		s.AddedHighlightBg = bg
180		s.AddedHighlightFg = fg
181	}
182}
183
184func WithRemovedLineNumberBg(color lipgloss.Color) StyleOption {
185	return func(s *StyleConfig) { s.RemovedLineNumberBg = color }
186}
187
188func WithAddedLineNumberBg(color lipgloss.Color) StyleOption {
189	return func(s *StyleConfig) { s.AddedLineNamerBg = color }
190}
191
192func WithHunkLineBg(color lipgloss.Color) StyleOption {
193	return func(s *StyleConfig) { s.HunkLineBg = color }
194}
195
196func WithHunkLineFg(color lipgloss.Color) StyleOption {
197	return func(s *StyleConfig) { s.HunkLineFg = color }
198}
199
200func WithShowHeader(show bool) StyleOption {
201	return func(s *StyleConfig) { s.ShowHeader = show }
202}
203
204func WithShowHunkHeader(show bool) StyleOption {
205	return func(s *StyleConfig) { s.ShowHunkHeader = show }
206}
207
208// -------------------------------------------------------------------------
209// Parse Configuration
210// -------------------------------------------------------------------------
211
212// ParseConfig configures the behavior of diff parsing
213type ParseConfig struct {
214	ContextSize int // Number of context lines to include
215}
216
217// ParseOption modifies a ParseConfig
218type ParseOption func(*ParseConfig)
219
220// WithContextSize sets the number of context lines to include
221func WithContextSize(size int) ParseOption {
222	return func(p *ParseConfig) {
223		if size >= 0 {
224			p.ContextSize = size
225		}
226	}
227}
228
229// -------------------------------------------------------------------------
230// Side-by-Side Configuration
231// -------------------------------------------------------------------------
232
233// SideBySideConfig configures the rendering of side-by-side diffs
234type SideBySideConfig struct {
235	TotalWidth int
236	Style      StyleConfig
237}
238
239// SideBySideOption modifies a SideBySideConfig
240type SideBySideOption func(*SideBySideConfig)
241
242// NewSideBySideConfig creates a SideBySideConfig with default values
243func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig {
244	config := SideBySideConfig{
245		TotalWidth: 160, // Default width for side-by-side view
246		Style:      NewStyleConfig(),
247	}
248
249	for _, opt := range opts {
250		opt(&config)
251	}
252
253	return config
254}
255
256// WithTotalWidth sets the total width for side-by-side view
257func WithTotalWidth(width int) SideBySideOption {
258	return func(s *SideBySideConfig) {
259		if width > 0 {
260			s.TotalWidth = width
261		}
262	}
263}
264
265// WithStyle sets the styling configuration
266func WithStyle(style StyleConfig) SideBySideOption {
267	return func(s *SideBySideConfig) {
268		s.Style = style
269	}
270}
271
272// WithStyleOptions applies the specified style options
273func WithStyleOptions(opts ...StyleOption) SideBySideOption {
274	return func(s *SideBySideConfig) {
275		s.Style = NewStyleConfig(opts...)
276	}
277}
278
279// -------------------------------------------------------------------------
280// Diff Parsing
281// -------------------------------------------------------------------------
282
283// ParseUnifiedDiff parses a unified diff format string into structured data
284func ParseUnifiedDiff(diff string) (DiffResult, error) {
285	var result DiffResult
286	var currentHunk *Hunk
287
288	hunkHeaderRe := regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`)
289	lines := strings.Split(diff, "\n")
290
291	var oldLine, newLine int
292	inFileHeader := true
293
294	for _, line := range lines {
295		// Parse file headers
296		if inFileHeader {
297			if strings.HasPrefix(line, "--- a/") {
298				result.OldFile = strings.TrimPrefix(line, "--- a/")
299				continue
300			}
301			if strings.HasPrefix(line, "+++ b/") {
302				result.NewFile = strings.TrimPrefix(line, "+++ b/")
303				inFileHeader = false
304				continue
305			}
306		}
307
308		// Parse hunk headers
309		if matches := hunkHeaderRe.FindStringSubmatch(line); matches != nil {
310			if currentHunk != nil {
311				result.Hunks = append(result.Hunks, *currentHunk)
312			}
313			currentHunk = &Hunk{
314				Header: line,
315				Lines:  []DiffLine{},
316			}
317
318			oldStart, _ := strconv.Atoi(matches[1])
319			newStart, _ := strconv.Atoi(matches[3])
320			oldLine = oldStart
321			newLine = newStart
322			continue
323		}
324
325		// Ignore "No newline at end of file" markers
326		if strings.HasPrefix(line, "\\ No newline at end of file") {
327			continue
328		}
329
330		if currentHunk == nil {
331			continue
332		}
333
334		// Process the line based on its prefix
335		if len(line) > 0 {
336			switch line[0] {
337			case '+':
338				currentHunk.Lines = append(currentHunk.Lines, DiffLine{
339					OldLineNo: 0,
340					NewLineNo: newLine,
341					Kind:      LineAdded,
342					Content:   line[1:],
343				})
344				newLine++
345			case '-':
346				currentHunk.Lines = append(currentHunk.Lines, DiffLine{
347					OldLineNo: oldLine,
348					NewLineNo: 0,
349					Kind:      LineRemoved,
350					Content:   line[1:],
351				})
352				oldLine++
353			default:
354				currentHunk.Lines = append(currentHunk.Lines, DiffLine{
355					OldLineNo: oldLine,
356					NewLineNo: newLine,
357					Kind:      LineContext,
358					Content:   line,
359				})
360				oldLine++
361				newLine++
362			}
363		} else {
364			// Handle empty lines
365			currentHunk.Lines = append(currentHunk.Lines, DiffLine{
366				OldLineNo: oldLine,
367				NewLineNo: newLine,
368				Kind:      LineContext,
369				Content:   "",
370			})
371			oldLine++
372			newLine++
373		}
374	}
375
376	// Add the last hunk if there is one
377	if currentHunk != nil {
378		result.Hunks = append(result.Hunks, *currentHunk)
379	}
380
381	return result, nil
382}
383
384// HighlightIntralineChanges updates lines in a hunk to show character-level differences
385func HighlightIntralineChanges(h *Hunk, style StyleConfig) {
386	var updated []DiffLine
387	dmp := diffmatchpatch.New()
388
389	for i := 0; i < len(h.Lines); i++ {
390		// Look for removed line followed by added line
391		if i+1 < len(h.Lines) &&
392			h.Lines[i].Kind == LineRemoved &&
393			h.Lines[i+1].Kind == LineAdded {
394
395			oldLine := h.Lines[i]
396			newLine := h.Lines[i+1]
397
398			// Find character-level differences
399			patches := dmp.DiffMain(oldLine.Content, newLine.Content, false)
400			patches = dmp.DiffCleanupSemantic(patches)
401			patches = dmp.DiffCleanupMerge(patches)
402			patches = dmp.DiffCleanupEfficiency(patches)
403
404			segments := make([]Segment, 0)
405
406			removeStart := 0
407			addStart := 0
408			for _, patch := range patches {
409				switch patch.Type {
410				case diffmatchpatch.DiffDelete:
411					segments = append(segments, Segment{
412						Start: removeStart,
413						End:   removeStart + len(patch.Text),
414						Type:  LineRemoved,
415						Text:  patch.Text,
416					})
417					removeStart += len(patch.Text)
418				case diffmatchpatch.DiffInsert:
419					segments = append(segments, Segment{
420						Start: addStart,
421						End:   addStart + len(patch.Text),
422						Type:  LineAdded,
423						Text:  patch.Text,
424					})
425					addStart += len(patch.Text)
426				default:
427					// Context text, no highlighting needed
428					removeStart += len(patch.Text)
429					addStart += len(patch.Text)
430				}
431			}
432			oldLine.Segments = segments
433			newLine.Segments = segments
434
435			updated = append(updated, oldLine, newLine)
436			i++ // Skip the next line as we've already processed it
437		} else {
438			updated = append(updated, h.Lines[i])
439		}
440	}
441
442	h.Lines = updated
443}
444
445// pairLines converts a flat list of diff lines to pairs for side-by-side display
446func pairLines(lines []DiffLine) []linePair {
447	var pairs []linePair
448	i := 0
449
450	for i < len(lines) {
451		switch lines[i].Kind {
452		case LineRemoved:
453			// Check if the next line is an addition, if so pair them
454			if i+1 < len(lines) && lines[i+1].Kind == LineAdded {
455				pairs = append(pairs, linePair{left: &lines[i], right: &lines[i+1]})
456				i += 2
457			} else {
458				pairs = append(pairs, linePair{left: &lines[i], right: nil})
459				i++
460			}
461		case LineAdded:
462			pairs = append(pairs, linePair{left: nil, right: &lines[i]})
463			i++
464		case LineContext:
465			pairs = append(pairs, linePair{left: &lines[i], right: &lines[i]})
466			i++
467		}
468	}
469
470	return pairs
471}
472
473// -------------------------------------------------------------------------
474// Syntax Highlighting
475// -------------------------------------------------------------------------
476
477// SyntaxHighlight applies syntax highlighting to text based on file extension
478func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipgloss.TerminalColor) error {
479	// Determine the language lexer to use
480	l := lexers.Match(fileName)
481	if l == nil {
482		l = lexers.Analyse(source)
483	}
484	if l == nil {
485		l = lexers.Fallback
486	}
487	l = chroma.Coalesce(l)
488
489	// Get the formatter
490	f := formatters.Get(formatter)
491	if f == nil {
492		f = formatters.Fallback
493	}
494	theme := `
495	<style name="vscode-dark-plus">
496	<!-- Base colors -->
497	<entry type="Background" style="bg:#1E1E1E"/>
498	<entry type="Text" style="#D4D4D4"/>
499	<entry type="Other" style="#D4D4D4"/>
500	<entry type="Error" style="#F44747"/>
501	<!-- Keywords - using the Control flow / Special keywords color -->
502	<entry type="Keyword" style="#C586C0"/>
503	<entry type="KeywordConstant" style="#4FC1FF"/>
504	<entry type="KeywordDeclaration" style="#C586C0"/>
505	<entry type="KeywordNamespace" style="#C586C0"/>
506	<entry type="KeywordPseudo" style="#C586C0"/>
507	<entry type="KeywordReserved" style="#C586C0"/>
508	<entry type="KeywordType" style="#4EC9B0"/>
509	<!-- Names -->
510	<entry type="Name" style="#D4D4D4"/>
511	<entry type="NameAttribute" style="#9CDCFE"/>
512	<entry type="NameBuiltin" style="#4EC9B0"/>
513	<entry type="NameBuiltinPseudo" style="#9CDCFE"/>
514	<entry type="NameClass" style="#4EC9B0"/>
515	<entry type="NameConstant" style="#4FC1FF"/>
516	<entry type="NameDecorator" style="#DCDCAA"/>
517	<entry type="NameEntity" style="#9CDCFE"/>
518	<entry type="NameException" style="#4EC9B0"/>
519	<entry type="NameFunction" style="#DCDCAA"/>
520	<entry type="NameLabel" style="#C8C8C8"/>
521	<entry type="NameNamespace" style="#4EC9B0"/>
522	<entry type="NameOther" style="#9CDCFE"/>
523	<entry type="NameTag" style="#569CD6"/>
524	<entry type="NameVariable" style="#9CDCFE"/>
525	<entry type="NameVariableClass" style="#9CDCFE"/>
526	<entry type="NameVariableGlobal" style="#9CDCFE"/>
527	<entry type="NameVariableInstance" style="#9CDCFE"/>
528	<!-- Literals -->
529	<entry type="Literal" style="#CE9178"/>
530	<entry type="LiteralDate" style="#CE9178"/>
531	<entry type="LiteralString" style="#CE9178"/>
532	<entry type="LiteralStringBacktick" style="#CE9178"/>
533	<entry type="LiteralStringChar" style="#CE9178"/>
534	<entry type="LiteralStringDoc" style="#CE9178"/>
535	<entry type="LiteralStringDouble" style="#CE9178"/>
536	<entry type="LiteralStringEscape" style="#d7ba7d"/>
537	<entry type="LiteralStringHeredoc" style="#CE9178"/>
538	<entry type="LiteralStringInterpol" style="#CE9178"/>
539	<entry type="LiteralStringOther" style="#CE9178"/>
540	<entry type="LiteralStringRegex" style="#d16969"/>
541	<entry type="LiteralStringSingle" style="#CE9178"/>
542	<entry type="LiteralStringSymbol" style="#CE9178"/>
543	<!-- Numbers - using the numberLiteral color -->
544	<entry type="LiteralNumber" style="#b5cea8"/>
545	<entry type="LiteralNumberBin" style="#b5cea8"/>
546	<entry type="LiteralNumberFloat" style="#b5cea8"/>
547	<entry type="LiteralNumberHex" style="#b5cea8"/>
548	<entry type="LiteralNumberInteger" style="#b5cea8"/>
549	<entry type="LiteralNumberIntegerLong" style="#b5cea8"/>
550	<entry type="LiteralNumberOct" style="#b5cea8"/>
551	<!-- Operators -->
552	<entry type="Operator" style="#D4D4D4"/>
553	<entry type="OperatorWord" style="#C586C0"/>
554	<entry type="Punctuation" style="#D4D4D4"/>
555	<!-- Comments - standard VSCode Dark+ comment color -->
556	<entry type="Comment" style="#6A9955"/>
557	<entry type="CommentHashbang" style="#6A9955"/>
558	<entry type="CommentMultiline" style="#6A9955"/>
559	<entry type="CommentSingle" style="#6A9955"/>
560	<entry type="CommentSpecial" style="#6A9955"/>
561	<entry type="CommentPreproc" style="#C586C0"/>
562	<!-- Generic styles -->
563	<entry type="Generic" style="#D4D4D4"/>
564	<entry type="GenericDeleted" style="#F44747"/>
565	<entry type="GenericEmph" style="italic #D4D4D4"/>
566	<entry type="GenericError" style="#F44747"/>
567	<entry type="GenericHeading" style="bold #D4D4D4"/>
568	<entry type="GenericInserted" style="#b5cea8"/>
569	<entry type="GenericOutput" style="#808080"/>
570	<entry type="GenericPrompt" style="#D4D4D4"/>
571	<entry type="GenericStrong" style="bold #D4D4D4"/>
572	<entry type="GenericSubheading" style="bold #D4D4D4"/>
573	<entry type="GenericTraceback" style="#F44747"/>
574	<entry type="GenericUnderline" style="underline"/>
575	<entry type="TextWhitespace" style="#D4D4D4"/>
576</style>
577`
578
579	r := strings.NewReader(theme)
580	style := chroma.MustNewXMLStyle(r)
581	// Modify the style to use the provided background
582	s, err := style.Builder().Transform(
583		func(t chroma.StyleEntry) chroma.StyleEntry {
584			r, g, b, _ := bg.RGBA()
585			t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
586			return t
587		},
588	).Build()
589	if err != nil {
590		s = styles.Fallback
591	}
592
593	// Tokenize and format
594	it, err := l.Tokenise(nil, source)
595	if err != nil {
596		return err
597	}
598
599	return f.Format(w, s, it)
600}
601
602// highlightLine applies syntax highlighting to a single line
603func highlightLine(fileName string, line string, bg lipgloss.TerminalColor) string {
604	var buf bytes.Buffer
605	err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg)
606	if err != nil {
607		return line
608	}
609	return buf.String()
610}
611
612// createStyles generates the lipgloss styles needed for rendering diffs
613func createStyles(config StyleConfig) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
614	removedLineStyle = lipgloss.NewStyle().Background(config.RemovedLineBg)
615	addedLineStyle = lipgloss.NewStyle().Background(config.AddedLineBg)
616	contextLineStyle = lipgloss.NewStyle().Background(config.ContextLineBg)
617	lineNumberStyle = lipgloss.NewStyle().Foreground(config.LineNumberFg)
618
619	return
620}
621
622// -------------------------------------------------------------------------
623// Rendering Functions
624// -------------------------------------------------------------------------
625
626// applyHighlighting applies intra-line highlighting to a piece of text
627func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg lipgloss.Color,
628) string {
629	// Find all ANSI sequences in the content
630	ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
631	ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
632
633	// Build a mapping of visible character positions to their actual indices
634	visibleIdx := 0
635	ansiSequences := make(map[int]string)
636	lastAnsiSeq := "\x1b[0m" // Default reset sequence
637
638	for i := 0; i < len(content); {
639		isAnsi := false
640		for _, match := range ansiMatches {
641			if match[0] == i {
642				ansiSequences[visibleIdx] = content[match[0]:match[1]]
643				lastAnsiSeq = content[match[0]:match[1]]
644				i = match[1]
645				isAnsi = true
646				break
647			}
648		}
649		if isAnsi {
650			continue
651		}
652
653		// For non-ANSI positions, store the last ANSI sequence
654		if _, exists := ansiSequences[visibleIdx]; !exists {
655			ansiSequences[visibleIdx] = lastAnsiSeq
656		}
657		visibleIdx++
658		i++
659	}
660
661	// Apply highlighting
662	var sb strings.Builder
663	inSelection := false
664	currentPos := 0
665
666	for i := 0; i < len(content); {
667		// Check if we're at an ANSI sequence
668		isAnsi := false
669		for _, match := range ansiMatches {
670			if match[0] == i {
671				sb.WriteString(content[match[0]:match[1]]) // Preserve ANSI sequence
672				i = match[1]
673				isAnsi = true
674				break
675			}
676		}
677		if isAnsi {
678			continue
679		}
680
681		// Check for segment boundaries
682		for _, seg := range segments {
683			if seg.Type == segmentType {
684				if currentPos == seg.Start {
685					inSelection = true
686				}
687				if currentPos == seg.End {
688					inSelection = false
689				}
690			}
691		}
692
693		// Get current character
694		char := string(content[i])
695
696		if inSelection {
697			// Get the current styling
698			currentStyle := ansiSequences[currentPos]
699
700			// Apply background highlight
701			sb.WriteString("\x1b[48;2;")
702			r, g, b, _ := highlightBg.RGBA()
703			sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
704			sb.WriteString(char)
705			sb.WriteString("\x1b[49m") // Reset only background
706
707			// Reapply the original ANSI sequence
708			sb.WriteString(currentStyle)
709		} else {
710			// Not in selection, just copy the character
711			sb.WriteString(char)
712		}
713
714		currentPos++
715		i++
716	}
717
718	return sb.String()
719}
720
721// renderLeftColumn formats the left side of a side-by-side diff
722func renderLeftColumn(fileName string, dl *DiffLine, colWidth int, styles StyleConfig) string {
723	if dl == nil {
724		contextLineStyle := lipgloss.NewStyle().Background(styles.ContextLineBg)
725		return contextLineStyle.Width(colWidth).Render("")
726	}
727
728	removedLineStyle, _, contextLineStyle, lineNumberStyle := createStyles(styles)
729
730	// Determine line style based on line type
731	var marker string
732	var bgStyle lipgloss.Style
733	switch dl.Kind {
734	case LineRemoved:
735		marker = removedLineStyle.Foreground(styles.RemovedFg).Render("-")
736		bgStyle = removedLineStyle
737		lineNumberStyle = lineNumberStyle.Foreground(styles.RemovedFg).Background(styles.RemovedLineNumberBg)
738	case LineAdded:
739		marker = "?"
740		bgStyle = contextLineStyle
741	case LineContext:
742		marker = contextLineStyle.Render(" ")
743		bgStyle = contextLineStyle
744	}
745
746	// Format line number
747	lineNum := ""
748	if dl.OldLineNo > 0 {
749		lineNum = fmt.Sprintf("%6d", dl.OldLineNo)
750	}
751
752	// Create the line prefix
753	prefix := lineNumberStyle.Render(lineNum + " " + marker)
754
755	// Apply syntax highlighting
756	content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
757
758	// Apply intra-line highlighting for removed lines
759	if dl.Kind == LineRemoved && len(dl.Segments) > 0 {
760		content = applyHighlighting(content, dl.Segments, LineRemoved, styles.RemovedHighlightBg)
761	}
762
763	// Add a padding space for removed lines
764	if dl.Kind == LineRemoved {
765		content = bgStyle.Render(" ") + content
766	}
767
768	// Create the final line and truncate if needed
769	lineText := prefix + content
770	return bgStyle.MaxHeight(1).Width(colWidth).Render(
771		ansi.Truncate(
772			lineText,
773			colWidth,
774			lipgloss.NewStyle().Background(styles.HunkLineBg).Foreground(styles.HunkLineFg).Render("..."),
775		),
776	)
777}
778
779// renderRightColumn formats the right side of a side-by-side diff
780func renderRightColumn(fileName string, dl *DiffLine, colWidth int, styles StyleConfig) string {
781	if dl == nil {
782		contextLineStyle := lipgloss.NewStyle().Background(styles.ContextLineBg)
783		return contextLineStyle.Width(colWidth).Render("")
784	}
785
786	_, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(styles)
787
788	// Determine line style based on line type
789	var marker string
790	var bgStyle lipgloss.Style
791	switch dl.Kind {
792	case LineAdded:
793		marker = addedLineStyle.Foreground(styles.AddedFg).Render("+")
794		bgStyle = addedLineStyle
795		lineNumberStyle = lineNumberStyle.Foreground(styles.AddedFg).Background(styles.AddedLineNamerBg)
796	case LineRemoved:
797		marker = "?"
798		bgStyle = contextLineStyle
799	case LineContext:
800		marker = contextLineStyle.Render(" ")
801		bgStyle = contextLineStyle
802	}
803
804	// Format line number
805	lineNum := ""
806	if dl.NewLineNo > 0 {
807		lineNum = fmt.Sprintf("%6d", dl.NewLineNo)
808	}
809
810	// Create the line prefix
811	prefix := lineNumberStyle.Render(lineNum + " " + marker)
812
813	// Apply syntax highlighting
814	content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
815
816	// Apply intra-line highlighting for added lines
817	if dl.Kind == LineAdded && len(dl.Segments) > 0 {
818		content = applyHighlighting(content, dl.Segments, LineAdded, styles.AddedHighlightBg)
819	}
820
821	// Add a padding space for added lines
822	if dl.Kind == LineAdded {
823		content = bgStyle.Render(" ") + content
824	}
825
826	// Create the final line and truncate if needed
827	lineText := prefix + content
828	return bgStyle.MaxHeight(1).Width(colWidth).Render(
829		ansi.Truncate(
830			lineText,
831			colWidth,
832			lipgloss.NewStyle().Background(styles.HunkLineBg).Foreground(styles.HunkLineFg).Render("..."),
833		),
834	)
835}
836
837// -------------------------------------------------------------------------
838// Public API
839// -------------------------------------------------------------------------
840
841// RenderSideBySideHunk formats a hunk for side-by-side display
842func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
843	// Apply options to create the configuration
844	config := NewSideBySideConfig(opts...)
845
846	// Make a copy of the hunk so we don't modify the original
847	hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
848	copy(hunkCopy.Lines, h.Lines)
849
850	// Highlight changes within lines
851	HighlightIntralineChanges(&hunkCopy, config.Style)
852
853	// Pair lines for side-by-side display
854	pairs := pairLines(hunkCopy.Lines)
855
856	// Calculate column width
857	colWidth := config.TotalWidth / 2
858
859	leftWidth := colWidth
860	rightWidth := config.TotalWidth - colWidth
861	var sb strings.Builder
862	for _, p := range pairs {
863		leftStr := renderLeftColumn(fileName, p.left, leftWidth, config.Style)
864		rightStr := renderRightColumn(fileName, p.right, rightWidth, config.Style)
865		sb.WriteString(leftStr + rightStr + "\n")
866	}
867
868	return sb.String()
869}
870
871// FormatDiff creates a side-by-side formatted view of a diff
872func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
873	diffResult, err := ParseUnifiedDiff(diffText)
874	if err != nil {
875		return "", err
876	}
877
878	var sb strings.Builder
879	config := NewSideBySideConfig(opts...)
880
881	if config.Style.ShowHeader {
882		removeIcon := lipgloss.NewStyle().
883			Background(config.Style.RemovedLineBg).
884			Foreground(config.Style.RemovedFg).
885			Render("⏹")
886		addIcon := lipgloss.NewStyle().
887			Background(config.Style.AddedLineBg).
888			Foreground(config.Style.AddedFg).
889			Render("⏹")
890
891		fileName := lipgloss.NewStyle().
892			Background(config.Style.ContextLineBg).
893			Foreground(config.Style.FileNameFg).
894			Render(" " + diffResult.OldFile)
895		sb.WriteString(
896			lipgloss.NewStyle().
897				Background(config.Style.ContextLineBg).
898				Padding(0, 1, 0, 1).
899				Foreground(config.Style.FileNameFg).
900				BorderStyle(lipgloss.NormalBorder()).
901				BorderTop(true).
902				BorderBottom(true).
903				BorderForeground(config.Style.FileNameFg).
904				BorderBackground(config.Style.ContextLineBg).
905				Width(config.TotalWidth).
906				Render(
907					lipgloss.JoinHorizontal(lipgloss.Top,
908						removeIcon,
909						addIcon,
910						fileName,
911					),
912				) + "\n",
913		)
914	}
915
916	for _, h := range diffResult.Hunks {
917		// Render hunk header
918		if config.Style.ShowHunkHeader {
919			sb.WriteString(
920				lipgloss.NewStyle().
921					Background(config.Style.HunkLineBg).
922					Foreground(config.Style.HunkLineFg).
923					Width(config.TotalWidth).
924					Render(h.Header) + "\n",
925			)
926		}
927		sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
928	}
929
930	return sb.String(), nil
931}
932
933// GenerateDiff creates a unified diff from two file contents
934func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, int) {
935	// remove the cwd prefix and ensure consistent path format
936	// this prevents issues with absolute paths in different environments
937	cwd := config.WorkingDirectory()
938	fileName = strings.TrimPrefix(fileName, cwd)
939	fileName = strings.TrimPrefix(fileName, "/")
940
941	var (
942		unified   = udiff.Unified("a/"+fileName, "b/"+fileName, beforeContent, afterContent)
943		additions = 0
944		removals  = 0
945	)
946
947	lines := strings.Split(unified, "\n")
948	for _, line := range lines {
949		if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") {
950			additions++
951		} else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") {
952			removals++
953		}
954	}
955
956	return unified, additions, removals
957}