1use std::path::Path;
6
7use lsp_types::{GotoDefinitionResponse, Location, Position, Uri};
8
9use crate::core::call_graph::{CallGraphInputs, SymbolSpan};
10use crate::lsp::backend::LspBackend;
11use crate::lsp::capabilities::{SemanticCapabilities, encode_column};
12use crate::lsp::client::{file_path_to_uri, uri_to_file_path};
13use crate::lsp::router::{self, StartPolicy};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Resolution {
18 Resolved {
20 file: String,
21 line: usize,
23 symbol: Option<String>,
25 },
26 External,
29 NoResult,
31 Ambiguous,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct SemanticAnswer {
38 pub resolution: Resolution,
39 pub backend: String,
40}
41
42pub fn project_key(path: &str, project_root: &str) -> Option<String> {
45 use crate::core::pathutil::safe_canonicalize;
46 let root = safe_canonicalize(Path::new(project_root)).ok()?;
47 let canon = safe_canonicalize(Path::new(path)).ok()?;
48 canon.strip_prefix(&root).ok()?;
49 Some(crate::core::index_paths::graph_relative_key(
50 &canon.to_string_lossy(),
51 &root.to_string_lossy(),
52 ))
53}
54
55pub fn symbol_at<'a>(symbols: &'a [SymbolSpan], file: &str, line: usize) -> Option<&'a SymbolSpan> {
58 symbols
59 .iter()
60 .filter(|s| s.file == file && s.start_line <= line && line <= s.end_line)
61 .min_by(|a, b| {
62 (a.end_line - a.start_line, &a.name).cmp(&(b.end_line - b.start_line, &b.name))
63 })
64}
65
66fn locations(resp: GotoDefinitionResponse) -> Vec<Location> {
67 match resp {
68 GotoDefinitionResponse::Scalar(l) => vec![l],
69 GotoDefinitionResponse::Array(v) => v,
70 GotoDefinitionResponse::Link(links) => links
71 .into_iter()
72 .map(|l| Location {
73 uri: l.target_uri,
74 range: l.target_selection_range,
75 })
76 .collect(),
77 }
78}
79
80pub fn classify(locs: &[Location], project_root: &str, inputs: &CallGraphInputs) -> Resolution {
83 if locs.is_empty() {
84 return Resolution::NoResult;
85 }
86 let mut in_project: Vec<(String, usize)> = locs
87 .iter()
88 .filter_map(|l| {
89 indexed_key(&l.uri, project_root, inputs)
90 .map(|key| (key, l.range.start.line as usize + 1))
91 })
92 .collect();
93 if in_project.is_empty() {
94 return Resolution::External;
95 }
96 in_project.sort();
97 in_project.dedup_by(|a, b| a.0 == b.0);
98 if in_project.len() > 1 {
99 return Resolution::Ambiguous;
100 }
101 let (file, line) = in_project.swap_remove(0);
102 let symbol = symbol_at(&inputs.symbols, &file, line).map(|s| s.name.clone());
103 Resolution::Resolved { file, line, symbol }
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub struct Access {
109 pub policy: StartPolicy,
111 pub wait: bool,
114 pub timeout: Option<std::time::Duration>,
116 pub deadline: Option<std::time::Instant>,
119}
120
121impl Access {
122 fn remaining(&self) -> Option<std::time::Duration> {
124 self.deadline
125 .map(|d| d.saturating_duration_since(std::time::Instant::now()))
126 }
127
128 fn request_timeout(&self) -> Option<std::time::Duration> {
130 match (self.timeout, self.remaining()) {
131 (Some(cap), Some(left)) => Some(cap.min(left)),
132 (cap, left) => cap.or(left),
133 }
134 }
135}
136
137pub fn live_backend_identity(project_root: &str, file: &str) -> router::LiveIdentity {
142 let live = router::live_identity(
143 &Path::new(project_root).join(file).to_string_lossy(),
144 project_root,
145 );
146 if live == router::LiveIdentity::NotRunning
147 && let Some(bridge) = editor_bridge_for(project_root)
148 {
149 return router::LiveIdentity::Known(bridge.identity(project_root));
150 }
151 live
152}
153
154fn editor_bridge_for(project_root: &str) -> Option<crate::lsp::editor_bridge::BridgeFile> {
158 if crate::lsp::port_discovery::read_port_file(project_root).is_some() {
159 return None;
160 }
161 crate::lsp::editor_bridge::discover(project_root)
162}
163
164pub fn backend_may_answer(project_root: &str, file: &str, policy: StartPolicy) -> bool {
168 use crate::lsp::port_discovery::{health_ok, pid_alive, read_port_file};
169 policy == StartPolicy::Lazy
170 || read_port_file(project_root).is_some_and(|pf| pid_alive(pf.pid) && health_ok(&pf))
172 || live_backend_identity(project_root, file) != router::LiveIdentity::NotRunning
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
177pub enum ResolveError {
178 Unavailable(String),
182 Site(String),
184}
185
186fn query_at<T>(
192 project_root: &str,
193 file: &str,
194 pos: (usize, usize),
195 access: Access,
196 supported: impl FnOnce(&SemanticCapabilities) -> bool,
197 op: impl FnOnce(&mut dyn LspBackend, &Uri, Position) -> Result<T, String>,
198) -> Result<(String, T, bool), ResolveError> {
199 let abs = Path::new(project_root).join(file);
200 let abs_str = abs.to_string_lossy().to_string();
201 if project_key(&abs_str, project_root).is_none() {
202 return Err(ResolveError::Site(format!("{file} is outside the project")));
203 }
204 let content = std::fs::read_to_string(&abs)
205 .map_err(|e| ResolveError::Site(format!("read {file}: {e}")))?;
206 let line_text = content
207 .lines()
208 .nth(pos.0.saturating_sub(1))
209 .ok_or_else(|| ResolveError::Site(format!("{file}: line {} out of range", pos.0)))?;
210
211 if access.remaining().is_some_and(|left| left.is_zero()) {
212 return Err(ResolveError::Site("semantic budget exhausted".into()));
213 }
214
215 let mut reached = false;
218 let mut unsupported = false;
219 let run = |b: &mut dyn LspBackend, lang: &str| {
220 reached = true;
221 let info = b.backend_info();
222 if !supported(&info.capabilities) {
223 unsupported = true;
224 return Err(format!("{} lacks the required capability", info.identity()));
225 }
226 let timeout = access.request_timeout();
228 if timeout.is_some_and(|t| t.is_zero()) {
229 return Err("semantic budget exhausted during start-up".into());
230 }
231 let uri: Uri = file_path_to_uri(&abs_str)?;
232 b.open_file(&uri, lang, &content)?;
233 let position = Position {
234 line: u32::try_from(pos.0.saturating_sub(1)).unwrap_or(u32::MAX),
235 character: encode_column(line_text, pos.1, info.utf8_positions),
236 };
237 b.set_request_timeout(timeout);
238 let answer = op(b, &uri, position);
239 b.set_request_timeout(None);
240 let truncated = b.last_truncation().is_some_and(|t| t.truncated);
241 Ok((info.identity(), answer?, truncated))
242 };
243 let bridge = (router::live_identity(&abs_str, project_root)
246 == router::LiveIdentity::NotRunning)
247 .then(|| editor_bridge_for(project_root))
248 .flatten();
249 let result = match bridge {
250 Some(bridge) => {
251 let lang = Path::new(file)
252 .extension()
253 .and_then(|e| e.to_str())
254 .and_then(crate::lsp::config::language_for_extension)
255 .unwrap_or("");
256 let result = run(&mut bridge.backend(project_root), lang);
257 if result.is_err() {
258 crate::lsp::editor_bridge::forget(project_root);
260 }
261 result
262 }
263 None => router::with_backend_opts(
264 &abs_str,
265 project_root,
266 access.policy,
267 router::BackendOpts {
268 wait: access.wait,
269 start_timeout: access.remaining(),
270 },
271 run,
272 ),
273 };
274 result.map_err(|e| {
275 if reached && !unsupported {
276 ResolveError::Site(e)
277 } else {
278 ResolveError::Unavailable(e)
279 }
280 })
281}
282
283pub fn resolve_definition(
286 project_root: &str,
287 caller_file: &str,
288 pos: (usize, usize),
289 access: Access,
290 inputs: &CallGraphInputs,
291) -> Result<SemanticAnswer, ResolveError> {
292 let (backend, resp, _) = query_at(
293 project_root,
294 caller_file,
295 pos,
296 access,
297 |c| c.definition,
298 |b, uri, p| b.definition(uri, p),
299 )?;
300 Ok(SemanticAnswer {
301 resolution: classify(&locations(resp), project_root, inputs),
302 backend,
303 })
304}
305
306#[derive(Debug, Clone, PartialEq, Eq)]
311pub struct RelatedFiles {
312 pub files: Vec<String>,
313 pub definitive: bool,
314}
315
316pub fn resolve_implementations(
320 project_root: &str,
321 file: &str,
322 pos: (usize, usize),
323 access: Access,
324 inputs: &CallGraphInputs,
325) -> Result<(String, RelatedFiles), ResolveError> {
326 let (backend, locs, truncated) = query_at(
327 project_root,
328 file,
329 pos,
330 access,
331 |c| c.implementations,
332 |b, uri, p| b.implementations(uri, p, "project"),
333 )?;
334 let files = other_indexed(
335 locs.iter()
336 .map(|l| indexed_key(&l.uri, project_root, inputs)),
337 file,
338 );
339 let definitive = !truncated && !files.is_empty();
340 Ok((backend, RelatedFiles { files, definitive }))
341}
342
343fn indexed_key(uri: &Uri, project_root: &str, inputs: &CallGraphInputs) -> Option<String> {
345 let path = uri_to_file_path(uri)?;
346 indexed_path(&path, project_root, inputs)
347}
348
349fn indexed_path(path: &str, project_root: &str, inputs: &CallGraphInputs) -> Option<String> {
352 let absolute = if Path::new(path).is_absolute() {
353 path.to_string()
354 } else {
355 Path::new(project_root)
356 .join(path)
357 .to_string_lossy()
358 .to_string()
359 };
360 let key = project_key(&absolute, project_root)?.replace('\\', "/");
361 inputs
362 .file_paths
363 .iter()
364 .find(|f| f.replace('\\', "/") == key)
365 .cloned()
366}
367
368fn other_indexed(paths: impl Iterator<Item = Option<String>>, file: &str) -> Vec<String> {
370 let set: std::collections::BTreeSet<String> = paths.flatten().filter(|f| f != file).collect();
371 set.into_iter().collect()
372}
373
374pub fn resolve_supertypes(
379 project_root: &str,
380 file: &str,
381 pos: (usize, usize),
382 access: Access,
383 inputs: &CallGraphInputs,
384) -> Result<(String, RelatedFiles), ResolveError> {
385 let (backend, node, truncated) = query_at(
386 project_root,
387 file,
388 pos,
389 access,
390 |c| c.type_hierarchy,
391 |b, uri, p| b.type_hierarchy(uri, p, crate::lsp::backend::HierarchyDirection::Supertypes),
392 )?;
393 let files = other_indexed(
394 node.children
395 .iter()
396 .map(|c| indexed_path(&c.path, project_root, inputs)),
397 file,
398 );
399 let definitive = !truncated && !node.name.is_empty();
400 Ok((backend, RelatedFiles { files, definitive }))
401}
402
403pub fn resolve_references(
409 project_root: &str,
410 file: &str,
411 pos: (usize, usize),
412 access: Access,
413 inputs: &CallGraphInputs,
414) -> Result<(String, RelatedFiles), ResolveError> {
415 let (backend, locs, truncated) = query_at(
416 project_root,
417 file,
418 pos,
419 access,
420 |c| c.references,
421 |b, uri, p| b.references(uri, p, "project"),
422 )?;
423 let definitive = !truncated && !locs.is_empty();
424 let files = other_indexed(
425 locs.iter()
426 .map(|l| indexed_key(&l.uri, project_root, inputs)),
427 file,
428 );
429 Ok((backend, RelatedFiles { files, definitive }))
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 fn loc(path: &std::path::Path, line: u32) -> Location {
437 Location {
438 uri: file_path_to_uri(&path.to_string_lossy()).unwrap(),
439 range: lsp_types::Range {
440 start: Position { line, character: 0 },
441 end: Position { line, character: 1 },
442 },
443 }
444 }
445
446 #[test]
447 fn classify_maps_locations_onto_the_indexed_project_only() {
448 let dir = tempfile::tempdir().unwrap();
449 let root = dir.path().canonicalize().unwrap();
450 std::fs::create_dir_all(root.join("src")).unwrap();
451 for f in ["src/repo.rs", "src/other.rs", "src/gen.rs"] {
452 std::fs::write(root.join(f), "x").unwrap();
453 }
454 let outside = tempfile::tempdir().unwrap();
455 std::fs::write(outside.path().join("std.rs"), "x").unwrap();
456
457 let inputs = CallGraphInputs {
458 project_root: root.to_string_lossy().to_string(),
459 file_paths: vec!["src/repo.rs".into(), "src/other.rs".into()],
461 symbols: vec![
462 SymbolSpan {
463 file: "src/repo.rs".into(),
464 name: "Repo".into(),
465 start_line: 1,
466 end_line: 20,
467 ..Default::default()
468 },
469 SymbolSpan {
470 file: "src/repo.rs".into(),
471 name: "save".into(),
472 start_line: 5,
473 end_line: 9,
474 ..Default::default()
475 },
476 ],
477 ..Default::default()
478 };
479 let r = root.to_string_lossy().to_string();
480
481 assert_eq!(
482 classify(&[loc(&root.join("src/repo.rs"), 6)], &r, &inputs),
483 Resolution::Resolved {
484 file: "src/repo.rs".into(),
485 line: 7,
486 symbol: Some("save".into()),
487 },
488 "0-based LSP line → 1-based, narrowest enclosing symbol"
489 );
490 assert_eq!(
491 classify(&[loc(&outside.path().join("std.rs"), 0)], &r, &inputs),
492 Resolution::External
493 );
494 assert_eq!(
495 classify(&[loc(&root.join("src/gen.rs"), 0)], &r, &inputs),
496 Resolution::External,
497 "unindexed project files never become graph targets"
498 );
499 assert_eq!(
500 classify(
501 &[
502 loc(&root.join("src/repo.rs"), 1),
503 loc(&root.join("src/other.rs"), 1)
504 ],
505 &r,
506 &inputs
507 ),
508 Resolution::Ambiguous
509 );
510 assert_eq!(classify(&[], &r, &inputs), Resolution::NoResult);
511 }
512}