krait/commands/
workspace_edit.rs1use std::path::{Path, PathBuf};
2
3use anyhow::Context;
4use serde_json::Value;
5
6pub fn apply_workspace_edit(edit: &Value, project_root: &Path) -> anyhow::Result<Vec<PathBuf>> {
16 let file_edits = collect_file_edits(edit);
17
18 let mut modified = Vec::new();
19 for (path, edits) in file_edits {
20 if edits.is_empty() {
21 continue;
22 }
23 let abs_path = if path.is_absolute() {
24 path.clone()
25 } else {
26 project_root.join(&path)
27 };
28 apply_text_edits_to_file(&abs_path, &edits)
29 .with_context(|| format!("failed to apply edits to {}", abs_path.display()))?;
30 modified.push(abs_path);
31 }
32
33 Ok(modified)
34}
35
36fn collect_file_edits(edit: &Value) -> Vec<(PathBuf, Vec<Value>)> {
38 let mut result: Vec<(PathBuf, Vec<Value>)> = Vec::new();
39
40 if let Some(doc_changes) = edit.get("documentChanges").and_then(Value::as_array) {
41 for change in doc_changes {
42 if let Some(edits_arr) = change.get("edits").and_then(Value::as_array) {
44 let uri = change
45 .pointer("/textDocument/uri")
46 .and_then(Value::as_str)
47 .unwrap_or_default();
48 result.push((uri_to_path(uri), edits_arr.clone()));
49 }
50 }
52 } else if let Some(changes) = edit.get("changes").and_then(Value::as_object) {
53 for (uri, edits_val) in changes {
54 let edits = edits_val.as_array().cloned().unwrap_or_default();
55 result.push((uri_to_path(uri), edits));
56 }
57 }
58
59 result
60}
61
62fn uri_to_path(uri: &str) -> PathBuf {
63 let path = uri.strip_prefix("file://").unwrap_or(uri);
64 PathBuf::from(path)
65}
66
67fn apply_text_edits_to_file(path: &Path, edits: &[Value]) -> anyhow::Result<()> {
72 let content = std::fs::read_to_string(path)
73 .with_context(|| format!("failed to read {}", path.display()))?;
74
75 let trailing_newline = content.ends_with('\n');
76 let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
77
78 let mut sorted: Vec<&Value> = edits.iter().collect();
80 sorted.sort_by(|a, b| {
81 let al = a
82 .pointer("/range/start/line")
83 .and_then(Value::as_u64)
84 .unwrap_or(0);
85 let bl = b
86 .pointer("/range/start/line")
87 .and_then(Value::as_u64)
88 .unwrap_or(0);
89 let ac = a
90 .pointer("/range/start/character")
91 .and_then(Value::as_u64)
92 .unwrap_or(0);
93 let bc = b
94 .pointer("/range/start/character")
95 .and_then(Value::as_u64)
96 .unwrap_or(0);
97 bl.cmp(&al).then(bc.cmp(&ac))
98 });
99
100 for edit in sorted {
101 #[allow(clippy::cast_possible_truncation)]
102 let start_line = edit
103 .pointer("/range/start/line")
104 .and_then(Value::as_u64)
105 .unwrap_or(0) as usize;
106 #[allow(clippy::cast_possible_truncation)]
107 let start_char = edit
108 .pointer("/range/start/character")
109 .and_then(Value::as_u64)
110 .unwrap_or(0) as usize;
111 #[allow(clippy::cast_possible_truncation)]
112 let end_line = edit
113 .pointer("/range/end/line")
114 .and_then(Value::as_u64)
115 .unwrap_or(0) as usize;
116 #[allow(clippy::cast_possible_truncation)]
117 let end_char = edit
118 .pointer("/range/end/character")
119 .and_then(Value::as_u64)
120 .unwrap_or(0) as usize;
121 let new_text = edit.get("newText").and_then(Value::as_str).unwrap_or("");
122
123 apply_single_edit(
124 &mut lines, start_line, start_char, end_line, end_char, new_text,
125 );
126 }
127
128 let mut new_content = lines.join("\n");
129 if trailing_newline && !new_content.ends_with('\n') {
130 new_content.push('\n');
131 }
132
133 let tmp = path.with_extension("tmp");
135 std::fs::write(&tmp, &new_content)
136 .with_context(|| format!("failed to write temp file: {}", tmp.display()))?;
137 std::fs::rename(&tmp, path).map_err(|e| {
138 let _ = std::fs::remove_file(&tmp);
139 anyhow::anyhow!("failed to rename temp file to {}: {e}", path.display())
140 })?;
141
142 Ok(())
143}
144
145fn apply_single_edit(
146 lines: &mut Vec<String>,
147 start_line: usize,
148 start_char: usize,
149 end_line: usize,
150 end_char: usize,
151 new_text: &str,
152) {
153 while lines.len() <= end_line {
155 lines.push(String::new());
156 }
157
158 if start_line == end_line {
159 let line = &lines[start_line];
160 let byte_start = char_offset_to_byte(line, start_char);
161 let byte_end = char_offset_to_byte(line, end_char);
162 let mut combined = line[..byte_start].to_string();
163 combined.push_str(new_text);
164 combined.push_str(&line[byte_end..]);
165
166 if new_text.contains('\n') {
167 let new_lines: Vec<String> = combined.lines().map(str::to_string).collect();
168 lines.splice(start_line..=start_line, new_lines);
169 } else {
170 lines[start_line] = combined;
171 }
172 } else {
173 let prefix = {
175 let l = &lines[start_line];
176 let b = char_offset_to_byte(l, start_char);
177 l[..b].to_string()
178 };
179 let suffix = {
180 let l = &lines[end_line];
181 let b = char_offset_to_byte(l, end_char);
182 l[b..].to_string()
183 };
184 let combined = format!("{prefix}{new_text}{suffix}");
185 let new_lines: Vec<String> = combined.lines().map(str::to_string).collect();
186 lines.splice(start_line..=end_line, new_lines);
187 }
188}
189
190fn char_offset_to_byte(s: &str, char_offset: usize) -> usize {
192 s.char_indices()
193 .nth(char_offset)
194 .map_or(s.len(), |(i, _)| i)
195}
196
197pub fn count_workspace_edits(edit: &Value) -> usize {
199 let mut count = 0usize;
200 if let Some(doc_changes) = edit.get("documentChanges").and_then(Value::as_array) {
201 for change in doc_changes {
202 if let Some(edits) = change.get("edits").and_then(Value::as_array) {
203 count += edits.len();
204 }
205 }
206 } else if let Some(changes) = edit.get("changes").and_then(Value::as_object) {
207 for edits_val in changes.values() {
208 if let Some(edits) = edits_val.as_array() {
209 count += edits.len();
210 }
211 }
212 }
213 count
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn apply_single_line_edit() {
222 let dir = tempfile::tempdir().unwrap();
223 let path = dir.path().join("test.rs");
224 std::fs::write(&path, "fn hello() {}\nfn world() {}\n").unwrap();
225
226 let edit = serde_json::json!({
227 "changes": {
228 format!("file://{}", path.display()): [
229 {
230 "range": {
231 "start": {"line": 0, "character": 3},
232 "end": {"line": 0, "character": 8}
233 },
234 "newText": "greet"
235 }
236 ]
237 }
238 });
239
240 let modified = apply_workspace_edit(&edit, dir.path()).unwrap();
241 assert_eq!(modified.len(), 1);
242 let content = std::fs::read_to_string(&path).unwrap();
243 assert!(content.contains("fn greet() {}"));
244 }
245
246 #[test]
247 fn apply_multi_line_edit() {
248 let dir = tempfile::tempdir().unwrap();
249 let path = dir.path().join("test.rs");
250 std::fs::write(&path, "fn a() {\n let x = 1;\n}\n").unwrap();
251
252 let edit = serde_json::json!({
253 "changes": {
254 format!("file://{}", path.display()): [
255 {
256 "range": {
257 "start": {"line": 0, "character": 0},
258 "end": {"line": 2, "character": 1}
259 },
260 "newText": "fn b() {}"
261 }
262 ]
263 }
264 });
265
266 apply_workspace_edit(&edit, dir.path()).unwrap();
267 let content = std::fs::read_to_string(&path).unwrap();
268 assert!(content.contains("fn b() {}"));
269 }
270
271 #[test]
272 fn count_workspace_edits_changes() {
273 let edit = serde_json::json!({
274 "changes": {
275 "file:///a.rs": [{"range": {}, "newText": "x"}, {"range": {}, "newText": "y"}],
276 "file:///b.rs": [{"range": {}, "newText": "z"}],
277 }
278 });
279 assert_eq!(count_workspace_edits(&edit), 3);
280 }
281
282 #[test]
283 fn count_workspace_edits_document_changes() {
284 let edit = serde_json::json!({
285 "documentChanges": [
286 {
287 "textDocument": {"uri": "file:///a.rs"},
288 "edits": [{"range": {}, "newText": "x"}]
289 }
290 ]
291 });
292 assert_eq!(count_workspace_edits(&edit), 1);
293 }
294}