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