1package util
2
3import (
4 "bytes"
5 "fmt"
6 "os"
7 "sort"
8 "strings"
9
10 powernap "github.com/charmbracelet/x/powernap/pkg/lsp"
11 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
12)
13
14func applyTextEdits(uri protocol.DocumentURI, edits []protocol.TextEdit, encoding powernap.OffsetEncoding) error {
15 path, err := uri.Path()
16 if err != nil {
17 return fmt.Errorf("invalid URI: %w", err)
18 }
19
20 // Read the file content
21 content, err := os.ReadFile(path)
22 if err != nil {
23 return fmt.Errorf("failed to read file: %w", err)
24 }
25
26 // Detect line ending style
27 var lineEnding string
28 if bytes.Contains(content, []byte("\r\n")) {
29 lineEnding = "\r\n"
30 } else {
31 lineEnding = "\n"
32 }
33
34 // Track if file ends with a newline
35 endsWithNewline := len(content) > 0 && bytes.HasSuffix(content, []byte(lineEnding))
36
37 // Split into lines without the endings
38 lines := strings.Split(string(content), lineEnding)
39
40 // Check for overlapping edits
41 for i, edit1 := range edits {
42 for j := i + 1; j < len(edits); j++ {
43 if rangesOverlap(edit1.Range, edits[j].Range) {
44 return fmt.Errorf("overlapping edits detected between edit %d and %d", i, j)
45 }
46 }
47 }
48
49 // Sort edits in reverse order
50 sortedEdits := make([]protocol.TextEdit, len(edits))
51 copy(sortedEdits, edits)
52 sort.Slice(sortedEdits, func(i, j int) bool {
53 if sortedEdits[i].Range.Start.Line != sortedEdits[j].Range.Start.Line {
54 return sortedEdits[i].Range.Start.Line > sortedEdits[j].Range.Start.Line
55 }
56 return sortedEdits[i].Range.Start.Character > sortedEdits[j].Range.Start.Character
57 })
58
59 // Apply each edit
60 for _, edit := range sortedEdits {
61 newLines, err := applyTextEdit(lines, edit, encoding)
62 if err != nil {
63 return fmt.Errorf("failed to apply edit: %w", err)
64 }
65 lines = newLines
66 }
67
68 // Join lines with proper line endings
69 var newContent strings.Builder
70 for i, line := range lines {
71 if i > 0 {
72 newContent.WriteString(lineEnding)
73 }
74 newContent.WriteString(line)
75 }
76
77 // Only add a newline if the original file had one and we haven't already added it
78 if endsWithNewline && !strings.HasSuffix(newContent.String(), lineEnding) {
79 newContent.WriteString(lineEnding)
80 }
81
82 if err := os.WriteFile(path, []byte(newContent.String()), 0o644); err != nil {
83 return fmt.Errorf("failed to write file: %w", err)
84 }
85
86 return nil
87}
88
89func applyTextEdit(lines []string, edit protocol.TextEdit, encoding powernap.OffsetEncoding) ([]string, error) {
90 startLine := int(edit.Range.Start.Line)
91 endLine := int(edit.Range.End.Line)
92
93 // Validate positions before accessing lines.
94 if startLine < 0 || startLine >= len(lines) {
95 return nil, fmt.Errorf("invalid start line: %d", startLine)
96 }
97 if endLine < 0 || endLine >= len(lines) {
98 endLine = len(lines) - 1
99 }
100
101 var startChar, endChar int
102 switch encoding {
103 case powernap.UTF8:
104 // UTF-8: Character offset is already a byte offset
105 startChar = int(edit.Range.Start.Character)
106 endChar = int(edit.Range.End.Character)
107 case powernap.UTF16:
108 // UTF-16 (default): Convert to byte offset
109 startLineContent := lines[startLine]
110 endLineContent := lines[endLine]
111 startChar = powernap.PositionToByteOffset(startLineContent, edit.Range.Start.Character)
112 endChar = powernap.PositionToByteOffset(endLineContent, edit.Range.End.Character)
113 default:
114 // UTF-32: Character offset is codepoint count, convert to byte offset
115 startLineContent := lines[startLine]
116 endLineContent := lines[endLine]
117 startChar = utf32ToByteOffset(startLineContent, edit.Range.Start.Character)
118 endChar = utf32ToByteOffset(endLineContent, edit.Range.End.Character)
119 }
120
121 // Create result slice with initial capacity
122 result := make([]string, 0, len(lines))
123
124 // Copy lines before edit
125 result = append(result, lines[:startLine]...)
126
127 // Get the prefix of the start line
128 startLineContent := lines[startLine]
129 if startChar < 0 || startChar > len(startLineContent) {
130 startChar = len(startLineContent)
131 }
132 prefix := startLineContent[:startChar]
133
134 // Get the suffix of the end line
135 endLineContent := lines[endLine]
136 if endChar < 0 || endChar > len(endLineContent) {
137 endChar = len(endLineContent)
138 }
139 suffix := endLineContent[endChar:]
140
141 // Handle the edit
142 if edit.NewText == "" {
143 if prefix+suffix != "" {
144 result = append(result, prefix+suffix)
145 }
146 } else {
147 // Split new text into lines, being careful not to add extra newlines
148 // newLines := strings.Split(strings.TrimRight(edit.NewText, "\n"), "\n")
149 newLines := strings.Split(edit.NewText, "\n")
150
151 if len(newLines) == 1 {
152 // Single line change
153 result = append(result, prefix+newLines[0]+suffix)
154 } else {
155 // Multi-line change
156 result = append(result, prefix+newLines[0])
157 result = append(result, newLines[1:len(newLines)-1]...)
158 result = append(result, newLines[len(newLines)-1]+suffix)
159 }
160 }
161
162 // Add remaining lines
163 if endLine+1 < len(lines) {
164 result = append(result, lines[endLine+1:]...)
165 }
166
167 return result, nil
168}
169
170// applyDocumentChange applies a DocumentChange (create/rename/delete operations)
171func applyDocumentChange(change protocol.DocumentChange, encoding powernap.OffsetEncoding) error {
172 if change.CreateFile != nil {
173 path, err := change.CreateFile.URI.Path()
174 if err != nil {
175 return fmt.Errorf("invalid URI: %w", err)
176 }
177
178 if change.CreateFile.Options != nil {
179 if change.CreateFile.Options.Overwrite {
180 // Proceed with overwrite
181 } else if change.CreateFile.Options.IgnoreIfExists {
182 if _, err := os.Stat(path); err == nil {
183 return nil // File exists and we're ignoring it
184 }
185 }
186 }
187 if err := os.WriteFile(path, []byte(""), 0o644); err != nil {
188 return fmt.Errorf("failed to create file: %w", err)
189 }
190 }
191
192 if change.DeleteFile != nil {
193 path, err := change.DeleteFile.URI.Path()
194 if err != nil {
195 return fmt.Errorf("invalid URI: %w", err)
196 }
197
198 if change.DeleteFile.Options != nil && change.DeleteFile.Options.Recursive {
199 if err := os.RemoveAll(path); err != nil {
200 return fmt.Errorf("failed to delete directory recursively: %w", err)
201 }
202 } else {
203 if err := os.Remove(path); err != nil {
204 return fmt.Errorf("failed to delete file: %w", err)
205 }
206 }
207 }
208
209 if change.RenameFile != nil {
210 var newPath, oldPath string
211 var err error
212
213 oldPath, err = change.RenameFile.OldURI.Path()
214 if err != nil {
215 return err
216 }
217
218 newPath, err = change.RenameFile.NewURI.Path()
219 if err != nil {
220 return err
221 }
222
223 if change.RenameFile.Options != nil {
224 if !change.RenameFile.Options.Overwrite {
225 if _, err := os.Stat(newPath); err == nil {
226 return fmt.Errorf("target file already exists and overwrite is not allowed: %s", newPath)
227 }
228 }
229 }
230 if err := os.Rename(oldPath, newPath); err != nil {
231 return fmt.Errorf("failed to rename file: %w", err)
232 }
233 }
234
235 if change.TextDocumentEdit != nil {
236 textEdits := make([]protocol.TextEdit, len(change.TextDocumentEdit.Edits))
237 for i, edit := range change.TextDocumentEdit.Edits {
238 var err error
239 textEdits[i], err = edit.AsTextEdit()
240 if err != nil {
241 return fmt.Errorf("invalid edit type: %w", err)
242 }
243 }
244 return applyTextEdits(change.TextDocumentEdit.TextDocument.URI, textEdits, encoding)
245 }
246
247 return nil
248}
249
250// utf32ToByteOffset converts a UTF-32 codepoint offset to a byte offset.
251func utf32ToByteOffset(lineText string, codepointOffset uint32) int {
252 if codepointOffset == 0 {
253 return 0
254 }
255
256 var codepointCount uint32
257 for byteOffset := range lineText {
258 if codepointCount >= codepointOffset {
259 return byteOffset
260 }
261 codepointCount++
262 }
263 return len(lineText)
264}
265
266// ApplyWorkspaceEdit applies the given WorkspaceEdit to the filesystem.
267// The encoding parameter specifies the position encoding used by the LSP server
268// (UTF8, UTF16, or UTF32). This affects how character offsets are interpreted.
269func ApplyWorkspaceEdit(edit protocol.WorkspaceEdit, encoding powernap.OffsetEncoding) error {
270 // Handle Changes field
271 for uri, textEdits := range edit.Changes {
272 if err := applyTextEdits(uri, textEdits, encoding); err != nil {
273 return fmt.Errorf("failed to apply text edits: %w", err)
274 }
275 }
276
277 // Handle DocumentChanges field
278 for _, change := range edit.DocumentChanges {
279 if err := applyDocumentChange(change, encoding); err != nil {
280 return fmt.Errorf("failed to apply document change: %w", err)
281 }
282 }
283
284 return nil
285}
286
287// rangesOverlap checks if two LSP ranges overlap.
288// Per the LSP specification, ranges are half-open intervals [start, end),
289// so adjacent ranges where one's end equals another's start do NOT overlap.
290// See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#range
291func rangesOverlap(r1, r2 protocol.Range) bool {
292 if r1.Start.Line > r2.End.Line || r2.Start.Line > r1.End.Line {
293 return false
294 }
295 if r1.Start.Line == r2.End.Line && r1.Start.Character >= r2.End.Character {
296 return false
297 }
298 if r2.Start.Line == r1.End.Line && r2.Start.Character >= r1.End.Character {
299 return false
300 }
301 return true
302}