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