1use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
9use crate::callgraph::{self, EdgeResolution, FileCallData, TraceToSymbolCandidate};
10use crate::context::SubcLifecycleAdmission;
11use crate::error::AftError;
12use crate::imports::{ImportForm, ImportGroup, ImportKind, ImportStatement};
13use crate::parser::{grammar_for, LangId};
14use crate::symbols::{Range, SymbolKind};
15use rayon::prelude::*;
16use rusqlite::{
17 params, params_from_iter, Connection, OpenFlags, OptionalExtension, Statement, Transaction,
18};
19use std::collections::{hash_map::Entry, BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
20use std::fmt;
21use std::io::Read;
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
24use std::sync::{Arc, Condvar, Mutex, OnceLock};
25use std::thread::JoinHandle;
26use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
27use tree_sitter::{Node, Parser};
28
29const SCHEMA_VERSION: i64 = 1;
30const BACKEND_TREESITTER: &str = "treesitter";
31const PROVENANCE_TREESITTER: &str = "treesitter+resolver";
32const PROVENANCE_NAME_MATCH: &str = "name_match";
33const PROVENANCE_TYPE_MATCH: &str = "type_match";
34const NAME_MATCH_SCORE_THRESHOLD: f64 = 2.0;
35const TOP_LEVEL_SYMBOL: &str = "<top-level>";
36const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
37const MIGRATION_MANIFEST_VERSION: u32 = 1;
38const MIGRATION_GENERATION_TAG: &str = ".migrated.";
39const MIGRATION_BACKUP_PAGES_PER_STEP: i32 = 128;
40const MIGRATION_BACKUP_RETRY_BUDGET: usize = 25;
41const MIGRATION_BACKUP_WALL_CLOCK_BUDGET: Duration = Duration::from_secs(10);
42const SQLITE_FILE_SET_SUFFIXES: &[&str] = &["", "-wal", "-shm", "-journal"];
43const MARKED_GENERATION_RETENTION_TTL: Duration = Duration::from_secs(6 * 60 * 60);
48const REFRESH_WORKER_WARN_AFTER: Duration = Duration::from_secs(5);
49const REFRESH_WORKER_FINAL_AFTER: Duration = Duration::from_secs(30);
50pub const REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET: Duration = Duration::from_millis(100);
51
52type ColdBuildSwapObserver = dyn Fn(&Path, &Path) + Send + Sync + 'static;
53#[cfg(test)]
54type ColdBuildBeforePublishObserver = dyn Fn() + Send + Sync + 'static;
55thread_local! {
62 static COLD_BUILD_SWAP_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSwapObserver>>> =
63 const { std::cell::RefCell::new(None) };
64 #[cfg(test)]
65 static COLD_BUILD_BEFORE_PUBLISH_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildBeforePublishObserver>>> =
66 const { std::cell::RefCell::new(None) };
67 static MIGRATION_AVAILABLE_DISK_OVERRIDE: std::cell::RefCell<Option<u64>> =
68 const { std::cell::RefCell::new(None) };
69 static MIGRATION_FAIL_AFTER_TEMP_COPY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
70 static MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED: std::cell::Cell<bool> =
71 const { std::cell::Cell::new(false) };
72 static PUBLISH_ADMISSION: std::cell::RefCell<Option<(crate::root_cache::ArtifactPublishEpoch, u64)>> =
73 const { std::cell::RefCell::new(None) };
74 static REFRESH_COMMIT_ADMISSION: std::cell::RefCell<Option<(SubcLifecycleAdmission, Arc<std::sync::atomic::AtomicU64>, u64)>> =
75 const { std::cell::RefCell::new(None) };
76}
77
78mod dead_code_projection;
79pub use dead_code_projection::project_dead_code_snapshot;
80
81#[doc(hidden)]
82pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
83 COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
84}
85
86#[cfg(test)]
87fn set_cold_build_before_publish_observer(observer: Option<Arc<ColdBuildBeforePublishObserver>>) {
88 COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
89}
90
91#[cfg(test)]
92fn notify_cold_build_before_publish_observer() {
93 let observer = COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| slot.borrow().clone());
94 if let Some(observer) = observer {
95 observer();
96 }
97}
98
99#[cfg(not(test))]
100fn notify_cold_build_before_publish_observer() {}
101
102#[doc(hidden)]
103pub fn set_legacy_migration_available_disk_for_test(bytes: Option<u64>) {
104 MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow_mut() = bytes);
105}
106
107#[doc(hidden)]
108pub fn set_legacy_migration_fail_after_temp_copy_for_test(enabled: bool) {
109 MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.set(enabled));
110}
111
112#[doc(hidden)]
113pub fn set_legacy_migration_backup_budget_exhausted_for_test(enabled: bool) {
114 MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.set(enabled));
115}
116
117struct PublishAdmissionGuard {
118 previous: Option<(crate::root_cache::ArtifactPublishEpoch, u64)>,
119}
120
121impl Drop for PublishAdmissionGuard {
122 fn drop(&mut self) {
123 PUBLISH_ADMISSION.with(|slot| {
124 *slot.borrow_mut() = self.previous.take();
125 });
126 }
127}
128
129pub(crate) fn with_publish_epoch<R>(
130 epoch: crate::root_cache::ArtifactPublishEpoch,
131 expected: u64,
132 run: impl FnOnce() -> R,
133) -> R {
134 let previous = PUBLISH_ADMISSION.with(|slot| slot.replace(Some((epoch, expected))));
135 let _guard = PublishAdmissionGuard { previous };
136 run()
137}
138
139fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
140 let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
141 match admission {
142 Some((epoch, expected)) => epoch
143 .run_if_current(expected, publish)
144 .unwrap_or(Err(CallGraphStoreError::Superseded)),
145 None => publish(),
146 }
147}
148
149struct RefreshCommitAdmissionGuard {
150 previous: Option<(
151 SubcLifecycleAdmission,
152 Arc<std::sync::atomic::AtomicU64>,
153 u64,
154 )>,
155}
156
157impl Drop for RefreshCommitAdmissionGuard {
158 fn drop(&mut self) {
159 REFRESH_COMMIT_ADMISSION.with(|slot| {
160 *slot.borrow_mut() = self.previous.take();
161 });
162 }
163}
164
165fn with_refresh_commit_admission<R>(
166 lifecycle: SubcLifecycleAdmission,
167 generation_flag: Arc<std::sync::atomic::AtomicU64>,
168 expected_generation: u64,
169 run: impl FnOnce() -> R,
170) -> R {
171 let previous = REFRESH_COMMIT_ADMISSION
172 .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
173 let _guard = RefreshCommitAdmissionGuard { previous };
174 run()
175}
176
177fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
178 let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
179 let commit = || {
180 publish_if_current(|| {
181 tx.commit()?;
182 Ok(())
183 })
184 };
185 match admission {
186 Some((lifecycle, generation_flag, expected_generation)) => lifecycle
187 .run_if_current(generation_flag.as_ref(), expected_generation, commit)
188 .unwrap_or(Err(CallGraphStoreError::Superseded)),
189 None => commit(),
190 }
191}
192
193fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
194 let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
195 if let Some(observer) = observer {
196 observer(temp_path, target_path);
197 }
198}
199
200#[derive(Debug)]
201pub enum CallGraphStoreError {
202 Io(std::io::Error),
203 Sqlite(rusqlite::Error),
204 Json(serde_json::Error),
205 Aft(AftError),
206 Lock(crate::fs_lock::AcquireError),
207 MissingCallerData { file: String },
208 Unavailable(String),
209 Superseded,
210 StaleFiles(Vec<String>),
211}
212
213impl fmt::Display for CallGraphStoreError {
214 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
215 match self {
216 Self::Io(error) => write!(formatter, "I/O error: {error}"),
217 Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
218 Self::Json(error) => write!(formatter, "json error: {error}"),
219 Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
220 Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
221 Self::MissingCallerData { file } => {
222 write!(formatter, "missing extracted caller data for {file}")
223 }
224 Self::Unavailable(message) => {
225 write!(formatter, "callgraph store unavailable: {message}")
226 }
227 Self::Superseded => {
228 write!(formatter, "callgraph store build superseded before publish")
229 }
230 Self::StaleFiles(files) => {
231 write!(
232 formatter,
233 "callgraph store has stale files: {}",
234 files.join(", ")
235 )
236 }
237 }
238 }
239}
240
241impl std::error::Error for CallGraphStoreError {}
242
243impl From<std::io::Error> for CallGraphStoreError {
244 fn from(error: std::io::Error) -> Self {
245 Self::Io(error)
246 }
247}
248
249impl From<rusqlite::Error> for CallGraphStoreError {
250 fn from(error: rusqlite::Error) -> Self {
251 Self::Sqlite(error)
252 }
253}
254
255impl From<serde_json::Error> for CallGraphStoreError {
256 fn from(error: serde_json::Error) -> Self {
257 Self::Json(error)
258 }
259}
260
261impl From<AftError> for CallGraphStoreError {
262 fn from(error: AftError) -> Self {
263 Self::Aft(error)
264 }
265}
266
267impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
268 fn from(error: crate::fs_lock::AcquireError) -> Self {
269 Self::Lock(error)
270 }
271}
272
273pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
274
275pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
279
280#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
281pub struct CallGraphStoreOptions {
282 pub enabled: bool,
283}
284
285pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
286
287type WorkspaceCratePrefixes = HashMap<String, String>;
288
289#[derive(Clone, Debug, Default)]
290struct WorkspaceCratePrefixCache(Arc<OnceLock<WorkspaceCratePrefixes>>);
291
292const REFRESH_WORKSPACE_CACHE_ROOT_CAP: usize = 128;
293
294pub(crate) fn invalidates_workspace_crate_prefix_cache(path: &Path) -> bool {
295 path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")
296}
297
298#[derive(Clone, Debug, Hash, PartialEq, Eq)]
299struct RefreshRoot {
300 callgraph_dir: PathBuf,
301 project_root: PathBuf,
302}
303
304#[derive(Clone)]
305pub(crate) struct CallgraphRefreshTicket {
306 lifecycle: SubcLifecycleAdmission,
307 generation_flag: Arc<std::sync::atomic::AtomicU64>,
308 expected_generation: u64,
309 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
310 expected_publish_epoch: u64,
311}
312
313impl CallgraphRefreshTicket {
314 pub(crate) fn new(
315 lifecycle: SubcLifecycleAdmission,
316 generation_flag: Arc<std::sync::atomic::AtomicU64>,
317 expected_generation: u64,
318 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
319 expected_publish_epoch: u64,
320 ) -> Self {
321 Self {
322 lifecycle,
323 generation_flag,
324 expected_generation,
325 publish_epoch,
326 expected_publish_epoch,
327 }
328 }
329
330 fn is_current(&self) -> bool {
331 self.lifecycle
332 .is_current(self.generation_flag.as_ref(), self.expected_generation)
333 && self.publish_epoch.current() == self.expected_publish_epoch
334 }
335}
336
337#[derive(Clone)]
338struct RefreshBatch {
339 root: RefreshRoot,
340 paths: BTreeSet<PathBuf>,
341 pending_sinks: Vec<PendingCallGraphStorePaths>,
342 ticket: Option<CallgraphRefreshTicket>,
343}
344
345impl RefreshBatch {
346 fn defer(&self) {
347 for sink in &self.pending_sinks {
348 sink.lock().extend(self.paths.iter().cloned());
349 }
350 }
351
352 fn merge(
353 &mut self,
354 paths: impl IntoIterator<Item = PathBuf>,
355 sink: PendingCallGraphStorePaths,
356 ticket: Option<CallgraphRefreshTicket>,
357 ) {
358 self.paths.extend(paths);
359 if ticket.is_some() {
360 self.ticket = ticket;
361 }
362 if !self
363 .pending_sinks
364 .iter()
365 .any(|existing| Arc::ptr_eq(existing, &sink))
366 {
367 self.pending_sinks.push(sink);
368 }
369 }
370}
371
372#[derive(Default)]
373struct RefreshQueue {
374 order: VecDeque<RefreshRoot>,
375 queued: HashMap<RefreshRoot, RefreshBatch>,
376 active: Option<RefreshBatch>,
377 shutdown_requested: bool,
378}
379
380struct RefreshWorkerShared {
381 queue: Mutex<RefreshQueue>,
382 wake: Condvar,
383}
384
385struct RefreshWorker {
386 shared: Arc<RefreshWorkerShared>,
387 thread: Mutex<Option<JoinHandle<()>>>,
388}
389
390struct RefreshWorkerWatchdog {
391 first_path: PathBuf,
392 batch_len: usize,
393 started: Instant,
394}
395
396impl RefreshWorkerWatchdog {
397 fn start(paths: &[PathBuf]) -> Self {
398 Self {
399 first_path: paths
400 .first()
401 .expect("non-empty callgraph refresh batch has a first path")
402 .clone(),
403 batch_len: paths.len(),
404 started: Instant::now(),
405 }
406 }
407}
408
409impl Drop for RefreshWorkerWatchdog {
410 fn drop(&mut self) {
411 let elapsed = self.started.elapsed();
412 if elapsed < REFRESH_WORKER_WARN_AFTER {
413 return;
414 }
415 let path = if self.batch_len == 1 {
416 self.first_path.display().to_string()
417 } else {
418 format!(
419 "{} (+{} paths)",
420 self.first_path.display(),
421 self.batch_len - 1
422 )
423 };
424 log::warn!(
425 "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
426 path,
427 elapsed.as_millis()
428 );
429 if elapsed >= REFRESH_WORKER_FINAL_AFTER {
430 log::warn!(
431 "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
432 path,
433 elapsed.as_millis()
434 );
435 }
436 }
437}
438
439impl RefreshWorker {
440 fn spawn() -> Arc<Self> {
441 let shared = Arc::new(RefreshWorkerShared {
442 queue: Mutex::new(RefreshQueue::default()),
443 wake: Condvar::new(),
444 });
445 let thread_shared = Arc::clone(&shared);
446 let thread = std::thread::Builder::new()
447 .name("aft-callgraph-refresh".to_string())
448 .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
449 .expect("failed to spawn callgraph refresh worker");
450 Arc::new(Self {
451 shared,
452 thread: Mutex::new(Some(thread)),
453 })
454 }
455
456 fn enqueue(
457 &self,
458 root: RefreshRoot,
459 paths: Vec<PathBuf>,
460 pending_sink: PendingCallGraphStorePaths,
461 ticket: Option<CallgraphRefreshTicket>,
462 ) -> bool {
463 let mut queue = self
464 .shared
465 .queue
466 .lock()
467 .expect("callgraph refresh queue mutex poisoned");
468 if queue.shutdown_requested {
469 pending_sink.lock().extend(paths);
470 return false;
471 }
472 if let Some(batch) = queue.queued.get_mut(&root) {
473 batch.merge(paths, pending_sink, ticket);
474 } else {
475 queue.order.push_back(root.clone());
476 queue.queued.insert(
477 root.clone(),
478 RefreshBatch {
479 root,
480 paths: paths.into_iter().collect(),
481 pending_sinks: vec![pending_sink],
482 ticket,
483 },
484 );
485 }
486 self.shared.wake.notify_one();
487 true
488 }
489
490 fn shutdown_with_budget(&self, budget: Duration) -> bool {
491 let deadline = Instant::now() + budget;
492 let mut queue = self
493 .shared
494 .queue
495 .lock()
496 .expect("callgraph refresh queue mutex poisoned");
497 queue.shutdown_requested = true;
498 self.shared.wake.notify_one();
499 while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
500 let remaining = deadline.saturating_duration_since(Instant::now());
501 let (next, _) = self
502 .shared
503 .wake
504 .wait_timeout(queue, remaining)
505 .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
506 queue = next;
507 }
508 let drained = queue.active.is_none() && queue.order.is_empty();
509 if !drained {
510 if let Some(active) = queue.active.as_ref() {
511 active.defer();
512 }
513 for batch in queue.queued.values() {
514 batch.defer();
515 }
516 queue.order.clear();
517 queue.queued.clear();
518 }
519 drop(queue);
520
521 if drained {
522 if let Some(thread) = self
523 .thread
524 .lock()
525 .expect("callgraph refresh worker thread mutex poisoned")
526 .take()
527 {
528 let _ = thread.join();
529 }
530 }
531 drained
532 }
533}
534
535static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
536
537pub fn enqueue_callgraph_store_refresh(
538 callgraph_dir: PathBuf,
539 project_root: PathBuf,
540 paths: Vec<PathBuf>,
541 pending_sink: PendingCallGraphStorePaths,
542) -> bool {
543 enqueue_callgraph_store_refresh_inner(callgraph_dir, project_root, paths, pending_sink, None)
544}
545
546pub(crate) fn enqueue_callgraph_store_refresh_fenced(
547 callgraph_dir: PathBuf,
548 project_root: PathBuf,
549 paths: Vec<PathBuf>,
550 pending_sink: PendingCallGraphStorePaths,
551 ticket: CallgraphRefreshTicket,
552) -> bool {
553 enqueue_callgraph_store_refresh_inner(
554 callgraph_dir,
555 project_root,
556 paths,
557 pending_sink,
558 Some(ticket),
559 )
560}
561
562fn enqueue_callgraph_store_refresh_inner(
563 callgraph_dir: PathBuf,
564 project_root: PathBuf,
565 paths: Vec<PathBuf>,
566 pending_sink: PendingCallGraphStorePaths,
567 ticket: Option<CallgraphRefreshTicket>,
568) -> bool {
569 if paths.is_empty() {
570 return true;
571 }
572 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
573 let worker = {
574 let mut worker = slot
575 .lock()
576 .expect("callgraph refresh worker mutex poisoned");
577 Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
578 };
579 worker.enqueue(
580 RefreshRoot {
581 callgraph_dir,
582 project_root,
583 },
584 paths,
585 pending_sink,
586 ticket,
587 )
588}
589
590pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
591 flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
592}
593
594#[doc(hidden)]
595pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
596 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
597 let worker = slot
598 .lock()
599 .expect("callgraph refresh worker mutex poisoned")
600 .clone();
601 let Some(worker) = worker else {
602 return true;
603 };
604 let drained = worker.shutdown_with_budget(budget);
605 if drained {
606 let mut current = slot
607 .lock()
608 .expect("callgraph refresh worker mutex poisoned");
609 if current
610 .as_ref()
611 .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
612 {
613 *current = None;
614 }
615 }
616 drained
617}
618
619fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
620 let mut workspace_crate_prefixes = HashMap::new();
623 loop {
624 let batch = {
625 let mut queue = shared
626 .queue
627 .lock()
628 .expect("callgraph refresh queue mutex poisoned");
629 loop {
630 if let Some(root) = queue.order.pop_front() {
631 let batch = queue
632 .queued
633 .remove(&root)
634 .expect("queued callgraph refresh root has a batch");
635 queue.active = Some(batch.clone());
636 break batch;
637 }
638 if queue.shutdown_requested {
639 return;
640 }
641 queue = shared
642 .wake
643 .wait(queue)
644 .expect("callgraph refresh queue mutex poisoned while waiting");
645 }
646 };
647
648 process_callgraph_refresh_batch(&batch, &mut workspace_crate_prefixes);
649
650 let mut queue = shared
651 .queue
652 .lock()
653 .expect("callgraph refresh queue mutex poisoned");
654 queue.active = None;
655 shared.wake.notify_all();
656 }
657}
658
659fn process_callgraph_refresh_batch(
660 batch: &RefreshBatch,
661 workspace_crate_prefixes: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
662) {
663 if batch
667 .paths
668 .iter()
669 .any(|path| invalidates_workspace_crate_prefix_cache(path))
670 {
671 workspace_crate_prefixes.remove(&batch.root);
672 }
673
674 if batch
675 .ticket
676 .as_ref()
677 .is_some_and(|ticket| !ticket.is_current())
678 {
679 batch.defer();
682 return;
683 }
684 let paths = batch
685 .paths
686 .iter()
687 .filter(|path| crate::parser::detect_language(path).is_some())
688 .cloned()
689 .collect::<Vec<_>>();
690 if paths.is_empty() {
691 return;
692 }
693 let workspace_crate_prefix_cache =
694 workspace_crate_prefix_cache_for_root(workspace_crate_prefixes, &batch.root);
695 let _watchdog = RefreshWorkerWatchdog::start(&paths);
696 let store = match CallGraphStore::open_ready(
697 batch.root.callgraph_dir.clone(),
698 batch.root.project_root.clone(),
699 ) {
700 Ok(Some(store)) => store,
701 Ok(None) => {
702 batch.defer();
703 return;
704 }
705 Err(error) => {
706 batch.defer();
707 crate::slog_warn!(
708 "callgraph store writer open failed during refresh; deferred paths: {}",
709 error
710 );
711 return;
712 }
713 };
714
715 let test_seam = refresh_worker_test_seam(&batch.root.project_root);
716 note_refresh_worker_call_for_test(&batch.root.project_root);
717 #[cfg(test)]
718 if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
719 let _ = gate.held_tx.send(());
720 let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
721 }
722 if !test_seam.delay.is_zero() {
723 std::thread::sleep(test_seam.delay);
724 }
725 if batch
726 .ticket
727 .as_ref()
728 .is_some_and(|ticket| !ticket.is_current())
729 {
730 batch.defer();
731 return;
732 }
733 let refresh_result = if test_seam.fail_refresh {
734 Err(CallGraphStoreError::Unavailable(
735 "injected refresh worker failure".to_string(),
736 ))
737 } else if let Some(ticket) = &batch.ticket {
738 with_publish_epoch(
739 ticket.publish_epoch.clone(),
740 ticket.expected_publish_epoch,
741 || {
742 with_refresh_commit_admission(
743 ticket.lifecycle.clone(),
744 Arc::clone(&ticket.generation_flag),
745 ticket.expected_generation,
746 || {
747 store
748 .refresh_files_with_workspace_crate_prefix_cache(
749 &paths,
750 workspace_crate_prefix_cache.clone(),
751 )
752 .map(|_| ())
753 },
754 )
755 },
756 )
757 } else {
758 store
759 .refresh_files_with_workspace_crate_prefix_cache(
760 &paths,
761 workspace_crate_prefix_cache.clone(),
762 )
763 .map(|_| ())
764 };
765 if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
766 batch.defer();
770 return;
771 }
772 if let Err(error) = refresh_result {
773 crate::slog_warn!("callgraph store refresh failed: {}", error);
774 match store.mark_files_stale(&paths) {
775 Ok(marked) => {
776 note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
777 crate::slog_warn!(
778 "marked {} callgraph store file(s) stale after refresh failure",
779 marked.len()
780 );
781 }
782 Err(mark_error) => crate::slog_warn!(
783 "failed to mark callgraph store files stale after refresh failure: {}",
784 mark_error
785 ),
786 }
787 } else {
788 crate::logging::note_callgraph_invalidations(paths.len());
789 }
790}
791
792fn workspace_crate_prefix_cache_for_root(
793 caches: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
794 root: &RefreshRoot,
795) -> WorkspaceCratePrefixCache {
796 if !caches.contains_key(root) && caches.len() >= REFRESH_WORKSPACE_CACHE_ROOT_CAP {
797 if let Some(evicted) = caches.keys().next().cloned() {
799 caches.remove(&evicted);
800 }
801 }
802 caches.entry(root.clone()).or_default().clone()
803}
804
805#[derive(Clone, Copy, Default)]
806struct RefreshWorkerTestSeam {
807 delay: Duration,
808 fail_refresh: bool,
809 refresh_calls: usize,
810 stale_marks: usize,
811}
812
813static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
814 OnceLock::new();
815
816#[cfg(test)]
817struct RefreshWorkerTestGate {
818 held_tx: crossbeam_channel::Sender<()>,
819 release_rx: crossbeam_channel::Receiver<()>,
820}
821
822#[cfg(test)]
823static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
824 OnceLock::new();
825
826#[cfg(test)]
827fn install_refresh_worker_test_gate(
828 project_root: PathBuf,
829) -> (
830 crossbeam_channel::Receiver<()>,
831 crossbeam_channel::Sender<()>,
832) {
833 let (held_tx, held_rx) = crossbeam_channel::bounded(1);
834 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
835 REFRESH_WORKER_TEST_GATES
836 .get_or_init(|| Mutex::new(HashMap::new()))
837 .lock()
838 .expect("callgraph refresh test gate mutex poisoned")
839 .insert(
840 project_root,
841 RefreshWorkerTestGate {
842 held_tx,
843 release_rx,
844 },
845 );
846 (held_rx, release_tx)
847}
848
849#[cfg(test)]
850fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
851 REFRESH_WORKER_TEST_GATES
852 .get_or_init(|| Mutex::new(HashMap::new()))
853 .lock()
854 .expect("callgraph refresh test gate mutex poisoned")
855 .remove(project_root)
856}
857
858fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
859 let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
860 return RefreshWorkerTestSeam::default();
861 };
862 seams
863 .lock()
864 .expect("callgraph refresh test seam mutex poisoned")
865 .get(project_root)
866 .copied()
867 .unwrap_or_default()
868}
869
870fn note_refresh_worker_call_for_test(project_root: &Path) {
871 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
872 if let Some(seam) = seams
873 .lock()
874 .expect("callgraph refresh test seam mutex poisoned")
875 .get_mut(project_root)
876 {
877 seam.refresh_calls += 1;
878 }
879 }
880}
881
882fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
883 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
884 if let Some(seam) = seams
885 .lock()
886 .expect("callgraph refresh test seam mutex poisoned")
887 .get_mut(project_root)
888 {
889 seam.stale_marks += 1;
890 }
891 }
892}
893
894#[doc(hidden)]
895pub fn set_callgraph_refresh_worker_test_seam(
896 project_root: PathBuf,
897 delay: Duration,
898 fail_refresh: bool,
899) {
900 REFRESH_WORKER_TEST_SEAMS
901 .get_or_init(|| Mutex::new(HashMap::new()))
902 .lock()
903 .expect("callgraph refresh test seam mutex poisoned")
904 .insert(
905 project_root,
906 RefreshWorkerTestSeam {
907 delay,
908 fail_refresh,
909 ..RefreshWorkerTestSeam::default()
910 },
911 );
912}
913
914#[doc(hidden)]
915pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
916 let seam = refresh_worker_test_seam(project_root);
917 (seam.refresh_calls, seam.stale_marks)
918}
919
920#[doc(hidden)]
921pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
922 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
923 seams
924 .lock()
925 .expect("callgraph refresh test seam mutex poisoned")
926 .remove(project_root);
927 }
928}
929
930#[derive(Debug)]
931pub struct CallGraphStore {
932 project_root: PathBuf,
933 project_key: String,
934 sqlite_path: PathBuf,
938 publication_dir: PathBuf,
942 legacy_fallback: bool,
946 generation: Option<String>,
951 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
952 read_marker: Option<crate::root_cache::ReadMarker>,
953 database_ready: AtomicBool,
956 conn: Mutex<Connection>,
957}
958
959#[derive(Debug)]
960pub struct ReadonlyCallGraphStore {
961 inner: CallGraphStore,
962}
963
964pub trait CallGraphRead {
965 fn project_root(&self) -> &Path;
966 fn project_key(&self) -> &str;
967 fn sqlite_path(&self) -> &Path;
968 fn is_current(&self) -> bool;
969 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
970 fn indexed_file_count(&self) -> Result<usize>;
971 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
972 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
973 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
974 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
975 fn direct_caller_counts_of(
976 &self,
977 targets: &[(String, String)],
978 ) -> Result<HashMap<(String, String), usize>>;
979 fn outgoing_calls_for_symbols(
980 &self,
981 sources: &[(String, String)],
982 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
983 fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
984 -> Result<StoreCallersResult>;
985 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
986 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
987 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
988 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
989 fn call_tree(
990 &self,
991 file_rel: &Path,
992 symbol: &str,
993 depth: usize,
994 ) -> Result<callgraph::CallTreeNode>;
995 fn trace_to(
996 &self,
997 file_rel: &Path,
998 symbol: &str,
999 max_depth: usize,
1000 ) -> Result<callgraph::TraceToResult>;
1001 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
1002 fn trace_to_symbol(
1003 &self,
1004 file_rel: &Path,
1005 symbol: &str,
1006 to_symbol: &str,
1007 to_file: Option<&Path>,
1008 max_depth: usize,
1009 ) -> Result<callgraph::TraceToSymbolResult>;
1010}
1011
1012#[derive(Debug, Clone, PartialEq, Eq)]
1013enum OpenRootRepair {
1014 None,
1015 ReRooted,
1016 NeedsRebuild {
1017 previous_roots: Vec<String>,
1018 current_root: String,
1019 reason: String,
1020 },
1021}
1022
1023struct OpenedStore {
1024 store: CallGraphStore,
1025 root_repair: OpenRootRepair,
1026}
1027
1028#[derive(Clone, Debug)]
1029struct LegacyCallgraphPartition {
1030 harness: String,
1031 dir: PathBuf,
1032 key: String,
1033 bytes: u64,
1034 freshness: Option<SystemTime>,
1035}
1036
1037#[derive(Clone, Debug)]
1038struct LegacyCallgraphTarget {
1039 partition: LegacyCallgraphPartition,
1040 sqlite_path: PathBuf,
1041 generation: Option<String>,
1042 source_bytes: u64,
1043 source_blake3: String,
1044}
1045
1046#[derive(Clone, Debug)]
1047struct SourceFingerprint {
1048 bytes: u64,
1049 blake3: String,
1050}
1051
1052#[derive(Clone, Debug)]
1053struct PublishedLegacyMigration {
1054 generation: String,
1055 migrated_bytes: u64,
1056}
1057
1058#[derive(Debug, Clone)]
1059pub struct ColdBuildStats {
1060 pub files: usize,
1061 pub nodes: usize,
1062 pub refs: usize,
1063 pub edges: usize,
1064 pub failed_files: Vec<String>,
1065 pub elapsed_ms: u128,
1066}
1067
1068#[derive(Debug, Clone)]
1069pub struct IncrementalStats {
1070 pub changed_files: Vec<String>,
1071 pub surface_changed: Vec<String>,
1072 pub deleted_files: Vec<String>,
1073 pub dependency_selected_refs: usize,
1074 pub refreshed_own_files: usize,
1075}
1076
1077#[doc(hidden)]
1079#[derive(Debug, Clone, Default, PartialEq, Eq)]
1080pub struct RefreshFilesProfile {
1081 pub parse: Duration,
1082 pub dependency_selection: Duration,
1083 pub row_deletes: Duration,
1084 pub row_inserts: Duration,
1085 pub dependent_parse: Duration,
1086 pub index_load: Duration,
1087 pub ref_resolution: Duration,
1088 pub method_dispatch: Duration,
1089 pub commit: Duration,
1090 pub total: Duration,
1091}
1092
1093impl RefreshFilesProfile {
1094 pub fn report(&self) -> String {
1095 format!(
1096 "parse={}ms dependency_selection={}ms row_deletes={}ms row_inserts={}ms dependent_parse={}ms index_load={}ms ref_resolution={}ms method_dispatch={}ms commit={}ms total={}ms",
1097 self.parse.as_millis(),
1098 self.dependency_selection.as_millis(),
1099 self.row_deletes.as_millis(),
1100 self.row_inserts.as_millis(),
1101 self.dependent_parse.as_millis(),
1102 self.index_load.as_millis(),
1103 self.ref_resolution.as_millis(),
1104 self.method_dispatch.as_millis(),
1105 self.commit.as_millis(),
1106 self.total.as_millis(),
1107 )
1108 }
1109}
1110
1111#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1112pub struct StoredEdge {
1113 pub source_file: String,
1114 pub source_symbol: String,
1115 pub target_file: String,
1116 pub target_symbol: String,
1117 pub kind: String,
1118 pub line: u32,
1119}
1120
1121#[derive(Debug, Clone, PartialEq, Eq)]
1122pub struct StoreNode {
1123 node_id: String,
1124 pub file: String,
1125 pub symbol: String,
1126 pub name: String,
1127 pub kind: String,
1128 pub line: u32,
1129 pub end_line: u32,
1130 pub signature: Option<String>,
1131 pub exported: bool,
1132 pub is_entry_point: bool,
1133 pub lang: LangId,
1134}
1135
1136#[cfg(test)]
1137impl StoreNode {
1138 pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
1139 Self {
1140 node_id: format!("{file}:{symbol}"),
1141 file: file.to_string(),
1142 symbol: symbol.to_string(),
1143 name: symbol.to_string(),
1144 kind: "function".to_string(),
1145 line: 1,
1146 end_line: 1,
1147 signature: None,
1148 exported: is_entry_point,
1149 is_entry_point,
1150 lang: LangId::TypeScript,
1151 }
1152 }
1153}
1154
1155#[derive(Debug, Clone, PartialEq, Eq)]
1156pub struct StoreCallSite {
1157 pub caller: StoreNode,
1158 pub target_file: String,
1159 pub target_symbol: String,
1160 pub target: Option<StoreNode>,
1161 pub line: u32,
1162 pub byte_start: usize,
1163 pub byte_end: usize,
1164 pub resolved: bool,
1165 pub provenance: String,
1166}
1167
1168impl StoreCallSite {
1169 pub fn approximate(&self) -> bool {
1170 self.provenance == PROVENANCE_NAME_MATCH
1171 }
1172
1173 pub fn resolved_by(&self) -> &str {
1174 &self.provenance
1175 }
1176
1177 pub fn supplemental_resolution(&self) -> Option<&str> {
1178 match self.provenance.as_str() {
1179 PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
1180 _ => None,
1181 }
1182 }
1183}
1184
1185#[derive(Debug, Clone, PartialEq, Eq)]
1186pub struct StoreUnresolvedCall {
1187 pub caller: StoreNode,
1188 pub symbol: String,
1189 pub full_ref: Option<String>,
1190 pub line: u32,
1191 pub byte_start: usize,
1192 pub byte_end: usize,
1193}
1194
1195#[derive(Debug, Clone, PartialEq, Eq)]
1196pub struct StoreCallersResult {
1197 pub target: StoreNode,
1198 pub callers: Vec<StoreCallSite>,
1199 pub scanned_files: usize,
1200 pub depth_limited: bool,
1201 pub truncated: usize,
1202}
1203
1204#[derive(Debug, Clone, PartialEq, Eq)]
1205pub struct StoreImpactCaller {
1206 pub site: StoreCallSite,
1207 pub signature: Option<String>,
1208 pub is_entry_point: bool,
1209 pub call_expression: Option<String>,
1210 pub parameters: Vec<String>,
1211}
1212
1213#[derive(Debug, Clone, PartialEq, Eq)]
1214pub struct StoreImpactResult {
1215 pub target: StoreNode,
1216 pub parameters: Vec<String>,
1217 pub callers: Vec<StoreImpactCaller>,
1218 pub depth_limited: bool,
1219 pub truncated: usize,
1220}
1221
1222#[derive(Debug, Clone)]
1223struct ExtractFailure {
1224 rel_path: String,
1225 freshness: Option<FileFreshness>,
1226}
1227
1228#[derive(Debug, Clone)]
1229struct BuildExtractsResult {
1230 extracts: Vec<FileExtract>,
1231 failures: Vec<ExtractFailure>,
1232}
1233
1234#[derive(Debug, Clone)]
1235enum StoreForwardCall {
1236 Resolved(StoreCallSite),
1237 Unresolved(StoreUnresolvedCall),
1238}
1239
1240impl StoreForwardCall {
1241 fn byte_start(&self) -> usize {
1242 match self {
1243 Self::Resolved(site) => site.byte_start,
1244 Self::Unresolved(call) => call.byte_start,
1245 }
1246 }
1247
1248 fn line(&self) -> u32 {
1249 match self {
1250 Self::Resolved(site) => site.line,
1251 Self::Unresolved(call) => call.line,
1252 }
1253 }
1254}
1255
1256#[derive(Debug, Clone)]
1257struct FileExtract {
1258 rel_path: String,
1259 freshness: FileFreshness,
1260 lang: LangId,
1261 data: FileCallData,
1262 nodes: Vec<NodeRecord>,
1263 raw_refs: Vec<RawRef>,
1264 dispatch_hints: Vec<DispatchHint>,
1265 surface_fingerprint: String,
1266}
1267
1268#[derive(Debug, Clone)]
1269struct NodeRecord {
1270 id: String,
1271 file_path: String,
1272 name: String,
1273 scoped_name: String,
1274 kind: String,
1275 range: Range,
1276 range_ordinal: u32,
1277 signature: Option<String>,
1278 exported: bool,
1279 is_default_export: bool,
1280 is_type_like: bool,
1281 is_callgraph_entry_point: bool,
1282}
1283
1284#[derive(Debug, Clone)]
1285struct RawRef {
1286 ref_id: String,
1287 caller_node: Option<String>,
1288 caller_symbol: Option<String>,
1289 caller_file: String,
1290 kind: String,
1291 short_name: Option<String>,
1292 full_ref: Option<String>,
1293 module_path: Option<String>,
1294 import_kind: Option<String>,
1295 local_name: Option<String>,
1296 requested_name: Option<String>,
1297 namespace_alias: Option<String>,
1298 wildcard: bool,
1299 line: u32,
1300 byte_start: usize,
1301 byte_end: usize,
1302 dependencies: BTreeSet<String>,
1303}
1304
1305#[derive(Debug, Clone)]
1306struct ResolvedRef {
1307 raw: RawRef,
1308 status: String,
1309 target_node: Option<String>,
1310 target_file: Option<String>,
1311 target_symbol: Option<String>,
1312 dependencies: BTreeSet<String>,
1313 edge: Option<EdgeRecord>,
1314}
1315
1316#[derive(Debug, Clone)]
1317struct EdgeRecord {
1318 edge_id: String,
1319 source_node: String,
1320 target_node: Option<String>,
1321 target_file: String,
1322 target_symbol: String,
1323 kind: String,
1324 line: u32,
1325}
1326
1327#[derive(Debug, Clone)]
1328struct DispatchHint {
1329 id: String,
1330 method_name: String,
1331 caller_node: String,
1332 file: String,
1333 line: u32,
1334 byte_start: usize,
1335 byte_end: usize,
1336}
1337
1338#[derive(Debug, Clone)]
1339struct NameMatchRef {
1340 ref_id: String,
1341 caller_node: String,
1342 caller_file: String,
1343 caller_symbol: String,
1344 caller_signature: Option<String>,
1345 receiver: String,
1346 method_name: String,
1347 colon_dispatch: bool,
1348 line: u32,
1349 lang: String,
1350}
1351
1352#[derive(Debug, Clone)]
1353struct NameMatchCandidate {
1354 node_id: String,
1355 file_path: String,
1356 scoped_name: String,
1357 kind: String,
1358}
1359
1360#[derive(Debug, Clone)]
1361struct FileRow {
1362 surface_fingerprint: String,
1363 freshness: FileFreshness,
1364}
1365
1366#[derive(Debug, Clone)]
1367struct DbFileIndex {
1368 lang: Option<LangId>,
1369 exports: HashSet<String>,
1370 default_export: Option<String>,
1371 export_aliases: HashMap<String, String>,
1372 node_by_scoped: HashMap<String, String>,
1373 node_by_bare: HashMap<String, String>,
1374 module_targets: HashMap<String, Option<String>>,
1375 reexports: Vec<ReexportIndex>,
1376}
1377
1378#[derive(Debug, Clone)]
1379struct ReexportIndex {
1380 target_file: Option<String>,
1381 named: HashMap<String, String>,
1382 wildcard: bool,
1383}
1384
1385#[derive(Debug, Clone)]
1386struct ProjectIndex<'a> {
1387 project_root: PathBuf,
1388 files: HashMap<String, DbFileIndex>,
1389 caller_data: HashMap<String, &'a FileCallData>,
1390 workspace_crate_prefixes: WorkspaceCratePrefixCache,
1395}
1396
1397impl ProjectIndex<'_> {
1398 fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
1401 self.workspace_crate_prefixes
1402 .0
1403 .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
1404 .get(crate_name)
1405 .cloned()
1406 }
1407}
1408
1409impl CallGraphStore {
1410 pub fn open_if_enabled(
1411 options: CallGraphStoreOptions,
1412 callgraph_dir: PathBuf,
1413 project_root: PathBuf,
1414 ) -> Result<Option<Self>> {
1415 if !options.enabled {
1416 return Ok(None);
1417 }
1418 Self::open(callgraph_dir, project_root).map(Some)
1419 }
1420
1421 pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
1422 let project_key = crate::search_index::artifact_cache_key(&project_root);
1423 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1424 else {
1425 return match Self::open_readonly(callgraph_dir.clone(), project_root.clone())? {
1426 Some(store) => Ok(store.into_inner()),
1427 None => Self::borrow_only_empty(callgraph_dir, project_root, project_key),
1428 };
1429 };
1430 std::fs::create_dir_all(&callgraph_dir)?;
1431 let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
1435 .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
1436 let OpenedStore { store, root_repair } = Self::open_at_path(
1437 project_root.clone(),
1438 project_key,
1439 sqlite_path,
1440 generation,
1441 true,
1442 Some(Arc::clone(&writer_lease)),
1443 None,
1444 )?;
1445 match root_repair {
1446 OpenRootRepair::NeedsRebuild { .. } => {
1447 log_root_repair_rebuild(&root_repair);
1448 drop(store);
1449 drop(writer_lease);
1450 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1451 let (store, _stats) =
1452 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
1453 Ok(store)
1454 }
1455 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
1456 }
1457 }
1458
1459 pub fn open_readonly(
1460 callgraph_dir: PathBuf,
1461 project_root: PathBuf,
1462 ) -> Result<Option<ReadonlyCallGraphStore>> {
1463 let project_key = crate::search_index::artifact_cache_key(&project_root);
1464 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1465 {
1466 let conn = open_readonly_connection(&sqlite_path)?;
1467 if !database_ready(&conn).unwrap_or(false) {
1468 return Ok(None);
1469 }
1470 let marker_label = generation.as_deref().unwrap_or("legacy");
1471 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
1472 return Ok(Some(ReadonlyCallGraphStore::from_inner(
1473 Self::from_connection(
1474 project_root,
1475 project_key,
1476 sqlite_path,
1477 callgraph_dir,
1478 false,
1479 generation,
1480 None,
1481 Some(read_marker),
1482 conn,
1483 ),
1484 )));
1485 }
1486
1487 let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
1488 return Ok(None);
1489 };
1490 crate::slog_warn!(
1491 "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
1492 target.partition.harness,
1493 target.sqlite_path.display()
1494 );
1495 let conn = open_readonly_connection(&target.sqlite_path)?;
1496 if !database_ready(&conn).unwrap_or(false) {
1497 return Ok(None);
1498 }
1499 let marker_label =
1500 legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
1501 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
1502 Ok(Some(ReadonlyCallGraphStore::from_inner(
1503 Self::from_connection(
1504 project_root,
1505 project_key,
1506 target.sqlite_path,
1507 callgraph_dir,
1508 true,
1509 target.generation,
1510 None,
1511 Some(read_marker),
1512 conn,
1513 ),
1514 )))
1515 }
1516
1517 pub fn open_ready_repairing(
1523 callgraph_dir: PathBuf,
1524 project_root: PathBuf,
1525 ) -> Result<Option<Self>> {
1526 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true, true)
1527 }
1528
1529 pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
1533 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false, false)
1534 }
1535
1536 pub fn open_ready_no_rebuild(
1537 callgraph_dir: PathBuf,
1538 project_root: PathBuf,
1539 ) -> Result<Option<Self>> {
1540 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true, true)
1541 }
1542
1543 fn open_ready_with_rebuild_policy(
1544 callgraph_dir: PathBuf,
1545 project_root: PathBuf,
1546 allow_cold_build: bool,
1547 allow_root_repair: bool,
1548 allow_borrow_only: bool,
1549 ) -> Result<Option<Self>> {
1550 let project_key = crate::search_index::artifact_cache_key(&project_root);
1551 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1552 else {
1553 if !allow_borrow_only {
1554 return Ok(None);
1555 }
1556 return Self::open_readonly(callgraph_dir, project_root)
1557 .map(|store| store.map(ReadonlyCallGraphStore::into_inner));
1558 };
1559 let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1560 else {
1561 return Ok(None);
1562 };
1563 let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
1564 project_root.clone(),
1565 project_key,
1566 sqlite_path,
1567 generation,
1568 true,
1569 Some(Arc::clone(&writer_lease)),
1570 None,
1571 allow_root_repair,
1572 )?;
1573 match root_repair {
1574 OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
1575 log_root_repair_rebuild(&root_repair);
1576 drop(store);
1577 drop(writer_lease);
1578 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1579 let (store, _stats) =
1580 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
1581 Ok(Some(store))
1582 }
1583 OpenRootRepair::NeedsRebuild { .. } => {
1584 crate::slog_info!(
1585 "callgraph store root repair requires rebuild; open-only reader reports unavailable"
1586 );
1587 Ok(None)
1588 }
1589 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
1590 }
1591 }
1592
1593 pub fn cold_build_with_lease(
1594 callgraph_dir: PathBuf,
1595 project_root: PathBuf,
1596 files: &[PathBuf],
1597 ) -> Result<(Self, ColdBuildStats)> {
1598 Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
1599 }
1600
1601 pub fn cold_build_with_lease_chunked(
1602 callgraph_dir: PathBuf,
1603 project_root: PathBuf,
1604 files: &[PathBuf],
1605 chunk_size: usize,
1606 ) -> Result<(Self, ColdBuildStats)> {
1607 Self::cold_build_with_lease_chunked_inner(
1608 callgraph_dir,
1609 project_root,
1610 files,
1611 chunk_size,
1612 false,
1613 )
1614 }
1615
1616 pub(crate) fn force_cold_build_with_lease_chunked(
1617 callgraph_dir: PathBuf,
1618 project_root: PathBuf,
1619 files: &[PathBuf],
1620 chunk_size: usize,
1621 ) -> Result<(Self, ColdBuildStats)> {
1622 Self::cold_build_with_lease_chunked_inner(
1623 callgraph_dir,
1624 project_root,
1625 files,
1626 chunk_size,
1627 true,
1628 )
1629 }
1630
1631 fn cold_build_with_lease_chunked_inner(
1632 callgraph_dir: PathBuf,
1633 project_root: PathBuf,
1634 files: &[PathBuf],
1635 chunk_size: usize,
1636 require_new_publication: bool,
1637 ) -> Result<(Self, ColdBuildStats)> {
1638 let project_key = crate::search_index::artifact_cache_key(&project_root);
1639 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1640 else {
1641 if require_new_publication {
1642 return Err(CallGraphStoreError::Unavailable(
1643 "forced rebuild could not acquire the writer lease".to_string(),
1644 ));
1645 }
1646 let store = match Self::open_readonly(callgraph_dir.clone(), project_root.clone())? {
1647 Some(store) => store.into_inner(),
1648 None => Self::borrow_only_empty(callgraph_dir, project_root, project_key)?,
1649 };
1650 return Ok((
1651 store,
1652 ColdBuildStats {
1653 files: 0,
1654 nodes: 0,
1655 refs: 0,
1656 edges: 0,
1657 failed_files: Vec::new(),
1658 elapsed_ms: 0,
1659 },
1660 ));
1661 };
1662 std::fs::create_dir_all(&callgraph_dir)?;
1663 let (stats, generation) = Self::cold_build_publish_locked(
1664 &callgraph_dir,
1665 &project_root,
1666 &project_key,
1667 files,
1668 chunk_size,
1669 Arc::clone(&writer_lease),
1670 )?;
1671 let store = Self::open_generation(
1672 &callgraph_dir,
1673 project_root,
1674 project_key,
1675 generation,
1676 writer_lease,
1677 )?;
1678 Ok((store, stats))
1679 }
1680
1681 pub fn ensure_built_with_lease(
1682 callgraph_dir: PathBuf,
1683 project_root: PathBuf,
1684 files: &[PathBuf],
1685 ) -> Result<(Self, Option<ColdBuildStats>)> {
1686 Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
1687 }
1688
1689 pub fn ensure_built_with_lease_chunked(
1690 callgraph_dir: PathBuf,
1691 project_root: PathBuf,
1692 files: &[PathBuf],
1693 chunk_size: usize,
1694 ) -> Result<(Self, Option<ColdBuildStats>)> {
1695 let project_key = crate::search_index::artifact_cache_key(&project_root);
1696 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1697 else {
1698 return match Self::open_readonly(callgraph_dir.clone(), project_root.clone())? {
1699 Some(store) => Ok((store.into_inner(), None)),
1700 None => Self::borrow_only_empty(callgraph_dir, project_root, project_key)
1701 .map(|store| (store, None)),
1702 };
1703 };
1704 std::fs::create_dir_all(&callgraph_dir)?;
1705 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
1706 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1713 {
1714 let OpenedStore { store, root_repair } = Self::open_at_path(
1715 project_root.clone(),
1716 project_key.clone(),
1717 sqlite_path,
1718 generation,
1719 true,
1720 Some(Arc::clone(&writer_lease)),
1721 None,
1722 )?;
1723 match root_repair {
1724 OpenRootRepair::NeedsRebuild { .. } => {
1725 log_root_repair_rebuild(&root_repair);
1726 drop(store);
1727 let (stats, generation) = Self::cold_build_publish_locked(
1728 &callgraph_dir,
1729 &project_root,
1730 &project_key,
1731 files,
1732 chunk_size,
1733 Arc::clone(&writer_lease),
1734 )?;
1735 let store = Self::open_generation(
1736 &callgraph_dir,
1737 project_root,
1738 project_key,
1739 generation,
1740 writer_lease,
1741 )?;
1742 return Ok((store, Some(stats)));
1743 }
1744 OpenRootRepair::None | OpenRootRepair::ReRooted => {
1745 return Ok((store, None));
1746 }
1747 }
1748 }
1749 if let Some(store) = try_legacy_migration_or_fallback(
1750 &callgraph_dir,
1751 &project_root,
1752 &project_key,
1753 Arc::clone(&writer_lease),
1754 )? {
1755 return Ok((store, None));
1756 }
1757 let (stats, generation) = Self::cold_build_publish_locked(
1758 &callgraph_dir,
1759 &project_root,
1760 &project_key,
1761 files,
1762 chunk_size,
1763 Arc::clone(&writer_lease),
1764 )?;
1765 let store = Self::open_generation(
1766 &callgraph_dir,
1767 project_root,
1768 project_key,
1769 generation,
1770 writer_lease,
1771 )?;
1772 Ok((store, Some(stats)))
1773 }
1774
1775 pub fn migrate_legacy_with_lease(
1782 callgraph_dir: PathBuf,
1783 project_root: PathBuf,
1784 ) -> Result<Option<Self>> {
1785 let project_key = crate::search_index::artifact_cache_key(&project_root);
1786 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1787 else {
1788 return Ok(None);
1789 };
1790 std::fs::create_dir_all(&callgraph_dir)?;
1791 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
1792
1793 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1797 {
1798 let OpenedStore { store, root_repair } = Self::open_at_path(
1799 project_root,
1800 project_key,
1801 sqlite_path,
1802 generation,
1803 true,
1804 Some(writer_lease),
1805 None,
1806 )?;
1807 return match root_repair {
1808 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
1809 OpenRootRepair::NeedsRebuild { reason, .. } => {
1810 Err(CallGraphStoreError::Unavailable(format!(
1811 "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
1812 )))
1813 }
1814 };
1815 }
1816
1817 let store = try_legacy_migration_or_fallback(
1818 &callgraph_dir,
1819 &project_root,
1820 &project_key,
1821 writer_lease,
1822 )?;
1823 Ok(store.filter(|store| !store.is_legacy_fallback()))
1827 }
1828
1829 fn cold_build_publish_locked(
1840 callgraph_dir: &Path,
1841 project_root: &Path,
1842 project_key: &str,
1843 files: &[PathBuf],
1844 chunk_size: usize,
1845 writer_lease: Arc<crate::root_cache::WriterLease>,
1846 ) -> Result<(ColdBuildStats, String)> {
1847 let generation = generation_file_name(project_key);
1848 let gen_path = callgraph_dir.join(&generation);
1849 let temp_path = callgraph_dir.join(format!(
1850 "{generation}.tmp.{}.{}",
1851 std::process::id(),
1852 now_nanos()
1853 ));
1854 remove_sqlite_file_set(&temp_path);
1855
1856 let stats = {
1857 let temp_store = Self::open_at_path(
1858 project_root.to_path_buf(),
1859 project_key.to_string(),
1860 temp_path.clone(),
1861 None,
1862 false,
1863 Some(Arc::clone(&writer_lease)),
1864 None,
1865 )?
1866 .store;
1867 let stats = temp_store.cold_build_chunked(files, chunk_size)?;
1868 temp_store.prepare_for_atomic_swap()?;
1869 stats
1870 };
1871
1872 notify_cold_build_before_publish_observer();
1873 let publication = publish_if_current(|| {
1874 verify_writer_lease(&writer_lease)?;
1875 remove_sqlite_file_set(&gen_path);
1878 crate::fs_lock::rename_over(&temp_path, &gen_path)?;
1879 crate::fs_lock::sync_parent(&gen_path);
1880 remove_sqlite_sidecars(&gen_path);
1881
1882 notify_cold_build_swap_observer(&temp_path, &gen_path);
1883
1884 verify_writer_lease(&writer_lease)?;
1886 publish_pointer(callgraph_dir, project_key, &generation)?;
1887 gc_old_generations(callgraph_dir, project_key, &generation);
1888 sweep_orphaned_build_temps_store_wide(callgraph_dir);
1892 Ok(())
1893 });
1894 if matches!(publication, Err(CallGraphStoreError::Superseded)) {
1895 remove_sqlite_file_set(&temp_path);
1896 }
1897 publication?;
1898 Ok((stats, generation))
1899 }
1900
1901 fn open_generation(
1904 callgraph_dir: &Path,
1905 project_root: PathBuf,
1906 project_key: String,
1907 generation: String,
1908 writer_lease: Arc<crate::root_cache::WriterLease>,
1909 ) -> Result<Self> {
1910 let gen_path = callgraph_dir.join(&generation);
1911 Ok(Self::open_at_path(
1912 project_root,
1913 project_key,
1914 gen_path,
1915 Some(generation),
1916 true,
1917 Some(writer_lease),
1918 None,
1919 )?
1920 .store)
1921 }
1922
1923 pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
1924 let project_key = crate::search_index::artifact_cache_key(project_root);
1925 Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
1928 }
1929
1930 fn open_at_path(
1931 project_root: PathBuf,
1932 project_key: String,
1933 sqlite_path: PathBuf,
1934 generation: Option<String>,
1935 use_wal: bool,
1936 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1937 read_marker: Option<crate::root_cache::ReadMarker>,
1938 ) -> Result<OpenedStore> {
1939 Self::open_at_path_with_root_repair(
1940 project_root,
1941 project_key,
1942 sqlite_path,
1943 generation,
1944 use_wal,
1945 writer_lease,
1946 read_marker,
1947 true,
1948 )
1949 }
1950
1951 fn open_at_path_with_root_repair(
1952 project_root: PathBuf,
1953 project_key: String,
1954 sqlite_path: PathBuf,
1955 generation: Option<String>,
1956 use_wal: bool,
1957 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1958 read_marker: Option<crate::root_cache::ReadMarker>,
1959 allow_root_repair: bool,
1960 ) -> Result<OpenedStore> {
1961 if let Some(lease) = writer_lease.as_ref() {
1962 verify_writer_lease(lease)?;
1963 }
1964 if let Some(parent) = sqlite_path.parent() {
1965 std::fs::create_dir_all(parent)?;
1966 }
1967 let mut conn = Connection::open(&sqlite_path)?;
1968 if use_wal {
1969 configure_connection(&conn)?;
1970 } else {
1971 configure_build_connection(&conn)?;
1972 }
1973 if let Some(lease) = writer_lease.as_ref() {
1974 verify_writer_lease(lease)?;
1975 }
1976 initialize_schema(&conn)?;
1977 if let Some(lease) = writer_lease.as_ref() {
1978 verify_writer_lease(lease)?;
1979 }
1980 let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
1981 let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
1982 (Some(marker), _, _) => Some(marker),
1983 (None, Some(label), Some(cache_dir)) => {
1984 Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
1985 }
1986 (None, _, _) => None,
1987 };
1988 let publication_dir = sqlite_path
1989 .parent()
1990 .map(Path::to_path_buf)
1991 .unwrap_or_default();
1992 let store = Self::from_connection(
1993 project_root,
1994 project_key,
1995 sqlite_path,
1996 publication_dir,
1997 false,
1998 generation,
1999 writer_lease,
2000 read_marker,
2001 conn,
2002 );
2003 Ok(OpenedStore { store, root_repair })
2004 }
2005
2006 fn borrow_only_empty(
2007 callgraph_dir: PathBuf,
2008 project_root: PathBuf,
2009 project_key: String,
2010 ) -> Result<Self> {
2011 let conn = Connection::open_in_memory()?;
2012 initialize_schema(&conn)?;
2013 conn.pragma_update(None, "query_only", true)?;
2014 Ok(Self::from_connection(
2015 project_root,
2016 project_key.clone(),
2017 callgraph_dir.join(format!("{project_key}.borrow-only")),
2018 callgraph_dir,
2019 false,
2020 None,
2021 None,
2022 None,
2023 conn,
2024 ))
2025 }
2026
2027 fn prepare_for_atomic_swap(&self) -> Result<()> {
2028 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2029 conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
2030 Ok(())
2031 }
2032
2033 fn atomic_swap_checkpoint_sql(&self) -> &'static str {
2034 let protected_reader = self.generation.as_deref().is_some_and(|generation| {
2035 self.sqlite_path
2036 .parent()
2037 .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
2038 });
2039 if protected_reader {
2040 "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
2041 } else {
2042 "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
2043 }
2044 }
2045
2046 fn from_connection(
2047 project_root: PathBuf,
2048 project_key: String,
2049 sqlite_path: PathBuf,
2050 publication_dir: PathBuf,
2051 legacy_fallback: bool,
2052 generation: Option<String>,
2053 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2054 read_marker: Option<crate::root_cache::ReadMarker>,
2055 conn: Connection,
2056 ) -> Self {
2057 Self {
2058 project_root,
2059 project_key,
2060 sqlite_path,
2061 publication_dir,
2062 legacy_fallback,
2063 generation,
2064 writer_lease,
2065 read_marker,
2066 database_ready: AtomicBool::new(false),
2067 conn: Mutex::new(conn),
2068 }
2069 }
2070
2071 fn ensure_ready(&self, conn: &Connection) -> Result<()> {
2072 if self.database_ready.load(AtomicOrdering::Acquire) {
2073 return Ok(());
2074 }
2075 ensure_database_ready(conn)?;
2076 self.database_ready.store(true, AtomicOrdering::Release);
2077 Ok(())
2078 }
2079
2080 pub fn project_root(&self) -> &Path {
2081 &self.project_root
2082 }
2083
2084 pub fn project_key(&self) -> &str {
2085 &self.project_key
2086 }
2087
2088 pub fn sqlite_path(&self) -> &Path {
2089 &self.sqlite_path
2090 }
2091
2092 pub fn is_legacy_fallback(&self) -> bool {
2095 self.legacy_fallback
2096 }
2097
2098 pub(crate) fn is_legacy_migration(&self) -> bool {
2099 self.generation.as_deref().is_some_and(|generation| {
2100 migration_generation_requires_manifest(generation)
2101 && migration_manifest_valid(&self.publication_dir, generation)
2102 })
2103 }
2104
2105 pub fn writer_epoch_for_test(&self) -> Option<&str> {
2106 self.writer_lease.as_ref().map(|lease| lease.epoch())
2107 }
2108
2109 fn verify_writer_lease(&self) -> Result<()> {
2110 let Some(lease) = self.writer_lease.as_ref() else {
2111 return Err(CallGraphStoreError::Unavailable(
2112 "callgraph store opened read-only; write API is unavailable".to_string(),
2113 ));
2114 };
2115 verify_writer_lease(lease)
2116 }
2117
2118 fn refresh_read_marker(&self) -> Result<()> {
2119 if let Some(marker) = self.read_marker.as_ref() {
2120 marker.touch_if_due()?;
2121 }
2122 Ok(())
2123 }
2124
2125 pub fn is_current(&self) -> bool {
2131 let _ = self.refresh_read_marker();
2132 match (
2133 read_pointer(&self.publication_dir, &self.project_key),
2134 &self.generation,
2135 ) {
2136 (Some(_), _) if self.legacy_fallback => false,
2139 (Some(published), Some(opened)) => &published == opened,
2140 (Some(_), None) => false,
2142 (None, _) => true,
2145 }
2146 }
2147
2148 pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
2149 self.cold_build_chunked(files, 0)
2150 }
2151
2152 pub fn cold_build_chunked(
2153 &self,
2154 files: &[PathBuf],
2155 chunk_size: usize,
2156 ) -> Result<ColdBuildStats> {
2157 let started = Instant::now();
2158 let bench = std::env::var("AFT_BENCH_COLD").is_ok();
2159 macro_rules! phase {
2160 ($label:expr, $t:expr) => {
2161 if bench {
2162 eprintln!(" cold_build[{}]: {} ms", $label, $t.elapsed().as_millis());
2163 let _ = std::io::Write::flush(&mut std::io::stderr());
2164 }
2165 };
2166 }
2167 let files = normalize_file_list(&self.project_root, files)?;
2168
2169 if chunk_size == 0 {
2170 let t = Instant::now();
2171 let build = build_extracts_parallel(&self.project_root, &files);
2172 phase!("extract_parallel", t);
2173 let extracts = build.extracts;
2174 let failures = build.failures;
2175 let node_count = extracts.iter().map(|extract| extract.nodes.len()).sum();
2176
2177 let t = Instant::now();
2178 let index = ProjectIndex::from_extracts(&self.project_root, &extracts);
2179 phase!("build_index", t);
2180 let t = Instant::now();
2181 let mut resolved_refs = Vec::new();
2182 for extract in &extracts {
2183 for raw_ref in &extract.raw_refs {
2184 resolved_refs.push(resolve_ref(raw_ref.clone(), &index)?);
2185 }
2186 }
2187 phase!("resolve_refs", t);
2188 let ref_count = resolved_refs.len();
2189 let edge_count = resolved_refs
2190 .iter()
2191 .filter(|item| item.edge.is_some())
2192 .count();
2193
2194 let t = Instant::now();
2195 self.verify_writer_lease()?;
2196 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2197 let tx = conn.transaction()?;
2198 clear_tables(&tx)?;
2199 insert_meta(&tx)?;
2200 drop_cold_build_secondary_indexes(&tx)?;
2201 {
2202 let workspace_root = self.project_root.display().to_string();
2203 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2204 for extract in &extracts {
2205 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
2206 }
2207 for failure in &failures {
2208 insert_backend_state_prepared(
2209 &mut inserts.backend_state,
2210 &workspace_root,
2211 &failure.rel_path,
2212 failure
2213 .freshness
2214 .as_ref()
2215 .map(|freshness| &freshness.content_hash),
2216 "stale",
2217 )?;
2218 }
2219 for resolved in &resolved_refs {
2220 insert_resolved_ref_prepared(&mut inserts, resolved)?;
2221 }
2222 }
2223 create_cold_build_secondary_indexes(&tx)?;
2224 let supplemental_edge_count =
2225 insert_method_dispatch_edges(&tx, &self.project_root, None)?;
2226 set_meta_ready(&tx, true)?;
2227 tx.commit()?;
2228 phase!("sqlite_insert", t);
2229
2230 let elapsed_ms = started.elapsed().as_millis();
2231 crate::slog_info!(
2232 "perf callgraph_store cold_build: files={} nodes={} refs={} edges={} ms={}",
2233 extracts.len(),
2234 node_count,
2235 ref_count,
2236 edge_count + supplemental_edge_count,
2237 elapsed_ms
2238 );
2239 return Ok(ColdBuildStats {
2240 files: extracts.len(),
2241 nodes: node_count,
2242 refs: ref_count,
2243 edges: edge_count + supplemental_edge_count,
2244 failed_files: failures
2245 .into_iter()
2246 .map(|failure| failure.rel_path)
2247 .collect(),
2248 elapsed_ms,
2249 });
2250 }
2251
2252 let t = Instant::now();
2255 self.verify_writer_lease()?;
2256 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2257 let tx = conn.transaction()?;
2258 clear_tables(&tx)?;
2259 insert_meta(&tx)?;
2260 drop_cold_build_secondary_indexes(&tx)?;
2261
2262 let mut all_raw_refs = Vec::new();
2263 let mut failures = Vec::new();
2264 let mut node_count = 0;
2265 let mut files_parsed = 0;
2266
2267 let mut persistent_call_data = Vec::new();
2268 let mut file_to_call_data_index = HashMap::new();
2269 let mut files_index = HashMap::new();
2270
2271 let workspace_root = self.project_root.display().to_string();
2272
2273 {
2274 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2275 for chunk in files.chunks(chunk_size) {
2276 let build = build_extracts_parallel(&self.project_root, chunk);
2277 failures.extend(build.failures.clone());
2278
2279 for extract in build.extracts {
2280 files_parsed += 1;
2281 node_count += extract.nodes.len();
2282 insert_file_extract_prepared(&mut inserts, &workspace_root, &extract)?;
2283
2284 let db_file_index = DbFileIndex::from_extract(&self.project_root, &extract);
2285 files_index.insert(extract.rel_path.clone(), db_file_index);
2286
2287 persistent_call_data.push(extract.data);
2288 let idx = persistent_call_data.len() - 1;
2289 file_to_call_data_index.insert(extract.rel_path.clone(), idx);
2290
2291 all_raw_refs.push((extract.rel_path, extract.raw_refs));
2292 }
2293 for failure in &build.failures {
2294 insert_backend_state_prepared(
2295 &mut inserts.backend_state,
2296 &workspace_root,
2297 &failure.rel_path,
2298 failure
2299 .freshness
2300 .as_ref()
2301 .map(|freshness| &freshness.content_hash),
2302 "stale",
2303 )?;
2304 }
2305 }
2306 }
2307
2308 let mut caller_data = HashMap::new();
2309 for (rel_path, idx) in &file_to_call_data_index {
2310 caller_data.insert(rel_path.clone(), &persistent_call_data[*idx]);
2311 }
2312 let indexed_caller_files = files_index.keys().cloned().collect::<BTreeSet<_>>();
2313 let index = ProjectIndex::from_parts(
2314 &self.project_root,
2315 files_index,
2316 caller_data,
2317 WorkspaceCratePrefixCache::default(),
2318 );
2319
2320 let mut resolved_refs = Vec::new();
2321 for (_, raw_refs) in all_raw_refs {
2322 for raw_ref in raw_refs {
2323 resolved_refs.push(resolve_ref(raw_ref, &index)?);
2324 }
2325 }
2326
2327 let ref_count = resolved_refs.len();
2328 let edge_count = resolved_refs
2329 .iter()
2330 .filter(|item| item.edge.is_some())
2331 .count();
2332
2333 {
2334 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2335 for resolved in &resolved_refs {
2336 insert_resolved_ref_prepared(&mut inserts, resolved)?;
2337 }
2338 }
2339 create_cold_build_secondary_indexes(&tx)?;
2340 let supplemental_edge_count = insert_method_dispatch_edges_chunked(
2341 &tx,
2342 &self.project_root,
2343 &indexed_caller_files,
2344 chunk_size,
2345 )?;
2346 set_meta_ready(&tx, true)?;
2347 tx.commit()?;
2348 phase!("sqlite_insert", t);
2349
2350 let elapsed_ms = started.elapsed().as_millis();
2351 crate::slog_info!(
2352 "perf callgraph_store cold_build (chunked): files={} nodes={} refs={} edges={} ms={}",
2353 files_parsed,
2354 node_count,
2355 ref_count,
2356 edge_count + supplemental_edge_count,
2357 elapsed_ms
2358 );
2359 Ok(ColdBuildStats {
2360 files: files_parsed,
2361 nodes: node_count,
2362 refs: ref_count,
2363 edges: edge_count + supplemental_edge_count,
2364 failed_files: failures
2365 .into_iter()
2366 .map(|failure| failure.rel_path)
2367 .collect(),
2368 elapsed_ms,
2369 })
2370 }
2371
2372 pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
2373 self.refresh_files_with_workspace_crate_prefix_cache(
2374 changed_files,
2375 WorkspaceCratePrefixCache::default(),
2376 )
2377 }
2378
2379 fn refresh_files_with_workspace_crate_prefix_cache(
2380 &self,
2381 changed_files: &[PathBuf],
2382 workspace_crate_prefixes: WorkspaceCratePrefixCache,
2383 ) -> Result<IncrementalStats> {
2384 let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
2385 changed_files,
2386 workspace_crate_prefixes,
2387 )?;
2388 if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
2389 eprintln!("refresh_files phases: {}", profile.report());
2390 }
2391 Ok(stats)
2392 }
2393
2394 #[doc(hidden)]
2396 pub fn refresh_files_profiled(
2397 &self,
2398 changed_files: &[PathBuf],
2399 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2400 self.refresh_files_profiled_with_workspace_crate_prefix_cache(
2401 changed_files,
2402 WorkspaceCratePrefixCache::default(),
2403 )
2404 }
2405
2406 fn refresh_files_profiled_with_workspace_crate_prefix_cache(
2407 &self,
2408 changed_files: &[PathBuf],
2409 workspace_crate_prefixes: WorkspaceCratePrefixCache,
2410 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2411 let total_started = Instant::now();
2412 let mut profile = RefreshFilesProfile::default();
2413 self.verify_writer_lease()?;
2414 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2415 let tx = conn.transaction()?;
2416 ensure_database_ready(&tx)?;
2417 let mut changed = Vec::new();
2418 let mut surface_changed = BTreeSet::new();
2419 let mut deleted = BTreeSet::new();
2420 let mut own_refresh = BTreeSet::new();
2421 let mut selected_ref_ids = BTreeSet::new();
2422 let mut selected_refs_by_caller = BTreeMap::new();
2423 let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
2424
2425 for input in changed_files {
2426 let abs_path = normalize_file_path(&self.project_root, input)?;
2427 let rel_path = relative_path(&self.project_root, &abs_path);
2428 changed.push(rel_path.clone());
2429 let old_row = load_file_row(&tx, &rel_path)?;
2430 if !abs_path.exists() {
2431 if old_row.is_some() {
2432 surface_changed.insert(rel_path.clone());
2433 deleted.insert(rel_path.clone());
2434 let started = Instant::now();
2435 let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
2436 profile.dependency_selection += started.elapsed();
2437 record_dependent_refs(
2438 &mut selected_ref_ids,
2439 &mut selected_refs_by_caller,
2440 dependent_refs,
2441 );
2442 let started = Instant::now();
2443 delete_file_rows(&tx, &rel_path)?;
2444 clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
2445 profile.row_deletes += started.elapsed();
2446 }
2447 continue;
2448 }
2449
2450 if let Some(row) = &old_row {
2451 match cache_freshness::verify_file(&abs_path, &row.freshness) {
2452 FreshnessVerdict::HotFresh => continue,
2453 FreshnessVerdict::ContentFresh {
2454 new_mtime,
2455 new_size,
2456 } => {
2457 update_file_fresh_metadata(
2458 &tx,
2459 &rel_path,
2460 &row.freshness.content_hash,
2461 new_mtime,
2462 new_size,
2463 )?;
2464 continue;
2465 }
2466 FreshnessVerdict::Deleted => {
2467 surface_changed.insert(rel_path.clone());
2468 deleted.insert(rel_path.clone());
2469 let started = Instant::now();
2470 let dependent_refs =
2471 ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
2472 profile.dependency_selection += started.elapsed();
2473 record_dependent_refs(
2474 &mut selected_ref_ids,
2475 &mut selected_refs_by_caller,
2476 dependent_refs,
2477 );
2478 let started = Instant::now();
2479 delete_file_rows(&tx, &rel_path)?;
2480 clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
2481 profile.row_deletes += started.elapsed();
2482 continue;
2483 }
2484 FreshnessVerdict::Stale => {}
2485 }
2486 }
2487
2488 let started = Instant::now();
2489 let extract = build_file_extract(&self.project_root, &abs_path)?;
2490 profile.parse += started.elapsed();
2491 let surface_is_changed = old_row
2492 .as_ref()
2493 .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
2494 .unwrap_or(true);
2495 if surface_is_changed {
2496 surface_changed.insert(rel_path.clone());
2497 let started = Instant::now();
2498 let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
2499 profile.dependency_selection += started.elapsed();
2500 record_dependent_refs(
2501 &mut selected_ref_ids,
2502 &mut selected_refs_by_caller,
2503 dependent_refs,
2504 );
2505 }
2506 own_refresh.insert(rel_path.clone());
2507 let started = Instant::now();
2508 delete_file_rows(&tx, &rel_path)?;
2509 profile.row_deletes += started.elapsed();
2510 let started = Instant::now();
2511 insert_file_extract(&tx, &self.project_root, &extract)?;
2512 profile.row_inserts += started.elapsed();
2513 changed_extracts.insert(rel_path, extract);
2514 }
2515
2516 let dependency_selected_refs = selected_ref_ids.len();
2517 let mut touched_callers: BTreeSet<String> =
2518 selected_refs_by_caller.keys().cloned().collect();
2519 touched_callers.extend(own_refresh.iter().cloned());
2520
2521 let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
2522 for rel_path in &touched_callers {
2523 if deleted.contains(rel_path) {
2524 continue;
2525 }
2526 if let Some(extract) = changed_extracts.get(rel_path) {
2527 caller_extracts.insert(rel_path.clone(), extract.clone());
2528 continue;
2529 }
2530 let abs_path = self.project_root.join(rel_path);
2531 if abs_path.exists() {
2532 let started = Instant::now();
2533 let extract = build_file_extract(&self.project_root, &abs_path)?;
2534 profile.dependent_parse += started.elapsed();
2535 caller_extracts.insert(rel_path.clone(), extract);
2536 }
2537 }
2538
2539 let dependency_callers = touched_callers
2540 .iter()
2541 .filter(|rel_path| !deleted.contains(*rel_path) && !own_refresh.contains(*rel_path))
2542 .cloned()
2543 .collect::<Vec<_>>();
2544 for rel_path in dependency_callers {
2545 let Some(extract) = caller_extracts.get(&rel_path) else {
2546 continue;
2547 };
2548 if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
2549 continue;
2550 }
2551
2552 own_refresh.insert(rel_path.clone());
2553 let started = Instant::now();
2554 delete_file_rows(&tx, &rel_path)?;
2555 profile.row_deletes += started.elapsed();
2556 let started = Instant::now();
2557 insert_file_extract(&tx, &self.project_root, extract)?;
2558 profile.row_inserts += started.elapsed();
2559 }
2560
2561 let started = Instant::now();
2562 let index = ProjectIndex::from_db_and_callers(
2563 &tx,
2564 &self.project_root,
2565 &caller_extracts,
2566 workspace_crate_prefixes,
2567 )?;
2568 profile.index_load += started.elapsed();
2569 let started = Instant::now();
2570 for rel_path in &touched_callers {
2571 if deleted.contains(rel_path) {
2572 continue;
2573 }
2574 let Some(extract) = caller_extracts.get(rel_path) else {
2575 continue;
2576 };
2577 if own_refresh.contains(rel_path) {
2578 delete_refs_for_caller(&tx, rel_path)?;
2579 for raw_ref in &extract.raw_refs {
2580 let resolved = resolve_ref(raw_ref.clone(), &index)?;
2581 insert_resolved_ref(&tx, &resolved)?;
2582 }
2583 continue;
2584 }
2585
2586 let selected_for_caller = selected_refs_by_caller
2587 .get(rel_path)
2588 .cloned()
2589 .unwrap_or_default();
2590 delete_ref_ids(&tx, &selected_for_caller)?;
2591 for raw_ref in &extract.raw_refs {
2592 if selected_for_caller.contains(&raw_ref.ref_id) {
2593 let resolved = resolve_ref(raw_ref.clone(), &index)?;
2594 insert_resolved_ref(&tx, &resolved)?;
2595 }
2596 }
2597 }
2598 profile.ref_resolution += started.elapsed();
2599
2600 let started = Instant::now();
2601 delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
2602 insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
2603 profile.method_dispatch += started.elapsed();
2604
2605 let started = Instant::now();
2606 commit_incremental_if_current(tx)?;
2607 profile.commit += started.elapsed();
2608 profile.total = total_started.elapsed();
2609 Ok((
2610 IncrementalStats {
2611 changed_files: changed,
2612 surface_changed: surface_changed.into_iter().collect(),
2613 deleted_files: deleted.into_iter().collect(),
2614 dependency_selected_refs,
2615 refreshed_own_files: own_refresh.len(),
2616 },
2617 profile,
2618 ))
2619 }
2620
2621 pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
2622 self.cold_build(current_files)
2623 }
2624
2625 pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
2626 self.verify_writer_lease()?;
2627 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2628 let tx = conn.transaction()?;
2629 let mut marked = Vec::new();
2630 for path in files {
2631 let abs_path = normalize_file_path(&self.project_root, path)?;
2632 let rel_path = relative_path(&self.project_root, &abs_path);
2633 let freshness = cache_freshness::collect(&abs_path).ok();
2634 mark_backend_state(
2635 &tx,
2636 &self.project_root,
2637 &rel_path,
2638 freshness.as_ref().map(|freshness| &freshness.content_hash),
2639 "stale",
2640 )?;
2641 marked.push(rel_path);
2642 }
2643 tx.commit()?;
2644 marked.sort();
2645 marked.dedup();
2646 Ok(marked)
2647 }
2648
2649 pub fn stale_files(&self) -> Result<Vec<String>> {
2650 self.refresh_read_marker()?;
2651 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2652 let mut stmt = conn.prepare(
2653 "SELECT DISTINCT file_path FROM backend_file_state
2654 WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
2655 ORDER BY file_path",
2656 )?;
2657 let rows = stmt.query_map(
2658 params![BACKEND_TREESITTER, self.project_root.display().to_string()],
2659 |row| row.get::<_, String>(0),
2660 )?;
2661 rows.collect::<std::result::Result<Vec<_>, _>>()
2662 .map_err(Into::into)
2663 }
2664
2665 pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
2666 self.refresh_read_marker()?;
2667 let rel_path = relative_path(
2668 &self.project_root,
2669 &normalize_file_path(&self.project_root, file)?,
2670 );
2671 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2672 conn.query_row(
2673 "SELECT status FROM backend_file_state
2674 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
2675 ORDER BY updated_at DESC LIMIT 1",
2676 params![
2677 BACKEND_TREESITTER,
2678 self.project_root.display().to_string(),
2679 rel_path
2680 ],
2681 |row| row.get(0),
2682 )
2683 .optional()
2684 .map_err(Into::into)
2685 }
2686
2687 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
2688 self.refresh_read_marker()?;
2689 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2690 self.ensure_ready(&conn)?;
2691 edge_snapshot_with_conn(&conn)
2692 }
2693
2694 pub fn indexed_file_count(&self) -> Result<usize> {
2695 self.refresh_read_marker()?;
2696 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2697 self.ensure_ready(&conn)?;
2698 indexed_file_count(&conn)
2699 }
2700
2701 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
2702 self.refresh_read_marker()?;
2703 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
2704 let rel_path = relative_path(&self.project_root, &abs_path);
2705 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2706 self.ensure_ready(&conn)?;
2707 resolve_node_for_rel(&conn, &rel_path, symbol)
2708 }
2709
2710 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
2715 self.refresh_read_marker()?;
2716 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
2717 let rel_path = relative_path(&self.project_root, &abs_path);
2718 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2719 self.ensure_ready(&conn)?;
2720 nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
2721 }
2722
2723 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
2725 self.refresh_read_marker()?;
2726 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2727 self.ensure_ready(&conn)?;
2728 nodes_matching_symbol(&conn, symbol)
2729 }
2730
2731 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
2733 self.refresh_read_marker()?;
2734 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
2735 let rel_path = relative_path(&self.project_root, &abs_path);
2736 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2737 self.ensure_ready(&conn)?;
2738 direct_callers_for_tuple(&conn, &rel_path, symbol)
2739 }
2740
2741 pub fn direct_caller_counts_of(
2743 &self,
2744 targets: &[(String, String)],
2745 ) -> Result<HashMap<(String, String), usize>> {
2746 if targets.is_empty() {
2747 return Ok(HashMap::new());
2748 }
2749 self.refresh_read_marker()?;
2750 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2751 self.ensure_ready(&conn)?;
2752 direct_caller_counts_for_tuples(&conn, targets)
2753 }
2754
2755 pub fn callers_of(
2756 &self,
2757 file_rel: &Path,
2758 symbol: &str,
2759 depth: usize,
2760 ) -> Result<StoreCallersResult> {
2761 let target = self.node_for(file_rel, symbol)?;
2762 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2763 self.ensure_ready(&conn)?;
2764 let effective_depth = depth.max(1);
2765 let mut visited = HashSet::new();
2766 let mut callers = Vec::new();
2767 let mut depth_limited = false;
2768 let mut truncated = 0usize;
2769 collect_callers_recursive(
2770 &conn,
2771 &target.file,
2772 &target.symbol,
2773 effective_depth,
2774 0,
2775 &mut visited,
2776 &mut callers,
2777 &mut depth_limited,
2778 &mut truncated,
2779 )?;
2780 Ok(StoreCallersResult {
2781 target,
2782 callers,
2783 scanned_files: indexed_file_count(&conn)?,
2784 depth_limited,
2785 truncated,
2786 })
2787 }
2788
2789 pub fn impact_of(
2790 &self,
2791 file_rel: &Path,
2792 symbol: &str,
2793 depth: usize,
2794 ) -> Result<StoreImpactResult> {
2795 let callers = self.callers_of(file_rel, symbol, depth)?;
2796 let target_parameters = callers
2797 .target
2798 .signature
2799 .as_deref()
2800 .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
2801 .unwrap_or_default();
2802 let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
2803 for site in &callers.callers {
2804 source_lines_by_file
2805 .entry(site.caller.file.clone())
2806 .or_insert_with(|| {
2807 read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
2808 });
2809 }
2810 let enriched = callers
2811 .callers
2812 .iter()
2813 .map(|site| StoreImpactCaller {
2814 site: site.clone(),
2815 signature: site.caller.signature.clone(),
2816 is_entry_point: site.caller.is_entry_point,
2817 call_expression: source_lines_by_file
2818 .get(&site.caller.file)
2819 .and_then(|lines| lines.as_ref())
2820 .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
2821 .cloned(),
2822 parameters: site
2823 .caller
2824 .signature
2825 .as_deref()
2826 .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
2827 .unwrap_or_default(),
2828 })
2829 .collect();
2830 Ok(StoreImpactResult {
2831 target: callers.target,
2832 parameters: target_parameters,
2833 callers: enriched,
2834 depth_limited: callers.depth_limited,
2835 truncated: callers.truncated,
2836 })
2837 }
2838
2839 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
2840 self.refresh_read_marker()?;
2841 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2842 self.ensure_ready(&conn)?;
2843 outgoing_calls_for_node(&conn, node)
2844 }
2845
2846 pub fn outgoing_calls_for_symbols(
2848 &self,
2849 sources: &[(String, String)],
2850 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
2851 if sources.is_empty() {
2852 return Ok(HashMap::new());
2853 }
2854 self.refresh_read_marker()?;
2855 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2856 self.ensure_ready(&conn)?;
2857 outgoing_calls_for_symbol_tuples(&conn, sources)
2858 }
2859
2860 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
2862 self.refresh_read_marker()?;
2863 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2864 self.ensure_ready(&conn)?;
2865 resolved_self_calls_for_node(&conn, node)
2866 }
2867
2868 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
2869 self.refresh_read_marker()?;
2870 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2871 self.ensure_ready(&conn)?;
2872 unresolved_calls_for_node(&conn, node)
2873 }
2874
2875 pub fn call_tree(
2876 &self,
2877 file_rel: &Path,
2878 symbol: &str,
2879 max_depth: usize,
2880 ) -> Result<callgraph::CallTreeNode> {
2881 let node = self.node_for(file_rel, symbol)?;
2882 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2883 self.ensure_ready(&conn)?;
2884 let mut visited = HashSet::new();
2885 call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
2886 }
2887
2888 pub fn trace_to(
2889 &self,
2890 file_rel: &Path,
2891 symbol: &str,
2892 max_depth: usize,
2893 ) -> Result<callgraph::TraceToResult> {
2894 let target = self.node_for(file_rel, symbol)?;
2895 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2896 self.ensure_ready(&conn)?;
2897 let effective_max = if max_depth == 0 { 10 } else { max_depth };
2898
2899 #[derive(Clone)]
2900 struct PathElem {
2901 node: StoreNode,
2902 }
2903
2904 let initial = vec![PathElem {
2905 node: target.clone(),
2906 }];
2907 let mut complete_paths = Vec::new();
2908 if target.is_entry_point {
2909 complete_paths.push(initial.clone());
2910 }
2911
2912 let mut queue = vec![(initial, 0usize)];
2913 let mut max_depth_reached = false;
2914 let mut truncated_paths = 0usize;
2915
2916 while let Some((path, depth)) = queue.pop() {
2917 if depth >= effective_max {
2918 max_depth_reached = true;
2919 continue;
2920 }
2921 let Some(current) = path.last() else {
2922 continue;
2923 };
2924 let callers =
2925 direct_callers_for_tuple(&conn, ¤t.node.file, ¤t.node.symbol)?;
2926 if callers.is_empty() {
2927 if path.len() > 1 {
2928 truncated_paths += 1;
2929 }
2930 continue;
2931 }
2932
2933 let mut has_new_path = false;
2934 for site in callers {
2935 if path.iter().any(|elem| {
2936 elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
2937 }) {
2938 continue;
2939 }
2940 has_new_path = true;
2941 let mut new_path = path.clone();
2942 new_path.push(PathElem {
2943 node: site.caller.clone(),
2944 });
2945 if site.caller.is_entry_point {
2946 complete_paths.push(new_path.clone());
2947 }
2948 queue.push((new_path, depth + 1));
2949 }
2950 if !has_new_path && path.len() > 1 {
2951 truncated_paths += 1;
2952 }
2953 }
2954
2955 let mut paths: Vec<callgraph::TracePath> = complete_paths
2956 .into_iter()
2957 .map(|mut elems| {
2958 elems.reverse();
2959 let hops = elems
2960 .iter()
2961 .enumerate()
2962 .map(|(index, elem)| callgraph::TraceHop {
2963 symbol: elem.node.symbol.clone(),
2964 file: elem.node.file.clone(),
2965 line: elem.node.line,
2966 signature: elem.node.signature.clone(),
2967 is_entry_point: index == 0 && elem.node.is_entry_point,
2968 })
2969 .collect();
2970 callgraph::TracePath { hops }
2971 })
2972 .collect();
2973 paths.sort_by(|left, right| {
2974 let left_entry = left
2975 .hops
2976 .first()
2977 .map(|hop| hop.symbol.as_str())
2978 .unwrap_or("");
2979 let right_entry = right
2980 .hops
2981 .first()
2982 .map(|hop| hop.symbol.as_str())
2983 .unwrap_or("");
2984 left_entry
2985 .cmp(right_entry)
2986 .then(left.hops.len().cmp(&right.hops.len()))
2987 });
2988 let entry_points_found = paths
2989 .iter()
2990 .filter_map(|path| path.hops.first())
2991 .filter(|hop| hop.is_entry_point)
2992 .map(|hop| (hop.file.clone(), hop.symbol.clone()))
2993 .collect::<HashSet<_>>()
2994 .len();
2995
2996 Ok(callgraph::TraceToResult {
2997 target_symbol: target.symbol,
2998 target_file: target.file,
2999 total_paths: paths.len(),
3000 paths,
3001 entry_points_found,
3002 max_depth_reached,
3003 truncated_paths,
3004 })
3005 }
3006
3007 pub fn trace_to_symbol_candidates(
3008 &self,
3009 to_symbol: &str,
3010 ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
3011 self.refresh_read_marker()?;
3012 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3013 self.ensure_ready(&conn)?;
3014 let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
3015 for node in nodes_matching_symbol(&conn, to_symbol)? {
3016 candidates_by_file
3017 .entry(node.file)
3018 .and_modify(|line| *line = (*line).min(node.line))
3019 .or_insert(node.line);
3020 }
3021 let mut candidates: Vec<_> = candidates_by_file
3022 .into_iter()
3023 .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
3024 .collect();
3025 candidates
3026 .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
3027 Ok(candidates)
3028 }
3029
3030 pub fn trace_to_symbol(
3031 &self,
3032 file_rel: &Path,
3033 symbol: &str,
3034 to_symbol: &str,
3035 to_file: Option<&Path>,
3036 max_depth: usize,
3037 ) -> Result<callgraph::TraceToSymbolResult> {
3038 let origin = self.node_for(file_rel, symbol)?;
3039 let target_file = to_file
3040 .map(|path| normalize_file_path(&self.project_root, path))
3041 .transpose()?
3042 .map(|path| relative_path(&self.project_root, &path));
3043 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3044 self.ensure_ready(&conn)?;
3045 let effective_max = if max_depth == 0 {
3046 10
3047 } else {
3048 max_depth.min(16)
3049 };
3050
3051 let start_hop = trace_to_symbol_hop(&origin);
3052 if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
3053 return Ok(callgraph::TraceToSymbolResult {
3054 path: Some(vec![start_hop]),
3055 complete: true,
3056 reason: None,
3057 });
3058 }
3059
3060 let mut queue = VecDeque::new();
3061 queue.push_back((origin.clone(), vec![start_hop], 0usize));
3062 let mut visited = HashSet::new();
3063 visited.insert((origin.file.clone(), origin.symbol.clone()));
3064 let mut max_depth_exhausted = false;
3065
3066 while let Some((current, path, depth)) = queue.pop_front() {
3067 let callees = outgoing_calls_for_node(&conn, ¤t)?
3068 .into_iter()
3069 .filter_map(|site| site.target)
3070 .collect::<Vec<_>>();
3071
3072 if depth >= effective_max {
3073 if callees
3074 .iter()
3075 .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
3076 {
3077 max_depth_exhausted = true;
3078 }
3079 continue;
3080 }
3081
3082 for callee in callees {
3083 if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
3084 continue;
3085 }
3086 let mut next_path = path.clone();
3087 next_path.push(trace_to_symbol_hop(&callee));
3088 if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
3089 return Ok(callgraph::TraceToSymbolResult {
3090 path: Some(next_path),
3091 complete: true,
3092 reason: None,
3093 });
3094 }
3095 queue.push_back((callee, next_path, depth + 1));
3096 }
3097 }
3098
3099 if max_depth_exhausted {
3100 Ok(callgraph::TraceToSymbolResult {
3101 path: None,
3102 complete: false,
3103 reason: Some("max_depth_exhausted".to_string()),
3104 })
3105 } else {
3106 Ok(callgraph::TraceToSymbolResult {
3107 path: None,
3108 complete: true,
3109 reason: Some("no_path_found".to_string()),
3110 })
3111 }
3112 }
3113}
3114
3115impl ReadonlyCallGraphStore {
3116 fn from_inner(inner: CallGraphStore) -> Self {
3117 Self { inner }
3118 }
3119
3120 fn into_inner(self) -> CallGraphStore {
3121 self.inner
3122 }
3123
3124 pub fn project_root(&self) -> &Path {
3125 self.inner.project_root()
3126 }
3127
3128 pub fn project_key(&self) -> &str {
3129 self.inner.project_key()
3130 }
3131
3132 pub fn sqlite_path(&self) -> &Path {
3133 self.inner.sqlite_path()
3134 }
3135
3136 pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
3139 crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
3140 }
3141
3142 pub fn is_legacy_fallback(&self) -> bool {
3144 self.inner.is_legacy_fallback()
3145 }
3146
3147 pub fn is_current(&self) -> bool {
3148 self.inner.is_current()
3149 }
3150
3151 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3152 self.inner.edge_snapshot()
3153 }
3154
3155 pub fn indexed_file_count(&self) -> Result<usize> {
3156 self.inner.indexed_file_count()
3157 }
3158
3159 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3160 self.inner.node_for(file_rel, symbol)
3161 }
3162
3163 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3164 self.inner.nodes_for(file_rel, symbol)
3165 }
3166
3167 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3168 self.inner.nodes_matching(symbol)
3169 }
3170
3171 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3172 self.inner.direct_callers_of(file_rel, symbol)
3173 }
3174
3175 pub fn direct_caller_counts_of(
3176 &self,
3177 targets: &[(String, String)],
3178 ) -> Result<HashMap<(String, String), usize>> {
3179 self.inner.direct_caller_counts_of(targets)
3180 }
3181
3182 pub fn callers_of(
3183 &self,
3184 file_rel: &Path,
3185 symbol: &str,
3186 depth: usize,
3187 ) -> Result<StoreCallersResult> {
3188 self.inner.callers_of(file_rel, symbol, depth)
3189 }
3190
3191 pub fn impact_of(
3192 &self,
3193 file_rel: &Path,
3194 symbol: &str,
3195 depth: usize,
3196 ) -> Result<StoreImpactResult> {
3197 self.inner.impact_of(file_rel, symbol, depth)
3198 }
3199
3200 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3201 self.inner.outgoing_calls_of(node)
3202 }
3203
3204 pub fn outgoing_calls_for_symbols(
3205 &self,
3206 sources: &[(String, String)],
3207 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3208 self.inner.outgoing_calls_for_symbols(sources)
3209 }
3210
3211 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3212 self.inner.resolved_self_calls_of(node)
3213 }
3214
3215 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3216 self.inner.unresolved_calls_of(node)
3217 }
3218
3219 pub fn call_tree(
3220 &self,
3221 file_rel: &Path,
3222 symbol: &str,
3223 depth: usize,
3224 ) -> Result<callgraph::CallTreeNode> {
3225 self.inner.call_tree(file_rel, symbol, depth)
3226 }
3227
3228 pub fn trace_to(
3229 &self,
3230 file_rel: &Path,
3231 symbol: &str,
3232 max_depth: usize,
3233 ) -> Result<callgraph::TraceToResult> {
3234 self.inner.trace_to(file_rel, symbol, max_depth)
3235 }
3236
3237 pub fn trace_to_symbol_candidates(
3238 &self,
3239 to_symbol: &str,
3240 ) -> Result<Vec<TraceToSymbolCandidate>> {
3241 self.inner.trace_to_symbol_candidates(to_symbol)
3242 }
3243
3244 pub fn trace_to_symbol(
3245 &self,
3246 file_rel: &Path,
3247 symbol: &str,
3248 to_symbol: &str,
3249 to_file: Option<&Path>,
3250 max_depth: usize,
3251 ) -> Result<callgraph::TraceToSymbolResult> {
3252 self.inner
3253 .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3254 }
3255}
3256
3257impl CallGraphRead for CallGraphStore {
3258 fn project_root(&self) -> &Path {
3259 CallGraphStore::project_root(self)
3260 }
3261 fn project_key(&self) -> &str {
3262 CallGraphStore::project_key(self)
3263 }
3264 fn sqlite_path(&self) -> &Path {
3265 CallGraphStore::sqlite_path(self)
3266 }
3267 fn is_current(&self) -> bool {
3268 CallGraphStore::is_current(self)
3269 }
3270 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3271 CallGraphStore::edge_snapshot(self)
3272 }
3273 fn indexed_file_count(&self) -> Result<usize> {
3274 CallGraphStore::indexed_file_count(self)
3275 }
3276 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3277 CallGraphStore::node_for(self, file_rel, symbol)
3278 }
3279 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3280 CallGraphStore::nodes_for(self, file_rel, symbol)
3281 }
3282 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3283 CallGraphStore::nodes_matching(self, symbol)
3284 }
3285 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3286 CallGraphStore::direct_callers_of(self, file_rel, symbol)
3287 }
3288 fn direct_caller_counts_of(
3289 &self,
3290 targets: &[(String, String)],
3291 ) -> Result<HashMap<(String, String), usize>> {
3292 CallGraphStore::direct_caller_counts_of(self, targets)
3293 }
3294 fn callers_of(
3295 &self,
3296 file_rel: &Path,
3297 symbol: &str,
3298 depth: usize,
3299 ) -> Result<StoreCallersResult> {
3300 CallGraphStore::callers_of(self, file_rel, symbol, depth)
3301 }
3302 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3303 CallGraphStore::impact_of(self, file_rel, symbol, depth)
3304 }
3305 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3306 CallGraphStore::outgoing_calls_of(self, node)
3307 }
3308 fn outgoing_calls_for_symbols(
3309 &self,
3310 sources: &[(String, String)],
3311 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3312 CallGraphStore::outgoing_calls_for_symbols(self, sources)
3313 }
3314 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3315 CallGraphStore::resolved_self_calls_of(self, node)
3316 }
3317 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3318 CallGraphStore::unresolved_calls_of(self, node)
3319 }
3320 fn call_tree(
3321 &self,
3322 file_rel: &Path,
3323 symbol: &str,
3324 depth: usize,
3325 ) -> Result<callgraph::CallTreeNode> {
3326 CallGraphStore::call_tree(self, file_rel, symbol, depth)
3327 }
3328 fn trace_to(
3329 &self,
3330 file_rel: &Path,
3331 symbol: &str,
3332 max_depth: usize,
3333 ) -> Result<callgraph::TraceToResult> {
3334 CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
3335 }
3336 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3337 CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
3338 }
3339 fn trace_to_symbol(
3340 &self,
3341 file_rel: &Path,
3342 symbol: &str,
3343 to_symbol: &str,
3344 to_file: Option<&Path>,
3345 max_depth: usize,
3346 ) -> Result<callgraph::TraceToSymbolResult> {
3347 CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
3348 }
3349}
3350
3351impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
3352 fn project_root(&self) -> &Path {
3353 (**self).project_root()
3354 }
3355 fn project_key(&self) -> &str {
3356 (**self).project_key()
3357 }
3358 fn sqlite_path(&self) -> &Path {
3359 (**self).sqlite_path()
3360 }
3361 fn is_current(&self) -> bool {
3362 (**self).is_current()
3363 }
3364 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3365 (**self).edge_snapshot()
3366 }
3367 fn indexed_file_count(&self) -> Result<usize> {
3368 (**self).indexed_file_count()
3369 }
3370 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3371 (**self).node_for(file_rel, symbol)
3372 }
3373 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3374 (**self).nodes_for(file_rel, symbol)
3375 }
3376 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3377 (**self).nodes_matching(symbol)
3378 }
3379 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3380 (**self).direct_callers_of(file_rel, symbol)
3381 }
3382 fn direct_caller_counts_of(
3383 &self,
3384 targets: &[(String, String)],
3385 ) -> Result<HashMap<(String, String), usize>> {
3386 (**self).direct_caller_counts_of(targets)
3387 }
3388 fn callers_of(
3389 &self,
3390 file_rel: &Path,
3391 symbol: &str,
3392 depth: usize,
3393 ) -> Result<StoreCallersResult> {
3394 (**self).callers_of(file_rel, symbol, depth)
3395 }
3396 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3397 (**self).impact_of(file_rel, symbol, depth)
3398 }
3399 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3400 (**self).outgoing_calls_of(node)
3401 }
3402 fn outgoing_calls_for_symbols(
3403 &self,
3404 sources: &[(String, String)],
3405 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3406 (**self).outgoing_calls_for_symbols(sources)
3407 }
3408 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3409 (**self).resolved_self_calls_of(node)
3410 }
3411 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3412 (**self).unresolved_calls_of(node)
3413 }
3414 fn call_tree(
3415 &self,
3416 file_rel: &Path,
3417 symbol: &str,
3418 depth: usize,
3419 ) -> Result<callgraph::CallTreeNode> {
3420 (**self).call_tree(file_rel, symbol, depth)
3421 }
3422 fn trace_to(
3423 &self,
3424 file_rel: &Path,
3425 symbol: &str,
3426 max_depth: usize,
3427 ) -> Result<callgraph::TraceToResult> {
3428 (**self).trace_to(file_rel, symbol, max_depth)
3429 }
3430 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3431 (**self).trace_to_symbol_candidates(to_symbol)
3432 }
3433 fn trace_to_symbol(
3434 &self,
3435 file_rel: &Path,
3436 symbol: &str,
3437 to_symbol: &str,
3438 to_file: Option<&Path>,
3439 max_depth: usize,
3440 ) -> Result<callgraph::TraceToSymbolResult> {
3441 (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3442 }
3443}
3444
3445impl CallGraphRead for ReadonlyCallGraphStore {
3446 fn project_root(&self) -> &Path {
3447 self.project_root()
3448 }
3449 fn project_key(&self) -> &str {
3450 self.project_key()
3451 }
3452 fn sqlite_path(&self) -> &Path {
3453 self.sqlite_path()
3454 }
3455 fn is_current(&self) -> bool {
3456 self.is_current()
3457 }
3458 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3459 self.edge_snapshot()
3460 }
3461 fn indexed_file_count(&self) -> Result<usize> {
3462 self.indexed_file_count()
3463 }
3464 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3465 self.node_for(file_rel, symbol)
3466 }
3467 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3468 self.nodes_for(file_rel, symbol)
3469 }
3470 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3471 self.nodes_matching(symbol)
3472 }
3473 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3474 self.direct_callers_of(file_rel, symbol)
3475 }
3476 fn direct_caller_counts_of(
3477 &self,
3478 targets: &[(String, String)],
3479 ) -> Result<HashMap<(String, String), usize>> {
3480 self.direct_caller_counts_of(targets)
3481 }
3482 fn callers_of(
3483 &self,
3484 file_rel: &Path,
3485 symbol: &str,
3486 depth: usize,
3487 ) -> Result<StoreCallersResult> {
3488 self.callers_of(file_rel, symbol, depth)
3489 }
3490 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3491 self.impact_of(file_rel, symbol, depth)
3492 }
3493 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3494 self.outgoing_calls_of(node)
3495 }
3496 fn outgoing_calls_for_symbols(
3497 &self,
3498 sources: &[(String, String)],
3499 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3500 self.outgoing_calls_for_symbols(sources)
3501 }
3502 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3503 self.resolved_self_calls_of(node)
3504 }
3505 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3506 self.unresolved_calls_of(node)
3507 }
3508 fn call_tree(
3509 &self,
3510 file_rel: &Path,
3511 symbol: &str,
3512 depth: usize,
3513 ) -> Result<callgraph::CallTreeNode> {
3514 self.call_tree(file_rel, symbol, depth)
3515 }
3516 fn trace_to(
3517 &self,
3518 file_rel: &Path,
3519 symbol: &str,
3520 max_depth: usize,
3521 ) -> Result<callgraph::TraceToResult> {
3522 self.trace_to(file_rel, symbol, max_depth)
3523 }
3524 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3525 self.trace_to_symbol_candidates(to_symbol)
3526 }
3527 fn trace_to_symbol(
3528 &self,
3529 file_rel: &Path,
3530 symbol: &str,
3531 to_symbol: &str,
3532 to_file: Option<&Path>,
3533 max_depth: usize,
3534 ) -> Result<callgraph::TraceToSymbolResult> {
3535 self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3536 }
3537}
3538
3539fn indexed_file_count(conn: &Connection) -> Result<usize> {
3540 let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
3541 Ok(count.max(0) as usize)
3542}
3543
3544fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
3545 let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
3546 match candidates.as_slice() {
3547 [candidate] => Ok(candidate.clone()),
3548 [] => Err(AftError::SymbolNotFound {
3549 name: symbol.to_string(),
3550 file: rel_path.to_string(),
3551 }
3552 .into()),
3553 _ => Err(AftError::AmbiguousSymbol {
3554 name: symbol.to_string(),
3555 candidates: candidates
3556 .iter()
3557 .map(|candidate| candidate.symbol.clone())
3558 .collect(),
3559 }
3560 .into()),
3561 }
3562}
3563
3564fn nodes_for_file_matching_symbol(
3565 conn: &Connection,
3566 rel_path: &str,
3567 symbol: &str,
3568) -> Result<Vec<StoreNode>> {
3569 let qualified_query = symbol.contains("::");
3570 let sql = if qualified_query {
3571 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3572 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3573 FROM nodes n JOIN files f ON f.path = n.file_path
3574 WHERE n.file_path = ?1 AND n.scoped_name = ?2
3575 ORDER BY n.scoped_name, n.start_line, n.start_col"
3576 } else {
3577 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3578 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3579 FROM nodes n JOIN files f ON f.path = n.file_path
3580 WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
3581 ORDER BY n.scoped_name, n.start_line, n.start_col"
3582 };
3583 let mut stmt = conn.prepare(sql)?;
3584 let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
3585 rows.collect::<std::result::Result<Vec<_>, _>>()
3586 .map_err(Into::into)
3587}
3588
3589fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
3590 let qualified_query = symbol.contains("::");
3591 let sql = if qualified_query {
3592 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3593 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3594 FROM nodes n JOIN files f ON f.path = n.file_path
3595 WHERE n.scoped_name = ?1
3596 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
3597 } else {
3598 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3599 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3600 FROM nodes n JOIN files f ON f.path = n.file_path
3601 WHERE n.scoped_name = ?1 OR n.name = ?1
3602 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
3603 };
3604 let mut stmt = conn.prepare(sql)?;
3605 let rows = stmt.query_map(params![symbol], store_node_from_row)?;
3606 rows.collect::<std::result::Result<Vec<_>, _>>()
3607 .map_err(Into::into)
3608}
3609
3610fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
3611 store_node_from_row_at(row, 0)
3612}
3613
3614fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
3615 let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
3616 let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
3617 let lang_label_value: String = row.get(offset + 10)?;
3618 Ok(StoreNode {
3619 node_id: row.get(offset)?,
3620 file: row.get(offset + 1)?,
3621 symbol: row.get(offset + 2)?,
3622 name: row.get(offset + 3)?,
3623 kind: row.get(offset + 4)?,
3624 line: start_line.saturating_add(1),
3625 end_line: end_line.saturating_add(1),
3626 signature: row.get(offset + 7)?,
3627 exported: row.get::<_, i64>(offset + 8)? != 0,
3628 is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
3629 lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
3630 })
3631}
3632
3633fn optional_store_node_from_row_at(
3634 row: &rusqlite::Row<'_>,
3635 offset: usize,
3636) -> rusqlite::Result<Option<StoreNode>> {
3637 if row.get::<_, Option<String>>(offset)?.is_some() {
3638 store_node_from_row_at(row, offset).map(Some)
3639 } else {
3640 Ok(None)
3641 }
3642}
3643
3644#[allow(clippy::too_many_arguments)]
3645fn collect_callers_recursive(
3646 conn: &Connection,
3647 file: &str,
3648 symbol: &str,
3649 max_depth: usize,
3650 current_depth: usize,
3651 visited: &mut HashSet<(String, String)>,
3652 result: &mut Vec<StoreCallSite>,
3653 depth_limited: &mut bool,
3654 truncated: &mut usize,
3655) -> Result<()> {
3656 if current_depth >= max_depth {
3657 let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
3658 if omitted > 0 {
3659 *depth_limited = true;
3660 *truncated += omitted;
3661 }
3662 return Ok(());
3663 }
3664
3665 if !visited.insert((file.to_string(), symbol.to_string())) {
3666 return Ok(());
3667 }
3668
3669 let sites = direct_callers_for_tuple(conn, file, symbol)?;
3670 for site in sites {
3671 result.push(site.clone());
3672 if current_depth + 1 < max_depth {
3673 collect_callers_recursive(
3674 conn,
3675 &site.caller.file,
3676 &site.caller.symbol,
3677 max_depth,
3678 current_depth + 1,
3679 visited,
3680 result,
3681 depth_limited,
3682 truncated,
3683 )?;
3684 } else {
3685 let omitted =
3686 direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
3687 if omitted > 0 {
3688 *depth_limited = true;
3689 *truncated += omitted;
3690 }
3691 }
3692 }
3693 Ok(())
3694}
3695
3696const DIRECT_CALLER_COUNT_BATCH_SIZE: usize = 499;
3698
3699fn direct_caller_counts_for_tuples(
3700 conn: &Connection,
3701 targets: &[(String, String)],
3702) -> Result<HashMap<(String, String), usize>> {
3703 let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
3704 let mut counts = unique_targets
3705 .iter()
3706 .cloned()
3707 .map(|target| (target, 0usize))
3708 .collect::<HashMap<_, _>>();
3709
3710 let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
3711 for chunk in unique_targets.chunks(DIRECT_CALLER_COUNT_BATCH_SIZE) {
3712 let requested_values = (0..chunk.len())
3713 .map(|_| "(?, ?)")
3714 .collect::<Vec<_>>()
3715 .join(", ");
3716 let sql = format!(
3717 "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
3718 deduped AS (
3719 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
3720 FROM requested requested
3721 JOIN edges e
3722 ON e.target_file = requested.target_file
3723 AND e.target_symbol = requested.target_symbol
3724 AND e.kind = 'call'
3725 JOIN refs r ON r.ref_id = e.ref_id
3726 JOIN nodes src ON src.id = e.source_node
3727 JOIN files src_file ON src_file.path = src.file_path
3728 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
3729 )
3730 SELECT target_file, target_symbol, COUNT(*)
3731 FROM deduped
3732 GROUP BY target_file, target_symbol"
3733 );
3734 let bindings = chunk
3735 .iter()
3736 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
3737 let mut stmt = conn.prepare(&sql)?;
3738 let rows = stmt.query_map(params_from_iter(bindings), |row| {
3739 Ok((
3740 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
3741 row.get::<_, i64>(2)?,
3742 ))
3743 })?;
3744 for row in rows {
3745 let (target, count) = row?;
3746 counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
3747 }
3748 }
3749
3750 Ok(counts)
3751}
3752
3753fn direct_caller_count_for_tuple(
3754 conn: &Connection,
3755 target_file: &str,
3756 target_symbol: &str,
3757) -> Result<usize> {
3758 let count: i64 = conn.query_row(
3759 "SELECT COUNT(*)
3760 FROM edges e
3761 JOIN refs r ON r.ref_id = e.ref_id
3762 JOIN nodes src ON src.id = e.source_node
3763 JOIN files src_file ON src_file.path = src.file_path
3764 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
3765 params![target_file, target_symbol],
3766 |row| row.get(0),
3767 )?;
3768 Ok(usize::try_from(count).unwrap_or(usize::MAX))
3769}
3770
3771fn direct_callers_for_tuple(
3772 conn: &Connection,
3773 target_file: &str,
3774 target_symbol: &str,
3775) -> Result<Vec<StoreCallSite>> {
3776 let mut stmt = conn.prepare(
3777 "SELECT e.target_file, e.target_symbol, e.line,
3778 r.byte_start, r.byte_end, r.status, e.provenance,
3779 src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
3780 src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
3781 src_file.lang,
3782 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
3783 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
3784 tgt_file.lang
3785 FROM edges e
3786 JOIN refs r ON r.ref_id = e.ref_id
3787 JOIN nodes src ON src.id = e.source_node
3788 JOIN files src_file ON src_file.path = src.file_path
3789 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
3790 ON tgt.id = e.target_node
3791 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
3792 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
3793 )?;
3794 let rows = stmt.query_map(params![target_file, target_symbol], |row| {
3795 let caller = store_node_from_row_at(row, 7)?;
3796 let target = optional_store_node_from_row_at(row, 18)?;
3797 Ok(StoreCallSite {
3798 caller,
3799 target_file: row.get(0)?,
3800 target_symbol: row.get(1)?,
3801 target,
3802 line: row.get::<_, i64>(2)?.max(0) as u32,
3803 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
3804 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
3805 resolved: row.get::<_, String>(5)? == "resolved",
3806 provenance: row.get(6)?,
3807 })
3808 })?;
3809 rows.collect::<std::result::Result<Vec<_>, _>>()
3810 .map_err(Into::into)
3811}
3812
3813const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
3815const OUTGOING_NODE_BATCH_SIZE: usize = 999;
3817
3818fn outgoing_calls_for_symbol_tuples(
3819 conn: &Connection,
3820 sources: &[(String, String)],
3821) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3822 let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
3823 let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
3824 let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
3825 let source_nodes = unique_sources
3826 .iter()
3827 .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
3828 .cloned()
3829 .collect::<Vec<_>>();
3830 let source_nodes_by_id = source_nodes
3831 .iter()
3832 .cloned()
3833 .map(|node| (node.node_id.clone(), node))
3834 .collect::<HashMap<_, _>>();
3835 let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
3836
3837 for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
3838 let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
3839 let sql = format!(
3840 "SELECT e.source_node,
3841 e.target_file, e.target_symbol, e.line,
3842 r.byte_start, r.byte_end, r.status, e.provenance,
3843 CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
3844 tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
3845 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
3846 tgt_file.lang
3847 FROM edges e
3848 JOIN refs r ON r.ref_id = e.ref_id
3849 LEFT JOIN nodes tgt ON tgt.id = e.target_node
3850 LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
3851 WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
3852 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
3853 );
3854 let bindings = chunk.iter().map(|node| node.node_id.as_str());
3855 let mut stmt = conn.prepare(&sql)?;
3856 let rows = stmt.query_map(params_from_iter(bindings), |row| {
3857 let source_node_id = row.get::<_, String>(0)?;
3858 let caller = source_nodes_by_id
3859 .get(&source_node_id)
3860 .expect("batched outgoing row belongs to a requested source node")
3861 .clone();
3862 let target = optional_store_node_from_row_at(row, 8)?;
3863 Ok((
3864 source_node_id,
3865 StoreCallSite {
3866 caller,
3867 target_file: row.get(1)?,
3868 target_symbol: row.get(2)?,
3869 target,
3870 line: row.get::<_, i64>(3)?.max(0) as u32,
3871 byte_start: row.get::<_, i64>(4)?.max(0) as usize,
3872 byte_end: row.get::<_, i64>(5)?.max(0) as usize,
3873 resolved: row.get::<_, String>(6)? == "resolved",
3874 provenance: row.get(7)?,
3875 },
3876 ))
3877 })?;
3878 for row in rows {
3879 let (source_node_id, call) = row?;
3880 calls_by_node.entry(source_node_id).or_default().push(call);
3881 }
3882 }
3883
3884 let mut calls_by_source = HashMap::new();
3885 for source in &unique_sources {
3886 let mut calls = Vec::new();
3887 if let Some(nodes) = source_nodes_by_symbol.get(source) {
3888 for node in nodes {
3889 if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
3890 calls.extend(node_calls);
3891 }
3892 }
3893 }
3894 calls_by_source.insert(source.clone(), calls);
3895 }
3896
3897 let target_tuples = calls_by_source
3900 .values()
3901 .flatten()
3902 .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
3903 .collect::<Vec<_>>();
3904 let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
3905 for calls in calls_by_source.values_mut() {
3906 for call in calls {
3907 if let Some(target) = target_nodes
3908 .get(&(call.target_file.clone(), call.target_symbol.clone()))
3909 .and_then(|nodes| nodes.first())
3910 {
3911 call.target = Some(target.clone());
3912 }
3913 }
3914 }
3915
3916 Ok(calls_by_source)
3917}
3918
3919fn nodes_for_symbol_tuples(
3920 conn: &Connection,
3921 symbols: &[(String, String)],
3922) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
3923 let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
3924 let mut nodes_by_symbol = unique_symbols
3925 .iter()
3926 .cloned()
3927 .map(|symbol| (symbol, Vec::new()))
3928 .collect::<HashMap<_, _>>();
3929 let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
3930
3931 for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
3932 let requested_values = (0..chunk.len())
3933 .map(|_| "(?, ?)")
3934 .collect::<Vec<_>>()
3935 .join(", ");
3936 let sql = format!(
3937 "WITH requested(file, symbol) AS (VALUES {requested_values})
3938 SELECT requested.file, requested.symbol,
3939 node.id, node.file_path, node.scoped_name, node.name, node.kind,
3940 node.start_line, node.end_line, node.signature, node.exported,
3941 node.is_callgraph_entry_point, node_file.lang
3942 FROM requested
3943 JOIN nodes node INDEXED BY idx_nodes_file
3944 ON node.file_path = requested.file
3945 AND node.scoped_name = requested.symbol
3946 JOIN files node_file ON node_file.path = node.file_path
3947 ORDER BY requested.file, requested.symbol,
3948 node.scoped_name, node.start_line, node.end_line,
3949 node.start_col, node.range_ordinal"
3950 );
3951 let bindings = chunk
3952 .iter()
3953 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
3954 let mut stmt = conn.prepare(&sql)?;
3955 let rows = stmt.query_map(params_from_iter(bindings), |row| {
3956 Ok((
3957 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
3958 store_node_from_row_at(row, 2)?,
3959 ))
3960 })?;
3961 for row in rows {
3962 let (symbol, node) = row?;
3963 nodes_by_symbol.entry(symbol).or_default().push(node);
3964 }
3965 }
3966
3967 Ok(nodes_by_symbol)
3968}
3969
3970fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3971 let mut stmt = conn.prepare(
3972 "SELECT e.target_file, e.target_symbol, e.line,
3973 r.byte_start, r.byte_end, r.status, e.provenance,
3974 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
3975 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
3976 tgt_file.lang
3977 FROM edges e
3978 JOIN refs r ON r.ref_id = e.ref_id
3979 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
3980 ON tgt.id = e.target_node
3981 WHERE e.kind = 'call' AND e.source_node = ?1
3982 ORDER BY r.byte_start, r.line, r.ref_id",
3983 )?;
3984 let rows = stmt.query_map(params![node.node_id], |row| {
3985 let target = optional_store_node_from_row_at(row, 7)?;
3986 Ok(StoreCallSite {
3987 caller: node.clone(),
3988 target_file: row.get(0)?,
3989 target_symbol: row.get(1)?,
3990 target,
3991 line: row.get::<_, i64>(2)?.max(0) as u32,
3992 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
3993 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
3994 resolved: row.get::<_, String>(5)? == "resolved",
3995 provenance: row.get(6)?,
3996 })
3997 })?;
3998 rows.collect::<std::result::Result<Vec<_>, _>>()
3999 .map_err(Into::into)
4000}
4001
4002fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4003 let mut stmt = conn.prepare(
4004 "SELECT r.target_file, r.target_symbol, r.line,
4005 r.byte_start, r.byte_end, r.status, r.provenance,
4006 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4007 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4008 tgt_file.lang
4009 FROM refs r
4010 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4011 ON tgt.id = r.target_node
4012 WHERE r.caller_node = ?1
4013 AND r.kind = 'call'
4014 AND r.status <> 'unresolved'
4015 AND r.target_file = ?2
4016 AND r.target_symbol = ?3
4017 AND r.provenance = ?4
4018 AND NOT EXISTS (
4019 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
4020 )
4021 ORDER BY r.byte_start, r.line, r.ref_id",
4022 )?;
4023 let rows = stmt.query_map(
4024 params![
4025 &node.node_id,
4026 &node.file,
4027 &node.symbol,
4028 PROVENANCE_TREESITTER
4029 ],
4030 |row| {
4031 let target = optional_store_node_from_row_at(row, 7)?;
4032 Ok(StoreCallSite {
4033 caller: node.clone(),
4034 target_file: row.get(0)?,
4035 target_symbol: row.get(1)?,
4036 target,
4037 line: row.get::<_, i64>(2)?.max(0) as u32,
4038 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4039 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4040 resolved: row.get::<_, String>(5)? == "resolved",
4041 provenance: row.get(6)?,
4042 })
4043 },
4044 )?;
4045 rows.collect::<std::result::Result<Vec<_>, _>>()
4046 .map_err(Into::into)
4047}
4048
4049fn unresolved_calls_for_node(
4050 conn: &Connection,
4051 node: &StoreNode,
4052) -> Result<Vec<StoreUnresolvedCall>> {
4053 let mut stmt = conn.prepare(
4054 "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
4055 FROM refs
4056 WHERE caller_node = ?1
4057 AND kind = 'call'
4058 AND status = 'unresolved'
4059 AND NOT EXISTS (
4060 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4061 )
4062 ORDER BY byte_start, line, ref_id",
4063 )?;
4064 let rows = stmt.query_map(params![node.node_id], |row| {
4065 Ok(StoreUnresolvedCall {
4066 caller: node.clone(),
4067 symbol: row.get(0)?,
4068 full_ref: row.get(1)?,
4069 line: row.get::<_, i64>(2)?.max(0) as u32,
4070 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4071 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4072 })
4073 })?;
4074 rows.collect::<std::result::Result<Vec<_>, _>>()
4075 .map_err(Into::into)
4076}
4077
4078fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
4079 let mut calls = Vec::new();
4080 calls.extend(
4081 outgoing_calls_for_node(conn, node)?
4082 .into_iter()
4083 .map(StoreForwardCall::Resolved),
4084 );
4085 calls.extend(
4086 unresolved_calls_for_node(conn, node)?
4087 .into_iter()
4088 .map(StoreForwardCall::Unresolved),
4089 );
4090 calls.sort_by(|left, right| {
4091 left.byte_start()
4092 .cmp(&right.byte_start())
4093 .then(left.line().cmp(&right.line()))
4094 });
4095 Ok(calls)
4096}
4097
4098fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
4099 let resolved_count: i64 = conn.query_row(
4100 "SELECT COUNT(*)
4101 FROM edges e
4102 JOIN refs r ON r.ref_id = e.ref_id
4103 WHERE e.kind = 'call' AND e.source_node = ?1",
4104 params![&node.node_id],
4105 |row| row.get(0),
4106 )?;
4107 let unresolved_count: i64 = conn.query_row(
4108 "SELECT COUNT(*)
4109 FROM refs
4110 WHERE caller_node = ?1
4111 AND kind = 'call'
4112 AND status = 'unresolved'
4113 AND NOT EXISTS (
4114 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4115 )",
4116 params![&node.node_id],
4117 |row| row.get(0),
4118 )?;
4119 let total = resolved_count.saturating_add(unresolved_count);
4120 Ok(usize::try_from(total).unwrap_or(usize::MAX))
4121}
4122
4123fn call_tree_inner(
4124 conn: &Connection,
4125 node: &StoreNode,
4126 max_depth: usize,
4127 current_depth: usize,
4128 visited: &mut HashSet<(String, String)>,
4129) -> Result<callgraph::CallTreeNode> {
4130 let visit_key = (node.file.clone(), node.symbol.clone());
4131 if visited.contains(&visit_key) {
4132 return Ok(callgraph::CallTreeNode {
4133 name: node.symbol.clone(),
4134 file: node.file.clone(),
4135 line: node.line,
4136 signature: node.signature.clone(),
4137 resolved: true,
4138 children: Vec::new(),
4139 depth_limited: false,
4140 truncated: 0,
4141 });
4142 }
4143 visited.insert(visit_key.clone());
4144
4145 let mut children = Vec::new();
4146 let mut depth_limited = false;
4147 let mut truncated = 0usize;
4148
4149 if current_depth < max_depth {
4150 let calls = forward_calls_for_node(conn, node)?;
4151 for call in calls {
4152 match call {
4153 StoreForwardCall::Resolved(site) => {
4154 if let Some(target) = site.target {
4155 let child =
4156 call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
4157 depth_limited |= child.depth_limited;
4158 truncated += child.truncated;
4159 children.push(child);
4160 } else {
4161 children.push(callgraph::CallTreeNode {
4162 name: site.target_symbol,
4163 file: site.target_file,
4164 line: site.line,
4165 signature: None,
4166 resolved: false,
4167 children: Vec::new(),
4168 depth_limited: false,
4169 truncated: 0,
4170 });
4171 }
4172 }
4173 StoreForwardCall::Unresolved(call) => {
4174 children.push(callgraph::CallTreeNode {
4175 name: call.symbol,
4176 file: call.caller.file,
4177 line: call.line,
4178 signature: None,
4179 resolved: false,
4180 children: Vec::new(),
4181 depth_limited: false,
4182 truncated: 0,
4183 });
4184 }
4185 }
4186 }
4187 } else {
4188 truncated = forward_call_count_for_node(conn, node)?;
4189 depth_limited = truncated > 0;
4190 }
4191
4192 visited.remove(&visit_key);
4193 Ok(callgraph::CallTreeNode {
4194 name: node.symbol.clone(),
4195 file: node.file.clone(),
4196 line: node.line,
4197 signature: node.signature.clone(),
4198 resolved: true,
4199 children,
4200 depth_limited,
4201 truncated,
4202 })
4203}
4204
4205fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
4206 callgraph::TraceToSymbolHop {
4207 symbol: node.symbol.clone(),
4208 file: node.file.clone(),
4209 line: node.line,
4210 }
4211}
4212
4213fn trace_to_symbol_matches_target(
4214 node: &StoreNode,
4215 to_symbol: &str,
4216 to_file: Option<&str>,
4217) -> bool {
4218 if !symbol_query_matches(&node.symbol, to_symbol) {
4219 return false;
4220 }
4221 match to_file {
4222 Some(file) => node.file == file,
4223 None => true,
4224 }
4225}
4226
4227fn symbol_query_matches(symbol: &str, query: &str) -> bool {
4228 symbol == query || unqualified_name(symbol) == query
4229}
4230
4231fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
4232 let source = std::fs::read_to_string(path).ok()?;
4233 Some(source.lines().map(|line| line.trim().to_string()).collect())
4234}
4235
4236#[doc(hidden)]
4237pub fn live_callgraph_edge_snapshot(
4238 project_root: &Path,
4239 files: &[PathBuf],
4240) -> Result<BTreeSet<StoredEdge>> {
4241 let files = normalize_file_list(project_root, files)?;
4242 let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
4243 let mut file_data = Vec::new();
4244 for file in &files {
4245 let canon = canonicalize_path(file);
4246 let data = graph.build_file(&canon)?.clone();
4247 file_data.push((canon, data));
4248 }
4249
4250 let mut edges = BTreeSet::new();
4251 for (caller_file, data) in &file_data {
4252 for (caller_symbol, call_sites) in &data.calls_by_symbol {
4253 for call_site in call_sites {
4254 let resolution = graph.resolve_cross_file_edge(
4255 &call_site.full_callee,
4256 &call_site.callee_name,
4257 caller_file,
4258 &data.import_block,
4259 );
4260 let (target_file, target_symbol) = match resolution {
4261 EdgeResolution::Resolved { file, symbol } => (file, symbol),
4262 EdgeResolution::Unresolved { callee_name } => {
4263 if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
4264 continue;
4265 }
4266 let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
4267 data,
4268 caller_file,
4269 &callee_name,
4270 ) else {
4271 continue;
4272 };
4273 (caller_file.clone(), target_symbol)
4274 }
4275 };
4276 if target_file == *caller_file && target_symbol == *caller_symbol {
4277 continue;
4278 }
4279 edges.insert(StoredEdge {
4280 source_file: relative_path(project_root, caller_file),
4281 source_symbol: caller_symbol.clone(),
4282 target_file: relative_path(project_root, &target_file),
4283 target_symbol,
4284 kind: "call".to_string(),
4285 line: call_site.line,
4286 });
4287 }
4288 }
4289 }
4290 Ok(edges)
4291}
4292
4293fn acquire_writer_lease(
4294 callgraph_dir: &Path,
4295 project_key: &str,
4296 project_root: &Path,
4297) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
4298 crate::root_cache::WriterLease::acquire_shared(
4299 crate::root_cache::RootCacheDomain::Callgraph,
4300 callgraph_dir,
4301 project_key,
4302 project_root,
4303 )
4304 .map_err(CallGraphStoreError::from)
4305}
4306
4307fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
4308 if lease.verify()? {
4309 Ok(())
4310 } else {
4311 Err(CallGraphStoreError::Unavailable(format!(
4312 "callgraph writer lease for key {} lost epoch {}; aborting write",
4313 lease.key(),
4314 lease.epoch()
4315 )))
4316 }
4317}
4318
4319fn legacy_migration_completion_line(
4320 project_key: &str,
4321 method: &str,
4322 legacy_bytes: u64,
4323 migrated_bytes: u64,
4324) -> String {
4325 format!(
4326 "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
4327 )
4328}
4329
4330fn log_legacy_migration_completion(
4331 project_key: &str,
4332 method: &str,
4333 legacy_bytes: u64,
4334 migrated_bytes: u64,
4335) {
4336 crate::slog_info!(
4337 "{}",
4338 legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
4339 );
4340}
4341
4342fn try_legacy_migration_or_fallback(
4343 callgraph_dir: &Path,
4344 project_root: &Path,
4345 project_key: &str,
4346 writer_lease: Arc<crate::root_cache::WriterLease>,
4347) -> Result<Option<CallGraphStore>> {
4348 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
4349 if partitions.is_empty() {
4350 return Ok(None);
4351 }
4352
4353 for partition in &partitions {
4354 if let Some(source) = newest_superseded_legacy_generation(partition)? {
4355 if !migration_disk_floor_allows(&source, callgraph_dir)? {
4356 return open_legacy_fallback_store(
4357 callgraph_dir,
4358 project_root,
4359 project_key,
4360 &partitions,
4361 );
4362 }
4363 match publish_generation_copy_migration(
4364 callgraph_dir,
4365 project_key,
4366 &source,
4367 Arc::clone(&writer_lease),
4368 ) {
4369 Ok(published) => {
4370 log_legacy_migration_completion(
4371 project_key,
4372 "generation_copy",
4373 source.source_bytes,
4374 published.migrated_bytes,
4375 );
4376 return CallGraphStore::open_generation(
4377 callgraph_dir,
4378 project_root.to_path_buf(),
4379 project_key.to_string(),
4380 published.generation,
4381 writer_lease,
4382 )
4383 .map(Some);
4384 }
4385 Err(error) => {
4386 crate::slog_warn!(
4387 "root-keyed callgraph generation-copy migration failed from {}: {}",
4388 source.sqlite_path.display(),
4389 error
4390 );
4391 return open_legacy_fallback_store(
4392 callgraph_dir,
4393 project_root,
4394 project_key,
4395 &partitions,
4396 );
4397 }
4398 }
4399 }
4400
4401 if let Some(source) = current_legacy_generation(partition)? {
4402 if !migration_disk_floor_allows(&source, callgraph_dir)? {
4403 return open_legacy_fallback_store(
4404 callgraph_dir,
4405 project_root,
4406 project_key,
4407 &partitions,
4408 );
4409 }
4410 match publish_backup_migration(
4411 callgraph_dir,
4412 project_key,
4413 &source,
4414 Arc::clone(&writer_lease),
4415 ) {
4416 Ok(published) => {
4417 log_legacy_migration_completion(
4418 project_key,
4419 "sqlite_backup",
4420 source.source_bytes,
4421 published.migrated_bytes,
4422 );
4423 return CallGraphStore::open_generation(
4424 callgraph_dir,
4425 project_root.to_path_buf(),
4426 project_key.to_string(),
4427 published.generation,
4428 writer_lease,
4429 )
4430 .map(Some);
4431 }
4432 Err(error) => {
4433 crate::slog_warn!(
4434 "root-keyed callgraph backup migration failed from {}: {}",
4435 source.sqlite_path.display(),
4436 error
4437 );
4438 return open_legacy_fallback_store(
4439 callgraph_dir,
4440 project_root,
4441 project_key,
4442 &partitions,
4443 );
4444 }
4445 }
4446 }
4447 }
4448
4449 open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
4450}
4451
4452fn open_legacy_fallback_store(
4453 callgraph_dir: &Path,
4454 project_root: &Path,
4455 project_key: &str,
4456 partitions: &[LegacyCallgraphPartition],
4457) -> Result<Option<CallGraphStore>> {
4458 let Some(target) = first_ready_legacy_target(partitions)? else {
4459 return Ok(None);
4460 };
4461 crate::slog_warn!(
4462 "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
4463 target.partition.harness,
4464 target.sqlite_path.display()
4465 );
4466 let conn = open_readonly_connection(&target.sqlite_path)?;
4467 if !database_ready(&conn).unwrap_or(false) {
4468 return Ok(None);
4469 }
4470 let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
4471 let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
4472 Ok(Some(CallGraphStore::from_connection(
4473 project_root.to_path_buf(),
4474 project_key.to_string(),
4475 target.sqlite_path,
4476 callgraph_dir.to_path_buf(),
4477 true,
4478 target.generation,
4479 None,
4480 Some(read_marker),
4481 conn,
4482 )))
4483}
4484
4485fn migration_disk_floor_allows(
4486 source: &LegacyCallgraphTarget,
4487 callgraph_dir: &Path,
4488) -> Result<bool> {
4489 let available = migration_available_disk(callgraph_dir)?;
4490 let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
4491 source.source_bytes,
4492 available,
4493 );
4494 if decision.should_skip_copy() {
4495 crate::slog_warn!(
4496 "{}",
4497 decision.warning_message(&source.sqlite_path, callgraph_dir)
4498 );
4499 return Ok(false);
4500 }
4501 Ok(true)
4502}
4503
4504fn migration_available_disk(path: &Path) -> Result<u64> {
4505 if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
4506 return Ok(bytes);
4507 }
4508 crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
4509}
4510
4511fn legacy_callgraph_partitions(
4512 callgraph_dir: &Path,
4513 project_key: &str,
4514) -> Result<Vec<LegacyCallgraphPartition>> {
4515 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
4516 return Ok(Vec::new());
4517 };
4518 let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
4519 let mut partitions = inventory
4520 .into_iter()
4521 .filter(|entry| {
4522 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
4523 && entry.key == project_key
4524 })
4525 .map(|entry| {
4526 let dir = if entry.path.is_dir() {
4527 entry.path.clone()
4528 } else {
4529 entry
4530 .path
4531 .parent()
4532 .map(Path::to_path_buf)
4533 .unwrap_or_else(|| entry.path.clone())
4534 };
4535 LegacyCallgraphPartition {
4536 harness: entry.harness,
4537 dir,
4538 key: entry.key,
4539 bytes: entry.bytes,
4540 freshness: entry.callgraph_pointer_mtime,
4541 }
4542 })
4543 .collect::<Vec<_>>();
4544 partitions.sort_by(|left, right| {
4545 right
4546 .freshness
4547 .cmp(&left.freshness)
4548 .then_with(|| right.bytes.cmp(&left.bytes))
4549 .then_with(|| left.harness.cmp(&right.harness))
4550 });
4551 Ok(partitions)
4552}
4553
4554fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
4555 let domain_dir = callgraph_dir.parent()?;
4556 if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
4557 return None;
4558 }
4559 domain_dir.parent().map(Path::to_path_buf)
4560}
4561
4562pub(crate) fn all_legacy_partitions_migrated_for_keys(
4563 callgraph_dir: &Path,
4564 configured_keys: &BTreeSet<String>,
4565) -> Result<bool> {
4566 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
4567 return Ok(false);
4568 };
4569 let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
4570 .into_iter()
4571 .filter(|entry| {
4572 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
4573 && configured_keys.contains(&entry.key)
4574 })
4575 .map(|entry| entry.key)
4576 .collect::<BTreeSet<_>>();
4577 if legacy_keys.is_empty() {
4578 return Ok(false);
4579 }
4580
4581 for key in legacy_keys {
4582 let migrated_dir = storage_root.join("callgraph").join(&key);
4583 let Some(generation) = read_pointer(&migrated_dir, &key) else {
4584 return Ok(false);
4585 };
4586 if !migration_generation_requires_manifest(&generation)
4587 || !migration_manifest_valid(&migrated_dir, &generation)
4588 {
4589 return Ok(false);
4590 }
4591 }
4592 Ok(true)
4593}
4594
4595fn newest_superseded_legacy_generation(
4596 partition: &LegacyCallgraphPartition,
4597) -> Result<Option<LegacyCallgraphTarget>> {
4598 let Some(current) = read_pointer(&partition.dir, &partition.key) else {
4599 return Ok(None);
4600 };
4601 let prefix = format!("{}.g", partition.key);
4602 let Ok(entries) = std::fs::read_dir(&partition.dir) else {
4603 return Ok(None);
4604 };
4605 let mut candidates = Vec::new();
4606 for entry in entries.flatten() {
4607 let name = entry.file_name().to_string_lossy().to_string();
4608 if name == current
4609 || name.contains(".tmp.")
4610 || !name.starts_with(&prefix)
4611 || !name.ends_with(".sqlite")
4612 {
4613 continue;
4614 }
4615 let path = entry.path();
4616 if !db_path_ready(&path) {
4617 continue;
4618 }
4619 let modified = entry
4620 .metadata()
4621 .and_then(|metadata| metadata.modified())
4622 .unwrap_or(SystemTime::UNIX_EPOCH);
4623 candidates.push((modified, path, name));
4624 }
4625 candidates.sort_by(|left, right| right.0.cmp(&left.0));
4626 let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
4627 return Ok(None);
4628 };
4629 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
4630 Ok(Some(LegacyCallgraphTarget {
4631 partition: partition.clone(),
4632 sqlite_path,
4633 generation: Some(generation),
4634 source_bytes,
4635 source_blake3: String::new(),
4636 }))
4637}
4638
4639fn current_legacy_generation(
4640 partition: &LegacyCallgraphPartition,
4641) -> Result<Option<LegacyCallgraphTarget>> {
4642 let Some(target) = ready_legacy_target(partition)? else {
4643 return Ok(None);
4644 };
4645 let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
4646 if has_superseded {
4647 return Ok(None);
4648 }
4649 Ok(Some(target))
4650}
4651
4652fn freshest_legacy_fallback_target(
4653 callgraph_dir: &Path,
4654 project_key: &str,
4655) -> Result<Option<LegacyCallgraphTarget>> {
4656 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
4657 first_ready_legacy_target(&partitions)
4658}
4659
4660fn first_ready_legacy_target(
4661 partitions: &[LegacyCallgraphPartition],
4662) -> Result<Option<LegacyCallgraphTarget>> {
4663 for partition in partitions {
4664 if let Some(target) = ready_legacy_target(partition)? {
4665 return Ok(Some(target));
4666 }
4667 }
4668 Ok(None)
4669}
4670
4671fn ready_legacy_target(
4672 partition: &LegacyCallgraphPartition,
4673) -> Result<Option<LegacyCallgraphTarget>> {
4674 if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
4675 let sqlite_path = partition.dir.join(&generation);
4676 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
4677 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
4678 return Ok(Some(LegacyCallgraphTarget {
4679 partition: partition.clone(),
4680 sqlite_path,
4681 generation: Some(generation),
4682 source_bytes,
4683 source_blake3: String::new(),
4684 }));
4685 }
4686 }
4687
4688 let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
4689 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
4690 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
4691 return Ok(Some(LegacyCallgraphTarget {
4692 partition: partition.clone(),
4693 sqlite_path,
4694 generation: None,
4695 source_bytes,
4696 source_blake3: String::new(),
4697 }));
4698 }
4699 Ok(None)
4700}
4701
4702fn publish_generation_copy_migration(
4703 callgraph_dir: &Path,
4704 project_key: &str,
4705 source: &LegacyCallgraphTarget,
4706 writer_lease: Arc<crate::root_cache::WriterLease>,
4707) -> Result<PublishedLegacyMigration> {
4708 let generation = migration_generation_file_name(project_key, "copy");
4709 let temp_path = migration_temp_path(callgraph_dir, &generation);
4710 remove_sqlite_file_set(&temp_path);
4711 copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
4712 fail_after_temp_copy_for_test()?;
4713
4714 let mut source = source.clone();
4715 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
4716 source.source_blake3 = fingerprint.blake3;
4717 let generation = publish_migrated_generation(
4718 callgraph_dir,
4719 project_key,
4720 &generation,
4721 &temp_path,
4722 &source,
4723 fingerprint.bytes,
4724 writer_lease,
4725 "generation_copy",
4726 )?;
4727 Ok(PublishedLegacyMigration {
4728 generation,
4729 migrated_bytes: fingerprint.bytes,
4730 })
4731}
4732
4733fn publish_backup_migration(
4734 callgraph_dir: &Path,
4735 project_key: &str,
4736 source: &LegacyCallgraphTarget,
4737 writer_lease: Arc<crate::root_cache::WriterLease>,
4738) -> Result<PublishedLegacyMigration> {
4739 if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
4740 return Err(CallGraphStoreError::Unavailable(
4741 "legacy callgraph backup migration budget exhausted by test seam".to_string(),
4742 ));
4743 }
4744
4745 let generation = migration_generation_file_name(project_key, "backup");
4746 let temp_path = migration_temp_path(callgraph_dir, &generation);
4747 remove_sqlite_file_set(&temp_path);
4748
4749 let source_conn = open_readonly_connection(&source.sqlite_path)?;
4750 let mut destination = Connection::open(&temp_path)?;
4751 destination.busy_timeout(Duration::from_secs(5))?;
4752 let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
4753 let started = Instant::now();
4754 let mut retries = 0;
4755 loop {
4756 match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
4757 rusqlite::backup::StepResult::Done => break,
4758 rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
4759 rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
4760 retries += 1;
4761 if retries > MIGRATION_BACKUP_RETRY_BUDGET
4762 || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
4763 {
4764 return Err(CallGraphStoreError::Unavailable(format!(
4765 "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
4766 )));
4767 }
4768 std::thread::sleep(Duration::from_millis(20));
4769 }
4770 _ => {
4771 return Err(CallGraphStoreError::Unavailable(
4772 "legacy callgraph backup returned an unknown step result".to_string(),
4773 ));
4774 }
4775 }
4776 }
4777 drop(backup);
4778
4779 let integrity: String =
4780 destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
4781 if integrity != "ok" {
4782 return Err(CallGraphStoreError::Unavailable(format!(
4783 "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
4784 )));
4785 }
4786 if !database_ready(&destination)? {
4787 return Err(CallGraphStoreError::Unavailable(
4788 "legacy callgraph backup produced a database without ready metadata".to_string(),
4789 ));
4790 }
4791 destination.execute_batch("PRAGMA optimize;")?;
4792 drop(destination);
4793 sync_file(&temp_path)?;
4794 fail_after_temp_copy_for_test()?;
4795
4796 let mut source = source.clone();
4797 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
4798 source.source_blake3 = fingerprint.blake3;
4799 let generation = publish_migrated_generation(
4800 callgraph_dir,
4801 project_key,
4802 &generation,
4803 &temp_path,
4804 &source,
4805 fingerprint.bytes,
4806 writer_lease,
4807 "sqlite_backup",
4808 )?;
4809 Ok(PublishedLegacyMigration {
4810 generation,
4811 migrated_bytes: fingerprint.bytes,
4812 })
4813}
4814
4815fn publish_migrated_generation(
4816 callgraph_dir: &Path,
4817 project_key: &str,
4818 generation: &str,
4819 temp_path: &Path,
4820 source: &LegacyCallgraphTarget,
4821 migrated_bytes: u64,
4822 writer_lease: Arc<crate::root_cache::WriterLease>,
4823 method: &str,
4824) -> Result<String> {
4825 let gen_path = callgraph_dir.join(generation);
4826 let publication = publish_if_current(|| {
4827 verify_writer_lease(&writer_lease)?;
4828 remove_sqlite_file_set(&gen_path);
4829 rename_sqlite_file_set(temp_path, &gen_path)?;
4830 crate::fs_lock::sync_parent(&gen_path);
4831
4832 verify_writer_lease(&writer_lease)?;
4833 publish_pointer(callgraph_dir, project_key, generation)?;
4834 write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
4835 Ok(generation.to_string())
4836 });
4837 if matches!(publication, Err(CallGraphStoreError::Superseded)) {
4838 remove_sqlite_file_set(temp_path);
4839 }
4840 publication
4841}
4842
4843fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
4844 if let Some(parent) = destination.parent() {
4845 std::fs::create_dir_all(parent)?;
4846 }
4847 for suffix in SQLITE_FILE_SET_SUFFIXES {
4848 let source_path = sqlite_file_set_path(source, suffix);
4849 if !source_path.is_file() {
4850 continue;
4851 }
4852 let destination_path = sqlite_file_set_path(destination, suffix);
4853 std::fs::copy(&source_path, &destination_path)?;
4854 sync_file(&destination_path)?;
4855 }
4856 Ok(())
4857}
4858
4859fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
4860 for suffix in SQLITE_FILE_SET_SUFFIXES {
4861 let source_path = sqlite_file_set_path(source, suffix);
4862 if !source_path.exists() {
4863 continue;
4864 }
4865 let destination_path = sqlite_file_set_path(destination, suffix);
4866 if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
4867 let _ = std::fs::remove_file(&source_path);
4868 return Err(error.into());
4869 }
4870 }
4871 Ok(())
4872}
4873
4874fn sqlite_file_set_size(path: &Path) -> Result<u64> {
4875 let mut bytes = 0_u64;
4876 for suffix in SQLITE_FILE_SET_SUFFIXES {
4877 let member = sqlite_file_set_path(path, suffix);
4878 if !member.is_file() {
4879 continue;
4880 }
4881 bytes = bytes.saturating_add(member.metadata()?.len());
4882 }
4883 Ok(bytes)
4884}
4885
4886fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
4887 let mut hasher = blake3::Hasher::new();
4888 let mut bytes = 0_u64;
4889 let mut buffer = [0_u8; 64 * 1024];
4890 for suffix in SQLITE_FILE_SET_SUFFIXES {
4891 let member = sqlite_file_set_path(path, suffix);
4892 if !member.is_file() {
4893 continue;
4894 }
4895 hasher.update(suffix.as_bytes());
4896 let mut file = std::fs::File::open(&member)?;
4897 loop {
4898 let read = file.read(&mut buffer)?;
4899 if read == 0 {
4900 break;
4901 }
4902 bytes = bytes.saturating_add(read as u64);
4903 hasher.update(&buffer[..read]);
4904 }
4905 }
4906 Ok(SourceFingerprint {
4907 bytes,
4908 blake3: hash_to_hex(hasher.finalize()),
4909 })
4910}
4911
4912fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
4913 if suffix.is_empty() {
4914 path.to_path_buf()
4915 } else {
4916 PathBuf::from(format!("{}{suffix}", path.display()))
4917 }
4918}
4919
4920fn sync_file(path: &Path) -> Result<()> {
4921 let file = std::fs::OpenOptions::new()
4922 .read(true)
4923 .write(true)
4924 .open(path)?;
4925 file.sync_all()?;
4926 Ok(())
4927}
4928
4929fn fail_after_temp_copy_for_test() -> Result<()> {
4930 if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
4931 return Err(CallGraphStoreError::Unavailable(
4932 "legacy callgraph migration stopped after temp copy by test seam".to_string(),
4933 ));
4934 }
4935 Ok(())
4936}
4937
4938fn migration_generation_file_name(project_key: &str, method: &str) -> String {
4939 format!(
4940 "{project_key}.g{}.{}{}{}.sqlite",
4941 now_nanos(),
4942 std::process::id(),
4943 MIGRATION_GENERATION_TAG,
4944 method
4945 )
4946}
4947
4948fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
4949 callgraph_dir.join(format!(
4950 "{generation}.tmp.{}.{}",
4951 std::process::id(),
4952 now_nanos()
4953 ))
4954}
4955
4956fn write_migration_manifest(
4957 callgraph_dir: &Path,
4958 generation: &str,
4959 source: &LegacyCallgraphTarget,
4960 migrated_bytes: u64,
4961 method: &str,
4962) -> Result<()> {
4963 let manifest_path = migration_manifest_path(callgraph_dir, generation);
4964 let temp_path = manifest_path.with_extension(format!(
4965 "migration.json.tmp.{}.{}",
4966 std::process::id(),
4967 now_nanos()
4968 ));
4969 let manifest = serde_json::json!({
4970 "version": MIGRATION_MANIFEST_VERSION,
4971 "method": method,
4972 "target_generation": generation,
4973 "source_harness": source.partition.harness,
4974 "source_path": source.sqlite_path.display().to_string(),
4975 "source_generation": source.generation,
4976 "source_bytes": source.source_bytes,
4977 "source_blake3": source.source_blake3,
4978 "migrated_bytes": migrated_bytes,
4979 });
4980 {
4981 use std::io::Write as _;
4982 let mut file = std::fs::File::create(&temp_path)?;
4983 file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
4984 file.write_all(b"\n")?;
4985 file.sync_all()?;
4986 }
4987 if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
4988 let _ = std::fs::remove_file(&temp_path);
4989 return Err(error.into());
4990 }
4991 crate::fs_lock::sync_parent(&manifest_path);
4992 Ok(())
4993}
4994
4995fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
4996 callgraph_dir.join(format!("{generation}.migration.json"))
4997}
4998
4999fn migration_generation_requires_manifest(generation: &str) -> bool {
5000 generation.contains(MIGRATION_GENERATION_TAG)
5001}
5002
5003fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
5004 if !migration_generation_requires_manifest(generation) {
5005 return true;
5006 }
5007 let path = migration_manifest_path(callgraph_dir, generation);
5008 let Ok(bytes) = std::fs::read(path) else {
5009 return false;
5010 };
5011 let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
5012 return false;
5013 };
5014 value.get("version").and_then(serde_json::Value::as_u64)
5015 == Some(MIGRATION_MANIFEST_VERSION as u64)
5016 && value
5017 .get("target_generation")
5018 .and_then(serde_json::Value::as_str)
5019 == Some(generation)
5020 && value
5021 .get("source_bytes")
5022 .and_then(serde_json::Value::as_u64)
5023 .is_some_and(|bytes| bytes > 0)
5024 && value
5025 .get("source_blake3")
5026 .and_then(serde_json::Value::as_str)
5027 .is_some_and(|hash| hash.len() == 64)
5028}
5029
5030fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
5031 let pointer_generation = read_pointer(callgraph_dir, project_key);
5032 if let Some(generation) = pointer_generation.as_deref() {
5033 if migration_generation_requires_manifest(generation)
5034 && !migration_manifest_valid(callgraph_dir, generation)
5035 {
5036 let path = callgraph_dir.join(generation);
5037 remove_sqlite_file_set(&path);
5038 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
5039 let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
5040 }
5041 }
5042
5043 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
5044 return;
5045 };
5046 for entry in entries.flatten() {
5047 let name = entry.file_name().to_string_lossy().to_string();
5048 let path = entry.path();
5049 if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
5050 let _ = std::fs::remove_file(path);
5051 continue;
5052 }
5053 if name.starts_with(&format!("{project_key}.g"))
5054 && name.ends_with(".sqlite")
5055 && name.contains(MIGRATION_GENERATION_TAG)
5056 && pointer_generation.as_deref() != Some(&name)
5057 && !migration_manifest_valid(callgraph_dir, &name)
5058 {
5059 remove_sqlite_file_set(&path);
5060 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
5061 }
5062 }
5063 crate::fs_lock::sync_parent(callgraph_dir);
5064}
5065
5066fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
5067 let mut hasher = blake3::Hasher::new();
5068 hasher.update(path.to_string_lossy().as_bytes());
5069 if let Some(generation) = generation {
5070 hasher.update(generation.as_bytes());
5071 }
5072 let digest = hash_to_hex(hasher.finalize());
5073 format!("legacy-{}", &digest[..16])
5074}
5075
5076fn open_readonly_connection(path: &Path) -> Result<Connection> {
5077 let uri = sqlite_readonly_uri(path);
5078 let conn = Connection::open_with_flags(
5079 &uri,
5080 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
5081 )?;
5082 conn.busy_timeout(reader_busy_timeout())?;
5083 conn.execute_batch("PRAGMA query_only=ON;")?;
5084 Ok(conn)
5085}
5086
5087fn reader_busy_timeout() -> Duration {
5088 let jitter = (now_nanos() % 500) as u64;
5089 Duration::from_millis(250 + jitter)
5090}
5091
5092fn sqlite_readonly_uri(path: &Path) -> String {
5093 let raw = path.to_string_lossy().replace('\\', "/");
5094 let encoded = percent_encode_sqlite_uri_path(&raw);
5095 if raw.starts_with('/') {
5096 format!("file://{encoded}?mode=ro")
5097 } else if raw.as_bytes().get(1) == Some(&b':') {
5098 format!("file:///{encoded}?mode=ro")
5099 } else {
5100 format!("file:{encoded}?mode=ro")
5101 }
5102}
5103
5104fn percent_encode_sqlite_uri_path(path: &str) -> String {
5105 let mut encoded = String::with_capacity(path.len());
5106 for byte in path.bytes() {
5107 match byte {
5108 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
5109 encoded.push(byte as char)
5110 }
5111 _ => encoded.push_str(&format!("%{byte:02X}")),
5112 }
5113 }
5114 encoded
5115}
5116
5117fn configure_connection(conn: &Connection) -> Result<()> {
5118 conn.pragma_update(None, "journal_mode", "WAL")?;
5119 conn.pragma_update(None, "busy_timeout", 5_000)?;
5120 Ok(())
5121}
5122
5123fn configure_build_connection(conn: &Connection) -> Result<()> {
5124 conn.pragma_update(None, "journal_mode", "DELETE")?;
5125 conn.pragma_update(None, "busy_timeout", 5_000)?;
5126 Ok(())
5127}
5128
5129fn initialize_schema(conn: &Connection) -> Result<()> {
5130 conn.execute_batch(
5131 "CREATE TABLE IF NOT EXISTS files (
5132 path TEXT PRIMARY KEY,
5133 content_hash TEXT NOT NULL,
5134 mtime_ns INTEGER NOT NULL,
5135 size INTEGER NOT NULL,
5136 lang TEXT NOT NULL,
5137 is_dead_code_root INTEGER NOT NULL DEFAULT 0,
5138 is_public_api INTEGER NOT NULL DEFAULT 0,
5139 surface_fingerprint TEXT NOT NULL,
5140 indexed_at INTEGER NOT NULL
5141 );
5142
5143 CREATE TABLE IF NOT EXISTS nodes (
5144 id TEXT PRIMARY KEY,
5145 file_path TEXT NOT NULL,
5146 name TEXT NOT NULL,
5147 scoped_name TEXT NOT NULL,
5148 kind TEXT NOT NULL,
5149 start_line INTEGER NOT NULL,
5150 start_col INTEGER NOT NULL,
5151 end_line INTEGER NOT NULL,
5152 end_col INTEGER NOT NULL,
5153 range_ordinal INTEGER NOT NULL,
5154 signature TEXT,
5155 exported INTEGER NOT NULL,
5156 is_default_export INTEGER NOT NULL,
5157 is_type_like INTEGER NOT NULL,
5158 is_callgraph_entry_point INTEGER NOT NULL,
5159 provenance TEXT NOT NULL,
5160 UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
5161 );
5162 CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
5163 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
5164 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
5165
5166 CREATE TABLE IF NOT EXISTS refs (
5167 ref_id TEXT PRIMARY KEY,
5168 caller_node TEXT,
5169 caller_file TEXT NOT NULL,
5170 kind TEXT NOT NULL,
5171 short_name TEXT,
5172 full_ref TEXT,
5173 module_path TEXT,
5174 import_kind TEXT,
5175 local_name TEXT,
5176 requested_name TEXT,
5177 namespace_alias TEXT,
5178 wildcard INTEGER NOT NULL DEFAULT 0,
5179 line INTEGER NOT NULL,
5180 byte_start INTEGER NOT NULL,
5181 byte_end INTEGER NOT NULL,
5182 status TEXT NOT NULL,
5183 target_node TEXT,
5184 target_file TEXT,
5185 target_symbol TEXT,
5186 provenance TEXT NOT NULL
5187 );
5188 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
5189 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
5190 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
5191 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
5192 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
5193
5194 CREATE TABLE IF NOT EXISTS file_dependencies (
5195 file_path TEXT NOT NULL,
5196 dep_file TEXT NOT NULL,
5197 PRIMARY KEY(file_path, dep_file)
5198 );
5199 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
5200
5201 CREATE TABLE IF NOT EXISTS edges (
5202 edge_id TEXT PRIMARY KEY,
5203 ref_id TEXT NOT NULL,
5204 source_node TEXT NOT NULL,
5205 target_node TEXT,
5206 target_file TEXT NOT NULL,
5207 target_symbol TEXT NOT NULL,
5208 kind TEXT NOT NULL,
5209 line INTEGER NOT NULL,
5210 provenance TEXT NOT NULL
5211 );
5212 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
5213 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
5214 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
5215 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
5216
5217 CREATE TABLE IF NOT EXISTS dispatch_hints (
5218 id TEXT PRIMARY KEY,
5219 method_name TEXT NOT NULL,
5220 caller_node TEXT NOT NULL,
5221 file TEXT NOT NULL,
5222 line INTEGER NOT NULL,
5223 byte_start INTEGER NOT NULL,
5224 byte_end INTEGER NOT NULL,
5225 provenance TEXT NOT NULL
5226 );
5227 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
5228
5229 CREATE TABLE IF NOT EXISTS type_ref_names (
5230 name TEXT PRIMARY KEY
5231 );
5232
5233 CREATE TABLE IF NOT EXISTS backend_file_state (
5234 backend TEXT NOT NULL,
5235 workspace_root TEXT NOT NULL,
5236 file_path TEXT NOT NULL,
5237 content_hash TEXT NOT NULL,
5238 status TEXT NOT NULL,
5239 updated_at INTEGER NOT NULL,
5240 PRIMARY KEY(backend, workspace_root, file_path, content_hash)
5241 );
5242 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
5243
5244 CREATE TABLE IF NOT EXISTS meta (
5245 k TEXT PRIMARY KEY,
5246 v TEXT NOT NULL
5247 );",
5248 )?;
5249 insert_meta(conn)?;
5250 Ok(())
5251}
5252
5253fn insert_meta(conn: &Connection) -> Result<()> {
5254 conn.execute(
5255 "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
5256 params![SCHEMA_VERSION.to_string()],
5257 )?;
5258 conn.execute(
5259 "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
5260 params![schema_fingerprint()],
5261 )?;
5262 Ok(())
5263}
5264
5265fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
5266 conn.execute(
5267 "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
5268 params![if ready { "1" } else { "0" }],
5269 )?;
5270 Ok(())
5271}
5272
5273fn database_ready(conn: &Connection) -> Result<bool> {
5274 let schema_version: Option<String> = conn
5275 .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
5276 row.get(0)
5277 })
5278 .optional()?;
5279 let fingerprint: Option<String> = conn
5280 .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
5281 row.get(0)
5282 })
5283 .optional()?;
5284 let ready: Option<String> = conn
5285 .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
5286 .optional()?;
5287
5288 let expected_schema = SCHEMA_VERSION.to_string();
5289 let expected_fingerprint = schema_fingerprint();
5290 Ok(schema_version.as_deref() == Some(expected_schema.as_str())
5291 && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
5292 && ready.as_deref() == Some("1"))
5293}
5294
5295fn ensure_database_ready(conn: &Connection) -> Result<()> {
5296 if database_ready(conn)? {
5297 Ok(())
5298 } else {
5299 Err(CallGraphStoreError::Unavailable(
5300 "database is missing, stale, or mid-build".to_string(),
5301 ))
5302 }
5303}
5304
5305fn schema_fingerprint() -> String {
5306 let input =
5311 format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
5312 hash_to_hex(blake3::hash(input.as_bytes()))
5313}
5314
5315fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
5316 tx.execute_batch(
5317 "DELETE FROM edges;
5318 DELETE FROM file_dependencies;
5319 DELETE FROM refs;
5320 DELETE FROM dispatch_hints;
5321 DELETE FROM type_ref_names;
5322 DELETE FROM backend_file_state;
5323 DELETE FROM nodes;
5324 DELETE FROM files;",
5325 )?;
5326 Ok(())
5327}
5328
5329fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
5330 tx.execute_batch(
5331 "DROP INDEX IF EXISTS idx_nodes_file;
5332 DROP INDEX IF EXISTS idx_nodes_name;
5333 DROP INDEX IF EXISTS idx_nodes_scoped;
5334 DROP INDEX IF EXISTS idx_refs_short_name;
5335 DROP INDEX IF EXISTS idx_refs_kind_caller_file;
5336 DROP INDEX IF EXISTS idx_refs_caller_file;
5337 DROP INDEX IF EXISTS idx_refs_caller_node_kind;
5338 DROP INDEX IF EXISTS idx_refs_target_file;
5339 DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
5340 DROP INDEX IF EXISTS idx_edges_source_kind;
5341 DROP INDEX IF EXISTS idx_edges_target_kind;
5342 DROP INDEX IF EXISTS idx_edges_target_file_symbol;
5343 DROP INDEX IF EXISTS idx_edges_ref_id;
5344 DROP INDEX IF EXISTS idx_dispatch_hints_method;
5345 DROP INDEX IF EXISTS idx_backend_file_state_file;",
5346 )?;
5347 Ok(())
5348}
5349
5350fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
5351 tx.execute_batch(
5352 "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
5353 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
5354 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
5355 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
5356 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
5357 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
5358 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
5359 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
5360 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
5361 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
5362 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
5363 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
5364 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
5365 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
5366 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
5367 )?;
5368 Ok(())
5369}
5370
5371const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
5372 ("files", "path"),
5373 ("nodes", "file_path"),
5374 ("refs", "caller_file"),
5375 ("refs", "target_file"),
5376 ("file_dependencies", "file_path"),
5377 ("file_dependencies", "dep_file"),
5378 ("edges", "target_file"),
5379 ("dispatch_hints", "file"),
5380 ("backend_file_state", "file_path"),
5381];
5382
5383fn reconcile_workspace_roots(
5396 conn: &mut Connection,
5397 project_root: &Path,
5398 allow_repair: bool,
5399) -> Result<OpenRootRepair> {
5400 let roots = stored_workspace_roots(conn)?;
5401 let current_root = project_root.display().to_string();
5402 if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
5403 return Ok(OpenRootRepair::None);
5404 }
5405
5406 if let Some(sample) = sample_absolute_data_path(conn)? {
5407 return Ok(OpenRootRepair::NeedsRebuild {
5408 previous_roots: roots,
5409 current_root,
5410 reason: format!("absolute store data path row {sample}"),
5411 });
5412 }
5413
5414 for stored_root in roots.iter() {
5415 if stored_root == ¤t_root {
5416 continue;
5417 }
5418 if Path::new(stored_root).exists() {
5419 let reason = format!(
5420 "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
5421 );
5422 return Ok(OpenRootRepair::NeedsRebuild {
5423 previous_roots: roots,
5424 current_root,
5425 reason,
5426 });
5427 }
5428 }
5429
5430 if !allow_repair {
5431 return Ok(OpenRootRepair::NeedsRebuild {
5432 previous_roots: roots,
5433 current_root,
5434 reason: "workspace root metadata requires deferred repair".to_string(),
5435 });
5436 }
5437
5438 publish_if_current(|| {
5439 let tx = conn.transaction()?;
5440 tx.execute(
5441 "UPDATE OR IGNORE backend_file_state
5442 SET workspace_root = ?1
5443 WHERE workspace_root <> ?1",
5444 params![¤t_root],
5445 )?;
5446 tx.execute(
5447 "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
5448 params![¤t_root],
5449 )?;
5450 tx.commit()?;
5451 Ok(())
5452 })?;
5453
5454 crate::slog_info!(
5455 "callgraph store re-rooted from {} to {}",
5456 roots.join(", "),
5457 current_root
5458 );
5459 Ok(OpenRootRepair::ReRooted)
5460}
5461
5462fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
5463 let mut stmt = conn.prepare(
5464 "SELECT DISTINCT workspace_root
5465 FROM backend_file_state
5466 ORDER BY workspace_root",
5467 )?;
5468 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
5469 rows.collect::<std::result::Result<Vec<_>, _>>()
5470 .map_err(Into::into)
5471}
5472
5473fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
5474 for (table, column) in STORE_DATA_PATH_COLUMNS {
5475 let sql = format!(
5476 "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
5477 );
5478 let mut stmt = conn.prepare(&sql)?;
5479 let mut rows = stmt.query([])?;
5480 while let Some(row) = rows.next()? {
5481 let value: String = row.get(0)?;
5482 if stored_path_is_absolute(&value) {
5483 return Ok(Some(format!("{table}.{column}={value}")));
5484 }
5485 }
5486 }
5487 Ok(None)
5488}
5489
5490fn stored_path_is_absolute(value: &str) -> bool {
5491 if value.is_empty() {
5492 return false;
5493 }
5494 if Path::new(value).is_absolute() || value.starts_with('/') {
5495 return true;
5496 }
5497 let bytes = value.as_bytes();
5498 if bytes.len() >= 3
5499 && bytes[1] == b':'
5500 && (bytes[2] == b'/' || bytes[2] == b'\\')
5501 && bytes[0].is_ascii_alphabetic()
5502 {
5503 return true;
5504 }
5505 value.starts_with("\\\\") || value.starts_with("//")
5506}
5507
5508fn log_root_repair_rebuild(repair: &OpenRootRepair) {
5509 if let OpenRootRepair::NeedsRebuild {
5510 previous_roots,
5511 current_root,
5512 reason,
5513 } = repair
5514 {
5515 crate::slog_info!(
5516 "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
5517 previous_roots.join(", "),
5518 current_root,
5519 reason
5520 );
5521 }
5522}
5523
5524fn now_nanos() -> u128 {
5526 SystemTime::now()
5527 .duration_since(UNIX_EPOCH)
5528 .unwrap_or(Duration::ZERO)
5529 .as_nanos()
5530}
5531
5532fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
5537 callgraph_dir.join(format!("{project_key}.current"))
5538}
5539
5540fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
5544 callgraph_dir.join(format!("{project_key}.sqlite"))
5545}
5546
5547fn generation_file_name(project_key: &str) -> String {
5551 format!(
5552 "{project_key}.g{}.{}.sqlite",
5553 now_nanos(),
5554 std::process::id()
5555 )
5556}
5557
5558fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
5560 let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
5561 let name = text.trim();
5562 if name.is_empty() {
5563 None
5564 } else {
5565 Some(name.to_string())
5566 }
5567}
5568
5569fn db_path_ready(path: &Path) -> bool {
5572 (|| -> Result<bool> {
5573 let conn = open_readonly_connection(path)?;
5574 database_ready(&conn)
5575 })()
5576 .unwrap_or(false)
5577}
5578
5579fn resolve_ready_target(
5587 callgraph_dir: &Path,
5588 project_key: &str,
5589) -> Option<(PathBuf, Option<String>)> {
5590 for _ in 0..5 {
5591 if let Some(generation) = read_pointer(callgraph_dir, project_key) {
5592 let gen_path = callgraph_dir.join(&generation);
5593 if gen_path.is_file() {
5594 return (migration_manifest_valid(callgraph_dir, &generation)
5595 && db_path_ready(&gen_path))
5596 .then_some((gen_path, Some(generation)));
5597 }
5598 std::thread::sleep(Duration::from_millis(5));
5601 continue;
5602 }
5603 let legacy = legacy_sqlite_path(callgraph_dir, project_key);
5605 return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
5606 }
5607 None
5608}
5609
5610fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
5614 let pointer = pointer_path(callgraph_dir, project_key);
5615 let tmp = callgraph_dir.join(format!(
5616 "{project_key}.current.tmp.{}.{}",
5617 std::process::id(),
5618 now_nanos()
5619 ));
5620 {
5621 use std::io::Write as _;
5622 let mut file = std::fs::File::create(&tmp)?;
5623 file.write_all(generation.as_bytes())?;
5624 file.write_all(b"\n")?;
5625 file.sync_all()?;
5626 }
5627 if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
5628 let _ = std::fs::remove_file(&tmp);
5629 return Err(error.into());
5630 }
5631 crate::fs_lock::sync_parent(&pointer);
5632 Ok(())
5633}
5634
5635#[derive(Clone, Debug)]
5636struct GenerationGcCandidate {
5637 name: String,
5638 path: PathBuf,
5639 modified: SystemTime,
5640}
5641
5642fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
5648 let temp_grace = Duration::from_secs(60);
5649 let now = SystemTime::now();
5650 let pointer_current =
5651 read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
5652 let gen_prefix = format!("{project_key}.g");
5653 let tmp_prefixes = [
5654 format!("{project_key}.g"), format!("{project_key}.current."), format!("{project_key}.sqlite.tmp."), ];
5658 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
5659 return;
5660 };
5661 let mut gens: Vec<GenerationGcCandidate> = Vec::new();
5662 for entry in entries.flatten() {
5663 let name = entry.file_name();
5664 let name = name.to_string_lossy().to_string();
5665 let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
5666 let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
5667
5668 if name.contains(".tmp.") {
5670 if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
5671 let _ = std::fs::remove_file(entry.path());
5672 }
5673 continue;
5674 }
5675
5676 if name == format!("{project_key}.sqlite") {
5679 remove_sqlite_file_set(&entry.path());
5680 continue;
5681 }
5682
5683 if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
5684 gens.push(GenerationGcCandidate {
5685 name,
5686 path: entry.path(),
5687 modified: mtime,
5688 });
5689 }
5690 }
5691
5692 let mut superseded = gens
5693 .iter()
5694 .filter(|generation| generation.name != pointer_current)
5695 .collect::<Vec<_>>();
5696 superseded.sort_by(|left, right| {
5697 right
5698 .modified
5699 .cmp(&left.modified)
5700 .then_with(|| right.name.cmp(&left.name))
5701 });
5702 let previous = superseded.first().map(|generation| generation.name.clone());
5703
5704 for generation in gens {
5705 let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
5706 if generation.name == pointer_current
5707 || Some(generation.name.as_str()) == previous.as_deref()
5708 {
5709 continue;
5710 }
5711
5712 let age = now
5713 .duration_since(generation.modified)
5714 .unwrap_or(Duration::ZERO);
5715 if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
5716 continue;
5717 }
5718
5719 remove_sqlite_file_set(&generation.path);
5720 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
5721 let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
5722 callgraph_dir,
5723 &generation.name,
5724 ));
5725 }
5726}
5727
5728fn remove_sqlite_file_set(path: &Path) {
5729 let _ = std::fs::remove_file(path);
5730 remove_sqlite_sidecars(path);
5731}
5732
5733fn remove_sqlite_sidecars(path: &Path) {
5734 let path_text = path.to_string_lossy();
5735 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
5736 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
5737 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
5738}
5739
5740const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
5754
5755fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
5767 sweep_orphaned_build_temps(callgraph_dir);
5768 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5769 return;
5770 };
5771 let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
5772
5773 if let Ok(entries) = std::fs::read_dir(storage_root.join(domain)) {
5775 for entry in entries.flatten() {
5776 if entry.path().is_dir() {
5777 sweep_orphaned_build_temps(&entry.path());
5778 }
5779 }
5780 }
5781
5782 if let Ok(entries) = std::fs::read_dir(&storage_root) {
5784 for entry in entries.flatten() {
5785 let legacy_dir = entry.path().join(domain);
5786 if legacy_dir.is_dir() {
5787 sweep_orphaned_build_temps(&legacy_dir);
5788 }
5789 }
5790 }
5791}
5792
5793fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
5796 sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
5797}
5798
5799fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
5802 let now = SystemTime::now();
5803 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
5804 return;
5805 };
5806 let mut removed_any = false;
5807 for entry in entries.flatten() {
5808 let name = entry.file_name().to_string_lossy().to_string();
5809 if !name.contains(".sqlite.tmp.") {
5815 continue;
5816 }
5817 let mtime = entry
5818 .metadata()
5819 .and_then(|meta| meta.modified())
5820 .unwrap_or(now);
5821 if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
5822 continue;
5823 }
5824 match std::fs::remove_file(entry.path()) {
5830 Ok(()) => removed_any = true,
5831 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
5832 Err(_) => {}
5833 }
5834 }
5835 if removed_any {
5836 crate::fs_lock::sync_parent(callgraph_dir);
5837 }
5838}
5839
5840fn build_pool_size() -> usize {
5848 std::thread::available_parallelism()
5849 .map(|parallelism| parallelism.get())
5850 .unwrap_or(1)
5851 .div_ceil(2)
5852 .clamp(1, 8)
5853}
5854
5855fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
5856 let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
5857 Ok(extract) => Ok(extract),
5858 Err(error) => {
5859 let abs_path =
5860 normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
5861 let rel_path = relative_path(project_root, &abs_path);
5862 let freshness = cache_freshness::collect(&abs_path).ok();
5863 log::debug!(
5864 "callgraph store: skipping {} during cold build: {}",
5865 abs_path.display(),
5866 error
5867 );
5868 Err(ExtractFailure {
5869 rel_path,
5870 freshness,
5871 })
5872 }
5873 };
5874
5875 let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
5876 files.par_iter().map(extract_one).collect()
5877 };
5878
5879 let results = match rayon::ThreadPoolBuilder::new()
5882 .num_threads(build_pool_size())
5883 .thread_name(|index| format!("aft-callgraph-build-{index}"))
5884 .stack_size(8 * 1024 * 1024)
5885 .build()
5886 {
5887 Ok(pool) => pool.install(run),
5888 Err(error) => {
5889 log::warn!(
5890 "callgraph store: bounded build pool unavailable ({error}); using global pool"
5891 );
5892 run()
5893 }
5894 };
5895
5896 let mut extracts = Vec::new();
5897 let mut failures = Vec::new();
5898 for result in results {
5899 match result {
5900 Ok(extract) => extracts.push(extract),
5901 Err(failure) => failures.push(failure),
5902 }
5903 }
5904 BuildExtractsResult { extracts, failures }
5905}
5906
5907fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
5908 let metadata = std::fs::metadata(path)?;
5909 let size = metadata.len();
5910 let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
5911 cache_freshness::zero_hash()
5912 } else if source.len() as u64 == size {
5913 cache_freshness::hash_bytes(source.as_bytes())
5914 } else {
5915 cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
5916 };
5917 Ok(FileFreshness {
5918 mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
5919 size,
5920 content_hash,
5921 })
5922}
5923
5924fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
5925 let abs_path = normalize_file_path(project_root, path)?;
5926 let rel_path = relative_path(project_root, &abs_path);
5927 let source = std::fs::read_to_string(&abs_path)?;
5928 let freshness = collect_source_freshness(&abs_path, &source)?;
5929 let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
5930 let lang = data.lang;
5931 if lang == LangId::Rust {
5932 extend_rust_imports_with_nested_uses(&source, &mut data);
5933 }
5934 let mut nodes = build_node_records(&rel_path, &source, &data)?;
5935 let node_by_scoped: HashMap<String, String> = nodes
5936 .iter()
5937 .map(|node| (node.scoped_name.clone(), node.id.clone()))
5938 .collect();
5939 let import_dependencies =
5940 import_dependencies(project_root, &abs_path, &data.import_block.imports);
5941 let line_index = LineIndex::new(&source);
5942 let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
5943 let rust_reexports = if lang == LangId::Rust {
5944 collect_rust_pub_use_reexport_refs(
5945 project_root,
5946 &abs_path,
5947 &rel_path,
5948 &data.import_block.imports,
5949 &line_index,
5950 )
5951 } else {
5952 ReexportRefs {
5953 raw_refs: Vec::new(),
5954 surface_parts: Vec::new(),
5955 }
5956 };
5957 let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
5958 let mut raw_refs = Vec::new();
5959 raw_refs.extend(build_call_refs(
5960 &rel_path,
5961 &data,
5962 &node_by_scoped,
5963 &import_dependencies,
5964 ));
5965 raw_refs.extend(build_import_refs(
5966 project_root,
5967 &abs_path,
5968 &rel_path,
5969 &data.import_block.imports,
5970 &line_index,
5971 ));
5972 let mut surface_parts = reexports.surface_parts;
5973 surface_parts.extend(rust_reexports.surface_parts);
5974 surface_parts.extend(source_less_exports.surface_parts);
5975 raw_refs.extend(reexports.raw_refs);
5976 raw_refs.extend(rust_reexports.raw_refs);
5977 raw_refs.extend(source_less_exports.raw_refs);
5978 let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
5979 let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
5980
5981 Ok(FileExtract {
5982 rel_path,
5983 freshness,
5984 lang,
5985 data,
5986 nodes,
5987 raw_refs,
5988 dispatch_hints,
5989 surface_fingerprint,
5990 })
5991}
5992
5993fn build_node_records(
5994 rel_path: &str,
5995 source: &str,
5996 data: &FileCallData,
5997) -> Result<Vec<NodeRecord>> {
5998 let mut records = Vec::new();
5999 let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
6000 let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
6001 metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
6002
6003 for (scoped_name, meta) in metadata {
6004 let name = unqualified_name(scoped_name).to_string();
6005 let range = selection_range(source, scoped_name, &name, &meta.range);
6006 let range_key = (
6007 range.start_line,
6008 range.start_col,
6009 range.end_line,
6010 range.end_col,
6011 );
6012 let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
6013 let range_ordinal = *ordinal;
6014 *ordinal += 1;
6015 let id = node_id(rel_path, &range, range_ordinal, scoped_name);
6016 let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
6017 let is_default_export = data
6018 .default_export_symbol
6019 .as_deref()
6020 .map(|default| default == scoped_name || default == name)
6021 .unwrap_or(false);
6022 records.push(NodeRecord {
6023 id,
6024 file_path: rel_path.to_string(),
6025 name: name.clone(),
6026 scoped_name: scoped_name.clone(),
6027 kind: symbol_kind_label(&meta.kind).to_string(),
6028 range,
6029 range_ordinal,
6030 signature: meta.signature.clone(),
6031 exported,
6032 is_default_export,
6033 is_type_like: is_type_like(&meta.kind),
6034 is_callgraph_entry_point: meta.entry_point_attribute.is_some()
6035 || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
6036 });
6037 }
6038
6039 Ok(records)
6040}
6041
6042fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
6043 if scoped_name == TOP_LEVEL_SYMBOL {
6044 return Range {
6045 start_line: 0,
6046 start_col: 0,
6047 end_line: 0,
6048 end_col: 0,
6049 };
6050 }
6051 let Some(line) = source.lines().nth(fallback.start_line as usize) else {
6052 return fallback.clone();
6053 };
6054 let start_col = fallback.start_col as usize;
6055 let search_start = start_col.min(line.len());
6056 if let Some(offset) = line[search_start..].find(name) {
6057 let col = search_start + offset;
6058 return Range {
6059 start_line: fallback.start_line,
6060 start_col: col as u32,
6061 end_line: fallback.start_line,
6062 end_col: (col + name.len()) as u32,
6063 };
6064 }
6065 if let Some(offset) = line.find(name) {
6066 return Range {
6067 start_line: fallback.start_line,
6068 start_col: offset as u32,
6069 end_line: fallback.start_line,
6070 end_col: (offset + name.len()) as u32,
6071 };
6072 }
6073 Range {
6074 start_line: fallback.start_line,
6075 start_col: fallback.start_col,
6076 end_line: fallback.start_line,
6077 end_col: fallback.start_col.saturating_add(name.len() as u32),
6078 }
6079}
6080
6081fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
6082 if scoped_name == TOP_LEVEL_SYMBOL {
6083 return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
6084 }
6085 let input = format!(
6086 "{rel_path}:{}:{}:{}:{}:{ordinal}",
6087 range.start_line, range.start_col, range.end_line, range.end_col
6088 );
6089 format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
6090}
6091
6092fn build_call_refs(
6093 rel_path: &str,
6094 data: &FileCallData,
6095 node_by_scoped: &HashMap<String, String>,
6096 import_dependencies: &BTreeSet<String>,
6097) -> Vec<RawRef> {
6098 let mut refs = Vec::new();
6099 let mut ordinal = 0usize;
6100 let mut symbols: Vec<_> = data.calls_by_symbol.iter().collect();
6101 symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
6102 for (caller_symbol, call_sites) in symbols {
6103 let caller_node = node_by_scoped.get(caller_symbol).cloned();
6104 for call_site in call_sites {
6105 ordinal += 1;
6106 let ref_id = ref_id(&[
6107 rel_path,
6108 "call",
6109 caller_symbol,
6110 &call_site.line.to_string(),
6111 &call_site.byte_start.to_string(),
6112 &call_site.byte_end.to_string(),
6113 &call_site.full_callee,
6114 &ordinal.to_string(),
6115 ]);
6116 refs.push(RawRef {
6117 ref_id,
6118 caller_node: caller_node.clone(),
6119 caller_symbol: Some(caller_symbol.clone()),
6120 caller_file: rel_path.to_string(),
6121 kind: "call".to_string(),
6122 short_name: Some(call_site.callee_name.clone()),
6123 full_ref: Some(call_site.full_callee.clone()),
6124 module_path: None,
6125 import_kind: None,
6126 local_name: Some(call_site.callee_name.clone()),
6127 requested_name: Some(call_site.callee_name.clone()),
6128 namespace_alias: namespace_alias(&call_site.full_callee),
6129 wildcard: false,
6130 line: call_site.line,
6131 byte_start: call_site.byte_start,
6132 byte_end: call_site.byte_end,
6133 dependencies: import_dependencies.clone(),
6134 });
6135 }
6136 }
6137 refs
6138}
6139
6140fn build_import_refs(
6141 project_root: &Path,
6142 abs_path: &Path,
6143 rel_path: &str,
6144 imports: &[ImportStatement],
6145 line_index: &LineIndex,
6146) -> Vec<RawRef> {
6147 let mut refs = Vec::new();
6148 for (index, import) in imports.iter().enumerate() {
6149 let import_kind = import_kind_label(import.kind).to_string();
6150 let local_name = import_local_names(import).join(",");
6151 let requested_name = import_requested_names(import).join(",");
6152 let ref_id = ref_id(&[
6153 rel_path,
6154 "import",
6155 &import.byte_range.start.to_string(),
6156 &import.byte_range.end.to_string(),
6157 &import.module_path,
6158 &index.to_string(),
6159 ]);
6160 refs.push(RawRef {
6161 ref_id,
6162 caller_node: None,
6163 caller_symbol: None,
6164 caller_file: rel_path.to_string(),
6165 kind: "import".to_string(),
6166 short_name: None,
6167 full_ref: Some(import.raw_text.clone()),
6168 module_path: Some(import.module_path.clone()),
6169 import_kind: Some(import_kind),
6170 local_name: empty_to_none(local_name),
6171 requested_name: empty_to_none(requested_name),
6172 namespace_alias: import.namespace_import.clone(),
6173 wildcard: import_is_wildcard(import),
6174 line: line_index.byte_to_line(import.byte_range.start),
6175 byte_start: import.byte_range.start,
6176 byte_end: import.byte_range.end,
6177 dependencies: module_dependencies(project_root, abs_path, &import.module_path),
6178 });
6179 }
6180 refs
6181}
6182
6183fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
6184 let grammar = grammar_for(LangId::Rust);
6185 let mut parser = Parser::new();
6186 if parser.set_language(&grammar).is_err() {
6187 return;
6188 }
6189 let Some(tree) = parser.parse(source, None) else {
6190 return;
6191 };
6192
6193 let mut seen = data
6194 .import_block
6195 .imports
6196 .iter()
6197 .map(|import| (import.byte_range.start, import.byte_range.end))
6198 .collect::<HashSet<_>>();
6199 let mut nested_imports = Vec::new();
6200 collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
6201 if nested_imports.is_empty() {
6202 return;
6203 }
6204
6205 data.import_block.imports.extend(nested_imports);
6206 data.import_block
6207 .imports
6208 .sort_by_key(|import| import.byte_range.start);
6209 data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
6210}
6211
6212fn collect_rust_use_imports(
6213 source: &str,
6214 node: Node<'_>,
6215 seen: &mut HashSet<(usize, usize)>,
6216 imports: &mut Vec<ImportStatement>,
6217) {
6218 if node.kind() == "use_declaration" {
6219 let range = node.byte_range();
6220 if seen.insert((range.start, range.end)) {
6221 if let Some(import) = rust_import_from_use_node(source, node) {
6222 imports.push(import);
6223 }
6224 }
6225 }
6226
6227 let mut cursor = node.walk();
6228 if !cursor.goto_first_child() {
6229 return;
6230 }
6231 loop {
6232 collect_rust_use_imports(source, cursor.node(), seen, imports);
6233 if !cursor.goto_next_sibling() {
6234 break;
6235 }
6236 }
6237}
6238
6239fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
6240 let raw_text = source[node.byte_range()].to_string();
6241 let body = rust_use_body(&raw_text)?.to_string();
6242 let visibility = rust_use_visibility(&raw_text);
6243 let names = rust_use_list_names(&body);
6244 let group = classify_rust_import_group(&body);
6245 let byte_range = node.byte_range();
6246
6247 Some(ImportStatement {
6248 module_path: body,
6249 names: names.clone(),
6250 default_import: visibility.clone(),
6251 namespace_import: None,
6252 kind: ImportKind::Value,
6253 group,
6254 byte_range,
6255 raw_text,
6256 form: ImportForm::RustUse {
6257 visibility,
6258 named: names,
6259 },
6260 })
6261}
6262
6263fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
6264 let start = imports.iter().map(|import| import.byte_range.start).min()?;
6265 let end = imports.iter().map(|import| import.byte_range.end).max()?;
6266 Some(start..end)
6267}
6268
6269fn rust_use_visibility(raw_text: &str) -> Option<String> {
6270 let use_pos = raw_text.find("use ")?;
6271 let prefix = raw_text[..use_pos].trim();
6272 if prefix.is_empty() {
6273 None
6274 } else {
6275 Some(prefix.to_string())
6276 }
6277}
6278
6279fn rust_use_body(raw_text: &str) -> Option<&str> {
6280 let use_pos = raw_text.find("use ")?;
6281 Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
6282}
6283
6284fn rust_use_list_names(body: &str) -> Vec<String> {
6285 let Some(open) = body.find("::{") else {
6286 return Vec::new();
6287 };
6288 let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
6289 return Vec::new();
6290 };
6291 body[open + 3..close]
6292 .split(',')
6293 .filter_map(|spec| {
6294 let spec = spec.trim();
6295 if spec.is_empty() {
6296 None
6297 } else {
6298 Some(spec.to_string())
6299 }
6300 })
6301 .collect()
6302}
6303
6304fn classify_rust_import_group(body: &str) -> ImportGroup {
6305 let first = body
6306 .split("::")
6307 .next()
6308 .unwrap_or(body)
6309 .split_whitespace()
6310 .next()
6311 .unwrap_or(body);
6312 match first.trim() {
6313 "std" | "core" | "alloc" => ImportGroup::Stdlib,
6314 "crate" | "self" | "super" => ImportGroup::Internal,
6315 _ => ImportGroup::External,
6316 }
6317}
6318
6319#[derive(Debug, Clone)]
6320struct ReexportRefs {
6321 raw_refs: Vec<RawRef>,
6322 surface_parts: Vec<String>,
6323}
6324
6325fn collect_reexport_refs(
6326 project_root: &Path,
6327 abs_path: &Path,
6328 rel_path: &str,
6329 source: &str,
6330) -> ReexportRefs {
6331 let mut raw_refs = Vec::new();
6332 let mut surface_parts = Vec::new();
6333 let mut search_start = 0usize;
6334 let mut ordinal = 0usize;
6335 while let Some(export_offset) = source[search_start..].find("export") {
6336 let start = search_start + export_offset;
6337 let Some(statement_end_offset) = source[start..].find(';') else {
6338 break;
6339 };
6340 let end = start + statement_end_offset + 1;
6341 let statement = &source[start..end];
6342 search_start = end;
6343 if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
6344 continue;
6345 }
6346 let Some(module_path) = quoted_module_path(statement) else {
6347 continue;
6348 };
6349 ordinal += 1;
6350 let wildcard = statement.contains('*');
6351 let line = source[..start]
6352 .bytes()
6353 .filter(|byte| *byte == b'\n')
6354 .count() as u32
6355 + 1;
6356 let ref_id = ref_id(&[
6357 rel_path,
6358 "reexport",
6359 &start.to_string(),
6360 &end.to_string(),
6361 &module_path,
6362 &ordinal.to_string(),
6363 ]);
6364 surface_parts.push(format!("reexport\t{statement}"));
6365 raw_refs.push(RawRef {
6366 ref_id,
6367 caller_node: None,
6368 caller_symbol: None,
6369 caller_file: rel_path.to_string(),
6370 kind: "reexport".to_string(),
6371 short_name: None,
6372 full_ref: Some(statement.to_string()),
6373 module_path: Some(module_path.clone()),
6374 import_kind: Some("reexport".to_string()),
6375 local_name: None,
6376 requested_name: None,
6377 namespace_alias: None,
6378 wildcard,
6379 line,
6380 byte_start: start,
6381 byte_end: end,
6382 dependencies: module_dependencies(project_root, abs_path, &module_path),
6383 });
6384 }
6385 ReexportRefs {
6386 raw_refs,
6387 surface_parts,
6388 }
6389}
6390
6391fn collect_rust_pub_use_reexport_refs(
6392 project_root: &Path,
6393 abs_path: &Path,
6394 rel_path: &str,
6395 imports: &[ImportStatement],
6396 line_index: &LineIndex,
6397) -> ReexportRefs {
6398 let mut raw_refs = Vec::new();
6399 let mut surface_parts = Vec::new();
6400 let mut ordinal = 0usize;
6401
6402 for import in imports {
6403 let Some(visibility) = &import.default_import else {
6404 continue;
6405 };
6406 if !visibility.starts_with("pub") {
6407 continue;
6408 }
6409 let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
6410 continue;
6411 };
6412 ordinal += 1;
6413 let ref_id = ref_id(&[
6414 rel_path,
6415 "rust_reexport",
6416 &import.byte_range.start.to_string(),
6417 &import.byte_range.end.to_string(),
6418 &module_path,
6419 &ordinal.to_string(),
6420 ]);
6421 surface_parts.push(format!("reexport\t{}", import.raw_text));
6422 raw_refs.push(RawRef {
6423 ref_id,
6424 caller_node: None,
6425 caller_symbol: None,
6426 caller_file: rel_path.to_string(),
6427 kind: "reexport".to_string(),
6428 short_name: None,
6429 full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
6430 module_path: Some(module_path.clone()),
6431 import_kind: Some("reexport".to_string()),
6432 local_name: None,
6433 requested_name: None,
6434 namespace_alias: None,
6435 wildcard,
6436 line: line_index.byte_to_line(import.byte_range.start),
6437 byte_start: import.byte_range.start,
6438 byte_end: import.byte_range.end,
6439 dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
6440 });
6441 }
6442
6443 ReexportRefs {
6444 raw_refs,
6445 surface_parts,
6446 }
6447}
6448
6449fn rust_pub_use_reexport_parts(
6450 import: &ImportStatement,
6451) -> Option<(String, HashMap<String, String>, bool)> {
6452 let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
6453 let body = body.trim();
6454 if let Some(module_path) = body.strip_suffix("::*") {
6455 return Some((module_path.trim().to_string(), HashMap::new(), true));
6456 }
6457
6458 if let Some(brace_start) = body.find("::{") {
6459 let module_path = body[..brace_start].trim().to_string();
6460 let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
6461 if names.is_empty() {
6462 return None;
6463 }
6464 return Some((module_path, names, false));
6465 }
6466
6467 let (module_path, spec) = body.rsplit_once("::")?;
6468 let names = rust_reexport_names_from_specs(spec);
6469 if names.is_empty() {
6470 return None;
6471 }
6472 Some((module_path.trim().to_string(), names, false))
6473}
6474
6475fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
6476 let mut names = HashMap::new();
6477 for spec in specs.split(',') {
6478 let spec = spec.trim();
6479 if spec.is_empty() || spec == "self" {
6480 continue;
6481 }
6482 if let Some((source, local)) = spec.split_once(" as ") {
6483 let source = source.trim();
6484 let local = local.trim();
6485 if !source.is_empty() && !local.is_empty() && source != "self" {
6486 names.insert(local.to_string(), source.to_string());
6487 }
6488 } else {
6489 names.insert(spec.to_string(), spec.to_string());
6490 }
6491 }
6492 names
6493}
6494
6495fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
6496 if named.is_empty() {
6497 return fallback.to_string();
6498 }
6499 let mut specs = named
6500 .iter()
6501 .map(|(local, source)| {
6502 if local == source {
6503 source.clone()
6504 } else {
6505 format!("{source} as {local}")
6506 }
6507 })
6508 .collect::<Vec<_>>();
6509 specs.sort();
6510 format!("pub use {{{}}};", specs.join(", "))
6511}
6512
6513fn quoted_module_path(statement: &str) -> Option<String> {
6514 let quote = match (statement.find('\''), statement.find('"')) {
6515 (Some(single), Some(double)) if single < double => '\'',
6516 (Some(_), Some(_)) => '"',
6517 (Some(_), None) => '\'',
6518 (None, Some(_)) => '"',
6519 (None, None) => return None,
6520 };
6521 let start = statement.find(quote)? + 1;
6522 let end = statement[start..].find(quote)? + start;
6523 Some(statement[start..end].to_string())
6524}
6525
6526#[derive(Debug, Clone)]
6527struct SourceLessExportRefs {
6528 raw_refs: Vec<RawRef>,
6529 surface_parts: Vec<String>,
6530}
6531
6532fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
6533 let mut raw_refs = Vec::new();
6534 let mut surface_parts = Vec::new();
6535 let mut search_start = 0usize;
6536 let mut ordinal = 0usize;
6537 while let Some(export_offset) = source[search_start..].find("export") {
6538 let start = search_start + export_offset;
6539 let Some(statement_end_offset) = source[start..].find(';') else {
6540 break;
6541 };
6542 let end = start + statement_end_offset + 1;
6543 let statement = &source[start..end];
6544 search_start = end;
6545 if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
6546 continue;
6547 }
6548 let aliases = parse_reexport_names(statement);
6549 if aliases.is_empty() {
6550 continue;
6551 }
6552 let line = source[..start]
6553 .bytes()
6554 .filter(|byte| *byte == b'\n')
6555 .count() as u32
6556 + 1;
6557 for (exported, source_symbol) in aliases {
6558 ordinal += 1;
6559 let ref_id = ref_id(&[
6560 rel_path,
6561 "export_alias",
6562 &start.to_string(),
6563 &end.to_string(),
6564 &exported,
6565 &source_symbol,
6566 &ordinal.to_string(),
6567 ]);
6568 surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
6569 raw_refs.push(RawRef {
6570 ref_id,
6571 caller_node: None,
6572 caller_symbol: None,
6573 caller_file: rel_path.to_string(),
6574 kind: "export_alias".to_string(),
6575 short_name: None,
6576 full_ref: Some(statement.to_string()),
6577 module_path: None,
6578 import_kind: Some("export_alias".to_string()),
6579 local_name: Some(exported),
6580 requested_name: Some(source_symbol),
6581 namespace_alias: None,
6582 wildcard: false,
6583 line,
6584 byte_start: start,
6585 byte_end: end,
6586 dependencies: BTreeSet::new(),
6587 });
6588 }
6589 }
6590 SourceLessExportRefs {
6591 raw_refs,
6592 surface_parts,
6593 }
6594}
6595
6596fn build_dispatch_hints(
6597 rel_path: &str,
6598 data: &FileCallData,
6599 node_by_scoped: &HashMap<String, String>,
6600) -> Vec<DispatchHint> {
6601 let mut hints = Vec::new();
6602 let mut ordinal = 0usize;
6603 for (caller_symbol, call_sites) in &data.calls_by_symbol {
6604 let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
6605 continue;
6606 };
6607 for call_site in call_sites {
6608 if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
6609 continue;
6610 }
6611 ordinal += 1;
6612 hints.push(DispatchHint {
6613 id: ref_id(&[
6614 rel_path,
6615 "dispatch",
6616 caller_symbol,
6617 &call_site.line.to_string(),
6618 &call_site.byte_start.to_string(),
6619 &call_site.byte_end.to_string(),
6620 &ordinal.to_string(),
6621 ]),
6622 method_name: call_site.callee_name.clone(),
6623 caller_node: caller_node.clone(),
6624 file: rel_path.to_string(),
6625 line: call_site.line,
6626 byte_start: call_site.byte_start,
6627 byte_end: call_site.byte_end,
6628 });
6629 }
6630 }
6631 hints
6632}
6633
6634fn surface_fingerprint(
6635 nodes: &mut [NodeRecord],
6636 data: &FileCallData,
6637 reexport_parts: &[String],
6638) -> String {
6639 nodes.sort_by(|left, right| {
6640 (left.file_path.as_str(), left.scoped_name.as_str())
6641 .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
6642 });
6643 let mut parts = Vec::new();
6644 for node in nodes.iter() {
6645 parts.push(format!(
6646 "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
6647 node.scoped_name,
6648 node.name,
6649 node.kind,
6650 node.exported,
6651 node.range.start_line,
6652 node.range.start_col,
6653 node.range.end_line,
6654 node.range.end_col,
6655 node.range_ordinal,
6656 node.signature.as_deref().unwrap_or("")
6657 ));
6658 }
6659 let mut exports = data.exported_symbols.clone();
6660 exports.sort();
6661 for export in exports {
6662 parts.push(format!("export\t{export}"));
6663 }
6664 if let Some(default_export) = &data.default_export_symbol {
6665 parts.push(format!("default\t{default_export}"));
6666 }
6667 let mut imports: Vec<String> = data
6668 .import_block
6669 .imports
6670 .iter()
6671 .map(|import| {
6672 format!(
6673 "import\t{}\t{:?}\t{}",
6674 import.module_path, import.form, import.raw_text
6675 )
6676 })
6677 .collect();
6678 imports.sort();
6679 parts.extend(imports);
6680 parts.extend(reexport_parts.iter().cloned());
6681 hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
6682}
6683
6684fn resolve_ref(raw: RawRef, index: &ProjectIndex<'_>) -> Result<ResolvedRef> {
6685 if raw.kind != "call" {
6686 return Ok(ResolvedRef {
6687 dependencies: raw.dependencies.clone(),
6688 raw,
6689 status: "unresolved".to_string(),
6690 target_node: None,
6691 target_file: None,
6692 target_symbol: None,
6693 edge: None,
6694 });
6695 }
6696
6697 let caller_file = raw.caller_file.clone();
6698 let caller_data = index.caller_data.get(&caller_file).ok_or_else(|| {
6699 CallGraphStoreError::MissingCallerData {
6700 file: caller_file.clone(),
6701 }
6702 })?;
6703 let full_ref = raw.full_ref.as_deref().unwrap_or_default();
6704 let short_name = raw.short_name.as_deref().unwrap_or_default();
6705 let mut dependencies = raw.dependencies.clone();
6706
6707 let resolved = match index.lang_for(&caller_file) {
6708 Some(LangId::Rust) => {
6709 resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
6710 }
6711 Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
6712 resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
6713 }
6714 _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
6715 };
6716
6717 let Some((status, target_file, target_symbol)) = resolved else {
6718 return Ok(ResolvedRef {
6719 raw,
6720 status: "unresolved".to_string(),
6721 target_node: None,
6722 target_file: None,
6723 target_symbol: None,
6724 dependencies,
6725 edge: None,
6726 });
6727 };
6728
6729 dependencies.insert(target_file.clone());
6730 let target_node = index.node_for_symbol(&target_file, &target_symbol);
6731 let source_node = raw.caller_node.clone();
6732 let edge = if let Some(source_node) = source_node {
6733 if target_file == caller_file
6734 && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
6735 {
6736 None
6737 } else {
6738 Some(EdgeRecord {
6739 edge_id: ref_id(&[&raw.ref_id, "edge"]),
6740 source_node,
6741 target_node: target_node.clone(),
6742 target_file: target_file.clone(),
6743 target_symbol: target_symbol.clone(),
6744 kind: "call".to_string(),
6745 line: raw.line,
6746 })
6747 }
6748 } else {
6749 None
6750 };
6751
6752 Ok(ResolvedRef {
6753 raw,
6754 status,
6755 target_node,
6756 target_file: Some(target_file),
6757 target_symbol: Some(target_symbol),
6758 dependencies,
6759 edge,
6760 })
6761}
6762
6763fn resolve_js_ts_target(
6764 index: &ProjectIndex<'_>,
6765 caller_file: &str,
6766 full_ref: &str,
6767 short_name: &str,
6768 caller_data: &FileCallData,
6769) -> Option<(String, String, String)> {
6770 if let Some((namespace, member)) = full_ref.split_once('.') {
6771 for import in &caller_data.import_block.imports {
6772 if import.namespace_import.as_deref() == Some(namespace) {
6773 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6774 if let Some((file, symbol)) =
6775 resolve_exported_symbol(index, &target_file, member, 0)
6776 {
6777 return Some(("resolved".to_string(), file, symbol));
6778 }
6779 }
6780 }
6781 }
6782 }
6783
6784 for import in &caller_data.import_block.imports {
6785 for spec in &import.names {
6786 if crate::imports::specifier_local_name(spec) == short_name {
6787 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6788 let requested = crate::imports::specifier_imported_name(spec);
6789 let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
6790 .unwrap_or_else(|| (target_file, requested.to_string()));
6791 return Some(("resolved".to_string(), file, symbol));
6792 }
6793 }
6794 }
6795
6796 if import.default_import.as_deref() == Some(short_name) {
6797 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6798 let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
6799 .or_else(|| {
6800 index
6801 .files
6802 .get(&target_file)
6803 .and_then(|file| file.default_export.clone())
6804 .map(|symbol| (target_file.clone(), symbol))
6805 })
6806 .unwrap_or_else(|| {
6807 let file_name = Path::new(&target_file)
6808 .file_name()
6809 .and_then(|name| name.to_str())
6810 .unwrap_or("unknown")
6811 .to_string();
6812 (target_file, format!("<default:{file_name}>"))
6813 });
6814 return Some(("resolved".to_string(), file, symbol));
6815 }
6816 }
6817 }
6818
6819 for import in &caller_data.import_block.imports {
6820 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6821 if index
6822 .files
6823 .get(&target_file)
6824 .map(|file| file.exports.contains(short_name))
6825 .unwrap_or(false)
6826 {
6827 return Some(("resolved".to_string(), target_file, short_name.to_string()));
6828 }
6829 }
6830 }
6831
6832 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
6833}
6834
6835fn resolve_exported_symbol(
6836 index: &ProjectIndex<'_>,
6837 file: &str,
6838 requested: &str,
6839 depth: usize,
6840) -> Option<(String, String)> {
6841 let mut visited = std::collections::HashMap::new();
6842 resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
6843}
6844
6845fn resolve_exported_symbol_inner(
6854 index: &ProjectIndex<'_>,
6855 file: &str,
6856 requested: &str,
6857 depth: usize,
6858 visited: &mut std::collections::HashMap<(String, String), usize>,
6859) -> Option<(String, String)> {
6860 if depth > 16 {
6861 return None;
6862 }
6863 if requested != "default" {
6864 if let Some(source_symbol) = index
6865 .files
6866 .get(file)
6867 .and_then(|item| item.export_aliases.get(requested))
6868 {
6869 return Some((file.to_string(), source_symbol.clone()));
6870 }
6871 if index
6872 .files
6873 .get(file)
6874 .map(|item| item.exports.contains(requested))
6875 .unwrap_or(false)
6876 {
6877 return Some((file.to_string(), requested.to_string()));
6878 }
6879 } else if let Some(default) = index
6880 .files
6881 .get(file)
6882 .and_then(|item| item.default_export.clone())
6883 {
6884 return Some((file.to_string(), default));
6885 }
6886
6887 match visited.entry((file.to_string(), requested.to_string())) {
6891 std::collections::hash_map::Entry::Occupied(mut seen) => {
6892 if *seen.get() <= depth {
6893 return None;
6894 }
6895 seen.insert(depth);
6896 }
6897 std::collections::hash_map::Entry::Vacant(slot) => {
6898 slot.insert(depth);
6899 }
6900 }
6901
6902 for reexport in index.reexports_for(file) {
6903 let mut next_requested = requested.to_string();
6904 let matches = if reexport.wildcard {
6905 true
6906 } else if let Some(source_name) = reexport.named.get(requested) {
6907 next_requested = source_name.clone();
6908 true
6909 } else {
6910 false
6911 };
6912 if !matches {
6913 continue;
6914 }
6915 if let Some(target_file) = &reexport.target_file {
6916 if let Some(target) = resolve_exported_symbol_inner(
6917 index,
6918 target_file,
6919 &next_requested,
6920 depth + 1,
6921 visited,
6922 ) {
6923 return Some(target);
6924 }
6925 }
6926 }
6927 None
6928}
6929
6930fn resolve_rust_target(
6931 index: &ProjectIndex<'_>,
6932 caller_file: &str,
6933 full_ref: &str,
6934 short_name: &str,
6935 caller_data: &FileCallData,
6936 raw: &RawRef,
6937) -> Option<(String, String, String)> {
6938 if full_ref.contains("::") {
6939 if let Some((target_file, target_symbol)) =
6940 rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
6941 {
6942 return Some(("resolved".to_string(), target_file, target_symbol));
6943 }
6944 }
6945
6946 for import in &caller_data.import_block.imports {
6947 if let Some((target_file, target_symbol)) =
6948 rust_target_for_use(index, caller_file, import, short_name)
6949 {
6950 return Some(("resolved".to_string(), target_file, target_symbol));
6951 }
6952 }
6953
6954 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
6955}
6956
6957fn rust_target_for_qualified(
6958 index: &ProjectIndex<'_>,
6959 caller_file: &str,
6960 full_ref: &str,
6961 short_name: &str,
6962 caller_data: &FileCallData,
6963 raw: &RawRef,
6964) -> Option<(String, String)> {
6965 let mut segments: Vec<&str> = full_ref.split("::").collect();
6966 if segments.len() < 2 {
6967 return None;
6968 }
6969 segments.pop();
6970 let requested_symbol = rust_target_symbol(full_ref, short_name);
6971
6972 for path in rust_module_path_candidates(&segments, caller_data, raw) {
6973 let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
6974 if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
6975 if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
6976 return Some(rust_resolve_reexport_if_symbol_missing(
6977 index,
6978 target_file,
6979 requested_symbol.clone(),
6980 ));
6981 }
6982 }
6983
6984 let module_segments = rust_resolve_segments(caller_file, &path_refs)?;
6985 if let Some(target) =
6986 rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
6987 {
6988 return Some(target);
6989 }
6990 if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
6991 return Some(rust_resolve_reexport_if_symbol_missing(
6992 index,
6993 target_file,
6994 requested_symbol.clone(),
6995 ));
6996 }
6997 }
6998 None
6999}
7000
7001fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
7002 full_ref
7003 .rsplit("::")
7004 .next()
7005 .filter(|name| !name.is_empty())
7006 .unwrap_or(short_name)
7007 .to_string()
7008}
7009
7010fn rust_resolve_reexport_if_symbol_missing(
7011 index: &ProjectIndex<'_>,
7012 target_file: String,
7013 target_symbol: String,
7014) -> (String, String) {
7015 if index
7016 .node_for_symbol(&target_file, &target_symbol)
7017 .is_some()
7018 {
7019 return (target_file, target_symbol);
7020 }
7021 if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
7022 resolved
7023 } else {
7024 (target_file, target_symbol)
7025 }
7026}
7027
7028fn rust_module_path_candidates(
7029 segments: &[&str],
7030 caller_data: &FileCallData,
7031 raw: &RawRef,
7032) -> Vec<Vec<String>> {
7033 let mut candidates = Vec::new();
7034 if let Some(first) = segments.first().copied() {
7035 for import in &caller_data.import_block.imports {
7036 if !rust_import_is_visible_to_call(import, raw) {
7037 continue;
7038 }
7039 let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
7040 continue;
7041 };
7042 if local_name == first {
7043 path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
7044 rust_push_unique_path_candidate(&mut candidates, path_segments);
7045 }
7046 }
7047 }
7048 rust_push_unique_path_candidate(
7049 &mut candidates,
7050 segments
7051 .iter()
7052 .map(|segment| (*segment).to_string())
7053 .collect(),
7054 );
7055 candidates
7056}
7057
7058fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
7059 if !candidates.iter().any(|existing| existing == &candidate) {
7060 candidates.push(candidate);
7061 }
7062}
7063
7064fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
7065 import.byte_range.start <= raw.byte_start
7066}
7067
7068fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
7069 let path = import.module_path.trim().trim_end_matches(';').trim();
7070 if path.contains("::{") || path.contains('{') || path.contains('*') {
7071 return None;
7072 }
7073 let (path_without_alias, alias) = path
7074 .split_once(" as ")
7075 .map(|(left, right)| (left.trim(), Some(right.trim())))
7076 .unwrap_or((path, None));
7077 let segments = path_without_alias
7078 .split("::")
7079 .map(str::trim)
7080 .filter(|segment| !segment.is_empty())
7081 .collect::<Vec<_>>();
7082 let local_name = alias.or_else(|| segments.last().copied())?.to_string();
7083 if local_name.chars().next().is_some_and(char::is_uppercase) {
7084 return None;
7085 }
7086 Some((
7087 local_name,
7088 segments
7089 .into_iter()
7090 .map(|segment| segment.to_string())
7091 .collect(),
7092 ))
7093}
7094
7095fn rust_inline_scoped_target(
7096 index: &ProjectIndex<'_>,
7097 caller_file: &str,
7098 module_segments: &[String],
7099 short_name: &str,
7100) -> Option<(String, String)> {
7101 let src_prefix = rust_src_prefix(caller_file);
7102 let mut file_paths = index.files.keys().cloned().collect::<Vec<_>>();
7103 file_paths.sort();
7104 if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
7105 let caller = file_paths.remove(position);
7106 file_paths.insert(0, caller);
7107 }
7108
7109 for file_path in file_paths {
7110 if index.lang_for(&file_path) != Some(LangId::Rust)
7111 || rust_src_prefix(&file_path) != src_prefix
7112 {
7113 continue;
7114 }
7115 let file_module_segments = rust_module_segments_for_rel(&file_path);
7116 if !module_segments.starts_with(&file_module_segments) {
7117 continue;
7118 }
7119 let scoped_segments = &module_segments[file_module_segments.len()..];
7120 if scoped_segments.is_empty() {
7121 continue;
7122 }
7123 let mut scoped_symbol = scoped_segments.join("::");
7124 scoped_symbol.push_str("::");
7125 scoped_symbol.push_str(short_name);
7126 if index.node_for_symbol(&file_path, &scoped_symbol).is_some() {
7127 return Some((file_path, scoped_symbol));
7128 }
7129 }
7130 None
7131}
7132
7133fn rust_target_for_use(
7134 index: &ProjectIndex<'_>,
7135 caller_file: &str,
7136 import: &ImportStatement,
7137 short_name: &str,
7138) -> Option<(String, String)> {
7139 let path = import.module_path.trim().trim_end_matches(';');
7140 if let Some(brace_start) = path.find("::{") {
7141 let prefix = &path[..brace_start];
7142 if import.names.iter().any(|name| name == short_name) {
7143 let prefix_segments: Vec<&str> = prefix.split("::").collect();
7144 let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
7145 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
7146 return Some((file, short_name.to_string()));
7147 }
7148 return None;
7149 }
7150
7151 let (path_without_alias, alias) = path
7152 .split_once(" as ")
7153 .map(|(left, right)| (left.trim(), Some(right.trim())))
7154 .unwrap_or((path, None));
7155 let segments: Vec<&str> = path_without_alias.split("::").collect();
7156 let imported = alias.or_else(|| segments.last().copied())?;
7157 if imported != short_name {
7158 return None;
7159 }
7160 if segments.len() < 2 {
7161 return None;
7162 }
7163 let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
7164 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
7165 Some((file, segments.last().unwrap_or(&short_name).to_string()))
7166}
7167
7168fn rust_workspace_file_for_segments(index: &ProjectIndex<'_>, segments: &[&str]) -> Option<String> {
7169 let crate_name = segments.first().copied()?;
7170 let src_prefix = index.crate_src_prefix(crate_name)?;
7171 let module_segments = segments[1..]
7172 .iter()
7173 .map(|segment| segment.to_string())
7174 .collect::<Vec<_>>();
7175 rust_file_for_src_prefix(index, &src_prefix, &module_segments)
7176}
7177
7178#[cfg(test)]
7179static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
7180 OnceLock::new();
7181
7182#[cfg(test)]
7183fn note_workspace_crate_prefix_build(project_root: &Path) {
7184 let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
7185 .get_or_init(|| Mutex::new(HashMap::new()))
7186 .lock()
7187 .expect("workspace crate prefix build counts mutex poisoned");
7188 *counts.entry(project_root.to_path_buf()).or_default() += 1;
7189}
7190
7191#[cfg(not(test))]
7192fn note_workspace_crate_prefix_build(_project_root: &Path) {}
7193
7194#[cfg(test)]
7195fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
7196 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
7197 .get_or_init(|| Mutex::new(HashMap::new()))
7198 .lock()
7199 .expect("workspace crate prefix build counts mutex poisoned")
7200 .remove(project_root);
7201}
7202
7203#[cfg(test)]
7204fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
7205 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
7206 .get_or_init(|| Mutex::new(HashMap::new()))
7207 .lock()
7208 .expect("workspace crate prefix build counts mutex poisoned")
7209 .get(project_root)
7210 .copied()
7211 .unwrap_or(0)
7212}
7213
7214fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
7219 note_workspace_crate_prefix_build(project_root);
7220 let mut prefixes = HashMap::new();
7221 let mut stack = vec![project_root.to_path_buf()];
7222 while let Some(dir) = stack.pop() {
7223 let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
7224 if matches!(name, "target" | "node_modules" | ".git") {
7225 continue;
7226 }
7227 let manifest = dir.join("Cargo.toml");
7228 if manifest.is_file() {
7229 let crate_names = rust_manifest_crate_names(&manifest);
7230 if !crate_names.is_empty() {
7231 let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
7232 for crate_name in crate_names {
7233 prefixes
7234 .entry(crate_name)
7235 .or_insert_with(|| src_prefix.clone());
7236 }
7237 }
7238 }
7239 let Ok(entries) = std::fs::read_dir(&dir) else {
7240 continue;
7241 };
7242 for entry in entries.flatten() {
7243 let path = entry.path();
7244 if path.is_dir() {
7245 stack.push(path);
7246 }
7247 }
7248 }
7249 prefixes
7250}
7251
7252fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
7256 let Ok(source) = std::fs::read_to_string(manifest) else {
7257 return Vec::new();
7258 };
7259 let mut in_lib = false;
7260 let mut package_name = None;
7261 let mut lib_name = None;
7262 for line in source.lines() {
7263 let trimmed = line.trim();
7264 if trimmed.starts_with('[') {
7265 in_lib = trimmed == "[lib]";
7266 continue;
7267 }
7268 let Some((key, value)) = trimmed.split_once('=') else {
7269 continue;
7270 };
7271 let key = key.trim();
7272 let value = value.trim().trim_matches('"');
7273 if in_lib && key == "name" {
7274 lib_name = Some(value.to_string());
7275 } else if !in_lib && key == "name" && package_name.is_none() {
7276 package_name = Some(value.to_string());
7277 }
7278 }
7279 let mut names = Vec::new();
7280 if let Some(lib) = lib_name {
7281 names.push(lib);
7282 }
7283 if let Some(package) = package_name {
7284 let normalized = package.replace('-', "_");
7285 if !names.contains(&normalized) {
7286 names.push(normalized);
7287 }
7288 }
7289 names
7290}
7291
7292fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
7293 if segments.is_empty() {
7294 return Some(Vec::new());
7295 }
7296 let caller_segments = rust_module_segments_for_rel(caller_file);
7297 match segments[0] {
7298 "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
7299 "self" => {
7300 let mut resolved = caller_segments;
7301 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
7302 Some(resolved)
7303 }
7304 "super" => {
7305 let mut resolved = caller_segments;
7306 resolved.pop();
7307 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
7308 Some(resolved)
7309 }
7310 _ => {
7311 let mut resolved = caller_segments;
7312 resolved.pop();
7313 resolved.extend(segments.iter().map(|item| item.to_string()));
7314 Some(resolved)
7315 }
7316 }
7317}
7318
7319fn rust_file_for_segments(
7320 index: &ProjectIndex<'_>,
7321 caller_file: &str,
7322 segments: &[String],
7323) -> Option<String> {
7324 rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
7325}
7326
7327fn rust_file_for_src_prefix(
7328 index: &ProjectIndex<'_>,
7329 src_prefix: &str,
7330 segments: &[String],
7331) -> Option<String> {
7332 let candidate = if segments.is_empty() {
7333 [src_prefix, "lib.rs"].join("/")
7334 } else {
7335 format!("{}/{}.rs", src_prefix, segments.join("/"))
7336 };
7337 if index.files.contains_key(&candidate) {
7338 return Some(candidate);
7339 }
7340 if !segments.is_empty() {
7341 let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
7342 if index.files.contains_key(&mod_candidate) {
7343 return Some(mod_candidate);
7344 }
7345 }
7346 None
7347}
7348
7349fn rust_src_prefix(rel_path: &str) -> String {
7350 rel_path
7351 .split_once("/src/")
7352 .map(|(prefix, _)| format!("{prefix}/src"))
7353 .unwrap_or_else(|| "src".to_string())
7354}
7355
7356fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
7357 let after_src = rel_path
7358 .split_once("/src/")
7359 .map(|(_, rest)| rest)
7360 .or_else(|| rel_path.strip_prefix("src/"))
7361 .unwrap_or(rel_path);
7362 if matches!(after_src, "lib.rs" | "main.rs") {
7363 return Vec::new();
7364 }
7365 if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
7366 return prefix.split('/').map(|item| item.to_string()).collect();
7367 }
7368 after_src
7369 .strip_suffix(".rs")
7370 .unwrap_or(after_src)
7371 .split('/')
7372 .map(|item| item.to_string())
7373 .collect()
7374}
7375
7376fn resolve_local_target(
7377 _index: &ProjectIndex<'_>,
7378 caller_file: &str,
7379 full_ref: &str,
7380 short_name: &str,
7381 caller_data: &FileCallData,
7382) -> Option<(String, String, String)> {
7383 if !callgraph::is_bare_callee(full_ref, short_name) {
7384 return None;
7385 }
7386 callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
7387 .ok()
7388 .map(|symbol| {
7389 (
7390 "resolved_local".to_string(),
7391 caller_file.to_string(),
7392 symbol,
7393 )
7394 })
7395}
7396
7397impl<'a> ProjectIndex<'a> {
7398 fn from_parts(
7399 project_root: &Path,
7400 files: HashMap<String, DbFileIndex>,
7401 caller_data: HashMap<String, &'a FileCallData>,
7402 workspace_crate_prefixes: WorkspaceCratePrefixCache,
7403 ) -> Self {
7404 Self {
7405 project_root: project_root.to_path_buf(),
7406 files,
7407 caller_data,
7408 workspace_crate_prefixes,
7409 }
7410 }
7411
7412 fn from_extracts(project_root: &Path, extracts: &'a [FileExtract]) -> Self {
7413 let mut files = HashMap::new();
7414 let mut caller_data = HashMap::new();
7415 for extract in extracts {
7416 let index = DbFileIndex::from_extract(project_root, extract);
7417 caller_data.insert(extract.rel_path.clone(), &extract.data);
7418 files.insert(extract.rel_path.clone(), index);
7419 }
7420 Self::from_parts(
7421 project_root,
7422 files,
7423 caller_data,
7424 WorkspaceCratePrefixCache::default(),
7425 )
7426 }
7427
7428 fn from_db_and_callers(
7429 tx: &Transaction<'_>,
7430 project_root: &Path,
7431 caller_extracts: &'a HashMap<String, FileExtract>,
7432 workspace_crate_prefixes: WorkspaceCratePrefixCache,
7433 ) -> Result<Self> {
7434 let mut files = load_db_file_indexes(tx, project_root)?;
7435 let mut caller_data = HashMap::new();
7436 for (rel_path, extract) in caller_extracts {
7437 files.insert(
7438 rel_path.clone(),
7439 DbFileIndex::from_extract(project_root, extract),
7440 );
7441 caller_data.insert(rel_path.clone(), &extract.data);
7442 }
7443 Ok(Self::from_parts(
7444 project_root,
7445 files,
7446 caller_data,
7447 workspace_crate_prefixes,
7448 ))
7449 }
7450
7451 fn lang_for(&self, rel_path: &str) -> Option<LangId> {
7452 self.files.get(rel_path).and_then(|file| file.lang)
7453 }
7454
7455 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
7456 self.files
7457 .get(caller_file)
7458 .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
7459 }
7460
7461 fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
7462 self.files
7463 .get(rel_path)
7464 .map(|file| file.reexports.as_slice())
7465 .unwrap_or(&[])
7466 }
7467
7468 fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
7469 self.files.get(rel_path).and_then(|file| {
7470 file.node_by_scoped
7471 .get(symbol)
7472 .cloned()
7473 .or_else(|| file.node_by_bare.get(symbol).cloned())
7474 })
7475 }
7476}
7477
7478impl DbFileIndex {
7479 fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
7480 let mut node_by_scoped = HashMap::new();
7481 let mut node_by_bare = HashMap::new();
7482 for node in &extract.nodes {
7483 node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
7484 node_by_bare
7485 .entry(node.name.clone())
7486 .or_insert(node.id.clone());
7487 }
7488 let mut export_aliases = HashMap::new();
7489 for raw_ref in &extract.raw_refs {
7490 if raw_ref.kind == "export_alias" {
7491 if let (Some(exported), Some(source_symbol)) =
7492 (&raw_ref.local_name, &raw_ref.requested_name)
7493 {
7494 export_aliases.insert(exported.clone(), source_symbol.clone());
7495 }
7496 }
7497 }
7498 let mut module_targets = HashMap::new();
7499 let mut reexports = Vec::new();
7500 for raw_ref in &extract.raw_refs {
7501 if !matches!(raw_ref.kind.as_str(), "import" | "reexport") {
7502 continue;
7503 }
7504 let Some(module_path) = &raw_ref.module_path else {
7505 continue;
7506 };
7507 let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
7508 module_targets
7509 .entry(module_path.clone())
7510 .or_insert_with(|| target_file.clone());
7511 if raw_ref.kind == "reexport" {
7512 reexports.push(reexport_index_from_raw(raw_ref, target_file));
7513 }
7514 }
7515 Self {
7516 lang: Some(extract.lang),
7517 exports: extract.data.exported_symbols.iter().cloned().collect(),
7518 default_export: extract.data.default_export_symbol.clone(),
7519 export_aliases,
7520 node_by_scoped,
7521 node_by_bare,
7522 module_targets,
7523 reexports,
7524 }
7525 }
7526}
7527
7528fn load_db_file_indexes(
7529 tx: &Transaction<'_>,
7530 project_root: &Path,
7531) -> Result<HashMap<String, DbFileIndex>> {
7532 let mut files = HashMap::new();
7533 let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
7534 let rows = stmt.query_map([], |row| {
7535 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7536 })?;
7537 for row in rows {
7538 let (rel_path, lang) = row?;
7539 files.insert(
7540 rel_path.clone(),
7541 DbFileIndex {
7542 lang: lang_from_label(&lang),
7543 exports: HashSet::new(),
7544 default_export: None,
7545 export_aliases: HashMap::new(),
7546 node_by_scoped: HashMap::new(),
7547 node_by_bare: HashMap::new(),
7548 module_targets: HashMap::new(),
7549 reexports: Vec::new(),
7550 },
7551 );
7552 }
7553
7554 let mut node_stmt = tx.prepare(
7555 "SELECT file_path, id, name, scoped_name, exported, is_default_export FROM nodes",
7556 )?;
7557 let nodes = node_stmt.query_map([], |row| {
7558 Ok((
7559 row.get::<_, String>(0)?,
7560 row.get::<_, String>(1)?,
7561 row.get::<_, String>(2)?,
7562 row.get::<_, String>(3)?,
7563 row.get::<_, i64>(4)? != 0,
7564 row.get::<_, i64>(5)? != 0,
7565 ))
7566 })?;
7567 for row in nodes {
7568 let (file_path, id, name, scoped_name, exported, is_default_export) = row?;
7569 let file = files
7570 .entry(file_path.clone())
7571 .or_insert_with(|| DbFileIndex {
7572 lang: None,
7573 exports: HashSet::new(),
7574 default_export: None,
7575 export_aliases: HashMap::new(),
7576 node_by_scoped: HashMap::new(),
7577 node_by_bare: HashMap::new(),
7578 module_targets: HashMap::new(),
7579 reexports: Vec::new(),
7580 });
7581 if exported {
7582 file.exports.insert(name.clone());
7583 file.exports.insert(scoped_name.clone());
7584 }
7585 if is_default_export {
7586 file.default_export = Some(scoped_name.clone());
7587 }
7588 file.node_by_scoped.insert(scoped_name, id.clone());
7589 file.node_by_bare.entry(name).or_insert(id);
7590 }
7591 let file_keys: HashSet<String> = files.keys().cloned().collect();
7592 let dependencies_by_file = load_file_dependencies_index(tx)?;
7596 let mut ref_stmt = tx.prepare(
7597 "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
7598 FROM refs WHERE kind IN ('reexport', 'export_alias')",
7599 )?;
7600 let ref_rows = ref_stmt.query_map([], |row| {
7601 Ok((
7602 row.get::<_, String>(0)?,
7603 row.get::<_, String>(1)?,
7604 row.get::<_, String>(2)?,
7605 row.get::<_, Option<String>>(3)?,
7606 row.get::<_, Option<String>>(4)?,
7607 row.get::<_, i64>(5)? != 0,
7608 row.get::<_, Option<String>>(6)?,
7609 row.get::<_, Option<String>>(7)?,
7610 ))
7611 })?;
7612 for row in ref_rows {
7613 let (
7614 ref_id,
7615 caller_file,
7616 kind,
7617 module_path,
7618 full_ref,
7619 wildcard,
7620 local_name,
7621 requested_name,
7622 ) = row?;
7623 if kind == "export_alias" {
7624 if let (Some(exported), Some(source_symbol), Some(file)) =
7625 (local_name, requested_name, files.get_mut(&caller_file))
7626 {
7627 file.export_aliases.insert(exported, source_symbol);
7628 }
7629 continue;
7630 }
7631 let Some(module_path) = module_path else {
7632 continue;
7633 };
7634 let file_deps = dependencies_by_file
7635 .get(&caller_file)
7636 .cloned()
7637 .unwrap_or_default();
7638 let deps = stored_dependencies_for_module(
7639 project_root,
7640 &caller_file,
7641 &module_path,
7642 &file_deps,
7643 &file_keys,
7644 );
7645 let target_file = deps
7646 .iter()
7647 .find(|dep| file_keys.contains(*dep))
7648 .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
7649 if let Some(file) = files.get_mut(&caller_file) {
7650 file.module_targets
7651 .entry(module_path.clone())
7652 .or_insert_with(|| target_file.clone());
7653 if kind == "reexport" {
7654 let raw = RawRef {
7655 ref_id,
7656 caller_node: None,
7657 caller_symbol: None,
7658 caller_file,
7659 kind,
7660 short_name: None,
7661 full_ref,
7662 module_path: Some(module_path),
7663 import_kind: Some("reexport".to_string()),
7664 local_name: None,
7665 requested_name: None,
7666 namespace_alias: None,
7667 wildcard,
7668 line: 0,
7669 byte_start: 0,
7670 byte_end: 0,
7671 dependencies: deps,
7672 };
7673 file.reexports
7674 .push(reexport_index_from_raw(&raw, target_file));
7675 }
7676 }
7677 }
7678
7679 Ok(files)
7680}
7681
7682fn stored_dependencies_for_module(
7683 project_root: &Path,
7684 caller_file: &str,
7685 module_path: &str,
7686 caller_dependencies: &BTreeSet<String>,
7687 indexed_files: &HashSet<String>,
7688) -> BTreeSet<String> {
7689 let caller_path = project_root.join(caller_file);
7690 let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
7691 if module_path.starts_with('.') {
7692 let caller_dir = caller_path.parent().unwrap_or(project_root);
7693 for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
7694 let normalized = if candidate.is_file() {
7695 canonicalize_path(&candidate)
7696 } else {
7697 candidate
7698 };
7699 candidates.insert(relative_path(project_root, &normalized));
7700 }
7701 }
7702 let exact = candidates
7703 .intersection(caller_dependencies)
7704 .filter(|dependency| indexed_files.contains(*dependency))
7705 .cloned()
7706 .collect::<BTreeSet<_>>();
7707 if !exact.is_empty() || module_path.starts_with('.') {
7708 return exact;
7709 }
7710
7711 let module_path = rust_module_path_without_alias_or_use_list(module_path)
7712 .trim_matches(|character| matches!(character, '\'' | '"'));
7713 let package_name = module_path
7714 .split('/')
7715 .next_back()
7716 .unwrap_or(module_path)
7717 .replace('_', "-");
7718 let matched = caller_dependencies
7719 .iter()
7720 .filter(|dependency| indexed_files.contains(*dependency))
7721 .filter(|dependency| {
7722 dependency.as_str() == module_path
7723 || dependency.ends_with(&format!("/{module_path}"))
7724 || Path::new(dependency).components().any(|component| {
7725 component.as_os_str().to_string_lossy().replace('_', "-") == package_name
7726 })
7727 })
7728 .cloned()
7729 .collect::<BTreeSet<_>>();
7730 if matched.len() == 1 {
7731 matched
7732 } else {
7733 BTreeSet::new()
7734 }
7735}
7736
7737fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
7738 let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
7739 let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
7740 let rows = stmt.query_map([], |row| {
7741 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7742 })?;
7743 for row in rows {
7744 let (file_path, dependency) = row?;
7745 by_file.entry(file_path).or_default().insert(dependency);
7746 }
7747 Ok(by_file)
7748}
7749
7750struct ColdBuildInsertStatements<'stmt> {
7751 file: Statement<'stmt>,
7752 node: Statement<'stmt>,
7753 file_dependency: Statement<'stmt>,
7754 dispatch_hint: Statement<'stmt>,
7755 backend_state: Statement<'stmt>,
7756 reference: Statement<'stmt>,
7757 edge: Statement<'stmt>,
7758}
7759
7760impl<'stmt> ColdBuildInsertStatements<'stmt> {
7761 fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
7762 Ok(Self {
7763 file: tx.prepare(
7764 "INSERT OR REPLACE INTO files(
7765 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
7766 is_public_api, surface_fingerprint, indexed_at
7767 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
7768 )?,
7769 node: tx.prepare(
7770 "INSERT OR REPLACE INTO nodes(
7771 id, file_path, name, scoped_name, kind, start_line, start_col,
7772 end_line, end_col, range_ordinal, signature, exported,
7773 is_default_export, is_type_like, is_callgraph_entry_point, provenance
7774 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
7775 )?,
7776 file_dependency: tx.prepare(
7777 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
7778 )?,
7779 dispatch_hint: tx.prepare(
7780 "INSERT OR REPLACE INTO dispatch_hints(
7781 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
7782 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
7783 )?,
7784 backend_state: tx.prepare(
7785 "INSERT OR REPLACE INTO backend_file_state(
7786 backend, workspace_root, file_path, content_hash, status, updated_at
7787 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
7788 )?,
7789 reference: tx.prepare(
7790 "INSERT OR REPLACE INTO refs(
7791 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
7792 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
7793 byte_start, byte_end, status, target_node, target_file, target_symbol,
7794 provenance
7795 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
7796 )?,
7797 edge: tx.prepare(
7798 "INSERT OR REPLACE INTO edges(
7799 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
7800 kind, line, provenance
7801 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
7802 )?,
7803 })
7804 }
7805}
7806
7807fn insert_file_extract_prepared(
7808 statements: &mut ColdBuildInsertStatements<'_>,
7809 workspace_root: &str,
7810 extract: &FileExtract,
7811) -> Result<()> {
7812 statements.file.execute(params![
7813 extract.rel_path,
7814 hash_to_hex(extract.freshness.content_hash),
7815 system_time_to_ns(extract.freshness.mtime),
7816 extract.freshness.size as i64,
7817 lang_label(extract.lang),
7818 extract.surface_fingerprint,
7819 unix_seconds_now(),
7820 ])?;
7821 for node in &extract.nodes {
7822 statements.node.execute(params![
7823 node.id,
7824 node.file_path,
7825 node.name,
7826 node.scoped_name,
7827 node.kind,
7828 node.range.start_line as i64,
7829 node.range.start_col as i64,
7830 node.range.end_line as i64,
7831 node.range.end_col as i64,
7832 node.range_ordinal as i64,
7833 node.signature,
7834 bool_int(node.exported),
7835 bool_int(node.is_default_export),
7836 bool_int(node.is_type_like),
7837 bool_int(node.is_callgraph_entry_point),
7838 PROVENANCE_TREESITTER,
7839 ])?;
7840 }
7841
7842 let mut dependencies = BTreeSet::new();
7843 for raw_ref in &extract.raw_refs {
7844 dependencies.extend(raw_ref.dependencies.iter().cloned());
7845 }
7846 for dep_file in &dependencies {
7847 statements
7848 .file_dependency
7849 .execute(params![extract.rel_path, dep_file])?;
7850 }
7851
7852 for hint in &extract.dispatch_hints {
7853 statements.dispatch_hint.execute(params![
7854 hint.id,
7855 hint.method_name,
7856 hint.caller_node,
7857 hint.file,
7858 hint.line as i64,
7859 hint.byte_start as i64,
7860 hint.byte_end as i64,
7861 PROVENANCE_TREESITTER,
7862 ])?;
7863 }
7864 insert_backend_state_prepared(
7865 &mut statements.backend_state,
7866 workspace_root,
7867 &extract.rel_path,
7868 Some(&extract.freshness.content_hash),
7869 "fresh",
7870 )?;
7871 Ok(())
7872}
7873
7874fn insert_backend_state_prepared(
7875 stmt: &mut Statement<'_>,
7876 workspace_root: &str,
7877 rel_path: &str,
7878 content_hash: Option<&blake3::Hash>,
7879 status: &str,
7880) -> Result<()> {
7881 let hash = content_hash
7882 .map(|hash| hash_to_hex(*hash))
7883 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
7884 stmt.execute(params![
7885 BACKEND_TREESITTER,
7886 workspace_root,
7887 rel_path,
7888 hash,
7889 status,
7890 unix_seconds_now(),
7891 ])?;
7892 Ok(())
7893}
7894
7895fn insert_resolved_ref_prepared(
7896 statements: &mut ColdBuildInsertStatements<'_>,
7897 resolved: &ResolvedRef,
7898) -> Result<()> {
7899 let raw = &resolved.raw;
7900 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
7901 statements.reference.execute(params![
7902 raw.ref_id,
7903 raw.caller_node,
7904 raw.caller_file,
7905 raw.kind,
7906 raw.short_name,
7907 raw.full_ref,
7908 raw.module_path,
7909 raw.import_kind,
7910 raw.local_name,
7911 raw.requested_name,
7912 raw.namespace_alias,
7913 bool_int(raw.wildcard),
7914 raw.line as i64,
7915 raw.byte_start as i64,
7916 raw.byte_end as i64,
7917 resolved.status,
7918 resolved.target_node,
7919 resolved.target_file,
7920 resolved.target_symbol,
7921 PROVENANCE_TREESITTER,
7922 ])?;
7923 if let Some(edge) = &resolved.edge {
7924 statements.edge.execute(params![
7925 edge.edge_id,
7926 raw.ref_id,
7927 edge.source_node,
7928 edge.target_node,
7929 edge.target_file,
7930 edge.target_symbol,
7931 edge.kind,
7932 edge.line as i64,
7933 PROVENANCE_TREESITTER,
7934 ])?;
7935 }
7936 Ok(())
7937}
7938
7939fn insert_file_extract(
7940 tx: &Transaction<'_>,
7941 project_root: &Path,
7942 extract: &FileExtract,
7943) -> Result<()> {
7944 tx.execute(
7945 "INSERT OR REPLACE INTO files(
7946 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
7947 is_public_api, surface_fingerprint, indexed_at
7948 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
7949 params![
7950 extract.rel_path,
7951 hash_to_hex(extract.freshness.content_hash),
7952 system_time_to_ns(extract.freshness.mtime),
7953 extract.freshness.size as i64,
7954 lang_label(extract.lang),
7955 extract.surface_fingerprint,
7956 unix_seconds_now(),
7957 ],
7958 )?;
7959 for node in &extract.nodes {
7960 tx.execute(
7961 "INSERT OR REPLACE INTO nodes(
7962 id, file_path, name, scoped_name, kind, start_line, start_col,
7963 end_line, end_col, range_ordinal, signature, exported,
7964 is_default_export, is_type_like, is_callgraph_entry_point, provenance
7965 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
7966 params![
7967 node.id,
7968 node.file_path,
7969 node.name,
7970 node.scoped_name,
7971 node.kind,
7972 node.range.start_line as i64,
7973 node.range.start_col as i64,
7974 node.range.end_line as i64,
7975 node.range.end_col as i64,
7976 node.range_ordinal as i64,
7977 node.signature,
7978 bool_int(node.exported),
7979 bool_int(node.is_default_export),
7980 bool_int(node.is_type_like),
7981 bool_int(node.is_callgraph_entry_point),
7982 PROVENANCE_TREESITTER,
7983 ],
7984 )?;
7985 }
7986 let mut dependencies = BTreeSet::new();
7987 for raw_ref in &extract.raw_refs {
7988 dependencies.extend(raw_ref.dependencies.iter().cloned());
7989 }
7990 insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
7991
7992 for hint in &extract.dispatch_hints {
7993 tx.execute(
7994 "INSERT OR REPLACE INTO dispatch_hints(
7995 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
7996 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
7997 params![
7998 hint.id,
7999 hint.method_name,
8000 hint.caller_node,
8001 hint.file,
8002 hint.line as i64,
8003 hint.byte_start as i64,
8004 hint.byte_end as i64,
8005 PROVENANCE_TREESITTER,
8006 ],
8007 )?;
8008 }
8009 mark_backend_state(
8010 tx,
8011 project_root,
8012 &extract.rel_path,
8013 Some(&extract.freshness.content_hash),
8014 "fresh",
8015 )?;
8016 Ok(())
8017}
8018
8019fn insert_file_dependencies(
8020 tx: &Transaction<'_>,
8021 file_path: &str,
8022 dependencies: &BTreeSet<String>,
8023) -> Result<()> {
8024 for dep_file in dependencies {
8025 tx.execute(
8026 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
8027 params![file_path, dep_file],
8028 )?;
8029 }
8030 Ok(())
8031}
8032
8033fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
8034 let raw = &resolved.raw;
8035 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
8036 tx.execute(
8037 "INSERT OR REPLACE INTO refs(
8038 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
8039 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
8040 byte_start, byte_end, status, target_node, target_file, target_symbol,
8041 provenance
8042 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
8043 params![
8044 raw.ref_id,
8045 raw.caller_node,
8046 raw.caller_file,
8047 raw.kind,
8048 raw.short_name,
8049 raw.full_ref,
8050 raw.module_path,
8051 raw.import_kind,
8052 raw.local_name,
8053 raw.requested_name,
8054 raw.namespace_alias,
8055 bool_int(raw.wildcard),
8056 raw.line as i64,
8057 raw.byte_start as i64,
8058 raw.byte_end as i64,
8059 resolved.status,
8060 resolved.target_node,
8061 resolved.target_file,
8062 resolved.target_symbol,
8063 PROVENANCE_TREESITTER,
8064 ],
8065 )?;
8066 if let Some(edge) = &resolved.edge {
8067 tx.execute(
8068 "INSERT OR REPLACE INTO edges(
8069 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8070 kind, line, provenance
8071 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
8072 params![
8073 edge.edge_id,
8074 raw.ref_id,
8075 edge.source_node,
8076 edge.target_node,
8077 edge.target_file,
8078 edge.target_symbol,
8079 edge.kind,
8080 edge.line as i64,
8081 PROVENANCE_TREESITTER,
8082 ],
8083 )?;
8084 }
8085 Ok(())
8086}
8087
8088fn insert_method_dispatch_edges(
8089 tx: &Transaction<'_>,
8090 project_root: &Path,
8091 caller_files: Option<&BTreeSet<String>>,
8092) -> Result<usize> {
8093 let references = load_name_match_refs(tx, caller_files)?;
8094 if references.is_empty() {
8095 return Ok(0);
8096 }
8097
8098 let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
8099 let mut source_cache: DispatchSourceCache = HashMap::new();
8100 let mut inserted = 0usize;
8101 for reference in references {
8102 let key = (reference.method_name.clone(), reference.lang.clone());
8103 let candidates = match candidates_by_name.entry(key) {
8104 Entry::Occupied(entry) => entry.into_mut(),
8105 Entry::Vacant(entry) => {
8106 let candidates =
8107 load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
8108 entry.insert(candidates)
8109 }
8110 };
8111
8112 if let Some(receiver_type) =
8113 infer_receiver_type(project_root, &reference, &mut source_cache)
8114 {
8115 let Some(candidate) =
8116 select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
8117 else {
8118 continue;
8119 };
8120 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
8121 inserted += 1;
8122 continue;
8123 }
8124
8125 if method_name_match_denylisted(&reference.method_name) {
8126 continue;
8127 }
8128
8129 let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
8130 continue;
8131 };
8132 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
8133 inserted += 1;
8134 }
8135 Ok(inserted)
8136}
8137
8138fn insert_method_dispatch_edges_chunked(
8139 tx: &Transaction<'_>,
8140 project_root: &Path,
8141 caller_files: &BTreeSet<String>,
8142 chunk_size: usize,
8143) -> Result<usize> {
8144 if caller_files.is_empty() {
8145 return Ok(0);
8146 }
8147 if chunk_size == 0 || caller_files.len() <= chunk_size {
8148 return insert_method_dispatch_edges(tx, project_root, Some(caller_files));
8149 }
8150
8151 let mut inserted = 0usize;
8152 let mut batch = BTreeSet::new();
8153 for caller_file in caller_files {
8154 batch.insert(caller_file.clone());
8155 if batch.len() == chunk_size {
8156 inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
8157 batch.clear();
8158 }
8159 }
8160 if !batch.is_empty() {
8161 inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
8162 }
8163 Ok(inserted)
8164}
8165
8166fn insert_method_dispatch_edge(
8167 tx: &Transaction<'_>,
8168 reference: &NameMatchRef,
8169 candidate: &NameMatchCandidate,
8170 provenance: &str,
8171) -> Result<()> {
8172 tx.execute(
8173 "INSERT OR REPLACE INTO edges(
8174 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8175 kind, line, provenance
8176 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
8177 params![
8178 ref_id(&[&reference.ref_id, provenance, "edge"]),
8179 &reference.ref_id,
8180 &reference.caller_node,
8181 &candidate.node_id,
8182 &candidate.file_path,
8183 &candidate.scoped_name,
8184 reference.line as i64,
8185 provenance,
8186 ],
8187 )?;
8188 Ok(())
8189}
8190
8191fn delete_method_dispatch_edges_for_callers(
8192 tx: &Transaction<'_>,
8193 caller_files: &BTreeSet<String>,
8194) -> Result<()> {
8195 if caller_files.is_empty() {
8196 return Ok(());
8197 }
8198
8199 let mut stmt = tx.prepare(
8200 "DELETE FROM edges
8201 WHERE provenance IN (?1, ?2)
8202 AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
8203 )?;
8204 for caller_file in caller_files {
8205 stmt.execute(params![
8206 PROVENANCE_NAME_MATCH,
8207 PROVENANCE_TYPE_MATCH,
8208 caller_file
8209 ])?;
8210 }
8211 Ok(())
8212}
8213
8214fn load_name_match_refs(
8215 tx: &Transaction<'_>,
8216 caller_files: Option<&BTreeSet<String>>,
8217) -> Result<Vec<NameMatchRef>> {
8218 let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
8219 n.signature, r.short_name, r.full_ref, r.line, f.lang
8220 FROM refs r
8221 JOIN files f ON f.path = r.caller_file
8222 JOIN nodes n ON n.id = r.caller_node
8223 WHERE r.kind = 'call'
8224 AND r.status = 'unresolved'
8225 AND r.caller_node IS NOT NULL
8226 AND r.full_ref IS NOT NULL
8227 AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
8228 AND NOT EXISTS (
8229 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
8230 )";
8231 let mut references = Vec::new();
8232
8233 if let Some(caller_files) = caller_files {
8234 if caller_files.is_empty() {
8235 return Ok(references);
8236 }
8237 let sql = format!(
8238 "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
8239 );
8240 let mut stmt = tx.prepare(&sql)?;
8241 for caller_file in caller_files {
8242 let rows = stmt.query_map(params![caller_file], |row| {
8243 Ok((
8244 row.get::<_, String>(0)?,
8245 row.get::<_, Option<String>>(1)?,
8246 row.get::<_, String>(2)?,
8247 row.get::<_, String>(3)?,
8248 row.get::<_, Option<String>>(4)?,
8249 row.get::<_, Option<String>>(5)?,
8250 row.get::<_, Option<String>>(6)?,
8251 row.get::<_, i64>(7)?,
8252 row.get::<_, String>(8)?,
8253 ))
8254 })?;
8255 for row in rows {
8256 let (
8257 ref_id,
8258 caller_node,
8259 caller_file,
8260 caller_symbol,
8261 caller_signature,
8262 short_name,
8263 full_ref,
8264 line,
8265 lang,
8266 ) = row?;
8267 if let Some(reference) = name_match_ref_from_parts(
8268 ref_id,
8269 caller_node,
8270 caller_file,
8271 caller_symbol,
8272 caller_signature,
8273 short_name,
8274 full_ref,
8275 line,
8276 lang,
8277 ) {
8278 references.push(reference);
8279 }
8280 }
8281 }
8282 return Ok(references);
8283 }
8284
8285 let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
8286 let mut stmt = tx.prepare(&sql)?;
8287 let rows = stmt.query_map([], |row| {
8288 Ok((
8289 row.get::<_, String>(0)?,
8290 row.get::<_, Option<String>>(1)?,
8291 row.get::<_, String>(2)?,
8292 row.get::<_, String>(3)?,
8293 row.get::<_, Option<String>>(4)?,
8294 row.get::<_, Option<String>>(5)?,
8295 row.get::<_, Option<String>>(6)?,
8296 row.get::<_, i64>(7)?,
8297 row.get::<_, String>(8)?,
8298 ))
8299 })?;
8300 for row in rows {
8301 let (
8302 ref_id,
8303 caller_node,
8304 caller_file,
8305 caller_symbol,
8306 caller_signature,
8307 short_name,
8308 full_ref,
8309 line,
8310 lang,
8311 ) = row?;
8312 if let Some(reference) = name_match_ref_from_parts(
8313 ref_id,
8314 caller_node,
8315 caller_file,
8316 caller_symbol,
8317 caller_signature,
8318 short_name,
8319 full_ref,
8320 line,
8321 lang,
8322 ) {
8323 references.push(reference);
8324 }
8325 }
8326 Ok(references)
8327}
8328
8329#[allow(clippy::too_many_arguments)]
8330fn name_match_ref_from_parts(
8331 ref_id: String,
8332 caller_node: Option<String>,
8333 caller_file: String,
8334 caller_symbol: String,
8335 caller_signature: Option<String>,
8336 short_name: Option<String>,
8337 full_ref: Option<String>,
8338 line: i64,
8339 lang: String,
8340) -> Option<NameMatchRef> {
8341 let caller_node = caller_node?;
8342 let full_ref = full_ref?;
8343 let (receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
8344 let method_name = if member.is_empty() {
8345 short_name.as_deref()?.to_string()
8346 } else {
8347 member
8348 };
8349 Some(NameMatchRef {
8350 ref_id,
8351 caller_node,
8352 caller_file,
8353 caller_symbol,
8354 caller_signature,
8355 receiver,
8356 method_name,
8357 colon_dispatch,
8358 line: line.max(0) as u32,
8359 lang,
8360 })
8361}
8362
8363fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, bool)> {
8364 let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
8365 let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
8366 let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
8367 let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
8368 .into_iter()
8369 .flatten()
8370 .max_by_key(|(index, _, _)| *index)?;
8371 if delimiter == 0 {
8372 return None;
8373 }
8374 let member_start = delimiter + delimiter_len;
8375 if member_start >= full_ref.len() {
8376 return None;
8377 }
8378 let receiver = last_name_segment(&full_ref[..delimiter]);
8379 let member = &full_ref[member_start..];
8380 if receiver.is_empty() || member.is_empty() {
8381 return None;
8382 }
8383 Some((receiver.to_string(), member.to_string(), colon_dispatch))
8384}
8385
8386fn last_name_segment(value: &str) -> &str {
8387 value
8388 .rsplit(['.', ':', '/', '\\', '-', '>'])
8389 .find(|segment| !segment.is_empty())
8390 .unwrap_or(value)
8391}
8392
8393fn load_name_match_candidates(
8394 tx: &Transaction<'_>,
8395 method_name: &str,
8396 lang: &str,
8397) -> Result<Vec<NameMatchCandidate>> {
8398 let mut stmt = tx.prepare(
8399 "SELECT n.id, n.file_path, n.scoped_name, n.kind
8400 FROM nodes n JOIN files f ON f.path = n.file_path
8401 WHERE n.name = ?1
8402 AND f.lang = ?2
8403 AND n.kind IN ('method', 'function')
8404 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
8405 )?;
8406 let rows = stmt.query_map(params![method_name, lang], |row| {
8407 Ok(NameMatchCandidate {
8408 node_id: row.get(0)?,
8409 file_path: row.get(1)?,
8410 scoped_name: row.get(2)?,
8411 kind: row.get(3)?,
8412 })
8413 })?;
8414 rows.collect::<std::result::Result<Vec<_>, _>>()
8415 .map_err(Into::into)
8416}
8417
8418struct ParsedDispatchSource {
8419 source: String,
8420 tree: tree_sitter::Tree,
8421}
8422
8423type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
8424
8425fn infer_receiver_type(
8426 project_root: &Path,
8427 reference: &NameMatchRef,
8428 source_cache: &mut DispatchSourceCache,
8429) -> Option<String> {
8430 match reference.lang.as_str() {
8431 "rust" => infer_rust_receiver_type(reference),
8432 "java" => {
8433 infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
8434 }
8435 "kotlin" => {
8436 infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
8437 }
8438 "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache),
8439 _ => None,
8440 }
8441}
8442
8443fn parse_dispatch_source(
8444 project_root: &Path,
8445 caller_file: &str,
8446 lang: LangId,
8447) -> Option<ParsedDispatchSource> {
8448 let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
8449 let grammar = crate::parser::grammar_for(lang);
8450 let mut parser = tree_sitter::Parser::new();
8451 parser.set_language(&grammar).ok()?;
8452 let tree = parser.parse(&source, None)?;
8453 Some(ParsedDispatchSource { source, tree })
8454}
8455
8456fn parsed_dispatch_source<'a>(
8457 project_root: &Path,
8458 reference: &NameMatchRef,
8459 lang: LangId,
8460 source_cache: &'a mut DispatchSourceCache,
8461) -> Option<&'a ParsedDispatchSource> {
8462 let key = (reference.caller_file.clone(), reference.lang.clone());
8463 source_cache
8464 .entry(key)
8465 .or_insert_with(|| parse_dispatch_source(project_root, &reference.caller_file, lang))
8466 .as_ref()
8467}
8468
8469fn infer_java_like_receiver_type(
8470 project_root: &Path,
8471 reference: &NameMatchRef,
8472 lang: LangId,
8473 source_cache: &mut DispatchSourceCache,
8474) -> Option<String> {
8475 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
8476 return None;
8477 }
8478
8479 let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
8480 let root = parsed.tree.root_node();
8481 let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
8482
8483 let callable_scope = type_node
8484 .and_then(|node| {
8485 find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
8486 })
8487 .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
8488
8489 if let Some(callable_scope) = callable_scope {
8490 if let Some(receiver_type) = infer_java_like_local_receiver_type(
8491 callable_scope,
8492 &parsed.source,
8493 &reference.receiver,
8494 reference.line.max(1),
8495 lang,
8496 ) {
8497 return Some(receiver_type);
8498 }
8499 }
8500
8501 type_node.and_then(|node| {
8502 infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
8503 })
8504}
8505
8506fn infer_cpp_receiver_type(
8507 project_root: &Path,
8508 reference: &NameMatchRef,
8509 source_cache: &mut DispatchSourceCache,
8510) -> Option<String> {
8511 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
8512 return None;
8513 }
8514
8515 let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
8516 let root = parsed.tree.root_node();
8517 let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
8518 infer_cpp_receiver_type_from_scope(
8519 scope,
8520 &parsed.source,
8521 &reference.receiver,
8522 reference.line.max(1),
8523 )
8524}
8525
8526fn find_enclosing_java_like_type_node<'tree>(
8527 root: tree_sitter::Node<'tree>,
8528 source: &str,
8529 reference: &NameMatchRef,
8530 lang: LangId,
8531) -> Option<tree_sitter::Node<'tree>> {
8532 let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
8533 .and_then(|name| simple_type_name(&name));
8534 let line = reference.line.max(1);
8535 let mut best = None;
8536 let mut stack = vec![root];
8537 while let Some(node) = stack.pop() {
8538 if !node_contains_line(node, line) {
8539 continue;
8540 }
8541 if is_java_like_type_kind(node.kind(), lang) {
8542 let name = declaration_name(node, source);
8543 if expected_type
8544 .as_deref()
8545 .is_none_or(|expected| name == Some(expected))
8546 {
8547 best = tighter_node(best, node);
8548 }
8549 }
8550 push_named_children(node, &mut stack);
8551 }
8552 best
8553}
8554
8555fn find_enclosing_java_like_callable_node<'tree>(
8556 root: tree_sitter::Node<'tree>,
8557 source: &str,
8558 reference: &NameMatchRef,
8559 lang: LangId,
8560) -> Option<tree_sitter::Node<'tree>> {
8561 let expected_name = reference.caller_symbol.rsplit("::").next();
8562 let line = reference.line.max(1);
8563 let mut best = None;
8564 let mut stack = vec![root];
8565 while let Some(node) = stack.pop() {
8566 if !node_contains_line(node, line) {
8567 continue;
8568 }
8569 if is_java_like_callable_kind(node.kind(), lang) {
8570 let name = declaration_name(node, source);
8571 if expected_name.is_none_or(|expected| name == Some(expected)) {
8572 best = tighter_node(best, node);
8573 }
8574 }
8575 push_named_children(node, &mut stack);
8576 }
8577 best
8578}
8579
8580fn find_enclosing_cpp_callable_node<'tree>(
8581 root: tree_sitter::Node<'tree>,
8582 _source: &str,
8583 reference: &NameMatchRef,
8584) -> Option<tree_sitter::Node<'tree>> {
8585 let line = reference.line.max(1);
8586 let mut best = None;
8587 let mut stack = vec![root];
8588 while let Some(node) = stack.pop() {
8589 if !node_contains_line(node, line) {
8590 continue;
8591 }
8592 if node.kind() == "function_definition" {
8593 best = tighter_node(best, node);
8594 }
8595 push_named_children(node, &mut stack);
8596 }
8597 best
8598}
8599
8600fn tighter_node<'tree>(
8601 current: Option<tree_sitter::Node<'tree>>,
8602 candidate: tree_sitter::Node<'tree>,
8603) -> Option<tree_sitter::Node<'tree>> {
8604 match current {
8605 Some(current)
8606 if current.start_byte() > candidate.start_byte()
8607 || (current.start_byte() == candidate.start_byte()
8608 && current.end_byte() <= candidate.end_byte()) =>
8609 {
8610 Some(current)
8611 }
8612 _ => Some(candidate),
8613 }
8614}
8615
8616fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
8617 let start = node.start_position().row as u32 + 1;
8618 let end = node.end_position().row as u32 + 1;
8619 start <= line && line <= end
8620}
8621
8622fn push_named_children<'tree>(
8623 node: tree_sitter::Node<'tree>,
8624 stack: &mut Vec<tree_sitter::Node<'tree>>,
8625) {
8626 for index in 0..node.named_child_count() {
8627 if let Some(child) = node.named_child(index as u32) {
8628 stack.push(child);
8629 }
8630 }
8631}
8632
8633fn declaration_name<'source>(
8634 node: tree_sitter::Node<'_>,
8635 source: &'source str,
8636) -> Option<&'source str> {
8637 node.child_by_field_name("name")
8638 .map(|name| node_text(name, source))
8639 .or_else(|| {
8640 first_named_child_text(
8641 node,
8642 source,
8643 &["identifier", "type_identifier", "simple_identifier"],
8644 )
8645 })
8646}
8647
8648fn first_named_child_text<'source>(
8649 node: tree_sitter::Node<'_>,
8650 source: &'source str,
8651 kinds: &[&str],
8652) -> Option<&'source str> {
8653 for index in 0..node.named_child_count() {
8654 let child = node.named_child(index as u32)?;
8655 if kinds.contains(&child.kind()) {
8656 return Some(node_text(child, source));
8657 }
8658 }
8659 None
8660}
8661
8662fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
8663 &source[node.byte_range()]
8664}
8665
8666fn infer_java_like_field_receiver_type(
8667 type_node: tree_sitter::Node<'_>,
8668 source: &str,
8669 receiver: &str,
8670 lang: LangId,
8671) -> Option<String> {
8672 let mut stack = Vec::new();
8673 push_named_children(type_node, &mut stack);
8674 while let Some(node) = stack.pop() {
8675 if is_java_like_field_kind(node.kind(), lang) {
8676 if let Some(receiver_type) =
8677 extract_java_like_declared_type(node_text(node, source), receiver, lang)
8678 {
8679 return Some(receiver_type);
8680 }
8681 }
8682 if is_java_like_type_kind(node.kind(), lang)
8683 || is_java_like_callable_kind(node.kind(), lang)
8684 {
8685 continue;
8686 }
8687 push_named_children(node, &mut stack);
8688 }
8689 None
8690}
8691
8692fn infer_java_like_local_receiver_type(
8693 callable_node: tree_sitter::Node<'_>,
8694 source: &str,
8695 receiver: &str,
8696 call_line: u32,
8697 lang: LangId,
8698) -> Option<String> {
8699 let mut best: Option<(u32, String)> = None;
8700 let mut stack = Vec::new();
8701 push_named_children(callable_node, &mut stack);
8702 while let Some(node) = stack.pop() {
8703 let start_line = node.start_position().row as u32 + 1;
8704 if start_line > call_line {
8705 continue;
8706 }
8707 if is_java_like_local_kind(node.kind(), lang) {
8708 if let Some(receiver_type) =
8709 extract_java_like_declared_type(node_text(node, source), receiver, lang)
8710 {
8711 if best
8712 .as_ref()
8713 .is_none_or(|(best_line, _)| start_line >= *best_line)
8714 {
8715 best = Some((start_line, receiver_type));
8716 }
8717 }
8718 }
8719 if is_java_like_type_kind(node.kind(), lang)
8720 || is_java_like_callable_kind(node.kind(), lang)
8721 {
8722 continue;
8723 }
8724 push_named_children(node, &mut stack);
8725 }
8726 best.map(|(_, receiver_type)| receiver_type)
8727}
8728
8729fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
8730 match lang {
8731 LangId::Java => matches!(
8732 kind,
8733 "class_declaration"
8734 | "interface_declaration"
8735 | "enum_declaration"
8736 | "record_declaration"
8737 | "annotation_type_declaration"
8738 ),
8739 LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
8740 _ => false,
8741 }
8742}
8743
8744fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
8745 match lang {
8746 LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
8747 LangId::Kotlin => kind == "function_declaration",
8748 _ => false,
8749 }
8750}
8751
8752fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
8753 match lang {
8754 LangId::Java => kind == "field_declaration",
8755 LangId::Kotlin => kind == "property_declaration",
8756 _ => false,
8757 }
8758}
8759
8760fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
8761 match lang {
8762 LangId::Java => kind == "local_variable_declaration",
8763 LangId::Kotlin => kind == "property_declaration",
8764 _ => false,
8765 }
8766}
8767
8768fn extract_java_like_declared_type(
8769 declaration: &str,
8770 receiver: &str,
8771 lang: LangId,
8772) -> Option<String> {
8773 match lang {
8774 LangId::Java => extract_java_declared_type(declaration, receiver),
8775 LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
8776 _ => None,
8777 }
8778}
8779
8780fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
8781 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
8782 let after = declaration[receiver_start + receiver.len()..].trim_start();
8783 if after
8784 .chars()
8785 .next()
8786 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
8787 {
8788 return None;
8789 }
8790
8791 let before = declaration[..receiver_start].trim_end();
8792 if before.contains(',') {
8793 return None;
8794 }
8795 normalize_receiver_type_name(strip_java_declaration_prefixes(before))
8796}
8797
8798fn strip_java_declaration_prefixes(mut value: &str) -> &str {
8799 loop {
8800 value = value.trim_start();
8801 if let Some(stripped) = strip_leading_java_annotation(value) {
8802 value = stripped;
8803 continue;
8804 }
8805 if let Some(stripped) = strip_leading_java_modifier(value) {
8806 value = stripped;
8807 continue;
8808 }
8809 return value.trim();
8810 }
8811}
8812
8813fn strip_leading_java_annotation(value: &str) -> Option<&str> {
8814 let value = value.trim_start();
8815 let mut chars = value.char_indices();
8816 let (_, first) = chars.next()?;
8817 if first != '@' {
8818 return None;
8819 }
8820 let mut end = first.len_utf8();
8821 for (index, ch) in chars {
8822 if !(is_code_ident_char(ch) || ch == '.') {
8823 end = index;
8824 break;
8825 }
8826 end = index + ch.len_utf8();
8827 }
8828 let rest = value[end..].trim_start();
8829 if let Some(stripped) = rest.strip_prefix('(') {
8830 let mut depth = 1usize;
8831 for (index, ch) in stripped.char_indices() {
8832 match ch {
8833 '(' => depth += 1,
8834 ')' => {
8835 depth = depth.saturating_sub(1);
8836 if depth == 0 {
8837 return Some(stripped[index + ch.len_utf8()..].trim_start());
8838 }
8839 }
8840 _ => {}
8841 }
8842 }
8843 return Some("");
8844 }
8845 Some(rest)
8846}
8847
8848fn strip_leading_java_modifier(value: &str) -> Option<&str> {
8849 const MODIFIERS: &[&str] = &[
8850 "public",
8851 "protected",
8852 "private",
8853 "abstract",
8854 "static",
8855 "final",
8856 "transient",
8857 "volatile",
8858 "synchronized",
8859 "native",
8860 "strictfp",
8861 ];
8862 MODIFIERS
8863 .iter()
8864 .find_map(|modifier| strip_leading_word(value, modifier))
8865}
8866
8867fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
8868 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
8869 let before = &declaration[..receiver_start];
8870 if find_identifier_occurrence(before, "val").is_none()
8871 && find_identifier_occurrence(before, "var").is_none()
8872 {
8873 return None;
8874 }
8875
8876 let after = declaration[receiver_start + receiver.len()..].trim_start();
8877 if let Some(type_text) = after.strip_prefix(':') {
8878 return normalize_receiver_type_name(read_type_prefix(type_text));
8879 }
8880 after
8881 .strip_prefix('=')
8882 .and_then(infer_kotlin_constructor_type)
8883}
8884
8885fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
8886 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
8887 if rest.trim_start().starts_with('(') {
8888 normalize_receiver_type_name(head)
8889 } else {
8890 None
8891 }
8892}
8893
8894fn read_type_prefix(value: &str) -> &str {
8895 let mut angle_depth = 0usize;
8896 for (index, ch) in value.char_indices() {
8897 match ch {
8898 '<' => angle_depth += 1,
8899 '>' => angle_depth = angle_depth.saturating_sub(1),
8900 '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
8901 return value[..index].trim();
8902 }
8903 _ => {}
8904 }
8905 }
8906 value.trim()
8907}
8908
8909fn infer_cpp_receiver_type_from_scope(
8910 scope: tree_sitter::Node<'_>,
8911 source: &str,
8912 receiver: &str,
8913 call_line: u32,
8914) -> Option<String> {
8915 let lines = source.lines().collect::<Vec<_>>();
8916 if lines.is_empty() {
8917 return None;
8918 }
8919 let scope_start = scope.start_position().row as usize;
8920 let call_index = (call_line as usize)
8921 .saturating_sub(1)
8922 .min(lines.len().saturating_sub(1));
8923 for index in (scope_start..=call_index).rev() {
8924 if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
8925 return Some(receiver_type);
8926 }
8927 }
8928 None
8929}
8930
8931fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
8932 for receiver_start in identifier_occurrences(line, receiver) {
8933 let after = line[receiver_start + receiver.len()..].trim_start();
8934 if after
8935 .chars()
8936 .next()
8937 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
8938 {
8939 continue;
8940 }
8941 let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
8942 let normalized = normalize_cpp_type_name(type_text)?;
8943 if normalized == "auto" {
8944 if let Some(rhs) = after.strip_prefix('=') {
8945 return infer_cpp_auto_receiver_type(rhs);
8946 }
8947 continue;
8948 }
8949 return Some(normalized);
8950 }
8951 None
8952}
8953
8954fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
8955 let candidate = prefix
8956 .rsplit([';', '{', '}', '('])
8957 .next()
8958 .unwrap_or(prefix)
8959 .trim();
8960 if candidate.is_empty() || candidate.ends_with(',') {
8961 None
8962 } else {
8963 Some(candidate)
8964 }
8965}
8966
8967fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
8968 let without_templates = strip_angle_groups(type_text);
8969 let mut cleaned = String::with_capacity(without_templates.len());
8970 for token in without_templates.split_whitespace() {
8971 if matches!(
8972 token,
8973 "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
8974 ) {
8975 continue;
8976 }
8977 if !cleaned.is_empty() {
8978 cleaned.push(' ');
8979 }
8980 cleaned.push_str(token);
8981 }
8982 let token = cleaned
8983 .split_whitespace()
8984 .last()
8985 .unwrap_or(cleaned.trim())
8986 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
8987 .trim_matches(['*', '&']);
8988 let simple = token.rsplit("::").next().unwrap_or(token).trim();
8989 if simple.is_empty() || cpp_non_type_token(simple) {
8990 None
8991 } else {
8992 Some(simple.to_string())
8993 }
8994}
8995
8996fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
8997 let rhs = rhs.trim_start();
8998 if let Some(after_new) = rhs.strip_prefix("new ") {
8999 return infer_cpp_constructor_type(after_new);
9000 }
9001 infer_cpp_make_template_type(rhs)
9002 .or_else(|| infer_cpp_constructor_type(rhs))
9003 .or_else(|| infer_cpp_factory_type(rhs))
9004}
9005
9006fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
9007 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9008 let normalized = normalize_cpp_type_name(head)?;
9009 if !normalized
9010 .chars()
9011 .next()
9012 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
9013 {
9014 return None;
9015 }
9016 if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
9017 Some(normalized)
9018 } else {
9019 None
9020 }
9021}
9022
9023fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
9024 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9025 if !rest.trim_start().starts_with('(') {
9026 return None;
9027 }
9028 let base = head.split('<').next().unwrap_or(head);
9029 let base_simple = base.rsplit("::").next().unwrap_or(base);
9030 if !matches!(base_simple, "make_unique" | "make_shared") {
9031 return None;
9032 }
9033 first_angle_arg(head).and_then(normalize_cpp_type_name)
9034}
9035
9036fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
9037 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9038 if !rest.trim_start().starts_with('(') {
9039 return None;
9040 }
9041 let simple = head
9042 .split('<')
9043 .next()
9044 .unwrap_or(head)
9045 .rsplit("::")
9046 .next()
9047 .unwrap_or(head);
9048 for prefix in ["make", "create", "build"] {
9049 if let Some(suffix) = simple.strip_prefix(prefix) {
9050 if suffix
9051 .chars()
9052 .next()
9053 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
9054 {
9055 return normalize_cpp_type_name(suffix);
9056 }
9057 }
9058 }
9059 None
9060}
9061
9062#[derive(Debug, Clone, Copy)]
9063enum JavaLikeInvocation {
9064 Kotlin,
9065 Cpp,
9066}
9067
9068fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
9069 let value = value.trim_start();
9070 let mut end = 0usize;
9071 for (index, ch) in value.char_indices() {
9072 let allowed_separator = match flavor {
9073 JavaLikeInvocation::Kotlin => ch == '.',
9074 JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
9075 };
9076 if is_code_ident_char(ch) || allowed_separator {
9077 end = index + ch.len_utf8();
9078 continue;
9079 }
9080 break;
9081 }
9082 if end == 0 {
9083 return None;
9084 }
9085 let mut rest = &value[end..];
9086 if let Some(stripped) = rest.trim_start().strip_prefix('<') {
9087 let skipped = skip_balanced_angle(stripped)?;
9088 let rest_start = rest.len() - rest.trim_start().len();
9089 let angle_len = 1 + skipped;
9090 end += rest_start + angle_len;
9091 rest = &value[end..];
9092 }
9093 Some((value[..end].trim(), rest))
9094}
9095
9096fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
9097 let mut depth = 1usize;
9098 for (index, ch) in value_after_open.char_indices() {
9099 match ch {
9100 '<' => depth += 1,
9101 '>' => {
9102 depth = depth.saturating_sub(1);
9103 if depth == 0 {
9104 return Some(index + ch.len_utf8());
9105 }
9106 }
9107 _ => {}
9108 }
9109 }
9110 None
9111}
9112
9113fn first_angle_arg(value: &str) -> Option<&str> {
9114 let open = value.find('<')?;
9115 let inner_len = skip_balanced_angle(&value[open + 1..])?;
9116 let inner = &value[open + 1..open + inner_len];
9117 split_top_level_commas(inner).into_iter().next()
9118}
9119
9120fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
9121 let without_generics = strip_angle_groups(type_text);
9122 let cleaned = without_generics
9123 .replace("[]", " ")
9124 .replace("...", " ")
9125 .replace(['?', '&', '*'], " ");
9126 let token = cleaned
9127 .split_whitespace()
9128 .last()
9129 .unwrap_or(cleaned.trim())
9130 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
9131 let token = token.rsplit("::").next().unwrap_or(token);
9132 let simple = token.rsplit('.').next().unwrap_or(token).trim();
9133 if simple.is_empty()
9134 || java_like_primitive_type(simple)
9135 || !simple
9136 .chars()
9137 .next()
9138 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
9139 {
9140 None
9141 } else {
9142 Some(simple.to_string())
9143 }
9144}
9145
9146fn simple_type_name(scoped_name: &str) -> Option<String> {
9147 scoped_name
9148 .rsplit("::")
9149 .find(|segment| !segment.is_empty())
9150 .and_then(normalize_receiver_type_name)
9151}
9152
9153fn strip_angle_groups(value: &str) -> String {
9154 let mut output = String::with_capacity(value.len());
9155 let mut depth = 0usize;
9156 for ch in value.chars() {
9157 match ch {
9158 '<' => {
9159 if depth == 0 {
9160 output.push(' ');
9161 }
9162 depth += 1;
9163 }
9164 '>' => depth = depth.saturating_sub(1),
9165 _ if depth == 0 => output.push(ch),
9166 _ => {}
9167 }
9168 }
9169 output
9170}
9171
9172fn java_like_primitive_type(value: &str) -> bool {
9173 matches!(
9174 value,
9175 "boolean"
9176 | "byte"
9177 | "char"
9178 | "double"
9179 | "float"
9180 | "int"
9181 | "long"
9182 | "short"
9183 | "void"
9184 | "Boolean"
9185 | "Byte"
9186 | "Char"
9187 | "Double"
9188 | "Float"
9189 | "Int"
9190 | "Long"
9191 | "Short"
9192 | "Unit"
9193 )
9194}
9195
9196fn cpp_non_type_token(value: &str) -> bool {
9197 matches!(
9198 value,
9199 "return"
9200 | "if"
9201 | "else"
9202 | "for"
9203 | "while"
9204 | "do"
9205 | "switch"
9206 | "case"
9207 | "default"
9208 | "break"
9209 | "continue"
9210 | "goto"
9211 | "throw"
9212 | "new"
9213 | "delete"
9214 | "co_await"
9215 | "co_yield"
9216 | "co_return"
9217 | "static_cast"
9218 | "const_cast"
9219 | "dynamic_cast"
9220 | "reinterpret_cast"
9221 | "sizeof"
9222 | "alignof"
9223 | "typeid"
9224 | "and"
9225 | "or"
9226 | "not"
9227 | "xor"
9228 )
9229}
9230
9231fn receiver_is_bare_identifier(value: &str) -> bool {
9232 let mut chars = value.chars();
9233 let Some(first) = chars.next() else {
9234 return false;
9235 };
9236 (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
9237}
9238
9239fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
9240 identifier_occurrences(value, needle).into_iter().next()
9241}
9242
9243fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
9244 value
9245 .match_indices(needle)
9246 .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
9247 .collect()
9248}
9249
9250fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
9251 let before = value[..start].chars().next_back();
9252 let after = value[start + len..].chars().next();
9253 !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
9254}
9255
9256fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
9257 let stripped = value.strip_prefix(word)?;
9258 if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
9259 Some(stripped.trim_start())
9260 } else {
9261 None
9262 }
9263}
9264
9265fn is_code_ident_char(ch: char) -> bool {
9266 ch == '_' || ch.is_ascii_alphanumeric()
9267}
9268
9269fn infer_rust_receiver_type(reference: &NameMatchRef) -> Option<String> {
9270 if matches!(reference.receiver.as_str(), "self" | "Self") {
9271 return enclosing_type_from_scoped_name(&reference.caller_symbol);
9272 }
9273
9274 if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
9275 return Some(reference.receiver.clone());
9276 }
9277
9278 reference
9279 .caller_signature
9280 .as_deref()
9281 .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
9282}
9283
9284fn rust_receiver_looks_type_like(receiver: &str) -> bool {
9285 receiver
9286 .chars()
9287 .next()
9288 .is_some_and(|ch| ch == '_' || ch.is_uppercase())
9289}
9290
9291fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
9292 scoped_name
9293 .rsplit_once("::")
9294 .map(|(enclosing, _)| enclosing)
9295 .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
9296 .map(ToString::to_string)
9297}
9298
9299fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
9300 let params = signature_parameter_text(signature)?;
9301 for param in split_top_level_commas(params) {
9302 let Some((pattern, type_text)) = param.split_once(':') else {
9303 continue;
9304 };
9305 let Some(name) = rust_parameter_name(pattern) else {
9306 continue;
9307 };
9308 if name == receiver {
9309 return normalize_rust_receiver_type(type_text);
9310 }
9311 }
9312 None
9313}
9314
9315fn signature_parameter_text(signature: &str) -> Option<&str> {
9316 let open = signature.find('(')?;
9317 let mut depth = 0usize;
9318 for (offset, ch) in signature[open..].char_indices() {
9319 match ch {
9320 '(' => depth += 1,
9321 ')' => {
9322 depth = depth.saturating_sub(1);
9323 if depth == 0 {
9324 return Some(&signature[open + 1..open + offset]);
9325 }
9326 }
9327 _ => {}
9328 }
9329 }
9330 None
9331}
9332
9333fn split_top_level_commas(value: &str) -> Vec<&str> {
9334 let mut parts = Vec::new();
9335 let mut start = 0usize;
9336 let mut angle_depth = 0usize;
9337 let mut paren_depth = 0usize;
9338 let mut bracket_depth = 0usize;
9339 for (index, ch) in value.char_indices() {
9340 match ch {
9341 '<' => angle_depth += 1,
9342 '>' => angle_depth = angle_depth.saturating_sub(1),
9343 '(' => paren_depth += 1,
9344 ')' => paren_depth = paren_depth.saturating_sub(1),
9345 '[' => bracket_depth += 1,
9346 ']' => bracket_depth = bracket_depth.saturating_sub(1),
9347 ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
9348 let part = value[start..index].trim();
9349 if !part.is_empty() {
9350 parts.push(part);
9351 }
9352 start = index + ch.len_utf8();
9353 }
9354 _ => {}
9355 }
9356 }
9357 let part = value[start..].trim();
9358 if !part.is_empty() {
9359 parts.push(part);
9360 }
9361 parts
9362}
9363
9364fn rust_parameter_name(pattern: &str) -> Option<&str> {
9365 let mut pattern = pattern.trim();
9366 if let Some(stripped) = pattern.strip_prefix("mut ") {
9367 pattern = stripped.trim_start();
9368 }
9369 pattern
9370 .rsplit(|ch: char| !is_rust_ident_char(ch))
9371 .find(|part| !part.is_empty())
9372}
9373
9374fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
9375 let mut ty = strip_leading_rust_type_modifiers(type_text);
9376 let owned_inner;
9377 if let Some(inner) = single_outer_generic_arg(ty) {
9378 owned_inner = inner.trim().to_string();
9379 ty = strip_leading_rust_type_modifiers(&owned_inner);
9380 }
9381 rust_base_type_ident(ty)
9382}
9383
9384fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
9385 loop {
9386 ty = ty.trim_start();
9387 if let Some(stripped) = ty.strip_prefix('&') {
9388 ty = stripped.trim_start();
9389 if let Some(stripped) = strip_leading_lifetime(ty) {
9390 ty = stripped.trim_start();
9391 }
9392 if let Some(stripped) = ty.strip_prefix("mut ") {
9393 ty = stripped.trim_start();
9394 }
9395 continue;
9396 }
9397 if let Some(stripped) = ty.strip_prefix("mut ") {
9398 ty = stripped.trim_start();
9399 continue;
9400 }
9401 if let Some(stripped) = ty.strip_prefix("dyn ") {
9402 ty = stripped.trim_start();
9403 continue;
9404 }
9405 if let Some(stripped) = ty.strip_prefix("impl ") {
9406 ty = stripped.trim_start();
9407 continue;
9408 }
9409 break ty.trim();
9410 }
9411}
9412
9413fn strip_leading_lifetime(value: &str) -> Option<&str> {
9414 let mut chars = value.char_indices();
9415 let (_, first) = chars.next()?;
9416 if first != '\'' {
9417 return None;
9418 }
9419 for (index, ch) in chars {
9420 if !(ch == '_' || ch.is_ascii_alphanumeric()) {
9421 return Some(&value[index..]);
9422 }
9423 }
9424 Some("")
9425}
9426
9427fn single_outer_generic_arg(ty: &str) -> Option<&str> {
9428 let ty = ty.trim();
9429 let open = ty.find('<')?;
9430 let mut depth = 0usize;
9431 let mut close = None;
9432 for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
9433 match ch {
9434 '<' => depth += 1,
9435 '>' => {
9436 depth = depth.saturating_sub(1);
9437 if depth == 0 {
9438 close = Some(index);
9439 break;
9440 }
9441 }
9442 _ => {}
9443 }
9444 }
9445 let close = close?;
9446 if !ty[close + 1..].trim().is_empty() {
9447 return None;
9448 }
9449 let inner = &ty[open + 1..close];
9450 let args = split_top_level_commas(inner);
9451 match args.as_slice() {
9452 [arg] => Some(*arg),
9453 _ => None,
9454 }
9455}
9456
9457fn rust_base_type_ident(ty: &str) -> Option<String> {
9458 let ty = ty.trim();
9459 let head = ty
9460 .split([' ', '+', '='])
9461 .find(|part| !part.is_empty())
9462 .unwrap_or(ty);
9463 let head = head.split('<').next().unwrap_or(head).trim();
9464 let ident = head
9465 .rsplit("::")
9466 .next()
9467 .unwrap_or(head)
9468 .trim_matches(|ch: char| !is_rust_ident_char(ch));
9469 if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
9470 None
9471 } else {
9472 Some(ident.to_string())
9473 }
9474}
9475
9476fn is_rust_ident_char(ch: char) -> bool {
9477 ch == '_' || ch.is_ascii_alphanumeric()
9478}
9479
9480fn select_type_match_candidate(
9481 reference: &NameMatchRef,
9482 candidates: &[NameMatchCandidate],
9483 receiver_type: &str,
9484) -> Option<NameMatchCandidate> {
9485 let candidates = candidates
9486 .iter()
9487 .filter(|candidate| candidate.node_id != reference.caller_node)
9488 .filter(|candidate| {
9489 type_candidate_matches(candidate, receiver_type, &reference.method_name)
9490 })
9491 .collect::<Vec<_>>();
9492 match candidates.as_slice() {
9493 [candidate] => Some((**candidate).clone()),
9494 _ => None,
9495 }
9496}
9497
9498fn type_candidate_matches(
9499 candidate: &NameMatchCandidate,
9500 receiver_type: &str,
9501 method_name: &str,
9502) -> bool {
9503 let normalized_type = receiver_type.replace('.', "::");
9504 let suffix = format!("{normalized_type}::{method_name}");
9505 candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
9506}
9507
9508fn select_name_match_candidate(
9509 reference: &NameMatchRef,
9510 candidates: &[NameMatchCandidate],
9511) -> Option<NameMatchCandidate> {
9512 let candidates = candidates
9513 .iter()
9514 .filter(|candidate| candidate.node_id != reference.caller_node)
9515 .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
9516 .collect::<Vec<_>>();
9517 match candidates.as_slice() {
9518 [] => None,
9519 [candidate] => Some((**candidate).clone()),
9520 _ => select_scored_name_match_candidate(reference, &candidates),
9521 }
9522}
9523
9524fn candidate_allowed_for_reference(
9525 reference: &NameMatchRef,
9526 candidate: &NameMatchCandidate,
9527) -> bool {
9528 if !reference.colon_dispatch {
9529 return true;
9530 }
9531
9532 candidate.kind == "method"
9533 && candidate
9534 .scoped_name
9535 .split("::")
9536 .any(|segment| segment == reference.receiver)
9537}
9538
9539fn select_scored_name_match_candidate(
9540 reference: &NameMatchRef,
9541 candidates: &[&NameMatchCandidate],
9542) -> Option<NameMatchCandidate> {
9543 let receiver_words = split_camel_case(&reference.receiver);
9544 if receiver_words.is_empty() {
9545 return None;
9546 }
9547
9548 let mut best: Option<(&NameMatchCandidate, f64)> = None;
9549 let mut tied_best = false;
9550 for candidate in candidates {
9551 let candidate_words = split_camel_case(&candidate.scoped_name);
9552 let overlap = receiver_words
9553 .iter()
9554 .filter(|receiver_word| {
9555 candidate_words
9556 .iter()
9557 .any(|candidate_word| candidate_word == *receiver_word)
9558 })
9559 .count() as f64;
9560 let score =
9561 overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
9562 match best {
9563 None => {
9564 best = Some((*candidate, score));
9565 tied_best = false;
9566 }
9567 Some((_, best_score)) if score > best_score => {
9568 best = Some((*candidate, score));
9569 tied_best = false;
9570 }
9571 Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
9572 tied_best = true;
9573 }
9574 _ => {}
9575 }
9576 }
9577
9578 let (candidate, score) = best?;
9579 if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
9580 Some(candidate.clone())
9581 } else {
9582 None
9583 }
9584}
9585
9586fn method_name_match_denylisted(method_name: &str) -> bool {
9587 matches!(
9588 method_name,
9589 "and_then"
9590 | "as_bytes"
9591 | "as_deref"
9592 | "as_mut"
9593 | "as_ref"
9594 | "as_str"
9595 | "borrow"
9596 | "borrow_mut"
9597 | "clear"
9598 | "clone"
9599 | "collect"
9600 | "contains"
9601 | "contains_key"
9602 | "count"
9603 | "dedup"
9604 | "default"
9605 | "drain"
9606 | "ends_with"
9607 | "entry"
9608 | "err"
9609 | "expect"
9610 | "extend"
9611 | "filter"
9612 | "filter_map"
9613 | "find"
9614 | "from"
9615 | "get"
9616 | "get_mut"
9617 | "insert"
9618 | "into"
9619 | "into_iter"
9620 | "is_empty"
9621 | "is_err"
9622 | "is_none"
9623 | "is_ok"
9624 | "is_some"
9625 | "iter"
9626 | "iter_mut"
9627 | "join"
9628 | "len"
9629 | "lock"
9630 | "map"
9631 | "map_err"
9632 | "max"
9633 | "min"
9634 | "new"
9635 | "next"
9636 | "ok"
9637 | "or_default"
9638 | "or_else"
9639 | "or_insert"
9640 | "or_insert_with"
9641 | "parse"
9642 | "pop"
9643 | "position"
9644 | "push"
9645 | "read"
9646 | "recv"
9647 | "remove"
9648 | "replace"
9649 | "retain"
9650 | "send"
9651 | "sort"
9652 | "sort_by"
9653 | "split"
9654 | "starts_with"
9655 | "sum"
9656 | "take"
9657 | "to_owned"
9658 | "to_string"
9659 | "trim"
9660 | "try_from"
9661 | "try_into"
9662 | "unwrap"
9663 | "unwrap_or"
9664 | "unwrap_or_default"
9665 | "unwrap_or_else"
9666 | "with_capacity"
9667 | "write"
9668 )
9669}
9670
9671fn split_camel_case(value: &str) -> Vec<String> {
9672 let chars = value.chars().collect::<Vec<_>>();
9673 let mut normalized = String::with_capacity(value.len() + 8);
9674 for (index, ch) in chars.iter().enumerate() {
9675 let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
9676 let next = chars.get(index + 1);
9677 let is_separator = ch.is_whitespace()
9678 || matches!(
9679 ch,
9680 '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
9681 );
9682 if is_separator {
9683 normalized.push(' ');
9684 continue;
9685 }
9686 let camel_boundary = previous.is_some_and(|prev| {
9687 (prev.is_lowercase() && ch.is_uppercase())
9688 || (prev.is_ascii_digit() && ch.is_alphabetic())
9689 || (prev.is_uppercase()
9690 && ch.is_uppercase()
9691 && next.is_some_and(|next| next.is_lowercase()))
9692 });
9693 if camel_boundary {
9694 normalized.push(' ');
9695 }
9696 normalized.push(*ch);
9697 }
9698
9699 normalized
9700 .split_whitespace()
9701 .filter(|word| word.len() > 1)
9702 .map(|word| word.to_ascii_lowercase())
9703 .collect()
9704}
9705
9706fn compute_path_proximity(left: &str, right: &str) -> f64 {
9707 let left_dirs = left
9708 .rsplit_once('/')
9709 .map(|(dir, _)| dir)
9710 .unwrap_or_default()
9711 .split('/')
9712 .filter(|part| !part.is_empty());
9713 let right_dirs = right
9714 .rsplit_once('/')
9715 .map(|(dir, _)| dir)
9716 .unwrap_or_default()
9717 .split('/')
9718 .filter(|part| !part.is_empty());
9719
9720 let shared = left_dirs
9721 .zip(right_dirs)
9722 .take_while(|(left, right)| left == right)
9723 .count();
9724 ((shared as f64) * 0.05).min(0.5)
9725}
9726
9727fn mark_backend_state(
9728 tx: &Transaction<'_>,
9729 project_root: &Path,
9730 rel_path: &str,
9731 content_hash: Option<&blake3::Hash>,
9732 status: &str,
9733) -> Result<()> {
9734 clear_backend_state_for_file(tx, project_root, rel_path)?;
9735 let hash = content_hash
9736 .map(|hash| hash_to_hex(*hash))
9737 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
9738 tx.execute(
9739 "INSERT OR REPLACE INTO backend_file_state(
9740 backend, workspace_root, file_path, content_hash, status, updated_at
9741 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
9742 params![
9743 BACKEND_TREESITTER,
9744 project_root.display().to_string(),
9745 rel_path,
9746 hash,
9747 status,
9748 unix_seconds_now(),
9749 ],
9750 )?;
9751 Ok(())
9752}
9753
9754fn clear_backend_state_for_file(
9755 tx: &Transaction<'_>,
9756 project_root: &Path,
9757 rel_path: &str,
9758) -> Result<()> {
9759 tx.execute(
9760 "DELETE FROM backend_file_state
9761 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
9762 params![
9763 BACKEND_TREESITTER,
9764 project_root.display().to_string(),
9765 rel_path
9766 ],
9767 )?;
9768 Ok(())
9769}
9770
9771fn load_file_row(tx: &Transaction<'_>, rel_path: &str) -> Result<Option<FileRow>> {
9772 tx.query_row(
9773 "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
9774 params![rel_path],
9775 |row| {
9776 let hash_text: String = row.get(1)?;
9777 Ok(FileRow {
9778 surface_fingerprint: row.get(0)?,
9779 freshness: FileFreshness {
9780 content_hash: hash_from_hex(&hash_text)
9781 .unwrap_or_else(cache_freshness::zero_hash),
9782 mtime: ns_to_system_time(row.get::<_, i64>(2)?),
9783 size: row.get::<_, i64>(3)? as u64,
9784 },
9785 })
9786 },
9787 )
9788 .optional()
9789 .map_err(CallGraphStoreError::from)
9790}
9791
9792fn stored_node_ids_match_extract(
9793 tx: &Transaction<'_>,
9794 rel_path: &str,
9795 extract: &FileExtract,
9796) -> Result<bool> {
9797 let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
9798 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
9799 let mut stored = BTreeSet::new();
9800 for row in rows {
9801 stored.insert(row?);
9802 }
9803 let extracted = extract
9804 .nodes
9805 .iter()
9806 .map(|node| node.id.clone())
9807 .collect::<BTreeSet<_>>();
9808 Ok(stored == extracted)
9809}
9810
9811fn update_file_fresh_metadata(
9812 tx: &Transaction<'_>,
9813 rel_path: &str,
9814 hash: &blake3::Hash,
9815 mtime: SystemTime,
9816 size: u64,
9817) -> Result<()> {
9818 tx.execute(
9819 "UPDATE files SET mtime_ns = ?2, size = ?3, indexed_at = ?4 WHERE path = ?1",
9820 params![
9821 rel_path,
9822 system_time_to_ns(mtime),
9823 size as i64,
9824 unix_seconds_now()
9825 ],
9826 )?;
9827 tx.execute(
9828 "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
9829 WHERE backend = ?1 AND file_path = ?2 AND content_hash = ?3",
9830 params![
9831 BACKEND_TREESITTER,
9832 rel_path,
9833 hash_to_hex(*hash),
9834 unix_seconds_now(),
9835 ],
9836 )?;
9837 Ok(())
9838}
9839
9840#[derive(Debug, Clone, PartialEq, Eq)]
9841struct DependentRefSelection {
9842 ref_id: String,
9843 caller_file: String,
9844}
9845
9846fn ref_ids_depending_on(
9847 tx: &Transaction<'_>,
9848 project_root: &Path,
9849 rel_path: &str,
9850) -> Result<Vec<DependentRefSelection>> {
9851 let mut stmt = tx.prepare(
9852 "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
9853 FROM refs r
9854 WHERE r.caller_file IN (
9855 SELECT file_path FROM file_dependencies WHERE dep_file = ?1
9856 )
9857 OR r.target_file = ?1
9858 ORDER BY r.ref_id",
9859 )?;
9860 let rows = stmt.query_map(params![rel_path], |row| {
9861 Ok(RefDependencyRow {
9862 ref_id: row.get(0)?,
9863 kind: row.get(1)?,
9864 caller_file: row.get(2)?,
9865 module_path: row.get(3)?,
9866 target_file: row.get(4)?,
9867 })
9868 })?;
9869 let mut ids = Vec::new();
9870 for row in rows {
9871 let row = row?;
9872 if ref_dependency_row_depends_on(project_root, &row, rel_path) {
9873 ids.push(DependentRefSelection {
9874 ref_id: row.ref_id,
9875 caller_file: row.caller_file,
9876 });
9877 }
9878 }
9879 Ok(ids)
9880}
9881
9882fn record_dependent_refs(
9883 selected_ref_ids: &mut BTreeSet<String>,
9884 selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
9885 dependent_refs: Vec<DependentRefSelection>,
9886) {
9887 for dependent_ref in dependent_refs {
9888 let DependentRefSelection {
9889 ref_id,
9890 caller_file,
9891 } = dependent_ref;
9892 selected_ref_ids.insert(ref_id.clone());
9893 selected_refs_by_caller
9894 .entry(caller_file)
9895 .or_default()
9896 .insert(ref_id);
9897 }
9898}
9899
9900#[cfg(test)]
9901fn refs_by_caller_for_ref_ids(
9902 tx: &Transaction<'_>,
9903 ref_ids: &BTreeSet<String>,
9904) -> Result<BTreeMap<String, BTreeSet<String>>> {
9905 let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
9906 let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
9907 for ref_id in ref_ids {
9908 if let Some(caller) = stmt
9909 .query_row(params![ref_id], |row| row.get::<_, String>(0))
9910 .optional()?
9911 {
9912 by_caller.entry(caller).or_default().insert(ref_id.clone());
9913 }
9914 }
9915 Ok(by_caller)
9916}
9917
9918fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
9919 tx.execute(
9920 "DELETE FROM file_dependencies WHERE file_path = ?1",
9921 params![rel_path],
9922 )?;
9923 delete_refs_for_caller(tx, rel_path)?;
9924 tx.execute(
9925 "DELETE FROM dispatch_hints WHERE file = ?1",
9926 params![rel_path],
9927 )?;
9928 tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
9929 tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
9930 Ok(())
9931}
9932
9933fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
9934 let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
9935 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
9936 let mut ids = BTreeSet::new();
9937 for row in rows {
9938 ids.insert(row?);
9939 }
9940 delete_ref_ids(tx, &ids)
9941}
9942
9943fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
9944 for ref_id in ref_ids {
9945 tx.execute("DELETE FROM edges WHERE ref_id = ?1", params![ref_id])?;
9946 tx.execute("DELETE FROM refs WHERE ref_id = ?1", params![ref_id])?;
9947 }
9948 Ok(())
9949}
9950
9951fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
9952 let mut stmt = conn.prepare(
9953 "SELECT source.file_path, source.scoped_name, edges.target_file,
9954 edges.target_symbol, edges.kind, edges.line
9955 FROM edges
9956 JOIN nodes AS source ON source.id = edges.source_node
9957 ORDER BY source.file_path, source.scoped_name, edges.target_file,
9958 edges.target_symbol, edges.kind, edges.line",
9959 )?;
9960 let rows = stmt.query_map([], |row| {
9961 Ok(StoredEdge {
9962 source_file: row.get(0)?,
9963 source_symbol: row.get(1)?,
9964 target_file: row.get(2)?,
9965 target_symbol: row.get(3)?,
9966 kind: row.get(4)?,
9967 line: row.get::<_, i64>(5)? as u32,
9968 })
9969 })?;
9970 let mut edges = BTreeSet::new();
9971 for row in rows {
9972 edges.insert(row?);
9973 }
9974 Ok(edges)
9975}
9976
9977fn module_target_from_dependencies(
9978 project_root: &Path,
9979 dependencies: &BTreeSet<String>,
9980) -> Option<String> {
9981 dependencies.iter().find_map(|dep| {
9982 let path = project_root.join(dep);
9983 if path.is_file() {
9984 Some(relative_path(project_root, &canonicalize_path(&path)))
9985 } else {
9986 None
9987 }
9988 })
9989}
9990
9991fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
9992 let mut named = HashMap::new();
9993 if let Some(full_ref) = &raw_ref.full_ref {
9994 named = parse_reexport_names(full_ref);
9995 }
9996 ReexportIndex {
9997 target_file,
9998 named,
9999 wildcard: raw_ref.wildcard,
10000 }
10001}
10002
10003fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
10004 let mut names = HashMap::new();
10005 let Some(open) = statement.find('{') else {
10006 return names;
10007 };
10008 let Some(close) = statement[open + 1..]
10009 .find('}')
10010 .map(|offset| open + 1 + offset)
10011 else {
10012 return names;
10013 };
10014 for spec in statement[open + 1..close].split(',') {
10015 let spec = spec.trim();
10016 if spec.is_empty() {
10017 continue;
10018 }
10019 if let Some((source, local)) = spec.split_once(" as ") {
10020 names.insert(local.trim().to_string(), source.trim().to_string());
10021 } else {
10022 names.insert(spec.to_string(), spec.to_string());
10023 }
10024 }
10025 names
10026}
10027
10028#[derive(Debug)]
10029struct RefDependencyRow {
10030 ref_id: String,
10031 kind: String,
10032 caller_file: String,
10033 module_path: Option<String>,
10034 target_file: Option<String>,
10035}
10036
10037fn ref_dependency_row_depends_on(
10038 project_root: &Path,
10039 row: &RefDependencyRow,
10040 rel_path: &str,
10041) -> bool {
10042 if row.target_file.as_deref() == Some(rel_path) {
10043 return true;
10044 }
10045
10046 match row.kind.as_str() {
10047 "call" => true,
10048 "import" | "reexport" => row
10049 .module_path
10050 .as_deref()
10051 .map(|module_path| {
10052 module_dependencies_for_ref(project_root, &row.caller_file, module_path)
10053 .contains(rel_path)
10054 })
10055 .unwrap_or(false),
10056 "export_alias" => false,
10057 _ => false,
10058 }
10059}
10060
10061fn module_dependencies_for_ref(
10062 project_root: &Path,
10063 caller_file: &str,
10064 module_path: &str,
10065) -> BTreeSet<String> {
10066 module_dependencies(project_root, &project_root.join(caller_file), module_path)
10067}
10068
10069fn import_dependencies(
10070 project_root: &Path,
10071 abs_path: &Path,
10072 imports: &[ImportStatement],
10073) -> BTreeSet<String> {
10074 let mut deps = BTreeSet::new();
10075 for import in imports {
10076 deps.extend(module_dependencies(
10077 project_root,
10078 abs_path,
10079 &import.module_path,
10080 ));
10081 }
10082 deps
10083}
10084
10085fn module_dependencies(
10086 project_root: &Path,
10087 abs_path: &Path,
10088 module_path: &str,
10089) -> BTreeSet<String> {
10090 let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
10091 let caller_dir = abs_path.parent().unwrap_or(project_root);
10092 if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
10093 deps.insert(relative_path(project_root, &resolved));
10094 }
10095 if module_path.starts_with('.') {
10096 let base = caller_dir.join(module_path);
10097 for candidate in relative_module_candidates(&base) {
10098 deps.insert(relative_path(project_root, &candidate));
10099 }
10100 }
10101 deps
10102}
10103
10104fn rust_module_dependencies(
10105 project_root: &Path,
10106 abs_path: &Path,
10107 module_path: &str,
10108) -> BTreeSet<String> {
10109 let mut deps = BTreeSet::new();
10110 let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
10111 let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
10112 return deps;
10113 };
10114 let src_prefix = rust_src_prefix(&rel_path);
10115 rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
10116 if !path_segments.is_empty() {
10117 rust_push_module_dependency_candidate(
10118 project_root,
10119 &mut deps,
10120 &src_prefix,
10121 &path_segments[..path_segments.len() - 1],
10122 );
10123 }
10124 deps
10125}
10126
10127fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
10128 let path = rust_module_path_without_alias_or_use_list(module_path);
10129 let segments = path
10130 .split("::")
10131 .map(str::trim)
10132 .filter(|segment| !segment.is_empty())
10133 .collect::<Vec<_>>();
10134 if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
10135 return None;
10136 }
10137 rust_resolve_segments(rel_path, &segments)
10138}
10139
10140fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
10141 let path = module_path
10142 .trim()
10143 .trim_end_matches(';')
10144 .split_once(" as ")
10145 .map(|(left, _)| left.trim())
10146 .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
10147 path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
10148}
10149
10150fn rust_push_module_dependency_candidate(
10151 project_root: &Path,
10152 deps: &mut BTreeSet<String>,
10153 src_prefix: &str,
10154 segments: &[String],
10155) {
10156 let candidates = if segments.is_empty() {
10157 vec![
10158 format!("{src_prefix}/lib.rs"),
10159 format!("{src_prefix}/main.rs"),
10160 ]
10161 } else {
10162 vec![
10163 format!("{}/{}.rs", src_prefix, segments.join("/")),
10164 format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
10165 ]
10166 };
10167 for candidate in candidates {
10168 if project_root.join(&candidate).is_file() {
10169 deps.insert(candidate);
10170 }
10171 }
10172}
10173
10174fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
10175 let mut candidates = Vec::new();
10176 if base.extension().is_some() {
10177 candidates.push(base.to_path_buf());
10178 return candidates;
10179 }
10180 for ext in JS_TS_EXTENSIONS {
10181 candidates.push(base.with_extension(ext));
10182 }
10183 for ext in JS_TS_EXTENSIONS {
10184 candidates.push(base.join(format!("index.{ext}")));
10185 }
10186 candidates
10187}
10188
10189fn import_local_names(import: &ImportStatement) -> Vec<String> {
10190 let mut names = Vec::new();
10191 if let Some(default) = &import.default_import {
10192 names.push(default.clone());
10193 }
10194 if let Some(namespace) = &import.namespace_import {
10195 names.push(namespace.clone());
10196 }
10197 for name in &import.names {
10198 names.push(crate::imports::specifier_local_name(name).to_string());
10199 }
10200 names
10201}
10202
10203fn import_requested_names(import: &ImportStatement) -> Vec<String> {
10204 import
10205 .names
10206 .iter()
10207 .map(|name| crate::imports::specifier_imported_name(name).to_string())
10208 .collect()
10209}
10210
10211fn import_is_wildcard(import: &ImportStatement) -> bool {
10212 import.namespace_import.is_some() || import.raw_text.contains('*')
10213}
10214
10215fn namespace_alias(full_ref: &str) -> Option<String> {
10216 full_ref
10217 .split_once('.')
10218 .map(|(namespace, _)| namespace.to_string())
10219}
10220
10221fn import_kind_label(kind: ImportKind) -> &'static str {
10222 match kind {
10223 ImportKind::Value => "value",
10224 ImportKind::Type => "type",
10225 ImportKind::SideEffect => "side_effect",
10226 }
10227}
10228
10229fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
10230 match kind {
10231 SymbolKind::Function => "function",
10232 SymbolKind::Class => "class",
10233 SymbolKind::Method => "method",
10234 SymbolKind::Struct => "struct",
10235 SymbolKind::Interface => "interface",
10236 SymbolKind::Enum => "enum",
10237 SymbolKind::TypeAlias => "type_alias",
10238 SymbolKind::Variable => "variable",
10239 SymbolKind::Heading => "heading",
10240 SymbolKind::FileSummary => "file_summary",
10241 }
10242}
10243
10244fn is_type_like(kind: &SymbolKind) -> bool {
10245 matches!(
10246 kind,
10247 SymbolKind::Class
10248 | SymbolKind::Struct
10249 | SymbolKind::Interface
10250 | SymbolKind::Enum
10251 | SymbolKind::TypeAlias
10252 )
10253}
10254
10255fn lang_label(lang: LangId) -> &'static str {
10256 match lang {
10257 LangId::TypeScript => "typescript",
10258 LangId::Tsx => "tsx",
10259 LangId::JavaScript => "javascript",
10260 LangId::Python => "python",
10261 LangId::Rust => "rust",
10262 LangId::Go => "go",
10263 LangId::C => "c",
10264 LangId::Cpp => "cpp",
10265 LangId::Zig => "zig",
10266 LangId::CSharp => "csharp",
10267 LangId::Bash => "bash",
10268 LangId::Html => "html",
10269 LangId::Markdown => "markdown",
10270 LangId::Solidity => "solidity",
10271 LangId::Scss => "scss",
10272 LangId::Vue => "vue",
10273 LangId::Json => "json",
10274 LangId::Scala => "scala",
10275 LangId::Java => "java",
10276 LangId::Ruby => "ruby",
10277 LangId::Kotlin => "kotlin",
10278 LangId::Swift => "swift",
10279 LangId::Php => "php",
10280 LangId::Lua => "lua",
10281 LangId::Perl => "perl",
10282 LangId::Yaml => "yaml",
10283 LangId::Pascal => "pascal",
10284 LangId::R => "r",
10285 LangId::Groovy => "groovy",
10286 LangId::ObjC => "objc",
10287 }
10288}
10289
10290fn lang_from_label(label: &str) -> Option<LangId> {
10291 match label {
10292 "typescript" => Some(LangId::TypeScript),
10293 "tsx" => Some(LangId::Tsx),
10294 "javascript" => Some(LangId::JavaScript),
10295 "python" => Some(LangId::Python),
10296 "rust" => Some(LangId::Rust),
10297 "go" => Some(LangId::Go),
10298 "c" => Some(LangId::C),
10299 "cpp" => Some(LangId::Cpp),
10300 "zig" => Some(LangId::Zig),
10301 "csharp" => Some(LangId::CSharp),
10302 "bash" => Some(LangId::Bash),
10303 "html" => Some(LangId::Html),
10304 "markdown" => Some(LangId::Markdown),
10305 "solidity" => Some(LangId::Solidity),
10306 "scss" => Some(LangId::Scss),
10307 "vue" => Some(LangId::Vue),
10308 "json" => Some(LangId::Json),
10309 "scala" => Some(LangId::Scala),
10310 "java" => Some(LangId::Java),
10311 "ruby" => Some(LangId::Ruby),
10312 "kotlin" => Some(LangId::Kotlin),
10313 "swift" => Some(LangId::Swift),
10314 "php" => Some(LangId::Php),
10315 "lua" => Some(LangId::Lua),
10316 "perl" => Some(LangId::Perl),
10317 "yaml" => Some(LangId::Yaml),
10318 "pascal" => Some(LangId::Pascal),
10319 "r" => Some(LangId::R),
10320 "groovy" => Some(LangId::Groovy),
10321 "objc" => Some(LangId::ObjC),
10322 _ => None,
10323 }
10324}
10325
10326fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
10327 let mut normalized = if files.is_empty() {
10328 callgraph::walk_project_files(project_root).collect::<Vec<_>>()
10329 } else {
10330 files
10331 .iter()
10332 .map(|path| normalize_file_path(project_root, path))
10333 .collect::<Result<Vec<_>>>()?
10334 };
10335 normalized.sort();
10336 normalized.dedup();
10337 Ok(normalized)
10338}
10339
10340fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
10341 let full_path = if path.is_relative() {
10342 project_root.join(path)
10343 } else {
10344 path.to_path_buf()
10345 };
10346 Ok(canonicalize_path(&full_path))
10347}
10348
10349fn canonicalize_path(path: &Path) -> PathBuf {
10350 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
10351}
10352
10353fn relative_path(project_root: &Path, path: &Path) -> String {
10354 if let Ok(stripped) = path.strip_prefix(project_root) {
10355 return stripped.to_string_lossy().replace('\\', "/");
10356 }
10357 let canon_root = canonicalize_path(project_root);
10358 let canon_path = canonicalize_path(path);
10359 if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
10360 return stripped.to_string_lossy().replace('\\', "/");
10361 }
10362 canon_path.to_string_lossy().replace('\\', "/")
10363}
10364
10365fn unqualified_name(scoped: &str) -> &str {
10366 if scoped == TOP_LEVEL_SYMBOL {
10367 return scoped;
10368 }
10369 scoped
10370 .rsplit("::")
10371 .next()
10372 .unwrap_or(scoped)
10373 .rsplit('.')
10374 .next()
10375 .unwrap_or(scoped)
10376 .rsplit('#')
10377 .next()
10378 .unwrap_or(scoped)
10379}
10380
10381fn ref_id(parts: &[&str]) -> String {
10382 let joined = parts.join("\0");
10383 hash_to_hex(blake3::hash(joined.as_bytes()))
10384}
10385
10386fn hash_to_hex(hash: blake3::Hash) -> String {
10387 hash.to_hex().to_string()
10388}
10389
10390fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
10391 let bytes = hex_to_bytes(value)?;
10392 Some(blake3::Hash::from_bytes(bytes))
10393}
10394
10395fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
10396 if value.len() != 64 {
10397 return None;
10398 }
10399 let mut bytes = [0u8; 32];
10400 for (index, slot) in bytes.iter_mut().enumerate() {
10401 let start = index * 2;
10402 let end = start + 2;
10403 *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
10404 }
10405 Some(bytes)
10406}
10407
10408#[derive(Debug, Clone)]
10409struct LineIndex {
10410 newline_offsets: Vec<usize>,
10411 source_len: usize,
10412}
10413
10414impl LineIndex {
10415 fn new(source: &str) -> Self {
10416 Self {
10417 newline_offsets: source
10418 .bytes()
10419 .enumerate()
10420 .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
10421 .collect(),
10422 source_len: source.len(),
10423 }
10424 }
10425
10426 fn byte_to_line(&self, byte_offset: usize) -> u32 {
10427 let byte_offset = byte_offset.min(self.source_len);
10428 self.newline_offsets
10429 .partition_point(|offset| *offset < byte_offset) as u32
10430 + 1
10431 }
10432}
10433
10434fn empty_to_none(value: String) -> Option<String> {
10435 if value.is_empty() {
10436 None
10437 } else {
10438 Some(value)
10439 }
10440}
10441
10442fn bool_int(value: bool) -> i64 {
10443 if value {
10444 1
10445 } else {
10446 0
10447 }
10448}
10449
10450fn system_time_to_ns(time: SystemTime) -> i64 {
10451 time.duration_since(UNIX_EPOCH)
10452 .unwrap_or_default()
10453 .as_nanos()
10454 .min(i64::MAX as u128) as i64
10455}
10456
10457fn ns_to_system_time(value: i64) -> SystemTime {
10458 UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
10459}
10460
10461fn unix_seconds_now() -> i64 {
10462 SystemTime::now()
10463 .duration_since(UNIX_EPOCH)
10464 .unwrap_or_default()
10465 .as_secs() as i64
10466}
10467
10468#[cfg(test)]
10473pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
10474
10475#[cfg(test)]
10476mod refresh_worker_tests {
10477 use super::*;
10478 use std::fs;
10479 use tempfile::tempdir;
10480
10481 fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
10482 let temp = tempdir().unwrap();
10483 let root = temp.path().join("root");
10484 let callgraph_dir = temp
10485 .path()
10486 .join("storage")
10487 .join("callgraph")
10488 .join(crate::search_index::artifact_cache_key(&root));
10489 fs::create_dir_all(&root).unwrap();
10490 let source = root.join("main.rs");
10491 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
10492 let (store, _) = CallGraphStore::cold_build_with_lease(
10493 callgraph_dir.clone(),
10494 root.clone(),
10495 std::slice::from_ref(&source),
10496 )
10497 .unwrap();
10498 drop(store);
10499 (temp, root, callgraph_dir, source)
10500 }
10501
10502 fn pending_paths() -> PendingCallGraphStorePaths {
10503 Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
10504 }
10505
10506 fn wait_for_refresh_calls(root: &Path, expected: usize) {
10507 let deadline = Instant::now() + Duration::from_secs(12);
10508 while callgraph_refresh_worker_test_counts(root).0 < expected {
10509 assert!(
10510 Instant::now() < deadline,
10511 "timed out waiting for {expected} callgraph refresh worker call(s)"
10512 );
10513 std::thread::sleep(Duration::from_millis(5));
10514 }
10515 }
10516
10517 fn wait_for_refresh_worker_idle() {
10518 let deadline = Instant::now() + Duration::from_secs(12);
10519 loop {
10520 let worker = CALLGRAPH_REFRESH_WORKER
10521 .get_or_init(|| Mutex::new(None))
10522 .lock()
10523 .expect("callgraph refresh worker mutex poisoned")
10524 .clone();
10525 let idle = worker.is_none_or(|worker| {
10526 let queue = worker
10527 .shared
10528 .queue
10529 .lock()
10530 .expect("callgraph refresh queue mutex poisoned");
10531 queue.active.is_none() && queue.order.is_empty()
10532 });
10533 if idle {
10534 return;
10535 }
10536 assert!(
10537 Instant::now() < deadline,
10538 "timed out waiting for callgraph refresh worker to become idle"
10539 );
10540 std::thread::sleep(Duration::from_millis(5));
10541 }
10542 }
10543
10544 fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
10545 let temp = tempdir().unwrap();
10546 let root = temp.path().join("workspace");
10547 let callgraph_dir = temp
10548 .path()
10549 .join("storage")
10550 .join("callgraph")
10551 .join(crate::search_index::artifact_cache_key(&root));
10552 fs::create_dir_all(root.join("app/src")).unwrap();
10553 fs::write(
10554 root.join("Cargo.toml"),
10555 "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
10556 )
10557 .unwrap();
10558 fs::write(
10559 root.join("app/Cargo.toml"),
10560 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
10561 )
10562 .unwrap();
10563 let caller = root.join("app/src/lib.rs");
10564 fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
10565 let (store, _) = CallGraphStore::cold_build_with_lease(
10566 callgraph_dir.clone(),
10567 root.clone(),
10568 std::slice::from_ref(&caller),
10569 )
10570 .unwrap();
10571 drop(store);
10572 (temp, root, callgraph_dir, caller)
10573 }
10574
10575 #[test]
10576 fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
10577 let _guard = REFRESH_WORKER_TEST_LOCK
10578 .lock()
10579 .unwrap_or_else(std::sync::PoisonError::into_inner);
10580 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10581 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
10582 reset_workspace_crate_prefix_build_count(&root);
10583 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10584
10585 for revision in ["first", "second"] {
10586 fs::write(
10587 &caller,
10588 format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
10589 )
10590 .unwrap();
10591 enqueue_callgraph_store_refresh(
10592 callgraph_dir.clone(),
10593 root.clone(),
10594 vec![caller.clone()],
10595 pending_paths(),
10596 );
10597 wait_for_refresh_worker_idle();
10598 }
10599
10600 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
10601 assert!(flush_callgraph_store_refreshes_with_budget(
10602 Duration::from_secs(5)
10603 ));
10604 clear_callgraph_refresh_worker_test_seam(&root);
10605 }
10606
10607 #[test]
10608 fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
10609 let _guard = REFRESH_WORKER_TEST_LOCK
10610 .lock()
10611 .unwrap_or_else(std::sync::PoisonError::into_inner);
10612 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10613 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
10614 reset_workspace_crate_prefix_build_count(&root);
10615 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10616
10617 fs::write(
10618 &caller,
10619 "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
10620 )
10621 .unwrap();
10622 enqueue_callgraph_store_refresh(
10623 callgraph_dir.clone(),
10624 root.clone(),
10625 vec![caller.clone()],
10626 pending_paths(),
10627 );
10628 wait_for_refresh_worker_idle();
10629 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
10630
10631 let added_manifest = root.join("added/Cargo.toml");
10632 let added_source = root.join("added/src/lib.rs");
10633 fs::create_dir_all(added_source.parent().unwrap()).unwrap();
10634 fs::write(
10635 root.join("Cargo.toml"),
10636 "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
10637 )
10638 .unwrap();
10639 fs::write(
10640 &added_manifest,
10641 "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
10642 )
10643 .unwrap();
10644 fs::write(&added_source, "pub fn target() {}\n").unwrap();
10645 fs::write(
10646 &caller,
10647 "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
10648 )
10649 .unwrap();
10650
10651 enqueue_callgraph_store_refresh(
10652 callgraph_dir.clone(),
10653 root.clone(),
10654 vec![
10655 root.join("Cargo.toml"),
10656 added_manifest,
10657 added_source,
10658 caller,
10659 ],
10660 pending_paths(),
10661 );
10662 assert!(flush_callgraph_store_refreshes_with_budget(
10663 Duration::from_secs(12)
10664 ));
10665
10666 assert_eq!(workspace_crate_prefix_build_count(&root), 2);
10670 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
10671 .unwrap()
10672 .expect("refreshed workspace store");
10673 let tree = store
10674 .call_tree(Path::new("app/src/lib.rs"), "run", 1)
10675 .unwrap();
10676 assert_eq!(tree.children.len(), 1);
10677 assert_eq!(tree.children[0].file, "added/src/lib.rs");
10678 assert_eq!(tree.children[0].name, "target");
10679 assert!(tree.children[0].resolved);
10680 clear_callgraph_refresh_worker_test_seam(&root);
10681 }
10682
10683 #[test]
10684 fn forced_rebuild_without_writer_capability_cannot_report_old_store_as_ready() {
10685 let _git_env = crate::test_env::hermetic_git_env_guard();
10686 let temp = tempdir().unwrap();
10687 let main = temp.path().join("main");
10688 let root = temp.path().join("worktree");
10689 fs::create_dir_all(&main).unwrap();
10690 let mut git = std::process::Command::new("git");
10691 assert!(
10692 crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
10693 .status()
10694 .unwrap()
10695 .success()
10696 );
10697 let source = main.join("lib.rs");
10698 fs::write(&source, "pub fn marker() {}\n").unwrap();
10699 for args in [
10700 vec![
10701 "-C",
10702 main.to_str().unwrap(),
10703 "config",
10704 "user.email",
10705 "test@example.com",
10706 ],
10707 vec![
10708 "-C",
10709 main.to_str().unwrap(),
10710 "config",
10711 "user.name",
10712 "AFT Test",
10713 ],
10714 vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
10715 vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
10716 ] {
10717 let mut command = std::process::Command::new("git");
10718 assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
10719 .status()
10720 .unwrap()
10721 .success());
10722 }
10723 let mut worktree = std::process::Command::new("git");
10724 assert!(crate::test_env::apply_hermetic_git_env(
10725 worktree
10726 .arg("-C")
10727 .arg(&main)
10728 .args(["worktree", "add", "--detach"])
10729 .arg(&root),
10730 )
10731 .status()
10732 .unwrap()
10733 .success());
10734
10735 let project_key = crate::search_index::artifact_cache_key(&root);
10736 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
10737 crate::root_cache::configure_artifact_access(&root, &project_key, true);
10738 let source = root.join("lib.rs");
10739 let error =
10740 CallGraphStore::force_cold_build_with_lease_chunked(callgraph_dir, root, &[source], 1)
10741 .expect_err("borrow-only forced rebuild must remain unsatisfied");
10742
10743 assert!(matches!(error, CallGraphStoreError::Unavailable(_)));
10744 }
10745
10746 #[test]
10747 fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
10748 let _guard = REFRESH_WORKER_TEST_LOCK
10749 .lock()
10750 .unwrap_or_else(std::sync::PoisonError::into_inner);
10751 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10752 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10753 let pending = pending_paths();
10754 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10755
10756 let lifecycle = SubcLifecycleAdmission::default();
10757 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
10758 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
10759 let ticket = CallgraphRefreshTicket::new(
10760 lifecycle,
10761 Arc::clone(&generation),
10762 7,
10763 publish_epoch.clone(),
10764 publish_epoch.current(),
10765 );
10766 generation.store(8, std::sync::atomic::Ordering::SeqCst);
10768
10769 enqueue_callgraph_store_refresh_fenced(
10770 callgraph_dir,
10771 root.clone(),
10772 vec![source.clone()],
10773 Arc::clone(&pending),
10774 ticket,
10775 );
10776 assert!(flush_callgraph_store_refreshes_with_budget(
10777 Duration::from_secs(5)
10778 ));
10779 assert_eq!(
10780 callgraph_refresh_worker_test_counts(&root).0,
10781 0,
10782 "superseded batch must not reach refresh_files"
10783 );
10784 assert!(
10785 pending.lock().contains(&source),
10786 "superseded batch must defer its paths to the pending sink"
10787 );
10788 clear_callgraph_refresh_worker_test_seam(&root);
10789 }
10790
10791 #[test]
10792 fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
10793 let _guard = REFRESH_WORKER_TEST_LOCK
10794 .lock()
10795 .unwrap_or_else(std::sync::PoisonError::into_inner);
10796 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10797 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10798 let pending = pending_paths();
10799 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10800
10801 let lifecycle = SubcLifecycleAdmission::default();
10802 let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
10803 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
10804 let expected_epoch = publish_epoch.current();
10805 let ticket = CallgraphRefreshTicket::new(
10806 lifecycle,
10807 generation,
10808 3,
10809 publish_epoch.clone(),
10810 expected_epoch,
10811 );
10812 publish_epoch.next();
10814
10815 enqueue_callgraph_store_refresh_fenced(
10816 callgraph_dir,
10817 root.clone(),
10818 vec![source.clone()],
10819 Arc::clone(&pending),
10820 ticket,
10821 );
10822 assert!(flush_callgraph_store_refreshes_with_budget(
10823 Duration::from_secs(5)
10824 ));
10825 assert_eq!(
10826 callgraph_refresh_worker_test_counts(&root).0,
10827 0,
10828 "epoch-superseded batch must not reach refresh_files"
10829 );
10830 assert!(
10831 pending.lock().contains(&source),
10832 "epoch-superseded batch must defer its paths to the pending sink"
10833 );
10834 clear_callgraph_refresh_worker_test_seam(&root);
10835 }
10836
10837 #[test]
10838 fn fenced_refresh_with_current_ticket_commits_normally() {
10839 let _guard = REFRESH_WORKER_TEST_LOCK
10840 .lock()
10841 .unwrap_or_else(std::sync::PoisonError::into_inner);
10842 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10843 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10844 let pending = pending_paths();
10845 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10846
10847 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
10848
10849 let lifecycle = SubcLifecycleAdmission::default();
10850 let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
10851 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
10852 let ticket = CallgraphRefreshTicket::new(
10853 lifecycle,
10854 generation,
10855 5,
10856 publish_epoch.clone(),
10857 publish_epoch.current(),
10858 );
10859
10860 enqueue_callgraph_store_refresh_fenced(
10861 callgraph_dir.clone(),
10862 root.clone(),
10863 vec![source.clone()],
10864 Arc::clone(&pending),
10865 ticket,
10866 );
10867 assert!(flush_callgraph_store_refreshes_with_budget(
10868 Duration::from_secs(5)
10869 ));
10870 assert_eq!(
10871 callgraph_refresh_worker_test_counts(&root).0,
10872 1,
10873 "current ticket must run the refresh"
10874 );
10875 assert!(
10876 pending.lock().is_empty(),
10877 "committed batch must not defer paths"
10878 );
10879
10880 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
10881 .unwrap()
10882 .expect("published generation must remain readable");
10883 let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
10884 assert_eq!(
10885 tree.children[0].name, "new_leaf",
10886 "fenced commit must actually persist the refreshed content"
10887 );
10888 clear_callgraph_refresh_worker_test_seam(&root);
10889 }
10890
10891 #[test]
10892 fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
10893 let _guard = REFRESH_WORKER_TEST_LOCK
10894 .lock()
10895 .unwrap_or_else(std::sync::PoisonError::into_inner);
10896 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10901 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10902 let pending = pending_paths();
10903 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
10904
10905 enqueue_callgraph_store_refresh(
10906 callgraph_dir.clone(),
10907 root.clone(),
10908 vec![source.clone()],
10909 Arc::clone(&pending),
10910 );
10911 wait_for_refresh_calls(&root, 1);
10912 for _ in 0..3 {
10913 enqueue_callgraph_store_refresh(
10914 callgraph_dir.clone(),
10915 root.clone(),
10916 vec![source.clone()],
10917 Arc::clone(&pending),
10918 );
10919 }
10920
10921 assert!(flush_callgraph_store_refreshes_with_budget(
10922 Duration::from_secs(2)
10923 ));
10924 assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
10925 assert!(pending.lock().is_empty());
10926 clear_callgraph_refresh_worker_test_seam(&root);
10927 }
10928
10929 #[test]
10930 fn queued_refresh_opens_generation_published_after_enqueue() {
10931 let _guard = REFRESH_WORKER_TEST_LOCK
10932 .lock()
10933 .unwrap_or_else(std::sync::PoisonError::into_inner);
10934 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10939 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
10940 let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
10941 set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
10942 let (active_held_rx, active_release_tx) =
10943 install_refresh_worker_test_gate(active_root.clone());
10944 set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
10945 enqueue_callgraph_store_refresh(
10946 active_dir,
10947 active_root.clone(),
10948 vec![active_source],
10949 pending_paths(),
10950 );
10951 active_held_rx
10952 .recv_timeout(Duration::from_secs(12))
10953 .expect("active refresh worker holds the queue");
10954
10955 fs::write(
10956 &target_source,
10957 "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
10958 )
10959 .unwrap();
10960 enqueue_callgraph_store_refresh(
10961 target_dir.clone(),
10962 target_root.clone(),
10963 vec![target_source.clone()],
10964 pending_paths(),
10965 );
10966 let (new_generation, _) = CallGraphStore::cold_build_with_lease(
10967 target_dir.clone(),
10968 target_root.clone(),
10969 std::slice::from_ref(&target_source),
10970 )
10971 .unwrap();
10972 fs::write(
10973 &target_source,
10974 "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
10975 )
10976 .unwrap();
10977 drop(new_generation);
10978
10979 active_release_tx
10980 .send(())
10981 .expect("release active refresh worker");
10982 wait_for_refresh_calls(&target_root, 1);
10983 assert!(flush_callgraph_store_refreshes_with_budget(
10984 Duration::from_secs(12)
10985 ));
10986 let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
10987 .unwrap()
10988 .expect("current callgraph generation");
10989 let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
10990 assert_eq!(tree.children[0].name, "worker_leaf");
10991 assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
10992 clear_callgraph_refresh_worker_test_seam(&active_root);
10993 clear_callgraph_refresh_worker_test_seam(&target_root);
10994 }
10995
10996 #[test]
10997 fn refresh_failure_marks_files_stale() {
10998 let _guard = REFRESH_WORKER_TEST_LOCK
10999 .lock()
11000 .unwrap_or_else(std::sync::PoisonError::into_inner);
11001 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
11006 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
11007 let pending = pending_paths();
11008 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
11009
11010 enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
11011 assert!(flush_callgraph_store_refreshes_with_budget(
11012 Duration::from_secs(2)
11013 ));
11014
11015 assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
11016 let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
11017 .unwrap()
11018 .expect("ready callgraph store");
11019 assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
11020 clear_callgraph_refresh_worker_test_seam(&root);
11021 }
11022
11023 #[test]
11024 fn bounded_shutdown_defers_unprocessed_batches() {
11025 let _guard = REFRESH_WORKER_TEST_LOCK
11026 .lock()
11027 .unwrap_or_else(std::sync::PoisonError::into_inner);
11028 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
11033 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
11034 let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
11035 let active_pending = pending_paths();
11036 let queued_pending = pending_paths();
11037 set_callgraph_refresh_worker_test_seam(
11038 active_root.clone(),
11039 Duration::from_millis(300),
11040 false,
11041 );
11042
11043 enqueue_callgraph_store_refresh(
11044 active_dir,
11045 active_root.clone(),
11046 vec![active_source.clone()],
11047 Arc::clone(&active_pending),
11048 );
11049 wait_for_refresh_calls(&active_root, 1);
11050 enqueue_callgraph_store_refresh(
11051 queued_dir,
11052 queued_root.clone(),
11053 vec![queued_source.clone()],
11054 Arc::clone(&queued_pending),
11055 );
11056
11057 assert!(!flush_callgraph_store_refreshes_with_budget(
11058 Duration::from_millis(20)
11059 ));
11060 assert!(active_pending.lock().contains(&active_source));
11061 assert!(queued_pending.lock().contains(&queued_source));
11062 assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
11063 clear_callgraph_refresh_worker_test_seam(&active_root);
11064 }
11065}
11066
11067#[cfg(test)]
11068mod cold_build_insert_tests {
11069 use super::*;
11070 use crate::imports::ImportBlock;
11071 use std::cell::Cell;
11072 use std::fs;
11073 use std::path::{Path, PathBuf};
11074 use tempfile::tempdir;
11075
11076 thread_local! {
11077 static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
11078 static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
11079 static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
11080 }
11081
11082 fn count_caller_traversal_selects(sql: &str) {
11083 let sql = sql.trim_start();
11084 if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
11085 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
11086 }
11087 if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
11088 && sql.contains("e.target_file =")
11089 {
11090 CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
11091 }
11092 if sql.starts_with("WITH requested") {
11093 BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
11094 }
11095 }
11096
11097 #[test]
11098 fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
11099 let dir = tempdir().unwrap();
11100 let previous_root = dir.path().join("previous-root");
11101 let current_root = dir.path().join("current-root");
11102 fs::create_dir_all(&previous_root).unwrap();
11103 fs::create_dir_all(¤t_root).unwrap();
11104 fs::remove_dir(&previous_root).unwrap();
11105 let mut conn = Connection::open_in_memory().unwrap();
11106 initialize_schema(&conn).unwrap();
11107 conn.execute(
11108 "INSERT INTO backend_file_state(
11109 backend, workspace_root, file_path, content_hash, status, updated_at
11110 ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
11111 params![previous_root.display().to_string()],
11112 )
11113 .unwrap();
11114
11115 let repair = reconcile_workspace_roots(&mut conn, ¤t_root, false).unwrap();
11116
11117 assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
11118 assert_eq!(
11119 stored_workspace_roots(&conn).unwrap(),
11120 vec![previous_root.display().to_string()]
11121 );
11122 }
11123
11124 #[test]
11125 fn sqlite_readonly_uri_percent_encodes_windows_paths() {
11126 assert_eq!(
11127 sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
11128 "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
11129 );
11130 }
11131
11132 #[test]
11133 fn legacy_migration_completion_log_has_operator_fields() {
11134 assert_eq!(
11135 legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
11136 "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
11137 );
11138 }
11139
11140 fn write_generation_with_age(
11141 dir: &Path,
11142 project_key: &str,
11143 ordinal: u64,
11144 age: Duration,
11145 ) -> String {
11146 let generation = format!("{project_key}.g{ordinal}.1.sqlite");
11147 let path = dir.join(&generation);
11148 fs::write(&path, b"sqlite placeholder").unwrap();
11149 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
11150 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
11151 generation
11152 }
11153
11154 #[test]
11155 fn gc_old_generations_preserves_live_reader_until_marker_drops() {
11156 let dir = tempfile::tempdir().unwrap();
11157 let project_key = "project";
11158 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
11159 let previous =
11160 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
11161 let pinned =
11162 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
11163 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
11164
11165 gc_old_generations(dir.path(), project_key, ¤t);
11166
11167 assert!(dir.path().join(&previous).is_file());
11168 assert!(dir.path().join(&pinned).is_file());
11169
11170 drop(marker);
11171 gc_old_generations(dir.path(), project_key, ¤t);
11172
11173 assert!(dir.path().join(&previous).is_file());
11174 assert!(!dir.path().join(&pinned).exists());
11175 }
11176
11177 #[test]
11178 fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
11179 let dir = tempfile::tempdir().unwrap();
11180 let project_key = "project";
11181 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
11182 let _previous =
11183 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
11184 let pinned =
11185 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
11186 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
11187 filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
11188
11189 gc_old_generations(dir.path(), project_key, ¤t);
11190
11191 assert!(dir.path().join(&pinned).is_file());
11192 }
11193
11194 #[test]
11195 fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
11196 let dir = tempfile::tempdir().unwrap();
11197 let project_key = "project";
11198 let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
11199 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
11200 let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
11201 let old = write_generation_with_age(
11202 dir.path(),
11203 project_key,
11204 200,
11205 expired + Duration::from_secs(60),
11206 );
11207 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
11208
11209 gc_old_generations(dir.path(), project_key, ¤t);
11210
11211 assert!(dir.path().join(¤t).is_file());
11212 assert!(dir.path().join(&previous).is_file());
11213 assert!(!dir.path().join(&old).exists());
11214 }
11215
11216 fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
11217 let path = dir.join(name);
11218 fs::write(&path, b"temp placeholder").unwrap();
11219 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
11220 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
11221 path
11222 }
11223
11224 #[test]
11225 fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
11226 let dir = tempdir().unwrap();
11227 let aged = "project.g100.1.sqlite.tmp.1.200";
11231 let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
11232 let fresh = "project.g300.1.sqlite.tmp.1.400";
11233 let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
11234 write_build_temp_with_age(dir.path(), aged, aged_age);
11235 write_build_temp_with_age(dir.path(), aged_journal, aged_age);
11236 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
11237
11238 sweep_orphaned_build_temps(dir.path());
11239
11240 assert!(
11241 !dir.path().join(aged).exists(),
11242 "aged orphan must be removed"
11243 );
11244 assert!(
11245 !dir.path().join(aged_journal).exists(),
11246 "aged journal sidecar must be removed"
11247 );
11248 assert!(
11249 dir.path().join(fresh).is_file(),
11250 "fresh temporary must survive"
11251 );
11252 }
11253
11254 #[test]
11255 fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
11256 let storage = tempdir().unwrap();
11257 let storage_root = storage.path();
11258 let legacy_dir = storage_root.join("opencode").join("callgraph");
11264 fs::create_dir_all(&legacy_dir).unwrap();
11265 let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
11266 write_build_temp_with_age(
11267 &legacy_dir,
11268 orphan,
11269 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
11270 );
11271 assert!(
11272 !legacy_dir.join("deadbeef.current").exists(),
11273 "the dead root has no current pointer"
11274 );
11275
11276 let root_keyed_dir = storage_root.join("callgraph").join("livekey");
11277 fs::create_dir_all(&root_keyed_dir).unwrap();
11278
11279 sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
11280
11281 assert!(
11282 !legacy_dir.join(orphan).exists(),
11283 "legacy orphan must be reclaimed by the store-wide sweep"
11284 );
11285 }
11286
11287 #[test]
11288 fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
11289 let dir = tempdir().unwrap();
11295 let fresh = "project.g300.1.sqlite.tmp.1.400";
11296 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
11297
11298 sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
11299
11300 assert!(
11301 !dir.path().join(fresh).exists(),
11302 "with the age predicate forced open, the fresh temporary is removed"
11303 );
11304 }
11305
11306 #[test]
11307 fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
11308 let dir = tempdir().unwrap();
11309 let generation = write_generation_with_age(
11313 dir.path(),
11314 "project",
11315 400,
11316 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
11317 );
11318 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
11319
11320 sweep_orphaned_build_temps(dir.path());
11321
11322 assert!(
11323 dir.path().join(&generation).is_file(),
11324 "completed generation must survive the orphan sweep"
11325 );
11326 assert!(
11327 crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
11328 "read marker must survive the orphan sweep"
11329 );
11330 }
11331
11332 #[test]
11333 fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
11334 let dir = tempfile::tempdir().unwrap();
11335 let project_key = "project".to_string();
11336 let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
11337 let sqlite_path = dir.path().join(&generation);
11338 fs::remove_file(&sqlite_path).unwrap();
11339 let conn = Connection::open(&sqlite_path).unwrap();
11340 let store = CallGraphStore::from_connection(
11341 dir.path().to_path_buf(),
11342 project_key,
11343 sqlite_path,
11344 dir.path().to_path_buf(),
11345 false,
11346 Some(generation.clone()),
11347 None,
11348 None,
11349 conn,
11350 );
11351
11352 let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
11353 assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
11354
11355 drop(marker);
11356 assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
11357 }
11358
11359 #[test]
11360 fn readiness_cache_only_skips_checks_after_a_successful_validation() {
11361 let dir = tempdir().expect("temp dir");
11362 let file = dir.path().join("main.ts");
11363 fs::write(&file, "export function main() {}\n").expect("write fixture");
11364 let store = CallGraphStore::open(
11365 dir.path().join(".store-readiness-cache"),
11366 dir.path().to_path_buf(),
11367 )
11368 .expect("open store");
11369 {
11370 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
11371 conn.trace(Some(count_caller_traversal_selects));
11372 }
11373
11374 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
11375 assert!(store.indexed_file_count().is_err());
11376 assert!(store.indexed_file_count().is_err());
11377 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
11378
11379 store
11380 .cold_build(std::slice::from_ref(&file))
11381 .expect("cold build");
11382 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
11383 assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
11384 assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
11385 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
11386
11387 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
11388 conn.trace(None);
11389 }
11390
11391 #[test]
11392 fn callers_depth_boundary_batches_sqlite_counts() {
11393 const CALLER_COUNT: usize = 1_000;
11394
11395 let dir = tempdir().expect("temp dir");
11396 let file = dir.path().join("main.ts");
11397 let mut source = String::from("export function sharedHotHelper() {}\n");
11398 for index in 0..CALLER_COUNT {
11399 source.push_str(&format!(
11400 "export function caller{index}() {{ sharedHotHelper(); }}\n"
11401 ));
11402 }
11403 fs::write(&file, source).expect("write fixture");
11404
11405 let store = CallGraphStore::open(
11406 dir.path().join(".store-callers-query-fanout"),
11407 dir.path().to_path_buf(),
11408 )
11409 .expect("open store");
11410 store
11411 .cold_build(std::slice::from_ref(&file))
11412 .expect("cold build");
11413
11414 CALLER_QUERY_SELECTS.with(|count| count.set(0));
11415 BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
11416 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
11417 {
11418 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
11419 conn.trace(Some(count_caller_traversal_selects));
11420 }
11421
11422 let started = Instant::now();
11423 let result = crate::commands::callgraph_store_adapter::callers_result(
11424 &store,
11425 Path::new("main.ts"),
11426 "sharedHotHelper",
11427 1,
11428 true,
11429 )
11430 .expect("callers result");
11431 let elapsed = started.elapsed();
11432
11433 {
11434 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
11435 conn.trace(None);
11436 }
11437 let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
11438 let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
11439 let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
11440 eprintln!(
11441 "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
11442 result.total_callers,
11443 caller_queries,
11444 boundary_queries,
11445 total_selects,
11446 elapsed.as_secs_f64() * 1_000.0
11447 );
11448
11449 assert_eq!(result.total_callers, CALLER_COUNT);
11450 assert_eq!(caller_queries, 1);
11451 assert_eq!(boundary_queries, 3);
11452 assert_eq!(total_selects, 9);
11453 }
11454
11455 #[test]
11456 fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
11457 let dir = tempdir().expect("temp dir");
11458 let file = dir.path().join("main.ts");
11459 fs::write(
11460 &file,
11461 r#"export function topA() {
11462 root();
11463}
11464
11465export function topB() {
11466 root();
11467}
11468
11469export function root() {
11470 leaf();
11471 missing();
11472}
11473
11474export function leaf() {}
11475"#,
11476 )
11477 .expect("write fixture");
11478
11479 let store = CallGraphStore::open(
11480 dir.path().join(".store-depth-boundary-counts"),
11481 dir.path().to_path_buf(),
11482 )
11483 .expect("open store");
11484 store
11485 .cold_build(std::slice::from_ref(&file))
11486 .expect("cold build");
11487
11488 let root = store
11489 .node_for(Path::new("main.ts"), "root")
11490 .expect("root node");
11491 let leaf = store
11492 .node_for(Path::new("main.ts"), "leaf")
11493 .expect("leaf node");
11494
11495 let (full_forward_len, full_direct_len) = {
11496 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
11497 conn.execute(
11498 "INSERT INTO edges (
11499 edge_id, ref_id, source_node, target_node, target_file,
11500 target_symbol, kind, line, provenance
11501 ) VALUES (
11502 'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
11503 ?2, ?3, 'call', 98, ?4
11504 )",
11505 rusqlite::params![
11506 &root.node_id,
11507 &leaf.file,
11508 &leaf.symbol,
11509 PROVENANCE_TREESITTER
11510 ],
11511 )
11512 .expect("insert dangling forward edge");
11513 conn.execute(
11514 "INSERT INTO edges (
11515 edge_id, ref_id, source_node, target_node, target_file,
11516 target_symbol, kind, line, provenance
11517 ) VALUES (
11518 'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
11519 ?1, ?2, ?3, 'call', 99, ?4
11520 )",
11521 rusqlite::params![
11522 &root.node_id,
11523 &root.file,
11524 &root.symbol,
11525 PROVENANCE_TREESITTER
11526 ],
11527 )
11528 .expect("insert dangling direct-caller edge");
11529
11530 let full_forward_len = forward_calls_for_node(&conn, &root)
11531 .expect("full forward calls")
11532 .len();
11533 let counted_forward_len =
11534 forward_call_count_for_node(&conn, &root).expect("counted forward calls");
11535 assert_eq!(
11536 counted_forward_len, full_forward_len,
11537 "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
11538 );
11539
11540 let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
11541 .expect("full direct callers");
11542 let full_direct_len = full_direct.len();
11543 let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
11544 .expect("counted direct callers");
11545 assert_eq!(
11546 counted_direct_len, full_direct_len,
11547 "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
11548 );
11549
11550 let distinct_direct_len = full_direct
11551 .iter()
11552 .map(|site| {
11553 (
11554 site.caller.file.clone(),
11555 site.line,
11556 site.target_file.clone(),
11557 site.target_symbol.clone(),
11558 )
11559 })
11560 .collect::<BTreeSet<_>>()
11561 .len();
11562 let batch_counts = direct_caller_counts_for_tuples(
11563 &conn,
11564 &[
11565 (root.file.clone(), root.symbol.clone()),
11566 (root.file.clone(), root.symbol.clone()),
11567 (leaf.file.clone(), leaf.symbol.clone()),
11568 ],
11569 )
11570 .expect("batched direct-caller counts");
11571 assert_eq!(batch_counts.len(), 2);
11572 assert_eq!(
11573 batch_counts.get(&(root.file.clone(), root.symbol.clone())),
11574 Some(&distinct_direct_len)
11575 );
11576
11577 (full_forward_len, full_direct_len)
11578 };
11579
11580 assert_eq!(
11581 full_forward_len, 2,
11582 "fixture root should have one resolved and one unresolved outgoing call"
11583 );
11584 assert_eq!(
11585 full_direct_len, 2,
11586 "fixture root should have two real direct callers"
11587 );
11588
11589 let tree = store
11590 .call_tree(Path::new("main.ts"), "root", 0)
11591 .expect("call tree");
11592 assert!(tree.depth_limited);
11593 assert_eq!(tree.children.len(), 0);
11594 assert_eq!(
11595 tree.truncated, full_forward_len,
11596 "call_tree depth boundary must report the full forward-call list length"
11597 );
11598
11599 let callers = store
11600 .callers_of(Path::new("main.ts"), "leaf", 0)
11601 .expect("callers");
11602 assert!(callers.depth_limited);
11603 assert_eq!(callers.callers.len(), 1);
11604 assert_eq!(callers.callers[0].caller.symbol, "root");
11605 assert_eq!(
11606 callers.truncated, full_direct_len,
11607 "callers depth boundary must report the full direct-caller list length"
11608 );
11609 }
11610
11611 #[test]
11612 fn source_freshness_matches_cache_collect_for_same_bytes() {
11613 let dir = tempdir().expect("temp dir");
11614 let path = dir.path().join("fixture.ts");
11615 let source = "export function main() { return helper(); }\n";
11616 fs::write(&path, source).expect("write fixture");
11617
11618 let expected = cache_freshness::collect(&path).expect("collect freshness from file");
11619 let actual =
11620 collect_source_freshness(&path, source).expect("collect freshness from source");
11621
11622 assert_eq!(actual, expected);
11623 }
11624
11625 #[test]
11626 fn superseded_cold_build_cannot_publish_after_newer_epoch() {
11627 let root = tempfile::tempdir().unwrap();
11628 let callgraph_dir = tempfile::tempdir().unwrap();
11629 let source_dir = root.path().join("src");
11630 std::fs::create_dir_all(&source_dir).unwrap();
11631 let source = source_dir.join("lib.rs");
11632 std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
11633 let files = vec![source.clone()];
11634 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
11635 let old_epoch = epoch.next();
11636 let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
11637 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
11638 let old_epoch_flag = epoch.clone();
11639 let old_dir = callgraph_dir.path().to_path_buf();
11640 let old_root = root.path().to_path_buf();
11641 let old_files = files.clone();
11642 let old = std::thread::spawn(move || {
11643 set_cold_build_before_publish_observer(Some(Arc::new(move || {
11644 reached_tx.send(()).unwrap();
11645 release_rx.recv().unwrap();
11646 })));
11647 let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
11648 CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
11649 });
11650 set_cold_build_before_publish_observer(None);
11651 result
11652 });
11653 reached_rx
11657 .recv_timeout(Duration::from_secs(30))
11658 .expect("older build did not reach its publication barrier");
11659
11660 std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
11661 let new_epoch = epoch.next();
11662 let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
11663 CallGraphStore::cold_build_with_lease(
11664 callgraph_dir.path().to_path_buf(),
11665 root.path().to_path_buf(),
11666 &files,
11667 )
11668 })
11669 .expect("newer build should publish");
11670 drop(new_store);
11671
11672 release_tx.send(()).unwrap();
11673 assert!(matches!(
11674 old.join().unwrap(),
11675 Err(CallGraphStoreError::Superseded)
11676 ));
11677
11678 let current = CallGraphStore::open_readonly(
11679 callgraph_dir.path().to_path_buf(),
11680 root.path().to_path_buf(),
11681 )
11682 .unwrap()
11683 .expect("current callgraph generation");
11684 assert_eq!(
11685 current
11686 .nodes_matching("new_generation_marker")
11687 .unwrap()
11688 .len(),
11689 1
11690 );
11691 assert!(current
11692 .nodes_matching("old_generation_marker")
11693 .unwrap()
11694 .is_empty());
11695 }
11696
11697 #[test]
11698 fn cold_build_prepared_bulk_insert_matches_reference_rows() {
11699 let dir = tempdir().expect("temp dir");
11700 let project_root = dir.path();
11701 let extract = fixture_extract(project_root);
11702 let resolved = fixture_resolved(&extract);
11703
11704 let reference = build_reference_connection(project_root, &extract, &resolved);
11705 let optimized = build_optimized_connection(project_root, &extract, &resolved);
11706
11707 for table in [
11708 "files",
11709 "nodes",
11710 "file_dependencies",
11711 "dispatch_hints",
11712 "refs",
11713 "edges",
11714 ] {
11715 let excluded: &[&str] = if table == "files" {
11722 &["indexed_at"]
11723 } else {
11724 &[]
11725 };
11726 assert_eq!(
11727 table_rows_without(&reference, table, excluded),
11728 table_rows_without(&optimized, table, excluded),
11729 "table `{table}` rows must match apart from wall-clock columns"
11730 );
11731 }
11732 assert_eq!(
11733 backend_state_rows(&reference),
11734 backend_state_rows(&optimized),
11735 "backend freshness rows must match apart from updated_at"
11736 );
11737 assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
11738 }
11739
11740 #[test]
11741 fn cold_build_chunked_matches_unchunked_logical_rows() {
11742 let dir = tempdir().expect("temp dir");
11743 let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
11744 write_chunked_equivalence_fixture(&project_root);
11745 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
11746 assert!(
11747 files.len() > 6,
11748 "fixture should be large enough to split into multiple chunks"
11749 );
11750
11751 let unchunked = CallGraphStore::open(
11752 project_root.join(".store-unchunked"),
11753 project_root.to_path_buf(),
11754 )
11755 .expect("open unchunked store");
11756 let unchunked_stats = unchunked
11757 .cold_build_chunked(&files, 0)
11758 .expect("unchunked cold build");
11759
11760 let chunked = CallGraphStore::open(
11761 project_root.join(".store-chunked"),
11762 project_root.to_path_buf(),
11763 )
11764 .expect("open chunked store");
11765 let chunked_stats = chunked
11766 .cold_build_chunked(&files, 3)
11767 .expect("chunked cold build");
11768
11769 assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
11770 assert_eq!(
11771 unchunked.edge_snapshot().expect("unchunked edge snapshot"),
11772 chunked.edge_snapshot().expect("chunked edge snapshot"),
11773 "public edge snapshots must match"
11774 );
11775
11776 let dispatch_edges = {
11777 let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
11778 conn.query_row(
11779 "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
11780 [],
11781 |row| row.get::<_, i64>(0),
11782 )
11783 .expect("count dispatch edges")
11784 };
11785 assert!(
11786 dispatch_edges > 0,
11787 "fixture must exercise method-dispatch edge insertion"
11788 );
11789
11790 for table in [
11791 "edges",
11792 "refs",
11793 "nodes",
11794 "file_dependencies",
11795 "dispatch_hints",
11796 ] {
11797 assert_eq!(
11798 graph_table_rows(&unchunked, table),
11799 graph_table_rows(&chunked, table),
11800 "chunked cold build must match unchunked rows for {table}"
11801 );
11802 }
11803 assert_eq!(
11804 graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
11805 graph_table_rows_without(&chunked, "files", &["indexed_at"]),
11806 "files rows must match apart from indexed_at"
11807 );
11808 assert_eq!(
11809 graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
11810 graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
11811 "backend freshness rows must match apart from updated_at"
11812 );
11813
11814 let published_dir = project_root.join(".store-published");
11815 let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
11816 published_dir.clone(),
11817 project_root.to_path_buf(),
11818 &files,
11819 0,
11820 )
11821 .expect("published unchunked cold build");
11822 assert!(
11823 !CallGraphStore::needs_cold_build(&published_dir, &project_root)
11824 .expect("needs_cold_build after publish"),
11825 "published store should be ready"
11826 );
11827 drop(_published);
11828 let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
11829 published_dir,
11830 project_root.to_path_buf(),
11831 &files,
11832 3,
11833 )
11834 .expect("ensure with a different chunk size");
11835 assert!(
11836 rebuild_stats.is_none(),
11837 "changing callgraph_chunk_size must not affect store identity or force a rebuild"
11838 );
11839 }
11840
11841 #[test]
11848 #[ignore]
11849 fn bench_cold_build_chunk() {
11850 let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
11851 let chunk: usize = std::env::var("AFT_PERF_CHUNK")
11852 .expect("AFT_PERF_CHUNK")
11853 .parse()
11854 .expect("AFT_PERF_CHUNK must be a non-negative integer");
11855 let project_root = fs::canonicalize(&repo).expect("canonical repo root");
11856 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
11857 let dir = tempdir().expect("temp dir");
11858 let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
11859 .expect("open store");
11860 let started = Instant::now();
11861 let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
11862 let ms = started.elapsed().as_millis();
11863 println!(
11864 "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
11865 stats.files, stats.nodes, stats.refs, stats.edges
11866 );
11867 }
11868
11869 #[test]
11870 fn persisted_workspace_reexport_selects_its_package_dependency() {
11871 let root = tempdir().expect("temp dir");
11872 let dependencies = BTreeSet::from([
11873 "packages/aft-bridge/src/index.ts".to_string(),
11874 "packages/opencode-plugin/src/types.ts".to_string(),
11875 ]);
11876 let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
11877
11878 assert_eq!(
11879 stored_dependencies_for_module(
11880 root.path(),
11881 "packages/opencode-plugin/src/shared/bash-hints.ts",
11882 "@cortexkit/aft-bridge",
11883 &dependencies,
11884 &indexed_files,
11885 ),
11886 BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
11887 );
11888 }
11889
11890 #[test]
11891 fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
11892 let dir = tempdir().expect("temp dir");
11893 let project_root = dir.path();
11894 let files =
11895 write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
11896 let index_path = project_root.join("src/index.ts");
11897
11898 let store = CallGraphStore::open(
11899 project_root.join(".store-incremental-barrel"),
11900 project_root.to_path_buf(),
11901 )
11902 .expect("open incremental store");
11903 store.cold_build(&files).expect("initial cold build");
11904
11905 {
11906 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
11907 let tx = conn.transaction().expect("dependency transaction");
11908 let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
11909 .expect("dependent refs for barrel");
11910 let selected_ref_ids = dependent_refs
11911 .iter()
11912 .map(|dependent_ref| dependent_ref.ref_id.clone())
11913 .collect::<BTreeSet<_>>();
11914 let mut threaded_ref_ids = BTreeSet::new();
11915 let mut threaded_by_caller = BTreeMap::new();
11916 record_dependent_refs(
11917 &mut threaded_ref_ids,
11918 &mut threaded_by_caller,
11919 dependent_refs,
11920 );
11921 let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
11922 .expect("old per-ref caller lookup");
11923
11924 assert_eq!(threaded_ref_ids, selected_ref_ids);
11925 assert_eq!(threaded_by_caller, old_by_caller);
11926 for consumer in [
11927 "src/consumer_a.ts",
11928 "src/consumer_b.ts",
11929 "src/consumer_c.ts",
11930 ] {
11931 assert!(
11932 threaded_by_caller.contains_key(consumer),
11933 "barrel edit should select dependent refs from {consumer}"
11934 );
11935 }
11936 }
11937
11938 fs::write(
11939 &index_path,
11940 "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
11941 )
11942 .expect("edit barrel");
11943 let stats = store
11944 .refresh_files(std::slice::from_ref(&index_path))
11945 .expect("incremental refresh");
11946 assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
11947 assert!(
11948 stats.dependency_selected_refs > 0,
11949 "barrel surface edit should select dependent refs"
11950 );
11951
11952 let cold_store = CallGraphStore::open(
11953 project_root.join(".store-cold-barrel"),
11954 project_root.to_path_buf(),
11955 )
11956 .expect("open cold rebuild store");
11957 cold_store
11958 .cold_build(&files)
11959 .expect("comparison cold build");
11960
11961 for table in [
11962 "nodes",
11963 "refs",
11964 "file_dependencies",
11965 "edges",
11966 "dispatch_hints",
11967 ] {
11968 assert_eq!(
11969 graph_table_rows(&store, table),
11970 graph_table_rows(&cold_store, table),
11971 "incremental refresh {table} rows must match cold rebuild"
11972 );
11973 }
11974
11975 let consumer_path = project_root.join("src/consumer_a.ts");
11976 fs::write(
11977 &consumer_path,
11978 "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
11979 )
11980 .expect("edit barrel consumer");
11981 store
11982 .refresh_files(std::slice::from_ref(&consumer_path))
11983 .expect("refresh consumer through unchanged barrel");
11984 cold_store
11985 .cold_build(&files)
11986 .expect("comparison cold rebuild after consumer refresh");
11987 for table in [
11988 "nodes",
11989 "refs",
11990 "file_dependencies",
11991 "edges",
11992 "dispatch_hints",
11993 ] {
11994 assert_eq!(
11995 graph_table_rows(&store, table),
11996 graph_table_rows(&cold_store, table),
11997 "refresh through a persisted barrel must preserve cold-build {table} rows"
11998 );
11999 }
12000 }
12001
12002 fn build_reference_connection(
12003 project_root: &Path,
12004 extract: &FileExtract,
12005 resolved: &ResolvedRef,
12006 ) -> Connection {
12007 let mut conn = Connection::open_in_memory().expect("open reference db");
12008 configure_build_connection(&conn).expect("configure reference db");
12009 initialize_schema(&conn).expect("initialize reference schema");
12010 {
12011 let tx = conn.transaction().expect("reference transaction");
12012 clear_tables(&tx).expect("reference clear");
12013 insert_meta(&tx).expect("reference meta");
12014 insert_file_extract(&tx, project_root, extract).expect("reference file extract");
12015 insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
12016 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
12017 .expect("reference dispatch edges");
12018 assert_eq!(supplemental, 0);
12019 tx.commit().expect("reference commit");
12020 }
12021 conn
12022 }
12023
12024 fn build_optimized_connection(
12025 project_root: &Path,
12026 extract: &FileExtract,
12027 resolved: &ResolvedRef,
12028 ) -> Connection {
12029 let mut conn = Connection::open_in_memory().expect("open optimized db");
12030 configure_build_connection(&conn).expect("configure optimized db");
12031 initialize_schema(&conn).expect("initialize optimized schema");
12032 {
12033 let tx = conn.transaction().expect("optimized transaction");
12034 clear_tables(&tx).expect("optimized clear");
12035 insert_meta(&tx).expect("optimized meta");
12036 drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
12037 {
12038 let workspace_root = project_root.display().to_string();
12039 let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
12040 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
12041 .expect("optimized file extract");
12042 insert_resolved_ref_prepared(&mut inserts, resolved)
12043 .expect("optimized resolved ref");
12044 }
12045 create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
12046 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
12047 .expect("optimized dispatch edges");
12048 assert_eq!(supplemental, 0);
12049 tx.commit().expect("optimized commit");
12050 }
12051 conn
12052 }
12053
12054 fn fixture_extract(_project_root: &Path) -> FileExtract {
12055 let rel_path = "src/main.ts".to_string();
12056 let target_path = "src/helper.ts".to_string();
12057 let node = NodeRecord {
12058 id: "node-main".to_string(),
12059 file_path: rel_path.clone(),
12060 name: "main".to_string(),
12061 scoped_name: "main".to_string(),
12062 kind: "function".to_string(),
12063 range: Range {
12064 start_line: 0,
12065 start_col: 0,
12066 end_line: 0,
12067 end_col: 32,
12068 },
12069 range_ordinal: 0,
12070 signature: Some("export function main()".to_string()),
12071 exported: true,
12072 is_default_export: false,
12073 is_type_like: false,
12074 is_callgraph_entry_point: true,
12075 };
12076 let mut dependencies = BTreeSet::new();
12077 dependencies.insert(target_path.clone());
12078 let raw_ref = RawRef {
12079 ref_id: "ref-main-helper".to_string(),
12080 caller_node: Some(node.id.clone()),
12081 caller_symbol: Some(node.scoped_name.clone()),
12082 caller_file: rel_path.clone(),
12083 kind: "call".to_string(),
12084 short_name: Some("helper".to_string()),
12085 full_ref: Some("helper".to_string()),
12086 module_path: None,
12087 import_kind: None,
12088 local_name: Some("helper".to_string()),
12089 requested_name: Some("helper".to_string()),
12090 namespace_alias: None,
12091 wildcard: false,
12092 line: 1,
12093 byte_start: 24,
12094 byte_end: 32,
12095 dependencies,
12096 };
12097 FileExtract {
12098 rel_path,
12099 freshness: FileFreshness {
12100 mtime: UNIX_EPOCH + Duration::from_secs(123),
12101 size: 40,
12102 content_hash: cache_freshness::hash_bytes(b"fixture source"),
12103 },
12104 lang: LangId::TypeScript,
12105 data: FileCallData {
12106 calls_by_symbol: HashMap::new(),
12107 exported_symbols: Vec::new(),
12108 symbol_metadata: HashMap::new(),
12109 default_export_symbol: None,
12110 import_block: ImportBlock::empty(),
12111 lang: LangId::TypeScript,
12112 },
12113 nodes: vec![node.clone()],
12114 raw_refs: vec![raw_ref],
12115 dispatch_hints: vec![DispatchHint {
12116 id: "dispatch-main-helper".to_string(),
12117 method_name: "helper".to_string(),
12118 caller_node: node.id,
12119 file: "src/main.ts".to_string(),
12120 line: 1,
12121 byte_start: 24,
12122 byte_end: 32,
12123 }],
12124 surface_fingerprint: "surface".to_string(),
12125 }
12126 }
12127
12128 fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
12129 let raw = extract.raw_refs[0].clone();
12130 let mut dependencies = raw.dependencies.clone();
12131 dependencies.insert("src/helper.ts".to_string());
12132 ResolvedRef {
12133 edge: Some(EdgeRecord {
12134 edge_id: "edge-main-helper".to_string(),
12135 source_node: raw.caller_node.clone().expect("caller node"),
12136 target_node: Some("node-helper".to_string()),
12137 target_file: "src/helper.ts".to_string(),
12138 target_symbol: "helper".to_string(),
12139 kind: "call".to_string(),
12140 line: raw.line,
12141 }),
12142 raw,
12143 status: "resolved".to_string(),
12144 target_node: Some("node-helper".to_string()),
12145 target_file: Some("src/helper.ts".to_string()),
12146 target_symbol: Some("helper".to_string()),
12147 dependencies,
12148 }
12149 }
12150
12151 fn write_chunked_equivalence_fixture(project_root: &Path) {
12152 let ts_dir = project_root.join("ts");
12153 fs::create_dir_all(&ts_dir).expect("create ts dir");
12154 fs::write(
12155 ts_dir.join("leaf.ts"),
12156 "export function leaf(value: number) {\n return value + 1;\n}\n",
12157 )
12158 .expect("write ts leaf");
12159 fs::write(
12160 ts_dir.join("mid.ts"),
12161 "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n return leaf(value);\n}\n",
12162 )
12163 .expect("write ts mid");
12164 fs::write(
12165 ts_dir.join("entry.ts"),
12166 "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n return mid(worker.run());\n}\n",
12167 )
12168 .expect("write ts entry");
12169 fs::write(
12170 ts_dir.join("worker.ts"),
12171 "export class Worker {\n run() {\n return 41;\n }\n}\n",
12172 )
12173 .expect("write ts worker");
12174 for idx in 0..4 {
12175 fs::write(
12176 ts_dir.join(format!("extra_{idx}.ts")),
12177 format!(
12178 "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n return entry(new Worker());\n}}\n"
12179 ),
12180 )
12181 .expect("write ts extra");
12182 }
12183
12184 let rust_dir = project_root.join("src");
12185 let commands_dir = rust_dir.join("commands");
12186 fs::create_dir_all(&commands_dir).expect("create rust commands dir");
12187 fs::write(
12188 rust_dir.join("context.rs"),
12189 r#"pub struct AppContext;
12190
12191impl AppContext {
12192 pub fn callgraph_store_for_ops(&self) -> usize {
12193 1
12194 }
12195}
12196"#,
12197 )
12198 .expect("write rust context");
12199 fs::write(
12200 rust_dir.join("lib.rs"),
12201 "pub mod context;\npub mod commands;\n",
12202 )
12203 .expect("write rust lib");
12204 fs::write(
12205 commands_dir.join("mod.rs"),
12206 "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
12207 )
12208 .expect("write rust commands mod");
12209 for name in ["callers", "impact", "trace_to"] {
12210 fs::write(
12211 commands_dir.join(format!("{name}.rs")),
12212 format!(
12213 r#"use crate::context::AppContext;
12214
12215pub fn handle_{name}(ctx: &AppContext) -> usize {{
12216 ctx.callgraph_store_for_ops()
12217}}
12218"#
12219 ),
12220 )
12221 .expect("write rust command");
12222 }
12223 }
12224
12225 fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
12226 let src_dir = project_root.join("src");
12227 fs::create_dir_all(&src_dir).expect("create src dir");
12228
12229 let target_path = src_dir.join("target.ts");
12230 fs::write(&target_path, "export function target() {\n return 1;\n}\n")
12231 .expect("write target");
12232
12233 let index_path = src_dir.join("index.ts");
12234 fs::write(&index_path, barrel_source).expect("write barrel");
12235
12236 let mut files = vec![target_path, index_path];
12237 for (file_name, function_name) in [
12238 ("consumer_a.ts", "consumerA"),
12239 ("consumer_b.ts", "consumerB"),
12240 ("consumer_c.ts", "consumerC"),
12241 ] {
12242 let path = src_dir.join(file_name);
12243 fs::write(
12244 &path,
12245 format!(
12246 "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n return target();\n}}\n"
12247 ),
12248 )
12249 .expect("write consumer");
12250 files.push(path);
12251 }
12252 files
12253 }
12254
12255 fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
12256 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
12257 table_rows(&conn, table)
12258 }
12259
12260 fn graph_table_rows_without(
12261 store: &CallGraphStore,
12262 table: &str,
12263 excluded_columns: &[&str],
12264 ) -> Vec<String> {
12265 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
12266 table_rows_without(&conn, table, excluded_columns)
12267 }
12268
12269 fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
12270 table_rows_without(conn, table, &[])
12271 }
12272
12273 fn table_rows_without(
12274 conn: &Connection,
12275 table: &str,
12276 excluded_columns: &[&str],
12277 ) -> Vec<String> {
12278 let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
12279 let columns: Vec<String> = conn
12280 .prepare(&format!("PRAGMA table_info({table})"))
12281 .expect("prepare table_info")
12282 .query_map([], |row| row.get::<_, String>(1))
12283 .expect("query table_info")
12284 .collect::<std::result::Result<Vec<String>, _>>()
12285 .expect("collect columns")
12286 .into_iter()
12287 .filter(|column| !excluded_columns.contains(column.as_str()))
12288 .collect();
12289 let sql = format!(
12290 "SELECT {} FROM {table} ORDER BY {}",
12291 columns.join(", "),
12292 columns.join(", ")
12293 );
12294 conn.prepare(&sql)
12295 .expect("prepare table rows")
12296 .query_map([], |row| row_to_strings(row, columns.len()))
12297 .expect("query table rows")
12298 .collect::<std::result::Result<_, _>>()
12299 .expect("collect table rows")
12300 }
12301
12302 fn assert_cold_build_stats_match_except_elapsed(
12303 expected: &ColdBuildStats,
12304 actual: &ColdBuildStats,
12305 ) {
12306 assert_eq!(actual.files, expected.files, "file counts must match");
12307 assert_eq!(actual.nodes, expected.nodes, "node counts must match");
12308 assert_eq!(actual.refs, expected.refs, "ref counts must match");
12309 assert_eq!(actual.edges, expected.edges, "edge counts must match");
12310 assert_eq!(
12311 actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
12312 expected
12313 .failed_files
12314 .iter()
12315 .cloned()
12316 .collect::<BTreeSet<_>>(),
12317 "failed file sets must match"
12318 );
12319 }
12320
12321 fn backend_state_rows(conn: &Connection) -> Vec<String> {
12322 conn.prepare(
12323 "SELECT backend, workspace_root, file_path, content_hash, status
12324 FROM backend_file_state
12325 ORDER BY backend, workspace_root, file_path, content_hash, status",
12326 )
12327 .expect("prepare backend rows")
12328 .query_map([], |row| row_to_strings(row, 5))
12329 .expect("query backend rows")
12330 .collect::<std::result::Result<_, _>>()
12331 .expect("collect backend rows")
12332 }
12333
12334 fn secondary_indexes(conn: &Connection) -> Vec<String> {
12335 let mut indexes = Vec::new();
12336 for table in [
12337 "files",
12338 "nodes",
12339 "refs",
12340 "file_dependencies",
12341 "edges",
12342 "dispatch_hints",
12343 "type_ref_names",
12344 "backend_file_state",
12345 "meta",
12346 ] {
12347 let sql = format!("PRAGMA index_list({table})");
12348 let mut stmt = conn.prepare(&sql).expect("prepare index list");
12349 let rows = stmt
12350 .query_map([], |row| row.get::<_, String>(1))
12351 .expect("query index list");
12352 for name in rows {
12353 let name = name.expect("index name");
12354 if name.starts_with("idx_") {
12355 indexes.push(format!("{table}:{name}"));
12356 }
12357 }
12358 }
12359 indexes.sort();
12360 indexes
12361 }
12362
12363 fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
12364 let mut values = Vec::with_capacity(len);
12365 for index in 0..len {
12366 let value = row.get_ref(index)?;
12367 values.push(match value {
12368 rusqlite::types::ValueRef::Null => "NULL".to_string(),
12369 rusqlite::types::ValueRef::Integer(value) => value.to_string(),
12370 rusqlite::types::ValueRef::Real(value) => value.to_string(),
12371 rusqlite::types::ValueRef::Text(value) => {
12372 String::from_utf8_lossy(value).into_owned()
12373 }
12374 rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
12375 });
12376 }
12377 Ok(values.join("\u{1f}"))
12378 }
12379}
12380
12381#[cfg(test)]
12382mod rust_resolution_tests {
12383 use super::*;
12384 use crate::inspect::job::CallgraphSnapshot;
12385 use std::fs;
12386 use tempfile::tempdir;
12387
12388 #[test]
12389 fn rust_function_scoped_module_alias_resolves_and_projects_live() {
12390 let dir = tempdir().expect("tempdir");
12391 let root = dir.path();
12392 write_rust_manifest(root, "scoped-alias-fixture");
12393 write_file(
12394 root,
12395 "src/lib.rs",
12396 r#"pub mod finalization_contract;
12397
12398pub fn run_alias() {
12399 use crate::finalization_contract as fc;
12400 fc::check_mason_contract();
12401}
12402"#,
12403 );
12404 write_file(
12405 root,
12406 "src/finalization_contract.rs",
12407 r#"pub fn check_mason_contract() {}
12408fn planted_dead() {}
12409"#,
12410 );
12411
12412 let (store, snapshot) = cold_build_twice(root);
12413 assert_direct_caller(
12414 &store,
12415 "src/finalization_contract.rs",
12416 "check_mason_contract",
12417 "src/lib.rs",
12418 "run_alias",
12419 );
12420 assert_projected_call(
12421 root,
12422 &snapshot,
12423 "src/finalization_contract.rs",
12424 "check_mason_contract",
12425 );
12426 assert_no_projected_call(
12427 root,
12428 &snapshot,
12429 "src/finalization_contract.rs",
12430 "planted_dead",
12431 );
12432 assert!(
12433 store
12434 .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
12435 .expect("planted dead callers")
12436 .is_empty(),
12437 "planted-dead guard should stay without callers"
12438 );
12439 }
12440
12441 #[test]
12442 fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
12443 let dir = tempdir().expect("tempdir");
12444 let root = dir.path();
12445 write_rust_manifest(root, "inline-module-fixture");
12446 write_file(
12447 root,
12448 "src/lib.rs",
12449 r#"mod work_graph { fn operations() {} }
12450mod manifest { fn operations() {} }
12451mod audit { fn operations() {} }
12452mod dispatch { fn operations() {} }
12453mod finalization { fn operations() {} }
12454
12455pub fn run_inline_operations() {
12456 work_graph::operations();
12457 manifest::operations();
12458 audit::operations();
12459 dispatch::operations();
12460 finalization::operations();
12461}
12462
12463fn planted_dead() {}
12464"#,
12465 );
12466
12467 let (store, snapshot) = cold_build_twice(root);
12468 for module in [
12469 "work_graph",
12470 "manifest",
12471 "audit",
12472 "dispatch",
12473 "finalization",
12474 ] {
12475 assert_direct_caller(
12476 &store,
12477 "src/lib.rs",
12478 &format!("{module}::operations"),
12479 "src/lib.rs",
12480 "run_inline_operations",
12481 );
12482 }
12483 assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
12484 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
12485 }
12486
12487 #[test]
12488 fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
12489 let dir = tempdir().expect("tempdir");
12490 let root = dir.path();
12491 fs::write(
12492 root.join("Cargo.toml"),
12493 "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
12494 )
12495 .expect("write workspace manifest");
12496 write_file(
12497 root,
12498 "crates/but-action/Cargo.toml",
12499 r#"[package]
12500name = "but-action"
12501version = "0.1.0"
12502edition = "2021"
12503"#,
12504 );
12505 write_file(
12506 root,
12507 "crates/but-action/src/lib.rs",
12508 "mod action;\npub use action::{list_actions};\n",
12509 );
12510 write_file(
12511 root,
12512 "crates/but-action/src/action.rs",
12513 "pub fn list_actions() {}\nfn planted_dead() {}\n",
12514 );
12515 write_file(
12516 root,
12517 "crates/app/Cargo.toml",
12518 r#"[package]
12519name = "app"
12520version = "0.1.0"
12521edition = "2021"
12522"#,
12523 );
12524 write_file(
12525 root,
12526 "crates/app/src/lib.rs",
12527 "pub fn run_actions() {\n but_action::list_actions();\n}\n",
12528 );
12529
12530 let (store, snapshot) = cold_build_twice(root);
12531 assert_direct_caller(
12532 &store,
12533 "crates/but-action/src/action.rs",
12534 "list_actions",
12535 "crates/app/src/lib.rs",
12536 "run_actions",
12537 );
12538 assert!(
12539 store
12540 .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
12541 .expect("lib reexport callers")
12542 .is_empty(),
12543 "call should target the reexported source function, not lib.rs"
12544 );
12545 assert_projected_call(
12546 root,
12547 &snapshot,
12548 "crates/but-action/src/action.rs",
12549 "list_actions",
12550 );
12551 assert_no_projected_call(
12552 root,
12553 &snapshot,
12554 "crates/but-action/src/action.rs",
12555 "planted_dead",
12556 );
12557 }
12558
12559 #[test]
12560 fn rust_generic_self_turbofish_method_dispatch_resolves() {
12561 let dir = tempdir().expect("tempdir");
12562 let root = dir.path();
12563 write_rust_manifest(root, "generic-self-fixture");
12564 write_file(
12565 root,
12566 "src/lib.rs",
12567 r#"pub struct Matcher;
12568
12569impl Matcher {
12570 pub fn run(&self) -> bool {
12571 self.fuzzy_match_optimal::<usize>("needle")
12572 }
12573
12574 fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
12575 let _ = std::marker::PhantomData::<T>;
12576 true
12577 }
12578
12579 fn planted_dead(&self) {}
12580}
12581
12582pub fn entry() -> bool {
12583 let matcher = Matcher;
12584 matcher.run()
12585}
12586"#,
12587 );
12588
12589 let (store, snapshot) = cold_build_twice(root);
12590 assert_direct_caller(
12591 &store,
12592 "src/lib.rs",
12593 "Matcher::fuzzy_match_optimal",
12594 "src/lib.rs",
12595 "Matcher::run",
12596 );
12597 assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
12598 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
12599 }
12600
12601 #[test]
12602 fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
12603 let dir = tempdir().expect("tempdir");
12604 let root = dir.path();
12605 write_rust_manifest(root, "manifest-operations-fixture");
12606 write_file(
12607 root,
12608 "src/main.rs",
12609 r#"mod dispatch;
12610use dispatch::{manifest_operations};
12611
12612fn main() {
12613 manifest_operations();
12614}
12615"#,
12616 );
12617 write_file(
12618 root,
12619 "src/dispatch.rs",
12620 r#"mod work_graph { fn operations() {} }
12621mod manifest { fn operations() {} }
12622mod audit { fn operations() {} }
12623mod descriptor { fn operations() {} }
12624mod writer { fn operations() {} }
12625
12626pub fn manifest_operations() {
12627 manifest::operations();
12628}
12629
12630pub fn work_graph_operations() {
12631 work_graph::operations();
12632}
12633
12634pub fn audit_operations() {
12635 audit::operations();
12636}
12637
12638pub fn descriptor_operations() {
12639 descriptor::operations();
12640}
12641
12642pub fn writer_operations() {
12643 writer::operations();
12644}
12645
12646fn planted_dead() {}
12647"#,
12648 );
12649
12650 let (store, snapshot) = cold_build_twice(root);
12651 assert_direct_caller(
12652 &store,
12653 "src/dispatch.rs",
12654 "manifest_operations",
12655 "src/main.rs",
12656 "main",
12657 );
12658 assert_direct_caller(
12659 &store,
12660 "src/dispatch.rs",
12661 "manifest::operations",
12662 "src/dispatch.rs",
12663 "manifest_operations",
12664 );
12665 assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
12666 assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
12667 assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
12668 }
12669
12670 fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
12671 let files = rust_files(root);
12672 let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
12673 .expect("open first store");
12674 first.cold_build(&files).expect("first cold build");
12675 let first_snapshot =
12676 project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
12677
12678 let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
12679 .expect("open second store");
12680 second.cold_build(&files).expect("second cold build");
12681 let second_snapshot =
12682 project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
12683
12684 assert_eq!(
12685 projection_rows(&first_snapshot),
12686 projection_rows(&second_snapshot),
12687 "cold-build projection should be deterministic"
12688 );
12689 (first, first_snapshot)
12690 }
12691
12692 fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
12693 let mut rows = Vec::new();
12694 for export in &snapshot.exported_symbols {
12695 rows.push(format!(
12696 "export\t{}\t{}\t{}\t{}",
12697 export.file.display(),
12698 export.symbol,
12699 export.kind,
12700 export.line
12701 ));
12702 }
12703 for call in &snapshot.outbound_calls {
12704 rows.push(format!(
12705 "call\t{}\t{}\t{}\t{}\t{}",
12706 call.caller_file.display(),
12707 call.caller_symbol,
12708 call.target,
12709 call.line,
12710 call.provenance
12711 ));
12712 }
12713 for file in &snapshot.entry_points {
12714 rows.push(format!("entry_file\t{}", file.display()));
12715 }
12716 for (file, symbols) in &snapshot.entry_point_symbols {
12717 for symbol in symbols {
12718 rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
12719 }
12720 }
12721 rows.sort();
12722 rows
12723 }
12724
12725 fn assert_direct_caller(
12726 store: &CallGraphStore,
12727 target_rel: &str,
12728 target_symbol: &str,
12729 caller_rel: &str,
12730 caller_symbol: &str,
12731 ) {
12732 let callers = store
12733 .direct_callers_of(Path::new(target_rel), target_symbol)
12734 .unwrap_or_else(|error| {
12735 panic!("direct callers for {target_rel}::{target_symbol}: {error}")
12736 });
12737 assert!(
12738 callers.iter().any(|site| {
12739 site.caller.file == caller_rel && site.caller.symbol == caller_symbol
12740 }),
12741 "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
12742 );
12743 }
12744
12745 fn assert_projected_call(
12746 root: &Path,
12747 snapshot: &CallgraphSnapshot,
12748 target_rel: &str,
12749 symbol: &str,
12750 ) {
12751 let target = projected_target(root, target_rel, symbol);
12752 assert!(
12753 snapshot.outbound_calls.iter().any(|call| {
12754 call.target == target
12755 || call.target.starts_with(&format!(
12756 "{target}{}",
12757 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
12758 ))
12759 }),
12760 "expected projected call to {target}; calls: {:#?}",
12761 snapshot.outbound_calls
12762 );
12763 }
12764
12765 fn assert_no_projected_call(
12766 root: &Path,
12767 snapshot: &CallgraphSnapshot,
12768 target_rel: &str,
12769 symbol: &str,
12770 ) {
12771 let target = projected_target(root, target_rel, symbol);
12772 assert!(
12773 snapshot.outbound_calls.iter().all(|call| {
12774 call.target != target
12775 && !call.target.starts_with(&format!(
12776 "{target}{}",
12777 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
12778 ))
12779 }),
12780 "did not expect projected call to {target}; calls: {:#?}",
12781 snapshot.outbound_calls
12782 );
12783 }
12784
12785 fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
12786 let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
12789 format!("{}::{symbol}", path.display())
12790 }
12791
12792 fn write_rust_manifest(root: &Path, name: &str) {
12793 write_file(
12794 root,
12795 "Cargo.toml",
12796 &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
12797 );
12798 }
12799
12800 fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
12801 let path = root.join(rel_path);
12802 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
12803 fs::write(&path, source).expect("write fixture file");
12804 path
12805 }
12806
12807 fn rust_files(root: &Path) -> Vec<PathBuf> {
12808 let mut files = Vec::new();
12809 collect_rust_files(root, &mut files);
12810 files.sort();
12811 files
12812 }
12813
12814 fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
12815 for entry in fs::read_dir(dir).expect("read fixture dir") {
12816 let entry = entry.expect("read fixture entry");
12817 let path = entry.path();
12818 if path.is_dir() {
12819 let name = path
12820 .file_name()
12821 .and_then(|name| name.to_str())
12822 .unwrap_or("");
12823 if !name.starts_with(".store") {
12824 collect_rust_files(&path, files);
12825 }
12826 } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
12827 files.push(path);
12828 }
12829 }
12830 }
12831}
12832
12833#[cfg(test)]
12834mod build_pool_tests {
12835 use super::build_pool_size;
12836
12837 #[test]
12838 fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
12839 let size = build_pool_size();
12840 assert!(size >= 1, "pool size must be at least 1");
12843 assert!(size <= 8, "pool size must be capped at 8, got {size}");
12844
12845 let cores = std::thread::available_parallelism()
12846 .map(|p| p.get())
12847 .unwrap_or(1);
12848 let expected = cores.div_ceil(2).clamp(1, 8);
12849 assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
12850 }
12851}
12852
12853#[cfg(test)]
12854mod reexport_resolution_tests {
12855 use super::*;
12856
12857 fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
12858 ProjectIndex {
12859 project_root: PathBuf::from("/fixture"),
12860 files: files.into_iter().collect(),
12861 caller_data: HashMap::new(),
12862 workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
12863 }
12864 }
12865
12866 fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
12867 DbFileIndex {
12868 lang: None,
12869 exports: HashSet::new(),
12870 default_export: None,
12871 export_aliases: HashMap::new(),
12872 node_by_scoped: HashMap::new(),
12873 node_by_bare: HashMap::new(),
12874 module_targets: HashMap::new(),
12875 reexports: reexport_targets
12876 .iter()
12877 .map(|target| ReexportIndex {
12878 target_file: Some((*target).to_string()),
12879 named: HashMap::new(),
12880 wildcard: true,
12881 })
12882 .collect(),
12883 }
12884 }
12885
12886 #[test]
12893 fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
12894 let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
12895 let files = names
12896 .iter()
12897 .map(|name| {
12898 let targets: Vec<&str> = names
12899 .iter()
12900 .filter(|other| *other != name)
12901 .map(String::as_str)
12902 .collect();
12903 (name.clone(), barrel_file(&targets))
12904 })
12905 .collect();
12906 let index = barrel_index(files);
12907
12908 assert_eq!(
12909 resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
12910 None
12911 );
12912 }
12913
12914 #[test]
12920 fn shallow_revisit_after_deep_capped_visit_still_resolves() {
12921 let mut leaf = barrel_file(&[]);
12922 leaf.exports.insert("deep_symbol".to_string());
12923 let mut files: Vec<(String, DbFileIndex)> = Vec::new();
12924 files.push((
12927 "src/entry.ts".to_string(),
12928 barrel_file(&["src/chain0.ts", "src/shared.ts"]),
12929 ));
12930 for i in 0..15 {
12931 let next = if i == 14 {
12932 "src/shared.ts".to_string()
12933 } else {
12934 format!("src/chain{}.ts", i + 1)
12935 };
12936 files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
12937 }
12938 files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
12939 files.push(("src/leaf.ts".to_string(), leaf));
12940 let index = barrel_index(files);
12941
12942 assert_eq!(
12943 resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
12944 Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
12945 "a shallower re-visit must not be pruned by a deeper capped visit"
12946 );
12947 }
12948
12949 #[test]
12950 fn symbol_reachable_through_reexport_cycle_still_resolves() {
12951 let mut leaf = barrel_file(&[]);
12952 leaf.exports.insert("real_symbol".to_string());
12953 let index = barrel_index(vec![
12954 (
12955 "src/a.ts".to_string(),
12956 barrel_file(&["src/b.ts", "src/a.ts"]),
12957 ),
12958 (
12959 "src/b.ts".to_string(),
12960 barrel_file(&["src/a.ts", "src/leaf.ts"]),
12961 ),
12962 ("src/leaf.ts".to_string(), leaf),
12963 ]);
12964
12965 assert_eq!(
12966 resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
12967 Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
12968 );
12969 }
12970}
12971
12972#[cfg(test)]
12973mod method_dispatch_inference_tests {
12974 use super::*;
12975 use std::fs;
12976 use tempfile::tempdir;
12977
12978 #[test]
12979 fn java_field_receiver_type_selects_declared_class_method() {
12980 let source = r#"class EntryPoint {
12981 private UserService userService;
12982
12983 void handle() {
12984 userService.find();
12985 }
12986}
12987
12988class UserService {
12989 void find() {}
12990}
12991
12992class AuditService {
12993 void find() {}
12994}
12995"#;
12996 let dir = tempdir().expect("temp dir");
12997 let root = dir.path();
12998 write_fixture(root, "src/EntryPoint.java", source);
12999 let reference = reference(
13000 "java",
13001 "src/EntryPoint.java",
13002 "EntryPoint::handle",
13003 "userService",
13004 "find",
13005 line_of(source, "userService.find()"),
13006 );
13007 let mut cache = DispatchSourceCache::new();
13008
13009 let receiver_type =
13010 infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
13011 assert_eq!(receiver_type, "UserService");
13012
13013 let candidates = vec![
13014 method_candidate("audit", "AuditService::find"),
13015 method_candidate("user", "UserService::find"),
13016 ];
13017 let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
13018 .expect("type candidate");
13019 assert_eq!(selected.scoped_name, "UserService::find");
13020
13021 let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
13022 assert!(
13023 select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
13024 );
13025 }
13026
13027 #[test]
13028 fn kotlin_property_and_local_value_types_are_inferred() {
13029 let source = r#"class Handler {
13030 private val auditService: AuditService = AuditService()
13031
13032 fun handle() {
13033 auditService.find()
13034 val userService: UserService = UserService()
13035 userService.find()
13036 val billingService = BillingService()
13037 billingService.find()
13038 }
13039}
13040
13041class UserService { fun find() {} }
13042class AuditService { fun find() {} }
13043class BillingService { fun find() {} }
13044"#;
13045 let dir = tempdir().expect("temp dir");
13046 let root = dir.path();
13047 write_fixture(root, "src/Handler.kt", source);
13048 let mut cache = DispatchSourceCache::new();
13049
13050 let audit_ref = reference(
13051 "kotlin",
13052 "src/Handler.kt",
13053 "Handler::handle",
13054 "auditService",
13055 "find",
13056 line_of(source, "auditService.find()"),
13057 );
13058 assert_eq!(
13059 infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
13060 Some("AuditService")
13061 );
13062
13063 let user_ref = reference(
13064 "kotlin",
13065 "src/Handler.kt",
13066 "Handler::handle",
13067 "userService",
13068 "find",
13069 line_of(source, "userService.find()"),
13070 );
13071 assert_eq!(
13072 infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
13073 Some("UserService")
13074 );
13075
13076 let billing_ref = reference(
13077 "kotlin",
13078 "src/Handler.kt",
13079 "Handler::handle",
13080 "billingService",
13081 "find",
13082 line_of(source, "billingService.find()"),
13083 );
13084 assert_eq!(
13085 infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
13086 Some("BillingService")
13087 );
13088 }
13089
13090 #[test]
13091 fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
13092 let source = r#"struct Foo { void run(); };
13093struct PointerFoo { void run(); };
13094struct FactoryFoo { void run(); };
13095FactoryFoo makeFactoryFoo();
13096
13097void handle() {
13098 Foo foo;
13099 foo.run();
13100 PointerFoo* pointerFoo = nullptr;
13101 pointerFoo->run();
13102 auto factoryFoo = makeFactoryFoo();
13103 factoryFoo.run();
13104}
13105"#;
13106 let dir = tempdir().expect("temp dir");
13107 let root = dir.path();
13108 write_fixture(root, "src/fixture.cpp", source);
13109 let mut cache = DispatchSourceCache::new();
13110
13111 let foo_ref = reference(
13112 "cpp",
13113 "src/fixture.cpp",
13114 "handle",
13115 "foo",
13116 "run",
13117 line_of(source, "foo.run()"),
13118 );
13119 assert_eq!(
13120 infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
13121 Some("Foo")
13122 );
13123
13124 let pointer_ref = reference(
13125 "cpp",
13126 "src/fixture.cpp",
13127 "handle",
13128 "pointerFoo",
13129 "run",
13130 line_of(source, "pointerFoo->run()"),
13131 );
13132 assert_eq!(
13133 infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
13134 Some("PointerFoo")
13135 );
13136
13137 let factory_ref = reference(
13138 "cpp",
13139 "src/fixture.cpp",
13140 "handle",
13141 "factoryFoo",
13142 "run",
13143 line_of(source, "factoryFoo.run()"),
13144 );
13145 assert_eq!(
13146 infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
13147 Some("FactoryFoo")
13148 );
13149 }
13150
13151 #[test]
13152 fn unknown_java_receiver_still_uses_name_match_fallback() {
13153 let source = r#"class EntryPoint {
13154 void handle() {
13155 service.runSpecial();
13156 }
13157}
13158
13159class OnlyService {
13160 void runSpecial() {}
13161}
13162"#;
13163 let dir = tempdir().expect("temp dir");
13164 let root = dir.path();
13165 write_fixture(root, "src/EntryPoint.java", source);
13166 let reference = reference(
13167 "java",
13168 "src/EntryPoint.java",
13169 "EntryPoint::handle",
13170 "service",
13171 "runSpecial",
13172 line_of(source, "service.runSpecial()"),
13173 );
13174 let mut cache = DispatchSourceCache::new();
13175
13176 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
13177 let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
13178 let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
13179 assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
13180 }
13181
13182 fn reference(
13183 lang: &str,
13184 caller_file: &str,
13185 caller_symbol: &str,
13186 receiver: &str,
13187 method_name: &str,
13188 line: u32,
13189 ) -> NameMatchRef {
13190 NameMatchRef {
13191 ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
13192 caller_node: format!("{caller_symbol}:node"),
13193 caller_file: caller_file.to_string(),
13194 caller_symbol: caller_symbol.to_string(),
13195 caller_signature: None,
13196 receiver: receiver.to_string(),
13197 method_name: method_name.to_string(),
13198 colon_dispatch: false,
13199 line,
13200 lang: lang.to_string(),
13201 }
13202 }
13203
13204 fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
13205 NameMatchCandidate {
13206 node_id: node_id.to_string(),
13207 file_path: "src/targets.fixture".to_string(),
13208 scoped_name: scoped_name.to_string(),
13209 kind: "method".to_string(),
13210 }
13211 }
13212
13213 fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
13214 let path = root.join(rel_path);
13215 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
13216 fs::write(path, source).expect("write fixture");
13217 }
13218
13219 fn line_of(source: &str, needle: &str) -> u32 {
13220 source
13221 .lines()
13222 .position(|line| line.contains(needle))
13223 .map(|index| index as u32 + 1)
13224 .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
13225 }
13226}