1package util
2
3import (
4 "bytes"
5 "fmt"
6 "os"
7 "sort"
8 "strings"
9
10 "github.com/charmbracelet/crush/internal/lsp/protocol"
11)
12
13func applyTextEdits(uri protocol.DocumentUri, edits []protocol.TextEdit) error {
14 path := strings.TrimPrefix(string(uri), "file://")
15
16 // Read the file content
17 content, err := os.ReadFile(path)
18 if err != nil {
19 return fmt.Errorf("failed to read file: %w", err)
20 }
21
22 // Detect line ending style
23 var lineEnding string
24 if bytes.Contains(content, []byte("\r\n")) {
25 lineEnding = "\r\n"
26 } else {
27 lineEnding = "\n"
28 }
29
30 // Track if file ends with a newline
31 endsWithNewline := len(content) > 0 && bytes.HasSuffix(content, []byte(lineEnding))
32
33 // Split into lines without the endings
34 lines := strings.Split(string(content), lineEnding)
35
36 // Check for overlapping edits
37 for i, edit1 := range edits {
38 for j := i + 1; j < len(edits); j++ {
39 if rangesOverlap(edit1.Range, edits[j].Range) {
40 return fmt.Errorf("overlapping edits detected between edit %d and %d", i, j)
41 }
42 }
43 }
44
45 // Sort edits in reverse order
46 sortedEdits := make([]protocol.TextEdit, len(edits))
47 copy(sortedEdits, edits)
48 sort.Slice(sortedEdits, func(i, j int) bool {
49 if sortedEdits[i].Range.Start.Line != sortedEdits[j].Range.Start.Line {
50 return sortedEdits[i].Range.Start.Line > sortedEdits[j].Range.Start.Line
51 }
52 return sortedEdits[i].Range.Start.Character > sortedEdits[j].Range.Start.Character
53 })
54
55 // Apply each edit
56 for _, edit := range sortedEdits {
57 newLines, err := applyTextEdit(lines, edit)
58 if err != nil {
59 return fmt.Errorf("failed to apply edit: %w", err)
60 }
61 lines = newLines
62 }
63
64 // Join lines with proper line endings
65 var newContent strings.Builder
66 for i, line := range lines {
67 if i > 0 {
68 newContent.WriteString(lineEnding)
69 }
70 newContent.WriteString(line)
71 }
72
73 // Only add a newline if the original file had one and we haven't already added it
74 if endsWithNewline && !strings.HasSuffix(newContent.String(), lineEnding) {
75 newContent.WriteString(lineEnding)
76 }
77
78 if err := os.WriteFile(path, []byte(newContent.String()), 0o644); err != nil {
79 return fmt.Errorf("failed to write file: %w", err)
80 }
81
82 return nil
83}
84
85func applyTextEdit(lines []string, edit protocol.TextEdit) ([]string, error) {
86 startLine := int(edit.Range.Start.Line)
87 endLine := int(edit.Range.End.Line)
88 startChar := int(edit.Range.Start.Character)
89 endChar := int(edit.Range.End.Character)
90
91 // Validate positions
92 if startLine < 0 || startLine >= len(lines) {
93 return nil, fmt.Errorf("invalid start line: %d", startLine)
94 }
95 if endLine < 0 || endLine >= len(lines) {
96 endLine = len(lines) - 1
97 }
98
99 // Create result slice with initial capacity
100 result := make([]string, 0, len(lines))
101
102 // Copy lines before edit
103 result = append(result, lines[:startLine]...)
104
105 // Get the prefix of the start line
106 startLineContent := lines[startLine]
107 if startChar < 0 || startChar > len(startLineContent) {
108 startChar = len(startLineContent)
109 }
110 prefix := startLineContent[:startChar]
111
112 // Get the suffix of the end line
113 endLineContent := lines[endLine]
114 if endChar < 0 || endChar > len(endLineContent) {
115 endChar = len(endLineContent)
116 }
117 suffix := endLineContent[endChar:]
118
119 // Handle the edit
120 if edit.NewText == "" {
121 if prefix+suffix != "" {
122 result = append(result, prefix+suffix)
123 }
124 } else {
125 // Split new text into lines, being careful not to add extra newlines
126 // newLines := strings.Split(strings.TrimRight(edit.NewText, "\n"), "\n")
127 newLines := strings.Split(edit.NewText, "\n")
128
129 if len(newLines) == 1 {
130 // Single line change
131 result = append(result, prefix+newLines[0]+suffix)
132 } else {
133 // Multi-line change
134 result = append(result, prefix+newLines[0])
135 result = append(result, newLines[1:len(newLines)-1]...)
136 result = append(result, newLines[len(newLines)-1]+suffix)
137 }
138 }
139
140 // Add remaining lines
141 if endLine+1 < len(lines) {
142 result = append(result, lines[endLine+1:]...)
143 }
144
145 return result, nil
146}
147
148// applyDocumentChange applies a DocumentChange (create/rename/delete operations)
149func applyDocumentChange(change protocol.DocumentChange) error {
150 if change.CreateFile != nil {
151 path := strings.TrimPrefix(string(change.CreateFile.URI), "file://")
152 if change.CreateFile.Options != nil {
153 if change.CreateFile.Options.Overwrite {
154 // Proceed with overwrite
155 } else if change.CreateFile.Options.IgnoreIfExists {
156 if _, err := os.Stat(path); err == nil {
157 return nil // File exists and we're ignoring it
158 }
159 }
160 }
161 if err := os.WriteFile(path, []byte(""), 0o644); err != nil {
162 return fmt.Errorf("failed to create file: %w", err)
163 }
164 }
165
166 if change.DeleteFile != nil {
167 path := strings.TrimPrefix(string(change.DeleteFile.URI), "file://")
168 if change.DeleteFile.Options != nil && change.DeleteFile.Options.Recursive {
169 if err := os.RemoveAll(path); err != nil {
170 return fmt.Errorf("failed to delete directory recursively: %w", err)
171 }
172 } else {
173 if err := os.Remove(path); err != nil {
174 return fmt.Errorf("failed to delete file: %w", err)
175 }
176 }
177 }
178
179 if change.RenameFile != nil {
180 oldPath := strings.TrimPrefix(string(change.RenameFile.OldURI), "file://")
181 newPath := strings.TrimPrefix(string(change.RenameFile.NewURI), "file://")
182 if change.RenameFile.Options != nil {
183 if !change.RenameFile.Options.Overwrite {
184 if _, err := os.Stat(newPath); err == nil {
185 return fmt.Errorf("target file already exists and overwrite is not allowed: %s", newPath)
186 }
187 }
188 }
189 if err := os.Rename(oldPath, newPath); err != nil {
190 return fmt.Errorf("failed to rename file: %w", err)
191 }
192 }
193
194 if change.TextDocumentEdit != nil {
195 textEdits := make([]protocol.TextEdit, len(change.TextDocumentEdit.Edits))
196 for i, edit := range change.TextDocumentEdit.Edits {
197 var err error
198 textEdits[i], err = edit.AsTextEdit()
199 if err != nil {
200 return fmt.Errorf("invalid edit type: %w", err)
201 }
202 }
203 return applyTextEdits(change.TextDocumentEdit.TextDocument.URI, textEdits)
204 }
205
206 return nil
207}
208
209// ApplyWorkspaceEdit applies the given WorkspaceEdit to the filesystem
210func ApplyWorkspaceEdit(edit protocol.WorkspaceEdit) error {
211 // Handle Changes field
212 for uri, textEdits := range edit.Changes {
213 if err := applyTextEdits(uri, textEdits); err != nil {
214 return fmt.Errorf("failed to apply text edits: %w", err)
215 }
216 }
217
218 // Handle DocumentChanges field
219 for _, change := range edit.DocumentChanges {
220 if err := applyDocumentChange(change); err != nil {
221 return fmt.Errorf("failed to apply document change: %w", err)
222 }
223 }
224
225 return nil
226}
227
228func rangesOverlap(r1, r2 protocol.Range) bool {
229 if r1.Start.Line > r2.End.Line || r2.Start.Line > r1.End.Line {
230 return false
231 }
232 if r1.Start.Line == r2.End.Line && r1.Start.Character > r2.End.Character {
233 return false
234 }
235 if r2.Start.Line == r1.End.Line && r2.Start.Character > r1.End.Character {
236 return false
237 }
238 return true
239}