Skip to main content

lean_ctx/core/semantic/
relations.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Symbol-level relations from a semantic backend, lifted to file edges:
3//!
4//! - `implements`: implementation file → trait/interface file
5//!   (`textDocument/implementation` on the trait/interface),
6//! - `extends`: subtype file → supertype file (`typeHierarchy/supertypes`),
7//! - `references`: referencing file → declaring file (`textDocument/references`).
8//!
9//! Tree-sitter sees `impl Store for Pg`, `class Pg extends Base` or a use of
10//! `Store`, but cannot bind the name to its declaration across modules; the
11//! backend can. `implements` and `extends` run in the background pass;
12//! `references` costs one request per symbol and runs on demand for the one
13//! file `ctx_impact` analyses.
14
15use std::collections::{BTreeSet, HashMap};
16use std::time::{Duration, Instant};
17
18use crate::core::call_graph::{CallGraphInputs, SymbolSpan};
19use crate::core::config::SemanticMode;
20use crate::core::property_graph::{CachedResolution, CodeGraph, EdgeKind};
21use crate::lsp::router::{LiveIdentity, StartPolicy};
22
23use super::resolve::{
24    Access, RelatedFiles, ResolveError, live_backend_identity, resolve_implementations,
25    resolve_references, resolve_supertypes,
26};
27
28/// A symbol-level relation a backend can enumerate.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Relation {
31    Implements,
32    Extends,
33    References,
34}
35
36impl Relation {
37    /// Cache operation key (`semantic_resolutions.op`).
38    fn op(self) -> &'static str {
39        match self {
40            Self::Implements => "implementations",
41            Self::Extends => "supertypes",
42            Self::References => "references",
43        }
44    }
45
46    /// Symbol kinds the relation is asked for.
47    fn kinds(self) -> &'static [&'static str] {
48        match self {
49            Self::Implements => &["trait", "interface"],
50            Self::Extends => &["class", "struct", "interface", "trait"],
51            Self::References => &[
52                "fn",
53                "method",
54                "class",
55                "struct",
56                "enum",
57                "interface",
58                "trait",
59                "type",
60                "const",
61                "macro",
62            ],
63        }
64    }
65
66    /// Graph edge kind the relation is stored as.
67    pub fn edge_kind(self) -> EdgeKind {
68        match self {
69            Self::Implements => EdgeKind::Implements,
70            Self::Extends => EdgeKind::Extends,
71            Self::References => EdgeKind::References,
72        }
73    }
74
75    /// `true`: edges point from the declaring file to the found files
76    /// (subtype → supertype); otherwise from the found files to it.
77    fn outgoing(self) -> bool {
78        self == Self::Extends
79    }
80}
81
82/// How much one pass may ask a backend.
83#[derive(Debug, Clone, Copy)]
84pub struct RelationBudget {
85    pub max_live_queries: usize,
86    pub wall: Duration,
87    pub per_request: Duration,
88}
89
90impl RelationBudget {
91    /// Background graph pass.
92    pub const BACKGROUND: Self = Self {
93        max_live_queries: 100,
94        wall: Duration::from_secs(10),
95        per_request: Duration::from_secs(5),
96    };
97    /// One file, while a tool call waits (`ctx_impact`).
98    pub const INTERACTIVE: Self = Self {
99        max_live_queries: 40,
100        wall: Duration::from_secs(8),
101        per_request: Duration::from_secs(3),
102    };
103}
104
105/// A file-level relation edge, oriented as stored in the graph.
106#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
107pub struct RelationEdge {
108    pub from: String,
109    pub to: String,
110    pub backend: String,
111}
112
113/// Result of one relation pass.
114#[derive(Debug, Clone, Default, PartialEq, Eq)]
115pub struct RelationPass {
116    /// Sorted, deduplicated edges found this pass.
117    pub edges: Vec<RelationEdge>,
118    /// Declaring files whose every queried symbol got a definitive answer.
119    /// Only their earlier edges may be pruned; files left unanswered
120    /// (backend unavailable, budget, cold server) keep theirs.
121    pub settled: BTreeSet<String>,
122    /// Symbols asked live this pass, and answered from the cache.
123    pub live_queries: usize,
124    pub cache_hits: usize,
125    /// Symbols that got any answer (live or cached, complete or not). Zero
126    /// means the pass learned nothing — e.g. a file declaring no symbol of
127    /// the relation's kinds is settled without a single question.
128    pub answered: usize,
129}
130
131impl RelationPass {
132    /// Whether this pass may withdraw the earlier edge `from → to`: only
133    /// when the declaring file was settled.
134    pub fn may_prune(&self, relation: Relation, from: &str, to: &str) -> bool {
135        let declaring = if relation.outgoing() { from } else { to };
136        self.settled.contains(declaring)
137    }
138}
139
140/// 0-based byte column of `name` as a whole identifier in `line`.
141fn name_column(line: &str, name: &str) -> Option<usize> {
142    let is_ident = |c: char| c.is_alphanumeric() || c == '_';
143    line.match_indices(name).map(|(i, _)| i).find(|&i| {
144        let before = line[..i].chars().next_back();
145        let after = line[i + name.len()..].chars().next();
146        !before.is_some_and(is_ident) && !after.is_some_and(is_ident)
147    })
148}
149
150fn declaration_position(project_root: &str, sym: &SymbolSpan) -> Option<(usize, usize)> {
151    let content =
152        std::fs::read_to_string(std::path::Path::new(project_root).join(&sym.file)).ok()?;
153    let line = content.lines().nth(sym.start_line.checked_sub(1)?)?;
154    Some((sym.start_line, name_column(line, &sym.name)?))
155}
156
157/// Revision of the whole indexed project. Related files can appear or vanish
158/// anywhere without the declaring file changing, so a cached answer is valid
159/// only for the exact project revision it was computed on.
160fn project_revision(file_hashes: &HashMap<String, String>) -> String {
161    let mut entries: Vec<String> = file_hashes
162        .iter()
163        .map(|(f, h)| format!("{f}\t{h}"))
164        .collect();
165    entries.sort_unstable();
166    crate::core::hasher::hash_str(&entries.join("\n"))
167}
168
169/// What a cached related-file list is worth now.
170#[derive(Debug, PartialEq, Eq)]
171enum RelCache {
172    Reuse(Vec<String>),
173    Stale,
174    /// The live backend is busy and cannot vouch for the entry: leave the
175    /// symbol unsettled this run, without querying.
176    Defer,
177}
178
179/// Cached related files (newline-separated in `target_file`), if the entry is
180/// still valid for this project revision and no *different* backend is live.
181fn related_from_cache(hit: &CachedResolution, revision: &str, live: &LiveIdentity) -> RelCache {
182    if hit.outcome != "resolved" || hit.context != revision {
183        return RelCache::Stale;
184    }
185    match live {
186        LiveIdentity::Known(id) if *id != hit.backend => RelCache::Stale,
187        LiveIdentity::Busy => RelCache::Defer,
188        LiveIdentity::Known(_) | LiveIdentity::NotRunning => RelCache::Reuse(
189            hit.target_file
190                .as_deref()
191                .unwrap_or("")
192                .lines()
193                .map(str::to_string)
194                .collect(),
195        ),
196    }
197}
198
199/// One live question. A non-definitive answer (capped, or nothing from a
200/// server still indexing) still contributes the files it names — each is a
201/// verified relation — but is neither cached nor allowed to settle the file.
202fn query(
203    relation: Relation,
204    project_root: &str,
205    file: &str,
206    pos: (usize, usize),
207    access: Access,
208    inputs: &CallGraphInputs,
209) -> Result<(String, RelatedFiles), ResolveError> {
210    match relation {
211        Relation::Implements => resolve_implementations(project_root, file, pos, access, inputs),
212        Relation::Extends => resolve_supertypes(project_root, file, pos, access, inputs),
213        Relation::References => resolve_references(project_root, file, pos, access, inputs),
214    }
215}
216
217/// Resolves `relation` for the indexed symbols of the relation's kinds — all
218/// files, or only `only_file` — in sorted `(file, line, name)` order under
219/// `budget`.
220#[allow(clippy::too_many_arguments)] // one pass = graph, project, mode, scope, budget
221pub fn resolve_relation_edges(
222    graph: &CodeGraph,
223    project_root: &str,
224    inputs: &CallGraphInputs,
225    file_hashes: &HashMap<String, String>,
226    mode: SemanticMode,
227    relation: Relation,
228    only_file: Option<&str>,
229    budget: RelationBudget,
230) -> RelationPass {
231    let mut symbols: Vec<&SymbolSpan> = inputs
232        .symbols
233        .iter()
234        .filter(|s| relation.kinds().contains(&s.kind.as_str()))
235        .filter(|s| only_file.is_none_or(|f| s.file == f))
236        .filter(|s| {
237            std::path::Path::new(&s.file)
238                .extension()
239                .and_then(|e| e.to_str())
240                .and_then(crate::lsp::config::language_for_extension)
241                .is_some()
242        })
243        .collect();
244    // Every file in scope starts settled — including one that no longer
245    // declares any such symbol, so its stale edges are withdrawn. Symbols
246    // that get no definitive answer below unsettle their file.
247    let scope: BTreeSet<String> = match only_file {
248        Some(f) => inputs
249            .file_paths
250            .iter()
251            .filter(|p| *p == f)
252            .cloned()
253            .collect(),
254        None => inputs.file_paths.iter().cloned().collect(),
255    };
256    if mode == SemanticMode::Off {
257        // A deliberate choice, not missing evidence: everything is settled.
258        return RelationPass {
259            settled: scope,
260            ..RelationPass::default()
261        };
262    }
263    symbols.sort_by(|a, b| (&a.file, a.start_line, &a.name).cmp(&(&b.file, b.start_line, &b.name)));
264
265    let access = Access {
266        policy: if mode == SemanticMode::Eager {
267            StartPolicy::Lazy
268        } else {
269            StartPolicy::ReuseOnly
270        },
271        wait: false,
272        timeout: Some(budget.per_request),
273        deadline: None,
274    };
275    let revision = project_revision(file_hashes);
276    // One storage-admission snapshot for every answer this pass keeps.
277    let admission = crate::core::context_admission::stores::StoreAdmission::current();
278    let started = Instant::now();
279    let mut pass = RelationPass::default();
280    let mut live_identity: HashMap<String, LiveIdentity> = HashMap::new();
281    let mut unavailable: BTreeSet<String> = BTreeSet::new();
282    let mut edges: BTreeSet<RelationEdge> = BTreeSet::new();
283    let mut unsettled: BTreeSet<String> = BTreeSet::new();
284
285    for sym in symbols {
286        let (Some(hash), Some(pos)) = (
287            file_hashes.get(&sym.file),
288            declaration_position(project_root, sym),
289        ) else {
290            unsettled.insert(sym.file.clone());
291            continue;
292        };
293        let site = (sym.file.as_str(), pos.0, pos.1, relation.op());
294        let ext = std::path::Path::new(&sym.file)
295            .extension()
296            .and_then(|e| e.to_str())
297            .unwrap_or("")
298            .to_string();
299
300        let mut cache = RelCache::Stale;
301        let mut cached_backend = String::new();
302        if let Ok(Some(hit)) = graph.semantic_lookup(site, hash) {
303            if live_identity
304                .get(&ext)
305                .is_none_or(|l| *l == LiveIdentity::Busy)
306            {
307                live_identity.insert(ext.clone(), live_backend_identity(project_root, &sym.file));
308            }
309            let live_id = live_identity.get(&ext).unwrap_or(&LiveIdentity::NotRunning);
310            cache = related_from_cache(&hit, &revision, live_id);
311            cached_backend = hit.backend;
312        }
313        // `(backend, files, definitive)`; `None` = no answer at all.
314        let found = match cache {
315            RelCache::Reuse(files) => {
316                pass.cache_hits += 1;
317                Some((cached_backend, files, true))
318            }
319            RelCache::Defer => None,
320            RelCache::Stale => {
321                let remaining = budget.wall.saturating_sub(started.elapsed());
322                if unavailable.contains(&ext)
323                    || pass.live_queries >= budget.max_live_queries
324                    || remaining.is_zero()
325                {
326                    None
327                } else {
328                    pass.live_queries += 1;
329                    let access = Access {
330                        deadline: Some(started + budget.wall),
331                        ..access
332                    };
333                    match query(relation, project_root, &sym.file, pos, access, inputs) {
334                        Ok((backend, related)) => {
335                            if related.definitive {
336                                let _ = graph.semantic_store(
337                                    site,
338                                    hash,
339                                    &CachedResolution {
340                                        backend: backend.clone(),
341                                        outcome: "resolved".into(),
342                                        target_file: Some(related.files.join("\n")),
343                                        target_line: None,
344                                        target_symbol: Some(sym.name.clone()),
345                                        context: revision.clone(),
346                                    },
347                                    &admission,
348                                );
349                            }
350                            live_identity.insert(ext, LiveIdentity::Known(backend.clone()));
351                            Some((backend, related.files, related.definitive))
352                        }
353                        Err(ResolveError::Unavailable(_)) => {
354                            unavailable.insert(ext);
355                            None
356                        }
357                        Err(ResolveError::Site(_)) => None,
358                    }
359                }
360            }
361        };
362
363        let Some((backend, files, definitive)) = found else {
364            unsettled.insert(sym.file.clone());
365            continue;
366        };
367        pass.answered += 1;
368        if !definitive {
369            unsettled.insert(sym.file.clone());
370        }
371        // Every named file is a verified relation, even from a partial answer.
372        for other in files.into_iter().filter(|f| *f != sym.file) {
373            let (from, to) = if relation.outgoing() {
374                (sym.file.clone(), other)
375            } else {
376                (other, sym.file.clone())
377            };
378            edges.insert(RelationEdge {
379                from,
380                to,
381                backend: backend.clone(),
382            });
383        }
384    }
385    pass.edges = edges.into_iter().collect();
386    pass.settled = scope.difference(&unsettled).cloned().collect();
387    tracing::debug!(
388        target: "lean_ctx::semantic",
389        "{}: {} live, {} cached, {} edges, {}/{} files settled in {} ms",
390        relation.op(),
391        pass.live_queries,
392        pass.cache_hits,
393        pass.edges.len(),
394        pass.settled.len(),
395        scope.len(),
396        started.elapsed().as_millis()
397    );
398    pass
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn declaration_name_is_found_as_a_whole_identifier() {
407        assert_eq!(
408            name_column("pub trait Store: StoreBase {", "Store"),
409            Some(10)
410        );
411        assert_eq!(
412            name_column("interface IStore extends Store {", "Store"),
413            Some(25)
414        );
415        assert_eq!(name_column("trait Storage {", "Store"), None);
416    }
417
418    /// Related files can change anywhere: a cached answer is bound to the
419    /// project revision and to the backend that produced it.
420    #[test]
421    fn cached_relations_are_valid_only_for_their_revision_and_backend() {
422        let hit = CachedResolution {
423            backend: "lsp:ra@1".into(),
424            outcome: "resolved".into(),
425            target_file: Some("mem.rs\npg.rs".into()),
426            target_line: None,
427            target_symbol: Some("Store".into()),
428            context: "rev-1".into(),
429        };
430        let files = RelCache::Reuse(vec!["mem.rs".to_string(), "pg.rs".to_string()]);
431        let known = |id: &str| LiveIdentity::Known(id.into());
432        let none = LiveIdentity::NotRunning;
433        assert_eq!(related_from_cache(&hit, "rev-1", &none), files);
434        assert_eq!(related_from_cache(&hit, "rev-1", &known("lsp:ra@1")), files);
435        assert_eq!(
436            related_from_cache(&hit, "rev-2", &none),
437            RelCache::Stale,
438            "any file changed"
439        );
440        assert_eq!(
441            related_from_cache(&hit, "rev-1", &known("lsp:ra@2")),
442            RelCache::Stale
443        );
444        assert_eq!(
445            related_from_cache(&hit, "rev-1", &LiveIdentity::Busy),
446            RelCache::Defer
447        );
448
449        let a = HashMap::from([("a.rs".to_string(), "1".to_string())]);
450        let b = HashMap::from([("a.rs".to_string(), "2".to_string())]);
451        assert_ne!(project_revision(&a), project_revision(&b));
452    }
453
454    /// Only the declaring side of an edge decides whether it may be pruned:
455    /// the trait/interface for `implements`/`references`, the subtype for
456    /// `extends`.
457    #[test]
458    fn pruning_is_decided_by_the_declaring_file() {
459        let pass = RelationPass {
460            settled: BTreeSet::from(["decl.rs".to_string()]),
461            ..RelationPass::default()
462        };
463        assert!(pass.may_prune(Relation::Implements, "impl.rs", "decl.rs"));
464        assert!(pass.may_prune(Relation::References, "user.rs", "decl.rs"));
465        assert!(!pass.may_prune(Relation::References, "decl.rs", "other.rs"));
466        assert!(pass.may_prune(Relation::Extends, "decl.rs", "base.rs"));
467        assert!(!pass.may_prune(Relation::Extends, "sub.rs", "decl.rs"));
468    }
469}