Skip to main content

lean_ctx/lsp/
edit_apply.rs

1//! Shared headless apply path for symbol-body edits (spec v2a §5.1).
2//!
3//! `local_range_write` is the Trait-default for `replace_symbol_body` /
4//! `insert_before_symbol` / `insert_after_symbol`: it writes a resolved range
5//! to disk atomically, so edits work without any running language server / IDE.
6//! `JetBrainsHttpBackend` overrides the Trait methods with the in-IDE HTTP path;
7//! both paths apply the *same* tree-sitter range → byte-identical result.
8
9use crate::lsp::backend::{EditResult, RangeEdit, TextRange0Based};
10
11/// Convert a 0-based (line, character) coordinate to a byte offset into `content`.
12/// `line`/`character` count UTF-8 *bytes* per line (wire convention here is byte
13/// columns, matching how Rust slices `&str`). Out-of-range → `Err`.
14pub fn offset_of(content: &str, line: u32, character: u32) -> Result<usize, String> {
15    let mut offset = 0usize;
16    let mut cur_line = 0u32;
17    for l in content.split_inclusive('\n') {
18        if cur_line == line {
19            let line_len = l.trim_end_matches('\n').len();
20            if character as usize > line_len {
21                return Err(format!(
22                    "POSITION_OUT_OF_RANGE: character {character} past end of line {line}"
23                ));
24            }
25            return Ok(offset + character as usize);
26        }
27        offset += l.len();
28        cur_line += 1;
29    }
30    // Allow the position one past the last line (line == cur_line, character 0):
31    if line == cur_line && character == 0 {
32        return Ok(offset);
33    }
34    Err(format!(
35        "POSITION_OUT_OF_RANGE: line {line} past end of file"
36    ))
37}
38
39/// Apply a resolved `RangeEdit` to disk (headless). Reads the file, optionally
40/// verifies `expected_hash` against the *current* bytes covered by `range`
41/// (mismatch → `CONFLICT`), replaces the range with `text`, writes atomically,
42/// and returns the post-edit range + a compact diff.
43pub fn local_range_write(edit: &RangeEdit) -> Result<EditResult, String> {
44    let content = std::fs::read_to_string(&edit.abs_path)
45        .map_err(|e| format!("FILE_NOT_FOUND: {}: {e}", edit.abs_path))?;
46
47    let start = offset_of(&content, edit.range.start_line, edit.range.start_char)?;
48    let end = offset_of(&content, edit.range.end_line, edit.range.end_char)?;
49    if end < start {
50        return Err("POSITION_OUT_OF_RANGE: end before start".to_string());
51    }
52    let old = &content[start..end];
53
54    if let Some(expected) = edit.expected_hash.as_deref() {
55        let actual = crate::core::hasher::hash_hex(old.as_bytes());
56        if expected != actual {
57            return Err(format!(
58                "CONFLICT: range hash mismatch (expected={expected}, actual={actual})"
59            ));
60        }
61    }
62
63    let mut new_content = String::with_capacity(content.len() - old.len() + edit.text.len());
64    new_content.push_str(&content[..start]);
65    new_content.push_str(&edit.text);
66    new_content.push_str(&content[end..]);
67
68    write_file_atomic(&edit.abs_path, &new_content)?;
69
70    let new_range = range_after_write(&content[..start], &edit.text);
71    Ok(EditResult {
72        applied: true,
73        new_range,
74        edited_text: edit.text.clone(),
75        diff: build_range_diff(&edit.rel_path, old, &edit.text),
76    })
77}
78
79/// Walk up from a file path to the nearest ancestor directory containing `.git`
80/// (best-effort project-root detection; `nearest_project_root` does not exist in
81/// this repo). Returns None if no `.git` ancestor is found.
82fn nearest_git_root(abs_path: &str) -> Option<String> {
83    let mut dir = std::path::Path::new(abs_path).parent();
84    while let Some(d) = dir {
85        if d.join(".git").exists() {
86            return Some(d.to_string_lossy().to_string());
87        }
88        dir = d.parent();
89    }
90    None
91}
92
93/// Build a file's structure overview from the tree-sitter symbol index
94/// (headless `symbols_overview` default, spec v2a §5.2). Best-effort: returns
95/// an empty vec when no graph is available.
96pub fn overview_from_index(abs_path: &str) -> Vec<crate::lsp::backend::SymbolOverviewItem> {
97    use crate::core::graph_provider;
98    let Some(project_root) = nearest_git_root(abs_path) else {
99        return Vec::new();
100    };
101    let Some(open) = graph_provider::open_or_build(&project_root) else {
102        return Vec::new();
103    };
104    let rel = abs_path
105        .strip_prefix(&project_root)
106        .map_or(abs_path, |s| s.trim_start_matches('/'));
107    let mut items: Vec<_> = open
108        .provider
109        .find_symbols("", Some(rel), None)
110        .into_iter()
111        .map(|s| crate::lsp::backend::SymbolOverviewItem {
112            name: s.name,
113            kind: s.kind,
114            line: s.start_line as u32,
115        })
116        .collect();
117    items.sort_by_key(|i| i.line);
118    items
119}
120
121/// Compute the 0-based range the freshly written `text` now occupies, given the
122/// `prefix` (everything before the insertion point).
123fn range_after_write(prefix: &str, text: &str) -> TextRange0Based {
124    let (sl, sc) = line_col_at_end(prefix);
125    let (dl, dc) = line_col_at_end(text);
126    let end_line = sl + dl;
127    let end_char = if dl == 0 { sc + dc } else { dc };
128    TextRange0Based {
129        start_line: sl,
130        start_char: sc,
131        end_line,
132        end_char,
133    }
134}
135
136/// (line, character) of the position *after* the last byte of `s` (0-based).
137fn line_col_at_end(s: &str) -> (u32, u32) {
138    let line = s.matches('\n').count() as u32;
139    let col = match s.rfind('\n') {
140        Some(i) => (s.len() - i - 1) as u32,
141        None => s.len() as u32,
142    };
143    (line, col)
144}
145
146fn build_range_diff(path: &str, old: &str, new: &str) -> String {
147    let mut out = format!("--- {path}\n");
148    for l in old.lines() {
149        out.push_str(&format!("- {l}\n"));
150    }
151    for l in new.lines() {
152        out.push_str(&format!("+ {l}\n"));
153    }
154    out
155}
156
157fn write_file_atomic(path: &str, content: &str) -> Result<(), String> {
158    let p = std::path::Path::new(path);
159    // Read-only-roots choke point (#475): headless ctx_refactor symbol edits
160    // funnel through here — default-deny inside a read-only root.
161    crate::core::pathjail::enforce_writable(p)?;
162    let parent = p
163        .parent()
164        .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
165    let filename = p
166        .file_name()
167        .ok_or_else(|| "invalid path (no filename)".to_string())?
168        .to_string_lossy();
169    let pid = std::process::id();
170    let tmp = parent.join(format!(".{filename}.lean-ctx.v2a.tmp.{pid}"));
171    std::fs::write(&tmp, content.as_bytes())
172        .map_err(|e| format!("cannot write {}: {e}", tmp.display()))?;
173    std::fs::rename(&tmp, p).map_err(|e| {
174        let _ = std::fs::remove_file(&tmp);
175        format!("atomic write failed: {e}")
176    })
177}
178
179/// Zero-dependency backend that carries only the Trait default-apply for the
180/// three edit methods (used by ctx_refactor when no IDE is reachable). The five
181/// mandatory read methods are unsupported here (edits never call them).
182pub struct HeadlessBackend;
183
184impl crate::lsp::backend::LspBackend for HeadlessBackend {
185    fn open_file(&mut self, _u: &lsp_types::Uri, _l: &str, _t: &str) -> Result<(), String> {
186        Ok(())
187    }
188    fn references(
189        &mut self,
190        _u: &lsp_types::Uri,
191        _p: lsp_types::Position,
192        _s: &str,
193    ) -> Result<Vec<lsp_types::Location>, String> {
194        Err("references requires a backend".into())
195    }
196    fn definition(
197        &mut self,
198        _u: &lsp_types::Uri,
199        _p: lsp_types::Position,
200    ) -> Result<lsp_types::GotoDefinitionResponse, String> {
201        Err("definition requires a backend".into())
202    }
203    fn implementations(
204        &mut self,
205        _u: &lsp_types::Uri,
206        _p: lsp_types::Position,
207        _s: &str,
208    ) -> Result<Vec<lsp_types::Location>, String> {
209        Err("implementations requires a backend".into())
210    }
211    fn rename(
212        &mut self,
213        _u: &lsp_types::Uri,
214        _p: lsp_types::Position,
215        _n: &str,
216    ) -> Result<Option<lsp_types::WorkspaceEdit>, String> {
217        Err("rename requires a backend".into())
218    }
219    // replace_symbol_body / insert_before_symbol / insert_after_symbol inherit
220    // the Trait default → local_range_write.
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn offset_of_maps_lines_and_columns() {
229        let s = "ab\ncde\nf";
230        assert_eq!(offset_of(s, 0, 0).unwrap(), 0);
231        assert_eq!(offset_of(s, 0, 2).unwrap(), 2); // end of "ab"
232        assert_eq!(offset_of(s, 1, 0).unwrap(), 3); // start of "cde"
233        assert_eq!(offset_of(s, 1, 3).unwrap(), 6); // end of "cde"
234        assert_eq!(offset_of(s, 2, 1).unwrap(), 8); // end of "f"
235    }
236
237    #[test]
238    fn offset_of_one_past_last_line_is_eof() {
239        let s = "ab\ncde\n";
240        assert_eq!(offset_of(s, 2, 0).unwrap(), s.len());
241    }
242
243    #[test]
244    fn offset_of_rejects_overrun() {
245        let s = "ab\ncde";
246        assert!(offset_of(s, 0, 5).is_err());
247        assert!(offset_of(s, 9, 0).is_err());
248    }
249
250    fn tmp_file(content: &str) -> (tempfile::TempDir, String) {
251        let dir = tempfile::tempdir().unwrap();
252        let path = dir.path().join("Foo.txt");
253        std::fs::write(&path, content).unwrap();
254        (dir, path.to_string_lossy().to_string())
255    }
256
257    fn edit(abs: &str, r: TextRange0Based, text: &str, hash: Option<String>) -> RangeEdit {
258        RangeEdit {
259            abs_path: abs.to_string(),
260            rel_path: "Foo.txt".to_string(),
261            range: r,
262            text: text.to_string(),
263            expected_hash: hash,
264        }
265    }
266
267    #[test]
268    fn local_range_write_replaces_range() {
269        let (_d, p) = tmp_file("aaa\nBODY\nccc\n");
270        let r = TextRange0Based {
271            start_line: 1,
272            start_char: 0,
273            end_line: 1,
274            end_char: 4,
275        };
276        let res = local_range_write(&edit(&p, r, "NEW", None)).unwrap();
277        assert!(res.applied);
278        assert_eq!(std::fs::read_to_string(&p).unwrap(), "aaa\nNEW\nccc\n");
279        assert_eq!(res.edited_text, "NEW");
280    }
281
282    /// #475: the headless symbol-edit write path (`write_file_atomic`) must
283    /// default-deny inside a read-only root, leaving the file untouched.
284    #[cfg(not(feature = "no-jail"))]
285    #[test]
286    fn local_range_write_denied_in_read_only_root() {
287        let _iso = crate::core::data_dir::isolated_data_dir();
288        let dir = tempfile::tempdir().unwrap();
289        let ro = dir.path().join("refrepo");
290        std::fs::create_dir_all(&ro).unwrap();
291        let path = ro.join("Foo.txt");
292        std::fs::write(&path, "aaa\nBODY\nccc\n").unwrap();
293
294        let ro_canon = crate::core::pathjail::canonicalize_or_self(&ro);
295        crate::test_env::set_var(
296            "LEAN_CTX_READ_ONLY_ROOTS",
297            ro_canon.to_string_lossy().as_ref(),
298        );
299        let r = TextRange0Based {
300            start_line: 1,
301            start_char: 0,
302            end_line: 1,
303            end_char: 4,
304        };
305        let res = local_range_write(&edit(&path.to_string_lossy(), r, "NEW", None));
306        crate::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
307
308        let err = res.expect_err("write into a read-only root must be denied");
309        assert!(
310            err.contains("read-only"),
311            "error must name the read-only tier: {err}"
312        );
313        assert_eq!(
314            std::fs::read_to_string(&path).unwrap(),
315            "aaa\nBODY\nccc\n",
316            "the file must be left untouched"
317        );
318    }
319
320    #[test]
321    fn overview_from_index_is_empty_without_graph() {
322        // A path outside any project root must degrade to empty, not panic.
323        let items = overview_from_index("/nonexistent/Nope.rs");
324        assert!(items.is_empty());
325    }
326
327    #[test]
328    fn local_range_write_zero_width_insert() {
329        let (_d, p) = tmp_file("aaa\nccc\n");
330        let r = TextRange0Based {
331            start_line: 1,
332            start_char: 0,
333            end_line: 1,
334            end_char: 0,
335        };
336        local_range_write(&edit(&p, r, "bbb\n", None)).unwrap();
337        assert_eq!(std::fs::read_to_string(&p).unwrap(), "aaa\nbbb\nccc\n");
338    }
339
340    #[test]
341    fn local_range_write_hash_match_and_mismatch() {
342        let (_d, p) = tmp_file("aaa\nBODY\nccc\n");
343        let r = TextRange0Based {
344            start_line: 1,
345            start_char: 0,
346            end_line: 1,
347            end_char: 4,
348        };
349        let good = crate::core::hasher::hash_hex(b"BODY");
350        // good hash matches current "BODY" → applies, line stays 4 chars wide ("XXXX")
351        local_range_write(&edit(&p, r, "XXXX", Some(good))).unwrap();
352        assert_eq!(std::fs::read_to_string(&p).unwrap(), "aaa\nXXXX\nccc\n");
353        // second write with a stale hash on the still-valid range → CONFLICT, file unchanged
354        let err = local_range_write(&edit(&p, r, "YYYY", Some("deadbeef".into()))).unwrap_err();
355        assert!(err.starts_with("CONFLICT"), "got: {err}");
356        assert_eq!(std::fs::read_to_string(&p).unwrap(), "aaa\nXXXX\nccc\n");
357    }
358}