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, Statement, 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 Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
567 }
568
569 pub fn cold_build_with_lease_chunked(
570 callgraph_dir: PathBuf,
571 project_root: PathBuf,
572 files: &[PathBuf],
573 chunk_size: usize,
574 ) -> Result<(Self, ColdBuildStats)> {
575 std::fs::create_dir_all(&callgraph_dir)?;
576 let project_key = crate::search_index::project_cache_key(&project_root);
577 let lock_path = callgraph_dir.join(format!("{project_key}.build.lock"));
578 let _guard = crate::fs_lock::try_acquire(&lock_path, Duration::from_secs(30))?;
579 let (stats, generation) = Self::cold_build_publish_locked(
580 &callgraph_dir,
581 &project_root,
582 &project_key,
583 files,
584 chunk_size,
585 )?;
586 let store = Self::open_generation(&callgraph_dir, project_root, project_key, generation)?;
587 Ok((store, stats))
588 }
589
590 pub fn ensure_built_with_lease(
591 callgraph_dir: PathBuf,
592 project_root: PathBuf,
593 files: &[PathBuf],
594 ) -> Result<(Self, Option<ColdBuildStats>)> {
595 Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
596 }
597
598 pub fn ensure_built_with_lease_chunked(
599 callgraph_dir: PathBuf,
600 project_root: PathBuf,
601 files: &[PathBuf],
602 chunk_size: usize,
603 ) -> Result<(Self, Option<ColdBuildStats>)> {
604 std::fs::create_dir_all(&callgraph_dir)?;
605 let project_key = crate::search_index::project_cache_key(&project_root);
606 let lock_path = callgraph_dir.join(format!("{project_key}.build.lock"));
607 let _guard = crate::fs_lock::try_acquire(&lock_path, Duration::from_secs(30))?;
608 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
615 {
616 let OpenedStore { store, root_repair } = Self::open_at_path(
617 project_root.clone(),
618 project_key.clone(),
619 sqlite_path,
620 generation,
621 true,
622 )?;
623 match root_repair {
624 OpenRootRepair::NeedsRebuild { .. } => {
625 log_root_repair_rebuild(&root_repair);
626 drop(store);
627 let (stats, generation) = Self::cold_build_publish_locked(
628 &callgraph_dir,
629 &project_root,
630 &project_key,
631 files,
632 chunk_size,
633 )?;
634 let store = Self::open_generation(
635 &callgraph_dir,
636 project_root,
637 project_key,
638 generation,
639 )?;
640 return Ok((store, Some(stats)));
641 }
642 OpenRootRepair::None | OpenRootRepair::ReRooted => {
643 return Ok((store, None));
644 }
645 }
646 }
647 let (stats, generation) = Self::cold_build_publish_locked(
648 &callgraph_dir,
649 &project_root,
650 &project_key,
651 files,
652 chunk_size,
653 )?;
654 let store = Self::open_generation(&callgraph_dir, project_root, project_key, generation)?;
655 Ok((store, Some(stats)))
656 }
657
658 fn cold_build_publish_locked(
669 callgraph_dir: &Path,
670 project_root: &Path,
671 project_key: &str,
672 files: &[PathBuf],
673 chunk_size: usize,
674 ) -> Result<(ColdBuildStats, String)> {
675 let generation = generation_file_name(project_key);
676 let gen_path = callgraph_dir.join(&generation);
677 let temp_path = callgraph_dir.join(format!(
678 "{generation}.tmp.{}.{}",
679 std::process::id(),
680 now_nanos()
681 ));
682 remove_sqlite_file_set(&temp_path);
683
684 let stats = {
685 let temp_store = Self::open_at_path(
686 project_root.to_path_buf(),
687 project_key.to_string(),
688 temp_path.clone(),
689 None,
690 false,
691 )?
692 .store;
693 let stats = temp_store.cold_build_chunked(files, chunk_size)?;
694 temp_store.prepare_for_atomic_swap()?;
695 stats
696 };
697
698 remove_sqlite_file_set(&gen_path);
701 std::fs::rename(&temp_path, &gen_path)?;
702 remove_sqlite_sidecars(&gen_path);
703
704 notify_cold_build_swap_observer(&temp_path, &gen_path);
705
706 publish_pointer(callgraph_dir, project_key, &generation)?;
708 gc_old_generations(callgraph_dir, project_key, &generation);
709 Ok((stats, generation))
710 }
711
712 fn open_generation(
715 callgraph_dir: &Path,
716 project_root: PathBuf,
717 project_key: String,
718 generation: String,
719 ) -> Result<Self> {
720 let gen_path = callgraph_dir.join(&generation);
721 Ok(Self::open_at_path(project_root, project_key, gen_path, Some(generation), true)?.store)
722 }
723
724 pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
725 let project_key = crate::search_index::project_cache_key(project_root);
726 Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
729 }
730
731 fn open_at_path(
732 project_root: PathBuf,
733 project_key: String,
734 sqlite_path: PathBuf,
735 generation: Option<String>,
736 use_wal: bool,
737 ) -> Result<OpenedStore> {
738 if let Some(parent) = sqlite_path.parent() {
739 std::fs::create_dir_all(parent)?;
740 }
741 let mut conn = Connection::open(&sqlite_path)?;
742 if use_wal {
743 configure_connection(&conn)?;
744 } else {
745 configure_build_connection(&conn)?;
746 }
747 initialize_schema(&conn)?;
748 let root_repair = reconcile_workspace_roots(&mut conn, &project_root)?;
749 let store = Self::from_connection(project_root, project_key, sqlite_path, generation, conn);
750 Ok(OpenedStore { store, root_repair })
751 }
752
753 fn prepare_for_atomic_swap(&self) -> Result<()> {
754 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
755 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;")?;
756 Ok(())
757 }
758
759 fn from_connection(
760 project_root: PathBuf,
761 project_key: String,
762 sqlite_path: PathBuf,
763 generation: Option<String>,
764 conn: Connection,
765 ) -> Self {
766 Self {
767 project_root,
768 project_key,
769 sqlite_path,
770 generation,
771 conn: Mutex::new(conn),
772 }
773 }
774
775 pub fn project_root(&self) -> &Path {
776 &self.project_root
777 }
778
779 pub fn project_key(&self) -> &str {
780 &self.project_key
781 }
782
783 pub fn sqlite_path(&self) -> &Path {
784 &self.sqlite_path
785 }
786
787 pub fn is_current(&self) -> bool {
793 let Some(dir) = self.sqlite_path.parent() else {
794 return true;
795 };
796 match (read_pointer(dir, &self.project_key), &self.generation) {
797 (Some(published), Some(opened)) => &published == opened,
798 (Some(_), None) => false,
800 (None, _) => true,
803 }
804 }
805
806 pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
807 self.cold_build_chunked(files, 0)
808 }
809
810 pub fn cold_build_chunked(
811 &self,
812 files: &[PathBuf],
813 chunk_size: usize,
814 ) -> Result<ColdBuildStats> {
815 let started = Instant::now();
816 let bench = std::env::var("AFT_BENCH_COLD").is_ok();
817 macro_rules! phase {
818 ($label:expr, $t:expr) => {
819 if bench {
820 eprintln!(" cold_build[{}]: {} ms", $label, $t.elapsed().as_millis());
821 let _ = std::io::Write::flush(&mut std::io::stderr());
822 }
823 };
824 }
825 let files = normalize_file_list(&self.project_root, files)?;
826
827 if chunk_size == 0 {
828 let t = Instant::now();
829 let build = build_extracts_parallel(&self.project_root, &files);
830 phase!("extract_parallel", t);
831 let extracts = build.extracts;
832 let failures = build.failures;
833 let node_count = extracts.iter().map(|extract| extract.nodes.len()).sum();
834
835 let t = Instant::now();
836 let index = ProjectIndex::from_extracts(&self.project_root, &extracts);
837 phase!("build_index", t);
838 let t = Instant::now();
839 let mut resolved_refs = Vec::new();
840 for extract in &extracts {
841 for raw_ref in &extract.raw_refs {
842 resolved_refs.push(resolve_ref(raw_ref.clone(), &index)?);
843 }
844 }
845 phase!("resolve_refs", t);
846 let ref_count = resolved_refs.len();
847 let edge_count = resolved_refs
848 .iter()
849 .filter(|item| item.edge.is_some())
850 .count();
851
852 let t = Instant::now();
853 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
854 let tx = conn.transaction()?;
855 clear_tables(&tx)?;
856 insert_meta(&tx)?;
857 drop_cold_build_secondary_indexes(&tx)?;
858 {
859 let workspace_root = self.project_root.display().to_string();
860 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
861 for extract in &extracts {
862 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
863 }
864 for failure in &failures {
865 insert_backend_state_prepared(
866 &mut inserts.backend_state,
867 &workspace_root,
868 &failure.rel_path,
869 failure
870 .freshness
871 .as_ref()
872 .map(|freshness| &freshness.content_hash),
873 "stale",
874 )?;
875 }
876 for resolved in &resolved_refs {
877 insert_resolved_ref_prepared(&mut inserts, resolved)?;
878 }
879 }
880 create_cold_build_secondary_indexes(&tx)?;
881 let supplemental_edge_count =
882 insert_method_dispatch_edges(&tx, &self.project_root, None)?;
883 set_meta_ready(&tx, true)?;
884 tx.commit()?;
885 phase!("sqlite_insert", t);
886
887 let elapsed_ms = started.elapsed().as_millis();
888 crate::slog_info!(
889 "perf callgraph_store cold_build: files={} nodes={} refs={} edges={} ms={}",
890 extracts.len(),
891 node_count,
892 ref_count,
893 edge_count + supplemental_edge_count,
894 elapsed_ms
895 );
896 return Ok(ColdBuildStats {
897 files: extracts.len(),
898 nodes: node_count,
899 refs: ref_count,
900 edges: edge_count + supplemental_edge_count,
901 failed_files: failures
902 .into_iter()
903 .map(|failure| failure.rel_path)
904 .collect(),
905 elapsed_ms,
906 });
907 }
908
909 let t = Instant::now();
912 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
913 let tx = conn.transaction()?;
914 clear_tables(&tx)?;
915 insert_meta(&tx)?;
916 drop_cold_build_secondary_indexes(&tx)?;
917
918 let mut all_raw_refs = Vec::new();
919 let mut failures = Vec::new();
920 let mut node_count = 0;
921 let mut files_parsed = 0;
922
923 let mut persistent_call_data = Vec::new();
924 let mut file_to_call_data_index = HashMap::new();
925 let mut files_index = HashMap::new();
926
927 let workspace_root = self.project_root.display().to_string();
928
929 {
930 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
931 for chunk in files.chunks(chunk_size) {
932 let build = build_extracts_parallel(&self.project_root, chunk);
933 failures.extend(build.failures.clone());
934
935 for extract in build.extracts {
936 files_parsed += 1;
937 node_count += extract.nodes.len();
938 insert_file_extract_prepared(&mut inserts, &workspace_root, &extract)?;
939
940 let db_file_index = DbFileIndex::from_extract(&self.project_root, &extract);
941 files_index.insert(extract.rel_path.clone(), db_file_index);
942
943 persistent_call_data.push(extract.data);
944 let idx = persistent_call_data.len() - 1;
945 file_to_call_data_index.insert(extract.rel_path.clone(), idx);
946
947 all_raw_refs.push((extract.rel_path, extract.raw_refs));
948 }
949 for failure in &build.failures {
950 insert_backend_state_prepared(
951 &mut inserts.backend_state,
952 &workspace_root,
953 &failure.rel_path,
954 failure
955 .freshness
956 .as_ref()
957 .map(|freshness| &freshness.content_hash),
958 "stale",
959 )?;
960 }
961 }
962 }
963
964 let mut caller_data = HashMap::new();
965 for (rel_path, idx) in &file_to_call_data_index {
966 caller_data.insert(rel_path.clone(), &persistent_call_data[*idx]);
967 }
968 let indexed_caller_files = files_index.keys().cloned().collect::<BTreeSet<_>>();
969 let index = ProjectIndex::from_parts(&self.project_root, files_index, caller_data);
970
971 let mut resolved_refs = Vec::new();
972 for (_, raw_refs) in all_raw_refs {
973 for raw_ref in raw_refs {
974 resolved_refs.push(resolve_ref(raw_ref, &index)?);
975 }
976 }
977
978 let ref_count = resolved_refs.len();
979 let edge_count = resolved_refs
980 .iter()
981 .filter(|item| item.edge.is_some())
982 .count();
983
984 {
985 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
986 for resolved in &resolved_refs {
987 insert_resolved_ref_prepared(&mut inserts, resolved)?;
988 }
989 }
990 create_cold_build_secondary_indexes(&tx)?;
991 let supplemental_edge_count = insert_method_dispatch_edges_chunked(
992 &tx,
993 &self.project_root,
994 &indexed_caller_files,
995 chunk_size,
996 )?;
997 set_meta_ready(&tx, true)?;
998 tx.commit()?;
999 phase!("sqlite_insert", t);
1000
1001 let elapsed_ms = started.elapsed().as_millis();
1002 crate::slog_info!(
1003 "perf callgraph_store cold_build (chunked): files={} nodes={} refs={} edges={} ms={}",
1004 files_parsed,
1005 node_count,
1006 ref_count,
1007 edge_count + supplemental_edge_count,
1008 elapsed_ms
1009 );
1010 Ok(ColdBuildStats {
1011 files: files_parsed,
1012 nodes: node_count,
1013 refs: ref_count,
1014 edges: edge_count + supplemental_edge_count,
1015 failed_files: failures
1016 .into_iter()
1017 .map(|failure| failure.rel_path)
1018 .collect(),
1019 elapsed_ms,
1020 })
1021 }
1022
1023 pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
1024 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
1025 let tx = conn.transaction()?;
1026 ensure_database_ready(&tx)?;
1027 let mut changed = Vec::new();
1028 let mut surface_changed = BTreeSet::new();
1029 let mut deleted = BTreeSet::new();
1030 let mut own_refresh = BTreeSet::new();
1031 let mut selected_ref_ids = BTreeSet::new();
1032 let mut selected_refs_by_caller = BTreeMap::new();
1033 let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
1034
1035 for input in changed_files {
1036 let abs_path = normalize_file_path(&self.project_root, input)?;
1037 let rel_path = relative_path(&self.project_root, &abs_path);
1038 changed.push(rel_path.clone());
1039 let old_row = load_file_row(&tx, &rel_path)?;
1040 if !abs_path.exists() {
1041 if old_row.is_some() {
1042 surface_changed.insert(rel_path.clone());
1043 deleted.insert(rel_path.clone());
1044 record_dependent_refs(
1045 &mut selected_ref_ids,
1046 &mut selected_refs_by_caller,
1047 ref_ids_depending_on(&tx, &self.project_root, &rel_path)?,
1048 );
1049 delete_file_rows(&tx, &rel_path)?;
1050 clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
1051 }
1052 continue;
1053 }
1054
1055 if let Some(row) = &old_row {
1056 match cache_freshness::verify_file(&abs_path, &row.freshness) {
1057 FreshnessVerdict::HotFresh => continue,
1058 FreshnessVerdict::ContentFresh {
1059 new_mtime,
1060 new_size,
1061 } => {
1062 update_file_fresh_metadata(
1063 &tx,
1064 &rel_path,
1065 &row.freshness.content_hash,
1066 new_mtime,
1067 new_size,
1068 )?;
1069 continue;
1070 }
1071 FreshnessVerdict::Deleted => {
1072 surface_changed.insert(rel_path.clone());
1073 deleted.insert(rel_path.clone());
1074 record_dependent_refs(
1075 &mut selected_ref_ids,
1076 &mut selected_refs_by_caller,
1077 ref_ids_depending_on(&tx, &self.project_root, &rel_path)?,
1078 );
1079 delete_file_rows(&tx, &rel_path)?;
1080 clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
1081 continue;
1082 }
1083 FreshnessVerdict::Stale => {}
1084 }
1085 }
1086
1087 let extract = build_file_extract(&self.project_root, &abs_path)?;
1088 let surface_is_changed = old_row
1089 .as_ref()
1090 .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
1091 .unwrap_or(true);
1092 if surface_is_changed {
1093 surface_changed.insert(rel_path.clone());
1094 record_dependent_refs(
1095 &mut selected_ref_ids,
1096 &mut selected_refs_by_caller,
1097 ref_ids_depending_on(&tx, &self.project_root, &rel_path)?,
1098 );
1099 }
1100 own_refresh.insert(rel_path.clone());
1101 delete_file_rows(&tx, &rel_path)?;
1102 insert_file_extract(&tx, &self.project_root, &extract)?;
1103 changed_extracts.insert(rel_path, extract);
1104 }
1105
1106 let dependency_selected_refs = selected_ref_ids.len();
1107 let mut touched_callers: BTreeSet<String> =
1108 selected_refs_by_caller.keys().cloned().collect();
1109 touched_callers.extend(own_refresh.iter().cloned());
1110
1111 let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
1112 for rel_path in &touched_callers {
1113 if deleted.contains(rel_path) {
1114 continue;
1115 }
1116 if let Some(extract) = changed_extracts.get(rel_path) {
1117 caller_extracts.insert(rel_path.clone(), extract.clone());
1118 continue;
1119 }
1120 let abs_path = self.project_root.join(rel_path);
1121 if abs_path.exists() {
1122 let extract = build_file_extract(&self.project_root, &abs_path)?;
1123 caller_extracts.insert(rel_path.clone(), extract);
1124 }
1125 }
1126
1127 let dependency_callers = touched_callers
1128 .iter()
1129 .filter(|rel_path| !deleted.contains(*rel_path) && !own_refresh.contains(*rel_path))
1130 .cloned()
1131 .collect::<Vec<_>>();
1132 for rel_path in dependency_callers {
1133 let Some(extract) = caller_extracts.get(&rel_path) else {
1134 continue;
1135 };
1136 if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
1137 continue;
1138 }
1139
1140 own_refresh.insert(rel_path.clone());
1141 delete_file_rows(&tx, &rel_path)?;
1142 insert_file_extract(&tx, &self.project_root, extract)?;
1143 }
1144
1145 let index = ProjectIndex::from_db_and_callers(&tx, &self.project_root, &caller_extracts)?;
1146 for rel_path in &touched_callers {
1147 if deleted.contains(rel_path) {
1148 continue;
1149 }
1150 let Some(extract) = caller_extracts.get(rel_path) else {
1151 continue;
1152 };
1153 if own_refresh.contains(rel_path) {
1154 delete_refs_for_caller(&tx, rel_path)?;
1155 for raw_ref in &extract.raw_refs {
1156 let resolved = resolve_ref(raw_ref.clone(), &index)?;
1157 insert_resolved_ref(&tx, &resolved)?;
1158 }
1159 continue;
1160 }
1161
1162 let selected_for_caller = selected_refs_by_caller
1163 .get(rel_path)
1164 .cloned()
1165 .unwrap_or_default();
1166 delete_ref_ids(&tx, &selected_for_caller)?;
1167 for raw_ref in &extract.raw_refs {
1168 if selected_for_caller.contains(&raw_ref.ref_id) {
1169 let resolved = resolve_ref(raw_ref.clone(), &index)?;
1170 insert_resolved_ref(&tx, &resolved)?;
1171 }
1172 }
1173 }
1174
1175 delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
1176 insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
1177
1178 tx.commit()?;
1179 Ok(IncrementalStats {
1180 changed_files: changed,
1181 surface_changed: surface_changed.into_iter().collect(),
1182 deleted_files: deleted.into_iter().collect(),
1183 dependency_selected_refs,
1184 refreshed_own_files: own_refresh.len(),
1185 })
1186 }
1187
1188 pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
1189 self.cold_build(current_files)
1190 }
1191
1192 pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
1193 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
1194 let tx = conn.transaction()?;
1195 let mut marked = Vec::new();
1196 for path in files {
1197 let abs_path = normalize_file_path(&self.project_root, path)?;
1198 let rel_path = relative_path(&self.project_root, &abs_path);
1199 let freshness = cache_freshness::collect(&abs_path).ok();
1200 mark_backend_state(
1201 &tx,
1202 &self.project_root,
1203 &rel_path,
1204 freshness.as_ref().map(|freshness| &freshness.content_hash),
1205 "stale",
1206 )?;
1207 marked.push(rel_path);
1208 }
1209 tx.commit()?;
1210 marked.sort();
1211 marked.dedup();
1212 Ok(marked)
1213 }
1214
1215 pub fn stale_files(&self) -> Result<Vec<String>> {
1216 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1217 let mut stmt = conn.prepare(
1218 "SELECT DISTINCT file_path FROM backend_file_state
1219 WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
1220 ORDER BY file_path",
1221 )?;
1222 let rows = stmt.query_map(
1223 params![BACKEND_TREESITTER, self.project_root.display().to_string()],
1224 |row| row.get::<_, String>(0),
1225 )?;
1226 rows.collect::<std::result::Result<Vec<_>, _>>()
1227 .map_err(Into::into)
1228 }
1229
1230 pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
1231 let rel_path = relative_path(
1232 &self.project_root,
1233 &normalize_file_path(&self.project_root, file)?,
1234 );
1235 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1236 conn.query_row(
1237 "SELECT status FROM backend_file_state
1238 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
1239 ORDER BY updated_at DESC LIMIT 1",
1240 params![
1241 BACKEND_TREESITTER,
1242 self.project_root.display().to_string(),
1243 rel_path
1244 ],
1245 |row| row.get(0),
1246 )
1247 .optional()
1248 .map_err(Into::into)
1249 }
1250
1251 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
1252 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1253 ensure_database_ready(&conn)?;
1254 edge_snapshot_with_conn(&conn)
1255 }
1256
1257 pub fn indexed_file_count(&self) -> Result<usize> {
1258 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1259 ensure_database_ready(&conn)?;
1260 indexed_file_count(&conn)
1261 }
1262
1263 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
1264 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
1265 let rel_path = relative_path(&self.project_root, &abs_path);
1266 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1267 ensure_database_ready(&conn)?;
1268 resolve_node_for_rel(&conn, &rel_path, symbol)
1269 }
1270
1271 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
1276 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
1277 let rel_path = relative_path(&self.project_root, &abs_path);
1278 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1279 ensure_database_ready(&conn)?;
1280 nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
1281 }
1282
1283 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
1285 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1286 ensure_database_ready(&conn)?;
1287 nodes_matching_symbol(&conn, symbol)
1288 }
1289
1290 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
1292 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
1293 let rel_path = relative_path(&self.project_root, &abs_path);
1294 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1295 ensure_database_ready(&conn)?;
1296 direct_callers_for_tuple(&conn, &rel_path, symbol)
1297 }
1298
1299 pub fn callers_of(
1300 &self,
1301 file_rel: &Path,
1302 symbol: &str,
1303 depth: usize,
1304 ) -> Result<StoreCallersResult> {
1305 let target = self.node_for(file_rel, symbol)?;
1306 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1307 ensure_database_ready(&conn)?;
1308 let effective_depth = depth.max(1);
1309 let mut visited = HashSet::new();
1310 let mut callers = Vec::new();
1311 let mut depth_limited = false;
1312 let mut truncated = 0usize;
1313 collect_callers_recursive(
1314 &conn,
1315 &target.file,
1316 &target.symbol,
1317 effective_depth,
1318 0,
1319 &mut visited,
1320 &mut callers,
1321 &mut depth_limited,
1322 &mut truncated,
1323 )?;
1324 Ok(StoreCallersResult {
1325 target,
1326 callers,
1327 scanned_files: indexed_file_count(&conn)?,
1328 depth_limited,
1329 truncated,
1330 })
1331 }
1332
1333 pub fn impact_of(
1334 &self,
1335 file_rel: &Path,
1336 symbol: &str,
1337 depth: usize,
1338 ) -> Result<StoreImpactResult> {
1339 let callers = self.callers_of(file_rel, symbol, depth)?;
1340 let target_parameters = callers
1341 .target
1342 .signature
1343 .as_deref()
1344 .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
1345 .unwrap_or_default();
1346 let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
1347 for site in &callers.callers {
1348 source_lines_by_file
1349 .entry(site.caller.file.clone())
1350 .or_insert_with(|| {
1351 read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
1352 });
1353 }
1354 let enriched = callers
1355 .callers
1356 .iter()
1357 .map(|site| StoreImpactCaller {
1358 site: site.clone(),
1359 signature: site.caller.signature.clone(),
1360 is_entry_point: site.caller.is_entry_point,
1361 call_expression: source_lines_by_file
1362 .get(&site.caller.file)
1363 .and_then(|lines| lines.as_ref())
1364 .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
1365 .cloned(),
1366 parameters: site
1367 .caller
1368 .signature
1369 .as_deref()
1370 .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
1371 .unwrap_or_default(),
1372 })
1373 .collect();
1374 Ok(StoreImpactResult {
1375 target: callers.target,
1376 parameters: target_parameters,
1377 callers: enriched,
1378 depth_limited: callers.depth_limited,
1379 truncated: callers.truncated,
1380 })
1381 }
1382
1383 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
1384 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1385 ensure_database_ready(&conn)?;
1386 outgoing_calls_for_node(&conn, node)
1387 }
1388
1389 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
1391 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1392 ensure_database_ready(&conn)?;
1393 resolved_self_calls_for_node(&conn, node)
1394 }
1395
1396 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
1397 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1398 ensure_database_ready(&conn)?;
1399 unresolved_calls_for_node(&conn, node)
1400 }
1401
1402 pub fn call_tree(
1403 &self,
1404 file_rel: &Path,
1405 symbol: &str,
1406 max_depth: usize,
1407 ) -> Result<callgraph::CallTreeNode> {
1408 let node = self.node_for(file_rel, symbol)?;
1409 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1410 ensure_database_ready(&conn)?;
1411 let mut visited = HashSet::new();
1412 call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
1413 }
1414
1415 pub fn trace_to(
1416 &self,
1417 file_rel: &Path,
1418 symbol: &str,
1419 max_depth: usize,
1420 ) -> Result<callgraph::TraceToResult> {
1421 let target = self.node_for(file_rel, symbol)?;
1422 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1423 ensure_database_ready(&conn)?;
1424 let effective_max = if max_depth == 0 { 10 } else { max_depth };
1425
1426 #[derive(Clone)]
1427 struct PathElem {
1428 node: StoreNode,
1429 }
1430
1431 let initial = vec![PathElem {
1432 node: target.clone(),
1433 }];
1434 let mut complete_paths = Vec::new();
1435 if target.is_entry_point {
1436 complete_paths.push(initial.clone());
1437 }
1438
1439 let mut queue = vec![(initial, 0usize)];
1440 let mut max_depth_reached = false;
1441 let mut truncated_paths = 0usize;
1442
1443 while let Some((path, depth)) = queue.pop() {
1444 if depth >= effective_max {
1445 max_depth_reached = true;
1446 continue;
1447 }
1448 let Some(current) = path.last() else {
1449 continue;
1450 };
1451 let callers =
1452 direct_callers_for_tuple(&conn, ¤t.node.file, ¤t.node.symbol)?;
1453 if callers.is_empty() {
1454 if path.len() > 1 {
1455 truncated_paths += 1;
1456 }
1457 continue;
1458 }
1459
1460 let mut has_new_path = false;
1461 for site in callers {
1462 if path.iter().any(|elem| {
1463 elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
1464 }) {
1465 continue;
1466 }
1467 has_new_path = true;
1468 let mut new_path = path.clone();
1469 new_path.push(PathElem {
1470 node: site.caller.clone(),
1471 });
1472 if site.caller.is_entry_point {
1473 complete_paths.push(new_path.clone());
1474 }
1475 queue.push((new_path, depth + 1));
1476 }
1477 if !has_new_path && path.len() > 1 {
1478 truncated_paths += 1;
1479 }
1480 }
1481
1482 let mut paths: Vec<callgraph::TracePath> = complete_paths
1483 .into_iter()
1484 .map(|mut elems| {
1485 elems.reverse();
1486 let hops = elems
1487 .iter()
1488 .enumerate()
1489 .map(|(index, elem)| callgraph::TraceHop {
1490 symbol: elem.node.symbol.clone(),
1491 file: elem.node.file.clone(),
1492 line: elem.node.line,
1493 signature: elem.node.signature.clone(),
1494 is_entry_point: index == 0 && elem.node.is_entry_point,
1495 })
1496 .collect();
1497 callgraph::TracePath { hops }
1498 })
1499 .collect();
1500 paths.sort_by(|left, right| {
1501 let left_entry = left
1502 .hops
1503 .first()
1504 .map(|hop| hop.symbol.as_str())
1505 .unwrap_or("");
1506 let right_entry = right
1507 .hops
1508 .first()
1509 .map(|hop| hop.symbol.as_str())
1510 .unwrap_or("");
1511 left_entry
1512 .cmp(right_entry)
1513 .then(left.hops.len().cmp(&right.hops.len()))
1514 });
1515 let entry_points_found = paths
1516 .iter()
1517 .filter_map(|path| path.hops.first())
1518 .filter(|hop| hop.is_entry_point)
1519 .map(|hop| (hop.file.clone(), hop.symbol.clone()))
1520 .collect::<HashSet<_>>()
1521 .len();
1522
1523 Ok(callgraph::TraceToResult {
1524 target_symbol: target.symbol,
1525 target_file: target.file,
1526 total_paths: paths.len(),
1527 paths,
1528 entry_points_found,
1529 max_depth_reached,
1530 truncated_paths,
1531 })
1532 }
1533
1534 pub fn trace_to_symbol_candidates(
1535 &self,
1536 to_symbol: &str,
1537 ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
1538 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1539 ensure_database_ready(&conn)?;
1540 let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
1541 for node in nodes_matching_symbol(&conn, to_symbol)? {
1542 candidates_by_file
1543 .entry(node.file)
1544 .and_modify(|line| *line = (*line).min(node.line))
1545 .or_insert(node.line);
1546 }
1547 let mut candidates: Vec<_> = candidates_by_file
1548 .into_iter()
1549 .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
1550 .collect();
1551 candidates
1552 .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
1553 Ok(candidates)
1554 }
1555
1556 pub fn trace_to_symbol(
1557 &self,
1558 file_rel: &Path,
1559 symbol: &str,
1560 to_symbol: &str,
1561 to_file: Option<&Path>,
1562 max_depth: usize,
1563 ) -> Result<callgraph::TraceToSymbolResult> {
1564 let origin = self.node_for(file_rel, symbol)?;
1565 let target_file = to_file
1566 .map(|path| normalize_file_path(&self.project_root, path))
1567 .transpose()?
1568 .map(|path| relative_path(&self.project_root, &path));
1569 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1570 ensure_database_ready(&conn)?;
1571 let effective_max = if max_depth == 0 {
1572 10
1573 } else {
1574 max_depth.min(16)
1575 };
1576
1577 let start_hop = trace_to_symbol_hop(&origin);
1578 if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
1579 return Ok(callgraph::TraceToSymbolResult {
1580 path: Some(vec![start_hop]),
1581 complete: true,
1582 reason: None,
1583 });
1584 }
1585
1586 let mut queue = VecDeque::new();
1587 queue.push_back((origin.clone(), vec![start_hop], 0usize));
1588 let mut visited = HashSet::new();
1589 visited.insert((origin.file.clone(), origin.symbol.clone()));
1590 let mut max_depth_exhausted = false;
1591
1592 while let Some((current, path, depth)) = queue.pop_front() {
1593 let callees = outgoing_calls_for_node(&conn, ¤t)?
1594 .into_iter()
1595 .filter_map(|site| site.target)
1596 .collect::<Vec<_>>();
1597
1598 if depth >= effective_max {
1599 if callees
1600 .iter()
1601 .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
1602 {
1603 max_depth_exhausted = true;
1604 }
1605 continue;
1606 }
1607
1608 for callee in callees {
1609 if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
1610 continue;
1611 }
1612 let mut next_path = path.clone();
1613 next_path.push(trace_to_symbol_hop(&callee));
1614 if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
1615 return Ok(callgraph::TraceToSymbolResult {
1616 path: Some(next_path),
1617 complete: true,
1618 reason: None,
1619 });
1620 }
1621 queue.push_back((callee, next_path, depth + 1));
1622 }
1623 }
1624
1625 if max_depth_exhausted {
1626 Ok(callgraph::TraceToSymbolResult {
1627 path: None,
1628 complete: false,
1629 reason: Some("max_depth_exhausted".to_string()),
1630 })
1631 } else {
1632 Ok(callgraph::TraceToSymbolResult {
1633 path: None,
1634 complete: true,
1635 reason: Some("no_path_found".to_string()),
1636 })
1637 }
1638 }
1639}
1640
1641fn indexed_file_count(conn: &Connection) -> Result<usize> {
1642 let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
1643 Ok(count.max(0) as usize)
1644}
1645
1646fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
1647 let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
1648 match candidates.as_slice() {
1649 [candidate] => Ok(candidate.clone()),
1650 [] => Err(AftError::SymbolNotFound {
1651 name: symbol.to_string(),
1652 file: rel_path.to_string(),
1653 }
1654 .into()),
1655 _ => Err(AftError::AmbiguousSymbol {
1656 name: symbol.to_string(),
1657 candidates: candidates
1658 .iter()
1659 .map(|candidate| candidate.symbol.clone())
1660 .collect(),
1661 }
1662 .into()),
1663 }
1664}
1665
1666fn nodes_for_file_matching_symbol(
1667 conn: &Connection,
1668 rel_path: &str,
1669 symbol: &str,
1670) -> Result<Vec<StoreNode>> {
1671 let qualified_query = symbol.contains("::");
1672 let sql = if qualified_query {
1673 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
1674 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
1675 FROM nodes n JOIN files f ON f.path = n.file_path
1676 WHERE n.file_path = ?1 AND n.scoped_name = ?2
1677 ORDER BY n.scoped_name, n.start_line, n.start_col"
1678 } else {
1679 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
1680 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
1681 FROM nodes n JOIN files f ON f.path = n.file_path
1682 WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
1683 ORDER BY n.scoped_name, n.start_line, n.start_col"
1684 };
1685 let mut stmt = conn.prepare(sql)?;
1686 let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
1687 rows.collect::<std::result::Result<Vec<_>, _>>()
1688 .map_err(Into::into)
1689}
1690
1691fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
1692 let qualified_query = symbol.contains("::");
1693 let sql = if qualified_query {
1694 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
1695 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
1696 FROM nodes n JOIN files f ON f.path = n.file_path
1697 WHERE n.scoped_name = ?1
1698 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
1699 } else {
1700 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
1701 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
1702 FROM nodes n JOIN files f ON f.path = n.file_path
1703 WHERE n.scoped_name = ?1 OR n.name = ?1
1704 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
1705 };
1706 let mut stmt = conn.prepare(sql)?;
1707 let rows = stmt.query_map(params![symbol], store_node_from_row)?;
1708 rows.collect::<std::result::Result<Vec<_>, _>>()
1709 .map_err(Into::into)
1710}
1711
1712fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
1713 store_node_from_row_at(row, 0)
1714}
1715
1716fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
1717 let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
1718 let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
1719 let lang_label_value: String = row.get(offset + 10)?;
1720 Ok(StoreNode {
1721 node_id: row.get(offset)?,
1722 file: row.get(offset + 1)?,
1723 symbol: row.get(offset + 2)?,
1724 name: row.get(offset + 3)?,
1725 kind: row.get(offset + 4)?,
1726 line: start_line.saturating_add(1),
1727 end_line: end_line.saturating_add(1),
1728 signature: row.get(offset + 7)?,
1729 exported: row.get::<_, i64>(offset + 8)? != 0,
1730 is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
1731 lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
1732 })
1733}
1734
1735fn optional_store_node_from_row_at(
1736 row: &rusqlite::Row<'_>,
1737 offset: usize,
1738) -> rusqlite::Result<Option<StoreNode>> {
1739 if row.get::<_, Option<String>>(offset)?.is_some() {
1740 store_node_from_row_at(row, offset).map(Some)
1741 } else {
1742 Ok(None)
1743 }
1744}
1745
1746#[allow(clippy::too_many_arguments)]
1747fn collect_callers_recursive(
1748 conn: &Connection,
1749 file: &str,
1750 symbol: &str,
1751 max_depth: usize,
1752 current_depth: usize,
1753 visited: &mut HashSet<(String, String)>,
1754 result: &mut Vec<StoreCallSite>,
1755 depth_limited: &mut bool,
1756 truncated: &mut usize,
1757) -> Result<()> {
1758 if current_depth >= max_depth {
1759 let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
1760 if omitted > 0 {
1761 *depth_limited = true;
1762 *truncated += omitted;
1763 }
1764 return Ok(());
1765 }
1766
1767 if !visited.insert((file.to_string(), symbol.to_string())) {
1768 return Ok(());
1769 }
1770
1771 let sites = direct_callers_for_tuple(conn, file, symbol)?;
1772 for site in sites {
1773 result.push(site.clone());
1774 if current_depth + 1 < max_depth {
1775 collect_callers_recursive(
1776 conn,
1777 &site.caller.file,
1778 &site.caller.symbol,
1779 max_depth,
1780 current_depth + 1,
1781 visited,
1782 result,
1783 depth_limited,
1784 truncated,
1785 )?;
1786 } else {
1787 let omitted =
1788 direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
1789 if omitted > 0 {
1790 *depth_limited = true;
1791 *truncated += omitted;
1792 }
1793 }
1794 }
1795 Ok(())
1796}
1797
1798fn direct_caller_count_for_tuple(
1799 conn: &Connection,
1800 target_file: &str,
1801 target_symbol: &str,
1802) -> Result<usize> {
1803 let count: i64 = conn.query_row(
1804 "SELECT COUNT(*)
1805 FROM edges e
1806 JOIN refs r ON r.ref_id = e.ref_id
1807 JOIN nodes src ON src.id = e.source_node
1808 JOIN files src_file ON src_file.path = src.file_path
1809 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
1810 params![target_file, target_symbol],
1811 |row| row.get(0),
1812 )?;
1813 Ok(usize::try_from(count).unwrap_or(usize::MAX))
1814}
1815
1816fn direct_callers_for_tuple(
1817 conn: &Connection,
1818 target_file: &str,
1819 target_symbol: &str,
1820) -> Result<Vec<StoreCallSite>> {
1821 let mut stmt = conn.prepare(
1822 "SELECT e.target_file, e.target_symbol, e.line,
1823 r.byte_start, r.byte_end, r.status, e.provenance,
1824 src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
1825 src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
1826 src_file.lang,
1827 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
1828 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
1829 tgt_file.lang
1830 FROM edges e
1831 JOIN refs r ON r.ref_id = e.ref_id
1832 JOIN nodes src ON src.id = e.source_node
1833 JOIN files src_file ON src_file.path = src.file_path
1834 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
1835 ON tgt.id = e.target_node
1836 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
1837 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
1838 )?;
1839 let rows = stmt.query_map(params![target_file, target_symbol], |row| {
1840 let caller = store_node_from_row_at(row, 7)?;
1841 let target = optional_store_node_from_row_at(row, 18)?;
1842 Ok(StoreCallSite {
1843 caller,
1844 target_file: row.get(0)?,
1845 target_symbol: row.get(1)?,
1846 target,
1847 line: row.get::<_, i64>(2)?.max(0) as u32,
1848 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
1849 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
1850 resolved: row.get::<_, String>(5)? == "resolved",
1851 provenance: row.get(6)?,
1852 })
1853 })?;
1854 rows.collect::<std::result::Result<Vec<_>, _>>()
1855 .map_err(Into::into)
1856}
1857
1858fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
1859 let mut stmt = conn.prepare(
1860 "SELECT e.target_file, e.target_symbol, e.line,
1861 r.byte_start, r.byte_end, r.status, e.provenance,
1862 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
1863 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
1864 tgt_file.lang
1865 FROM edges e
1866 JOIN refs r ON r.ref_id = e.ref_id
1867 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
1868 ON tgt.id = e.target_node
1869 WHERE e.kind = 'call' AND e.source_node = ?1
1870 ORDER BY r.byte_start, r.line, r.ref_id",
1871 )?;
1872 let rows = stmt.query_map(params![node.node_id], |row| {
1873 let target = optional_store_node_from_row_at(row, 7)?;
1874 Ok(StoreCallSite {
1875 caller: node.clone(),
1876 target_file: row.get(0)?,
1877 target_symbol: row.get(1)?,
1878 target,
1879 line: row.get::<_, i64>(2)?.max(0) as u32,
1880 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
1881 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
1882 resolved: row.get::<_, String>(5)? == "resolved",
1883 provenance: row.get(6)?,
1884 })
1885 })?;
1886 rows.collect::<std::result::Result<Vec<_>, _>>()
1887 .map_err(Into::into)
1888}
1889
1890fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
1891 let mut stmt = conn.prepare(
1892 "SELECT r.target_file, r.target_symbol, r.line,
1893 r.byte_start, r.byte_end, r.status, r.provenance,
1894 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
1895 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
1896 tgt_file.lang
1897 FROM refs r
1898 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
1899 ON tgt.id = r.target_node
1900 WHERE r.caller_node = ?1
1901 AND r.kind = 'call'
1902 AND r.status <> 'unresolved'
1903 AND r.target_file = ?2
1904 AND r.target_symbol = ?3
1905 AND r.provenance = ?4
1906 AND NOT EXISTS (
1907 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
1908 )
1909 ORDER BY r.byte_start, r.line, r.ref_id",
1910 )?;
1911 let rows = stmt.query_map(
1912 params![
1913 &node.node_id,
1914 &node.file,
1915 &node.symbol,
1916 PROVENANCE_TREESITTER
1917 ],
1918 |row| {
1919 let target = optional_store_node_from_row_at(row, 7)?;
1920 Ok(StoreCallSite {
1921 caller: node.clone(),
1922 target_file: row.get(0)?,
1923 target_symbol: row.get(1)?,
1924 target,
1925 line: row.get::<_, i64>(2)?.max(0) as u32,
1926 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
1927 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
1928 resolved: row.get::<_, String>(5)? == "resolved",
1929 provenance: row.get(6)?,
1930 })
1931 },
1932 )?;
1933 rows.collect::<std::result::Result<Vec<_>, _>>()
1934 .map_err(Into::into)
1935}
1936
1937fn unresolved_calls_for_node(
1938 conn: &Connection,
1939 node: &StoreNode,
1940) -> Result<Vec<StoreUnresolvedCall>> {
1941 let mut stmt = conn.prepare(
1942 "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
1943 FROM refs
1944 WHERE caller_node = ?1
1945 AND kind = 'call'
1946 AND status = 'unresolved'
1947 AND NOT EXISTS (
1948 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
1949 )
1950 ORDER BY byte_start, line, ref_id",
1951 )?;
1952 let rows = stmt.query_map(params![node.node_id], |row| {
1953 Ok(StoreUnresolvedCall {
1954 caller: node.clone(),
1955 symbol: row.get(0)?,
1956 full_ref: row.get(1)?,
1957 line: row.get::<_, i64>(2)?.max(0) as u32,
1958 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
1959 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
1960 })
1961 })?;
1962 rows.collect::<std::result::Result<Vec<_>, _>>()
1963 .map_err(Into::into)
1964}
1965
1966fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
1967 let mut calls = Vec::new();
1968 calls.extend(
1969 outgoing_calls_for_node(conn, node)?
1970 .into_iter()
1971 .map(StoreForwardCall::Resolved),
1972 );
1973 calls.extend(
1974 unresolved_calls_for_node(conn, node)?
1975 .into_iter()
1976 .map(StoreForwardCall::Unresolved),
1977 );
1978 calls.sort_by(|left, right| {
1979 left.byte_start()
1980 .cmp(&right.byte_start())
1981 .then(left.line().cmp(&right.line()))
1982 });
1983 Ok(calls)
1984}
1985
1986fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
1987 let resolved_count: i64 = conn.query_row(
1988 "SELECT COUNT(*)
1989 FROM edges e
1990 JOIN refs r ON r.ref_id = e.ref_id
1991 WHERE e.kind = 'call' AND e.source_node = ?1",
1992 params![&node.node_id],
1993 |row| row.get(0),
1994 )?;
1995 let unresolved_count: i64 = conn.query_row(
1996 "SELECT COUNT(*)
1997 FROM refs
1998 WHERE caller_node = ?1
1999 AND kind = 'call'
2000 AND status = 'unresolved'
2001 AND NOT EXISTS (
2002 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
2003 )",
2004 params![&node.node_id],
2005 |row| row.get(0),
2006 )?;
2007 let total = resolved_count.saturating_add(unresolved_count);
2008 Ok(usize::try_from(total).unwrap_or(usize::MAX))
2009}
2010
2011fn call_tree_inner(
2012 conn: &Connection,
2013 node: &StoreNode,
2014 max_depth: usize,
2015 current_depth: usize,
2016 visited: &mut HashSet<(String, String)>,
2017) -> Result<callgraph::CallTreeNode> {
2018 let visit_key = (node.file.clone(), node.symbol.clone());
2019 if visited.contains(&visit_key) {
2020 return Ok(callgraph::CallTreeNode {
2021 name: node.symbol.clone(),
2022 file: node.file.clone(),
2023 line: node.line,
2024 signature: node.signature.clone(),
2025 resolved: true,
2026 children: Vec::new(),
2027 depth_limited: false,
2028 truncated: 0,
2029 });
2030 }
2031 visited.insert(visit_key.clone());
2032
2033 let mut children = Vec::new();
2034 let mut depth_limited = false;
2035 let mut truncated = 0usize;
2036
2037 if current_depth < max_depth {
2038 let calls = forward_calls_for_node(conn, node)?;
2039 for call in calls {
2040 match call {
2041 StoreForwardCall::Resolved(site) => {
2042 if let Some(target) = site.target {
2043 let child =
2044 call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
2045 depth_limited |= child.depth_limited;
2046 truncated += child.truncated;
2047 children.push(child);
2048 } else {
2049 children.push(callgraph::CallTreeNode {
2050 name: site.target_symbol,
2051 file: site.target_file,
2052 line: site.line,
2053 signature: None,
2054 resolved: false,
2055 children: Vec::new(),
2056 depth_limited: false,
2057 truncated: 0,
2058 });
2059 }
2060 }
2061 StoreForwardCall::Unresolved(call) => {
2062 children.push(callgraph::CallTreeNode {
2063 name: call.symbol,
2064 file: call.caller.file,
2065 line: call.line,
2066 signature: None,
2067 resolved: false,
2068 children: Vec::new(),
2069 depth_limited: false,
2070 truncated: 0,
2071 });
2072 }
2073 }
2074 }
2075 } else {
2076 truncated = forward_call_count_for_node(conn, node)?;
2077 depth_limited = truncated > 0;
2078 }
2079
2080 visited.remove(&visit_key);
2081 Ok(callgraph::CallTreeNode {
2082 name: node.symbol.clone(),
2083 file: node.file.clone(),
2084 line: node.line,
2085 signature: node.signature.clone(),
2086 resolved: true,
2087 children,
2088 depth_limited,
2089 truncated,
2090 })
2091}
2092
2093fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
2094 callgraph::TraceToSymbolHop {
2095 symbol: node.symbol.clone(),
2096 file: node.file.clone(),
2097 line: node.line,
2098 }
2099}
2100
2101fn trace_to_symbol_matches_target(
2102 node: &StoreNode,
2103 to_symbol: &str,
2104 to_file: Option<&str>,
2105) -> bool {
2106 if !symbol_query_matches(&node.symbol, to_symbol) {
2107 return false;
2108 }
2109 match to_file {
2110 Some(file) => node.file == file,
2111 None => true,
2112 }
2113}
2114
2115fn symbol_query_matches(symbol: &str, query: &str) -> bool {
2116 symbol == query || unqualified_name(symbol) == query
2117}
2118
2119fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
2120 let source = std::fs::read_to_string(path).ok()?;
2121 Some(source.lines().map(|line| line.trim().to_string()).collect())
2122}
2123
2124#[doc(hidden)]
2125pub fn live_callgraph_edge_snapshot(
2126 project_root: &Path,
2127 files: &[PathBuf],
2128) -> Result<BTreeSet<StoredEdge>> {
2129 let files = normalize_file_list(project_root, files)?;
2130 let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
2131 let mut file_data = Vec::new();
2132 for file in &files {
2133 let canon = canonicalize_path(file);
2134 let data = graph.build_file(&canon)?.clone();
2135 file_data.push((canon, data));
2136 }
2137
2138 let mut edges = BTreeSet::new();
2139 for (caller_file, data) in &file_data {
2140 for (caller_symbol, call_sites) in &data.calls_by_symbol {
2141 for call_site in call_sites {
2142 let resolution = graph.resolve_cross_file_edge(
2143 &call_site.full_callee,
2144 &call_site.callee_name,
2145 caller_file,
2146 &data.import_block,
2147 );
2148 let (target_file, target_symbol) = match resolution {
2149 EdgeResolution::Resolved { file, symbol } => (file, symbol),
2150 EdgeResolution::Unresolved { callee_name } => {
2151 if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
2152 continue;
2153 }
2154 let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
2155 data,
2156 caller_file,
2157 &callee_name,
2158 ) else {
2159 continue;
2160 };
2161 (caller_file.clone(), target_symbol)
2162 }
2163 };
2164 if target_file == *caller_file && target_symbol == *caller_symbol {
2165 continue;
2166 }
2167 edges.insert(StoredEdge {
2168 source_file: relative_path(project_root, caller_file),
2169 source_symbol: caller_symbol.clone(),
2170 target_file: relative_path(project_root, &target_file),
2171 target_symbol,
2172 kind: "call".to_string(),
2173 line: call_site.line,
2174 });
2175 }
2176 }
2177 }
2178 Ok(edges)
2179}
2180
2181fn configure_connection(conn: &Connection) -> Result<()> {
2182 conn.pragma_update(None, "journal_mode", "WAL")?;
2183 conn.pragma_update(None, "busy_timeout", 5_000)?;
2184 Ok(())
2185}
2186
2187fn configure_build_connection(conn: &Connection) -> Result<()> {
2188 conn.pragma_update(None, "journal_mode", "DELETE")?;
2189 conn.pragma_update(None, "busy_timeout", 5_000)?;
2190 Ok(())
2191}
2192
2193fn initialize_schema(conn: &Connection) -> Result<()> {
2194 conn.execute_batch(
2195 "CREATE TABLE IF NOT EXISTS files (
2196 path TEXT PRIMARY KEY,
2197 content_hash TEXT NOT NULL,
2198 mtime_ns INTEGER NOT NULL,
2199 size INTEGER NOT NULL,
2200 lang TEXT NOT NULL,
2201 is_dead_code_root INTEGER NOT NULL DEFAULT 0,
2202 is_public_api INTEGER NOT NULL DEFAULT 0,
2203 surface_fingerprint TEXT NOT NULL,
2204 indexed_at INTEGER NOT NULL
2205 );
2206
2207 CREATE TABLE IF NOT EXISTS nodes (
2208 id TEXT PRIMARY KEY,
2209 file_path TEXT NOT NULL,
2210 name TEXT NOT NULL,
2211 scoped_name TEXT NOT NULL,
2212 kind TEXT NOT NULL,
2213 start_line INTEGER NOT NULL,
2214 start_col INTEGER NOT NULL,
2215 end_line INTEGER NOT NULL,
2216 end_col INTEGER NOT NULL,
2217 range_ordinal INTEGER NOT NULL,
2218 signature TEXT,
2219 exported INTEGER NOT NULL,
2220 is_default_export INTEGER NOT NULL,
2221 is_type_like INTEGER NOT NULL,
2222 is_callgraph_entry_point INTEGER NOT NULL,
2223 provenance TEXT NOT NULL,
2224 UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
2225 );
2226 CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
2227 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
2228 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
2229
2230 CREATE TABLE IF NOT EXISTS refs (
2231 ref_id TEXT PRIMARY KEY,
2232 caller_node TEXT,
2233 caller_file TEXT NOT NULL,
2234 kind TEXT NOT NULL,
2235 short_name TEXT,
2236 full_ref TEXT,
2237 module_path TEXT,
2238 import_kind TEXT,
2239 local_name TEXT,
2240 requested_name TEXT,
2241 namespace_alias TEXT,
2242 wildcard INTEGER NOT NULL DEFAULT 0,
2243 line INTEGER NOT NULL,
2244 byte_start INTEGER NOT NULL,
2245 byte_end INTEGER NOT NULL,
2246 status TEXT NOT NULL,
2247 target_node TEXT,
2248 target_file TEXT,
2249 target_symbol TEXT,
2250 provenance TEXT NOT NULL
2251 );
2252 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
2253 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
2254 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
2255 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
2256
2257 CREATE TABLE IF NOT EXISTS file_dependencies (
2258 file_path TEXT NOT NULL,
2259 dep_file TEXT NOT NULL,
2260 PRIMARY KEY(file_path, dep_file)
2261 );
2262 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
2263
2264 CREATE TABLE IF NOT EXISTS edges (
2265 edge_id TEXT PRIMARY KEY,
2266 ref_id TEXT NOT NULL,
2267 source_node TEXT NOT NULL,
2268 target_node TEXT,
2269 target_file TEXT NOT NULL,
2270 target_symbol TEXT NOT NULL,
2271 kind TEXT NOT NULL,
2272 line INTEGER NOT NULL,
2273 provenance TEXT NOT NULL
2274 );
2275 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
2276 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
2277 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
2278 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
2279
2280 CREATE TABLE IF NOT EXISTS dispatch_hints (
2281 id TEXT PRIMARY KEY,
2282 method_name TEXT NOT NULL,
2283 caller_node TEXT NOT NULL,
2284 file TEXT NOT NULL,
2285 line INTEGER NOT NULL,
2286 byte_start INTEGER NOT NULL,
2287 byte_end INTEGER NOT NULL,
2288 provenance TEXT NOT NULL
2289 );
2290 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
2291
2292 CREATE TABLE IF NOT EXISTS type_ref_names (
2293 name TEXT PRIMARY KEY
2294 );
2295
2296 CREATE TABLE IF NOT EXISTS backend_file_state (
2297 backend TEXT NOT NULL,
2298 workspace_root TEXT NOT NULL,
2299 file_path TEXT NOT NULL,
2300 content_hash TEXT NOT NULL,
2301 status TEXT NOT NULL,
2302 updated_at INTEGER NOT NULL,
2303 PRIMARY KEY(backend, workspace_root, file_path, content_hash)
2304 );
2305 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
2306
2307 CREATE TABLE IF NOT EXISTS meta (
2308 k TEXT PRIMARY KEY,
2309 v TEXT NOT NULL
2310 );",
2311 )?;
2312 insert_meta(conn)?;
2313 Ok(())
2314}
2315
2316fn insert_meta(conn: &Connection) -> Result<()> {
2317 conn.execute(
2318 "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
2319 params![SCHEMA_VERSION.to_string()],
2320 )?;
2321 conn.execute(
2322 "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
2323 params![schema_fingerprint()],
2324 )?;
2325 Ok(())
2326}
2327
2328fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
2329 conn.execute(
2330 "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
2331 params![if ready { "1" } else { "0" }],
2332 )?;
2333 Ok(())
2334}
2335
2336fn database_ready(conn: &Connection) -> Result<bool> {
2337 let schema_version: Option<String> = conn
2338 .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
2339 row.get(0)
2340 })
2341 .optional()?;
2342 let fingerprint: Option<String> = conn
2343 .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
2344 row.get(0)
2345 })
2346 .optional()?;
2347 let ready: Option<String> = conn
2348 .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
2349 .optional()?;
2350
2351 let expected_schema = SCHEMA_VERSION.to_string();
2352 let expected_fingerprint = schema_fingerprint();
2353 Ok(schema_version.as_deref() == Some(expected_schema.as_str())
2354 && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
2355 && ready.as_deref() == Some("1"))
2356}
2357
2358fn ensure_database_ready(conn: &Connection) -> Result<()> {
2359 if database_ready(conn)? {
2360 Ok(())
2361 } else {
2362 Err(CallGraphStoreError::Unavailable(
2363 "database is missing, stale, or mid-build".to_string(),
2364 ))
2365 }
2366}
2367
2368fn schema_fingerprint() -> String {
2369 let input = format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v7-lean");
2374 hash_to_hex(blake3::hash(input.as_bytes()))
2375}
2376
2377fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
2378 tx.execute_batch(
2379 "DELETE FROM edges;
2380 DELETE FROM file_dependencies;
2381 DELETE FROM refs;
2382 DELETE FROM dispatch_hints;
2383 DELETE FROM type_ref_names;
2384 DELETE FROM backend_file_state;
2385 DELETE FROM nodes;
2386 DELETE FROM files;",
2387 )?;
2388 Ok(())
2389}
2390
2391fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
2392 tx.execute_batch(
2393 "DROP INDEX IF EXISTS idx_nodes_file;
2394 DROP INDEX IF EXISTS idx_nodes_name;
2395 DROP INDEX IF EXISTS idx_nodes_scoped;
2396 DROP INDEX IF EXISTS idx_refs_short_name;
2397 DROP INDEX IF EXISTS idx_refs_caller_file;
2398 DROP INDEX IF EXISTS idx_refs_caller_node_kind;
2399 DROP INDEX IF EXISTS idx_refs_target_file;
2400 DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
2401 DROP INDEX IF EXISTS idx_edges_source_kind;
2402 DROP INDEX IF EXISTS idx_edges_target_kind;
2403 DROP INDEX IF EXISTS idx_edges_target_file_symbol;
2404 DROP INDEX IF EXISTS idx_edges_ref_id;
2405 DROP INDEX IF EXISTS idx_dispatch_hints_method;
2406 DROP INDEX IF EXISTS idx_backend_file_state_file;",
2407 )?;
2408 Ok(())
2409}
2410
2411fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
2412 tx.execute_batch(
2413 "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
2414 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
2415 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
2416 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
2417 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
2418 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
2419 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
2420 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
2421 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
2422 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
2423 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
2424 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
2425 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
2426 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
2427 )?;
2428 Ok(())
2429}
2430
2431const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
2432 ("files", "path"),
2433 ("nodes", "file_path"),
2434 ("refs", "caller_file"),
2435 ("refs", "target_file"),
2436 ("file_dependencies", "file_path"),
2437 ("file_dependencies", "dep_file"),
2438 ("edges", "target_file"),
2439 ("dispatch_hints", "file"),
2440 ("backend_file_state", "file_path"),
2441];
2442
2443fn reconcile_workspace_roots(conn: &mut Connection, project_root: &Path) -> Result<OpenRootRepair> {
2456 let roots = stored_workspace_roots(conn)?;
2457 let current_root = project_root.display().to_string();
2458 if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
2459 return Ok(OpenRootRepair::None);
2460 }
2461
2462 if let Some(sample) = sample_absolute_data_path(conn)? {
2463 return Ok(OpenRootRepair::NeedsRebuild {
2464 previous_roots: roots,
2465 current_root,
2466 reason: format!("absolute store data path row {sample}"),
2467 });
2468 }
2469
2470 for stored_root in roots.iter() {
2471 if stored_root == ¤t_root {
2472 continue;
2473 }
2474 if Path::new(stored_root).exists() {
2475 let reason = format!(
2476 "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
2477 );
2478 return Ok(OpenRootRepair::NeedsRebuild {
2479 previous_roots: roots,
2480 current_root,
2481 reason,
2482 });
2483 }
2484 }
2485
2486 let tx = conn.transaction()?;
2487 tx.execute(
2488 "UPDATE OR IGNORE backend_file_state
2489 SET workspace_root = ?1
2490 WHERE workspace_root <> ?1",
2491 params![¤t_root],
2492 )?;
2493 tx.execute(
2494 "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
2495 params![¤t_root],
2496 )?;
2497 tx.commit()?;
2498
2499 crate::slog_info!(
2500 "callgraph store re-rooted from {} to {}",
2501 roots.join(", "),
2502 current_root
2503 );
2504 Ok(OpenRootRepair::ReRooted)
2505}
2506
2507fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
2508 let mut stmt = conn.prepare(
2509 "SELECT DISTINCT workspace_root
2510 FROM backend_file_state
2511 ORDER BY workspace_root",
2512 )?;
2513 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
2514 rows.collect::<std::result::Result<Vec<_>, _>>()
2515 .map_err(Into::into)
2516}
2517
2518fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
2519 for (table, column) in STORE_DATA_PATH_COLUMNS {
2520 let sql = format!(
2521 "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
2522 );
2523 let mut stmt = conn.prepare(&sql)?;
2524 let mut rows = stmt.query([])?;
2525 while let Some(row) = rows.next()? {
2526 let value: String = row.get(0)?;
2527 if stored_path_is_absolute(&value) {
2528 return Ok(Some(format!("{table}.{column}={value}")));
2529 }
2530 }
2531 }
2532 Ok(None)
2533}
2534
2535fn stored_path_is_absolute(value: &str) -> bool {
2536 if value.is_empty() {
2537 return false;
2538 }
2539 if Path::new(value).is_absolute() || value.starts_with('/') {
2540 return true;
2541 }
2542 let bytes = value.as_bytes();
2543 if bytes.len() >= 3
2544 && bytes[1] == b':'
2545 && (bytes[2] == b'/' || bytes[2] == b'\\')
2546 && bytes[0].is_ascii_alphabetic()
2547 {
2548 return true;
2549 }
2550 value.starts_with("\\\\") || value.starts_with("//")
2551}
2552
2553fn log_root_repair_rebuild(repair: &OpenRootRepair) {
2554 if let OpenRootRepair::NeedsRebuild {
2555 previous_roots,
2556 current_root,
2557 reason,
2558 } = repair
2559 {
2560 crate::slog_info!(
2561 "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
2562 previous_roots.join(", "),
2563 current_root,
2564 reason
2565 );
2566 }
2567}
2568
2569fn now_nanos() -> u128 {
2571 SystemTime::now()
2572 .duration_since(UNIX_EPOCH)
2573 .unwrap_or(Duration::ZERO)
2574 .as_nanos()
2575}
2576
2577fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
2582 callgraph_dir.join(format!("{project_key}.current"))
2583}
2584
2585fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
2589 callgraph_dir.join(format!("{project_key}.sqlite"))
2590}
2591
2592fn generation_file_name(project_key: &str) -> String {
2596 format!(
2597 "{project_key}.g{}.{}.sqlite",
2598 now_nanos(),
2599 std::process::id()
2600 )
2601}
2602
2603fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
2605 let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
2606 let name = text.trim();
2607 if name.is_empty() {
2608 None
2609 } else {
2610 Some(name.to_string())
2611 }
2612}
2613
2614fn db_path_ready(path: &Path) -> bool {
2617 (|| -> Result<bool> {
2618 let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
2619 conn.busy_timeout(Duration::from_millis(5_000))?;
2620 database_ready(&conn)
2621 })()
2622 .unwrap_or(false)
2623}
2624
2625fn resolve_ready_target(
2633 callgraph_dir: &Path,
2634 project_key: &str,
2635) -> Option<(PathBuf, Option<String>)> {
2636 for _ in 0..5 {
2637 if let Some(generation) = read_pointer(callgraph_dir, project_key) {
2638 let gen_path = callgraph_dir.join(&generation);
2639 if gen_path.is_file() {
2640 return db_path_ready(&gen_path).then_some((gen_path, Some(generation)));
2641 }
2642 std::thread::sleep(Duration::from_millis(5));
2645 continue;
2646 }
2647 let legacy = legacy_sqlite_path(callgraph_dir, project_key);
2649 return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
2650 }
2651 None
2652}
2653
2654fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
2658 let pointer = pointer_path(callgraph_dir, project_key);
2659 let tmp = callgraph_dir.join(format!(
2660 "{project_key}.current.tmp.{}.{}",
2661 std::process::id(),
2662 now_nanos()
2663 ));
2664 {
2665 use std::io::Write as _;
2666 let mut file = std::fs::File::create(&tmp)?;
2667 file.write_all(generation.as_bytes())?;
2668 file.write_all(b"\n")?;
2669 file.sync_all()?;
2670 }
2671 if let Err(error) = std::fs::rename(&tmp, &pointer) {
2672 let _ = std::fs::remove_file(&tmp);
2673 return Err(error.into());
2674 }
2675 Ok(())
2676}
2677
2678fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
2684 let grace = Duration::from_secs(60);
2685 let now = SystemTime::now();
2686 let gen_prefix = format!("{project_key}.g");
2687 let tmp_prefixes = [
2688 format!("{project_key}.g"), format!("{project_key}.current."), format!("{project_key}.sqlite.tmp."), ];
2692 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
2693 return;
2694 };
2695 let mut gens: Vec<(PathBuf, SystemTime)> = Vec::new();
2696 for entry in entries.flatten() {
2697 let name = entry.file_name();
2698 let name = name.to_string_lossy();
2699 let mtime = entry
2700 .metadata()
2701 .and_then(|m| m.modified())
2702 .unwrap_or_else(|_| SystemTime::now());
2703 let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= grace;
2704
2705 if name.contains(".tmp.") {
2707 if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
2708 let _ = std::fs::remove_file(entry.path());
2709 }
2710 continue;
2711 }
2712
2713 if *name == *format!("{project_key}.sqlite") {
2716 remove_sqlite_file_set(&entry.path());
2717 continue;
2718 }
2719
2720 if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") && name != current {
2721 gens.push((entry.path(), mtime));
2722 }
2723 }
2724 gens.sort_by(|a, b| b.1.cmp(&a.1));
2729 for (index, (path, mtime)) in gens.into_iter().enumerate() {
2730 if index == 0 {
2731 continue;
2732 }
2733 if now.duration_since(mtime).unwrap_or(Duration::ZERO) < grace {
2734 continue;
2735 }
2736 remove_sqlite_file_set(&path);
2737 }
2738}
2739
2740fn remove_sqlite_file_set(path: &Path) {
2741 let _ = std::fs::remove_file(path);
2742 remove_sqlite_sidecars(path);
2743}
2744
2745fn remove_sqlite_sidecars(path: &Path) {
2746 let path_text = path.to_string_lossy();
2747 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
2748 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
2749 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
2750}
2751
2752fn build_pool_size() -> usize {
2760 std::thread::available_parallelism()
2761 .map(|parallelism| parallelism.get())
2762 .unwrap_or(1)
2763 .div_ceil(2)
2764 .clamp(1, 8)
2765}
2766
2767fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
2768 let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
2769 Ok(extract) => Ok(extract),
2770 Err(error) => {
2771 let abs_path =
2772 normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
2773 let rel_path = relative_path(project_root, &abs_path);
2774 let freshness = cache_freshness::collect(&abs_path).ok();
2775 log::debug!(
2776 "callgraph store: skipping {} during cold build: {}",
2777 abs_path.display(),
2778 error
2779 );
2780 Err(ExtractFailure {
2781 rel_path,
2782 freshness,
2783 })
2784 }
2785 };
2786
2787 let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
2788 files.par_iter().map(extract_one).collect()
2789 };
2790
2791 let results = match rayon::ThreadPoolBuilder::new()
2794 .num_threads(build_pool_size())
2795 .thread_name(|index| format!("aft-callgraph-build-{index}"))
2796 .stack_size(8 * 1024 * 1024)
2797 .build()
2798 {
2799 Ok(pool) => pool.install(run),
2800 Err(error) => {
2801 log::warn!(
2802 "callgraph store: bounded build pool unavailable ({error}); using global pool"
2803 );
2804 run()
2805 }
2806 };
2807
2808 let mut extracts = Vec::new();
2809 let mut failures = Vec::new();
2810 for result in results {
2811 match result {
2812 Ok(extract) => extracts.push(extract),
2813 Err(failure) => failures.push(failure),
2814 }
2815 }
2816 BuildExtractsResult { extracts, failures }
2817}
2818
2819fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
2820 let metadata = std::fs::metadata(path)?;
2821 let size = metadata.len();
2822 let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
2823 cache_freshness::zero_hash()
2824 } else if source.len() as u64 == size {
2825 cache_freshness::hash_bytes(source.as_bytes())
2826 } else {
2827 cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
2828 };
2829 Ok(FileFreshness {
2830 mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
2831 size,
2832 content_hash,
2833 })
2834}
2835
2836fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
2837 let abs_path = normalize_file_path(project_root, path)?;
2838 let rel_path = relative_path(project_root, &abs_path);
2839 let source = std::fs::read_to_string(&abs_path)?;
2840 let freshness = collect_source_freshness(&abs_path, &source)?;
2841 let data = callgraph::build_file_data_from_source(&abs_path, &source)?;
2842 let lang = data.lang;
2843 let mut nodes = build_node_records(&rel_path, &source, &data)?;
2844 let node_by_scoped: HashMap<String, String> = nodes
2845 .iter()
2846 .map(|node| (node.scoped_name.clone(), node.id.clone()))
2847 .collect();
2848 let import_dependencies =
2849 import_dependencies(project_root, &abs_path, &data.import_block.imports);
2850 let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
2851 let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
2852 let mut raw_refs = Vec::new();
2853 raw_refs.extend(build_call_refs(
2854 &rel_path,
2855 &data,
2856 &node_by_scoped,
2857 &import_dependencies,
2858 ));
2859 let line_index = LineIndex::new(&source);
2860 raw_refs.extend(build_import_refs(
2861 project_root,
2862 &abs_path,
2863 &rel_path,
2864 &data.import_block.imports,
2865 &line_index,
2866 ));
2867 let mut surface_parts = reexports.surface_parts;
2868 surface_parts.extend(source_less_exports.surface_parts);
2869 raw_refs.extend(reexports.raw_refs);
2870 raw_refs.extend(source_less_exports.raw_refs);
2871 let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
2872 let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
2873
2874 Ok(FileExtract {
2875 abs_path,
2876 rel_path,
2877 freshness,
2878 lang,
2879 data,
2880 nodes,
2881 raw_refs,
2882 dispatch_hints,
2883 surface_fingerprint,
2884 })
2885}
2886
2887fn build_node_records(
2888 rel_path: &str,
2889 source: &str,
2890 data: &FileCallData,
2891) -> Result<Vec<NodeRecord>> {
2892 let mut records = Vec::new();
2893 let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
2894 let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
2895 metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
2896
2897 for (scoped_name, meta) in metadata {
2898 let name = unqualified_name(scoped_name).to_string();
2899 let range = selection_range(source, scoped_name, &name, &meta.range);
2900 let range_key = (
2901 range.start_line,
2902 range.start_col,
2903 range.end_line,
2904 range.end_col,
2905 );
2906 let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
2907 let range_ordinal = *ordinal;
2908 *ordinal += 1;
2909 let id = node_id(rel_path, &range, range_ordinal, scoped_name);
2910 let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
2911 let is_default_export = data
2912 .default_export_symbol
2913 .as_deref()
2914 .map(|default| default == scoped_name || default == name)
2915 .unwrap_or(false);
2916 records.push(NodeRecord {
2917 id,
2918 file_path: rel_path.to_string(),
2919 name: name.clone(),
2920 scoped_name: scoped_name.clone(),
2921 kind: symbol_kind_label(&meta.kind).to_string(),
2922 range,
2923 range_ordinal,
2924 signature: meta.signature.clone(),
2925 exported,
2926 is_default_export,
2927 is_type_like: is_type_like(&meta.kind),
2928 is_callgraph_entry_point: callgraph::is_entry_point(
2929 scoped_name,
2930 &meta.kind,
2931 exported,
2932 data.lang,
2933 ),
2934 });
2935 }
2936
2937 Ok(records)
2938}
2939
2940fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
2941 if scoped_name == TOP_LEVEL_SYMBOL {
2942 return Range {
2943 start_line: 0,
2944 start_col: 0,
2945 end_line: 0,
2946 end_col: 0,
2947 };
2948 }
2949 let Some(line) = source.lines().nth(fallback.start_line as usize) else {
2950 return fallback.clone();
2951 };
2952 let start_col = fallback.start_col as usize;
2953 let search_start = start_col.min(line.len());
2954 if let Some(offset) = line[search_start..].find(name) {
2955 let col = search_start + offset;
2956 return Range {
2957 start_line: fallback.start_line,
2958 start_col: col as u32,
2959 end_line: fallback.start_line,
2960 end_col: (col + name.len()) as u32,
2961 };
2962 }
2963 if let Some(offset) = line.find(name) {
2964 return Range {
2965 start_line: fallback.start_line,
2966 start_col: offset as u32,
2967 end_line: fallback.start_line,
2968 end_col: (offset + name.len()) as u32,
2969 };
2970 }
2971 Range {
2972 start_line: fallback.start_line,
2973 start_col: fallback.start_col,
2974 end_line: fallback.start_line,
2975 end_col: fallback.start_col.saturating_add(name.len() as u32),
2976 }
2977}
2978
2979fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
2980 if scoped_name == TOP_LEVEL_SYMBOL {
2981 return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
2982 }
2983 let input = format!(
2984 "{rel_path}:{}:{}:{}:{}:{ordinal}",
2985 range.start_line, range.start_col, range.end_line, range.end_col
2986 );
2987 format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
2988}
2989
2990fn build_call_refs(
2991 rel_path: &str,
2992 data: &FileCallData,
2993 node_by_scoped: &HashMap<String, String>,
2994 import_dependencies: &BTreeSet<String>,
2995) -> Vec<RawRef> {
2996 let mut refs = Vec::new();
2997 let mut ordinal = 0usize;
2998 let mut symbols: Vec<_> = data.calls_by_symbol.iter().collect();
2999 symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
3000 for (caller_symbol, call_sites) in symbols {
3001 let caller_node = node_by_scoped.get(caller_symbol).cloned();
3002 for call_site in call_sites {
3003 ordinal += 1;
3004 let ref_id = ref_id(&[
3005 rel_path,
3006 "call",
3007 caller_symbol,
3008 &call_site.line.to_string(),
3009 &call_site.byte_start.to_string(),
3010 &call_site.byte_end.to_string(),
3011 &call_site.full_callee,
3012 &ordinal.to_string(),
3013 ]);
3014 refs.push(RawRef {
3015 ref_id,
3016 caller_node: caller_node.clone(),
3017 caller_symbol: Some(caller_symbol.clone()),
3018 caller_file: rel_path.to_string(),
3019 kind: "call".to_string(),
3020 short_name: Some(call_site.callee_name.clone()),
3021 full_ref: Some(call_site.full_callee.clone()),
3022 module_path: None,
3023 import_kind: None,
3024 local_name: Some(call_site.callee_name.clone()),
3025 requested_name: Some(call_site.callee_name.clone()),
3026 namespace_alias: namespace_alias(&call_site.full_callee),
3027 wildcard: false,
3028 line: call_site.line,
3029 byte_start: call_site.byte_start,
3030 byte_end: call_site.byte_end,
3031 dependencies: import_dependencies.clone(),
3032 });
3033 }
3034 }
3035 refs
3036}
3037
3038fn build_import_refs(
3039 project_root: &Path,
3040 abs_path: &Path,
3041 rel_path: &str,
3042 imports: &[ImportStatement],
3043 line_index: &LineIndex,
3044) -> Vec<RawRef> {
3045 let mut refs = Vec::new();
3046 for (index, import) in imports.iter().enumerate() {
3047 let import_kind = import_kind_label(import.kind).to_string();
3048 let local_name = import_local_names(import).join(",");
3049 let requested_name = import_requested_names(import).join(",");
3050 let ref_id = ref_id(&[
3051 rel_path,
3052 "import",
3053 &import.byte_range.start.to_string(),
3054 &import.byte_range.end.to_string(),
3055 &import.module_path,
3056 &index.to_string(),
3057 ]);
3058 refs.push(RawRef {
3059 ref_id,
3060 caller_node: None,
3061 caller_symbol: None,
3062 caller_file: rel_path.to_string(),
3063 kind: "import".to_string(),
3064 short_name: None,
3065 full_ref: Some(import.raw_text.clone()),
3066 module_path: Some(import.module_path.clone()),
3067 import_kind: Some(import_kind),
3068 local_name: empty_to_none(local_name),
3069 requested_name: empty_to_none(requested_name),
3070 namespace_alias: import.namespace_import.clone(),
3071 wildcard: import_is_wildcard(import),
3072 line: line_index.byte_to_line(import.byte_range.start),
3073 byte_start: import.byte_range.start,
3074 byte_end: import.byte_range.end,
3075 dependencies: module_dependencies(project_root, abs_path, &import.module_path),
3076 });
3077 }
3078 refs
3079}
3080
3081#[derive(Debug, Clone)]
3082struct ReexportRefs {
3083 raw_refs: Vec<RawRef>,
3084 surface_parts: Vec<String>,
3085}
3086
3087fn collect_reexport_refs(
3088 project_root: &Path,
3089 abs_path: &Path,
3090 rel_path: &str,
3091 source: &str,
3092) -> ReexportRefs {
3093 let mut raw_refs = Vec::new();
3094 let mut surface_parts = Vec::new();
3095 let mut search_start = 0usize;
3096 let mut ordinal = 0usize;
3097 while let Some(export_offset) = source[search_start..].find("export") {
3098 let start = search_start + export_offset;
3099 let Some(statement_end_offset) = source[start..].find(';') else {
3100 break;
3101 };
3102 let end = start + statement_end_offset + 1;
3103 let statement = &source[start..end];
3104 search_start = end;
3105 if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
3106 continue;
3107 }
3108 let Some(module_path) = quoted_module_path(statement) else {
3109 continue;
3110 };
3111 ordinal += 1;
3112 let wildcard = statement.contains('*');
3113 let line = source[..start]
3114 .bytes()
3115 .filter(|byte| *byte == b'\n')
3116 .count() as u32
3117 + 1;
3118 let ref_id = ref_id(&[
3119 rel_path,
3120 "reexport",
3121 &start.to_string(),
3122 &end.to_string(),
3123 &module_path,
3124 &ordinal.to_string(),
3125 ]);
3126 surface_parts.push(format!("reexport\t{statement}"));
3127 raw_refs.push(RawRef {
3128 ref_id,
3129 caller_node: None,
3130 caller_symbol: None,
3131 caller_file: rel_path.to_string(),
3132 kind: "reexport".to_string(),
3133 short_name: None,
3134 full_ref: Some(statement.to_string()),
3135 module_path: Some(module_path.clone()),
3136 import_kind: Some("reexport".to_string()),
3137 local_name: None,
3138 requested_name: None,
3139 namespace_alias: None,
3140 wildcard,
3141 line,
3142 byte_start: start,
3143 byte_end: end,
3144 dependencies: module_dependencies(project_root, abs_path, &module_path),
3145 });
3146 }
3147 ReexportRefs {
3148 raw_refs,
3149 surface_parts,
3150 }
3151}
3152
3153fn quoted_module_path(statement: &str) -> Option<String> {
3154 let quote = match (statement.find('\''), statement.find('"')) {
3155 (Some(single), Some(double)) if single < double => '\'',
3156 (Some(_), Some(_)) => '"',
3157 (Some(_), None) => '\'',
3158 (None, Some(_)) => '"',
3159 (None, None) => return None,
3160 };
3161 let start = statement.find(quote)? + 1;
3162 let end = statement[start..].find(quote)? + start;
3163 Some(statement[start..end].to_string())
3164}
3165
3166#[derive(Debug, Clone)]
3167struct SourceLessExportRefs {
3168 raw_refs: Vec<RawRef>,
3169 surface_parts: Vec<String>,
3170}
3171
3172fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
3173 let mut raw_refs = Vec::new();
3174 let mut surface_parts = Vec::new();
3175 let mut search_start = 0usize;
3176 let mut ordinal = 0usize;
3177 while let Some(export_offset) = source[search_start..].find("export") {
3178 let start = search_start + export_offset;
3179 let Some(statement_end_offset) = source[start..].find(';') else {
3180 break;
3181 };
3182 let end = start + statement_end_offset + 1;
3183 let statement = &source[start..end];
3184 search_start = end;
3185 if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
3186 continue;
3187 }
3188 let aliases = parse_reexport_names(statement);
3189 if aliases.is_empty() {
3190 continue;
3191 }
3192 let line = source[..start]
3193 .bytes()
3194 .filter(|byte| *byte == b'\n')
3195 .count() as u32
3196 + 1;
3197 for (exported, source_symbol) in aliases {
3198 ordinal += 1;
3199 let ref_id = ref_id(&[
3200 rel_path,
3201 "export_alias",
3202 &start.to_string(),
3203 &end.to_string(),
3204 &exported,
3205 &source_symbol,
3206 &ordinal.to_string(),
3207 ]);
3208 surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
3209 raw_refs.push(RawRef {
3210 ref_id,
3211 caller_node: None,
3212 caller_symbol: None,
3213 caller_file: rel_path.to_string(),
3214 kind: "export_alias".to_string(),
3215 short_name: None,
3216 full_ref: Some(statement.to_string()),
3217 module_path: None,
3218 import_kind: Some("export_alias".to_string()),
3219 local_name: Some(exported),
3220 requested_name: Some(source_symbol),
3221 namespace_alias: None,
3222 wildcard: false,
3223 line,
3224 byte_start: start,
3225 byte_end: end,
3226 dependencies: BTreeSet::new(),
3227 });
3228 }
3229 }
3230 SourceLessExportRefs {
3231 raw_refs,
3232 surface_parts,
3233 }
3234}
3235
3236fn build_dispatch_hints(
3237 rel_path: &str,
3238 data: &FileCallData,
3239 node_by_scoped: &HashMap<String, String>,
3240) -> Vec<DispatchHint> {
3241 let mut hints = Vec::new();
3242 let mut ordinal = 0usize;
3243 for (caller_symbol, call_sites) in &data.calls_by_symbol {
3244 let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
3245 continue;
3246 };
3247 for call_site in call_sites {
3248 if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
3249 continue;
3250 }
3251 ordinal += 1;
3252 hints.push(DispatchHint {
3253 id: ref_id(&[
3254 rel_path,
3255 "dispatch",
3256 caller_symbol,
3257 &call_site.line.to_string(),
3258 &call_site.byte_start.to_string(),
3259 &call_site.byte_end.to_string(),
3260 &ordinal.to_string(),
3261 ]),
3262 method_name: call_site.callee_name.clone(),
3263 caller_node: caller_node.clone(),
3264 file: rel_path.to_string(),
3265 line: call_site.line,
3266 byte_start: call_site.byte_start,
3267 byte_end: call_site.byte_end,
3268 });
3269 }
3270 }
3271 hints
3272}
3273
3274fn surface_fingerprint(
3275 nodes: &mut [NodeRecord],
3276 data: &FileCallData,
3277 reexport_parts: &[String],
3278) -> String {
3279 nodes.sort_by(|left, right| {
3280 (left.file_path.as_str(), left.scoped_name.as_str())
3281 .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
3282 });
3283 let mut parts = Vec::new();
3284 for node in nodes.iter() {
3285 parts.push(format!(
3286 "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
3287 node.scoped_name,
3288 node.name,
3289 node.kind,
3290 node.exported,
3291 node.range.start_line,
3292 node.range.start_col,
3293 node.range.end_line,
3294 node.range.end_col,
3295 node.range_ordinal,
3296 node.signature.as_deref().unwrap_or("")
3297 ));
3298 }
3299 let mut exports = data.exported_symbols.clone();
3300 exports.sort();
3301 for export in exports {
3302 parts.push(format!("export\t{export}"));
3303 }
3304 if let Some(default_export) = &data.default_export_symbol {
3305 parts.push(format!("default\t{default_export}"));
3306 }
3307 let mut imports: Vec<String> = data
3308 .import_block
3309 .imports
3310 .iter()
3311 .map(|import| {
3312 format!(
3313 "import\t{}\t{:?}\t{}",
3314 import.module_path, import.form, import.raw_text
3315 )
3316 })
3317 .collect();
3318 imports.sort();
3319 parts.extend(imports);
3320 parts.extend(reexport_parts.iter().cloned());
3321 hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
3322}
3323
3324fn resolve_ref(raw: RawRef, index: &ProjectIndex<'_>) -> Result<ResolvedRef> {
3325 if raw.kind != "call" {
3326 return Ok(ResolvedRef {
3327 dependencies: raw.dependencies.clone(),
3328 raw,
3329 status: "unresolved".to_string(),
3330 target_node: None,
3331 target_file: None,
3332 target_symbol: None,
3333 edge: None,
3334 });
3335 }
3336
3337 let caller_file = raw.caller_file.clone();
3338 let caller_data = index.caller_data.get(&caller_file).ok_or_else(|| {
3339 CallGraphStoreError::MissingCallerData {
3340 file: caller_file.clone(),
3341 }
3342 })?;
3343 let full_ref = raw.full_ref.as_deref().unwrap_or_default();
3344 let short_name = raw.short_name.as_deref().unwrap_or_default();
3345 let mut dependencies = raw.dependencies.clone();
3346
3347 let resolved = match index.lang_for(&caller_file) {
3348 Some(LangId::Rust) => {
3349 resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data)
3350 }
3351 Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
3352 resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
3353 }
3354 _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
3355 };
3356
3357 let Some((status, target_file, target_symbol)) = resolved else {
3358 return Ok(ResolvedRef {
3359 raw,
3360 status: "unresolved".to_string(),
3361 target_node: None,
3362 target_file: None,
3363 target_symbol: None,
3364 dependencies,
3365 edge: None,
3366 });
3367 };
3368
3369 dependencies.insert(target_file.clone());
3370 let target_node = index.node_for_symbol(&target_file, &target_symbol);
3371 let source_node = raw.caller_node.clone();
3372 let edge = if let Some(source_node) = source_node {
3373 if target_file == caller_file
3374 && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
3375 {
3376 None
3377 } else {
3378 Some(EdgeRecord {
3379 edge_id: ref_id(&[&raw.ref_id, "edge"]),
3380 source_node,
3381 target_node: target_node.clone(),
3382 target_file: target_file.clone(),
3383 target_symbol: target_symbol.clone(),
3384 kind: "call".to_string(),
3385 line: raw.line,
3386 })
3387 }
3388 } else {
3389 None
3390 };
3391
3392 Ok(ResolvedRef {
3393 raw,
3394 status,
3395 target_node,
3396 target_file: Some(target_file),
3397 target_symbol: Some(target_symbol),
3398 dependencies,
3399 edge,
3400 })
3401}
3402
3403fn resolve_js_ts_target(
3404 index: &ProjectIndex<'_>,
3405 caller_file: &str,
3406 full_ref: &str,
3407 short_name: &str,
3408 caller_data: &FileCallData,
3409) -> Option<(String, String, String)> {
3410 if let Some((namespace, member)) = full_ref.split_once('.') {
3411 for import in &caller_data.import_block.imports {
3412 if import.namespace_import.as_deref() == Some(namespace) {
3413 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
3414 if let Some((file, symbol)) =
3415 resolve_exported_symbol(index, &target_file, member, 0)
3416 {
3417 return Some(("resolved".to_string(), file, symbol));
3418 }
3419 }
3420 }
3421 }
3422 }
3423
3424 for import in &caller_data.import_block.imports {
3425 for spec in &import.names {
3426 if crate::imports::specifier_local_name(spec) == short_name {
3427 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
3428 let requested = crate::imports::specifier_imported_name(spec);
3429 let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
3430 .unwrap_or_else(|| (target_file, requested.to_string()));
3431 return Some(("resolved".to_string(), file, symbol));
3432 }
3433 }
3434 }
3435
3436 if import.default_import.as_deref() == Some(short_name) {
3437 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
3438 let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
3439 .or_else(|| {
3440 index
3441 .files
3442 .get(&target_file)
3443 .and_then(|file| file.default_export.clone())
3444 .map(|symbol| (target_file.clone(), symbol))
3445 })
3446 .unwrap_or_else(|| {
3447 let file_name = Path::new(&target_file)
3448 .file_name()
3449 .and_then(|name| name.to_str())
3450 .unwrap_or("unknown")
3451 .to_string();
3452 (target_file, format!("<default:{file_name}>"))
3453 });
3454 return Some(("resolved".to_string(), file, symbol));
3455 }
3456 }
3457 }
3458
3459 for import in &caller_data.import_block.imports {
3460 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
3461 if index
3462 .files
3463 .get(&target_file)
3464 .map(|file| file.exports.contains(short_name))
3465 .unwrap_or(false)
3466 {
3467 return Some(("resolved".to_string(), target_file, short_name.to_string()));
3468 }
3469 }
3470 }
3471
3472 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
3473}
3474
3475fn resolve_exported_symbol(
3476 index: &ProjectIndex<'_>,
3477 file: &str,
3478 requested: &str,
3479 depth: usize,
3480) -> Option<(String, String)> {
3481 if depth > 16 {
3482 return None;
3483 }
3484 if requested != "default" {
3485 if let Some(source_symbol) = index
3486 .files
3487 .get(file)
3488 .and_then(|item| item.export_aliases.get(requested))
3489 {
3490 return Some((file.to_string(), source_symbol.clone()));
3491 }
3492 if index
3493 .files
3494 .get(file)
3495 .map(|item| item.exports.contains(requested))
3496 .unwrap_or(false)
3497 {
3498 return Some((file.to_string(), requested.to_string()));
3499 }
3500 } else if let Some(default) = index
3501 .files
3502 .get(file)
3503 .and_then(|item| item.default_export.clone())
3504 {
3505 return Some((file.to_string(), default));
3506 }
3507
3508 for reexport in index.reexports_for(file) {
3509 let mut next_requested = requested.to_string();
3510 let matches = if reexport.wildcard {
3511 true
3512 } else if let Some(source_name) = reexport.named.get(requested) {
3513 next_requested = source_name.clone();
3514 true
3515 } else {
3516 false
3517 };
3518 if !matches {
3519 continue;
3520 }
3521 if let Some(target_file) = &reexport.target_file {
3522 if let Some(target) =
3523 resolve_exported_symbol(index, target_file, &next_requested, depth + 1)
3524 {
3525 return Some(target);
3526 }
3527 }
3528 }
3529 None
3530}
3531
3532fn resolve_rust_target(
3533 index: &ProjectIndex<'_>,
3534 caller_file: &str,
3535 full_ref: &str,
3536 short_name: &str,
3537 caller_data: &FileCallData,
3538) -> Option<(String, String, String)> {
3539 if full_ref.contains("::") {
3540 if let Some(target_file) = rust_target_for_qualified(index, caller_file, full_ref) {
3541 return Some((
3542 "resolved".to_string(),
3543 target_file,
3544 rust_target_symbol(full_ref, short_name),
3545 ));
3546 }
3547 }
3548
3549 for import in &caller_data.import_block.imports {
3550 if let Some((target_file, target_symbol)) =
3551 rust_target_for_use(index, caller_file, import, short_name)
3552 {
3553 return Some(("resolved".to_string(), target_file, target_symbol));
3554 }
3555 }
3556
3557 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
3558}
3559
3560fn rust_target_for_qualified(
3561 index: &ProjectIndex<'_>,
3562 caller_file: &str,
3563 full_ref: &str,
3564) -> Option<String> {
3565 let mut segments: Vec<&str> = full_ref.split("::").collect();
3566 if segments.len() < 2 {
3567 return None;
3568 }
3569 segments.pop();
3570 if !matches!(segments.first().copied(), Some("crate" | "self" | "super")) {
3571 if let Some(target) = rust_workspace_file_for_segments(index, &segments) {
3572 return Some(target);
3573 }
3574 }
3575 let module_segments = rust_resolve_segments(caller_file, &segments)?;
3576 rust_file_for_segments(index, caller_file, &module_segments)
3577}
3578
3579fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
3580 full_ref
3581 .rsplit("::")
3582 .next()
3583 .filter(|name| !name.is_empty())
3584 .unwrap_or(short_name)
3585 .to_string()
3586}
3587
3588fn rust_target_for_use(
3589 index: &ProjectIndex<'_>,
3590 caller_file: &str,
3591 import: &ImportStatement,
3592 short_name: &str,
3593) -> Option<(String, String)> {
3594 let path = import.module_path.trim().trim_end_matches(';');
3595 if let Some(brace_start) = path.find("::{") {
3596 let prefix = &path[..brace_start];
3597 if import.names.iter().any(|name| name == short_name) {
3598 let prefix_segments: Vec<&str> = prefix.split("::").collect();
3599 let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
3600 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
3601 return Some((file, short_name.to_string()));
3602 }
3603 return None;
3604 }
3605
3606 let (path_without_alias, alias) = path
3607 .split_once(" as ")
3608 .map(|(left, right)| (left.trim(), Some(right.trim())))
3609 .unwrap_or((path, None));
3610 let segments: Vec<&str> = path_without_alias.split("::").collect();
3611 let imported = alias.or_else(|| segments.last().copied())?;
3612 if imported != short_name {
3613 return None;
3614 }
3615 if segments.len() < 2 {
3616 return None;
3617 }
3618 let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
3619 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
3620 Some((file, segments.last().unwrap_or(&short_name).to_string()))
3621}
3622
3623fn rust_workspace_file_for_segments(index: &ProjectIndex<'_>, segments: &[&str]) -> Option<String> {
3624 let crate_name = segments.first().copied()?;
3625 let src_prefix = index.crate_src_prefix(crate_name)?;
3626 let module_segments = segments[1..]
3627 .iter()
3628 .map(|segment| segment.to_string())
3629 .collect::<Vec<_>>();
3630 rust_file_for_src_prefix(index, &src_prefix, &module_segments)
3631}
3632
3633fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
3638 let mut prefixes = HashMap::new();
3639 let mut stack = vec![project_root.to_path_buf()];
3640 while let Some(dir) = stack.pop() {
3641 let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
3642 if matches!(name, "target" | "node_modules" | ".git") {
3643 continue;
3644 }
3645 let manifest = dir.join("Cargo.toml");
3646 if manifest.is_file() {
3647 let crate_names = rust_manifest_crate_names(&manifest);
3648 if !crate_names.is_empty() {
3649 let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
3650 for crate_name in crate_names {
3651 prefixes
3652 .entry(crate_name)
3653 .or_insert_with(|| src_prefix.clone());
3654 }
3655 }
3656 }
3657 let Ok(entries) = std::fs::read_dir(&dir) else {
3658 continue;
3659 };
3660 for entry in entries.flatten() {
3661 let path = entry.path();
3662 if path.is_dir() {
3663 stack.push(path);
3664 }
3665 }
3666 }
3667 prefixes
3668}
3669
3670fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
3674 let Ok(source) = std::fs::read_to_string(manifest) else {
3675 return Vec::new();
3676 };
3677 let mut in_lib = false;
3678 let mut package_name = None;
3679 let mut lib_name = None;
3680 for line in source.lines() {
3681 let trimmed = line.trim();
3682 if trimmed.starts_with('[') {
3683 in_lib = trimmed == "[lib]";
3684 continue;
3685 }
3686 let Some((key, value)) = trimmed.split_once('=') else {
3687 continue;
3688 };
3689 let key = key.trim();
3690 let value = value.trim().trim_matches('"');
3691 if in_lib && key == "name" {
3692 lib_name = Some(value.to_string());
3693 } else if !in_lib && key == "name" && package_name.is_none() {
3694 package_name = Some(value.to_string());
3695 }
3696 }
3697 let mut names = Vec::new();
3698 if let Some(lib) = lib_name {
3699 names.push(lib);
3700 }
3701 if let Some(package) = package_name {
3702 let normalized = package.replace('-', "_");
3703 if !names.contains(&normalized) {
3704 names.push(normalized);
3705 }
3706 }
3707 names
3708}
3709
3710fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
3711 if segments.is_empty() {
3712 return Some(Vec::new());
3713 }
3714 let caller_segments = rust_module_segments_for_rel(caller_file);
3715 match segments[0] {
3716 "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
3717 "self" => {
3718 let mut resolved = caller_segments;
3719 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
3720 Some(resolved)
3721 }
3722 "super" => {
3723 let mut resolved = caller_segments;
3724 resolved.pop();
3725 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
3726 Some(resolved)
3727 }
3728 _ => {
3729 let mut resolved = caller_segments;
3730 resolved.pop();
3731 resolved.extend(segments.iter().map(|item| item.to_string()));
3732 Some(resolved)
3733 }
3734 }
3735}
3736
3737fn rust_file_for_segments(
3738 index: &ProjectIndex<'_>,
3739 caller_file: &str,
3740 segments: &[String],
3741) -> Option<String> {
3742 rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
3743}
3744
3745fn rust_file_for_src_prefix(
3746 index: &ProjectIndex<'_>,
3747 src_prefix: &str,
3748 segments: &[String],
3749) -> Option<String> {
3750 let candidate = if segments.is_empty() {
3751 [src_prefix, "lib.rs"].join("/")
3752 } else {
3753 format!("{}/{}.rs", src_prefix, segments.join("/"))
3754 };
3755 if index.files.contains_key(&candidate) {
3756 return Some(candidate);
3757 }
3758 if !segments.is_empty() {
3759 let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
3760 if index.files.contains_key(&mod_candidate) {
3761 return Some(mod_candidate);
3762 }
3763 }
3764 None
3765}
3766
3767fn rust_src_prefix(rel_path: &str) -> String {
3768 rel_path
3769 .split_once("/src/")
3770 .map(|(prefix, _)| format!("{prefix}/src"))
3771 .unwrap_or_else(|| "src".to_string())
3772}
3773
3774fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
3775 let after_src = rel_path
3776 .split_once("/src/")
3777 .map(|(_, rest)| rest)
3778 .or_else(|| rel_path.strip_prefix("src/"))
3779 .unwrap_or(rel_path);
3780 if matches!(after_src, "lib.rs" | "main.rs") {
3781 return Vec::new();
3782 }
3783 if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
3784 return prefix.split('/').map(|item| item.to_string()).collect();
3785 }
3786 after_src
3787 .strip_suffix(".rs")
3788 .unwrap_or(after_src)
3789 .split('/')
3790 .map(|item| item.to_string())
3791 .collect()
3792}
3793
3794fn resolve_local_target(
3795 _index: &ProjectIndex<'_>,
3796 caller_file: &str,
3797 full_ref: &str,
3798 short_name: &str,
3799 caller_data: &FileCallData,
3800) -> Option<(String, String, String)> {
3801 if !callgraph::is_bare_callee(full_ref, short_name) {
3802 return None;
3803 }
3804 callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
3805 .ok()
3806 .map(|symbol| {
3807 (
3808 "resolved_local".to_string(),
3809 caller_file.to_string(),
3810 symbol,
3811 )
3812 })
3813}
3814
3815impl<'a> ProjectIndex<'a> {
3816 fn from_parts(
3817 project_root: &Path,
3818 files: HashMap<String, DbFileIndex>,
3819 caller_data: HashMap<String, &'a FileCallData>,
3820 ) -> Self {
3821 Self {
3822 project_root: project_root.to_path_buf(),
3823 files,
3824 caller_data,
3825 workspace_crate_prefixes: std::sync::OnceLock::new(),
3826 }
3827 }
3828
3829 fn from_extracts(project_root: &Path, extracts: &'a [FileExtract]) -> Self {
3830 let mut files = HashMap::new();
3831 let mut caller_data = HashMap::new();
3832 for extract in extracts {
3833 let index = DbFileIndex::from_extract(project_root, extract);
3834 caller_data.insert(extract.rel_path.clone(), &extract.data);
3835 files.insert(extract.rel_path.clone(), index);
3836 }
3837 Self::from_parts(project_root, files, caller_data)
3838 }
3839
3840 fn from_db_and_callers(
3841 tx: &Transaction<'_>,
3842 project_root: &Path,
3843 caller_extracts: &'a HashMap<String, FileExtract>,
3844 ) -> Result<Self> {
3845 let mut files = load_db_file_indexes(tx, project_root)?;
3846 let mut caller_data = HashMap::new();
3847 for (rel_path, extract) in caller_extracts {
3848 files.insert(
3849 rel_path.clone(),
3850 DbFileIndex::from_extract(project_root, extract),
3851 );
3852 caller_data.insert(rel_path.clone(), &extract.data);
3853 }
3854 Ok(Self::from_parts(project_root, files, caller_data))
3855 }
3856
3857 fn lang_for(&self, rel_path: &str) -> Option<LangId> {
3858 self.files.get(rel_path).and_then(|file| file.lang)
3859 }
3860
3861 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
3862 self.files
3863 .get(caller_file)
3864 .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
3865 }
3866
3867 fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
3868 self.files
3869 .get(rel_path)
3870 .map(|file| file.reexports.as_slice())
3871 .unwrap_or(&[])
3872 }
3873
3874 fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
3875 self.files.get(rel_path).and_then(|file| {
3876 file.node_by_scoped
3877 .get(symbol)
3878 .cloned()
3879 .or_else(|| file.node_by_bare.get(symbol).cloned())
3880 })
3881 }
3882}
3883
3884impl DbFileIndex {
3885 fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
3886 let mut node_by_scoped = HashMap::new();
3887 let mut node_by_bare = HashMap::new();
3888 for node in &extract.nodes {
3889 node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
3890 node_by_bare
3891 .entry(node.name.clone())
3892 .or_insert(node.id.clone());
3893 }
3894 let mut export_aliases = HashMap::new();
3895 for raw_ref in &extract.raw_refs {
3896 if raw_ref.kind == "export_alias" {
3897 if let (Some(exported), Some(source_symbol)) =
3898 (&raw_ref.local_name, &raw_ref.requested_name)
3899 {
3900 export_aliases.insert(exported.clone(), source_symbol.clone());
3901 }
3902 }
3903 }
3904 let mut module_targets = HashMap::new();
3905 for import in &extract.data.import_block.imports {
3906 module_targets.insert(
3907 import.module_path.clone(),
3908 module_target_from_dependencies(
3909 project_root,
3910 &module_dependencies(project_root, &extract.abs_path, &import.module_path),
3911 ),
3912 );
3913 }
3914 let mut reexports = Vec::new();
3915 for raw_ref in &extract.raw_refs {
3916 if raw_ref.kind == "reexport" {
3917 if let Some(module_path) = &raw_ref.module_path {
3918 let target_file =
3919 module_target_from_dependencies(project_root, &raw_ref.dependencies);
3920 module_targets.insert(module_path.clone(), target_file.clone());
3921 reexports.push(reexport_index_from_raw(raw_ref, target_file));
3922 }
3923 }
3924 }
3925 Self {
3926 lang: Some(extract.lang),
3927 exports: extract.data.exported_symbols.iter().cloned().collect(),
3928 default_export: extract.data.default_export_symbol.clone(),
3929 export_aliases,
3930 node_by_scoped,
3931 node_by_bare,
3932 module_targets,
3933 reexports,
3934 }
3935 }
3936}
3937
3938fn load_db_file_indexes(
3939 tx: &Transaction<'_>,
3940 project_root: &Path,
3941) -> Result<HashMap<String, DbFileIndex>> {
3942 let mut files = HashMap::new();
3943 let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
3944 let rows = stmt.query_map([], |row| {
3945 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3946 })?;
3947 for row in rows {
3948 let (rel_path, lang) = row?;
3949 files.insert(
3950 rel_path.clone(),
3951 DbFileIndex {
3952 lang: lang_from_label(&lang),
3953 exports: HashSet::new(),
3954 default_export: None,
3955 export_aliases: HashMap::new(),
3956 node_by_scoped: HashMap::new(),
3957 node_by_bare: HashMap::new(),
3958 module_targets: HashMap::new(),
3959 reexports: Vec::new(),
3960 },
3961 );
3962 }
3963
3964 let mut node_stmt = tx.prepare(
3965 "SELECT file_path, id, name, scoped_name, exported, is_default_export FROM nodes",
3966 )?;
3967 let nodes = node_stmt.query_map([], |row| {
3968 Ok((
3969 row.get::<_, String>(0)?,
3970 row.get::<_, String>(1)?,
3971 row.get::<_, String>(2)?,
3972 row.get::<_, String>(3)?,
3973 row.get::<_, i64>(4)? != 0,
3974 row.get::<_, i64>(5)? != 0,
3975 ))
3976 })?;
3977 for row in nodes {
3978 let (file_path, id, name, scoped_name, exported, is_default_export) = row?;
3979 let file = files
3980 .entry(file_path.clone())
3981 .or_insert_with(|| DbFileIndex {
3982 lang: None,
3983 exports: HashSet::new(),
3984 default_export: None,
3985 export_aliases: HashMap::new(),
3986 node_by_scoped: HashMap::new(),
3987 node_by_bare: HashMap::new(),
3988 module_targets: HashMap::new(),
3989 reexports: Vec::new(),
3990 });
3991 if exported {
3992 file.exports.insert(name.clone());
3993 file.exports.insert(scoped_name.clone());
3994 }
3995 if is_default_export {
3996 file.default_export = Some(scoped_name.clone());
3997 }
3998 file.node_by_scoped.insert(scoped_name, id.clone());
3999 file.node_by_bare.entry(name).or_insert(id);
4000 }
4001 let file_keys: HashSet<String> = files.keys().cloned().collect();
4002 let mut ref_stmt = tx.prepare(
4003 "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
4004 FROM refs WHERE kind IN ('import', 'reexport', 'export_alias')",
4005 )?;
4006 let ref_rows = ref_stmt.query_map([], |row| {
4007 Ok((
4008 row.get::<_, String>(0)?,
4009 row.get::<_, String>(1)?,
4010 row.get::<_, String>(2)?,
4011 row.get::<_, Option<String>>(3)?,
4012 row.get::<_, Option<String>>(4)?,
4013 row.get::<_, i64>(5)? != 0,
4014 row.get::<_, Option<String>>(6)?,
4015 row.get::<_, Option<String>>(7)?,
4016 ))
4017 })?;
4018 for row in ref_rows {
4019 let (
4020 ref_id,
4021 caller_file,
4022 kind,
4023 module_path,
4024 full_ref,
4025 wildcard,
4026 local_name,
4027 requested_name,
4028 ) = row?;
4029 if kind == "export_alias" {
4030 if let (Some(exported), Some(source_symbol), Some(file)) =
4031 (local_name, requested_name, files.get_mut(&caller_file))
4032 {
4033 file.export_aliases.insert(exported, source_symbol);
4034 }
4035 continue;
4036 }
4037 let Some(module_path) = module_path else {
4038 continue;
4039 };
4040 let deps = dependencies_for_ref(tx, project_root, &ref_id)?;
4041 let target_file = deps
4042 .iter()
4043 .find(|dep| file_keys.contains(*dep))
4044 .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
4045 if let Some(file) = files.get_mut(&caller_file) {
4046 file.module_targets
4047 .entry(module_path.clone())
4048 .or_insert_with(|| target_file.clone());
4049 if kind == "reexport" {
4050 let raw = RawRef {
4051 ref_id,
4052 caller_node: None,
4053 caller_symbol: None,
4054 caller_file,
4055 kind,
4056 short_name: None,
4057 full_ref,
4058 module_path: Some(module_path),
4059 import_kind: Some("reexport".to_string()),
4060 local_name: None,
4061 requested_name: None,
4062 namespace_alias: None,
4063 wildcard,
4064 line: 0,
4065 byte_start: 0,
4066 byte_end: 0,
4067 dependencies: deps,
4068 };
4069 file.reexports
4070 .push(reexport_index_from_raw(&raw, target_file));
4071 }
4072 }
4073 }
4074
4075 Ok(files)
4076}
4077
4078struct ColdBuildInsertStatements<'stmt> {
4079 file: Statement<'stmt>,
4080 node: Statement<'stmt>,
4081 file_dependency: Statement<'stmt>,
4082 dispatch_hint: Statement<'stmt>,
4083 backend_state: Statement<'stmt>,
4084 reference: Statement<'stmt>,
4085 edge: Statement<'stmt>,
4086}
4087
4088impl<'stmt> ColdBuildInsertStatements<'stmt> {
4089 fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
4090 Ok(Self {
4091 file: tx.prepare(
4092 "INSERT OR REPLACE INTO files(
4093 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
4094 is_public_api, surface_fingerprint, indexed_at
4095 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
4096 )?,
4097 node: tx.prepare(
4098 "INSERT OR REPLACE INTO nodes(
4099 id, file_path, name, scoped_name, kind, start_line, start_col,
4100 end_line, end_col, range_ordinal, signature, exported,
4101 is_default_export, is_type_like, is_callgraph_entry_point, provenance
4102 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
4103 )?,
4104 file_dependency: tx.prepare(
4105 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
4106 )?,
4107 dispatch_hint: tx.prepare(
4108 "INSERT OR REPLACE INTO dispatch_hints(
4109 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
4110 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
4111 )?,
4112 backend_state: tx.prepare(
4113 "INSERT OR REPLACE INTO backend_file_state(
4114 backend, workspace_root, file_path, content_hash, status, updated_at
4115 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
4116 )?,
4117 reference: tx.prepare(
4118 "INSERT OR REPLACE INTO refs(
4119 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
4120 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
4121 byte_start, byte_end, status, target_node, target_file, target_symbol,
4122 provenance
4123 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
4124 )?,
4125 edge: tx.prepare(
4126 "INSERT OR REPLACE INTO edges(
4127 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
4128 kind, line, provenance
4129 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
4130 )?,
4131 })
4132 }
4133}
4134
4135fn insert_file_extract_prepared(
4136 statements: &mut ColdBuildInsertStatements<'_>,
4137 workspace_root: &str,
4138 extract: &FileExtract,
4139) -> Result<()> {
4140 statements.file.execute(params![
4141 extract.rel_path,
4142 hash_to_hex(extract.freshness.content_hash),
4143 system_time_to_ns(extract.freshness.mtime),
4144 extract.freshness.size as i64,
4145 lang_label(extract.lang),
4146 extract.surface_fingerprint,
4147 unix_seconds_now(),
4148 ])?;
4149 for node in &extract.nodes {
4150 statements.node.execute(params![
4151 node.id,
4152 node.file_path,
4153 node.name,
4154 node.scoped_name,
4155 node.kind,
4156 node.range.start_line as i64,
4157 node.range.start_col as i64,
4158 node.range.end_line as i64,
4159 node.range.end_col as i64,
4160 node.range_ordinal as i64,
4161 node.signature,
4162 bool_int(node.exported),
4163 bool_int(node.is_default_export),
4164 bool_int(node.is_type_like),
4165 bool_int(node.is_callgraph_entry_point),
4166 PROVENANCE_TREESITTER,
4167 ])?;
4168 }
4169
4170 let mut dependencies = BTreeSet::new();
4171 for raw_ref in &extract.raw_refs {
4172 dependencies.extend(raw_ref.dependencies.iter().cloned());
4173 }
4174 for dep_file in &dependencies {
4175 statements
4176 .file_dependency
4177 .execute(params![extract.rel_path, dep_file])?;
4178 }
4179
4180 for hint in &extract.dispatch_hints {
4181 statements.dispatch_hint.execute(params![
4182 hint.id,
4183 hint.method_name,
4184 hint.caller_node,
4185 hint.file,
4186 hint.line as i64,
4187 hint.byte_start as i64,
4188 hint.byte_end as i64,
4189 PROVENANCE_TREESITTER,
4190 ])?;
4191 }
4192 insert_backend_state_prepared(
4193 &mut statements.backend_state,
4194 workspace_root,
4195 &extract.rel_path,
4196 Some(&extract.freshness.content_hash),
4197 "fresh",
4198 )?;
4199 Ok(())
4200}
4201
4202fn insert_backend_state_prepared(
4203 stmt: &mut Statement<'_>,
4204 workspace_root: &str,
4205 rel_path: &str,
4206 content_hash: Option<&blake3::Hash>,
4207 status: &str,
4208) -> Result<()> {
4209 let hash = content_hash
4210 .map(|hash| hash_to_hex(*hash))
4211 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
4212 stmt.execute(params![
4213 BACKEND_TREESITTER,
4214 workspace_root,
4215 rel_path,
4216 hash,
4217 status,
4218 unix_seconds_now(),
4219 ])?;
4220 Ok(())
4221}
4222
4223fn insert_resolved_ref_prepared(
4224 statements: &mut ColdBuildInsertStatements<'_>,
4225 resolved: &ResolvedRef,
4226) -> Result<()> {
4227 let raw = &resolved.raw;
4228 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
4229 statements.reference.execute(params![
4230 raw.ref_id,
4231 raw.caller_node,
4232 raw.caller_file,
4233 raw.kind,
4234 raw.short_name,
4235 raw.full_ref,
4236 raw.module_path,
4237 raw.import_kind,
4238 raw.local_name,
4239 raw.requested_name,
4240 raw.namespace_alias,
4241 bool_int(raw.wildcard),
4242 raw.line as i64,
4243 raw.byte_start as i64,
4244 raw.byte_end as i64,
4245 resolved.status,
4246 resolved.target_node,
4247 resolved.target_file,
4248 resolved.target_symbol,
4249 PROVENANCE_TREESITTER,
4250 ])?;
4251 if let Some(edge) = &resolved.edge {
4252 statements.edge.execute(params![
4253 edge.edge_id,
4254 raw.ref_id,
4255 edge.source_node,
4256 edge.target_node,
4257 edge.target_file,
4258 edge.target_symbol,
4259 edge.kind,
4260 edge.line as i64,
4261 PROVENANCE_TREESITTER,
4262 ])?;
4263 }
4264 Ok(())
4265}
4266
4267fn insert_file_extract(
4268 tx: &Transaction<'_>,
4269 project_root: &Path,
4270 extract: &FileExtract,
4271) -> Result<()> {
4272 tx.execute(
4273 "INSERT OR REPLACE INTO files(
4274 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
4275 is_public_api, surface_fingerprint, indexed_at
4276 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
4277 params![
4278 extract.rel_path,
4279 hash_to_hex(extract.freshness.content_hash),
4280 system_time_to_ns(extract.freshness.mtime),
4281 extract.freshness.size as i64,
4282 lang_label(extract.lang),
4283 extract.surface_fingerprint,
4284 unix_seconds_now(),
4285 ],
4286 )?;
4287 for node in &extract.nodes {
4288 tx.execute(
4289 "INSERT OR REPLACE INTO nodes(
4290 id, file_path, name, scoped_name, kind, start_line, start_col,
4291 end_line, end_col, range_ordinal, signature, exported,
4292 is_default_export, is_type_like, is_callgraph_entry_point, provenance
4293 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
4294 params![
4295 node.id,
4296 node.file_path,
4297 node.name,
4298 node.scoped_name,
4299 node.kind,
4300 node.range.start_line as i64,
4301 node.range.start_col as i64,
4302 node.range.end_line as i64,
4303 node.range.end_col as i64,
4304 node.range_ordinal as i64,
4305 node.signature,
4306 bool_int(node.exported),
4307 bool_int(node.is_default_export),
4308 bool_int(node.is_type_like),
4309 bool_int(node.is_callgraph_entry_point),
4310 PROVENANCE_TREESITTER,
4311 ],
4312 )?;
4313 }
4314 let mut dependencies = BTreeSet::new();
4315 for raw_ref in &extract.raw_refs {
4316 dependencies.extend(raw_ref.dependencies.iter().cloned());
4317 }
4318 insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
4319
4320 for hint in &extract.dispatch_hints {
4321 tx.execute(
4322 "INSERT OR REPLACE INTO dispatch_hints(
4323 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
4324 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
4325 params![
4326 hint.id,
4327 hint.method_name,
4328 hint.caller_node,
4329 hint.file,
4330 hint.line as i64,
4331 hint.byte_start as i64,
4332 hint.byte_end as i64,
4333 PROVENANCE_TREESITTER,
4334 ],
4335 )?;
4336 }
4337 mark_backend_state(
4338 tx,
4339 project_root,
4340 &extract.rel_path,
4341 Some(&extract.freshness.content_hash),
4342 "fresh",
4343 )?;
4344 Ok(())
4345}
4346
4347fn insert_file_dependencies(
4348 tx: &Transaction<'_>,
4349 file_path: &str,
4350 dependencies: &BTreeSet<String>,
4351) -> Result<()> {
4352 for dep_file in dependencies {
4353 tx.execute(
4354 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
4355 params![file_path, dep_file],
4356 )?;
4357 }
4358 Ok(())
4359}
4360
4361fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
4362 let raw = &resolved.raw;
4363 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
4364 tx.execute(
4365 "INSERT OR REPLACE INTO refs(
4366 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
4367 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
4368 byte_start, byte_end, status, target_node, target_file, target_symbol,
4369 provenance
4370 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
4371 params![
4372 raw.ref_id,
4373 raw.caller_node,
4374 raw.caller_file,
4375 raw.kind,
4376 raw.short_name,
4377 raw.full_ref,
4378 raw.module_path,
4379 raw.import_kind,
4380 raw.local_name,
4381 raw.requested_name,
4382 raw.namespace_alias,
4383 bool_int(raw.wildcard),
4384 raw.line as i64,
4385 raw.byte_start as i64,
4386 raw.byte_end as i64,
4387 resolved.status,
4388 resolved.target_node,
4389 resolved.target_file,
4390 resolved.target_symbol,
4391 PROVENANCE_TREESITTER,
4392 ],
4393 )?;
4394 if let Some(edge) = &resolved.edge {
4395 tx.execute(
4396 "INSERT OR REPLACE INTO edges(
4397 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
4398 kind, line, provenance
4399 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
4400 params![
4401 edge.edge_id,
4402 raw.ref_id,
4403 edge.source_node,
4404 edge.target_node,
4405 edge.target_file,
4406 edge.target_symbol,
4407 edge.kind,
4408 edge.line as i64,
4409 PROVENANCE_TREESITTER,
4410 ],
4411 )?;
4412 }
4413 Ok(())
4414}
4415
4416fn insert_method_dispatch_edges(
4417 tx: &Transaction<'_>,
4418 project_root: &Path,
4419 caller_files: Option<&BTreeSet<String>>,
4420) -> Result<usize> {
4421 let references = load_name_match_refs(tx, caller_files)?;
4422 if references.is_empty() {
4423 return Ok(0);
4424 }
4425
4426 let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
4427 let mut source_cache: DispatchSourceCache = HashMap::new();
4428 let mut inserted = 0usize;
4429 for reference in references {
4430 let key = (reference.method_name.clone(), reference.lang.clone());
4431 let candidates = match candidates_by_name.entry(key) {
4432 Entry::Occupied(entry) => entry.into_mut(),
4433 Entry::Vacant(entry) => {
4434 let candidates =
4435 load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
4436 entry.insert(candidates)
4437 }
4438 };
4439
4440 if let Some(receiver_type) =
4441 infer_receiver_type(project_root, &reference, &mut source_cache)
4442 {
4443 let Some(candidate) =
4444 select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
4445 else {
4446 continue;
4447 };
4448 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
4449 inserted += 1;
4450 continue;
4451 }
4452
4453 if method_name_match_denylisted(&reference.method_name) {
4454 continue;
4455 }
4456
4457 let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
4458 continue;
4459 };
4460 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
4461 inserted += 1;
4462 }
4463 Ok(inserted)
4464}
4465
4466fn insert_method_dispatch_edges_chunked(
4467 tx: &Transaction<'_>,
4468 project_root: &Path,
4469 caller_files: &BTreeSet<String>,
4470 chunk_size: usize,
4471) -> Result<usize> {
4472 if caller_files.is_empty() {
4473 return Ok(0);
4474 }
4475 if chunk_size == 0 || caller_files.len() <= chunk_size {
4476 return insert_method_dispatch_edges(tx, project_root, Some(caller_files));
4477 }
4478
4479 let mut inserted = 0usize;
4480 let mut batch = BTreeSet::new();
4481 for caller_file in caller_files {
4482 batch.insert(caller_file.clone());
4483 if batch.len() == chunk_size {
4484 inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
4485 batch.clear();
4486 }
4487 }
4488 if !batch.is_empty() {
4489 inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
4490 }
4491 Ok(inserted)
4492}
4493
4494fn insert_method_dispatch_edge(
4495 tx: &Transaction<'_>,
4496 reference: &NameMatchRef,
4497 candidate: &NameMatchCandidate,
4498 provenance: &str,
4499) -> Result<()> {
4500 tx.execute(
4501 "INSERT OR REPLACE INTO edges(
4502 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
4503 kind, line, provenance
4504 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
4505 params![
4506 ref_id(&[&reference.ref_id, provenance, "edge"]),
4507 &reference.ref_id,
4508 &reference.caller_node,
4509 &candidate.node_id,
4510 &candidate.file_path,
4511 &candidate.scoped_name,
4512 reference.line as i64,
4513 provenance,
4514 ],
4515 )?;
4516 Ok(())
4517}
4518
4519fn delete_method_dispatch_edges_for_callers(
4520 tx: &Transaction<'_>,
4521 caller_files: &BTreeSet<String>,
4522) -> Result<()> {
4523 if caller_files.is_empty() {
4524 return Ok(());
4525 }
4526
4527 let mut stmt = tx.prepare(
4528 "DELETE FROM edges
4529 WHERE provenance IN (?1, ?2)
4530 AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
4531 )?;
4532 for caller_file in caller_files {
4533 stmt.execute(params![
4534 PROVENANCE_NAME_MATCH,
4535 PROVENANCE_TYPE_MATCH,
4536 caller_file
4537 ])?;
4538 }
4539 Ok(())
4540}
4541
4542fn load_name_match_refs(
4543 tx: &Transaction<'_>,
4544 caller_files: Option<&BTreeSet<String>>,
4545) -> Result<Vec<NameMatchRef>> {
4546 let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
4547 n.signature, r.short_name, r.full_ref, r.line, f.lang
4548 FROM refs r
4549 JOIN files f ON f.path = r.caller_file
4550 JOIN nodes n ON n.id = r.caller_node
4551 WHERE r.kind = 'call'
4552 AND r.status = 'unresolved'
4553 AND r.caller_node IS NOT NULL
4554 AND r.full_ref IS NOT NULL
4555 AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
4556 AND NOT EXISTS (
4557 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
4558 )";
4559 let mut references = Vec::new();
4560
4561 if let Some(caller_files) = caller_files {
4562 if caller_files.is_empty() {
4563 return Ok(references);
4564 }
4565 let sql = format!(
4566 "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
4567 );
4568 let mut stmt = tx.prepare(&sql)?;
4569 for caller_file in caller_files {
4570 let rows = stmt.query_map(params![caller_file], |row| {
4571 Ok((
4572 row.get::<_, String>(0)?,
4573 row.get::<_, Option<String>>(1)?,
4574 row.get::<_, String>(2)?,
4575 row.get::<_, String>(3)?,
4576 row.get::<_, Option<String>>(4)?,
4577 row.get::<_, Option<String>>(5)?,
4578 row.get::<_, Option<String>>(6)?,
4579 row.get::<_, i64>(7)?,
4580 row.get::<_, String>(8)?,
4581 ))
4582 })?;
4583 for row in rows {
4584 let (
4585 ref_id,
4586 caller_node,
4587 caller_file,
4588 caller_symbol,
4589 caller_signature,
4590 short_name,
4591 full_ref,
4592 line,
4593 lang,
4594 ) = row?;
4595 if let Some(reference) = name_match_ref_from_parts(
4596 ref_id,
4597 caller_node,
4598 caller_file,
4599 caller_symbol,
4600 caller_signature,
4601 short_name,
4602 full_ref,
4603 line,
4604 lang,
4605 ) {
4606 references.push(reference);
4607 }
4608 }
4609 }
4610 return Ok(references);
4611 }
4612
4613 let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
4614 let mut stmt = tx.prepare(&sql)?;
4615 let rows = stmt.query_map([], |row| {
4616 Ok((
4617 row.get::<_, String>(0)?,
4618 row.get::<_, Option<String>>(1)?,
4619 row.get::<_, String>(2)?,
4620 row.get::<_, String>(3)?,
4621 row.get::<_, Option<String>>(4)?,
4622 row.get::<_, Option<String>>(5)?,
4623 row.get::<_, Option<String>>(6)?,
4624 row.get::<_, i64>(7)?,
4625 row.get::<_, String>(8)?,
4626 ))
4627 })?;
4628 for row in rows {
4629 let (
4630 ref_id,
4631 caller_node,
4632 caller_file,
4633 caller_symbol,
4634 caller_signature,
4635 short_name,
4636 full_ref,
4637 line,
4638 lang,
4639 ) = row?;
4640 if let Some(reference) = name_match_ref_from_parts(
4641 ref_id,
4642 caller_node,
4643 caller_file,
4644 caller_symbol,
4645 caller_signature,
4646 short_name,
4647 full_ref,
4648 line,
4649 lang,
4650 ) {
4651 references.push(reference);
4652 }
4653 }
4654 Ok(references)
4655}
4656
4657#[allow(clippy::too_many_arguments)]
4658fn name_match_ref_from_parts(
4659 ref_id: String,
4660 caller_node: Option<String>,
4661 caller_file: String,
4662 caller_symbol: String,
4663 caller_signature: Option<String>,
4664 short_name: Option<String>,
4665 full_ref: Option<String>,
4666 line: i64,
4667 lang: String,
4668) -> Option<NameMatchRef> {
4669 let caller_node = caller_node?;
4670 let full_ref = full_ref?;
4671 let (receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
4672 let method_name = if member.is_empty() {
4673 short_name.as_deref()?.to_string()
4674 } else {
4675 member
4676 };
4677 Some(NameMatchRef {
4678 ref_id,
4679 caller_node,
4680 caller_file,
4681 caller_symbol,
4682 caller_signature,
4683 receiver,
4684 method_name,
4685 colon_dispatch,
4686 line: line.max(0) as u32,
4687 lang,
4688 })
4689}
4690
4691fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, bool)> {
4692 let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
4693 let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
4694 let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
4695 let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
4696 .into_iter()
4697 .flatten()
4698 .max_by_key(|(index, _, _)| *index)?;
4699 if delimiter == 0 {
4700 return None;
4701 }
4702 let member_start = delimiter + delimiter_len;
4703 if member_start >= full_ref.len() {
4704 return None;
4705 }
4706 let receiver = last_name_segment(&full_ref[..delimiter]);
4707 let member = &full_ref[member_start..];
4708 if receiver.is_empty() || member.is_empty() {
4709 return None;
4710 }
4711 Some((receiver.to_string(), member.to_string(), colon_dispatch))
4712}
4713
4714fn last_name_segment(value: &str) -> &str {
4715 value
4716 .rsplit(['.', ':', '/', '\\', '-', '>'])
4717 .find(|segment| !segment.is_empty())
4718 .unwrap_or(value)
4719}
4720
4721fn load_name_match_candidates(
4722 tx: &Transaction<'_>,
4723 method_name: &str,
4724 lang: &str,
4725) -> Result<Vec<NameMatchCandidate>> {
4726 let mut stmt = tx.prepare(
4727 "SELECT n.id, n.file_path, n.scoped_name, n.kind
4728 FROM nodes n JOIN files f ON f.path = n.file_path
4729 WHERE n.name = ?1
4730 AND f.lang = ?2
4731 AND n.kind IN ('method', 'function')
4732 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
4733 )?;
4734 let rows = stmt.query_map(params![method_name, lang], |row| {
4735 Ok(NameMatchCandidate {
4736 node_id: row.get(0)?,
4737 file_path: row.get(1)?,
4738 scoped_name: row.get(2)?,
4739 kind: row.get(3)?,
4740 })
4741 })?;
4742 rows.collect::<std::result::Result<Vec<_>, _>>()
4743 .map_err(Into::into)
4744}
4745
4746struct ParsedDispatchSource {
4747 source: String,
4748 tree: tree_sitter::Tree,
4749}
4750
4751type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
4752
4753fn infer_receiver_type(
4754 project_root: &Path,
4755 reference: &NameMatchRef,
4756 source_cache: &mut DispatchSourceCache,
4757) -> Option<String> {
4758 match reference.lang.as_str() {
4759 "rust" => infer_rust_receiver_type(reference),
4760 "java" => {
4761 infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
4762 }
4763 "kotlin" => {
4764 infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
4765 }
4766 "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache),
4767 _ => None,
4768 }
4769}
4770
4771fn parse_dispatch_source(
4772 project_root: &Path,
4773 caller_file: &str,
4774 lang: LangId,
4775) -> Option<ParsedDispatchSource> {
4776 let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
4777 let grammar = crate::parser::grammar_for(lang);
4778 let mut parser = tree_sitter::Parser::new();
4779 parser.set_language(&grammar).ok()?;
4780 let tree = parser.parse(&source, None)?;
4781 Some(ParsedDispatchSource { source, tree })
4782}
4783
4784fn parsed_dispatch_source<'a>(
4785 project_root: &Path,
4786 reference: &NameMatchRef,
4787 lang: LangId,
4788 source_cache: &'a mut DispatchSourceCache,
4789) -> Option<&'a ParsedDispatchSource> {
4790 let key = (reference.caller_file.clone(), reference.lang.clone());
4791 source_cache
4792 .entry(key)
4793 .or_insert_with(|| parse_dispatch_source(project_root, &reference.caller_file, lang))
4794 .as_ref()
4795}
4796
4797fn infer_java_like_receiver_type(
4798 project_root: &Path,
4799 reference: &NameMatchRef,
4800 lang: LangId,
4801 source_cache: &mut DispatchSourceCache,
4802) -> Option<String> {
4803 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
4804 return None;
4805 }
4806
4807 let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
4808 let root = parsed.tree.root_node();
4809 let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
4810
4811 let callable_scope = type_node
4812 .and_then(|node| {
4813 find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
4814 })
4815 .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
4816
4817 if let Some(callable_scope) = callable_scope {
4818 if let Some(receiver_type) = infer_java_like_local_receiver_type(
4819 callable_scope,
4820 &parsed.source,
4821 &reference.receiver,
4822 reference.line.max(1),
4823 lang,
4824 ) {
4825 return Some(receiver_type);
4826 }
4827 }
4828
4829 type_node.and_then(|node| {
4830 infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
4831 })
4832}
4833
4834fn infer_cpp_receiver_type(
4835 project_root: &Path,
4836 reference: &NameMatchRef,
4837 source_cache: &mut DispatchSourceCache,
4838) -> Option<String> {
4839 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
4840 return None;
4841 }
4842
4843 let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
4844 let root = parsed.tree.root_node();
4845 let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
4846 infer_cpp_receiver_type_from_scope(
4847 scope,
4848 &parsed.source,
4849 &reference.receiver,
4850 reference.line.max(1),
4851 )
4852}
4853
4854fn find_enclosing_java_like_type_node<'tree>(
4855 root: tree_sitter::Node<'tree>,
4856 source: &str,
4857 reference: &NameMatchRef,
4858 lang: LangId,
4859) -> Option<tree_sitter::Node<'tree>> {
4860 let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
4861 .and_then(|name| simple_type_name(&name));
4862 let line = reference.line.max(1);
4863 let mut best = None;
4864 let mut stack = vec![root];
4865 while let Some(node) = stack.pop() {
4866 if !node_contains_line(node, line) {
4867 continue;
4868 }
4869 if is_java_like_type_kind(node.kind(), lang) {
4870 let name = declaration_name(node, source);
4871 if expected_type
4872 .as_deref()
4873 .is_none_or(|expected| name == Some(expected))
4874 {
4875 best = tighter_node(best, node);
4876 }
4877 }
4878 push_named_children(node, &mut stack);
4879 }
4880 best
4881}
4882
4883fn find_enclosing_java_like_callable_node<'tree>(
4884 root: tree_sitter::Node<'tree>,
4885 source: &str,
4886 reference: &NameMatchRef,
4887 lang: LangId,
4888) -> Option<tree_sitter::Node<'tree>> {
4889 let expected_name = reference.caller_symbol.rsplit("::").next();
4890 let line = reference.line.max(1);
4891 let mut best = None;
4892 let mut stack = vec![root];
4893 while let Some(node) = stack.pop() {
4894 if !node_contains_line(node, line) {
4895 continue;
4896 }
4897 if is_java_like_callable_kind(node.kind(), lang) {
4898 let name = declaration_name(node, source);
4899 if expected_name.is_none_or(|expected| name == Some(expected)) {
4900 best = tighter_node(best, node);
4901 }
4902 }
4903 push_named_children(node, &mut stack);
4904 }
4905 best
4906}
4907
4908fn find_enclosing_cpp_callable_node<'tree>(
4909 root: tree_sitter::Node<'tree>,
4910 _source: &str,
4911 reference: &NameMatchRef,
4912) -> Option<tree_sitter::Node<'tree>> {
4913 let line = reference.line.max(1);
4914 let mut best = None;
4915 let mut stack = vec![root];
4916 while let Some(node) = stack.pop() {
4917 if !node_contains_line(node, line) {
4918 continue;
4919 }
4920 if node.kind() == "function_definition" {
4921 best = tighter_node(best, node);
4922 }
4923 push_named_children(node, &mut stack);
4924 }
4925 best
4926}
4927
4928fn tighter_node<'tree>(
4929 current: Option<tree_sitter::Node<'tree>>,
4930 candidate: tree_sitter::Node<'tree>,
4931) -> Option<tree_sitter::Node<'tree>> {
4932 match current {
4933 Some(current)
4934 if current.start_byte() > candidate.start_byte()
4935 || (current.start_byte() == candidate.start_byte()
4936 && current.end_byte() <= candidate.end_byte()) =>
4937 {
4938 Some(current)
4939 }
4940 _ => Some(candidate),
4941 }
4942}
4943
4944fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
4945 let start = node.start_position().row as u32 + 1;
4946 let end = node.end_position().row as u32 + 1;
4947 start <= line && line <= end
4948}
4949
4950fn push_named_children<'tree>(
4951 node: tree_sitter::Node<'tree>,
4952 stack: &mut Vec<tree_sitter::Node<'tree>>,
4953) {
4954 for index in 0..node.named_child_count() {
4955 if let Some(child) = node.named_child(index as u32) {
4956 stack.push(child);
4957 }
4958 }
4959}
4960
4961fn declaration_name<'source>(
4962 node: tree_sitter::Node<'_>,
4963 source: &'source str,
4964) -> Option<&'source str> {
4965 node.child_by_field_name("name")
4966 .map(|name| node_text(name, source))
4967 .or_else(|| {
4968 first_named_child_text(
4969 node,
4970 source,
4971 &["identifier", "type_identifier", "simple_identifier"],
4972 )
4973 })
4974}
4975
4976fn first_named_child_text<'source>(
4977 node: tree_sitter::Node<'_>,
4978 source: &'source str,
4979 kinds: &[&str],
4980) -> Option<&'source str> {
4981 for index in 0..node.named_child_count() {
4982 let child = node.named_child(index as u32)?;
4983 if kinds.contains(&child.kind()) {
4984 return Some(node_text(child, source));
4985 }
4986 }
4987 None
4988}
4989
4990fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
4991 &source[node.byte_range()]
4992}
4993
4994fn infer_java_like_field_receiver_type(
4995 type_node: tree_sitter::Node<'_>,
4996 source: &str,
4997 receiver: &str,
4998 lang: LangId,
4999) -> Option<String> {
5000 let mut stack = Vec::new();
5001 push_named_children(type_node, &mut stack);
5002 while let Some(node) = stack.pop() {
5003 if is_java_like_field_kind(node.kind(), lang) {
5004 if let Some(receiver_type) =
5005 extract_java_like_declared_type(node_text(node, source), receiver, lang)
5006 {
5007 return Some(receiver_type);
5008 }
5009 }
5010 if is_java_like_type_kind(node.kind(), lang)
5011 || is_java_like_callable_kind(node.kind(), lang)
5012 {
5013 continue;
5014 }
5015 push_named_children(node, &mut stack);
5016 }
5017 None
5018}
5019
5020fn infer_java_like_local_receiver_type(
5021 callable_node: tree_sitter::Node<'_>,
5022 source: &str,
5023 receiver: &str,
5024 call_line: u32,
5025 lang: LangId,
5026) -> Option<String> {
5027 let mut best: Option<(u32, String)> = None;
5028 let mut stack = Vec::new();
5029 push_named_children(callable_node, &mut stack);
5030 while let Some(node) = stack.pop() {
5031 let start_line = node.start_position().row as u32 + 1;
5032 if start_line > call_line {
5033 continue;
5034 }
5035 if is_java_like_local_kind(node.kind(), lang) {
5036 if let Some(receiver_type) =
5037 extract_java_like_declared_type(node_text(node, source), receiver, lang)
5038 {
5039 if best
5040 .as_ref()
5041 .is_none_or(|(best_line, _)| start_line >= *best_line)
5042 {
5043 best = Some((start_line, receiver_type));
5044 }
5045 }
5046 }
5047 if is_java_like_type_kind(node.kind(), lang)
5048 || is_java_like_callable_kind(node.kind(), lang)
5049 {
5050 continue;
5051 }
5052 push_named_children(node, &mut stack);
5053 }
5054 best.map(|(_, receiver_type)| receiver_type)
5055}
5056
5057fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
5058 match lang {
5059 LangId::Java => matches!(
5060 kind,
5061 "class_declaration"
5062 | "interface_declaration"
5063 | "enum_declaration"
5064 | "record_declaration"
5065 | "annotation_type_declaration"
5066 ),
5067 LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
5068 _ => false,
5069 }
5070}
5071
5072fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
5073 match lang {
5074 LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
5075 LangId::Kotlin => kind == "function_declaration",
5076 _ => false,
5077 }
5078}
5079
5080fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
5081 match lang {
5082 LangId::Java => kind == "field_declaration",
5083 LangId::Kotlin => kind == "property_declaration",
5084 _ => false,
5085 }
5086}
5087
5088fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
5089 match lang {
5090 LangId::Java => kind == "local_variable_declaration",
5091 LangId::Kotlin => kind == "property_declaration",
5092 _ => false,
5093 }
5094}
5095
5096fn extract_java_like_declared_type(
5097 declaration: &str,
5098 receiver: &str,
5099 lang: LangId,
5100) -> Option<String> {
5101 match lang {
5102 LangId::Java => extract_java_declared_type(declaration, receiver),
5103 LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
5104 _ => None,
5105 }
5106}
5107
5108fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
5109 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
5110 let after = declaration[receiver_start + receiver.len()..].trim_start();
5111 if after
5112 .chars()
5113 .next()
5114 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
5115 {
5116 return None;
5117 }
5118
5119 let before = declaration[..receiver_start].trim_end();
5120 if before.contains(',') {
5121 return None;
5122 }
5123 normalize_receiver_type_name(strip_java_declaration_prefixes(before))
5124}
5125
5126fn strip_java_declaration_prefixes(mut value: &str) -> &str {
5127 loop {
5128 value = value.trim_start();
5129 if let Some(stripped) = strip_leading_java_annotation(value) {
5130 value = stripped;
5131 continue;
5132 }
5133 if let Some(stripped) = strip_leading_java_modifier(value) {
5134 value = stripped;
5135 continue;
5136 }
5137 return value.trim();
5138 }
5139}
5140
5141fn strip_leading_java_annotation(value: &str) -> Option<&str> {
5142 let value = value.trim_start();
5143 let mut chars = value.char_indices();
5144 let (_, first) = chars.next()?;
5145 if first != '@' {
5146 return None;
5147 }
5148 let mut end = first.len_utf8();
5149 for (index, ch) in chars {
5150 if !(is_code_ident_char(ch) || ch == '.') {
5151 end = index;
5152 break;
5153 }
5154 end = index + ch.len_utf8();
5155 }
5156 let rest = value[end..].trim_start();
5157 if let Some(stripped) = rest.strip_prefix('(') {
5158 let mut depth = 1usize;
5159 for (index, ch) in stripped.char_indices() {
5160 match ch {
5161 '(' => depth += 1,
5162 ')' => {
5163 depth = depth.saturating_sub(1);
5164 if depth == 0 {
5165 return Some(stripped[index + ch.len_utf8()..].trim_start());
5166 }
5167 }
5168 _ => {}
5169 }
5170 }
5171 return Some("");
5172 }
5173 Some(rest)
5174}
5175
5176fn strip_leading_java_modifier(value: &str) -> Option<&str> {
5177 const MODIFIERS: &[&str] = &[
5178 "public",
5179 "protected",
5180 "private",
5181 "abstract",
5182 "static",
5183 "final",
5184 "transient",
5185 "volatile",
5186 "synchronized",
5187 "native",
5188 "strictfp",
5189 ];
5190 MODIFIERS
5191 .iter()
5192 .find_map(|modifier| strip_leading_word(value, modifier))
5193}
5194
5195fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
5196 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
5197 let before = &declaration[..receiver_start];
5198 if find_identifier_occurrence(before, "val").is_none()
5199 && find_identifier_occurrence(before, "var").is_none()
5200 {
5201 return None;
5202 }
5203
5204 let after = declaration[receiver_start + receiver.len()..].trim_start();
5205 if let Some(type_text) = after.strip_prefix(':') {
5206 return normalize_receiver_type_name(read_type_prefix(type_text));
5207 }
5208 after
5209 .strip_prefix('=')
5210 .and_then(infer_kotlin_constructor_type)
5211}
5212
5213fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
5214 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
5215 if rest.trim_start().starts_with('(') {
5216 normalize_receiver_type_name(head)
5217 } else {
5218 None
5219 }
5220}
5221
5222fn read_type_prefix(value: &str) -> &str {
5223 let mut angle_depth = 0usize;
5224 for (index, ch) in value.char_indices() {
5225 match ch {
5226 '<' => angle_depth += 1,
5227 '>' => angle_depth = angle_depth.saturating_sub(1),
5228 '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
5229 return value[..index].trim();
5230 }
5231 _ => {}
5232 }
5233 }
5234 value.trim()
5235}
5236
5237fn infer_cpp_receiver_type_from_scope(
5238 scope: tree_sitter::Node<'_>,
5239 source: &str,
5240 receiver: &str,
5241 call_line: u32,
5242) -> Option<String> {
5243 let lines = source.lines().collect::<Vec<_>>();
5244 if lines.is_empty() {
5245 return None;
5246 }
5247 let scope_start = scope.start_position().row as usize;
5248 let call_index = (call_line as usize)
5249 .saturating_sub(1)
5250 .min(lines.len().saturating_sub(1));
5251 for index in (scope_start..=call_index).rev() {
5252 if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
5253 return Some(receiver_type);
5254 }
5255 }
5256 None
5257}
5258
5259fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
5260 for receiver_start in identifier_occurrences(line, receiver) {
5261 let after = line[receiver_start + receiver.len()..].trim_start();
5262 if after
5263 .chars()
5264 .next()
5265 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
5266 {
5267 continue;
5268 }
5269 let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
5270 let normalized = normalize_cpp_type_name(type_text)?;
5271 if normalized == "auto" {
5272 if let Some(rhs) = after.strip_prefix('=') {
5273 return infer_cpp_auto_receiver_type(rhs);
5274 }
5275 continue;
5276 }
5277 return Some(normalized);
5278 }
5279 None
5280}
5281
5282fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
5283 let candidate = prefix
5284 .rsplit([';', '{', '}', '('])
5285 .next()
5286 .unwrap_or(prefix)
5287 .trim();
5288 if candidate.is_empty() || candidate.ends_with(',') {
5289 None
5290 } else {
5291 Some(candidate)
5292 }
5293}
5294
5295fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
5296 let without_templates = strip_angle_groups(type_text);
5297 let mut cleaned = String::with_capacity(without_templates.len());
5298 for token in without_templates.split_whitespace() {
5299 if matches!(
5300 token,
5301 "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
5302 ) {
5303 continue;
5304 }
5305 if !cleaned.is_empty() {
5306 cleaned.push(' ');
5307 }
5308 cleaned.push_str(token);
5309 }
5310 let token = cleaned
5311 .split_whitespace()
5312 .last()
5313 .unwrap_or(cleaned.trim())
5314 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
5315 .trim_matches(['*', '&']);
5316 let simple = token.rsplit("::").next().unwrap_or(token).trim();
5317 if simple.is_empty() || cpp_non_type_token(simple) {
5318 None
5319 } else {
5320 Some(simple.to_string())
5321 }
5322}
5323
5324fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
5325 let rhs = rhs.trim_start();
5326 if let Some(after_new) = rhs.strip_prefix("new ") {
5327 return infer_cpp_constructor_type(after_new);
5328 }
5329 infer_cpp_make_template_type(rhs)
5330 .or_else(|| infer_cpp_constructor_type(rhs))
5331 .or_else(|| infer_cpp_factory_type(rhs))
5332}
5333
5334fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
5335 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
5336 let normalized = normalize_cpp_type_name(head)?;
5337 if !normalized
5338 .chars()
5339 .next()
5340 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
5341 {
5342 return None;
5343 }
5344 if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
5345 Some(normalized)
5346 } else {
5347 None
5348 }
5349}
5350
5351fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
5352 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
5353 if !rest.trim_start().starts_with('(') {
5354 return None;
5355 }
5356 let base = head.split('<').next().unwrap_or(head);
5357 let base_simple = base.rsplit("::").next().unwrap_or(base);
5358 if !matches!(base_simple, "make_unique" | "make_shared") {
5359 return None;
5360 }
5361 first_angle_arg(head).and_then(normalize_cpp_type_name)
5362}
5363
5364fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
5365 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
5366 if !rest.trim_start().starts_with('(') {
5367 return None;
5368 }
5369 let simple = head
5370 .split('<')
5371 .next()
5372 .unwrap_or(head)
5373 .rsplit("::")
5374 .next()
5375 .unwrap_or(head);
5376 for prefix in ["make", "create", "build"] {
5377 if let Some(suffix) = simple.strip_prefix(prefix) {
5378 if suffix
5379 .chars()
5380 .next()
5381 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
5382 {
5383 return normalize_cpp_type_name(suffix);
5384 }
5385 }
5386 }
5387 None
5388}
5389
5390#[derive(Debug, Clone, Copy)]
5391enum JavaLikeInvocation {
5392 Kotlin,
5393 Cpp,
5394}
5395
5396fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
5397 let value = value.trim_start();
5398 let mut end = 0usize;
5399 for (index, ch) in value.char_indices() {
5400 let allowed_separator = match flavor {
5401 JavaLikeInvocation::Kotlin => ch == '.',
5402 JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
5403 };
5404 if is_code_ident_char(ch) || allowed_separator {
5405 end = index + ch.len_utf8();
5406 continue;
5407 }
5408 break;
5409 }
5410 if end == 0 {
5411 return None;
5412 }
5413 let mut rest = &value[end..];
5414 if let Some(stripped) = rest.trim_start().strip_prefix('<') {
5415 let skipped = skip_balanced_angle(stripped)?;
5416 let rest_start = rest.len() - rest.trim_start().len();
5417 let angle_len = 1 + skipped;
5418 end += rest_start + angle_len;
5419 rest = &value[end..];
5420 }
5421 Some((value[..end].trim(), rest))
5422}
5423
5424fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
5425 let mut depth = 1usize;
5426 for (index, ch) in value_after_open.char_indices() {
5427 match ch {
5428 '<' => depth += 1,
5429 '>' => {
5430 depth = depth.saturating_sub(1);
5431 if depth == 0 {
5432 return Some(index + ch.len_utf8());
5433 }
5434 }
5435 _ => {}
5436 }
5437 }
5438 None
5439}
5440
5441fn first_angle_arg(value: &str) -> Option<&str> {
5442 let open = value.find('<')?;
5443 let inner_len = skip_balanced_angle(&value[open + 1..])?;
5444 let inner = &value[open + 1..open + inner_len];
5445 split_top_level_commas(inner).into_iter().next()
5446}
5447
5448fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
5449 let without_generics = strip_angle_groups(type_text);
5450 let cleaned = without_generics
5451 .replace("[]", " ")
5452 .replace("...", " ")
5453 .replace(['?', '&', '*'], " ");
5454 let token = cleaned
5455 .split_whitespace()
5456 .last()
5457 .unwrap_or(cleaned.trim())
5458 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
5459 let token = token.rsplit("::").next().unwrap_or(token);
5460 let simple = token.rsplit('.').next().unwrap_or(token).trim();
5461 if simple.is_empty()
5462 || java_like_primitive_type(simple)
5463 || !simple
5464 .chars()
5465 .next()
5466 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
5467 {
5468 None
5469 } else {
5470 Some(simple.to_string())
5471 }
5472}
5473
5474fn simple_type_name(scoped_name: &str) -> Option<String> {
5475 scoped_name
5476 .rsplit("::")
5477 .find(|segment| !segment.is_empty())
5478 .and_then(normalize_receiver_type_name)
5479}
5480
5481fn strip_angle_groups(value: &str) -> String {
5482 let mut output = String::with_capacity(value.len());
5483 let mut depth = 0usize;
5484 for ch in value.chars() {
5485 match ch {
5486 '<' => {
5487 if depth == 0 {
5488 output.push(' ');
5489 }
5490 depth += 1;
5491 }
5492 '>' => depth = depth.saturating_sub(1),
5493 _ if depth == 0 => output.push(ch),
5494 _ => {}
5495 }
5496 }
5497 output
5498}
5499
5500fn java_like_primitive_type(value: &str) -> bool {
5501 matches!(
5502 value,
5503 "boolean"
5504 | "byte"
5505 | "char"
5506 | "double"
5507 | "float"
5508 | "int"
5509 | "long"
5510 | "short"
5511 | "void"
5512 | "Boolean"
5513 | "Byte"
5514 | "Char"
5515 | "Double"
5516 | "Float"
5517 | "Int"
5518 | "Long"
5519 | "Short"
5520 | "Unit"
5521 )
5522}
5523
5524fn cpp_non_type_token(value: &str) -> bool {
5525 matches!(
5526 value,
5527 "return"
5528 | "if"
5529 | "else"
5530 | "for"
5531 | "while"
5532 | "do"
5533 | "switch"
5534 | "case"
5535 | "default"
5536 | "break"
5537 | "continue"
5538 | "goto"
5539 | "throw"
5540 | "new"
5541 | "delete"
5542 | "co_await"
5543 | "co_yield"
5544 | "co_return"
5545 | "static_cast"
5546 | "const_cast"
5547 | "dynamic_cast"
5548 | "reinterpret_cast"
5549 | "sizeof"
5550 | "alignof"
5551 | "typeid"
5552 | "and"
5553 | "or"
5554 | "not"
5555 | "xor"
5556 )
5557}
5558
5559fn receiver_is_bare_identifier(value: &str) -> bool {
5560 let mut chars = value.chars();
5561 let Some(first) = chars.next() else {
5562 return false;
5563 };
5564 (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
5565}
5566
5567fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
5568 identifier_occurrences(value, needle).into_iter().next()
5569}
5570
5571fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
5572 value
5573 .match_indices(needle)
5574 .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
5575 .collect()
5576}
5577
5578fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
5579 let before = value[..start].chars().next_back();
5580 let after = value[start + len..].chars().next();
5581 !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
5582}
5583
5584fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
5585 let stripped = value.strip_prefix(word)?;
5586 if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
5587 Some(stripped.trim_start())
5588 } else {
5589 None
5590 }
5591}
5592
5593fn is_code_ident_char(ch: char) -> bool {
5594 ch == '_' || ch.is_ascii_alphanumeric()
5595}
5596
5597fn infer_rust_receiver_type(reference: &NameMatchRef) -> Option<String> {
5598 if matches!(reference.receiver.as_str(), "self" | "Self") {
5599 return enclosing_type_from_scoped_name(&reference.caller_symbol);
5600 }
5601
5602 if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
5603 return Some(reference.receiver.clone());
5604 }
5605
5606 reference
5607 .caller_signature
5608 .as_deref()
5609 .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
5610}
5611
5612fn rust_receiver_looks_type_like(receiver: &str) -> bool {
5613 receiver
5614 .chars()
5615 .next()
5616 .is_some_and(|ch| ch == '_' || ch.is_uppercase())
5617}
5618
5619fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
5620 scoped_name
5621 .rsplit_once("::")
5622 .map(|(enclosing, _)| enclosing)
5623 .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
5624 .map(ToString::to_string)
5625}
5626
5627fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
5628 let params = signature_parameter_text(signature)?;
5629 for param in split_top_level_commas(params) {
5630 let Some((pattern, type_text)) = param.split_once(':') else {
5631 continue;
5632 };
5633 let Some(name) = rust_parameter_name(pattern) else {
5634 continue;
5635 };
5636 if name == receiver {
5637 return normalize_rust_receiver_type(type_text);
5638 }
5639 }
5640 None
5641}
5642
5643fn signature_parameter_text(signature: &str) -> Option<&str> {
5644 let open = signature.find('(')?;
5645 let mut depth = 0usize;
5646 for (offset, ch) in signature[open..].char_indices() {
5647 match ch {
5648 '(' => depth += 1,
5649 ')' => {
5650 depth = depth.saturating_sub(1);
5651 if depth == 0 {
5652 return Some(&signature[open + 1..open + offset]);
5653 }
5654 }
5655 _ => {}
5656 }
5657 }
5658 None
5659}
5660
5661fn split_top_level_commas(value: &str) -> Vec<&str> {
5662 let mut parts = Vec::new();
5663 let mut start = 0usize;
5664 let mut angle_depth = 0usize;
5665 let mut paren_depth = 0usize;
5666 let mut bracket_depth = 0usize;
5667 for (index, ch) in value.char_indices() {
5668 match ch {
5669 '<' => angle_depth += 1,
5670 '>' => angle_depth = angle_depth.saturating_sub(1),
5671 '(' => paren_depth += 1,
5672 ')' => paren_depth = paren_depth.saturating_sub(1),
5673 '[' => bracket_depth += 1,
5674 ']' => bracket_depth = bracket_depth.saturating_sub(1),
5675 ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
5676 let part = value[start..index].trim();
5677 if !part.is_empty() {
5678 parts.push(part);
5679 }
5680 start = index + ch.len_utf8();
5681 }
5682 _ => {}
5683 }
5684 }
5685 let part = value[start..].trim();
5686 if !part.is_empty() {
5687 parts.push(part);
5688 }
5689 parts
5690}
5691
5692fn rust_parameter_name(pattern: &str) -> Option<&str> {
5693 let mut pattern = pattern.trim();
5694 if let Some(stripped) = pattern.strip_prefix("mut ") {
5695 pattern = stripped.trim_start();
5696 }
5697 pattern
5698 .rsplit(|ch: char| !is_rust_ident_char(ch))
5699 .find(|part| !part.is_empty())
5700}
5701
5702fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
5703 let mut ty = strip_leading_rust_type_modifiers(type_text);
5704 let owned_inner;
5705 if let Some(inner) = single_outer_generic_arg(ty) {
5706 owned_inner = inner.trim().to_string();
5707 ty = strip_leading_rust_type_modifiers(&owned_inner);
5708 }
5709 rust_base_type_ident(ty)
5710}
5711
5712fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
5713 loop {
5714 ty = ty.trim_start();
5715 if let Some(stripped) = ty.strip_prefix('&') {
5716 ty = stripped.trim_start();
5717 if let Some(stripped) = strip_leading_lifetime(ty) {
5718 ty = stripped.trim_start();
5719 }
5720 if let Some(stripped) = ty.strip_prefix("mut ") {
5721 ty = stripped.trim_start();
5722 }
5723 continue;
5724 }
5725 if let Some(stripped) = ty.strip_prefix("mut ") {
5726 ty = stripped.trim_start();
5727 continue;
5728 }
5729 if let Some(stripped) = ty.strip_prefix("dyn ") {
5730 ty = stripped.trim_start();
5731 continue;
5732 }
5733 if let Some(stripped) = ty.strip_prefix("impl ") {
5734 ty = stripped.trim_start();
5735 continue;
5736 }
5737 break ty.trim();
5738 }
5739}
5740
5741fn strip_leading_lifetime(value: &str) -> Option<&str> {
5742 let mut chars = value.char_indices();
5743 let (_, first) = chars.next()?;
5744 if first != '\'' {
5745 return None;
5746 }
5747 for (index, ch) in chars {
5748 if !(ch == '_' || ch.is_ascii_alphanumeric()) {
5749 return Some(&value[index..]);
5750 }
5751 }
5752 Some("")
5753}
5754
5755fn single_outer_generic_arg(ty: &str) -> Option<&str> {
5756 let ty = ty.trim();
5757 let open = ty.find('<')?;
5758 let mut depth = 0usize;
5759 let mut close = None;
5760 for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
5761 match ch {
5762 '<' => depth += 1,
5763 '>' => {
5764 depth = depth.saturating_sub(1);
5765 if depth == 0 {
5766 close = Some(index);
5767 break;
5768 }
5769 }
5770 _ => {}
5771 }
5772 }
5773 let close = close?;
5774 if !ty[close + 1..].trim().is_empty() {
5775 return None;
5776 }
5777 let inner = &ty[open + 1..close];
5778 let args = split_top_level_commas(inner);
5779 match args.as_slice() {
5780 [arg] => Some(*arg),
5781 _ => None,
5782 }
5783}
5784
5785fn rust_base_type_ident(ty: &str) -> Option<String> {
5786 let ty = ty.trim();
5787 let head = ty
5788 .split([' ', '+', '='])
5789 .find(|part| !part.is_empty())
5790 .unwrap_or(ty);
5791 let head = head.split('<').next().unwrap_or(head).trim();
5792 let ident = head
5793 .rsplit("::")
5794 .next()
5795 .unwrap_or(head)
5796 .trim_matches(|ch: char| !is_rust_ident_char(ch));
5797 if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
5798 None
5799 } else {
5800 Some(ident.to_string())
5801 }
5802}
5803
5804fn is_rust_ident_char(ch: char) -> bool {
5805 ch == '_' || ch.is_ascii_alphanumeric()
5806}
5807
5808fn select_type_match_candidate(
5809 reference: &NameMatchRef,
5810 candidates: &[NameMatchCandidate],
5811 receiver_type: &str,
5812) -> Option<NameMatchCandidate> {
5813 let candidates = candidates
5814 .iter()
5815 .filter(|candidate| candidate.node_id != reference.caller_node)
5816 .filter(|candidate| {
5817 type_candidate_matches(candidate, receiver_type, &reference.method_name)
5818 })
5819 .collect::<Vec<_>>();
5820 match candidates.as_slice() {
5821 [candidate] => Some((**candidate).clone()),
5822 _ => None,
5823 }
5824}
5825
5826fn type_candidate_matches(
5827 candidate: &NameMatchCandidate,
5828 receiver_type: &str,
5829 method_name: &str,
5830) -> bool {
5831 let normalized_type = receiver_type.replace('.', "::");
5832 let suffix = format!("{normalized_type}::{method_name}");
5833 candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
5834}
5835
5836fn select_name_match_candidate(
5837 reference: &NameMatchRef,
5838 candidates: &[NameMatchCandidate],
5839) -> Option<NameMatchCandidate> {
5840 let candidates = candidates
5841 .iter()
5842 .filter(|candidate| candidate.node_id != reference.caller_node)
5843 .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
5844 .collect::<Vec<_>>();
5845 match candidates.as_slice() {
5846 [] => None,
5847 [candidate] => Some((**candidate).clone()),
5848 _ => select_scored_name_match_candidate(reference, &candidates),
5849 }
5850}
5851
5852fn candidate_allowed_for_reference(
5853 reference: &NameMatchRef,
5854 candidate: &NameMatchCandidate,
5855) -> bool {
5856 if !reference.colon_dispatch {
5857 return true;
5858 }
5859
5860 candidate.kind == "method"
5861 && candidate
5862 .scoped_name
5863 .split("::")
5864 .any(|segment| segment == reference.receiver)
5865}
5866
5867fn select_scored_name_match_candidate(
5868 reference: &NameMatchRef,
5869 candidates: &[&NameMatchCandidate],
5870) -> Option<NameMatchCandidate> {
5871 let receiver_words = split_camel_case(&reference.receiver);
5872 if receiver_words.is_empty() {
5873 return None;
5874 }
5875
5876 let mut best: Option<(&NameMatchCandidate, f64)> = None;
5877 let mut tied_best = false;
5878 for candidate in candidates {
5879 let candidate_words = split_camel_case(&candidate.scoped_name);
5880 let overlap = receiver_words
5881 .iter()
5882 .filter(|receiver_word| {
5883 candidate_words
5884 .iter()
5885 .any(|candidate_word| candidate_word == *receiver_word)
5886 })
5887 .count() as f64;
5888 let score =
5889 overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
5890 match best {
5891 None => {
5892 best = Some((*candidate, score));
5893 tied_best = false;
5894 }
5895 Some((_, best_score)) if score > best_score => {
5896 best = Some((*candidate, score));
5897 tied_best = false;
5898 }
5899 Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
5900 tied_best = true;
5901 }
5902 _ => {}
5903 }
5904 }
5905
5906 let (candidate, score) = best?;
5907 if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
5908 Some(candidate.clone())
5909 } else {
5910 None
5911 }
5912}
5913
5914fn method_name_match_denylisted(method_name: &str) -> bool {
5915 matches!(
5916 method_name,
5917 "and_then"
5918 | "as_bytes"
5919 | "as_deref"
5920 | "as_mut"
5921 | "as_ref"
5922 | "as_str"
5923 | "borrow"
5924 | "borrow_mut"
5925 | "clear"
5926 | "clone"
5927 | "collect"
5928 | "contains"
5929 | "contains_key"
5930 | "count"
5931 | "dedup"
5932 | "default"
5933 | "drain"
5934 | "ends_with"
5935 | "entry"
5936 | "err"
5937 | "expect"
5938 | "extend"
5939 | "filter"
5940 | "filter_map"
5941 | "find"
5942 | "from"
5943 | "get"
5944 | "get_mut"
5945 | "insert"
5946 | "into"
5947 | "into_iter"
5948 | "is_empty"
5949 | "is_err"
5950 | "is_none"
5951 | "is_ok"
5952 | "is_some"
5953 | "iter"
5954 | "iter_mut"
5955 | "join"
5956 | "len"
5957 | "lock"
5958 | "map"
5959 | "map_err"
5960 | "max"
5961 | "min"
5962 | "new"
5963 | "next"
5964 | "ok"
5965 | "or_default"
5966 | "or_else"
5967 | "or_insert"
5968 | "or_insert_with"
5969 | "parse"
5970 | "pop"
5971 | "position"
5972 | "push"
5973 | "read"
5974 | "recv"
5975 | "remove"
5976 | "replace"
5977 | "retain"
5978 | "send"
5979 | "sort"
5980 | "sort_by"
5981 | "split"
5982 | "starts_with"
5983 | "sum"
5984 | "take"
5985 | "to_owned"
5986 | "to_string"
5987 | "trim"
5988 | "try_from"
5989 | "try_into"
5990 | "unwrap"
5991 | "unwrap_or"
5992 | "unwrap_or_default"
5993 | "unwrap_or_else"
5994 | "with_capacity"
5995 | "write"
5996 )
5997}
5998
5999fn split_camel_case(value: &str) -> Vec<String> {
6000 let chars = value.chars().collect::<Vec<_>>();
6001 let mut normalized = String::with_capacity(value.len() + 8);
6002 for (index, ch) in chars.iter().enumerate() {
6003 let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
6004 let next = chars.get(index + 1);
6005 let is_separator = ch.is_whitespace()
6006 || matches!(
6007 ch,
6008 '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
6009 );
6010 if is_separator {
6011 normalized.push(' ');
6012 continue;
6013 }
6014 let camel_boundary = previous.is_some_and(|prev| {
6015 (prev.is_lowercase() && ch.is_uppercase())
6016 || (prev.is_ascii_digit() && ch.is_alphabetic())
6017 || (prev.is_uppercase()
6018 && ch.is_uppercase()
6019 && next.is_some_and(|next| next.is_lowercase()))
6020 });
6021 if camel_boundary {
6022 normalized.push(' ');
6023 }
6024 normalized.push(*ch);
6025 }
6026
6027 normalized
6028 .split_whitespace()
6029 .filter(|word| word.len() > 1)
6030 .map(|word| word.to_ascii_lowercase())
6031 .collect()
6032}
6033
6034fn compute_path_proximity(left: &str, right: &str) -> f64 {
6035 let left_dirs = left
6036 .rsplit_once('/')
6037 .map(|(dir, _)| dir)
6038 .unwrap_or_default()
6039 .split('/')
6040 .filter(|part| !part.is_empty());
6041 let right_dirs = right
6042 .rsplit_once('/')
6043 .map(|(dir, _)| dir)
6044 .unwrap_or_default()
6045 .split('/')
6046 .filter(|part| !part.is_empty());
6047
6048 let shared = left_dirs
6049 .zip(right_dirs)
6050 .take_while(|(left, right)| left == right)
6051 .count();
6052 ((shared as f64) * 0.05).min(0.5)
6053}
6054
6055fn mark_backend_state(
6056 tx: &Transaction<'_>,
6057 project_root: &Path,
6058 rel_path: &str,
6059 content_hash: Option<&blake3::Hash>,
6060 status: &str,
6061) -> Result<()> {
6062 clear_backend_state_for_file(tx, project_root, rel_path)?;
6063 let hash = content_hash
6064 .map(|hash| hash_to_hex(*hash))
6065 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
6066 tx.execute(
6067 "INSERT OR REPLACE INTO backend_file_state(
6068 backend, workspace_root, file_path, content_hash, status, updated_at
6069 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
6070 params![
6071 BACKEND_TREESITTER,
6072 project_root.display().to_string(),
6073 rel_path,
6074 hash,
6075 status,
6076 unix_seconds_now(),
6077 ],
6078 )?;
6079 Ok(())
6080}
6081
6082fn clear_backend_state_for_file(
6083 tx: &Transaction<'_>,
6084 project_root: &Path,
6085 rel_path: &str,
6086) -> Result<()> {
6087 tx.execute(
6088 "DELETE FROM backend_file_state
6089 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
6090 params![
6091 BACKEND_TREESITTER,
6092 project_root.display().to_string(),
6093 rel_path
6094 ],
6095 )?;
6096 Ok(())
6097}
6098
6099fn load_file_row(tx: &Transaction<'_>, rel_path: &str) -> Result<Option<FileRow>> {
6100 tx.query_row(
6101 "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
6102 params![rel_path],
6103 |row| {
6104 let hash_text: String = row.get(1)?;
6105 Ok(FileRow {
6106 surface_fingerprint: row.get(0)?,
6107 freshness: FileFreshness {
6108 content_hash: hash_from_hex(&hash_text)
6109 .unwrap_or_else(cache_freshness::zero_hash),
6110 mtime: ns_to_system_time(row.get::<_, i64>(2)?),
6111 size: row.get::<_, i64>(3)? as u64,
6112 },
6113 })
6114 },
6115 )
6116 .optional()
6117 .map_err(CallGraphStoreError::from)
6118}
6119
6120fn stored_node_ids_match_extract(
6121 tx: &Transaction<'_>,
6122 rel_path: &str,
6123 extract: &FileExtract,
6124) -> Result<bool> {
6125 let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
6126 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
6127 let mut stored = BTreeSet::new();
6128 for row in rows {
6129 stored.insert(row?);
6130 }
6131 let extracted = extract
6132 .nodes
6133 .iter()
6134 .map(|node| node.id.clone())
6135 .collect::<BTreeSet<_>>();
6136 Ok(stored == extracted)
6137}
6138
6139fn update_file_fresh_metadata(
6140 tx: &Transaction<'_>,
6141 rel_path: &str,
6142 hash: &blake3::Hash,
6143 mtime: SystemTime,
6144 size: u64,
6145) -> Result<()> {
6146 tx.execute(
6147 "UPDATE files SET mtime_ns = ?2, size = ?3, indexed_at = ?4 WHERE path = ?1",
6148 params![
6149 rel_path,
6150 system_time_to_ns(mtime),
6151 size as i64,
6152 unix_seconds_now()
6153 ],
6154 )?;
6155 tx.execute(
6156 "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
6157 WHERE backend = ?1 AND file_path = ?2 AND content_hash = ?3",
6158 params![
6159 BACKEND_TREESITTER,
6160 rel_path,
6161 hash_to_hex(*hash),
6162 unix_seconds_now(),
6163 ],
6164 )?;
6165 Ok(())
6166}
6167
6168#[derive(Debug, Clone, PartialEq, Eq)]
6169struct DependentRefSelection {
6170 ref_id: String,
6171 caller_file: String,
6172}
6173
6174fn ref_ids_depending_on(
6175 tx: &Transaction<'_>,
6176 project_root: &Path,
6177 rel_path: &str,
6178) -> Result<Vec<DependentRefSelection>> {
6179 let mut stmt = tx.prepare(
6180 "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
6181 FROM refs r
6182 WHERE r.caller_file IN (
6183 SELECT file_path FROM file_dependencies WHERE dep_file = ?1
6184 )
6185 OR r.target_file = ?1
6186 ORDER BY r.ref_id",
6187 )?;
6188 let rows = stmt.query_map(params![rel_path], |row| {
6189 Ok(RefDependencyRow {
6190 ref_id: row.get(0)?,
6191 kind: row.get(1)?,
6192 caller_file: row.get(2)?,
6193 module_path: row.get(3)?,
6194 target_file: row.get(4)?,
6195 })
6196 })?;
6197 let mut ids = Vec::new();
6198 for row in rows {
6199 let row = row?;
6200 if ref_dependency_row_depends_on(project_root, &row, rel_path) {
6201 ids.push(DependentRefSelection {
6202 ref_id: row.ref_id,
6203 caller_file: row.caller_file,
6204 });
6205 }
6206 }
6207 Ok(ids)
6208}
6209
6210fn record_dependent_refs(
6211 selected_ref_ids: &mut BTreeSet<String>,
6212 selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
6213 dependent_refs: Vec<DependentRefSelection>,
6214) {
6215 for dependent_ref in dependent_refs {
6216 let DependentRefSelection {
6217 ref_id,
6218 caller_file,
6219 } = dependent_ref;
6220 selected_ref_ids.insert(ref_id.clone());
6221 selected_refs_by_caller
6222 .entry(caller_file)
6223 .or_default()
6224 .insert(ref_id);
6225 }
6226}
6227
6228#[cfg(test)]
6229fn refs_by_caller_for_ref_ids(
6230 tx: &Transaction<'_>,
6231 ref_ids: &BTreeSet<String>,
6232) -> Result<BTreeMap<String, BTreeSet<String>>> {
6233 let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
6234 let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
6235 for ref_id in ref_ids {
6236 if let Some(caller) = stmt
6237 .query_row(params![ref_id], |row| row.get::<_, String>(0))
6238 .optional()?
6239 {
6240 by_caller.entry(caller).or_default().insert(ref_id.clone());
6241 }
6242 }
6243 Ok(by_caller)
6244}
6245
6246fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
6247 tx.execute(
6248 "DELETE FROM file_dependencies WHERE file_path = ?1",
6249 params![rel_path],
6250 )?;
6251 delete_refs_for_caller(tx, rel_path)?;
6252 tx.execute(
6253 "DELETE FROM dispatch_hints WHERE file = ?1",
6254 params![rel_path],
6255 )?;
6256 tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
6257 tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
6258 Ok(())
6259}
6260
6261fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
6262 let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
6263 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
6264 let mut ids = BTreeSet::new();
6265 for row in rows {
6266 ids.insert(row?);
6267 }
6268 delete_ref_ids(tx, &ids)
6269}
6270
6271fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
6272 for ref_id in ref_ids {
6273 tx.execute("DELETE FROM edges WHERE ref_id = ?1", params![ref_id])?;
6274 tx.execute("DELETE FROM refs WHERE ref_id = ?1", params![ref_id])?;
6275 }
6276 Ok(())
6277}
6278
6279fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
6280 let mut stmt = conn.prepare(
6281 "SELECT source.file_path, source.scoped_name, edges.target_file,
6282 edges.target_symbol, edges.kind, edges.line
6283 FROM edges
6284 JOIN nodes AS source ON source.id = edges.source_node
6285 ORDER BY source.file_path, source.scoped_name, edges.target_file,
6286 edges.target_symbol, edges.kind, edges.line",
6287 )?;
6288 let rows = stmt.query_map([], |row| {
6289 Ok(StoredEdge {
6290 source_file: row.get(0)?,
6291 source_symbol: row.get(1)?,
6292 target_file: row.get(2)?,
6293 target_symbol: row.get(3)?,
6294 kind: row.get(4)?,
6295 line: row.get::<_, i64>(5)? as u32,
6296 })
6297 })?;
6298 let mut edges = BTreeSet::new();
6299 for row in rows {
6300 edges.insert(row?);
6301 }
6302 Ok(edges)
6303}
6304
6305fn module_target_from_dependencies(
6306 project_root: &Path,
6307 dependencies: &BTreeSet<String>,
6308) -> Option<String> {
6309 dependencies.iter().find_map(|dep| {
6310 let path = project_root.join(dep);
6311 if path.is_file() {
6312 Some(relative_path(project_root, &canonicalize_path(&path)))
6313 } else {
6314 None
6315 }
6316 })
6317}
6318
6319fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
6320 let mut named = HashMap::new();
6321 if let Some(full_ref) = &raw_ref.full_ref {
6322 named = parse_reexport_names(full_ref);
6323 }
6324 ReexportIndex {
6325 target_file,
6326 named,
6327 wildcard: raw_ref.wildcard,
6328 }
6329}
6330
6331fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
6332 let mut names = HashMap::new();
6333 let Some(open) = statement.find('{') else {
6334 return names;
6335 };
6336 let Some(close) = statement[open + 1..]
6337 .find('}')
6338 .map(|offset| open + 1 + offset)
6339 else {
6340 return names;
6341 };
6342 for spec in statement[open + 1..close].split(',') {
6343 let spec = spec.trim();
6344 if spec.is_empty() {
6345 continue;
6346 }
6347 if let Some((source, local)) = spec.split_once(" as ") {
6348 names.insert(local.trim().to_string(), source.trim().to_string());
6349 } else {
6350 names.insert(spec.to_string(), spec.to_string());
6351 }
6352 }
6353 names
6354}
6355
6356fn dependencies_for_ref(
6357 tx: &Transaction<'_>,
6358 project_root: &Path,
6359 ref_id: &str,
6360) -> Result<BTreeSet<String>> {
6361 let row = tx.query_row(
6362 "SELECT kind, caller_file, module_path, target_file FROM refs WHERE ref_id = ?1",
6363 params![ref_id],
6364 |row| {
6365 Ok(RefDependencyRow {
6366 ref_id: ref_id.to_string(),
6367 kind: row.get(0)?,
6368 caller_file: row.get(1)?,
6369 module_path: row.get(2)?,
6370 target_file: row.get(3)?,
6371 })
6372 },
6373 )?;
6374
6375 match row.kind.as_str() {
6376 "import" | "reexport" => {
6377 let Some(module_path) = row.module_path.as_deref() else {
6378 return Ok(BTreeSet::new());
6379 };
6380 let file_deps = file_dependencies_for_file(tx, &row.caller_file)?;
6381 let module_deps =
6382 module_dependencies_for_ref(project_root, &row.caller_file, module_path);
6383 Ok(file_deps.intersection(&module_deps).cloned().collect())
6384 }
6385 "export_alias" => Ok(BTreeSet::new()),
6386 "call" => {
6387 let mut deps = file_dependencies_for_file(tx, &row.caller_file)?;
6388 if let Some(target_file) = row.target_file {
6389 deps.insert(target_file);
6390 }
6391 Ok(deps)
6392 }
6393 _ => file_dependencies_for_file(tx, &row.caller_file),
6394 }
6395}
6396
6397#[derive(Debug)]
6398struct RefDependencyRow {
6399 ref_id: String,
6400 kind: String,
6401 caller_file: String,
6402 module_path: Option<String>,
6403 target_file: Option<String>,
6404}
6405
6406fn ref_dependency_row_depends_on(
6407 project_root: &Path,
6408 row: &RefDependencyRow,
6409 rel_path: &str,
6410) -> bool {
6411 if row.target_file.as_deref() == Some(rel_path) {
6412 return true;
6413 }
6414
6415 match row.kind.as_str() {
6416 "call" => true,
6417 "import" | "reexport" => row
6418 .module_path
6419 .as_deref()
6420 .map(|module_path| {
6421 module_dependencies_for_ref(project_root, &row.caller_file, module_path)
6422 .contains(rel_path)
6423 })
6424 .unwrap_or(false),
6425 "export_alias" => false,
6426 _ => false,
6427 }
6428}
6429
6430fn file_dependencies_for_file(tx: &Transaction<'_>, file_path: &str) -> Result<BTreeSet<String>> {
6431 let mut stmt = tx
6432 .prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1 ORDER BY dep_file")?;
6433 let rows = stmt.query_map(params![file_path], |row| row.get::<_, String>(0))?;
6434 let mut deps = BTreeSet::new();
6435 for row in rows {
6436 deps.insert(row?);
6437 }
6438 Ok(deps)
6439}
6440
6441fn module_dependencies_for_ref(
6442 project_root: &Path,
6443 caller_file: &str,
6444 module_path: &str,
6445) -> BTreeSet<String> {
6446 module_dependencies(project_root, &project_root.join(caller_file), module_path)
6447}
6448
6449fn import_dependencies(
6450 project_root: &Path,
6451 abs_path: &Path,
6452 imports: &[ImportStatement],
6453) -> BTreeSet<String> {
6454 let mut deps = BTreeSet::new();
6455 for import in imports {
6456 deps.extend(module_dependencies(
6457 project_root,
6458 abs_path,
6459 &import.module_path,
6460 ));
6461 }
6462 deps
6463}
6464
6465fn module_dependencies(
6466 project_root: &Path,
6467 abs_path: &Path,
6468 module_path: &str,
6469) -> BTreeSet<String> {
6470 let mut deps = BTreeSet::new();
6471 let caller_dir = abs_path.parent().unwrap_or(project_root);
6472 if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
6473 deps.insert(relative_path(project_root, &resolved));
6474 }
6475 if module_path.starts_with('.') {
6476 let base = caller_dir.join(module_path);
6477 for candidate in relative_module_candidates(&base) {
6478 deps.insert(relative_path(project_root, &candidate));
6479 }
6480 }
6481 deps
6482}
6483
6484fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
6485 let mut candidates = Vec::new();
6486 if base.extension().is_some() {
6487 candidates.push(base.to_path_buf());
6488 return candidates;
6489 }
6490 for ext in JS_TS_EXTENSIONS {
6491 candidates.push(base.with_extension(ext));
6492 }
6493 for ext in JS_TS_EXTENSIONS {
6494 candidates.push(base.join(format!("index.{ext}")));
6495 }
6496 candidates
6497}
6498
6499fn import_local_names(import: &ImportStatement) -> Vec<String> {
6500 let mut names = Vec::new();
6501 if let Some(default) = &import.default_import {
6502 names.push(default.clone());
6503 }
6504 if let Some(namespace) = &import.namespace_import {
6505 names.push(namespace.clone());
6506 }
6507 for name in &import.names {
6508 names.push(crate::imports::specifier_local_name(name).to_string());
6509 }
6510 names
6511}
6512
6513fn import_requested_names(import: &ImportStatement) -> Vec<String> {
6514 import
6515 .names
6516 .iter()
6517 .map(|name| crate::imports::specifier_imported_name(name).to_string())
6518 .collect()
6519}
6520
6521fn import_is_wildcard(import: &ImportStatement) -> bool {
6522 import.namespace_import.is_some() || import.raw_text.contains('*')
6523}
6524
6525fn namespace_alias(full_ref: &str) -> Option<String> {
6526 full_ref
6527 .split_once('.')
6528 .map(|(namespace, _)| namespace.to_string())
6529}
6530
6531fn import_kind_label(kind: ImportKind) -> &'static str {
6532 match kind {
6533 ImportKind::Value => "value",
6534 ImportKind::Type => "type",
6535 ImportKind::SideEffect => "side_effect",
6536 }
6537}
6538
6539fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
6540 match kind {
6541 SymbolKind::Function => "function",
6542 SymbolKind::Class => "class",
6543 SymbolKind::Method => "method",
6544 SymbolKind::Struct => "struct",
6545 SymbolKind::Interface => "interface",
6546 SymbolKind::Enum => "enum",
6547 SymbolKind::TypeAlias => "type_alias",
6548 SymbolKind::Variable => "variable",
6549 SymbolKind::Heading => "heading",
6550 SymbolKind::FileSummary => "file_summary",
6551 }
6552}
6553
6554fn is_type_like(kind: &SymbolKind) -> bool {
6555 matches!(
6556 kind,
6557 SymbolKind::Class
6558 | SymbolKind::Struct
6559 | SymbolKind::Interface
6560 | SymbolKind::Enum
6561 | SymbolKind::TypeAlias
6562 )
6563}
6564
6565fn lang_label(lang: LangId) -> &'static str {
6566 match lang {
6567 LangId::TypeScript => "typescript",
6568 LangId::Tsx => "tsx",
6569 LangId::JavaScript => "javascript",
6570 LangId::Python => "python",
6571 LangId::Rust => "rust",
6572 LangId::Go => "go",
6573 LangId::C => "c",
6574 LangId::Cpp => "cpp",
6575 LangId::Zig => "zig",
6576 LangId::CSharp => "csharp",
6577 LangId::Bash => "bash",
6578 LangId::Html => "html",
6579 LangId::Markdown => "markdown",
6580 LangId::Solidity => "solidity",
6581 LangId::Scss => "scss",
6582 LangId::Vue => "vue",
6583 LangId::Json => "json",
6584 LangId::Scala => "scala",
6585 LangId::Java => "java",
6586 LangId::Ruby => "ruby",
6587 LangId::Kotlin => "kotlin",
6588 LangId::Swift => "swift",
6589 LangId::Php => "php",
6590 LangId::Lua => "lua",
6591 LangId::Perl => "perl",
6592 LangId::Yaml => "yaml",
6593 LangId::Pascal => "pascal",
6594 LangId::R => "r",
6595 }
6596}
6597
6598fn lang_from_label(label: &str) -> Option<LangId> {
6599 match label {
6600 "typescript" => Some(LangId::TypeScript),
6601 "tsx" => Some(LangId::Tsx),
6602 "javascript" => Some(LangId::JavaScript),
6603 "python" => Some(LangId::Python),
6604 "rust" => Some(LangId::Rust),
6605 "go" => Some(LangId::Go),
6606 "c" => Some(LangId::C),
6607 "cpp" => Some(LangId::Cpp),
6608 "zig" => Some(LangId::Zig),
6609 "csharp" => Some(LangId::CSharp),
6610 "bash" => Some(LangId::Bash),
6611 "html" => Some(LangId::Html),
6612 "markdown" => Some(LangId::Markdown),
6613 "solidity" => Some(LangId::Solidity),
6614 "scss" => Some(LangId::Scss),
6615 "vue" => Some(LangId::Vue),
6616 "json" => Some(LangId::Json),
6617 "scala" => Some(LangId::Scala),
6618 "java" => Some(LangId::Java),
6619 "ruby" => Some(LangId::Ruby),
6620 "kotlin" => Some(LangId::Kotlin),
6621 "swift" => Some(LangId::Swift),
6622 "php" => Some(LangId::Php),
6623 "lua" => Some(LangId::Lua),
6624 "perl" => Some(LangId::Perl),
6625 "yaml" => Some(LangId::Yaml),
6626 "pascal" => Some(LangId::Pascal),
6627 "r" => Some(LangId::R),
6628 _ => None,
6629 }
6630}
6631
6632fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
6633 let mut normalized = if files.is_empty() {
6634 callgraph::walk_project_files(project_root).collect::<Vec<_>>()
6635 } else {
6636 files
6637 .iter()
6638 .map(|path| normalize_file_path(project_root, path))
6639 .collect::<Result<Vec<_>>>()?
6640 };
6641 normalized.sort();
6642 normalized.dedup();
6643 Ok(normalized)
6644}
6645
6646fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
6647 let full_path = if path.is_relative() {
6648 project_root.join(path)
6649 } else {
6650 path.to_path_buf()
6651 };
6652 Ok(canonicalize_path(&full_path))
6653}
6654
6655fn canonicalize_path(path: &Path) -> PathBuf {
6656 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
6657}
6658
6659fn relative_path(project_root: &Path, path: &Path) -> String {
6660 if let Ok(stripped) = path.strip_prefix(project_root) {
6661 return stripped.to_string_lossy().replace('\\', "/");
6662 }
6663 let canon_root = canonicalize_path(project_root);
6664 let canon_path = canonicalize_path(path);
6665 if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
6666 return stripped.to_string_lossy().replace('\\', "/");
6667 }
6668 canon_path.to_string_lossy().replace('\\', "/")
6669}
6670
6671fn unqualified_name(scoped: &str) -> &str {
6672 if scoped == TOP_LEVEL_SYMBOL {
6673 return scoped;
6674 }
6675 scoped
6676 .rsplit("::")
6677 .next()
6678 .unwrap_or(scoped)
6679 .rsplit('.')
6680 .next()
6681 .unwrap_or(scoped)
6682 .rsplit('#')
6683 .next()
6684 .unwrap_or(scoped)
6685}
6686
6687fn ref_id(parts: &[&str]) -> String {
6688 let joined = parts.join("\0");
6689 hash_to_hex(blake3::hash(joined.as_bytes()))
6690}
6691
6692fn hash_to_hex(hash: blake3::Hash) -> String {
6693 hash.to_hex().to_string()
6694}
6695
6696fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
6697 let bytes = hex_to_bytes(value)?;
6698 Some(blake3::Hash::from_bytes(bytes))
6699}
6700
6701fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
6702 if value.len() != 64 {
6703 return None;
6704 }
6705 let mut bytes = [0u8; 32];
6706 for (index, slot) in bytes.iter_mut().enumerate() {
6707 let start = index * 2;
6708 let end = start + 2;
6709 *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
6710 }
6711 Some(bytes)
6712}
6713
6714#[derive(Debug, Clone)]
6715struct LineIndex {
6716 newline_offsets: Vec<usize>,
6717 source_len: usize,
6718}
6719
6720impl LineIndex {
6721 fn new(source: &str) -> Self {
6722 Self {
6723 newline_offsets: source
6724 .bytes()
6725 .enumerate()
6726 .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
6727 .collect(),
6728 source_len: source.len(),
6729 }
6730 }
6731
6732 fn byte_to_line(&self, byte_offset: usize) -> u32 {
6733 let byte_offset = byte_offset.min(self.source_len);
6734 self.newline_offsets
6735 .partition_point(|offset| *offset < byte_offset) as u32
6736 + 1
6737 }
6738}
6739
6740fn empty_to_none(value: String) -> Option<String> {
6741 if value.is_empty() {
6742 None
6743 } else {
6744 Some(value)
6745 }
6746}
6747
6748fn bool_int(value: bool) -> i64 {
6749 if value {
6750 1
6751 } else {
6752 0
6753 }
6754}
6755
6756fn system_time_to_ns(time: SystemTime) -> i64 {
6757 time.duration_since(UNIX_EPOCH)
6758 .unwrap_or_default()
6759 .as_nanos()
6760 .min(i64::MAX as u128) as i64
6761}
6762
6763fn ns_to_system_time(value: i64) -> SystemTime {
6764 UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
6765}
6766
6767fn unix_seconds_now() -> i64 {
6768 SystemTime::now()
6769 .duration_since(UNIX_EPOCH)
6770 .unwrap_or_default()
6771 .as_secs() as i64
6772}
6773
6774#[cfg(test)]
6775mod cold_build_insert_tests {
6776 use super::*;
6777 use crate::imports::ImportBlock;
6778 use std::fs;
6779 use tempfile::tempdir;
6780
6781 #[test]
6782 fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
6783 let dir = tempdir().expect("temp dir");
6784 let file = dir.path().join("main.ts");
6785 fs::write(
6786 &file,
6787 r#"export function topA() {
6788 root();
6789}
6790
6791export function topB() {
6792 root();
6793}
6794
6795export function root() {
6796 leaf();
6797 missing();
6798}
6799
6800export function leaf() {}
6801"#,
6802 )
6803 .expect("write fixture");
6804
6805 let store = CallGraphStore::open(
6806 dir.path().join(".store-depth-boundary-counts"),
6807 dir.path().to_path_buf(),
6808 )
6809 .expect("open store");
6810 store
6811 .cold_build(std::slice::from_ref(&file))
6812 .expect("cold build");
6813
6814 let root = store
6815 .node_for(Path::new("main.ts"), "root")
6816 .expect("root node");
6817 let leaf = store
6818 .node_for(Path::new("main.ts"), "leaf")
6819 .expect("leaf node");
6820
6821 let (full_forward_len, full_direct_len) = {
6822 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
6823 conn.execute(
6824 "INSERT INTO edges (
6825 edge_id, ref_id, source_node, target_node, target_file,
6826 target_symbol, kind, line, provenance
6827 ) VALUES (
6828 'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
6829 ?2, ?3, 'call', 98, ?4
6830 )",
6831 rusqlite::params![
6832 &root.node_id,
6833 &leaf.file,
6834 &leaf.symbol,
6835 PROVENANCE_TREESITTER
6836 ],
6837 )
6838 .expect("insert dangling forward edge");
6839 conn.execute(
6840 "INSERT INTO edges (
6841 edge_id, ref_id, source_node, target_node, target_file,
6842 target_symbol, kind, line, provenance
6843 ) VALUES (
6844 'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
6845 ?1, ?2, ?3, 'call', 99, ?4
6846 )",
6847 rusqlite::params![
6848 &root.node_id,
6849 &root.file,
6850 &root.symbol,
6851 PROVENANCE_TREESITTER
6852 ],
6853 )
6854 .expect("insert dangling direct-caller edge");
6855
6856 let full_forward_len = forward_calls_for_node(&conn, &root)
6857 .expect("full forward calls")
6858 .len();
6859 let counted_forward_len =
6860 forward_call_count_for_node(&conn, &root).expect("counted forward calls");
6861 assert_eq!(
6862 counted_forward_len, full_forward_len,
6863 "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
6864 );
6865
6866 let full_direct_len = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
6867 .expect("full direct callers")
6868 .len();
6869 let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
6870 .expect("counted direct callers");
6871 assert_eq!(
6872 counted_direct_len, full_direct_len,
6873 "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
6874 );
6875
6876 (full_forward_len, full_direct_len)
6877 };
6878
6879 assert_eq!(
6880 full_forward_len, 2,
6881 "fixture root should have one resolved and one unresolved outgoing call"
6882 );
6883 assert_eq!(
6884 full_direct_len, 2,
6885 "fixture root should have two real direct callers"
6886 );
6887
6888 let tree = store
6889 .call_tree(Path::new("main.ts"), "root", 0)
6890 .expect("call tree");
6891 assert!(tree.depth_limited);
6892 assert_eq!(tree.children.len(), 0);
6893 assert_eq!(
6894 tree.truncated, full_forward_len,
6895 "call_tree depth boundary must report the full forward-call list length"
6896 );
6897
6898 let callers = store
6899 .callers_of(Path::new("main.ts"), "leaf", 0)
6900 .expect("callers");
6901 assert!(callers.depth_limited);
6902 assert_eq!(callers.callers.len(), 1);
6903 assert_eq!(callers.callers[0].caller.symbol, "root");
6904 assert_eq!(
6905 callers.truncated, full_direct_len,
6906 "callers depth boundary must report the full direct-caller list length"
6907 );
6908 }
6909
6910 #[test]
6911 fn source_freshness_matches_cache_collect_for_same_bytes() {
6912 let dir = tempdir().expect("temp dir");
6913 let path = dir.path().join("fixture.ts");
6914 let source = "export function main() { return helper(); }\n";
6915 fs::write(&path, source).expect("write fixture");
6916
6917 let expected = cache_freshness::collect(&path).expect("collect freshness from file");
6918 let actual =
6919 collect_source_freshness(&path, source).expect("collect freshness from source");
6920
6921 assert_eq!(actual, expected);
6922 }
6923
6924 #[test]
6925 fn cold_build_prepared_bulk_insert_matches_reference_rows() {
6926 let dir = tempdir().expect("temp dir");
6927 let project_root = dir.path();
6928 let extract = fixture_extract(project_root);
6929 let resolved = fixture_resolved(&extract);
6930
6931 let reference = build_reference_connection(project_root, &extract, &resolved);
6932 let optimized = build_optimized_connection(project_root, &extract, &resolved);
6933
6934 for table in [
6935 "files",
6936 "nodes",
6937 "file_dependencies",
6938 "dispatch_hints",
6939 "refs",
6940 "edges",
6941 ] {
6942 assert_eq!(table_rows(&reference, table), table_rows(&optimized, table),);
6943 }
6944 assert_eq!(
6945 backend_state_rows(&reference),
6946 backend_state_rows(&optimized),
6947 "backend freshness rows must match apart from updated_at"
6948 );
6949 assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
6950 }
6951
6952 #[test]
6953 fn cold_build_chunked_matches_unchunked_logical_rows() {
6954 let dir = tempdir().expect("temp dir");
6955 let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
6956 write_chunked_equivalence_fixture(&project_root);
6957 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
6958 assert!(
6959 files.len() > 6,
6960 "fixture should be large enough to split into multiple chunks"
6961 );
6962
6963 let unchunked = CallGraphStore::open(
6964 project_root.join(".store-unchunked"),
6965 project_root.to_path_buf(),
6966 )
6967 .expect("open unchunked store");
6968 let unchunked_stats = unchunked
6969 .cold_build_chunked(&files, 0)
6970 .expect("unchunked cold build");
6971
6972 let chunked = CallGraphStore::open(
6973 project_root.join(".store-chunked"),
6974 project_root.to_path_buf(),
6975 )
6976 .expect("open chunked store");
6977 let chunked_stats = chunked
6978 .cold_build_chunked(&files, 3)
6979 .expect("chunked cold build");
6980
6981 assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
6982 assert_eq!(
6983 unchunked.edge_snapshot().expect("unchunked edge snapshot"),
6984 chunked.edge_snapshot().expect("chunked edge snapshot"),
6985 "public edge snapshots must match"
6986 );
6987
6988 let dispatch_edges = {
6989 let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
6990 conn.query_row(
6991 "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
6992 [],
6993 |row| row.get::<_, i64>(0),
6994 )
6995 .expect("count dispatch edges")
6996 };
6997 assert!(
6998 dispatch_edges > 0,
6999 "fixture must exercise method-dispatch edge insertion"
7000 );
7001
7002 for table in [
7003 "edges",
7004 "refs",
7005 "nodes",
7006 "file_dependencies",
7007 "dispatch_hints",
7008 ] {
7009 assert_eq!(
7010 graph_table_rows(&unchunked, table),
7011 graph_table_rows(&chunked, table),
7012 "chunked cold build must match unchunked rows for {table}"
7013 );
7014 }
7015 assert_eq!(
7016 graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
7017 graph_table_rows_without(&chunked, "files", &["indexed_at"]),
7018 "files rows must match apart from indexed_at"
7019 );
7020 assert_eq!(
7021 graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
7022 graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
7023 "backend freshness rows must match apart from updated_at"
7024 );
7025
7026 let published_dir = project_root.join(".store-published");
7027 let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
7028 published_dir.clone(),
7029 project_root.to_path_buf(),
7030 &files,
7031 0,
7032 )
7033 .expect("published unchunked cold build");
7034 assert!(
7035 !CallGraphStore::needs_cold_build(&published_dir, &project_root)
7036 .expect("needs_cold_build after publish"),
7037 "published store should be ready"
7038 );
7039 let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
7040 published_dir,
7041 project_root.to_path_buf(),
7042 &files,
7043 3,
7044 )
7045 .expect("ensure with a different chunk size");
7046 assert!(
7047 rebuild_stats.is_none(),
7048 "changing callgraph_chunk_size must not affect store identity or force a rebuild"
7049 );
7050 }
7051
7052 #[test]
7059 #[ignore]
7060 fn bench_cold_build_chunk() {
7061 let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
7062 let chunk: usize = std::env::var("AFT_PERF_CHUNK")
7063 .expect("AFT_PERF_CHUNK")
7064 .parse()
7065 .expect("AFT_PERF_CHUNK must be a non-negative integer");
7066 let project_root = fs::canonicalize(&repo).expect("canonical repo root");
7067 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
7068 let dir = tempdir().expect("temp dir");
7069 let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
7070 .expect("open store");
7071 let started = Instant::now();
7072 let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
7073 let ms = started.elapsed().as_millis();
7074 println!(
7075 "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
7076 stats.files, stats.nodes, stats.refs, stats.edges
7077 );
7078 }
7079
7080 #[test]
7081 fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
7082 let dir = tempdir().expect("temp dir");
7083 let project_root = dir.path();
7084 let files =
7085 write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
7086 let index_path = project_root.join("src/index.ts");
7087
7088 let store = CallGraphStore::open(
7089 project_root.join(".store-incremental-barrel"),
7090 project_root.to_path_buf(),
7091 )
7092 .expect("open incremental store");
7093 store.cold_build(&files).expect("initial cold build");
7094
7095 {
7096 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
7097 let tx = conn.transaction().expect("dependency transaction");
7098 let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
7099 .expect("dependent refs for barrel");
7100 let selected_ref_ids = dependent_refs
7101 .iter()
7102 .map(|dependent_ref| dependent_ref.ref_id.clone())
7103 .collect::<BTreeSet<_>>();
7104 let mut threaded_ref_ids = BTreeSet::new();
7105 let mut threaded_by_caller = BTreeMap::new();
7106 record_dependent_refs(
7107 &mut threaded_ref_ids,
7108 &mut threaded_by_caller,
7109 dependent_refs,
7110 );
7111 let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
7112 .expect("old per-ref caller lookup");
7113
7114 assert_eq!(threaded_ref_ids, selected_ref_ids);
7115 assert_eq!(threaded_by_caller, old_by_caller);
7116 for consumer in [
7117 "src/consumer_a.ts",
7118 "src/consumer_b.ts",
7119 "src/consumer_c.ts",
7120 ] {
7121 assert!(
7122 threaded_by_caller.contains_key(consumer),
7123 "barrel edit should select dependent refs from {consumer}"
7124 );
7125 }
7126 }
7127
7128 fs::write(
7129 &index_path,
7130 "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
7131 )
7132 .expect("edit barrel");
7133 let stats = store
7134 .refresh_files(std::slice::from_ref(&index_path))
7135 .expect("incremental refresh");
7136 assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
7137 assert!(
7138 stats.dependency_selected_refs > 0,
7139 "barrel surface edit should select dependent refs"
7140 );
7141
7142 let cold_store = CallGraphStore::open(
7143 project_root.join(".store-cold-barrel"),
7144 project_root.to_path_buf(),
7145 )
7146 .expect("open cold rebuild store");
7147 cold_store
7148 .cold_build(&files)
7149 .expect("comparison cold build");
7150
7151 for table in [
7152 "nodes",
7153 "refs",
7154 "file_dependencies",
7155 "edges",
7156 "dispatch_hints",
7157 ] {
7158 assert_eq!(
7159 graph_table_rows(&store, table),
7160 graph_table_rows(&cold_store, table),
7161 "incremental refresh {table} rows must match cold rebuild"
7162 );
7163 }
7164 }
7165
7166 fn build_reference_connection(
7167 project_root: &Path,
7168 extract: &FileExtract,
7169 resolved: &ResolvedRef,
7170 ) -> Connection {
7171 let mut conn = Connection::open_in_memory().expect("open reference db");
7172 configure_build_connection(&conn).expect("configure reference db");
7173 initialize_schema(&conn).expect("initialize reference schema");
7174 {
7175 let tx = conn.transaction().expect("reference transaction");
7176 clear_tables(&tx).expect("reference clear");
7177 insert_meta(&tx).expect("reference meta");
7178 insert_file_extract(&tx, project_root, extract).expect("reference file extract");
7179 insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
7180 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
7181 .expect("reference dispatch edges");
7182 assert_eq!(supplemental, 0);
7183 tx.commit().expect("reference commit");
7184 }
7185 conn
7186 }
7187
7188 fn build_optimized_connection(
7189 project_root: &Path,
7190 extract: &FileExtract,
7191 resolved: &ResolvedRef,
7192 ) -> Connection {
7193 let mut conn = Connection::open_in_memory().expect("open optimized db");
7194 configure_build_connection(&conn).expect("configure optimized db");
7195 initialize_schema(&conn).expect("initialize optimized schema");
7196 {
7197 let tx = conn.transaction().expect("optimized transaction");
7198 clear_tables(&tx).expect("optimized clear");
7199 insert_meta(&tx).expect("optimized meta");
7200 drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
7201 {
7202 let workspace_root = project_root.display().to_string();
7203 let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
7204 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
7205 .expect("optimized file extract");
7206 insert_resolved_ref_prepared(&mut inserts, resolved)
7207 .expect("optimized resolved ref");
7208 }
7209 create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
7210 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
7211 .expect("optimized dispatch edges");
7212 assert_eq!(supplemental, 0);
7213 tx.commit().expect("optimized commit");
7214 }
7215 conn
7216 }
7217
7218 fn fixture_extract(project_root: &Path) -> FileExtract {
7219 let rel_path = "src/main.ts".to_string();
7220 let target_path = "src/helper.ts".to_string();
7221 let node = NodeRecord {
7222 id: "node-main".to_string(),
7223 file_path: rel_path.clone(),
7224 name: "main".to_string(),
7225 scoped_name: "main".to_string(),
7226 kind: "function".to_string(),
7227 range: Range {
7228 start_line: 0,
7229 start_col: 0,
7230 end_line: 0,
7231 end_col: 32,
7232 },
7233 range_ordinal: 0,
7234 signature: Some("export function main()".to_string()),
7235 exported: true,
7236 is_default_export: false,
7237 is_type_like: false,
7238 is_callgraph_entry_point: true,
7239 };
7240 let mut dependencies = BTreeSet::new();
7241 dependencies.insert(target_path.clone());
7242 let raw_ref = RawRef {
7243 ref_id: "ref-main-helper".to_string(),
7244 caller_node: Some(node.id.clone()),
7245 caller_symbol: Some(node.scoped_name.clone()),
7246 caller_file: rel_path.clone(),
7247 kind: "call".to_string(),
7248 short_name: Some("helper".to_string()),
7249 full_ref: Some("helper".to_string()),
7250 module_path: None,
7251 import_kind: None,
7252 local_name: Some("helper".to_string()),
7253 requested_name: Some("helper".to_string()),
7254 namespace_alias: None,
7255 wildcard: false,
7256 line: 1,
7257 byte_start: 24,
7258 byte_end: 32,
7259 dependencies,
7260 };
7261 FileExtract {
7262 abs_path: project_root.join(&rel_path),
7263 rel_path,
7264 freshness: FileFreshness {
7265 mtime: UNIX_EPOCH + Duration::from_secs(123),
7266 size: 40,
7267 content_hash: cache_freshness::hash_bytes(b"fixture source"),
7268 },
7269 lang: LangId::TypeScript,
7270 data: FileCallData {
7271 calls_by_symbol: HashMap::new(),
7272 exported_symbols: Vec::new(),
7273 symbol_metadata: HashMap::new(),
7274 default_export_symbol: None,
7275 import_block: ImportBlock::empty(),
7276 lang: LangId::TypeScript,
7277 },
7278 nodes: vec![node.clone()],
7279 raw_refs: vec![raw_ref],
7280 dispatch_hints: vec![DispatchHint {
7281 id: "dispatch-main-helper".to_string(),
7282 method_name: "helper".to_string(),
7283 caller_node: node.id,
7284 file: "src/main.ts".to_string(),
7285 line: 1,
7286 byte_start: 24,
7287 byte_end: 32,
7288 }],
7289 surface_fingerprint: "surface".to_string(),
7290 }
7291 }
7292
7293 fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
7294 let raw = extract.raw_refs[0].clone();
7295 let mut dependencies = raw.dependencies.clone();
7296 dependencies.insert("src/helper.ts".to_string());
7297 ResolvedRef {
7298 edge: Some(EdgeRecord {
7299 edge_id: "edge-main-helper".to_string(),
7300 source_node: raw.caller_node.clone().expect("caller node"),
7301 target_node: Some("node-helper".to_string()),
7302 target_file: "src/helper.ts".to_string(),
7303 target_symbol: "helper".to_string(),
7304 kind: "call".to_string(),
7305 line: raw.line,
7306 }),
7307 raw,
7308 status: "resolved".to_string(),
7309 target_node: Some("node-helper".to_string()),
7310 target_file: Some("src/helper.ts".to_string()),
7311 target_symbol: Some("helper".to_string()),
7312 dependencies,
7313 }
7314 }
7315
7316 fn write_chunked_equivalence_fixture(project_root: &Path) {
7317 let ts_dir = project_root.join("ts");
7318 fs::create_dir_all(&ts_dir).expect("create ts dir");
7319 fs::write(
7320 ts_dir.join("leaf.ts"),
7321 "export function leaf(value: number) {\n return value + 1;\n}\n",
7322 )
7323 .expect("write ts leaf");
7324 fs::write(
7325 ts_dir.join("mid.ts"),
7326 "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n return leaf(value);\n}\n",
7327 )
7328 .expect("write ts mid");
7329 fs::write(
7330 ts_dir.join("entry.ts"),
7331 "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n return mid(worker.run());\n}\n",
7332 )
7333 .expect("write ts entry");
7334 fs::write(
7335 ts_dir.join("worker.ts"),
7336 "export class Worker {\n run() {\n return 41;\n }\n}\n",
7337 )
7338 .expect("write ts worker");
7339 for idx in 0..4 {
7340 fs::write(
7341 ts_dir.join(format!("extra_{idx}.ts")),
7342 format!(
7343 "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n return entry(new Worker());\n}}\n"
7344 ),
7345 )
7346 .expect("write ts extra");
7347 }
7348
7349 let rust_dir = project_root.join("src");
7350 let commands_dir = rust_dir.join("commands");
7351 fs::create_dir_all(&commands_dir).expect("create rust commands dir");
7352 fs::write(
7353 rust_dir.join("context.rs"),
7354 r#"pub struct AppContext;
7355
7356impl AppContext {
7357 pub fn callgraph_store_for_ops(&self) -> usize {
7358 1
7359 }
7360}
7361"#,
7362 )
7363 .expect("write rust context");
7364 fs::write(
7365 rust_dir.join("lib.rs"),
7366 "pub mod context;\npub mod commands;\n",
7367 )
7368 .expect("write rust lib");
7369 fs::write(
7370 commands_dir.join("mod.rs"),
7371 "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
7372 )
7373 .expect("write rust commands mod");
7374 for name in ["callers", "impact", "trace_to"] {
7375 fs::write(
7376 commands_dir.join(format!("{name}.rs")),
7377 format!(
7378 r#"use crate::context::AppContext;
7379
7380pub fn handle_{name}(ctx: &AppContext) -> usize {{
7381 ctx.callgraph_store_for_ops()
7382}}
7383"#
7384 ),
7385 )
7386 .expect("write rust command");
7387 }
7388 }
7389
7390 fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
7391 let src_dir = project_root.join("src");
7392 fs::create_dir_all(&src_dir).expect("create src dir");
7393
7394 let target_path = src_dir.join("target.ts");
7395 fs::write(&target_path, "export function target() {\n return 1;\n}\n")
7396 .expect("write target");
7397
7398 let index_path = src_dir.join("index.ts");
7399 fs::write(&index_path, barrel_source).expect("write barrel");
7400
7401 let mut files = vec![target_path, index_path];
7402 for (file_name, function_name) in [
7403 ("consumer_a.ts", "consumerA"),
7404 ("consumer_b.ts", "consumerB"),
7405 ("consumer_c.ts", "consumerC"),
7406 ] {
7407 let path = src_dir.join(file_name);
7408 fs::write(
7409 &path,
7410 format!(
7411 "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n return target();\n}}\n"
7412 ),
7413 )
7414 .expect("write consumer");
7415 files.push(path);
7416 }
7417 files
7418 }
7419
7420 fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
7421 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
7422 table_rows(&conn, table)
7423 }
7424
7425 fn graph_table_rows_without(
7426 store: &CallGraphStore,
7427 table: &str,
7428 excluded_columns: &[&str],
7429 ) -> Vec<String> {
7430 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
7431 table_rows_without(&conn, table, excluded_columns)
7432 }
7433
7434 fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
7435 table_rows_without(conn, table, &[])
7436 }
7437
7438 fn table_rows_without(
7439 conn: &Connection,
7440 table: &str,
7441 excluded_columns: &[&str],
7442 ) -> Vec<String> {
7443 let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
7444 let columns: Vec<String> = conn
7445 .prepare(&format!("PRAGMA table_info({table})"))
7446 .expect("prepare table_info")
7447 .query_map([], |row| row.get::<_, String>(1))
7448 .expect("query table_info")
7449 .collect::<std::result::Result<Vec<String>, _>>()
7450 .expect("collect columns")
7451 .into_iter()
7452 .filter(|column| !excluded_columns.contains(column.as_str()))
7453 .collect();
7454 let sql = format!(
7455 "SELECT {} FROM {table} ORDER BY {}",
7456 columns.join(", "),
7457 columns.join(", ")
7458 );
7459 conn.prepare(&sql)
7460 .expect("prepare table rows")
7461 .query_map([], |row| row_to_strings(row, columns.len()))
7462 .expect("query table rows")
7463 .collect::<std::result::Result<_, _>>()
7464 .expect("collect table rows")
7465 }
7466
7467 fn assert_cold_build_stats_match_except_elapsed(
7468 expected: &ColdBuildStats,
7469 actual: &ColdBuildStats,
7470 ) {
7471 assert_eq!(actual.files, expected.files, "file counts must match");
7472 assert_eq!(actual.nodes, expected.nodes, "node counts must match");
7473 assert_eq!(actual.refs, expected.refs, "ref counts must match");
7474 assert_eq!(actual.edges, expected.edges, "edge counts must match");
7475 assert_eq!(
7476 actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
7477 expected
7478 .failed_files
7479 .iter()
7480 .cloned()
7481 .collect::<BTreeSet<_>>(),
7482 "failed file sets must match"
7483 );
7484 }
7485
7486 fn backend_state_rows(conn: &Connection) -> Vec<String> {
7487 conn.prepare(
7488 "SELECT backend, workspace_root, file_path, content_hash, status
7489 FROM backend_file_state
7490 ORDER BY backend, workspace_root, file_path, content_hash, status",
7491 )
7492 .expect("prepare backend rows")
7493 .query_map([], |row| row_to_strings(row, 5))
7494 .expect("query backend rows")
7495 .collect::<std::result::Result<_, _>>()
7496 .expect("collect backend rows")
7497 }
7498
7499 fn secondary_indexes(conn: &Connection) -> Vec<String> {
7500 let mut indexes = Vec::new();
7501 for table in [
7502 "files",
7503 "nodes",
7504 "refs",
7505 "file_dependencies",
7506 "edges",
7507 "dispatch_hints",
7508 "type_ref_names",
7509 "backend_file_state",
7510 "meta",
7511 ] {
7512 let sql = format!("PRAGMA index_list({table})");
7513 let mut stmt = conn.prepare(&sql).expect("prepare index list");
7514 let rows = stmt
7515 .query_map([], |row| row.get::<_, String>(1))
7516 .expect("query index list");
7517 for name in rows {
7518 let name = name.expect("index name");
7519 if name.starts_with("idx_") {
7520 indexes.push(format!("{table}:{name}"));
7521 }
7522 }
7523 }
7524 indexes.sort();
7525 indexes
7526 }
7527
7528 fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
7529 let mut values = Vec::with_capacity(len);
7530 for index in 0..len {
7531 let value = row.get_ref(index)?;
7532 values.push(match value {
7533 rusqlite::types::ValueRef::Null => "NULL".to_string(),
7534 rusqlite::types::ValueRef::Integer(value) => value.to_string(),
7535 rusqlite::types::ValueRef::Real(value) => value.to_string(),
7536 rusqlite::types::ValueRef::Text(value) => {
7537 String::from_utf8_lossy(value).into_owned()
7538 }
7539 rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
7540 });
7541 }
7542 Ok(values.join("\u{1f}"))
7543 }
7544}
7545
7546#[cfg(test)]
7547mod build_pool_tests {
7548 use super::build_pool_size;
7549
7550 #[test]
7551 fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
7552 let size = build_pool_size();
7553 assert!(size >= 1, "pool size must be at least 1");
7556 assert!(size <= 8, "pool size must be capped at 8, got {size}");
7557
7558 let cores = std::thread::available_parallelism()
7559 .map(|p| p.get())
7560 .unwrap_or(1);
7561 let expected = cores.div_ceil(2).clamp(1, 8);
7562 assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
7563 }
7564}
7565
7566#[cfg(test)]
7567mod method_dispatch_inference_tests {
7568 use super::*;
7569 use std::fs;
7570 use tempfile::tempdir;
7571
7572 #[test]
7573 fn java_field_receiver_type_selects_declared_class_method() {
7574 let source = r#"class EntryPoint {
7575 private UserService userService;
7576
7577 void handle() {
7578 userService.find();
7579 }
7580}
7581
7582class UserService {
7583 void find() {}
7584}
7585
7586class AuditService {
7587 void find() {}
7588}
7589"#;
7590 let dir = tempdir().expect("temp dir");
7591 let root = dir.path();
7592 write_fixture(root, "src/EntryPoint.java", source);
7593 let reference = reference(
7594 "java",
7595 "src/EntryPoint.java",
7596 "EntryPoint::handle",
7597 "userService",
7598 "find",
7599 line_of(source, "userService.find()"),
7600 );
7601 let mut cache = DispatchSourceCache::new();
7602
7603 let receiver_type =
7604 infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
7605 assert_eq!(receiver_type, "UserService");
7606
7607 let candidates = vec![
7608 method_candidate("audit", "AuditService::find"),
7609 method_candidate("user", "UserService::find"),
7610 ];
7611 let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
7612 .expect("type candidate");
7613 assert_eq!(selected.scoped_name, "UserService::find");
7614
7615 let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
7616 assert!(
7617 select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
7618 );
7619 }
7620
7621 #[test]
7622 fn kotlin_property_and_local_value_types_are_inferred() {
7623 let source = r#"class Handler {
7624 private val auditService: AuditService = AuditService()
7625
7626 fun handle() {
7627 auditService.find()
7628 val userService: UserService = UserService()
7629 userService.find()
7630 val billingService = BillingService()
7631 billingService.find()
7632 }
7633}
7634
7635class UserService { fun find() {} }
7636class AuditService { fun find() {} }
7637class BillingService { fun find() {} }
7638"#;
7639 let dir = tempdir().expect("temp dir");
7640 let root = dir.path();
7641 write_fixture(root, "src/Handler.kt", source);
7642 let mut cache = DispatchSourceCache::new();
7643
7644 let audit_ref = reference(
7645 "kotlin",
7646 "src/Handler.kt",
7647 "Handler::handle",
7648 "auditService",
7649 "find",
7650 line_of(source, "auditService.find()"),
7651 );
7652 assert_eq!(
7653 infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
7654 Some("AuditService")
7655 );
7656
7657 let user_ref = reference(
7658 "kotlin",
7659 "src/Handler.kt",
7660 "Handler::handle",
7661 "userService",
7662 "find",
7663 line_of(source, "userService.find()"),
7664 );
7665 assert_eq!(
7666 infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
7667 Some("UserService")
7668 );
7669
7670 let billing_ref = reference(
7671 "kotlin",
7672 "src/Handler.kt",
7673 "Handler::handle",
7674 "billingService",
7675 "find",
7676 line_of(source, "billingService.find()"),
7677 );
7678 assert_eq!(
7679 infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
7680 Some("BillingService")
7681 );
7682 }
7683
7684 #[test]
7685 fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
7686 let source = r#"struct Foo { void run(); };
7687struct PointerFoo { void run(); };
7688struct FactoryFoo { void run(); };
7689FactoryFoo makeFactoryFoo();
7690
7691void handle() {
7692 Foo foo;
7693 foo.run();
7694 PointerFoo* pointerFoo = nullptr;
7695 pointerFoo->run();
7696 auto factoryFoo = makeFactoryFoo();
7697 factoryFoo.run();
7698}
7699"#;
7700 let dir = tempdir().expect("temp dir");
7701 let root = dir.path();
7702 write_fixture(root, "src/fixture.cpp", source);
7703 let mut cache = DispatchSourceCache::new();
7704
7705 let foo_ref = reference(
7706 "cpp",
7707 "src/fixture.cpp",
7708 "handle",
7709 "foo",
7710 "run",
7711 line_of(source, "foo.run()"),
7712 );
7713 assert_eq!(
7714 infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
7715 Some("Foo")
7716 );
7717
7718 let pointer_ref = reference(
7719 "cpp",
7720 "src/fixture.cpp",
7721 "handle",
7722 "pointerFoo",
7723 "run",
7724 line_of(source, "pointerFoo->run()"),
7725 );
7726 assert_eq!(
7727 infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
7728 Some("PointerFoo")
7729 );
7730
7731 let factory_ref = reference(
7732 "cpp",
7733 "src/fixture.cpp",
7734 "handle",
7735 "factoryFoo",
7736 "run",
7737 line_of(source, "factoryFoo.run()"),
7738 );
7739 assert_eq!(
7740 infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
7741 Some("FactoryFoo")
7742 );
7743 }
7744
7745 #[test]
7746 fn unknown_java_receiver_still_uses_name_match_fallback() {
7747 let source = r#"class EntryPoint {
7748 void handle() {
7749 service.runSpecial();
7750 }
7751}
7752
7753class OnlyService {
7754 void runSpecial() {}
7755}
7756"#;
7757 let dir = tempdir().expect("temp dir");
7758 let root = dir.path();
7759 write_fixture(root, "src/EntryPoint.java", source);
7760 let reference = reference(
7761 "java",
7762 "src/EntryPoint.java",
7763 "EntryPoint::handle",
7764 "service",
7765 "runSpecial",
7766 line_of(source, "service.runSpecial()"),
7767 );
7768 let mut cache = DispatchSourceCache::new();
7769
7770 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
7771 let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
7772 let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
7773 assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
7774 }
7775
7776 fn reference(
7777 lang: &str,
7778 caller_file: &str,
7779 caller_symbol: &str,
7780 receiver: &str,
7781 method_name: &str,
7782 line: u32,
7783 ) -> NameMatchRef {
7784 NameMatchRef {
7785 ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
7786 caller_node: format!("{caller_symbol}:node"),
7787 caller_file: caller_file.to_string(),
7788 caller_symbol: caller_symbol.to_string(),
7789 caller_signature: None,
7790 receiver: receiver.to_string(),
7791 method_name: method_name.to_string(),
7792 colon_dispatch: false,
7793 line,
7794 lang: lang.to_string(),
7795 }
7796 }
7797
7798 fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
7799 NameMatchCandidate {
7800 node_id: node_id.to_string(),
7801 file_path: "src/targets.fixture".to_string(),
7802 scoped_name: scoped_name.to_string(),
7803 kind: "method".to_string(),
7804 }
7805 }
7806
7807 fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
7808 let path = root.join(rel_path);
7809 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
7810 fs::write(path, source).expect("write fixture");
7811 }
7812
7813 fn line_of(source: &str, needle: &str) -> u32 {
7814 source
7815 .lines()
7816 .position(|line| line.contains(needle))
7817 .map(|index| index as u32 + 1)
7818 .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
7819 }
7820}