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