1use crate::lsp::backend::{EditResult, RangeEdit, TextRange0Based};
10
11pub 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 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
39pub 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
79fn 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
93pub 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
121fn 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
136fn 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
176pub 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 }
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); assert_eq!(offset_of(s, 1, 0).unwrap(), 3); assert_eq!(offset_of(s, 1, 3).unwrap(), 6); assert_eq!(offset_of(s, 2, 1).unwrap(), 8); }
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 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 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 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}