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