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