Skip to main content

krait/commands/
workspace_edit.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::Context;
4use serde_json::Value;
5
6/// Apply a JSON `WorkspaceEdit` to files on disk atomically.
7///
8/// Handles both `changes` (old form: `{uri: [TextEdit]}`) and
9/// `documentChanges` (new form with `TextDocumentEdit`).
10///
11/// Returns the list of absolute paths that were modified.
12///
13/// # Errors
14/// Returns an error if any file cannot be read or written.
15pub 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
36/// Collect `(absolute_path, Vec<TextEdit>)` pairs from any `WorkspaceEdit` format.
37fn 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            // TextDocumentEdit: has `textDocument` and `edits`
43            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            // CreateFile/RenameFile/DeleteFile: skip (unsupported for now)
51        }
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
67/// Apply LSP `TextEdit` list to a file on disk.
68///
69/// Reads the file, applies edits in reverse position order (to preserve offsets),
70/// then writes back atomically via a temp file + rename.
71fn 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    // Sort in reverse order: last position first so earlier positions stay valid
79    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    // Atomic write: temp file + rename
134    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    // Extend lines if needed
154    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        // Multi-line replacement
174        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
190/// Convert a UTF-16 character offset to a byte offset in `s`.
191fn 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
197/// Count the total number of `TextEdit` entries across all files in a `WorkspaceEdit`.
198pub 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}