use std::path::Path;
use lsp_types::{GotoDefinitionResponse, Location, Position, Uri};
use crate::core::call_graph::{CallGraphInputs, SymbolSpan};
use crate::lsp::backend::LspBackend;
use crate::lsp::capabilities::{SemanticCapabilities, encode_column};
use crate::lsp::client::{file_path_to_uri, uri_to_file_path};
use crate::lsp::router::{self, StartPolicy};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Resolution {
Resolved {
file: String,
line: usize,
symbol: Option<String>,
},
External,
NoResult,
Ambiguous,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SemanticAnswer {
pub resolution: Resolution,
pub backend: String,
}
pub fn project_key(path: &str, project_root: &str) -> Option<String> {
use crate::core::pathutil::safe_canonicalize;
let root = safe_canonicalize(Path::new(project_root)).ok()?;
let canon = safe_canonicalize(Path::new(path)).ok()?;
canon.strip_prefix(&root).ok()?;
Some(crate::core::index_paths::graph_relative_key(
&canon.to_string_lossy(),
&root.to_string_lossy(),
))
}
pub fn symbol_at<'a>(symbols: &'a [SymbolSpan], file: &str, line: usize) -> Option<&'a SymbolSpan> {
symbols
.iter()
.filter(|s| s.file == file && s.start_line <= line && line <= s.end_line)
.min_by(|a, b| {
(a.end_line - a.start_line, &a.name).cmp(&(b.end_line - b.start_line, &b.name))
})
}
fn locations(resp: GotoDefinitionResponse) -> Vec<Location> {
match resp {
GotoDefinitionResponse::Scalar(l) => vec![l],
GotoDefinitionResponse::Array(v) => v,
GotoDefinitionResponse::Link(links) => links
.into_iter()
.map(|l| Location {
uri: l.target_uri,
range: l.target_selection_range,
})
.collect(),
}
}
pub fn classify(locs: &[Location], project_root: &str, inputs: &CallGraphInputs) -> Resolution {
if locs.is_empty() {
return Resolution::NoResult;
}
let mut in_project: Vec<(String, usize)> = locs
.iter()
.filter_map(|l| {
indexed_key(&l.uri, project_root, inputs)
.map(|key| (key, l.range.start.line as usize + 1))
})
.collect();
if in_project.is_empty() {
return Resolution::External;
}
in_project.sort();
in_project.dedup_by(|a, b| a.0 == b.0);
if in_project.len() > 1 {
return Resolution::Ambiguous;
}
let (file, line) = in_project.swap_remove(0);
let symbol = symbol_at(&inputs.symbols, &file, line).map(|s| s.name.clone());
Resolution::Resolved { file, line, symbol }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Access {
pub policy: StartPolicy,
pub wait: bool,
pub timeout: Option<std::time::Duration>,
pub deadline: Option<std::time::Instant>,
}
impl Access {
fn remaining(&self) -> Option<std::time::Duration> {
self.deadline
.map(|d| d.saturating_duration_since(std::time::Instant::now()))
}
fn request_timeout(&self) -> Option<std::time::Duration> {
match (self.timeout, self.remaining()) {
(Some(cap), Some(left)) => Some(cap.min(left)),
(cap, left) => cap.or(left),
}
}
}
pub fn live_backend_identity(project_root: &str, file: &str) -> router::LiveIdentity {
let live = router::live_identity(
&Path::new(project_root).join(file).to_string_lossy(),
project_root,
);
if live == router::LiveIdentity::NotRunning
&& let Some(bridge) = editor_bridge_for(project_root)
{
return router::LiveIdentity::Known(bridge.identity(project_root));
}
live
}
fn editor_bridge_for(project_root: &str) -> Option<crate::lsp::editor_bridge::BridgeFile> {
if crate::lsp::port_discovery::read_port_file(project_root).is_some() {
return None;
}
crate::lsp::editor_bridge::discover(project_root)
}
pub fn backend_may_answer(project_root: &str, file: &str, policy: StartPolicy) -> bool {
use crate::lsp::port_discovery::{health_ok, pid_alive, read_port_file};
policy == StartPolicy::Lazy
|| read_port_file(project_root).is_some_and(|pf| pid_alive(pf.pid) && health_ok(&pf))
|| live_backend_identity(project_root, file) != router::LiveIdentity::NotRunning
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolveError {
Unavailable(String),
Site(String),
}
fn query_at<T>(
project_root: &str,
file: &str,
pos: (usize, usize),
access: Access,
supported: impl FnOnce(&SemanticCapabilities) -> bool,
op: impl FnOnce(&mut dyn LspBackend, &Uri, Position) -> Result<T, String>,
) -> Result<(String, T, bool), ResolveError> {
let abs = Path::new(project_root).join(file);
let abs_str = abs.to_string_lossy().to_string();
if project_key(&abs_str, project_root).is_none() {
return Err(ResolveError::Site(format!("{file} is outside the project")));
}
let content = std::fs::read_to_string(&abs)
.map_err(|e| ResolveError::Site(format!("read {file}: {e}")))?;
let line_text = content
.lines()
.nth(pos.0.saturating_sub(1))
.ok_or_else(|| ResolveError::Site(format!("{file}: line {} out of range", pos.0)))?;
if access.remaining().is_some_and(|left| left.is_zero()) {
return Err(ResolveError::Site("semantic budget exhausted".into()));
}
let mut reached = false;
let mut unsupported = false;
let run = |b: &mut dyn LspBackend, lang: &str| {
reached = true;
let info = b.backend_info();
if !supported(&info.capabilities) {
unsupported = true;
return Err(format!("{} lacks the required capability", info.identity()));
}
let timeout = access.request_timeout();
if timeout.is_some_and(|t| t.is_zero()) {
return Err("semantic budget exhausted during start-up".into());
}
let uri: Uri = file_path_to_uri(&abs_str)?;
b.open_file(&uri, lang, &content)?;
let position = Position {
line: u32::try_from(pos.0.saturating_sub(1)).unwrap_or(u32::MAX),
character: encode_column(line_text, pos.1, info.utf8_positions),
};
b.set_request_timeout(timeout);
let answer = op(b, &uri, position);
b.set_request_timeout(None);
let truncated = b.last_truncation().is_some_and(|t| t.truncated);
Ok((info.identity(), answer?, truncated))
};
let bridge = (router::live_identity(&abs_str, project_root)
== router::LiveIdentity::NotRunning)
.then(|| editor_bridge_for(project_root))
.flatten();
let result = match bridge {
Some(bridge) => {
let lang = Path::new(file)
.extension()
.and_then(|e| e.to_str())
.and_then(crate::lsp::config::language_for_extension)
.unwrap_or("");
let result = run(&mut bridge.backend(project_root), lang);
if result.is_err() {
crate::lsp::editor_bridge::forget(project_root);
}
result
}
None => router::with_backend_opts(
&abs_str,
project_root,
access.policy,
router::BackendOpts {
wait: access.wait,
start_timeout: access.remaining(),
},
run,
),
};
result.map_err(|e| {
if reached && !unsupported {
ResolveError::Site(e)
} else {
ResolveError::Unavailable(e)
}
})
}
pub fn resolve_definition(
project_root: &str,
caller_file: &str,
pos: (usize, usize),
access: Access,
inputs: &CallGraphInputs,
) -> Result<SemanticAnswer, ResolveError> {
let (backend, resp, _) = query_at(
project_root,
caller_file,
pos,
access,
|c| c.definition,
|b, uri, p| b.definition(uri, p),
)?;
Ok(SemanticAnswer {
resolution: classify(&locations(resp), project_root, inputs),
backend,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RelatedFiles {
pub files: Vec<String>,
pub definitive: bool,
}
pub fn resolve_implementations(
project_root: &str,
file: &str,
pos: (usize, usize),
access: Access,
inputs: &CallGraphInputs,
) -> Result<(String, RelatedFiles), ResolveError> {
let (backend, locs, truncated) = query_at(
project_root,
file,
pos,
access,
|c| c.implementations,
|b, uri, p| b.implementations(uri, p, "project"),
)?;
let files = other_indexed(
locs.iter()
.map(|l| indexed_key(&l.uri, project_root, inputs)),
file,
);
let definitive = !truncated && !files.is_empty();
Ok((backend, RelatedFiles { files, definitive }))
}
fn indexed_key(uri: &Uri, project_root: &str, inputs: &CallGraphInputs) -> Option<String> {
let path = uri_to_file_path(uri)?;
indexed_path(&path, project_root, inputs)
}
fn indexed_path(path: &str, project_root: &str, inputs: &CallGraphInputs) -> Option<String> {
let absolute = if Path::new(path).is_absolute() {
path.to_string()
} else {
Path::new(project_root)
.join(path)
.to_string_lossy()
.to_string()
};
let key = project_key(&absolute, project_root)?.replace('\\', "/");
inputs
.file_paths
.iter()
.find(|f| f.replace('\\', "/") == key)
.cloned()
}
fn other_indexed(paths: impl Iterator<Item = Option<String>>, file: &str) -> Vec<String> {
let set: std::collections::BTreeSet<String> = paths.flatten().filter(|f| f != file).collect();
set.into_iter().collect()
}
pub fn resolve_supertypes(
project_root: &str,
file: &str,
pos: (usize, usize),
access: Access,
inputs: &CallGraphInputs,
) -> Result<(String, RelatedFiles), ResolveError> {
let (backend, node, truncated) = query_at(
project_root,
file,
pos,
access,
|c| c.type_hierarchy,
|b, uri, p| b.type_hierarchy(uri, p, crate::lsp::backend::HierarchyDirection::Supertypes),
)?;
let files = other_indexed(
node.children
.iter()
.map(|c| indexed_path(&c.path, project_root, inputs)),
file,
);
let definitive = !truncated && !node.name.is_empty();
Ok((backend, RelatedFiles { files, definitive }))
}
pub fn resolve_references(
project_root: &str,
file: &str,
pos: (usize, usize),
access: Access,
inputs: &CallGraphInputs,
) -> Result<(String, RelatedFiles), ResolveError> {
let (backend, locs, truncated) = query_at(
project_root,
file,
pos,
access,
|c| c.references,
|b, uri, p| b.references(uri, p, "project"),
)?;
let definitive = !truncated && !locs.is_empty();
let files = other_indexed(
locs.iter()
.map(|l| indexed_key(&l.uri, project_root, inputs)),
file,
);
Ok((backend, RelatedFiles { files, definitive }))
}
#[cfg(test)]
mod tests {
use super::*;
fn loc(path: &std::path::Path, line: u32) -> Location {
Location {
uri: file_path_to_uri(&path.to_string_lossy()).unwrap(),
range: lsp_types::Range {
start: Position { line, character: 0 },
end: Position { line, character: 1 },
},
}
}
#[test]
fn classify_maps_locations_onto_the_indexed_project_only() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
std::fs::create_dir_all(root.join("src")).unwrap();
for f in ["src/repo.rs", "src/other.rs", "src/gen.rs"] {
std::fs::write(root.join(f), "x").unwrap();
}
let outside = tempfile::tempdir().unwrap();
std::fs::write(outside.path().join("std.rs"), "x").unwrap();
let inputs = CallGraphInputs {
project_root: root.to_string_lossy().to_string(),
file_paths: vec!["src/repo.rs".into(), "src/other.rs".into()],
symbols: vec![
SymbolSpan {
file: "src/repo.rs".into(),
name: "Repo".into(),
start_line: 1,
end_line: 20,
..Default::default()
},
SymbolSpan {
file: "src/repo.rs".into(),
name: "save".into(),
start_line: 5,
end_line: 9,
..Default::default()
},
],
..Default::default()
};
let r = root.to_string_lossy().to_string();
assert_eq!(
classify(&[loc(&root.join("src/repo.rs"), 6)], &r, &inputs),
Resolution::Resolved {
file: "src/repo.rs".into(),
line: 7,
symbol: Some("save".into()),
},
"0-based LSP line → 1-based, narrowest enclosing symbol"
);
assert_eq!(
classify(&[loc(&outside.path().join("std.rs"), 0)], &r, &inputs),
Resolution::External
);
assert_eq!(
classify(&[loc(&root.join("src/gen.rs"), 0)], &r, &inputs),
Resolution::External,
"unindexed project files never become graph targets"
);
assert_eq!(
classify(
&[
loc(&root.join("src/repo.rs"), 1),
loc(&root.join("src/other.rs"), 1)
],
&r,
&inputs
),
Resolution::Ambiguous
);
assert_eq!(classify(&[], &r, &inputs), Resolution::NoResult);
}
}