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    let parent = p
160        .parent()
161        .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
162    let filename = p
163        .file_name()
164        .ok_or_else(|| "invalid path (no filename)".to_string())?
165        .to_string_lossy();
166    let pid = std::process::id();
167    let tmp = parent.join(format!(".{filename}.lean-ctx.v2a.tmp.{pid}"));
168    std::fs::write(&tmp, content.as_bytes())
169        .map_err(|e| format!("cannot write {}: {e}", tmp.display()))?;
170    std::fs::rename(&tmp, p).map_err(|e| {
171        let _ = std::fs::remove_file(&tmp);
172        format!("atomic write failed: {e}")
173    })
174}
175
176/// Zero-dependency backend that carries only the Trait default-apply for the
177/// three edit methods (used by ctx_refactor when no IDE is reachable). The five
178/// mandatory read methods are unsupported here (edits never call them).
179pub struct HeadlessBackend;
180
181impl crate::lsp::backend::LspBackend for HeadlessBackend {
182    fn open_file(&mut self, _u: &lsp_types::Uri, _l: &str, _t: &str) -> Result<(), String> {
183        Ok(())
184    }
185    fn references(
186        &mut self,
187        _u: &lsp_types::Uri,
188        _p: lsp_types::Position,
189        _s: &str,
190    ) -> Result<Vec<lsp_types::Location>, String> {
191        Err("references requires a backend".into())
192    }
193    fn definition(
194        &mut self,
195        _u: &lsp_types::Uri,
196        _p: lsp_types::Position,
197    ) -> Result<lsp_types::GotoDefinitionResponse, String> {
198        Err("definition requires a backend".into())
199    }
200    fn implementations(
201        &mut self,
202        _u: &lsp_types::Uri,
203        _p: lsp_types::Position,
204        _s: &str,
205    ) -> Result<Vec<lsp_types::Location>, String> {
206        Err("implementations requires a backend".into())
207    }
208    fn rename(
209        &mut self,
210        _u: &lsp_types::Uri,
211        _p: lsp_types::Position,
212        _n: &str,
213    ) -> Result<Option<lsp_types::WorkspaceEdit>, String> {
214        Err("rename requires a backend".into())
215    }
216    // replace_symbol_body / insert_before_symbol / insert_after_symbol inherit
217    // the Trait default → local_range_write.
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn offset_of_maps_lines_and_columns() {
226        let s = "ab\ncde\nf";
227        assert_eq!(offset_of(s, 0, 0).unwrap(), 0);
228        assert_eq!(offset_of(s, 0, 2).unwrap(), 2); // end of "ab"
229        assert_eq!(offset_of(s, 1, 0).unwrap(), 3); // start of "cde"
230        assert_eq!(offset_of(s, 1, 3).unwrap(), 6); // end of "cde"
231        assert_eq!(offset_of(s, 2, 1).unwrap(), 8); // end of "f"
232    }
233
234    #[test]
235    fn offset_of_one_past_last_line_is_eof() {
236        let s = "ab\ncde\n";
237        assert_eq!(offset_of(s, 2, 0).unwrap(), s.len());
238    }
239
240    #[test]
241    fn offset_of_rejects_overrun() {
242        let s = "ab\ncde";
243        assert!(offset_of(s, 0, 5).is_err());
244        assert!(offset_of(s, 9, 0).is_err());
245    }
246
247    fn tmp_file(content: &str) -> (tempfile::TempDir, String) {
248        let dir = tempfile::tempdir().unwrap();
249        let path = dir.path().join("Foo.txt");
250        std::fs::write(&path, content).unwrap();
251        (dir, path.to_string_lossy().to_string())
252    }
253
254    fn edit(abs: &str, r: TextRange0Based, text: &str, hash: Option<String>) -> RangeEdit {
255        RangeEdit {
256            abs_path: abs.to_string(),
257            rel_path: "Foo.txt".to_string(),
258            range: r,
259            text: text.to_string(),
260            expected_hash: hash,
261        }
262    }
263
264    #[test]
265    fn local_range_write_replaces_range() {
266        let (_d, p) = tmp_file("aaa\nBODY\nccc\n");
267        let r = TextRange0Based {
268            start_line: 1,
269            start_char: 0,
270            end_line: 1,
271            end_char: 4,
272        };
273        let res = local_range_write(&edit(&p, r, "NEW", None)).unwrap();
274        assert!(res.applied);
275        assert_eq!(std::fs::read_to_string(&p).unwrap(), "aaa\nNEW\nccc\n");
276        assert_eq!(res.edited_text, "NEW");
277    }
278
279    #[test]
280    fn overview_from_index_is_empty_without_graph() {
281        // A path outside any project root must degrade to empty, not panic.
282        let items = overview_from_index("/nonexistent/Nope.rs");
283        assert!(items.is_empty());
284    }
285
286    #[test]
287    fn local_range_write_zero_width_insert() {
288        let (_d, p) = tmp_file("aaa\nccc\n");
289        let r = TextRange0Based {
290            start_line: 1,
291            start_char: 0,
292            end_line: 1,
293            end_char: 0,
294        };
295        local_range_write(&edit(&p, r, "bbb\n", None)).unwrap();
296        assert_eq!(std::fs::read_to_string(&p).unwrap(), "aaa\nbbb\nccc\n");
297    }
298
299    #[test]
300    fn local_range_write_hash_match_and_mismatch() {
301        let (_d, p) = tmp_file("aaa\nBODY\nccc\n");
302        let r = TextRange0Based {
303            start_line: 1,
304            start_char: 0,
305            end_line: 1,
306            end_char: 4,
307        };
308        let good = crate::core::hasher::hash_hex(b"BODY");
309        // good hash matches current "BODY" → applies, line stays 4 chars wide ("XXXX")
310        local_range_write(&edit(&p, r, "XXXX", Some(good))).unwrap();
311        assert_eq!(std::fs::read_to_string(&p).unwrap(), "aaa\nXXXX\nccc\n");
312        // second write with a stale hash on the still-valid range → CONFLICT, file unchanged
313        let err = local_range_write(&edit(&p, r, "YYYY", Some("deadbeef".into()))).unwrap_err();
314        assert!(err.starts_with("CONFLICT"), "got: {err}");
315        assert_eq!(std::fs::read_to_string(&p).unwrap(), "aaa\nXXXX\nccc\n");
316    }
317}