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 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
179pub 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 }
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); 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); }
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 #[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 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 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 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}