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
287#[derive(Clone)]
291pub(crate) struct CallgraphRefreshState {
292 store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
293 heavy_root_work_allowed: Arc<AtomicBool>,
294}
295
296impl CallgraphRefreshState {
297 pub(crate) fn new(
298 store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
299 heavy_root_work_allowed: Arc<AtomicBool>,
300 ) -> Self {
301 Self {
302 store,
303 heavy_root_work_allowed,
304 }
305 }
306
307 fn installed_store_snapshot(&self) -> Option<Arc<ReadonlyCallGraphStore>> {
308 self.store
309 .read()
310 .unwrap_or_else(std::sync::PoisonError::into_inner)
311 .as_ref()
312 .map(Arc::clone)
313 }
314}
315
316type WorkspaceCratePrefixes = HashMap<String, String>;
317
318#[derive(Clone, Debug, Default)]
319struct WorkspaceCratePrefixCache(Arc<OnceLock<WorkspaceCratePrefixes>>);
320
321const REFRESH_WORKSPACE_CACHE_ROOT_CAP: usize = 128;
322
323pub(crate) fn invalidates_workspace_crate_prefix_cache(path: &Path) -> bool {
324 path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")
325}
326
327#[derive(Clone, Debug, Hash, PartialEq, Eq)]
328struct RefreshRoot {
329 callgraph_dir: PathBuf,
330 project_root: PathBuf,
331}
332
333#[derive(Clone)]
334pub(crate) struct CallgraphRefreshTicket {
335 lifecycle: SubcLifecycleAdmission,
336 generation_flag: Arc<std::sync::atomic::AtomicU64>,
337 expected_generation: u64,
338 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
339 expected_publish_epoch: u64,
340}
341
342impl CallgraphRefreshTicket {
343 pub(crate) fn new(
344 lifecycle: SubcLifecycleAdmission,
345 generation_flag: Arc<std::sync::atomic::AtomicU64>,
346 expected_generation: u64,
347 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
348 expected_publish_epoch: u64,
349 ) -> Self {
350 Self {
351 lifecycle,
352 generation_flag,
353 expected_generation,
354 publish_epoch,
355 expected_publish_epoch,
356 }
357 }
358
359 fn is_current(&self) -> bool {
360 self.lifecycle
361 .is_current(self.generation_flag.as_ref(), self.expected_generation)
362 && self.publish_epoch.current() == self.expected_publish_epoch
363 }
364}
365
366#[derive(Clone)]
367struct RefreshBatch {
368 root: RefreshRoot,
369 paths: BTreeSet<PathBuf>,
370 pending_sinks: Vec<PendingCallGraphStorePaths>,
371 refresh_states: Vec<CallgraphRefreshState>,
372 ticket: Option<CallgraphRefreshTicket>,
373}
374
375impl RefreshBatch {
376 fn defer(&self) {
377 for sink in &self.pending_sinks {
378 sink.lock().extend(self.paths.iter().cloned());
379 }
380 }
381
382 fn defer_after_open_failure(&self) {
383 self.defer();
384 if self
385 .ticket
386 .as_ref()
387 .is_some_and(|ticket| !ticket.is_current())
388 || !self
389 .refresh_states
390 .iter()
391 .any(|state| state.heavy_root_work_allowed.load(AtomicOrdering::SeqCst))
392 {
393 return;
394 }
395
396 let ready_store_installed = self.refresh_states.iter().any(|state| {
397 let store = state.installed_store_snapshot();
398 store.is_some_and(|store| {
399 store.project_root() == self.root.project_root
400 && !store.is_legacy_fallback()
401 && store.is_current()
402 })
403 });
404 if !ready_store_installed {
405 return;
406 }
407
408 for sink in &self.pending_sinks {
412 let paths = {
413 let mut pending = sink.lock();
414 self.paths
415 .iter()
416 .filter(|path| pending.remove(*path))
417 .cloned()
418 .collect::<Vec<_>>()
419 };
420 if paths.is_empty() {
421 continue;
422 }
423 let _ = enqueue_callgraph_store_refresh_inner(
424 self.root.callgraph_dir.clone(),
425 self.root.project_root.clone(),
426 paths,
427 Arc::clone(sink),
428 self.refresh_states.clone(),
429 self.ticket.clone(),
430 );
431 }
432 }
433
434 fn merge(
435 &mut self,
436 paths: impl IntoIterator<Item = PathBuf>,
437 sink: PendingCallGraphStorePaths,
438 refresh_states: Vec<CallgraphRefreshState>,
439 ticket: Option<CallgraphRefreshTicket>,
440 ) {
441 self.paths.extend(paths);
442 if ticket.is_some() {
443 self.ticket = ticket;
444 }
445 if !self
446 .pending_sinks
447 .iter()
448 .any(|existing| Arc::ptr_eq(existing, &sink))
449 {
450 self.pending_sinks.push(sink);
451 }
452 for refresh_state in refresh_states {
453 if !self.refresh_states.iter().any(|existing| {
454 Arc::ptr_eq(&existing.store, &refresh_state.store)
455 && Arc::ptr_eq(
456 &existing.heavy_root_work_allowed,
457 &refresh_state.heavy_root_work_allowed,
458 )
459 }) {
460 self.refresh_states.push(refresh_state);
461 }
462 }
463 }
464}
465
466#[derive(Default)]
467struct RefreshQueue {
468 order: VecDeque<RefreshRoot>,
469 queued: HashMap<RefreshRoot, RefreshBatch>,
470 active: Option<RefreshBatch>,
471 shutdown_requested: bool,
472}
473
474struct RefreshWorkerShared {
475 queue: Mutex<RefreshQueue>,
476 wake: Condvar,
477}
478
479struct RefreshWorker {
480 shared: Arc<RefreshWorkerShared>,
481 thread: Mutex<Option<JoinHandle<()>>>,
482}
483
484struct RefreshWorkerWatchdog {
485 first_path: PathBuf,
486 batch_len: usize,
487 started: Instant,
488}
489
490impl RefreshWorkerWatchdog {
491 fn start(paths: &[PathBuf]) -> Self {
492 Self {
493 first_path: paths
494 .first()
495 .expect("non-empty callgraph refresh batch has a first path")
496 .clone(),
497 batch_len: paths.len(),
498 started: Instant::now(),
499 }
500 }
501}
502
503impl Drop for RefreshWorkerWatchdog {
504 fn drop(&mut self) {
505 let elapsed = self.started.elapsed();
506 if elapsed < REFRESH_WORKER_WARN_AFTER {
507 return;
508 }
509 let path = if self.batch_len == 1 {
510 self.first_path.display().to_string()
511 } else {
512 format!(
513 "{} (+{} paths)",
514 self.first_path.display(),
515 self.batch_len - 1
516 )
517 };
518 log::warn!(
519 "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
520 path,
521 elapsed.as_millis()
522 );
523 if elapsed >= REFRESH_WORKER_FINAL_AFTER {
524 log::warn!(
525 "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
526 path,
527 elapsed.as_millis()
528 );
529 }
530 }
531}
532
533impl RefreshWorker {
534 fn spawn() -> Arc<Self> {
535 let shared = Arc::new(RefreshWorkerShared {
536 queue: Mutex::new(RefreshQueue::default()),
537 wake: Condvar::new(),
538 });
539 let thread_shared = Arc::clone(&shared);
540 let thread = std::thread::Builder::new()
541 .name("aft-callgraph-refresh".to_string())
542 .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
543 .expect("failed to spawn callgraph refresh worker");
544 Arc::new(Self {
545 shared,
546 thread: Mutex::new(Some(thread)),
547 })
548 }
549
550 fn enqueue(
551 &self,
552 root: RefreshRoot,
553 paths: Vec<PathBuf>,
554 pending_sink: PendingCallGraphStorePaths,
555 refresh_states: Vec<CallgraphRefreshState>,
556 ticket: Option<CallgraphRefreshTicket>,
557 ) -> bool {
558 let mut queue = self
559 .shared
560 .queue
561 .lock()
562 .expect("callgraph refresh queue mutex poisoned");
563 if queue.shutdown_requested {
564 pending_sink.lock().extend(paths);
565 return false;
566 }
567 if let Some(batch) = queue.queued.get_mut(&root) {
568 batch.merge(paths, pending_sink, refresh_states, ticket);
569 } else {
570 queue.order.push_back(root.clone());
571 queue.queued.insert(
572 root.clone(),
573 RefreshBatch {
574 root,
575 paths: paths.into_iter().collect(),
576 pending_sinks: vec![pending_sink],
577 refresh_states,
578 ticket,
579 },
580 );
581 }
582 self.shared.wake.notify_one();
583 true
584 }
585
586 fn shutdown_with_budget(&self, budget: Duration) -> bool {
587 let deadline = Instant::now() + budget;
588 let mut queue = self
589 .shared
590 .queue
591 .lock()
592 .expect("callgraph refresh queue mutex poisoned");
593 queue.shutdown_requested = true;
594 self.shared.wake.notify_one();
595 while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
596 let remaining = deadline.saturating_duration_since(Instant::now());
597 let (next, _) = self
598 .shared
599 .wake
600 .wait_timeout(queue, remaining)
601 .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
602 queue = next;
603 }
604 let drained = queue.active.is_none() && queue.order.is_empty();
605 if !drained {
606 if let Some(active) = queue.active.as_ref() {
607 active.defer();
608 }
609 for batch in queue.queued.values() {
610 batch.defer();
611 }
612 queue.order.clear();
613 queue.queued.clear();
614 }
615 drop(queue);
616
617 if drained {
618 if let Some(thread) = self
619 .thread
620 .lock()
621 .expect("callgraph refresh worker thread mutex poisoned")
622 .take()
623 {
624 let _ = thread.join();
625 }
626 }
627 drained
628 }
629}
630
631static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
632
633pub fn enqueue_callgraph_store_refresh(
634 callgraph_dir: PathBuf,
635 project_root: PathBuf,
636 paths: Vec<PathBuf>,
637 pending_sink: PendingCallGraphStorePaths,
638) -> bool {
639 enqueue_callgraph_store_refresh_inner(
640 callgraph_dir,
641 project_root,
642 paths,
643 pending_sink,
644 Vec::new(),
645 None,
646 )
647}
648
649#[cfg(test)]
650pub(crate) fn enqueue_callgraph_store_refresh_fenced(
651 callgraph_dir: PathBuf,
652 project_root: PathBuf,
653 paths: Vec<PathBuf>,
654 pending_sink: PendingCallGraphStorePaths,
655 ticket: CallgraphRefreshTicket,
656) -> bool {
657 enqueue_callgraph_store_refresh_inner(
658 callgraph_dir,
659 project_root,
660 paths,
661 pending_sink,
662 Vec::new(),
663 Some(ticket),
664 )
665}
666
667pub(crate) fn enqueue_callgraph_store_refresh_fenced_with_state(
668 callgraph_dir: PathBuf,
669 project_root: PathBuf,
670 paths: Vec<PathBuf>,
671 pending_sink: PendingCallGraphStorePaths,
672 refresh_state: CallgraphRefreshState,
673 ticket: CallgraphRefreshTicket,
674) -> bool {
675 enqueue_callgraph_store_refresh_inner(
676 callgraph_dir,
677 project_root,
678 paths,
679 pending_sink,
680 vec![refresh_state],
681 Some(ticket),
682 )
683}
684
685fn enqueue_callgraph_store_refresh_inner(
686 callgraph_dir: PathBuf,
687 project_root: PathBuf,
688 paths: Vec<PathBuf>,
689 pending_sink: PendingCallGraphStorePaths,
690 refresh_states: Vec<CallgraphRefreshState>,
691 ticket: Option<CallgraphRefreshTicket>,
692) -> bool {
693 if paths.is_empty() {
694 return true;
695 }
696 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
697 let worker = {
698 let mut worker = slot
699 .lock()
700 .expect("callgraph refresh worker mutex poisoned");
701 Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
702 };
703 worker.enqueue(
704 RefreshRoot {
705 callgraph_dir,
706 project_root,
707 },
708 paths,
709 pending_sink,
710 refresh_states,
711 ticket,
712 )
713}
714
715pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
716 flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
717}
718
719#[doc(hidden)]
720pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
721 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
722 let worker = slot
723 .lock()
724 .expect("callgraph refresh worker mutex poisoned")
725 .clone();
726 let Some(worker) = worker else {
727 return true;
728 };
729 let drained = worker.shutdown_with_budget(budget);
730 if drained {
731 let mut current = slot
732 .lock()
733 .expect("callgraph refresh worker mutex poisoned");
734 if current
735 .as_ref()
736 .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
737 {
738 *current = None;
739 }
740 }
741 drained
742}
743
744fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
745 let mut workspace_crate_prefixes = HashMap::new();
748 loop {
749 let batch = {
750 let mut queue = shared
751 .queue
752 .lock()
753 .expect("callgraph refresh queue mutex poisoned");
754 loop {
755 if let Some(root) = queue.order.pop_front() {
756 let batch = queue
757 .queued
758 .remove(&root)
759 .expect("queued callgraph refresh root has a batch");
760 queue.active = Some(batch.clone());
761 break batch;
762 }
763 if queue.shutdown_requested {
764 return;
765 }
766 queue = shared
767 .wake
768 .wait(queue)
769 .expect("callgraph refresh queue mutex poisoned while waiting");
770 }
771 };
772
773 process_callgraph_refresh_batch(&batch, &mut workspace_crate_prefixes);
774
775 let mut queue = shared
776 .queue
777 .lock()
778 .expect("callgraph refresh queue mutex poisoned");
779 queue.active = None;
780 shared.wake.notify_all();
781 }
782}
783
784fn process_callgraph_refresh_batch(
785 batch: &RefreshBatch,
786 workspace_crate_prefixes: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
787) {
788 if batch
792 .paths
793 .iter()
794 .any(|path| invalidates_workspace_crate_prefix_cache(path))
795 {
796 workspace_crate_prefixes.remove(&batch.root);
797 }
798
799 let paths = batch
800 .paths
801 .iter()
802 .filter(|path| crate::parser::detect_language(path).is_some())
803 .cloned()
804 .collect::<Vec<_>>();
805 if paths.is_empty() {
806 return;
807 }
808 note_refresh_worker_batch_for_test(&batch.root.project_root);
809 if batch
810 .ticket
811 .as_ref()
812 .is_some_and(|ticket| !ticket.is_current())
813 {
814 batch.defer();
817 return;
818 }
819 let workspace_crate_prefix_cache =
820 workspace_crate_prefix_cache_for_root(workspace_crate_prefixes, &batch.root);
821 let _watchdog = RefreshWorkerWatchdog::start(&paths);
822 let test_seam = refresh_worker_test_seam(&batch.root.project_root);
823 note_refresh_worker_call_for_test(&batch.root.project_root);
824 let opened = if test_seam.fail_open {
825 Ok(None)
826 } else {
827 CallGraphStore::open_ready(
828 batch.root.callgraph_dir.clone(),
829 batch.root.project_root.clone(),
830 )
831 };
832 if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
833 let _ = gate.held_tx.send(());
836 let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
837 }
838 let store = match opened {
839 Ok(Some(store)) => store,
840 Ok(None) => {
841 batch.defer_after_open_failure();
842 return;
843 }
844 Err(error) => {
845 batch.defer_after_open_failure();
846 crate::slog_warn!(
847 "callgraph store writer open failed during refresh; deferred paths: {}",
848 error
849 );
850 return;
851 }
852 };
853 if !test_seam.delay.is_zero() {
854 std::thread::sleep(test_seam.delay);
855 }
856 if batch
857 .ticket
858 .as_ref()
859 .is_some_and(|ticket| !ticket.is_current())
860 {
861 batch.defer();
864 return;
865 }
866 let refresh_result = if test_seam.fail_refresh {
867 Err(CallGraphStoreError::Unavailable(
868 "injected refresh worker failure".to_string(),
869 ))
870 } else if let Some(ticket) = &batch.ticket {
871 with_publish_epoch(
872 ticket.publish_epoch.clone(),
873 ticket.expected_publish_epoch,
874 || {
875 with_refresh_commit_admission(
876 ticket.lifecycle.clone(),
877 Arc::clone(&ticket.generation_flag),
878 ticket.expected_generation,
879 || {
880 store
881 .refresh_files_with_workspace_crate_prefix_cache(
882 &paths,
883 workspace_crate_prefix_cache.clone(),
884 )
885 .map(|_| ())
886 },
887 )
888 },
889 )
890 } else {
891 store
892 .refresh_files_with_workspace_crate_prefix_cache(
893 &paths,
894 workspace_crate_prefix_cache.clone(),
895 )
896 .map(|_| ())
897 };
898 if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
899 batch.defer();
903 return;
904 }
905 if let Err(error) = refresh_result {
906 crate::slog_warn!("callgraph store refresh failed: {}", error);
907 match store.mark_files_stale(&paths) {
908 Ok(marked) => {
909 note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
910 crate::slog_warn!(
911 "marked {} callgraph store file(s) stale after refresh failure",
912 marked.len()
913 );
914 }
915 Err(mark_error) => crate::slog_warn!(
916 "failed to mark callgraph store files stale after refresh failure: {}",
917 mark_error
918 ),
919 }
920 } else {
921 crate::logging::note_callgraph_invalidations(paths.len());
922 }
923}
924
925fn workspace_crate_prefix_cache_for_root(
926 caches: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
927 root: &RefreshRoot,
928) -> WorkspaceCratePrefixCache {
929 if !caches.contains_key(root) && caches.len() >= REFRESH_WORKSPACE_CACHE_ROOT_CAP {
930 if let Some(evicted) = caches.keys().next().cloned() {
932 caches.remove(&evicted);
933 }
934 }
935 caches.entry(root.clone()).or_default().clone()
936}
937
938#[derive(Clone, Copy, Default)]
939struct RefreshWorkerTestSeam {
940 delay: Duration,
941 fail_refresh: bool,
942 fail_open: bool,
943 refresh_calls: usize,
944 worker_calls: usize,
945 stale_marks: usize,
946}
947
948static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
949 OnceLock::new();
950
951struct RefreshWorkerTestGate {
952 held_tx: crossbeam_channel::Sender<()>,
953 release_rx: crossbeam_channel::Receiver<()>,
954}
955
956static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
957 OnceLock::new();
958
959#[doc(hidden)]
960pub fn install_callgraph_refresh_worker_test_gate(
961 project_root: PathBuf,
962) -> (
963 crossbeam_channel::Receiver<()>,
964 crossbeam_channel::Sender<()>,
965) {
966 let (held_tx, held_rx) = crossbeam_channel::bounded(1);
967 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
968 REFRESH_WORKER_TEST_GATES
969 .get_or_init(|| Mutex::new(HashMap::new()))
970 .lock()
971 .expect("callgraph refresh test gate mutex poisoned")
972 .insert(
973 project_root,
974 RefreshWorkerTestGate {
975 held_tx,
976 release_rx,
977 },
978 );
979 (held_rx, release_tx)
980}
981
982fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
983 REFRESH_WORKER_TEST_GATES
984 .get_or_init(|| Mutex::new(HashMap::new()))
985 .lock()
986 .expect("callgraph refresh test gate mutex poisoned")
987 .remove(project_root)
988}
989
990fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
991 let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
992 return RefreshWorkerTestSeam::default();
993 };
994 seams
995 .lock()
996 .expect("callgraph refresh test seam mutex poisoned")
997 .get(project_root)
998 .copied()
999 .unwrap_or_default()
1000}
1001
1002fn note_refresh_worker_batch_for_test(project_root: &Path) {
1003 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1004 if let Some(seam) = seams
1005 .lock()
1006 .expect("callgraph refresh test seam mutex poisoned")
1007 .get_mut(project_root)
1008 {
1009 seam.worker_calls += 1;
1010 }
1011 }
1012}
1013
1014fn note_refresh_worker_call_for_test(project_root: &Path) {
1015 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1016 if let Some(seam) = seams
1017 .lock()
1018 .expect("callgraph refresh test seam mutex poisoned")
1019 .get_mut(project_root)
1020 {
1021 seam.refresh_calls += 1;
1022 }
1023 }
1024}
1025
1026fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
1027 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1028 if let Some(seam) = seams
1029 .lock()
1030 .expect("callgraph refresh test seam mutex poisoned")
1031 .get_mut(project_root)
1032 {
1033 seam.stale_marks += 1;
1034 }
1035 }
1036}
1037
1038#[doc(hidden)]
1039pub fn set_callgraph_refresh_worker_test_seam(
1040 project_root: PathBuf,
1041 delay: Duration,
1042 fail_refresh: bool,
1043) {
1044 REFRESH_WORKER_TEST_SEAMS
1045 .get_or_init(|| Mutex::new(HashMap::new()))
1046 .lock()
1047 .expect("callgraph refresh test seam mutex poisoned")
1048 .insert(
1049 project_root,
1050 RefreshWorkerTestSeam {
1051 delay,
1052 fail_refresh,
1053 ..RefreshWorkerTestSeam::default()
1054 },
1055 );
1056}
1057
1058#[doc(hidden)]
1059pub fn set_callgraph_refresh_worker_test_open_failure(project_root: PathBuf, enabled: bool) {
1060 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1061 if let Some(seam) = seams
1062 .lock()
1063 .expect("callgraph refresh test seam mutex poisoned")
1064 .get_mut(&project_root)
1065 {
1066 seam.fail_open = enabled;
1067 }
1068 }
1069}
1070
1071#[doc(hidden)]
1072pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
1073 let seam = refresh_worker_test_seam(project_root);
1074 (seam.refresh_calls, seam.stale_marks)
1075}
1076
1077#[doc(hidden)]
1078pub fn callgraph_refresh_worker_test_worker_calls(project_root: &Path) -> usize {
1079 refresh_worker_test_seam(project_root).worker_calls
1080}
1081
1082#[doc(hidden)]
1083pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
1084 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1085 seams
1086 .lock()
1087 .expect("callgraph refresh test seam mutex poisoned")
1088 .remove(project_root);
1089 }
1090}
1091
1092#[derive(Debug)]
1093pub struct CallGraphStore {
1094 project_root: PathBuf,
1095 project_key: String,
1096 sqlite_path: PathBuf,
1100 publication_dir: PathBuf,
1104 legacy_fallback: bool,
1108 generation: Option<String>,
1113 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1114 read_marker: Option<crate::root_cache::ReadMarker>,
1115 database_ready: AtomicBool,
1118 conn: Mutex<Connection>,
1119}
1120
1121#[derive(Debug)]
1122pub struct ReadonlyCallGraphStore {
1123 inner: CallGraphStore,
1124}
1125
1126pub trait CallGraphRead {
1127 fn project_root(&self) -> &Path;
1128 fn project_key(&self) -> &str;
1129 fn sqlite_path(&self) -> &Path;
1130 fn is_current(&self) -> bool;
1131 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
1132 fn indexed_file_count(&self) -> Result<usize>;
1133 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
1134 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
1135 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
1136 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
1137 fn direct_caller_counts_of(
1138 &self,
1139 targets: &[(String, String)],
1140 ) -> Result<HashMap<(String, String), usize>>;
1141 fn outgoing_calls_for_symbols(
1142 &self,
1143 sources: &[(String, String)],
1144 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
1145 fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
1146 -> Result<StoreCallersResult>;
1147 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
1148 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1149 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1150 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
1151 fn call_tree(
1152 &self,
1153 file_rel: &Path,
1154 symbol: &str,
1155 depth: usize,
1156 ) -> Result<callgraph::CallTreeNode>;
1157 fn trace_to(
1158 &self,
1159 file_rel: &Path,
1160 symbol: &str,
1161 max_depth: usize,
1162 ) -> Result<callgraph::TraceToResult>;
1163 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
1164 fn trace_to_symbol(
1165 &self,
1166 file_rel: &Path,
1167 symbol: &str,
1168 to_symbol: &str,
1169 to_file: Option<&Path>,
1170 max_depth: usize,
1171 ) -> Result<callgraph::TraceToSymbolResult>;
1172}
1173
1174#[derive(Debug, Clone, PartialEq, Eq)]
1175enum OpenRootRepair {
1176 None,
1177 ReRooted,
1178 NeedsRebuild {
1179 previous_roots: Vec<String>,
1180 current_root: String,
1181 reason: String,
1182 },
1183}
1184
1185struct OpenedStore {
1186 store: CallGraphStore,
1187 root_repair: OpenRootRepair,
1188}
1189
1190#[derive(Clone, Debug)]
1191struct LegacyCallgraphPartition {
1192 harness: String,
1193 dir: PathBuf,
1194 key: String,
1195 bytes: u64,
1196 freshness: Option<SystemTime>,
1197}
1198
1199#[derive(Clone, Debug)]
1200struct LegacyCallgraphTarget {
1201 partition: LegacyCallgraphPartition,
1202 sqlite_path: PathBuf,
1203 generation: Option<String>,
1204 source_bytes: u64,
1205 source_blake3: String,
1206}
1207
1208#[derive(Clone, Debug)]
1209struct SourceFingerprint {
1210 bytes: u64,
1211 blake3: String,
1212}
1213
1214#[derive(Clone, Debug)]
1215struct PublishedLegacyMigration {
1216 generation: String,
1217 migrated_bytes: u64,
1218}
1219
1220#[derive(Debug, Clone)]
1221pub struct ColdBuildStats {
1222 pub files: usize,
1223 pub nodes: usize,
1224 pub refs: usize,
1225 pub edges: usize,
1226 pub failed_files: Vec<String>,
1227 pub elapsed_ms: u128,
1228}
1229
1230#[derive(Debug, Clone)]
1231pub struct IncrementalStats {
1232 pub changed_files: Vec<String>,
1233 pub surface_changed: Vec<String>,
1234 pub deleted_files: Vec<String>,
1235 pub dependency_selected_refs: usize,
1236 pub refreshed_own_files: usize,
1237}
1238
1239#[doc(hidden)]
1241#[derive(Debug, Clone, Default, PartialEq, Eq)]
1242pub struct RefreshFilesProfile {
1243 pub parse: Duration,
1244 pub dependency_selection: Duration,
1245 pub row_deletes: Duration,
1246 pub row_inserts: Duration,
1247 pub dependent_parse: Duration,
1248 pub index_load: Duration,
1249 pub ref_resolution: Duration,
1250 pub method_dispatch: Duration,
1251 pub commit: Duration,
1252 pub total: Duration,
1253}
1254
1255impl RefreshFilesProfile {
1256 pub fn report(&self) -> String {
1257 format!(
1258 "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",
1259 self.parse.as_millis(),
1260 self.dependency_selection.as_millis(),
1261 self.row_deletes.as_millis(),
1262 self.row_inserts.as_millis(),
1263 self.dependent_parse.as_millis(),
1264 self.index_load.as_millis(),
1265 self.ref_resolution.as_millis(),
1266 self.method_dispatch.as_millis(),
1267 self.commit.as_millis(),
1268 self.total.as_millis(),
1269 )
1270 }
1271}
1272
1273#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1274pub struct StoredEdge {
1275 pub source_file: String,
1276 pub source_symbol: String,
1277 pub target_file: String,
1278 pub target_symbol: String,
1279 pub kind: String,
1280 pub line: u32,
1281}
1282
1283#[derive(Debug, Clone, PartialEq, Eq)]
1284pub struct StoreNode {
1285 node_id: String,
1286 pub file: String,
1287 pub symbol: String,
1288 pub name: String,
1289 pub kind: String,
1290 pub line: u32,
1291 pub end_line: u32,
1292 pub signature: Option<String>,
1293 pub exported: bool,
1294 pub is_entry_point: bool,
1295 pub lang: LangId,
1296}
1297
1298#[cfg(test)]
1299impl StoreNode {
1300 pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
1301 Self {
1302 node_id: format!("{file}:{symbol}"),
1303 file: file.to_string(),
1304 symbol: symbol.to_string(),
1305 name: symbol.to_string(),
1306 kind: "function".to_string(),
1307 line: 1,
1308 end_line: 1,
1309 signature: None,
1310 exported: is_entry_point,
1311 is_entry_point,
1312 lang: LangId::TypeScript,
1313 }
1314 }
1315}
1316
1317#[derive(Debug, Clone, PartialEq, Eq)]
1318pub struct StoreCallSite {
1319 pub caller: StoreNode,
1320 pub target_file: String,
1321 pub target_symbol: String,
1322 pub target: Option<StoreNode>,
1323 pub line: u32,
1324 pub byte_start: usize,
1325 pub byte_end: usize,
1326 pub resolved: bool,
1327 pub provenance: String,
1328}
1329
1330impl StoreCallSite {
1331 pub fn approximate(&self) -> bool {
1332 self.provenance == PROVENANCE_NAME_MATCH
1333 }
1334
1335 pub fn resolved_by(&self) -> &str {
1336 &self.provenance
1337 }
1338
1339 pub fn supplemental_resolution(&self) -> Option<&str> {
1340 match self.provenance.as_str() {
1341 PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
1342 _ => None,
1343 }
1344 }
1345}
1346
1347#[derive(Debug, Clone, PartialEq, Eq)]
1348pub struct StoreUnresolvedCall {
1349 pub caller: StoreNode,
1350 pub symbol: String,
1351 pub full_ref: Option<String>,
1352 pub line: u32,
1353 pub byte_start: usize,
1354 pub byte_end: usize,
1355}
1356
1357#[derive(Debug, Clone, PartialEq, Eq)]
1358pub struct StoreCallersResult {
1359 pub target: StoreNode,
1360 pub callers: Vec<StoreCallSite>,
1361 pub scanned_files: usize,
1362 pub depth_limited: bool,
1363 pub truncated: usize,
1364}
1365
1366#[derive(Debug, Clone, PartialEq, Eq)]
1367pub struct StoreImpactCaller {
1368 pub site: StoreCallSite,
1369 pub signature: Option<String>,
1370 pub is_entry_point: bool,
1371 pub call_expression: Option<String>,
1372 pub parameters: Vec<String>,
1373}
1374
1375#[derive(Debug, Clone, PartialEq, Eq)]
1376pub struct StoreImpactResult {
1377 pub target: StoreNode,
1378 pub parameters: Vec<String>,
1379 pub callers: Vec<StoreImpactCaller>,
1380 pub depth_limited: bool,
1381 pub truncated: usize,
1382}
1383
1384#[derive(Debug, Clone)]
1385struct ExtractFailure {
1386 rel_path: String,
1387 freshness: Option<FileFreshness>,
1388}
1389
1390#[derive(Debug, Clone)]
1391struct BuildExtractsResult {
1392 extracts: Vec<FileExtract>,
1393 failures: Vec<ExtractFailure>,
1394}
1395
1396#[derive(Debug, Clone)]
1397enum StoreForwardCall {
1398 Resolved(StoreCallSite),
1399 Unresolved(StoreUnresolvedCall),
1400}
1401
1402impl StoreForwardCall {
1403 fn byte_start(&self) -> usize {
1404 match self {
1405 Self::Resolved(site) => site.byte_start,
1406 Self::Unresolved(call) => call.byte_start,
1407 }
1408 }
1409
1410 fn line(&self) -> u32 {
1411 match self {
1412 Self::Resolved(site) => site.line,
1413 Self::Unresolved(call) => call.line,
1414 }
1415 }
1416}
1417
1418#[derive(Debug, Clone)]
1419struct FileExtract {
1420 rel_path: String,
1421 freshness: FileFreshness,
1422 lang: LangId,
1423 data: FileCallData,
1424 nodes: Vec<NodeRecord>,
1425 raw_refs: Vec<RawRef>,
1426 dispatch_hints: Vec<DispatchHint>,
1427 surface_fingerprint: String,
1428}
1429
1430#[derive(Debug, Clone)]
1431struct NodeRecord {
1432 id: String,
1433 file_path: String,
1434 name: String,
1435 scoped_name: String,
1436 kind: String,
1437 range: Range,
1438 range_ordinal: u32,
1439 signature: Option<String>,
1440 exported: bool,
1441 is_default_export: bool,
1442 is_type_like: bool,
1443 is_callgraph_entry_point: bool,
1444}
1445
1446#[derive(Debug, Clone)]
1447struct RawRef {
1448 ref_id: String,
1449 caller_node: Option<String>,
1450 caller_symbol: Option<String>,
1451 caller_file: String,
1452 kind: String,
1453 short_name: Option<String>,
1454 full_ref: Option<String>,
1455 module_path: Option<String>,
1456 import_kind: Option<String>,
1457 local_name: Option<String>,
1458 requested_name: Option<String>,
1459 namespace_alias: Option<String>,
1460 wildcard: bool,
1461 line: u32,
1462 byte_start: usize,
1463 byte_end: usize,
1464 dependencies: BTreeSet<String>,
1465}
1466
1467#[derive(Debug, Clone)]
1468struct ResolvedRef {
1469 raw: RawRef,
1470 status: String,
1471 target_node: Option<String>,
1472 target_file: Option<String>,
1473 target_symbol: Option<String>,
1474 dependencies: BTreeSet<String>,
1475 edge: Option<EdgeRecord>,
1476}
1477
1478#[derive(Debug, Clone)]
1479struct EdgeRecord {
1480 edge_id: String,
1481 source_node: String,
1482 target_node: Option<String>,
1483 target_file: String,
1484 target_symbol: String,
1485 kind: String,
1486 line: u32,
1487}
1488
1489#[derive(Debug, Clone)]
1490struct DispatchHint {
1491 id: String,
1492 method_name: String,
1493 caller_node: String,
1494 file: String,
1495 line: u32,
1496 byte_start: usize,
1497 byte_end: usize,
1498}
1499
1500#[derive(Debug, Clone)]
1501struct NameMatchRef {
1502 ref_id: String,
1503 caller_node: String,
1504 caller_file: String,
1505 caller_symbol: String,
1506 caller_signature: Option<String>,
1507 receiver: String,
1508 method_name: String,
1509 colon_dispatch: bool,
1510 line: u32,
1511 lang: String,
1512}
1513
1514#[derive(Debug, Clone)]
1515struct NameMatchCandidate {
1516 node_id: String,
1517 file_path: String,
1518 scoped_name: String,
1519 kind: String,
1520}
1521
1522#[derive(Debug, Clone)]
1523struct FileRow {
1524 surface_fingerprint: String,
1525 freshness: FileFreshness,
1526}
1527
1528#[derive(Debug, Clone)]
1529struct DbFileIndex {
1530 lang: Option<LangId>,
1531 exports: HashSet<String>,
1532 default_export: Option<String>,
1533 export_aliases: HashMap<String, String>,
1534 node_by_scoped: HashMap<String, String>,
1535 node_by_bare: HashMap<String, String>,
1536 module_targets: HashMap<String, Option<String>>,
1537 reexports: Vec<ReexportIndex>,
1538}
1539
1540#[derive(Debug, Clone)]
1541struct ReexportIndex {
1542 target_file: Option<String>,
1543 named: HashMap<String, String>,
1544 wildcard: bool,
1545}
1546
1547#[derive(Debug, Clone)]
1548struct ProjectIndex<'a> {
1549 project_root: PathBuf,
1550 files: HashMap<String, DbFileIndex>,
1551 caller_data: HashMap<String, &'a FileCallData>,
1552 workspace_crate_prefixes: WorkspaceCratePrefixCache,
1557}
1558
1559impl ProjectIndex<'_> {
1560 fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
1563 self.workspace_crate_prefixes
1564 .0
1565 .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
1566 .get(crate_name)
1567 .cloned()
1568 }
1569}
1570
1571impl CallGraphStore {
1572 pub fn open_if_enabled(
1573 options: CallGraphStoreOptions,
1574 callgraph_dir: PathBuf,
1575 project_root: PathBuf,
1576 ) -> Result<Option<Self>> {
1577 if !options.enabled {
1578 return Ok(None);
1579 }
1580 Self::open(callgraph_dir, project_root).map(Some)
1581 }
1582
1583 pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
1584 let project_key = crate::search_index::artifact_cache_key(&project_root);
1585 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1586 else {
1587 return match Self::open_readonly(callgraph_dir.clone(), project_root.clone())? {
1588 Some(store) => Ok(store.into_inner()),
1589 None => Self::borrow_only_empty(callgraph_dir, project_root, project_key),
1590 };
1591 };
1592 std::fs::create_dir_all(&callgraph_dir)?;
1593 let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
1597 .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
1598 let OpenedStore { store, root_repair } = Self::open_at_path(
1599 project_root.clone(),
1600 project_key,
1601 sqlite_path,
1602 generation,
1603 true,
1604 Some(Arc::clone(&writer_lease)),
1605 None,
1606 )?;
1607 match root_repair {
1608 OpenRootRepair::NeedsRebuild { .. } => {
1609 log_root_repair_rebuild(&root_repair);
1610 drop(store);
1611 drop(writer_lease);
1612 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1613 let (store, _stats) =
1614 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
1615 Ok(store)
1616 }
1617 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
1618 }
1619 }
1620
1621 pub fn open_readonly(
1622 callgraph_dir: PathBuf,
1623 project_root: PathBuf,
1624 ) -> Result<Option<ReadonlyCallGraphStore>> {
1625 let project_key = crate::search_index::artifact_cache_key(&project_root);
1626 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1627 {
1628 let conn = open_readonly_connection(&sqlite_path)?;
1629 if !database_ready(&conn).unwrap_or(false) {
1630 return Ok(None);
1631 }
1632 let marker_label = generation.as_deref().unwrap_or("legacy");
1633 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
1634 return Ok(Some(ReadonlyCallGraphStore::from_inner(
1635 Self::from_connection(
1636 project_root,
1637 project_key,
1638 sqlite_path,
1639 callgraph_dir,
1640 false,
1641 generation,
1642 None,
1643 Some(read_marker),
1644 conn,
1645 ),
1646 )));
1647 }
1648
1649 let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
1650 return Ok(None);
1651 };
1652 crate::slog_warn!(
1653 "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
1654 target.partition.harness,
1655 target.sqlite_path.display()
1656 );
1657 let conn = open_readonly_connection(&target.sqlite_path)?;
1658 if !database_ready(&conn).unwrap_or(false) {
1659 return Ok(None);
1660 }
1661 let marker_label =
1662 legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
1663 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
1664 Ok(Some(ReadonlyCallGraphStore::from_inner(
1665 Self::from_connection(
1666 project_root,
1667 project_key,
1668 target.sqlite_path,
1669 callgraph_dir,
1670 true,
1671 target.generation,
1672 None,
1673 Some(read_marker),
1674 conn,
1675 ),
1676 )))
1677 }
1678
1679 pub fn open_ready_repairing(
1685 callgraph_dir: PathBuf,
1686 project_root: PathBuf,
1687 ) -> Result<Option<Self>> {
1688 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true, true)
1689 }
1690
1691 pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
1695 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false, false)
1696 }
1697
1698 pub fn open_ready_no_rebuild(
1699 callgraph_dir: PathBuf,
1700 project_root: PathBuf,
1701 ) -> Result<Option<Self>> {
1702 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true, true)
1703 }
1704
1705 fn open_ready_with_rebuild_policy(
1706 callgraph_dir: PathBuf,
1707 project_root: PathBuf,
1708 allow_cold_build: bool,
1709 allow_root_repair: bool,
1710 allow_borrow_only: bool,
1711 ) -> Result<Option<Self>> {
1712 let project_key = crate::search_index::artifact_cache_key(&project_root);
1713 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1714 else {
1715 if !allow_borrow_only {
1716 return Ok(None);
1717 }
1718 return Self::open_readonly(callgraph_dir, project_root)
1719 .map(|store| store.map(ReadonlyCallGraphStore::into_inner));
1720 };
1721 let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1722 else {
1723 return Ok(None);
1724 };
1725 let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
1726 project_root.clone(),
1727 project_key,
1728 sqlite_path,
1729 generation,
1730 true,
1731 Some(Arc::clone(&writer_lease)),
1732 None,
1733 allow_root_repair,
1734 )?;
1735 match root_repair {
1736 OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
1737 log_root_repair_rebuild(&root_repair);
1738 drop(store);
1739 drop(writer_lease);
1740 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1741 let (store, _stats) =
1742 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
1743 Ok(Some(store))
1744 }
1745 OpenRootRepair::NeedsRebuild { .. } => {
1746 crate::slog_info!(
1747 "callgraph store root repair requires rebuild; open-only reader reports unavailable"
1748 );
1749 Ok(None)
1750 }
1751 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
1752 }
1753 }
1754
1755 pub fn cold_build_with_lease(
1756 callgraph_dir: PathBuf,
1757 project_root: PathBuf,
1758 files: &[PathBuf],
1759 ) -> Result<(Self, ColdBuildStats)> {
1760 Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
1761 }
1762
1763 pub fn cold_build_with_lease_chunked(
1764 callgraph_dir: PathBuf,
1765 project_root: PathBuf,
1766 files: &[PathBuf],
1767 chunk_size: usize,
1768 ) -> Result<(Self, ColdBuildStats)> {
1769 Self::cold_build_with_lease_chunked_inner(
1770 callgraph_dir,
1771 project_root,
1772 files,
1773 chunk_size,
1774 false,
1775 )
1776 }
1777
1778 pub(crate) fn force_cold_build_with_lease_chunked(
1779 callgraph_dir: PathBuf,
1780 project_root: PathBuf,
1781 files: &[PathBuf],
1782 chunk_size: usize,
1783 ) -> Result<(Self, ColdBuildStats)> {
1784 Self::cold_build_with_lease_chunked_inner(
1785 callgraph_dir,
1786 project_root,
1787 files,
1788 chunk_size,
1789 true,
1790 )
1791 }
1792
1793 fn cold_build_with_lease_chunked_inner(
1794 callgraph_dir: PathBuf,
1795 project_root: PathBuf,
1796 files: &[PathBuf],
1797 chunk_size: usize,
1798 require_new_publication: bool,
1799 ) -> Result<(Self, ColdBuildStats)> {
1800 let project_key = crate::search_index::artifact_cache_key(&project_root);
1801 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1802 else {
1803 if require_new_publication {
1804 return Err(CallGraphStoreError::Unavailable(
1805 "forced rebuild could not acquire the writer lease".to_string(),
1806 ));
1807 }
1808 let store = match Self::open_readonly(callgraph_dir.clone(), project_root.clone())? {
1809 Some(store) => store.into_inner(),
1810 None => Self::borrow_only_empty(callgraph_dir, project_root, project_key)?,
1811 };
1812 return Ok((
1813 store,
1814 ColdBuildStats {
1815 files: 0,
1816 nodes: 0,
1817 refs: 0,
1818 edges: 0,
1819 failed_files: Vec::new(),
1820 elapsed_ms: 0,
1821 },
1822 ));
1823 };
1824 std::fs::create_dir_all(&callgraph_dir)?;
1825 let (stats, generation) = Self::cold_build_publish_locked(
1826 &callgraph_dir,
1827 &project_root,
1828 &project_key,
1829 files,
1830 chunk_size,
1831 Arc::clone(&writer_lease),
1832 )?;
1833 let store = Self::open_generation(
1834 &callgraph_dir,
1835 project_root,
1836 project_key,
1837 generation,
1838 writer_lease,
1839 )?;
1840 Ok((store, stats))
1841 }
1842
1843 pub fn ensure_built_with_lease(
1844 callgraph_dir: PathBuf,
1845 project_root: PathBuf,
1846 files: &[PathBuf],
1847 ) -> Result<(Self, Option<ColdBuildStats>)> {
1848 Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
1849 }
1850
1851 pub fn ensure_built_with_lease_chunked(
1852 callgraph_dir: PathBuf,
1853 project_root: PathBuf,
1854 files: &[PathBuf],
1855 chunk_size: usize,
1856 ) -> Result<(Self, Option<ColdBuildStats>)> {
1857 let project_key = crate::search_index::artifact_cache_key(&project_root);
1858 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1859 else {
1860 return match Self::open_readonly(callgraph_dir.clone(), project_root.clone())? {
1861 Some(store) => Ok((store.into_inner(), None)),
1862 None => Self::borrow_only_empty(callgraph_dir, project_root, project_key)
1863 .map(|store| (store, None)),
1864 };
1865 };
1866 std::fs::create_dir_all(&callgraph_dir)?;
1867 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
1868 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1875 {
1876 let OpenedStore { store, root_repair } = Self::open_at_path(
1877 project_root.clone(),
1878 project_key.clone(),
1879 sqlite_path,
1880 generation,
1881 true,
1882 Some(Arc::clone(&writer_lease)),
1883 None,
1884 )?;
1885 match root_repair {
1886 OpenRootRepair::NeedsRebuild { .. } => {
1887 log_root_repair_rebuild(&root_repair);
1888 drop(store);
1889 let (stats, generation) = Self::cold_build_publish_locked(
1890 &callgraph_dir,
1891 &project_root,
1892 &project_key,
1893 files,
1894 chunk_size,
1895 Arc::clone(&writer_lease),
1896 )?;
1897 let store = Self::open_generation(
1898 &callgraph_dir,
1899 project_root,
1900 project_key,
1901 generation,
1902 writer_lease,
1903 )?;
1904 return Ok((store, Some(stats)));
1905 }
1906 OpenRootRepair::None | OpenRootRepair::ReRooted => {
1907 return Ok((store, None));
1908 }
1909 }
1910 }
1911 if let Some(store) = try_legacy_migration_or_fallback(
1912 &callgraph_dir,
1913 &project_root,
1914 &project_key,
1915 Arc::clone(&writer_lease),
1916 )? {
1917 return Ok((store, None));
1918 }
1919 let (stats, generation) = Self::cold_build_publish_locked(
1920 &callgraph_dir,
1921 &project_root,
1922 &project_key,
1923 files,
1924 chunk_size,
1925 Arc::clone(&writer_lease),
1926 )?;
1927 let store = Self::open_generation(
1928 &callgraph_dir,
1929 project_root,
1930 project_key,
1931 generation,
1932 writer_lease,
1933 )?;
1934 Ok((store, Some(stats)))
1935 }
1936
1937 pub fn migrate_legacy_with_lease(
1944 callgraph_dir: PathBuf,
1945 project_root: PathBuf,
1946 ) -> Result<Option<Self>> {
1947 let project_key = crate::search_index::artifact_cache_key(&project_root);
1948 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1949 else {
1950 return Ok(None);
1951 };
1952 std::fs::create_dir_all(&callgraph_dir)?;
1953 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
1954
1955 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1959 {
1960 let OpenedStore { store, root_repair } = Self::open_at_path(
1961 project_root,
1962 project_key,
1963 sqlite_path,
1964 generation,
1965 true,
1966 Some(writer_lease),
1967 None,
1968 )?;
1969 return match root_repair {
1970 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
1971 OpenRootRepair::NeedsRebuild { reason, .. } => {
1972 Err(CallGraphStoreError::Unavailable(format!(
1973 "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
1974 )))
1975 }
1976 };
1977 }
1978
1979 let store = try_legacy_migration_or_fallback(
1980 &callgraph_dir,
1981 &project_root,
1982 &project_key,
1983 writer_lease,
1984 )?;
1985 Ok(store.filter(|store| !store.is_legacy_fallback()))
1989 }
1990
1991 fn cold_build_publish_locked(
2002 callgraph_dir: &Path,
2003 project_root: &Path,
2004 project_key: &str,
2005 files: &[PathBuf],
2006 chunk_size: usize,
2007 writer_lease: Arc<crate::root_cache::WriterLease>,
2008 ) -> Result<(ColdBuildStats, String)> {
2009 let generation = generation_file_name(project_key);
2010 let gen_path = callgraph_dir.join(&generation);
2011 let temp_path = callgraph_dir.join(format!(
2012 "{generation}.tmp.{}.{}",
2013 std::process::id(),
2014 now_nanos()
2015 ));
2016 remove_sqlite_file_set(&temp_path);
2017
2018 let stats = {
2019 let temp_store = Self::open_at_path(
2020 project_root.to_path_buf(),
2021 project_key.to_string(),
2022 temp_path.clone(),
2023 None,
2024 false,
2025 Some(Arc::clone(&writer_lease)),
2026 None,
2027 )?
2028 .store;
2029 let stats = temp_store.cold_build_chunked(files, chunk_size)?;
2030 temp_store.prepare_for_atomic_swap()?;
2031 stats
2032 };
2033
2034 notify_cold_build_before_publish_observer();
2035 let publication = publish_if_current(|| {
2036 verify_writer_lease(&writer_lease)?;
2037 remove_sqlite_file_set(&gen_path);
2040 crate::fs_lock::rename_over(&temp_path, &gen_path)?;
2041 crate::fs_lock::sync_parent(&gen_path);
2042 remove_sqlite_sidecars(&gen_path);
2043
2044 notify_cold_build_swap_observer(&temp_path, &gen_path);
2045
2046 verify_writer_lease(&writer_lease)?;
2048 publish_pointer(callgraph_dir, project_key, &generation)?;
2049 gc_old_generations(callgraph_dir, project_key, &generation);
2050 sweep_orphaned_build_temps_store_wide(callgraph_dir);
2054 if let Some(storage_root) = root_storage_dir(callgraph_dir) {
2055 let inspect_root =
2056 storage_root.join(crate::root_cache::RootCacheDomain::Inspect.as_str());
2057 let live_scope_keys = crate::root_cache::live_scope_keys_for_storage(&storage_root);
2058 crate::inspect::cache::sweep_inspect_scope_dirs(&inspect_root, &live_scope_keys);
2059 }
2060 Ok(())
2061 });
2062 if matches!(publication, Err(CallGraphStoreError::Superseded)) {
2063 remove_sqlite_file_set(&temp_path);
2064 }
2065 publication?;
2066 Ok((stats, generation))
2067 }
2068
2069 fn open_generation(
2072 callgraph_dir: &Path,
2073 project_root: PathBuf,
2074 project_key: String,
2075 generation: String,
2076 writer_lease: Arc<crate::root_cache::WriterLease>,
2077 ) -> Result<Self> {
2078 let gen_path = callgraph_dir.join(&generation);
2079 Ok(Self::open_at_path(
2080 project_root,
2081 project_key,
2082 gen_path,
2083 Some(generation),
2084 true,
2085 Some(writer_lease),
2086 None,
2087 )?
2088 .store)
2089 }
2090
2091 pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
2092 let project_key = crate::search_index::artifact_cache_key(project_root);
2093 Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
2096 }
2097
2098 fn open_at_path(
2099 project_root: PathBuf,
2100 project_key: String,
2101 sqlite_path: PathBuf,
2102 generation: Option<String>,
2103 use_wal: bool,
2104 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2105 read_marker: Option<crate::root_cache::ReadMarker>,
2106 ) -> Result<OpenedStore> {
2107 Self::open_at_path_with_root_repair(
2108 project_root,
2109 project_key,
2110 sqlite_path,
2111 generation,
2112 use_wal,
2113 writer_lease,
2114 read_marker,
2115 true,
2116 )
2117 }
2118
2119 fn open_at_path_with_root_repair(
2120 project_root: PathBuf,
2121 project_key: String,
2122 sqlite_path: PathBuf,
2123 generation: Option<String>,
2124 use_wal: bool,
2125 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2126 read_marker: Option<crate::root_cache::ReadMarker>,
2127 allow_root_repair: bool,
2128 ) -> Result<OpenedStore> {
2129 if let Some(lease) = writer_lease.as_ref() {
2130 verify_writer_lease(lease)?;
2131 }
2132 if let Some(parent) = sqlite_path.parent() {
2133 std::fs::create_dir_all(parent)?;
2134 }
2135 let mut conn = Connection::open(&sqlite_path)?;
2136 if use_wal {
2137 configure_connection(&conn)?;
2138 } else {
2139 configure_build_connection(&conn)?;
2140 }
2141 if let Some(lease) = writer_lease.as_ref() {
2142 verify_writer_lease(lease)?;
2143 }
2144 initialize_schema(&conn)?;
2145 if let Some(lease) = writer_lease.as_ref() {
2146 verify_writer_lease(lease)?;
2147 }
2148 let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
2149 let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
2150 (Some(marker), _, _) => Some(marker),
2151 (None, Some(label), Some(cache_dir)) => {
2152 Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
2153 }
2154 (None, _, _) => None,
2155 };
2156 let publication_dir = sqlite_path
2157 .parent()
2158 .map(Path::to_path_buf)
2159 .unwrap_or_default();
2160 let store = Self::from_connection(
2161 project_root,
2162 project_key,
2163 sqlite_path,
2164 publication_dir,
2165 false,
2166 generation,
2167 writer_lease,
2168 read_marker,
2169 conn,
2170 );
2171 Ok(OpenedStore { store, root_repair })
2172 }
2173
2174 fn borrow_only_empty(
2175 callgraph_dir: PathBuf,
2176 project_root: PathBuf,
2177 project_key: String,
2178 ) -> Result<Self> {
2179 let conn = Connection::open_in_memory()?;
2180 initialize_schema(&conn)?;
2181 conn.pragma_update(None, "query_only", true)?;
2182 Ok(Self::from_connection(
2183 project_root,
2184 project_key.clone(),
2185 callgraph_dir.join(format!("{project_key}.borrow-only")),
2186 callgraph_dir,
2187 false,
2188 None,
2189 None,
2190 None,
2191 conn,
2192 ))
2193 }
2194
2195 fn prepare_for_atomic_swap(&self) -> Result<()> {
2196 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2197 conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
2198 Ok(())
2199 }
2200
2201 fn atomic_swap_checkpoint_sql(&self) -> &'static str {
2202 let protected_reader = self.generation.as_deref().is_some_and(|generation| {
2203 self.sqlite_path
2204 .parent()
2205 .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
2206 });
2207 if protected_reader {
2208 "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
2209 } else {
2210 "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
2211 }
2212 }
2213
2214 fn from_connection(
2215 project_root: PathBuf,
2216 project_key: String,
2217 sqlite_path: PathBuf,
2218 publication_dir: PathBuf,
2219 legacy_fallback: bool,
2220 generation: Option<String>,
2221 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2222 read_marker: Option<crate::root_cache::ReadMarker>,
2223 conn: Connection,
2224 ) -> Self {
2225 Self {
2226 project_root,
2227 project_key,
2228 sqlite_path,
2229 publication_dir,
2230 legacy_fallback,
2231 generation,
2232 writer_lease,
2233 read_marker,
2234 database_ready: AtomicBool::new(false),
2235 conn: Mutex::new(conn),
2236 }
2237 }
2238
2239 fn ensure_ready(&self, conn: &Connection) -> Result<()> {
2240 if self.database_ready.load(AtomicOrdering::Acquire) {
2241 return Ok(());
2242 }
2243 ensure_database_ready(conn)?;
2244 self.database_ready.store(true, AtomicOrdering::Release);
2245 Ok(())
2246 }
2247
2248 pub fn project_root(&self) -> &Path {
2249 &self.project_root
2250 }
2251
2252 pub fn project_key(&self) -> &str {
2253 &self.project_key
2254 }
2255
2256 pub fn sqlite_path(&self) -> &Path {
2257 &self.sqlite_path
2258 }
2259
2260 pub fn is_legacy_fallback(&self) -> bool {
2263 self.legacy_fallback
2264 }
2265
2266 pub(crate) fn is_legacy_migration(&self) -> bool {
2267 self.generation.as_deref().is_some_and(|generation| {
2268 migration_generation_requires_manifest(generation)
2269 && migration_manifest_valid(&self.publication_dir, generation)
2270 })
2271 }
2272
2273 pub fn writer_epoch_for_test(&self) -> Option<&str> {
2274 self.writer_lease.as_ref().map(|lease| lease.epoch())
2275 }
2276
2277 fn verify_writer_lease(&self) -> Result<()> {
2278 let Some(lease) = self.writer_lease.as_ref() else {
2279 return Err(CallGraphStoreError::Unavailable(
2280 "callgraph store opened read-only; write API is unavailable".to_string(),
2281 ));
2282 };
2283 verify_writer_lease(lease)
2284 }
2285
2286 fn refresh_read_marker(&self) -> Result<()> {
2287 if let Some(marker) = self.read_marker.as_ref() {
2288 marker.touch_if_due()?;
2289 }
2290 Ok(())
2291 }
2292
2293 pub fn is_current(&self) -> bool {
2299 let _ = self.refresh_read_marker();
2300 match (
2301 read_pointer(&self.publication_dir, &self.project_key),
2302 &self.generation,
2303 ) {
2304 (Some(_), _) if self.legacy_fallback => false,
2307 (Some(published), Some(opened)) => &published == opened,
2308 (Some(_), None) => false,
2310 (None, _) => true,
2313 }
2314 }
2315
2316 pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
2317 self.cold_build_chunked(files, 0)
2318 }
2319
2320 pub fn cold_build_chunked(
2321 &self,
2322 files: &[PathBuf],
2323 chunk_size: usize,
2324 ) -> Result<ColdBuildStats> {
2325 let started = Instant::now();
2326 let bench = std::env::var("AFT_BENCH_COLD").is_ok();
2327 macro_rules! phase {
2328 ($label:expr, $t:expr) => {
2329 if bench {
2330 eprintln!(" cold_build[{}]: {} ms", $label, $t.elapsed().as_millis());
2331 let _ = std::io::Write::flush(&mut std::io::stderr());
2332 }
2333 };
2334 }
2335 let files = normalize_file_list(&self.project_root, files)?;
2336
2337 if chunk_size == 0 {
2338 let t = Instant::now();
2339 let build = build_extracts_parallel(&self.project_root, &files);
2340 phase!("extract_parallel", t);
2341 let extracts = build.extracts;
2342 let failures = build.failures;
2343 let node_count = extracts.iter().map(|extract| extract.nodes.len()).sum();
2344
2345 let t = Instant::now();
2346 let index = ProjectIndex::from_extracts(&self.project_root, &extracts);
2347 phase!("build_index", t);
2348 let t = Instant::now();
2349 let mut resolved_refs = Vec::new();
2350 for extract in &extracts {
2351 for raw_ref in &extract.raw_refs {
2352 resolved_refs.push(resolve_ref(raw_ref.clone(), &index)?);
2353 }
2354 }
2355 phase!("resolve_refs", t);
2356 let ref_count = resolved_refs.len();
2357 let edge_count = resolved_refs
2358 .iter()
2359 .filter(|item| item.edge.is_some())
2360 .count();
2361
2362 let t = Instant::now();
2363 self.verify_writer_lease()?;
2364 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2365 let tx = conn.transaction()?;
2366 clear_tables(&tx)?;
2367 insert_meta(&tx)?;
2368 drop_cold_build_secondary_indexes(&tx)?;
2369 {
2370 let workspace_root = self.project_root.display().to_string();
2371 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2372 for extract in &extracts {
2373 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
2374 }
2375 for failure in &failures {
2376 insert_backend_state_prepared(
2377 &mut inserts.backend_state,
2378 &workspace_root,
2379 &failure.rel_path,
2380 failure
2381 .freshness
2382 .as_ref()
2383 .map(|freshness| &freshness.content_hash),
2384 "stale",
2385 )?;
2386 }
2387 for resolved in &resolved_refs {
2388 insert_resolved_ref_prepared(&mut inserts, resolved)?;
2389 }
2390 }
2391 create_cold_build_secondary_indexes(&tx)?;
2392 let supplemental_edge_count =
2393 insert_method_dispatch_edges(&tx, &self.project_root, None)?;
2394 set_meta_ready(&tx, true)?;
2395 tx.commit()?;
2396 phase!("sqlite_insert", t);
2397
2398 let elapsed_ms = started.elapsed().as_millis();
2399 crate::slog_info!(
2400 "perf callgraph_store cold_build: files={} nodes={} refs={} edges={} ms={}",
2401 extracts.len(),
2402 node_count,
2403 ref_count,
2404 edge_count + supplemental_edge_count,
2405 elapsed_ms
2406 );
2407 return Ok(ColdBuildStats {
2408 files: extracts.len(),
2409 nodes: node_count,
2410 refs: ref_count,
2411 edges: edge_count + supplemental_edge_count,
2412 failed_files: failures
2413 .into_iter()
2414 .map(|failure| failure.rel_path)
2415 .collect(),
2416 elapsed_ms,
2417 });
2418 }
2419
2420 let t = Instant::now();
2423 self.verify_writer_lease()?;
2424 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2425 let tx = conn.transaction()?;
2426 clear_tables(&tx)?;
2427 insert_meta(&tx)?;
2428 drop_cold_build_secondary_indexes(&tx)?;
2429
2430 let mut all_raw_refs = Vec::new();
2431 let mut failures = Vec::new();
2432 let mut node_count = 0;
2433 let mut files_parsed = 0;
2434
2435 let mut persistent_call_data = Vec::new();
2436 let mut file_to_call_data_index = HashMap::new();
2437 let mut files_index = HashMap::new();
2438
2439 let workspace_root = self.project_root.display().to_string();
2440
2441 {
2442 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2443 for chunk in files.chunks(chunk_size) {
2444 let build = build_extracts_parallel(&self.project_root, chunk);
2445 failures.extend(build.failures.clone());
2446
2447 for extract in build.extracts {
2448 files_parsed += 1;
2449 node_count += extract.nodes.len();
2450 insert_file_extract_prepared(&mut inserts, &workspace_root, &extract)?;
2451
2452 let db_file_index = DbFileIndex::from_extract(&self.project_root, &extract);
2453 files_index.insert(extract.rel_path.clone(), db_file_index);
2454
2455 persistent_call_data.push(extract.data);
2456 let idx = persistent_call_data.len() - 1;
2457 file_to_call_data_index.insert(extract.rel_path.clone(), idx);
2458
2459 all_raw_refs.push((extract.rel_path, extract.raw_refs));
2460 }
2461 for failure in &build.failures {
2462 insert_backend_state_prepared(
2463 &mut inserts.backend_state,
2464 &workspace_root,
2465 &failure.rel_path,
2466 failure
2467 .freshness
2468 .as_ref()
2469 .map(|freshness| &freshness.content_hash),
2470 "stale",
2471 )?;
2472 }
2473 }
2474 }
2475
2476 let mut caller_data = HashMap::new();
2477 for (rel_path, idx) in &file_to_call_data_index {
2478 caller_data.insert(rel_path.clone(), &persistent_call_data[*idx]);
2479 }
2480 let indexed_caller_files = files_index.keys().cloned().collect::<BTreeSet<_>>();
2481 let index = ProjectIndex::from_parts(
2482 &self.project_root,
2483 files_index,
2484 caller_data,
2485 WorkspaceCratePrefixCache::default(),
2486 );
2487
2488 let mut resolved_refs = Vec::new();
2489 for (_, raw_refs) in all_raw_refs {
2490 for raw_ref in raw_refs {
2491 resolved_refs.push(resolve_ref(raw_ref, &index)?);
2492 }
2493 }
2494
2495 let ref_count = resolved_refs.len();
2496 let edge_count = resolved_refs
2497 .iter()
2498 .filter(|item| item.edge.is_some())
2499 .count();
2500
2501 {
2502 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2503 for resolved in &resolved_refs {
2504 insert_resolved_ref_prepared(&mut inserts, resolved)?;
2505 }
2506 }
2507 create_cold_build_secondary_indexes(&tx)?;
2508 let supplemental_edge_count = insert_method_dispatch_edges_chunked(
2509 &tx,
2510 &self.project_root,
2511 &indexed_caller_files,
2512 chunk_size,
2513 )?;
2514 set_meta_ready(&tx, true)?;
2515 tx.commit()?;
2516 phase!("sqlite_insert", t);
2517
2518 let elapsed_ms = started.elapsed().as_millis();
2519 crate::slog_info!(
2520 "perf callgraph_store cold_build (chunked): files={} nodes={} refs={} edges={} ms={}",
2521 files_parsed,
2522 node_count,
2523 ref_count,
2524 edge_count + supplemental_edge_count,
2525 elapsed_ms
2526 );
2527 Ok(ColdBuildStats {
2528 files: files_parsed,
2529 nodes: node_count,
2530 refs: ref_count,
2531 edges: edge_count + supplemental_edge_count,
2532 failed_files: failures
2533 .into_iter()
2534 .map(|failure| failure.rel_path)
2535 .collect(),
2536 elapsed_ms,
2537 })
2538 }
2539
2540 pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
2541 self.refresh_files_with_workspace_crate_prefix_cache(
2542 changed_files,
2543 WorkspaceCratePrefixCache::default(),
2544 )
2545 }
2546
2547 fn refresh_files_with_workspace_crate_prefix_cache(
2548 &self,
2549 changed_files: &[PathBuf],
2550 workspace_crate_prefixes: WorkspaceCratePrefixCache,
2551 ) -> Result<IncrementalStats> {
2552 let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
2553 changed_files,
2554 workspace_crate_prefixes,
2555 )?;
2556 if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
2557 eprintln!("refresh_files phases: {}", profile.report());
2558 }
2559 Ok(stats)
2560 }
2561
2562 #[doc(hidden)]
2564 pub fn refresh_files_profiled(
2565 &self,
2566 changed_files: &[PathBuf],
2567 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2568 self.refresh_files_profiled_with_workspace_crate_prefix_cache(
2569 changed_files,
2570 WorkspaceCratePrefixCache::default(),
2571 )
2572 }
2573
2574 fn refresh_files_profiled_with_workspace_crate_prefix_cache(
2575 &self,
2576 changed_files: &[PathBuf],
2577 workspace_crate_prefixes: WorkspaceCratePrefixCache,
2578 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2579 let total_started = Instant::now();
2580 let mut profile = RefreshFilesProfile::default();
2581 self.verify_writer_lease()?;
2582 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2583 let tx = conn.transaction()?;
2584 ensure_database_ready(&tx)?;
2585 let mut changed = Vec::new();
2586 let mut surface_changed = BTreeSet::new();
2587 let mut deleted = BTreeSet::new();
2588 let mut own_refresh = BTreeSet::new();
2589 let mut selected_ref_ids = BTreeSet::new();
2590 let mut selected_refs_by_caller = BTreeMap::new();
2591 let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
2592
2593 for input in changed_files {
2594 let abs_path = normalize_file_path(&self.project_root, input)?;
2595 let rel_path = relative_path(&self.project_root, &abs_path);
2596 changed.push(rel_path.clone());
2597 let old_row = load_file_row(&tx, &rel_path)?;
2598 if !abs_path.exists() {
2599 if old_row.is_some() {
2600 surface_changed.insert(rel_path.clone());
2601 deleted.insert(rel_path.clone());
2602 let started = Instant::now();
2603 let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
2604 profile.dependency_selection += started.elapsed();
2605 record_dependent_refs(
2606 &mut selected_ref_ids,
2607 &mut selected_refs_by_caller,
2608 dependent_refs,
2609 );
2610 let started = Instant::now();
2611 delete_file_rows(&tx, &rel_path)?;
2612 clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
2613 profile.row_deletes += started.elapsed();
2614 }
2615 continue;
2616 }
2617
2618 if let Some(row) = &old_row {
2619 match cache_freshness::verify_file(&abs_path, &row.freshness) {
2620 FreshnessVerdict::HotFresh => continue,
2621 FreshnessVerdict::ContentFresh {
2622 new_mtime,
2623 new_size,
2624 } => {
2625 update_file_fresh_metadata(
2626 &tx,
2627 &rel_path,
2628 &row.freshness.content_hash,
2629 new_mtime,
2630 new_size,
2631 )?;
2632 continue;
2633 }
2634 FreshnessVerdict::Deleted => {
2635 surface_changed.insert(rel_path.clone());
2636 deleted.insert(rel_path.clone());
2637 let started = Instant::now();
2638 let dependent_refs =
2639 ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
2640 profile.dependency_selection += started.elapsed();
2641 record_dependent_refs(
2642 &mut selected_ref_ids,
2643 &mut selected_refs_by_caller,
2644 dependent_refs,
2645 );
2646 let started = Instant::now();
2647 delete_file_rows(&tx, &rel_path)?;
2648 clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
2649 profile.row_deletes += started.elapsed();
2650 continue;
2651 }
2652 FreshnessVerdict::Stale => {}
2653 }
2654 }
2655
2656 let started = Instant::now();
2657 let extract = build_file_extract(&self.project_root, &abs_path)?;
2658 profile.parse += started.elapsed();
2659 let surface_is_changed = old_row
2660 .as_ref()
2661 .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
2662 .unwrap_or(true);
2663 if surface_is_changed {
2664 surface_changed.insert(rel_path.clone());
2665 let started = Instant::now();
2666 let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
2667 profile.dependency_selection += started.elapsed();
2668 record_dependent_refs(
2669 &mut selected_ref_ids,
2670 &mut selected_refs_by_caller,
2671 dependent_refs,
2672 );
2673 }
2674 own_refresh.insert(rel_path.clone());
2675 let started = Instant::now();
2676 delete_file_rows(&tx, &rel_path)?;
2677 profile.row_deletes += started.elapsed();
2678 let started = Instant::now();
2679 insert_file_extract(&tx, &self.project_root, &extract)?;
2680 profile.row_inserts += started.elapsed();
2681 changed_extracts.insert(rel_path, extract);
2682 }
2683
2684 let dependency_selected_refs = selected_ref_ids.len();
2685 let mut touched_callers: BTreeSet<String> =
2686 selected_refs_by_caller.keys().cloned().collect();
2687 touched_callers.extend(own_refresh.iter().cloned());
2688
2689 let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
2690 for rel_path in &touched_callers {
2691 if deleted.contains(rel_path) {
2692 continue;
2693 }
2694 if let Some(extract) = changed_extracts.get(rel_path) {
2695 caller_extracts.insert(rel_path.clone(), extract.clone());
2696 continue;
2697 }
2698 let abs_path = self.project_root.join(rel_path);
2699 if abs_path.exists() {
2700 let started = Instant::now();
2701 let extract = build_file_extract(&self.project_root, &abs_path)?;
2702 profile.dependent_parse += started.elapsed();
2703 caller_extracts.insert(rel_path.clone(), extract);
2704 }
2705 }
2706
2707 let dependency_callers = touched_callers
2708 .iter()
2709 .filter(|rel_path| !deleted.contains(*rel_path) && !own_refresh.contains(*rel_path))
2710 .cloned()
2711 .collect::<Vec<_>>();
2712 for rel_path in dependency_callers {
2713 let Some(extract) = caller_extracts.get(&rel_path) else {
2714 continue;
2715 };
2716 if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
2717 continue;
2718 }
2719
2720 own_refresh.insert(rel_path.clone());
2721 let started = Instant::now();
2722 delete_file_rows(&tx, &rel_path)?;
2723 profile.row_deletes += started.elapsed();
2724 let started = Instant::now();
2725 insert_file_extract(&tx, &self.project_root, extract)?;
2726 profile.row_inserts += started.elapsed();
2727 }
2728
2729 let started = Instant::now();
2730 let index = ProjectIndex::from_db_and_callers(
2731 &tx,
2732 &self.project_root,
2733 &caller_extracts,
2734 workspace_crate_prefixes,
2735 )?;
2736 profile.index_load += started.elapsed();
2737 let started = Instant::now();
2738 for rel_path in &touched_callers {
2739 if deleted.contains(rel_path) {
2740 continue;
2741 }
2742 let Some(extract) = caller_extracts.get(rel_path) else {
2743 continue;
2744 };
2745 if own_refresh.contains(rel_path) {
2746 delete_refs_for_caller(&tx, rel_path)?;
2747 for raw_ref in &extract.raw_refs {
2748 let resolved = resolve_ref(raw_ref.clone(), &index)?;
2749 insert_resolved_ref(&tx, &resolved)?;
2750 }
2751 continue;
2752 }
2753
2754 let selected_for_caller = selected_refs_by_caller
2755 .get(rel_path)
2756 .cloned()
2757 .unwrap_or_default();
2758 delete_ref_ids(&tx, &selected_for_caller)?;
2759 for raw_ref in &extract.raw_refs {
2760 if selected_for_caller.contains(&raw_ref.ref_id) {
2761 let resolved = resolve_ref(raw_ref.clone(), &index)?;
2762 insert_resolved_ref(&tx, &resolved)?;
2763 }
2764 }
2765 }
2766 profile.ref_resolution += started.elapsed();
2767
2768 let started = Instant::now();
2769 delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
2770 insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
2771 profile.method_dispatch += started.elapsed();
2772
2773 let started = Instant::now();
2774 commit_incremental_if_current(tx)?;
2775 profile.commit += started.elapsed();
2776 profile.total = total_started.elapsed();
2777 Ok((
2778 IncrementalStats {
2779 changed_files: changed,
2780 surface_changed: surface_changed.into_iter().collect(),
2781 deleted_files: deleted.into_iter().collect(),
2782 dependency_selected_refs,
2783 refreshed_own_files: own_refresh.len(),
2784 },
2785 profile,
2786 ))
2787 }
2788
2789 pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
2790 self.cold_build(current_files)
2791 }
2792
2793 pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
2794 self.verify_writer_lease()?;
2795 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2796 let tx = conn.transaction()?;
2797 let mut marked = Vec::new();
2798 for path in files {
2799 let abs_path = normalize_file_path(&self.project_root, path)?;
2800 let rel_path = relative_path(&self.project_root, &abs_path);
2801 let freshness = cache_freshness::collect(&abs_path).ok();
2802 mark_backend_state(
2803 &tx,
2804 &self.project_root,
2805 &rel_path,
2806 freshness.as_ref().map(|freshness| &freshness.content_hash),
2807 "stale",
2808 )?;
2809 marked.push(rel_path);
2810 }
2811 tx.commit()?;
2812 marked.sort();
2813 marked.dedup();
2814 Ok(marked)
2815 }
2816
2817 pub fn stale_files(&self) -> Result<Vec<String>> {
2818 self.refresh_read_marker()?;
2819 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2820 let mut stmt = conn.prepare(
2821 "SELECT DISTINCT file_path FROM backend_file_state
2822 WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
2823 ORDER BY file_path",
2824 )?;
2825 let rows = stmt.query_map(
2826 params![BACKEND_TREESITTER, self.project_root.display().to_string()],
2827 |row| row.get::<_, String>(0),
2828 )?;
2829 rows.collect::<std::result::Result<Vec<_>, _>>()
2830 .map_err(Into::into)
2831 }
2832
2833 pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
2834 self.refresh_read_marker()?;
2835 let rel_path = relative_path(
2836 &self.project_root,
2837 &normalize_file_path(&self.project_root, file)?,
2838 );
2839 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2840 conn.query_row(
2841 "SELECT status FROM backend_file_state
2842 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
2843 ORDER BY updated_at DESC LIMIT 1",
2844 params![
2845 BACKEND_TREESITTER,
2846 self.project_root.display().to_string(),
2847 rel_path
2848 ],
2849 |row| row.get(0),
2850 )
2851 .optional()
2852 .map_err(Into::into)
2853 }
2854
2855 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
2856 self.refresh_read_marker()?;
2857 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2858 self.ensure_ready(&conn)?;
2859 edge_snapshot_with_conn(&conn)
2860 }
2861
2862 pub fn indexed_file_count(&self) -> Result<usize> {
2863 self.refresh_read_marker()?;
2864 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2865 self.ensure_ready(&conn)?;
2866 indexed_file_count(&conn)
2867 }
2868
2869 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
2870 self.refresh_read_marker()?;
2871 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
2872 let rel_path = relative_path(&self.project_root, &abs_path);
2873 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2874 self.ensure_ready(&conn)?;
2875 resolve_node_for_rel(&conn, &rel_path, symbol)
2876 }
2877
2878 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
2883 self.refresh_read_marker()?;
2884 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
2885 let rel_path = relative_path(&self.project_root, &abs_path);
2886 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2887 self.ensure_ready(&conn)?;
2888 nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
2889 }
2890
2891 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
2893 self.refresh_read_marker()?;
2894 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2895 self.ensure_ready(&conn)?;
2896 nodes_matching_symbol(&conn, symbol)
2897 }
2898
2899 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
2901 self.refresh_read_marker()?;
2902 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
2903 let rel_path = relative_path(&self.project_root, &abs_path);
2904 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2905 self.ensure_ready(&conn)?;
2906 direct_callers_for_tuple(&conn, &rel_path, symbol)
2907 }
2908
2909 pub fn direct_caller_counts_of(
2911 &self,
2912 targets: &[(String, String)],
2913 ) -> Result<HashMap<(String, String), usize>> {
2914 if targets.is_empty() {
2915 return Ok(HashMap::new());
2916 }
2917 self.refresh_read_marker()?;
2918 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2919 self.ensure_ready(&conn)?;
2920 direct_caller_counts_for_tuples(&conn, targets)
2921 }
2922
2923 pub fn callers_of(
2924 &self,
2925 file_rel: &Path,
2926 symbol: &str,
2927 depth: usize,
2928 ) -> Result<StoreCallersResult> {
2929 let target = self.node_for(file_rel, symbol)?;
2930 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2931 self.ensure_ready(&conn)?;
2932 let effective_depth = depth.max(1);
2933 let mut visited = HashSet::new();
2934 let mut callers = Vec::new();
2935 let mut depth_limited = false;
2936 let mut truncated = 0usize;
2937 collect_callers_recursive(
2938 &conn,
2939 &target.file,
2940 &target.symbol,
2941 effective_depth,
2942 0,
2943 &mut visited,
2944 &mut callers,
2945 &mut depth_limited,
2946 &mut truncated,
2947 )?;
2948 Ok(StoreCallersResult {
2949 target,
2950 callers,
2951 scanned_files: indexed_file_count(&conn)?,
2952 depth_limited,
2953 truncated,
2954 })
2955 }
2956
2957 pub fn impact_of(
2958 &self,
2959 file_rel: &Path,
2960 symbol: &str,
2961 depth: usize,
2962 ) -> Result<StoreImpactResult> {
2963 let callers = self.callers_of(file_rel, symbol, depth)?;
2964 let target_parameters = callers
2965 .target
2966 .signature
2967 .as_deref()
2968 .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
2969 .unwrap_or_default();
2970 let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
2971 for site in &callers.callers {
2972 source_lines_by_file
2973 .entry(site.caller.file.clone())
2974 .or_insert_with(|| {
2975 read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
2976 });
2977 }
2978 let enriched = callers
2979 .callers
2980 .iter()
2981 .map(|site| StoreImpactCaller {
2982 site: site.clone(),
2983 signature: site.caller.signature.clone(),
2984 is_entry_point: site.caller.is_entry_point,
2985 call_expression: source_lines_by_file
2986 .get(&site.caller.file)
2987 .and_then(|lines| lines.as_ref())
2988 .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
2989 .cloned(),
2990 parameters: site
2991 .caller
2992 .signature
2993 .as_deref()
2994 .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
2995 .unwrap_or_default(),
2996 })
2997 .collect();
2998 Ok(StoreImpactResult {
2999 target: callers.target,
3000 parameters: target_parameters,
3001 callers: enriched,
3002 depth_limited: callers.depth_limited,
3003 truncated: callers.truncated,
3004 })
3005 }
3006
3007 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3008 self.refresh_read_marker()?;
3009 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3010 self.ensure_ready(&conn)?;
3011 outgoing_calls_for_node(&conn, node)
3012 }
3013
3014 pub fn outgoing_calls_for_symbols(
3016 &self,
3017 sources: &[(String, String)],
3018 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3019 if sources.is_empty() {
3020 return Ok(HashMap::new());
3021 }
3022 self.refresh_read_marker()?;
3023 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3024 self.ensure_ready(&conn)?;
3025 outgoing_calls_for_symbol_tuples(&conn, sources)
3026 }
3027
3028 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3030 self.refresh_read_marker()?;
3031 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3032 self.ensure_ready(&conn)?;
3033 resolved_self_calls_for_node(&conn, node)
3034 }
3035
3036 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3037 self.refresh_read_marker()?;
3038 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3039 self.ensure_ready(&conn)?;
3040 unresolved_calls_for_node(&conn, node)
3041 }
3042
3043 pub fn call_tree(
3044 &self,
3045 file_rel: &Path,
3046 symbol: &str,
3047 max_depth: usize,
3048 ) -> Result<callgraph::CallTreeNode> {
3049 let node = self.node_for(file_rel, symbol)?;
3050 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3051 self.ensure_ready(&conn)?;
3052 let mut visited = HashSet::new();
3053 call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
3054 }
3055
3056 pub fn trace_to(
3057 &self,
3058 file_rel: &Path,
3059 symbol: &str,
3060 max_depth: usize,
3061 ) -> Result<callgraph::TraceToResult> {
3062 let target = self.node_for(file_rel, symbol)?;
3063 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3064 self.ensure_ready(&conn)?;
3065 let effective_max = if max_depth == 0 { 10 } else { max_depth };
3066
3067 #[derive(Clone)]
3068 struct PathElem {
3069 node: StoreNode,
3070 }
3071
3072 let initial = vec![PathElem {
3073 node: target.clone(),
3074 }];
3075 let mut complete_paths = Vec::new();
3076 if target.is_entry_point {
3077 complete_paths.push(initial.clone());
3078 }
3079
3080 let mut queue = vec![(initial, 0usize)];
3081 let mut max_depth_reached = false;
3082 let mut truncated_paths = 0usize;
3083
3084 while let Some((path, depth)) = queue.pop() {
3085 if depth >= effective_max {
3086 max_depth_reached = true;
3087 continue;
3088 }
3089 let Some(current) = path.last() else {
3090 continue;
3091 };
3092 let callers =
3093 direct_callers_for_tuple(&conn, ¤t.node.file, ¤t.node.symbol)?;
3094 if callers.is_empty() {
3095 if path.len() > 1 {
3096 truncated_paths += 1;
3097 }
3098 continue;
3099 }
3100
3101 let mut has_new_path = false;
3102 for site in callers {
3103 if path.iter().any(|elem| {
3104 elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
3105 }) {
3106 continue;
3107 }
3108 has_new_path = true;
3109 let mut new_path = path.clone();
3110 new_path.push(PathElem {
3111 node: site.caller.clone(),
3112 });
3113 if site.caller.is_entry_point {
3114 complete_paths.push(new_path.clone());
3115 }
3116 queue.push((new_path, depth + 1));
3117 }
3118 if !has_new_path && path.len() > 1 {
3119 truncated_paths += 1;
3120 }
3121 }
3122
3123 let mut paths: Vec<callgraph::TracePath> = complete_paths
3124 .into_iter()
3125 .map(|mut elems| {
3126 elems.reverse();
3127 let hops = elems
3128 .iter()
3129 .enumerate()
3130 .map(|(index, elem)| callgraph::TraceHop {
3131 symbol: elem.node.symbol.clone(),
3132 file: elem.node.file.clone(),
3133 line: elem.node.line,
3134 signature: elem.node.signature.clone(),
3135 is_entry_point: index == 0 && elem.node.is_entry_point,
3136 })
3137 .collect();
3138 callgraph::TracePath { hops }
3139 })
3140 .collect();
3141 paths.sort_by(|left, right| {
3142 let left_entry = left
3143 .hops
3144 .first()
3145 .map(|hop| hop.symbol.as_str())
3146 .unwrap_or("");
3147 let right_entry = right
3148 .hops
3149 .first()
3150 .map(|hop| hop.symbol.as_str())
3151 .unwrap_or("");
3152 left_entry
3153 .cmp(right_entry)
3154 .then(left.hops.len().cmp(&right.hops.len()))
3155 });
3156 let entry_points_found = paths
3157 .iter()
3158 .filter_map(|path| path.hops.first())
3159 .filter(|hop| hop.is_entry_point)
3160 .map(|hop| (hop.file.clone(), hop.symbol.clone()))
3161 .collect::<HashSet<_>>()
3162 .len();
3163
3164 Ok(callgraph::TraceToResult {
3165 target_symbol: target.symbol,
3166 target_file: target.file,
3167 total_paths: paths.len(),
3168 paths,
3169 entry_points_found,
3170 max_depth_reached,
3171 truncated_paths,
3172 })
3173 }
3174
3175 pub fn trace_to_symbol_candidates(
3176 &self,
3177 to_symbol: &str,
3178 ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
3179 self.refresh_read_marker()?;
3180 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3181 self.ensure_ready(&conn)?;
3182 let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
3183 for node in nodes_matching_symbol(&conn, to_symbol)? {
3184 candidates_by_file
3185 .entry(node.file)
3186 .and_modify(|line| *line = (*line).min(node.line))
3187 .or_insert(node.line);
3188 }
3189 let mut candidates: Vec<_> = candidates_by_file
3190 .into_iter()
3191 .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
3192 .collect();
3193 candidates
3194 .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
3195 Ok(candidates)
3196 }
3197
3198 pub fn trace_to_symbol(
3199 &self,
3200 file_rel: &Path,
3201 symbol: &str,
3202 to_symbol: &str,
3203 to_file: Option<&Path>,
3204 max_depth: usize,
3205 ) -> Result<callgraph::TraceToSymbolResult> {
3206 let origin = self.node_for(file_rel, symbol)?;
3207 let target_file = to_file
3208 .map(|path| normalize_file_path(&self.project_root, path))
3209 .transpose()?
3210 .map(|path| relative_path(&self.project_root, &path));
3211 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3212 self.ensure_ready(&conn)?;
3213 let effective_max = if max_depth == 0 {
3214 10
3215 } else {
3216 max_depth.min(16)
3217 };
3218
3219 let start_hop = trace_to_symbol_hop(&origin);
3220 if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
3221 return Ok(callgraph::TraceToSymbolResult {
3222 path: Some(vec![start_hop]),
3223 complete: true,
3224 reason: None,
3225 });
3226 }
3227
3228 let mut queue = VecDeque::new();
3229 queue.push_back((origin.clone(), vec![start_hop], 0usize));
3230 let mut visited = HashSet::new();
3231 visited.insert((origin.file.clone(), origin.symbol.clone()));
3232 let mut max_depth_exhausted = false;
3233
3234 while let Some((current, path, depth)) = queue.pop_front() {
3235 let callees = outgoing_calls_for_node(&conn, ¤t)?
3236 .into_iter()
3237 .filter_map(|site| site.target)
3238 .collect::<Vec<_>>();
3239
3240 if depth >= effective_max {
3241 if callees
3242 .iter()
3243 .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
3244 {
3245 max_depth_exhausted = true;
3246 }
3247 continue;
3248 }
3249
3250 for callee in callees {
3251 if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
3252 continue;
3253 }
3254 let mut next_path = path.clone();
3255 next_path.push(trace_to_symbol_hop(&callee));
3256 if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
3257 return Ok(callgraph::TraceToSymbolResult {
3258 path: Some(next_path),
3259 complete: true,
3260 reason: None,
3261 });
3262 }
3263 queue.push_back((callee, next_path, depth + 1));
3264 }
3265 }
3266
3267 if max_depth_exhausted {
3268 Ok(callgraph::TraceToSymbolResult {
3269 path: None,
3270 complete: false,
3271 reason: Some("max_depth_exhausted".to_string()),
3272 })
3273 } else {
3274 Ok(callgraph::TraceToSymbolResult {
3275 path: None,
3276 complete: true,
3277 reason: Some("no_path_found".to_string()),
3278 })
3279 }
3280 }
3281}
3282
3283impl ReadonlyCallGraphStore {
3284 fn from_inner(inner: CallGraphStore) -> Self {
3285 Self { inner }
3286 }
3287
3288 fn into_inner(self) -> CallGraphStore {
3289 self.inner
3290 }
3291
3292 pub fn project_root(&self) -> &Path {
3293 self.inner.project_root()
3294 }
3295
3296 pub fn project_key(&self) -> &str {
3297 self.inner.project_key()
3298 }
3299
3300 pub fn sqlite_path(&self) -> &Path {
3301 self.inner.sqlite_path()
3302 }
3303
3304 pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
3307 crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
3308 }
3309
3310 pub fn is_legacy_fallback(&self) -> bool {
3312 self.inner.is_legacy_fallback()
3313 }
3314
3315 pub fn is_current(&self) -> bool {
3316 self.inner.is_current()
3317 }
3318
3319 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3320 self.inner.edge_snapshot()
3321 }
3322
3323 pub fn indexed_file_count(&self) -> Result<usize> {
3324 self.inner.indexed_file_count()
3325 }
3326
3327 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3328 self.inner.node_for(file_rel, symbol)
3329 }
3330
3331 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3332 self.inner.nodes_for(file_rel, symbol)
3333 }
3334
3335 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3336 self.inner.nodes_matching(symbol)
3337 }
3338
3339 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3340 self.inner.direct_callers_of(file_rel, symbol)
3341 }
3342
3343 pub fn direct_caller_counts_of(
3344 &self,
3345 targets: &[(String, String)],
3346 ) -> Result<HashMap<(String, String), usize>> {
3347 self.inner.direct_caller_counts_of(targets)
3348 }
3349
3350 pub fn callers_of(
3351 &self,
3352 file_rel: &Path,
3353 symbol: &str,
3354 depth: usize,
3355 ) -> Result<StoreCallersResult> {
3356 self.inner.callers_of(file_rel, symbol, depth)
3357 }
3358
3359 pub fn impact_of(
3360 &self,
3361 file_rel: &Path,
3362 symbol: &str,
3363 depth: usize,
3364 ) -> Result<StoreImpactResult> {
3365 self.inner.impact_of(file_rel, symbol, depth)
3366 }
3367
3368 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3369 self.inner.outgoing_calls_of(node)
3370 }
3371
3372 pub fn outgoing_calls_for_symbols(
3373 &self,
3374 sources: &[(String, String)],
3375 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3376 self.inner.outgoing_calls_for_symbols(sources)
3377 }
3378
3379 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3380 self.inner.resolved_self_calls_of(node)
3381 }
3382
3383 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3384 self.inner.unresolved_calls_of(node)
3385 }
3386
3387 pub fn call_tree(
3388 &self,
3389 file_rel: &Path,
3390 symbol: &str,
3391 depth: usize,
3392 ) -> Result<callgraph::CallTreeNode> {
3393 self.inner.call_tree(file_rel, symbol, depth)
3394 }
3395
3396 pub fn trace_to(
3397 &self,
3398 file_rel: &Path,
3399 symbol: &str,
3400 max_depth: usize,
3401 ) -> Result<callgraph::TraceToResult> {
3402 self.inner.trace_to(file_rel, symbol, max_depth)
3403 }
3404
3405 pub fn trace_to_symbol_candidates(
3406 &self,
3407 to_symbol: &str,
3408 ) -> Result<Vec<TraceToSymbolCandidate>> {
3409 self.inner.trace_to_symbol_candidates(to_symbol)
3410 }
3411
3412 pub fn trace_to_symbol(
3413 &self,
3414 file_rel: &Path,
3415 symbol: &str,
3416 to_symbol: &str,
3417 to_file: Option<&Path>,
3418 max_depth: usize,
3419 ) -> Result<callgraph::TraceToSymbolResult> {
3420 self.inner
3421 .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3422 }
3423}
3424
3425impl CallGraphRead for CallGraphStore {
3426 fn project_root(&self) -> &Path {
3427 CallGraphStore::project_root(self)
3428 }
3429 fn project_key(&self) -> &str {
3430 CallGraphStore::project_key(self)
3431 }
3432 fn sqlite_path(&self) -> &Path {
3433 CallGraphStore::sqlite_path(self)
3434 }
3435 fn is_current(&self) -> bool {
3436 CallGraphStore::is_current(self)
3437 }
3438 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3439 CallGraphStore::edge_snapshot(self)
3440 }
3441 fn indexed_file_count(&self) -> Result<usize> {
3442 CallGraphStore::indexed_file_count(self)
3443 }
3444 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3445 CallGraphStore::node_for(self, file_rel, symbol)
3446 }
3447 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3448 CallGraphStore::nodes_for(self, file_rel, symbol)
3449 }
3450 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3451 CallGraphStore::nodes_matching(self, symbol)
3452 }
3453 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3454 CallGraphStore::direct_callers_of(self, file_rel, symbol)
3455 }
3456 fn direct_caller_counts_of(
3457 &self,
3458 targets: &[(String, String)],
3459 ) -> Result<HashMap<(String, String), usize>> {
3460 CallGraphStore::direct_caller_counts_of(self, targets)
3461 }
3462 fn callers_of(
3463 &self,
3464 file_rel: &Path,
3465 symbol: &str,
3466 depth: usize,
3467 ) -> Result<StoreCallersResult> {
3468 CallGraphStore::callers_of(self, file_rel, symbol, depth)
3469 }
3470 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3471 CallGraphStore::impact_of(self, file_rel, symbol, depth)
3472 }
3473 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3474 CallGraphStore::outgoing_calls_of(self, node)
3475 }
3476 fn outgoing_calls_for_symbols(
3477 &self,
3478 sources: &[(String, String)],
3479 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3480 CallGraphStore::outgoing_calls_for_symbols(self, sources)
3481 }
3482 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3483 CallGraphStore::resolved_self_calls_of(self, node)
3484 }
3485 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3486 CallGraphStore::unresolved_calls_of(self, node)
3487 }
3488 fn call_tree(
3489 &self,
3490 file_rel: &Path,
3491 symbol: &str,
3492 depth: usize,
3493 ) -> Result<callgraph::CallTreeNode> {
3494 CallGraphStore::call_tree(self, file_rel, symbol, depth)
3495 }
3496 fn trace_to(
3497 &self,
3498 file_rel: &Path,
3499 symbol: &str,
3500 max_depth: usize,
3501 ) -> Result<callgraph::TraceToResult> {
3502 CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
3503 }
3504 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3505 CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
3506 }
3507 fn trace_to_symbol(
3508 &self,
3509 file_rel: &Path,
3510 symbol: &str,
3511 to_symbol: &str,
3512 to_file: Option<&Path>,
3513 max_depth: usize,
3514 ) -> Result<callgraph::TraceToSymbolResult> {
3515 CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
3516 }
3517}
3518
3519impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
3520 fn project_root(&self) -> &Path {
3521 (**self).project_root()
3522 }
3523 fn project_key(&self) -> &str {
3524 (**self).project_key()
3525 }
3526 fn sqlite_path(&self) -> &Path {
3527 (**self).sqlite_path()
3528 }
3529 fn is_current(&self) -> bool {
3530 (**self).is_current()
3531 }
3532 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3533 (**self).edge_snapshot()
3534 }
3535 fn indexed_file_count(&self) -> Result<usize> {
3536 (**self).indexed_file_count()
3537 }
3538 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3539 (**self).node_for(file_rel, symbol)
3540 }
3541 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3542 (**self).nodes_for(file_rel, symbol)
3543 }
3544 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3545 (**self).nodes_matching(symbol)
3546 }
3547 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3548 (**self).direct_callers_of(file_rel, symbol)
3549 }
3550 fn direct_caller_counts_of(
3551 &self,
3552 targets: &[(String, String)],
3553 ) -> Result<HashMap<(String, String), usize>> {
3554 (**self).direct_caller_counts_of(targets)
3555 }
3556 fn callers_of(
3557 &self,
3558 file_rel: &Path,
3559 symbol: &str,
3560 depth: usize,
3561 ) -> Result<StoreCallersResult> {
3562 (**self).callers_of(file_rel, symbol, depth)
3563 }
3564 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3565 (**self).impact_of(file_rel, symbol, depth)
3566 }
3567 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3568 (**self).outgoing_calls_of(node)
3569 }
3570 fn outgoing_calls_for_symbols(
3571 &self,
3572 sources: &[(String, String)],
3573 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3574 (**self).outgoing_calls_for_symbols(sources)
3575 }
3576 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3577 (**self).resolved_self_calls_of(node)
3578 }
3579 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3580 (**self).unresolved_calls_of(node)
3581 }
3582 fn call_tree(
3583 &self,
3584 file_rel: &Path,
3585 symbol: &str,
3586 depth: usize,
3587 ) -> Result<callgraph::CallTreeNode> {
3588 (**self).call_tree(file_rel, symbol, depth)
3589 }
3590 fn trace_to(
3591 &self,
3592 file_rel: &Path,
3593 symbol: &str,
3594 max_depth: usize,
3595 ) -> Result<callgraph::TraceToResult> {
3596 (**self).trace_to(file_rel, symbol, max_depth)
3597 }
3598 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3599 (**self).trace_to_symbol_candidates(to_symbol)
3600 }
3601 fn trace_to_symbol(
3602 &self,
3603 file_rel: &Path,
3604 symbol: &str,
3605 to_symbol: &str,
3606 to_file: Option<&Path>,
3607 max_depth: usize,
3608 ) -> Result<callgraph::TraceToSymbolResult> {
3609 (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3610 }
3611}
3612
3613impl CallGraphRead for ReadonlyCallGraphStore {
3614 fn project_root(&self) -> &Path {
3615 self.project_root()
3616 }
3617 fn project_key(&self) -> &str {
3618 self.project_key()
3619 }
3620 fn sqlite_path(&self) -> &Path {
3621 self.sqlite_path()
3622 }
3623 fn is_current(&self) -> bool {
3624 self.is_current()
3625 }
3626 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3627 self.edge_snapshot()
3628 }
3629 fn indexed_file_count(&self) -> Result<usize> {
3630 self.indexed_file_count()
3631 }
3632 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3633 self.node_for(file_rel, symbol)
3634 }
3635 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3636 self.nodes_for(file_rel, symbol)
3637 }
3638 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3639 self.nodes_matching(symbol)
3640 }
3641 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3642 self.direct_callers_of(file_rel, symbol)
3643 }
3644 fn direct_caller_counts_of(
3645 &self,
3646 targets: &[(String, String)],
3647 ) -> Result<HashMap<(String, String), usize>> {
3648 self.direct_caller_counts_of(targets)
3649 }
3650 fn callers_of(
3651 &self,
3652 file_rel: &Path,
3653 symbol: &str,
3654 depth: usize,
3655 ) -> Result<StoreCallersResult> {
3656 self.callers_of(file_rel, symbol, depth)
3657 }
3658 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3659 self.impact_of(file_rel, symbol, depth)
3660 }
3661 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3662 self.outgoing_calls_of(node)
3663 }
3664 fn outgoing_calls_for_symbols(
3665 &self,
3666 sources: &[(String, String)],
3667 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3668 self.outgoing_calls_for_symbols(sources)
3669 }
3670 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3671 self.resolved_self_calls_of(node)
3672 }
3673 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3674 self.unresolved_calls_of(node)
3675 }
3676 fn call_tree(
3677 &self,
3678 file_rel: &Path,
3679 symbol: &str,
3680 depth: usize,
3681 ) -> Result<callgraph::CallTreeNode> {
3682 self.call_tree(file_rel, symbol, depth)
3683 }
3684 fn trace_to(
3685 &self,
3686 file_rel: &Path,
3687 symbol: &str,
3688 max_depth: usize,
3689 ) -> Result<callgraph::TraceToResult> {
3690 self.trace_to(file_rel, symbol, max_depth)
3691 }
3692 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3693 self.trace_to_symbol_candidates(to_symbol)
3694 }
3695 fn trace_to_symbol(
3696 &self,
3697 file_rel: &Path,
3698 symbol: &str,
3699 to_symbol: &str,
3700 to_file: Option<&Path>,
3701 max_depth: usize,
3702 ) -> Result<callgraph::TraceToSymbolResult> {
3703 self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3704 }
3705}
3706
3707fn indexed_file_count(conn: &Connection) -> Result<usize> {
3708 let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
3709 Ok(count.max(0) as usize)
3710}
3711
3712fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
3713 let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
3714 match candidates.as_slice() {
3715 [candidate] => Ok(candidate.clone()),
3716 [] => Err(AftError::SymbolNotFound {
3717 name: symbol.to_string(),
3718 file: rel_path.to_string(),
3719 }
3720 .into()),
3721 _ => Err(AftError::AmbiguousSymbol {
3722 name: symbol.to_string(),
3723 candidates: candidates
3724 .iter()
3725 .map(|candidate| candidate.symbol.clone())
3726 .collect(),
3727 }
3728 .into()),
3729 }
3730}
3731
3732fn nodes_for_file_matching_symbol(
3733 conn: &Connection,
3734 rel_path: &str,
3735 symbol: &str,
3736) -> Result<Vec<StoreNode>> {
3737 let qualified_query = symbol.contains("::");
3738 let sql = if qualified_query {
3739 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3740 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3741 FROM nodes n JOIN files f ON f.path = n.file_path
3742 WHERE n.file_path = ?1 AND n.scoped_name = ?2
3743 ORDER BY n.scoped_name, n.start_line, n.start_col"
3744 } else {
3745 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3746 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3747 FROM nodes n JOIN files f ON f.path = n.file_path
3748 WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
3749 ORDER BY n.scoped_name, n.start_line, n.start_col"
3750 };
3751 let mut stmt = conn.prepare(sql)?;
3752 let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
3753 rows.collect::<std::result::Result<Vec<_>, _>>()
3754 .map_err(Into::into)
3755}
3756
3757fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
3758 let qualified_query = symbol.contains("::");
3759 let sql = if qualified_query {
3760 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3761 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3762 FROM nodes n JOIN files f ON f.path = n.file_path
3763 WHERE n.scoped_name = ?1
3764 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
3765 } else {
3766 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3767 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3768 FROM nodes n JOIN files f ON f.path = n.file_path
3769 WHERE n.scoped_name = ?1 OR n.name = ?1
3770 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
3771 };
3772 let mut stmt = conn.prepare(sql)?;
3773 let rows = stmt.query_map(params![symbol], store_node_from_row)?;
3774 rows.collect::<std::result::Result<Vec<_>, _>>()
3775 .map_err(Into::into)
3776}
3777
3778fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
3779 store_node_from_row_at(row, 0)
3780}
3781
3782fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
3783 let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
3784 let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
3785 let lang_label_value: String = row.get(offset + 10)?;
3786 Ok(StoreNode {
3787 node_id: row.get(offset)?,
3788 file: row.get(offset + 1)?,
3789 symbol: row.get(offset + 2)?,
3790 name: row.get(offset + 3)?,
3791 kind: row.get(offset + 4)?,
3792 line: start_line.saturating_add(1),
3793 end_line: end_line.saturating_add(1),
3794 signature: row.get(offset + 7)?,
3795 exported: row.get::<_, i64>(offset + 8)? != 0,
3796 is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
3797 lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
3798 })
3799}
3800
3801fn optional_store_node_from_row_at(
3802 row: &rusqlite::Row<'_>,
3803 offset: usize,
3804) -> rusqlite::Result<Option<StoreNode>> {
3805 if row.get::<_, Option<String>>(offset)?.is_some() {
3806 store_node_from_row_at(row, offset).map(Some)
3807 } else {
3808 Ok(None)
3809 }
3810}
3811
3812#[allow(clippy::too_many_arguments)]
3813fn collect_callers_recursive(
3814 conn: &Connection,
3815 file: &str,
3816 symbol: &str,
3817 max_depth: usize,
3818 current_depth: usize,
3819 visited: &mut HashSet<(String, String)>,
3820 result: &mut Vec<StoreCallSite>,
3821 depth_limited: &mut bool,
3822 truncated: &mut usize,
3823) -> Result<()> {
3824 if current_depth >= max_depth {
3825 let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
3826 if omitted > 0 {
3827 *depth_limited = true;
3828 *truncated += omitted;
3829 }
3830 return Ok(());
3831 }
3832
3833 if !visited.insert((file.to_string(), symbol.to_string())) {
3834 return Ok(());
3835 }
3836
3837 let sites = direct_callers_for_tuple(conn, file, symbol)?;
3838 for site in sites {
3839 result.push(site.clone());
3840 if current_depth + 1 < max_depth {
3841 collect_callers_recursive(
3842 conn,
3843 &site.caller.file,
3844 &site.caller.symbol,
3845 max_depth,
3846 current_depth + 1,
3847 visited,
3848 result,
3849 depth_limited,
3850 truncated,
3851 )?;
3852 } else {
3853 let omitted =
3854 direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
3855 if omitted > 0 {
3856 *depth_limited = true;
3857 *truncated += omitted;
3858 }
3859 }
3860 }
3861 Ok(())
3862}
3863
3864const DIRECT_CALLER_COUNT_BATCH_SIZE: usize = 499;
3866
3867fn direct_caller_counts_for_tuples(
3868 conn: &Connection,
3869 targets: &[(String, String)],
3870) -> Result<HashMap<(String, String), usize>> {
3871 let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
3872 let mut counts = unique_targets
3873 .iter()
3874 .cloned()
3875 .map(|target| (target, 0usize))
3876 .collect::<HashMap<_, _>>();
3877
3878 let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
3879 for chunk in unique_targets.chunks(DIRECT_CALLER_COUNT_BATCH_SIZE) {
3880 let requested_values = (0..chunk.len())
3881 .map(|_| "(?, ?)")
3882 .collect::<Vec<_>>()
3883 .join(", ");
3884 let sql = format!(
3885 "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
3886 deduped AS (
3887 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
3888 FROM requested requested
3889 JOIN edges e
3890 ON e.target_file = requested.target_file
3891 AND e.target_symbol = requested.target_symbol
3892 AND e.kind = 'call'
3893 JOIN refs r ON r.ref_id = e.ref_id
3894 JOIN nodes src ON src.id = e.source_node
3895 JOIN files src_file ON src_file.path = src.file_path
3896 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
3897 )
3898 SELECT target_file, target_symbol, COUNT(*)
3899 FROM deduped
3900 GROUP BY target_file, target_symbol"
3901 );
3902 let bindings = chunk
3903 .iter()
3904 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
3905 let mut stmt = conn.prepare(&sql)?;
3906 let rows = stmt.query_map(params_from_iter(bindings), |row| {
3907 Ok((
3908 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
3909 row.get::<_, i64>(2)?,
3910 ))
3911 })?;
3912 for row in rows {
3913 let (target, count) = row?;
3914 counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
3915 }
3916 }
3917
3918 Ok(counts)
3919}
3920
3921fn direct_caller_count_for_tuple(
3922 conn: &Connection,
3923 target_file: &str,
3924 target_symbol: &str,
3925) -> Result<usize> {
3926 let count: i64 = conn.query_row(
3927 "SELECT COUNT(*)
3928 FROM edges e
3929 JOIN refs r ON r.ref_id = e.ref_id
3930 JOIN nodes src ON src.id = e.source_node
3931 JOIN files src_file ON src_file.path = src.file_path
3932 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
3933 params![target_file, target_symbol],
3934 |row| row.get(0),
3935 )?;
3936 Ok(usize::try_from(count).unwrap_or(usize::MAX))
3937}
3938
3939fn direct_callers_for_tuple(
3940 conn: &Connection,
3941 target_file: &str,
3942 target_symbol: &str,
3943) -> Result<Vec<StoreCallSite>> {
3944 let mut stmt = conn.prepare(
3945 "SELECT e.target_file, e.target_symbol, e.line,
3946 r.byte_start, r.byte_end, r.status, e.provenance,
3947 src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
3948 src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
3949 src_file.lang,
3950 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
3951 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
3952 tgt_file.lang
3953 FROM edges e
3954 JOIN refs r ON r.ref_id = e.ref_id
3955 JOIN nodes src ON src.id = e.source_node
3956 JOIN files src_file ON src_file.path = src.file_path
3957 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
3958 ON tgt.id = e.target_node
3959 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
3960 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
3961 )?;
3962 let rows = stmt.query_map(params![target_file, target_symbol], |row| {
3963 let caller = store_node_from_row_at(row, 7)?;
3964 let target = optional_store_node_from_row_at(row, 18)?;
3965 Ok(StoreCallSite {
3966 caller,
3967 target_file: row.get(0)?,
3968 target_symbol: row.get(1)?,
3969 target,
3970 line: row.get::<_, i64>(2)?.max(0) as u32,
3971 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
3972 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
3973 resolved: row.get::<_, String>(5)? == "resolved",
3974 provenance: row.get(6)?,
3975 })
3976 })?;
3977 rows.collect::<std::result::Result<Vec<_>, _>>()
3978 .map_err(Into::into)
3979}
3980
3981const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
3983const OUTGOING_NODE_BATCH_SIZE: usize = 999;
3985
3986fn outgoing_calls_for_symbol_tuples(
3987 conn: &Connection,
3988 sources: &[(String, String)],
3989) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3990 let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
3991 let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
3992 let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
3993 let source_nodes = unique_sources
3994 .iter()
3995 .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
3996 .cloned()
3997 .collect::<Vec<_>>();
3998 let source_nodes_by_id = source_nodes
3999 .iter()
4000 .cloned()
4001 .map(|node| (node.node_id.clone(), node))
4002 .collect::<HashMap<_, _>>();
4003 let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
4004
4005 for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
4006 let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
4007 let sql = format!(
4008 "SELECT e.source_node,
4009 e.target_file, e.target_symbol, e.line,
4010 r.byte_start, r.byte_end, r.status, e.provenance,
4011 CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
4012 tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4013 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4014 tgt_file.lang
4015 FROM edges e
4016 JOIN refs r ON r.ref_id = e.ref_id
4017 LEFT JOIN nodes tgt ON tgt.id = e.target_node
4018 LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
4019 WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
4020 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
4021 );
4022 let bindings = chunk.iter().map(|node| node.node_id.as_str());
4023 let mut stmt = conn.prepare(&sql)?;
4024 let rows = stmt.query_map(params_from_iter(bindings), |row| {
4025 let source_node_id = row.get::<_, String>(0)?;
4026 let caller = source_nodes_by_id
4027 .get(&source_node_id)
4028 .expect("batched outgoing row belongs to a requested source node")
4029 .clone();
4030 let target = optional_store_node_from_row_at(row, 8)?;
4031 Ok((
4032 source_node_id,
4033 StoreCallSite {
4034 caller,
4035 target_file: row.get(1)?,
4036 target_symbol: row.get(2)?,
4037 target,
4038 line: row.get::<_, i64>(3)?.max(0) as u32,
4039 byte_start: row.get::<_, i64>(4)?.max(0) as usize,
4040 byte_end: row.get::<_, i64>(5)?.max(0) as usize,
4041 resolved: row.get::<_, String>(6)? == "resolved",
4042 provenance: row.get(7)?,
4043 },
4044 ))
4045 })?;
4046 for row in rows {
4047 let (source_node_id, call) = row?;
4048 calls_by_node.entry(source_node_id).or_default().push(call);
4049 }
4050 }
4051
4052 let mut calls_by_source = HashMap::new();
4053 for source in &unique_sources {
4054 let mut calls = Vec::new();
4055 if let Some(nodes) = source_nodes_by_symbol.get(source) {
4056 for node in nodes {
4057 if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
4058 calls.extend(node_calls);
4059 }
4060 }
4061 }
4062 calls_by_source.insert(source.clone(), calls);
4063 }
4064
4065 let target_tuples = calls_by_source
4068 .values()
4069 .flatten()
4070 .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
4071 .collect::<Vec<_>>();
4072 let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
4073 for calls in calls_by_source.values_mut() {
4074 for call in calls {
4075 if let Some(target) = target_nodes
4076 .get(&(call.target_file.clone(), call.target_symbol.clone()))
4077 .and_then(|nodes| nodes.first())
4078 {
4079 call.target = Some(target.clone());
4080 }
4081 }
4082 }
4083
4084 Ok(calls_by_source)
4085}
4086
4087fn nodes_for_symbol_tuples(
4088 conn: &Connection,
4089 symbols: &[(String, String)],
4090) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
4091 let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
4092 let mut nodes_by_symbol = unique_symbols
4093 .iter()
4094 .cloned()
4095 .map(|symbol| (symbol, Vec::new()))
4096 .collect::<HashMap<_, _>>();
4097 let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
4098
4099 for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
4100 let requested_values = (0..chunk.len())
4101 .map(|_| "(?, ?)")
4102 .collect::<Vec<_>>()
4103 .join(", ");
4104 let sql = format!(
4105 "WITH requested(file, symbol) AS (VALUES {requested_values})
4106 SELECT requested.file, requested.symbol,
4107 node.id, node.file_path, node.scoped_name, node.name, node.kind,
4108 node.start_line, node.end_line, node.signature, node.exported,
4109 node.is_callgraph_entry_point, node_file.lang
4110 FROM requested
4111 JOIN nodes node INDEXED BY idx_nodes_file
4112 ON node.file_path = requested.file
4113 AND node.scoped_name = requested.symbol
4114 JOIN files node_file ON node_file.path = node.file_path
4115 ORDER BY requested.file, requested.symbol,
4116 node.scoped_name, node.start_line, node.end_line,
4117 node.start_col, node.range_ordinal"
4118 );
4119 let bindings = chunk
4120 .iter()
4121 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4122 let mut stmt = conn.prepare(&sql)?;
4123 let rows = stmt.query_map(params_from_iter(bindings), |row| {
4124 Ok((
4125 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
4126 store_node_from_row_at(row, 2)?,
4127 ))
4128 })?;
4129 for row in rows {
4130 let (symbol, node) = row?;
4131 nodes_by_symbol.entry(symbol).or_default().push(node);
4132 }
4133 }
4134
4135 Ok(nodes_by_symbol)
4136}
4137
4138fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4139 let mut stmt = conn.prepare(
4140 "SELECT e.target_file, e.target_symbol, e.line,
4141 r.byte_start, r.byte_end, r.status, e.provenance,
4142 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4143 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4144 tgt_file.lang
4145 FROM edges e
4146 JOIN refs r ON r.ref_id = e.ref_id
4147 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4148 ON tgt.id = e.target_node
4149 WHERE e.kind = 'call' AND e.source_node = ?1
4150 ORDER BY r.byte_start, r.line, r.ref_id",
4151 )?;
4152 let rows = stmt.query_map(params![node.node_id], |row| {
4153 let target = optional_store_node_from_row_at(row, 7)?;
4154 Ok(StoreCallSite {
4155 caller: node.clone(),
4156 target_file: row.get(0)?,
4157 target_symbol: row.get(1)?,
4158 target,
4159 line: row.get::<_, i64>(2)?.max(0) as u32,
4160 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4161 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4162 resolved: row.get::<_, String>(5)? == "resolved",
4163 provenance: row.get(6)?,
4164 })
4165 })?;
4166 rows.collect::<std::result::Result<Vec<_>, _>>()
4167 .map_err(Into::into)
4168}
4169
4170fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4171 let mut stmt = conn.prepare(
4172 "SELECT r.target_file, r.target_symbol, r.line,
4173 r.byte_start, r.byte_end, r.status, r.provenance,
4174 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4175 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4176 tgt_file.lang
4177 FROM refs r
4178 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4179 ON tgt.id = r.target_node
4180 WHERE r.caller_node = ?1
4181 AND r.kind = 'call'
4182 AND r.status <> 'unresolved'
4183 AND r.target_file = ?2
4184 AND r.target_symbol = ?3
4185 AND r.provenance = ?4
4186 AND NOT EXISTS (
4187 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
4188 )
4189 ORDER BY r.byte_start, r.line, r.ref_id",
4190 )?;
4191 let rows = stmt.query_map(
4192 params![
4193 &node.node_id,
4194 &node.file,
4195 &node.symbol,
4196 PROVENANCE_TREESITTER
4197 ],
4198 |row| {
4199 let target = optional_store_node_from_row_at(row, 7)?;
4200 Ok(StoreCallSite {
4201 caller: node.clone(),
4202 target_file: row.get(0)?,
4203 target_symbol: row.get(1)?,
4204 target,
4205 line: row.get::<_, i64>(2)?.max(0) as u32,
4206 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4207 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4208 resolved: row.get::<_, String>(5)? == "resolved",
4209 provenance: row.get(6)?,
4210 })
4211 },
4212 )?;
4213 rows.collect::<std::result::Result<Vec<_>, _>>()
4214 .map_err(Into::into)
4215}
4216
4217fn unresolved_calls_for_node(
4218 conn: &Connection,
4219 node: &StoreNode,
4220) -> Result<Vec<StoreUnresolvedCall>> {
4221 let mut stmt = conn.prepare(
4222 "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
4223 FROM refs
4224 WHERE caller_node = ?1
4225 AND kind = 'call'
4226 AND status = 'unresolved'
4227 AND NOT EXISTS (
4228 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4229 )
4230 ORDER BY byte_start, line, ref_id",
4231 )?;
4232 let rows = stmt.query_map(params![node.node_id], |row| {
4233 Ok(StoreUnresolvedCall {
4234 caller: node.clone(),
4235 symbol: row.get(0)?,
4236 full_ref: row.get(1)?,
4237 line: row.get::<_, i64>(2)?.max(0) as u32,
4238 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4239 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4240 })
4241 })?;
4242 rows.collect::<std::result::Result<Vec<_>, _>>()
4243 .map_err(Into::into)
4244}
4245
4246fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
4247 let mut calls = Vec::new();
4248 calls.extend(
4249 outgoing_calls_for_node(conn, node)?
4250 .into_iter()
4251 .map(StoreForwardCall::Resolved),
4252 );
4253 calls.extend(
4254 unresolved_calls_for_node(conn, node)?
4255 .into_iter()
4256 .map(StoreForwardCall::Unresolved),
4257 );
4258 calls.sort_by(|left, right| {
4259 left.byte_start()
4260 .cmp(&right.byte_start())
4261 .then(left.line().cmp(&right.line()))
4262 });
4263 Ok(calls)
4264}
4265
4266fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
4267 let resolved_count: i64 = conn.query_row(
4268 "SELECT COUNT(*)
4269 FROM edges e
4270 JOIN refs r ON r.ref_id = e.ref_id
4271 WHERE e.kind = 'call' AND e.source_node = ?1",
4272 params![&node.node_id],
4273 |row| row.get(0),
4274 )?;
4275 let unresolved_count: i64 = conn.query_row(
4276 "SELECT COUNT(*)
4277 FROM refs
4278 WHERE caller_node = ?1
4279 AND kind = 'call'
4280 AND status = 'unresolved'
4281 AND NOT EXISTS (
4282 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4283 )",
4284 params![&node.node_id],
4285 |row| row.get(0),
4286 )?;
4287 let total = resolved_count.saturating_add(unresolved_count);
4288 Ok(usize::try_from(total).unwrap_or(usize::MAX))
4289}
4290
4291fn call_tree_inner(
4292 conn: &Connection,
4293 node: &StoreNode,
4294 max_depth: usize,
4295 current_depth: usize,
4296 visited: &mut HashSet<(String, String)>,
4297) -> Result<callgraph::CallTreeNode> {
4298 let visit_key = (node.file.clone(), node.symbol.clone());
4299 if visited.contains(&visit_key) {
4300 return Ok(callgraph::CallTreeNode {
4301 name: node.symbol.clone(),
4302 file: node.file.clone(),
4303 line: node.line,
4304 signature: node.signature.clone(),
4305 resolved: true,
4306 children: Vec::new(),
4307 depth_limited: false,
4308 truncated: 0,
4309 });
4310 }
4311 visited.insert(visit_key.clone());
4312
4313 let mut children = Vec::new();
4314 let mut depth_limited = false;
4315 let mut truncated = 0usize;
4316
4317 if current_depth < max_depth {
4318 let calls = forward_calls_for_node(conn, node)?;
4319 for call in calls {
4320 match call {
4321 StoreForwardCall::Resolved(site) => {
4322 if let Some(target) = site.target {
4323 let child =
4324 call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
4325 depth_limited |= child.depth_limited;
4326 truncated += child.truncated;
4327 children.push(child);
4328 } else {
4329 children.push(callgraph::CallTreeNode {
4330 name: site.target_symbol,
4331 file: site.target_file,
4332 line: site.line,
4333 signature: None,
4334 resolved: false,
4335 children: Vec::new(),
4336 depth_limited: false,
4337 truncated: 0,
4338 });
4339 }
4340 }
4341 StoreForwardCall::Unresolved(call) => {
4342 children.push(callgraph::CallTreeNode {
4343 name: call.symbol,
4344 file: call.caller.file,
4345 line: call.line,
4346 signature: None,
4347 resolved: false,
4348 children: Vec::new(),
4349 depth_limited: false,
4350 truncated: 0,
4351 });
4352 }
4353 }
4354 }
4355 } else {
4356 truncated = forward_call_count_for_node(conn, node)?;
4357 depth_limited = truncated > 0;
4358 }
4359
4360 visited.remove(&visit_key);
4361 Ok(callgraph::CallTreeNode {
4362 name: node.symbol.clone(),
4363 file: node.file.clone(),
4364 line: node.line,
4365 signature: node.signature.clone(),
4366 resolved: true,
4367 children,
4368 depth_limited,
4369 truncated,
4370 })
4371}
4372
4373fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
4374 callgraph::TraceToSymbolHop {
4375 symbol: node.symbol.clone(),
4376 file: node.file.clone(),
4377 line: node.line,
4378 }
4379}
4380
4381fn trace_to_symbol_matches_target(
4382 node: &StoreNode,
4383 to_symbol: &str,
4384 to_file: Option<&str>,
4385) -> bool {
4386 if !symbol_query_matches(&node.symbol, to_symbol) {
4387 return false;
4388 }
4389 match to_file {
4390 Some(file) => node.file == file,
4391 None => true,
4392 }
4393}
4394
4395fn symbol_query_matches(symbol: &str, query: &str) -> bool {
4396 symbol == query || unqualified_name(symbol) == query
4397}
4398
4399fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
4400 let source = std::fs::read_to_string(path).ok()?;
4401 Some(source.lines().map(|line| line.trim().to_string()).collect())
4402}
4403
4404#[doc(hidden)]
4405pub fn live_callgraph_edge_snapshot(
4406 project_root: &Path,
4407 files: &[PathBuf],
4408) -> Result<BTreeSet<StoredEdge>> {
4409 let files = normalize_file_list(project_root, files)?;
4410 let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
4411 let mut file_data = Vec::new();
4412 for file in &files {
4413 let canon = canonicalize_path(file);
4414 let data = graph.build_file(&canon)?.clone();
4415 file_data.push((canon, data));
4416 }
4417
4418 let mut edges = BTreeSet::new();
4419 for (caller_file, data) in &file_data {
4420 for (caller_symbol, call_sites) in &data.calls_by_symbol {
4421 for call_site in call_sites {
4422 let resolution = graph.resolve_cross_file_edge(
4423 &call_site.full_callee,
4424 &call_site.callee_name,
4425 caller_file,
4426 &data.import_block,
4427 );
4428 let (target_file, target_symbol) = match resolution {
4429 EdgeResolution::Resolved { file, symbol } => (file, symbol),
4430 EdgeResolution::Unresolved { callee_name } => {
4431 if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
4432 continue;
4433 }
4434 let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
4435 data,
4436 caller_file,
4437 &callee_name,
4438 ) else {
4439 continue;
4440 };
4441 (caller_file.clone(), target_symbol)
4442 }
4443 };
4444 if target_file == *caller_file && target_symbol == *caller_symbol {
4445 continue;
4446 }
4447 edges.insert(StoredEdge {
4448 source_file: relative_path(project_root, caller_file),
4449 source_symbol: caller_symbol.clone(),
4450 target_file: relative_path(project_root, &target_file),
4451 target_symbol,
4452 kind: "call".to_string(),
4453 line: call_site.line,
4454 });
4455 }
4456 }
4457 }
4458 Ok(edges)
4459}
4460
4461fn acquire_writer_lease(
4462 callgraph_dir: &Path,
4463 project_key: &str,
4464 project_root: &Path,
4465) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
4466 crate::root_cache::WriterLease::acquire_shared(
4467 crate::root_cache::RootCacheDomain::Callgraph,
4468 callgraph_dir,
4469 project_key,
4470 project_root,
4471 )
4472 .map_err(CallGraphStoreError::from)
4473}
4474
4475fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
4476 if lease.verify()? {
4477 Ok(())
4478 } else {
4479 Err(CallGraphStoreError::Unavailable(format!(
4480 "callgraph writer lease for key {} lost epoch {}; aborting write",
4481 lease.key(),
4482 lease.epoch()
4483 )))
4484 }
4485}
4486
4487fn legacy_migration_completion_line(
4488 project_key: &str,
4489 method: &str,
4490 legacy_bytes: u64,
4491 migrated_bytes: u64,
4492) -> String {
4493 format!(
4494 "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
4495 )
4496}
4497
4498fn log_legacy_migration_completion(
4499 project_key: &str,
4500 method: &str,
4501 legacy_bytes: u64,
4502 migrated_bytes: u64,
4503) {
4504 crate::slog_info!(
4505 "{}",
4506 legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
4507 );
4508}
4509
4510fn try_legacy_migration_or_fallback(
4511 callgraph_dir: &Path,
4512 project_root: &Path,
4513 project_key: &str,
4514 writer_lease: Arc<crate::root_cache::WriterLease>,
4515) -> Result<Option<CallGraphStore>> {
4516 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
4517 if partitions.is_empty() {
4518 return Ok(None);
4519 }
4520
4521 for partition in &partitions {
4522 if let Some(source) = newest_superseded_legacy_generation(partition)? {
4523 if !migration_disk_floor_allows(&source, callgraph_dir)? {
4524 return open_legacy_fallback_store(
4525 callgraph_dir,
4526 project_root,
4527 project_key,
4528 &partitions,
4529 );
4530 }
4531 match publish_generation_copy_migration(
4532 callgraph_dir,
4533 project_key,
4534 &source,
4535 Arc::clone(&writer_lease),
4536 ) {
4537 Ok(published) => {
4538 log_legacy_migration_completion(
4539 project_key,
4540 "generation_copy",
4541 source.source_bytes,
4542 published.migrated_bytes,
4543 );
4544 return CallGraphStore::open_generation(
4545 callgraph_dir,
4546 project_root.to_path_buf(),
4547 project_key.to_string(),
4548 published.generation,
4549 writer_lease,
4550 )
4551 .map(Some);
4552 }
4553 Err(error) => {
4554 crate::slog_warn!(
4555 "root-keyed callgraph generation-copy migration failed from {}: {}",
4556 source.sqlite_path.display(),
4557 error
4558 );
4559 return open_legacy_fallback_store(
4560 callgraph_dir,
4561 project_root,
4562 project_key,
4563 &partitions,
4564 );
4565 }
4566 }
4567 }
4568
4569 if let Some(source) = current_legacy_generation(partition)? {
4570 if !migration_disk_floor_allows(&source, callgraph_dir)? {
4571 return open_legacy_fallback_store(
4572 callgraph_dir,
4573 project_root,
4574 project_key,
4575 &partitions,
4576 );
4577 }
4578 match publish_backup_migration(
4579 callgraph_dir,
4580 project_key,
4581 &source,
4582 Arc::clone(&writer_lease),
4583 ) {
4584 Ok(published) => {
4585 log_legacy_migration_completion(
4586 project_key,
4587 "sqlite_backup",
4588 source.source_bytes,
4589 published.migrated_bytes,
4590 );
4591 return CallGraphStore::open_generation(
4592 callgraph_dir,
4593 project_root.to_path_buf(),
4594 project_key.to_string(),
4595 published.generation,
4596 writer_lease,
4597 )
4598 .map(Some);
4599 }
4600 Err(error) => {
4601 crate::slog_warn!(
4602 "root-keyed callgraph backup migration failed from {}: {}",
4603 source.sqlite_path.display(),
4604 error
4605 );
4606 return open_legacy_fallback_store(
4607 callgraph_dir,
4608 project_root,
4609 project_key,
4610 &partitions,
4611 );
4612 }
4613 }
4614 }
4615 }
4616
4617 open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
4618}
4619
4620fn open_legacy_fallback_store(
4621 callgraph_dir: &Path,
4622 project_root: &Path,
4623 project_key: &str,
4624 partitions: &[LegacyCallgraphPartition],
4625) -> Result<Option<CallGraphStore>> {
4626 let Some(target) = first_ready_legacy_target(partitions)? else {
4627 return Ok(None);
4628 };
4629 crate::slog_warn!(
4630 "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
4631 target.partition.harness,
4632 target.sqlite_path.display()
4633 );
4634 let conn = open_readonly_connection(&target.sqlite_path)?;
4635 if !database_ready(&conn).unwrap_or(false) {
4636 return Ok(None);
4637 }
4638 let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
4639 let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
4640 Ok(Some(CallGraphStore::from_connection(
4641 project_root.to_path_buf(),
4642 project_key.to_string(),
4643 target.sqlite_path,
4644 callgraph_dir.to_path_buf(),
4645 true,
4646 target.generation,
4647 None,
4648 Some(read_marker),
4649 conn,
4650 )))
4651}
4652
4653fn migration_disk_floor_allows(
4654 source: &LegacyCallgraphTarget,
4655 callgraph_dir: &Path,
4656) -> Result<bool> {
4657 let available = migration_available_disk(callgraph_dir)?;
4658 let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
4659 source.source_bytes,
4660 available,
4661 );
4662 if decision.should_skip_copy() {
4663 crate::slog_warn!(
4664 "{}",
4665 decision.warning_message(&source.sqlite_path, callgraph_dir)
4666 );
4667 return Ok(false);
4668 }
4669 Ok(true)
4670}
4671
4672fn migration_available_disk(path: &Path) -> Result<u64> {
4673 if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
4674 return Ok(bytes);
4675 }
4676 crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
4677}
4678
4679fn legacy_callgraph_partitions(
4680 callgraph_dir: &Path,
4681 project_key: &str,
4682) -> Result<Vec<LegacyCallgraphPartition>> {
4683 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
4684 return Ok(Vec::new());
4685 };
4686 let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
4687 let mut partitions = inventory
4688 .into_iter()
4689 .filter(|entry| {
4690 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
4691 && entry.key == project_key
4692 })
4693 .map(|entry| {
4694 let dir = if entry.path.is_dir() {
4695 entry.path.clone()
4696 } else {
4697 entry
4698 .path
4699 .parent()
4700 .map(Path::to_path_buf)
4701 .unwrap_or_else(|| entry.path.clone())
4702 };
4703 LegacyCallgraphPartition {
4704 harness: entry.harness,
4705 dir,
4706 key: entry.key,
4707 bytes: entry.bytes,
4708 freshness: entry.callgraph_pointer_mtime,
4709 }
4710 })
4711 .collect::<Vec<_>>();
4712 partitions.sort_by(|left, right| {
4713 right
4714 .freshness
4715 .cmp(&left.freshness)
4716 .then_with(|| right.bytes.cmp(&left.bytes))
4717 .then_with(|| left.harness.cmp(&right.harness))
4718 });
4719 Ok(partitions)
4720}
4721
4722fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
4723 let domain_dir = callgraph_dir.parent()?;
4724 if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
4725 return None;
4726 }
4727 domain_dir.parent().map(Path::to_path_buf)
4728}
4729
4730pub(crate) fn all_legacy_partitions_migrated_for_keys(
4731 callgraph_dir: &Path,
4732 configured_keys: &BTreeSet<String>,
4733) -> Result<bool> {
4734 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
4735 return Ok(false);
4736 };
4737 let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
4738 .into_iter()
4739 .filter(|entry| {
4740 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
4741 && configured_keys.contains(&entry.key)
4742 })
4743 .map(|entry| entry.key)
4744 .collect::<BTreeSet<_>>();
4745 if legacy_keys.is_empty() {
4746 return Ok(false);
4747 }
4748
4749 for key in legacy_keys {
4750 let migrated_dir = storage_root.join("callgraph").join(&key);
4751 let Some(generation) = read_pointer(&migrated_dir, &key) else {
4752 return Ok(false);
4753 };
4754 if !migration_generation_requires_manifest(&generation)
4755 || !migration_manifest_valid(&migrated_dir, &generation)
4756 {
4757 return Ok(false);
4758 }
4759 }
4760 Ok(true)
4761}
4762
4763fn newest_superseded_legacy_generation(
4764 partition: &LegacyCallgraphPartition,
4765) -> Result<Option<LegacyCallgraphTarget>> {
4766 let Some(current) = read_pointer(&partition.dir, &partition.key) else {
4767 return Ok(None);
4768 };
4769 let prefix = format!("{}.g", partition.key);
4770 let Ok(entries) = std::fs::read_dir(&partition.dir) else {
4771 return Ok(None);
4772 };
4773 let mut candidates = Vec::new();
4774 for entry in entries.flatten() {
4775 let name = entry.file_name().to_string_lossy().to_string();
4776 if name == current
4777 || name.contains(".tmp.")
4778 || !name.starts_with(&prefix)
4779 || !name.ends_with(".sqlite")
4780 {
4781 continue;
4782 }
4783 let path = entry.path();
4784 if !db_path_ready(&path) {
4785 continue;
4786 }
4787 let modified = entry
4788 .metadata()
4789 .and_then(|metadata| metadata.modified())
4790 .unwrap_or(SystemTime::UNIX_EPOCH);
4791 candidates.push((modified, path, name));
4792 }
4793 candidates.sort_by(|left, right| right.0.cmp(&left.0));
4794 let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
4795 return Ok(None);
4796 };
4797 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
4798 Ok(Some(LegacyCallgraphTarget {
4799 partition: partition.clone(),
4800 sqlite_path,
4801 generation: Some(generation),
4802 source_bytes,
4803 source_blake3: String::new(),
4804 }))
4805}
4806
4807fn current_legacy_generation(
4808 partition: &LegacyCallgraphPartition,
4809) -> Result<Option<LegacyCallgraphTarget>> {
4810 let Some(target) = ready_legacy_target(partition)? else {
4811 return Ok(None);
4812 };
4813 let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
4814 if has_superseded {
4815 return Ok(None);
4816 }
4817 Ok(Some(target))
4818}
4819
4820fn freshest_legacy_fallback_target(
4821 callgraph_dir: &Path,
4822 project_key: &str,
4823) -> Result<Option<LegacyCallgraphTarget>> {
4824 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
4825 first_ready_legacy_target(&partitions)
4826}
4827
4828fn first_ready_legacy_target(
4829 partitions: &[LegacyCallgraphPartition],
4830) -> Result<Option<LegacyCallgraphTarget>> {
4831 for partition in partitions {
4832 if let Some(target) = ready_legacy_target(partition)? {
4833 return Ok(Some(target));
4834 }
4835 }
4836 Ok(None)
4837}
4838
4839fn ready_legacy_target(
4840 partition: &LegacyCallgraphPartition,
4841) -> Result<Option<LegacyCallgraphTarget>> {
4842 if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
4843 let sqlite_path = partition.dir.join(&generation);
4844 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
4845 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
4846 return Ok(Some(LegacyCallgraphTarget {
4847 partition: partition.clone(),
4848 sqlite_path,
4849 generation: Some(generation),
4850 source_bytes,
4851 source_blake3: String::new(),
4852 }));
4853 }
4854 }
4855
4856 let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
4857 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
4858 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
4859 return Ok(Some(LegacyCallgraphTarget {
4860 partition: partition.clone(),
4861 sqlite_path,
4862 generation: None,
4863 source_bytes,
4864 source_blake3: String::new(),
4865 }));
4866 }
4867 Ok(None)
4868}
4869
4870fn publish_generation_copy_migration(
4871 callgraph_dir: &Path,
4872 project_key: &str,
4873 source: &LegacyCallgraphTarget,
4874 writer_lease: Arc<crate::root_cache::WriterLease>,
4875) -> Result<PublishedLegacyMigration> {
4876 let generation = migration_generation_file_name(project_key, "copy");
4877 let temp_path = migration_temp_path(callgraph_dir, &generation);
4878 remove_sqlite_file_set(&temp_path);
4879 copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
4880 fail_after_temp_copy_for_test()?;
4881
4882 let mut source = source.clone();
4883 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
4884 source.source_blake3 = fingerprint.blake3;
4885 let generation = publish_migrated_generation(
4886 callgraph_dir,
4887 project_key,
4888 &generation,
4889 &temp_path,
4890 &source,
4891 fingerprint.bytes,
4892 writer_lease,
4893 "generation_copy",
4894 )?;
4895 Ok(PublishedLegacyMigration {
4896 generation,
4897 migrated_bytes: fingerprint.bytes,
4898 })
4899}
4900
4901fn publish_backup_migration(
4902 callgraph_dir: &Path,
4903 project_key: &str,
4904 source: &LegacyCallgraphTarget,
4905 writer_lease: Arc<crate::root_cache::WriterLease>,
4906) -> Result<PublishedLegacyMigration> {
4907 if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
4908 return Err(CallGraphStoreError::Unavailable(
4909 "legacy callgraph backup migration budget exhausted by test seam".to_string(),
4910 ));
4911 }
4912
4913 let generation = migration_generation_file_name(project_key, "backup");
4914 let temp_path = migration_temp_path(callgraph_dir, &generation);
4915 remove_sqlite_file_set(&temp_path);
4916
4917 let source_conn = open_readonly_connection(&source.sqlite_path)?;
4918 let mut destination = Connection::open(&temp_path)?;
4919 destination.busy_timeout(Duration::from_secs(5))?;
4920 let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
4921 let started = Instant::now();
4922 let mut retries = 0;
4923 loop {
4924 match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
4925 rusqlite::backup::StepResult::Done => break,
4926 rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
4927 rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
4928 retries += 1;
4929 if retries > MIGRATION_BACKUP_RETRY_BUDGET
4930 || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
4931 {
4932 return Err(CallGraphStoreError::Unavailable(format!(
4933 "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
4934 )));
4935 }
4936 std::thread::sleep(Duration::from_millis(20));
4937 }
4938 _ => {
4939 return Err(CallGraphStoreError::Unavailable(
4940 "legacy callgraph backup returned an unknown step result".to_string(),
4941 ));
4942 }
4943 }
4944 }
4945 drop(backup);
4946
4947 let integrity: String =
4948 destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
4949 if integrity != "ok" {
4950 return Err(CallGraphStoreError::Unavailable(format!(
4951 "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
4952 )));
4953 }
4954 if !database_ready(&destination)? {
4955 return Err(CallGraphStoreError::Unavailable(
4956 "legacy callgraph backup produced a database without ready metadata".to_string(),
4957 ));
4958 }
4959 destination.execute_batch("PRAGMA optimize;")?;
4960 drop(destination);
4961 sync_file(&temp_path)?;
4962 fail_after_temp_copy_for_test()?;
4963
4964 let mut source = source.clone();
4965 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
4966 source.source_blake3 = fingerprint.blake3;
4967 let generation = publish_migrated_generation(
4968 callgraph_dir,
4969 project_key,
4970 &generation,
4971 &temp_path,
4972 &source,
4973 fingerprint.bytes,
4974 writer_lease,
4975 "sqlite_backup",
4976 )?;
4977 Ok(PublishedLegacyMigration {
4978 generation,
4979 migrated_bytes: fingerprint.bytes,
4980 })
4981}
4982
4983fn publish_migrated_generation(
4984 callgraph_dir: &Path,
4985 project_key: &str,
4986 generation: &str,
4987 temp_path: &Path,
4988 source: &LegacyCallgraphTarget,
4989 migrated_bytes: u64,
4990 writer_lease: Arc<crate::root_cache::WriterLease>,
4991 method: &str,
4992) -> Result<String> {
4993 let gen_path = callgraph_dir.join(generation);
4994 let publication = publish_if_current(|| {
4995 verify_writer_lease(&writer_lease)?;
4996 remove_sqlite_file_set(&gen_path);
4997 rename_sqlite_file_set(temp_path, &gen_path)?;
4998 crate::fs_lock::sync_parent(&gen_path);
4999
5000 verify_writer_lease(&writer_lease)?;
5001 publish_pointer(callgraph_dir, project_key, generation)?;
5002 write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
5003 Ok(generation.to_string())
5004 });
5005 if matches!(publication, Err(CallGraphStoreError::Superseded)) {
5006 remove_sqlite_file_set(temp_path);
5007 }
5008 publication
5009}
5010
5011fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
5012 if let Some(parent) = destination.parent() {
5013 std::fs::create_dir_all(parent)?;
5014 }
5015 for suffix in SQLITE_FILE_SET_SUFFIXES {
5016 let source_path = sqlite_file_set_path(source, suffix);
5017 if !source_path.is_file() {
5018 continue;
5019 }
5020 let destination_path = sqlite_file_set_path(destination, suffix);
5021 std::fs::copy(&source_path, &destination_path)?;
5022 sync_file(&destination_path)?;
5023 }
5024 Ok(())
5025}
5026
5027fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
5028 for suffix in SQLITE_FILE_SET_SUFFIXES {
5029 let source_path = sqlite_file_set_path(source, suffix);
5030 if !source_path.exists() {
5031 continue;
5032 }
5033 let destination_path = sqlite_file_set_path(destination, suffix);
5034 if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
5035 let _ = std::fs::remove_file(&source_path);
5036 return Err(error.into());
5037 }
5038 }
5039 Ok(())
5040}
5041
5042fn sqlite_file_set_size(path: &Path) -> Result<u64> {
5043 let mut bytes = 0_u64;
5044 for suffix in SQLITE_FILE_SET_SUFFIXES {
5045 let member = sqlite_file_set_path(path, suffix);
5046 if !member.is_file() {
5047 continue;
5048 }
5049 bytes = bytes.saturating_add(member.metadata()?.len());
5050 }
5051 Ok(bytes)
5052}
5053
5054fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
5055 let mut hasher = blake3::Hasher::new();
5056 let mut bytes = 0_u64;
5057 let mut buffer = [0_u8; 64 * 1024];
5058 for suffix in SQLITE_FILE_SET_SUFFIXES {
5059 let member = sqlite_file_set_path(path, suffix);
5060 if !member.is_file() {
5061 continue;
5062 }
5063 hasher.update(suffix.as_bytes());
5064 let mut file = std::fs::File::open(&member)?;
5065 loop {
5066 let read = file.read(&mut buffer)?;
5067 if read == 0 {
5068 break;
5069 }
5070 bytes = bytes.saturating_add(read as u64);
5071 hasher.update(&buffer[..read]);
5072 }
5073 }
5074 Ok(SourceFingerprint {
5075 bytes,
5076 blake3: hash_to_hex(hasher.finalize()),
5077 })
5078}
5079
5080fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
5081 if suffix.is_empty() {
5082 path.to_path_buf()
5083 } else {
5084 PathBuf::from(format!("{}{suffix}", path.display()))
5085 }
5086}
5087
5088fn sync_file(path: &Path) -> Result<()> {
5089 let file = std::fs::OpenOptions::new()
5090 .read(true)
5091 .write(true)
5092 .open(path)?;
5093 file.sync_all()?;
5094 Ok(())
5095}
5096
5097fn fail_after_temp_copy_for_test() -> Result<()> {
5098 if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
5099 return Err(CallGraphStoreError::Unavailable(
5100 "legacy callgraph migration stopped after temp copy by test seam".to_string(),
5101 ));
5102 }
5103 Ok(())
5104}
5105
5106fn migration_generation_file_name(project_key: &str, method: &str) -> String {
5107 format!(
5108 "{project_key}.g{}.{}{}{}.sqlite",
5109 now_nanos(),
5110 std::process::id(),
5111 MIGRATION_GENERATION_TAG,
5112 method
5113 )
5114}
5115
5116fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
5117 callgraph_dir.join(format!(
5118 "{generation}.tmp.{}.{}",
5119 std::process::id(),
5120 now_nanos()
5121 ))
5122}
5123
5124fn write_migration_manifest(
5125 callgraph_dir: &Path,
5126 generation: &str,
5127 source: &LegacyCallgraphTarget,
5128 migrated_bytes: u64,
5129 method: &str,
5130) -> Result<()> {
5131 let manifest_path = migration_manifest_path(callgraph_dir, generation);
5132 let temp_path = manifest_path.with_extension(format!(
5133 "migration.json.tmp.{}.{}",
5134 std::process::id(),
5135 now_nanos()
5136 ));
5137 let manifest = serde_json::json!({
5138 "version": MIGRATION_MANIFEST_VERSION,
5139 "method": method,
5140 "target_generation": generation,
5141 "source_harness": source.partition.harness,
5142 "source_path": source.sqlite_path.display().to_string(),
5143 "source_generation": source.generation,
5144 "source_bytes": source.source_bytes,
5145 "source_blake3": source.source_blake3,
5146 "migrated_bytes": migrated_bytes,
5147 });
5148 {
5149 use std::io::Write as _;
5150 let mut file = std::fs::File::create(&temp_path)?;
5151 file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
5152 file.write_all(b"\n")?;
5153 file.sync_all()?;
5154 }
5155 if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
5156 let _ = std::fs::remove_file(&temp_path);
5157 return Err(error.into());
5158 }
5159 crate::fs_lock::sync_parent(&manifest_path);
5160 Ok(())
5161}
5162
5163fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
5164 callgraph_dir.join(format!("{generation}.migration.json"))
5165}
5166
5167fn migration_generation_requires_manifest(generation: &str) -> bool {
5168 generation.contains(MIGRATION_GENERATION_TAG)
5169}
5170
5171fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
5172 if !migration_generation_requires_manifest(generation) {
5173 return true;
5174 }
5175 let path = migration_manifest_path(callgraph_dir, generation);
5176 let Ok(bytes) = std::fs::read(path) else {
5177 return false;
5178 };
5179 let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
5180 return false;
5181 };
5182 value.get("version").and_then(serde_json::Value::as_u64)
5183 == Some(MIGRATION_MANIFEST_VERSION as u64)
5184 && value
5185 .get("target_generation")
5186 .and_then(serde_json::Value::as_str)
5187 == Some(generation)
5188 && value
5189 .get("source_bytes")
5190 .and_then(serde_json::Value::as_u64)
5191 .is_some_and(|bytes| bytes > 0)
5192 && value
5193 .get("source_blake3")
5194 .and_then(serde_json::Value::as_str)
5195 .is_some_and(|hash| hash.len() == 64)
5196}
5197
5198fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
5199 let pointer_generation = read_pointer(callgraph_dir, project_key);
5200 if let Some(generation) = pointer_generation.as_deref() {
5201 if migration_generation_requires_manifest(generation)
5202 && !migration_manifest_valid(callgraph_dir, generation)
5203 {
5204 let path = callgraph_dir.join(generation);
5205 remove_sqlite_file_set(&path);
5206 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
5207 let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
5208 }
5209 }
5210
5211 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
5212 return;
5213 };
5214 for entry in entries.flatten() {
5215 let name = entry.file_name().to_string_lossy().to_string();
5216 let path = entry.path();
5217 if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
5218 let _ = std::fs::remove_file(path);
5219 continue;
5220 }
5221 if name.starts_with(&format!("{project_key}.g"))
5222 && name.ends_with(".sqlite")
5223 && name.contains(MIGRATION_GENERATION_TAG)
5224 && pointer_generation.as_deref() != Some(&name)
5225 && !migration_manifest_valid(callgraph_dir, &name)
5226 {
5227 remove_sqlite_file_set(&path);
5228 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
5229 }
5230 }
5231 crate::fs_lock::sync_parent(callgraph_dir);
5232}
5233
5234fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
5235 let mut hasher = blake3::Hasher::new();
5236 hasher.update(path.to_string_lossy().as_bytes());
5237 if let Some(generation) = generation {
5238 hasher.update(generation.as_bytes());
5239 }
5240 let digest = hash_to_hex(hasher.finalize());
5241 format!("legacy-{}", &digest[..16])
5242}
5243
5244fn open_readonly_connection(path: &Path) -> Result<Connection> {
5245 let uri = sqlite_readonly_uri(path);
5246 let conn = Connection::open_with_flags(
5247 &uri,
5248 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
5249 )?;
5250 conn.busy_timeout(reader_busy_timeout())?;
5251 conn.execute_batch("PRAGMA query_only=ON;")?;
5252 Ok(conn)
5253}
5254
5255fn reader_busy_timeout() -> Duration {
5256 let jitter = (now_nanos() % 500) as u64;
5257 Duration::from_millis(250 + jitter)
5258}
5259
5260fn sqlite_readonly_uri(path: &Path) -> String {
5261 let raw = path.to_string_lossy().replace('\\', "/");
5262 let encoded = percent_encode_sqlite_uri_path(&raw);
5263 if raw.starts_with('/') {
5264 format!("file://{encoded}?mode=ro")
5265 } else if raw.as_bytes().get(1) == Some(&b':') {
5266 format!("file:///{encoded}?mode=ro")
5267 } else {
5268 format!("file:{encoded}?mode=ro")
5269 }
5270}
5271
5272fn percent_encode_sqlite_uri_path(path: &str) -> String {
5273 let mut encoded = String::with_capacity(path.len());
5274 for byte in path.bytes() {
5275 match byte {
5276 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
5277 encoded.push(byte as char)
5278 }
5279 _ => encoded.push_str(&format!("%{byte:02X}")),
5280 }
5281 }
5282 encoded
5283}
5284
5285fn configure_connection(conn: &Connection) -> Result<()> {
5286 conn.pragma_update(None, "journal_mode", "WAL")?;
5287 conn.pragma_update(None, "busy_timeout", 5_000)?;
5288 Ok(())
5289}
5290
5291fn configure_build_connection(conn: &Connection) -> Result<()> {
5292 conn.pragma_update(None, "journal_mode", "DELETE")?;
5293 conn.pragma_update(None, "busy_timeout", 5_000)?;
5294 Ok(())
5295}
5296
5297fn initialize_schema(conn: &Connection) -> Result<()> {
5298 conn.execute_batch(
5299 "CREATE TABLE IF NOT EXISTS files (
5300 path TEXT PRIMARY KEY,
5301 content_hash TEXT NOT NULL,
5302 mtime_ns INTEGER NOT NULL,
5303 size INTEGER NOT NULL,
5304 lang TEXT NOT NULL,
5305 is_dead_code_root INTEGER NOT NULL DEFAULT 0,
5306 is_public_api INTEGER NOT NULL DEFAULT 0,
5307 surface_fingerprint TEXT NOT NULL,
5308 indexed_at INTEGER NOT NULL
5309 );
5310
5311 CREATE TABLE IF NOT EXISTS nodes (
5312 id TEXT PRIMARY KEY,
5313 file_path TEXT NOT NULL,
5314 name TEXT NOT NULL,
5315 scoped_name TEXT NOT NULL,
5316 kind TEXT NOT NULL,
5317 start_line INTEGER NOT NULL,
5318 start_col INTEGER NOT NULL,
5319 end_line INTEGER NOT NULL,
5320 end_col INTEGER NOT NULL,
5321 range_ordinal INTEGER NOT NULL,
5322 signature TEXT,
5323 exported INTEGER NOT NULL,
5324 is_default_export INTEGER NOT NULL,
5325 is_type_like INTEGER NOT NULL,
5326 is_callgraph_entry_point INTEGER NOT NULL,
5327 provenance TEXT NOT NULL,
5328 UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
5329 );
5330 CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
5331 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
5332 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
5333
5334 CREATE TABLE IF NOT EXISTS refs (
5335 ref_id TEXT PRIMARY KEY,
5336 caller_node TEXT,
5337 caller_file TEXT NOT NULL,
5338 kind TEXT NOT NULL,
5339 short_name TEXT,
5340 full_ref TEXT,
5341 module_path TEXT,
5342 import_kind TEXT,
5343 local_name TEXT,
5344 requested_name TEXT,
5345 namespace_alias TEXT,
5346 wildcard INTEGER NOT NULL DEFAULT 0,
5347 line INTEGER NOT NULL,
5348 byte_start INTEGER NOT NULL,
5349 byte_end INTEGER NOT NULL,
5350 status TEXT NOT NULL,
5351 target_node TEXT,
5352 target_file TEXT,
5353 target_symbol TEXT,
5354 provenance TEXT NOT NULL
5355 );
5356 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
5357 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
5358 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
5359 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
5360 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
5361
5362 CREATE TABLE IF NOT EXISTS file_dependencies (
5363 file_path TEXT NOT NULL,
5364 dep_file TEXT NOT NULL,
5365 PRIMARY KEY(file_path, dep_file)
5366 );
5367 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
5368
5369 CREATE TABLE IF NOT EXISTS edges (
5370 edge_id TEXT PRIMARY KEY,
5371 ref_id TEXT NOT NULL,
5372 source_node TEXT NOT NULL,
5373 target_node TEXT,
5374 target_file TEXT NOT NULL,
5375 target_symbol TEXT NOT NULL,
5376 kind TEXT NOT NULL,
5377 line INTEGER NOT NULL,
5378 provenance TEXT NOT NULL
5379 );
5380 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
5381 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
5382 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
5383 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
5384
5385 CREATE TABLE IF NOT EXISTS dispatch_hints (
5386 id TEXT PRIMARY KEY,
5387 method_name TEXT NOT NULL,
5388 caller_node TEXT NOT NULL,
5389 file TEXT NOT NULL,
5390 line INTEGER NOT NULL,
5391 byte_start INTEGER NOT NULL,
5392 byte_end INTEGER NOT NULL,
5393 provenance TEXT NOT NULL
5394 );
5395 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
5396
5397 CREATE TABLE IF NOT EXISTS type_ref_names (
5398 name TEXT PRIMARY KEY
5399 );
5400
5401 CREATE TABLE IF NOT EXISTS backend_file_state (
5402 backend TEXT NOT NULL,
5403 workspace_root TEXT NOT NULL,
5404 file_path TEXT NOT NULL,
5405 content_hash TEXT NOT NULL,
5406 status TEXT NOT NULL,
5407 updated_at INTEGER NOT NULL,
5408 PRIMARY KEY(backend, workspace_root, file_path, content_hash)
5409 );
5410 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
5411
5412 CREATE TABLE IF NOT EXISTS meta (
5413 k TEXT PRIMARY KEY,
5414 v TEXT NOT NULL
5415 );",
5416 )?;
5417 insert_meta(conn)?;
5418 Ok(())
5419}
5420
5421fn insert_meta(conn: &Connection) -> Result<()> {
5422 conn.execute(
5423 "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
5424 params![SCHEMA_VERSION.to_string()],
5425 )?;
5426 conn.execute(
5427 "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
5428 params![schema_fingerprint()],
5429 )?;
5430 Ok(())
5431}
5432
5433fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
5434 conn.execute(
5435 "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
5436 params![if ready { "1" } else { "0" }],
5437 )?;
5438 Ok(())
5439}
5440
5441fn database_ready(conn: &Connection) -> Result<bool> {
5442 let schema_version: Option<String> = conn
5443 .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
5444 row.get(0)
5445 })
5446 .optional()?;
5447 let fingerprint: Option<String> = conn
5448 .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
5449 row.get(0)
5450 })
5451 .optional()?;
5452 let ready: Option<String> = conn
5453 .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
5454 .optional()?;
5455
5456 let expected_schema = SCHEMA_VERSION.to_string();
5457 let expected_fingerprint = schema_fingerprint();
5458 Ok(schema_version.as_deref() == Some(expected_schema.as_str())
5459 && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
5460 && ready.as_deref() == Some("1"))
5461}
5462
5463fn ensure_database_ready(conn: &Connection) -> Result<()> {
5464 if database_ready(conn)? {
5465 Ok(())
5466 } else {
5467 Err(CallGraphStoreError::Unavailable(
5468 "database is missing, stale, or mid-build".to_string(),
5469 ))
5470 }
5471}
5472
5473fn schema_fingerprint() -> String {
5474 let input =
5479 format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
5480 hash_to_hex(blake3::hash(input.as_bytes()))
5481}
5482
5483fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
5484 tx.execute_batch(
5485 "DELETE FROM edges;
5486 DELETE FROM file_dependencies;
5487 DELETE FROM refs;
5488 DELETE FROM dispatch_hints;
5489 DELETE FROM type_ref_names;
5490 DELETE FROM backend_file_state;
5491 DELETE FROM nodes;
5492 DELETE FROM files;",
5493 )?;
5494 Ok(())
5495}
5496
5497fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
5498 tx.execute_batch(
5499 "DROP INDEX IF EXISTS idx_nodes_file;
5500 DROP INDEX IF EXISTS idx_nodes_name;
5501 DROP INDEX IF EXISTS idx_nodes_scoped;
5502 DROP INDEX IF EXISTS idx_refs_short_name;
5503 DROP INDEX IF EXISTS idx_refs_kind_caller_file;
5504 DROP INDEX IF EXISTS idx_refs_caller_file;
5505 DROP INDEX IF EXISTS idx_refs_caller_node_kind;
5506 DROP INDEX IF EXISTS idx_refs_target_file;
5507 DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
5508 DROP INDEX IF EXISTS idx_edges_source_kind;
5509 DROP INDEX IF EXISTS idx_edges_target_kind;
5510 DROP INDEX IF EXISTS idx_edges_target_file_symbol;
5511 DROP INDEX IF EXISTS idx_edges_ref_id;
5512 DROP INDEX IF EXISTS idx_dispatch_hints_method;
5513 DROP INDEX IF EXISTS idx_backend_file_state_file;",
5514 )?;
5515 Ok(())
5516}
5517
5518fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
5519 tx.execute_batch(
5520 "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
5521 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
5522 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
5523 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
5524 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
5525 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
5526 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
5527 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
5528 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
5529 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
5530 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
5531 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
5532 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
5533 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
5534 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
5535 )?;
5536 Ok(())
5537}
5538
5539const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
5540 ("files", "path"),
5541 ("nodes", "file_path"),
5542 ("refs", "caller_file"),
5543 ("refs", "target_file"),
5544 ("file_dependencies", "file_path"),
5545 ("file_dependencies", "dep_file"),
5546 ("edges", "target_file"),
5547 ("dispatch_hints", "file"),
5548 ("backend_file_state", "file_path"),
5549];
5550
5551fn reconcile_workspace_roots(
5564 conn: &mut Connection,
5565 project_root: &Path,
5566 allow_repair: bool,
5567) -> Result<OpenRootRepair> {
5568 let roots = stored_workspace_roots(conn)?;
5569 let current_root = project_root.display().to_string();
5570 if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
5571 return Ok(OpenRootRepair::None);
5572 }
5573
5574 if let Some(sample) = sample_absolute_data_path(conn)? {
5575 return Ok(OpenRootRepair::NeedsRebuild {
5576 previous_roots: roots,
5577 current_root,
5578 reason: format!("absolute store data path row {sample}"),
5579 });
5580 }
5581
5582 for stored_root in roots.iter() {
5583 if stored_root == ¤t_root {
5584 continue;
5585 }
5586 if Path::new(stored_root).exists() {
5587 let reason = format!(
5588 "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
5589 );
5590 return Ok(OpenRootRepair::NeedsRebuild {
5591 previous_roots: roots,
5592 current_root,
5593 reason,
5594 });
5595 }
5596 }
5597
5598 if !allow_repair {
5599 return Ok(OpenRootRepair::NeedsRebuild {
5600 previous_roots: roots,
5601 current_root,
5602 reason: "workspace root metadata requires deferred repair".to_string(),
5603 });
5604 }
5605
5606 publish_if_current(|| {
5607 let tx = conn.transaction()?;
5608 tx.execute(
5609 "UPDATE OR IGNORE backend_file_state
5610 SET workspace_root = ?1
5611 WHERE workspace_root <> ?1",
5612 params![¤t_root],
5613 )?;
5614 tx.execute(
5615 "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
5616 params![¤t_root],
5617 )?;
5618 tx.commit()?;
5619 Ok(())
5620 })?;
5621
5622 crate::slog_info!(
5623 "callgraph store re-rooted from {} to {}",
5624 roots.join(", "),
5625 current_root
5626 );
5627 Ok(OpenRootRepair::ReRooted)
5628}
5629
5630fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
5631 let mut stmt = conn.prepare(
5632 "SELECT DISTINCT workspace_root
5633 FROM backend_file_state
5634 ORDER BY workspace_root",
5635 )?;
5636 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
5637 rows.collect::<std::result::Result<Vec<_>, _>>()
5638 .map_err(Into::into)
5639}
5640
5641fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
5642 for (table, column) in STORE_DATA_PATH_COLUMNS {
5643 let sql = format!(
5644 "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
5645 );
5646 let mut stmt = conn.prepare(&sql)?;
5647 let mut rows = stmt.query([])?;
5648 while let Some(row) = rows.next()? {
5649 let value: String = row.get(0)?;
5650 if stored_path_is_absolute(&value) {
5651 return Ok(Some(format!("{table}.{column}={value}")));
5652 }
5653 }
5654 }
5655 Ok(None)
5656}
5657
5658fn stored_path_is_absolute(value: &str) -> bool {
5659 if value.is_empty() {
5660 return false;
5661 }
5662 if Path::new(value).is_absolute() || value.starts_with('/') {
5663 return true;
5664 }
5665 let bytes = value.as_bytes();
5666 if bytes.len() >= 3
5667 && bytes[1] == b':'
5668 && (bytes[2] == b'/' || bytes[2] == b'\\')
5669 && bytes[0].is_ascii_alphabetic()
5670 {
5671 return true;
5672 }
5673 value.starts_with("\\\\") || value.starts_with("//")
5674}
5675
5676fn log_root_repair_rebuild(repair: &OpenRootRepair) {
5677 if let OpenRootRepair::NeedsRebuild {
5678 previous_roots,
5679 current_root,
5680 reason,
5681 } = repair
5682 {
5683 crate::slog_info!(
5684 "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
5685 previous_roots.join(", "),
5686 current_root,
5687 reason
5688 );
5689 }
5690}
5691
5692fn now_nanos() -> u128 {
5694 SystemTime::now()
5695 .duration_since(UNIX_EPOCH)
5696 .unwrap_or(Duration::ZERO)
5697 .as_nanos()
5698}
5699
5700fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
5705 callgraph_dir.join(format!("{project_key}.current"))
5706}
5707
5708fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
5712 callgraph_dir.join(format!("{project_key}.sqlite"))
5713}
5714
5715fn generation_file_name(project_key: &str) -> String {
5719 format!(
5720 "{project_key}.g{}.{}.sqlite",
5721 now_nanos(),
5722 std::process::id()
5723 )
5724}
5725
5726fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
5728 let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
5729 let name = text.trim();
5730 if name.is_empty() {
5731 None
5732 } else {
5733 Some(name.to_string())
5734 }
5735}
5736
5737fn db_path_ready(path: &Path) -> bool {
5740 (|| -> Result<bool> {
5741 let conn = open_readonly_connection(path)?;
5742 database_ready(&conn)
5743 })()
5744 .unwrap_or(false)
5745}
5746
5747fn resolve_ready_target(
5755 callgraph_dir: &Path,
5756 project_key: &str,
5757) -> Option<(PathBuf, Option<String>)> {
5758 for _ in 0..5 {
5759 if let Some(generation) = read_pointer(callgraph_dir, project_key) {
5760 let gen_path = callgraph_dir.join(&generation);
5761 if gen_path.is_file() {
5762 return (migration_manifest_valid(callgraph_dir, &generation)
5763 && db_path_ready(&gen_path))
5764 .then_some((gen_path, Some(generation)));
5765 }
5766 std::thread::sleep(Duration::from_millis(5));
5769 continue;
5770 }
5771 let legacy = legacy_sqlite_path(callgraph_dir, project_key);
5773 return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
5774 }
5775 None
5776}
5777
5778fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
5782 let pointer = pointer_path(callgraph_dir, project_key);
5783 let tmp = callgraph_dir.join(format!(
5784 "{project_key}.current.tmp.{}.{}",
5785 std::process::id(),
5786 now_nanos()
5787 ));
5788 {
5789 use std::io::Write as _;
5790 let mut file = std::fs::File::create(&tmp)?;
5791 file.write_all(generation.as_bytes())?;
5792 file.write_all(b"\n")?;
5793 file.sync_all()?;
5794 }
5795 if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
5796 let _ = std::fs::remove_file(&tmp);
5797 return Err(error.into());
5798 }
5799 crate::fs_lock::sync_parent(&pointer);
5800 Ok(())
5801}
5802
5803#[derive(Clone, Debug)]
5804struct GenerationGcCandidate {
5805 name: String,
5806 path: PathBuf,
5807 modified: SystemTime,
5808}
5809
5810fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
5816 let temp_grace = Duration::from_secs(60);
5817 let now = SystemTime::now();
5818 let pointer_current =
5819 read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
5820 let gen_prefix = format!("{project_key}.g");
5821 let tmp_prefixes = [
5822 format!("{project_key}.g"), format!("{project_key}.current."), format!("{project_key}.sqlite.tmp."), ];
5826 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
5827 return;
5828 };
5829 let mut gens: Vec<GenerationGcCandidate> = Vec::new();
5830 for entry in entries.flatten() {
5831 let name = entry.file_name();
5832 let name = name.to_string_lossy().to_string();
5833 let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
5834 let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
5835
5836 if name.contains(".tmp.") {
5838 if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
5839 let _ = std::fs::remove_file(entry.path());
5840 }
5841 continue;
5842 }
5843
5844 if name == format!("{project_key}.sqlite") {
5847 remove_sqlite_file_set(&entry.path());
5848 continue;
5849 }
5850
5851 if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
5852 gens.push(GenerationGcCandidate {
5853 name,
5854 path: entry.path(),
5855 modified: mtime,
5856 });
5857 }
5858 }
5859
5860 let mut superseded = gens
5861 .iter()
5862 .filter(|generation| generation.name != pointer_current)
5863 .collect::<Vec<_>>();
5864 superseded.sort_by(|left, right| {
5865 right
5866 .modified
5867 .cmp(&left.modified)
5868 .then_with(|| right.name.cmp(&left.name))
5869 });
5870 let previous = superseded.first().map(|generation| generation.name.clone());
5871
5872 for generation in gens {
5873 let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
5874 if generation.name == pointer_current
5875 || Some(generation.name.as_str()) == previous.as_deref()
5876 {
5877 continue;
5878 }
5879
5880 let age = now
5881 .duration_since(generation.modified)
5882 .unwrap_or(Duration::ZERO);
5883 if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
5884 continue;
5885 }
5886
5887 remove_sqlite_file_set(&generation.path);
5888 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
5889 let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
5890 callgraph_dir,
5891 &generation.name,
5892 ));
5893 }
5894}
5895
5896fn remove_sqlite_file_set(path: &Path) {
5897 let _ = std::fs::remove_file(path);
5898 remove_sqlite_sidecars(path);
5899}
5900
5901fn remove_sqlite_sidecars(path: &Path) {
5902 let path_text = path.to_string_lossy();
5903 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
5904 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
5905 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
5906}
5907
5908const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
5922
5923fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
5935 sweep_orphaned_build_temps(callgraph_dir);
5936 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5937 return;
5938 };
5939 let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
5940
5941 if let Ok(entries) = std::fs::read_dir(storage_root.join(domain)) {
5943 for entry in entries.flatten() {
5944 if entry.path().is_dir() {
5945 sweep_orphaned_build_temps(&entry.path());
5946 }
5947 }
5948 }
5949
5950 if let Ok(entries) = std::fs::read_dir(&storage_root) {
5952 for entry in entries.flatten() {
5953 let legacy_dir = entry.path().join(domain);
5954 if legacy_dir.is_dir() {
5955 sweep_orphaned_build_temps(&legacy_dir);
5956 }
5957 }
5958 }
5959}
5960
5961fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
5964 sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
5965}
5966
5967fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
5970 let now = SystemTime::now();
5971 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
5972 return;
5973 };
5974 let mut removed_any = false;
5975 for entry in entries.flatten() {
5976 let name = entry.file_name().to_string_lossy().to_string();
5977 if !name.contains(".sqlite.tmp.") {
5983 continue;
5984 }
5985 let mtime = entry
5986 .metadata()
5987 .and_then(|meta| meta.modified())
5988 .unwrap_or(now);
5989 if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
5990 continue;
5991 }
5992 match std::fs::remove_file(entry.path()) {
5998 Ok(()) => removed_any = true,
5999 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
6000 Err(_) => {}
6001 }
6002 }
6003 if removed_any {
6004 crate::fs_lock::sync_parent(callgraph_dir);
6005 }
6006}
6007
6008fn build_pool_size() -> usize {
6016 std::thread::available_parallelism()
6017 .map(|parallelism| parallelism.get())
6018 .unwrap_or(1)
6019 .div_ceil(2)
6020 .clamp(1, 8)
6021}
6022
6023fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
6024 let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
6025 Ok(extract) => Ok(extract),
6026 Err(error) => {
6027 let abs_path =
6028 normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
6029 let rel_path = relative_path(project_root, &abs_path);
6030 let freshness = cache_freshness::collect(&abs_path).ok();
6031 log::debug!(
6032 "callgraph store: skipping {} during cold build: {}",
6033 abs_path.display(),
6034 error
6035 );
6036 Err(ExtractFailure {
6037 rel_path,
6038 freshness,
6039 })
6040 }
6041 };
6042
6043 let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
6044 files.par_iter().map(extract_one).collect()
6045 };
6046
6047 let results = match rayon::ThreadPoolBuilder::new()
6050 .num_threads(build_pool_size())
6051 .thread_name(|index| format!("aft-callgraph-build-{index}"))
6052 .stack_size(8 * 1024 * 1024)
6053 .build()
6054 {
6055 Ok(pool) => pool.install(run),
6056 Err(error) => {
6057 log::warn!(
6058 "callgraph store: bounded build pool unavailable ({error}); using global pool"
6059 );
6060 run()
6061 }
6062 };
6063
6064 let mut extracts = Vec::new();
6065 let mut failures = Vec::new();
6066 for result in results {
6067 match result {
6068 Ok(extract) => extracts.push(extract),
6069 Err(failure) => failures.push(failure),
6070 }
6071 }
6072 BuildExtractsResult { extracts, failures }
6073}
6074
6075fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
6076 let metadata = std::fs::metadata(path)?;
6077 let size = metadata.len();
6078 let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
6079 cache_freshness::zero_hash()
6080 } else if source.len() as u64 == size {
6081 cache_freshness::hash_bytes(source.as_bytes())
6082 } else {
6083 cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
6084 };
6085 Ok(FileFreshness {
6086 mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
6087 size,
6088 content_hash,
6089 })
6090}
6091
6092fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
6093 let abs_path = normalize_file_path(project_root, path)?;
6094 let rel_path = relative_path(project_root, &abs_path);
6095 let source = std::fs::read_to_string(&abs_path)?;
6096 let freshness = collect_source_freshness(&abs_path, &source)?;
6097 let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
6098 let lang = data.lang;
6099 if lang == LangId::Rust {
6100 extend_rust_imports_with_nested_uses(&source, &mut data);
6101 }
6102 let mut nodes = build_node_records(&rel_path, &source, &data)?;
6103 let node_by_scoped: HashMap<String, String> = nodes
6104 .iter()
6105 .map(|node| (node.scoped_name.clone(), node.id.clone()))
6106 .collect();
6107 let import_dependencies =
6108 import_dependencies(project_root, &abs_path, &data.import_block.imports);
6109 let line_index = LineIndex::new(&source);
6110 let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
6111 let rust_reexports = if lang == LangId::Rust {
6112 collect_rust_pub_use_reexport_refs(
6113 project_root,
6114 &abs_path,
6115 &rel_path,
6116 &data.import_block.imports,
6117 &line_index,
6118 )
6119 } else {
6120 ReexportRefs {
6121 raw_refs: Vec::new(),
6122 surface_parts: Vec::new(),
6123 }
6124 };
6125 let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
6126 let mut raw_refs = Vec::new();
6127 raw_refs.extend(build_call_refs(
6128 &rel_path,
6129 &data,
6130 &node_by_scoped,
6131 &import_dependencies,
6132 ));
6133 raw_refs.extend(build_import_refs(
6134 project_root,
6135 &abs_path,
6136 &rel_path,
6137 &data.import_block.imports,
6138 &line_index,
6139 ));
6140 let mut surface_parts = reexports.surface_parts;
6141 surface_parts.extend(rust_reexports.surface_parts);
6142 surface_parts.extend(source_less_exports.surface_parts);
6143 raw_refs.extend(reexports.raw_refs);
6144 raw_refs.extend(rust_reexports.raw_refs);
6145 raw_refs.extend(source_less_exports.raw_refs);
6146 let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
6147 let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
6148
6149 Ok(FileExtract {
6150 rel_path,
6151 freshness,
6152 lang,
6153 data,
6154 nodes,
6155 raw_refs,
6156 dispatch_hints,
6157 surface_fingerprint,
6158 })
6159}
6160
6161fn build_node_records(
6162 rel_path: &str,
6163 source: &str,
6164 data: &FileCallData,
6165) -> Result<Vec<NodeRecord>> {
6166 let mut records = Vec::new();
6167 let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
6168 let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
6169 metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
6170
6171 for (scoped_name, meta) in metadata {
6172 let name = unqualified_name(scoped_name).to_string();
6173 let range = selection_range(source, scoped_name, &name, &meta.range);
6174 let range_key = (
6175 range.start_line,
6176 range.start_col,
6177 range.end_line,
6178 range.end_col,
6179 );
6180 let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
6181 let range_ordinal = *ordinal;
6182 *ordinal += 1;
6183 let id = node_id(rel_path, &range, range_ordinal, scoped_name);
6184 let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
6185 let is_default_export = data
6186 .default_export_symbol
6187 .as_deref()
6188 .map(|default| default == scoped_name || default == name)
6189 .unwrap_or(false);
6190 records.push(NodeRecord {
6191 id,
6192 file_path: rel_path.to_string(),
6193 name: name.clone(),
6194 scoped_name: scoped_name.clone(),
6195 kind: symbol_kind_label(&meta.kind).to_string(),
6196 range,
6197 range_ordinal,
6198 signature: meta.signature.clone(),
6199 exported,
6200 is_default_export,
6201 is_type_like: is_type_like(&meta.kind),
6202 is_callgraph_entry_point: meta.entry_point_attribute.is_some()
6203 || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
6204 });
6205 }
6206
6207 Ok(records)
6208}
6209
6210fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
6211 if scoped_name == TOP_LEVEL_SYMBOL {
6212 return Range {
6213 start_line: 0,
6214 start_col: 0,
6215 end_line: 0,
6216 end_col: 0,
6217 };
6218 }
6219 let Some(line) = source.lines().nth(fallback.start_line as usize) else {
6220 return fallback.clone();
6221 };
6222 let start_col = fallback.start_col as usize;
6223 let search_start = start_col.min(line.len());
6224 if let Some(offset) = line[search_start..].find(name) {
6225 let col = search_start + offset;
6226 return Range {
6227 start_line: fallback.start_line,
6228 start_col: col as u32,
6229 end_line: fallback.start_line,
6230 end_col: (col + name.len()) as u32,
6231 };
6232 }
6233 if let Some(offset) = line.find(name) {
6234 return Range {
6235 start_line: fallback.start_line,
6236 start_col: offset as u32,
6237 end_line: fallback.start_line,
6238 end_col: (offset + name.len()) as u32,
6239 };
6240 }
6241 Range {
6242 start_line: fallback.start_line,
6243 start_col: fallback.start_col,
6244 end_line: fallback.start_line,
6245 end_col: fallback.start_col.saturating_add(name.len() as u32),
6246 }
6247}
6248
6249fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
6250 if scoped_name == TOP_LEVEL_SYMBOL {
6251 return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
6252 }
6253 let input = format!(
6254 "{rel_path}:{}:{}:{}:{}:{ordinal}",
6255 range.start_line, range.start_col, range.end_line, range.end_col
6256 );
6257 format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
6258}
6259
6260fn build_call_refs(
6261 rel_path: &str,
6262 data: &FileCallData,
6263 node_by_scoped: &HashMap<String, String>,
6264 import_dependencies: &BTreeSet<String>,
6265) -> Vec<RawRef> {
6266 let mut refs = Vec::new();
6267 let mut ordinal = 0usize;
6268 let mut symbols: Vec<_> = data.calls_by_symbol.iter().collect();
6269 symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
6270 for (caller_symbol, call_sites) in symbols {
6271 let caller_node = node_by_scoped.get(caller_symbol).cloned();
6272 for call_site in call_sites {
6273 ordinal += 1;
6274 let ref_id = ref_id(&[
6275 rel_path,
6276 "call",
6277 caller_symbol,
6278 &call_site.line.to_string(),
6279 &call_site.byte_start.to_string(),
6280 &call_site.byte_end.to_string(),
6281 &call_site.full_callee,
6282 &ordinal.to_string(),
6283 ]);
6284 refs.push(RawRef {
6285 ref_id,
6286 caller_node: caller_node.clone(),
6287 caller_symbol: Some(caller_symbol.clone()),
6288 caller_file: rel_path.to_string(),
6289 kind: "call".to_string(),
6290 short_name: Some(call_site.callee_name.clone()),
6291 full_ref: Some(call_site.full_callee.clone()),
6292 module_path: None,
6293 import_kind: None,
6294 local_name: Some(call_site.callee_name.clone()),
6295 requested_name: Some(call_site.callee_name.clone()),
6296 namespace_alias: namespace_alias(&call_site.full_callee),
6297 wildcard: false,
6298 line: call_site.line,
6299 byte_start: call_site.byte_start,
6300 byte_end: call_site.byte_end,
6301 dependencies: import_dependencies.clone(),
6302 });
6303 }
6304 }
6305 refs
6306}
6307
6308fn build_import_refs(
6309 project_root: &Path,
6310 abs_path: &Path,
6311 rel_path: &str,
6312 imports: &[ImportStatement],
6313 line_index: &LineIndex,
6314) -> Vec<RawRef> {
6315 let mut refs = Vec::new();
6316 for (index, import) in imports.iter().enumerate() {
6317 let import_kind = import_kind_label(import.kind).to_string();
6318 let local_name = import_local_names(import).join(",");
6319 let requested_name = import_requested_names(import).join(",");
6320 let ref_id = ref_id(&[
6321 rel_path,
6322 "import",
6323 &import.byte_range.start.to_string(),
6324 &import.byte_range.end.to_string(),
6325 &import.module_path,
6326 &index.to_string(),
6327 ]);
6328 refs.push(RawRef {
6329 ref_id,
6330 caller_node: None,
6331 caller_symbol: None,
6332 caller_file: rel_path.to_string(),
6333 kind: "import".to_string(),
6334 short_name: None,
6335 full_ref: Some(import.raw_text.clone()),
6336 module_path: Some(import.module_path.clone()),
6337 import_kind: Some(import_kind),
6338 local_name: empty_to_none(local_name),
6339 requested_name: empty_to_none(requested_name),
6340 namespace_alias: import.namespace_import.clone(),
6341 wildcard: import_is_wildcard(import),
6342 line: line_index.byte_to_line(import.byte_range.start),
6343 byte_start: import.byte_range.start,
6344 byte_end: import.byte_range.end,
6345 dependencies: module_dependencies(project_root, abs_path, &import.module_path),
6346 });
6347 }
6348 refs
6349}
6350
6351fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
6352 let grammar = grammar_for(LangId::Rust);
6353 let mut parser = Parser::new();
6354 if parser.set_language(&grammar).is_err() {
6355 return;
6356 }
6357 let Some(tree) = parser.parse(source, None) else {
6358 return;
6359 };
6360
6361 let mut seen = data
6362 .import_block
6363 .imports
6364 .iter()
6365 .map(|import| (import.byte_range.start, import.byte_range.end))
6366 .collect::<HashSet<_>>();
6367 let mut nested_imports = Vec::new();
6368 collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
6369 if nested_imports.is_empty() {
6370 return;
6371 }
6372
6373 data.import_block.imports.extend(nested_imports);
6374 data.import_block
6375 .imports
6376 .sort_by_key(|import| import.byte_range.start);
6377 data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
6378}
6379
6380fn collect_rust_use_imports(
6381 source: &str,
6382 node: Node<'_>,
6383 seen: &mut HashSet<(usize, usize)>,
6384 imports: &mut Vec<ImportStatement>,
6385) {
6386 if node.kind() == "use_declaration" {
6387 let range = node.byte_range();
6388 if seen.insert((range.start, range.end)) {
6389 if let Some(import) = rust_import_from_use_node(source, node) {
6390 imports.push(import);
6391 }
6392 }
6393 }
6394
6395 let mut cursor = node.walk();
6396 if !cursor.goto_first_child() {
6397 return;
6398 }
6399 loop {
6400 collect_rust_use_imports(source, cursor.node(), seen, imports);
6401 if !cursor.goto_next_sibling() {
6402 break;
6403 }
6404 }
6405}
6406
6407fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
6408 let raw_text = source[node.byte_range()].to_string();
6409 let body = rust_use_body(&raw_text)?.to_string();
6410 let visibility = rust_use_visibility(&raw_text);
6411 let names = rust_use_list_names(&body);
6412 let group = classify_rust_import_group(&body);
6413 let byte_range = node.byte_range();
6414
6415 Some(ImportStatement {
6416 module_path: body,
6417 names: names.clone(),
6418 default_import: visibility.clone(),
6419 namespace_import: None,
6420 kind: ImportKind::Value,
6421 group,
6422 byte_range,
6423 raw_text,
6424 form: ImportForm::RustUse {
6425 visibility,
6426 named: names,
6427 },
6428 })
6429}
6430
6431fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
6432 let start = imports.iter().map(|import| import.byte_range.start).min()?;
6433 let end = imports.iter().map(|import| import.byte_range.end).max()?;
6434 Some(start..end)
6435}
6436
6437fn rust_use_visibility(raw_text: &str) -> Option<String> {
6438 let use_pos = raw_text.find("use ")?;
6439 let prefix = raw_text[..use_pos].trim();
6440 if prefix.is_empty() {
6441 None
6442 } else {
6443 Some(prefix.to_string())
6444 }
6445}
6446
6447fn rust_use_body(raw_text: &str) -> Option<&str> {
6448 let use_pos = raw_text.find("use ")?;
6449 Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
6450}
6451
6452fn rust_use_list_names(body: &str) -> Vec<String> {
6453 let Some(open) = body.find("::{") else {
6454 return Vec::new();
6455 };
6456 let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
6457 return Vec::new();
6458 };
6459 body[open + 3..close]
6460 .split(',')
6461 .filter_map(|spec| {
6462 let spec = spec.trim();
6463 if spec.is_empty() {
6464 None
6465 } else {
6466 Some(spec.to_string())
6467 }
6468 })
6469 .collect()
6470}
6471
6472fn classify_rust_import_group(body: &str) -> ImportGroup {
6473 let first = body
6474 .split("::")
6475 .next()
6476 .unwrap_or(body)
6477 .split_whitespace()
6478 .next()
6479 .unwrap_or(body);
6480 match first.trim() {
6481 "std" | "core" | "alloc" => ImportGroup::Stdlib,
6482 "crate" | "self" | "super" => ImportGroup::Internal,
6483 _ => ImportGroup::External,
6484 }
6485}
6486
6487#[derive(Debug, Clone)]
6488struct ReexportRefs {
6489 raw_refs: Vec<RawRef>,
6490 surface_parts: Vec<String>,
6491}
6492
6493fn collect_reexport_refs(
6494 project_root: &Path,
6495 abs_path: &Path,
6496 rel_path: &str,
6497 source: &str,
6498) -> ReexportRefs {
6499 let mut raw_refs = Vec::new();
6500 let mut surface_parts = Vec::new();
6501 let mut search_start = 0usize;
6502 let mut ordinal = 0usize;
6503 while let Some(export_offset) = source[search_start..].find("export") {
6504 let start = search_start + export_offset;
6505 let Some(statement_end_offset) = source[start..].find(';') else {
6506 break;
6507 };
6508 let end = start + statement_end_offset + 1;
6509 let statement = &source[start..end];
6510 search_start = end;
6511 if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
6512 continue;
6513 }
6514 let Some(module_path) = quoted_module_path(statement) else {
6515 continue;
6516 };
6517 ordinal += 1;
6518 let wildcard = statement.contains('*');
6519 let line = source[..start]
6520 .bytes()
6521 .filter(|byte| *byte == b'\n')
6522 .count() as u32
6523 + 1;
6524 let ref_id = ref_id(&[
6525 rel_path,
6526 "reexport",
6527 &start.to_string(),
6528 &end.to_string(),
6529 &module_path,
6530 &ordinal.to_string(),
6531 ]);
6532 surface_parts.push(format!("reexport\t{statement}"));
6533 raw_refs.push(RawRef {
6534 ref_id,
6535 caller_node: None,
6536 caller_symbol: None,
6537 caller_file: rel_path.to_string(),
6538 kind: "reexport".to_string(),
6539 short_name: None,
6540 full_ref: Some(statement.to_string()),
6541 module_path: Some(module_path.clone()),
6542 import_kind: Some("reexport".to_string()),
6543 local_name: None,
6544 requested_name: None,
6545 namespace_alias: None,
6546 wildcard,
6547 line,
6548 byte_start: start,
6549 byte_end: end,
6550 dependencies: module_dependencies(project_root, abs_path, &module_path),
6551 });
6552 }
6553 ReexportRefs {
6554 raw_refs,
6555 surface_parts,
6556 }
6557}
6558
6559fn collect_rust_pub_use_reexport_refs(
6560 project_root: &Path,
6561 abs_path: &Path,
6562 rel_path: &str,
6563 imports: &[ImportStatement],
6564 line_index: &LineIndex,
6565) -> ReexportRefs {
6566 let mut raw_refs = Vec::new();
6567 let mut surface_parts = Vec::new();
6568 let mut ordinal = 0usize;
6569
6570 for import in imports {
6571 let Some(visibility) = &import.default_import else {
6572 continue;
6573 };
6574 if !visibility.starts_with("pub") {
6575 continue;
6576 }
6577 let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
6578 continue;
6579 };
6580 ordinal += 1;
6581 let ref_id = ref_id(&[
6582 rel_path,
6583 "rust_reexport",
6584 &import.byte_range.start.to_string(),
6585 &import.byte_range.end.to_string(),
6586 &module_path,
6587 &ordinal.to_string(),
6588 ]);
6589 surface_parts.push(format!("reexport\t{}", import.raw_text));
6590 raw_refs.push(RawRef {
6591 ref_id,
6592 caller_node: None,
6593 caller_symbol: None,
6594 caller_file: rel_path.to_string(),
6595 kind: "reexport".to_string(),
6596 short_name: None,
6597 full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
6598 module_path: Some(module_path.clone()),
6599 import_kind: Some("reexport".to_string()),
6600 local_name: None,
6601 requested_name: None,
6602 namespace_alias: None,
6603 wildcard,
6604 line: line_index.byte_to_line(import.byte_range.start),
6605 byte_start: import.byte_range.start,
6606 byte_end: import.byte_range.end,
6607 dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
6608 });
6609 }
6610
6611 ReexportRefs {
6612 raw_refs,
6613 surface_parts,
6614 }
6615}
6616
6617fn rust_pub_use_reexport_parts(
6618 import: &ImportStatement,
6619) -> Option<(String, HashMap<String, String>, bool)> {
6620 let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
6621 let body = body.trim();
6622 if let Some(module_path) = body.strip_suffix("::*") {
6623 return Some((module_path.trim().to_string(), HashMap::new(), true));
6624 }
6625
6626 if let Some(brace_start) = body.find("::{") {
6627 let module_path = body[..brace_start].trim().to_string();
6628 let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
6629 if names.is_empty() {
6630 return None;
6631 }
6632 return Some((module_path, names, false));
6633 }
6634
6635 let (module_path, spec) = body.rsplit_once("::")?;
6636 let names = rust_reexport_names_from_specs(spec);
6637 if names.is_empty() {
6638 return None;
6639 }
6640 Some((module_path.trim().to_string(), names, false))
6641}
6642
6643fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
6644 let mut names = HashMap::new();
6645 for spec in specs.split(',') {
6646 let spec = spec.trim();
6647 if spec.is_empty() || spec == "self" {
6648 continue;
6649 }
6650 if let Some((source, local)) = spec.split_once(" as ") {
6651 let source = source.trim();
6652 let local = local.trim();
6653 if !source.is_empty() && !local.is_empty() && source != "self" {
6654 names.insert(local.to_string(), source.to_string());
6655 }
6656 } else {
6657 names.insert(spec.to_string(), spec.to_string());
6658 }
6659 }
6660 names
6661}
6662
6663fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
6664 if named.is_empty() {
6665 return fallback.to_string();
6666 }
6667 let mut specs = named
6668 .iter()
6669 .map(|(local, source)| {
6670 if local == source {
6671 source.clone()
6672 } else {
6673 format!("{source} as {local}")
6674 }
6675 })
6676 .collect::<Vec<_>>();
6677 specs.sort();
6678 format!("pub use {{{}}};", specs.join(", "))
6679}
6680
6681fn quoted_module_path(statement: &str) -> Option<String> {
6682 let quote = match (statement.find('\''), statement.find('"')) {
6683 (Some(single), Some(double)) if single < double => '\'',
6684 (Some(_), Some(_)) => '"',
6685 (Some(_), None) => '\'',
6686 (None, Some(_)) => '"',
6687 (None, None) => return None,
6688 };
6689 let start = statement.find(quote)? + 1;
6690 let end = statement[start..].find(quote)? + start;
6691 Some(statement[start..end].to_string())
6692}
6693
6694#[derive(Debug, Clone)]
6695struct SourceLessExportRefs {
6696 raw_refs: Vec<RawRef>,
6697 surface_parts: Vec<String>,
6698}
6699
6700fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
6701 let mut raw_refs = Vec::new();
6702 let mut surface_parts = Vec::new();
6703 let mut search_start = 0usize;
6704 let mut ordinal = 0usize;
6705 while let Some(export_offset) = source[search_start..].find("export") {
6706 let start = search_start + export_offset;
6707 let Some(statement_end_offset) = source[start..].find(';') else {
6708 break;
6709 };
6710 let end = start + statement_end_offset + 1;
6711 let statement = &source[start..end];
6712 search_start = end;
6713 if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
6714 continue;
6715 }
6716 let aliases = parse_reexport_names(statement);
6717 if aliases.is_empty() {
6718 continue;
6719 }
6720 let line = source[..start]
6721 .bytes()
6722 .filter(|byte| *byte == b'\n')
6723 .count() as u32
6724 + 1;
6725 for (exported, source_symbol) in aliases {
6726 ordinal += 1;
6727 let ref_id = ref_id(&[
6728 rel_path,
6729 "export_alias",
6730 &start.to_string(),
6731 &end.to_string(),
6732 &exported,
6733 &source_symbol,
6734 &ordinal.to_string(),
6735 ]);
6736 surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
6737 raw_refs.push(RawRef {
6738 ref_id,
6739 caller_node: None,
6740 caller_symbol: None,
6741 caller_file: rel_path.to_string(),
6742 kind: "export_alias".to_string(),
6743 short_name: None,
6744 full_ref: Some(statement.to_string()),
6745 module_path: None,
6746 import_kind: Some("export_alias".to_string()),
6747 local_name: Some(exported),
6748 requested_name: Some(source_symbol),
6749 namespace_alias: None,
6750 wildcard: false,
6751 line,
6752 byte_start: start,
6753 byte_end: end,
6754 dependencies: BTreeSet::new(),
6755 });
6756 }
6757 }
6758 SourceLessExportRefs {
6759 raw_refs,
6760 surface_parts,
6761 }
6762}
6763
6764fn build_dispatch_hints(
6765 rel_path: &str,
6766 data: &FileCallData,
6767 node_by_scoped: &HashMap<String, String>,
6768) -> Vec<DispatchHint> {
6769 let mut hints = Vec::new();
6770 let mut ordinal = 0usize;
6771 for (caller_symbol, call_sites) in &data.calls_by_symbol {
6772 let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
6773 continue;
6774 };
6775 for call_site in call_sites {
6776 if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
6777 continue;
6778 }
6779 ordinal += 1;
6780 hints.push(DispatchHint {
6781 id: ref_id(&[
6782 rel_path,
6783 "dispatch",
6784 caller_symbol,
6785 &call_site.line.to_string(),
6786 &call_site.byte_start.to_string(),
6787 &call_site.byte_end.to_string(),
6788 &ordinal.to_string(),
6789 ]),
6790 method_name: call_site.callee_name.clone(),
6791 caller_node: caller_node.clone(),
6792 file: rel_path.to_string(),
6793 line: call_site.line,
6794 byte_start: call_site.byte_start,
6795 byte_end: call_site.byte_end,
6796 });
6797 }
6798 }
6799 hints
6800}
6801
6802fn surface_fingerprint(
6803 nodes: &mut [NodeRecord],
6804 data: &FileCallData,
6805 reexport_parts: &[String],
6806) -> String {
6807 nodes.sort_by(|left, right| {
6808 (left.file_path.as_str(), left.scoped_name.as_str())
6809 .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
6810 });
6811 let mut parts = Vec::new();
6812 for node in nodes.iter() {
6813 parts.push(format!(
6814 "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
6815 node.scoped_name,
6816 node.name,
6817 node.kind,
6818 node.exported,
6819 node.range.start_line,
6820 node.range.start_col,
6821 node.range.end_line,
6822 node.range.end_col,
6823 node.range_ordinal,
6824 node.signature.as_deref().unwrap_or("")
6825 ));
6826 }
6827 let mut exports = data.exported_symbols.clone();
6828 exports.sort();
6829 for export in exports {
6830 parts.push(format!("export\t{export}"));
6831 }
6832 if let Some(default_export) = &data.default_export_symbol {
6833 parts.push(format!("default\t{default_export}"));
6834 }
6835 let mut imports: Vec<String> = data
6836 .import_block
6837 .imports
6838 .iter()
6839 .map(|import| {
6840 format!(
6841 "import\t{}\t{:?}\t{}",
6842 import.module_path, import.form, import.raw_text
6843 )
6844 })
6845 .collect();
6846 imports.sort();
6847 parts.extend(imports);
6848 parts.extend(reexport_parts.iter().cloned());
6849 hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
6850}
6851
6852fn resolve_ref(raw: RawRef, index: &ProjectIndex<'_>) -> Result<ResolvedRef> {
6853 if raw.kind != "call" {
6854 return Ok(ResolvedRef {
6855 dependencies: raw.dependencies.clone(),
6856 raw,
6857 status: "unresolved".to_string(),
6858 target_node: None,
6859 target_file: None,
6860 target_symbol: None,
6861 edge: None,
6862 });
6863 }
6864
6865 let caller_file = raw.caller_file.clone();
6866 let caller_data = index.caller_data.get(&caller_file).ok_or_else(|| {
6867 CallGraphStoreError::MissingCallerData {
6868 file: caller_file.clone(),
6869 }
6870 })?;
6871 let full_ref = raw.full_ref.as_deref().unwrap_or_default();
6872 let short_name = raw.short_name.as_deref().unwrap_or_default();
6873 let mut dependencies = raw.dependencies.clone();
6874
6875 let resolved = match index.lang_for(&caller_file) {
6876 Some(LangId::Rust) => {
6877 resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
6878 }
6879 Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
6880 resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
6881 }
6882 _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
6883 };
6884
6885 let Some((status, target_file, target_symbol)) = resolved else {
6886 return Ok(ResolvedRef {
6887 raw,
6888 status: "unresolved".to_string(),
6889 target_node: None,
6890 target_file: None,
6891 target_symbol: None,
6892 dependencies,
6893 edge: None,
6894 });
6895 };
6896
6897 dependencies.insert(target_file.clone());
6898 let target_node = index.node_for_symbol(&target_file, &target_symbol);
6899 let source_node = raw.caller_node.clone();
6900 let edge = if let Some(source_node) = source_node {
6901 if target_file == caller_file
6902 && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
6903 {
6904 None
6905 } else {
6906 Some(EdgeRecord {
6907 edge_id: ref_id(&[&raw.ref_id, "edge"]),
6908 source_node,
6909 target_node: target_node.clone(),
6910 target_file: target_file.clone(),
6911 target_symbol: target_symbol.clone(),
6912 kind: "call".to_string(),
6913 line: raw.line,
6914 })
6915 }
6916 } else {
6917 None
6918 };
6919
6920 Ok(ResolvedRef {
6921 raw,
6922 status,
6923 target_node,
6924 target_file: Some(target_file),
6925 target_symbol: Some(target_symbol),
6926 dependencies,
6927 edge,
6928 })
6929}
6930
6931fn resolve_js_ts_target(
6932 index: &ProjectIndex<'_>,
6933 caller_file: &str,
6934 full_ref: &str,
6935 short_name: &str,
6936 caller_data: &FileCallData,
6937) -> Option<(String, String, String)> {
6938 if let Some((namespace, member)) = full_ref.split_once('.') {
6939 for import in &caller_data.import_block.imports {
6940 if import.namespace_import.as_deref() == Some(namespace) {
6941 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6942 if let Some((file, symbol)) =
6943 resolve_exported_symbol(index, &target_file, member, 0)
6944 {
6945 return Some(("resolved".to_string(), file, symbol));
6946 }
6947 }
6948 }
6949 }
6950 }
6951
6952 for import in &caller_data.import_block.imports {
6953 for spec in &import.names {
6954 if crate::imports::specifier_local_name(spec) == short_name {
6955 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6956 let requested = crate::imports::specifier_imported_name(spec);
6957 let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
6958 .unwrap_or_else(|| (target_file, requested.to_string()));
6959 return Some(("resolved".to_string(), file, symbol));
6960 }
6961 }
6962 }
6963
6964 if import.default_import.as_deref() == Some(short_name) {
6965 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6966 let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
6967 .or_else(|| {
6968 index
6969 .files
6970 .get(&target_file)
6971 .and_then(|file| file.default_export.clone())
6972 .map(|symbol| (target_file.clone(), symbol))
6973 })
6974 .unwrap_or_else(|| {
6975 let file_name = Path::new(&target_file)
6976 .file_name()
6977 .and_then(|name| name.to_str())
6978 .unwrap_or("unknown")
6979 .to_string();
6980 (target_file, format!("<default:{file_name}>"))
6981 });
6982 return Some(("resolved".to_string(), file, symbol));
6983 }
6984 }
6985 }
6986
6987 for import in &caller_data.import_block.imports {
6988 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6989 if index
6990 .files
6991 .get(&target_file)
6992 .map(|file| file.exports.contains(short_name))
6993 .unwrap_or(false)
6994 {
6995 return Some(("resolved".to_string(), target_file, short_name.to_string()));
6996 }
6997 }
6998 }
6999
7000 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
7001}
7002
7003fn resolve_exported_symbol(
7004 index: &ProjectIndex<'_>,
7005 file: &str,
7006 requested: &str,
7007 depth: usize,
7008) -> Option<(String, String)> {
7009 let mut visited = std::collections::HashMap::new();
7010 resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
7011}
7012
7013fn resolve_exported_symbol_inner(
7022 index: &ProjectIndex<'_>,
7023 file: &str,
7024 requested: &str,
7025 depth: usize,
7026 visited: &mut std::collections::HashMap<(String, String), usize>,
7027) -> Option<(String, String)> {
7028 if depth > 16 {
7029 return None;
7030 }
7031 if requested != "default" {
7032 if let Some(source_symbol) = index
7033 .files
7034 .get(file)
7035 .and_then(|item| item.export_aliases.get(requested))
7036 {
7037 return Some((file.to_string(), source_symbol.clone()));
7038 }
7039 if index
7040 .files
7041 .get(file)
7042 .map(|item| item.exports.contains(requested))
7043 .unwrap_or(false)
7044 {
7045 return Some((file.to_string(), requested.to_string()));
7046 }
7047 } else if let Some(default) = index
7048 .files
7049 .get(file)
7050 .and_then(|item| item.default_export.clone())
7051 {
7052 return Some((file.to_string(), default));
7053 }
7054
7055 match visited.entry((file.to_string(), requested.to_string())) {
7059 std::collections::hash_map::Entry::Occupied(mut seen) => {
7060 if *seen.get() <= depth {
7061 return None;
7062 }
7063 seen.insert(depth);
7064 }
7065 std::collections::hash_map::Entry::Vacant(slot) => {
7066 slot.insert(depth);
7067 }
7068 }
7069
7070 for reexport in index.reexports_for(file) {
7071 let mut next_requested = requested.to_string();
7072 let matches = if reexport.wildcard {
7073 true
7074 } else if let Some(source_name) = reexport.named.get(requested) {
7075 next_requested = source_name.clone();
7076 true
7077 } else {
7078 false
7079 };
7080 if !matches {
7081 continue;
7082 }
7083 if let Some(target_file) = &reexport.target_file {
7084 if let Some(target) = resolve_exported_symbol_inner(
7085 index,
7086 target_file,
7087 &next_requested,
7088 depth + 1,
7089 visited,
7090 ) {
7091 return Some(target);
7092 }
7093 }
7094 }
7095 None
7096}
7097
7098fn resolve_rust_target(
7099 index: &ProjectIndex<'_>,
7100 caller_file: &str,
7101 full_ref: &str,
7102 short_name: &str,
7103 caller_data: &FileCallData,
7104 raw: &RawRef,
7105) -> Option<(String, String, String)> {
7106 if full_ref.contains("::") {
7107 if let Some((target_file, target_symbol)) =
7108 rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
7109 {
7110 return Some(("resolved".to_string(), target_file, target_symbol));
7111 }
7112 }
7113
7114 for import in &caller_data.import_block.imports {
7115 if let Some((target_file, target_symbol)) =
7116 rust_target_for_use(index, caller_file, import, short_name)
7117 {
7118 return Some(("resolved".to_string(), target_file, target_symbol));
7119 }
7120 }
7121
7122 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
7123}
7124
7125fn rust_target_for_qualified(
7126 index: &ProjectIndex<'_>,
7127 caller_file: &str,
7128 full_ref: &str,
7129 short_name: &str,
7130 caller_data: &FileCallData,
7131 raw: &RawRef,
7132) -> Option<(String, String)> {
7133 let mut segments: Vec<&str> = full_ref.split("::").collect();
7134 if segments.len() < 2 {
7135 return None;
7136 }
7137 segments.pop();
7138 let requested_symbol = rust_target_symbol(full_ref, short_name);
7139
7140 for path in rust_module_path_candidates(&segments, caller_data, raw) {
7141 let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
7142 if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
7143 if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
7144 return Some(rust_resolve_reexport_if_symbol_missing(
7145 index,
7146 target_file,
7147 requested_symbol.clone(),
7148 ));
7149 }
7150 }
7151
7152 let module_segments = rust_resolve_segments(caller_file, &path_refs)?;
7153 if let Some(target) =
7154 rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
7155 {
7156 return Some(target);
7157 }
7158 if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
7159 return Some(rust_resolve_reexport_if_symbol_missing(
7160 index,
7161 target_file,
7162 requested_symbol.clone(),
7163 ));
7164 }
7165 }
7166 None
7167}
7168
7169fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
7170 full_ref
7171 .rsplit("::")
7172 .next()
7173 .filter(|name| !name.is_empty())
7174 .unwrap_or(short_name)
7175 .to_string()
7176}
7177
7178fn rust_resolve_reexport_if_symbol_missing(
7179 index: &ProjectIndex<'_>,
7180 target_file: String,
7181 target_symbol: String,
7182) -> (String, String) {
7183 if index
7184 .node_for_symbol(&target_file, &target_symbol)
7185 .is_some()
7186 {
7187 return (target_file, target_symbol);
7188 }
7189 if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
7190 resolved
7191 } else {
7192 (target_file, target_symbol)
7193 }
7194}
7195
7196fn rust_module_path_candidates(
7197 segments: &[&str],
7198 caller_data: &FileCallData,
7199 raw: &RawRef,
7200) -> Vec<Vec<String>> {
7201 let mut candidates = Vec::new();
7202 if let Some(first) = segments.first().copied() {
7203 for import in &caller_data.import_block.imports {
7204 if !rust_import_is_visible_to_call(import, raw) {
7205 continue;
7206 }
7207 let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
7208 continue;
7209 };
7210 if local_name == first {
7211 path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
7212 rust_push_unique_path_candidate(&mut candidates, path_segments);
7213 }
7214 }
7215 }
7216 rust_push_unique_path_candidate(
7217 &mut candidates,
7218 segments
7219 .iter()
7220 .map(|segment| (*segment).to_string())
7221 .collect(),
7222 );
7223 candidates
7224}
7225
7226fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
7227 if !candidates.iter().any(|existing| existing == &candidate) {
7228 candidates.push(candidate);
7229 }
7230}
7231
7232fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
7233 import.byte_range.start <= raw.byte_start
7234}
7235
7236fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
7237 let path = import.module_path.trim().trim_end_matches(';').trim();
7238 if path.contains("::{") || path.contains('{') || path.contains('*') {
7239 return None;
7240 }
7241 let (path_without_alias, alias) = path
7242 .split_once(" as ")
7243 .map(|(left, right)| (left.trim(), Some(right.trim())))
7244 .unwrap_or((path, None));
7245 let segments = path_without_alias
7246 .split("::")
7247 .map(str::trim)
7248 .filter(|segment| !segment.is_empty())
7249 .collect::<Vec<_>>();
7250 let local_name = alias.or_else(|| segments.last().copied())?.to_string();
7251 if local_name.chars().next().is_some_and(char::is_uppercase) {
7252 return None;
7253 }
7254 Some((
7255 local_name,
7256 segments
7257 .into_iter()
7258 .map(|segment| segment.to_string())
7259 .collect(),
7260 ))
7261}
7262
7263fn rust_inline_scoped_target(
7264 index: &ProjectIndex<'_>,
7265 caller_file: &str,
7266 module_segments: &[String],
7267 short_name: &str,
7268) -> Option<(String, String)> {
7269 let src_prefix = rust_src_prefix(caller_file);
7270 let mut file_paths = index.files.keys().cloned().collect::<Vec<_>>();
7271 file_paths.sort();
7272 if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
7273 let caller = file_paths.remove(position);
7274 file_paths.insert(0, caller);
7275 }
7276
7277 for file_path in file_paths {
7278 if index.lang_for(&file_path) != Some(LangId::Rust)
7279 || rust_src_prefix(&file_path) != src_prefix
7280 {
7281 continue;
7282 }
7283 let file_module_segments = rust_module_segments_for_rel(&file_path);
7284 if !module_segments.starts_with(&file_module_segments) {
7285 continue;
7286 }
7287 let scoped_segments = &module_segments[file_module_segments.len()..];
7288 if scoped_segments.is_empty() {
7289 continue;
7290 }
7291 let mut scoped_symbol = scoped_segments.join("::");
7292 scoped_symbol.push_str("::");
7293 scoped_symbol.push_str(short_name);
7294 if index.node_for_symbol(&file_path, &scoped_symbol).is_some() {
7295 return Some((file_path, scoped_symbol));
7296 }
7297 }
7298 None
7299}
7300
7301fn rust_target_for_use(
7302 index: &ProjectIndex<'_>,
7303 caller_file: &str,
7304 import: &ImportStatement,
7305 short_name: &str,
7306) -> Option<(String, String)> {
7307 let path = import.module_path.trim().trim_end_matches(';');
7308 if let Some(brace_start) = path.find("::{") {
7309 let prefix = &path[..brace_start];
7310 if import.names.iter().any(|name| name == short_name) {
7311 let prefix_segments: Vec<&str> = prefix.split("::").collect();
7312 let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
7313 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
7314 return Some((file, short_name.to_string()));
7315 }
7316 return None;
7317 }
7318
7319 let (path_without_alias, alias) = path
7320 .split_once(" as ")
7321 .map(|(left, right)| (left.trim(), Some(right.trim())))
7322 .unwrap_or((path, None));
7323 let segments: Vec<&str> = path_without_alias.split("::").collect();
7324 let imported = alias.or_else(|| segments.last().copied())?;
7325 if imported != short_name {
7326 return None;
7327 }
7328 if segments.len() < 2 {
7329 return None;
7330 }
7331 let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
7332 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
7333 Some((file, segments.last().unwrap_or(&short_name).to_string()))
7334}
7335
7336fn rust_workspace_file_for_segments(index: &ProjectIndex<'_>, segments: &[&str]) -> Option<String> {
7337 let crate_name = segments.first().copied()?;
7338 let src_prefix = index.crate_src_prefix(crate_name)?;
7339 let module_segments = segments[1..]
7340 .iter()
7341 .map(|segment| segment.to_string())
7342 .collect::<Vec<_>>();
7343 rust_file_for_src_prefix(index, &src_prefix, &module_segments)
7344}
7345
7346#[cfg(test)]
7347static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
7348 OnceLock::new();
7349
7350#[cfg(test)]
7351fn note_workspace_crate_prefix_build(project_root: &Path) {
7352 let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
7353 .get_or_init(|| Mutex::new(HashMap::new()))
7354 .lock()
7355 .expect("workspace crate prefix build counts mutex poisoned");
7356 *counts.entry(project_root.to_path_buf()).or_default() += 1;
7357}
7358
7359#[cfg(not(test))]
7360fn note_workspace_crate_prefix_build(_project_root: &Path) {}
7361
7362#[cfg(test)]
7363fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
7364 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
7365 .get_or_init(|| Mutex::new(HashMap::new()))
7366 .lock()
7367 .expect("workspace crate prefix build counts mutex poisoned")
7368 .remove(project_root);
7369}
7370
7371#[cfg(test)]
7372fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
7373 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
7374 .get_or_init(|| Mutex::new(HashMap::new()))
7375 .lock()
7376 .expect("workspace crate prefix build counts mutex poisoned")
7377 .get(project_root)
7378 .copied()
7379 .unwrap_or(0)
7380}
7381
7382fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
7387 note_workspace_crate_prefix_build(project_root);
7388 let mut prefixes = HashMap::new();
7389 let mut stack = vec![project_root.to_path_buf()];
7390 while let Some(dir) = stack.pop() {
7391 let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
7392 if matches!(name, "target" | "node_modules" | ".git") {
7393 continue;
7394 }
7395 let manifest = dir.join("Cargo.toml");
7396 if manifest.is_file() {
7397 let crate_names = rust_manifest_crate_names(&manifest);
7398 if !crate_names.is_empty() {
7399 let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
7400 for crate_name in crate_names {
7401 prefixes
7402 .entry(crate_name)
7403 .or_insert_with(|| src_prefix.clone());
7404 }
7405 }
7406 }
7407 let Ok(entries) = std::fs::read_dir(&dir) else {
7408 continue;
7409 };
7410 for entry in entries.flatten() {
7411 let path = entry.path();
7412 if path.is_dir() {
7413 stack.push(path);
7414 }
7415 }
7416 }
7417 prefixes
7418}
7419
7420fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
7424 let Ok(source) = std::fs::read_to_string(manifest) else {
7425 return Vec::new();
7426 };
7427 let mut in_lib = false;
7428 let mut package_name = None;
7429 let mut lib_name = None;
7430 for line in source.lines() {
7431 let trimmed = line.trim();
7432 if trimmed.starts_with('[') {
7433 in_lib = trimmed == "[lib]";
7434 continue;
7435 }
7436 let Some((key, value)) = trimmed.split_once('=') else {
7437 continue;
7438 };
7439 let key = key.trim();
7440 let value = value.trim().trim_matches('"');
7441 if in_lib && key == "name" {
7442 lib_name = Some(value.to_string());
7443 } else if !in_lib && key == "name" && package_name.is_none() {
7444 package_name = Some(value.to_string());
7445 }
7446 }
7447 let mut names = Vec::new();
7448 if let Some(lib) = lib_name {
7449 names.push(lib);
7450 }
7451 if let Some(package) = package_name {
7452 let normalized = package.replace('-', "_");
7453 if !names.contains(&normalized) {
7454 names.push(normalized);
7455 }
7456 }
7457 names
7458}
7459
7460fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
7461 if segments.is_empty() {
7462 return Some(Vec::new());
7463 }
7464 let caller_segments = rust_module_segments_for_rel(caller_file);
7465 match segments[0] {
7466 "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
7467 "self" => {
7468 let mut resolved = caller_segments;
7469 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
7470 Some(resolved)
7471 }
7472 "super" => {
7473 let mut resolved = caller_segments;
7474 resolved.pop();
7475 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
7476 Some(resolved)
7477 }
7478 _ => {
7479 let mut resolved = caller_segments;
7480 resolved.pop();
7481 resolved.extend(segments.iter().map(|item| item.to_string()));
7482 Some(resolved)
7483 }
7484 }
7485}
7486
7487fn rust_file_for_segments(
7488 index: &ProjectIndex<'_>,
7489 caller_file: &str,
7490 segments: &[String],
7491) -> Option<String> {
7492 rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
7493}
7494
7495fn rust_file_for_src_prefix(
7496 index: &ProjectIndex<'_>,
7497 src_prefix: &str,
7498 segments: &[String],
7499) -> Option<String> {
7500 let candidate = if segments.is_empty() {
7501 [src_prefix, "lib.rs"].join("/")
7502 } else {
7503 format!("{}/{}.rs", src_prefix, segments.join("/"))
7504 };
7505 if index.files.contains_key(&candidate) {
7506 return Some(candidate);
7507 }
7508 if !segments.is_empty() {
7509 let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
7510 if index.files.contains_key(&mod_candidate) {
7511 return Some(mod_candidate);
7512 }
7513 }
7514 None
7515}
7516
7517fn rust_src_prefix(rel_path: &str) -> String {
7518 rel_path
7519 .split_once("/src/")
7520 .map(|(prefix, _)| format!("{prefix}/src"))
7521 .unwrap_or_else(|| "src".to_string())
7522}
7523
7524fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
7525 let after_src = rel_path
7526 .split_once("/src/")
7527 .map(|(_, rest)| rest)
7528 .or_else(|| rel_path.strip_prefix("src/"))
7529 .unwrap_or(rel_path);
7530 if matches!(after_src, "lib.rs" | "main.rs") {
7531 return Vec::new();
7532 }
7533 if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
7534 return prefix.split('/').map(|item| item.to_string()).collect();
7535 }
7536 after_src
7537 .strip_suffix(".rs")
7538 .unwrap_or(after_src)
7539 .split('/')
7540 .map(|item| item.to_string())
7541 .collect()
7542}
7543
7544fn resolve_local_target(
7545 _index: &ProjectIndex<'_>,
7546 caller_file: &str,
7547 full_ref: &str,
7548 short_name: &str,
7549 caller_data: &FileCallData,
7550) -> Option<(String, String, String)> {
7551 if !callgraph::is_bare_callee(full_ref, short_name) {
7552 return None;
7553 }
7554 callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
7555 .ok()
7556 .map(|symbol| {
7557 (
7558 "resolved_local".to_string(),
7559 caller_file.to_string(),
7560 symbol,
7561 )
7562 })
7563}
7564
7565impl<'a> ProjectIndex<'a> {
7566 fn from_parts(
7567 project_root: &Path,
7568 files: HashMap<String, DbFileIndex>,
7569 caller_data: HashMap<String, &'a FileCallData>,
7570 workspace_crate_prefixes: WorkspaceCratePrefixCache,
7571 ) -> Self {
7572 Self {
7573 project_root: project_root.to_path_buf(),
7574 files,
7575 caller_data,
7576 workspace_crate_prefixes,
7577 }
7578 }
7579
7580 fn from_extracts(project_root: &Path, extracts: &'a [FileExtract]) -> Self {
7581 let mut files = HashMap::new();
7582 let mut caller_data = HashMap::new();
7583 for extract in extracts {
7584 let index = DbFileIndex::from_extract(project_root, extract);
7585 caller_data.insert(extract.rel_path.clone(), &extract.data);
7586 files.insert(extract.rel_path.clone(), index);
7587 }
7588 Self::from_parts(
7589 project_root,
7590 files,
7591 caller_data,
7592 WorkspaceCratePrefixCache::default(),
7593 )
7594 }
7595
7596 fn from_db_and_callers(
7597 tx: &Transaction<'_>,
7598 project_root: &Path,
7599 caller_extracts: &'a HashMap<String, FileExtract>,
7600 workspace_crate_prefixes: WorkspaceCratePrefixCache,
7601 ) -> Result<Self> {
7602 let mut files = load_db_file_indexes(tx, project_root)?;
7603 let mut caller_data = HashMap::new();
7604 for (rel_path, extract) in caller_extracts {
7605 files.insert(
7606 rel_path.clone(),
7607 DbFileIndex::from_extract(project_root, extract),
7608 );
7609 caller_data.insert(rel_path.clone(), &extract.data);
7610 }
7611 Ok(Self::from_parts(
7612 project_root,
7613 files,
7614 caller_data,
7615 workspace_crate_prefixes,
7616 ))
7617 }
7618
7619 fn lang_for(&self, rel_path: &str) -> Option<LangId> {
7620 self.files.get(rel_path).and_then(|file| file.lang)
7621 }
7622
7623 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
7624 self.files
7625 .get(caller_file)
7626 .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
7627 }
7628
7629 fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
7630 self.files
7631 .get(rel_path)
7632 .map(|file| file.reexports.as_slice())
7633 .unwrap_or(&[])
7634 }
7635
7636 fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
7637 self.files.get(rel_path).and_then(|file| {
7638 file.node_by_scoped
7639 .get(symbol)
7640 .cloned()
7641 .or_else(|| file.node_by_bare.get(symbol).cloned())
7642 })
7643 }
7644}
7645
7646impl DbFileIndex {
7647 fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
7648 let mut node_by_scoped = HashMap::new();
7649 let mut node_by_bare = HashMap::new();
7650 for node in &extract.nodes {
7651 node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
7652 node_by_bare
7653 .entry(node.name.clone())
7654 .or_insert(node.id.clone());
7655 }
7656 let mut export_aliases = HashMap::new();
7657 for raw_ref in &extract.raw_refs {
7658 if raw_ref.kind == "export_alias" {
7659 if let (Some(exported), Some(source_symbol)) =
7660 (&raw_ref.local_name, &raw_ref.requested_name)
7661 {
7662 export_aliases.insert(exported.clone(), source_symbol.clone());
7663 }
7664 }
7665 }
7666 let mut module_targets = HashMap::new();
7667 let mut reexports = Vec::new();
7668 for raw_ref in &extract.raw_refs {
7669 if !matches!(raw_ref.kind.as_str(), "import" | "reexport") {
7670 continue;
7671 }
7672 let Some(module_path) = &raw_ref.module_path else {
7673 continue;
7674 };
7675 let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
7676 module_targets
7677 .entry(module_path.clone())
7678 .or_insert_with(|| target_file.clone());
7679 if raw_ref.kind == "reexport" {
7680 reexports.push(reexport_index_from_raw(raw_ref, target_file));
7681 }
7682 }
7683 Self {
7684 lang: Some(extract.lang),
7685 exports: extract.data.exported_symbols.iter().cloned().collect(),
7686 default_export: extract.data.default_export_symbol.clone(),
7687 export_aliases,
7688 node_by_scoped,
7689 node_by_bare,
7690 module_targets,
7691 reexports,
7692 }
7693 }
7694}
7695
7696fn load_db_file_indexes(
7697 tx: &Transaction<'_>,
7698 project_root: &Path,
7699) -> Result<HashMap<String, DbFileIndex>> {
7700 let mut files = HashMap::new();
7701 let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
7702 let rows = stmt.query_map([], |row| {
7703 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7704 })?;
7705 for row in rows {
7706 let (rel_path, lang) = row?;
7707 files.insert(
7708 rel_path.clone(),
7709 DbFileIndex {
7710 lang: lang_from_label(&lang),
7711 exports: HashSet::new(),
7712 default_export: None,
7713 export_aliases: HashMap::new(),
7714 node_by_scoped: HashMap::new(),
7715 node_by_bare: HashMap::new(),
7716 module_targets: HashMap::new(),
7717 reexports: Vec::new(),
7718 },
7719 );
7720 }
7721
7722 let mut node_stmt = tx.prepare(
7723 "SELECT file_path, id, name, scoped_name, exported, is_default_export FROM nodes",
7724 )?;
7725 let nodes = node_stmt.query_map([], |row| {
7726 Ok((
7727 row.get::<_, String>(0)?,
7728 row.get::<_, String>(1)?,
7729 row.get::<_, String>(2)?,
7730 row.get::<_, String>(3)?,
7731 row.get::<_, i64>(4)? != 0,
7732 row.get::<_, i64>(5)? != 0,
7733 ))
7734 })?;
7735 for row in nodes {
7736 let (file_path, id, name, scoped_name, exported, is_default_export) = row?;
7737 let file = files
7738 .entry(file_path.clone())
7739 .or_insert_with(|| DbFileIndex {
7740 lang: None,
7741 exports: HashSet::new(),
7742 default_export: None,
7743 export_aliases: HashMap::new(),
7744 node_by_scoped: HashMap::new(),
7745 node_by_bare: HashMap::new(),
7746 module_targets: HashMap::new(),
7747 reexports: Vec::new(),
7748 });
7749 if exported {
7750 file.exports.insert(name.clone());
7751 file.exports.insert(scoped_name.clone());
7752 }
7753 if is_default_export {
7754 file.default_export = Some(scoped_name.clone());
7755 }
7756 file.node_by_scoped.insert(scoped_name, id.clone());
7757 file.node_by_bare.entry(name).or_insert(id);
7758 }
7759 let file_keys: HashSet<String> = files.keys().cloned().collect();
7760 let dependencies_by_file = load_file_dependencies_index(tx)?;
7764 let mut ref_stmt = tx.prepare(
7765 "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
7766 FROM refs WHERE kind IN ('reexport', 'export_alias')",
7767 )?;
7768 let ref_rows = ref_stmt.query_map([], |row| {
7769 Ok((
7770 row.get::<_, String>(0)?,
7771 row.get::<_, String>(1)?,
7772 row.get::<_, String>(2)?,
7773 row.get::<_, Option<String>>(3)?,
7774 row.get::<_, Option<String>>(4)?,
7775 row.get::<_, i64>(5)? != 0,
7776 row.get::<_, Option<String>>(6)?,
7777 row.get::<_, Option<String>>(7)?,
7778 ))
7779 })?;
7780 for row in ref_rows {
7781 let (
7782 ref_id,
7783 caller_file,
7784 kind,
7785 module_path,
7786 full_ref,
7787 wildcard,
7788 local_name,
7789 requested_name,
7790 ) = row?;
7791 if kind == "export_alias" {
7792 if let (Some(exported), Some(source_symbol), Some(file)) =
7793 (local_name, requested_name, files.get_mut(&caller_file))
7794 {
7795 file.export_aliases.insert(exported, source_symbol);
7796 }
7797 continue;
7798 }
7799 let Some(module_path) = module_path else {
7800 continue;
7801 };
7802 let file_deps = dependencies_by_file
7803 .get(&caller_file)
7804 .cloned()
7805 .unwrap_or_default();
7806 let deps = stored_dependencies_for_module(
7807 project_root,
7808 &caller_file,
7809 &module_path,
7810 &file_deps,
7811 &file_keys,
7812 );
7813 let target_file = deps
7814 .iter()
7815 .find(|dep| file_keys.contains(*dep))
7816 .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
7817 if let Some(file) = files.get_mut(&caller_file) {
7818 file.module_targets
7819 .entry(module_path.clone())
7820 .or_insert_with(|| target_file.clone());
7821 if kind == "reexport" {
7822 let raw = RawRef {
7823 ref_id,
7824 caller_node: None,
7825 caller_symbol: None,
7826 caller_file,
7827 kind,
7828 short_name: None,
7829 full_ref,
7830 module_path: Some(module_path),
7831 import_kind: Some("reexport".to_string()),
7832 local_name: None,
7833 requested_name: None,
7834 namespace_alias: None,
7835 wildcard,
7836 line: 0,
7837 byte_start: 0,
7838 byte_end: 0,
7839 dependencies: deps,
7840 };
7841 file.reexports
7842 .push(reexport_index_from_raw(&raw, target_file));
7843 }
7844 }
7845 }
7846
7847 Ok(files)
7848}
7849
7850fn stored_dependencies_for_module(
7851 project_root: &Path,
7852 caller_file: &str,
7853 module_path: &str,
7854 caller_dependencies: &BTreeSet<String>,
7855 indexed_files: &HashSet<String>,
7856) -> BTreeSet<String> {
7857 let caller_path = project_root.join(caller_file);
7858 let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
7859 if module_path.starts_with('.') {
7860 let caller_dir = caller_path.parent().unwrap_or(project_root);
7861 for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
7862 let normalized = if candidate.is_file() {
7863 canonicalize_path(&candidate)
7864 } else {
7865 candidate
7866 };
7867 candidates.insert(relative_path(project_root, &normalized));
7868 }
7869 }
7870 let exact = candidates
7871 .intersection(caller_dependencies)
7872 .filter(|dependency| indexed_files.contains(*dependency))
7873 .cloned()
7874 .collect::<BTreeSet<_>>();
7875 if !exact.is_empty() || module_path.starts_with('.') {
7876 return exact;
7877 }
7878
7879 let module_path = rust_module_path_without_alias_or_use_list(module_path)
7880 .trim_matches(|character| matches!(character, '\'' | '"'));
7881 let package_name = module_path
7882 .split('/')
7883 .next_back()
7884 .unwrap_or(module_path)
7885 .replace('_', "-");
7886 let matched = caller_dependencies
7887 .iter()
7888 .filter(|dependency| indexed_files.contains(*dependency))
7889 .filter(|dependency| {
7890 dependency.as_str() == module_path
7891 || dependency.ends_with(&format!("/{module_path}"))
7892 || Path::new(dependency).components().any(|component| {
7893 component.as_os_str().to_string_lossy().replace('_', "-") == package_name
7894 })
7895 })
7896 .cloned()
7897 .collect::<BTreeSet<_>>();
7898 if matched.len() == 1 {
7899 matched
7900 } else {
7901 BTreeSet::new()
7902 }
7903}
7904
7905fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
7906 let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
7907 let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
7908 let rows = stmt.query_map([], |row| {
7909 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7910 })?;
7911 for row in rows {
7912 let (file_path, dependency) = row?;
7913 by_file.entry(file_path).or_default().insert(dependency);
7914 }
7915 Ok(by_file)
7916}
7917
7918struct ColdBuildInsertStatements<'stmt> {
7919 file: Statement<'stmt>,
7920 node: Statement<'stmt>,
7921 file_dependency: Statement<'stmt>,
7922 dispatch_hint: Statement<'stmt>,
7923 backend_state: Statement<'stmt>,
7924 reference: Statement<'stmt>,
7925 edge: Statement<'stmt>,
7926}
7927
7928impl<'stmt> ColdBuildInsertStatements<'stmt> {
7929 fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
7930 Ok(Self {
7931 file: tx.prepare(
7932 "INSERT OR REPLACE INTO files(
7933 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
7934 is_public_api, surface_fingerprint, indexed_at
7935 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
7936 )?,
7937 node: tx.prepare(
7938 "INSERT OR REPLACE INTO nodes(
7939 id, file_path, name, scoped_name, kind, start_line, start_col,
7940 end_line, end_col, range_ordinal, signature, exported,
7941 is_default_export, is_type_like, is_callgraph_entry_point, provenance
7942 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
7943 )?,
7944 file_dependency: tx.prepare(
7945 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
7946 )?,
7947 dispatch_hint: tx.prepare(
7948 "INSERT OR REPLACE INTO dispatch_hints(
7949 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
7950 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
7951 )?,
7952 backend_state: tx.prepare(
7953 "INSERT OR REPLACE INTO backend_file_state(
7954 backend, workspace_root, file_path, content_hash, status, updated_at
7955 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
7956 )?,
7957 reference: tx.prepare(
7958 "INSERT OR REPLACE INTO refs(
7959 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
7960 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
7961 byte_start, byte_end, status, target_node, target_file, target_symbol,
7962 provenance
7963 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
7964 )?,
7965 edge: tx.prepare(
7966 "INSERT OR REPLACE INTO edges(
7967 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
7968 kind, line, provenance
7969 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
7970 )?,
7971 })
7972 }
7973}
7974
7975fn insert_file_extract_prepared(
7976 statements: &mut ColdBuildInsertStatements<'_>,
7977 workspace_root: &str,
7978 extract: &FileExtract,
7979) -> Result<()> {
7980 statements.file.execute(params![
7981 extract.rel_path,
7982 hash_to_hex(extract.freshness.content_hash),
7983 system_time_to_ns(extract.freshness.mtime),
7984 extract.freshness.size as i64,
7985 lang_label(extract.lang),
7986 extract.surface_fingerprint,
7987 unix_seconds_now(),
7988 ])?;
7989 for node in &extract.nodes {
7990 statements.node.execute(params![
7991 node.id,
7992 node.file_path,
7993 node.name,
7994 node.scoped_name,
7995 node.kind,
7996 node.range.start_line as i64,
7997 node.range.start_col as i64,
7998 node.range.end_line as i64,
7999 node.range.end_col as i64,
8000 node.range_ordinal as i64,
8001 node.signature,
8002 bool_int(node.exported),
8003 bool_int(node.is_default_export),
8004 bool_int(node.is_type_like),
8005 bool_int(node.is_callgraph_entry_point),
8006 PROVENANCE_TREESITTER,
8007 ])?;
8008 }
8009
8010 let mut dependencies = BTreeSet::new();
8011 for raw_ref in &extract.raw_refs {
8012 dependencies.extend(raw_ref.dependencies.iter().cloned());
8013 }
8014 for dep_file in &dependencies {
8015 statements
8016 .file_dependency
8017 .execute(params![extract.rel_path, dep_file])?;
8018 }
8019
8020 for hint in &extract.dispatch_hints {
8021 statements.dispatch_hint.execute(params![
8022 hint.id,
8023 hint.method_name,
8024 hint.caller_node,
8025 hint.file,
8026 hint.line as i64,
8027 hint.byte_start as i64,
8028 hint.byte_end as i64,
8029 PROVENANCE_TREESITTER,
8030 ])?;
8031 }
8032 insert_backend_state_prepared(
8033 &mut statements.backend_state,
8034 workspace_root,
8035 &extract.rel_path,
8036 Some(&extract.freshness.content_hash),
8037 "fresh",
8038 )?;
8039 Ok(())
8040}
8041
8042fn insert_backend_state_prepared(
8043 stmt: &mut Statement<'_>,
8044 workspace_root: &str,
8045 rel_path: &str,
8046 content_hash: Option<&blake3::Hash>,
8047 status: &str,
8048) -> Result<()> {
8049 let hash = content_hash
8050 .map(|hash| hash_to_hex(*hash))
8051 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
8052 stmt.execute(params![
8053 BACKEND_TREESITTER,
8054 workspace_root,
8055 rel_path,
8056 hash,
8057 status,
8058 unix_seconds_now(),
8059 ])?;
8060 Ok(())
8061}
8062
8063fn insert_resolved_ref_prepared(
8064 statements: &mut ColdBuildInsertStatements<'_>,
8065 resolved: &ResolvedRef,
8066) -> Result<()> {
8067 let raw = &resolved.raw;
8068 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
8069 statements.reference.execute(params![
8070 raw.ref_id,
8071 raw.caller_node,
8072 raw.caller_file,
8073 raw.kind,
8074 raw.short_name,
8075 raw.full_ref,
8076 raw.module_path,
8077 raw.import_kind,
8078 raw.local_name,
8079 raw.requested_name,
8080 raw.namespace_alias,
8081 bool_int(raw.wildcard),
8082 raw.line as i64,
8083 raw.byte_start as i64,
8084 raw.byte_end as i64,
8085 resolved.status,
8086 resolved.target_node,
8087 resolved.target_file,
8088 resolved.target_symbol,
8089 PROVENANCE_TREESITTER,
8090 ])?;
8091 if let Some(edge) = &resolved.edge {
8092 statements.edge.execute(params![
8093 edge.edge_id,
8094 raw.ref_id,
8095 edge.source_node,
8096 edge.target_node,
8097 edge.target_file,
8098 edge.target_symbol,
8099 edge.kind,
8100 edge.line as i64,
8101 PROVENANCE_TREESITTER,
8102 ])?;
8103 }
8104 Ok(())
8105}
8106
8107fn insert_file_extract(
8108 tx: &Transaction<'_>,
8109 project_root: &Path,
8110 extract: &FileExtract,
8111) -> Result<()> {
8112 tx.execute(
8113 "INSERT OR REPLACE INTO files(
8114 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
8115 is_public_api, surface_fingerprint, indexed_at
8116 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
8117 params![
8118 extract.rel_path,
8119 hash_to_hex(extract.freshness.content_hash),
8120 system_time_to_ns(extract.freshness.mtime),
8121 extract.freshness.size as i64,
8122 lang_label(extract.lang),
8123 extract.surface_fingerprint,
8124 unix_seconds_now(),
8125 ],
8126 )?;
8127 for node in &extract.nodes {
8128 tx.execute(
8129 "INSERT OR REPLACE INTO nodes(
8130 id, file_path, name, scoped_name, kind, start_line, start_col,
8131 end_line, end_col, range_ordinal, signature, exported,
8132 is_default_export, is_type_like, is_callgraph_entry_point, provenance
8133 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
8134 params![
8135 node.id,
8136 node.file_path,
8137 node.name,
8138 node.scoped_name,
8139 node.kind,
8140 node.range.start_line as i64,
8141 node.range.start_col as i64,
8142 node.range.end_line as i64,
8143 node.range.end_col as i64,
8144 node.range_ordinal as i64,
8145 node.signature,
8146 bool_int(node.exported),
8147 bool_int(node.is_default_export),
8148 bool_int(node.is_type_like),
8149 bool_int(node.is_callgraph_entry_point),
8150 PROVENANCE_TREESITTER,
8151 ],
8152 )?;
8153 }
8154 let mut dependencies = BTreeSet::new();
8155 for raw_ref in &extract.raw_refs {
8156 dependencies.extend(raw_ref.dependencies.iter().cloned());
8157 }
8158 insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
8159
8160 for hint in &extract.dispatch_hints {
8161 tx.execute(
8162 "INSERT OR REPLACE INTO dispatch_hints(
8163 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
8164 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
8165 params![
8166 hint.id,
8167 hint.method_name,
8168 hint.caller_node,
8169 hint.file,
8170 hint.line as i64,
8171 hint.byte_start as i64,
8172 hint.byte_end as i64,
8173 PROVENANCE_TREESITTER,
8174 ],
8175 )?;
8176 }
8177 mark_backend_state(
8178 tx,
8179 project_root,
8180 &extract.rel_path,
8181 Some(&extract.freshness.content_hash),
8182 "fresh",
8183 )?;
8184 Ok(())
8185}
8186
8187fn insert_file_dependencies(
8188 tx: &Transaction<'_>,
8189 file_path: &str,
8190 dependencies: &BTreeSet<String>,
8191) -> Result<()> {
8192 for dep_file in dependencies {
8193 tx.execute(
8194 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
8195 params![file_path, dep_file],
8196 )?;
8197 }
8198 Ok(())
8199}
8200
8201fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
8202 let raw = &resolved.raw;
8203 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
8204 tx.execute(
8205 "INSERT OR REPLACE INTO refs(
8206 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
8207 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
8208 byte_start, byte_end, status, target_node, target_file, target_symbol,
8209 provenance
8210 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
8211 params![
8212 raw.ref_id,
8213 raw.caller_node,
8214 raw.caller_file,
8215 raw.kind,
8216 raw.short_name,
8217 raw.full_ref,
8218 raw.module_path,
8219 raw.import_kind,
8220 raw.local_name,
8221 raw.requested_name,
8222 raw.namespace_alias,
8223 bool_int(raw.wildcard),
8224 raw.line as i64,
8225 raw.byte_start as i64,
8226 raw.byte_end as i64,
8227 resolved.status,
8228 resolved.target_node,
8229 resolved.target_file,
8230 resolved.target_symbol,
8231 PROVENANCE_TREESITTER,
8232 ],
8233 )?;
8234 if let Some(edge) = &resolved.edge {
8235 tx.execute(
8236 "INSERT OR REPLACE INTO edges(
8237 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8238 kind, line, provenance
8239 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
8240 params![
8241 edge.edge_id,
8242 raw.ref_id,
8243 edge.source_node,
8244 edge.target_node,
8245 edge.target_file,
8246 edge.target_symbol,
8247 edge.kind,
8248 edge.line as i64,
8249 PROVENANCE_TREESITTER,
8250 ],
8251 )?;
8252 }
8253 Ok(())
8254}
8255
8256fn insert_method_dispatch_edges(
8257 tx: &Transaction<'_>,
8258 project_root: &Path,
8259 caller_files: Option<&BTreeSet<String>>,
8260) -> Result<usize> {
8261 let references = load_name_match_refs(tx, caller_files)?;
8262 if references.is_empty() {
8263 return Ok(0);
8264 }
8265
8266 let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
8267 let mut source_cache: DispatchSourceCache = HashMap::new();
8268 let mut inserted = 0usize;
8269 for reference in references {
8270 let key = (reference.method_name.clone(), reference.lang.clone());
8271 let candidates = match candidates_by_name.entry(key) {
8272 Entry::Occupied(entry) => entry.into_mut(),
8273 Entry::Vacant(entry) => {
8274 let candidates =
8275 load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
8276 entry.insert(candidates)
8277 }
8278 };
8279
8280 if let Some(receiver_type) =
8281 infer_receiver_type(project_root, &reference, &mut source_cache)
8282 {
8283 let Some(candidate) =
8284 select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
8285 else {
8286 continue;
8287 };
8288 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
8289 inserted += 1;
8290 continue;
8291 }
8292
8293 if method_name_match_denylisted(&reference.method_name) {
8294 continue;
8295 }
8296
8297 let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
8298 continue;
8299 };
8300 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
8301 inserted += 1;
8302 }
8303 Ok(inserted)
8304}
8305
8306fn insert_method_dispatch_edges_chunked(
8307 tx: &Transaction<'_>,
8308 project_root: &Path,
8309 caller_files: &BTreeSet<String>,
8310 chunk_size: usize,
8311) -> Result<usize> {
8312 if caller_files.is_empty() {
8313 return Ok(0);
8314 }
8315 if chunk_size == 0 || caller_files.len() <= chunk_size {
8316 return insert_method_dispatch_edges(tx, project_root, Some(caller_files));
8317 }
8318
8319 let mut inserted = 0usize;
8320 let mut batch = BTreeSet::new();
8321 for caller_file in caller_files {
8322 batch.insert(caller_file.clone());
8323 if batch.len() == chunk_size {
8324 inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
8325 batch.clear();
8326 }
8327 }
8328 if !batch.is_empty() {
8329 inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
8330 }
8331 Ok(inserted)
8332}
8333
8334fn insert_method_dispatch_edge(
8335 tx: &Transaction<'_>,
8336 reference: &NameMatchRef,
8337 candidate: &NameMatchCandidate,
8338 provenance: &str,
8339) -> Result<()> {
8340 tx.execute(
8341 "INSERT OR REPLACE INTO edges(
8342 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8343 kind, line, provenance
8344 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
8345 params![
8346 ref_id(&[&reference.ref_id, provenance, "edge"]),
8347 &reference.ref_id,
8348 &reference.caller_node,
8349 &candidate.node_id,
8350 &candidate.file_path,
8351 &candidate.scoped_name,
8352 reference.line as i64,
8353 provenance,
8354 ],
8355 )?;
8356 Ok(())
8357}
8358
8359fn delete_method_dispatch_edges_for_callers(
8360 tx: &Transaction<'_>,
8361 caller_files: &BTreeSet<String>,
8362) -> Result<()> {
8363 if caller_files.is_empty() {
8364 return Ok(());
8365 }
8366
8367 let mut stmt = tx.prepare(
8368 "DELETE FROM edges
8369 WHERE provenance IN (?1, ?2)
8370 AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
8371 )?;
8372 for caller_file in caller_files {
8373 stmt.execute(params![
8374 PROVENANCE_NAME_MATCH,
8375 PROVENANCE_TYPE_MATCH,
8376 caller_file
8377 ])?;
8378 }
8379 Ok(())
8380}
8381
8382fn load_name_match_refs(
8383 tx: &Transaction<'_>,
8384 caller_files: Option<&BTreeSet<String>>,
8385) -> Result<Vec<NameMatchRef>> {
8386 let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
8387 n.signature, r.short_name, r.full_ref, r.line, f.lang
8388 FROM refs r
8389 JOIN files f ON f.path = r.caller_file
8390 JOIN nodes n ON n.id = r.caller_node
8391 WHERE r.kind = 'call'
8392 AND r.status = 'unresolved'
8393 AND r.caller_node IS NOT NULL
8394 AND r.full_ref IS NOT NULL
8395 AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
8396 AND NOT EXISTS (
8397 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
8398 )";
8399 let mut references = Vec::new();
8400
8401 if let Some(caller_files) = caller_files {
8402 if caller_files.is_empty() {
8403 return Ok(references);
8404 }
8405 let sql = format!(
8406 "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
8407 );
8408 let mut stmt = tx.prepare(&sql)?;
8409 for caller_file in caller_files {
8410 let rows = stmt.query_map(params![caller_file], |row| {
8411 Ok((
8412 row.get::<_, String>(0)?,
8413 row.get::<_, Option<String>>(1)?,
8414 row.get::<_, String>(2)?,
8415 row.get::<_, String>(3)?,
8416 row.get::<_, Option<String>>(4)?,
8417 row.get::<_, Option<String>>(5)?,
8418 row.get::<_, Option<String>>(6)?,
8419 row.get::<_, i64>(7)?,
8420 row.get::<_, String>(8)?,
8421 ))
8422 })?;
8423 for row in rows {
8424 let (
8425 ref_id,
8426 caller_node,
8427 caller_file,
8428 caller_symbol,
8429 caller_signature,
8430 short_name,
8431 full_ref,
8432 line,
8433 lang,
8434 ) = row?;
8435 if let Some(reference) = name_match_ref_from_parts(
8436 ref_id,
8437 caller_node,
8438 caller_file,
8439 caller_symbol,
8440 caller_signature,
8441 short_name,
8442 full_ref,
8443 line,
8444 lang,
8445 ) {
8446 references.push(reference);
8447 }
8448 }
8449 }
8450 return Ok(references);
8451 }
8452
8453 let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
8454 let mut stmt = tx.prepare(&sql)?;
8455 let rows = stmt.query_map([], |row| {
8456 Ok((
8457 row.get::<_, String>(0)?,
8458 row.get::<_, Option<String>>(1)?,
8459 row.get::<_, String>(2)?,
8460 row.get::<_, String>(3)?,
8461 row.get::<_, Option<String>>(4)?,
8462 row.get::<_, Option<String>>(5)?,
8463 row.get::<_, Option<String>>(6)?,
8464 row.get::<_, i64>(7)?,
8465 row.get::<_, String>(8)?,
8466 ))
8467 })?;
8468 for row in rows {
8469 let (
8470 ref_id,
8471 caller_node,
8472 caller_file,
8473 caller_symbol,
8474 caller_signature,
8475 short_name,
8476 full_ref,
8477 line,
8478 lang,
8479 ) = row?;
8480 if let Some(reference) = name_match_ref_from_parts(
8481 ref_id,
8482 caller_node,
8483 caller_file,
8484 caller_symbol,
8485 caller_signature,
8486 short_name,
8487 full_ref,
8488 line,
8489 lang,
8490 ) {
8491 references.push(reference);
8492 }
8493 }
8494 Ok(references)
8495}
8496
8497#[allow(clippy::too_many_arguments)]
8498fn name_match_ref_from_parts(
8499 ref_id: String,
8500 caller_node: Option<String>,
8501 caller_file: String,
8502 caller_symbol: String,
8503 caller_signature: Option<String>,
8504 short_name: Option<String>,
8505 full_ref: Option<String>,
8506 line: i64,
8507 lang: String,
8508) -> Option<NameMatchRef> {
8509 let caller_node = caller_node?;
8510 let full_ref = full_ref?;
8511 let (receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
8512 let method_name = if member.is_empty() {
8513 short_name.as_deref()?.to_string()
8514 } else {
8515 member
8516 };
8517 Some(NameMatchRef {
8518 ref_id,
8519 caller_node,
8520 caller_file,
8521 caller_symbol,
8522 caller_signature,
8523 receiver,
8524 method_name,
8525 colon_dispatch,
8526 line: line.max(0) as u32,
8527 lang,
8528 })
8529}
8530
8531fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, bool)> {
8532 let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
8533 let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
8534 let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
8535 let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
8536 .into_iter()
8537 .flatten()
8538 .max_by_key(|(index, _, _)| *index)?;
8539 if delimiter == 0 {
8540 return None;
8541 }
8542 let member_start = delimiter + delimiter_len;
8543 if member_start >= full_ref.len() {
8544 return None;
8545 }
8546 let receiver = last_name_segment(&full_ref[..delimiter]);
8547 let member = &full_ref[member_start..];
8548 if receiver.is_empty() || member.is_empty() {
8549 return None;
8550 }
8551 Some((receiver.to_string(), member.to_string(), colon_dispatch))
8552}
8553
8554fn last_name_segment(value: &str) -> &str {
8555 value
8556 .rsplit(['.', ':', '/', '\\', '-', '>'])
8557 .find(|segment| !segment.is_empty())
8558 .unwrap_or(value)
8559}
8560
8561fn load_name_match_candidates(
8562 tx: &Transaction<'_>,
8563 method_name: &str,
8564 lang: &str,
8565) -> Result<Vec<NameMatchCandidate>> {
8566 let mut stmt = tx.prepare(
8567 "SELECT n.id, n.file_path, n.scoped_name, n.kind
8568 FROM nodes n JOIN files f ON f.path = n.file_path
8569 WHERE n.name = ?1
8570 AND f.lang = ?2
8571 AND n.kind IN ('method', 'function')
8572 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
8573 )?;
8574 let rows = stmt.query_map(params![method_name, lang], |row| {
8575 Ok(NameMatchCandidate {
8576 node_id: row.get(0)?,
8577 file_path: row.get(1)?,
8578 scoped_name: row.get(2)?,
8579 kind: row.get(3)?,
8580 })
8581 })?;
8582 rows.collect::<std::result::Result<Vec<_>, _>>()
8583 .map_err(Into::into)
8584}
8585
8586struct ParsedDispatchSource {
8587 source: String,
8588 tree: tree_sitter::Tree,
8589}
8590
8591type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
8592
8593fn infer_receiver_type(
8594 project_root: &Path,
8595 reference: &NameMatchRef,
8596 source_cache: &mut DispatchSourceCache,
8597) -> Option<String> {
8598 match reference.lang.as_str() {
8599 "rust" => infer_rust_receiver_type(reference),
8600 "java" => {
8601 infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
8602 }
8603 "kotlin" => {
8604 infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
8605 }
8606 "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache),
8607 _ => None,
8608 }
8609}
8610
8611fn parse_dispatch_source(
8612 project_root: &Path,
8613 caller_file: &str,
8614 lang: LangId,
8615) -> Option<ParsedDispatchSource> {
8616 let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
8617 let grammar = crate::parser::grammar_for(lang);
8618 let mut parser = tree_sitter::Parser::new();
8619 parser.set_language(&grammar).ok()?;
8620 let tree = parser.parse(&source, None)?;
8621 Some(ParsedDispatchSource { source, tree })
8622}
8623
8624fn parsed_dispatch_source<'a>(
8625 project_root: &Path,
8626 reference: &NameMatchRef,
8627 lang: LangId,
8628 source_cache: &'a mut DispatchSourceCache,
8629) -> Option<&'a ParsedDispatchSource> {
8630 let key = (reference.caller_file.clone(), reference.lang.clone());
8631 source_cache
8632 .entry(key)
8633 .or_insert_with(|| parse_dispatch_source(project_root, &reference.caller_file, lang))
8634 .as_ref()
8635}
8636
8637fn infer_java_like_receiver_type(
8638 project_root: &Path,
8639 reference: &NameMatchRef,
8640 lang: LangId,
8641 source_cache: &mut DispatchSourceCache,
8642) -> Option<String> {
8643 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
8644 return None;
8645 }
8646
8647 let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
8648 let root = parsed.tree.root_node();
8649 let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
8650
8651 let callable_scope = type_node
8652 .and_then(|node| {
8653 find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
8654 })
8655 .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
8656
8657 if let Some(callable_scope) = callable_scope {
8658 if let Some(receiver_type) = infer_java_like_local_receiver_type(
8659 callable_scope,
8660 &parsed.source,
8661 &reference.receiver,
8662 reference.line.max(1),
8663 lang,
8664 ) {
8665 return Some(receiver_type);
8666 }
8667 }
8668
8669 type_node.and_then(|node| {
8670 infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
8671 })
8672}
8673
8674fn infer_cpp_receiver_type(
8675 project_root: &Path,
8676 reference: &NameMatchRef,
8677 source_cache: &mut DispatchSourceCache,
8678) -> Option<String> {
8679 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
8680 return None;
8681 }
8682
8683 let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
8684 let root = parsed.tree.root_node();
8685 let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
8686 infer_cpp_receiver_type_from_scope(
8687 scope,
8688 &parsed.source,
8689 &reference.receiver,
8690 reference.line.max(1),
8691 )
8692}
8693
8694fn find_enclosing_java_like_type_node<'tree>(
8695 root: tree_sitter::Node<'tree>,
8696 source: &str,
8697 reference: &NameMatchRef,
8698 lang: LangId,
8699) -> Option<tree_sitter::Node<'tree>> {
8700 let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
8701 .and_then(|name| simple_type_name(&name));
8702 let line = reference.line.max(1);
8703 let mut best = None;
8704 let mut stack = vec![root];
8705 while let Some(node) = stack.pop() {
8706 if !node_contains_line(node, line) {
8707 continue;
8708 }
8709 if is_java_like_type_kind(node.kind(), lang) {
8710 let name = declaration_name(node, source);
8711 if expected_type
8712 .as_deref()
8713 .is_none_or(|expected| name == Some(expected))
8714 {
8715 best = tighter_node(best, node);
8716 }
8717 }
8718 push_named_children(node, &mut stack);
8719 }
8720 best
8721}
8722
8723fn find_enclosing_java_like_callable_node<'tree>(
8724 root: tree_sitter::Node<'tree>,
8725 source: &str,
8726 reference: &NameMatchRef,
8727 lang: LangId,
8728) -> Option<tree_sitter::Node<'tree>> {
8729 let expected_name = reference.caller_symbol.rsplit("::").next();
8730 let line = reference.line.max(1);
8731 let mut best = None;
8732 let mut stack = vec![root];
8733 while let Some(node) = stack.pop() {
8734 if !node_contains_line(node, line) {
8735 continue;
8736 }
8737 if is_java_like_callable_kind(node.kind(), lang) {
8738 let name = declaration_name(node, source);
8739 if expected_name.is_none_or(|expected| name == Some(expected)) {
8740 best = tighter_node(best, node);
8741 }
8742 }
8743 push_named_children(node, &mut stack);
8744 }
8745 best
8746}
8747
8748fn find_enclosing_cpp_callable_node<'tree>(
8749 root: tree_sitter::Node<'tree>,
8750 _source: &str,
8751 reference: &NameMatchRef,
8752) -> Option<tree_sitter::Node<'tree>> {
8753 let line = reference.line.max(1);
8754 let mut best = None;
8755 let mut stack = vec![root];
8756 while let Some(node) = stack.pop() {
8757 if !node_contains_line(node, line) {
8758 continue;
8759 }
8760 if node.kind() == "function_definition" {
8761 best = tighter_node(best, node);
8762 }
8763 push_named_children(node, &mut stack);
8764 }
8765 best
8766}
8767
8768fn tighter_node<'tree>(
8769 current: Option<tree_sitter::Node<'tree>>,
8770 candidate: tree_sitter::Node<'tree>,
8771) -> Option<tree_sitter::Node<'tree>> {
8772 match current {
8773 Some(current)
8774 if current.start_byte() > candidate.start_byte()
8775 || (current.start_byte() == candidate.start_byte()
8776 && current.end_byte() <= candidate.end_byte()) =>
8777 {
8778 Some(current)
8779 }
8780 _ => Some(candidate),
8781 }
8782}
8783
8784fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
8785 let start = node.start_position().row as u32 + 1;
8786 let end = node.end_position().row as u32 + 1;
8787 start <= line && line <= end
8788}
8789
8790fn push_named_children<'tree>(
8791 node: tree_sitter::Node<'tree>,
8792 stack: &mut Vec<tree_sitter::Node<'tree>>,
8793) {
8794 for index in 0..node.named_child_count() {
8795 if let Some(child) = node.named_child(index as u32) {
8796 stack.push(child);
8797 }
8798 }
8799}
8800
8801fn declaration_name<'source>(
8802 node: tree_sitter::Node<'_>,
8803 source: &'source str,
8804) -> Option<&'source str> {
8805 node.child_by_field_name("name")
8806 .map(|name| node_text(name, source))
8807 .or_else(|| {
8808 first_named_child_text(
8809 node,
8810 source,
8811 &["identifier", "type_identifier", "simple_identifier"],
8812 )
8813 })
8814}
8815
8816fn first_named_child_text<'source>(
8817 node: tree_sitter::Node<'_>,
8818 source: &'source str,
8819 kinds: &[&str],
8820) -> Option<&'source str> {
8821 for index in 0..node.named_child_count() {
8822 let child = node.named_child(index as u32)?;
8823 if kinds.contains(&child.kind()) {
8824 return Some(node_text(child, source));
8825 }
8826 }
8827 None
8828}
8829
8830fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
8831 &source[node.byte_range()]
8832}
8833
8834fn infer_java_like_field_receiver_type(
8835 type_node: tree_sitter::Node<'_>,
8836 source: &str,
8837 receiver: &str,
8838 lang: LangId,
8839) -> Option<String> {
8840 let mut stack = Vec::new();
8841 push_named_children(type_node, &mut stack);
8842 while let Some(node) = stack.pop() {
8843 if is_java_like_field_kind(node.kind(), lang) {
8844 if let Some(receiver_type) =
8845 extract_java_like_declared_type(node_text(node, source), receiver, lang)
8846 {
8847 return Some(receiver_type);
8848 }
8849 }
8850 if is_java_like_type_kind(node.kind(), lang)
8851 || is_java_like_callable_kind(node.kind(), lang)
8852 {
8853 continue;
8854 }
8855 push_named_children(node, &mut stack);
8856 }
8857 None
8858}
8859
8860fn infer_java_like_local_receiver_type(
8861 callable_node: tree_sitter::Node<'_>,
8862 source: &str,
8863 receiver: &str,
8864 call_line: u32,
8865 lang: LangId,
8866) -> Option<String> {
8867 let mut best: Option<(u32, String)> = None;
8868 let mut stack = Vec::new();
8869 push_named_children(callable_node, &mut stack);
8870 while let Some(node) = stack.pop() {
8871 let start_line = node.start_position().row as u32 + 1;
8872 if start_line > call_line {
8873 continue;
8874 }
8875 if is_java_like_local_kind(node.kind(), lang) {
8876 if let Some(receiver_type) =
8877 extract_java_like_declared_type(node_text(node, source), receiver, lang)
8878 {
8879 if best
8880 .as_ref()
8881 .is_none_or(|(best_line, _)| start_line >= *best_line)
8882 {
8883 best = Some((start_line, receiver_type));
8884 }
8885 }
8886 }
8887 if is_java_like_type_kind(node.kind(), lang)
8888 || is_java_like_callable_kind(node.kind(), lang)
8889 {
8890 continue;
8891 }
8892 push_named_children(node, &mut stack);
8893 }
8894 best.map(|(_, receiver_type)| receiver_type)
8895}
8896
8897fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
8898 match lang {
8899 LangId::Java => matches!(
8900 kind,
8901 "class_declaration"
8902 | "interface_declaration"
8903 | "enum_declaration"
8904 | "record_declaration"
8905 | "annotation_type_declaration"
8906 ),
8907 LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
8908 _ => false,
8909 }
8910}
8911
8912fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
8913 match lang {
8914 LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
8915 LangId::Kotlin => kind == "function_declaration",
8916 _ => false,
8917 }
8918}
8919
8920fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
8921 match lang {
8922 LangId::Java => kind == "field_declaration",
8923 LangId::Kotlin => kind == "property_declaration",
8924 _ => false,
8925 }
8926}
8927
8928fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
8929 match lang {
8930 LangId::Java => kind == "local_variable_declaration",
8931 LangId::Kotlin => kind == "property_declaration",
8932 _ => false,
8933 }
8934}
8935
8936fn extract_java_like_declared_type(
8937 declaration: &str,
8938 receiver: &str,
8939 lang: LangId,
8940) -> Option<String> {
8941 match lang {
8942 LangId::Java => extract_java_declared_type(declaration, receiver),
8943 LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
8944 _ => None,
8945 }
8946}
8947
8948fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
8949 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
8950 let after = declaration[receiver_start + receiver.len()..].trim_start();
8951 if after
8952 .chars()
8953 .next()
8954 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
8955 {
8956 return None;
8957 }
8958
8959 let before = declaration[..receiver_start].trim_end();
8960 if before.contains(',') {
8961 return None;
8962 }
8963 normalize_receiver_type_name(strip_java_declaration_prefixes(before))
8964}
8965
8966fn strip_java_declaration_prefixes(mut value: &str) -> &str {
8967 loop {
8968 value = value.trim_start();
8969 if let Some(stripped) = strip_leading_java_annotation(value) {
8970 value = stripped;
8971 continue;
8972 }
8973 if let Some(stripped) = strip_leading_java_modifier(value) {
8974 value = stripped;
8975 continue;
8976 }
8977 return value.trim();
8978 }
8979}
8980
8981fn strip_leading_java_annotation(value: &str) -> Option<&str> {
8982 let value = value.trim_start();
8983 let mut chars = value.char_indices();
8984 let (_, first) = chars.next()?;
8985 if first != '@' {
8986 return None;
8987 }
8988 let mut end = first.len_utf8();
8989 for (index, ch) in chars {
8990 if !(is_code_ident_char(ch) || ch == '.') {
8991 end = index;
8992 break;
8993 }
8994 end = index + ch.len_utf8();
8995 }
8996 let rest = value[end..].trim_start();
8997 if let Some(stripped) = rest.strip_prefix('(') {
8998 let mut depth = 1usize;
8999 for (index, ch) in stripped.char_indices() {
9000 match ch {
9001 '(' => depth += 1,
9002 ')' => {
9003 depth = depth.saturating_sub(1);
9004 if depth == 0 {
9005 return Some(stripped[index + ch.len_utf8()..].trim_start());
9006 }
9007 }
9008 _ => {}
9009 }
9010 }
9011 return Some("");
9012 }
9013 Some(rest)
9014}
9015
9016fn strip_leading_java_modifier(value: &str) -> Option<&str> {
9017 const MODIFIERS: &[&str] = &[
9018 "public",
9019 "protected",
9020 "private",
9021 "abstract",
9022 "static",
9023 "final",
9024 "transient",
9025 "volatile",
9026 "synchronized",
9027 "native",
9028 "strictfp",
9029 ];
9030 MODIFIERS
9031 .iter()
9032 .find_map(|modifier| strip_leading_word(value, modifier))
9033}
9034
9035fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
9036 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
9037 let before = &declaration[..receiver_start];
9038 if find_identifier_occurrence(before, "val").is_none()
9039 && find_identifier_occurrence(before, "var").is_none()
9040 {
9041 return None;
9042 }
9043
9044 let after = declaration[receiver_start + receiver.len()..].trim_start();
9045 if let Some(type_text) = after.strip_prefix(':') {
9046 return normalize_receiver_type_name(read_type_prefix(type_text));
9047 }
9048 after
9049 .strip_prefix('=')
9050 .and_then(infer_kotlin_constructor_type)
9051}
9052
9053fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
9054 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
9055 if rest.trim_start().starts_with('(') {
9056 normalize_receiver_type_name(head)
9057 } else {
9058 None
9059 }
9060}
9061
9062fn read_type_prefix(value: &str) -> &str {
9063 let mut angle_depth = 0usize;
9064 for (index, ch) in value.char_indices() {
9065 match ch {
9066 '<' => angle_depth += 1,
9067 '>' => angle_depth = angle_depth.saturating_sub(1),
9068 '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
9069 return value[..index].trim();
9070 }
9071 _ => {}
9072 }
9073 }
9074 value.trim()
9075}
9076
9077fn infer_cpp_receiver_type_from_scope(
9078 scope: tree_sitter::Node<'_>,
9079 source: &str,
9080 receiver: &str,
9081 call_line: u32,
9082) -> Option<String> {
9083 let lines = source.lines().collect::<Vec<_>>();
9084 if lines.is_empty() {
9085 return None;
9086 }
9087 let scope_start = scope.start_position().row as usize;
9088 let call_index = (call_line as usize)
9089 .saturating_sub(1)
9090 .min(lines.len().saturating_sub(1));
9091 for index in (scope_start..=call_index).rev() {
9092 if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
9093 return Some(receiver_type);
9094 }
9095 }
9096 None
9097}
9098
9099fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
9100 for receiver_start in identifier_occurrences(line, receiver) {
9101 let after = line[receiver_start + receiver.len()..].trim_start();
9102 if after
9103 .chars()
9104 .next()
9105 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
9106 {
9107 continue;
9108 }
9109 let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
9110 let normalized = normalize_cpp_type_name(type_text)?;
9111 if normalized == "auto" {
9112 if let Some(rhs) = after.strip_prefix('=') {
9113 return infer_cpp_auto_receiver_type(rhs);
9114 }
9115 continue;
9116 }
9117 return Some(normalized);
9118 }
9119 None
9120}
9121
9122fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
9123 let candidate = prefix
9124 .rsplit([';', '{', '}', '('])
9125 .next()
9126 .unwrap_or(prefix)
9127 .trim();
9128 if candidate.is_empty() || candidate.ends_with(',') {
9129 None
9130 } else {
9131 Some(candidate)
9132 }
9133}
9134
9135fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
9136 let without_templates = strip_angle_groups(type_text);
9137 let mut cleaned = String::with_capacity(without_templates.len());
9138 for token in without_templates.split_whitespace() {
9139 if matches!(
9140 token,
9141 "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
9142 ) {
9143 continue;
9144 }
9145 if !cleaned.is_empty() {
9146 cleaned.push(' ');
9147 }
9148 cleaned.push_str(token);
9149 }
9150 let token = cleaned
9151 .split_whitespace()
9152 .last()
9153 .unwrap_or(cleaned.trim())
9154 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
9155 .trim_matches(['*', '&']);
9156 let simple = token.rsplit("::").next().unwrap_or(token).trim();
9157 if simple.is_empty() || cpp_non_type_token(simple) {
9158 None
9159 } else {
9160 Some(simple.to_string())
9161 }
9162}
9163
9164fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
9165 let rhs = rhs.trim_start();
9166 if let Some(after_new) = rhs.strip_prefix("new ") {
9167 return infer_cpp_constructor_type(after_new);
9168 }
9169 infer_cpp_make_template_type(rhs)
9170 .or_else(|| infer_cpp_constructor_type(rhs))
9171 .or_else(|| infer_cpp_factory_type(rhs))
9172}
9173
9174fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
9175 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9176 let normalized = normalize_cpp_type_name(head)?;
9177 if !normalized
9178 .chars()
9179 .next()
9180 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
9181 {
9182 return None;
9183 }
9184 if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
9185 Some(normalized)
9186 } else {
9187 None
9188 }
9189}
9190
9191fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
9192 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9193 if !rest.trim_start().starts_with('(') {
9194 return None;
9195 }
9196 let base = head.split('<').next().unwrap_or(head);
9197 let base_simple = base.rsplit("::").next().unwrap_or(base);
9198 if !matches!(base_simple, "make_unique" | "make_shared") {
9199 return None;
9200 }
9201 first_angle_arg(head).and_then(normalize_cpp_type_name)
9202}
9203
9204fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
9205 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9206 if !rest.trim_start().starts_with('(') {
9207 return None;
9208 }
9209 let simple = head
9210 .split('<')
9211 .next()
9212 .unwrap_or(head)
9213 .rsplit("::")
9214 .next()
9215 .unwrap_or(head);
9216 for prefix in ["make", "create", "build"] {
9217 if let Some(suffix) = simple.strip_prefix(prefix) {
9218 if suffix
9219 .chars()
9220 .next()
9221 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
9222 {
9223 return normalize_cpp_type_name(suffix);
9224 }
9225 }
9226 }
9227 None
9228}
9229
9230#[derive(Debug, Clone, Copy)]
9231enum JavaLikeInvocation {
9232 Kotlin,
9233 Cpp,
9234}
9235
9236fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
9237 let value = value.trim_start();
9238 let mut end = 0usize;
9239 for (index, ch) in value.char_indices() {
9240 let allowed_separator = match flavor {
9241 JavaLikeInvocation::Kotlin => ch == '.',
9242 JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
9243 };
9244 if is_code_ident_char(ch) || allowed_separator {
9245 end = index + ch.len_utf8();
9246 continue;
9247 }
9248 break;
9249 }
9250 if end == 0 {
9251 return None;
9252 }
9253 let mut rest = &value[end..];
9254 if let Some(stripped) = rest.trim_start().strip_prefix('<') {
9255 let skipped = skip_balanced_angle(stripped)?;
9256 let rest_start = rest.len() - rest.trim_start().len();
9257 let angle_len = 1 + skipped;
9258 end += rest_start + angle_len;
9259 rest = &value[end..];
9260 }
9261 Some((value[..end].trim(), rest))
9262}
9263
9264fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
9265 let mut depth = 1usize;
9266 for (index, ch) in value_after_open.char_indices() {
9267 match ch {
9268 '<' => depth += 1,
9269 '>' => {
9270 depth = depth.saturating_sub(1);
9271 if depth == 0 {
9272 return Some(index + ch.len_utf8());
9273 }
9274 }
9275 _ => {}
9276 }
9277 }
9278 None
9279}
9280
9281fn first_angle_arg(value: &str) -> Option<&str> {
9282 let open = value.find('<')?;
9283 let inner_len = skip_balanced_angle(&value[open + 1..])?;
9284 let inner = &value[open + 1..open + inner_len];
9285 split_top_level_commas(inner).into_iter().next()
9286}
9287
9288fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
9289 let without_generics = strip_angle_groups(type_text);
9290 let cleaned = without_generics
9291 .replace("[]", " ")
9292 .replace("...", " ")
9293 .replace(['?', '&', '*'], " ");
9294 let token = cleaned
9295 .split_whitespace()
9296 .last()
9297 .unwrap_or(cleaned.trim())
9298 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
9299 let token = token.rsplit("::").next().unwrap_or(token);
9300 let simple = token.rsplit('.').next().unwrap_or(token).trim();
9301 if simple.is_empty()
9302 || java_like_primitive_type(simple)
9303 || !simple
9304 .chars()
9305 .next()
9306 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
9307 {
9308 None
9309 } else {
9310 Some(simple.to_string())
9311 }
9312}
9313
9314fn simple_type_name(scoped_name: &str) -> Option<String> {
9315 scoped_name
9316 .rsplit("::")
9317 .find(|segment| !segment.is_empty())
9318 .and_then(normalize_receiver_type_name)
9319}
9320
9321fn strip_angle_groups(value: &str) -> String {
9322 let mut output = String::with_capacity(value.len());
9323 let mut depth = 0usize;
9324 for ch in value.chars() {
9325 match ch {
9326 '<' => {
9327 if depth == 0 {
9328 output.push(' ');
9329 }
9330 depth += 1;
9331 }
9332 '>' => depth = depth.saturating_sub(1),
9333 _ if depth == 0 => output.push(ch),
9334 _ => {}
9335 }
9336 }
9337 output
9338}
9339
9340fn java_like_primitive_type(value: &str) -> bool {
9341 matches!(
9342 value,
9343 "boolean"
9344 | "byte"
9345 | "char"
9346 | "double"
9347 | "float"
9348 | "int"
9349 | "long"
9350 | "short"
9351 | "void"
9352 | "Boolean"
9353 | "Byte"
9354 | "Char"
9355 | "Double"
9356 | "Float"
9357 | "Int"
9358 | "Long"
9359 | "Short"
9360 | "Unit"
9361 )
9362}
9363
9364fn cpp_non_type_token(value: &str) -> bool {
9365 matches!(
9366 value,
9367 "return"
9368 | "if"
9369 | "else"
9370 | "for"
9371 | "while"
9372 | "do"
9373 | "switch"
9374 | "case"
9375 | "default"
9376 | "break"
9377 | "continue"
9378 | "goto"
9379 | "throw"
9380 | "new"
9381 | "delete"
9382 | "co_await"
9383 | "co_yield"
9384 | "co_return"
9385 | "static_cast"
9386 | "const_cast"
9387 | "dynamic_cast"
9388 | "reinterpret_cast"
9389 | "sizeof"
9390 | "alignof"
9391 | "typeid"
9392 | "and"
9393 | "or"
9394 | "not"
9395 | "xor"
9396 )
9397}
9398
9399fn receiver_is_bare_identifier(value: &str) -> bool {
9400 let mut chars = value.chars();
9401 let Some(first) = chars.next() else {
9402 return false;
9403 };
9404 (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
9405}
9406
9407fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
9408 identifier_occurrences(value, needle).into_iter().next()
9409}
9410
9411fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
9412 value
9413 .match_indices(needle)
9414 .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
9415 .collect()
9416}
9417
9418fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
9419 let before = value[..start].chars().next_back();
9420 let after = value[start + len..].chars().next();
9421 !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
9422}
9423
9424fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
9425 let stripped = value.strip_prefix(word)?;
9426 if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
9427 Some(stripped.trim_start())
9428 } else {
9429 None
9430 }
9431}
9432
9433fn is_code_ident_char(ch: char) -> bool {
9434 ch == '_' || ch.is_ascii_alphanumeric()
9435}
9436
9437fn infer_rust_receiver_type(reference: &NameMatchRef) -> Option<String> {
9438 if matches!(reference.receiver.as_str(), "self" | "Self") {
9439 return enclosing_type_from_scoped_name(&reference.caller_symbol);
9440 }
9441
9442 if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
9443 return Some(reference.receiver.clone());
9444 }
9445
9446 reference
9447 .caller_signature
9448 .as_deref()
9449 .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
9450}
9451
9452fn rust_receiver_looks_type_like(receiver: &str) -> bool {
9453 receiver
9454 .chars()
9455 .next()
9456 .is_some_and(|ch| ch == '_' || ch.is_uppercase())
9457}
9458
9459fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
9460 scoped_name
9461 .rsplit_once("::")
9462 .map(|(enclosing, _)| enclosing)
9463 .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
9464 .map(ToString::to_string)
9465}
9466
9467fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
9468 let params = signature_parameter_text(signature)?;
9469 for param in split_top_level_commas(params) {
9470 let Some((pattern, type_text)) = param.split_once(':') else {
9471 continue;
9472 };
9473 let Some(name) = rust_parameter_name(pattern) else {
9474 continue;
9475 };
9476 if name == receiver {
9477 return normalize_rust_receiver_type(type_text);
9478 }
9479 }
9480 None
9481}
9482
9483fn signature_parameter_text(signature: &str) -> Option<&str> {
9484 let open = signature.find('(')?;
9485 let mut depth = 0usize;
9486 for (offset, ch) in signature[open..].char_indices() {
9487 match ch {
9488 '(' => depth += 1,
9489 ')' => {
9490 depth = depth.saturating_sub(1);
9491 if depth == 0 {
9492 return Some(&signature[open + 1..open + offset]);
9493 }
9494 }
9495 _ => {}
9496 }
9497 }
9498 None
9499}
9500
9501fn split_top_level_commas(value: &str) -> Vec<&str> {
9502 let mut parts = Vec::new();
9503 let mut start = 0usize;
9504 let mut angle_depth = 0usize;
9505 let mut paren_depth = 0usize;
9506 let mut bracket_depth = 0usize;
9507 for (index, ch) in value.char_indices() {
9508 match ch {
9509 '<' => angle_depth += 1,
9510 '>' => angle_depth = angle_depth.saturating_sub(1),
9511 '(' => paren_depth += 1,
9512 ')' => paren_depth = paren_depth.saturating_sub(1),
9513 '[' => bracket_depth += 1,
9514 ']' => bracket_depth = bracket_depth.saturating_sub(1),
9515 ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
9516 let part = value[start..index].trim();
9517 if !part.is_empty() {
9518 parts.push(part);
9519 }
9520 start = index + ch.len_utf8();
9521 }
9522 _ => {}
9523 }
9524 }
9525 let part = value[start..].trim();
9526 if !part.is_empty() {
9527 parts.push(part);
9528 }
9529 parts
9530}
9531
9532fn rust_parameter_name(pattern: &str) -> Option<&str> {
9533 let mut pattern = pattern.trim();
9534 if let Some(stripped) = pattern.strip_prefix("mut ") {
9535 pattern = stripped.trim_start();
9536 }
9537 pattern
9538 .rsplit(|ch: char| !is_rust_ident_char(ch))
9539 .find(|part| !part.is_empty())
9540}
9541
9542fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
9543 let mut ty = strip_leading_rust_type_modifiers(type_text);
9544 let owned_inner;
9545 if let Some(inner) = single_outer_generic_arg(ty) {
9546 owned_inner = inner.trim().to_string();
9547 ty = strip_leading_rust_type_modifiers(&owned_inner);
9548 }
9549 rust_base_type_ident(ty)
9550}
9551
9552fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
9553 loop {
9554 ty = ty.trim_start();
9555 if let Some(stripped) = ty.strip_prefix('&') {
9556 ty = stripped.trim_start();
9557 if let Some(stripped) = strip_leading_lifetime(ty) {
9558 ty = stripped.trim_start();
9559 }
9560 if let Some(stripped) = ty.strip_prefix("mut ") {
9561 ty = stripped.trim_start();
9562 }
9563 continue;
9564 }
9565 if let Some(stripped) = ty.strip_prefix("mut ") {
9566 ty = stripped.trim_start();
9567 continue;
9568 }
9569 if let Some(stripped) = ty.strip_prefix("dyn ") {
9570 ty = stripped.trim_start();
9571 continue;
9572 }
9573 if let Some(stripped) = ty.strip_prefix("impl ") {
9574 ty = stripped.trim_start();
9575 continue;
9576 }
9577 break ty.trim();
9578 }
9579}
9580
9581fn strip_leading_lifetime(value: &str) -> Option<&str> {
9582 let mut chars = value.char_indices();
9583 let (_, first) = chars.next()?;
9584 if first != '\'' {
9585 return None;
9586 }
9587 for (index, ch) in chars {
9588 if !(ch == '_' || ch.is_ascii_alphanumeric()) {
9589 return Some(&value[index..]);
9590 }
9591 }
9592 Some("")
9593}
9594
9595fn single_outer_generic_arg(ty: &str) -> Option<&str> {
9596 let ty = ty.trim();
9597 let open = ty.find('<')?;
9598 let mut depth = 0usize;
9599 let mut close = None;
9600 for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
9601 match ch {
9602 '<' => depth += 1,
9603 '>' => {
9604 depth = depth.saturating_sub(1);
9605 if depth == 0 {
9606 close = Some(index);
9607 break;
9608 }
9609 }
9610 _ => {}
9611 }
9612 }
9613 let close = close?;
9614 if !ty[close + 1..].trim().is_empty() {
9615 return None;
9616 }
9617 let inner = &ty[open + 1..close];
9618 let args = split_top_level_commas(inner);
9619 match args.as_slice() {
9620 [arg] => Some(*arg),
9621 _ => None,
9622 }
9623}
9624
9625fn rust_base_type_ident(ty: &str) -> Option<String> {
9626 let ty = ty.trim();
9627 let head = ty
9628 .split([' ', '+', '='])
9629 .find(|part| !part.is_empty())
9630 .unwrap_or(ty);
9631 let head = head.split('<').next().unwrap_or(head).trim();
9632 let ident = head
9633 .rsplit("::")
9634 .next()
9635 .unwrap_or(head)
9636 .trim_matches(|ch: char| !is_rust_ident_char(ch));
9637 if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
9638 None
9639 } else {
9640 Some(ident.to_string())
9641 }
9642}
9643
9644fn is_rust_ident_char(ch: char) -> bool {
9645 ch == '_' || ch.is_ascii_alphanumeric()
9646}
9647
9648fn select_type_match_candidate(
9649 reference: &NameMatchRef,
9650 candidates: &[NameMatchCandidate],
9651 receiver_type: &str,
9652) -> Option<NameMatchCandidate> {
9653 let candidates = candidates
9654 .iter()
9655 .filter(|candidate| candidate.node_id != reference.caller_node)
9656 .filter(|candidate| {
9657 type_candidate_matches(candidate, receiver_type, &reference.method_name)
9658 })
9659 .collect::<Vec<_>>();
9660 match candidates.as_slice() {
9661 [candidate] => Some((**candidate).clone()),
9662 _ => None,
9663 }
9664}
9665
9666fn type_candidate_matches(
9667 candidate: &NameMatchCandidate,
9668 receiver_type: &str,
9669 method_name: &str,
9670) -> bool {
9671 let normalized_type = receiver_type.replace('.', "::");
9672 let suffix = format!("{normalized_type}::{method_name}");
9673 candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
9674}
9675
9676fn select_name_match_candidate(
9677 reference: &NameMatchRef,
9678 candidates: &[NameMatchCandidate],
9679) -> Option<NameMatchCandidate> {
9680 let candidates = candidates
9681 .iter()
9682 .filter(|candidate| candidate.node_id != reference.caller_node)
9683 .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
9684 .collect::<Vec<_>>();
9685 match candidates.as_slice() {
9686 [] => None,
9687 [candidate] => Some((**candidate).clone()),
9688 _ => select_scored_name_match_candidate(reference, &candidates),
9689 }
9690}
9691
9692fn candidate_allowed_for_reference(
9693 reference: &NameMatchRef,
9694 candidate: &NameMatchCandidate,
9695) -> bool {
9696 if !reference.colon_dispatch {
9697 return true;
9698 }
9699
9700 candidate.kind == "method"
9701 && candidate
9702 .scoped_name
9703 .split("::")
9704 .any(|segment| segment == reference.receiver)
9705}
9706
9707fn select_scored_name_match_candidate(
9708 reference: &NameMatchRef,
9709 candidates: &[&NameMatchCandidate],
9710) -> Option<NameMatchCandidate> {
9711 let receiver_words = split_camel_case(&reference.receiver);
9712 if receiver_words.is_empty() {
9713 return None;
9714 }
9715
9716 let mut best: Option<(&NameMatchCandidate, f64)> = None;
9717 let mut tied_best = false;
9718 for candidate in candidates {
9719 let candidate_words = split_camel_case(&candidate.scoped_name);
9720 let overlap = receiver_words
9721 .iter()
9722 .filter(|receiver_word| {
9723 candidate_words
9724 .iter()
9725 .any(|candidate_word| candidate_word == *receiver_word)
9726 })
9727 .count() as f64;
9728 let score =
9729 overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
9730 match best {
9731 None => {
9732 best = Some((*candidate, score));
9733 tied_best = false;
9734 }
9735 Some((_, best_score)) if score > best_score => {
9736 best = Some((*candidate, score));
9737 tied_best = false;
9738 }
9739 Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
9740 tied_best = true;
9741 }
9742 _ => {}
9743 }
9744 }
9745
9746 let (candidate, score) = best?;
9747 if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
9748 Some(candidate.clone())
9749 } else {
9750 None
9751 }
9752}
9753
9754fn method_name_match_denylisted(method_name: &str) -> bool {
9755 matches!(
9756 method_name,
9757 "and_then"
9758 | "as_bytes"
9759 | "as_deref"
9760 | "as_mut"
9761 | "as_ref"
9762 | "as_str"
9763 | "borrow"
9764 | "borrow_mut"
9765 | "clear"
9766 | "clone"
9767 | "collect"
9768 | "contains"
9769 | "contains_key"
9770 | "count"
9771 | "dedup"
9772 | "default"
9773 | "drain"
9774 | "ends_with"
9775 | "entry"
9776 | "err"
9777 | "expect"
9778 | "extend"
9779 | "filter"
9780 | "filter_map"
9781 | "find"
9782 | "from"
9783 | "get"
9784 | "get_mut"
9785 | "insert"
9786 | "into"
9787 | "into_iter"
9788 | "is_empty"
9789 | "is_err"
9790 | "is_none"
9791 | "is_ok"
9792 | "is_some"
9793 | "iter"
9794 | "iter_mut"
9795 | "join"
9796 | "len"
9797 | "lock"
9798 | "map"
9799 | "map_err"
9800 | "max"
9801 | "min"
9802 | "new"
9803 | "next"
9804 | "ok"
9805 | "or_default"
9806 | "or_else"
9807 | "or_insert"
9808 | "or_insert_with"
9809 | "parse"
9810 | "pop"
9811 | "position"
9812 | "push"
9813 | "read"
9814 | "recv"
9815 | "remove"
9816 | "replace"
9817 | "retain"
9818 | "send"
9819 | "sort"
9820 | "sort_by"
9821 | "split"
9822 | "starts_with"
9823 | "sum"
9824 | "take"
9825 | "to_owned"
9826 | "to_string"
9827 | "trim"
9828 | "try_from"
9829 | "try_into"
9830 | "unwrap"
9831 | "unwrap_or"
9832 | "unwrap_or_default"
9833 | "unwrap_or_else"
9834 | "with_capacity"
9835 | "write"
9836 )
9837}
9838
9839fn split_camel_case(value: &str) -> Vec<String> {
9840 let chars = value.chars().collect::<Vec<_>>();
9841 let mut normalized = String::with_capacity(value.len() + 8);
9842 for (index, ch) in chars.iter().enumerate() {
9843 let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
9844 let next = chars.get(index + 1);
9845 let is_separator = ch.is_whitespace()
9846 || matches!(
9847 ch,
9848 '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
9849 );
9850 if is_separator {
9851 normalized.push(' ');
9852 continue;
9853 }
9854 let camel_boundary = previous.is_some_and(|prev| {
9855 (prev.is_lowercase() && ch.is_uppercase())
9856 || (prev.is_ascii_digit() && ch.is_alphabetic())
9857 || (prev.is_uppercase()
9858 && ch.is_uppercase()
9859 && next.is_some_and(|next| next.is_lowercase()))
9860 });
9861 if camel_boundary {
9862 normalized.push(' ');
9863 }
9864 normalized.push(*ch);
9865 }
9866
9867 normalized
9868 .split_whitespace()
9869 .filter(|word| word.len() > 1)
9870 .map(|word| word.to_ascii_lowercase())
9871 .collect()
9872}
9873
9874fn compute_path_proximity(left: &str, right: &str) -> f64 {
9875 let left_dirs = left
9876 .rsplit_once('/')
9877 .map(|(dir, _)| dir)
9878 .unwrap_or_default()
9879 .split('/')
9880 .filter(|part| !part.is_empty());
9881 let right_dirs = right
9882 .rsplit_once('/')
9883 .map(|(dir, _)| dir)
9884 .unwrap_or_default()
9885 .split('/')
9886 .filter(|part| !part.is_empty());
9887
9888 let shared = left_dirs
9889 .zip(right_dirs)
9890 .take_while(|(left, right)| left == right)
9891 .count();
9892 ((shared as f64) * 0.05).min(0.5)
9893}
9894
9895fn mark_backend_state(
9896 tx: &Transaction<'_>,
9897 project_root: &Path,
9898 rel_path: &str,
9899 content_hash: Option<&blake3::Hash>,
9900 status: &str,
9901) -> Result<()> {
9902 clear_backend_state_for_file(tx, project_root, rel_path)?;
9903 let hash = content_hash
9904 .map(|hash| hash_to_hex(*hash))
9905 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
9906 tx.execute(
9907 "INSERT OR REPLACE INTO backend_file_state(
9908 backend, workspace_root, file_path, content_hash, status, updated_at
9909 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
9910 params![
9911 BACKEND_TREESITTER,
9912 project_root.display().to_string(),
9913 rel_path,
9914 hash,
9915 status,
9916 unix_seconds_now(),
9917 ],
9918 )?;
9919 Ok(())
9920}
9921
9922fn clear_backend_state_for_file(
9923 tx: &Transaction<'_>,
9924 project_root: &Path,
9925 rel_path: &str,
9926) -> Result<()> {
9927 tx.execute(
9928 "DELETE FROM backend_file_state
9929 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
9930 params![
9931 BACKEND_TREESITTER,
9932 project_root.display().to_string(),
9933 rel_path
9934 ],
9935 )?;
9936 Ok(())
9937}
9938
9939fn load_file_row(tx: &Transaction<'_>, rel_path: &str) -> Result<Option<FileRow>> {
9940 tx.query_row(
9941 "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
9942 params![rel_path],
9943 |row| {
9944 let hash_text: String = row.get(1)?;
9945 Ok(FileRow {
9946 surface_fingerprint: row.get(0)?,
9947 freshness: FileFreshness {
9948 content_hash: hash_from_hex(&hash_text)
9949 .unwrap_or_else(cache_freshness::zero_hash),
9950 mtime: ns_to_system_time(row.get::<_, i64>(2)?),
9951 size: row.get::<_, i64>(3)? as u64,
9952 },
9953 })
9954 },
9955 )
9956 .optional()
9957 .map_err(CallGraphStoreError::from)
9958}
9959
9960fn stored_node_ids_match_extract(
9961 tx: &Transaction<'_>,
9962 rel_path: &str,
9963 extract: &FileExtract,
9964) -> Result<bool> {
9965 let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
9966 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
9967 let mut stored = BTreeSet::new();
9968 for row in rows {
9969 stored.insert(row?);
9970 }
9971 let extracted = extract
9972 .nodes
9973 .iter()
9974 .map(|node| node.id.clone())
9975 .collect::<BTreeSet<_>>();
9976 Ok(stored == extracted)
9977}
9978
9979fn update_file_fresh_metadata(
9980 tx: &Transaction<'_>,
9981 rel_path: &str,
9982 hash: &blake3::Hash,
9983 mtime: SystemTime,
9984 size: u64,
9985) -> Result<()> {
9986 tx.execute(
9987 "UPDATE files SET mtime_ns = ?2, size = ?3, indexed_at = ?4 WHERE path = ?1",
9988 params![
9989 rel_path,
9990 system_time_to_ns(mtime),
9991 size as i64,
9992 unix_seconds_now()
9993 ],
9994 )?;
9995 tx.execute(
9996 "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
9997 WHERE backend = ?1 AND file_path = ?2 AND content_hash = ?3",
9998 params![
9999 BACKEND_TREESITTER,
10000 rel_path,
10001 hash_to_hex(*hash),
10002 unix_seconds_now(),
10003 ],
10004 )?;
10005 Ok(())
10006}
10007
10008#[derive(Debug, Clone, PartialEq, Eq)]
10009struct DependentRefSelection {
10010 ref_id: String,
10011 caller_file: String,
10012}
10013
10014fn ref_ids_depending_on(
10015 tx: &Transaction<'_>,
10016 project_root: &Path,
10017 rel_path: &str,
10018) -> Result<Vec<DependentRefSelection>> {
10019 let mut stmt = tx.prepare(
10020 "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
10021 FROM refs r
10022 WHERE r.caller_file IN (
10023 SELECT file_path FROM file_dependencies WHERE dep_file = ?1
10024 )
10025 OR r.target_file = ?1
10026 ORDER BY r.ref_id",
10027 )?;
10028 let rows = stmt.query_map(params![rel_path], |row| {
10029 Ok(RefDependencyRow {
10030 ref_id: row.get(0)?,
10031 kind: row.get(1)?,
10032 caller_file: row.get(2)?,
10033 module_path: row.get(3)?,
10034 target_file: row.get(4)?,
10035 })
10036 })?;
10037 let mut ids = Vec::new();
10038 for row in rows {
10039 let row = row?;
10040 if ref_dependency_row_depends_on(project_root, &row, rel_path) {
10041 ids.push(DependentRefSelection {
10042 ref_id: row.ref_id,
10043 caller_file: row.caller_file,
10044 });
10045 }
10046 }
10047 Ok(ids)
10048}
10049
10050fn record_dependent_refs(
10051 selected_ref_ids: &mut BTreeSet<String>,
10052 selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
10053 dependent_refs: Vec<DependentRefSelection>,
10054) {
10055 for dependent_ref in dependent_refs {
10056 let DependentRefSelection {
10057 ref_id,
10058 caller_file,
10059 } = dependent_ref;
10060 selected_ref_ids.insert(ref_id.clone());
10061 selected_refs_by_caller
10062 .entry(caller_file)
10063 .or_default()
10064 .insert(ref_id);
10065 }
10066}
10067
10068#[cfg(test)]
10069fn refs_by_caller_for_ref_ids(
10070 tx: &Transaction<'_>,
10071 ref_ids: &BTreeSet<String>,
10072) -> Result<BTreeMap<String, BTreeSet<String>>> {
10073 let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10074 let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
10075 for ref_id in ref_ids {
10076 if let Some(caller) = stmt
10077 .query_row(params![ref_id], |row| row.get::<_, String>(0))
10078 .optional()?
10079 {
10080 by_caller.entry(caller).or_default().insert(ref_id.clone());
10081 }
10082 }
10083 Ok(by_caller)
10084}
10085
10086fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
10087 tx.execute(
10088 "DELETE FROM file_dependencies WHERE file_path = ?1",
10089 params![rel_path],
10090 )?;
10091 delete_refs_for_caller(tx, rel_path)?;
10092 tx.execute(
10093 "DELETE FROM dispatch_hints WHERE file = ?1",
10094 params![rel_path],
10095 )?;
10096 tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
10097 tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
10098 Ok(())
10099}
10100
10101fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
10102 let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
10103 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
10104 let mut ids = BTreeSet::new();
10105 for row in rows {
10106 ids.insert(row?);
10107 }
10108 delete_ref_ids(tx, &ids)
10109}
10110
10111fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
10112 for ref_id in ref_ids {
10113 tx.execute("DELETE FROM edges WHERE ref_id = ?1", params![ref_id])?;
10114 tx.execute("DELETE FROM refs WHERE ref_id = ?1", params![ref_id])?;
10115 }
10116 Ok(())
10117}
10118
10119fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
10120 let mut stmt = conn.prepare(
10121 "SELECT source.file_path, source.scoped_name, edges.target_file,
10122 edges.target_symbol, edges.kind, edges.line
10123 FROM edges
10124 JOIN nodes AS source ON source.id = edges.source_node
10125 ORDER BY source.file_path, source.scoped_name, edges.target_file,
10126 edges.target_symbol, edges.kind, edges.line",
10127 )?;
10128 let rows = stmt.query_map([], |row| {
10129 Ok(StoredEdge {
10130 source_file: row.get(0)?,
10131 source_symbol: row.get(1)?,
10132 target_file: row.get(2)?,
10133 target_symbol: row.get(3)?,
10134 kind: row.get(4)?,
10135 line: row.get::<_, i64>(5)? as u32,
10136 })
10137 })?;
10138 let mut edges = BTreeSet::new();
10139 for row in rows {
10140 edges.insert(row?);
10141 }
10142 Ok(edges)
10143}
10144
10145fn module_target_from_dependencies(
10146 project_root: &Path,
10147 dependencies: &BTreeSet<String>,
10148) -> Option<String> {
10149 dependencies.iter().find_map(|dep| {
10150 let path = project_root.join(dep);
10151 if path.is_file() {
10152 Some(relative_path(project_root, &canonicalize_path(&path)))
10153 } else {
10154 None
10155 }
10156 })
10157}
10158
10159fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
10160 let mut named = HashMap::new();
10161 if let Some(full_ref) = &raw_ref.full_ref {
10162 named = parse_reexport_names(full_ref);
10163 }
10164 ReexportIndex {
10165 target_file,
10166 named,
10167 wildcard: raw_ref.wildcard,
10168 }
10169}
10170
10171fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
10172 let mut names = HashMap::new();
10173 let Some(open) = statement.find('{') else {
10174 return names;
10175 };
10176 let Some(close) = statement[open + 1..]
10177 .find('}')
10178 .map(|offset| open + 1 + offset)
10179 else {
10180 return names;
10181 };
10182 for spec in statement[open + 1..close].split(',') {
10183 let spec = spec.trim();
10184 if spec.is_empty() {
10185 continue;
10186 }
10187 if let Some((source, local)) = spec.split_once(" as ") {
10188 names.insert(local.trim().to_string(), source.trim().to_string());
10189 } else {
10190 names.insert(spec.to_string(), spec.to_string());
10191 }
10192 }
10193 names
10194}
10195
10196#[derive(Debug)]
10197struct RefDependencyRow {
10198 ref_id: String,
10199 kind: String,
10200 caller_file: String,
10201 module_path: Option<String>,
10202 target_file: Option<String>,
10203}
10204
10205fn ref_dependency_row_depends_on(
10206 project_root: &Path,
10207 row: &RefDependencyRow,
10208 rel_path: &str,
10209) -> bool {
10210 if row.target_file.as_deref() == Some(rel_path) {
10211 return true;
10212 }
10213
10214 match row.kind.as_str() {
10215 "call" => true,
10216 "import" | "reexport" => row
10217 .module_path
10218 .as_deref()
10219 .map(|module_path| {
10220 module_dependencies_for_ref(project_root, &row.caller_file, module_path)
10221 .contains(rel_path)
10222 })
10223 .unwrap_or(false),
10224 "export_alias" => false,
10225 _ => false,
10226 }
10227}
10228
10229fn module_dependencies_for_ref(
10230 project_root: &Path,
10231 caller_file: &str,
10232 module_path: &str,
10233) -> BTreeSet<String> {
10234 module_dependencies(project_root, &project_root.join(caller_file), module_path)
10235}
10236
10237fn import_dependencies(
10238 project_root: &Path,
10239 abs_path: &Path,
10240 imports: &[ImportStatement],
10241) -> BTreeSet<String> {
10242 let mut deps = BTreeSet::new();
10243 for import in imports {
10244 deps.extend(module_dependencies(
10245 project_root,
10246 abs_path,
10247 &import.module_path,
10248 ));
10249 }
10250 deps
10251}
10252
10253fn module_dependencies(
10254 project_root: &Path,
10255 abs_path: &Path,
10256 module_path: &str,
10257) -> BTreeSet<String> {
10258 let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
10259 let caller_dir = abs_path.parent().unwrap_or(project_root);
10260 if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
10261 deps.insert(relative_path(project_root, &resolved));
10262 }
10263 if module_path.starts_with('.') {
10264 let base = caller_dir.join(module_path);
10265 for candidate in relative_module_candidates(&base) {
10266 deps.insert(relative_path(project_root, &candidate));
10267 }
10268 }
10269 deps
10270}
10271
10272fn rust_module_dependencies(
10273 project_root: &Path,
10274 abs_path: &Path,
10275 module_path: &str,
10276) -> BTreeSet<String> {
10277 let mut deps = BTreeSet::new();
10278 let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
10279 let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
10280 return deps;
10281 };
10282 let src_prefix = rust_src_prefix(&rel_path);
10283 rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
10284 if !path_segments.is_empty() {
10285 rust_push_module_dependency_candidate(
10286 project_root,
10287 &mut deps,
10288 &src_prefix,
10289 &path_segments[..path_segments.len() - 1],
10290 );
10291 }
10292 deps
10293}
10294
10295fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
10296 let path = rust_module_path_without_alias_or_use_list(module_path);
10297 let segments = path
10298 .split("::")
10299 .map(str::trim)
10300 .filter(|segment| !segment.is_empty())
10301 .collect::<Vec<_>>();
10302 if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
10303 return None;
10304 }
10305 rust_resolve_segments(rel_path, &segments)
10306}
10307
10308fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
10309 let path = module_path
10310 .trim()
10311 .trim_end_matches(';')
10312 .split_once(" as ")
10313 .map(|(left, _)| left.trim())
10314 .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
10315 path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
10316}
10317
10318fn rust_push_module_dependency_candidate(
10319 project_root: &Path,
10320 deps: &mut BTreeSet<String>,
10321 src_prefix: &str,
10322 segments: &[String],
10323) {
10324 let candidates = if segments.is_empty() {
10325 vec![
10326 format!("{src_prefix}/lib.rs"),
10327 format!("{src_prefix}/main.rs"),
10328 ]
10329 } else {
10330 vec![
10331 format!("{}/{}.rs", src_prefix, segments.join("/")),
10332 format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
10333 ]
10334 };
10335 for candidate in candidates {
10336 if project_root.join(&candidate).is_file() {
10337 deps.insert(candidate);
10338 }
10339 }
10340}
10341
10342fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
10343 let mut candidates = Vec::new();
10344 if base.extension().is_some() {
10345 candidates.push(base.to_path_buf());
10346 return candidates;
10347 }
10348 for ext in JS_TS_EXTENSIONS {
10349 candidates.push(base.with_extension(ext));
10350 }
10351 for ext in JS_TS_EXTENSIONS {
10352 candidates.push(base.join(format!("index.{ext}")));
10353 }
10354 candidates
10355}
10356
10357fn import_local_names(import: &ImportStatement) -> Vec<String> {
10358 let mut names = Vec::new();
10359 if let Some(default) = &import.default_import {
10360 names.push(default.clone());
10361 }
10362 if let Some(namespace) = &import.namespace_import {
10363 names.push(namespace.clone());
10364 }
10365 for name in &import.names {
10366 names.push(crate::imports::specifier_local_name(name).to_string());
10367 }
10368 names
10369}
10370
10371fn import_requested_names(import: &ImportStatement) -> Vec<String> {
10372 import
10373 .names
10374 .iter()
10375 .map(|name| crate::imports::specifier_imported_name(name).to_string())
10376 .collect()
10377}
10378
10379fn import_is_wildcard(import: &ImportStatement) -> bool {
10380 import.namespace_import.is_some() || import.raw_text.contains('*')
10381}
10382
10383fn namespace_alias(full_ref: &str) -> Option<String> {
10384 full_ref
10385 .split_once('.')
10386 .map(|(namespace, _)| namespace.to_string())
10387}
10388
10389fn import_kind_label(kind: ImportKind) -> &'static str {
10390 match kind {
10391 ImportKind::Value => "value",
10392 ImportKind::Type => "type",
10393 ImportKind::SideEffect => "side_effect",
10394 }
10395}
10396
10397fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
10398 match kind {
10399 SymbolKind::Function => "function",
10400 SymbolKind::Class => "class",
10401 SymbolKind::Method => "method",
10402 SymbolKind::Struct => "struct",
10403 SymbolKind::Interface => "interface",
10404 SymbolKind::Enum => "enum",
10405 SymbolKind::TypeAlias => "type_alias",
10406 SymbolKind::Variable => "variable",
10407 SymbolKind::Heading => "heading",
10408 SymbolKind::FileSummary => "file_summary",
10409 }
10410}
10411
10412fn is_type_like(kind: &SymbolKind) -> bool {
10413 matches!(
10414 kind,
10415 SymbolKind::Class
10416 | SymbolKind::Struct
10417 | SymbolKind::Interface
10418 | SymbolKind::Enum
10419 | SymbolKind::TypeAlias
10420 )
10421}
10422
10423fn lang_label(lang: LangId) -> &'static str {
10424 match lang {
10425 LangId::TypeScript => "typescript",
10426 LangId::Tsx => "tsx",
10427 LangId::JavaScript => "javascript",
10428 LangId::Python => "python",
10429 LangId::Rust => "rust",
10430 LangId::Go => "go",
10431 LangId::C => "c",
10432 LangId::Cpp => "cpp",
10433 LangId::Zig => "zig",
10434 LangId::CSharp => "csharp",
10435 LangId::Bash => "bash",
10436 LangId::Html => "html",
10437 LangId::Markdown => "markdown",
10438 LangId::Solidity => "solidity",
10439 LangId::Scss => "scss",
10440 LangId::Vue => "vue",
10441 LangId::Json => "json",
10442 LangId::Scala => "scala",
10443 LangId::Java => "java",
10444 LangId::Ruby => "ruby",
10445 LangId::Kotlin => "kotlin",
10446 LangId::Swift => "swift",
10447 LangId::Php => "php",
10448 LangId::Lua => "lua",
10449 LangId::Perl => "perl",
10450 LangId::Yaml => "yaml",
10451 LangId::Pascal => "pascal",
10452 LangId::R => "r",
10453 LangId::Groovy => "groovy",
10454 LangId::ObjC => "objc",
10455 }
10456}
10457
10458fn lang_from_label(label: &str) -> Option<LangId> {
10459 match label {
10460 "typescript" => Some(LangId::TypeScript),
10461 "tsx" => Some(LangId::Tsx),
10462 "javascript" => Some(LangId::JavaScript),
10463 "python" => Some(LangId::Python),
10464 "rust" => Some(LangId::Rust),
10465 "go" => Some(LangId::Go),
10466 "c" => Some(LangId::C),
10467 "cpp" => Some(LangId::Cpp),
10468 "zig" => Some(LangId::Zig),
10469 "csharp" => Some(LangId::CSharp),
10470 "bash" => Some(LangId::Bash),
10471 "html" => Some(LangId::Html),
10472 "markdown" => Some(LangId::Markdown),
10473 "solidity" => Some(LangId::Solidity),
10474 "scss" => Some(LangId::Scss),
10475 "vue" => Some(LangId::Vue),
10476 "json" => Some(LangId::Json),
10477 "scala" => Some(LangId::Scala),
10478 "java" => Some(LangId::Java),
10479 "ruby" => Some(LangId::Ruby),
10480 "kotlin" => Some(LangId::Kotlin),
10481 "swift" => Some(LangId::Swift),
10482 "php" => Some(LangId::Php),
10483 "lua" => Some(LangId::Lua),
10484 "perl" => Some(LangId::Perl),
10485 "yaml" => Some(LangId::Yaml),
10486 "pascal" => Some(LangId::Pascal),
10487 "r" => Some(LangId::R),
10488 "groovy" => Some(LangId::Groovy),
10489 "objc" => Some(LangId::ObjC),
10490 _ => None,
10491 }
10492}
10493
10494fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
10495 let mut normalized = if files.is_empty() {
10496 callgraph::walk_project_files(project_root).collect::<Vec<_>>()
10497 } else {
10498 files
10499 .iter()
10500 .map(|path| normalize_file_path(project_root, path))
10501 .collect::<Result<Vec<_>>>()?
10502 };
10503 normalized.sort();
10504 normalized.dedup();
10505 Ok(normalized)
10506}
10507
10508fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
10509 let full_path = if path.is_relative() {
10510 project_root.join(path)
10511 } else {
10512 path.to_path_buf()
10513 };
10514 Ok(canonicalize_path(&full_path))
10515}
10516
10517fn canonicalize_path(path: &Path) -> PathBuf {
10518 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
10519}
10520
10521fn relative_path(project_root: &Path, path: &Path) -> String {
10522 if let Ok(stripped) = path.strip_prefix(project_root) {
10523 return stripped.to_string_lossy().replace('\\', "/");
10524 }
10525 let canon_root = canonicalize_path(project_root);
10526 let canon_path = canonicalize_path(path);
10527 if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
10528 return stripped.to_string_lossy().replace('\\', "/");
10529 }
10530 canon_path.to_string_lossy().replace('\\', "/")
10531}
10532
10533fn unqualified_name(scoped: &str) -> &str {
10534 if scoped == TOP_LEVEL_SYMBOL {
10535 return scoped;
10536 }
10537 scoped
10538 .rsplit("::")
10539 .next()
10540 .unwrap_or(scoped)
10541 .rsplit('.')
10542 .next()
10543 .unwrap_or(scoped)
10544 .rsplit('#')
10545 .next()
10546 .unwrap_or(scoped)
10547}
10548
10549fn ref_id(parts: &[&str]) -> String {
10550 let joined = parts.join("\0");
10551 hash_to_hex(blake3::hash(joined.as_bytes()))
10552}
10553
10554fn hash_to_hex(hash: blake3::Hash) -> String {
10555 hash.to_hex().to_string()
10556}
10557
10558fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
10559 let bytes = hex_to_bytes(value)?;
10560 Some(blake3::Hash::from_bytes(bytes))
10561}
10562
10563fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
10564 if value.len() != 64 {
10565 return None;
10566 }
10567 let mut bytes = [0u8; 32];
10568 for (index, slot) in bytes.iter_mut().enumerate() {
10569 let start = index * 2;
10570 let end = start + 2;
10571 *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
10572 }
10573 Some(bytes)
10574}
10575
10576#[derive(Debug, Clone)]
10577struct LineIndex {
10578 newline_offsets: Vec<usize>,
10579 source_len: usize,
10580}
10581
10582impl LineIndex {
10583 fn new(source: &str) -> Self {
10584 Self {
10585 newline_offsets: source
10586 .bytes()
10587 .enumerate()
10588 .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
10589 .collect(),
10590 source_len: source.len(),
10591 }
10592 }
10593
10594 fn byte_to_line(&self, byte_offset: usize) -> u32 {
10595 let byte_offset = byte_offset.min(self.source_len);
10596 self.newline_offsets
10597 .partition_point(|offset| *offset < byte_offset) as u32
10598 + 1
10599 }
10600}
10601
10602fn empty_to_none(value: String) -> Option<String> {
10603 if value.is_empty() {
10604 None
10605 } else {
10606 Some(value)
10607 }
10608}
10609
10610fn bool_int(value: bool) -> i64 {
10611 if value {
10612 1
10613 } else {
10614 0
10615 }
10616}
10617
10618fn system_time_to_ns(time: SystemTime) -> i64 {
10619 time.duration_since(UNIX_EPOCH)
10620 .unwrap_or_default()
10621 .as_nanos()
10622 .min(i64::MAX as u128) as i64
10623}
10624
10625fn ns_to_system_time(value: i64) -> SystemTime {
10626 UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
10627}
10628
10629fn unix_seconds_now() -> i64 {
10630 SystemTime::now()
10631 .duration_since(UNIX_EPOCH)
10632 .unwrap_or_default()
10633 .as_secs() as i64
10634}
10635
10636#[cfg(test)]
10641pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
10642
10643#[cfg(test)]
10644mod refresh_worker_tests {
10645 use super::*;
10646 use std::fs;
10647 use tempfile::tempdir;
10648
10649 fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
10650 let temp = tempdir().unwrap();
10651 let root = temp.path().join("root");
10652 let callgraph_dir = temp
10653 .path()
10654 .join("storage")
10655 .join("callgraph")
10656 .join(crate::search_index::artifact_cache_key(&root));
10657 fs::create_dir_all(&root).unwrap();
10658 let source = root.join("main.rs");
10659 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
10660 let (store, _) = CallGraphStore::cold_build_with_lease(
10661 callgraph_dir.clone(),
10662 root.clone(),
10663 std::slice::from_ref(&source),
10664 )
10665 .unwrap();
10666 drop(store);
10667 (temp, root, callgraph_dir, source)
10668 }
10669
10670 fn pending_paths() -> PendingCallGraphStorePaths {
10671 Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
10672 }
10673
10674 fn wait_for_refresh_calls(root: &Path, expected: usize) {
10675 let deadline = Instant::now() + Duration::from_secs(12);
10676 while callgraph_refresh_worker_test_counts(root).0 < expected {
10677 assert!(
10678 Instant::now() < deadline,
10679 "timed out waiting for {expected} callgraph refresh worker call(s)"
10680 );
10681 std::thread::sleep(Duration::from_millis(5));
10682 }
10683 }
10684
10685 fn wait_for_refresh_worker_idle() {
10686 let deadline = Instant::now() + Duration::from_secs(12);
10687 loop {
10688 let worker = CALLGRAPH_REFRESH_WORKER
10689 .get_or_init(|| Mutex::new(None))
10690 .lock()
10691 .expect("callgraph refresh worker mutex poisoned")
10692 .clone();
10693 let idle = worker.is_none_or(|worker| {
10694 let queue = worker
10695 .shared
10696 .queue
10697 .lock()
10698 .expect("callgraph refresh queue mutex poisoned");
10699 queue.active.is_none() && queue.order.is_empty()
10700 });
10701 if idle {
10702 return;
10703 }
10704 assert!(
10705 Instant::now() < deadline,
10706 "timed out waiting for callgraph refresh worker to become idle"
10707 );
10708 std::thread::sleep(Duration::from_millis(5));
10709 }
10710 }
10711
10712 fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
10713 let temp = tempdir().unwrap();
10714 let root = temp.path().join("workspace");
10715 let callgraph_dir = temp
10716 .path()
10717 .join("storage")
10718 .join("callgraph")
10719 .join(crate::search_index::artifact_cache_key(&root));
10720 fs::create_dir_all(root.join("app/src")).unwrap();
10721 fs::write(
10722 root.join("Cargo.toml"),
10723 "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
10724 )
10725 .unwrap();
10726 fs::write(
10727 root.join("app/Cargo.toml"),
10728 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
10729 )
10730 .unwrap();
10731 let caller = root.join("app/src/lib.rs");
10732 fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
10733 let (store, _) = CallGraphStore::cold_build_with_lease(
10734 callgraph_dir.clone(),
10735 root.clone(),
10736 std::slice::from_ref(&caller),
10737 )
10738 .unwrap();
10739 drop(store);
10740 (temp, root, callgraph_dir, caller)
10741 }
10742
10743 #[test]
10744 fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
10745 let _guard = REFRESH_WORKER_TEST_LOCK
10746 .lock()
10747 .unwrap_or_else(std::sync::PoisonError::into_inner);
10748 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10749 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
10750 reset_workspace_crate_prefix_build_count(&root);
10751 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10752
10753 for revision in ["first", "second"] {
10754 fs::write(
10755 &caller,
10756 format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
10757 )
10758 .unwrap();
10759 enqueue_callgraph_store_refresh(
10760 callgraph_dir.clone(),
10761 root.clone(),
10762 vec![caller.clone()],
10763 pending_paths(),
10764 );
10765 wait_for_refresh_worker_idle();
10766 }
10767
10768 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
10769 assert!(flush_callgraph_store_refreshes_with_budget(
10770 Duration::from_secs(5)
10771 ));
10772 clear_callgraph_refresh_worker_test_seam(&root);
10773 }
10774
10775 #[test]
10776 fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
10777 let _guard = REFRESH_WORKER_TEST_LOCK
10778 .lock()
10779 .unwrap_or_else(std::sync::PoisonError::into_inner);
10780 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10781 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
10782 reset_workspace_crate_prefix_build_count(&root);
10783 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10784
10785 fs::write(
10786 &caller,
10787 "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
10788 )
10789 .unwrap();
10790 enqueue_callgraph_store_refresh(
10791 callgraph_dir.clone(),
10792 root.clone(),
10793 vec![caller.clone()],
10794 pending_paths(),
10795 );
10796 wait_for_refresh_worker_idle();
10797 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
10798
10799 let added_manifest = root.join("added/Cargo.toml");
10800 let added_source = root.join("added/src/lib.rs");
10801 fs::create_dir_all(added_source.parent().unwrap()).unwrap();
10802 fs::write(
10803 root.join("Cargo.toml"),
10804 "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
10805 )
10806 .unwrap();
10807 fs::write(
10808 &added_manifest,
10809 "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
10810 )
10811 .unwrap();
10812 fs::write(&added_source, "pub fn target() {}\n").unwrap();
10813 fs::write(
10814 &caller,
10815 "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
10816 )
10817 .unwrap();
10818
10819 enqueue_callgraph_store_refresh(
10820 callgraph_dir.clone(),
10821 root.clone(),
10822 vec![
10823 root.join("Cargo.toml"),
10824 added_manifest,
10825 added_source,
10826 caller,
10827 ],
10828 pending_paths(),
10829 );
10830 assert!(flush_callgraph_store_refreshes_with_budget(
10831 Duration::from_secs(12)
10832 ));
10833
10834 assert_eq!(workspace_crate_prefix_build_count(&root), 2);
10838 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
10839 .unwrap()
10840 .expect("refreshed workspace store");
10841 let tree = store
10842 .call_tree(Path::new("app/src/lib.rs"), "run", 1)
10843 .unwrap();
10844 assert_eq!(tree.children.len(), 1);
10845 assert_eq!(tree.children[0].file, "added/src/lib.rs");
10846 assert_eq!(tree.children[0].name, "target");
10847 assert!(tree.children[0].resolved);
10848 clear_callgraph_refresh_worker_test_seam(&root);
10849 }
10850
10851 #[test]
10852 fn forced_rebuild_without_writer_capability_cannot_report_old_store_as_ready() {
10853 let _git_env = crate::test_env::hermetic_git_env_guard();
10854 let temp = tempdir().unwrap();
10855 let main = temp.path().join("main");
10856 let root = temp.path().join("worktree");
10857 fs::create_dir_all(&main).unwrap();
10858 let mut git = std::process::Command::new("git");
10859 assert!(
10860 crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
10861 .status()
10862 .unwrap()
10863 .success()
10864 );
10865 let source = main.join("lib.rs");
10866 fs::write(&source, "pub fn marker() {}\n").unwrap();
10867 for args in [
10868 vec![
10869 "-C",
10870 main.to_str().unwrap(),
10871 "config",
10872 "user.email",
10873 "test@example.com",
10874 ],
10875 vec![
10876 "-C",
10877 main.to_str().unwrap(),
10878 "config",
10879 "user.name",
10880 "AFT Test",
10881 ],
10882 vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
10883 vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
10884 ] {
10885 let mut command = std::process::Command::new("git");
10886 assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
10887 .status()
10888 .unwrap()
10889 .success());
10890 }
10891 let mut worktree = std::process::Command::new("git");
10892 assert!(crate::test_env::apply_hermetic_git_env(
10893 worktree
10894 .arg("-C")
10895 .arg(&main)
10896 .args(["worktree", "add", "--detach"])
10897 .arg(&root),
10898 )
10899 .status()
10900 .unwrap()
10901 .success());
10902
10903 let project_key = crate::search_index::artifact_cache_key(&root);
10904 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
10905 crate::root_cache::configure_artifact_access(&root, &project_key, true);
10906 let source = root.join("lib.rs");
10907 let error =
10908 CallGraphStore::force_cold_build_with_lease_chunked(callgraph_dir, root, &[source], 1)
10909 .expect_err("borrow-only forced rebuild must remain unsatisfied");
10910
10911 assert!(matches!(error, CallGraphStoreError::Unavailable(_)));
10912 }
10913
10914 #[test]
10915 fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
10916 let _guard = REFRESH_WORKER_TEST_LOCK
10917 .lock()
10918 .unwrap_or_else(std::sync::PoisonError::into_inner);
10919 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10920 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10921 let pending = pending_paths();
10922 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10923
10924 let lifecycle = SubcLifecycleAdmission::default();
10925 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
10926 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
10927 let ticket = CallgraphRefreshTicket::new(
10928 lifecycle,
10929 Arc::clone(&generation),
10930 7,
10931 publish_epoch.clone(),
10932 publish_epoch.current(),
10933 );
10934 generation.store(8, std::sync::atomic::Ordering::SeqCst);
10936 let installed = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
10937 .unwrap()
10938 .expect("ready store snapshot");
10939 let refresh_state = CallgraphRefreshState::new(
10940 Arc::new(std::sync::RwLock::new(Some(Arc::new(installed)))),
10941 Arc::new(AtomicBool::new(true)),
10942 );
10943
10944 enqueue_callgraph_store_refresh_fenced_with_state(
10945 callgraph_dir,
10946 root.clone(),
10947 vec![source.clone()],
10948 Arc::clone(&pending),
10949 refresh_state,
10950 ticket,
10951 );
10952 assert!(flush_callgraph_store_refreshes_with_budget(
10953 Duration::from_secs(5)
10954 ));
10955 assert_eq!(
10956 callgraph_refresh_worker_test_counts(&root).0,
10957 0,
10958 "superseded batch must not reach refresh_files or self-replay"
10959 );
10960 assert!(
10961 pending.lock().contains(&source),
10962 "superseded batch must defer its paths to the pending sink"
10963 );
10964 clear_callgraph_refresh_worker_test_seam(&root);
10965 }
10966
10967 #[test]
10968 fn superseded_open_failure_defers_without_self_replay() {
10969 let _guard = REFRESH_WORKER_TEST_LOCK
10970 .lock()
10971 .unwrap_or_else(std::sync::PoisonError::into_inner);
10972 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10973 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10974 let pending = pending_paths();
10975 let installed = Arc::new(
10976 CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
10977 .unwrap()
10978 .expect("ready store snapshot"),
10979 );
10980 let refresh_state = CallgraphRefreshState::new(
10981 Arc::new(std::sync::RwLock::new(Some(Arc::clone(&installed)))),
10982 Arc::new(AtomicBool::new(true)),
10983 );
10984 assert!(!installed.is_legacy_fallback());
10985 assert!(installed.is_current());
10986 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
10987 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10988 set_callgraph_refresh_worker_test_open_failure(root.clone(), true);
10989 let (held_rx, release_tx) = install_callgraph_refresh_worker_test_gate(root.clone());
10990
10991 let lifecycle = SubcLifecycleAdmission::default();
10992 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
10993 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
10994 let ticket = CallgraphRefreshTicket::new(
10995 lifecycle,
10996 Arc::clone(&generation),
10997 7,
10998 publish_epoch.clone(),
10999 publish_epoch.current(),
11000 );
11001 enqueue_callgraph_store_refresh_fenced_with_state(
11002 callgraph_dir,
11003 root.clone(),
11004 vec![source.clone()],
11005 Arc::clone(&pending),
11006 refresh_state,
11007 ticket,
11008 );
11009 held_rx
11010 .recv_timeout(Duration::from_secs(12))
11011 .expect("refresh worker must hold after injected open failure");
11012
11013 generation.store(8, std::sync::atomic::Ordering::SeqCst);
11016 set_callgraph_refresh_worker_test_open_failure(root.clone(), false);
11017 release_tx
11018 .send(())
11019 .expect("release superseded refresh worker");
11020 wait_for_refresh_worker_idle();
11021
11022 assert_eq!(
11023 callgraph_refresh_worker_test_counts(&root).0,
11024 1,
11025 "superseded open-failure batch must not self-replay"
11026 );
11027 assert_eq!(
11028 callgraph_refresh_worker_test_worker_calls(&root),
11029 1,
11030 "superseded open-failure batch must not create another worker call"
11031 );
11032 assert!(
11033 pending.lock().contains(&source),
11034 "superseded open-failure paths must remain in the pending sink"
11035 );
11036 let tree = installed
11037 .call_tree(Path::new("main.rs"), "entry", 1)
11038 .unwrap();
11039 assert_eq!(
11040 tree.children[0].name, "old_leaf",
11041 "superseded open-failure batch must not converge the store"
11042 );
11043 clear_callgraph_refresh_worker_test_seam(&root);
11044 }
11045
11046 #[test]
11047 fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
11048 let _guard = REFRESH_WORKER_TEST_LOCK
11049 .lock()
11050 .unwrap_or_else(std::sync::PoisonError::into_inner);
11051 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
11052 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
11053 let pending = pending_paths();
11054 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
11055
11056 let lifecycle = SubcLifecycleAdmission::default();
11057 let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
11058 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
11059 let expected_epoch = publish_epoch.current();
11060 let ticket = CallgraphRefreshTicket::new(
11061 lifecycle,
11062 generation,
11063 3,
11064 publish_epoch.clone(),
11065 expected_epoch,
11066 );
11067 publish_epoch.next();
11069
11070 enqueue_callgraph_store_refresh_fenced(
11071 callgraph_dir,
11072 root.clone(),
11073 vec![source.clone()],
11074 Arc::clone(&pending),
11075 ticket,
11076 );
11077 assert!(flush_callgraph_store_refreshes_with_budget(
11078 Duration::from_secs(5)
11079 ));
11080 assert_eq!(
11081 callgraph_refresh_worker_test_counts(&root).0,
11082 0,
11083 "epoch-superseded batch must not reach refresh_files"
11084 );
11085 assert!(
11086 pending.lock().contains(&source),
11087 "epoch-superseded batch must defer its paths to the pending sink"
11088 );
11089 clear_callgraph_refresh_worker_test_seam(&root);
11090 }
11091
11092 #[test]
11093 fn fenced_refresh_with_current_ticket_commits_normally() {
11094 let _guard = REFRESH_WORKER_TEST_LOCK
11095 .lock()
11096 .unwrap_or_else(std::sync::PoisonError::into_inner);
11097 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
11098 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
11099 let pending = pending_paths();
11100 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
11101
11102 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
11103
11104 let lifecycle = SubcLifecycleAdmission::default();
11105 let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
11106 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
11107 let ticket = CallgraphRefreshTicket::new(
11108 lifecycle,
11109 generation,
11110 5,
11111 publish_epoch.clone(),
11112 publish_epoch.current(),
11113 );
11114
11115 enqueue_callgraph_store_refresh_fenced(
11116 callgraph_dir.clone(),
11117 root.clone(),
11118 vec![source.clone()],
11119 Arc::clone(&pending),
11120 ticket,
11121 );
11122 assert!(flush_callgraph_store_refreshes_with_budget(
11123 Duration::from_secs(5)
11124 ));
11125 assert_eq!(
11126 callgraph_refresh_worker_test_counts(&root).0,
11127 1,
11128 "current ticket must run the refresh"
11129 );
11130 assert!(
11131 pending.lock().is_empty(),
11132 "committed batch must not defer paths"
11133 );
11134
11135 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
11136 .unwrap()
11137 .expect("published generation must remain readable");
11138 let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
11139 assert_eq!(
11140 tree.children[0].name, "new_leaf",
11141 "fenced commit must actually persist the refreshed content"
11142 );
11143 clear_callgraph_refresh_worker_test_seam(&root);
11144 }
11145
11146 #[test]
11147 fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
11148 let _guard = REFRESH_WORKER_TEST_LOCK
11149 .lock()
11150 .unwrap_or_else(std::sync::PoisonError::into_inner);
11151 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
11156 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
11157 let pending = pending_paths();
11158 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
11159
11160 enqueue_callgraph_store_refresh(
11161 callgraph_dir.clone(),
11162 root.clone(),
11163 vec![source.clone()],
11164 Arc::clone(&pending),
11165 );
11166 wait_for_refresh_calls(&root, 1);
11167 for _ in 0..3 {
11168 enqueue_callgraph_store_refresh(
11169 callgraph_dir.clone(),
11170 root.clone(),
11171 vec![source.clone()],
11172 Arc::clone(&pending),
11173 );
11174 }
11175
11176 assert!(flush_callgraph_store_refreshes_with_budget(
11177 Duration::from_secs(2)
11178 ));
11179 assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
11180 assert!(pending.lock().is_empty());
11181 clear_callgraph_refresh_worker_test_seam(&root);
11182 }
11183
11184 #[test]
11185 fn queued_refresh_opens_generation_published_after_enqueue() {
11186 let _guard = REFRESH_WORKER_TEST_LOCK
11187 .lock()
11188 .unwrap_or_else(std::sync::PoisonError::into_inner);
11189 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
11194 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
11195 let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
11196 set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
11197 let (active_held_rx, active_release_tx) =
11198 install_callgraph_refresh_worker_test_gate(active_root.clone());
11199 set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
11200 enqueue_callgraph_store_refresh(
11201 active_dir,
11202 active_root.clone(),
11203 vec![active_source],
11204 pending_paths(),
11205 );
11206 active_held_rx
11207 .recv_timeout(Duration::from_secs(12))
11208 .expect("active refresh worker holds the queue");
11209
11210 fs::write(
11211 &target_source,
11212 "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
11213 )
11214 .unwrap();
11215 enqueue_callgraph_store_refresh(
11216 target_dir.clone(),
11217 target_root.clone(),
11218 vec![target_source.clone()],
11219 pending_paths(),
11220 );
11221 let (new_generation, _) = CallGraphStore::cold_build_with_lease(
11222 target_dir.clone(),
11223 target_root.clone(),
11224 std::slice::from_ref(&target_source),
11225 )
11226 .unwrap();
11227 fs::write(
11228 &target_source,
11229 "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
11230 )
11231 .unwrap();
11232 drop(new_generation);
11233
11234 active_release_tx
11235 .send(())
11236 .expect("release active refresh worker");
11237 wait_for_refresh_calls(&target_root, 1);
11238 assert!(flush_callgraph_store_refreshes_with_budget(
11239 Duration::from_secs(12)
11240 ));
11241 let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
11242 .unwrap()
11243 .expect("current callgraph generation");
11244 let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
11245 assert_eq!(tree.children[0].name, "worker_leaf");
11246 assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
11247 clear_callgraph_refresh_worker_test_seam(&active_root);
11248 clear_callgraph_refresh_worker_test_seam(&target_root);
11249 }
11250
11251 #[test]
11252 fn refresh_failure_marks_files_stale() {
11253 let _guard = REFRESH_WORKER_TEST_LOCK
11254 .lock()
11255 .unwrap_or_else(std::sync::PoisonError::into_inner);
11256 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
11261 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
11262 let pending = pending_paths();
11263 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
11264
11265 enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
11266 assert!(flush_callgraph_store_refreshes_with_budget(
11267 Duration::from_secs(2)
11268 ));
11269
11270 assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
11271 let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
11272 .unwrap()
11273 .expect("ready callgraph store");
11274 assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
11275 clear_callgraph_refresh_worker_test_seam(&root);
11276 }
11277
11278 #[test]
11279 fn bounded_shutdown_defers_unprocessed_batches() {
11280 let _guard = REFRESH_WORKER_TEST_LOCK
11281 .lock()
11282 .unwrap_or_else(std::sync::PoisonError::into_inner);
11283 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
11288 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
11289 let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
11290 let active_pending = pending_paths();
11291 let queued_pending = pending_paths();
11292 set_callgraph_refresh_worker_test_seam(
11293 active_root.clone(),
11294 Duration::from_millis(300),
11295 false,
11296 );
11297
11298 enqueue_callgraph_store_refresh(
11299 active_dir,
11300 active_root.clone(),
11301 vec![active_source.clone()],
11302 Arc::clone(&active_pending),
11303 );
11304 wait_for_refresh_calls(&active_root, 1);
11305 enqueue_callgraph_store_refresh(
11306 queued_dir,
11307 queued_root.clone(),
11308 vec![queued_source.clone()],
11309 Arc::clone(&queued_pending),
11310 );
11311
11312 assert!(!flush_callgraph_store_refreshes_with_budget(
11313 Duration::from_millis(20)
11314 ));
11315 assert!(active_pending.lock().contains(&active_source));
11316 assert!(queued_pending.lock().contains(&queued_source));
11317 assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
11318 clear_callgraph_refresh_worker_test_seam(&active_root);
11319 }
11320}
11321
11322#[cfg(test)]
11323mod cold_build_insert_tests {
11324 use super::*;
11325 use crate::imports::ImportBlock;
11326 use std::cell::Cell;
11327 use std::fs;
11328 use std::path::{Path, PathBuf};
11329 use tempfile::tempdir;
11330
11331 thread_local! {
11332 static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
11333 static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
11334 static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
11335 }
11336
11337 fn count_caller_traversal_selects(sql: &str) {
11338 let sql = sql.trim_start();
11339 if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
11340 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
11341 }
11342 if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
11343 && sql.contains("e.target_file =")
11344 {
11345 CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
11346 }
11347 if sql.starts_with("WITH requested") {
11348 BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
11349 }
11350 }
11351
11352 #[test]
11353 fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
11354 let dir = tempdir().unwrap();
11355 let previous_root = dir.path().join("previous-root");
11356 let current_root = dir.path().join("current-root");
11357 fs::create_dir_all(&previous_root).unwrap();
11358 fs::create_dir_all(¤t_root).unwrap();
11359 fs::remove_dir(&previous_root).unwrap();
11360 let mut conn = Connection::open_in_memory().unwrap();
11361 initialize_schema(&conn).unwrap();
11362 conn.execute(
11363 "INSERT INTO backend_file_state(
11364 backend, workspace_root, file_path, content_hash, status, updated_at
11365 ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
11366 params![previous_root.display().to_string()],
11367 )
11368 .unwrap();
11369
11370 let repair = reconcile_workspace_roots(&mut conn, ¤t_root, false).unwrap();
11371
11372 assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
11373 assert_eq!(
11374 stored_workspace_roots(&conn).unwrap(),
11375 vec![previous_root.display().to_string()]
11376 );
11377 }
11378
11379 #[test]
11380 fn sqlite_readonly_uri_percent_encodes_windows_paths() {
11381 assert_eq!(
11382 sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
11383 "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
11384 );
11385 }
11386
11387 #[test]
11388 fn legacy_migration_completion_log_has_operator_fields() {
11389 assert_eq!(
11390 legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
11391 "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
11392 );
11393 }
11394
11395 fn write_generation_with_age(
11396 dir: &Path,
11397 project_key: &str,
11398 ordinal: u64,
11399 age: Duration,
11400 ) -> String {
11401 let generation = format!("{project_key}.g{ordinal}.1.sqlite");
11402 let path = dir.join(&generation);
11403 fs::write(&path, b"sqlite placeholder").unwrap();
11404 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
11405 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
11406 generation
11407 }
11408
11409 #[test]
11410 fn gc_old_generations_preserves_live_reader_until_marker_drops() {
11411 let dir = tempfile::tempdir().unwrap();
11412 let project_key = "project";
11413 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
11414 let previous =
11415 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
11416 let pinned =
11417 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
11418 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
11419
11420 gc_old_generations(dir.path(), project_key, ¤t);
11421
11422 assert!(dir.path().join(&previous).is_file());
11423 assert!(dir.path().join(&pinned).is_file());
11424
11425 drop(marker);
11426 gc_old_generations(dir.path(), project_key, ¤t);
11427
11428 assert!(dir.path().join(&previous).is_file());
11429 assert!(!dir.path().join(&pinned).exists());
11430 }
11431
11432 #[test]
11433 fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
11434 let dir = tempfile::tempdir().unwrap();
11435 let project_key = "project";
11436 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
11437 let _previous =
11438 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
11439 let pinned =
11440 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
11441 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
11442 filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
11443
11444 gc_old_generations(dir.path(), project_key, ¤t);
11445
11446 assert!(dir.path().join(&pinned).is_file());
11447 }
11448
11449 #[test]
11450 fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
11451 let dir = tempfile::tempdir().unwrap();
11452 let project_key = "project";
11453 let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
11454 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
11455 let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
11456 let old = write_generation_with_age(
11457 dir.path(),
11458 project_key,
11459 200,
11460 expired + Duration::from_secs(60),
11461 );
11462 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
11463
11464 gc_old_generations(dir.path(), project_key, ¤t);
11465
11466 assert!(dir.path().join(¤t).is_file());
11467 assert!(dir.path().join(&previous).is_file());
11468 assert!(!dir.path().join(&old).exists());
11469 }
11470
11471 fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
11472 let path = dir.join(name);
11473 fs::write(&path, b"temp placeholder").unwrap();
11474 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
11475 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
11476 path
11477 }
11478
11479 #[test]
11480 fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
11481 let dir = tempdir().unwrap();
11482 let aged = "project.g100.1.sqlite.tmp.1.200";
11486 let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
11487 let fresh = "project.g300.1.sqlite.tmp.1.400";
11488 let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
11489 write_build_temp_with_age(dir.path(), aged, aged_age);
11490 write_build_temp_with_age(dir.path(), aged_journal, aged_age);
11491 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
11492
11493 sweep_orphaned_build_temps(dir.path());
11494
11495 assert!(
11496 !dir.path().join(aged).exists(),
11497 "aged orphan must be removed"
11498 );
11499 assert!(
11500 !dir.path().join(aged_journal).exists(),
11501 "aged journal sidecar must be removed"
11502 );
11503 assert!(
11504 dir.path().join(fresh).is_file(),
11505 "fresh temporary must survive"
11506 );
11507 }
11508
11509 #[test]
11510 fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
11511 let storage = tempdir().unwrap();
11512 let storage_root = storage.path();
11513 let legacy_dir = storage_root.join("opencode").join("callgraph");
11519 fs::create_dir_all(&legacy_dir).unwrap();
11520 let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
11521 write_build_temp_with_age(
11522 &legacy_dir,
11523 orphan,
11524 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
11525 );
11526 assert!(
11527 !legacy_dir.join("deadbeef.current").exists(),
11528 "the dead root has no current pointer"
11529 );
11530
11531 let root_keyed_dir = storage_root.join("callgraph").join("livekey");
11532 fs::create_dir_all(&root_keyed_dir).unwrap();
11533
11534 sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
11535
11536 assert!(
11537 !legacy_dir.join(orphan).exists(),
11538 "legacy orphan must be reclaimed by the store-wide sweep"
11539 );
11540 }
11541
11542 #[test]
11543 fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
11544 let dir = tempdir().unwrap();
11550 let fresh = "project.g300.1.sqlite.tmp.1.400";
11551 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
11552
11553 sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
11554
11555 assert!(
11556 !dir.path().join(fresh).exists(),
11557 "with the age predicate forced open, the fresh temporary is removed"
11558 );
11559 }
11560
11561 #[test]
11562 fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
11563 let dir = tempdir().unwrap();
11564 let generation = write_generation_with_age(
11568 dir.path(),
11569 "project",
11570 400,
11571 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
11572 );
11573 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
11574
11575 sweep_orphaned_build_temps(dir.path());
11576
11577 assert!(
11578 dir.path().join(&generation).is_file(),
11579 "completed generation must survive the orphan sweep"
11580 );
11581 assert!(
11582 crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
11583 "read marker must survive the orphan sweep"
11584 );
11585 }
11586
11587 #[test]
11588 fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
11589 let dir = tempfile::tempdir().unwrap();
11590 let project_key = "project".to_string();
11591 let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
11592 let sqlite_path = dir.path().join(&generation);
11593 fs::remove_file(&sqlite_path).unwrap();
11594 let conn = Connection::open(&sqlite_path).unwrap();
11595 let store = CallGraphStore::from_connection(
11596 dir.path().to_path_buf(),
11597 project_key,
11598 sqlite_path,
11599 dir.path().to_path_buf(),
11600 false,
11601 Some(generation.clone()),
11602 None,
11603 None,
11604 conn,
11605 );
11606
11607 let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
11608 assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
11609
11610 drop(marker);
11611 assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
11612 }
11613
11614 #[test]
11615 fn readiness_cache_only_skips_checks_after_a_successful_validation() {
11616 let dir = tempdir().expect("temp dir");
11617 let file = dir.path().join("main.ts");
11618 fs::write(&file, "export function main() {}\n").expect("write fixture");
11619 let store = CallGraphStore::open(
11620 dir.path().join(".store-readiness-cache"),
11621 dir.path().to_path_buf(),
11622 )
11623 .expect("open store");
11624 {
11625 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
11626 conn.trace(Some(count_caller_traversal_selects));
11627 }
11628
11629 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
11630 assert!(store.indexed_file_count().is_err());
11631 assert!(store.indexed_file_count().is_err());
11632 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
11633
11634 store
11635 .cold_build(std::slice::from_ref(&file))
11636 .expect("cold build");
11637 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
11638 assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
11639 assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
11640 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
11641
11642 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
11643 conn.trace(None);
11644 }
11645
11646 #[test]
11647 fn callers_depth_boundary_batches_sqlite_counts() {
11648 const CALLER_COUNT: usize = 1_000;
11649
11650 let dir = tempdir().expect("temp dir");
11651 let file = dir.path().join("main.ts");
11652 let mut source = String::from("export function sharedHotHelper() {}\n");
11653 for index in 0..CALLER_COUNT {
11654 source.push_str(&format!(
11655 "export function caller{index}() {{ sharedHotHelper(); }}\n"
11656 ));
11657 }
11658 fs::write(&file, source).expect("write fixture");
11659
11660 let store = CallGraphStore::open(
11661 dir.path().join(".store-callers-query-fanout"),
11662 dir.path().to_path_buf(),
11663 )
11664 .expect("open store");
11665 store
11666 .cold_build(std::slice::from_ref(&file))
11667 .expect("cold build");
11668
11669 CALLER_QUERY_SELECTS.with(|count| count.set(0));
11670 BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
11671 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
11672 {
11673 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
11674 conn.trace(Some(count_caller_traversal_selects));
11675 }
11676
11677 let started = Instant::now();
11678 let result = crate::commands::callgraph_store_adapter::callers_result(
11679 &store,
11680 Path::new("main.ts"),
11681 "sharedHotHelper",
11682 1,
11683 true,
11684 )
11685 .expect("callers result");
11686 let elapsed = started.elapsed();
11687
11688 {
11689 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
11690 conn.trace(None);
11691 }
11692 let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
11693 let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
11694 let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
11695 eprintln!(
11696 "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
11697 result.total_callers,
11698 caller_queries,
11699 boundary_queries,
11700 total_selects,
11701 elapsed.as_secs_f64() * 1_000.0
11702 );
11703
11704 assert_eq!(result.total_callers, CALLER_COUNT);
11705 assert_eq!(caller_queries, 1);
11706 assert_eq!(boundary_queries, 3);
11707 assert_eq!(total_selects, 9);
11708 }
11709
11710 #[test]
11711 fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
11712 let dir = tempdir().expect("temp dir");
11713 let file = dir.path().join("main.ts");
11714 fs::write(
11715 &file,
11716 r#"export function topA() {
11717 root();
11718}
11719
11720export function topB() {
11721 root();
11722}
11723
11724export function root() {
11725 leaf();
11726 missing();
11727}
11728
11729export function leaf() {}
11730"#,
11731 )
11732 .expect("write fixture");
11733
11734 let store = CallGraphStore::open(
11735 dir.path().join(".store-depth-boundary-counts"),
11736 dir.path().to_path_buf(),
11737 )
11738 .expect("open store");
11739 store
11740 .cold_build(std::slice::from_ref(&file))
11741 .expect("cold build");
11742
11743 let root = store
11744 .node_for(Path::new("main.ts"), "root")
11745 .expect("root node");
11746 let leaf = store
11747 .node_for(Path::new("main.ts"), "leaf")
11748 .expect("leaf node");
11749
11750 let (full_forward_len, full_direct_len) = {
11751 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
11752 conn.execute(
11753 "INSERT INTO edges (
11754 edge_id, ref_id, source_node, target_node, target_file,
11755 target_symbol, kind, line, provenance
11756 ) VALUES (
11757 'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
11758 ?2, ?3, 'call', 98, ?4
11759 )",
11760 rusqlite::params![
11761 &root.node_id,
11762 &leaf.file,
11763 &leaf.symbol,
11764 PROVENANCE_TREESITTER
11765 ],
11766 )
11767 .expect("insert dangling forward edge");
11768 conn.execute(
11769 "INSERT INTO edges (
11770 edge_id, ref_id, source_node, target_node, target_file,
11771 target_symbol, kind, line, provenance
11772 ) VALUES (
11773 'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
11774 ?1, ?2, ?3, 'call', 99, ?4
11775 )",
11776 rusqlite::params![
11777 &root.node_id,
11778 &root.file,
11779 &root.symbol,
11780 PROVENANCE_TREESITTER
11781 ],
11782 )
11783 .expect("insert dangling direct-caller edge");
11784
11785 let full_forward_len = forward_calls_for_node(&conn, &root)
11786 .expect("full forward calls")
11787 .len();
11788 let counted_forward_len =
11789 forward_call_count_for_node(&conn, &root).expect("counted forward calls");
11790 assert_eq!(
11791 counted_forward_len, full_forward_len,
11792 "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
11793 );
11794
11795 let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
11796 .expect("full direct callers");
11797 let full_direct_len = full_direct.len();
11798 let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
11799 .expect("counted direct callers");
11800 assert_eq!(
11801 counted_direct_len, full_direct_len,
11802 "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
11803 );
11804
11805 let distinct_direct_len = full_direct
11806 .iter()
11807 .map(|site| {
11808 (
11809 site.caller.file.clone(),
11810 site.line,
11811 site.target_file.clone(),
11812 site.target_symbol.clone(),
11813 )
11814 })
11815 .collect::<BTreeSet<_>>()
11816 .len();
11817 let batch_counts = direct_caller_counts_for_tuples(
11818 &conn,
11819 &[
11820 (root.file.clone(), root.symbol.clone()),
11821 (root.file.clone(), root.symbol.clone()),
11822 (leaf.file.clone(), leaf.symbol.clone()),
11823 ],
11824 )
11825 .expect("batched direct-caller counts");
11826 assert_eq!(batch_counts.len(), 2);
11827 assert_eq!(
11828 batch_counts.get(&(root.file.clone(), root.symbol.clone())),
11829 Some(&distinct_direct_len)
11830 );
11831
11832 (full_forward_len, full_direct_len)
11833 };
11834
11835 assert_eq!(
11836 full_forward_len, 2,
11837 "fixture root should have one resolved and one unresolved outgoing call"
11838 );
11839 assert_eq!(
11840 full_direct_len, 2,
11841 "fixture root should have two real direct callers"
11842 );
11843
11844 let tree = store
11845 .call_tree(Path::new("main.ts"), "root", 0)
11846 .expect("call tree");
11847 assert!(tree.depth_limited);
11848 assert_eq!(tree.children.len(), 0);
11849 assert_eq!(
11850 tree.truncated, full_forward_len,
11851 "call_tree depth boundary must report the full forward-call list length"
11852 );
11853
11854 let callers = store
11855 .callers_of(Path::new("main.ts"), "leaf", 0)
11856 .expect("callers");
11857 assert!(callers.depth_limited);
11858 assert_eq!(callers.callers.len(), 1);
11859 assert_eq!(callers.callers[0].caller.symbol, "root");
11860 assert_eq!(
11861 callers.truncated, full_direct_len,
11862 "callers depth boundary must report the full direct-caller list length"
11863 );
11864 }
11865
11866 #[test]
11867 fn source_freshness_matches_cache_collect_for_same_bytes() {
11868 let dir = tempdir().expect("temp dir");
11869 let path = dir.path().join("fixture.ts");
11870 let source = "export function main() { return helper(); }\n";
11871 fs::write(&path, source).expect("write fixture");
11872
11873 let expected = cache_freshness::collect(&path).expect("collect freshness from file");
11874 let actual =
11875 collect_source_freshness(&path, source).expect("collect freshness from source");
11876
11877 assert_eq!(actual, expected);
11878 }
11879
11880 #[test]
11881 fn superseded_cold_build_cannot_publish_after_newer_epoch() {
11882 let root = tempfile::tempdir().unwrap();
11883 let callgraph_dir = tempfile::tempdir().unwrap();
11884 let source_dir = root.path().join("src");
11885 std::fs::create_dir_all(&source_dir).unwrap();
11886 let source = source_dir.join("lib.rs");
11887 std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
11888 let files = vec![source.clone()];
11889 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
11890 let old_epoch = epoch.next();
11891 let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
11892 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
11893 let old_epoch_flag = epoch.clone();
11894 let old_dir = callgraph_dir.path().to_path_buf();
11895 let old_root = root.path().to_path_buf();
11896 let old_files = files.clone();
11897 let old = std::thread::spawn(move || {
11898 set_cold_build_before_publish_observer(Some(Arc::new(move || {
11899 reached_tx.send(()).unwrap();
11900 release_rx.recv().unwrap();
11901 })));
11902 let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
11903 CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
11904 });
11905 set_cold_build_before_publish_observer(None);
11906 result
11907 });
11908 reached_rx
11912 .recv_timeout(Duration::from_secs(30))
11913 .expect("older build did not reach its publication barrier");
11914
11915 std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
11916 let new_epoch = epoch.next();
11917 let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
11918 CallGraphStore::cold_build_with_lease(
11919 callgraph_dir.path().to_path_buf(),
11920 root.path().to_path_buf(),
11921 &files,
11922 )
11923 })
11924 .expect("newer build should publish");
11925 drop(new_store);
11926
11927 release_tx.send(()).unwrap();
11928 assert!(matches!(
11929 old.join().unwrap(),
11930 Err(CallGraphStoreError::Superseded)
11931 ));
11932
11933 let current = CallGraphStore::open_readonly(
11934 callgraph_dir.path().to_path_buf(),
11935 root.path().to_path_buf(),
11936 )
11937 .unwrap()
11938 .expect("current callgraph generation");
11939 assert_eq!(
11940 current
11941 .nodes_matching("new_generation_marker")
11942 .unwrap()
11943 .len(),
11944 1
11945 );
11946 assert!(current
11947 .nodes_matching("old_generation_marker")
11948 .unwrap()
11949 .is_empty());
11950 }
11951
11952 #[test]
11953 fn cold_build_prepared_bulk_insert_matches_reference_rows() {
11954 let dir = tempdir().expect("temp dir");
11955 let project_root = dir.path();
11956 let extract = fixture_extract(project_root);
11957 let resolved = fixture_resolved(&extract);
11958
11959 let reference = build_reference_connection(project_root, &extract, &resolved);
11960 let optimized = build_optimized_connection(project_root, &extract, &resolved);
11961
11962 for table in [
11963 "files",
11964 "nodes",
11965 "file_dependencies",
11966 "dispatch_hints",
11967 "refs",
11968 "edges",
11969 ] {
11970 let excluded: &[&str] = if table == "files" {
11977 &["indexed_at"]
11978 } else {
11979 &[]
11980 };
11981 assert_eq!(
11982 table_rows_without(&reference, table, excluded),
11983 table_rows_without(&optimized, table, excluded),
11984 "table `{table}` rows must match apart from wall-clock columns"
11985 );
11986 }
11987 assert_eq!(
11988 backend_state_rows(&reference),
11989 backend_state_rows(&optimized),
11990 "backend freshness rows must match apart from updated_at"
11991 );
11992 assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
11993 }
11994
11995 #[test]
11996 fn cold_build_chunked_matches_unchunked_logical_rows() {
11997 let dir = tempdir().expect("temp dir");
11998 let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
11999 write_chunked_equivalence_fixture(&project_root);
12000 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
12001 assert!(
12002 files.len() > 6,
12003 "fixture should be large enough to split into multiple chunks"
12004 );
12005
12006 let unchunked = CallGraphStore::open(
12007 project_root.join(".store-unchunked"),
12008 project_root.to_path_buf(),
12009 )
12010 .expect("open unchunked store");
12011 let unchunked_stats = unchunked
12012 .cold_build_chunked(&files, 0)
12013 .expect("unchunked cold build");
12014
12015 let chunked = CallGraphStore::open(
12016 project_root.join(".store-chunked"),
12017 project_root.to_path_buf(),
12018 )
12019 .expect("open chunked store");
12020 let chunked_stats = chunked
12021 .cold_build_chunked(&files, 3)
12022 .expect("chunked cold build");
12023
12024 assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
12025 assert_eq!(
12026 unchunked.edge_snapshot().expect("unchunked edge snapshot"),
12027 chunked.edge_snapshot().expect("chunked edge snapshot"),
12028 "public edge snapshots must match"
12029 );
12030
12031 let dispatch_edges = {
12032 let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
12033 conn.query_row(
12034 "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
12035 [],
12036 |row| row.get::<_, i64>(0),
12037 )
12038 .expect("count dispatch edges")
12039 };
12040 assert!(
12041 dispatch_edges > 0,
12042 "fixture must exercise method-dispatch edge insertion"
12043 );
12044
12045 for table in [
12046 "edges",
12047 "refs",
12048 "nodes",
12049 "file_dependencies",
12050 "dispatch_hints",
12051 ] {
12052 assert_eq!(
12053 graph_table_rows(&unchunked, table),
12054 graph_table_rows(&chunked, table),
12055 "chunked cold build must match unchunked rows for {table}"
12056 );
12057 }
12058 assert_eq!(
12059 graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
12060 graph_table_rows_without(&chunked, "files", &["indexed_at"]),
12061 "files rows must match apart from indexed_at"
12062 );
12063 assert_eq!(
12064 graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
12065 graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
12066 "backend freshness rows must match apart from updated_at"
12067 );
12068
12069 let published_dir = project_root.join(".store-published");
12070 let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
12071 published_dir.clone(),
12072 project_root.to_path_buf(),
12073 &files,
12074 0,
12075 )
12076 .expect("published unchunked cold build");
12077 assert!(
12078 !CallGraphStore::needs_cold_build(&published_dir, &project_root)
12079 .expect("needs_cold_build after publish"),
12080 "published store should be ready"
12081 );
12082 drop(_published);
12083 let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
12084 published_dir,
12085 project_root.to_path_buf(),
12086 &files,
12087 3,
12088 )
12089 .expect("ensure with a different chunk size");
12090 assert!(
12091 rebuild_stats.is_none(),
12092 "changing callgraph_chunk_size must not affect store identity or force a rebuild"
12093 );
12094 }
12095
12096 #[test]
12103 #[ignore]
12104 fn bench_cold_build_chunk() {
12105 let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
12106 let chunk: usize = std::env::var("AFT_PERF_CHUNK")
12107 .expect("AFT_PERF_CHUNK")
12108 .parse()
12109 .expect("AFT_PERF_CHUNK must be a non-negative integer");
12110 let project_root = fs::canonicalize(&repo).expect("canonical repo root");
12111 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
12112 let dir = tempdir().expect("temp dir");
12113 let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
12114 .expect("open store");
12115 let started = Instant::now();
12116 let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
12117 let ms = started.elapsed().as_millis();
12118 println!(
12119 "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
12120 stats.files, stats.nodes, stats.refs, stats.edges
12121 );
12122 }
12123
12124 #[test]
12125 fn persisted_workspace_reexport_selects_its_package_dependency() {
12126 let root = tempdir().expect("temp dir");
12127 let dependencies = BTreeSet::from([
12128 "packages/aft-bridge/src/index.ts".to_string(),
12129 "packages/opencode-plugin/src/types.ts".to_string(),
12130 ]);
12131 let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
12132
12133 assert_eq!(
12134 stored_dependencies_for_module(
12135 root.path(),
12136 "packages/opencode-plugin/src/shared/bash-hints.ts",
12137 "@cortexkit/aft-bridge",
12138 &dependencies,
12139 &indexed_files,
12140 ),
12141 BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
12142 );
12143 }
12144
12145 #[test]
12146 fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
12147 let dir = tempdir().expect("temp dir");
12148 let project_root = dir.path();
12149 let files =
12150 write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
12151 let index_path = project_root.join("src/index.ts");
12152
12153 let store = CallGraphStore::open(
12154 project_root.join(".store-incremental-barrel"),
12155 project_root.to_path_buf(),
12156 )
12157 .expect("open incremental store");
12158 store.cold_build(&files).expect("initial cold build");
12159
12160 {
12161 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
12162 let tx = conn.transaction().expect("dependency transaction");
12163 let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
12164 .expect("dependent refs for barrel");
12165 let selected_ref_ids = dependent_refs
12166 .iter()
12167 .map(|dependent_ref| dependent_ref.ref_id.clone())
12168 .collect::<BTreeSet<_>>();
12169 let mut threaded_ref_ids = BTreeSet::new();
12170 let mut threaded_by_caller = BTreeMap::new();
12171 record_dependent_refs(
12172 &mut threaded_ref_ids,
12173 &mut threaded_by_caller,
12174 dependent_refs,
12175 );
12176 let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
12177 .expect("old per-ref caller lookup");
12178
12179 assert_eq!(threaded_ref_ids, selected_ref_ids);
12180 assert_eq!(threaded_by_caller, old_by_caller);
12181 for consumer in [
12182 "src/consumer_a.ts",
12183 "src/consumer_b.ts",
12184 "src/consumer_c.ts",
12185 ] {
12186 assert!(
12187 threaded_by_caller.contains_key(consumer),
12188 "barrel edit should select dependent refs from {consumer}"
12189 );
12190 }
12191 }
12192
12193 fs::write(
12194 &index_path,
12195 "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
12196 )
12197 .expect("edit barrel");
12198 let stats = store
12199 .refresh_files(std::slice::from_ref(&index_path))
12200 .expect("incremental refresh");
12201 assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
12202 assert!(
12203 stats.dependency_selected_refs > 0,
12204 "barrel surface edit should select dependent refs"
12205 );
12206
12207 let cold_store = CallGraphStore::open(
12208 project_root.join(".store-cold-barrel"),
12209 project_root.to_path_buf(),
12210 )
12211 .expect("open cold rebuild store");
12212 cold_store
12213 .cold_build(&files)
12214 .expect("comparison cold build");
12215
12216 for table in [
12217 "nodes",
12218 "refs",
12219 "file_dependencies",
12220 "edges",
12221 "dispatch_hints",
12222 ] {
12223 assert_eq!(
12224 graph_table_rows(&store, table),
12225 graph_table_rows(&cold_store, table),
12226 "incremental refresh {table} rows must match cold rebuild"
12227 );
12228 }
12229
12230 let consumer_path = project_root.join("src/consumer_a.ts");
12231 fs::write(
12232 &consumer_path,
12233 "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
12234 )
12235 .expect("edit barrel consumer");
12236 store
12237 .refresh_files(std::slice::from_ref(&consumer_path))
12238 .expect("refresh consumer through unchanged barrel");
12239 cold_store
12240 .cold_build(&files)
12241 .expect("comparison cold rebuild after consumer refresh");
12242 for table in [
12243 "nodes",
12244 "refs",
12245 "file_dependencies",
12246 "edges",
12247 "dispatch_hints",
12248 ] {
12249 assert_eq!(
12250 graph_table_rows(&store, table),
12251 graph_table_rows(&cold_store, table),
12252 "refresh through a persisted barrel must preserve cold-build {table} rows"
12253 );
12254 }
12255 }
12256
12257 fn build_reference_connection(
12258 project_root: &Path,
12259 extract: &FileExtract,
12260 resolved: &ResolvedRef,
12261 ) -> Connection {
12262 let mut conn = Connection::open_in_memory().expect("open reference db");
12263 configure_build_connection(&conn).expect("configure reference db");
12264 initialize_schema(&conn).expect("initialize reference schema");
12265 {
12266 let tx = conn.transaction().expect("reference transaction");
12267 clear_tables(&tx).expect("reference clear");
12268 insert_meta(&tx).expect("reference meta");
12269 insert_file_extract(&tx, project_root, extract).expect("reference file extract");
12270 insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
12271 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
12272 .expect("reference dispatch edges");
12273 assert_eq!(supplemental, 0);
12274 tx.commit().expect("reference commit");
12275 }
12276 conn
12277 }
12278
12279 fn build_optimized_connection(
12280 project_root: &Path,
12281 extract: &FileExtract,
12282 resolved: &ResolvedRef,
12283 ) -> Connection {
12284 let mut conn = Connection::open_in_memory().expect("open optimized db");
12285 configure_build_connection(&conn).expect("configure optimized db");
12286 initialize_schema(&conn).expect("initialize optimized schema");
12287 {
12288 let tx = conn.transaction().expect("optimized transaction");
12289 clear_tables(&tx).expect("optimized clear");
12290 insert_meta(&tx).expect("optimized meta");
12291 drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
12292 {
12293 let workspace_root = project_root.display().to_string();
12294 let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
12295 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
12296 .expect("optimized file extract");
12297 insert_resolved_ref_prepared(&mut inserts, resolved)
12298 .expect("optimized resolved ref");
12299 }
12300 create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
12301 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
12302 .expect("optimized dispatch edges");
12303 assert_eq!(supplemental, 0);
12304 tx.commit().expect("optimized commit");
12305 }
12306 conn
12307 }
12308
12309 fn fixture_extract(_project_root: &Path) -> FileExtract {
12310 let rel_path = "src/main.ts".to_string();
12311 let target_path = "src/helper.ts".to_string();
12312 let node = NodeRecord {
12313 id: "node-main".to_string(),
12314 file_path: rel_path.clone(),
12315 name: "main".to_string(),
12316 scoped_name: "main".to_string(),
12317 kind: "function".to_string(),
12318 range: Range {
12319 start_line: 0,
12320 start_col: 0,
12321 end_line: 0,
12322 end_col: 32,
12323 },
12324 range_ordinal: 0,
12325 signature: Some("export function main()".to_string()),
12326 exported: true,
12327 is_default_export: false,
12328 is_type_like: false,
12329 is_callgraph_entry_point: true,
12330 };
12331 let mut dependencies = BTreeSet::new();
12332 dependencies.insert(target_path.clone());
12333 let raw_ref = RawRef {
12334 ref_id: "ref-main-helper".to_string(),
12335 caller_node: Some(node.id.clone()),
12336 caller_symbol: Some(node.scoped_name.clone()),
12337 caller_file: rel_path.clone(),
12338 kind: "call".to_string(),
12339 short_name: Some("helper".to_string()),
12340 full_ref: Some("helper".to_string()),
12341 module_path: None,
12342 import_kind: None,
12343 local_name: Some("helper".to_string()),
12344 requested_name: Some("helper".to_string()),
12345 namespace_alias: None,
12346 wildcard: false,
12347 line: 1,
12348 byte_start: 24,
12349 byte_end: 32,
12350 dependencies,
12351 };
12352 FileExtract {
12353 rel_path,
12354 freshness: FileFreshness {
12355 mtime: UNIX_EPOCH + Duration::from_secs(123),
12356 size: 40,
12357 content_hash: cache_freshness::hash_bytes(b"fixture source"),
12358 },
12359 lang: LangId::TypeScript,
12360 data: FileCallData {
12361 calls_by_symbol: HashMap::new(),
12362 exported_symbols: Vec::new(),
12363 symbol_metadata: HashMap::new(),
12364 default_export_symbol: None,
12365 import_block: ImportBlock::empty(),
12366 lang: LangId::TypeScript,
12367 },
12368 nodes: vec![node.clone()],
12369 raw_refs: vec![raw_ref],
12370 dispatch_hints: vec![DispatchHint {
12371 id: "dispatch-main-helper".to_string(),
12372 method_name: "helper".to_string(),
12373 caller_node: node.id,
12374 file: "src/main.ts".to_string(),
12375 line: 1,
12376 byte_start: 24,
12377 byte_end: 32,
12378 }],
12379 surface_fingerprint: "surface".to_string(),
12380 }
12381 }
12382
12383 fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
12384 let raw = extract.raw_refs[0].clone();
12385 let mut dependencies = raw.dependencies.clone();
12386 dependencies.insert("src/helper.ts".to_string());
12387 ResolvedRef {
12388 edge: Some(EdgeRecord {
12389 edge_id: "edge-main-helper".to_string(),
12390 source_node: raw.caller_node.clone().expect("caller node"),
12391 target_node: Some("node-helper".to_string()),
12392 target_file: "src/helper.ts".to_string(),
12393 target_symbol: "helper".to_string(),
12394 kind: "call".to_string(),
12395 line: raw.line,
12396 }),
12397 raw,
12398 status: "resolved".to_string(),
12399 target_node: Some("node-helper".to_string()),
12400 target_file: Some("src/helper.ts".to_string()),
12401 target_symbol: Some("helper".to_string()),
12402 dependencies,
12403 }
12404 }
12405
12406 fn write_chunked_equivalence_fixture(project_root: &Path) {
12407 let ts_dir = project_root.join("ts");
12408 fs::create_dir_all(&ts_dir).expect("create ts dir");
12409 fs::write(
12410 ts_dir.join("leaf.ts"),
12411 "export function leaf(value: number) {\n return value + 1;\n}\n",
12412 )
12413 .expect("write ts leaf");
12414 fs::write(
12415 ts_dir.join("mid.ts"),
12416 "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n return leaf(value);\n}\n",
12417 )
12418 .expect("write ts mid");
12419 fs::write(
12420 ts_dir.join("entry.ts"),
12421 "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n return mid(worker.run());\n}\n",
12422 )
12423 .expect("write ts entry");
12424 fs::write(
12425 ts_dir.join("worker.ts"),
12426 "export class Worker {\n run() {\n return 41;\n }\n}\n",
12427 )
12428 .expect("write ts worker");
12429 for idx in 0..4 {
12430 fs::write(
12431 ts_dir.join(format!("extra_{idx}.ts")),
12432 format!(
12433 "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n return entry(new Worker());\n}}\n"
12434 ),
12435 )
12436 .expect("write ts extra");
12437 }
12438
12439 let rust_dir = project_root.join("src");
12440 let commands_dir = rust_dir.join("commands");
12441 fs::create_dir_all(&commands_dir).expect("create rust commands dir");
12442 fs::write(
12443 rust_dir.join("context.rs"),
12444 r#"pub struct AppContext;
12445
12446impl AppContext {
12447 pub fn callgraph_store_for_ops(&self) -> usize {
12448 1
12449 }
12450}
12451"#,
12452 )
12453 .expect("write rust context");
12454 fs::write(
12455 rust_dir.join("lib.rs"),
12456 "pub mod context;\npub mod commands;\n",
12457 )
12458 .expect("write rust lib");
12459 fs::write(
12460 commands_dir.join("mod.rs"),
12461 "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
12462 )
12463 .expect("write rust commands mod");
12464 for name in ["callers", "impact", "trace_to"] {
12465 fs::write(
12466 commands_dir.join(format!("{name}.rs")),
12467 format!(
12468 r#"use crate::context::AppContext;
12469
12470pub fn handle_{name}(ctx: &AppContext) -> usize {{
12471 ctx.callgraph_store_for_ops()
12472}}
12473"#
12474 ),
12475 )
12476 .expect("write rust command");
12477 }
12478 }
12479
12480 fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
12481 let src_dir = project_root.join("src");
12482 fs::create_dir_all(&src_dir).expect("create src dir");
12483
12484 let target_path = src_dir.join("target.ts");
12485 fs::write(&target_path, "export function target() {\n return 1;\n}\n")
12486 .expect("write target");
12487
12488 let index_path = src_dir.join("index.ts");
12489 fs::write(&index_path, barrel_source).expect("write barrel");
12490
12491 let mut files = vec![target_path, index_path];
12492 for (file_name, function_name) in [
12493 ("consumer_a.ts", "consumerA"),
12494 ("consumer_b.ts", "consumerB"),
12495 ("consumer_c.ts", "consumerC"),
12496 ] {
12497 let path = src_dir.join(file_name);
12498 fs::write(
12499 &path,
12500 format!(
12501 "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n return target();\n}}\n"
12502 ),
12503 )
12504 .expect("write consumer");
12505 files.push(path);
12506 }
12507 files
12508 }
12509
12510 fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
12511 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
12512 table_rows(&conn, table)
12513 }
12514
12515 fn graph_table_rows_without(
12516 store: &CallGraphStore,
12517 table: &str,
12518 excluded_columns: &[&str],
12519 ) -> Vec<String> {
12520 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
12521 table_rows_without(&conn, table, excluded_columns)
12522 }
12523
12524 fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
12525 table_rows_without(conn, table, &[])
12526 }
12527
12528 fn table_rows_without(
12529 conn: &Connection,
12530 table: &str,
12531 excluded_columns: &[&str],
12532 ) -> Vec<String> {
12533 let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
12534 let columns: Vec<String> = conn
12535 .prepare(&format!("PRAGMA table_info({table})"))
12536 .expect("prepare table_info")
12537 .query_map([], |row| row.get::<_, String>(1))
12538 .expect("query table_info")
12539 .collect::<std::result::Result<Vec<String>, _>>()
12540 .expect("collect columns")
12541 .into_iter()
12542 .filter(|column| !excluded_columns.contains(column.as_str()))
12543 .collect();
12544 let sql = format!(
12545 "SELECT {} FROM {table} ORDER BY {}",
12546 columns.join(", "),
12547 columns.join(", ")
12548 );
12549 conn.prepare(&sql)
12550 .expect("prepare table rows")
12551 .query_map([], |row| row_to_strings(row, columns.len()))
12552 .expect("query table rows")
12553 .collect::<std::result::Result<_, _>>()
12554 .expect("collect table rows")
12555 }
12556
12557 fn assert_cold_build_stats_match_except_elapsed(
12558 expected: &ColdBuildStats,
12559 actual: &ColdBuildStats,
12560 ) {
12561 assert_eq!(actual.files, expected.files, "file counts must match");
12562 assert_eq!(actual.nodes, expected.nodes, "node counts must match");
12563 assert_eq!(actual.refs, expected.refs, "ref counts must match");
12564 assert_eq!(actual.edges, expected.edges, "edge counts must match");
12565 assert_eq!(
12566 actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
12567 expected
12568 .failed_files
12569 .iter()
12570 .cloned()
12571 .collect::<BTreeSet<_>>(),
12572 "failed file sets must match"
12573 );
12574 }
12575
12576 fn backend_state_rows(conn: &Connection) -> Vec<String> {
12577 conn.prepare(
12578 "SELECT backend, workspace_root, file_path, content_hash, status
12579 FROM backend_file_state
12580 ORDER BY backend, workspace_root, file_path, content_hash, status",
12581 )
12582 .expect("prepare backend rows")
12583 .query_map([], |row| row_to_strings(row, 5))
12584 .expect("query backend rows")
12585 .collect::<std::result::Result<_, _>>()
12586 .expect("collect backend rows")
12587 }
12588
12589 fn secondary_indexes(conn: &Connection) -> Vec<String> {
12590 let mut indexes = Vec::new();
12591 for table in [
12592 "files",
12593 "nodes",
12594 "refs",
12595 "file_dependencies",
12596 "edges",
12597 "dispatch_hints",
12598 "type_ref_names",
12599 "backend_file_state",
12600 "meta",
12601 ] {
12602 let sql = format!("PRAGMA index_list({table})");
12603 let mut stmt = conn.prepare(&sql).expect("prepare index list");
12604 let rows = stmt
12605 .query_map([], |row| row.get::<_, String>(1))
12606 .expect("query index list");
12607 for name in rows {
12608 let name = name.expect("index name");
12609 if name.starts_with("idx_") {
12610 indexes.push(format!("{table}:{name}"));
12611 }
12612 }
12613 }
12614 indexes.sort();
12615 indexes
12616 }
12617
12618 fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
12619 let mut values = Vec::with_capacity(len);
12620 for index in 0..len {
12621 let value = row.get_ref(index)?;
12622 values.push(match value {
12623 rusqlite::types::ValueRef::Null => "NULL".to_string(),
12624 rusqlite::types::ValueRef::Integer(value) => value.to_string(),
12625 rusqlite::types::ValueRef::Real(value) => value.to_string(),
12626 rusqlite::types::ValueRef::Text(value) => {
12627 String::from_utf8_lossy(value).into_owned()
12628 }
12629 rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
12630 });
12631 }
12632 Ok(values.join("\u{1f}"))
12633 }
12634}
12635
12636#[cfg(test)]
12637mod rust_resolution_tests {
12638 use super::*;
12639 use crate::inspect::job::CallgraphSnapshot;
12640 use std::fs;
12641 use tempfile::tempdir;
12642
12643 #[test]
12644 fn rust_function_scoped_module_alias_resolves_and_projects_live() {
12645 let dir = tempdir().expect("tempdir");
12646 let root = dir.path();
12647 write_rust_manifest(root, "scoped-alias-fixture");
12648 write_file(
12649 root,
12650 "src/lib.rs",
12651 r#"pub mod finalization_contract;
12652
12653pub fn run_alias() {
12654 use crate::finalization_contract as fc;
12655 fc::check_mason_contract();
12656}
12657"#,
12658 );
12659 write_file(
12660 root,
12661 "src/finalization_contract.rs",
12662 r#"pub fn check_mason_contract() {}
12663fn planted_dead() {}
12664"#,
12665 );
12666
12667 let (store, snapshot) = cold_build_twice(root);
12668 assert_direct_caller(
12669 &store,
12670 "src/finalization_contract.rs",
12671 "check_mason_contract",
12672 "src/lib.rs",
12673 "run_alias",
12674 );
12675 assert_projected_call(
12676 root,
12677 &snapshot,
12678 "src/finalization_contract.rs",
12679 "check_mason_contract",
12680 );
12681 assert_no_projected_call(
12682 root,
12683 &snapshot,
12684 "src/finalization_contract.rs",
12685 "planted_dead",
12686 );
12687 assert!(
12688 store
12689 .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
12690 .expect("planted dead callers")
12691 .is_empty(),
12692 "planted-dead guard should stay without callers"
12693 );
12694 }
12695
12696 #[test]
12697 fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
12698 let dir = tempdir().expect("tempdir");
12699 let root = dir.path();
12700 write_rust_manifest(root, "inline-module-fixture");
12701 write_file(
12702 root,
12703 "src/lib.rs",
12704 r#"mod work_graph { fn operations() {} }
12705mod manifest { fn operations() {} }
12706mod audit { fn operations() {} }
12707mod dispatch { fn operations() {} }
12708mod finalization { fn operations() {} }
12709
12710pub fn run_inline_operations() {
12711 work_graph::operations();
12712 manifest::operations();
12713 audit::operations();
12714 dispatch::operations();
12715 finalization::operations();
12716}
12717
12718fn planted_dead() {}
12719"#,
12720 );
12721
12722 let (store, snapshot) = cold_build_twice(root);
12723 for module in [
12724 "work_graph",
12725 "manifest",
12726 "audit",
12727 "dispatch",
12728 "finalization",
12729 ] {
12730 assert_direct_caller(
12731 &store,
12732 "src/lib.rs",
12733 &format!("{module}::operations"),
12734 "src/lib.rs",
12735 "run_inline_operations",
12736 );
12737 }
12738 assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
12739 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
12740 }
12741
12742 #[test]
12743 fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
12744 let dir = tempdir().expect("tempdir");
12745 let root = dir.path();
12746 fs::write(
12747 root.join("Cargo.toml"),
12748 "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
12749 )
12750 .expect("write workspace manifest");
12751 write_file(
12752 root,
12753 "crates/but-action/Cargo.toml",
12754 r#"[package]
12755name = "but-action"
12756version = "0.1.0"
12757edition = "2021"
12758"#,
12759 );
12760 write_file(
12761 root,
12762 "crates/but-action/src/lib.rs",
12763 "mod action;\npub use action::{list_actions};\n",
12764 );
12765 write_file(
12766 root,
12767 "crates/but-action/src/action.rs",
12768 "pub fn list_actions() {}\nfn planted_dead() {}\n",
12769 );
12770 write_file(
12771 root,
12772 "crates/app/Cargo.toml",
12773 r#"[package]
12774name = "app"
12775version = "0.1.0"
12776edition = "2021"
12777"#,
12778 );
12779 write_file(
12780 root,
12781 "crates/app/src/lib.rs",
12782 "pub fn run_actions() {\n but_action::list_actions();\n}\n",
12783 );
12784
12785 let (store, snapshot) = cold_build_twice(root);
12786 assert_direct_caller(
12787 &store,
12788 "crates/but-action/src/action.rs",
12789 "list_actions",
12790 "crates/app/src/lib.rs",
12791 "run_actions",
12792 );
12793 assert!(
12794 store
12795 .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
12796 .expect("lib reexport callers")
12797 .is_empty(),
12798 "call should target the reexported source function, not lib.rs"
12799 );
12800 assert_projected_call(
12801 root,
12802 &snapshot,
12803 "crates/but-action/src/action.rs",
12804 "list_actions",
12805 );
12806 assert_no_projected_call(
12807 root,
12808 &snapshot,
12809 "crates/but-action/src/action.rs",
12810 "planted_dead",
12811 );
12812 }
12813
12814 #[test]
12815 fn rust_generic_self_turbofish_method_dispatch_resolves() {
12816 let dir = tempdir().expect("tempdir");
12817 let root = dir.path();
12818 write_rust_manifest(root, "generic-self-fixture");
12819 write_file(
12820 root,
12821 "src/lib.rs",
12822 r#"pub struct Matcher;
12823
12824impl Matcher {
12825 pub fn run(&self) -> bool {
12826 self.fuzzy_match_optimal::<usize>("needle")
12827 }
12828
12829 fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
12830 let _ = std::marker::PhantomData::<T>;
12831 true
12832 }
12833
12834 fn planted_dead(&self) {}
12835}
12836
12837pub fn entry() -> bool {
12838 let matcher = Matcher;
12839 matcher.run()
12840}
12841"#,
12842 );
12843
12844 let (store, snapshot) = cold_build_twice(root);
12845 assert_direct_caller(
12846 &store,
12847 "src/lib.rs",
12848 "Matcher::fuzzy_match_optimal",
12849 "src/lib.rs",
12850 "Matcher::run",
12851 );
12852 assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
12853 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
12854 }
12855
12856 #[test]
12857 fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
12858 let dir = tempdir().expect("tempdir");
12859 let root = dir.path();
12860 write_rust_manifest(root, "manifest-operations-fixture");
12861 write_file(
12862 root,
12863 "src/main.rs",
12864 r#"mod dispatch;
12865use dispatch::{manifest_operations};
12866
12867fn main() {
12868 manifest_operations();
12869}
12870"#,
12871 );
12872 write_file(
12873 root,
12874 "src/dispatch.rs",
12875 r#"mod work_graph { fn operations() {} }
12876mod manifest { fn operations() {} }
12877mod audit { fn operations() {} }
12878mod descriptor { fn operations() {} }
12879mod writer { fn operations() {} }
12880
12881pub fn manifest_operations() {
12882 manifest::operations();
12883}
12884
12885pub fn work_graph_operations() {
12886 work_graph::operations();
12887}
12888
12889pub fn audit_operations() {
12890 audit::operations();
12891}
12892
12893pub fn descriptor_operations() {
12894 descriptor::operations();
12895}
12896
12897pub fn writer_operations() {
12898 writer::operations();
12899}
12900
12901fn planted_dead() {}
12902"#,
12903 );
12904
12905 let (store, snapshot) = cold_build_twice(root);
12906 assert_direct_caller(
12907 &store,
12908 "src/dispatch.rs",
12909 "manifest_operations",
12910 "src/main.rs",
12911 "main",
12912 );
12913 assert_direct_caller(
12914 &store,
12915 "src/dispatch.rs",
12916 "manifest::operations",
12917 "src/dispatch.rs",
12918 "manifest_operations",
12919 );
12920 assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
12921 assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
12922 assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
12923 }
12924
12925 fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
12926 let files = rust_files(root);
12927 let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
12928 .expect("open first store");
12929 first.cold_build(&files).expect("first cold build");
12930 let first_snapshot =
12931 project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
12932
12933 let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
12934 .expect("open second store");
12935 second.cold_build(&files).expect("second cold build");
12936 let second_snapshot =
12937 project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
12938
12939 assert_eq!(
12940 projection_rows(&first_snapshot),
12941 projection_rows(&second_snapshot),
12942 "cold-build projection should be deterministic"
12943 );
12944 (first, first_snapshot)
12945 }
12946
12947 fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
12948 let mut rows = Vec::new();
12949 for export in &snapshot.exported_symbols {
12950 rows.push(format!(
12951 "export\t{}\t{}\t{}\t{}",
12952 export.file.display(),
12953 export.symbol,
12954 export.kind,
12955 export.line
12956 ));
12957 }
12958 for call in &snapshot.outbound_calls {
12959 rows.push(format!(
12960 "call\t{}\t{}\t{}\t{}\t{}",
12961 call.caller_file.display(),
12962 call.caller_symbol,
12963 call.target,
12964 call.line,
12965 call.provenance
12966 ));
12967 }
12968 for file in &snapshot.entry_points {
12969 rows.push(format!("entry_file\t{}", file.display()));
12970 }
12971 for (file, symbols) in &snapshot.entry_point_symbols {
12972 for symbol in symbols {
12973 rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
12974 }
12975 }
12976 rows.sort();
12977 rows
12978 }
12979
12980 fn assert_direct_caller(
12981 store: &CallGraphStore,
12982 target_rel: &str,
12983 target_symbol: &str,
12984 caller_rel: &str,
12985 caller_symbol: &str,
12986 ) {
12987 let callers = store
12988 .direct_callers_of(Path::new(target_rel), target_symbol)
12989 .unwrap_or_else(|error| {
12990 panic!("direct callers for {target_rel}::{target_symbol}: {error}")
12991 });
12992 assert!(
12993 callers.iter().any(|site| {
12994 site.caller.file == caller_rel && site.caller.symbol == caller_symbol
12995 }),
12996 "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
12997 );
12998 }
12999
13000 fn assert_projected_call(
13001 root: &Path,
13002 snapshot: &CallgraphSnapshot,
13003 target_rel: &str,
13004 symbol: &str,
13005 ) {
13006 let target = projected_target(root, target_rel, symbol);
13007 assert!(
13008 snapshot.outbound_calls.iter().any(|call| {
13009 call.target == target
13010 || call.target.starts_with(&format!(
13011 "{target}{}",
13012 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
13013 ))
13014 }),
13015 "expected projected call to {target}; calls: {:#?}",
13016 snapshot.outbound_calls
13017 );
13018 }
13019
13020 fn assert_no_projected_call(
13021 root: &Path,
13022 snapshot: &CallgraphSnapshot,
13023 target_rel: &str,
13024 symbol: &str,
13025 ) {
13026 let target = projected_target(root, target_rel, symbol);
13027 assert!(
13028 snapshot.outbound_calls.iter().all(|call| {
13029 call.target != target
13030 && !call.target.starts_with(&format!(
13031 "{target}{}",
13032 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
13033 ))
13034 }),
13035 "did not expect projected call to {target}; calls: {:#?}",
13036 snapshot.outbound_calls
13037 );
13038 }
13039
13040 fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
13041 let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
13044 format!("{}::{symbol}", path.display())
13045 }
13046
13047 fn write_rust_manifest(root: &Path, name: &str) {
13048 write_file(
13049 root,
13050 "Cargo.toml",
13051 &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
13052 );
13053 }
13054
13055 fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
13056 let path = root.join(rel_path);
13057 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
13058 fs::write(&path, source).expect("write fixture file");
13059 path
13060 }
13061
13062 fn rust_files(root: &Path) -> Vec<PathBuf> {
13063 let mut files = Vec::new();
13064 collect_rust_files(root, &mut files);
13065 files.sort();
13066 files
13067 }
13068
13069 fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
13070 for entry in fs::read_dir(dir).expect("read fixture dir") {
13071 let entry = entry.expect("read fixture entry");
13072 let path = entry.path();
13073 if path.is_dir() {
13074 let name = path
13075 .file_name()
13076 .and_then(|name| name.to_str())
13077 .unwrap_or("");
13078 if !name.starts_with(".store") {
13079 collect_rust_files(&path, files);
13080 }
13081 } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
13082 files.push(path);
13083 }
13084 }
13085 }
13086}
13087
13088#[cfg(test)]
13089mod build_pool_tests {
13090 use super::build_pool_size;
13091
13092 #[test]
13093 fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
13094 let size = build_pool_size();
13095 assert!(size >= 1, "pool size must be at least 1");
13098 assert!(size <= 8, "pool size must be capped at 8, got {size}");
13099
13100 let cores = std::thread::available_parallelism()
13101 .map(|p| p.get())
13102 .unwrap_or(1);
13103 let expected = cores.div_ceil(2).clamp(1, 8);
13104 assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
13105 }
13106}
13107
13108#[cfg(test)]
13109mod reexport_resolution_tests {
13110 use super::*;
13111
13112 fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
13113 ProjectIndex {
13114 project_root: PathBuf::from("/fixture"),
13115 files: files.into_iter().collect(),
13116 caller_data: HashMap::new(),
13117 workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
13118 }
13119 }
13120
13121 fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
13122 DbFileIndex {
13123 lang: None,
13124 exports: HashSet::new(),
13125 default_export: None,
13126 export_aliases: HashMap::new(),
13127 node_by_scoped: HashMap::new(),
13128 node_by_bare: HashMap::new(),
13129 module_targets: HashMap::new(),
13130 reexports: reexport_targets
13131 .iter()
13132 .map(|target| ReexportIndex {
13133 target_file: Some((*target).to_string()),
13134 named: HashMap::new(),
13135 wildcard: true,
13136 })
13137 .collect(),
13138 }
13139 }
13140
13141 #[test]
13148 fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
13149 let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
13150 let files = names
13151 .iter()
13152 .map(|name| {
13153 let targets: Vec<&str> = names
13154 .iter()
13155 .filter(|other| *other != name)
13156 .map(String::as_str)
13157 .collect();
13158 (name.clone(), barrel_file(&targets))
13159 })
13160 .collect();
13161 let index = barrel_index(files);
13162
13163 assert_eq!(
13164 resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
13165 None
13166 );
13167 }
13168
13169 #[test]
13175 fn shallow_revisit_after_deep_capped_visit_still_resolves() {
13176 let mut leaf = barrel_file(&[]);
13177 leaf.exports.insert("deep_symbol".to_string());
13178 let mut files: Vec<(String, DbFileIndex)> = Vec::new();
13179 files.push((
13182 "src/entry.ts".to_string(),
13183 barrel_file(&["src/chain0.ts", "src/shared.ts"]),
13184 ));
13185 for i in 0..15 {
13186 let next = if i == 14 {
13187 "src/shared.ts".to_string()
13188 } else {
13189 format!("src/chain{}.ts", i + 1)
13190 };
13191 files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
13192 }
13193 files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
13194 files.push(("src/leaf.ts".to_string(), leaf));
13195 let index = barrel_index(files);
13196
13197 assert_eq!(
13198 resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
13199 Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
13200 "a shallower re-visit must not be pruned by a deeper capped visit"
13201 );
13202 }
13203
13204 #[test]
13205 fn symbol_reachable_through_reexport_cycle_still_resolves() {
13206 let mut leaf = barrel_file(&[]);
13207 leaf.exports.insert("real_symbol".to_string());
13208 let index = barrel_index(vec![
13209 (
13210 "src/a.ts".to_string(),
13211 barrel_file(&["src/b.ts", "src/a.ts"]),
13212 ),
13213 (
13214 "src/b.ts".to_string(),
13215 barrel_file(&["src/a.ts", "src/leaf.ts"]),
13216 ),
13217 ("src/leaf.ts".to_string(), leaf),
13218 ]);
13219
13220 assert_eq!(
13221 resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
13222 Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
13223 );
13224 }
13225}
13226
13227#[cfg(test)]
13228mod method_dispatch_inference_tests {
13229 use super::*;
13230 use std::fs;
13231 use tempfile::tempdir;
13232
13233 #[test]
13234 fn java_field_receiver_type_selects_declared_class_method() {
13235 let source = r#"class EntryPoint {
13236 private UserService userService;
13237
13238 void handle() {
13239 userService.find();
13240 }
13241}
13242
13243class UserService {
13244 void find() {}
13245}
13246
13247class AuditService {
13248 void find() {}
13249}
13250"#;
13251 let dir = tempdir().expect("temp dir");
13252 let root = dir.path();
13253 write_fixture(root, "src/EntryPoint.java", source);
13254 let reference = reference(
13255 "java",
13256 "src/EntryPoint.java",
13257 "EntryPoint::handle",
13258 "userService",
13259 "find",
13260 line_of(source, "userService.find()"),
13261 );
13262 let mut cache = DispatchSourceCache::new();
13263
13264 let receiver_type =
13265 infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
13266 assert_eq!(receiver_type, "UserService");
13267
13268 let candidates = vec![
13269 method_candidate("audit", "AuditService::find"),
13270 method_candidate("user", "UserService::find"),
13271 ];
13272 let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
13273 .expect("type candidate");
13274 assert_eq!(selected.scoped_name, "UserService::find");
13275
13276 let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
13277 assert!(
13278 select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
13279 );
13280 }
13281
13282 #[test]
13283 fn kotlin_property_and_local_value_types_are_inferred() {
13284 let source = r#"class Handler {
13285 private val auditService: AuditService = AuditService()
13286
13287 fun handle() {
13288 auditService.find()
13289 val userService: UserService = UserService()
13290 userService.find()
13291 val billingService = BillingService()
13292 billingService.find()
13293 }
13294}
13295
13296class UserService { fun find() {} }
13297class AuditService { fun find() {} }
13298class BillingService { fun find() {} }
13299"#;
13300 let dir = tempdir().expect("temp dir");
13301 let root = dir.path();
13302 write_fixture(root, "src/Handler.kt", source);
13303 let mut cache = DispatchSourceCache::new();
13304
13305 let audit_ref = reference(
13306 "kotlin",
13307 "src/Handler.kt",
13308 "Handler::handle",
13309 "auditService",
13310 "find",
13311 line_of(source, "auditService.find()"),
13312 );
13313 assert_eq!(
13314 infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
13315 Some("AuditService")
13316 );
13317
13318 let user_ref = reference(
13319 "kotlin",
13320 "src/Handler.kt",
13321 "Handler::handle",
13322 "userService",
13323 "find",
13324 line_of(source, "userService.find()"),
13325 );
13326 assert_eq!(
13327 infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
13328 Some("UserService")
13329 );
13330
13331 let billing_ref = reference(
13332 "kotlin",
13333 "src/Handler.kt",
13334 "Handler::handle",
13335 "billingService",
13336 "find",
13337 line_of(source, "billingService.find()"),
13338 );
13339 assert_eq!(
13340 infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
13341 Some("BillingService")
13342 );
13343 }
13344
13345 #[test]
13346 fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
13347 let source = r#"struct Foo { void run(); };
13348struct PointerFoo { void run(); };
13349struct FactoryFoo { void run(); };
13350FactoryFoo makeFactoryFoo();
13351
13352void handle() {
13353 Foo foo;
13354 foo.run();
13355 PointerFoo* pointerFoo = nullptr;
13356 pointerFoo->run();
13357 auto factoryFoo = makeFactoryFoo();
13358 factoryFoo.run();
13359}
13360"#;
13361 let dir = tempdir().expect("temp dir");
13362 let root = dir.path();
13363 write_fixture(root, "src/fixture.cpp", source);
13364 let mut cache = DispatchSourceCache::new();
13365
13366 let foo_ref = reference(
13367 "cpp",
13368 "src/fixture.cpp",
13369 "handle",
13370 "foo",
13371 "run",
13372 line_of(source, "foo.run()"),
13373 );
13374 assert_eq!(
13375 infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
13376 Some("Foo")
13377 );
13378
13379 let pointer_ref = reference(
13380 "cpp",
13381 "src/fixture.cpp",
13382 "handle",
13383 "pointerFoo",
13384 "run",
13385 line_of(source, "pointerFoo->run()"),
13386 );
13387 assert_eq!(
13388 infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
13389 Some("PointerFoo")
13390 );
13391
13392 let factory_ref = reference(
13393 "cpp",
13394 "src/fixture.cpp",
13395 "handle",
13396 "factoryFoo",
13397 "run",
13398 line_of(source, "factoryFoo.run()"),
13399 );
13400 assert_eq!(
13401 infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
13402 Some("FactoryFoo")
13403 );
13404 }
13405
13406 #[test]
13407 fn unknown_java_receiver_still_uses_name_match_fallback() {
13408 let source = r#"class EntryPoint {
13409 void handle() {
13410 service.runSpecial();
13411 }
13412}
13413
13414class OnlyService {
13415 void runSpecial() {}
13416}
13417"#;
13418 let dir = tempdir().expect("temp dir");
13419 let root = dir.path();
13420 write_fixture(root, "src/EntryPoint.java", source);
13421 let reference = reference(
13422 "java",
13423 "src/EntryPoint.java",
13424 "EntryPoint::handle",
13425 "service",
13426 "runSpecial",
13427 line_of(source, "service.runSpecial()"),
13428 );
13429 let mut cache = DispatchSourceCache::new();
13430
13431 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
13432 let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
13433 let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
13434 assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
13435 }
13436
13437 fn reference(
13438 lang: &str,
13439 caller_file: &str,
13440 caller_symbol: &str,
13441 receiver: &str,
13442 method_name: &str,
13443 line: u32,
13444 ) -> NameMatchRef {
13445 NameMatchRef {
13446 ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
13447 caller_node: format!("{caller_symbol}:node"),
13448 caller_file: caller_file.to_string(),
13449 caller_symbol: caller_symbol.to_string(),
13450 caller_signature: None,
13451 receiver: receiver.to_string(),
13452 method_name: method_name.to_string(),
13453 colon_dispatch: false,
13454 line,
13455 lang: lang.to_string(),
13456 }
13457 }
13458
13459 fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
13460 NameMatchCandidate {
13461 node_id: node_id.to_string(),
13462 file_path: "src/targets.fixture".to_string(),
13463 scoped_name: scoped_name.to_string(),
13464 kind: "method".to_string(),
13465 }
13466 }
13467
13468 fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
13469 let path = root.join(rel_path);
13470 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
13471 fs::write(path, source).expect("write fixture");
13472 }
13473
13474 fn line_of(source: &str, needle: &str) -> u32 {
13475 source
13476 .lines()
13477 .position(|line| line.contains(needle))
13478 .map(|index| index as u32 + 1)
13479 .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
13480 }
13481}