1use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
7use crate::callgraph::{self, EdgeResolution, FileCallData};
8use crate::error::AftError;
9use crate::imports::{ImportKind, ImportStatement};
10use crate::parser::LangId;
11use crate::symbols::{Range, SymbolKind};
12use rayon::prelude::*;
13use rusqlite::{params, Connection, OpenFlags, OptionalExtension, Transaction};
14use std::collections::{hash_map::Entry, BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
15use std::fmt;
16use std::path::{Path, PathBuf};
17use std::sync::{Arc, Mutex};
18use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
19
20const SCHEMA_VERSION: i64 = 1;
21const BACKEND_TREESITTER: &str = "treesitter";
22const PROVENANCE_TREESITTER: &str = "treesitter+resolver";
23const PROVENANCE_NAME_MATCH: &str = "name_match";
24const PROVENANCE_TYPE_MATCH: &str = "type_match";
25const NAME_MATCH_SCORE_THRESHOLD: f64 = 2.0;
26const TOP_LEVEL_SYMBOL: &str = "<top-level>";
27const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
28
29type ColdBuildSwapObserver = dyn Fn(&Path, &Path) + Send + Sync + 'static;
30thread_local! {
37 static COLD_BUILD_SWAP_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSwapObserver>>> =
38 const { std::cell::RefCell::new(None) };
39}
40
41mod dead_code_projection;
42pub use dead_code_projection::project_dead_code_snapshot;
43
44#[doc(hidden)]
45pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
46 COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
47}
48
49fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
50 let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
51 if let Some(observer) = observer {
52 observer(temp_path, target_path);
53 }
54}
55
56#[derive(Debug)]
57pub enum CallGraphStoreError {
58 Io(std::io::Error),
59 Sqlite(rusqlite::Error),
60 Json(serde_json::Error),
61 Aft(AftError),
62 Lock(crate::fs_lock::AcquireError),
63 MissingCallerData { file: String },
64 Unavailable(String),
65 StaleFiles(Vec<String>),
66}
67
68impl fmt::Display for CallGraphStoreError {
69 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
70 match self {
71 Self::Io(error) => write!(formatter, "I/O error: {error}"),
72 Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
73 Self::Json(error) => write!(formatter, "json error: {error}"),
74 Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
75 Self::Lock(error) => write!(formatter, "callgraph build lock error: {error}"),
76 Self::MissingCallerData { file } => {
77 write!(formatter, "missing extracted caller data for {file}")
78 }
79 Self::Unavailable(message) => {
80 write!(formatter, "callgraph store unavailable: {message}")
81 }
82 Self::StaleFiles(files) => {
83 write!(
84 formatter,
85 "callgraph store has stale files: {}",
86 files.join(", ")
87 )
88 }
89 }
90 }
91}
92
93impl std::error::Error for CallGraphStoreError {}
94
95impl From<std::io::Error> for CallGraphStoreError {
96 fn from(error: std::io::Error) -> Self {
97 Self::Io(error)
98 }
99}
100
101impl From<rusqlite::Error> for CallGraphStoreError {
102 fn from(error: rusqlite::Error) -> Self {
103 Self::Sqlite(error)
104 }
105}
106
107impl From<serde_json::Error> for CallGraphStoreError {
108 fn from(error: serde_json::Error) -> Self {
109 Self::Json(error)
110 }
111}
112
113impl From<AftError> for CallGraphStoreError {
114 fn from(error: AftError) -> Self {
115 Self::Aft(error)
116 }
117}
118
119impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
120 fn from(error: crate::fs_lock::AcquireError) -> Self {
121 Self::Lock(error)
122 }
123}
124
125pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
126
127pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
131
132#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
133pub struct CallGraphStoreOptions {
134 pub enabled: bool,
135}
136
137#[derive(Debug)]
138pub struct CallGraphStore {
139 project_root: PathBuf,
140 project_key: String,
141 sqlite_path: PathBuf,
145 generation: Option<String>,
150 conn: Mutex<Connection>,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
154enum OpenRootRepair {
155 None,
156 ReRooted,
157 NeedsRebuild {
158 previous_roots: Vec<String>,
159 current_root: String,
160 reason: String,
161 },
162}
163
164struct OpenedStore {
165 store: CallGraphStore,
166 root_repair: OpenRootRepair,
167}
168
169#[derive(Debug, Clone)]
170pub struct ColdBuildStats {
171 pub files: usize,
172 pub nodes: usize,
173 pub refs: usize,
174 pub edges: usize,
175 pub failed_files: Vec<String>,
176 pub elapsed_ms: u128,
177}
178
179#[derive(Debug, Clone)]
180pub struct IncrementalStats {
181 pub changed_files: Vec<String>,
182 pub surface_changed: Vec<String>,
183 pub deleted_files: Vec<String>,
184 pub dependency_selected_refs: usize,
185 pub refreshed_own_files: usize,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
189pub struct StoredEdge {
190 pub source_file: String,
191 pub source_symbol: String,
192 pub target_file: String,
193 pub target_symbol: String,
194 pub kind: String,
195 pub line: u32,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct StoreNode {
200 node_id: String,
201 pub file: String,
202 pub symbol: String,
203 pub name: String,
204 pub kind: String,
205 pub line: u32,
206 pub end_line: u32,
207 pub signature: Option<String>,
208 pub exported: bool,
209 pub is_entry_point: bool,
210 pub lang: LangId,
211}
212
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct StoreCallSite {
215 pub caller: StoreNode,
216 pub target_file: String,
217 pub target_symbol: String,
218 pub target: Option<StoreNode>,
219 pub line: u32,
220 pub byte_start: usize,
221 pub byte_end: usize,
222 pub resolved: bool,
223 pub provenance: String,
224}
225
226impl StoreCallSite {
227 pub fn approximate(&self) -> bool {
228 self.provenance == PROVENANCE_NAME_MATCH
229 }
230
231 pub fn resolved_by(&self) -> &str {
232 &self.provenance
233 }
234
235 pub fn supplemental_resolution(&self) -> Option<&str> {
236 match self.provenance.as_str() {
237 PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
238 _ => None,
239 }
240 }
241}
242
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub struct StoreUnresolvedCall {
245 pub caller: StoreNode,
246 pub symbol: String,
247 pub full_ref: Option<String>,
248 pub line: u32,
249 pub byte_start: usize,
250 pub byte_end: usize,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq)]
254pub struct StoreCallersResult {
255 pub target: StoreNode,
256 pub callers: Vec<StoreCallSite>,
257 pub scanned_files: usize,
258 pub depth_limited: bool,
259 pub truncated: usize,
260}
261
262#[derive(Debug, Clone, PartialEq, Eq)]
263pub struct StoreImpactCaller {
264 pub site: StoreCallSite,
265 pub signature: Option<String>,
266 pub is_entry_point: bool,
267 pub call_expression: Option<String>,
268 pub parameters: Vec<String>,
269}
270
271#[derive(Debug, Clone, PartialEq, Eq)]
272pub struct StoreImpactResult {
273 pub target: StoreNode,
274 pub parameters: Vec<String>,
275 pub callers: Vec<StoreImpactCaller>,
276 pub depth_limited: bool,
277 pub truncated: usize,
278}
279
280#[derive(Debug, Clone)]
281struct ExtractFailure {
282 rel_path: String,
283 freshness: Option<FileFreshness>,
284}
285
286#[derive(Debug, Clone)]
287struct BuildExtractsResult {
288 extracts: Vec<FileExtract>,
289 failures: Vec<ExtractFailure>,
290}
291
292#[derive(Debug, Clone)]
293enum StoreForwardCall {
294 Resolved(StoreCallSite),
295 Unresolved(StoreUnresolvedCall),
296}
297
298impl StoreForwardCall {
299 fn byte_start(&self) -> usize {
300 match self {
301 Self::Resolved(site) => site.byte_start,
302 Self::Unresolved(call) => call.byte_start,
303 }
304 }
305
306 fn line(&self) -> u32 {
307 match self {
308 Self::Resolved(site) => site.line,
309 Self::Unresolved(call) => call.line,
310 }
311 }
312}
313
314#[derive(Debug, Clone)]
315struct FileExtract {
316 abs_path: PathBuf,
317 rel_path: String,
318 freshness: FileFreshness,
319 lang: LangId,
320 data: FileCallData,
321 nodes: Vec<NodeRecord>,
322 raw_refs: Vec<RawRef>,
323 dispatch_hints: Vec<DispatchHint>,
324 surface_fingerprint: String,
325}
326
327#[derive(Debug, Clone)]
328struct NodeRecord {
329 id: String,
330 file_path: String,
331 name: String,
332 scoped_name: String,
333 kind: String,
334 range: Range,
335 range_ordinal: u32,
336 signature: Option<String>,
337 exported: bool,
338 is_default_export: bool,
339 is_type_like: bool,
340 is_callgraph_entry_point: bool,
341}
342
343#[derive(Debug, Clone)]
344struct RawRef {
345 ref_id: String,
346 caller_node: Option<String>,
347 caller_symbol: Option<String>,
348 caller_file: String,
349 kind: String,
350 short_name: Option<String>,
351 full_ref: Option<String>,
352 module_path: Option<String>,
353 import_kind: Option<String>,
354 local_name: Option<String>,
355 requested_name: Option<String>,
356 namespace_alias: Option<String>,
357 wildcard: bool,
358 line: u32,
359 byte_start: usize,
360 byte_end: usize,
361 dependencies: BTreeSet<String>,
362}
363
364#[derive(Debug, Clone)]
365struct ResolvedRef {
366 raw: RawRef,
367 status: String,
368 target_node: Option<String>,
369 target_file: Option<String>,
370 target_symbol: Option<String>,
371 dependencies: BTreeSet<String>,
372 edge: Option<EdgeRecord>,
373}
374
375#[derive(Debug, Clone)]
376struct EdgeRecord {
377 edge_id: String,
378 source_node: String,
379 target_node: Option<String>,
380 target_file: String,
381 target_symbol: String,
382 kind: String,
383 line: u32,
384}
385
386#[derive(Debug, Clone)]
387struct DispatchHint {
388 id: String,
389 method_name: String,
390 caller_node: String,
391 file: String,
392 line: u32,
393 byte_start: usize,
394 byte_end: usize,
395}
396
397#[derive(Debug, Clone)]
398struct NameMatchRef {
399 ref_id: String,
400 caller_node: String,
401 caller_file: String,
402 caller_symbol: String,
403 caller_signature: Option<String>,
404 receiver: String,
405 method_name: String,
406 colon_dispatch: bool,
407 line: u32,
408 lang: String,
409}
410
411#[derive(Debug, Clone)]
412struct NameMatchCandidate {
413 node_id: String,
414 file_path: String,
415 scoped_name: String,
416 kind: String,
417}
418
419#[derive(Debug, Clone)]
420struct FileRow {
421 surface_fingerprint: String,
422 freshness: FileFreshness,
423}
424
425#[derive(Debug, Clone)]
426struct DbFileIndex {
427 lang: Option<LangId>,
428 exports: HashSet<String>,
429 default_export: Option<String>,
430 export_aliases: HashMap<String, String>,
431 node_by_scoped: HashMap<String, String>,
432 node_by_bare: HashMap<String, String>,
433 module_targets: HashMap<String, Option<String>>,
434 reexports: Vec<ReexportIndex>,
435}
436
437#[derive(Debug, Clone)]
438struct ReexportIndex {
439 target_file: Option<String>,
440 named: HashMap<String, String>,
441 wildcard: bool,
442}
443
444#[derive(Debug, Clone)]
445struct ProjectIndex<'a> {
446 project_root: PathBuf,
447 files: HashMap<String, DbFileIndex>,
448 caller_data: HashMap<String, &'a FileCallData>,
449 workspace_crate_prefixes: std::sync::OnceLock<HashMap<String, String>>,
454}
455
456impl ProjectIndex<'_> {
457 fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
460 self.workspace_crate_prefixes
461 .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
462 .get(crate_name)
463 .cloned()
464 }
465}
466
467impl CallGraphStore {
468 pub fn open_if_enabled(
469 options: CallGraphStoreOptions,
470 callgraph_dir: PathBuf,
471 project_root: PathBuf,
472 ) -> Result<Option<Self>> {
473 if !options.enabled {
474 return Ok(None);
475 }
476 Self::open(callgraph_dir, project_root).map(Some)
477 }
478
479 pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
480 std::fs::create_dir_all(&callgraph_dir)?;
481 let project_key = crate::search_index::project_cache_key(&project_root);
482 let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
486 .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
487 let OpenedStore { store, root_repair } = Self::open_at_path(
488 project_root.clone(),
489 project_key,
490 sqlite_path,
491 generation,
492 true,
493 )?;
494 match root_repair {
495 OpenRootRepair::NeedsRebuild { .. } => {
496 log_root_repair_rebuild(&root_repair);
497 drop(store);
498 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
499 let (store, _stats) =
500 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
501 Ok(store)
502 }
503 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
504 }
505 }
506
507 pub fn open_readonly(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
508 let project_key = crate::search_index::project_cache_key(&project_root);
509 let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
510 else {
511 return Ok(None);
512 };
513 let conn = Connection::open_with_flags(&sqlite_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
514 conn.busy_timeout(Duration::from_millis(5_000))?;
515 if !database_ready(&conn).unwrap_or(false) {
516 return Ok(None);
517 }
518 Ok(Some(Self::from_connection(
519 project_root,
520 project_key,
521 sqlite_path,
522 generation,
523 conn,
524 )))
525 }
526
527 pub fn open_ready_repairing(
533 callgraph_dir: PathBuf,
534 project_root: PathBuf,
535 ) -> Result<Option<Self>> {
536 let project_key = crate::search_index::project_cache_key(&project_root);
537 let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
538 else {
539 return Ok(None);
540 };
541 let OpenedStore { store, root_repair } = Self::open_at_path(
542 project_root.clone(),
543 project_key,
544 sqlite_path,
545 generation,
546 true,
547 )?;
548 match root_repair {
549 OpenRootRepair::NeedsRebuild { .. } => {
550 log_root_repair_rebuild(&root_repair);
551 drop(store);
552 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
553 let (store, _stats) =
554 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
555 Ok(Some(store))
556 }
557 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
558 }
559 }
560
561 pub fn cold_build_with_lease(
562 callgraph_dir: PathBuf,
563 project_root: PathBuf,
564 files: &[PathBuf],
565 ) -> Result<(Self, ColdBuildStats)> {
566 std::fs::create_dir_all(&callgraph_dir)?;
567 let project_key = crate::search_index::project_cache_key(&project_root);
568 let lock_path = callgraph_dir.join(format!("{project_key}.build.lock"));
569 let _guard = crate::fs_lock::try_acquire(&lock_path, Duration::from_secs(30))?;
570 let (stats, generation) =
571 Self::cold_build_publish_locked(&callgraph_dir, &project_root, &project_key, files)?;
572 let store = Self::open_generation(&callgraph_dir, project_root, project_key, generation)?;
573 Ok((store, stats))
574 }
575
576 pub fn ensure_built_with_lease(
577 callgraph_dir: PathBuf,
578 project_root: PathBuf,
579 files: &[PathBuf],
580 ) -> Result<(Self, Option<ColdBuildStats>)> {
581 std::fs::create_dir_all(&callgraph_dir)?;
582 let project_key = crate::search_index::project_cache_key(&project_root);
583 let lock_path = callgraph_dir.join(format!("{project_key}.build.lock"));
584 let _guard = crate::fs_lock::try_acquire(&lock_path, Duration::from_secs(30))?;
585 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
592 {
593 let OpenedStore { store, root_repair } = Self::open_at_path(
594 project_root.clone(),
595 project_key.clone(),
596 sqlite_path,
597 generation,
598 true,
599 )?;
600 match root_repair {
601 OpenRootRepair::NeedsRebuild { .. } => {
602 log_root_repair_rebuild(&root_repair);
603 drop(store);
604 let (stats, generation) = Self::cold_build_publish_locked(
605 &callgraph_dir,
606 &project_root,
607 &project_key,
608 files,
609 )?;
610 let store = Self::open_generation(
611 &callgraph_dir,
612 project_root,
613 project_key,
614 generation,
615 )?;
616 return Ok((store, Some(stats)));
617 }
618 OpenRootRepair::None | OpenRootRepair::ReRooted => {
619 return Ok((store, None));
620 }
621 }
622 }
623 let (stats, generation) =
624 Self::cold_build_publish_locked(&callgraph_dir, &project_root, &project_key, files)?;
625 let store = Self::open_generation(&callgraph_dir, project_root, project_key, generation)?;
626 Ok((store, Some(stats)))
627 }
628
629 fn cold_build_publish_locked(
640 callgraph_dir: &Path,
641 project_root: &Path,
642 project_key: &str,
643 files: &[PathBuf],
644 ) -> Result<(ColdBuildStats, String)> {
645 let generation = generation_file_name(project_key);
646 let gen_path = callgraph_dir.join(&generation);
647 let temp_path = callgraph_dir.join(format!(
648 "{generation}.tmp.{}.{}",
649 std::process::id(),
650 now_nanos()
651 ));
652 remove_sqlite_file_set(&temp_path);
653
654 let stats = {
655 let temp_store = Self::open_at_path(
656 project_root.to_path_buf(),
657 project_key.to_string(),
658 temp_path.clone(),
659 None,
660 false,
661 )?
662 .store;
663 let stats = temp_store.cold_build(files)?;
664 temp_store.prepare_for_atomic_swap()?;
665 stats
666 };
667
668 remove_sqlite_file_set(&gen_path);
671 std::fs::rename(&temp_path, &gen_path)?;
672 remove_sqlite_sidecars(&gen_path);
673
674 notify_cold_build_swap_observer(&temp_path, &gen_path);
675
676 publish_pointer(callgraph_dir, project_key, &generation)?;
678 gc_old_generations(callgraph_dir, project_key, &generation);
679 Ok((stats, generation))
680 }
681
682 fn open_generation(
685 callgraph_dir: &Path,
686 project_root: PathBuf,
687 project_key: String,
688 generation: String,
689 ) -> Result<Self> {
690 let gen_path = callgraph_dir.join(&generation);
691 Ok(Self::open_at_path(project_root, project_key, gen_path, Some(generation), true)?.store)
692 }
693
694 pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
695 let project_key = crate::search_index::project_cache_key(project_root);
696 Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
699 }
700
701 fn open_at_path(
702 project_root: PathBuf,
703 project_key: String,
704 sqlite_path: PathBuf,
705 generation: Option<String>,
706 use_wal: bool,
707 ) -> Result<OpenedStore> {
708 if let Some(parent) = sqlite_path.parent() {
709 std::fs::create_dir_all(parent)?;
710 }
711 let mut conn = Connection::open(&sqlite_path)?;
712 if use_wal {
713 configure_connection(&conn)?;
714 } else {
715 configure_build_connection(&conn)?;
716 }
717 initialize_schema(&conn)?;
718 let root_repair = reconcile_workspace_roots(&mut conn, &project_root)?;
719 let store = Self::from_connection(project_root, project_key, sqlite_path, generation, conn);
720 Ok(OpenedStore { store, root_repair })
721 }
722
723 fn prepare_for_atomic_swap(&self) -> Result<()> {
724 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
725 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;")?;
726 Ok(())
727 }
728
729 fn from_connection(
730 project_root: PathBuf,
731 project_key: String,
732 sqlite_path: PathBuf,
733 generation: Option<String>,
734 conn: Connection,
735 ) -> Self {
736 Self {
737 project_root,
738 project_key,
739 sqlite_path,
740 generation,
741 conn: Mutex::new(conn),
742 }
743 }
744
745 pub fn project_root(&self) -> &Path {
746 &self.project_root
747 }
748
749 pub fn project_key(&self) -> &str {
750 &self.project_key
751 }
752
753 pub fn sqlite_path(&self) -> &Path {
754 &self.sqlite_path
755 }
756
757 pub fn is_current(&self) -> bool {
763 let Some(dir) = self.sqlite_path.parent() else {
764 return true;
765 };
766 match (read_pointer(dir, &self.project_key), &self.generation) {
767 (Some(published), Some(opened)) => &published == opened,
768 (Some(_), None) => false,
770 (None, _) => true,
773 }
774 }
775
776 pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
777 let started = Instant::now();
778 let bench = std::env::var("AFT_BENCH_COLD").is_ok();
779 macro_rules! phase {
780 ($label:expr, $t:expr) => {
781 if bench {
782 eprintln!(" cold_build[{}]: {} ms", $label, $t.elapsed().as_millis());
783 let _ = std::io::Write::flush(&mut std::io::stderr());
784 }
785 };
786 }
787 let files = normalize_file_list(&self.project_root, files)?;
788 let t = Instant::now();
789 let build = build_extracts_parallel(&self.project_root, &files);
790 phase!("extract_parallel", t);
791 let extracts = build.extracts;
792 let failures = build.failures;
793 let node_count = extracts.iter().map(|extract| extract.nodes.len()).sum();
794
795 let t = Instant::now();
796 let index = ProjectIndex::from_extracts(&self.project_root, &extracts);
797 phase!("build_index", t);
798 let t = Instant::now();
799 let mut resolved_refs = Vec::new();
800 for extract in &extracts {
801 for raw_ref in &extract.raw_refs {
802 resolved_refs.push(resolve_ref(raw_ref.clone(), &index)?);
803 }
804 }
805 phase!("resolve_refs", t);
806 let ref_count = resolved_refs.len();
807 let edge_count = resolved_refs
808 .iter()
809 .filter(|item| item.edge.is_some())
810 .count();
811
812 let t = Instant::now();
813 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
814 let tx = conn.transaction()?;
815 clear_tables(&tx)?;
816 insert_meta(&tx)?;
817 for extract in &extracts {
818 insert_file_extract(&tx, &self.project_root, extract)?;
819 }
820 for failure in &failures {
821 mark_backend_state(
822 &tx,
823 &self.project_root,
824 &failure.rel_path,
825 failure
826 .freshness
827 .as_ref()
828 .map(|freshness| &freshness.content_hash),
829 "stale",
830 )?;
831 }
832 for resolved in &resolved_refs {
833 insert_resolved_ref(&tx, resolved)?;
834 }
835 let supplemental_edge_count = insert_method_dispatch_edges(&tx, &self.project_root, None)?;
836 set_meta_ready(&tx, true)?;
837 tx.commit()?;
838 phase!("sqlite_insert", t);
839
840 let elapsed_ms = started.elapsed().as_millis();
841 crate::slog_info!(
846 "perf callgraph_store cold_build: files={} nodes={} refs={} edges={} ms={}",
847 extracts.len(),
848 node_count,
849 ref_count,
850 edge_count + supplemental_edge_count,
851 elapsed_ms
852 );
853
854 Ok(ColdBuildStats {
855 files: extracts.len(),
856 nodes: node_count,
857 refs: ref_count,
858 edges: edge_count + supplemental_edge_count,
859 failed_files: failures
860 .into_iter()
861 .map(|failure| failure.rel_path)
862 .collect(),
863 elapsed_ms,
864 })
865 }
866
867 pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
868 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
869 let tx = conn.transaction()?;
870 ensure_database_ready(&tx)?;
871 let mut changed = Vec::new();
872 let mut surface_changed = BTreeSet::new();
873 let mut deleted = BTreeSet::new();
874 let mut own_refresh = BTreeSet::new();
875 let mut selected_ref_ids = BTreeSet::new();
876 let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
877
878 for input in changed_files {
879 let abs_path = normalize_file_path(&self.project_root, input)?;
880 let rel_path = relative_path(&self.project_root, &abs_path);
881 changed.push(rel_path.clone());
882 let old_row = load_file_row(&tx, &rel_path)?;
883 if !abs_path.exists() {
884 if old_row.is_some() {
885 surface_changed.insert(rel_path.clone());
886 deleted.insert(rel_path.clone());
887 selected_ref_ids.extend(ref_ids_depending_on(
888 &tx,
889 &self.project_root,
890 &rel_path,
891 )?);
892 delete_file_rows(&tx, &rel_path)?;
893 mark_backend_state(&tx, &self.project_root, &rel_path, None, "stale")?;
894 }
895 continue;
896 }
897
898 if let Some(row) = &old_row {
899 match cache_freshness::verify_file(&abs_path, &row.freshness) {
900 FreshnessVerdict::HotFresh => continue,
901 FreshnessVerdict::ContentFresh {
902 new_mtime,
903 new_size,
904 } => {
905 update_file_fresh_metadata(
906 &tx,
907 &rel_path,
908 &row.freshness.content_hash,
909 new_mtime,
910 new_size,
911 )?;
912 continue;
913 }
914 FreshnessVerdict::Deleted => {
915 surface_changed.insert(rel_path.clone());
916 deleted.insert(rel_path.clone());
917 selected_ref_ids.extend(ref_ids_depending_on(
918 &tx,
919 &self.project_root,
920 &rel_path,
921 )?);
922 delete_file_rows(&tx, &rel_path)?;
923 mark_backend_state(&tx, &self.project_root, &rel_path, None, "stale")?;
924 continue;
925 }
926 FreshnessVerdict::Stale => {}
927 }
928 }
929
930 let extract = build_file_extract(&self.project_root, &abs_path)?;
931 let surface_is_changed = old_row
932 .as_ref()
933 .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
934 .unwrap_or(true);
935 if surface_is_changed {
936 surface_changed.insert(rel_path.clone());
937 selected_ref_ids.extend(ref_ids_depending_on(&tx, &self.project_root, &rel_path)?);
938 }
939 own_refresh.insert(rel_path.clone());
940 delete_file_rows(&tx, &rel_path)?;
941 insert_file_extract(&tx, &self.project_root, &extract)?;
942 changed_extracts.insert(rel_path, extract);
943 }
944
945 let dependency_selected_refs = selected_ref_ids.len();
946 let selected_refs_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)?;
947 let mut touched_callers: BTreeSet<String> =
948 selected_refs_by_caller.keys().cloned().collect();
949 touched_callers.extend(own_refresh.iter().cloned());
950
951 let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
952 for rel_path in &touched_callers {
953 if deleted.contains(rel_path) {
954 continue;
955 }
956 if let Some(extract) = changed_extracts.get(rel_path) {
957 caller_extracts.insert(rel_path.clone(), extract.clone());
958 continue;
959 }
960 let abs_path = self.project_root.join(rel_path);
961 if abs_path.exists() {
962 let extract = build_file_extract(&self.project_root, &abs_path)?;
963 caller_extracts.insert(rel_path.clone(), extract);
964 }
965 }
966
967 let dependency_callers = touched_callers
968 .iter()
969 .filter(|rel_path| !deleted.contains(*rel_path) && !own_refresh.contains(*rel_path))
970 .cloned()
971 .collect::<Vec<_>>();
972 for rel_path in dependency_callers {
973 let Some(extract) = caller_extracts.get(&rel_path) else {
974 continue;
975 };
976 if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
977 continue;
978 }
979
980 own_refresh.insert(rel_path.clone());
981 delete_file_rows(&tx, &rel_path)?;
982 insert_file_extract(&tx, &self.project_root, extract)?;
983 }
984
985 let index = ProjectIndex::from_db_and_callers(&tx, &self.project_root, &caller_extracts)?;
986 for rel_path in &touched_callers {
987 if deleted.contains(rel_path) {
988 continue;
989 }
990 let Some(extract) = caller_extracts.get(rel_path) else {
991 continue;
992 };
993 if own_refresh.contains(rel_path) {
994 delete_refs_for_caller(&tx, rel_path)?;
995 for raw_ref in &extract.raw_refs {
996 let resolved = resolve_ref(raw_ref.clone(), &index)?;
997 insert_resolved_ref(&tx, &resolved)?;
998 }
999 continue;
1000 }
1001
1002 let selected_for_caller = selected_refs_by_caller
1003 .get(rel_path)
1004 .cloned()
1005 .unwrap_or_default();
1006 delete_ref_ids(&tx, &selected_for_caller)?;
1007 for raw_ref in &extract.raw_refs {
1008 if selected_for_caller.contains(&raw_ref.ref_id) {
1009 let resolved = resolve_ref(raw_ref.clone(), &index)?;
1010 insert_resolved_ref(&tx, &resolved)?;
1011 }
1012 }
1013 }
1014
1015 delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
1016 insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
1017
1018 tx.commit()?;
1019 Ok(IncrementalStats {
1020 changed_files: changed,
1021 surface_changed: surface_changed.into_iter().collect(),
1022 deleted_files: deleted.into_iter().collect(),
1023 dependency_selected_refs,
1024 refreshed_own_files: own_refresh.len(),
1025 })
1026 }
1027
1028 pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
1029 self.cold_build(current_files)
1030 }
1031
1032 pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
1033 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
1034 let tx = conn.transaction()?;
1035 let mut marked = Vec::new();
1036 for path in files {
1037 let abs_path = normalize_file_path(&self.project_root, path)?;
1038 let rel_path = relative_path(&self.project_root, &abs_path);
1039 let freshness = cache_freshness::collect(&abs_path).ok();
1040 mark_backend_state(
1041 &tx,
1042 &self.project_root,
1043 &rel_path,
1044 freshness.as_ref().map(|freshness| &freshness.content_hash),
1045 "stale",
1046 )?;
1047 marked.push(rel_path);
1048 }
1049 tx.commit()?;
1050 marked.sort();
1051 marked.dedup();
1052 Ok(marked)
1053 }
1054
1055 pub fn stale_files(&self) -> Result<Vec<String>> {
1056 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1057 let mut stmt = conn.prepare(
1058 "SELECT DISTINCT file_path FROM backend_file_state
1059 WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
1060 ORDER BY file_path",
1061 )?;
1062 let rows = stmt.query_map(
1063 params![BACKEND_TREESITTER, self.project_root.display().to_string()],
1064 |row| row.get::<_, String>(0),
1065 )?;
1066 rows.collect::<std::result::Result<Vec<_>, _>>()
1067 .map_err(Into::into)
1068 }
1069
1070 pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
1071 let rel_path = relative_path(
1072 &self.project_root,
1073 &normalize_file_path(&self.project_root, file)?,
1074 );
1075 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1076 conn.query_row(
1077 "SELECT status FROM backend_file_state
1078 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
1079 ORDER BY updated_at DESC LIMIT 1",
1080 params![
1081 BACKEND_TREESITTER,
1082 self.project_root.display().to_string(),
1083 rel_path
1084 ],
1085 |row| row.get(0),
1086 )
1087 .optional()
1088 .map_err(Into::into)
1089 }
1090
1091 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
1092 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1093 ensure_database_ready(&conn)?;
1094 edge_snapshot_with_conn(&conn)
1095 }
1096
1097 pub fn indexed_file_count(&self) -> Result<usize> {
1098 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1099 ensure_database_ready(&conn)?;
1100 indexed_file_count(&conn)
1101 }
1102
1103 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
1104 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
1105 let rel_path = relative_path(&self.project_root, &abs_path);
1106 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1107 ensure_database_ready(&conn)?;
1108 resolve_node_for_rel(&conn, &rel_path, symbol)
1109 }
1110
1111 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
1116 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
1117 let rel_path = relative_path(&self.project_root, &abs_path);
1118 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1119 ensure_database_ready(&conn)?;
1120 nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
1121 }
1122
1123 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
1125 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1126 ensure_database_ready(&conn)?;
1127 nodes_matching_symbol(&conn, symbol)
1128 }
1129
1130 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
1132 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
1133 let rel_path = relative_path(&self.project_root, &abs_path);
1134 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1135 ensure_database_ready(&conn)?;
1136 direct_callers_for_tuple(&conn, &rel_path, symbol)
1137 }
1138
1139 pub fn callers_of(
1140 &self,
1141 file_rel: &Path,
1142 symbol: &str,
1143 depth: usize,
1144 ) -> Result<StoreCallersResult> {
1145 let target = self.node_for(file_rel, symbol)?;
1146 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1147 ensure_database_ready(&conn)?;
1148 let effective_depth = depth.max(1);
1149 let mut visited = HashSet::new();
1150 let mut callers = Vec::new();
1151 let mut depth_limited = false;
1152 let mut truncated = 0usize;
1153 collect_callers_recursive(
1154 &conn,
1155 &target.file,
1156 &target.symbol,
1157 effective_depth,
1158 0,
1159 &mut visited,
1160 &mut callers,
1161 &mut depth_limited,
1162 &mut truncated,
1163 )?;
1164 Ok(StoreCallersResult {
1165 target,
1166 callers,
1167 scanned_files: indexed_file_count(&conn)?,
1168 depth_limited,
1169 truncated,
1170 })
1171 }
1172
1173 pub fn impact_of(
1174 &self,
1175 file_rel: &Path,
1176 symbol: &str,
1177 depth: usize,
1178 ) -> Result<StoreImpactResult> {
1179 let callers = self.callers_of(file_rel, symbol, depth)?;
1180 let target_parameters = callers
1181 .target
1182 .signature
1183 .as_deref()
1184 .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
1185 .unwrap_or_default();
1186 let enriched = callers
1187 .callers
1188 .iter()
1189 .map(|site| StoreImpactCaller {
1190 site: site.clone(),
1191 signature: site.caller.signature.clone(),
1192 is_entry_point: site.caller.is_entry_point,
1193 call_expression: read_source_line(
1194 &self.project_root.join(&site.caller.file),
1195 site.line,
1196 ),
1197 parameters: site
1198 .caller
1199 .signature
1200 .as_deref()
1201 .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
1202 .unwrap_or_default(),
1203 })
1204 .collect();
1205 Ok(StoreImpactResult {
1206 target: callers.target,
1207 parameters: target_parameters,
1208 callers: enriched,
1209 depth_limited: callers.depth_limited,
1210 truncated: callers.truncated,
1211 })
1212 }
1213
1214 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
1215 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1216 ensure_database_ready(&conn)?;
1217 outgoing_calls_for_node(&conn, node)
1218 }
1219
1220 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
1222 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1223 ensure_database_ready(&conn)?;
1224 resolved_self_calls_for_node(&conn, node)
1225 }
1226
1227 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
1228 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1229 ensure_database_ready(&conn)?;
1230 unresolved_calls_for_node(&conn, node)
1231 }
1232
1233 pub fn call_tree(
1234 &self,
1235 file_rel: &Path,
1236 symbol: &str,
1237 max_depth: usize,
1238 ) -> Result<callgraph::CallTreeNode> {
1239 let node = self.node_for(file_rel, symbol)?;
1240 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1241 ensure_database_ready(&conn)?;
1242 let mut visited = HashSet::new();
1243 call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
1244 }
1245
1246 pub fn trace_to(
1247 &self,
1248 file_rel: &Path,
1249 symbol: &str,
1250 max_depth: usize,
1251 ) -> Result<callgraph::TraceToResult> {
1252 let target = self.node_for(file_rel, symbol)?;
1253 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1254 ensure_database_ready(&conn)?;
1255 let effective_max = if max_depth == 0 { 10 } else { max_depth };
1256
1257 #[derive(Clone)]
1258 struct PathElem {
1259 node: StoreNode,
1260 }
1261
1262 let initial = vec![PathElem {
1263 node: target.clone(),
1264 }];
1265 let mut complete_paths = Vec::new();
1266 if target.is_entry_point {
1267 complete_paths.push(initial.clone());
1268 }
1269
1270 let mut queue = vec![(initial, 0usize)];
1271 let mut max_depth_reached = false;
1272 let mut truncated_paths = 0usize;
1273
1274 while let Some((path, depth)) = queue.pop() {
1275 if depth >= effective_max {
1276 max_depth_reached = true;
1277 continue;
1278 }
1279 let Some(current) = path.last() else {
1280 continue;
1281 };
1282 let callers =
1283 direct_callers_for_tuple(&conn, ¤t.node.file, ¤t.node.symbol)?;
1284 if callers.is_empty() {
1285 if path.len() > 1 {
1286 truncated_paths += 1;
1287 }
1288 continue;
1289 }
1290
1291 let mut has_new_path = false;
1292 for site in callers {
1293 if path.iter().any(|elem| {
1294 elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
1295 }) {
1296 continue;
1297 }
1298 has_new_path = true;
1299 let mut new_path = path.clone();
1300 new_path.push(PathElem {
1301 node: site.caller.clone(),
1302 });
1303 if site.caller.is_entry_point {
1304 complete_paths.push(new_path.clone());
1305 }
1306 queue.push((new_path, depth + 1));
1307 }
1308 if !has_new_path && path.len() > 1 {
1309 truncated_paths += 1;
1310 }
1311 }
1312
1313 let mut paths: Vec<callgraph::TracePath> = complete_paths
1314 .into_iter()
1315 .map(|mut elems| {
1316 elems.reverse();
1317 let hops = elems
1318 .iter()
1319 .enumerate()
1320 .map(|(index, elem)| callgraph::TraceHop {
1321 symbol: elem.node.symbol.clone(),
1322 file: elem.node.file.clone(),
1323 line: elem.node.line,
1324 signature: elem.node.signature.clone(),
1325 is_entry_point: index == 0 && elem.node.is_entry_point,
1326 })
1327 .collect();
1328 callgraph::TracePath { hops }
1329 })
1330 .collect();
1331 paths.sort_by(|left, right| {
1332 let left_entry = left
1333 .hops
1334 .first()
1335 .map(|hop| hop.symbol.as_str())
1336 .unwrap_or("");
1337 let right_entry = right
1338 .hops
1339 .first()
1340 .map(|hop| hop.symbol.as_str())
1341 .unwrap_or("");
1342 left_entry
1343 .cmp(right_entry)
1344 .then(left.hops.len().cmp(&right.hops.len()))
1345 });
1346 let entry_points_found = paths
1347 .iter()
1348 .filter_map(|path| path.hops.first())
1349 .filter(|hop| hop.is_entry_point)
1350 .map(|hop| (hop.file.clone(), hop.symbol.clone()))
1351 .collect::<HashSet<_>>()
1352 .len();
1353
1354 Ok(callgraph::TraceToResult {
1355 target_symbol: target.symbol,
1356 target_file: target.file,
1357 total_paths: paths.len(),
1358 paths,
1359 entry_points_found,
1360 max_depth_reached,
1361 truncated_paths,
1362 })
1363 }
1364
1365 pub fn trace_to_symbol_candidates(
1366 &self,
1367 to_symbol: &str,
1368 ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
1369 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1370 ensure_database_ready(&conn)?;
1371 let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
1372 for node in nodes_matching_symbol(&conn, to_symbol)? {
1373 candidates_by_file
1374 .entry(node.file)
1375 .and_modify(|line| *line = (*line).min(node.line))
1376 .or_insert(node.line);
1377 }
1378 let mut candidates: Vec<_> = candidates_by_file
1379 .into_iter()
1380 .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
1381 .collect();
1382 candidates
1383 .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
1384 Ok(candidates)
1385 }
1386
1387 pub fn trace_to_symbol(
1388 &self,
1389 file_rel: &Path,
1390 symbol: &str,
1391 to_symbol: &str,
1392 to_file: Option<&Path>,
1393 max_depth: usize,
1394 ) -> Result<callgraph::TraceToSymbolResult> {
1395 let origin = self.node_for(file_rel, symbol)?;
1396 let target_file = to_file
1397 .map(|path| normalize_file_path(&self.project_root, path))
1398 .transpose()?
1399 .map(|path| relative_path(&self.project_root, &path));
1400 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1401 ensure_database_ready(&conn)?;
1402 let effective_max = if max_depth == 0 {
1403 10
1404 } else {
1405 max_depth.min(16)
1406 };
1407
1408 let start_hop = trace_to_symbol_hop(&origin);
1409 if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
1410 return Ok(callgraph::TraceToSymbolResult {
1411 path: Some(vec![start_hop]),
1412 complete: true,
1413 reason: None,
1414 });
1415 }
1416
1417 let mut queue = VecDeque::new();
1418 queue.push_back((origin.clone(), vec![start_hop], 0usize));
1419 let mut visited = HashSet::new();
1420 visited.insert((origin.file.clone(), origin.symbol.clone()));
1421 let mut max_depth_exhausted = false;
1422
1423 while let Some((current, path, depth)) = queue.pop_front() {
1424 let callees = outgoing_calls_for_node(&conn, ¤t)?
1425 .into_iter()
1426 .filter_map(|site| site.target)
1427 .collect::<Vec<_>>();
1428
1429 if depth >= effective_max {
1430 if callees
1431 .iter()
1432 .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
1433 {
1434 max_depth_exhausted = true;
1435 }
1436 continue;
1437 }
1438
1439 for callee in callees {
1440 if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
1441 continue;
1442 }
1443 let mut next_path = path.clone();
1444 next_path.push(trace_to_symbol_hop(&callee));
1445 if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
1446 return Ok(callgraph::TraceToSymbolResult {
1447 path: Some(next_path),
1448 complete: true,
1449 reason: None,
1450 });
1451 }
1452 queue.push_back((callee, next_path, depth + 1));
1453 }
1454 }
1455
1456 if max_depth_exhausted {
1457 Ok(callgraph::TraceToSymbolResult {
1458 path: None,
1459 complete: false,
1460 reason: Some("max_depth_exhausted".to_string()),
1461 })
1462 } else {
1463 Ok(callgraph::TraceToSymbolResult {
1464 path: None,
1465 complete: true,
1466 reason: Some("no_path_found".to_string()),
1467 })
1468 }
1469 }
1470}
1471
1472fn indexed_file_count(conn: &Connection) -> Result<usize> {
1473 let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
1474 Ok(count.max(0) as usize)
1475}
1476
1477fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
1478 let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
1479 match candidates.as_slice() {
1480 [candidate] => Ok(candidate.clone()),
1481 [] => Err(AftError::SymbolNotFound {
1482 name: symbol.to_string(),
1483 file: rel_path.to_string(),
1484 }
1485 .into()),
1486 _ => Err(AftError::AmbiguousSymbol {
1487 name: symbol.to_string(),
1488 candidates: candidates
1489 .iter()
1490 .map(|candidate| candidate.symbol.clone())
1491 .collect(),
1492 }
1493 .into()),
1494 }
1495}
1496
1497fn nodes_for_file_matching_symbol(
1498 conn: &Connection,
1499 rel_path: &str,
1500 symbol: &str,
1501) -> Result<Vec<StoreNode>> {
1502 let qualified_query = symbol.contains("::");
1503 let sql = if qualified_query {
1504 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
1505 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
1506 FROM nodes n JOIN files f ON f.path = n.file_path
1507 WHERE n.file_path = ?1 AND n.scoped_name = ?2
1508 ORDER BY n.scoped_name, n.start_line, n.start_col"
1509 } else {
1510 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
1511 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
1512 FROM nodes n JOIN files f ON f.path = n.file_path
1513 WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
1514 ORDER BY n.scoped_name, n.start_line, n.start_col"
1515 };
1516 let mut stmt = conn.prepare(sql)?;
1517 let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
1518 rows.collect::<std::result::Result<Vec<_>, _>>()
1519 .map_err(Into::into)
1520}
1521
1522fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
1523 let qualified_query = symbol.contains("::");
1524 let sql = if qualified_query {
1525 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
1526 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
1527 FROM nodes n JOIN files f ON f.path = n.file_path
1528 WHERE n.scoped_name = ?1
1529 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
1530 } else {
1531 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
1532 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
1533 FROM nodes n JOIN files f ON f.path = n.file_path
1534 WHERE n.scoped_name = ?1 OR n.name = ?1
1535 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
1536 };
1537 let mut stmt = conn.prepare(sql)?;
1538 let rows = stmt.query_map(params![symbol], store_node_from_row)?;
1539 rows.collect::<std::result::Result<Vec<_>, _>>()
1540 .map_err(Into::into)
1541}
1542
1543fn load_node_by_id(conn: &Connection, node_id: &str) -> Result<Option<StoreNode>> {
1544 conn.query_row(
1545 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
1546 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
1547 FROM nodes n JOIN files f ON f.path = n.file_path
1548 WHERE n.id = ?1",
1549 params![node_id],
1550 store_node_from_row,
1551 )
1552 .optional()
1553 .map_err(Into::into)
1554}
1555
1556fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
1557 let start_line: u32 = row.get::<_, i64>(5)?.max(0) as u32;
1558 let end_line: u32 = row.get::<_, i64>(6)?.max(0) as u32;
1559 let lang_label_value: String = row.get(10)?;
1560 Ok(StoreNode {
1561 node_id: row.get(0)?,
1562 file: row.get(1)?,
1563 symbol: row.get(2)?,
1564 name: row.get(3)?,
1565 kind: row.get(4)?,
1566 line: start_line.saturating_add(1),
1567 end_line: end_line.saturating_add(1),
1568 signature: row.get(7)?,
1569 exported: row.get::<_, i64>(8)? != 0,
1570 is_entry_point: row.get::<_, i64>(9)? != 0,
1571 lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
1572 })
1573}
1574
1575#[allow(clippy::too_many_arguments)]
1576fn collect_callers_recursive(
1577 conn: &Connection,
1578 file: &str,
1579 symbol: &str,
1580 max_depth: usize,
1581 current_depth: usize,
1582 visited: &mut HashSet<(String, String)>,
1583 result: &mut Vec<StoreCallSite>,
1584 depth_limited: &mut bool,
1585 truncated: &mut usize,
1586) -> Result<()> {
1587 if current_depth >= max_depth {
1588 let omitted = direct_callers_for_tuple(conn, file, symbol)?.len();
1589 if omitted > 0 {
1590 *depth_limited = true;
1591 *truncated += omitted;
1592 }
1593 return Ok(());
1594 }
1595
1596 if !visited.insert((file.to_string(), symbol.to_string())) {
1597 return Ok(());
1598 }
1599
1600 let sites = direct_callers_for_tuple(conn, file, symbol)?;
1601 for site in sites {
1602 result.push(site.clone());
1603 if current_depth + 1 < max_depth {
1604 collect_callers_recursive(
1605 conn,
1606 &site.caller.file,
1607 &site.caller.symbol,
1608 max_depth,
1609 current_depth + 1,
1610 visited,
1611 result,
1612 depth_limited,
1613 truncated,
1614 )?;
1615 } else {
1616 let omitted =
1617 direct_callers_for_tuple(conn, &site.caller.file, &site.caller.symbol)?.len();
1618 if omitted > 0 {
1619 *depth_limited = true;
1620 *truncated += omitted;
1621 }
1622 }
1623 }
1624 Ok(())
1625}
1626
1627fn direct_callers_for_tuple(
1628 conn: &Connection,
1629 target_file: &str,
1630 target_symbol: &str,
1631) -> Result<Vec<StoreCallSite>> {
1632 let mut stmt = conn.prepare(
1633 "SELECT e.source_node, e.target_node, e.target_file, e.target_symbol, e.line,
1634 r.byte_start, r.byte_end, r.status, e.provenance
1635 FROM edges e JOIN refs r ON r.ref_id = e.ref_id
1636 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
1637 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
1638 )?;
1639 let rows = stmt.query_map(params![target_file, target_symbol], |row| {
1640 Ok((
1641 row.get::<_, String>(0)?,
1642 row.get::<_, Option<String>>(1)?,
1643 row.get::<_, String>(2)?,
1644 row.get::<_, String>(3)?,
1645 row.get::<_, i64>(4)?,
1646 row.get::<_, i64>(5)?,
1647 row.get::<_, i64>(6)?,
1648 row.get::<_, String>(7)?,
1649 row.get::<_, String>(8)?,
1650 ))
1651 })?;
1652
1653 let mut sites = Vec::new();
1654 for row in rows {
1655 let (
1656 source_node,
1657 target_node,
1658 target_file,
1659 target_symbol,
1660 line,
1661 byte_start,
1662 byte_end,
1663 status,
1664 provenance,
1665 ) = row?;
1666 let Some(caller) = load_node_by_id(conn, &source_node)? else {
1667 continue;
1668 };
1669 let target = target_node
1670 .as_deref()
1671 .map(|node_id| load_node_by_id(conn, node_id))
1672 .transpose()?
1673 .flatten();
1674 sites.push(StoreCallSite {
1675 caller,
1676 target_file,
1677 target_symbol,
1678 target,
1679 line: line.max(0) as u32,
1680 byte_start: byte_start.max(0) as usize,
1681 byte_end: byte_end.max(0) as usize,
1682 resolved: status == "resolved",
1683 provenance,
1684 });
1685 }
1686 Ok(sites)
1687}
1688
1689fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
1690 let mut stmt = conn.prepare(
1691 "SELECT e.target_node, e.target_file, e.target_symbol, e.line,
1692 r.byte_start, r.byte_end, r.status, e.provenance
1693 FROM edges e JOIN refs r ON r.ref_id = e.ref_id
1694 WHERE e.kind = 'call' AND e.source_node = ?1
1695 ORDER BY r.byte_start, r.line, r.ref_id",
1696 )?;
1697 let rows = stmt.query_map(params![node.node_id], |row| {
1698 Ok((
1699 row.get::<_, Option<String>>(0)?,
1700 row.get::<_, String>(1)?,
1701 row.get::<_, String>(2)?,
1702 row.get::<_, i64>(3)?,
1703 row.get::<_, i64>(4)?,
1704 row.get::<_, i64>(5)?,
1705 row.get::<_, String>(6)?,
1706 row.get::<_, String>(7)?,
1707 ))
1708 })?;
1709
1710 let mut calls = Vec::new();
1711 for row in rows {
1712 let (
1713 target_node,
1714 target_file,
1715 target_symbol,
1716 line,
1717 byte_start,
1718 byte_end,
1719 status,
1720 provenance,
1721 ) = row?;
1722 let target = target_node
1723 .as_deref()
1724 .map(|node_id| load_node_by_id(conn, node_id))
1725 .transpose()?
1726 .flatten();
1727 calls.push(StoreCallSite {
1728 caller: node.clone(),
1729 target_file,
1730 target_symbol,
1731 target,
1732 line: line.max(0) as u32,
1733 byte_start: byte_start.max(0) as usize,
1734 byte_end: byte_end.max(0) as usize,
1735 resolved: status == "resolved",
1736 provenance,
1737 });
1738 }
1739 Ok(calls)
1740}
1741
1742fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
1743 let mut stmt = conn.prepare(
1744 "SELECT r.target_node, r.target_file, r.target_symbol, r.line,
1745 r.byte_start, r.byte_end, r.status, r.provenance
1746 FROM refs r
1747 WHERE r.caller_node = ?1
1748 AND r.kind = 'call'
1749 AND r.status <> 'unresolved'
1750 AND r.target_file = ?2
1751 AND r.target_symbol = ?3
1752 AND r.provenance = ?4
1753 AND NOT EXISTS (
1754 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
1755 )
1756 ORDER BY r.byte_start, r.line, r.ref_id",
1757 )?;
1758 let rows = stmt.query_map(
1759 params![
1760 &node.node_id,
1761 &node.file,
1762 &node.symbol,
1763 PROVENANCE_TREESITTER
1764 ],
1765 |row| {
1766 Ok((
1767 row.get::<_, Option<String>>(0)?,
1768 row.get::<_, String>(1)?,
1769 row.get::<_, String>(2)?,
1770 row.get::<_, i64>(3)?,
1771 row.get::<_, i64>(4)?,
1772 row.get::<_, i64>(5)?,
1773 row.get::<_, String>(6)?,
1774 row.get::<_, String>(7)?,
1775 ))
1776 },
1777 )?;
1778
1779 let mut calls = Vec::new();
1780 for row in rows {
1781 let (
1782 target_node,
1783 target_file,
1784 target_symbol,
1785 line,
1786 byte_start,
1787 byte_end,
1788 status,
1789 provenance,
1790 ) = row?;
1791 let target = target_node
1792 .as_deref()
1793 .map(|node_id| load_node_by_id(conn, node_id))
1794 .transpose()?
1795 .flatten();
1796 calls.push(StoreCallSite {
1797 caller: node.clone(),
1798 target_file,
1799 target_symbol,
1800 target,
1801 line: line.max(0) as u32,
1802 byte_start: byte_start.max(0) as usize,
1803 byte_end: byte_end.max(0) as usize,
1804 resolved: status == "resolved",
1805 provenance,
1806 });
1807 }
1808 Ok(calls)
1809}
1810
1811fn unresolved_calls_for_node(
1812 conn: &Connection,
1813 node: &StoreNode,
1814) -> Result<Vec<StoreUnresolvedCall>> {
1815 let mut stmt = conn.prepare(
1816 "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
1817 FROM refs
1818 WHERE caller_node = ?1
1819 AND kind = 'call'
1820 AND status = 'unresolved'
1821 AND NOT EXISTS (
1822 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
1823 )
1824 ORDER BY byte_start, line, ref_id",
1825 )?;
1826 let rows = stmt.query_map(params![node.node_id], |row| {
1827 Ok(StoreUnresolvedCall {
1828 caller: node.clone(),
1829 symbol: row.get(0)?,
1830 full_ref: row.get(1)?,
1831 line: row.get::<_, i64>(2)?.max(0) as u32,
1832 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
1833 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
1834 })
1835 })?;
1836 rows.collect::<std::result::Result<Vec<_>, _>>()
1837 .map_err(Into::into)
1838}
1839
1840fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
1841 let mut calls = Vec::new();
1842 calls.extend(
1843 outgoing_calls_for_node(conn, node)?
1844 .into_iter()
1845 .map(StoreForwardCall::Resolved),
1846 );
1847 calls.extend(
1848 unresolved_calls_for_node(conn, node)?
1849 .into_iter()
1850 .map(StoreForwardCall::Unresolved),
1851 );
1852 calls.sort_by(|left, right| {
1853 left.byte_start()
1854 .cmp(&right.byte_start())
1855 .then(left.line().cmp(&right.line()))
1856 });
1857 Ok(calls)
1858}
1859
1860fn call_tree_inner(
1861 conn: &Connection,
1862 node: &StoreNode,
1863 max_depth: usize,
1864 current_depth: usize,
1865 visited: &mut HashSet<(String, String)>,
1866) -> Result<callgraph::CallTreeNode> {
1867 let visit_key = (node.file.clone(), node.symbol.clone());
1868 if visited.contains(&visit_key) {
1869 return Ok(callgraph::CallTreeNode {
1870 name: node.symbol.clone(),
1871 file: node.file.clone(),
1872 line: node.line,
1873 signature: node.signature.clone(),
1874 resolved: true,
1875 children: Vec::new(),
1876 depth_limited: false,
1877 truncated: 0,
1878 });
1879 }
1880 visited.insert(visit_key.clone());
1881
1882 let calls = forward_calls_for_node(conn, node)?;
1883 let mut children = Vec::new();
1884 let mut depth_limited = false;
1885 let mut truncated = 0usize;
1886
1887 if current_depth < max_depth {
1888 for call in calls {
1889 match call {
1890 StoreForwardCall::Resolved(site) => {
1891 if let Some(target) = site.target {
1892 let child =
1893 call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
1894 depth_limited |= child.depth_limited;
1895 truncated += child.truncated;
1896 children.push(child);
1897 } else {
1898 children.push(callgraph::CallTreeNode {
1899 name: site.target_symbol,
1900 file: site.target_file,
1901 line: site.line,
1902 signature: None,
1903 resolved: false,
1904 children: Vec::new(),
1905 depth_limited: false,
1906 truncated: 0,
1907 });
1908 }
1909 }
1910 StoreForwardCall::Unresolved(call) => {
1911 children.push(callgraph::CallTreeNode {
1912 name: call.symbol,
1913 file: call.caller.file,
1914 line: call.line,
1915 signature: None,
1916 resolved: false,
1917 children: Vec::new(),
1918 depth_limited: false,
1919 truncated: 0,
1920 });
1921 }
1922 }
1923 }
1924 } else if !calls.is_empty() {
1925 depth_limited = true;
1926 truncated = calls.len();
1927 }
1928
1929 visited.remove(&visit_key);
1930 Ok(callgraph::CallTreeNode {
1931 name: node.symbol.clone(),
1932 file: node.file.clone(),
1933 line: node.line,
1934 signature: node.signature.clone(),
1935 resolved: true,
1936 children,
1937 depth_limited,
1938 truncated,
1939 })
1940}
1941
1942fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
1943 callgraph::TraceToSymbolHop {
1944 symbol: node.symbol.clone(),
1945 file: node.file.clone(),
1946 line: node.line,
1947 }
1948}
1949
1950fn trace_to_symbol_matches_target(
1951 node: &StoreNode,
1952 to_symbol: &str,
1953 to_file: Option<&str>,
1954) -> bool {
1955 if !symbol_query_matches(&node.symbol, to_symbol) {
1956 return false;
1957 }
1958 match to_file {
1959 Some(file) => node.file == file,
1960 None => true,
1961 }
1962}
1963
1964fn symbol_query_matches(symbol: &str, query: &str) -> bool {
1965 symbol == query || unqualified_name(symbol) == query
1966}
1967
1968fn read_source_line(path: &Path, line: u32) -> Option<String> {
1969 let source = std::fs::read_to_string(path).ok()?;
1970 source
1971 .lines()
1972 .nth(line.saturating_sub(1) as usize)
1973 .map(|line| line.trim().to_string())
1974}
1975
1976#[doc(hidden)]
1977pub fn live_callgraph_edge_snapshot(
1978 project_root: &Path,
1979 files: &[PathBuf],
1980) -> Result<BTreeSet<StoredEdge>> {
1981 let files = normalize_file_list(project_root, files)?;
1982 let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
1983 let mut file_data = Vec::new();
1984 for file in &files {
1985 let canon = canonicalize_path(file);
1986 let data = graph.build_file(&canon)?.clone();
1987 file_data.push((canon, data));
1988 }
1989
1990 let mut edges = BTreeSet::new();
1991 for (caller_file, data) in &file_data {
1992 for (caller_symbol, call_sites) in &data.calls_by_symbol {
1993 for call_site in call_sites {
1994 let resolution = graph.resolve_cross_file_edge(
1995 &call_site.full_callee,
1996 &call_site.callee_name,
1997 caller_file,
1998 &data.import_block,
1999 );
2000 let (target_file, target_symbol) = match resolution {
2001 EdgeResolution::Resolved { file, symbol } => (file, symbol),
2002 EdgeResolution::Unresolved { callee_name } => {
2003 if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
2004 continue;
2005 }
2006 let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
2007 data,
2008 caller_file,
2009 &callee_name,
2010 ) else {
2011 continue;
2012 };
2013 (caller_file.clone(), target_symbol)
2014 }
2015 };
2016 if target_file == *caller_file && target_symbol == *caller_symbol {
2017 continue;
2018 }
2019 edges.insert(StoredEdge {
2020 source_file: relative_path(project_root, caller_file),
2021 source_symbol: caller_symbol.clone(),
2022 target_file: relative_path(project_root, &target_file),
2023 target_symbol,
2024 kind: "call".to_string(),
2025 line: call_site.line,
2026 });
2027 }
2028 }
2029 }
2030 Ok(edges)
2031}
2032
2033fn configure_connection(conn: &Connection) -> Result<()> {
2034 conn.pragma_update(None, "journal_mode", "WAL")?;
2035 conn.pragma_update(None, "busy_timeout", 5_000)?;
2036 Ok(())
2037}
2038
2039fn configure_build_connection(conn: &Connection) -> Result<()> {
2040 conn.pragma_update(None, "journal_mode", "DELETE")?;
2041 conn.pragma_update(None, "busy_timeout", 5_000)?;
2042 Ok(())
2043}
2044
2045fn initialize_schema(conn: &Connection) -> Result<()> {
2046 conn.execute_batch(
2047 "CREATE TABLE IF NOT EXISTS files (
2048 path TEXT PRIMARY KEY,
2049 content_hash TEXT NOT NULL,
2050 mtime_ns INTEGER NOT NULL,
2051 size INTEGER NOT NULL,
2052 lang TEXT NOT NULL,
2053 is_dead_code_root INTEGER NOT NULL DEFAULT 0,
2054 is_public_api INTEGER NOT NULL DEFAULT 0,
2055 surface_fingerprint TEXT NOT NULL,
2056 indexed_at INTEGER NOT NULL
2057 );
2058
2059 CREATE TABLE IF NOT EXISTS nodes (
2060 id TEXT PRIMARY KEY,
2061 file_path TEXT NOT NULL,
2062 name TEXT NOT NULL,
2063 scoped_name TEXT NOT NULL,
2064 kind TEXT NOT NULL,
2065 start_line INTEGER NOT NULL,
2066 start_col INTEGER NOT NULL,
2067 end_line INTEGER NOT NULL,
2068 end_col INTEGER NOT NULL,
2069 range_ordinal INTEGER NOT NULL,
2070 signature TEXT,
2071 exported INTEGER NOT NULL,
2072 is_default_export INTEGER NOT NULL,
2073 is_type_like INTEGER NOT NULL,
2074 is_callgraph_entry_point INTEGER NOT NULL,
2075 provenance TEXT NOT NULL,
2076 UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
2077 );
2078 CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
2079 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
2080 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
2081
2082 CREATE TABLE IF NOT EXISTS refs (
2083 ref_id TEXT PRIMARY KEY,
2084 caller_node TEXT,
2085 caller_file TEXT NOT NULL,
2086 kind TEXT NOT NULL,
2087 short_name TEXT,
2088 full_ref TEXT,
2089 module_path TEXT,
2090 import_kind TEXT,
2091 local_name TEXT,
2092 requested_name TEXT,
2093 namespace_alias TEXT,
2094 wildcard INTEGER NOT NULL DEFAULT 0,
2095 line INTEGER NOT NULL,
2096 byte_start INTEGER NOT NULL,
2097 byte_end INTEGER NOT NULL,
2098 status TEXT NOT NULL,
2099 target_node TEXT,
2100 target_file TEXT,
2101 target_symbol TEXT,
2102 provenance TEXT NOT NULL
2103 );
2104 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
2105 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
2106 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
2107 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
2108
2109 CREATE TABLE IF NOT EXISTS file_dependencies (
2110 file_path TEXT NOT NULL,
2111 dep_file TEXT NOT NULL,
2112 PRIMARY KEY(file_path, dep_file)
2113 );
2114 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
2115
2116 CREATE TABLE IF NOT EXISTS edges (
2117 edge_id TEXT PRIMARY KEY,
2118 ref_id TEXT NOT NULL,
2119 source_node TEXT NOT NULL,
2120 target_node TEXT,
2121 target_file TEXT NOT NULL,
2122 target_symbol TEXT NOT NULL,
2123 kind TEXT NOT NULL,
2124 line INTEGER NOT NULL,
2125 provenance TEXT NOT NULL
2126 );
2127 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
2128 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
2129 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
2130 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
2131
2132 CREATE TABLE IF NOT EXISTS dispatch_hints (
2133 id TEXT PRIMARY KEY,
2134 method_name TEXT NOT NULL,
2135 caller_node TEXT NOT NULL,
2136 file TEXT NOT NULL,
2137 line INTEGER NOT NULL,
2138 byte_start INTEGER NOT NULL,
2139 byte_end INTEGER NOT NULL,
2140 provenance TEXT NOT NULL
2141 );
2142 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
2143
2144 CREATE TABLE IF NOT EXISTS type_ref_names (
2145 name TEXT PRIMARY KEY
2146 );
2147
2148 CREATE TABLE IF NOT EXISTS backend_file_state (
2149 backend TEXT NOT NULL,
2150 workspace_root TEXT NOT NULL,
2151 file_path TEXT NOT NULL,
2152 content_hash TEXT NOT NULL,
2153 status TEXT NOT NULL,
2154 updated_at INTEGER NOT NULL,
2155 PRIMARY KEY(backend, workspace_root, file_path, content_hash)
2156 );
2157 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
2158
2159 CREATE TABLE IF NOT EXISTS meta (
2160 k TEXT PRIMARY KEY,
2161 v TEXT NOT NULL
2162 );",
2163 )?;
2164 insert_meta(conn)?;
2165 Ok(())
2166}
2167
2168fn insert_meta(conn: &Connection) -> Result<()> {
2169 conn.execute(
2170 "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
2171 params![SCHEMA_VERSION.to_string()],
2172 )?;
2173 conn.execute(
2174 "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
2175 params![schema_fingerprint()],
2176 )?;
2177 Ok(())
2178}
2179
2180fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
2181 conn.execute(
2182 "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
2183 params![if ready { "1" } else { "0" }],
2184 )?;
2185 Ok(())
2186}
2187
2188fn database_ready(conn: &Connection) -> Result<bool> {
2189 let schema_version: Option<String> = conn
2190 .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
2191 row.get(0)
2192 })
2193 .optional()?;
2194 let fingerprint: Option<String> = conn
2195 .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
2196 row.get(0)
2197 })
2198 .optional()?;
2199 let ready: Option<String> = conn
2200 .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
2201 .optional()?;
2202
2203 let expected_schema = SCHEMA_VERSION.to_string();
2204 let expected_fingerprint = schema_fingerprint();
2205 Ok(schema_version.as_deref() == Some(expected_schema.as_str())
2206 && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
2207 && ready.as_deref() == Some("1"))
2208}
2209
2210fn ensure_database_ready(conn: &Connection) -> Result<()> {
2211 if database_ready(conn)? {
2212 Ok(())
2213 } else {
2214 Err(CallGraphStoreError::Unavailable(
2215 "database is missing, stale, or mid-build".to_string(),
2216 ))
2217 }
2218}
2219
2220fn schema_fingerprint() -> String {
2221 let input = format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v7-lean");
2226 hash_to_hex(blake3::hash(input.as_bytes()))
2227}
2228
2229fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
2230 tx.execute_batch(
2231 "DELETE FROM edges;
2232 DELETE FROM file_dependencies;
2233 DELETE FROM refs;
2234 DELETE FROM dispatch_hints;
2235 DELETE FROM type_ref_names;
2236 DELETE FROM backend_file_state;
2237 DELETE FROM nodes;
2238 DELETE FROM files;",
2239 )?;
2240 Ok(())
2241}
2242
2243const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
2244 ("files", "path"),
2245 ("nodes", "file_path"),
2246 ("refs", "caller_file"),
2247 ("refs", "target_file"),
2248 ("file_dependencies", "file_path"),
2249 ("file_dependencies", "dep_file"),
2250 ("edges", "target_file"),
2251 ("dispatch_hints", "file"),
2252 ("backend_file_state", "file_path"),
2253];
2254
2255fn reconcile_workspace_roots(conn: &mut Connection, project_root: &Path) -> Result<OpenRootRepair> {
2268 let roots = stored_workspace_roots(conn)?;
2269 let current_root = project_root.display().to_string();
2270 if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
2271 return Ok(OpenRootRepair::None);
2272 }
2273
2274 if let Some(sample) = sample_absolute_data_path(conn)? {
2275 return Ok(OpenRootRepair::NeedsRebuild {
2276 previous_roots: roots,
2277 current_root,
2278 reason: format!("absolute store data path row {sample}"),
2279 });
2280 }
2281
2282 for stored_root in roots.iter() {
2283 if stored_root == ¤t_root {
2284 continue;
2285 }
2286 if Path::new(stored_root).exists() {
2287 let reason = format!(
2288 "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
2289 );
2290 return Ok(OpenRootRepair::NeedsRebuild {
2291 previous_roots: roots,
2292 current_root,
2293 reason,
2294 });
2295 }
2296 }
2297
2298 let tx = conn.transaction()?;
2299 tx.execute(
2300 "UPDATE OR IGNORE backend_file_state
2301 SET workspace_root = ?1
2302 WHERE workspace_root <> ?1",
2303 params![¤t_root],
2304 )?;
2305 tx.execute(
2306 "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
2307 params![¤t_root],
2308 )?;
2309 tx.commit()?;
2310
2311 crate::slog_info!(
2312 "callgraph store re-rooted from {} to {}",
2313 roots.join(", "),
2314 current_root
2315 );
2316 Ok(OpenRootRepair::ReRooted)
2317}
2318
2319fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
2320 let mut stmt = conn.prepare(
2321 "SELECT DISTINCT workspace_root
2322 FROM backend_file_state
2323 ORDER BY workspace_root",
2324 )?;
2325 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
2326 rows.collect::<std::result::Result<Vec<_>, _>>()
2327 .map_err(Into::into)
2328}
2329
2330fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
2331 for (table, column) in STORE_DATA_PATH_COLUMNS {
2332 let sql = format!(
2333 "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
2334 );
2335 let mut stmt = conn.prepare(&sql)?;
2336 let mut rows = stmt.query([])?;
2337 while let Some(row) = rows.next()? {
2338 let value: String = row.get(0)?;
2339 if stored_path_is_absolute(&value) {
2340 return Ok(Some(format!("{table}.{column}={value}")));
2341 }
2342 }
2343 }
2344 Ok(None)
2345}
2346
2347fn stored_path_is_absolute(value: &str) -> bool {
2348 if value.is_empty() {
2349 return false;
2350 }
2351 if Path::new(value).is_absolute() || value.starts_with('/') {
2352 return true;
2353 }
2354 let bytes = value.as_bytes();
2355 if bytes.len() >= 3
2356 && bytes[1] == b':'
2357 && (bytes[2] == b'/' || bytes[2] == b'\\')
2358 && bytes[0].is_ascii_alphabetic()
2359 {
2360 return true;
2361 }
2362 value.starts_with("\\\\") || value.starts_with("//")
2363}
2364
2365fn log_root_repair_rebuild(repair: &OpenRootRepair) {
2366 if let OpenRootRepair::NeedsRebuild {
2367 previous_roots,
2368 current_root,
2369 reason,
2370 } = repair
2371 {
2372 crate::slog_info!(
2373 "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
2374 previous_roots.join(", "),
2375 current_root,
2376 reason
2377 );
2378 }
2379}
2380
2381fn now_nanos() -> u128 {
2383 SystemTime::now()
2384 .duration_since(UNIX_EPOCH)
2385 .unwrap_or(Duration::ZERO)
2386 .as_nanos()
2387}
2388
2389fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
2394 callgraph_dir.join(format!("{project_key}.current"))
2395}
2396
2397fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
2401 callgraph_dir.join(format!("{project_key}.sqlite"))
2402}
2403
2404fn generation_file_name(project_key: &str) -> String {
2408 format!(
2409 "{project_key}.g{}.{}.sqlite",
2410 now_nanos(),
2411 std::process::id()
2412 )
2413}
2414
2415fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
2417 let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
2418 let name = text.trim();
2419 if name.is_empty() {
2420 None
2421 } else {
2422 Some(name.to_string())
2423 }
2424}
2425
2426fn db_path_ready(path: &Path) -> bool {
2429 (|| -> Result<bool> {
2430 let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
2431 conn.busy_timeout(Duration::from_millis(5_000))?;
2432 database_ready(&conn)
2433 })()
2434 .unwrap_or(false)
2435}
2436
2437fn resolve_ready_target(
2445 callgraph_dir: &Path,
2446 project_key: &str,
2447) -> Option<(PathBuf, Option<String>)> {
2448 for _ in 0..5 {
2449 if let Some(generation) = read_pointer(callgraph_dir, project_key) {
2450 let gen_path = callgraph_dir.join(&generation);
2451 if gen_path.is_file() {
2452 return db_path_ready(&gen_path).then_some((gen_path, Some(generation)));
2453 }
2454 std::thread::sleep(Duration::from_millis(5));
2457 continue;
2458 }
2459 let legacy = legacy_sqlite_path(callgraph_dir, project_key);
2461 return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
2462 }
2463 None
2464}
2465
2466fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
2470 let pointer = pointer_path(callgraph_dir, project_key);
2471 let tmp = callgraph_dir.join(format!(
2472 "{project_key}.current.tmp.{}.{}",
2473 std::process::id(),
2474 now_nanos()
2475 ));
2476 {
2477 use std::io::Write as _;
2478 let mut file = std::fs::File::create(&tmp)?;
2479 file.write_all(generation.as_bytes())?;
2480 file.write_all(b"\n")?;
2481 file.sync_all()?;
2482 }
2483 if let Err(error) = std::fs::rename(&tmp, &pointer) {
2484 let _ = std::fs::remove_file(&tmp);
2485 return Err(error.into());
2486 }
2487 Ok(())
2488}
2489
2490fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
2496 let grace = Duration::from_secs(60);
2497 let now = SystemTime::now();
2498 let gen_prefix = format!("{project_key}.g");
2499 let tmp_prefixes = [
2500 format!("{project_key}.g"), format!("{project_key}.current."), format!("{project_key}.sqlite.tmp."), ];
2504 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
2505 return;
2506 };
2507 let mut gens: Vec<(PathBuf, SystemTime)> = Vec::new();
2508 for entry in entries.flatten() {
2509 let name = entry.file_name();
2510 let name = name.to_string_lossy();
2511 let mtime = entry
2512 .metadata()
2513 .and_then(|m| m.modified())
2514 .unwrap_or_else(|_| SystemTime::now());
2515 let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= grace;
2516
2517 if name.contains(".tmp.") {
2519 if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
2520 let _ = std::fs::remove_file(entry.path());
2521 }
2522 continue;
2523 }
2524
2525 if *name == *format!("{project_key}.sqlite") {
2528 remove_sqlite_file_set(&entry.path());
2529 continue;
2530 }
2531
2532 if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") && name != current {
2533 gens.push((entry.path(), mtime));
2534 }
2535 }
2536 gens.sort_by(|a, b| b.1.cmp(&a.1));
2541 for (index, (path, mtime)) in gens.into_iter().enumerate() {
2542 if index == 0 {
2543 continue;
2544 }
2545 if now.duration_since(mtime).unwrap_or(Duration::ZERO) < grace {
2546 continue;
2547 }
2548 remove_sqlite_file_set(&path);
2549 }
2550}
2551
2552fn remove_sqlite_file_set(path: &Path) {
2553 let _ = std::fs::remove_file(path);
2554 remove_sqlite_sidecars(path);
2555}
2556
2557fn remove_sqlite_sidecars(path: &Path) {
2558 let path_text = path.to_string_lossy();
2559 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
2560 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
2561 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
2562}
2563
2564fn build_pool_size() -> usize {
2572 std::thread::available_parallelism()
2573 .map(|parallelism| parallelism.get())
2574 .unwrap_or(1)
2575 .div_ceil(2)
2576 .clamp(1, 8)
2577}
2578
2579fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
2580 let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
2581 Ok(extract) => Ok(extract),
2582 Err(error) => {
2583 let abs_path =
2584 normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
2585 let rel_path = relative_path(project_root, &abs_path);
2586 let freshness = cache_freshness::collect(&abs_path).ok();
2587 log::debug!(
2588 "callgraph store: skipping {} during cold build: {}",
2589 abs_path.display(),
2590 error
2591 );
2592 Err(ExtractFailure {
2593 rel_path,
2594 freshness,
2595 })
2596 }
2597 };
2598
2599 let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
2600 files.par_iter().map(extract_one).collect()
2601 };
2602
2603 let results = match rayon::ThreadPoolBuilder::new()
2606 .num_threads(build_pool_size())
2607 .thread_name(|index| format!("aft-callgraph-build-{index}"))
2608 .stack_size(8 * 1024 * 1024)
2609 .build()
2610 {
2611 Ok(pool) => pool.install(run),
2612 Err(error) => {
2613 log::warn!(
2614 "callgraph store: bounded build pool unavailable ({error}); using global pool"
2615 );
2616 run()
2617 }
2618 };
2619
2620 let mut extracts = Vec::new();
2621 let mut failures = Vec::new();
2622 for result in results {
2623 match result {
2624 Ok(extract) => extracts.push(extract),
2625 Err(failure) => failures.push(failure),
2626 }
2627 }
2628 BuildExtractsResult { extracts, failures }
2629}
2630
2631fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
2632 let abs_path = normalize_file_path(project_root, path)?;
2633 let rel_path = relative_path(project_root, &abs_path);
2634 let source = std::fs::read_to_string(&abs_path)?;
2635 let freshness = cache_freshness::collect(&abs_path)?;
2636 let data = callgraph::build_file_data(&abs_path)?;
2637 let lang = data.lang;
2638 let mut nodes = build_node_records(&rel_path, &source, &data)?;
2639 let node_by_scoped: HashMap<String, String> = nodes
2640 .iter()
2641 .map(|node| (node.scoped_name.clone(), node.id.clone()))
2642 .collect();
2643 let import_dependencies =
2644 import_dependencies(project_root, &abs_path, &data.import_block.imports);
2645 let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
2646 let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
2647 let mut raw_refs = Vec::new();
2648 raw_refs.extend(build_call_refs(
2649 &rel_path,
2650 &data,
2651 &node_by_scoped,
2652 &import_dependencies,
2653 ));
2654 raw_refs.extend(build_import_refs(
2655 project_root,
2656 &abs_path,
2657 &rel_path,
2658 &data.import_block.imports,
2659 ));
2660 let mut surface_parts = reexports.surface_parts;
2661 surface_parts.extend(source_less_exports.surface_parts);
2662 raw_refs.extend(reexports.raw_refs);
2663 raw_refs.extend(source_less_exports.raw_refs);
2664 let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
2665 let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
2666
2667 Ok(FileExtract {
2668 abs_path,
2669 rel_path,
2670 freshness,
2671 lang,
2672 data,
2673 nodes,
2674 raw_refs,
2675 dispatch_hints,
2676 surface_fingerprint,
2677 })
2678}
2679
2680fn build_node_records(
2681 rel_path: &str,
2682 source: &str,
2683 data: &FileCallData,
2684) -> Result<Vec<NodeRecord>> {
2685 let mut records = Vec::new();
2686 let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
2687 let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
2688 metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
2689
2690 for (scoped_name, meta) in metadata {
2691 let name = unqualified_name(scoped_name).to_string();
2692 let range = selection_range(source, scoped_name, &name, &meta.range);
2693 let range_key = (
2694 range.start_line,
2695 range.start_col,
2696 range.end_line,
2697 range.end_col,
2698 );
2699 let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
2700 let range_ordinal = *ordinal;
2701 *ordinal += 1;
2702 let id = node_id(rel_path, &range, range_ordinal, scoped_name);
2703 let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
2704 let is_default_export = data
2705 .default_export_symbol
2706 .as_deref()
2707 .map(|default| default == scoped_name || default == name)
2708 .unwrap_or(false);
2709 records.push(NodeRecord {
2710 id,
2711 file_path: rel_path.to_string(),
2712 name: name.clone(),
2713 scoped_name: scoped_name.clone(),
2714 kind: symbol_kind_label(&meta.kind).to_string(),
2715 range,
2716 range_ordinal,
2717 signature: meta.signature.clone(),
2718 exported,
2719 is_default_export,
2720 is_type_like: is_type_like(&meta.kind),
2721 is_callgraph_entry_point: callgraph::is_entry_point(
2722 scoped_name,
2723 &meta.kind,
2724 exported,
2725 data.lang,
2726 ),
2727 });
2728 }
2729
2730 Ok(records)
2731}
2732
2733fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
2734 if scoped_name == TOP_LEVEL_SYMBOL {
2735 return Range {
2736 start_line: 0,
2737 start_col: 0,
2738 end_line: 0,
2739 end_col: 0,
2740 };
2741 }
2742 let Some(line) = source.lines().nth(fallback.start_line as usize) else {
2743 return fallback.clone();
2744 };
2745 let start_col = fallback.start_col as usize;
2746 let search_start = start_col.min(line.len());
2747 if let Some(offset) = line[search_start..].find(name) {
2748 let col = search_start + offset;
2749 return Range {
2750 start_line: fallback.start_line,
2751 start_col: col as u32,
2752 end_line: fallback.start_line,
2753 end_col: (col + name.len()) as u32,
2754 };
2755 }
2756 if let Some(offset) = line.find(name) {
2757 return Range {
2758 start_line: fallback.start_line,
2759 start_col: offset as u32,
2760 end_line: fallback.start_line,
2761 end_col: (offset + name.len()) as u32,
2762 };
2763 }
2764 Range {
2765 start_line: fallback.start_line,
2766 start_col: fallback.start_col,
2767 end_line: fallback.start_line,
2768 end_col: fallback.start_col.saturating_add(name.len() as u32),
2769 }
2770}
2771
2772fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
2773 if scoped_name == TOP_LEVEL_SYMBOL {
2774 return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
2775 }
2776 let input = format!(
2777 "{rel_path}:{}:{}:{}:{}:{ordinal}",
2778 range.start_line, range.start_col, range.end_line, range.end_col
2779 );
2780 format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
2781}
2782
2783fn build_call_refs(
2784 rel_path: &str,
2785 data: &FileCallData,
2786 node_by_scoped: &HashMap<String, String>,
2787 import_dependencies: &BTreeSet<String>,
2788) -> Vec<RawRef> {
2789 let mut refs = Vec::new();
2790 let mut ordinal = 0usize;
2791 let mut symbols: Vec<_> = data.calls_by_symbol.iter().collect();
2792 symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
2793 for (caller_symbol, call_sites) in symbols {
2794 let caller_node = node_by_scoped.get(caller_symbol).cloned();
2795 for call_site in call_sites {
2796 ordinal += 1;
2797 let ref_id = ref_id(&[
2798 rel_path,
2799 "call",
2800 caller_symbol,
2801 &call_site.line.to_string(),
2802 &call_site.byte_start.to_string(),
2803 &call_site.byte_end.to_string(),
2804 &call_site.full_callee,
2805 &ordinal.to_string(),
2806 ]);
2807 refs.push(RawRef {
2808 ref_id,
2809 caller_node: caller_node.clone(),
2810 caller_symbol: Some(caller_symbol.clone()),
2811 caller_file: rel_path.to_string(),
2812 kind: "call".to_string(),
2813 short_name: Some(call_site.callee_name.clone()),
2814 full_ref: Some(call_site.full_callee.clone()),
2815 module_path: None,
2816 import_kind: None,
2817 local_name: Some(call_site.callee_name.clone()),
2818 requested_name: Some(call_site.callee_name.clone()),
2819 namespace_alias: namespace_alias(&call_site.full_callee),
2820 wildcard: false,
2821 line: call_site.line,
2822 byte_start: call_site.byte_start,
2823 byte_end: call_site.byte_end,
2824 dependencies: import_dependencies.clone(),
2825 });
2826 }
2827 }
2828 refs
2829}
2830
2831fn build_import_refs(
2832 project_root: &Path,
2833 abs_path: &Path,
2834 rel_path: &str,
2835 imports: &[ImportStatement],
2836) -> Vec<RawRef> {
2837 let mut refs = Vec::new();
2838 for (index, import) in imports.iter().enumerate() {
2839 let import_kind = import_kind_label(import.kind).to_string();
2840 let local_name = import_local_names(import).join(",");
2841 let requested_name = import_requested_names(import).join(",");
2842 let ref_id = ref_id(&[
2843 rel_path,
2844 "import",
2845 &import.byte_range.start.to_string(),
2846 &import.byte_range.end.to_string(),
2847 &import.module_path,
2848 &index.to_string(),
2849 ]);
2850 refs.push(RawRef {
2851 ref_id,
2852 caller_node: None,
2853 caller_symbol: None,
2854 caller_file: rel_path.to_string(),
2855 kind: "import".to_string(),
2856 short_name: None,
2857 full_ref: Some(import.raw_text.clone()),
2858 module_path: Some(import.module_path.clone()),
2859 import_kind: Some(import_kind),
2860 local_name: empty_to_none(local_name),
2861 requested_name: empty_to_none(requested_name),
2862 namespace_alias: import.namespace_import.clone(),
2863 wildcard: import_is_wildcard(import),
2864 line: byte_to_line(abs_path, import.byte_range.start).unwrap_or(1),
2865 byte_start: import.byte_range.start,
2866 byte_end: import.byte_range.end,
2867 dependencies: module_dependencies(project_root, abs_path, &import.module_path),
2868 });
2869 }
2870 refs
2871}
2872
2873#[derive(Debug, Clone)]
2874struct ReexportRefs {
2875 raw_refs: Vec<RawRef>,
2876 surface_parts: Vec<String>,
2877}
2878
2879fn collect_reexport_refs(
2880 project_root: &Path,
2881 abs_path: &Path,
2882 rel_path: &str,
2883 source: &str,
2884) -> ReexportRefs {
2885 let mut raw_refs = Vec::new();
2886 let mut surface_parts = Vec::new();
2887 let mut search_start = 0usize;
2888 let mut ordinal = 0usize;
2889 while let Some(export_offset) = source[search_start..].find("export") {
2890 let start = search_start + export_offset;
2891 let Some(statement_end_offset) = source[start..].find(';') else {
2892 break;
2893 };
2894 let end = start + statement_end_offset + 1;
2895 let statement = &source[start..end];
2896 search_start = end;
2897 if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
2898 continue;
2899 }
2900 let Some(module_path) = quoted_module_path(statement) else {
2901 continue;
2902 };
2903 ordinal += 1;
2904 let wildcard = statement.contains('*');
2905 let line = source[..start]
2906 .bytes()
2907 .filter(|byte| *byte == b'\n')
2908 .count() as u32
2909 + 1;
2910 let ref_id = ref_id(&[
2911 rel_path,
2912 "reexport",
2913 &start.to_string(),
2914 &end.to_string(),
2915 &module_path,
2916 &ordinal.to_string(),
2917 ]);
2918 surface_parts.push(format!("reexport\t{statement}"));
2919 raw_refs.push(RawRef {
2920 ref_id,
2921 caller_node: None,
2922 caller_symbol: None,
2923 caller_file: rel_path.to_string(),
2924 kind: "reexport".to_string(),
2925 short_name: None,
2926 full_ref: Some(statement.to_string()),
2927 module_path: Some(module_path.clone()),
2928 import_kind: Some("reexport".to_string()),
2929 local_name: None,
2930 requested_name: None,
2931 namespace_alias: None,
2932 wildcard,
2933 line,
2934 byte_start: start,
2935 byte_end: end,
2936 dependencies: module_dependencies(project_root, abs_path, &module_path),
2937 });
2938 }
2939 ReexportRefs {
2940 raw_refs,
2941 surface_parts,
2942 }
2943}
2944
2945fn quoted_module_path(statement: &str) -> Option<String> {
2946 let quote = match (statement.find('\''), statement.find('"')) {
2947 (Some(single), Some(double)) if single < double => '\'',
2948 (Some(_), Some(_)) => '"',
2949 (Some(_), None) => '\'',
2950 (None, Some(_)) => '"',
2951 (None, None) => return None,
2952 };
2953 let start = statement.find(quote)? + 1;
2954 let end = statement[start..].find(quote)? + start;
2955 Some(statement[start..end].to_string())
2956}
2957
2958#[derive(Debug, Clone)]
2959struct SourceLessExportRefs {
2960 raw_refs: Vec<RawRef>,
2961 surface_parts: Vec<String>,
2962}
2963
2964fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
2965 let mut raw_refs = Vec::new();
2966 let mut surface_parts = Vec::new();
2967 let mut search_start = 0usize;
2968 let mut ordinal = 0usize;
2969 while let Some(export_offset) = source[search_start..].find("export") {
2970 let start = search_start + export_offset;
2971 let Some(statement_end_offset) = source[start..].find(';') else {
2972 break;
2973 };
2974 let end = start + statement_end_offset + 1;
2975 let statement = &source[start..end];
2976 search_start = end;
2977 if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
2978 continue;
2979 }
2980 let aliases = parse_reexport_names(statement);
2981 if aliases.is_empty() {
2982 continue;
2983 }
2984 let line = source[..start]
2985 .bytes()
2986 .filter(|byte| *byte == b'\n')
2987 .count() as u32
2988 + 1;
2989 for (exported, source_symbol) in aliases {
2990 ordinal += 1;
2991 let ref_id = ref_id(&[
2992 rel_path,
2993 "export_alias",
2994 &start.to_string(),
2995 &end.to_string(),
2996 &exported,
2997 &source_symbol,
2998 &ordinal.to_string(),
2999 ]);
3000 surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
3001 raw_refs.push(RawRef {
3002 ref_id,
3003 caller_node: None,
3004 caller_symbol: None,
3005 caller_file: rel_path.to_string(),
3006 kind: "export_alias".to_string(),
3007 short_name: None,
3008 full_ref: Some(statement.to_string()),
3009 module_path: None,
3010 import_kind: Some("export_alias".to_string()),
3011 local_name: Some(exported),
3012 requested_name: Some(source_symbol),
3013 namespace_alias: None,
3014 wildcard: false,
3015 line,
3016 byte_start: start,
3017 byte_end: end,
3018 dependencies: BTreeSet::new(),
3019 });
3020 }
3021 }
3022 SourceLessExportRefs {
3023 raw_refs,
3024 surface_parts,
3025 }
3026}
3027
3028fn build_dispatch_hints(
3029 rel_path: &str,
3030 data: &FileCallData,
3031 node_by_scoped: &HashMap<String, String>,
3032) -> Vec<DispatchHint> {
3033 let mut hints = Vec::new();
3034 let mut ordinal = 0usize;
3035 for (caller_symbol, call_sites) in &data.calls_by_symbol {
3036 let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
3037 continue;
3038 };
3039 for call_site in call_sites {
3040 if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
3041 continue;
3042 }
3043 ordinal += 1;
3044 hints.push(DispatchHint {
3045 id: ref_id(&[
3046 rel_path,
3047 "dispatch",
3048 caller_symbol,
3049 &call_site.line.to_string(),
3050 &call_site.byte_start.to_string(),
3051 &call_site.byte_end.to_string(),
3052 &ordinal.to_string(),
3053 ]),
3054 method_name: call_site.callee_name.clone(),
3055 caller_node: caller_node.clone(),
3056 file: rel_path.to_string(),
3057 line: call_site.line,
3058 byte_start: call_site.byte_start,
3059 byte_end: call_site.byte_end,
3060 });
3061 }
3062 }
3063 hints
3064}
3065
3066fn surface_fingerprint(
3067 nodes: &mut [NodeRecord],
3068 data: &FileCallData,
3069 reexport_parts: &[String],
3070) -> String {
3071 nodes.sort_by(|left, right| {
3072 (left.file_path.as_str(), left.scoped_name.as_str())
3073 .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
3074 });
3075 let mut parts = Vec::new();
3076 for node in nodes.iter() {
3077 parts.push(format!(
3078 "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
3079 node.scoped_name,
3080 node.name,
3081 node.kind,
3082 node.exported,
3083 node.range.start_line,
3084 node.range.start_col,
3085 node.range.end_line,
3086 node.range.end_col,
3087 node.range_ordinal,
3088 node.signature.as_deref().unwrap_or("")
3089 ));
3090 }
3091 let mut exports = data.exported_symbols.clone();
3092 exports.sort();
3093 for export in exports {
3094 parts.push(format!("export\t{export}"));
3095 }
3096 if let Some(default_export) = &data.default_export_symbol {
3097 parts.push(format!("default\t{default_export}"));
3098 }
3099 let mut imports: Vec<String> = data
3100 .import_block
3101 .imports
3102 .iter()
3103 .map(|import| {
3104 format!(
3105 "import\t{}\t{:?}\t{}",
3106 import.module_path, import.form, import.raw_text
3107 )
3108 })
3109 .collect();
3110 imports.sort();
3111 parts.extend(imports);
3112 parts.extend(reexport_parts.iter().cloned());
3113 hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
3114}
3115
3116fn resolve_ref(raw: RawRef, index: &ProjectIndex<'_>) -> Result<ResolvedRef> {
3117 if raw.kind != "call" {
3118 return Ok(ResolvedRef {
3119 dependencies: raw.dependencies.clone(),
3120 raw,
3121 status: "unresolved".to_string(),
3122 target_node: None,
3123 target_file: None,
3124 target_symbol: None,
3125 edge: None,
3126 });
3127 }
3128
3129 let caller_file = raw.caller_file.clone();
3130 let caller_data = index.caller_data.get(&caller_file).ok_or_else(|| {
3131 CallGraphStoreError::MissingCallerData {
3132 file: caller_file.clone(),
3133 }
3134 })?;
3135 let full_ref = raw.full_ref.as_deref().unwrap_or_default();
3136 let short_name = raw.short_name.as_deref().unwrap_or_default();
3137 let mut dependencies = raw.dependencies.clone();
3138
3139 let resolved = match index.lang_for(&caller_file) {
3140 Some(LangId::Rust) => {
3141 resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data)
3142 }
3143 Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
3144 resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
3145 }
3146 _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
3147 };
3148
3149 let Some((status, target_file, target_symbol)) = resolved else {
3150 return Ok(ResolvedRef {
3151 raw,
3152 status: "unresolved".to_string(),
3153 target_node: None,
3154 target_file: None,
3155 target_symbol: None,
3156 dependencies,
3157 edge: None,
3158 });
3159 };
3160
3161 dependencies.insert(target_file.clone());
3162 let target_node = index.node_for_symbol(&target_file, &target_symbol);
3163 let source_node = raw.caller_node.clone();
3164 let edge = if let Some(source_node) = source_node {
3165 if target_file == caller_file
3166 && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
3167 {
3168 None
3169 } else {
3170 Some(EdgeRecord {
3171 edge_id: ref_id(&[&raw.ref_id, "edge"]),
3172 source_node,
3173 target_node: target_node.clone(),
3174 target_file: target_file.clone(),
3175 target_symbol: target_symbol.clone(),
3176 kind: "call".to_string(),
3177 line: raw.line,
3178 })
3179 }
3180 } else {
3181 None
3182 };
3183
3184 Ok(ResolvedRef {
3185 raw,
3186 status,
3187 target_node,
3188 target_file: Some(target_file),
3189 target_symbol: Some(target_symbol),
3190 dependencies,
3191 edge,
3192 })
3193}
3194
3195fn resolve_js_ts_target(
3196 index: &ProjectIndex<'_>,
3197 caller_file: &str,
3198 full_ref: &str,
3199 short_name: &str,
3200 caller_data: &FileCallData,
3201) -> Option<(String, String, String)> {
3202 if let Some((namespace, member)) = full_ref.split_once('.') {
3203 for import in &caller_data.import_block.imports {
3204 if import.namespace_import.as_deref() == Some(namespace) {
3205 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
3206 if let Some((file, symbol)) =
3207 resolve_exported_symbol(index, &target_file, member, 0)
3208 {
3209 return Some(("resolved".to_string(), file, symbol));
3210 }
3211 }
3212 }
3213 }
3214 }
3215
3216 for import in &caller_data.import_block.imports {
3217 for spec in &import.names {
3218 if crate::imports::specifier_local_name(spec) == short_name {
3219 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
3220 let requested = crate::imports::specifier_imported_name(spec);
3221 let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
3222 .unwrap_or_else(|| (target_file, requested.to_string()));
3223 return Some(("resolved".to_string(), file, symbol));
3224 }
3225 }
3226 }
3227
3228 if import.default_import.as_deref() == Some(short_name) {
3229 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
3230 let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
3231 .or_else(|| {
3232 index
3233 .files
3234 .get(&target_file)
3235 .and_then(|file| file.default_export.clone())
3236 .map(|symbol| (target_file.clone(), symbol))
3237 })
3238 .unwrap_or_else(|| {
3239 let file_name = Path::new(&target_file)
3240 .file_name()
3241 .and_then(|name| name.to_str())
3242 .unwrap_or("unknown")
3243 .to_string();
3244 (target_file, format!("<default:{file_name}>"))
3245 });
3246 return Some(("resolved".to_string(), file, symbol));
3247 }
3248 }
3249 }
3250
3251 for import in &caller_data.import_block.imports {
3252 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
3253 if index
3254 .files
3255 .get(&target_file)
3256 .map(|file| file.exports.contains(short_name))
3257 .unwrap_or(false)
3258 {
3259 return Some(("resolved".to_string(), target_file, short_name.to_string()));
3260 }
3261 }
3262 }
3263
3264 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
3265}
3266
3267fn resolve_exported_symbol(
3268 index: &ProjectIndex<'_>,
3269 file: &str,
3270 requested: &str,
3271 depth: usize,
3272) -> Option<(String, String)> {
3273 if depth > 16 {
3274 return None;
3275 }
3276 if requested != "default" {
3277 if let Some(source_symbol) = index
3278 .files
3279 .get(file)
3280 .and_then(|item| item.export_aliases.get(requested))
3281 {
3282 return Some((file.to_string(), source_symbol.clone()));
3283 }
3284 if index
3285 .files
3286 .get(file)
3287 .map(|item| item.exports.contains(requested))
3288 .unwrap_or(false)
3289 {
3290 return Some((file.to_string(), requested.to_string()));
3291 }
3292 } else if let Some(default) = index
3293 .files
3294 .get(file)
3295 .and_then(|item| item.default_export.clone())
3296 {
3297 return Some((file.to_string(), default));
3298 }
3299
3300 for reexport in index.reexports_for(file) {
3301 let mut next_requested = requested.to_string();
3302 let matches = if reexport.wildcard {
3303 true
3304 } else if let Some(source_name) = reexport.named.get(requested) {
3305 next_requested = source_name.clone();
3306 true
3307 } else {
3308 false
3309 };
3310 if !matches {
3311 continue;
3312 }
3313 if let Some(target_file) = &reexport.target_file {
3314 if let Some(target) =
3315 resolve_exported_symbol(index, target_file, &next_requested, depth + 1)
3316 {
3317 return Some(target);
3318 }
3319 }
3320 }
3321 None
3322}
3323
3324fn resolve_rust_target(
3325 index: &ProjectIndex<'_>,
3326 caller_file: &str,
3327 full_ref: &str,
3328 short_name: &str,
3329 caller_data: &FileCallData,
3330) -> Option<(String, String, String)> {
3331 if full_ref.contains("::") {
3332 if let Some(target_file) = rust_target_for_qualified(index, caller_file, full_ref) {
3333 return Some((
3334 "resolved".to_string(),
3335 target_file,
3336 rust_target_symbol(full_ref, short_name),
3337 ));
3338 }
3339 }
3340
3341 for import in &caller_data.import_block.imports {
3342 if let Some((target_file, target_symbol)) =
3343 rust_target_for_use(index, caller_file, import, short_name)
3344 {
3345 return Some(("resolved".to_string(), target_file, target_symbol));
3346 }
3347 }
3348
3349 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
3350}
3351
3352fn rust_target_for_qualified(
3353 index: &ProjectIndex<'_>,
3354 caller_file: &str,
3355 full_ref: &str,
3356) -> Option<String> {
3357 let mut segments: Vec<&str> = full_ref.split("::").collect();
3358 if segments.len() < 2 {
3359 return None;
3360 }
3361 segments.pop();
3362 if !matches!(segments.first().copied(), Some("crate" | "self" | "super")) {
3363 if let Some(target) = rust_workspace_file_for_segments(index, &segments) {
3364 return Some(target);
3365 }
3366 }
3367 let module_segments = rust_resolve_segments(caller_file, &segments)?;
3368 rust_file_for_segments(index, caller_file, &module_segments)
3369}
3370
3371fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
3372 full_ref
3373 .rsplit("::")
3374 .next()
3375 .filter(|name| !name.is_empty())
3376 .unwrap_or(short_name)
3377 .to_string()
3378}
3379
3380fn rust_target_for_use(
3381 index: &ProjectIndex<'_>,
3382 caller_file: &str,
3383 import: &ImportStatement,
3384 short_name: &str,
3385) -> Option<(String, String)> {
3386 let path = import.module_path.trim().trim_end_matches(';');
3387 if let Some(brace_start) = path.find("::{") {
3388 let prefix = &path[..brace_start];
3389 if import.names.iter().any(|name| name == short_name) {
3390 let prefix_segments: Vec<&str> = prefix.split("::").collect();
3391 let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
3392 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
3393 return Some((file, short_name.to_string()));
3394 }
3395 return None;
3396 }
3397
3398 let (path_without_alias, alias) = path
3399 .split_once(" as ")
3400 .map(|(left, right)| (left.trim(), Some(right.trim())))
3401 .unwrap_or((path, None));
3402 let segments: Vec<&str> = path_without_alias.split("::").collect();
3403 let imported = alias.or_else(|| segments.last().copied())?;
3404 if imported != short_name {
3405 return None;
3406 }
3407 if segments.len() < 2 {
3408 return None;
3409 }
3410 let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
3411 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
3412 Some((file, segments.last().unwrap_or(&short_name).to_string()))
3413}
3414
3415fn rust_workspace_file_for_segments(index: &ProjectIndex<'_>, segments: &[&str]) -> Option<String> {
3416 let crate_name = segments.first().copied()?;
3417 let src_prefix = index.crate_src_prefix(crate_name)?;
3418 let module_segments = segments[1..]
3419 .iter()
3420 .map(|segment| segment.to_string())
3421 .collect::<Vec<_>>();
3422 rust_file_for_src_prefix(index, &src_prefix, &module_segments)
3423}
3424
3425fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
3430 let mut prefixes = HashMap::new();
3431 let mut stack = vec![project_root.to_path_buf()];
3432 while let Some(dir) = stack.pop() {
3433 let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
3434 if matches!(name, "target" | "node_modules" | ".git") {
3435 continue;
3436 }
3437 let manifest = dir.join("Cargo.toml");
3438 if manifest.is_file() {
3439 let crate_names = rust_manifest_crate_names(&manifest);
3440 if !crate_names.is_empty() {
3441 let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
3442 for crate_name in crate_names {
3443 prefixes
3444 .entry(crate_name)
3445 .or_insert_with(|| src_prefix.clone());
3446 }
3447 }
3448 }
3449 let Ok(entries) = std::fs::read_dir(&dir) else {
3450 continue;
3451 };
3452 for entry in entries.flatten() {
3453 let path = entry.path();
3454 if path.is_dir() {
3455 stack.push(path);
3456 }
3457 }
3458 }
3459 prefixes
3460}
3461
3462fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
3466 let Ok(source) = std::fs::read_to_string(manifest) else {
3467 return Vec::new();
3468 };
3469 let mut in_lib = false;
3470 let mut package_name = None;
3471 let mut lib_name = None;
3472 for line in source.lines() {
3473 let trimmed = line.trim();
3474 if trimmed.starts_with('[') {
3475 in_lib = trimmed == "[lib]";
3476 continue;
3477 }
3478 let Some((key, value)) = trimmed.split_once('=') else {
3479 continue;
3480 };
3481 let key = key.trim();
3482 let value = value.trim().trim_matches('"');
3483 if in_lib && key == "name" {
3484 lib_name = Some(value.to_string());
3485 } else if !in_lib && key == "name" && package_name.is_none() {
3486 package_name = Some(value.to_string());
3487 }
3488 }
3489 let mut names = Vec::new();
3490 if let Some(lib) = lib_name {
3491 names.push(lib);
3492 }
3493 if let Some(package) = package_name {
3494 let normalized = package.replace('-', "_");
3495 if !names.contains(&normalized) {
3496 names.push(normalized);
3497 }
3498 }
3499 names
3500}
3501
3502fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
3503 if segments.is_empty() {
3504 return Some(Vec::new());
3505 }
3506 let caller_segments = rust_module_segments_for_rel(caller_file);
3507 match segments[0] {
3508 "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
3509 "self" => {
3510 let mut resolved = caller_segments;
3511 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
3512 Some(resolved)
3513 }
3514 "super" => {
3515 let mut resolved = caller_segments;
3516 resolved.pop();
3517 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
3518 Some(resolved)
3519 }
3520 _ => {
3521 let mut resolved = caller_segments;
3522 resolved.pop();
3523 resolved.extend(segments.iter().map(|item| item.to_string()));
3524 Some(resolved)
3525 }
3526 }
3527}
3528
3529fn rust_file_for_segments(
3530 index: &ProjectIndex<'_>,
3531 caller_file: &str,
3532 segments: &[String],
3533) -> Option<String> {
3534 rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
3535}
3536
3537fn rust_file_for_src_prefix(
3538 index: &ProjectIndex<'_>,
3539 src_prefix: &str,
3540 segments: &[String],
3541) -> Option<String> {
3542 let candidate = if segments.is_empty() {
3543 [src_prefix, "lib.rs"].join("/")
3544 } else {
3545 format!("{}/{}.rs", src_prefix, segments.join("/"))
3546 };
3547 if index.files.contains_key(&candidate) {
3548 return Some(candidate);
3549 }
3550 if !segments.is_empty() {
3551 let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
3552 if index.files.contains_key(&mod_candidate) {
3553 return Some(mod_candidate);
3554 }
3555 }
3556 None
3557}
3558
3559fn rust_src_prefix(rel_path: &str) -> String {
3560 rel_path
3561 .split_once("/src/")
3562 .map(|(prefix, _)| format!("{prefix}/src"))
3563 .unwrap_or_else(|| "src".to_string())
3564}
3565
3566fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
3567 let after_src = rel_path
3568 .split_once("/src/")
3569 .map(|(_, rest)| rest)
3570 .or_else(|| rel_path.strip_prefix("src/"))
3571 .unwrap_or(rel_path);
3572 if matches!(after_src, "lib.rs" | "main.rs") {
3573 return Vec::new();
3574 }
3575 if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
3576 return prefix.split('/').map(|item| item.to_string()).collect();
3577 }
3578 after_src
3579 .strip_suffix(".rs")
3580 .unwrap_or(after_src)
3581 .split('/')
3582 .map(|item| item.to_string())
3583 .collect()
3584}
3585
3586fn resolve_local_target(
3587 _index: &ProjectIndex<'_>,
3588 caller_file: &str,
3589 full_ref: &str,
3590 short_name: &str,
3591 caller_data: &FileCallData,
3592) -> Option<(String, String, String)> {
3593 if !callgraph::is_bare_callee(full_ref, short_name) {
3594 return None;
3595 }
3596 callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
3597 .ok()
3598 .map(|symbol| {
3599 (
3600 "resolved_local".to_string(),
3601 caller_file.to_string(),
3602 symbol,
3603 )
3604 })
3605}
3606
3607impl<'a> ProjectIndex<'a> {
3608 fn from_extracts(project_root: &Path, extracts: &'a [FileExtract]) -> Self {
3609 let mut files = HashMap::new();
3610 let mut caller_data = HashMap::new();
3611 for extract in extracts {
3612 let index = DbFileIndex::from_extract(project_root, extract);
3613 caller_data.insert(extract.rel_path.clone(), &extract.data);
3614 files.insert(extract.rel_path.clone(), index);
3615 }
3616 Self {
3617 project_root: project_root.to_path_buf(),
3618 files,
3619 caller_data,
3620 workspace_crate_prefixes: std::sync::OnceLock::new(),
3621 }
3622 }
3623
3624 fn from_db_and_callers(
3625 tx: &Transaction<'_>,
3626 project_root: &Path,
3627 caller_extracts: &'a HashMap<String, FileExtract>,
3628 ) -> Result<Self> {
3629 let mut files = load_db_file_indexes(tx, project_root)?;
3630 let mut caller_data = HashMap::new();
3631 for (rel_path, extract) in caller_extracts {
3632 files.insert(
3633 rel_path.clone(),
3634 DbFileIndex::from_extract(project_root, extract),
3635 );
3636 caller_data.insert(rel_path.clone(), &extract.data);
3637 }
3638 Ok(Self {
3639 project_root: project_root.to_path_buf(),
3640 files,
3641 caller_data,
3642 workspace_crate_prefixes: std::sync::OnceLock::new(),
3643 })
3644 }
3645
3646 fn lang_for(&self, rel_path: &str) -> Option<LangId> {
3647 self.files.get(rel_path).and_then(|file| file.lang)
3648 }
3649
3650 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
3651 self.files
3652 .get(caller_file)
3653 .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
3654 }
3655
3656 fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
3657 self.files
3658 .get(rel_path)
3659 .map(|file| file.reexports.as_slice())
3660 .unwrap_or(&[])
3661 }
3662
3663 fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
3664 self.files.get(rel_path).and_then(|file| {
3665 file.node_by_scoped
3666 .get(symbol)
3667 .cloned()
3668 .or_else(|| file.node_by_bare.get(symbol).cloned())
3669 })
3670 }
3671}
3672
3673impl DbFileIndex {
3674 fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
3675 let mut node_by_scoped = HashMap::new();
3676 let mut node_by_bare = HashMap::new();
3677 for node in &extract.nodes {
3678 node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
3679 node_by_bare
3680 .entry(node.name.clone())
3681 .or_insert(node.id.clone());
3682 }
3683 let mut export_aliases = HashMap::new();
3684 for raw_ref in &extract.raw_refs {
3685 if raw_ref.kind == "export_alias" {
3686 if let (Some(exported), Some(source_symbol)) =
3687 (&raw_ref.local_name, &raw_ref.requested_name)
3688 {
3689 export_aliases.insert(exported.clone(), source_symbol.clone());
3690 }
3691 }
3692 }
3693 let mut module_targets = HashMap::new();
3694 for import in &extract.data.import_block.imports {
3695 module_targets.insert(
3696 import.module_path.clone(),
3697 module_target_from_dependencies(
3698 project_root,
3699 &module_dependencies(project_root, &extract.abs_path, &import.module_path),
3700 ),
3701 );
3702 }
3703 let mut reexports = Vec::new();
3704 for raw_ref in &extract.raw_refs {
3705 if raw_ref.kind == "reexport" {
3706 if let Some(module_path) = &raw_ref.module_path {
3707 let target_file =
3708 module_target_from_dependencies(project_root, &raw_ref.dependencies);
3709 module_targets.insert(module_path.clone(), target_file.clone());
3710 reexports.push(reexport_index_from_raw(raw_ref, target_file));
3711 }
3712 }
3713 }
3714 Self {
3715 lang: Some(extract.lang),
3716 exports: extract.data.exported_symbols.iter().cloned().collect(),
3717 default_export: extract.data.default_export_symbol.clone(),
3718 export_aliases,
3719 node_by_scoped,
3720 node_by_bare,
3721 module_targets,
3722 reexports,
3723 }
3724 }
3725}
3726
3727fn load_db_file_indexes(
3728 tx: &Transaction<'_>,
3729 project_root: &Path,
3730) -> Result<HashMap<String, DbFileIndex>> {
3731 let mut files = HashMap::new();
3732 let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
3733 let rows = stmt.query_map([], |row| {
3734 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3735 })?;
3736 for row in rows {
3737 let (rel_path, lang) = row?;
3738 files.insert(
3739 rel_path.clone(),
3740 DbFileIndex {
3741 lang: lang_from_label(&lang),
3742 exports: HashSet::new(),
3743 default_export: None,
3744 export_aliases: HashMap::new(),
3745 node_by_scoped: HashMap::new(),
3746 node_by_bare: HashMap::new(),
3747 module_targets: HashMap::new(),
3748 reexports: Vec::new(),
3749 },
3750 );
3751 }
3752
3753 let mut node_stmt = tx.prepare(
3754 "SELECT file_path, id, name, scoped_name, exported, is_default_export FROM nodes",
3755 )?;
3756 let nodes = node_stmt.query_map([], |row| {
3757 Ok((
3758 row.get::<_, String>(0)?,
3759 row.get::<_, String>(1)?,
3760 row.get::<_, String>(2)?,
3761 row.get::<_, String>(3)?,
3762 row.get::<_, i64>(4)? != 0,
3763 row.get::<_, i64>(5)? != 0,
3764 ))
3765 })?;
3766 for row in nodes {
3767 let (file_path, id, name, scoped_name, exported, is_default_export) = row?;
3768 let file = files
3769 .entry(file_path.clone())
3770 .or_insert_with(|| DbFileIndex {
3771 lang: None,
3772 exports: HashSet::new(),
3773 default_export: None,
3774 export_aliases: HashMap::new(),
3775 node_by_scoped: HashMap::new(),
3776 node_by_bare: HashMap::new(),
3777 module_targets: HashMap::new(),
3778 reexports: Vec::new(),
3779 });
3780 if exported {
3781 file.exports.insert(name.clone());
3782 file.exports.insert(scoped_name.clone());
3783 }
3784 if is_default_export {
3785 file.default_export = Some(scoped_name.clone());
3786 }
3787 file.node_by_scoped.insert(scoped_name, id.clone());
3788 file.node_by_bare.entry(name).or_insert(id);
3789 }
3790 let file_keys: HashSet<String> = files.keys().cloned().collect();
3791 let mut ref_stmt = tx.prepare(
3792 "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
3793 FROM refs WHERE kind IN ('import', 'reexport', 'export_alias')",
3794 )?;
3795 let ref_rows = ref_stmt.query_map([], |row| {
3796 Ok((
3797 row.get::<_, String>(0)?,
3798 row.get::<_, String>(1)?,
3799 row.get::<_, String>(2)?,
3800 row.get::<_, Option<String>>(3)?,
3801 row.get::<_, Option<String>>(4)?,
3802 row.get::<_, i64>(5)? != 0,
3803 row.get::<_, Option<String>>(6)?,
3804 row.get::<_, Option<String>>(7)?,
3805 ))
3806 })?;
3807 for row in ref_rows {
3808 let (
3809 ref_id,
3810 caller_file,
3811 kind,
3812 module_path,
3813 full_ref,
3814 wildcard,
3815 local_name,
3816 requested_name,
3817 ) = row?;
3818 if kind == "export_alias" {
3819 if let (Some(exported), Some(source_symbol), Some(file)) =
3820 (local_name, requested_name, files.get_mut(&caller_file))
3821 {
3822 file.export_aliases.insert(exported, source_symbol);
3823 }
3824 continue;
3825 }
3826 let Some(module_path) = module_path else {
3827 continue;
3828 };
3829 let deps = dependencies_for_ref(tx, project_root, &ref_id)?;
3830 let target_file = deps
3831 .iter()
3832 .find(|dep| file_keys.contains(*dep))
3833 .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
3834 if let Some(file) = files.get_mut(&caller_file) {
3835 file.module_targets
3836 .entry(module_path.clone())
3837 .or_insert_with(|| target_file.clone());
3838 if kind == "reexport" {
3839 let raw = RawRef {
3840 ref_id,
3841 caller_node: None,
3842 caller_symbol: None,
3843 caller_file,
3844 kind,
3845 short_name: None,
3846 full_ref,
3847 module_path: Some(module_path),
3848 import_kind: Some("reexport".to_string()),
3849 local_name: None,
3850 requested_name: None,
3851 namespace_alias: None,
3852 wildcard,
3853 line: 0,
3854 byte_start: 0,
3855 byte_end: 0,
3856 dependencies: deps,
3857 };
3858 file.reexports
3859 .push(reexport_index_from_raw(&raw, target_file));
3860 }
3861 }
3862 }
3863
3864 Ok(files)
3865}
3866
3867fn insert_file_extract(
3868 tx: &Transaction<'_>,
3869 project_root: &Path,
3870 extract: &FileExtract,
3871) -> Result<()> {
3872 tx.execute(
3873 "INSERT OR REPLACE INTO files(
3874 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
3875 is_public_api, surface_fingerprint, indexed_at
3876 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
3877 params![
3878 extract.rel_path,
3879 hash_to_hex(extract.freshness.content_hash),
3880 system_time_to_ns(extract.freshness.mtime),
3881 extract.freshness.size as i64,
3882 lang_label(extract.lang),
3883 extract.surface_fingerprint,
3884 unix_seconds_now(),
3885 ],
3886 )?;
3887 for node in &extract.nodes {
3888 tx.execute(
3889 "INSERT OR REPLACE INTO nodes(
3890 id, file_path, name, scoped_name, kind, start_line, start_col,
3891 end_line, end_col, range_ordinal, signature, exported,
3892 is_default_export, is_type_like, is_callgraph_entry_point, provenance
3893 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
3894 params![
3895 node.id,
3896 node.file_path,
3897 node.name,
3898 node.scoped_name,
3899 node.kind,
3900 node.range.start_line as i64,
3901 node.range.start_col as i64,
3902 node.range.end_line as i64,
3903 node.range.end_col as i64,
3904 node.range_ordinal as i64,
3905 node.signature,
3906 bool_int(node.exported),
3907 bool_int(node.is_default_export),
3908 bool_int(node.is_type_like),
3909 bool_int(node.is_callgraph_entry_point),
3910 PROVENANCE_TREESITTER,
3911 ],
3912 )?;
3913 }
3914 let mut dependencies = BTreeSet::new();
3915 for raw_ref in &extract.raw_refs {
3916 dependencies.extend(raw_ref.dependencies.iter().cloned());
3917 }
3918 insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
3919
3920 for hint in &extract.dispatch_hints {
3921 tx.execute(
3922 "INSERT OR REPLACE INTO dispatch_hints(
3923 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
3924 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
3925 params![
3926 hint.id,
3927 hint.method_name,
3928 hint.caller_node,
3929 hint.file,
3930 hint.line as i64,
3931 hint.byte_start as i64,
3932 hint.byte_end as i64,
3933 PROVENANCE_TREESITTER,
3934 ],
3935 )?;
3936 }
3937 mark_backend_state(
3938 tx,
3939 project_root,
3940 &extract.rel_path,
3941 Some(&extract.freshness.content_hash),
3942 "fresh",
3943 )?;
3944 Ok(())
3945}
3946
3947fn insert_file_dependencies(
3948 tx: &Transaction<'_>,
3949 file_path: &str,
3950 dependencies: &BTreeSet<String>,
3951) -> Result<()> {
3952 for dep_file in dependencies {
3953 tx.execute(
3954 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
3955 params![file_path, dep_file],
3956 )?;
3957 }
3958 Ok(())
3959}
3960
3961fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
3962 let raw = &resolved.raw;
3963 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
3964 tx.execute(
3965 "INSERT OR REPLACE INTO refs(
3966 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
3967 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
3968 byte_start, byte_end, status, target_node, target_file, target_symbol,
3969 provenance
3970 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
3971 params![
3972 raw.ref_id,
3973 raw.caller_node,
3974 raw.caller_file,
3975 raw.kind,
3976 raw.short_name,
3977 raw.full_ref,
3978 raw.module_path,
3979 raw.import_kind,
3980 raw.local_name,
3981 raw.requested_name,
3982 raw.namespace_alias,
3983 bool_int(raw.wildcard),
3984 raw.line as i64,
3985 raw.byte_start as i64,
3986 raw.byte_end as i64,
3987 resolved.status,
3988 resolved.target_node,
3989 resolved.target_file,
3990 resolved.target_symbol,
3991 PROVENANCE_TREESITTER,
3992 ],
3993 )?;
3994 if let Some(edge) = &resolved.edge {
3995 tx.execute(
3996 "INSERT OR REPLACE INTO edges(
3997 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
3998 kind, line, provenance
3999 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
4000 params![
4001 edge.edge_id,
4002 raw.ref_id,
4003 edge.source_node,
4004 edge.target_node,
4005 edge.target_file,
4006 edge.target_symbol,
4007 edge.kind,
4008 edge.line as i64,
4009 PROVENANCE_TREESITTER,
4010 ],
4011 )?;
4012 }
4013 Ok(())
4014}
4015
4016fn insert_method_dispatch_edges(
4017 tx: &Transaction<'_>,
4018 project_root: &Path,
4019 caller_files: Option<&BTreeSet<String>>,
4020) -> Result<usize> {
4021 let references = load_name_match_refs(tx, caller_files)?;
4022 if references.is_empty() {
4023 return Ok(0);
4024 }
4025
4026 let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
4027 let mut source_cache: DispatchSourceCache = HashMap::new();
4028 let mut inserted = 0usize;
4029 for reference in references {
4030 let key = (reference.method_name.clone(), reference.lang.clone());
4031 let candidates = match candidates_by_name.entry(key) {
4032 Entry::Occupied(entry) => entry.into_mut(),
4033 Entry::Vacant(entry) => {
4034 let candidates =
4035 load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
4036 entry.insert(candidates)
4037 }
4038 };
4039
4040 if let Some(receiver_type) =
4041 infer_receiver_type(project_root, &reference, &mut source_cache)
4042 {
4043 let Some(candidate) =
4044 select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
4045 else {
4046 continue;
4047 };
4048 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
4049 inserted += 1;
4050 continue;
4051 }
4052
4053 if method_name_match_denylisted(&reference.method_name) {
4054 continue;
4055 }
4056
4057 let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
4058 continue;
4059 };
4060 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
4061 inserted += 1;
4062 }
4063 Ok(inserted)
4064}
4065
4066fn insert_method_dispatch_edge(
4067 tx: &Transaction<'_>,
4068 reference: &NameMatchRef,
4069 candidate: &NameMatchCandidate,
4070 provenance: &str,
4071) -> Result<()> {
4072 tx.execute(
4073 "INSERT OR REPLACE INTO edges(
4074 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
4075 kind, line, provenance
4076 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
4077 params![
4078 ref_id(&[&reference.ref_id, provenance, "edge"]),
4079 &reference.ref_id,
4080 &reference.caller_node,
4081 &candidate.node_id,
4082 &candidate.file_path,
4083 &candidate.scoped_name,
4084 reference.line as i64,
4085 provenance,
4086 ],
4087 )?;
4088 Ok(())
4089}
4090
4091fn delete_method_dispatch_edges_for_callers(
4092 tx: &Transaction<'_>,
4093 caller_files: &BTreeSet<String>,
4094) -> Result<()> {
4095 if caller_files.is_empty() {
4096 return Ok(());
4097 }
4098
4099 let mut stmt = tx.prepare(
4100 "DELETE FROM edges
4101 WHERE provenance IN (?1, ?2)
4102 AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
4103 )?;
4104 for caller_file in caller_files {
4105 stmt.execute(params![
4106 PROVENANCE_NAME_MATCH,
4107 PROVENANCE_TYPE_MATCH,
4108 caller_file
4109 ])?;
4110 }
4111 Ok(())
4112}
4113
4114fn load_name_match_refs(
4115 tx: &Transaction<'_>,
4116 caller_files: Option<&BTreeSet<String>>,
4117) -> Result<Vec<NameMatchRef>> {
4118 let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
4119 n.signature, r.short_name, r.full_ref, r.line, f.lang
4120 FROM refs r
4121 JOIN files f ON f.path = r.caller_file
4122 JOIN nodes n ON n.id = r.caller_node
4123 WHERE r.kind = 'call'
4124 AND r.status = 'unresolved'
4125 AND r.caller_node IS NOT NULL
4126 AND r.full_ref IS NOT NULL
4127 AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
4128 AND NOT EXISTS (
4129 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
4130 )";
4131 let mut references = Vec::new();
4132
4133 if let Some(caller_files) = caller_files {
4134 if caller_files.is_empty() {
4135 return Ok(references);
4136 }
4137 let sql = format!(
4138 "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
4139 );
4140 let mut stmt = tx.prepare(&sql)?;
4141 for caller_file in caller_files {
4142 let rows = stmt.query_map(params![caller_file], |row| {
4143 Ok((
4144 row.get::<_, String>(0)?,
4145 row.get::<_, Option<String>>(1)?,
4146 row.get::<_, String>(2)?,
4147 row.get::<_, String>(3)?,
4148 row.get::<_, Option<String>>(4)?,
4149 row.get::<_, Option<String>>(5)?,
4150 row.get::<_, Option<String>>(6)?,
4151 row.get::<_, i64>(7)?,
4152 row.get::<_, String>(8)?,
4153 ))
4154 })?;
4155 for row in rows {
4156 let (
4157 ref_id,
4158 caller_node,
4159 caller_file,
4160 caller_symbol,
4161 caller_signature,
4162 short_name,
4163 full_ref,
4164 line,
4165 lang,
4166 ) = row?;
4167 if let Some(reference) = name_match_ref_from_parts(
4168 ref_id,
4169 caller_node,
4170 caller_file,
4171 caller_symbol,
4172 caller_signature,
4173 short_name,
4174 full_ref,
4175 line,
4176 lang,
4177 ) {
4178 references.push(reference);
4179 }
4180 }
4181 }
4182 return Ok(references);
4183 }
4184
4185 let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
4186 let mut stmt = tx.prepare(&sql)?;
4187 let rows = stmt.query_map([], |row| {
4188 Ok((
4189 row.get::<_, String>(0)?,
4190 row.get::<_, Option<String>>(1)?,
4191 row.get::<_, String>(2)?,
4192 row.get::<_, String>(3)?,
4193 row.get::<_, Option<String>>(4)?,
4194 row.get::<_, Option<String>>(5)?,
4195 row.get::<_, Option<String>>(6)?,
4196 row.get::<_, i64>(7)?,
4197 row.get::<_, String>(8)?,
4198 ))
4199 })?;
4200 for row in rows {
4201 let (
4202 ref_id,
4203 caller_node,
4204 caller_file,
4205 caller_symbol,
4206 caller_signature,
4207 short_name,
4208 full_ref,
4209 line,
4210 lang,
4211 ) = row?;
4212 if let Some(reference) = name_match_ref_from_parts(
4213 ref_id,
4214 caller_node,
4215 caller_file,
4216 caller_symbol,
4217 caller_signature,
4218 short_name,
4219 full_ref,
4220 line,
4221 lang,
4222 ) {
4223 references.push(reference);
4224 }
4225 }
4226 Ok(references)
4227}
4228
4229#[allow(clippy::too_many_arguments)]
4230fn name_match_ref_from_parts(
4231 ref_id: String,
4232 caller_node: Option<String>,
4233 caller_file: String,
4234 caller_symbol: String,
4235 caller_signature: Option<String>,
4236 short_name: Option<String>,
4237 full_ref: Option<String>,
4238 line: i64,
4239 lang: String,
4240) -> Option<NameMatchRef> {
4241 let caller_node = caller_node?;
4242 let full_ref = full_ref?;
4243 let (receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
4244 let method_name = if member.is_empty() {
4245 short_name.as_deref()?.to_string()
4246 } else {
4247 member
4248 };
4249 Some(NameMatchRef {
4250 ref_id,
4251 caller_node,
4252 caller_file,
4253 caller_symbol,
4254 caller_signature,
4255 receiver,
4256 method_name,
4257 colon_dispatch,
4258 line: line.max(0) as u32,
4259 lang,
4260 })
4261}
4262
4263fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, bool)> {
4264 let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
4265 let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
4266 let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
4267 let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
4268 .into_iter()
4269 .flatten()
4270 .max_by_key(|(index, _, _)| *index)?;
4271 if delimiter == 0 {
4272 return None;
4273 }
4274 let member_start = delimiter + delimiter_len;
4275 if member_start >= full_ref.len() {
4276 return None;
4277 }
4278 let receiver = last_name_segment(&full_ref[..delimiter]);
4279 let member = &full_ref[member_start..];
4280 if receiver.is_empty() || member.is_empty() {
4281 return None;
4282 }
4283 Some((receiver.to_string(), member.to_string(), colon_dispatch))
4284}
4285
4286fn last_name_segment(value: &str) -> &str {
4287 value
4288 .rsplit(['.', ':', '/', '\\', '-', '>'])
4289 .find(|segment| !segment.is_empty())
4290 .unwrap_or(value)
4291}
4292
4293fn load_name_match_candidates(
4294 tx: &Transaction<'_>,
4295 method_name: &str,
4296 lang: &str,
4297) -> Result<Vec<NameMatchCandidate>> {
4298 let mut stmt = tx.prepare(
4299 "SELECT n.id, n.file_path, n.scoped_name, n.kind
4300 FROM nodes n JOIN files f ON f.path = n.file_path
4301 WHERE n.name = ?1
4302 AND f.lang = ?2
4303 AND n.kind IN ('method', 'function')
4304 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
4305 )?;
4306 let rows = stmt.query_map(params![method_name, lang], |row| {
4307 Ok(NameMatchCandidate {
4308 node_id: row.get(0)?,
4309 file_path: row.get(1)?,
4310 scoped_name: row.get(2)?,
4311 kind: row.get(3)?,
4312 })
4313 })?;
4314 rows.collect::<std::result::Result<Vec<_>, _>>()
4315 .map_err(Into::into)
4316}
4317
4318struct ParsedDispatchSource {
4319 source: String,
4320 tree: tree_sitter::Tree,
4321}
4322
4323type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
4324
4325fn infer_receiver_type(
4326 project_root: &Path,
4327 reference: &NameMatchRef,
4328 source_cache: &mut DispatchSourceCache,
4329) -> Option<String> {
4330 match reference.lang.as_str() {
4331 "rust" => infer_rust_receiver_type(reference),
4332 "java" => {
4333 infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
4334 }
4335 "kotlin" => {
4336 infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
4337 }
4338 "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache),
4339 _ => None,
4340 }
4341}
4342
4343fn parse_dispatch_source(
4344 project_root: &Path,
4345 caller_file: &str,
4346 lang: LangId,
4347) -> Option<ParsedDispatchSource> {
4348 let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
4349 let grammar = crate::parser::grammar_for(lang);
4350 let mut parser = tree_sitter::Parser::new();
4351 parser.set_language(&grammar).ok()?;
4352 let tree = parser.parse(&source, None)?;
4353 Some(ParsedDispatchSource { source, tree })
4354}
4355
4356fn parsed_dispatch_source<'a>(
4357 project_root: &Path,
4358 reference: &NameMatchRef,
4359 lang: LangId,
4360 source_cache: &'a mut DispatchSourceCache,
4361) -> Option<&'a ParsedDispatchSource> {
4362 let key = (reference.caller_file.clone(), reference.lang.clone());
4363 source_cache
4364 .entry(key)
4365 .or_insert_with(|| parse_dispatch_source(project_root, &reference.caller_file, lang))
4366 .as_ref()
4367}
4368
4369fn infer_java_like_receiver_type(
4370 project_root: &Path,
4371 reference: &NameMatchRef,
4372 lang: LangId,
4373 source_cache: &mut DispatchSourceCache,
4374) -> Option<String> {
4375 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
4376 return None;
4377 }
4378
4379 let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
4380 let root = parsed.tree.root_node();
4381 let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
4382
4383 let callable_scope = type_node
4384 .and_then(|node| {
4385 find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
4386 })
4387 .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
4388
4389 if let Some(callable_scope) = callable_scope {
4390 if let Some(receiver_type) = infer_java_like_local_receiver_type(
4391 callable_scope,
4392 &parsed.source,
4393 &reference.receiver,
4394 reference.line.max(1),
4395 lang,
4396 ) {
4397 return Some(receiver_type);
4398 }
4399 }
4400
4401 type_node.and_then(|node| {
4402 infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
4403 })
4404}
4405
4406fn infer_cpp_receiver_type(
4407 project_root: &Path,
4408 reference: &NameMatchRef,
4409 source_cache: &mut DispatchSourceCache,
4410) -> Option<String> {
4411 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
4412 return None;
4413 }
4414
4415 let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
4416 let root = parsed.tree.root_node();
4417 let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
4418 infer_cpp_receiver_type_from_scope(
4419 scope,
4420 &parsed.source,
4421 &reference.receiver,
4422 reference.line.max(1),
4423 )
4424}
4425
4426fn find_enclosing_java_like_type_node<'tree>(
4427 root: tree_sitter::Node<'tree>,
4428 source: &str,
4429 reference: &NameMatchRef,
4430 lang: LangId,
4431) -> Option<tree_sitter::Node<'tree>> {
4432 let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
4433 .and_then(|name| simple_type_name(&name));
4434 let line = reference.line.max(1);
4435 let mut best = None;
4436 let mut stack = vec![root];
4437 while let Some(node) = stack.pop() {
4438 if !node_contains_line(node, line) {
4439 continue;
4440 }
4441 if is_java_like_type_kind(node.kind(), lang) {
4442 let name = declaration_name(node, source);
4443 if expected_type
4444 .as_deref()
4445 .is_none_or(|expected| name == Some(expected))
4446 {
4447 best = tighter_node(best, node);
4448 }
4449 }
4450 push_named_children(node, &mut stack);
4451 }
4452 best
4453}
4454
4455fn find_enclosing_java_like_callable_node<'tree>(
4456 root: tree_sitter::Node<'tree>,
4457 source: &str,
4458 reference: &NameMatchRef,
4459 lang: LangId,
4460) -> Option<tree_sitter::Node<'tree>> {
4461 let expected_name = reference.caller_symbol.rsplit("::").next();
4462 let line = reference.line.max(1);
4463 let mut best = None;
4464 let mut stack = vec![root];
4465 while let Some(node) = stack.pop() {
4466 if !node_contains_line(node, line) {
4467 continue;
4468 }
4469 if is_java_like_callable_kind(node.kind(), lang) {
4470 let name = declaration_name(node, source);
4471 if expected_name.is_none_or(|expected| name == Some(expected)) {
4472 best = tighter_node(best, node);
4473 }
4474 }
4475 push_named_children(node, &mut stack);
4476 }
4477 best
4478}
4479
4480fn find_enclosing_cpp_callable_node<'tree>(
4481 root: tree_sitter::Node<'tree>,
4482 _source: &str,
4483 reference: &NameMatchRef,
4484) -> Option<tree_sitter::Node<'tree>> {
4485 let line = reference.line.max(1);
4486 let mut best = None;
4487 let mut stack = vec![root];
4488 while let Some(node) = stack.pop() {
4489 if !node_contains_line(node, line) {
4490 continue;
4491 }
4492 if node.kind() == "function_definition" {
4493 best = tighter_node(best, node);
4494 }
4495 push_named_children(node, &mut stack);
4496 }
4497 best
4498}
4499
4500fn tighter_node<'tree>(
4501 current: Option<tree_sitter::Node<'tree>>,
4502 candidate: tree_sitter::Node<'tree>,
4503) -> Option<tree_sitter::Node<'tree>> {
4504 match current {
4505 Some(current)
4506 if current.start_byte() > candidate.start_byte()
4507 || (current.start_byte() == candidate.start_byte()
4508 && current.end_byte() <= candidate.end_byte()) =>
4509 {
4510 Some(current)
4511 }
4512 _ => Some(candidate),
4513 }
4514}
4515
4516fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
4517 let start = node.start_position().row as u32 + 1;
4518 let end = node.end_position().row as u32 + 1;
4519 start <= line && line <= end
4520}
4521
4522fn push_named_children<'tree>(
4523 node: tree_sitter::Node<'tree>,
4524 stack: &mut Vec<tree_sitter::Node<'tree>>,
4525) {
4526 for index in 0..node.named_child_count() {
4527 if let Some(child) = node.named_child(index as u32) {
4528 stack.push(child);
4529 }
4530 }
4531}
4532
4533fn declaration_name<'source>(
4534 node: tree_sitter::Node<'_>,
4535 source: &'source str,
4536) -> Option<&'source str> {
4537 node.child_by_field_name("name")
4538 .map(|name| node_text(name, source))
4539 .or_else(|| {
4540 first_named_child_text(
4541 node,
4542 source,
4543 &["identifier", "type_identifier", "simple_identifier"],
4544 )
4545 })
4546}
4547
4548fn first_named_child_text<'source>(
4549 node: tree_sitter::Node<'_>,
4550 source: &'source str,
4551 kinds: &[&str],
4552) -> Option<&'source str> {
4553 for index in 0..node.named_child_count() {
4554 let child = node.named_child(index as u32)?;
4555 if kinds.contains(&child.kind()) {
4556 return Some(node_text(child, source));
4557 }
4558 }
4559 None
4560}
4561
4562fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
4563 &source[node.byte_range()]
4564}
4565
4566fn infer_java_like_field_receiver_type(
4567 type_node: tree_sitter::Node<'_>,
4568 source: &str,
4569 receiver: &str,
4570 lang: LangId,
4571) -> Option<String> {
4572 let mut stack = Vec::new();
4573 push_named_children(type_node, &mut stack);
4574 while let Some(node) = stack.pop() {
4575 if is_java_like_field_kind(node.kind(), lang) {
4576 if let Some(receiver_type) =
4577 extract_java_like_declared_type(node_text(node, source), receiver, lang)
4578 {
4579 return Some(receiver_type);
4580 }
4581 }
4582 if is_java_like_type_kind(node.kind(), lang)
4583 || is_java_like_callable_kind(node.kind(), lang)
4584 {
4585 continue;
4586 }
4587 push_named_children(node, &mut stack);
4588 }
4589 None
4590}
4591
4592fn infer_java_like_local_receiver_type(
4593 callable_node: tree_sitter::Node<'_>,
4594 source: &str,
4595 receiver: &str,
4596 call_line: u32,
4597 lang: LangId,
4598) -> Option<String> {
4599 let mut best: Option<(u32, String)> = None;
4600 let mut stack = Vec::new();
4601 push_named_children(callable_node, &mut stack);
4602 while let Some(node) = stack.pop() {
4603 let start_line = node.start_position().row as u32 + 1;
4604 if start_line > call_line {
4605 continue;
4606 }
4607 if is_java_like_local_kind(node.kind(), lang) {
4608 if let Some(receiver_type) =
4609 extract_java_like_declared_type(node_text(node, source), receiver, lang)
4610 {
4611 if best
4612 .as_ref()
4613 .is_none_or(|(best_line, _)| start_line >= *best_line)
4614 {
4615 best = Some((start_line, receiver_type));
4616 }
4617 }
4618 }
4619 if is_java_like_type_kind(node.kind(), lang)
4620 || is_java_like_callable_kind(node.kind(), lang)
4621 {
4622 continue;
4623 }
4624 push_named_children(node, &mut stack);
4625 }
4626 best.map(|(_, receiver_type)| receiver_type)
4627}
4628
4629fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
4630 match lang {
4631 LangId::Java => matches!(
4632 kind,
4633 "class_declaration"
4634 | "interface_declaration"
4635 | "enum_declaration"
4636 | "record_declaration"
4637 | "annotation_type_declaration"
4638 ),
4639 LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
4640 _ => false,
4641 }
4642}
4643
4644fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
4645 match lang {
4646 LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
4647 LangId::Kotlin => kind == "function_declaration",
4648 _ => false,
4649 }
4650}
4651
4652fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
4653 match lang {
4654 LangId::Java => kind == "field_declaration",
4655 LangId::Kotlin => kind == "property_declaration",
4656 _ => false,
4657 }
4658}
4659
4660fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
4661 match lang {
4662 LangId::Java => kind == "local_variable_declaration",
4663 LangId::Kotlin => kind == "property_declaration",
4664 _ => false,
4665 }
4666}
4667
4668fn extract_java_like_declared_type(
4669 declaration: &str,
4670 receiver: &str,
4671 lang: LangId,
4672) -> Option<String> {
4673 match lang {
4674 LangId::Java => extract_java_declared_type(declaration, receiver),
4675 LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
4676 _ => None,
4677 }
4678}
4679
4680fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
4681 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
4682 let after = declaration[receiver_start + receiver.len()..].trim_start();
4683 if after
4684 .chars()
4685 .next()
4686 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
4687 {
4688 return None;
4689 }
4690
4691 let before = declaration[..receiver_start].trim_end();
4692 if before.contains(',') {
4693 return None;
4694 }
4695 normalize_receiver_type_name(strip_java_declaration_prefixes(before))
4696}
4697
4698fn strip_java_declaration_prefixes(mut value: &str) -> &str {
4699 loop {
4700 value = value.trim_start();
4701 if let Some(stripped) = strip_leading_java_annotation(value) {
4702 value = stripped;
4703 continue;
4704 }
4705 if let Some(stripped) = strip_leading_java_modifier(value) {
4706 value = stripped;
4707 continue;
4708 }
4709 return value.trim();
4710 }
4711}
4712
4713fn strip_leading_java_annotation(value: &str) -> Option<&str> {
4714 let value = value.trim_start();
4715 let mut chars = value.char_indices();
4716 let (_, first) = chars.next()?;
4717 if first != '@' {
4718 return None;
4719 }
4720 let mut end = first.len_utf8();
4721 for (index, ch) in chars {
4722 if !(is_code_ident_char(ch) || ch == '.') {
4723 end = index;
4724 break;
4725 }
4726 end = index + ch.len_utf8();
4727 }
4728 let rest = value[end..].trim_start();
4729 if let Some(stripped) = rest.strip_prefix('(') {
4730 let mut depth = 1usize;
4731 for (index, ch) in stripped.char_indices() {
4732 match ch {
4733 '(' => depth += 1,
4734 ')' => {
4735 depth = depth.saturating_sub(1);
4736 if depth == 0 {
4737 return Some(stripped[index + ch.len_utf8()..].trim_start());
4738 }
4739 }
4740 _ => {}
4741 }
4742 }
4743 return Some("");
4744 }
4745 Some(rest)
4746}
4747
4748fn strip_leading_java_modifier(value: &str) -> Option<&str> {
4749 const MODIFIERS: &[&str] = &[
4750 "public",
4751 "protected",
4752 "private",
4753 "abstract",
4754 "static",
4755 "final",
4756 "transient",
4757 "volatile",
4758 "synchronized",
4759 "native",
4760 "strictfp",
4761 ];
4762 MODIFIERS
4763 .iter()
4764 .find_map(|modifier| strip_leading_word(value, modifier))
4765}
4766
4767fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
4768 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
4769 let before = &declaration[..receiver_start];
4770 if find_identifier_occurrence(before, "val").is_none()
4771 && find_identifier_occurrence(before, "var").is_none()
4772 {
4773 return None;
4774 }
4775
4776 let after = declaration[receiver_start + receiver.len()..].trim_start();
4777 if let Some(type_text) = after.strip_prefix(':') {
4778 return normalize_receiver_type_name(read_type_prefix(type_text));
4779 }
4780 after
4781 .strip_prefix('=')
4782 .and_then(infer_kotlin_constructor_type)
4783}
4784
4785fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
4786 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
4787 if rest.trim_start().starts_with('(') {
4788 normalize_receiver_type_name(head)
4789 } else {
4790 None
4791 }
4792}
4793
4794fn read_type_prefix(value: &str) -> &str {
4795 let mut angle_depth = 0usize;
4796 for (index, ch) in value.char_indices() {
4797 match ch {
4798 '<' => angle_depth += 1,
4799 '>' => angle_depth = angle_depth.saturating_sub(1),
4800 '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
4801 return value[..index].trim();
4802 }
4803 _ => {}
4804 }
4805 }
4806 value.trim()
4807}
4808
4809fn infer_cpp_receiver_type_from_scope(
4810 scope: tree_sitter::Node<'_>,
4811 source: &str,
4812 receiver: &str,
4813 call_line: u32,
4814) -> Option<String> {
4815 let lines = source.lines().collect::<Vec<_>>();
4816 if lines.is_empty() {
4817 return None;
4818 }
4819 let scope_start = scope.start_position().row as usize;
4820 let call_index = (call_line as usize)
4821 .saturating_sub(1)
4822 .min(lines.len().saturating_sub(1));
4823 for index in (scope_start..=call_index).rev() {
4824 if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
4825 return Some(receiver_type);
4826 }
4827 }
4828 None
4829}
4830
4831fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
4832 for receiver_start in identifier_occurrences(line, receiver) {
4833 let after = line[receiver_start + receiver.len()..].trim_start();
4834 if after
4835 .chars()
4836 .next()
4837 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
4838 {
4839 continue;
4840 }
4841 let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
4842 let normalized = normalize_cpp_type_name(type_text)?;
4843 if normalized == "auto" {
4844 if let Some(rhs) = after.strip_prefix('=') {
4845 return infer_cpp_auto_receiver_type(rhs);
4846 }
4847 continue;
4848 }
4849 return Some(normalized);
4850 }
4851 None
4852}
4853
4854fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
4855 let candidate = prefix
4856 .rsplit([';', '{', '}', '('])
4857 .next()
4858 .unwrap_or(prefix)
4859 .trim();
4860 if candidate.is_empty() || candidate.ends_with(',') {
4861 None
4862 } else {
4863 Some(candidate)
4864 }
4865}
4866
4867fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
4868 let without_templates = strip_angle_groups(type_text);
4869 let mut cleaned = String::with_capacity(without_templates.len());
4870 for token in without_templates.split_whitespace() {
4871 if matches!(
4872 token,
4873 "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
4874 ) {
4875 continue;
4876 }
4877 if !cleaned.is_empty() {
4878 cleaned.push(' ');
4879 }
4880 cleaned.push_str(token);
4881 }
4882 let token = cleaned
4883 .split_whitespace()
4884 .last()
4885 .unwrap_or(cleaned.trim())
4886 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
4887 .trim_matches(['*', '&']);
4888 let simple = token.rsplit("::").next().unwrap_or(token).trim();
4889 if simple.is_empty() || cpp_non_type_token(simple) {
4890 None
4891 } else {
4892 Some(simple.to_string())
4893 }
4894}
4895
4896fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
4897 let rhs = rhs.trim_start();
4898 if let Some(after_new) = rhs.strip_prefix("new ") {
4899 return infer_cpp_constructor_type(after_new);
4900 }
4901 infer_cpp_make_template_type(rhs)
4902 .or_else(|| infer_cpp_constructor_type(rhs))
4903 .or_else(|| infer_cpp_factory_type(rhs))
4904}
4905
4906fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
4907 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
4908 let normalized = normalize_cpp_type_name(head)?;
4909 if !normalized
4910 .chars()
4911 .next()
4912 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
4913 {
4914 return None;
4915 }
4916 if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
4917 Some(normalized)
4918 } else {
4919 None
4920 }
4921}
4922
4923fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
4924 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
4925 if !rest.trim_start().starts_with('(') {
4926 return None;
4927 }
4928 let base = head.split('<').next().unwrap_or(head);
4929 let base_simple = base.rsplit("::").next().unwrap_or(base);
4930 if !matches!(base_simple, "make_unique" | "make_shared") {
4931 return None;
4932 }
4933 first_angle_arg(head).and_then(normalize_cpp_type_name)
4934}
4935
4936fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
4937 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
4938 if !rest.trim_start().starts_with('(') {
4939 return None;
4940 }
4941 let simple = head
4942 .split('<')
4943 .next()
4944 .unwrap_or(head)
4945 .rsplit("::")
4946 .next()
4947 .unwrap_or(head);
4948 for prefix in ["make", "create", "build"] {
4949 if let Some(suffix) = simple.strip_prefix(prefix) {
4950 if suffix
4951 .chars()
4952 .next()
4953 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
4954 {
4955 return normalize_cpp_type_name(suffix);
4956 }
4957 }
4958 }
4959 None
4960}
4961
4962#[derive(Debug, Clone, Copy)]
4963enum JavaLikeInvocation {
4964 Kotlin,
4965 Cpp,
4966}
4967
4968fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
4969 let value = value.trim_start();
4970 let mut end = 0usize;
4971 for (index, ch) in value.char_indices() {
4972 let allowed_separator = match flavor {
4973 JavaLikeInvocation::Kotlin => ch == '.',
4974 JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
4975 };
4976 if is_code_ident_char(ch) || allowed_separator {
4977 end = index + ch.len_utf8();
4978 continue;
4979 }
4980 break;
4981 }
4982 if end == 0 {
4983 return None;
4984 }
4985 let mut rest = &value[end..];
4986 if let Some(stripped) = rest.trim_start().strip_prefix('<') {
4987 let skipped = skip_balanced_angle(stripped)?;
4988 let rest_start = rest.len() - rest.trim_start().len();
4989 let angle_len = 1 + skipped;
4990 end += rest_start + angle_len;
4991 rest = &value[end..];
4992 }
4993 Some((value[..end].trim(), rest))
4994}
4995
4996fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
4997 let mut depth = 1usize;
4998 for (index, ch) in value_after_open.char_indices() {
4999 match ch {
5000 '<' => depth += 1,
5001 '>' => {
5002 depth = depth.saturating_sub(1);
5003 if depth == 0 {
5004 return Some(index + ch.len_utf8());
5005 }
5006 }
5007 _ => {}
5008 }
5009 }
5010 None
5011}
5012
5013fn first_angle_arg(value: &str) -> Option<&str> {
5014 let open = value.find('<')?;
5015 let inner_len = skip_balanced_angle(&value[open + 1..])?;
5016 let inner = &value[open + 1..open + inner_len];
5017 split_top_level_commas(inner).into_iter().next()
5018}
5019
5020fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
5021 let without_generics = strip_angle_groups(type_text);
5022 let cleaned = without_generics
5023 .replace("[]", " ")
5024 .replace("...", " ")
5025 .replace(['?', '&', '*'], " ");
5026 let token = cleaned
5027 .split_whitespace()
5028 .last()
5029 .unwrap_or(cleaned.trim())
5030 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
5031 let token = token.rsplit("::").next().unwrap_or(token);
5032 let simple = token.rsplit('.').next().unwrap_or(token).trim();
5033 if simple.is_empty()
5034 || java_like_primitive_type(simple)
5035 || !simple
5036 .chars()
5037 .next()
5038 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
5039 {
5040 None
5041 } else {
5042 Some(simple.to_string())
5043 }
5044}
5045
5046fn simple_type_name(scoped_name: &str) -> Option<String> {
5047 scoped_name
5048 .rsplit("::")
5049 .find(|segment| !segment.is_empty())
5050 .and_then(normalize_receiver_type_name)
5051}
5052
5053fn strip_angle_groups(value: &str) -> String {
5054 let mut output = String::with_capacity(value.len());
5055 let mut depth = 0usize;
5056 for ch in value.chars() {
5057 match ch {
5058 '<' => {
5059 if depth == 0 {
5060 output.push(' ');
5061 }
5062 depth += 1;
5063 }
5064 '>' => depth = depth.saturating_sub(1),
5065 _ if depth == 0 => output.push(ch),
5066 _ => {}
5067 }
5068 }
5069 output
5070}
5071
5072fn java_like_primitive_type(value: &str) -> bool {
5073 matches!(
5074 value,
5075 "boolean"
5076 | "byte"
5077 | "char"
5078 | "double"
5079 | "float"
5080 | "int"
5081 | "long"
5082 | "short"
5083 | "void"
5084 | "Boolean"
5085 | "Byte"
5086 | "Char"
5087 | "Double"
5088 | "Float"
5089 | "Int"
5090 | "Long"
5091 | "Short"
5092 | "Unit"
5093 )
5094}
5095
5096fn cpp_non_type_token(value: &str) -> bool {
5097 matches!(
5098 value,
5099 "return"
5100 | "if"
5101 | "else"
5102 | "for"
5103 | "while"
5104 | "do"
5105 | "switch"
5106 | "case"
5107 | "default"
5108 | "break"
5109 | "continue"
5110 | "goto"
5111 | "throw"
5112 | "new"
5113 | "delete"
5114 | "co_await"
5115 | "co_yield"
5116 | "co_return"
5117 | "static_cast"
5118 | "const_cast"
5119 | "dynamic_cast"
5120 | "reinterpret_cast"
5121 | "sizeof"
5122 | "alignof"
5123 | "typeid"
5124 | "and"
5125 | "or"
5126 | "not"
5127 | "xor"
5128 )
5129}
5130
5131fn receiver_is_bare_identifier(value: &str) -> bool {
5132 let mut chars = value.chars();
5133 let Some(first) = chars.next() else {
5134 return false;
5135 };
5136 (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
5137}
5138
5139fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
5140 identifier_occurrences(value, needle).into_iter().next()
5141}
5142
5143fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
5144 value
5145 .match_indices(needle)
5146 .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
5147 .collect()
5148}
5149
5150fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
5151 let before = value[..start].chars().next_back();
5152 let after = value[start + len..].chars().next();
5153 !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
5154}
5155
5156fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
5157 let stripped = value.strip_prefix(word)?;
5158 if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
5159 Some(stripped.trim_start())
5160 } else {
5161 None
5162 }
5163}
5164
5165fn is_code_ident_char(ch: char) -> bool {
5166 ch == '_' || ch.is_ascii_alphanumeric()
5167}
5168
5169fn infer_rust_receiver_type(reference: &NameMatchRef) -> Option<String> {
5170 if matches!(reference.receiver.as_str(), "self" | "Self") {
5171 return enclosing_type_from_scoped_name(&reference.caller_symbol);
5172 }
5173
5174 if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
5175 return Some(reference.receiver.clone());
5176 }
5177
5178 reference
5179 .caller_signature
5180 .as_deref()
5181 .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
5182}
5183
5184fn rust_receiver_looks_type_like(receiver: &str) -> bool {
5185 receiver
5186 .chars()
5187 .next()
5188 .is_some_and(|ch| ch == '_' || ch.is_uppercase())
5189}
5190
5191fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
5192 scoped_name
5193 .rsplit_once("::")
5194 .map(|(enclosing, _)| enclosing)
5195 .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
5196 .map(ToString::to_string)
5197}
5198
5199fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
5200 let params = signature_parameter_text(signature)?;
5201 for param in split_top_level_commas(params) {
5202 let Some((pattern, type_text)) = param.split_once(':') else {
5203 continue;
5204 };
5205 let Some(name) = rust_parameter_name(pattern) else {
5206 continue;
5207 };
5208 if name == receiver {
5209 return normalize_rust_receiver_type(type_text);
5210 }
5211 }
5212 None
5213}
5214
5215fn signature_parameter_text(signature: &str) -> Option<&str> {
5216 let open = signature.find('(')?;
5217 let mut depth = 0usize;
5218 for (offset, ch) in signature[open..].char_indices() {
5219 match ch {
5220 '(' => depth += 1,
5221 ')' => {
5222 depth = depth.saturating_sub(1);
5223 if depth == 0 {
5224 return Some(&signature[open + 1..open + offset]);
5225 }
5226 }
5227 _ => {}
5228 }
5229 }
5230 None
5231}
5232
5233fn split_top_level_commas(value: &str) -> Vec<&str> {
5234 let mut parts = Vec::new();
5235 let mut start = 0usize;
5236 let mut angle_depth = 0usize;
5237 let mut paren_depth = 0usize;
5238 let mut bracket_depth = 0usize;
5239 for (index, ch) in value.char_indices() {
5240 match ch {
5241 '<' => angle_depth += 1,
5242 '>' => angle_depth = angle_depth.saturating_sub(1),
5243 '(' => paren_depth += 1,
5244 ')' => paren_depth = paren_depth.saturating_sub(1),
5245 '[' => bracket_depth += 1,
5246 ']' => bracket_depth = bracket_depth.saturating_sub(1),
5247 ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
5248 let part = value[start..index].trim();
5249 if !part.is_empty() {
5250 parts.push(part);
5251 }
5252 start = index + ch.len_utf8();
5253 }
5254 _ => {}
5255 }
5256 }
5257 let part = value[start..].trim();
5258 if !part.is_empty() {
5259 parts.push(part);
5260 }
5261 parts
5262}
5263
5264fn rust_parameter_name(pattern: &str) -> Option<&str> {
5265 let mut pattern = pattern.trim();
5266 if let Some(stripped) = pattern.strip_prefix("mut ") {
5267 pattern = stripped.trim_start();
5268 }
5269 pattern
5270 .rsplit(|ch: char| !is_rust_ident_char(ch))
5271 .find(|part| !part.is_empty())
5272}
5273
5274fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
5275 let mut ty = strip_leading_rust_type_modifiers(type_text);
5276 let owned_inner;
5277 if let Some(inner) = single_outer_generic_arg(ty) {
5278 owned_inner = inner.trim().to_string();
5279 ty = strip_leading_rust_type_modifiers(&owned_inner);
5280 }
5281 rust_base_type_ident(ty)
5282}
5283
5284fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
5285 loop {
5286 ty = ty.trim_start();
5287 if let Some(stripped) = ty.strip_prefix('&') {
5288 ty = stripped.trim_start();
5289 if let Some(stripped) = strip_leading_lifetime(ty) {
5290 ty = stripped.trim_start();
5291 }
5292 if let Some(stripped) = ty.strip_prefix("mut ") {
5293 ty = stripped.trim_start();
5294 }
5295 continue;
5296 }
5297 if let Some(stripped) = ty.strip_prefix("mut ") {
5298 ty = stripped.trim_start();
5299 continue;
5300 }
5301 if let Some(stripped) = ty.strip_prefix("dyn ") {
5302 ty = stripped.trim_start();
5303 continue;
5304 }
5305 if let Some(stripped) = ty.strip_prefix("impl ") {
5306 ty = stripped.trim_start();
5307 continue;
5308 }
5309 break ty.trim();
5310 }
5311}
5312
5313fn strip_leading_lifetime(value: &str) -> Option<&str> {
5314 let mut chars = value.char_indices();
5315 let (_, first) = chars.next()?;
5316 if first != '\'' {
5317 return None;
5318 }
5319 for (index, ch) in chars {
5320 if !(ch == '_' || ch.is_ascii_alphanumeric()) {
5321 return Some(&value[index..]);
5322 }
5323 }
5324 Some("")
5325}
5326
5327fn single_outer_generic_arg(ty: &str) -> Option<&str> {
5328 let ty = ty.trim();
5329 let open = ty.find('<')?;
5330 let mut depth = 0usize;
5331 let mut close = None;
5332 for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
5333 match ch {
5334 '<' => depth += 1,
5335 '>' => {
5336 depth = depth.saturating_sub(1);
5337 if depth == 0 {
5338 close = Some(index);
5339 break;
5340 }
5341 }
5342 _ => {}
5343 }
5344 }
5345 let close = close?;
5346 if !ty[close + 1..].trim().is_empty() {
5347 return None;
5348 }
5349 let inner = &ty[open + 1..close];
5350 let args = split_top_level_commas(inner);
5351 match args.as_slice() {
5352 [arg] => Some(*arg),
5353 _ => None,
5354 }
5355}
5356
5357fn rust_base_type_ident(ty: &str) -> Option<String> {
5358 let ty = ty.trim();
5359 let head = ty
5360 .split([' ', '+', '='])
5361 .find(|part| !part.is_empty())
5362 .unwrap_or(ty);
5363 let head = head.split('<').next().unwrap_or(head).trim();
5364 let ident = head
5365 .rsplit("::")
5366 .next()
5367 .unwrap_or(head)
5368 .trim_matches(|ch: char| !is_rust_ident_char(ch));
5369 if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
5370 None
5371 } else {
5372 Some(ident.to_string())
5373 }
5374}
5375
5376fn is_rust_ident_char(ch: char) -> bool {
5377 ch == '_' || ch.is_ascii_alphanumeric()
5378}
5379
5380fn select_type_match_candidate(
5381 reference: &NameMatchRef,
5382 candidates: &[NameMatchCandidate],
5383 receiver_type: &str,
5384) -> Option<NameMatchCandidate> {
5385 let candidates = candidates
5386 .iter()
5387 .filter(|candidate| candidate.node_id != reference.caller_node)
5388 .filter(|candidate| {
5389 type_candidate_matches(candidate, receiver_type, &reference.method_name)
5390 })
5391 .collect::<Vec<_>>();
5392 match candidates.as_slice() {
5393 [candidate] => Some((**candidate).clone()),
5394 _ => None,
5395 }
5396}
5397
5398fn type_candidate_matches(
5399 candidate: &NameMatchCandidate,
5400 receiver_type: &str,
5401 method_name: &str,
5402) -> bool {
5403 let normalized_type = receiver_type.replace('.', "::");
5404 let suffix = format!("{normalized_type}::{method_name}");
5405 candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
5406}
5407
5408fn select_name_match_candidate(
5409 reference: &NameMatchRef,
5410 candidates: &[NameMatchCandidate],
5411) -> Option<NameMatchCandidate> {
5412 let candidates = candidates
5413 .iter()
5414 .filter(|candidate| candidate.node_id != reference.caller_node)
5415 .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
5416 .collect::<Vec<_>>();
5417 match candidates.as_slice() {
5418 [] => None,
5419 [candidate] => Some((**candidate).clone()),
5420 _ => select_scored_name_match_candidate(reference, &candidates),
5421 }
5422}
5423
5424fn candidate_allowed_for_reference(
5425 reference: &NameMatchRef,
5426 candidate: &NameMatchCandidate,
5427) -> bool {
5428 if !reference.colon_dispatch {
5429 return true;
5430 }
5431
5432 candidate.kind == "method"
5433 && candidate
5434 .scoped_name
5435 .split("::")
5436 .any(|segment| segment == reference.receiver)
5437}
5438
5439fn select_scored_name_match_candidate(
5440 reference: &NameMatchRef,
5441 candidates: &[&NameMatchCandidate],
5442) -> Option<NameMatchCandidate> {
5443 let receiver_words = split_camel_case(&reference.receiver);
5444 if receiver_words.is_empty() {
5445 return None;
5446 }
5447
5448 let mut best: Option<(&NameMatchCandidate, f64)> = None;
5449 let mut tied_best = false;
5450 for candidate in candidates {
5451 let candidate_words = split_camel_case(&candidate.scoped_name);
5452 let overlap = receiver_words
5453 .iter()
5454 .filter(|receiver_word| {
5455 candidate_words
5456 .iter()
5457 .any(|candidate_word| candidate_word == *receiver_word)
5458 })
5459 .count() as f64;
5460 let score =
5461 overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
5462 match best {
5463 None => {
5464 best = Some((*candidate, score));
5465 tied_best = false;
5466 }
5467 Some((_, best_score)) if score > best_score => {
5468 best = Some((*candidate, score));
5469 tied_best = false;
5470 }
5471 Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
5472 tied_best = true;
5473 }
5474 _ => {}
5475 }
5476 }
5477
5478 let (candidate, score) = best?;
5479 if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
5480 Some(candidate.clone())
5481 } else {
5482 None
5483 }
5484}
5485
5486fn method_name_match_denylisted(method_name: &str) -> bool {
5487 matches!(
5488 method_name,
5489 "and_then"
5490 | "as_bytes"
5491 | "as_deref"
5492 | "as_mut"
5493 | "as_ref"
5494 | "as_str"
5495 | "borrow"
5496 | "borrow_mut"
5497 | "clear"
5498 | "clone"
5499 | "collect"
5500 | "contains"
5501 | "contains_key"
5502 | "count"
5503 | "dedup"
5504 | "default"
5505 | "drain"
5506 | "ends_with"
5507 | "entry"
5508 | "err"
5509 | "expect"
5510 | "extend"
5511 | "filter"
5512 | "filter_map"
5513 | "find"
5514 | "from"
5515 | "get"
5516 | "get_mut"
5517 | "insert"
5518 | "into"
5519 | "into_iter"
5520 | "is_empty"
5521 | "is_err"
5522 | "is_none"
5523 | "is_ok"
5524 | "is_some"
5525 | "iter"
5526 | "iter_mut"
5527 | "join"
5528 | "len"
5529 | "lock"
5530 | "map"
5531 | "map_err"
5532 | "max"
5533 | "min"
5534 | "new"
5535 | "next"
5536 | "ok"
5537 | "or_default"
5538 | "or_else"
5539 | "or_insert"
5540 | "or_insert_with"
5541 | "parse"
5542 | "pop"
5543 | "position"
5544 | "push"
5545 | "read"
5546 | "recv"
5547 | "remove"
5548 | "replace"
5549 | "retain"
5550 | "send"
5551 | "sort"
5552 | "sort_by"
5553 | "split"
5554 | "starts_with"
5555 | "sum"
5556 | "take"
5557 | "to_owned"
5558 | "to_string"
5559 | "trim"
5560 | "try_from"
5561 | "try_into"
5562 | "unwrap"
5563 | "unwrap_or"
5564 | "unwrap_or_default"
5565 | "unwrap_or_else"
5566 | "with_capacity"
5567 | "write"
5568 )
5569}
5570
5571fn split_camel_case(value: &str) -> Vec<String> {
5572 let chars = value.chars().collect::<Vec<_>>();
5573 let mut normalized = String::with_capacity(value.len() + 8);
5574 for (index, ch) in chars.iter().enumerate() {
5575 let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
5576 let next = chars.get(index + 1);
5577 let is_separator = ch.is_whitespace()
5578 || matches!(
5579 ch,
5580 '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
5581 );
5582 if is_separator {
5583 normalized.push(' ');
5584 continue;
5585 }
5586 let camel_boundary = previous.is_some_and(|prev| {
5587 (prev.is_lowercase() && ch.is_uppercase())
5588 || (prev.is_ascii_digit() && ch.is_alphabetic())
5589 || (prev.is_uppercase()
5590 && ch.is_uppercase()
5591 && next.is_some_and(|next| next.is_lowercase()))
5592 });
5593 if camel_boundary {
5594 normalized.push(' ');
5595 }
5596 normalized.push(*ch);
5597 }
5598
5599 normalized
5600 .split_whitespace()
5601 .filter(|word| word.len() > 1)
5602 .map(|word| word.to_ascii_lowercase())
5603 .collect()
5604}
5605
5606fn compute_path_proximity(left: &str, right: &str) -> f64 {
5607 let left_dirs = left
5608 .rsplit_once('/')
5609 .map(|(dir, _)| dir)
5610 .unwrap_or_default()
5611 .split('/')
5612 .filter(|part| !part.is_empty());
5613 let right_dirs = right
5614 .rsplit_once('/')
5615 .map(|(dir, _)| dir)
5616 .unwrap_or_default()
5617 .split('/')
5618 .filter(|part| !part.is_empty());
5619
5620 let shared = left_dirs
5621 .zip(right_dirs)
5622 .take_while(|(left, right)| left == right)
5623 .count();
5624 ((shared as f64) * 0.05).min(0.5)
5625}
5626
5627fn mark_backend_state(
5628 tx: &Transaction<'_>,
5629 project_root: &Path,
5630 rel_path: &str,
5631 content_hash: Option<&blake3::Hash>,
5632 status: &str,
5633) -> Result<()> {
5634 let hash = content_hash
5635 .map(|hash| hash_to_hex(*hash))
5636 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
5637 tx.execute(
5638 "INSERT OR REPLACE INTO backend_file_state(
5639 backend, workspace_root, file_path, content_hash, status, updated_at
5640 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
5641 params![
5642 BACKEND_TREESITTER,
5643 project_root.display().to_string(),
5644 rel_path,
5645 hash,
5646 status,
5647 unix_seconds_now(),
5648 ],
5649 )?;
5650 Ok(())
5651}
5652
5653fn load_file_row(tx: &Transaction<'_>, rel_path: &str) -> Result<Option<FileRow>> {
5654 tx.query_row(
5655 "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
5656 params![rel_path],
5657 |row| {
5658 let hash_text: String = row.get(1)?;
5659 Ok(FileRow {
5660 surface_fingerprint: row.get(0)?,
5661 freshness: FileFreshness {
5662 content_hash: hash_from_hex(&hash_text)
5663 .unwrap_or_else(cache_freshness::zero_hash),
5664 mtime: ns_to_system_time(row.get::<_, i64>(2)?),
5665 size: row.get::<_, i64>(3)? as u64,
5666 },
5667 })
5668 },
5669 )
5670 .optional()
5671 .map_err(CallGraphStoreError::from)
5672}
5673
5674fn stored_node_ids_match_extract(
5675 tx: &Transaction<'_>,
5676 rel_path: &str,
5677 extract: &FileExtract,
5678) -> Result<bool> {
5679 let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
5680 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
5681 let mut stored = BTreeSet::new();
5682 for row in rows {
5683 stored.insert(row?);
5684 }
5685 let extracted = extract
5686 .nodes
5687 .iter()
5688 .map(|node| node.id.clone())
5689 .collect::<BTreeSet<_>>();
5690 Ok(stored == extracted)
5691}
5692
5693fn update_file_fresh_metadata(
5694 tx: &Transaction<'_>,
5695 rel_path: &str,
5696 hash: &blake3::Hash,
5697 mtime: SystemTime,
5698 size: u64,
5699) -> Result<()> {
5700 tx.execute(
5701 "UPDATE files SET mtime_ns = ?2, size = ?3, indexed_at = ?4 WHERE path = ?1",
5702 params![
5703 rel_path,
5704 system_time_to_ns(mtime),
5705 size as i64,
5706 unix_seconds_now()
5707 ],
5708 )?;
5709 tx.execute(
5710 "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
5711 WHERE backend = ?1 AND file_path = ?2 AND content_hash = ?3",
5712 params![
5713 BACKEND_TREESITTER,
5714 rel_path,
5715 hash_to_hex(*hash),
5716 unix_seconds_now(),
5717 ],
5718 )?;
5719 Ok(())
5720}
5721
5722fn ref_ids_depending_on(
5723 tx: &Transaction<'_>,
5724 project_root: &Path,
5725 rel_path: &str,
5726) -> Result<Vec<String>> {
5727 let mut stmt = tx.prepare(
5728 "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
5729 FROM refs r
5730 WHERE r.caller_file IN (
5731 SELECT file_path FROM file_dependencies WHERE dep_file = ?1
5732 )
5733 OR r.target_file = ?1
5734 ORDER BY r.ref_id",
5735 )?;
5736 let rows = stmt.query_map(params![rel_path], |row| {
5737 Ok(RefDependencyRow {
5738 ref_id: row.get(0)?,
5739 kind: row.get(1)?,
5740 caller_file: row.get(2)?,
5741 module_path: row.get(3)?,
5742 target_file: row.get(4)?,
5743 })
5744 })?;
5745 let mut ids = Vec::new();
5746 for row in rows {
5747 let row = row?;
5748 if ref_dependency_row_depends_on(project_root, &row, rel_path) {
5749 ids.push(row.ref_id);
5750 }
5751 }
5752 Ok(ids)
5753}
5754
5755fn refs_by_caller_for_ref_ids(
5756 tx: &Transaction<'_>,
5757 ref_ids: &BTreeSet<String>,
5758) -> Result<BTreeMap<String, BTreeSet<String>>> {
5759 let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
5760 let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
5761 for ref_id in ref_ids {
5762 if let Some(caller) = stmt
5763 .query_row(params![ref_id], |row| row.get::<_, String>(0))
5764 .optional()?
5765 {
5766 by_caller.entry(caller).or_default().insert(ref_id.clone());
5767 }
5768 }
5769 Ok(by_caller)
5770}
5771
5772fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
5773 tx.execute(
5774 "DELETE FROM file_dependencies WHERE file_path = ?1",
5775 params![rel_path],
5776 )?;
5777 delete_refs_for_caller(tx, rel_path)?;
5778 tx.execute(
5779 "DELETE FROM dispatch_hints WHERE file = ?1",
5780 params![rel_path],
5781 )?;
5782 tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
5783 tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
5784 Ok(())
5785}
5786
5787fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
5788 let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
5789 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
5790 let mut ids = BTreeSet::new();
5791 for row in rows {
5792 ids.insert(row?);
5793 }
5794 delete_ref_ids(tx, &ids)
5795}
5796
5797fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
5798 for ref_id in ref_ids {
5799 tx.execute("DELETE FROM edges WHERE ref_id = ?1", params![ref_id])?;
5800 tx.execute("DELETE FROM refs WHERE ref_id = ?1", params![ref_id])?;
5801 }
5802 Ok(())
5803}
5804
5805fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
5806 let mut stmt = conn.prepare(
5807 "SELECT source.file_path, source.scoped_name, edges.target_file,
5808 edges.target_symbol, edges.kind, edges.line
5809 FROM edges
5810 JOIN nodes AS source ON source.id = edges.source_node
5811 ORDER BY source.file_path, source.scoped_name, edges.target_file,
5812 edges.target_symbol, edges.kind, edges.line",
5813 )?;
5814 let rows = stmt.query_map([], |row| {
5815 Ok(StoredEdge {
5816 source_file: row.get(0)?,
5817 source_symbol: row.get(1)?,
5818 target_file: row.get(2)?,
5819 target_symbol: row.get(3)?,
5820 kind: row.get(4)?,
5821 line: row.get::<_, i64>(5)? as u32,
5822 })
5823 })?;
5824 let mut edges = BTreeSet::new();
5825 for row in rows {
5826 edges.insert(row?);
5827 }
5828 Ok(edges)
5829}
5830
5831fn module_target_from_dependencies(
5832 project_root: &Path,
5833 dependencies: &BTreeSet<String>,
5834) -> Option<String> {
5835 dependencies.iter().find_map(|dep| {
5836 let path = project_root.join(dep);
5837 if path.is_file() {
5838 Some(relative_path(project_root, &canonicalize_path(&path)))
5839 } else {
5840 None
5841 }
5842 })
5843}
5844
5845fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
5846 let mut named = HashMap::new();
5847 if let Some(full_ref) = &raw_ref.full_ref {
5848 named = parse_reexport_names(full_ref);
5849 }
5850 ReexportIndex {
5851 target_file,
5852 named,
5853 wildcard: raw_ref.wildcard,
5854 }
5855}
5856
5857fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
5858 let mut names = HashMap::new();
5859 let Some(open) = statement.find('{') else {
5860 return names;
5861 };
5862 let Some(close) = statement[open + 1..]
5863 .find('}')
5864 .map(|offset| open + 1 + offset)
5865 else {
5866 return names;
5867 };
5868 for spec in statement[open + 1..close].split(',') {
5869 let spec = spec.trim();
5870 if spec.is_empty() {
5871 continue;
5872 }
5873 if let Some((source, local)) = spec.split_once(" as ") {
5874 names.insert(local.trim().to_string(), source.trim().to_string());
5875 } else {
5876 names.insert(spec.to_string(), spec.to_string());
5877 }
5878 }
5879 names
5880}
5881
5882fn dependencies_for_ref(
5883 tx: &Transaction<'_>,
5884 project_root: &Path,
5885 ref_id: &str,
5886) -> Result<BTreeSet<String>> {
5887 let row = tx.query_row(
5888 "SELECT kind, caller_file, module_path, target_file FROM refs WHERE ref_id = ?1",
5889 params![ref_id],
5890 |row| {
5891 Ok(RefDependencyRow {
5892 ref_id: ref_id.to_string(),
5893 kind: row.get(0)?,
5894 caller_file: row.get(1)?,
5895 module_path: row.get(2)?,
5896 target_file: row.get(3)?,
5897 })
5898 },
5899 )?;
5900
5901 match row.kind.as_str() {
5902 "import" | "reexport" => {
5903 let Some(module_path) = row.module_path.as_deref() else {
5904 return Ok(BTreeSet::new());
5905 };
5906 let file_deps = file_dependencies_for_file(tx, &row.caller_file)?;
5907 let module_deps =
5908 module_dependencies_for_ref(project_root, &row.caller_file, module_path);
5909 Ok(file_deps.intersection(&module_deps).cloned().collect())
5910 }
5911 "export_alias" => Ok(BTreeSet::new()),
5912 "call" => {
5913 let mut deps = file_dependencies_for_file(tx, &row.caller_file)?;
5914 if let Some(target_file) = row.target_file {
5915 deps.insert(target_file);
5916 }
5917 Ok(deps)
5918 }
5919 _ => file_dependencies_for_file(tx, &row.caller_file),
5920 }
5921}
5922
5923#[derive(Debug)]
5924struct RefDependencyRow {
5925 ref_id: String,
5926 kind: String,
5927 caller_file: String,
5928 module_path: Option<String>,
5929 target_file: Option<String>,
5930}
5931
5932fn ref_dependency_row_depends_on(
5933 project_root: &Path,
5934 row: &RefDependencyRow,
5935 rel_path: &str,
5936) -> bool {
5937 if row.target_file.as_deref() == Some(rel_path) {
5938 return true;
5939 }
5940
5941 match row.kind.as_str() {
5942 "call" => true,
5943 "import" | "reexport" => row
5944 .module_path
5945 .as_deref()
5946 .map(|module_path| {
5947 module_dependencies_for_ref(project_root, &row.caller_file, module_path)
5948 .contains(rel_path)
5949 })
5950 .unwrap_or(false),
5951 "export_alias" => false,
5952 _ => false,
5953 }
5954}
5955
5956fn file_dependencies_for_file(tx: &Transaction<'_>, file_path: &str) -> Result<BTreeSet<String>> {
5957 let mut stmt = tx
5958 .prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1 ORDER BY dep_file")?;
5959 let rows = stmt.query_map(params![file_path], |row| row.get::<_, String>(0))?;
5960 let mut deps = BTreeSet::new();
5961 for row in rows {
5962 deps.insert(row?);
5963 }
5964 Ok(deps)
5965}
5966
5967fn module_dependencies_for_ref(
5968 project_root: &Path,
5969 caller_file: &str,
5970 module_path: &str,
5971) -> BTreeSet<String> {
5972 module_dependencies(project_root, &project_root.join(caller_file), module_path)
5973}
5974
5975fn import_dependencies(
5976 project_root: &Path,
5977 abs_path: &Path,
5978 imports: &[ImportStatement],
5979) -> BTreeSet<String> {
5980 let mut deps = BTreeSet::new();
5981 for import in imports {
5982 deps.extend(module_dependencies(
5983 project_root,
5984 abs_path,
5985 &import.module_path,
5986 ));
5987 }
5988 deps
5989}
5990
5991fn module_dependencies(
5992 project_root: &Path,
5993 abs_path: &Path,
5994 module_path: &str,
5995) -> BTreeSet<String> {
5996 let mut deps = BTreeSet::new();
5997 let caller_dir = abs_path.parent().unwrap_or(project_root);
5998 if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
5999 deps.insert(relative_path(project_root, &resolved));
6000 }
6001 if module_path.starts_with('.') {
6002 let base = caller_dir.join(module_path);
6003 for candidate in relative_module_candidates(&base) {
6004 deps.insert(relative_path(project_root, &candidate));
6005 }
6006 }
6007 deps
6008}
6009
6010fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
6011 let mut candidates = Vec::new();
6012 if base.extension().is_some() {
6013 candidates.push(base.to_path_buf());
6014 return candidates;
6015 }
6016 for ext in JS_TS_EXTENSIONS {
6017 candidates.push(base.with_extension(ext));
6018 }
6019 for ext in JS_TS_EXTENSIONS {
6020 candidates.push(base.join(format!("index.{ext}")));
6021 }
6022 candidates
6023}
6024
6025fn import_local_names(import: &ImportStatement) -> Vec<String> {
6026 let mut names = Vec::new();
6027 if let Some(default) = &import.default_import {
6028 names.push(default.clone());
6029 }
6030 if let Some(namespace) = &import.namespace_import {
6031 names.push(namespace.clone());
6032 }
6033 for name in &import.names {
6034 names.push(crate::imports::specifier_local_name(name).to_string());
6035 }
6036 names
6037}
6038
6039fn import_requested_names(import: &ImportStatement) -> Vec<String> {
6040 import
6041 .names
6042 .iter()
6043 .map(|name| crate::imports::specifier_imported_name(name).to_string())
6044 .collect()
6045}
6046
6047fn import_is_wildcard(import: &ImportStatement) -> bool {
6048 import.namespace_import.is_some() || import.raw_text.contains('*')
6049}
6050
6051fn namespace_alias(full_ref: &str) -> Option<String> {
6052 full_ref
6053 .split_once('.')
6054 .map(|(namespace, _)| namespace.to_string())
6055}
6056
6057fn import_kind_label(kind: ImportKind) -> &'static str {
6058 match kind {
6059 ImportKind::Value => "value",
6060 ImportKind::Type => "type",
6061 ImportKind::SideEffect => "side_effect",
6062 }
6063}
6064
6065fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
6066 match kind {
6067 SymbolKind::Function => "function",
6068 SymbolKind::Class => "class",
6069 SymbolKind::Method => "method",
6070 SymbolKind::Struct => "struct",
6071 SymbolKind::Interface => "interface",
6072 SymbolKind::Enum => "enum",
6073 SymbolKind::TypeAlias => "type_alias",
6074 SymbolKind::Variable => "variable",
6075 SymbolKind::Heading => "heading",
6076 SymbolKind::FileSummary => "file_summary",
6077 }
6078}
6079
6080fn is_type_like(kind: &SymbolKind) -> bool {
6081 matches!(
6082 kind,
6083 SymbolKind::Class
6084 | SymbolKind::Struct
6085 | SymbolKind::Interface
6086 | SymbolKind::Enum
6087 | SymbolKind::TypeAlias
6088 )
6089}
6090
6091fn lang_label(lang: LangId) -> &'static str {
6092 match lang {
6093 LangId::TypeScript => "typescript",
6094 LangId::Tsx => "tsx",
6095 LangId::JavaScript => "javascript",
6096 LangId::Python => "python",
6097 LangId::Rust => "rust",
6098 LangId::Go => "go",
6099 LangId::C => "c",
6100 LangId::Cpp => "cpp",
6101 LangId::Zig => "zig",
6102 LangId::CSharp => "csharp",
6103 LangId::Bash => "bash",
6104 LangId::Html => "html",
6105 LangId::Markdown => "markdown",
6106 LangId::Solidity => "solidity",
6107 LangId::Scss => "scss",
6108 LangId::Vue => "vue",
6109 LangId::Json => "json",
6110 LangId::Scala => "scala",
6111 LangId::Java => "java",
6112 LangId::Ruby => "ruby",
6113 LangId::Kotlin => "kotlin",
6114 LangId::Swift => "swift",
6115 LangId::Php => "php",
6116 LangId::Lua => "lua",
6117 LangId::Perl => "perl",
6118 LangId::Yaml => "yaml",
6119 LangId::Pascal => "pascal",
6120 }
6121}
6122
6123fn lang_from_label(label: &str) -> Option<LangId> {
6124 match label {
6125 "typescript" => Some(LangId::TypeScript),
6126 "tsx" => Some(LangId::Tsx),
6127 "javascript" => Some(LangId::JavaScript),
6128 "python" => Some(LangId::Python),
6129 "rust" => Some(LangId::Rust),
6130 "go" => Some(LangId::Go),
6131 "c" => Some(LangId::C),
6132 "cpp" => Some(LangId::Cpp),
6133 "zig" => Some(LangId::Zig),
6134 "csharp" => Some(LangId::CSharp),
6135 "bash" => Some(LangId::Bash),
6136 "html" => Some(LangId::Html),
6137 "markdown" => Some(LangId::Markdown),
6138 "solidity" => Some(LangId::Solidity),
6139 "scss" => Some(LangId::Scss),
6140 "vue" => Some(LangId::Vue),
6141 "json" => Some(LangId::Json),
6142 "scala" => Some(LangId::Scala),
6143 "java" => Some(LangId::Java),
6144 "ruby" => Some(LangId::Ruby),
6145 "kotlin" => Some(LangId::Kotlin),
6146 "swift" => Some(LangId::Swift),
6147 "php" => Some(LangId::Php),
6148 "lua" => Some(LangId::Lua),
6149 "perl" => Some(LangId::Perl),
6150 "yaml" => Some(LangId::Yaml),
6151 "pascal" => Some(LangId::Pascal),
6152 _ => None,
6153 }
6154}
6155
6156fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
6157 let mut normalized = if files.is_empty() {
6158 callgraph::walk_project_files(project_root).collect::<Vec<_>>()
6159 } else {
6160 files
6161 .iter()
6162 .map(|path| normalize_file_path(project_root, path))
6163 .collect::<Result<Vec<_>>>()?
6164 };
6165 normalized.sort();
6166 normalized.dedup();
6167 Ok(normalized)
6168}
6169
6170fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
6171 let full_path = if path.is_relative() {
6172 project_root.join(path)
6173 } else {
6174 path.to_path_buf()
6175 };
6176 Ok(canonicalize_path(&full_path))
6177}
6178
6179fn canonicalize_path(path: &Path) -> PathBuf {
6180 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
6181}
6182
6183fn relative_path(project_root: &Path, path: &Path) -> String {
6184 if let Ok(stripped) = path.strip_prefix(project_root) {
6185 return stripped.to_string_lossy().replace('\\', "/");
6186 }
6187 let canon_root = canonicalize_path(project_root);
6188 let canon_path = canonicalize_path(path);
6189 if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
6190 return stripped.to_string_lossy().replace('\\', "/");
6191 }
6192 canon_path.to_string_lossy().replace('\\', "/")
6193}
6194
6195fn unqualified_name(scoped: &str) -> &str {
6196 if scoped == TOP_LEVEL_SYMBOL {
6197 return scoped;
6198 }
6199 scoped
6200 .rsplit("::")
6201 .next()
6202 .unwrap_or(scoped)
6203 .rsplit('.')
6204 .next()
6205 .unwrap_or(scoped)
6206 .rsplit('#')
6207 .next()
6208 .unwrap_or(scoped)
6209}
6210
6211fn ref_id(parts: &[&str]) -> String {
6212 let joined = parts.join("\0");
6213 hash_to_hex(blake3::hash(joined.as_bytes()))
6214}
6215
6216fn hash_to_hex(hash: blake3::Hash) -> String {
6217 hash.to_hex().to_string()
6218}
6219
6220fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
6221 let bytes = hex_to_bytes(value)?;
6222 Some(blake3::Hash::from_bytes(bytes))
6223}
6224
6225fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
6226 if value.len() != 64 {
6227 return None;
6228 }
6229 let mut bytes = [0u8; 32];
6230 for (index, slot) in bytes.iter_mut().enumerate() {
6231 let start = index * 2;
6232 let end = start + 2;
6233 *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
6234 }
6235 Some(bytes)
6236}
6237
6238fn byte_to_line(path: &Path, byte_offset: usize) -> Option<u32> {
6239 let source = std::fs::read_to_string(path).ok()?;
6240 Some(
6241 source[..byte_offset.min(source.len())]
6242 .bytes()
6243 .filter(|byte| *byte == b'\n')
6244 .count() as u32
6245 + 1,
6246 )
6247}
6248
6249fn empty_to_none(value: String) -> Option<String> {
6250 if value.is_empty() {
6251 None
6252 } else {
6253 Some(value)
6254 }
6255}
6256
6257fn bool_int(value: bool) -> i64 {
6258 if value {
6259 1
6260 } else {
6261 0
6262 }
6263}
6264
6265fn system_time_to_ns(time: SystemTime) -> i64 {
6266 time.duration_since(UNIX_EPOCH)
6267 .unwrap_or_default()
6268 .as_nanos()
6269 .min(i64::MAX as u128) as i64
6270}
6271
6272fn ns_to_system_time(value: i64) -> SystemTime {
6273 UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
6274}
6275
6276fn unix_seconds_now() -> i64 {
6277 SystemTime::now()
6278 .duration_since(UNIX_EPOCH)
6279 .unwrap_or_default()
6280 .as_secs() as i64
6281}
6282
6283#[cfg(test)]
6284mod build_pool_tests {
6285 use super::build_pool_size;
6286
6287 #[test]
6288 fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
6289 let size = build_pool_size();
6290 assert!(size >= 1, "pool size must be at least 1");
6293 assert!(size <= 8, "pool size must be capped at 8, got {size}");
6294
6295 let cores = std::thread::available_parallelism()
6296 .map(|p| p.get())
6297 .unwrap_or(1);
6298 let expected = cores.div_ceil(2).clamp(1, 8);
6299 assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
6300 }
6301}
6302
6303#[cfg(test)]
6304mod method_dispatch_inference_tests {
6305 use super::*;
6306 use std::fs;
6307 use tempfile::tempdir;
6308
6309 #[test]
6310 fn java_field_receiver_type_selects_declared_class_method() {
6311 let source = r#"class EntryPoint {
6312 private UserService userService;
6313
6314 void handle() {
6315 userService.find();
6316 }
6317}
6318
6319class UserService {
6320 void find() {}
6321}
6322
6323class AuditService {
6324 void find() {}
6325}
6326"#;
6327 let dir = tempdir().expect("temp dir");
6328 let root = dir.path();
6329 write_fixture(root, "src/EntryPoint.java", source);
6330 let reference = reference(
6331 "java",
6332 "src/EntryPoint.java",
6333 "EntryPoint::handle",
6334 "userService",
6335 "find",
6336 line_of(source, "userService.find()"),
6337 );
6338 let mut cache = DispatchSourceCache::new();
6339
6340 let receiver_type =
6341 infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
6342 assert_eq!(receiver_type, "UserService");
6343
6344 let candidates = vec![
6345 method_candidate("audit", "AuditService::find"),
6346 method_candidate("user", "UserService::find"),
6347 ];
6348 let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
6349 .expect("type candidate");
6350 assert_eq!(selected.scoped_name, "UserService::find");
6351
6352 let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
6353 assert!(
6354 select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
6355 );
6356 }
6357
6358 #[test]
6359 fn kotlin_property_and_local_value_types_are_inferred() {
6360 let source = r#"class Handler {
6361 private val auditService: AuditService = AuditService()
6362
6363 fun handle() {
6364 auditService.find()
6365 val userService: UserService = UserService()
6366 userService.find()
6367 val billingService = BillingService()
6368 billingService.find()
6369 }
6370}
6371
6372class UserService { fun find() {} }
6373class AuditService { fun find() {} }
6374class BillingService { fun find() {} }
6375"#;
6376 let dir = tempdir().expect("temp dir");
6377 let root = dir.path();
6378 write_fixture(root, "src/Handler.kt", source);
6379 let mut cache = DispatchSourceCache::new();
6380
6381 let audit_ref = reference(
6382 "kotlin",
6383 "src/Handler.kt",
6384 "Handler::handle",
6385 "auditService",
6386 "find",
6387 line_of(source, "auditService.find()"),
6388 );
6389 assert_eq!(
6390 infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
6391 Some("AuditService")
6392 );
6393
6394 let user_ref = reference(
6395 "kotlin",
6396 "src/Handler.kt",
6397 "Handler::handle",
6398 "userService",
6399 "find",
6400 line_of(source, "userService.find()"),
6401 );
6402 assert_eq!(
6403 infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
6404 Some("UserService")
6405 );
6406
6407 let billing_ref = reference(
6408 "kotlin",
6409 "src/Handler.kt",
6410 "Handler::handle",
6411 "billingService",
6412 "find",
6413 line_of(source, "billingService.find()"),
6414 );
6415 assert_eq!(
6416 infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
6417 Some("BillingService")
6418 );
6419 }
6420
6421 #[test]
6422 fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
6423 let source = r#"struct Foo { void run(); };
6424struct PointerFoo { void run(); };
6425struct FactoryFoo { void run(); };
6426FactoryFoo makeFactoryFoo();
6427
6428void handle() {
6429 Foo foo;
6430 foo.run();
6431 PointerFoo* pointerFoo = nullptr;
6432 pointerFoo->run();
6433 auto factoryFoo = makeFactoryFoo();
6434 factoryFoo.run();
6435}
6436"#;
6437 let dir = tempdir().expect("temp dir");
6438 let root = dir.path();
6439 write_fixture(root, "src/fixture.cpp", source);
6440 let mut cache = DispatchSourceCache::new();
6441
6442 let foo_ref = reference(
6443 "cpp",
6444 "src/fixture.cpp",
6445 "handle",
6446 "foo",
6447 "run",
6448 line_of(source, "foo.run()"),
6449 );
6450 assert_eq!(
6451 infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
6452 Some("Foo")
6453 );
6454
6455 let pointer_ref = reference(
6456 "cpp",
6457 "src/fixture.cpp",
6458 "handle",
6459 "pointerFoo",
6460 "run",
6461 line_of(source, "pointerFoo->run()"),
6462 );
6463 assert_eq!(
6464 infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
6465 Some("PointerFoo")
6466 );
6467
6468 let factory_ref = reference(
6469 "cpp",
6470 "src/fixture.cpp",
6471 "handle",
6472 "factoryFoo",
6473 "run",
6474 line_of(source, "factoryFoo.run()"),
6475 );
6476 assert_eq!(
6477 infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
6478 Some("FactoryFoo")
6479 );
6480 }
6481
6482 #[test]
6483 fn unknown_java_receiver_still_uses_name_match_fallback() {
6484 let source = r#"class EntryPoint {
6485 void handle() {
6486 service.runSpecial();
6487 }
6488}
6489
6490class OnlyService {
6491 void runSpecial() {}
6492}
6493"#;
6494 let dir = tempdir().expect("temp dir");
6495 let root = dir.path();
6496 write_fixture(root, "src/EntryPoint.java", source);
6497 let reference = reference(
6498 "java",
6499 "src/EntryPoint.java",
6500 "EntryPoint::handle",
6501 "service",
6502 "runSpecial",
6503 line_of(source, "service.runSpecial()"),
6504 );
6505 let mut cache = DispatchSourceCache::new();
6506
6507 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
6508 let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
6509 let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
6510 assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
6511 }
6512
6513 fn reference(
6514 lang: &str,
6515 caller_file: &str,
6516 caller_symbol: &str,
6517 receiver: &str,
6518 method_name: &str,
6519 line: u32,
6520 ) -> NameMatchRef {
6521 NameMatchRef {
6522 ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
6523 caller_node: format!("{caller_symbol}:node"),
6524 caller_file: caller_file.to_string(),
6525 caller_symbol: caller_symbol.to_string(),
6526 caller_signature: None,
6527 receiver: receiver.to_string(),
6528 method_name: method_name.to_string(),
6529 colon_dispatch: false,
6530 line,
6531 lang: lang.to_string(),
6532 }
6533 }
6534
6535 fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
6536 NameMatchCandidate {
6537 node_id: node_id.to_string(),
6538 file_path: "src/targets.fixture".to_string(),
6539 scoped_name: scoped_name.to_string(),
6540 kind: "method".to_string(),
6541 }
6542 }
6543
6544 fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
6545 let path = root.join(rel_path);
6546 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
6547 fs::write(path, source).expect("write fixture");
6548 }
6549
6550 fn line_of(source: &str, needle: &str) -> u32 {
6551 source
6552 .lines()
6553 .position(|line| line.contains(needle))
6554 .map(|index| index as u32 + 1)
6555 .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
6556 }
6557}