From f3da73553c45f17e04b1e77cb13eb0fca714d1bd Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 30 May 2025 20:47:56 -0400 Subject: sync --- internal/diff/diff.go | 869 --------------------------------------------- internal/diff/diff_test.go | 103 ------ internal/diff/patch.go | 740 -------------------------------------- 3 files changed, 1712 deletions(-) delete mode 100644 internal/diff/diff.go delete mode 100644 internal/diff/diff_test.go delete mode 100644 internal/diff/patch.go (limited to 'internal/diff') diff --git a/internal/diff/diff.go b/internal/diff/diff.go deleted file mode 100644 index 350db664a..000000000 --- a/internal/diff/diff.go +++ /dev/null @@ -1,869 +0,0 @@ -package diff - -import ( - "bytes" - "fmt" - "io" - "regexp" - "strconv" - "strings" - - "github.com/alecthomas/chroma/v2" - "github.com/alecthomas/chroma/v2/formatters" - "github.com/alecthomas/chroma/v2/lexers" - "github.com/alecthomas/chroma/v2/styles" - "github.com/aymanbagabas/go-udiff" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/ansi" - "github.com/sergi/go-diff/diffmatchpatch" - "github.com/sst/opencode/internal/config" - "github.com/sst/opencode/internal/tui/theme" -) - -// ------------------------------------------------------------------------- -// Core Types -// ------------------------------------------------------------------------- - -// LineType represents the kind of line in a diff. -type LineType int - -const ( - LineContext LineType = iota // Line exists in both files - LineAdded // Line added in the new file - LineRemoved // Line removed from the old file -) - -// Segment represents a portion of a line for intra-line highlighting -type Segment struct { - Start int - End int - Type LineType - Text string -} - -// DiffLine represents a single line in a diff -type DiffLine struct { - OldLineNo int // Line number in old file (0 for added lines) - NewLineNo int // Line number in new file (0 for removed lines) - Kind LineType // Type of line (added, removed, context) - Content string // Content of the line - Segments []Segment // Segments for intraline highlighting -} - -// Hunk represents a section of changes in a diff -type Hunk struct { - Header string - Lines []DiffLine -} - -// DiffResult contains the parsed result of a diff -type DiffResult struct { - OldFile string - NewFile string - Hunks []Hunk -} - -// linePair represents a pair of lines for side-by-side display -type linePair struct { - left *DiffLine - right *DiffLine -} - -// ------------------------------------------------------------------------- -// Parse Configuration -// ------------------------------------------------------------------------- - -// ParseConfig configures the behavior of diff parsing -type ParseConfig struct { - ContextSize int // Number of context lines to include -} - -// ParseOption modifies a ParseConfig -type ParseOption func(*ParseConfig) - -// WithContextSize sets the number of context lines to include -func WithContextSize(size int) ParseOption { - return func(p *ParseConfig) { - if size >= 0 { - p.ContextSize = size - } - } -} - -// ------------------------------------------------------------------------- -// Side-by-Side Configuration -// ------------------------------------------------------------------------- - -// SideBySideConfig configures the rendering of side-by-side diffs -type SideBySideConfig struct { - TotalWidth int -} - -// SideBySideOption modifies a SideBySideConfig -type SideBySideOption func(*SideBySideConfig) - -// NewSideBySideConfig creates a SideBySideConfig with default values -func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig { - config := SideBySideConfig{ - TotalWidth: 160, // Default width for side-by-side view - } - - for _, opt := range opts { - opt(&config) - } - - return config -} - -// WithTotalWidth sets the total width for side-by-side view -func WithTotalWidth(width int) SideBySideOption { - return func(s *SideBySideConfig) { - if width > 0 { - s.TotalWidth = width - } - } -} - -// ------------------------------------------------------------------------- -// Diff Parsing -// ------------------------------------------------------------------------- - -// ParseUnifiedDiff parses a unified diff format string into structured data -func ParseUnifiedDiff(diff string) (DiffResult, error) { - var result DiffResult - var currentHunk *Hunk - - hunkHeaderRe := regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`) - lines := strings.Split(diff, "\n") - - var oldLine, newLine int - inFileHeader := true - - for _, line := range lines { - // Parse file headers - if inFileHeader { - if strings.HasPrefix(line, "--- a/") { - result.OldFile = strings.TrimPrefix(line, "--- a/") - continue - } - if strings.HasPrefix(line, "+++ b/") { - result.NewFile = strings.TrimPrefix(line, "+++ b/") - inFileHeader = false - continue - } - } - - // Parse hunk headers - if matches := hunkHeaderRe.FindStringSubmatch(line); matches != nil { - if currentHunk != nil { - result.Hunks = append(result.Hunks, *currentHunk) - } - currentHunk = &Hunk{ - Header: line, - Lines: []DiffLine{}, - } - - oldStart, _ := strconv.Atoi(matches[1]) - newStart, _ := strconv.Atoi(matches[3]) - oldLine = oldStart - newLine = newStart - continue - } - - // Ignore "No newline at end of file" markers - if strings.HasPrefix(line, "\\ No newline at end of file") { - continue - } - - if currentHunk == nil { - continue - } - - // Process the line based on its prefix - if len(line) > 0 { - switch line[0] { - case '+': - currentHunk.Lines = append(currentHunk.Lines, DiffLine{ - OldLineNo: 0, - NewLineNo: newLine, - Kind: LineAdded, - Content: line[1:], - }) - newLine++ - case '-': - currentHunk.Lines = append(currentHunk.Lines, DiffLine{ - OldLineNo: oldLine, - NewLineNo: 0, - Kind: LineRemoved, - Content: line[1:], - }) - oldLine++ - default: - currentHunk.Lines = append(currentHunk.Lines, DiffLine{ - OldLineNo: oldLine, - NewLineNo: newLine, - Kind: LineContext, - Content: line, - }) - oldLine++ - newLine++ - } - } else { - // Handle empty lines - currentHunk.Lines = append(currentHunk.Lines, DiffLine{ - OldLineNo: oldLine, - NewLineNo: newLine, - Kind: LineContext, - Content: "", - }) - oldLine++ - newLine++ - } - } - - // Add the last hunk if there is one - if currentHunk != nil { - result.Hunks = append(result.Hunks, *currentHunk) - } - - return result, nil -} - -// HighlightIntralineChanges updates lines in a hunk to show character-level differences -func HighlightIntralineChanges(h *Hunk) { - var updated []DiffLine - dmp := diffmatchpatch.New() - - for i := 0; i < len(h.Lines); i++ { - // Look for removed line followed by added line - if i+1 < len(h.Lines) && - h.Lines[i].Kind == LineRemoved && - h.Lines[i+1].Kind == LineAdded { - - oldLine := h.Lines[i] - newLine := h.Lines[i+1] - - // Find character-level differences - patches := dmp.DiffMain(oldLine.Content, newLine.Content, false) - patches = dmp.DiffCleanupSemantic(patches) - patches = dmp.DiffCleanupMerge(patches) - patches = dmp.DiffCleanupEfficiency(patches) - - segments := make([]Segment, 0) - - removeStart := 0 - addStart := 0 - for _, patch := range patches { - switch patch.Type { - case diffmatchpatch.DiffDelete: - segments = append(segments, Segment{ - Start: removeStart, - End: removeStart + len(patch.Text), - Type: LineRemoved, - Text: patch.Text, - }) - removeStart += len(patch.Text) - case diffmatchpatch.DiffInsert: - segments = append(segments, Segment{ - Start: addStart, - End: addStart + len(patch.Text), - Type: LineAdded, - Text: patch.Text, - }) - addStart += len(patch.Text) - default: - // Context text, no highlighting needed - removeStart += len(patch.Text) - addStart += len(patch.Text) - } - } - oldLine.Segments = segments - newLine.Segments = segments - - updated = append(updated, oldLine, newLine) - i++ // Skip the next line as we've already processed it - } else { - updated = append(updated, h.Lines[i]) - } - } - - h.Lines = updated -} - -// pairLines converts a flat list of diff lines to pairs for side-by-side display -func pairLines(lines []DiffLine) []linePair { - var pairs []linePair - i := 0 - - for i < len(lines) { - switch lines[i].Kind { - case LineRemoved: - // Check if the next line is an addition, if so pair them - if i+1 < len(lines) && lines[i+1].Kind == LineAdded { - pairs = append(pairs, linePair{left: &lines[i], right: &lines[i+1]}) - i += 2 - } else { - pairs = append(pairs, linePair{left: &lines[i], right: nil}) - i++ - } - case LineAdded: - pairs = append(pairs, linePair{left: nil, right: &lines[i]}) - i++ - case LineContext: - pairs = append(pairs, linePair{left: &lines[i], right: &lines[i]}) - i++ - } - } - - return pairs -} - -// ------------------------------------------------------------------------- -// Syntax Highlighting -// ------------------------------------------------------------------------- - -// SyntaxHighlight applies syntax highlighting to text based on file extension -func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipgloss.TerminalColor) error { - t := theme.CurrentTheme() - - // Determine the language lexer to use - l := lexers.Match(fileName) - if l == nil { - l = lexers.Analyse(source) - } - if l == nil { - l = lexers.Fallback - } - l = chroma.Coalesce(l) - - // Get the formatter - f := formatters.Get(formatter) - if f == nil { - f = formatters.Fallback - } - - // Dynamic theme based on current theme values - syntaxThemeXml := fmt.Sprintf(` - -`, - getColor(t.Background()), // Background - getColor(t.Text()), // Text - getColor(t.Text()), // Other - getColor(t.Error()), // Error - - getColor(t.SyntaxKeyword()), // Keyword - getColor(t.SyntaxKeyword()), // KeywordConstant - getColor(t.SyntaxKeyword()), // KeywordDeclaration - getColor(t.SyntaxKeyword()), // KeywordNamespace - getColor(t.SyntaxKeyword()), // KeywordPseudo - getColor(t.SyntaxKeyword()), // KeywordReserved - getColor(t.SyntaxType()), // KeywordType - - getColor(t.Text()), // Name - getColor(t.SyntaxVariable()), // NameAttribute - getColor(t.SyntaxType()), // NameBuiltin - getColor(t.SyntaxVariable()), // NameBuiltinPseudo - getColor(t.SyntaxType()), // NameClass - getColor(t.SyntaxVariable()), // NameConstant - getColor(t.SyntaxFunction()), // NameDecorator - getColor(t.SyntaxVariable()), // NameEntity - getColor(t.SyntaxType()), // NameException - getColor(t.SyntaxFunction()), // NameFunction - getColor(t.Text()), // NameLabel - getColor(t.SyntaxType()), // NameNamespace - getColor(t.SyntaxVariable()), // NameOther - getColor(t.SyntaxKeyword()), // NameTag - getColor(t.SyntaxVariable()), // NameVariable - getColor(t.SyntaxVariable()), // NameVariableClass - getColor(t.SyntaxVariable()), // NameVariableGlobal - getColor(t.SyntaxVariable()), // NameVariableInstance - - getColor(t.SyntaxString()), // Literal - getColor(t.SyntaxString()), // LiteralDate - getColor(t.SyntaxString()), // LiteralString - getColor(t.SyntaxString()), // LiteralStringBacktick - getColor(t.SyntaxString()), // LiteralStringChar - getColor(t.SyntaxString()), // LiteralStringDoc - getColor(t.SyntaxString()), // LiteralStringDouble - getColor(t.SyntaxString()), // LiteralStringEscape - getColor(t.SyntaxString()), // LiteralStringHeredoc - getColor(t.SyntaxString()), // LiteralStringInterpol - getColor(t.SyntaxString()), // LiteralStringOther - getColor(t.SyntaxString()), // LiteralStringRegex - getColor(t.SyntaxString()), // LiteralStringSingle - getColor(t.SyntaxString()), // LiteralStringSymbol - - getColor(t.SyntaxNumber()), // LiteralNumber - getColor(t.SyntaxNumber()), // LiteralNumberBin - getColor(t.SyntaxNumber()), // LiteralNumberFloat - getColor(t.SyntaxNumber()), // LiteralNumberHex - getColor(t.SyntaxNumber()), // LiteralNumberInteger - getColor(t.SyntaxNumber()), // LiteralNumberIntegerLong - getColor(t.SyntaxNumber()), // LiteralNumberOct - - getColor(t.SyntaxOperator()), // Operator - getColor(t.SyntaxKeyword()), // OperatorWord - getColor(t.SyntaxPunctuation()), // Punctuation - - getColor(t.SyntaxComment()), // Comment - getColor(t.SyntaxComment()), // CommentHashbang - getColor(t.SyntaxComment()), // CommentMultiline - getColor(t.SyntaxComment()), // CommentSingle - getColor(t.SyntaxComment()), // CommentSpecial - getColor(t.SyntaxKeyword()), // CommentPreproc - - getColor(t.Text()), // Generic - getColor(t.Error()), // GenericDeleted - getColor(t.Text()), // GenericEmph - getColor(t.Error()), // GenericError - getColor(t.Text()), // GenericHeading - getColor(t.Success()), // GenericInserted - getColor(t.TextMuted()), // GenericOutput - getColor(t.Text()), // GenericPrompt - getColor(t.Text()), // GenericStrong - getColor(t.Text()), // GenericSubheading - getColor(t.Error()), // GenericTraceback - getColor(t.Text()), // TextWhitespace - ) - - r := strings.NewReader(syntaxThemeXml) - style := chroma.MustNewXMLStyle(r) - - // Modify the style to use the provided background - s, err := style.Builder().Transform( - func(t chroma.StyleEntry) chroma.StyleEntry { - r, g, b, _ := bg.RGBA() - t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8)) - return t - }, - ).Build() - if err != nil { - s = styles.Fallback - } - - // Tokenize and format - it, err := l.Tokenise(nil, source) - if err != nil { - return err - } - - return f.Format(w, s, it) -} - -// getColor returns the appropriate hex color string based on terminal background -func getColor(adaptiveColor lipgloss.AdaptiveColor) string { - if lipgloss.HasDarkBackground() { - return adaptiveColor.Dark - } - return adaptiveColor.Light -} - -// highlightLine applies syntax highlighting to a single line -func highlightLine(fileName string, line string, bg lipgloss.TerminalColor) string { - var buf bytes.Buffer - err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg) - if err != nil { - return line - } - return buf.String() -} - -// createStyles generates the lipgloss styles needed for rendering diffs -func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) { - removedLineStyle = lipgloss.NewStyle().Background(t.DiffRemovedBg()) - addedLineStyle = lipgloss.NewStyle().Background(t.DiffAddedBg()) - contextLineStyle = lipgloss.NewStyle().Background(t.DiffContextBg()) - lineNumberStyle = lipgloss.NewStyle().Foreground(t.DiffLineNumber()) - - return -} - -// ------------------------------------------------------------------------- -// Rendering Functions -// ------------------------------------------------------------------------- - -// applyHighlighting applies intra-line highlighting to a piece of text -func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg lipgloss.AdaptiveColor) string { - // Find all ANSI sequences in the content - ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`) - ansiMatches := ansiRegex.FindAllStringIndex(content, -1) - - // Build a mapping of visible character positions to their actual indices - visibleIdx := 0 - ansiSequences := make(map[int]string) - lastAnsiSeq := "\x1b[0m" // Default reset sequence - - for i := 0; i < len(content); { - isAnsi := false - for _, match := range ansiMatches { - if match[0] == i { - ansiSequences[visibleIdx] = content[match[0]:match[1]] - lastAnsiSeq = content[match[0]:match[1]] - i = match[1] - isAnsi = true - break - } - } - if isAnsi { - continue - } - - // For non-ANSI positions, store the last ANSI sequence - if _, exists := ansiSequences[visibleIdx]; !exists { - ansiSequences[visibleIdx] = lastAnsiSeq - } - visibleIdx++ - i++ - } - - // Apply highlighting - var sb strings.Builder - inSelection := false - currentPos := 0 - - // Get the appropriate color based on terminal background - bgColor := lipgloss.Color(getColor(highlightBg)) - fgColor := lipgloss.Color(getColor(theme.CurrentTheme().Background())) - - for i := 0; i < len(content); { - // Check if we're at an ANSI sequence - isAnsi := false - for _, match := range ansiMatches { - if match[0] == i { - sb.WriteString(content[match[0]:match[1]]) // Preserve ANSI sequence - i = match[1] - isAnsi = true - break - } - } - if isAnsi { - continue - } - - // Check for segment boundaries - for _, seg := range segments { - if seg.Type == segmentType { - if currentPos == seg.Start { - inSelection = true - } - if currentPos == seg.End { - inSelection = false - } - } - } - - // Get current character - char := string(content[i]) - - if inSelection { - // Get the current styling - currentStyle := ansiSequences[currentPos] - - // Apply foreground and background highlight - sb.WriteString("\x1b[38;2;") - r, g, b, _ := fgColor.RGBA() - sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8)) - sb.WriteString("\x1b[48;2;") - r, g, b, _ = bgColor.RGBA() - sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8)) - sb.WriteString(char) - - // Full reset of all attributes to ensure clean state - sb.WriteString("\x1b[0m") - - // Reapply the original ANSI sequence - sb.WriteString(currentStyle) - } else { - // Not in selection, just copy the character - sb.WriteString(char) - } - - currentPos++ - i++ - } - - return sb.String() -} - -// renderDiffColumnLine is a helper function that handles the common logic for rendering diff columns -func renderDiffColumnLine( - fileName string, - dl *DiffLine, - colWidth int, - isLeftColumn bool, - t theme.Theme, -) string { - if dl == nil { - contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg()) - return contextLineStyle.Width(colWidth).Render("") - } - - removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t) - - // Determine line style based on line type and column - var marker string - var bgStyle lipgloss.Style - var lineNum string - var highlightType LineType - var highlightColor lipgloss.AdaptiveColor - - if isLeftColumn { - // Left column logic - switch dl.Kind { - case LineRemoved: - marker = "-" - bgStyle = removedLineStyle - lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg()) - highlightType = LineRemoved - highlightColor = t.DiffHighlightRemoved() - case LineAdded: - marker = "?" - bgStyle = contextLineStyle - case LineContext: - marker = " " - bgStyle = contextLineStyle - } - - // Format line number for left column - if dl.OldLineNo > 0 { - lineNum = fmt.Sprintf("%6d", dl.OldLineNo) - } - } else { - // Right column logic - switch dl.Kind { - case LineAdded: - marker = "+" - bgStyle = addedLineStyle - lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg()) - highlightType = LineAdded - highlightColor = t.DiffHighlightAdded() - case LineRemoved: - marker = "?" - bgStyle = contextLineStyle - case LineContext: - marker = " " - bgStyle = contextLineStyle - } - - // Format line number for right column - if dl.NewLineNo > 0 { - lineNum = fmt.Sprintf("%6d", dl.NewLineNo) - } - } - - // Style the marker based on line type - var styledMarker string - switch dl.Kind { - case LineRemoved: - styledMarker = removedLineStyle.Foreground(t.DiffRemoved()).Render(marker) - case LineAdded: - styledMarker = addedLineStyle.Foreground(t.DiffAdded()).Render(marker) - case LineContext: - styledMarker = contextLineStyle.Foreground(t.TextMuted()).Render(marker) - default: - styledMarker = marker - } - - // Create the line prefix - prefix := lineNumberStyle.Render(lineNum + " " + styledMarker) - - // Apply syntax highlighting - content := highlightLine(fileName, dl.Content, bgStyle.GetBackground()) - - // Apply intra-line highlighting if needed - if (dl.Kind == LineRemoved && isLeftColumn || dl.Kind == LineAdded && !isLeftColumn) && len(dl.Segments) > 0 { - content = applyHighlighting(content, dl.Segments, highlightType, highlightColor) - } - - // Add a padding space for added/removed lines - if (dl.Kind == LineRemoved && isLeftColumn) || (dl.Kind == LineAdded && !isLeftColumn) { - content = bgStyle.Render(" ") + content - } - - // Create the final line and truncate if needed - lineText := prefix + content - return bgStyle.MaxHeight(1).Width(colWidth).Render( - ansi.Truncate( - lineText, - colWidth, - lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."), - ), - ) -} - -// renderLeftColumn formats the left side of a side-by-side diff -func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string { - return renderDiffColumnLine(fileName, dl, colWidth, true, theme.CurrentTheme()) -} - -// renderRightColumn formats the right side of a side-by-side diff -func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string { - return renderDiffColumnLine(fileName, dl, colWidth, false, theme.CurrentTheme()) -} - -// ------------------------------------------------------------------------- -// Public API -// ------------------------------------------------------------------------- - -// RenderSideBySideHunk formats a hunk for side-by-side display -func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string { - // Apply options to create the configuration - config := NewSideBySideConfig(opts...) - - // Make a copy of the hunk so we don't modify the original - hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))} - copy(hunkCopy.Lines, h.Lines) - - // Highlight changes within lines - HighlightIntralineChanges(&hunkCopy) - - // Pair lines for side-by-side display - pairs := pairLines(hunkCopy.Lines) - - // Calculate column width - colWidth := config.TotalWidth / 2 - - leftWidth := colWidth - rightWidth := config.TotalWidth - colWidth - var sb strings.Builder - for _, p := range pairs { - leftStr := renderLeftColumn(fileName, p.left, leftWidth) - rightStr := renderRightColumn(fileName, p.right, rightWidth) - sb.WriteString(leftStr + rightStr + "\n") - } - - return sb.String() -} - -// FormatDiff creates a side-by-side formatted view of a diff -func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) { - t := theme.CurrentTheme() - diffResult, err := ParseUnifiedDiff(diffText) - if err != nil { - return "", err - } - - var sb strings.Builder - config := NewSideBySideConfig(opts...) - for _, h := range diffResult.Hunks { - sb.WriteString( - lipgloss.NewStyle(). - Background(t.DiffHunkHeader()). - Foreground(t.Background()). - Width(config.TotalWidth). - Render(h.Header) + "\n", - ) - sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...)) - } - - return sb.String(), nil -} - -// GenerateDiff creates a unified diff from two file contents -func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, int) { - // remove the cwd prefix and ensure consistent path format - // this prevents issues with absolute paths in different environments - cwd := config.WorkingDirectory() - fileName = strings.TrimPrefix(fileName, cwd) - fileName = strings.TrimPrefix(fileName, "/") - - edits := udiff.Strings(beforeContent, afterContent) - unified, _ := udiff.ToUnified("a/"+fileName, "b/"+fileName, beforeContent, edits, 8) - - var ( - additions = 0 - removals = 0 - ) - - lines := strings.SplitSeq(unified, "\n") - for line := range lines { - if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") { - additions++ - } else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") { - removals++ - } - } - - return unified, additions, removals -} diff --git a/internal/diff/diff_test.go b/internal/diff/diff_test.go deleted file mode 100644 index 4c014e45c..000000000 --- a/internal/diff/diff_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package diff - -import ( - "fmt" - "testing" - - "github.com/charmbracelet/lipgloss" - "github.com/stretchr/testify/assert" -) - -// TestApplyHighlighting tests the applyHighlighting function with various ANSI sequences -func TestApplyHighlighting(t *testing.T) { - t.Parallel() - - // Mock theme colors for testing - mockHighlightBg := lipgloss.AdaptiveColor{ - Dark: "#FF0000", // Red background for highlighting - Light: "#FF0000", - } - - // Test cases - tests := []struct { - name string - content string - segments []Segment - segmentType LineType - expectContains string - }{ - { - name: "Simple text with no ANSI", - content: "This is a test", - segments: []Segment{{Start: 0, End: 4, Type: LineAdded}}, - segmentType: LineAdded, - // Should contain full reset sequence after highlighting - expectContains: "\x1b[0m", - }, - { - name: "Text with existing ANSI foreground", - content: "This \x1b[32mis\x1b[0m a test", // "is" in green - segments: []Segment{{Start: 5, End: 7, Type: LineAdded}}, - segmentType: LineAdded, - // Should contain full reset sequence after highlighting - expectContains: "\x1b[0m", - }, - { - name: "Text with existing ANSI background", - content: "This \x1b[42mis\x1b[0m a test", // "is" with green background - segments: []Segment{{Start: 5, End: 7, Type: LineAdded}}, - segmentType: LineAdded, - // Should contain full reset sequence after highlighting - expectContains: "\x1b[0m", - }, - { - name: "Text with complex ANSI styling", - content: "This \x1b[1;32;45mis\x1b[0m a test", // "is" bold green on magenta - segments: []Segment{{Start: 5, End: 7, Type: LineAdded}}, - segmentType: LineAdded, - // Should contain full reset sequence after highlighting - expectContains: "\x1b[0m", - }, - } - - for _, tc := range tests { - tc := tc // Capture range variable for parallel testing - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - result := applyHighlighting(tc.content, tc.segments, tc.segmentType, mockHighlightBg) - - // Verify the result contains the expected sequence - assert.Contains(t, result, tc.expectContains, - "Result should contain full reset sequence") - - // Print the result for manual inspection if needed - if t.Failed() { - fmt.Printf("Original: %q\nResult: %q\n", tc.content, result) - } - }) - } -} - -// TestApplyHighlightingWithMultipleSegments tests highlighting multiple segments -func TestApplyHighlightingWithMultipleSegments(t *testing.T) { - t.Parallel() - - // Mock theme colors for testing - mockHighlightBg := lipgloss.AdaptiveColor{ - Dark: "#FF0000", // Red background for highlighting - Light: "#FF0000", - } - - content := "This is a test with multiple segments to highlight" - segments := []Segment{ - {Start: 0, End: 4, Type: LineAdded}, // "This" - {Start: 8, End: 9, Type: LineAdded}, // "a" - {Start: 15, End: 23, Type: LineAdded}, // "multiple" - } - - result := applyHighlighting(content, segments, LineAdded, mockHighlightBg) - - // Verify the result contains the full reset sequence - assert.Contains(t, result, "\x1b[0m", - "Result should contain full reset sequence") -} \ No newline at end of file diff --git a/internal/diff/patch.go b/internal/diff/patch.go deleted file mode 100644 index 49242f7ef..000000000 --- a/internal/diff/patch.go +++ /dev/null @@ -1,740 +0,0 @@ -package diff - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "strings" -) - -type ActionType string - -const ( - ActionAdd ActionType = "add" - ActionDelete ActionType = "delete" - ActionUpdate ActionType = "update" -) - -type FileChange struct { - Type ActionType - OldContent *string - NewContent *string - MovePath *string -} - -type Commit struct { - Changes map[string]FileChange -} - -type Chunk struct { - OrigIndex int // line index of the first line in the original file - DelLines []string // lines to delete - InsLines []string // lines to insert -} - -type PatchAction struct { - Type ActionType - NewFile *string - Chunks []Chunk - MovePath *string -} - -type Patch struct { - Actions map[string]PatchAction -} - -type DiffError struct { - message string -} - -func (e DiffError) Error() string { - return e.message -} - -// Helper functions for error handling -func NewDiffError(message string) DiffError { - return DiffError{message: message} -} - -func fileError(action, reason, path string) DiffError { - return NewDiffError(fmt.Sprintf("%s File Error: %s: %s", action, reason, path)) -} - -func contextError(index int, context string, isEOF bool) DiffError { - prefix := "Invalid Context" - if isEOF { - prefix = "Invalid EOF Context" - } - return NewDiffError(fmt.Sprintf("%s %d:\n%s", prefix, index, context)) -} - -type Parser struct { - currentFiles map[string]string - lines []string - index int - patch Patch - fuzz int -} - -func NewParser(currentFiles map[string]string, lines []string) *Parser { - return &Parser{ - currentFiles: currentFiles, - lines: lines, - index: 0, - patch: Patch{Actions: make(map[string]PatchAction, len(currentFiles))}, - fuzz: 0, - } -} - -func (p *Parser) isDone(prefixes []string) bool { - if p.index >= len(p.lines) { - return true - } - for _, prefix := range prefixes { - if strings.HasPrefix(p.lines[p.index], prefix) { - return true - } - } - return false -} - -func (p *Parser) startsWith(prefix any) bool { - var prefixes []string - switch v := prefix.(type) { - case string: - prefixes = []string{v} - case []string: - prefixes = v - } - - for _, pfx := range prefixes { - if strings.HasPrefix(p.lines[p.index], pfx) { - return true - } - } - return false -} - -func (p *Parser) readStr(prefix string, returnEverything bool) string { - if p.index >= len(p.lines) { - return "" // Changed from panic to return empty string for safer operation - } - if strings.HasPrefix(p.lines[p.index], prefix) { - var text string - if returnEverything { - text = p.lines[p.index] - } else { - text = p.lines[p.index][len(prefix):] - } - p.index++ - return text - } - return "" -} - -func (p *Parser) Parse() error { - endPatchPrefixes := []string{"*** End Patch"} - - for !p.isDone(endPatchPrefixes) { - path := p.readStr("*** Update File: ", false) - if path != "" { - if _, exists := p.patch.Actions[path]; exists { - return fileError("Update", "Duplicate Path", path) - } - moveTo := p.readStr("*** Move to: ", false) - if _, exists := p.currentFiles[path]; !exists { - return fileError("Update", "Missing File", path) - } - text := p.currentFiles[path] - action, err := p.parseUpdateFile(text) - if err != nil { - return err - } - if moveTo != "" { - action.MovePath = &moveTo - } - p.patch.Actions[path] = action - continue - } - - path = p.readStr("*** Delete File: ", false) - if path != "" { - if _, exists := p.patch.Actions[path]; exists { - return fileError("Delete", "Duplicate Path", path) - } - if _, exists := p.currentFiles[path]; !exists { - return fileError("Delete", "Missing File", path) - } - p.patch.Actions[path] = PatchAction{Type: ActionDelete, Chunks: []Chunk{}} - continue - } - - path = p.readStr("*** Add File: ", false) - if path != "" { - if _, exists := p.patch.Actions[path]; exists { - return fileError("Add", "Duplicate Path", path) - } - if _, exists := p.currentFiles[path]; exists { - return fileError("Add", "File already exists", path) - } - action, err := p.parseAddFile() - if err != nil { - return err - } - p.patch.Actions[path] = action - continue - } - - return NewDiffError(fmt.Sprintf("Unknown Line: %s", p.lines[p.index])) - } - - if !p.startsWith("*** End Patch") { - return NewDiffError("Missing End Patch") - } - p.index++ - - return nil -} - -func (p *Parser) parseUpdateFile(text string) (PatchAction, error) { - action := PatchAction{Type: ActionUpdate, Chunks: []Chunk{}} - fileLines := strings.Split(text, "\n") - index := 0 - - endPrefixes := []string{ - "*** End Patch", - "*** Update File:", - "*** Delete File:", - "*** Add File:", - "*** End of File", - } - - for !p.isDone(endPrefixes) { - defStr := p.readStr("@@ ", false) - sectionStr := "" - if defStr == "" && p.index < len(p.lines) && p.lines[p.index] == "@@" { - sectionStr = p.lines[p.index] - p.index++ - } - if defStr == "" && sectionStr == "" && index != 0 { - return action, NewDiffError(fmt.Sprintf("Invalid Line:\n%s", p.lines[p.index])) - } - if strings.TrimSpace(defStr) != "" { - found := false - for i := range fileLines[:index] { - if fileLines[i] == defStr { - found = true - break - } - } - - if !found { - for i := index; i < len(fileLines); i++ { - if fileLines[i] == defStr { - index = i + 1 - found = true - break - } - } - } - - if !found { - for i := range fileLines[:index] { - if strings.TrimSpace(fileLines[i]) == strings.TrimSpace(defStr) { - found = true - break - } - } - } - - if !found { - for i := index; i < len(fileLines); i++ { - if strings.TrimSpace(fileLines[i]) == strings.TrimSpace(defStr) { - index = i + 1 - p.fuzz++ - found = true - break - } - } - } - } - - nextChunkContext, chunks, endPatchIndex, eof := peekNextSection(p.lines, p.index) - newIndex, fuzz := findContext(fileLines, nextChunkContext, index, eof) - if newIndex == -1 { - ctxText := strings.Join(nextChunkContext, "\n") - return action, contextError(index, ctxText, eof) - } - p.fuzz += fuzz - - for _, ch := range chunks { - ch.OrigIndex += newIndex - action.Chunks = append(action.Chunks, ch) - } - index = newIndex + len(nextChunkContext) - p.index = endPatchIndex - } - return action, nil -} - -func (p *Parser) parseAddFile() (PatchAction, error) { - lines := make([]string, 0, 16) // Preallocate space for better performance - endPrefixes := []string{ - "*** End Patch", - "*** Update File:", - "*** Delete File:", - "*** Add File:", - } - - for !p.isDone(endPrefixes) { - s := p.readStr("", true) - if !strings.HasPrefix(s, "+") { - return PatchAction{}, NewDiffError(fmt.Sprintf("Invalid Add File Line: %s", s)) - } - lines = append(lines, s[1:]) - } - - newFile := strings.Join(lines, "\n") - return PatchAction{ - Type: ActionAdd, - NewFile: &newFile, - Chunks: []Chunk{}, - }, nil -} - -// Refactored to use a matcher function for each comparison type -func findContextCore(lines []string, context []string, start int) (int, int) { - if len(context) == 0 { - return start, 0 - } - - // Try exact match - if idx, fuzz := tryFindMatch(lines, context, start, func(a, b string) bool { - return a == b - }); idx >= 0 { - return idx, fuzz - } - - // Try trimming right whitespace - if idx, fuzz := tryFindMatch(lines, context, start, func(a, b string) bool { - return strings.TrimRight(a, " \t") == strings.TrimRight(b, " \t") - }); idx >= 0 { - return idx, fuzz - } - - // Try trimming all whitespace - if idx, fuzz := tryFindMatch(lines, context, start, func(a, b string) bool { - return strings.TrimSpace(a) == strings.TrimSpace(b) - }); idx >= 0 { - return idx, fuzz - } - - return -1, 0 -} - -// Helper function to DRY up the match logic -func tryFindMatch(lines []string, context []string, start int, - compareFunc func(string, string) bool, -) (int, int) { - for i := start; i < len(lines); i++ { - if i+len(context) <= len(lines) { - match := true - for j := range context { - if !compareFunc(lines[i+j], context[j]) { - match = false - break - } - } - if match { - // Return fuzz level: 0 for exact, 1 for trimRight, 100 for trimSpace - var fuzz int - if compareFunc("a ", "a") && !compareFunc("a", "b") { - fuzz = 1 - } else if compareFunc("a ", "a") { - fuzz = 100 - } - return i, fuzz - } - } - } - return -1, 0 -} - -func findContext(lines []string, context []string, start int, eof bool) (int, int) { - if eof { - newIndex, fuzz := findContextCore(lines, context, len(lines)-len(context)) - if newIndex != -1 { - return newIndex, fuzz - } - newIndex, fuzz = findContextCore(lines, context, start) - return newIndex, fuzz + 10000 - } - return findContextCore(lines, context, start) -} - -func peekNextSection(lines []string, initialIndex int) ([]string, []Chunk, int, bool) { - index := initialIndex - old := make([]string, 0, 32) // Preallocate for better performance - delLines := make([]string, 0, 8) - insLines := make([]string, 0, 8) - chunks := make([]Chunk, 0, 4) - mode := "keep" - - // End conditions for the section - endSectionConditions := func(s string) bool { - return strings.HasPrefix(s, "@@") || - strings.HasPrefix(s, "*** End Patch") || - strings.HasPrefix(s, "*** Update File:") || - strings.HasPrefix(s, "*** Delete File:") || - strings.HasPrefix(s, "*** Add File:") || - strings.HasPrefix(s, "*** End of File") || - s == "***" || - strings.HasPrefix(s, "***") - } - - for index < len(lines) { - s := lines[index] - if endSectionConditions(s) { - break - } - index++ - lastMode := mode - line := s - - if len(line) > 0 { - switch line[0] { - case '+': - mode = "add" - case '-': - mode = "delete" - case ' ': - mode = "keep" - default: - mode = "keep" - line = " " + line - } - } else { - mode = "keep" - line = " " - } - - line = line[1:] - if mode == "keep" && lastMode != mode { - if len(insLines) > 0 || len(delLines) > 0 { - chunks = append(chunks, Chunk{ - OrigIndex: len(old) - len(delLines), - DelLines: delLines, - InsLines: insLines, - }) - } - delLines = make([]string, 0, 8) - insLines = make([]string, 0, 8) - } - switch mode { - case "delete": - delLines = append(delLines, line) - old = append(old, line) - case "add": - insLines = append(insLines, line) - default: - old = append(old, line) - } - } - - if len(insLines) > 0 || len(delLines) > 0 { - chunks = append(chunks, Chunk{ - OrigIndex: len(old) - len(delLines), - DelLines: delLines, - InsLines: insLines, - }) - } - - if index < len(lines) && lines[index] == "*** End of File" { - index++ - return old, chunks, index, true - } - return old, chunks, index, false -} - -func TextToPatch(text string, orig map[string]string) (Patch, int, error) { - text = strings.TrimSpace(text) - lines := strings.Split(text, "\n") - if len(lines) < 2 || !strings.HasPrefix(lines[0], "*** Begin Patch") || lines[len(lines)-1] != "*** End Patch" { - return Patch{}, 0, NewDiffError("Invalid patch text") - } - parser := NewParser(orig, lines) - parser.index = 1 - if err := parser.Parse(); err != nil { - return Patch{}, 0, err - } - return parser.patch, parser.fuzz, nil -} - -func IdentifyFilesNeeded(text string) []string { - text = strings.TrimSpace(text) - lines := strings.Split(text, "\n") - result := make(map[string]bool) - - for _, line := range lines { - if strings.HasPrefix(line, "*** Update File: ") { - result[line[len("*** Update File: "):]] = true - } - if strings.HasPrefix(line, "*** Delete File: ") { - result[line[len("*** Delete File: "):]] = true - } - } - - files := make([]string, 0, len(result)) - for file := range result { - files = append(files, file) - } - return files -} - -func IdentifyFilesAdded(text string) []string { - text = strings.TrimSpace(text) - lines := strings.Split(text, "\n") - result := make(map[string]bool) - - for _, line := range lines { - if strings.HasPrefix(line, "*** Add File: ") { - result[line[len("*** Add File: "):]] = true - } - } - - files := make([]string, 0, len(result)) - for file := range result { - files = append(files, file) - } - return files -} - -func getUpdatedFile(text string, action PatchAction, path string) (string, error) { - if action.Type != ActionUpdate { - return "", errors.New("expected UPDATE action") - } - origLines := strings.Split(text, "\n") - destLines := make([]string, 0, len(origLines)) // Preallocate with capacity - origIndex := 0 - - for _, chunk := range action.Chunks { - if chunk.OrigIndex > len(origLines) { - return "", NewDiffError(fmt.Sprintf("%s: chunk.orig_index %d > len(lines) %d", path, chunk.OrigIndex, len(origLines))) - } - if origIndex > chunk.OrigIndex { - return "", NewDiffError(fmt.Sprintf("%s: orig_index %d > chunk.orig_index %d", path, origIndex, chunk.OrigIndex)) - } - destLines = append(destLines, origLines[origIndex:chunk.OrigIndex]...) - delta := chunk.OrigIndex - origIndex - origIndex += delta - - if len(chunk.InsLines) > 0 { - destLines = append(destLines, chunk.InsLines...) - } - origIndex += len(chunk.DelLines) - } - - destLines = append(destLines, origLines[origIndex:]...) - return strings.Join(destLines, "\n"), nil -} - -func PatchToCommit(patch Patch, orig map[string]string) (Commit, error) { - commit := Commit{Changes: make(map[string]FileChange, len(patch.Actions))} - for pathKey, action := range patch.Actions { - switch action.Type { - case ActionDelete: - oldContent := orig[pathKey] - commit.Changes[pathKey] = FileChange{ - Type: ActionDelete, - OldContent: &oldContent, - } - case ActionAdd: - commit.Changes[pathKey] = FileChange{ - Type: ActionAdd, - NewContent: action.NewFile, - } - case ActionUpdate: - newContent, err := getUpdatedFile(orig[pathKey], action, pathKey) - if err != nil { - return Commit{}, err - } - oldContent := orig[pathKey] - fileChange := FileChange{ - Type: ActionUpdate, - OldContent: &oldContent, - NewContent: &newContent, - } - if action.MovePath != nil { - fileChange.MovePath = action.MovePath - } - commit.Changes[pathKey] = fileChange - } - } - return commit, nil -} - -func AssembleChanges(orig map[string]string, updatedFiles map[string]string) Commit { - commit := Commit{Changes: make(map[string]FileChange, len(updatedFiles))} - for p, newContent := range updatedFiles { - oldContent, exists := orig[p] - if exists && oldContent == newContent { - continue - } - - if exists && newContent != "" { - commit.Changes[p] = FileChange{ - Type: ActionUpdate, - OldContent: &oldContent, - NewContent: &newContent, - } - } else if newContent != "" { - commit.Changes[p] = FileChange{ - Type: ActionAdd, - NewContent: &newContent, - } - } else if exists { - commit.Changes[p] = FileChange{ - Type: ActionDelete, - OldContent: &oldContent, - } - } else { - return commit // Changed from panic to simply return current commit - } - } - return commit -} - -func LoadFiles(paths []string, openFn func(string) (string, error)) (map[string]string, error) { - orig := make(map[string]string, len(paths)) - for _, p := range paths { - content, err := openFn(p) - if err != nil { - return nil, fileError("Open", "File not found", p) - } - orig[p] = content - } - return orig, nil -} - -func ApplyCommit(commit Commit, writeFn func(string, string) error, removeFn func(string) error) error { - for p, change := range commit.Changes { - switch change.Type { - case ActionDelete: - if err := removeFn(p); err != nil { - return err - } - case ActionAdd: - if change.NewContent == nil { - return NewDiffError(fmt.Sprintf("Add action for %s has nil new_content", p)) - } - if err := writeFn(p, *change.NewContent); err != nil { - return err - } - case ActionUpdate: - if change.NewContent == nil { - return NewDiffError(fmt.Sprintf("Update action for %s has nil new_content", p)) - } - if change.MovePath != nil { - if err := writeFn(*change.MovePath, *change.NewContent); err != nil { - return err - } - if err := removeFn(p); err != nil { - return err - } - } else { - if err := writeFn(p, *change.NewContent); err != nil { - return err - } - } - } - } - return nil -} - -func ProcessPatch(text string, openFn func(string) (string, error), writeFn func(string, string) error, removeFn func(string) error) (string, error) { - if !strings.HasPrefix(text, "*** Begin Patch") { - return "", NewDiffError("Patch must start with *** Begin Patch") - } - paths := IdentifyFilesNeeded(text) - orig, err := LoadFiles(paths, openFn) - if err != nil { - return "", err - } - - patch, fuzz, err := TextToPatch(text, orig) - if err != nil { - return "", err - } - - if fuzz > 0 { - return "", NewDiffError(fmt.Sprintf("Patch contains fuzzy matches (fuzz level: %d)", fuzz)) - } - - commit, err := PatchToCommit(patch, orig) - if err != nil { - return "", err - } - - if err := ApplyCommit(commit, writeFn, removeFn); err != nil { - return "", err - } - - return "Patch applied successfully", nil -} - -func OpenFile(p string) (string, error) { - data, err := os.ReadFile(p) - if err != nil { - return "", err - } - return string(data), nil -} - -func WriteFile(p string, content string) error { - if filepath.IsAbs(p) { - return NewDiffError("We do not support absolute paths.") - } - - dir := filepath.Dir(p) - if dir != "." { - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - } - - return os.WriteFile(p, []byte(content), 0o644) -} - -func RemoveFile(p string) error { - return os.Remove(p) -} - -func ValidatePatch(patchText string, files map[string]string) (bool, string, error) { - if !strings.HasPrefix(patchText, "*** Begin Patch") { - return false, "Patch must start with *** Begin Patch", nil - } - - neededFiles := IdentifyFilesNeeded(patchText) - for _, filePath := range neededFiles { - if _, exists := files[filePath]; !exists { - return false, fmt.Sprintf("File not found: %s", filePath), nil - } - } - - patch, fuzz, err := TextToPatch(patchText, files) - if err != nil { - return false, err.Error(), nil - } - - if fuzz > 0 { - return false, fmt.Sprintf("Patch contains fuzzy matches (fuzz level: %d)", fuzz), nil - } - - _, err = PatchToCommit(patch, files) - if err != nil { - return false, err.Error(), nil - } - - return true, "Patch is valid", nil -} -- cgit v1.2.3