use crate::lsp::backend::{EditResult, RangeEdit, TextRange0Based};
pub fn offset_of(content: &str, line: u32, character: u32) -> Result<usize, String> {
let mut offset = 0usize;
let mut cur_line = 0u32;
for l in content.split_inclusive('\n') {
if cur_line == line {
let line_len = l.trim_end_matches('\n').len();
if character as usize > line_len {
return Err(format!(
"POSITION_OUT_OF_RANGE: character {character} past end of line {line}"
));
}
return Ok(offset + character as usize);
}
offset += l.len();
cur_line += 1;
}
if line == cur_line && character == 0 {
return Ok(offset);
}
Err(format!(
"POSITION_OUT_OF_RANGE: line {line} past end of file"
))
}
pub fn local_range_write(edit: &RangeEdit) -> Result<EditResult, String> {
let content = std::fs::read_to_string(&edit.abs_path)
.map_err(|e| format!("FILE_NOT_FOUND: {}: {e}", edit.abs_path))?;
let start = offset_of(&content, edit.range.start_line, edit.range.start_char)?;
let end = offset_of(&content, edit.range.end_line, edit.range.end_char)?;
if end < start {
return Err("POSITION_OUT_OF_RANGE: end before start".to_string());
}
let old = &content[start..end];
if let Some(expected) = edit.expected_hash.as_deref() {
let actual = crate::core::hasher::hash_hex(old.as_bytes());
if expected != actual {
return Err(format!(
"CONFLICT: range hash mismatch (expected={expected}, actual={actual})"
));
}
}
let mut new_content = String::with_capacity(content.len() - old.len() + edit.text.len());
new_content.push_str(&content[..start]);
new_content.push_str(&edit.text);
new_content.push_str(&content[end..]);
write_file_atomic(&edit.abs_path, &new_content)?;
let new_range = range_after_write(&content[..start], &edit.text);
Ok(EditResult {
applied: true,
new_range,
edited_text: edit.text.clone(),
diff: build_range_diff(&edit.rel_path, old, &edit.text),
})
}
fn nearest_git_root(abs_path: &str) -> Option<String> {
let mut dir = std::path::Path::new(abs_path).parent();
while let Some(d) = dir {
if d.join(".git").exists() {
return Some(d.to_string_lossy().to_string());
}
dir = d.parent();
}
None
}
pub fn overview_from_index(abs_path: &str) -> Vec<crate::lsp::backend::SymbolOverviewItem> {
use crate::core::graph_provider;
let Some(project_root) = nearest_git_root(abs_path) else {
return Vec::new();
};
let Some(open) = graph_provider::open_or_build(&project_root) else {
return Vec::new();
};
let rel = abs_path
.strip_prefix(&project_root)
.map_or(abs_path, |s| s.trim_start_matches('/'));
let mut items: Vec<_> = open
.provider
.find_symbols("", Some(rel), None)
.into_iter()
.map(|s| crate::lsp::backend::SymbolOverviewItem {
name: s.name,
kind: s.kind,
line: s.start_line as u32,
})
.collect();
items.sort_by_key(|i| i.line);
items
}
fn range_after_write(prefix: &str, text: &str) -> TextRange0Based {
let (sl, sc) = line_col_at_end(prefix);
let (dl, dc) = line_col_at_end(text);
let end_line = sl + dl;
let end_char = if dl == 0 { sc + dc } else { dc };
TextRange0Based {
start_line: sl,
start_char: sc,
end_line,
end_char,
}
}
fn line_col_at_end(s: &str) -> (u32, u32) {
let line = s.matches('\n').count() as u32;
let col = match s.rfind('\n') {
Some(i) => (s.len() - i - 1) as u32,
None => s.len() as u32,
};
(line, col)
}
fn build_range_diff(path: &str, old: &str, new: &str) -> String {
let mut out = format!("--- {path}\n");
for l in old.lines() {
out.push_str(&format!("- {l}\n"));
}
for l in new.lines() {
out.push_str(&format!("+ {l}\n"));
}
out
}
fn write_file_atomic(path: &str, content: &str) -> Result<(), String> {
let p = std::path::Path::new(path);
let parent = p
.parent()
.ok_or_else(|| "invalid path (no parent directory)".to_string())?;
let filename = p
.file_name()
.ok_or_else(|| "invalid path (no filename)".to_string())?
.to_string_lossy();
let pid = std::process::id();
let tmp = parent.join(format!(".{filename}.lean-ctx.v2a.tmp.{pid}"));
std::fs::write(&tmp, content.as_bytes())
.map_err(|e| format!("cannot write {}: {e}", tmp.display()))?;
std::fs::rename(&tmp, p).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
format!("atomic write failed: {e}")
})
}
pub struct HeadlessBackend;
impl crate::lsp::backend::LspBackend for HeadlessBackend {
fn open_file(&mut self, _u: &lsp_types::Uri, _l: &str, _t: &str) -> Result<(), String> {
Ok(())
}
fn references(
&mut self,
_u: &lsp_types::Uri,
_p: lsp_types::Position,
_s: &str,
) -> Result<Vec<lsp_types::Location>, String> {
Err("references requires a backend".into())
}
fn definition(
&mut self,
_u: &lsp_types::Uri,
_p: lsp_types::Position,
) -> Result<lsp_types::GotoDefinitionResponse, String> {
Err("definition requires a backend".into())
}
fn implementations(
&mut self,
_u: &lsp_types::Uri,
_p: lsp_types::Position,
_s: &str,
) -> Result<Vec<lsp_types::Location>, String> {
Err("implementations requires a backend".into())
}
fn rename(
&mut self,
_u: &lsp_types::Uri,
_p: lsp_types::Position,
_n: &str,
) -> Result<Option<lsp_types::WorkspaceEdit>, String> {
Err("rename requires a backend".into())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn offset_of_maps_lines_and_columns() {
let s = "ab\ncde\nf";
assert_eq!(offset_of(s, 0, 0).unwrap(), 0);
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); }
#[test]
fn offset_of_one_past_last_line_is_eof() {
let s = "ab\ncde\n";
assert_eq!(offset_of(s, 2, 0).unwrap(), s.len());
}
#[test]
fn offset_of_rejects_overrun() {
let s = "ab\ncde";
assert!(offset_of(s, 0, 5).is_err());
assert!(offset_of(s, 9, 0).is_err());
}
fn tmp_file(content: &str) -> (tempfile::TempDir, String) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Foo.txt");
std::fs::write(&path, content).unwrap();
(dir, path.to_string_lossy().to_string())
}
fn edit(abs: &str, r: TextRange0Based, text: &str, hash: Option<String>) -> RangeEdit {
RangeEdit {
abs_path: abs.to_string(),
rel_path: "Foo.txt".to_string(),
range: r,
text: text.to_string(),
expected_hash: hash,
}
}
#[test]
fn local_range_write_replaces_range() {
let (_d, p) = tmp_file("aaa\nBODY\nccc\n");
let r = TextRange0Based {
start_line: 1,
start_char: 0,
end_line: 1,
end_char: 4,
};
let res = local_range_write(&edit(&p, r, "NEW", None)).unwrap();
assert!(res.applied);
assert_eq!(std::fs::read_to_string(&p).unwrap(), "aaa\nNEW\nccc\n");
assert_eq!(res.edited_text, "NEW");
}
#[test]
fn overview_from_index_is_empty_without_graph() {
let items = overview_from_index("/nonexistent/Nope.rs");
assert!(items.is_empty());
}
#[test]
fn local_range_write_zero_width_insert() {
let (_d, p) = tmp_file("aaa\nccc\n");
let r = TextRange0Based {
start_line: 1,
start_char: 0,
end_line: 1,
end_char: 0,
};
local_range_write(&edit(&p, r, "bbb\n", None)).unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "aaa\nbbb\nccc\n");
}
#[test]
fn local_range_write_hash_match_and_mismatch() {
let (_d, p) = tmp_file("aaa\nBODY\nccc\n");
let r = TextRange0Based {
start_line: 1,
start_char: 0,
end_line: 1,
end_char: 4,
};
let good = crate::core::hasher::hash_hex(b"BODY");
local_range_write(&edit(&p, r, "XXXX", Some(good))).unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "aaa\nXXXX\nccc\n");
let err = local_range_write(&edit(&p, r, "YYYY", Some("deadbeef".into()))).unwrap_err();
assert!(err.starts_with("CONFLICT"), "got: {err}");
assert_eq!(std::fs::read_to_string(&p).unwrap(), "aaa\nXXXX\nccc\n");
}
}