1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Relation {
31 Implements,
32 Extends,
33 References,
34}
35
36impl Relation {
37 fn op(self) -> &'static str {
39 match self {
40 Self::Implements => "implementations",
41 Self::Extends => "supertypes",
42 Self::References => "references",
43 }
44 }
45
46 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 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 fn outgoing(self) -> bool {
78 self == Self::Extends
79 }
80}
81
82#[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 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 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#[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
115pub struct RelationPass {
116 pub edges: Vec<RelationEdge>,
118 pub settled: BTreeSet<String>,
122 pub live_queries: usize,
124 pub cache_hits: usize,
125 pub answered: usize,
129}
130
131impl RelationPass {
132 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
140fn 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
157fn 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#[derive(Debug, PartialEq, Eq)]
171enum RelCache {
172 Reuse(Vec<String>),
173 Stale,
174 Defer,
177}
178
179fn 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
199fn 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#[allow(clippy::too_many_arguments)] pub 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 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 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 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 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 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 #[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 #[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}