edit.go

  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}