Skip to main content

lean_ctx/core/semantic/
resolve.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Asking a semantic backend where a call goes, and mapping the answer back
3//! onto the project graph.
4
5use 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/// Outcome of one semantic definition lookup.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Resolution {
18    /// Exactly one indexed project file defines the callee.
19    Resolved {
20        file: String,
21        /// 1-based line of the definition.
22        line: usize,
23        /// Narrowest indexed symbol containing that line.
24        symbol: Option<String>,
25    },
26    /// The definition lives outside the indexed project (std, dependency,
27    /// generated or vendored code).
28    External,
29    /// The backend found no definition.
30    NoResult,
31    /// Definitions in several project files (e.g. an unresolved overload set).
32    Ambiguous,
33}
34
35/// A [`Resolution`] plus the identity of the backend that produced it.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct SemanticAnswer {
38    pub resolution: Resolution,
39    pub backend: String,
40}
41
42/// Project-relative graph key of `path` if — and only if — it lies inside
43/// `project_root` (both canonicalized, so `..` and symlinks cannot escape).
44pub 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
55/// Narrowest indexed symbol in `file` whose span contains `line` (1-based).
56/// Ties break by name so the answer is deterministic.
57pub 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
80/// Maps backend locations onto the indexed project. Deterministic: candidate
81/// files are compared as a sorted set, and the earliest line wins.
82pub 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/// How a semantic query reaches its backend.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub struct Access {
109    /// Whether a server that is not running may be started.
110    pub policy: StartPolicy,
111    /// `false`: fail fast instead of waiting for a backend busy with another
112    /// call — opportunistic work never delays interactive tools.
113    pub wait: bool,
114    /// Per-request cap (`None` = backend default).
115    pub timeout: Option<std::time::Duration>,
116    /// Absolute end of the caller's budget. Bounds a server start-up *and* —
117    /// recomputed after it — the request, so start-up time is charged too.
118    pub deadline: Option<std::time::Instant>,
119}
120
121impl Access {
122    /// Time left until the deadline (`None` = unbounded).
123    fn remaining(&self) -> Option<std::time::Duration> {
124        self.deadline
125            .map(|d| d.saturating_duration_since(std::time::Instant::now()))
126    }
127
128    /// Request timeout: the per-request cap, shortened to what is left.
129    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
137/// Identity of the backend that answers for `file`'s language — a registry
138/// peek (see [`router::live_identity`]), else an editor bridge announced for
139/// the project. Used to retire cached answers produced by a different server
140/// or server version.
141pub 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
154/// The editor bridge that answers for `project_root` when the router has no
155/// backend to offer: no JetBrains IDE attached (it would take precedence),
156/// and an editor announced a live bridge for exactly this project.
157fn 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
164/// Whether a semantic question for `file` can get an answer without
165/// starting anything — or may start a server (`eager`). Lets on-demand
166/// callers skip the work of preparing a query that would only fail.
167pub 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        // A port file an IDE crash left behind does not count.
171        || 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/// Why a lookup produced no answer.
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub enum ResolveError {
178    /// No usable backend for this language (none running under
179    /// [`StartPolicy::ReuseOnly`], failed to start, or lacks the feature).
180    /// Holds for every call site of the language in this run.
181    Unavailable(String),
182    /// This site only: unreadable file, timeout, protocol error.
183    Site(String),
184}
185
186/// Runs one positional query against the backend for `file`: confines the
187/// file to the project, syncs its content, encodes the position for the
188/// backend's negotiated encoding, and classifies failures. `supported` checks
189/// the needed capability; `op` performs the request. Returns the backend
190/// identity with the result, and whether the backend capped (truncated) it.
191fn 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    // Set once a backend was handed to us: an error before that point is
216    // about backend availability, not about this site.
217    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        // Recomputed after a possible start-up, which consumed budget too.
227        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    // An editor bridge answers only when the router has nothing running for
244    // this language; it never displaces a live server or IDE.
245    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                // The editor may have closed: rediscover next time.
259                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
283/// Asks the semantic backend for the definition of the callee at `pos`
284/// (`(1-based line, 0-based byte column)`) in `caller_file`.
285pub 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/// A relation answer: the related indexed files (the asking file excluded,
307/// sorted, deduplicated), and whether the answer is complete. Only a
308/// complete answer may be cached and may settle the asking file — a capped
309/// list, or nothing from a server still indexing, may not.
310#[derive(Debug, Clone, PartialEq, Eq)]
311pub struct RelatedFiles {
312    pub files: Vec<String>,
313    pub definitive: bool,
314}
315
316/// Indexed project files implementing the trait/interface declared at `pos`
317/// in `file`. An empty answer is not definitive: a cold server reports none
318/// while it indexes.
319pub 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
343/// Index spelling of the project file behind `uri`, if it is indexed.
344fn 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
349/// Index spelling of `path` — absolute, or relative to the project root as
350/// IDE bridges report it — if it is an indexed project file.
351fn 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
368/// Sorted, deduplicated indexed files other than `file`.
369fn 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
374/// Indexed project files declaring a direct supertype of the type declared
375/// at `pos` in `file`. Not definitive when the backend could not place a
376/// type there (a cold or still-indexing server) or capped the tree; a type
377/// with no supertypes in the project is a definitive empty answer.
378pub 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
403/// Indexed project files that reference the symbol declared at `pos` in
404/// `file` (its own file excluded). The answer includes the declaration
405/// itself, so an empty one means the server had nothing yet; an answer
406/// naming only `file` is definitive: nothing else uses the symbol. A capped
407/// list is never definitive.
408pub 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            // gen.rs exists on disk but is not indexed.
460            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}