1package lsp
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "sort"
10 "strings"
11)
12
13const maxWorkspaceEditFileBytes = 8 * 1024 * 1024
14
15// ApplyWorkspaceEdit applies only text edits inside root. It rejects create,
16// rename, delete, non-file URIs, symlink escapes, malformed ranges, and files
17// larger than the bounded safety limit.
18func ApplyWorkspaceEdit(root string, edit WorkspaceEdit) error {
19 return applyWorkspaceEdit(root, edit, nil)
20}
21
22func applyWorkspaceEdit(root string, edit WorkspaceEdit, checkWritePath func(string) error) error {
23 root, err := filepath.Abs(root)
24 if err != nil {
25 return err
26 }
27 rootReal, err := filepath.EvalSymlinks(root)
28 if err != nil {
29 return fmt.Errorf("workspace root: %w", err)
30 }
31 all := make(map[string][]TextEdit, len(edit.Changes)+len(edit.DocumentChanges))
32 for uri, changes := range edit.Changes {
33 all[uri] = append(all[uri], changes...)
34 }
35 for _, raw := range edit.DocumentChanges {
36 var change struct {
37 TextDocument struct {
38 URI string `json:"uri"`
39 } `json:"textDocument"`
40 Edits []TextEdit `json:"edits"`
41 Kind string `json:"kind"`
42 }
43 if err := json.Unmarshal(raw, &change); err != nil {
44 return fmt.Errorf("workspace edit document change: %w", err)
45 }
46 if change.Kind != "" || change.TextDocument.URI == "" {
47 return fmt.Errorf("workspace edit contains an unsupported resource operation")
48 }
49 all[change.TextDocument.URI] = append(all[change.TextDocument.URI], change.Edits...)
50 }
51 targets, err := prepareEditTargets(rootReal, all, checkWritePath)
52 if err != nil {
53 return err
54 }
55 type plannedEdit struct {
56 location editTarget
57 output []byte
58 mode os.FileMode
59 }
60 plans := make([]plannedEdit, 0, len(targets))
61 for _, location := range targets {
62 if len(location.changes) == 0 {
63 continue
64 }
65 output, mode, err := prepareTextEdits(location.target, location.changes)
66 if err != nil {
67 return fmt.Errorf("validate edit to %s: %w", location.path, err)
68 }
69 plans = append(plans, plannedEdit{location: location, output: output, mode: mode})
70 }
71 for _, plan := range plans {
72 if err := writeTextEdits(plan.location.target, plan.output, plan.mode); err != nil {
73 return fmt.Errorf("apply edit to %s: %w", plan.location.path, err)
74 }
75 }
76 return nil
77}
78
79type editTarget struct {
80 path string
81 target string
82 changes []TextEdit
83}
84
85func prepareEditTargets(rootReal string, all map[string][]TextEdit, checkWritePath func(string) error) ([]editTarget, error) {
86 targets := make([]editTarget, 0, len(all))
87 for uri, changes := range all {
88 path, err := uriToPath(uri)
89 if err != nil {
90 return nil, err
91 }
92 if err := safeWorkspacePath(rootReal, path); err != nil {
93 return nil, err
94 }
95 target, err := filepath.EvalSymlinks(path)
96 if err != nil {
97 return nil, fmt.Errorf("workspace edit target %q: %w", path, err)
98 }
99 if err := safeWorkspacePath(rootReal, target); err != nil {
100 return nil, err
101 }
102 if checkWritePath != nil {
103 if err := checkWritePath(target); err != nil {
104 return nil, err
105 }
106 }
107 targets = append(targets, editTarget{path: path, target: target, changes: changes})
108 }
109 return targets, nil
110}
111
112func safeWorkspacePath(root, path string) error {
113 abs, err := filepath.Abs(path)
114 if err != nil {
115 return err
116 }
117 candidate := abs
118 if resolved, err := filepath.EvalSymlinks(abs); err == nil {
119 candidate = resolved
120 } else {
121 parent := filepath.Dir(abs)
122 if resolvedParent, parentErr := filepath.EvalSymlinks(parent); parentErr == nil {
123 candidate = filepath.Join(resolvedParent, filepath.Base(abs))
124 }
125 }
126 rel, err := filepath.Rel(root, candidate)
127 if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
128 return fmt.Errorf("workspace edit path %q is outside workspace", path)
129 }
130 return nil
131}
132
133type byteEdit struct {
134 start int
135 end int
136 text string
137}
138
139func prepareTextEdits(path string, edits []TextEdit) ([]byte, os.FileMode, error) {
140 info, err := os.Stat(path)
141 if err != nil {
142 return nil, 0, err
143 }
144 if info.Size() > maxWorkspaceEditFileBytes {
145 return nil, 0, fmt.Errorf("file is larger than %d bytes", maxWorkspaceEditFileBytes)
146 }
147 data, err := os.ReadFile(path)
148 if err != nil {
149 return nil, 0, err
150 }
151 converted := make([]byteEdit, 0, len(edits))
152 for _, edit := range edits {
153 start, err := positionOffset(data, edit.Range.Start)
154 if err != nil {
155 return nil, 0, err
156 }
157 end, err := positionOffset(data, edit.Range.End)
158 if err != nil {
159 return nil, 0, err
160 }
161 if end < start {
162 return nil, 0, fmt.Errorf("edit range ends before it starts")
163 }
164 converted = append(converted, byteEdit{start: start, end: end, text: edit.NewText})
165 }
166 sort.Slice(converted, func(i, j int) bool {
167 if converted[i].start != converted[j].start {
168 return converted[i].start > converted[j].start
169 }
170 return converted[i].end > converted[j].end
171 })
172 for i := 1; i < len(converted); i++ {
173 if converted[i].end > converted[i-1].start {
174 return nil, 0, fmt.Errorf("workspace text edits overlap")
175 }
176 }
177 var builder bytes.Buffer
178 cursor := 0
179 for i := len(converted) - 1; i >= 0; i-- {
180 edit := converted[i]
181 _, _ = builder.Write(data[cursor:edit.start])
182 _, _ = builder.WriteString(edit.text)
183 cursor = edit.end
184 }
185 _, _ = builder.Write(data[cursor:])
186 return builder.Bytes(), info.Mode().Perm(), nil
187}
188
189func writeTextEdits(path string, out []byte, mode os.FileMode) error {
190 tmp, err := os.CreateTemp(filepath.Dir(path), ".zut-lsp-edit-*")
191 if err != nil {
192 return err
193 }
194 tmpName := tmp.Name()
195 defer func() { _ = os.Remove(tmpName) }()
196 if err := tmp.Chmod(mode); err != nil {
197 _ = tmp.Close()
198 return err
199 }
200 if _, err := tmp.Write(out); err != nil {
201 _ = tmp.Close()
202 return err
203 }
204 if err := tmp.Sync(); err != nil {
205 _ = tmp.Close()
206 return err
207 }
208 if err := tmp.Close(); err != nil {
209 return err
210 }
211 return os.Rename(tmpName, path)
212}
213
214func positionOffset(data []byte, position Position) (int, error) {
215 if position.Line < 0 || position.Character < 0 {
216 return 0, fmt.Errorf("invalid LSP position %d:%d", position.Line, position.Character)
217 }
218 lineStart := 0
219 line := 0
220 for line < position.Line {
221 next := strings.IndexByte(string(data[lineStart:]), '\n')
222 if next < 0 {
223 return 0, fmt.Errorf("LSP line %d is outside file", position.Line)
224 }
225 lineStart += next + 1
226 line++
227 }
228 lineEnd := len(data)
229 if next := strings.IndexByte(string(data[lineStart:]), '\n'); next >= 0 {
230 lineEnd = lineStart + next
231 }
232 lineBytes := data[lineStart:lineEnd]
233 if len(lineBytes) > 0 && lineBytes[len(lineBytes)-1] == '\r' {
234 lineBytes = lineBytes[:len(lineBytes)-1]
235 lineEnd--
236 }
237 units := 0
238 for offset, r := range string(lineBytes) {
239 if units == position.Character {
240 return lineStart + offset, nil
241 }
242 if r > 0xffff {
243 units += 2
244 } else {
245 units++
246 }
247 }
248 if units == position.Character {
249 return lineEnd, nil
250 }
251 return 0, fmt.Errorf("LSP character %d is outside line %d", position.Character, position.Line)
252}