diff --git a/internal/diff/diff.go b/internal/diff/diff.go index 755c5e9891d2de693dd51a1ddf866f5911935d15..694b9db417790bbcda679ec84f426a2a4d0e8f7b 100644 --- a/internal/diff/diff.go +++ b/internal/diff/diff.go @@ -1,621 +1,12 @@ package diff import ( - "fmt" - "image/color" - "regexp" - "strconv" "strings" "github.com/aymanbagabas/go-udiff" "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/highlight" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/lipgloss/v2" - "github.com/charmbracelet/x/ansi" - "github.com/sergi/go-diff/diffmatchpatch" ) -// Pre-compiled regex patterns for better performance -var ( - hunkHeaderRegex = regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`) - ansiRegex = regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`) -) - -// ------------------------------------------------------------------------- -// 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 - - 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 := hunkHeaderRegex.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 -// ------------------------------------------------------------------------- -func getColor(c color.Color) string { - rgba := color.RGBAModel.Convert(c).(color.RGBA) - return fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B) -} - -// highlightLine applies syntax highlighting to a single line -func highlightLine(fileName string, line string, bg color.Color) string { - highlighted, err := highlight.SyntaxHighlight(line, fileName, bg) - if err != nil { - return line - } - return highlighted -} - -// createStyles generates the lipgloss styles needed for rendering diffs -func createStyles(t *styles.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) { - removedLineStyle = lipgloss.NewStyle().Background(t.S().Diff.RemovedBg) - addedLineStyle = lipgloss.NewStyle().Background(t.S().Diff.AddedBg) - contextLineStyle = lipgloss.NewStyle().Background(t.S().Diff.ContextBg) - lineNumberStyle = lipgloss.NewStyle().Foreground(t.S().Diff.LineNumber) - return -} - -// ------------------------------------------------------------------------- -// Rendering Functions -// ------------------------------------------------------------------------- - -// applyHighlighting applies intra-line highlighting to a piece of text -func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg color.Color) string { - // Find all ANSI sequences in the content using pre-compiled regex - 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) - // Reset foreground and background - // sb.WriteString("\x1b[39m") - - // Reapply the original ANSI sequence - sb.WriteString(currentStyle) - } else { - // Not in selection, just copy the character - sb.WriteString(char) - } - - currentPos++ - i++ - } - - return sb.String() -} - -// renderLeftColumn formats the left side of a side-by-side diff -func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string { - t := styles.CurrentTheme() - - if dl == nil { - contextLineStyle := t.S().Base.Background(t.S().Diff.ContextBg) - return contextLineStyle.Width(colWidth).Render("") - } - - removedLineStyle, _, contextLineStyle, lineNumberStyle := createStyles(t) - - // Determine line style based on line type - var marker string - var bgStyle lipgloss.Style - switch dl.Kind { - case LineRemoved: - marker = removedLineStyle.Foreground(t.S().Diff.Removed).Render("-") - bgStyle = removedLineStyle - lineNumberStyle = lineNumberStyle.Foreground(t.S().Diff.Removed).Background(t.S().Diff.RemovedLineNumberBg) - case LineAdded: - marker = "?" - bgStyle = contextLineStyle - case LineContext: - marker = contextLineStyle.Render(" ") - bgStyle = contextLineStyle - } - - // Format line number - lineNum := "" - if dl.OldLineNo > 0 { - lineNum = fmt.Sprintf("%6d", dl.OldLineNo) - } - - // Create the line prefix - prefix := lineNumberStyle.Render(lineNum + " " + marker) - - // Apply syntax highlighting - content := highlightLine(fileName, dl.Content, bgStyle.GetBackground()) - - // Apply intra-line highlighting for removed lines - if dl.Kind == LineRemoved && len(dl.Segments) > 0 { - content = applyHighlighting(content, dl.Segments, LineRemoved, t.S().Diff.HighlightRemoved) - } - - // Add a padding space for removed lines - if dl.Kind == LineRemoved { - 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.FgMuted).Render("..."), - ), - ) -} - -// renderRightColumn formats the right side of a side-by-side diff -func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string { - t := styles.CurrentTheme() - - if dl == nil { - contextLineStyle := lipgloss.NewStyle().Background(t.S().Diff.ContextBg) - return contextLineStyle.Width(colWidth).Render("") - } - - _, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t) - - // Determine line style based on line type - var marker string - var bgStyle lipgloss.Style - switch dl.Kind { - case LineAdded: - marker = addedLineStyle.Foreground(t.S().Diff.Added).Render("+") - bgStyle = addedLineStyle - lineNumberStyle = lineNumberStyle.Foreground(t.S().Diff.Added).Background(t.S().Diff.AddedLineNumberBg) - case LineRemoved: - marker = "?" - bgStyle = contextLineStyle - case LineContext: - marker = contextLineStyle.Render(" ") - bgStyle = contextLineStyle - } - - // Format line number - lineNum := "" - if dl.NewLineNo > 0 { - lineNum = fmt.Sprintf("%6d", dl.NewLineNo) - } - - // Create the line prefix - prefix := lineNumberStyle.Render(lineNum + " " + marker) - - // Apply syntax highlighting - content := highlightLine(fileName, dl.Content, bgStyle.GetBackground()) - - // Apply intra-line highlighting for added lines - if dl.Kind == LineAdded && len(dl.Segments) > 0 { - content = applyHighlighting(content, dl.Segments, LineAdded, t.S().Diff.HighlightAdded) - } - - // Add a padding space for added lines - if dl.Kind == LineAdded { - 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.FgMuted).Render("..."), - ), - ) -} - -// ------------------------------------------------------------------------- -// 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) { - diffResult, err := ParseUnifiedDiff(diffText) - if err != nil { - return "", err - } - - var sb strings.Builder - for _, h := range diffResult.Hunks { - 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 diff --git a/internal/diff/patch.go b/internal/diff/patch.go deleted file mode 100644 index 49242f7efc1c5c091f65a6d6de0827aef031e414..0000000000000000000000000000000000000000 --- 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 -} diff --git a/internal/llm/tools/write.go b/internal/llm/tools/write.go index 9dadc068e5517b4eb07a8c434e4d024d6e5cb78b..95692ba1a20489fdb91cc0618151b0d3c60a0403 100644 --- a/internal/llm/tools/write.go +++ b/internal/llm/tools/write.go @@ -23,8 +23,9 @@ type WriteParams struct { } type WritePermissionsParams struct { - FilePath string `json:"file_path"` - Diff string `json:"diff"` + FilePath string `json:"file_path"` + OldContent string `json:"old_content,omitempty"` + NewContent string `json:"new_content,omitempty"` } type writeTool struct { @@ -174,8 +175,9 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error Action: "write", Description: fmt.Sprintf("Create file %s", filePath), Params: WritePermissionsParams{ - FilePath: filePath, - Diff: diff, + FilePath: filePath, + OldContent: oldContent, + NewContent: params.Content, }, }, ) diff --git a/internal/tui/components/dialogs/permissions/permissions.go b/internal/tui/components/dialogs/permissions/permissions.go index 65908c2fd79b6fb0eb13cbd1cff097bf60a030fc..4d0563b244af45d8640a741bb79baa9007a0ff3c 100644 --- a/internal/tui/components/dialogs/permissions/permissions.go +++ b/internal/tui/components/dialogs/permissions/permissions.go @@ -7,7 +7,6 @@ import ( "github.com/charmbracelet/bubbles/v2/key" "github.com/charmbracelet/bubbles/v2/viewport" tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/crush/internal/diff" "github.com/charmbracelet/crush/internal/fsext" "github.com/charmbracelet/crush/internal/llm/tools" "github.com/charmbracelet/crush/internal/permission" @@ -285,10 +284,13 @@ func (p *permissionDialogCmp) renderEditContent() string { func (p *permissionDialogCmp) renderWriteContent() string { if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok { // Use the cache for diff rendering - diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) { - return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width())) - }) + formatter := core.DiffFormatter(). + Before(fsext.PrettyPath(pr.FilePath), pr.OldContent). + After(fsext.PrettyPath(pr.FilePath), pr.NewContent). + Width(p.contentViewPort.Width()). + Split() + diff := formatter.String() contentHeight := min(p.height-9, lipgloss.Height(diff)) p.contentViewPort.SetHeight(contentHeight) p.contentViewPort.SetContent(diff)