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