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