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::{params, Connection, OpenFlags, OptionalExtension, Statement, Transaction};
17use std::collections::{hash_map::Entry, BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
18use std::fmt;
19use std::io::Read;
20use std::path::{Path, PathBuf};
21use std::sync::{Arc, Condvar, Mutex, OnceLock};
22use std::thread::JoinHandle;
23use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
24use tree_sitter::{Node, Parser};
25
26const SCHEMA_VERSION: i64 = 1;
27const BACKEND_TREESITTER: &str = "treesitter";
28const PROVENANCE_TREESITTER: &str = "treesitter+resolver";
29const PROVENANCE_NAME_MATCH: &str = "name_match";
30const PROVENANCE_TYPE_MATCH: &str = "type_match";
31const NAME_MATCH_SCORE_THRESHOLD: f64 = 2.0;
32const TOP_LEVEL_SYMBOL: &str = "<top-level>";
33const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
34const MIGRATION_MANIFEST_VERSION: u32 = 1;
35const MIGRATION_GENERATION_TAG: &str = ".migrated.";
36const MIGRATION_BACKUP_PAGES_PER_STEP: i32 = 128;
37const MIGRATION_BACKUP_RETRY_BUDGET: usize = 25;
38const MIGRATION_BACKUP_WALL_CLOCK_BUDGET: Duration = Duration::from_secs(10);
39const SQLITE_FILE_SET_SUFFIXES: &[&str] = &["", "-wal", "-shm", "-journal"];
40const MARKED_GENERATION_RETENTION_TTL: Duration = Duration::from_secs(6 * 60 * 60);
45const REFRESH_WORKER_WARN_AFTER: Duration = Duration::from_secs(5);
46const REFRESH_WORKER_FINAL_AFTER: Duration = Duration::from_secs(30);
47pub const REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET: Duration = Duration::from_millis(100);
48
49type ColdBuildSwapObserver = dyn Fn(&Path, &Path) + Send + Sync + 'static;
50#[cfg(test)]
51type ColdBuildBeforePublishObserver = dyn Fn() + Send + Sync + 'static;
52thread_local! {
59 static COLD_BUILD_SWAP_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSwapObserver>>> =
60 const { std::cell::RefCell::new(None) };
61 #[cfg(test)]
62 static COLD_BUILD_BEFORE_PUBLISH_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildBeforePublishObserver>>> =
63 const { std::cell::RefCell::new(None) };
64 static MIGRATION_AVAILABLE_DISK_OVERRIDE: std::cell::RefCell<Option<u64>> =
65 const { std::cell::RefCell::new(None) };
66 static MIGRATION_FAIL_AFTER_TEMP_COPY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
67 static MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED: std::cell::Cell<bool> =
68 const { std::cell::Cell::new(false) };
69 static PUBLISH_ADMISSION: std::cell::RefCell<Option<(crate::root_cache::ArtifactPublishEpoch, u64)>> =
70 const { std::cell::RefCell::new(None) };
71 static REFRESH_COMMIT_ADMISSION: std::cell::RefCell<Option<(SubcLifecycleAdmission, Arc<std::sync::atomic::AtomicU64>, u64)>> =
72 const { std::cell::RefCell::new(None) };
73}
74
75mod dead_code_projection;
76pub use dead_code_projection::project_dead_code_snapshot;
77
78#[doc(hidden)]
79pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
80 COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
81}
82
83#[cfg(test)]
84fn set_cold_build_before_publish_observer(observer: Option<Arc<ColdBuildBeforePublishObserver>>) {
85 COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
86}
87
88#[cfg(test)]
89fn notify_cold_build_before_publish_observer() {
90 let observer = COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| slot.borrow().clone());
91 if let Some(observer) = observer {
92 observer();
93 }
94}
95
96#[cfg(not(test))]
97fn notify_cold_build_before_publish_observer() {}
98
99#[doc(hidden)]
100pub fn set_legacy_migration_available_disk_for_test(bytes: Option<u64>) {
101 MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow_mut() = bytes);
102}
103
104#[doc(hidden)]
105pub fn set_legacy_migration_fail_after_temp_copy_for_test(enabled: bool) {
106 MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.set(enabled));
107}
108
109#[doc(hidden)]
110pub fn set_legacy_migration_backup_budget_exhausted_for_test(enabled: bool) {
111 MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.set(enabled));
112}
113
114struct PublishAdmissionGuard {
115 previous: Option<(crate::root_cache::ArtifactPublishEpoch, u64)>,
116}
117
118impl Drop for PublishAdmissionGuard {
119 fn drop(&mut self) {
120 PUBLISH_ADMISSION.with(|slot| {
121 *slot.borrow_mut() = self.previous.take();
122 });
123 }
124}
125
126pub(crate) fn with_publish_epoch<R>(
127 epoch: crate::root_cache::ArtifactPublishEpoch,
128 expected: u64,
129 run: impl FnOnce() -> R,
130) -> R {
131 let previous = PUBLISH_ADMISSION.with(|slot| slot.replace(Some((epoch, expected))));
132 let _guard = PublishAdmissionGuard { previous };
133 run()
134}
135
136fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
137 let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
138 match admission {
139 Some((epoch, expected)) => epoch
140 .run_if_current(expected, publish)
141 .unwrap_or(Err(CallGraphStoreError::Superseded)),
142 None => publish(),
143 }
144}
145
146struct RefreshCommitAdmissionGuard {
147 previous: Option<(
148 SubcLifecycleAdmission,
149 Arc<std::sync::atomic::AtomicU64>,
150 u64,
151 )>,
152}
153
154impl Drop for RefreshCommitAdmissionGuard {
155 fn drop(&mut self) {
156 REFRESH_COMMIT_ADMISSION.with(|slot| {
157 *slot.borrow_mut() = self.previous.take();
158 });
159 }
160}
161
162fn with_refresh_commit_admission<R>(
163 lifecycle: SubcLifecycleAdmission,
164 generation_flag: Arc<std::sync::atomic::AtomicU64>,
165 expected_generation: u64,
166 run: impl FnOnce() -> R,
167) -> R {
168 let previous = REFRESH_COMMIT_ADMISSION
169 .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
170 let _guard = RefreshCommitAdmissionGuard { previous };
171 run()
172}
173
174fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
175 let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
176 let commit = || {
177 publish_if_current(|| {
178 tx.commit()?;
179 Ok(())
180 })
181 };
182 match admission {
183 Some((lifecycle, generation_flag, expected_generation)) => lifecycle
184 .run_if_current(generation_flag.as_ref(), expected_generation, commit)
185 .unwrap_or(Err(CallGraphStoreError::Superseded)),
186 None => commit(),
187 }
188}
189
190fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
191 let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
192 if let Some(observer) = observer {
193 observer(temp_path, target_path);
194 }
195}
196
197#[derive(Debug)]
198pub enum CallGraphStoreError {
199 Io(std::io::Error),
200 Sqlite(rusqlite::Error),
201 Json(serde_json::Error),
202 Aft(AftError),
203 Lock(crate::fs_lock::AcquireError),
204 MissingCallerData { file: String },
205 Unavailable(String),
206 Superseded,
207 StaleFiles(Vec<String>),
208}
209
210impl fmt::Display for CallGraphStoreError {
211 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
212 match self {
213 Self::Io(error) => write!(formatter, "I/O error: {error}"),
214 Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
215 Self::Json(error) => write!(formatter, "json error: {error}"),
216 Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
217 Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
218 Self::MissingCallerData { file } => {
219 write!(formatter, "missing extracted caller data for {file}")
220 }
221 Self::Unavailable(message) => {
222 write!(formatter, "callgraph store unavailable: {message}")
223 }
224 Self::Superseded => {
225 write!(formatter, "callgraph store build superseded before publish")
226 }
227 Self::StaleFiles(files) => {
228 write!(
229 formatter,
230 "callgraph store has stale files: {}",
231 files.join(", ")
232 )
233 }
234 }
235 }
236}
237
238impl std::error::Error for CallGraphStoreError {}
239
240impl From<std::io::Error> for CallGraphStoreError {
241 fn from(error: std::io::Error) -> Self {
242 Self::Io(error)
243 }
244}
245
246impl From<rusqlite::Error> for CallGraphStoreError {
247 fn from(error: rusqlite::Error) -> Self {
248 Self::Sqlite(error)
249 }
250}
251
252impl From<serde_json::Error> for CallGraphStoreError {
253 fn from(error: serde_json::Error) -> Self {
254 Self::Json(error)
255 }
256}
257
258impl From<AftError> for CallGraphStoreError {
259 fn from(error: AftError) -> Self {
260 Self::Aft(error)
261 }
262}
263
264impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
265 fn from(error: crate::fs_lock::AcquireError) -> Self {
266 Self::Lock(error)
267 }
268}
269
270pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
271
272pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
276
277#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
278pub struct CallGraphStoreOptions {
279 pub enabled: bool,
280}
281
282pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
283
284#[derive(Clone, Debug, Hash, PartialEq, Eq)]
285struct RefreshRoot {
286 callgraph_dir: PathBuf,
287 project_root: PathBuf,
288}
289
290#[derive(Clone)]
291pub(crate) struct CallgraphRefreshTicket {
292 lifecycle: SubcLifecycleAdmission,
293 generation_flag: Arc<std::sync::atomic::AtomicU64>,
294 expected_generation: u64,
295 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
296 expected_publish_epoch: u64,
297}
298
299impl CallgraphRefreshTicket {
300 pub(crate) fn new(
301 lifecycle: SubcLifecycleAdmission,
302 generation_flag: Arc<std::sync::atomic::AtomicU64>,
303 expected_generation: u64,
304 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
305 expected_publish_epoch: u64,
306 ) -> Self {
307 Self {
308 lifecycle,
309 generation_flag,
310 expected_generation,
311 publish_epoch,
312 expected_publish_epoch,
313 }
314 }
315
316 fn is_current(&self) -> bool {
317 self.lifecycle
318 .is_current(self.generation_flag.as_ref(), self.expected_generation)
319 && self.publish_epoch.current() == self.expected_publish_epoch
320 }
321}
322
323#[derive(Clone)]
324struct RefreshBatch {
325 root: RefreshRoot,
326 paths: BTreeSet<PathBuf>,
327 pending_sinks: Vec<PendingCallGraphStorePaths>,
328 ticket: Option<CallgraphRefreshTicket>,
329}
330
331impl RefreshBatch {
332 fn defer(&self) {
333 for sink in &self.pending_sinks {
334 sink.lock().extend(self.paths.iter().cloned());
335 }
336 }
337
338 fn merge(
339 &mut self,
340 paths: impl IntoIterator<Item = PathBuf>,
341 sink: PendingCallGraphStorePaths,
342 ticket: Option<CallgraphRefreshTicket>,
343 ) {
344 self.paths.extend(paths);
345 if ticket.is_some() {
346 self.ticket = ticket;
347 }
348 if !self
349 .pending_sinks
350 .iter()
351 .any(|existing| Arc::ptr_eq(existing, &sink))
352 {
353 self.pending_sinks.push(sink);
354 }
355 }
356}
357
358#[derive(Default)]
359struct RefreshQueue {
360 order: VecDeque<RefreshRoot>,
361 queued: HashMap<RefreshRoot, RefreshBatch>,
362 active: Option<RefreshBatch>,
363 shutdown_requested: bool,
364}
365
366struct RefreshWorkerShared {
367 queue: Mutex<RefreshQueue>,
368 wake: Condvar,
369}
370
371struct RefreshWorker {
372 shared: Arc<RefreshWorkerShared>,
373 thread: Mutex<Option<JoinHandle<()>>>,
374}
375
376struct RefreshWorkerWatchdog {
377 first_path: PathBuf,
378 batch_len: usize,
379 started: Instant,
380}
381
382impl RefreshWorkerWatchdog {
383 fn start(paths: &[PathBuf]) -> Self {
384 Self {
385 first_path: paths
386 .first()
387 .expect("non-empty callgraph refresh batch has a first path")
388 .clone(),
389 batch_len: paths.len(),
390 started: Instant::now(),
391 }
392 }
393}
394
395impl Drop for RefreshWorkerWatchdog {
396 fn drop(&mut self) {
397 let elapsed = self.started.elapsed();
398 if elapsed < REFRESH_WORKER_WARN_AFTER {
399 return;
400 }
401 let path = if self.batch_len == 1 {
402 self.first_path.display().to_string()
403 } else {
404 format!(
405 "{} (+{} paths)",
406 self.first_path.display(),
407 self.batch_len - 1
408 )
409 };
410 log::warn!(
411 "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
412 path,
413 elapsed.as_millis()
414 );
415 if elapsed >= REFRESH_WORKER_FINAL_AFTER {
416 log::warn!(
417 "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
418 path,
419 elapsed.as_millis()
420 );
421 }
422 }
423}
424
425impl RefreshWorker {
426 fn spawn() -> Arc<Self> {
427 let shared = Arc::new(RefreshWorkerShared {
428 queue: Mutex::new(RefreshQueue::default()),
429 wake: Condvar::new(),
430 });
431 let thread_shared = Arc::clone(&shared);
432 let thread = std::thread::Builder::new()
433 .name("aft-callgraph-refresh".to_string())
434 .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
435 .expect("failed to spawn callgraph refresh worker");
436 Arc::new(Self {
437 shared,
438 thread: Mutex::new(Some(thread)),
439 })
440 }
441
442 fn enqueue(
443 &self,
444 root: RefreshRoot,
445 paths: Vec<PathBuf>,
446 pending_sink: PendingCallGraphStorePaths,
447 ticket: Option<CallgraphRefreshTicket>,
448 ) -> bool {
449 let mut queue = self
450 .shared
451 .queue
452 .lock()
453 .expect("callgraph refresh queue mutex poisoned");
454 if queue.shutdown_requested {
455 pending_sink.lock().extend(paths);
456 return false;
457 }
458 if let Some(batch) = queue.queued.get_mut(&root) {
459 batch.merge(paths, pending_sink, ticket);
460 } else {
461 queue.order.push_back(root.clone());
462 queue.queued.insert(
463 root.clone(),
464 RefreshBatch {
465 root,
466 paths: paths.into_iter().collect(),
467 pending_sinks: vec![pending_sink],
468 ticket,
469 },
470 );
471 }
472 self.shared.wake.notify_one();
473 true
474 }
475
476 fn shutdown_with_budget(&self, budget: Duration) -> bool {
477 let deadline = Instant::now() + budget;
478 let mut queue = self
479 .shared
480 .queue
481 .lock()
482 .expect("callgraph refresh queue mutex poisoned");
483 queue.shutdown_requested = true;
484 self.shared.wake.notify_one();
485 while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
486 let remaining = deadline.saturating_duration_since(Instant::now());
487 let (next, _) = self
488 .shared
489 .wake
490 .wait_timeout(queue, remaining)
491 .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
492 queue = next;
493 }
494 let drained = queue.active.is_none() && queue.order.is_empty();
495 if !drained {
496 if let Some(active) = queue.active.as_ref() {
497 active.defer();
498 }
499 for batch in queue.queued.values() {
500 batch.defer();
501 }
502 queue.order.clear();
503 queue.queued.clear();
504 }
505 drop(queue);
506
507 if drained {
508 if let Some(thread) = self
509 .thread
510 .lock()
511 .expect("callgraph refresh worker thread mutex poisoned")
512 .take()
513 {
514 let _ = thread.join();
515 }
516 }
517 drained
518 }
519}
520
521static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
522
523pub fn enqueue_callgraph_store_refresh(
524 callgraph_dir: PathBuf,
525 project_root: PathBuf,
526 paths: Vec<PathBuf>,
527 pending_sink: PendingCallGraphStorePaths,
528) -> bool {
529 enqueue_callgraph_store_refresh_inner(callgraph_dir, project_root, paths, pending_sink, None)
530}
531
532pub(crate) fn enqueue_callgraph_store_refresh_fenced(
533 callgraph_dir: PathBuf,
534 project_root: PathBuf,
535 paths: Vec<PathBuf>,
536 pending_sink: PendingCallGraphStorePaths,
537 ticket: CallgraphRefreshTicket,
538) -> bool {
539 enqueue_callgraph_store_refresh_inner(
540 callgraph_dir,
541 project_root,
542 paths,
543 pending_sink,
544 Some(ticket),
545 )
546}
547
548fn enqueue_callgraph_store_refresh_inner(
549 callgraph_dir: PathBuf,
550 project_root: PathBuf,
551 paths: Vec<PathBuf>,
552 pending_sink: PendingCallGraphStorePaths,
553 ticket: Option<CallgraphRefreshTicket>,
554) -> bool {
555 if paths.is_empty() {
556 return true;
557 }
558 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
559 let worker = {
560 let mut worker = slot
561 .lock()
562 .expect("callgraph refresh worker mutex poisoned");
563 Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
564 };
565 worker.enqueue(
566 RefreshRoot {
567 callgraph_dir,
568 project_root,
569 },
570 paths,
571 pending_sink,
572 ticket,
573 )
574}
575
576pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
577 flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
578}
579
580#[doc(hidden)]
581pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
582 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
583 let worker = slot
584 .lock()
585 .expect("callgraph refresh worker mutex poisoned")
586 .clone();
587 let Some(worker) = worker else {
588 return true;
589 };
590 let drained = worker.shutdown_with_budget(budget);
591 if drained {
592 let mut current = slot
593 .lock()
594 .expect("callgraph refresh worker mutex poisoned");
595 if current
596 .as_ref()
597 .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
598 {
599 *current = None;
600 }
601 }
602 drained
603}
604
605fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
606 loop {
607 let batch = {
608 let mut queue = shared
609 .queue
610 .lock()
611 .expect("callgraph refresh queue mutex poisoned");
612 loop {
613 if let Some(root) = queue.order.pop_front() {
614 let batch = queue
615 .queued
616 .remove(&root)
617 .expect("queued callgraph refresh root has a batch");
618 queue.active = Some(batch.clone());
619 break batch;
620 }
621 if queue.shutdown_requested {
622 return;
623 }
624 queue = shared
625 .wake
626 .wait(queue)
627 .expect("callgraph refresh queue mutex poisoned while waiting");
628 }
629 };
630
631 process_callgraph_refresh_batch(&batch);
632
633 let mut queue = shared
634 .queue
635 .lock()
636 .expect("callgraph refresh queue mutex poisoned");
637 queue.active = None;
638 shared.wake.notify_all();
639 }
640}
641
642fn process_callgraph_refresh_batch(batch: &RefreshBatch) {
643 if batch
644 .ticket
645 .as_ref()
646 .is_some_and(|ticket| !ticket.is_current())
647 {
648 batch.defer();
651 return;
652 }
653 let paths = batch.paths.iter().cloned().collect::<Vec<_>>();
654 let _watchdog = RefreshWorkerWatchdog::start(&paths);
655 let store = match CallGraphStore::open_ready(
656 batch.root.callgraph_dir.clone(),
657 batch.root.project_root.clone(),
658 ) {
659 Ok(Some(store)) => store,
660 Ok(None) => {
661 batch.defer();
662 return;
663 }
664 Err(error) => {
665 batch.defer();
666 crate::slog_warn!(
667 "callgraph store writer open failed during refresh; deferred paths: {}",
668 error
669 );
670 return;
671 }
672 };
673
674 let test_seam = refresh_worker_test_seam(&batch.root.project_root);
675 note_refresh_worker_call_for_test(&batch.root.project_root);
676 #[cfg(test)]
677 if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
678 let _ = gate.held_tx.send(());
679 let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
680 }
681 if !test_seam.delay.is_zero() {
682 std::thread::sleep(test_seam.delay);
683 }
684 if batch
685 .ticket
686 .as_ref()
687 .is_some_and(|ticket| !ticket.is_current())
688 {
689 batch.defer();
690 return;
691 }
692 let refresh_result = if test_seam.fail_refresh {
693 Err(CallGraphStoreError::Unavailable(
694 "injected refresh worker failure".to_string(),
695 ))
696 } else if let Some(ticket) = &batch.ticket {
697 with_publish_epoch(
698 ticket.publish_epoch.clone(),
699 ticket.expected_publish_epoch,
700 || {
701 with_refresh_commit_admission(
702 ticket.lifecycle.clone(),
703 Arc::clone(&ticket.generation_flag),
704 ticket.expected_generation,
705 || store.refresh_files(&paths).map(|_| ()),
706 )
707 },
708 )
709 } else {
710 store.refresh_files(&paths).map(|_| ())
711 };
712 if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
713 batch.defer();
717 return;
718 }
719 if let Err(error) = refresh_result {
720 crate::slog_warn!("callgraph store refresh failed: {}", error);
721 match store.mark_files_stale(&paths) {
722 Ok(marked) => {
723 note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
724 crate::slog_warn!(
725 "marked {} callgraph store file(s) stale after refresh failure",
726 marked.len()
727 );
728 }
729 Err(mark_error) => crate::slog_warn!(
730 "failed to mark callgraph store files stale after refresh failure: {}",
731 mark_error
732 ),
733 }
734 } else {
735 crate::logging::note_callgraph_invalidations(paths.len());
736 }
737}
738
739#[derive(Clone, Copy, Default)]
740struct RefreshWorkerTestSeam {
741 delay: Duration,
742 fail_refresh: bool,
743 refresh_calls: usize,
744 stale_marks: usize,
745}
746
747static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
748 OnceLock::new();
749
750#[cfg(test)]
751struct RefreshWorkerTestGate {
752 held_tx: crossbeam_channel::Sender<()>,
753 release_rx: crossbeam_channel::Receiver<()>,
754}
755
756#[cfg(test)]
757static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
758 OnceLock::new();
759
760#[cfg(test)]
761fn install_refresh_worker_test_gate(
762 project_root: PathBuf,
763) -> (
764 crossbeam_channel::Receiver<()>,
765 crossbeam_channel::Sender<()>,
766) {
767 let (held_tx, held_rx) = crossbeam_channel::bounded(1);
768 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
769 REFRESH_WORKER_TEST_GATES
770 .get_or_init(|| Mutex::new(HashMap::new()))
771 .lock()
772 .expect("callgraph refresh test gate mutex poisoned")
773 .insert(
774 project_root,
775 RefreshWorkerTestGate {
776 held_tx,
777 release_rx,
778 },
779 );
780 (held_rx, release_tx)
781}
782
783#[cfg(test)]
784fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
785 REFRESH_WORKER_TEST_GATES
786 .get_or_init(|| Mutex::new(HashMap::new()))
787 .lock()
788 .expect("callgraph refresh test gate mutex poisoned")
789 .remove(project_root)
790}
791
792fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
793 let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
794 return RefreshWorkerTestSeam::default();
795 };
796 seams
797 .lock()
798 .expect("callgraph refresh test seam mutex poisoned")
799 .get(project_root)
800 .copied()
801 .unwrap_or_default()
802}
803
804fn note_refresh_worker_call_for_test(project_root: &Path) {
805 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
806 if let Some(seam) = seams
807 .lock()
808 .expect("callgraph refresh test seam mutex poisoned")
809 .get_mut(project_root)
810 {
811 seam.refresh_calls += 1;
812 }
813 }
814}
815
816fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
817 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
818 if let Some(seam) = seams
819 .lock()
820 .expect("callgraph refresh test seam mutex poisoned")
821 .get_mut(project_root)
822 {
823 seam.stale_marks += 1;
824 }
825 }
826}
827
828#[doc(hidden)]
829pub fn set_callgraph_refresh_worker_test_seam(
830 project_root: PathBuf,
831 delay: Duration,
832 fail_refresh: bool,
833) {
834 REFRESH_WORKER_TEST_SEAMS
835 .get_or_init(|| Mutex::new(HashMap::new()))
836 .lock()
837 .expect("callgraph refresh test seam mutex poisoned")
838 .insert(
839 project_root,
840 RefreshWorkerTestSeam {
841 delay,
842 fail_refresh,
843 ..RefreshWorkerTestSeam::default()
844 },
845 );
846}
847
848#[doc(hidden)]
849pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
850 let seam = refresh_worker_test_seam(project_root);
851 (seam.refresh_calls, seam.stale_marks)
852}
853
854#[doc(hidden)]
855pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
856 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
857 seams
858 .lock()
859 .expect("callgraph refresh test seam mutex poisoned")
860 .remove(project_root);
861 }
862}
863
864#[derive(Debug)]
865pub struct CallGraphStore {
866 project_root: PathBuf,
867 project_key: String,
868 sqlite_path: PathBuf,
872 publication_dir: PathBuf,
876 legacy_fallback: bool,
880 generation: Option<String>,
885 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
886 read_marker: Option<crate::root_cache::ReadMarker>,
887 conn: Mutex<Connection>,
888}
889
890#[derive(Debug)]
891pub struct ReadonlyCallGraphStore {
892 inner: CallGraphStore,
893}
894
895pub trait CallGraphRead {
896 fn project_root(&self) -> &Path;
897 fn project_key(&self) -> &str;
898 fn sqlite_path(&self) -> &Path;
899 fn is_current(&self) -> bool;
900 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
901 fn indexed_file_count(&self) -> Result<usize>;
902 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
903 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
904 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
905 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
906 fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
907 -> Result<StoreCallersResult>;
908 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
909 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
910 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
911 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
912 fn call_tree(
913 &self,
914 file_rel: &Path,
915 symbol: &str,
916 depth: usize,
917 ) -> Result<callgraph::CallTreeNode>;
918 fn trace_to(
919 &self,
920 file_rel: &Path,
921 symbol: &str,
922 max_depth: usize,
923 ) -> Result<callgraph::TraceToResult>;
924 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
925 fn trace_to_symbol(
926 &self,
927 file_rel: &Path,
928 symbol: &str,
929 to_symbol: &str,
930 to_file: Option<&Path>,
931 max_depth: usize,
932 ) -> Result<callgraph::TraceToSymbolResult>;
933}
934
935#[derive(Debug, Clone, PartialEq, Eq)]
936enum OpenRootRepair {
937 None,
938 ReRooted,
939 NeedsRebuild {
940 previous_roots: Vec<String>,
941 current_root: String,
942 reason: String,
943 },
944}
945
946struct OpenedStore {
947 store: CallGraphStore,
948 root_repair: OpenRootRepair,
949}
950
951#[derive(Clone, Debug)]
952struct LegacyCallgraphPartition {
953 harness: String,
954 dir: PathBuf,
955 key: String,
956 bytes: u64,
957 freshness: Option<SystemTime>,
958}
959
960#[derive(Clone, Debug)]
961struct LegacyCallgraphTarget {
962 partition: LegacyCallgraphPartition,
963 sqlite_path: PathBuf,
964 generation: Option<String>,
965 source_bytes: u64,
966 source_blake3: String,
967}
968
969#[derive(Clone, Debug)]
970struct SourceFingerprint {
971 bytes: u64,
972 blake3: String,
973}
974
975#[derive(Clone, Debug)]
976struct PublishedLegacyMigration {
977 generation: String,
978 migrated_bytes: u64,
979}
980
981#[derive(Debug, Clone)]
982pub struct ColdBuildStats {
983 pub files: usize,
984 pub nodes: usize,
985 pub refs: usize,
986 pub edges: usize,
987 pub failed_files: Vec<String>,
988 pub elapsed_ms: u128,
989}
990
991#[derive(Debug, Clone)]
992pub struct IncrementalStats {
993 pub changed_files: Vec<String>,
994 pub surface_changed: Vec<String>,
995 pub deleted_files: Vec<String>,
996 pub dependency_selected_refs: usize,
997 pub refreshed_own_files: usize,
998}
999
1000#[doc(hidden)]
1002#[derive(Debug, Clone, Default, PartialEq, Eq)]
1003pub struct RefreshFilesProfile {
1004 pub parse: Duration,
1005 pub dependency_selection: Duration,
1006 pub row_deletes: Duration,
1007 pub row_inserts: Duration,
1008 pub dependent_parse: Duration,
1009 pub index_load: Duration,
1010 pub ref_resolution: Duration,
1011 pub method_dispatch: Duration,
1012 pub commit: Duration,
1013 pub total: Duration,
1014}
1015
1016impl RefreshFilesProfile {
1017 pub fn report(&self) -> String {
1018 format!(
1019 "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",
1020 self.parse.as_millis(),
1021 self.dependency_selection.as_millis(),
1022 self.row_deletes.as_millis(),
1023 self.row_inserts.as_millis(),
1024 self.dependent_parse.as_millis(),
1025 self.index_load.as_millis(),
1026 self.ref_resolution.as_millis(),
1027 self.method_dispatch.as_millis(),
1028 self.commit.as_millis(),
1029 self.total.as_millis(),
1030 )
1031 }
1032}
1033
1034#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1035pub struct StoredEdge {
1036 pub source_file: String,
1037 pub source_symbol: String,
1038 pub target_file: String,
1039 pub target_symbol: String,
1040 pub kind: String,
1041 pub line: u32,
1042}
1043
1044#[derive(Debug, Clone, PartialEq, Eq)]
1045pub struct StoreNode {
1046 node_id: String,
1047 pub file: String,
1048 pub symbol: String,
1049 pub name: String,
1050 pub kind: String,
1051 pub line: u32,
1052 pub end_line: u32,
1053 pub signature: Option<String>,
1054 pub exported: bool,
1055 pub is_entry_point: bool,
1056 pub lang: LangId,
1057}
1058
1059#[derive(Debug, Clone, PartialEq, Eq)]
1060pub struct StoreCallSite {
1061 pub caller: StoreNode,
1062 pub target_file: String,
1063 pub target_symbol: String,
1064 pub target: Option<StoreNode>,
1065 pub line: u32,
1066 pub byte_start: usize,
1067 pub byte_end: usize,
1068 pub resolved: bool,
1069 pub provenance: String,
1070}
1071
1072impl StoreCallSite {
1073 pub fn approximate(&self) -> bool {
1074 self.provenance == PROVENANCE_NAME_MATCH
1075 }
1076
1077 pub fn resolved_by(&self) -> &str {
1078 &self.provenance
1079 }
1080
1081 pub fn supplemental_resolution(&self) -> Option<&str> {
1082 match self.provenance.as_str() {
1083 PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
1084 _ => None,
1085 }
1086 }
1087}
1088
1089#[derive(Debug, Clone, PartialEq, Eq)]
1090pub struct StoreUnresolvedCall {
1091 pub caller: StoreNode,
1092 pub symbol: String,
1093 pub full_ref: Option<String>,
1094 pub line: u32,
1095 pub byte_start: usize,
1096 pub byte_end: usize,
1097}
1098
1099#[derive(Debug, Clone, PartialEq, Eq)]
1100pub struct StoreCallersResult {
1101 pub target: StoreNode,
1102 pub callers: Vec<StoreCallSite>,
1103 pub scanned_files: usize,
1104 pub depth_limited: bool,
1105 pub truncated: usize,
1106}
1107
1108#[derive(Debug, Clone, PartialEq, Eq)]
1109pub struct StoreImpactCaller {
1110 pub site: StoreCallSite,
1111 pub signature: Option<String>,
1112 pub is_entry_point: bool,
1113 pub call_expression: Option<String>,
1114 pub parameters: Vec<String>,
1115}
1116
1117#[derive(Debug, Clone, PartialEq, Eq)]
1118pub struct StoreImpactResult {
1119 pub target: StoreNode,
1120 pub parameters: Vec<String>,
1121 pub callers: Vec<StoreImpactCaller>,
1122 pub depth_limited: bool,
1123 pub truncated: usize,
1124}
1125
1126#[derive(Debug, Clone)]
1127struct ExtractFailure {
1128 rel_path: String,
1129 freshness: Option<FileFreshness>,
1130}
1131
1132#[derive(Debug, Clone)]
1133struct BuildExtractsResult {
1134 extracts: Vec<FileExtract>,
1135 failures: Vec<ExtractFailure>,
1136}
1137
1138#[derive(Debug, Clone)]
1139enum StoreForwardCall {
1140 Resolved(StoreCallSite),
1141 Unresolved(StoreUnresolvedCall),
1142}
1143
1144impl StoreForwardCall {
1145 fn byte_start(&self) -> usize {
1146 match self {
1147 Self::Resolved(site) => site.byte_start,
1148 Self::Unresolved(call) => call.byte_start,
1149 }
1150 }
1151
1152 fn line(&self) -> u32 {
1153 match self {
1154 Self::Resolved(site) => site.line,
1155 Self::Unresolved(call) => call.line,
1156 }
1157 }
1158}
1159
1160#[derive(Debug, Clone)]
1161struct FileExtract {
1162 rel_path: String,
1163 freshness: FileFreshness,
1164 lang: LangId,
1165 data: FileCallData,
1166 nodes: Vec<NodeRecord>,
1167 raw_refs: Vec<RawRef>,
1168 dispatch_hints: Vec<DispatchHint>,
1169 surface_fingerprint: String,
1170}
1171
1172#[derive(Debug, Clone)]
1173struct NodeRecord {
1174 id: String,
1175 file_path: String,
1176 name: String,
1177 scoped_name: String,
1178 kind: String,
1179 range: Range,
1180 range_ordinal: u32,
1181 signature: Option<String>,
1182 exported: bool,
1183 is_default_export: bool,
1184 is_type_like: bool,
1185 is_callgraph_entry_point: bool,
1186}
1187
1188#[derive(Debug, Clone)]
1189struct RawRef {
1190 ref_id: String,
1191 caller_node: Option<String>,
1192 caller_symbol: Option<String>,
1193 caller_file: String,
1194 kind: String,
1195 short_name: Option<String>,
1196 full_ref: Option<String>,
1197 module_path: Option<String>,
1198 import_kind: Option<String>,
1199 local_name: Option<String>,
1200 requested_name: Option<String>,
1201 namespace_alias: Option<String>,
1202 wildcard: bool,
1203 line: u32,
1204 byte_start: usize,
1205 byte_end: usize,
1206 dependencies: BTreeSet<String>,
1207}
1208
1209#[derive(Debug, Clone)]
1210struct ResolvedRef {
1211 raw: RawRef,
1212 status: String,
1213 target_node: Option<String>,
1214 target_file: Option<String>,
1215 target_symbol: Option<String>,
1216 dependencies: BTreeSet<String>,
1217 edge: Option<EdgeRecord>,
1218}
1219
1220#[derive(Debug, Clone)]
1221struct EdgeRecord {
1222 edge_id: String,
1223 source_node: String,
1224 target_node: Option<String>,
1225 target_file: String,
1226 target_symbol: String,
1227 kind: String,
1228 line: u32,
1229}
1230
1231#[derive(Debug, Clone)]
1232struct DispatchHint {
1233 id: String,
1234 method_name: String,
1235 caller_node: String,
1236 file: String,
1237 line: u32,
1238 byte_start: usize,
1239 byte_end: usize,
1240}
1241
1242#[derive(Debug, Clone)]
1243struct NameMatchRef {
1244 ref_id: String,
1245 caller_node: String,
1246 caller_file: String,
1247 caller_symbol: String,
1248 caller_signature: Option<String>,
1249 receiver: String,
1250 method_name: String,
1251 colon_dispatch: bool,
1252 line: u32,
1253 lang: String,
1254}
1255
1256#[derive(Debug, Clone)]
1257struct NameMatchCandidate {
1258 node_id: String,
1259 file_path: String,
1260 scoped_name: String,
1261 kind: String,
1262}
1263
1264#[derive(Debug, Clone)]
1265struct FileRow {
1266 surface_fingerprint: String,
1267 freshness: FileFreshness,
1268}
1269
1270#[derive(Debug, Clone)]
1271struct DbFileIndex {
1272 lang: Option<LangId>,
1273 exports: HashSet<String>,
1274 default_export: Option<String>,
1275 export_aliases: HashMap<String, String>,
1276 node_by_scoped: HashMap<String, String>,
1277 node_by_bare: HashMap<String, String>,
1278 module_targets: HashMap<String, Option<String>>,
1279 reexports: Vec<ReexportIndex>,
1280}
1281
1282#[derive(Debug, Clone)]
1283struct ReexportIndex {
1284 target_file: Option<String>,
1285 named: HashMap<String, String>,
1286 wildcard: bool,
1287}
1288
1289#[derive(Debug, Clone)]
1290struct ProjectIndex<'a> {
1291 project_root: PathBuf,
1292 files: HashMap<String, DbFileIndex>,
1293 caller_data: HashMap<String, &'a FileCallData>,
1294 workspace_crate_prefixes: std::sync::OnceLock<HashMap<String, String>>,
1299}
1300
1301impl ProjectIndex<'_> {
1302 fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
1305 self.workspace_crate_prefixes
1306 .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
1307 .get(crate_name)
1308 .cloned()
1309 }
1310}
1311
1312impl CallGraphStore {
1313 pub fn open_if_enabled(
1314 options: CallGraphStoreOptions,
1315 callgraph_dir: PathBuf,
1316 project_root: PathBuf,
1317 ) -> Result<Option<Self>> {
1318 if !options.enabled {
1319 return Ok(None);
1320 }
1321 Self::open(callgraph_dir, project_root).map(Some)
1322 }
1323
1324 pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
1325 let project_key = crate::search_index::artifact_cache_key(&project_root);
1326 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1327 else {
1328 return match Self::open_readonly(callgraph_dir.clone(), project_root.clone())? {
1329 Some(store) => Ok(store.into_inner()),
1330 None => Self::borrow_only_empty(callgraph_dir, project_root, project_key),
1331 };
1332 };
1333 std::fs::create_dir_all(&callgraph_dir)?;
1334 let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
1338 .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
1339 let OpenedStore { store, root_repair } = Self::open_at_path(
1340 project_root.clone(),
1341 project_key,
1342 sqlite_path,
1343 generation,
1344 true,
1345 Some(Arc::clone(&writer_lease)),
1346 None,
1347 )?;
1348 match root_repair {
1349 OpenRootRepair::NeedsRebuild { .. } => {
1350 log_root_repair_rebuild(&root_repair);
1351 drop(store);
1352 drop(writer_lease);
1353 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1354 let (store, _stats) =
1355 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
1356 Ok(store)
1357 }
1358 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
1359 }
1360 }
1361
1362 pub fn open_readonly(
1363 callgraph_dir: PathBuf,
1364 project_root: PathBuf,
1365 ) -> Result<Option<ReadonlyCallGraphStore>> {
1366 let project_key = crate::search_index::artifact_cache_key(&project_root);
1367 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1368 {
1369 let conn = open_readonly_connection(&sqlite_path)?;
1370 if !database_ready(&conn).unwrap_or(false) {
1371 return Ok(None);
1372 }
1373 let marker_label = generation.as_deref().unwrap_or("legacy");
1374 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
1375 return Ok(Some(ReadonlyCallGraphStore::from_inner(
1376 Self::from_connection(
1377 project_root,
1378 project_key,
1379 sqlite_path,
1380 callgraph_dir,
1381 false,
1382 generation,
1383 None,
1384 Some(read_marker),
1385 conn,
1386 ),
1387 )));
1388 }
1389
1390 let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
1391 return Ok(None);
1392 };
1393 crate::slog_warn!(
1394 "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
1395 target.partition.harness,
1396 target.sqlite_path.display()
1397 );
1398 let conn = open_readonly_connection(&target.sqlite_path)?;
1399 if !database_ready(&conn).unwrap_or(false) {
1400 return Ok(None);
1401 }
1402 let marker_label =
1403 legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
1404 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
1405 Ok(Some(ReadonlyCallGraphStore::from_inner(
1406 Self::from_connection(
1407 project_root,
1408 project_key,
1409 target.sqlite_path,
1410 callgraph_dir,
1411 true,
1412 target.generation,
1413 None,
1414 Some(read_marker),
1415 conn,
1416 ),
1417 )))
1418 }
1419
1420 pub fn open_ready_repairing(
1426 callgraph_dir: PathBuf,
1427 project_root: PathBuf,
1428 ) -> Result<Option<Self>> {
1429 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true, true)
1430 }
1431
1432 pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
1436 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false, false)
1437 }
1438
1439 pub fn open_ready_no_rebuild(
1440 callgraph_dir: PathBuf,
1441 project_root: PathBuf,
1442 ) -> Result<Option<Self>> {
1443 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true, true)
1444 }
1445
1446 fn open_ready_with_rebuild_policy(
1447 callgraph_dir: PathBuf,
1448 project_root: PathBuf,
1449 allow_cold_build: bool,
1450 allow_root_repair: bool,
1451 allow_borrow_only: bool,
1452 ) -> Result<Option<Self>> {
1453 let project_key = crate::search_index::artifact_cache_key(&project_root);
1454 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1455 else {
1456 if !allow_borrow_only {
1457 return Ok(None);
1458 }
1459 return Self::open_readonly(callgraph_dir, project_root)
1460 .map(|store| store.map(ReadonlyCallGraphStore::into_inner));
1461 };
1462 let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1463 else {
1464 return Ok(None);
1465 };
1466 let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
1467 project_root.clone(),
1468 project_key,
1469 sqlite_path,
1470 generation,
1471 true,
1472 Some(Arc::clone(&writer_lease)),
1473 None,
1474 allow_root_repair,
1475 )?;
1476 match root_repair {
1477 OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
1478 log_root_repair_rebuild(&root_repair);
1479 drop(store);
1480 drop(writer_lease);
1481 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1482 let (store, _stats) =
1483 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
1484 Ok(Some(store))
1485 }
1486 OpenRootRepair::NeedsRebuild { .. } => {
1487 crate::slog_info!(
1488 "callgraph store root repair requires rebuild; open-only reader reports unavailable"
1489 );
1490 Ok(None)
1491 }
1492 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
1493 }
1494 }
1495
1496 pub fn cold_build_with_lease(
1497 callgraph_dir: PathBuf,
1498 project_root: PathBuf,
1499 files: &[PathBuf],
1500 ) -> Result<(Self, ColdBuildStats)> {
1501 Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
1502 }
1503
1504 pub fn cold_build_with_lease_chunked(
1505 callgraph_dir: PathBuf,
1506 project_root: PathBuf,
1507 files: &[PathBuf],
1508 chunk_size: usize,
1509 ) -> Result<(Self, ColdBuildStats)> {
1510 Self::cold_build_with_lease_chunked_inner(
1511 callgraph_dir,
1512 project_root,
1513 files,
1514 chunk_size,
1515 false,
1516 )
1517 }
1518
1519 pub(crate) fn force_cold_build_with_lease_chunked(
1520 callgraph_dir: PathBuf,
1521 project_root: PathBuf,
1522 files: &[PathBuf],
1523 chunk_size: usize,
1524 ) -> Result<(Self, ColdBuildStats)> {
1525 Self::cold_build_with_lease_chunked_inner(
1526 callgraph_dir,
1527 project_root,
1528 files,
1529 chunk_size,
1530 true,
1531 )
1532 }
1533
1534 fn cold_build_with_lease_chunked_inner(
1535 callgraph_dir: PathBuf,
1536 project_root: PathBuf,
1537 files: &[PathBuf],
1538 chunk_size: usize,
1539 require_new_publication: bool,
1540 ) -> Result<(Self, ColdBuildStats)> {
1541 let project_key = crate::search_index::artifact_cache_key(&project_root);
1542 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1543 else {
1544 if require_new_publication {
1545 return Err(CallGraphStoreError::Unavailable(
1546 "forced rebuild could not acquire the writer lease".to_string(),
1547 ));
1548 }
1549 let store = match Self::open_readonly(callgraph_dir.clone(), project_root.clone())? {
1550 Some(store) => store.into_inner(),
1551 None => Self::borrow_only_empty(callgraph_dir, project_root, project_key)?,
1552 };
1553 return Ok((
1554 store,
1555 ColdBuildStats {
1556 files: 0,
1557 nodes: 0,
1558 refs: 0,
1559 edges: 0,
1560 failed_files: Vec::new(),
1561 elapsed_ms: 0,
1562 },
1563 ));
1564 };
1565 std::fs::create_dir_all(&callgraph_dir)?;
1566 let (stats, generation) = Self::cold_build_publish_locked(
1567 &callgraph_dir,
1568 &project_root,
1569 &project_key,
1570 files,
1571 chunk_size,
1572 Arc::clone(&writer_lease),
1573 )?;
1574 let store = Self::open_generation(
1575 &callgraph_dir,
1576 project_root,
1577 project_key,
1578 generation,
1579 writer_lease,
1580 )?;
1581 Ok((store, stats))
1582 }
1583
1584 pub fn ensure_built_with_lease(
1585 callgraph_dir: PathBuf,
1586 project_root: PathBuf,
1587 files: &[PathBuf],
1588 ) -> Result<(Self, Option<ColdBuildStats>)> {
1589 Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
1590 }
1591
1592 pub fn ensure_built_with_lease_chunked(
1593 callgraph_dir: PathBuf,
1594 project_root: PathBuf,
1595 files: &[PathBuf],
1596 chunk_size: usize,
1597 ) -> Result<(Self, Option<ColdBuildStats>)> {
1598 let project_key = crate::search_index::artifact_cache_key(&project_root);
1599 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1600 else {
1601 return match Self::open_readonly(callgraph_dir.clone(), project_root.clone())? {
1602 Some(store) => Ok((store.into_inner(), None)),
1603 None => Self::borrow_only_empty(callgraph_dir, project_root, project_key)
1604 .map(|store| (store, None)),
1605 };
1606 };
1607 std::fs::create_dir_all(&callgraph_dir)?;
1608 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
1609 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1616 {
1617 let OpenedStore { store, root_repair } = Self::open_at_path(
1618 project_root.clone(),
1619 project_key.clone(),
1620 sqlite_path,
1621 generation,
1622 true,
1623 Some(Arc::clone(&writer_lease)),
1624 None,
1625 )?;
1626 match root_repair {
1627 OpenRootRepair::NeedsRebuild { .. } => {
1628 log_root_repair_rebuild(&root_repair);
1629 drop(store);
1630 let (stats, generation) = Self::cold_build_publish_locked(
1631 &callgraph_dir,
1632 &project_root,
1633 &project_key,
1634 files,
1635 chunk_size,
1636 Arc::clone(&writer_lease),
1637 )?;
1638 let store = Self::open_generation(
1639 &callgraph_dir,
1640 project_root,
1641 project_key,
1642 generation,
1643 writer_lease,
1644 )?;
1645 return Ok((store, Some(stats)));
1646 }
1647 OpenRootRepair::None | OpenRootRepair::ReRooted => {
1648 return Ok((store, None));
1649 }
1650 }
1651 }
1652 if let Some(store) = try_legacy_migration_or_fallback(
1653 &callgraph_dir,
1654 &project_root,
1655 &project_key,
1656 Arc::clone(&writer_lease),
1657 )? {
1658 return Ok((store, None));
1659 }
1660 let (stats, generation) = Self::cold_build_publish_locked(
1661 &callgraph_dir,
1662 &project_root,
1663 &project_key,
1664 files,
1665 chunk_size,
1666 Arc::clone(&writer_lease),
1667 )?;
1668 let store = Self::open_generation(
1669 &callgraph_dir,
1670 project_root,
1671 project_key,
1672 generation,
1673 writer_lease,
1674 )?;
1675 Ok((store, Some(stats)))
1676 }
1677
1678 pub fn migrate_legacy_with_lease(
1685 callgraph_dir: PathBuf,
1686 project_root: PathBuf,
1687 ) -> Result<Option<Self>> {
1688 let project_key = crate::search_index::artifact_cache_key(&project_root);
1689 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1690 else {
1691 return Ok(None);
1692 };
1693 std::fs::create_dir_all(&callgraph_dir)?;
1694 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
1695
1696 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1700 {
1701 let OpenedStore { store, root_repair } = Self::open_at_path(
1702 project_root,
1703 project_key,
1704 sqlite_path,
1705 generation,
1706 true,
1707 Some(writer_lease),
1708 None,
1709 )?;
1710 return match root_repair {
1711 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
1712 OpenRootRepair::NeedsRebuild { reason, .. } => {
1713 Err(CallGraphStoreError::Unavailable(format!(
1714 "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
1715 )))
1716 }
1717 };
1718 }
1719
1720 let store = try_legacy_migration_or_fallback(
1721 &callgraph_dir,
1722 &project_root,
1723 &project_key,
1724 writer_lease,
1725 )?;
1726 Ok(store.filter(|store| !store.is_legacy_fallback()))
1730 }
1731
1732 fn cold_build_publish_locked(
1743 callgraph_dir: &Path,
1744 project_root: &Path,
1745 project_key: &str,
1746 files: &[PathBuf],
1747 chunk_size: usize,
1748 writer_lease: Arc<crate::root_cache::WriterLease>,
1749 ) -> Result<(ColdBuildStats, String)> {
1750 let generation = generation_file_name(project_key);
1751 let gen_path = callgraph_dir.join(&generation);
1752 let temp_path = callgraph_dir.join(format!(
1753 "{generation}.tmp.{}.{}",
1754 std::process::id(),
1755 now_nanos()
1756 ));
1757 remove_sqlite_file_set(&temp_path);
1758
1759 let stats = {
1760 let temp_store = Self::open_at_path(
1761 project_root.to_path_buf(),
1762 project_key.to_string(),
1763 temp_path.clone(),
1764 None,
1765 false,
1766 Some(Arc::clone(&writer_lease)),
1767 None,
1768 )?
1769 .store;
1770 let stats = temp_store.cold_build_chunked(files, chunk_size)?;
1771 temp_store.prepare_for_atomic_swap()?;
1772 stats
1773 };
1774
1775 notify_cold_build_before_publish_observer();
1776 let publication = publish_if_current(|| {
1777 verify_writer_lease(&writer_lease)?;
1778 remove_sqlite_file_set(&gen_path);
1781 crate::fs_lock::rename_over(&temp_path, &gen_path)?;
1782 crate::fs_lock::sync_parent(&gen_path);
1783 remove_sqlite_sidecars(&gen_path);
1784
1785 notify_cold_build_swap_observer(&temp_path, &gen_path);
1786
1787 verify_writer_lease(&writer_lease)?;
1789 publish_pointer(callgraph_dir, project_key, &generation)?;
1790 gc_old_generations(callgraph_dir, project_key, &generation);
1791 Ok(())
1792 });
1793 if matches!(publication, Err(CallGraphStoreError::Superseded)) {
1794 remove_sqlite_file_set(&temp_path);
1795 }
1796 publication?;
1797 Ok((stats, generation))
1798 }
1799
1800 fn open_generation(
1803 callgraph_dir: &Path,
1804 project_root: PathBuf,
1805 project_key: String,
1806 generation: String,
1807 writer_lease: Arc<crate::root_cache::WriterLease>,
1808 ) -> Result<Self> {
1809 let gen_path = callgraph_dir.join(&generation);
1810 Ok(Self::open_at_path(
1811 project_root,
1812 project_key,
1813 gen_path,
1814 Some(generation),
1815 true,
1816 Some(writer_lease),
1817 None,
1818 )?
1819 .store)
1820 }
1821
1822 pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
1823 let project_key = crate::search_index::artifact_cache_key(project_root);
1824 Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
1827 }
1828
1829 fn open_at_path(
1830 project_root: PathBuf,
1831 project_key: String,
1832 sqlite_path: PathBuf,
1833 generation: Option<String>,
1834 use_wal: bool,
1835 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1836 read_marker: Option<crate::root_cache::ReadMarker>,
1837 ) -> Result<OpenedStore> {
1838 Self::open_at_path_with_root_repair(
1839 project_root,
1840 project_key,
1841 sqlite_path,
1842 generation,
1843 use_wal,
1844 writer_lease,
1845 read_marker,
1846 true,
1847 )
1848 }
1849
1850 fn open_at_path_with_root_repair(
1851 project_root: PathBuf,
1852 project_key: String,
1853 sqlite_path: PathBuf,
1854 generation: Option<String>,
1855 use_wal: bool,
1856 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1857 read_marker: Option<crate::root_cache::ReadMarker>,
1858 allow_root_repair: bool,
1859 ) -> Result<OpenedStore> {
1860 if let Some(lease) = writer_lease.as_ref() {
1861 verify_writer_lease(lease)?;
1862 }
1863 if let Some(parent) = sqlite_path.parent() {
1864 std::fs::create_dir_all(parent)?;
1865 }
1866 let mut conn = Connection::open(&sqlite_path)?;
1867 if use_wal {
1868 configure_connection(&conn)?;
1869 } else {
1870 configure_build_connection(&conn)?;
1871 }
1872 if let Some(lease) = writer_lease.as_ref() {
1873 verify_writer_lease(lease)?;
1874 }
1875 initialize_schema(&conn)?;
1876 if let Some(lease) = writer_lease.as_ref() {
1877 verify_writer_lease(lease)?;
1878 }
1879 let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
1880 let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
1881 (Some(marker), _, _) => Some(marker),
1882 (None, Some(label), Some(cache_dir)) => {
1883 Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
1884 }
1885 (None, _, _) => None,
1886 };
1887 let publication_dir = sqlite_path
1888 .parent()
1889 .map(Path::to_path_buf)
1890 .unwrap_or_default();
1891 let store = Self::from_connection(
1892 project_root,
1893 project_key,
1894 sqlite_path,
1895 publication_dir,
1896 false,
1897 generation,
1898 writer_lease,
1899 read_marker,
1900 conn,
1901 );
1902 Ok(OpenedStore { store, root_repair })
1903 }
1904
1905 fn borrow_only_empty(
1906 callgraph_dir: PathBuf,
1907 project_root: PathBuf,
1908 project_key: String,
1909 ) -> Result<Self> {
1910 let conn = Connection::open_in_memory()?;
1911 initialize_schema(&conn)?;
1912 conn.pragma_update(None, "query_only", true)?;
1913 Ok(Self::from_connection(
1914 project_root,
1915 project_key.clone(),
1916 callgraph_dir.join(format!("{project_key}.borrow-only")),
1917 callgraph_dir,
1918 false,
1919 None,
1920 None,
1921 None,
1922 conn,
1923 ))
1924 }
1925
1926 fn prepare_for_atomic_swap(&self) -> Result<()> {
1927 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1928 conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
1929 Ok(())
1930 }
1931
1932 fn atomic_swap_checkpoint_sql(&self) -> &'static str {
1933 let protected_reader = self.generation.as_deref().is_some_and(|generation| {
1934 self.sqlite_path
1935 .parent()
1936 .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
1937 });
1938 if protected_reader {
1939 "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
1940 } else {
1941 "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
1942 }
1943 }
1944
1945 fn from_connection(
1946 project_root: PathBuf,
1947 project_key: String,
1948 sqlite_path: PathBuf,
1949 publication_dir: PathBuf,
1950 legacy_fallback: bool,
1951 generation: Option<String>,
1952 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1953 read_marker: Option<crate::root_cache::ReadMarker>,
1954 conn: Connection,
1955 ) -> Self {
1956 Self {
1957 project_root,
1958 project_key,
1959 sqlite_path,
1960 publication_dir,
1961 legacy_fallback,
1962 generation,
1963 writer_lease,
1964 read_marker,
1965 conn: Mutex::new(conn),
1966 }
1967 }
1968
1969 pub fn project_root(&self) -> &Path {
1970 &self.project_root
1971 }
1972
1973 pub fn project_key(&self) -> &str {
1974 &self.project_key
1975 }
1976
1977 pub fn sqlite_path(&self) -> &Path {
1978 &self.sqlite_path
1979 }
1980
1981 pub fn is_legacy_fallback(&self) -> bool {
1984 self.legacy_fallback
1985 }
1986
1987 pub(crate) fn is_legacy_migration(&self) -> bool {
1988 self.generation.as_deref().is_some_and(|generation| {
1989 migration_generation_requires_manifest(generation)
1990 && migration_manifest_valid(&self.publication_dir, generation)
1991 })
1992 }
1993
1994 pub fn writer_epoch_for_test(&self) -> Option<&str> {
1995 self.writer_lease.as_ref().map(|lease| lease.epoch())
1996 }
1997
1998 fn verify_writer_lease(&self) -> Result<()> {
1999 let Some(lease) = self.writer_lease.as_ref() else {
2000 return Err(CallGraphStoreError::Unavailable(
2001 "callgraph store opened read-only; write API is unavailable".to_string(),
2002 ));
2003 };
2004 verify_writer_lease(lease)
2005 }
2006
2007 fn refresh_read_marker(&self) -> Result<()> {
2008 if let Some(marker) = self.read_marker.as_ref() {
2009 marker.touch_if_due()?;
2010 }
2011 Ok(())
2012 }
2013
2014 pub fn is_current(&self) -> bool {
2020 let _ = self.refresh_read_marker();
2021 match (
2022 read_pointer(&self.publication_dir, &self.project_key),
2023 &self.generation,
2024 ) {
2025 (Some(_), _) if self.legacy_fallback => false,
2028 (Some(published), Some(opened)) => &published == opened,
2029 (Some(_), None) => false,
2031 (None, _) => true,
2034 }
2035 }
2036
2037 pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
2038 self.cold_build_chunked(files, 0)
2039 }
2040
2041 pub fn cold_build_chunked(
2042 &self,
2043 files: &[PathBuf],
2044 chunk_size: usize,
2045 ) -> Result<ColdBuildStats> {
2046 let started = Instant::now();
2047 let bench = std::env::var("AFT_BENCH_COLD").is_ok();
2048 macro_rules! phase {
2049 ($label:expr, $t:expr) => {
2050 if bench {
2051 eprintln!(" cold_build[{}]: {} ms", $label, $t.elapsed().as_millis());
2052 let _ = std::io::Write::flush(&mut std::io::stderr());
2053 }
2054 };
2055 }
2056 let files = normalize_file_list(&self.project_root, files)?;
2057
2058 if chunk_size == 0 {
2059 let t = Instant::now();
2060 let build = build_extracts_parallel(&self.project_root, &files);
2061 phase!("extract_parallel", t);
2062 let extracts = build.extracts;
2063 let failures = build.failures;
2064 let node_count = extracts.iter().map(|extract| extract.nodes.len()).sum();
2065
2066 let t = Instant::now();
2067 let index = ProjectIndex::from_extracts(&self.project_root, &extracts);
2068 phase!("build_index", t);
2069 let t = Instant::now();
2070 let mut resolved_refs = Vec::new();
2071 for extract in &extracts {
2072 for raw_ref in &extract.raw_refs {
2073 resolved_refs.push(resolve_ref(raw_ref.clone(), &index)?);
2074 }
2075 }
2076 phase!("resolve_refs", t);
2077 let ref_count = resolved_refs.len();
2078 let edge_count = resolved_refs
2079 .iter()
2080 .filter(|item| item.edge.is_some())
2081 .count();
2082
2083 let t = Instant::now();
2084 self.verify_writer_lease()?;
2085 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2086 let tx = conn.transaction()?;
2087 clear_tables(&tx)?;
2088 insert_meta(&tx)?;
2089 drop_cold_build_secondary_indexes(&tx)?;
2090 {
2091 let workspace_root = self.project_root.display().to_string();
2092 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2093 for extract in &extracts {
2094 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
2095 }
2096 for failure in &failures {
2097 insert_backend_state_prepared(
2098 &mut inserts.backend_state,
2099 &workspace_root,
2100 &failure.rel_path,
2101 failure
2102 .freshness
2103 .as_ref()
2104 .map(|freshness| &freshness.content_hash),
2105 "stale",
2106 )?;
2107 }
2108 for resolved in &resolved_refs {
2109 insert_resolved_ref_prepared(&mut inserts, resolved)?;
2110 }
2111 }
2112 create_cold_build_secondary_indexes(&tx)?;
2113 let supplemental_edge_count =
2114 insert_method_dispatch_edges(&tx, &self.project_root, None)?;
2115 set_meta_ready(&tx, true)?;
2116 tx.commit()?;
2117 phase!("sqlite_insert", t);
2118
2119 let elapsed_ms = started.elapsed().as_millis();
2120 crate::slog_info!(
2121 "perf callgraph_store cold_build: files={} nodes={} refs={} edges={} ms={}",
2122 extracts.len(),
2123 node_count,
2124 ref_count,
2125 edge_count + supplemental_edge_count,
2126 elapsed_ms
2127 );
2128 return Ok(ColdBuildStats {
2129 files: extracts.len(),
2130 nodes: node_count,
2131 refs: ref_count,
2132 edges: edge_count + supplemental_edge_count,
2133 failed_files: failures
2134 .into_iter()
2135 .map(|failure| failure.rel_path)
2136 .collect(),
2137 elapsed_ms,
2138 });
2139 }
2140
2141 let t = Instant::now();
2144 self.verify_writer_lease()?;
2145 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2146 let tx = conn.transaction()?;
2147 clear_tables(&tx)?;
2148 insert_meta(&tx)?;
2149 drop_cold_build_secondary_indexes(&tx)?;
2150
2151 let mut all_raw_refs = Vec::new();
2152 let mut failures = Vec::new();
2153 let mut node_count = 0;
2154 let mut files_parsed = 0;
2155
2156 let mut persistent_call_data = Vec::new();
2157 let mut file_to_call_data_index = HashMap::new();
2158 let mut files_index = HashMap::new();
2159
2160 let workspace_root = self.project_root.display().to_string();
2161
2162 {
2163 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2164 for chunk in files.chunks(chunk_size) {
2165 let build = build_extracts_parallel(&self.project_root, chunk);
2166 failures.extend(build.failures.clone());
2167
2168 for extract in build.extracts {
2169 files_parsed += 1;
2170 node_count += extract.nodes.len();
2171 insert_file_extract_prepared(&mut inserts, &workspace_root, &extract)?;
2172
2173 let db_file_index = DbFileIndex::from_extract(&self.project_root, &extract);
2174 files_index.insert(extract.rel_path.clone(), db_file_index);
2175
2176 persistent_call_data.push(extract.data);
2177 let idx = persistent_call_data.len() - 1;
2178 file_to_call_data_index.insert(extract.rel_path.clone(), idx);
2179
2180 all_raw_refs.push((extract.rel_path, extract.raw_refs));
2181 }
2182 for failure in &build.failures {
2183 insert_backend_state_prepared(
2184 &mut inserts.backend_state,
2185 &workspace_root,
2186 &failure.rel_path,
2187 failure
2188 .freshness
2189 .as_ref()
2190 .map(|freshness| &freshness.content_hash),
2191 "stale",
2192 )?;
2193 }
2194 }
2195 }
2196
2197 let mut caller_data = HashMap::new();
2198 for (rel_path, idx) in &file_to_call_data_index {
2199 caller_data.insert(rel_path.clone(), &persistent_call_data[*idx]);
2200 }
2201 let indexed_caller_files = files_index.keys().cloned().collect::<BTreeSet<_>>();
2202 let index = ProjectIndex::from_parts(&self.project_root, files_index, caller_data);
2203
2204 let mut resolved_refs = Vec::new();
2205 for (_, raw_refs) in all_raw_refs {
2206 for raw_ref in raw_refs {
2207 resolved_refs.push(resolve_ref(raw_ref, &index)?);
2208 }
2209 }
2210
2211 let ref_count = resolved_refs.len();
2212 let edge_count = resolved_refs
2213 .iter()
2214 .filter(|item| item.edge.is_some())
2215 .count();
2216
2217 {
2218 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2219 for resolved in &resolved_refs {
2220 insert_resolved_ref_prepared(&mut inserts, resolved)?;
2221 }
2222 }
2223 create_cold_build_secondary_indexes(&tx)?;
2224 let supplemental_edge_count = insert_method_dispatch_edges_chunked(
2225 &tx,
2226 &self.project_root,
2227 &indexed_caller_files,
2228 chunk_size,
2229 )?;
2230 set_meta_ready(&tx, true)?;
2231 tx.commit()?;
2232 phase!("sqlite_insert", t);
2233
2234 let elapsed_ms = started.elapsed().as_millis();
2235 crate::slog_info!(
2236 "perf callgraph_store cold_build (chunked): files={} nodes={} refs={} edges={} ms={}",
2237 files_parsed,
2238 node_count,
2239 ref_count,
2240 edge_count + supplemental_edge_count,
2241 elapsed_ms
2242 );
2243 Ok(ColdBuildStats {
2244 files: files_parsed,
2245 nodes: node_count,
2246 refs: ref_count,
2247 edges: edge_count + supplemental_edge_count,
2248 failed_files: failures
2249 .into_iter()
2250 .map(|failure| failure.rel_path)
2251 .collect(),
2252 elapsed_ms,
2253 })
2254 }
2255
2256 pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
2257 let (stats, profile) = self.refresh_files_profiled(changed_files)?;
2258 if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
2259 eprintln!("refresh_files phases: {}", profile.report());
2260 }
2261 Ok(stats)
2262 }
2263
2264 #[doc(hidden)]
2266 pub fn refresh_files_profiled(
2267 &self,
2268 changed_files: &[PathBuf],
2269 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2270 let total_started = Instant::now();
2271 let mut profile = RefreshFilesProfile::default();
2272 self.verify_writer_lease()?;
2273 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2274 let tx = conn.transaction()?;
2275 ensure_database_ready(&tx)?;
2276 let mut changed = Vec::new();
2277 let mut surface_changed = BTreeSet::new();
2278 let mut deleted = BTreeSet::new();
2279 let mut own_refresh = BTreeSet::new();
2280 let mut selected_ref_ids = BTreeSet::new();
2281 let mut selected_refs_by_caller = BTreeMap::new();
2282 let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
2283
2284 for input in changed_files {
2285 let abs_path = normalize_file_path(&self.project_root, input)?;
2286 let rel_path = relative_path(&self.project_root, &abs_path);
2287 changed.push(rel_path.clone());
2288 let old_row = load_file_row(&tx, &rel_path)?;
2289 if !abs_path.exists() {
2290 if old_row.is_some() {
2291 surface_changed.insert(rel_path.clone());
2292 deleted.insert(rel_path.clone());
2293 let started = Instant::now();
2294 let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
2295 profile.dependency_selection += started.elapsed();
2296 record_dependent_refs(
2297 &mut selected_ref_ids,
2298 &mut selected_refs_by_caller,
2299 dependent_refs,
2300 );
2301 let started = Instant::now();
2302 delete_file_rows(&tx, &rel_path)?;
2303 clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
2304 profile.row_deletes += started.elapsed();
2305 }
2306 continue;
2307 }
2308
2309 if let Some(row) = &old_row {
2310 match cache_freshness::verify_file(&abs_path, &row.freshness) {
2311 FreshnessVerdict::HotFresh => continue,
2312 FreshnessVerdict::ContentFresh {
2313 new_mtime,
2314 new_size,
2315 } => {
2316 update_file_fresh_metadata(
2317 &tx,
2318 &rel_path,
2319 &row.freshness.content_hash,
2320 new_mtime,
2321 new_size,
2322 )?;
2323 continue;
2324 }
2325 FreshnessVerdict::Deleted => {
2326 surface_changed.insert(rel_path.clone());
2327 deleted.insert(rel_path.clone());
2328 let started = Instant::now();
2329 let dependent_refs =
2330 ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
2331 profile.dependency_selection += started.elapsed();
2332 record_dependent_refs(
2333 &mut selected_ref_ids,
2334 &mut selected_refs_by_caller,
2335 dependent_refs,
2336 );
2337 let started = Instant::now();
2338 delete_file_rows(&tx, &rel_path)?;
2339 clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
2340 profile.row_deletes += started.elapsed();
2341 continue;
2342 }
2343 FreshnessVerdict::Stale => {}
2344 }
2345 }
2346
2347 let started = Instant::now();
2348 let extract = build_file_extract(&self.project_root, &abs_path)?;
2349 profile.parse += started.elapsed();
2350 let surface_is_changed = old_row
2351 .as_ref()
2352 .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
2353 .unwrap_or(true);
2354 if surface_is_changed {
2355 surface_changed.insert(rel_path.clone());
2356 let started = Instant::now();
2357 let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
2358 profile.dependency_selection += started.elapsed();
2359 record_dependent_refs(
2360 &mut selected_ref_ids,
2361 &mut selected_refs_by_caller,
2362 dependent_refs,
2363 );
2364 }
2365 own_refresh.insert(rel_path.clone());
2366 let started = Instant::now();
2367 delete_file_rows(&tx, &rel_path)?;
2368 profile.row_deletes += started.elapsed();
2369 let started = Instant::now();
2370 insert_file_extract(&tx, &self.project_root, &extract)?;
2371 profile.row_inserts += started.elapsed();
2372 changed_extracts.insert(rel_path, extract);
2373 }
2374
2375 let dependency_selected_refs = selected_ref_ids.len();
2376 let mut touched_callers: BTreeSet<String> =
2377 selected_refs_by_caller.keys().cloned().collect();
2378 touched_callers.extend(own_refresh.iter().cloned());
2379
2380 let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
2381 for rel_path in &touched_callers {
2382 if deleted.contains(rel_path) {
2383 continue;
2384 }
2385 if let Some(extract) = changed_extracts.get(rel_path) {
2386 caller_extracts.insert(rel_path.clone(), extract.clone());
2387 continue;
2388 }
2389 let abs_path = self.project_root.join(rel_path);
2390 if abs_path.exists() {
2391 let started = Instant::now();
2392 let extract = build_file_extract(&self.project_root, &abs_path)?;
2393 profile.dependent_parse += started.elapsed();
2394 caller_extracts.insert(rel_path.clone(), extract);
2395 }
2396 }
2397
2398 let dependency_callers = touched_callers
2399 .iter()
2400 .filter(|rel_path| !deleted.contains(*rel_path) && !own_refresh.contains(*rel_path))
2401 .cloned()
2402 .collect::<Vec<_>>();
2403 for rel_path in dependency_callers {
2404 let Some(extract) = caller_extracts.get(&rel_path) else {
2405 continue;
2406 };
2407 if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
2408 continue;
2409 }
2410
2411 own_refresh.insert(rel_path.clone());
2412 let started = Instant::now();
2413 delete_file_rows(&tx, &rel_path)?;
2414 profile.row_deletes += started.elapsed();
2415 let started = Instant::now();
2416 insert_file_extract(&tx, &self.project_root, extract)?;
2417 profile.row_inserts += started.elapsed();
2418 }
2419
2420 let started = Instant::now();
2421 let index = ProjectIndex::from_db_and_callers(&tx, &self.project_root, &caller_extracts)?;
2422 profile.index_load += started.elapsed();
2423 let started = Instant::now();
2424 for rel_path in &touched_callers {
2425 if deleted.contains(rel_path) {
2426 continue;
2427 }
2428 let Some(extract) = caller_extracts.get(rel_path) else {
2429 continue;
2430 };
2431 if own_refresh.contains(rel_path) {
2432 delete_refs_for_caller(&tx, rel_path)?;
2433 for raw_ref in &extract.raw_refs {
2434 let resolved = resolve_ref(raw_ref.clone(), &index)?;
2435 insert_resolved_ref(&tx, &resolved)?;
2436 }
2437 continue;
2438 }
2439
2440 let selected_for_caller = selected_refs_by_caller
2441 .get(rel_path)
2442 .cloned()
2443 .unwrap_or_default();
2444 delete_ref_ids(&tx, &selected_for_caller)?;
2445 for raw_ref in &extract.raw_refs {
2446 if selected_for_caller.contains(&raw_ref.ref_id) {
2447 let resolved = resolve_ref(raw_ref.clone(), &index)?;
2448 insert_resolved_ref(&tx, &resolved)?;
2449 }
2450 }
2451 }
2452 profile.ref_resolution += started.elapsed();
2453
2454 let started = Instant::now();
2455 delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
2456 insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
2457 profile.method_dispatch += started.elapsed();
2458
2459 let started = Instant::now();
2460 commit_incremental_if_current(tx)?;
2461 profile.commit += started.elapsed();
2462 profile.total = total_started.elapsed();
2463 Ok((
2464 IncrementalStats {
2465 changed_files: changed,
2466 surface_changed: surface_changed.into_iter().collect(),
2467 deleted_files: deleted.into_iter().collect(),
2468 dependency_selected_refs,
2469 refreshed_own_files: own_refresh.len(),
2470 },
2471 profile,
2472 ))
2473 }
2474
2475 pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
2476 self.cold_build(current_files)
2477 }
2478
2479 pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
2480 self.verify_writer_lease()?;
2481 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2482 let tx = conn.transaction()?;
2483 let mut marked = Vec::new();
2484 for path in files {
2485 let abs_path = normalize_file_path(&self.project_root, path)?;
2486 let rel_path = relative_path(&self.project_root, &abs_path);
2487 let freshness = cache_freshness::collect(&abs_path).ok();
2488 mark_backend_state(
2489 &tx,
2490 &self.project_root,
2491 &rel_path,
2492 freshness.as_ref().map(|freshness| &freshness.content_hash),
2493 "stale",
2494 )?;
2495 marked.push(rel_path);
2496 }
2497 tx.commit()?;
2498 marked.sort();
2499 marked.dedup();
2500 Ok(marked)
2501 }
2502
2503 pub fn stale_files(&self) -> Result<Vec<String>> {
2504 self.refresh_read_marker()?;
2505 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2506 let mut stmt = conn.prepare(
2507 "SELECT DISTINCT file_path FROM backend_file_state
2508 WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
2509 ORDER BY file_path",
2510 )?;
2511 let rows = stmt.query_map(
2512 params![BACKEND_TREESITTER, self.project_root.display().to_string()],
2513 |row| row.get::<_, String>(0),
2514 )?;
2515 rows.collect::<std::result::Result<Vec<_>, _>>()
2516 .map_err(Into::into)
2517 }
2518
2519 pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
2520 self.refresh_read_marker()?;
2521 let rel_path = relative_path(
2522 &self.project_root,
2523 &normalize_file_path(&self.project_root, file)?,
2524 );
2525 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2526 conn.query_row(
2527 "SELECT status FROM backend_file_state
2528 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
2529 ORDER BY updated_at DESC LIMIT 1",
2530 params![
2531 BACKEND_TREESITTER,
2532 self.project_root.display().to_string(),
2533 rel_path
2534 ],
2535 |row| row.get(0),
2536 )
2537 .optional()
2538 .map_err(Into::into)
2539 }
2540
2541 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
2542 self.refresh_read_marker()?;
2543 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2544 ensure_database_ready(&conn)?;
2545 edge_snapshot_with_conn(&conn)
2546 }
2547
2548 pub fn indexed_file_count(&self) -> Result<usize> {
2549 self.refresh_read_marker()?;
2550 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2551 ensure_database_ready(&conn)?;
2552 indexed_file_count(&conn)
2553 }
2554
2555 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
2556 self.refresh_read_marker()?;
2557 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
2558 let rel_path = relative_path(&self.project_root, &abs_path);
2559 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2560 ensure_database_ready(&conn)?;
2561 resolve_node_for_rel(&conn, &rel_path, symbol)
2562 }
2563
2564 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
2569 self.refresh_read_marker()?;
2570 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
2571 let rel_path = relative_path(&self.project_root, &abs_path);
2572 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2573 ensure_database_ready(&conn)?;
2574 nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
2575 }
2576
2577 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
2579 self.refresh_read_marker()?;
2580 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2581 ensure_database_ready(&conn)?;
2582 nodes_matching_symbol(&conn, symbol)
2583 }
2584
2585 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
2587 self.refresh_read_marker()?;
2588 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
2589 let rel_path = relative_path(&self.project_root, &abs_path);
2590 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2591 ensure_database_ready(&conn)?;
2592 direct_callers_for_tuple(&conn, &rel_path, symbol)
2593 }
2594
2595 pub fn callers_of(
2596 &self,
2597 file_rel: &Path,
2598 symbol: &str,
2599 depth: usize,
2600 ) -> Result<StoreCallersResult> {
2601 let target = self.node_for(file_rel, symbol)?;
2602 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2603 ensure_database_ready(&conn)?;
2604 let effective_depth = depth.max(1);
2605 let mut visited = HashSet::new();
2606 let mut callers = Vec::new();
2607 let mut depth_limited = false;
2608 let mut truncated = 0usize;
2609 collect_callers_recursive(
2610 &conn,
2611 &target.file,
2612 &target.symbol,
2613 effective_depth,
2614 0,
2615 &mut visited,
2616 &mut callers,
2617 &mut depth_limited,
2618 &mut truncated,
2619 )?;
2620 Ok(StoreCallersResult {
2621 target,
2622 callers,
2623 scanned_files: indexed_file_count(&conn)?,
2624 depth_limited,
2625 truncated,
2626 })
2627 }
2628
2629 pub fn impact_of(
2630 &self,
2631 file_rel: &Path,
2632 symbol: &str,
2633 depth: usize,
2634 ) -> Result<StoreImpactResult> {
2635 let callers = self.callers_of(file_rel, symbol, depth)?;
2636 let target_parameters = callers
2637 .target
2638 .signature
2639 .as_deref()
2640 .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
2641 .unwrap_or_default();
2642 let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
2643 for site in &callers.callers {
2644 source_lines_by_file
2645 .entry(site.caller.file.clone())
2646 .or_insert_with(|| {
2647 read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
2648 });
2649 }
2650 let enriched = callers
2651 .callers
2652 .iter()
2653 .map(|site| StoreImpactCaller {
2654 site: site.clone(),
2655 signature: site.caller.signature.clone(),
2656 is_entry_point: site.caller.is_entry_point,
2657 call_expression: source_lines_by_file
2658 .get(&site.caller.file)
2659 .and_then(|lines| lines.as_ref())
2660 .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
2661 .cloned(),
2662 parameters: site
2663 .caller
2664 .signature
2665 .as_deref()
2666 .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
2667 .unwrap_or_default(),
2668 })
2669 .collect();
2670 Ok(StoreImpactResult {
2671 target: callers.target,
2672 parameters: target_parameters,
2673 callers: enriched,
2674 depth_limited: callers.depth_limited,
2675 truncated: callers.truncated,
2676 })
2677 }
2678
2679 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
2680 self.refresh_read_marker()?;
2681 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2682 ensure_database_ready(&conn)?;
2683 outgoing_calls_for_node(&conn, node)
2684 }
2685
2686 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
2688 self.refresh_read_marker()?;
2689 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2690 ensure_database_ready(&conn)?;
2691 resolved_self_calls_for_node(&conn, node)
2692 }
2693
2694 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
2695 self.refresh_read_marker()?;
2696 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2697 ensure_database_ready(&conn)?;
2698 unresolved_calls_for_node(&conn, node)
2699 }
2700
2701 pub fn call_tree(
2702 &self,
2703 file_rel: &Path,
2704 symbol: &str,
2705 max_depth: usize,
2706 ) -> Result<callgraph::CallTreeNode> {
2707 let node = self.node_for(file_rel, symbol)?;
2708 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2709 ensure_database_ready(&conn)?;
2710 let mut visited = HashSet::new();
2711 call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
2712 }
2713
2714 pub fn trace_to(
2715 &self,
2716 file_rel: &Path,
2717 symbol: &str,
2718 max_depth: usize,
2719 ) -> Result<callgraph::TraceToResult> {
2720 let target = self.node_for(file_rel, symbol)?;
2721 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2722 ensure_database_ready(&conn)?;
2723 let effective_max = if max_depth == 0 { 10 } else { max_depth };
2724
2725 #[derive(Clone)]
2726 struct PathElem {
2727 node: StoreNode,
2728 }
2729
2730 let initial = vec![PathElem {
2731 node: target.clone(),
2732 }];
2733 let mut complete_paths = Vec::new();
2734 if target.is_entry_point {
2735 complete_paths.push(initial.clone());
2736 }
2737
2738 let mut queue = vec![(initial, 0usize)];
2739 let mut max_depth_reached = false;
2740 let mut truncated_paths = 0usize;
2741
2742 while let Some((path, depth)) = queue.pop() {
2743 if depth >= effective_max {
2744 max_depth_reached = true;
2745 continue;
2746 }
2747 let Some(current) = path.last() else {
2748 continue;
2749 };
2750 let callers =
2751 direct_callers_for_tuple(&conn, ¤t.node.file, ¤t.node.symbol)?;
2752 if callers.is_empty() {
2753 if path.len() > 1 {
2754 truncated_paths += 1;
2755 }
2756 continue;
2757 }
2758
2759 let mut has_new_path = false;
2760 for site in callers {
2761 if path.iter().any(|elem| {
2762 elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
2763 }) {
2764 continue;
2765 }
2766 has_new_path = true;
2767 let mut new_path = path.clone();
2768 new_path.push(PathElem {
2769 node: site.caller.clone(),
2770 });
2771 if site.caller.is_entry_point {
2772 complete_paths.push(new_path.clone());
2773 }
2774 queue.push((new_path, depth + 1));
2775 }
2776 if !has_new_path && path.len() > 1 {
2777 truncated_paths += 1;
2778 }
2779 }
2780
2781 let mut paths: Vec<callgraph::TracePath> = complete_paths
2782 .into_iter()
2783 .map(|mut elems| {
2784 elems.reverse();
2785 let hops = elems
2786 .iter()
2787 .enumerate()
2788 .map(|(index, elem)| callgraph::TraceHop {
2789 symbol: elem.node.symbol.clone(),
2790 file: elem.node.file.clone(),
2791 line: elem.node.line,
2792 signature: elem.node.signature.clone(),
2793 is_entry_point: index == 0 && elem.node.is_entry_point,
2794 })
2795 .collect();
2796 callgraph::TracePath { hops }
2797 })
2798 .collect();
2799 paths.sort_by(|left, right| {
2800 let left_entry = left
2801 .hops
2802 .first()
2803 .map(|hop| hop.symbol.as_str())
2804 .unwrap_or("");
2805 let right_entry = right
2806 .hops
2807 .first()
2808 .map(|hop| hop.symbol.as_str())
2809 .unwrap_or("");
2810 left_entry
2811 .cmp(right_entry)
2812 .then(left.hops.len().cmp(&right.hops.len()))
2813 });
2814 let entry_points_found = paths
2815 .iter()
2816 .filter_map(|path| path.hops.first())
2817 .filter(|hop| hop.is_entry_point)
2818 .map(|hop| (hop.file.clone(), hop.symbol.clone()))
2819 .collect::<HashSet<_>>()
2820 .len();
2821
2822 Ok(callgraph::TraceToResult {
2823 target_symbol: target.symbol,
2824 target_file: target.file,
2825 total_paths: paths.len(),
2826 paths,
2827 entry_points_found,
2828 max_depth_reached,
2829 truncated_paths,
2830 })
2831 }
2832
2833 pub fn trace_to_symbol_candidates(
2834 &self,
2835 to_symbol: &str,
2836 ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
2837 self.refresh_read_marker()?;
2838 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2839 ensure_database_ready(&conn)?;
2840 let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
2841 for node in nodes_matching_symbol(&conn, to_symbol)? {
2842 candidates_by_file
2843 .entry(node.file)
2844 .and_modify(|line| *line = (*line).min(node.line))
2845 .or_insert(node.line);
2846 }
2847 let mut candidates: Vec<_> = candidates_by_file
2848 .into_iter()
2849 .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
2850 .collect();
2851 candidates
2852 .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
2853 Ok(candidates)
2854 }
2855
2856 pub fn trace_to_symbol(
2857 &self,
2858 file_rel: &Path,
2859 symbol: &str,
2860 to_symbol: &str,
2861 to_file: Option<&Path>,
2862 max_depth: usize,
2863 ) -> Result<callgraph::TraceToSymbolResult> {
2864 let origin = self.node_for(file_rel, symbol)?;
2865 let target_file = to_file
2866 .map(|path| normalize_file_path(&self.project_root, path))
2867 .transpose()?
2868 .map(|path| relative_path(&self.project_root, &path));
2869 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2870 ensure_database_ready(&conn)?;
2871 let effective_max = if max_depth == 0 {
2872 10
2873 } else {
2874 max_depth.min(16)
2875 };
2876
2877 let start_hop = trace_to_symbol_hop(&origin);
2878 if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
2879 return Ok(callgraph::TraceToSymbolResult {
2880 path: Some(vec![start_hop]),
2881 complete: true,
2882 reason: None,
2883 });
2884 }
2885
2886 let mut queue = VecDeque::new();
2887 queue.push_back((origin.clone(), vec![start_hop], 0usize));
2888 let mut visited = HashSet::new();
2889 visited.insert((origin.file.clone(), origin.symbol.clone()));
2890 let mut max_depth_exhausted = false;
2891
2892 while let Some((current, path, depth)) = queue.pop_front() {
2893 let callees = outgoing_calls_for_node(&conn, ¤t)?
2894 .into_iter()
2895 .filter_map(|site| site.target)
2896 .collect::<Vec<_>>();
2897
2898 if depth >= effective_max {
2899 if callees
2900 .iter()
2901 .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
2902 {
2903 max_depth_exhausted = true;
2904 }
2905 continue;
2906 }
2907
2908 for callee in callees {
2909 if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
2910 continue;
2911 }
2912 let mut next_path = path.clone();
2913 next_path.push(trace_to_symbol_hop(&callee));
2914 if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
2915 return Ok(callgraph::TraceToSymbolResult {
2916 path: Some(next_path),
2917 complete: true,
2918 reason: None,
2919 });
2920 }
2921 queue.push_back((callee, next_path, depth + 1));
2922 }
2923 }
2924
2925 if max_depth_exhausted {
2926 Ok(callgraph::TraceToSymbolResult {
2927 path: None,
2928 complete: false,
2929 reason: Some("max_depth_exhausted".to_string()),
2930 })
2931 } else {
2932 Ok(callgraph::TraceToSymbolResult {
2933 path: None,
2934 complete: true,
2935 reason: Some("no_path_found".to_string()),
2936 })
2937 }
2938 }
2939}
2940
2941impl ReadonlyCallGraphStore {
2942 fn from_inner(inner: CallGraphStore) -> Self {
2943 Self { inner }
2944 }
2945
2946 fn into_inner(self) -> CallGraphStore {
2947 self.inner
2948 }
2949
2950 pub fn project_root(&self) -> &Path {
2951 self.inner.project_root()
2952 }
2953
2954 pub fn project_key(&self) -> &str {
2955 self.inner.project_key()
2956 }
2957
2958 pub fn sqlite_path(&self) -> &Path {
2959 self.inner.sqlite_path()
2960 }
2961
2962 pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
2965 crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
2966 }
2967
2968 pub fn is_legacy_fallback(&self) -> bool {
2970 self.inner.is_legacy_fallback()
2971 }
2972
2973 pub fn is_current(&self) -> bool {
2974 self.inner.is_current()
2975 }
2976
2977 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
2978 self.inner.edge_snapshot()
2979 }
2980
2981 pub fn indexed_file_count(&self) -> Result<usize> {
2982 self.inner.indexed_file_count()
2983 }
2984
2985 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
2986 self.inner.node_for(file_rel, symbol)
2987 }
2988
2989 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
2990 self.inner.nodes_for(file_rel, symbol)
2991 }
2992
2993 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
2994 self.inner.nodes_matching(symbol)
2995 }
2996
2997 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
2998 self.inner.direct_callers_of(file_rel, symbol)
2999 }
3000
3001 pub fn callers_of(
3002 &self,
3003 file_rel: &Path,
3004 symbol: &str,
3005 depth: usize,
3006 ) -> Result<StoreCallersResult> {
3007 self.inner.callers_of(file_rel, symbol, depth)
3008 }
3009
3010 pub fn impact_of(
3011 &self,
3012 file_rel: &Path,
3013 symbol: &str,
3014 depth: usize,
3015 ) -> Result<StoreImpactResult> {
3016 self.inner.impact_of(file_rel, symbol, depth)
3017 }
3018
3019 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3020 self.inner.outgoing_calls_of(node)
3021 }
3022
3023 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3024 self.inner.resolved_self_calls_of(node)
3025 }
3026
3027 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3028 self.inner.unresolved_calls_of(node)
3029 }
3030
3031 pub fn call_tree(
3032 &self,
3033 file_rel: &Path,
3034 symbol: &str,
3035 depth: usize,
3036 ) -> Result<callgraph::CallTreeNode> {
3037 self.inner.call_tree(file_rel, symbol, depth)
3038 }
3039
3040 pub fn trace_to(
3041 &self,
3042 file_rel: &Path,
3043 symbol: &str,
3044 max_depth: usize,
3045 ) -> Result<callgraph::TraceToResult> {
3046 self.inner.trace_to(file_rel, symbol, max_depth)
3047 }
3048
3049 pub fn trace_to_symbol_candidates(
3050 &self,
3051 to_symbol: &str,
3052 ) -> Result<Vec<TraceToSymbolCandidate>> {
3053 self.inner.trace_to_symbol_candidates(to_symbol)
3054 }
3055
3056 pub fn trace_to_symbol(
3057 &self,
3058 file_rel: &Path,
3059 symbol: &str,
3060 to_symbol: &str,
3061 to_file: Option<&Path>,
3062 max_depth: usize,
3063 ) -> Result<callgraph::TraceToSymbolResult> {
3064 self.inner
3065 .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3066 }
3067}
3068
3069impl CallGraphRead for CallGraphStore {
3070 fn project_root(&self) -> &Path {
3071 CallGraphStore::project_root(self)
3072 }
3073 fn project_key(&self) -> &str {
3074 CallGraphStore::project_key(self)
3075 }
3076 fn sqlite_path(&self) -> &Path {
3077 CallGraphStore::sqlite_path(self)
3078 }
3079 fn is_current(&self) -> bool {
3080 CallGraphStore::is_current(self)
3081 }
3082 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3083 CallGraphStore::edge_snapshot(self)
3084 }
3085 fn indexed_file_count(&self) -> Result<usize> {
3086 CallGraphStore::indexed_file_count(self)
3087 }
3088 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3089 CallGraphStore::node_for(self, file_rel, symbol)
3090 }
3091 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3092 CallGraphStore::nodes_for(self, file_rel, symbol)
3093 }
3094 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3095 CallGraphStore::nodes_matching(self, symbol)
3096 }
3097 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3098 CallGraphStore::direct_callers_of(self, file_rel, symbol)
3099 }
3100 fn callers_of(
3101 &self,
3102 file_rel: &Path,
3103 symbol: &str,
3104 depth: usize,
3105 ) -> Result<StoreCallersResult> {
3106 CallGraphStore::callers_of(self, file_rel, symbol, depth)
3107 }
3108 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3109 CallGraphStore::impact_of(self, file_rel, symbol, depth)
3110 }
3111 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3112 CallGraphStore::outgoing_calls_of(self, node)
3113 }
3114 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3115 CallGraphStore::resolved_self_calls_of(self, node)
3116 }
3117 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3118 CallGraphStore::unresolved_calls_of(self, node)
3119 }
3120 fn call_tree(
3121 &self,
3122 file_rel: &Path,
3123 symbol: &str,
3124 depth: usize,
3125 ) -> Result<callgraph::CallTreeNode> {
3126 CallGraphStore::call_tree(self, file_rel, symbol, depth)
3127 }
3128 fn trace_to(
3129 &self,
3130 file_rel: &Path,
3131 symbol: &str,
3132 max_depth: usize,
3133 ) -> Result<callgraph::TraceToResult> {
3134 CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
3135 }
3136 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3137 CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
3138 }
3139 fn trace_to_symbol(
3140 &self,
3141 file_rel: &Path,
3142 symbol: &str,
3143 to_symbol: &str,
3144 to_file: Option<&Path>,
3145 max_depth: usize,
3146 ) -> Result<callgraph::TraceToSymbolResult> {
3147 CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
3148 }
3149}
3150
3151impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
3152 fn project_root(&self) -> &Path {
3153 (**self).project_root()
3154 }
3155 fn project_key(&self) -> &str {
3156 (**self).project_key()
3157 }
3158 fn sqlite_path(&self) -> &Path {
3159 (**self).sqlite_path()
3160 }
3161 fn is_current(&self) -> bool {
3162 (**self).is_current()
3163 }
3164 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3165 (**self).edge_snapshot()
3166 }
3167 fn indexed_file_count(&self) -> Result<usize> {
3168 (**self).indexed_file_count()
3169 }
3170 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3171 (**self).node_for(file_rel, symbol)
3172 }
3173 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3174 (**self).nodes_for(file_rel, symbol)
3175 }
3176 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3177 (**self).nodes_matching(symbol)
3178 }
3179 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3180 (**self).direct_callers_of(file_rel, symbol)
3181 }
3182 fn callers_of(
3183 &self,
3184 file_rel: &Path,
3185 symbol: &str,
3186 depth: usize,
3187 ) -> Result<StoreCallersResult> {
3188 (**self).callers_of(file_rel, symbol, depth)
3189 }
3190 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3191 (**self).impact_of(file_rel, symbol, depth)
3192 }
3193 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3194 (**self).outgoing_calls_of(node)
3195 }
3196 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3197 (**self).resolved_self_calls_of(node)
3198 }
3199 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3200 (**self).unresolved_calls_of(node)
3201 }
3202 fn call_tree(
3203 &self,
3204 file_rel: &Path,
3205 symbol: &str,
3206 depth: usize,
3207 ) -> Result<callgraph::CallTreeNode> {
3208 (**self).call_tree(file_rel, symbol, depth)
3209 }
3210 fn trace_to(
3211 &self,
3212 file_rel: &Path,
3213 symbol: &str,
3214 max_depth: usize,
3215 ) -> Result<callgraph::TraceToResult> {
3216 (**self).trace_to(file_rel, symbol, max_depth)
3217 }
3218 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3219 (**self).trace_to_symbol_candidates(to_symbol)
3220 }
3221 fn trace_to_symbol(
3222 &self,
3223 file_rel: &Path,
3224 symbol: &str,
3225 to_symbol: &str,
3226 to_file: Option<&Path>,
3227 max_depth: usize,
3228 ) -> Result<callgraph::TraceToSymbolResult> {
3229 (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3230 }
3231}
3232
3233impl CallGraphRead for ReadonlyCallGraphStore {
3234 fn project_root(&self) -> &Path {
3235 self.project_root()
3236 }
3237 fn project_key(&self) -> &str {
3238 self.project_key()
3239 }
3240 fn sqlite_path(&self) -> &Path {
3241 self.sqlite_path()
3242 }
3243 fn is_current(&self) -> bool {
3244 self.is_current()
3245 }
3246 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3247 self.edge_snapshot()
3248 }
3249 fn indexed_file_count(&self) -> Result<usize> {
3250 self.indexed_file_count()
3251 }
3252 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3253 self.node_for(file_rel, symbol)
3254 }
3255 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3256 self.nodes_for(file_rel, symbol)
3257 }
3258 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3259 self.nodes_matching(symbol)
3260 }
3261 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3262 self.direct_callers_of(file_rel, symbol)
3263 }
3264 fn callers_of(
3265 &self,
3266 file_rel: &Path,
3267 symbol: &str,
3268 depth: usize,
3269 ) -> Result<StoreCallersResult> {
3270 self.callers_of(file_rel, symbol, depth)
3271 }
3272 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3273 self.impact_of(file_rel, symbol, depth)
3274 }
3275 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3276 self.outgoing_calls_of(node)
3277 }
3278 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3279 self.resolved_self_calls_of(node)
3280 }
3281 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3282 self.unresolved_calls_of(node)
3283 }
3284 fn call_tree(
3285 &self,
3286 file_rel: &Path,
3287 symbol: &str,
3288 depth: usize,
3289 ) -> Result<callgraph::CallTreeNode> {
3290 self.call_tree(file_rel, symbol, depth)
3291 }
3292 fn trace_to(
3293 &self,
3294 file_rel: &Path,
3295 symbol: &str,
3296 max_depth: usize,
3297 ) -> Result<callgraph::TraceToResult> {
3298 self.trace_to(file_rel, symbol, max_depth)
3299 }
3300 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3301 self.trace_to_symbol_candidates(to_symbol)
3302 }
3303 fn trace_to_symbol(
3304 &self,
3305 file_rel: &Path,
3306 symbol: &str,
3307 to_symbol: &str,
3308 to_file: Option<&Path>,
3309 max_depth: usize,
3310 ) -> Result<callgraph::TraceToSymbolResult> {
3311 self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3312 }
3313}
3314
3315fn indexed_file_count(conn: &Connection) -> Result<usize> {
3316 let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
3317 Ok(count.max(0) as usize)
3318}
3319
3320fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
3321 let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
3322 match candidates.as_slice() {
3323 [candidate] => Ok(candidate.clone()),
3324 [] => Err(AftError::SymbolNotFound {
3325 name: symbol.to_string(),
3326 file: rel_path.to_string(),
3327 }
3328 .into()),
3329 _ => Err(AftError::AmbiguousSymbol {
3330 name: symbol.to_string(),
3331 candidates: candidates
3332 .iter()
3333 .map(|candidate| candidate.symbol.clone())
3334 .collect(),
3335 }
3336 .into()),
3337 }
3338}
3339
3340fn nodes_for_file_matching_symbol(
3341 conn: &Connection,
3342 rel_path: &str,
3343 symbol: &str,
3344) -> Result<Vec<StoreNode>> {
3345 let qualified_query = symbol.contains("::");
3346 let sql = if qualified_query {
3347 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3348 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3349 FROM nodes n JOIN files f ON f.path = n.file_path
3350 WHERE n.file_path = ?1 AND n.scoped_name = ?2
3351 ORDER BY n.scoped_name, n.start_line, n.start_col"
3352 } else {
3353 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3354 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3355 FROM nodes n JOIN files f ON f.path = n.file_path
3356 WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
3357 ORDER BY n.scoped_name, n.start_line, n.start_col"
3358 };
3359 let mut stmt = conn.prepare(sql)?;
3360 let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
3361 rows.collect::<std::result::Result<Vec<_>, _>>()
3362 .map_err(Into::into)
3363}
3364
3365fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
3366 let qualified_query = symbol.contains("::");
3367 let sql = if qualified_query {
3368 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3369 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3370 FROM nodes n JOIN files f ON f.path = n.file_path
3371 WHERE n.scoped_name = ?1
3372 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
3373 } else {
3374 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3375 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3376 FROM nodes n JOIN files f ON f.path = n.file_path
3377 WHERE n.scoped_name = ?1 OR n.name = ?1
3378 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
3379 };
3380 let mut stmt = conn.prepare(sql)?;
3381 let rows = stmt.query_map(params![symbol], store_node_from_row)?;
3382 rows.collect::<std::result::Result<Vec<_>, _>>()
3383 .map_err(Into::into)
3384}
3385
3386fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
3387 store_node_from_row_at(row, 0)
3388}
3389
3390fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
3391 let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
3392 let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
3393 let lang_label_value: String = row.get(offset + 10)?;
3394 Ok(StoreNode {
3395 node_id: row.get(offset)?,
3396 file: row.get(offset + 1)?,
3397 symbol: row.get(offset + 2)?,
3398 name: row.get(offset + 3)?,
3399 kind: row.get(offset + 4)?,
3400 line: start_line.saturating_add(1),
3401 end_line: end_line.saturating_add(1),
3402 signature: row.get(offset + 7)?,
3403 exported: row.get::<_, i64>(offset + 8)? != 0,
3404 is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
3405 lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
3406 })
3407}
3408
3409fn optional_store_node_from_row_at(
3410 row: &rusqlite::Row<'_>,
3411 offset: usize,
3412) -> rusqlite::Result<Option<StoreNode>> {
3413 if row.get::<_, Option<String>>(offset)?.is_some() {
3414 store_node_from_row_at(row, offset).map(Some)
3415 } else {
3416 Ok(None)
3417 }
3418}
3419
3420#[allow(clippy::too_many_arguments)]
3421fn collect_callers_recursive(
3422 conn: &Connection,
3423 file: &str,
3424 symbol: &str,
3425 max_depth: usize,
3426 current_depth: usize,
3427 visited: &mut HashSet<(String, String)>,
3428 result: &mut Vec<StoreCallSite>,
3429 depth_limited: &mut bool,
3430 truncated: &mut usize,
3431) -> Result<()> {
3432 if current_depth >= max_depth {
3433 let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
3434 if omitted > 0 {
3435 *depth_limited = true;
3436 *truncated += omitted;
3437 }
3438 return Ok(());
3439 }
3440
3441 if !visited.insert((file.to_string(), symbol.to_string())) {
3442 return Ok(());
3443 }
3444
3445 let sites = direct_callers_for_tuple(conn, file, symbol)?;
3446 for site in sites {
3447 result.push(site.clone());
3448 if current_depth + 1 < max_depth {
3449 collect_callers_recursive(
3450 conn,
3451 &site.caller.file,
3452 &site.caller.symbol,
3453 max_depth,
3454 current_depth + 1,
3455 visited,
3456 result,
3457 depth_limited,
3458 truncated,
3459 )?;
3460 } else {
3461 let omitted =
3462 direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
3463 if omitted > 0 {
3464 *depth_limited = true;
3465 *truncated += omitted;
3466 }
3467 }
3468 }
3469 Ok(())
3470}
3471
3472fn direct_caller_count_for_tuple(
3473 conn: &Connection,
3474 target_file: &str,
3475 target_symbol: &str,
3476) -> Result<usize> {
3477 let count: i64 = conn.query_row(
3478 "SELECT COUNT(*)
3479 FROM edges e
3480 JOIN refs r ON r.ref_id = e.ref_id
3481 JOIN nodes src ON src.id = e.source_node
3482 JOIN files src_file ON src_file.path = src.file_path
3483 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
3484 params![target_file, target_symbol],
3485 |row| row.get(0),
3486 )?;
3487 Ok(usize::try_from(count).unwrap_or(usize::MAX))
3488}
3489
3490fn direct_callers_for_tuple(
3491 conn: &Connection,
3492 target_file: &str,
3493 target_symbol: &str,
3494) -> Result<Vec<StoreCallSite>> {
3495 let mut stmt = conn.prepare(
3496 "SELECT e.target_file, e.target_symbol, e.line,
3497 r.byte_start, r.byte_end, r.status, e.provenance,
3498 src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
3499 src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
3500 src_file.lang,
3501 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
3502 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
3503 tgt_file.lang
3504 FROM edges e
3505 JOIN refs r ON r.ref_id = e.ref_id
3506 JOIN nodes src ON src.id = e.source_node
3507 JOIN files src_file ON src_file.path = src.file_path
3508 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
3509 ON tgt.id = e.target_node
3510 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
3511 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
3512 )?;
3513 let rows = stmt.query_map(params![target_file, target_symbol], |row| {
3514 let caller = store_node_from_row_at(row, 7)?;
3515 let target = optional_store_node_from_row_at(row, 18)?;
3516 Ok(StoreCallSite {
3517 caller,
3518 target_file: row.get(0)?,
3519 target_symbol: row.get(1)?,
3520 target,
3521 line: row.get::<_, i64>(2)?.max(0) as u32,
3522 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
3523 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
3524 resolved: row.get::<_, String>(5)? == "resolved",
3525 provenance: row.get(6)?,
3526 })
3527 })?;
3528 rows.collect::<std::result::Result<Vec<_>, _>>()
3529 .map_err(Into::into)
3530}
3531
3532fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3533 let mut stmt = conn.prepare(
3534 "SELECT e.target_file, e.target_symbol, e.line,
3535 r.byte_start, r.byte_end, r.status, e.provenance,
3536 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
3537 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
3538 tgt_file.lang
3539 FROM edges e
3540 JOIN refs r ON r.ref_id = e.ref_id
3541 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
3542 ON tgt.id = e.target_node
3543 WHERE e.kind = 'call' AND e.source_node = ?1
3544 ORDER BY r.byte_start, r.line, r.ref_id",
3545 )?;
3546 let rows = stmt.query_map(params![node.node_id], |row| {
3547 let target = optional_store_node_from_row_at(row, 7)?;
3548 Ok(StoreCallSite {
3549 caller: node.clone(),
3550 target_file: row.get(0)?,
3551 target_symbol: row.get(1)?,
3552 target,
3553 line: row.get::<_, i64>(2)?.max(0) as u32,
3554 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
3555 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
3556 resolved: row.get::<_, String>(5)? == "resolved",
3557 provenance: row.get(6)?,
3558 })
3559 })?;
3560 rows.collect::<std::result::Result<Vec<_>, _>>()
3561 .map_err(Into::into)
3562}
3563
3564fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3565 let mut stmt = conn.prepare(
3566 "SELECT r.target_file, r.target_symbol, r.line,
3567 r.byte_start, r.byte_end, r.status, r.provenance,
3568 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
3569 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
3570 tgt_file.lang
3571 FROM refs r
3572 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
3573 ON tgt.id = r.target_node
3574 WHERE r.caller_node = ?1
3575 AND r.kind = 'call'
3576 AND r.status <> 'unresolved'
3577 AND r.target_file = ?2
3578 AND r.target_symbol = ?3
3579 AND r.provenance = ?4
3580 AND NOT EXISTS (
3581 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
3582 )
3583 ORDER BY r.byte_start, r.line, r.ref_id",
3584 )?;
3585 let rows = stmt.query_map(
3586 params![
3587 &node.node_id,
3588 &node.file,
3589 &node.symbol,
3590 PROVENANCE_TREESITTER
3591 ],
3592 |row| {
3593 let target = optional_store_node_from_row_at(row, 7)?;
3594 Ok(StoreCallSite {
3595 caller: node.clone(),
3596 target_file: row.get(0)?,
3597 target_symbol: row.get(1)?,
3598 target,
3599 line: row.get::<_, i64>(2)?.max(0) as u32,
3600 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
3601 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
3602 resolved: row.get::<_, String>(5)? == "resolved",
3603 provenance: row.get(6)?,
3604 })
3605 },
3606 )?;
3607 rows.collect::<std::result::Result<Vec<_>, _>>()
3608 .map_err(Into::into)
3609}
3610
3611fn unresolved_calls_for_node(
3612 conn: &Connection,
3613 node: &StoreNode,
3614) -> Result<Vec<StoreUnresolvedCall>> {
3615 let mut stmt = conn.prepare(
3616 "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
3617 FROM refs
3618 WHERE caller_node = ?1
3619 AND kind = 'call'
3620 AND status = 'unresolved'
3621 AND NOT EXISTS (
3622 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
3623 )
3624 ORDER BY byte_start, line, ref_id",
3625 )?;
3626 let rows = stmt.query_map(params![node.node_id], |row| {
3627 Ok(StoreUnresolvedCall {
3628 caller: node.clone(),
3629 symbol: row.get(0)?,
3630 full_ref: row.get(1)?,
3631 line: row.get::<_, i64>(2)?.max(0) as u32,
3632 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
3633 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
3634 })
3635 })?;
3636 rows.collect::<std::result::Result<Vec<_>, _>>()
3637 .map_err(Into::into)
3638}
3639
3640fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
3641 let mut calls = Vec::new();
3642 calls.extend(
3643 outgoing_calls_for_node(conn, node)?
3644 .into_iter()
3645 .map(StoreForwardCall::Resolved),
3646 );
3647 calls.extend(
3648 unresolved_calls_for_node(conn, node)?
3649 .into_iter()
3650 .map(StoreForwardCall::Unresolved),
3651 );
3652 calls.sort_by(|left, right| {
3653 left.byte_start()
3654 .cmp(&right.byte_start())
3655 .then(left.line().cmp(&right.line()))
3656 });
3657 Ok(calls)
3658}
3659
3660fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
3661 let resolved_count: i64 = conn.query_row(
3662 "SELECT COUNT(*)
3663 FROM edges e
3664 JOIN refs r ON r.ref_id = e.ref_id
3665 WHERE e.kind = 'call' AND e.source_node = ?1",
3666 params![&node.node_id],
3667 |row| row.get(0),
3668 )?;
3669 let unresolved_count: i64 = conn.query_row(
3670 "SELECT COUNT(*)
3671 FROM refs
3672 WHERE caller_node = ?1
3673 AND kind = 'call'
3674 AND status = 'unresolved'
3675 AND NOT EXISTS (
3676 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
3677 )",
3678 params![&node.node_id],
3679 |row| row.get(0),
3680 )?;
3681 let total = resolved_count.saturating_add(unresolved_count);
3682 Ok(usize::try_from(total).unwrap_or(usize::MAX))
3683}
3684
3685fn call_tree_inner(
3686 conn: &Connection,
3687 node: &StoreNode,
3688 max_depth: usize,
3689 current_depth: usize,
3690 visited: &mut HashSet<(String, String)>,
3691) -> Result<callgraph::CallTreeNode> {
3692 let visit_key = (node.file.clone(), node.symbol.clone());
3693 if visited.contains(&visit_key) {
3694 return Ok(callgraph::CallTreeNode {
3695 name: node.symbol.clone(),
3696 file: node.file.clone(),
3697 line: node.line,
3698 signature: node.signature.clone(),
3699 resolved: true,
3700 children: Vec::new(),
3701 depth_limited: false,
3702 truncated: 0,
3703 });
3704 }
3705 visited.insert(visit_key.clone());
3706
3707 let mut children = Vec::new();
3708 let mut depth_limited = false;
3709 let mut truncated = 0usize;
3710
3711 if current_depth < max_depth {
3712 let calls = forward_calls_for_node(conn, node)?;
3713 for call in calls {
3714 match call {
3715 StoreForwardCall::Resolved(site) => {
3716 if let Some(target) = site.target {
3717 let child =
3718 call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
3719 depth_limited |= child.depth_limited;
3720 truncated += child.truncated;
3721 children.push(child);
3722 } else {
3723 children.push(callgraph::CallTreeNode {
3724 name: site.target_symbol,
3725 file: site.target_file,
3726 line: site.line,
3727 signature: None,
3728 resolved: false,
3729 children: Vec::new(),
3730 depth_limited: false,
3731 truncated: 0,
3732 });
3733 }
3734 }
3735 StoreForwardCall::Unresolved(call) => {
3736 children.push(callgraph::CallTreeNode {
3737 name: call.symbol,
3738 file: call.caller.file,
3739 line: call.line,
3740 signature: None,
3741 resolved: false,
3742 children: Vec::new(),
3743 depth_limited: false,
3744 truncated: 0,
3745 });
3746 }
3747 }
3748 }
3749 } else {
3750 truncated = forward_call_count_for_node(conn, node)?;
3751 depth_limited = truncated > 0;
3752 }
3753
3754 visited.remove(&visit_key);
3755 Ok(callgraph::CallTreeNode {
3756 name: node.symbol.clone(),
3757 file: node.file.clone(),
3758 line: node.line,
3759 signature: node.signature.clone(),
3760 resolved: true,
3761 children,
3762 depth_limited,
3763 truncated,
3764 })
3765}
3766
3767fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
3768 callgraph::TraceToSymbolHop {
3769 symbol: node.symbol.clone(),
3770 file: node.file.clone(),
3771 line: node.line,
3772 }
3773}
3774
3775fn trace_to_symbol_matches_target(
3776 node: &StoreNode,
3777 to_symbol: &str,
3778 to_file: Option<&str>,
3779) -> bool {
3780 if !symbol_query_matches(&node.symbol, to_symbol) {
3781 return false;
3782 }
3783 match to_file {
3784 Some(file) => node.file == file,
3785 None => true,
3786 }
3787}
3788
3789fn symbol_query_matches(symbol: &str, query: &str) -> bool {
3790 symbol == query || unqualified_name(symbol) == query
3791}
3792
3793fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
3794 let source = std::fs::read_to_string(path).ok()?;
3795 Some(source.lines().map(|line| line.trim().to_string()).collect())
3796}
3797
3798#[doc(hidden)]
3799pub fn live_callgraph_edge_snapshot(
3800 project_root: &Path,
3801 files: &[PathBuf],
3802) -> Result<BTreeSet<StoredEdge>> {
3803 let files = normalize_file_list(project_root, files)?;
3804 let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
3805 let mut file_data = Vec::new();
3806 for file in &files {
3807 let canon = canonicalize_path(file);
3808 let data = graph.build_file(&canon)?.clone();
3809 file_data.push((canon, data));
3810 }
3811
3812 let mut edges = BTreeSet::new();
3813 for (caller_file, data) in &file_data {
3814 for (caller_symbol, call_sites) in &data.calls_by_symbol {
3815 for call_site in call_sites {
3816 let resolution = graph.resolve_cross_file_edge(
3817 &call_site.full_callee,
3818 &call_site.callee_name,
3819 caller_file,
3820 &data.import_block,
3821 );
3822 let (target_file, target_symbol) = match resolution {
3823 EdgeResolution::Resolved { file, symbol } => (file, symbol),
3824 EdgeResolution::Unresolved { callee_name } => {
3825 if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
3826 continue;
3827 }
3828 let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
3829 data,
3830 caller_file,
3831 &callee_name,
3832 ) else {
3833 continue;
3834 };
3835 (caller_file.clone(), target_symbol)
3836 }
3837 };
3838 if target_file == *caller_file && target_symbol == *caller_symbol {
3839 continue;
3840 }
3841 edges.insert(StoredEdge {
3842 source_file: relative_path(project_root, caller_file),
3843 source_symbol: caller_symbol.clone(),
3844 target_file: relative_path(project_root, &target_file),
3845 target_symbol,
3846 kind: "call".to_string(),
3847 line: call_site.line,
3848 });
3849 }
3850 }
3851 }
3852 Ok(edges)
3853}
3854
3855fn acquire_writer_lease(
3856 callgraph_dir: &Path,
3857 project_key: &str,
3858 project_root: &Path,
3859) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
3860 crate::root_cache::WriterLease::acquire_shared(
3861 crate::root_cache::RootCacheDomain::Callgraph,
3862 callgraph_dir,
3863 project_key,
3864 project_root,
3865 )
3866 .map_err(CallGraphStoreError::from)
3867}
3868
3869fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
3870 if lease.verify()? {
3871 Ok(())
3872 } else {
3873 Err(CallGraphStoreError::Unavailable(format!(
3874 "callgraph writer lease for key {} lost epoch {}; aborting write",
3875 lease.key(),
3876 lease.epoch()
3877 )))
3878 }
3879}
3880
3881fn legacy_migration_completion_line(
3882 project_key: &str,
3883 method: &str,
3884 legacy_bytes: u64,
3885 migrated_bytes: u64,
3886) -> String {
3887 format!(
3888 "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
3889 )
3890}
3891
3892fn log_legacy_migration_completion(
3893 project_key: &str,
3894 method: &str,
3895 legacy_bytes: u64,
3896 migrated_bytes: u64,
3897) {
3898 crate::slog_info!(
3899 "{}",
3900 legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
3901 );
3902}
3903
3904fn try_legacy_migration_or_fallback(
3905 callgraph_dir: &Path,
3906 project_root: &Path,
3907 project_key: &str,
3908 writer_lease: Arc<crate::root_cache::WriterLease>,
3909) -> Result<Option<CallGraphStore>> {
3910 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
3911 if partitions.is_empty() {
3912 return Ok(None);
3913 }
3914
3915 for partition in &partitions {
3916 if let Some(source) = newest_superseded_legacy_generation(partition)? {
3917 if !migration_disk_floor_allows(&source, callgraph_dir)? {
3918 return open_legacy_fallback_store(
3919 callgraph_dir,
3920 project_root,
3921 project_key,
3922 &partitions,
3923 );
3924 }
3925 match publish_generation_copy_migration(
3926 callgraph_dir,
3927 project_key,
3928 &source,
3929 Arc::clone(&writer_lease),
3930 ) {
3931 Ok(published) => {
3932 log_legacy_migration_completion(
3933 project_key,
3934 "generation_copy",
3935 source.source_bytes,
3936 published.migrated_bytes,
3937 );
3938 return CallGraphStore::open_generation(
3939 callgraph_dir,
3940 project_root.to_path_buf(),
3941 project_key.to_string(),
3942 published.generation,
3943 writer_lease,
3944 )
3945 .map(Some);
3946 }
3947 Err(error) => {
3948 crate::slog_warn!(
3949 "root-keyed callgraph generation-copy migration failed from {}: {}",
3950 source.sqlite_path.display(),
3951 error
3952 );
3953 return open_legacy_fallback_store(
3954 callgraph_dir,
3955 project_root,
3956 project_key,
3957 &partitions,
3958 );
3959 }
3960 }
3961 }
3962
3963 if let Some(source) = current_legacy_generation(partition)? {
3964 if !migration_disk_floor_allows(&source, callgraph_dir)? {
3965 return open_legacy_fallback_store(
3966 callgraph_dir,
3967 project_root,
3968 project_key,
3969 &partitions,
3970 );
3971 }
3972 match publish_backup_migration(
3973 callgraph_dir,
3974 project_key,
3975 &source,
3976 Arc::clone(&writer_lease),
3977 ) {
3978 Ok(published) => {
3979 log_legacy_migration_completion(
3980 project_key,
3981 "sqlite_backup",
3982 source.source_bytes,
3983 published.migrated_bytes,
3984 );
3985 return CallGraphStore::open_generation(
3986 callgraph_dir,
3987 project_root.to_path_buf(),
3988 project_key.to_string(),
3989 published.generation,
3990 writer_lease,
3991 )
3992 .map(Some);
3993 }
3994 Err(error) => {
3995 crate::slog_warn!(
3996 "root-keyed callgraph backup migration failed from {}: {}",
3997 source.sqlite_path.display(),
3998 error
3999 );
4000 return open_legacy_fallback_store(
4001 callgraph_dir,
4002 project_root,
4003 project_key,
4004 &partitions,
4005 );
4006 }
4007 }
4008 }
4009 }
4010
4011 open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
4012}
4013
4014fn open_legacy_fallback_store(
4015 callgraph_dir: &Path,
4016 project_root: &Path,
4017 project_key: &str,
4018 partitions: &[LegacyCallgraphPartition],
4019) -> Result<Option<CallGraphStore>> {
4020 let Some(target) = first_ready_legacy_target(partitions)? else {
4021 return Ok(None);
4022 };
4023 crate::slog_warn!(
4024 "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
4025 target.partition.harness,
4026 target.sqlite_path.display()
4027 );
4028 let conn = open_readonly_connection(&target.sqlite_path)?;
4029 if !database_ready(&conn).unwrap_or(false) {
4030 return Ok(None);
4031 }
4032 let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
4033 let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
4034 Ok(Some(CallGraphStore::from_connection(
4035 project_root.to_path_buf(),
4036 project_key.to_string(),
4037 target.sqlite_path,
4038 callgraph_dir.to_path_buf(),
4039 true,
4040 target.generation,
4041 None,
4042 Some(read_marker),
4043 conn,
4044 )))
4045}
4046
4047fn migration_disk_floor_allows(
4048 source: &LegacyCallgraphTarget,
4049 callgraph_dir: &Path,
4050) -> Result<bool> {
4051 let available = migration_available_disk(callgraph_dir)?;
4052 let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
4053 source.source_bytes,
4054 available,
4055 );
4056 if decision.should_skip_copy() {
4057 crate::slog_warn!(
4058 "{}",
4059 decision.warning_message(&source.sqlite_path, callgraph_dir)
4060 );
4061 return Ok(false);
4062 }
4063 Ok(true)
4064}
4065
4066fn migration_available_disk(path: &Path) -> Result<u64> {
4067 if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
4068 return Ok(bytes);
4069 }
4070 crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
4071}
4072
4073fn legacy_callgraph_partitions(
4074 callgraph_dir: &Path,
4075 project_key: &str,
4076) -> Result<Vec<LegacyCallgraphPartition>> {
4077 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
4078 return Ok(Vec::new());
4079 };
4080 let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
4081 let mut partitions = inventory
4082 .into_iter()
4083 .filter(|entry| {
4084 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
4085 && entry.key == project_key
4086 })
4087 .map(|entry| {
4088 let dir = if entry.path.is_dir() {
4089 entry.path.clone()
4090 } else {
4091 entry
4092 .path
4093 .parent()
4094 .map(Path::to_path_buf)
4095 .unwrap_or_else(|| entry.path.clone())
4096 };
4097 LegacyCallgraphPartition {
4098 harness: entry.harness,
4099 dir,
4100 key: entry.key,
4101 bytes: entry.bytes,
4102 freshness: entry.callgraph_pointer_mtime,
4103 }
4104 })
4105 .collect::<Vec<_>>();
4106 partitions.sort_by(|left, right| {
4107 right
4108 .freshness
4109 .cmp(&left.freshness)
4110 .then_with(|| right.bytes.cmp(&left.bytes))
4111 .then_with(|| left.harness.cmp(&right.harness))
4112 });
4113 Ok(partitions)
4114}
4115
4116fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
4117 let domain_dir = callgraph_dir.parent()?;
4118 if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
4119 return None;
4120 }
4121 domain_dir.parent().map(Path::to_path_buf)
4122}
4123
4124pub(crate) fn all_legacy_partitions_migrated_for_keys(
4125 callgraph_dir: &Path,
4126 configured_keys: &BTreeSet<String>,
4127) -> Result<bool> {
4128 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
4129 return Ok(false);
4130 };
4131 let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
4132 .into_iter()
4133 .filter(|entry| {
4134 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
4135 && configured_keys.contains(&entry.key)
4136 })
4137 .map(|entry| entry.key)
4138 .collect::<BTreeSet<_>>();
4139 if legacy_keys.is_empty() {
4140 return Ok(false);
4141 }
4142
4143 for key in legacy_keys {
4144 let migrated_dir = storage_root.join("callgraph").join(&key);
4145 let Some(generation) = read_pointer(&migrated_dir, &key) else {
4146 return Ok(false);
4147 };
4148 if !migration_generation_requires_manifest(&generation)
4149 || !migration_manifest_valid(&migrated_dir, &generation)
4150 {
4151 return Ok(false);
4152 }
4153 }
4154 Ok(true)
4155}
4156
4157fn newest_superseded_legacy_generation(
4158 partition: &LegacyCallgraphPartition,
4159) -> Result<Option<LegacyCallgraphTarget>> {
4160 let Some(current) = read_pointer(&partition.dir, &partition.key) else {
4161 return Ok(None);
4162 };
4163 let prefix = format!("{}.g", partition.key);
4164 let Ok(entries) = std::fs::read_dir(&partition.dir) else {
4165 return Ok(None);
4166 };
4167 let mut candidates = Vec::new();
4168 for entry in entries.flatten() {
4169 let name = entry.file_name().to_string_lossy().to_string();
4170 if name == current
4171 || name.contains(".tmp.")
4172 || !name.starts_with(&prefix)
4173 || !name.ends_with(".sqlite")
4174 {
4175 continue;
4176 }
4177 let path = entry.path();
4178 if !db_path_ready(&path) {
4179 continue;
4180 }
4181 let modified = entry
4182 .metadata()
4183 .and_then(|metadata| metadata.modified())
4184 .unwrap_or(SystemTime::UNIX_EPOCH);
4185 candidates.push((modified, path, name));
4186 }
4187 candidates.sort_by(|left, right| right.0.cmp(&left.0));
4188 let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
4189 return Ok(None);
4190 };
4191 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
4192 Ok(Some(LegacyCallgraphTarget {
4193 partition: partition.clone(),
4194 sqlite_path,
4195 generation: Some(generation),
4196 source_bytes,
4197 source_blake3: String::new(),
4198 }))
4199}
4200
4201fn current_legacy_generation(
4202 partition: &LegacyCallgraphPartition,
4203) -> Result<Option<LegacyCallgraphTarget>> {
4204 let Some(target) = ready_legacy_target(partition)? else {
4205 return Ok(None);
4206 };
4207 let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
4208 if has_superseded {
4209 return Ok(None);
4210 }
4211 Ok(Some(target))
4212}
4213
4214fn freshest_legacy_fallback_target(
4215 callgraph_dir: &Path,
4216 project_key: &str,
4217) -> Result<Option<LegacyCallgraphTarget>> {
4218 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
4219 first_ready_legacy_target(&partitions)
4220}
4221
4222fn first_ready_legacy_target(
4223 partitions: &[LegacyCallgraphPartition],
4224) -> Result<Option<LegacyCallgraphTarget>> {
4225 for partition in partitions {
4226 if let Some(target) = ready_legacy_target(partition)? {
4227 return Ok(Some(target));
4228 }
4229 }
4230 Ok(None)
4231}
4232
4233fn ready_legacy_target(
4234 partition: &LegacyCallgraphPartition,
4235) -> Result<Option<LegacyCallgraphTarget>> {
4236 if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
4237 let sqlite_path = partition.dir.join(&generation);
4238 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
4239 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
4240 return Ok(Some(LegacyCallgraphTarget {
4241 partition: partition.clone(),
4242 sqlite_path,
4243 generation: Some(generation),
4244 source_bytes,
4245 source_blake3: String::new(),
4246 }));
4247 }
4248 }
4249
4250 let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
4251 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
4252 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
4253 return Ok(Some(LegacyCallgraphTarget {
4254 partition: partition.clone(),
4255 sqlite_path,
4256 generation: None,
4257 source_bytes,
4258 source_blake3: String::new(),
4259 }));
4260 }
4261 Ok(None)
4262}
4263
4264fn publish_generation_copy_migration(
4265 callgraph_dir: &Path,
4266 project_key: &str,
4267 source: &LegacyCallgraphTarget,
4268 writer_lease: Arc<crate::root_cache::WriterLease>,
4269) -> Result<PublishedLegacyMigration> {
4270 let generation = migration_generation_file_name(project_key, "copy");
4271 let temp_path = migration_temp_path(callgraph_dir, &generation);
4272 remove_sqlite_file_set(&temp_path);
4273 copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
4274 fail_after_temp_copy_for_test()?;
4275
4276 let mut source = source.clone();
4277 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
4278 source.source_blake3 = fingerprint.blake3;
4279 let generation = publish_migrated_generation(
4280 callgraph_dir,
4281 project_key,
4282 &generation,
4283 &temp_path,
4284 &source,
4285 fingerprint.bytes,
4286 writer_lease,
4287 "generation_copy",
4288 )?;
4289 Ok(PublishedLegacyMigration {
4290 generation,
4291 migrated_bytes: fingerprint.bytes,
4292 })
4293}
4294
4295fn publish_backup_migration(
4296 callgraph_dir: &Path,
4297 project_key: &str,
4298 source: &LegacyCallgraphTarget,
4299 writer_lease: Arc<crate::root_cache::WriterLease>,
4300) -> Result<PublishedLegacyMigration> {
4301 if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
4302 return Err(CallGraphStoreError::Unavailable(
4303 "legacy callgraph backup migration budget exhausted by test seam".to_string(),
4304 ));
4305 }
4306
4307 let generation = migration_generation_file_name(project_key, "backup");
4308 let temp_path = migration_temp_path(callgraph_dir, &generation);
4309 remove_sqlite_file_set(&temp_path);
4310
4311 let source_conn = open_readonly_connection(&source.sqlite_path)?;
4312 let mut destination = Connection::open(&temp_path)?;
4313 destination.busy_timeout(Duration::from_secs(5))?;
4314 let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
4315 let started = Instant::now();
4316 let mut retries = 0;
4317 loop {
4318 match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
4319 rusqlite::backup::StepResult::Done => break,
4320 rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
4321 rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
4322 retries += 1;
4323 if retries > MIGRATION_BACKUP_RETRY_BUDGET
4324 || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
4325 {
4326 return Err(CallGraphStoreError::Unavailable(format!(
4327 "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
4328 )));
4329 }
4330 std::thread::sleep(Duration::from_millis(20));
4331 }
4332 _ => {
4333 return Err(CallGraphStoreError::Unavailable(
4334 "legacy callgraph backup returned an unknown step result".to_string(),
4335 ));
4336 }
4337 }
4338 }
4339 drop(backup);
4340
4341 let integrity: String =
4342 destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
4343 if integrity != "ok" {
4344 return Err(CallGraphStoreError::Unavailable(format!(
4345 "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
4346 )));
4347 }
4348 if !database_ready(&destination)? {
4349 return Err(CallGraphStoreError::Unavailable(
4350 "legacy callgraph backup produced a database without ready metadata".to_string(),
4351 ));
4352 }
4353 destination.execute_batch("PRAGMA optimize;")?;
4354 drop(destination);
4355 sync_file(&temp_path)?;
4356 fail_after_temp_copy_for_test()?;
4357
4358 let mut source = source.clone();
4359 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
4360 source.source_blake3 = fingerprint.blake3;
4361 let generation = publish_migrated_generation(
4362 callgraph_dir,
4363 project_key,
4364 &generation,
4365 &temp_path,
4366 &source,
4367 fingerprint.bytes,
4368 writer_lease,
4369 "sqlite_backup",
4370 )?;
4371 Ok(PublishedLegacyMigration {
4372 generation,
4373 migrated_bytes: fingerprint.bytes,
4374 })
4375}
4376
4377fn publish_migrated_generation(
4378 callgraph_dir: &Path,
4379 project_key: &str,
4380 generation: &str,
4381 temp_path: &Path,
4382 source: &LegacyCallgraphTarget,
4383 migrated_bytes: u64,
4384 writer_lease: Arc<crate::root_cache::WriterLease>,
4385 method: &str,
4386) -> Result<String> {
4387 let gen_path = callgraph_dir.join(generation);
4388 let publication = publish_if_current(|| {
4389 verify_writer_lease(&writer_lease)?;
4390 remove_sqlite_file_set(&gen_path);
4391 rename_sqlite_file_set(temp_path, &gen_path)?;
4392 crate::fs_lock::sync_parent(&gen_path);
4393
4394 verify_writer_lease(&writer_lease)?;
4395 publish_pointer(callgraph_dir, project_key, generation)?;
4396 write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
4397 Ok(generation.to_string())
4398 });
4399 if matches!(publication, Err(CallGraphStoreError::Superseded)) {
4400 remove_sqlite_file_set(temp_path);
4401 }
4402 publication
4403}
4404
4405fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
4406 if let Some(parent) = destination.parent() {
4407 std::fs::create_dir_all(parent)?;
4408 }
4409 for suffix in SQLITE_FILE_SET_SUFFIXES {
4410 let source_path = sqlite_file_set_path(source, suffix);
4411 if !source_path.is_file() {
4412 continue;
4413 }
4414 let destination_path = sqlite_file_set_path(destination, suffix);
4415 std::fs::copy(&source_path, &destination_path)?;
4416 sync_file(&destination_path)?;
4417 }
4418 Ok(())
4419}
4420
4421fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
4422 for suffix in SQLITE_FILE_SET_SUFFIXES {
4423 let source_path = sqlite_file_set_path(source, suffix);
4424 if !source_path.exists() {
4425 continue;
4426 }
4427 let destination_path = sqlite_file_set_path(destination, suffix);
4428 if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
4429 let _ = std::fs::remove_file(&source_path);
4430 return Err(error.into());
4431 }
4432 }
4433 Ok(())
4434}
4435
4436fn sqlite_file_set_size(path: &Path) -> Result<u64> {
4437 let mut bytes = 0_u64;
4438 for suffix in SQLITE_FILE_SET_SUFFIXES {
4439 let member = sqlite_file_set_path(path, suffix);
4440 if !member.is_file() {
4441 continue;
4442 }
4443 bytes = bytes.saturating_add(member.metadata()?.len());
4444 }
4445 Ok(bytes)
4446}
4447
4448fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
4449 let mut hasher = blake3::Hasher::new();
4450 let mut bytes = 0_u64;
4451 let mut buffer = [0_u8; 64 * 1024];
4452 for suffix in SQLITE_FILE_SET_SUFFIXES {
4453 let member = sqlite_file_set_path(path, suffix);
4454 if !member.is_file() {
4455 continue;
4456 }
4457 hasher.update(suffix.as_bytes());
4458 let mut file = std::fs::File::open(&member)?;
4459 loop {
4460 let read = file.read(&mut buffer)?;
4461 if read == 0 {
4462 break;
4463 }
4464 bytes = bytes.saturating_add(read as u64);
4465 hasher.update(&buffer[..read]);
4466 }
4467 }
4468 Ok(SourceFingerprint {
4469 bytes,
4470 blake3: hash_to_hex(hasher.finalize()),
4471 })
4472}
4473
4474fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
4475 if suffix.is_empty() {
4476 path.to_path_buf()
4477 } else {
4478 PathBuf::from(format!("{}{suffix}", path.display()))
4479 }
4480}
4481
4482fn sync_file(path: &Path) -> Result<()> {
4483 let file = std::fs::OpenOptions::new()
4484 .read(true)
4485 .write(true)
4486 .open(path)?;
4487 file.sync_all()?;
4488 Ok(())
4489}
4490
4491fn fail_after_temp_copy_for_test() -> Result<()> {
4492 if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
4493 return Err(CallGraphStoreError::Unavailable(
4494 "legacy callgraph migration stopped after temp copy by test seam".to_string(),
4495 ));
4496 }
4497 Ok(())
4498}
4499
4500fn migration_generation_file_name(project_key: &str, method: &str) -> String {
4501 format!(
4502 "{project_key}.g{}.{}{}{}.sqlite",
4503 now_nanos(),
4504 std::process::id(),
4505 MIGRATION_GENERATION_TAG,
4506 method
4507 )
4508}
4509
4510fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
4511 callgraph_dir.join(format!(
4512 "{generation}.tmp.{}.{}",
4513 std::process::id(),
4514 now_nanos()
4515 ))
4516}
4517
4518fn write_migration_manifest(
4519 callgraph_dir: &Path,
4520 generation: &str,
4521 source: &LegacyCallgraphTarget,
4522 migrated_bytes: u64,
4523 method: &str,
4524) -> Result<()> {
4525 let manifest_path = migration_manifest_path(callgraph_dir, generation);
4526 let temp_path = manifest_path.with_extension(format!(
4527 "migration.json.tmp.{}.{}",
4528 std::process::id(),
4529 now_nanos()
4530 ));
4531 let manifest = serde_json::json!({
4532 "version": MIGRATION_MANIFEST_VERSION,
4533 "method": method,
4534 "target_generation": generation,
4535 "source_harness": source.partition.harness,
4536 "source_path": source.sqlite_path.display().to_string(),
4537 "source_generation": source.generation,
4538 "source_bytes": source.source_bytes,
4539 "source_blake3": source.source_blake3,
4540 "migrated_bytes": migrated_bytes,
4541 });
4542 {
4543 use std::io::Write as _;
4544 let mut file = std::fs::File::create(&temp_path)?;
4545 file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
4546 file.write_all(b"\n")?;
4547 file.sync_all()?;
4548 }
4549 if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
4550 let _ = std::fs::remove_file(&temp_path);
4551 return Err(error.into());
4552 }
4553 crate::fs_lock::sync_parent(&manifest_path);
4554 Ok(())
4555}
4556
4557fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
4558 callgraph_dir.join(format!("{generation}.migration.json"))
4559}
4560
4561fn migration_generation_requires_manifest(generation: &str) -> bool {
4562 generation.contains(MIGRATION_GENERATION_TAG)
4563}
4564
4565fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
4566 if !migration_generation_requires_manifest(generation) {
4567 return true;
4568 }
4569 let path = migration_manifest_path(callgraph_dir, generation);
4570 let Ok(bytes) = std::fs::read(path) else {
4571 return false;
4572 };
4573 let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
4574 return false;
4575 };
4576 value.get("version").and_then(serde_json::Value::as_u64)
4577 == Some(MIGRATION_MANIFEST_VERSION as u64)
4578 && value
4579 .get("target_generation")
4580 .and_then(serde_json::Value::as_str)
4581 == Some(generation)
4582 && value
4583 .get("source_bytes")
4584 .and_then(serde_json::Value::as_u64)
4585 .is_some_and(|bytes| bytes > 0)
4586 && value
4587 .get("source_blake3")
4588 .and_then(serde_json::Value::as_str)
4589 .is_some_and(|hash| hash.len() == 64)
4590}
4591
4592fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
4593 let pointer_generation = read_pointer(callgraph_dir, project_key);
4594 if let Some(generation) = pointer_generation.as_deref() {
4595 if migration_generation_requires_manifest(generation)
4596 && !migration_manifest_valid(callgraph_dir, generation)
4597 {
4598 let path = callgraph_dir.join(generation);
4599 remove_sqlite_file_set(&path);
4600 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
4601 let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
4602 }
4603 }
4604
4605 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
4606 return;
4607 };
4608 for entry in entries.flatten() {
4609 let name = entry.file_name().to_string_lossy().to_string();
4610 let path = entry.path();
4611 if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
4612 let _ = std::fs::remove_file(path);
4613 continue;
4614 }
4615 if name.starts_with(&format!("{project_key}.g"))
4616 && name.ends_with(".sqlite")
4617 && name.contains(MIGRATION_GENERATION_TAG)
4618 && pointer_generation.as_deref() != Some(&name)
4619 && !migration_manifest_valid(callgraph_dir, &name)
4620 {
4621 remove_sqlite_file_set(&path);
4622 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
4623 }
4624 }
4625 crate::fs_lock::sync_parent(callgraph_dir);
4626}
4627
4628fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
4629 let mut hasher = blake3::Hasher::new();
4630 hasher.update(path.to_string_lossy().as_bytes());
4631 if let Some(generation) = generation {
4632 hasher.update(generation.as_bytes());
4633 }
4634 let digest = hash_to_hex(hasher.finalize());
4635 format!("legacy-{}", &digest[..16])
4636}
4637
4638fn open_readonly_connection(path: &Path) -> Result<Connection> {
4639 let uri = sqlite_readonly_uri(path);
4640 let conn = Connection::open_with_flags(
4641 &uri,
4642 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
4643 )?;
4644 conn.busy_timeout(reader_busy_timeout())?;
4645 conn.execute_batch("PRAGMA query_only=ON;")?;
4646 Ok(conn)
4647}
4648
4649fn reader_busy_timeout() -> Duration {
4650 let jitter = (now_nanos() % 500) as u64;
4651 Duration::from_millis(250 + jitter)
4652}
4653
4654fn sqlite_readonly_uri(path: &Path) -> String {
4655 let raw = path.to_string_lossy().replace('\\', "/");
4656 let encoded = percent_encode_sqlite_uri_path(&raw);
4657 if raw.starts_with('/') {
4658 format!("file://{encoded}?mode=ro")
4659 } else if raw.as_bytes().get(1) == Some(&b':') {
4660 format!("file:///{encoded}?mode=ro")
4661 } else {
4662 format!("file:{encoded}?mode=ro")
4663 }
4664}
4665
4666fn percent_encode_sqlite_uri_path(path: &str) -> String {
4667 let mut encoded = String::with_capacity(path.len());
4668 for byte in path.bytes() {
4669 match byte {
4670 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
4671 encoded.push(byte as char)
4672 }
4673 _ => encoded.push_str(&format!("%{byte:02X}")),
4674 }
4675 }
4676 encoded
4677}
4678
4679fn configure_connection(conn: &Connection) -> Result<()> {
4680 conn.pragma_update(None, "journal_mode", "WAL")?;
4681 conn.pragma_update(None, "busy_timeout", 5_000)?;
4682 Ok(())
4683}
4684
4685fn configure_build_connection(conn: &Connection) -> Result<()> {
4686 conn.pragma_update(None, "journal_mode", "DELETE")?;
4687 conn.pragma_update(None, "busy_timeout", 5_000)?;
4688 Ok(())
4689}
4690
4691fn initialize_schema(conn: &Connection) -> Result<()> {
4692 conn.execute_batch(
4693 "CREATE TABLE IF NOT EXISTS files (
4694 path TEXT PRIMARY KEY,
4695 content_hash TEXT NOT NULL,
4696 mtime_ns INTEGER NOT NULL,
4697 size INTEGER NOT NULL,
4698 lang TEXT NOT NULL,
4699 is_dead_code_root INTEGER NOT NULL DEFAULT 0,
4700 is_public_api INTEGER NOT NULL DEFAULT 0,
4701 surface_fingerprint TEXT NOT NULL,
4702 indexed_at INTEGER NOT NULL
4703 );
4704
4705 CREATE TABLE IF NOT EXISTS nodes (
4706 id TEXT PRIMARY KEY,
4707 file_path TEXT NOT NULL,
4708 name TEXT NOT NULL,
4709 scoped_name TEXT NOT NULL,
4710 kind TEXT NOT NULL,
4711 start_line INTEGER NOT NULL,
4712 start_col INTEGER NOT NULL,
4713 end_line INTEGER NOT NULL,
4714 end_col INTEGER NOT NULL,
4715 range_ordinal INTEGER NOT NULL,
4716 signature TEXT,
4717 exported INTEGER NOT NULL,
4718 is_default_export INTEGER NOT NULL,
4719 is_type_like INTEGER NOT NULL,
4720 is_callgraph_entry_point INTEGER NOT NULL,
4721 provenance TEXT NOT NULL,
4722 UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
4723 );
4724 CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
4725 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
4726 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
4727
4728 CREATE TABLE IF NOT EXISTS refs (
4729 ref_id TEXT PRIMARY KEY,
4730 caller_node TEXT,
4731 caller_file TEXT NOT NULL,
4732 kind TEXT NOT NULL,
4733 short_name TEXT,
4734 full_ref TEXT,
4735 module_path TEXT,
4736 import_kind TEXT,
4737 local_name TEXT,
4738 requested_name TEXT,
4739 namespace_alias TEXT,
4740 wildcard INTEGER NOT NULL DEFAULT 0,
4741 line INTEGER NOT NULL,
4742 byte_start INTEGER NOT NULL,
4743 byte_end INTEGER NOT NULL,
4744 status TEXT NOT NULL,
4745 target_node TEXT,
4746 target_file TEXT,
4747 target_symbol TEXT,
4748 provenance TEXT NOT NULL
4749 );
4750 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
4751 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
4752 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
4753 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
4754 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
4755
4756 CREATE TABLE IF NOT EXISTS file_dependencies (
4757 file_path TEXT NOT NULL,
4758 dep_file TEXT NOT NULL,
4759 PRIMARY KEY(file_path, dep_file)
4760 );
4761 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
4762
4763 CREATE TABLE IF NOT EXISTS edges (
4764 edge_id TEXT PRIMARY KEY,
4765 ref_id TEXT NOT NULL,
4766 source_node TEXT NOT NULL,
4767 target_node TEXT,
4768 target_file TEXT NOT NULL,
4769 target_symbol TEXT NOT NULL,
4770 kind TEXT NOT NULL,
4771 line INTEGER NOT NULL,
4772 provenance TEXT NOT NULL
4773 );
4774 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
4775 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
4776 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
4777 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
4778
4779 CREATE TABLE IF NOT EXISTS dispatch_hints (
4780 id TEXT PRIMARY KEY,
4781 method_name TEXT NOT NULL,
4782 caller_node TEXT NOT NULL,
4783 file TEXT NOT NULL,
4784 line INTEGER NOT NULL,
4785 byte_start INTEGER NOT NULL,
4786 byte_end INTEGER NOT NULL,
4787 provenance TEXT NOT NULL
4788 );
4789 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
4790
4791 CREATE TABLE IF NOT EXISTS type_ref_names (
4792 name TEXT PRIMARY KEY
4793 );
4794
4795 CREATE TABLE IF NOT EXISTS backend_file_state (
4796 backend TEXT NOT NULL,
4797 workspace_root TEXT NOT NULL,
4798 file_path TEXT NOT NULL,
4799 content_hash TEXT NOT NULL,
4800 status TEXT NOT NULL,
4801 updated_at INTEGER NOT NULL,
4802 PRIMARY KEY(backend, workspace_root, file_path, content_hash)
4803 );
4804 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
4805
4806 CREATE TABLE IF NOT EXISTS meta (
4807 k TEXT PRIMARY KEY,
4808 v TEXT NOT NULL
4809 );",
4810 )?;
4811 insert_meta(conn)?;
4812 Ok(())
4813}
4814
4815fn insert_meta(conn: &Connection) -> Result<()> {
4816 conn.execute(
4817 "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
4818 params![SCHEMA_VERSION.to_string()],
4819 )?;
4820 conn.execute(
4821 "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
4822 params![schema_fingerprint()],
4823 )?;
4824 Ok(())
4825}
4826
4827fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
4828 conn.execute(
4829 "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
4830 params![if ready { "1" } else { "0" }],
4831 )?;
4832 Ok(())
4833}
4834
4835fn database_ready(conn: &Connection) -> Result<bool> {
4836 let schema_version: Option<String> = conn
4837 .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
4838 row.get(0)
4839 })
4840 .optional()?;
4841 let fingerprint: Option<String> = conn
4842 .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
4843 row.get(0)
4844 })
4845 .optional()?;
4846 let ready: Option<String> = conn
4847 .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
4848 .optional()?;
4849
4850 let expected_schema = SCHEMA_VERSION.to_string();
4851 let expected_fingerprint = schema_fingerprint();
4852 Ok(schema_version.as_deref() == Some(expected_schema.as_str())
4853 && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
4854 && ready.as_deref() == Some("1"))
4855}
4856
4857fn ensure_database_ready(conn: &Connection) -> Result<()> {
4858 if database_ready(conn)? {
4859 Ok(())
4860 } else {
4861 Err(CallGraphStoreError::Unavailable(
4862 "database is missing, stale, or mid-build".to_string(),
4863 ))
4864 }
4865}
4866
4867fn schema_fingerprint() -> String {
4868 let input =
4873 format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
4874 hash_to_hex(blake3::hash(input.as_bytes()))
4875}
4876
4877fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
4878 tx.execute_batch(
4879 "DELETE FROM edges;
4880 DELETE FROM file_dependencies;
4881 DELETE FROM refs;
4882 DELETE FROM dispatch_hints;
4883 DELETE FROM type_ref_names;
4884 DELETE FROM backend_file_state;
4885 DELETE FROM nodes;
4886 DELETE FROM files;",
4887 )?;
4888 Ok(())
4889}
4890
4891fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
4892 tx.execute_batch(
4893 "DROP INDEX IF EXISTS idx_nodes_file;
4894 DROP INDEX IF EXISTS idx_nodes_name;
4895 DROP INDEX IF EXISTS idx_nodes_scoped;
4896 DROP INDEX IF EXISTS idx_refs_short_name;
4897 DROP INDEX IF EXISTS idx_refs_kind_caller_file;
4898 DROP INDEX IF EXISTS idx_refs_caller_file;
4899 DROP INDEX IF EXISTS idx_refs_caller_node_kind;
4900 DROP INDEX IF EXISTS idx_refs_target_file;
4901 DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
4902 DROP INDEX IF EXISTS idx_edges_source_kind;
4903 DROP INDEX IF EXISTS idx_edges_target_kind;
4904 DROP INDEX IF EXISTS idx_edges_target_file_symbol;
4905 DROP INDEX IF EXISTS idx_edges_ref_id;
4906 DROP INDEX IF EXISTS idx_dispatch_hints_method;
4907 DROP INDEX IF EXISTS idx_backend_file_state_file;",
4908 )?;
4909 Ok(())
4910}
4911
4912fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
4913 tx.execute_batch(
4914 "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
4915 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
4916 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
4917 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
4918 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
4919 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
4920 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
4921 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
4922 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
4923 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
4924 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
4925 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
4926 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
4927 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
4928 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
4929 )?;
4930 Ok(())
4931}
4932
4933const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
4934 ("files", "path"),
4935 ("nodes", "file_path"),
4936 ("refs", "caller_file"),
4937 ("refs", "target_file"),
4938 ("file_dependencies", "file_path"),
4939 ("file_dependencies", "dep_file"),
4940 ("edges", "target_file"),
4941 ("dispatch_hints", "file"),
4942 ("backend_file_state", "file_path"),
4943];
4944
4945fn reconcile_workspace_roots(
4958 conn: &mut Connection,
4959 project_root: &Path,
4960 allow_repair: bool,
4961) -> Result<OpenRootRepair> {
4962 let roots = stored_workspace_roots(conn)?;
4963 let current_root = project_root.display().to_string();
4964 if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
4965 return Ok(OpenRootRepair::None);
4966 }
4967
4968 if let Some(sample) = sample_absolute_data_path(conn)? {
4969 return Ok(OpenRootRepair::NeedsRebuild {
4970 previous_roots: roots,
4971 current_root,
4972 reason: format!("absolute store data path row {sample}"),
4973 });
4974 }
4975
4976 for stored_root in roots.iter() {
4977 if stored_root == ¤t_root {
4978 continue;
4979 }
4980 if Path::new(stored_root).exists() {
4981 let reason = format!(
4982 "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
4983 );
4984 return Ok(OpenRootRepair::NeedsRebuild {
4985 previous_roots: roots,
4986 current_root,
4987 reason,
4988 });
4989 }
4990 }
4991
4992 if !allow_repair {
4993 return Ok(OpenRootRepair::NeedsRebuild {
4994 previous_roots: roots,
4995 current_root,
4996 reason: "workspace root metadata requires deferred repair".to_string(),
4997 });
4998 }
4999
5000 publish_if_current(|| {
5001 let tx = conn.transaction()?;
5002 tx.execute(
5003 "UPDATE OR IGNORE backend_file_state
5004 SET workspace_root = ?1
5005 WHERE workspace_root <> ?1",
5006 params![¤t_root],
5007 )?;
5008 tx.execute(
5009 "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
5010 params![¤t_root],
5011 )?;
5012 tx.commit()?;
5013 Ok(())
5014 })?;
5015
5016 crate::slog_info!(
5017 "callgraph store re-rooted from {} to {}",
5018 roots.join(", "),
5019 current_root
5020 );
5021 Ok(OpenRootRepair::ReRooted)
5022}
5023
5024fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
5025 let mut stmt = conn.prepare(
5026 "SELECT DISTINCT workspace_root
5027 FROM backend_file_state
5028 ORDER BY workspace_root",
5029 )?;
5030 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
5031 rows.collect::<std::result::Result<Vec<_>, _>>()
5032 .map_err(Into::into)
5033}
5034
5035fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
5036 for (table, column) in STORE_DATA_PATH_COLUMNS {
5037 let sql = format!(
5038 "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
5039 );
5040 let mut stmt = conn.prepare(&sql)?;
5041 let mut rows = stmt.query([])?;
5042 while let Some(row) = rows.next()? {
5043 let value: String = row.get(0)?;
5044 if stored_path_is_absolute(&value) {
5045 return Ok(Some(format!("{table}.{column}={value}")));
5046 }
5047 }
5048 }
5049 Ok(None)
5050}
5051
5052fn stored_path_is_absolute(value: &str) -> bool {
5053 if value.is_empty() {
5054 return false;
5055 }
5056 if Path::new(value).is_absolute() || value.starts_with('/') {
5057 return true;
5058 }
5059 let bytes = value.as_bytes();
5060 if bytes.len() >= 3
5061 && bytes[1] == b':'
5062 && (bytes[2] == b'/' || bytes[2] == b'\\')
5063 && bytes[0].is_ascii_alphabetic()
5064 {
5065 return true;
5066 }
5067 value.starts_with("\\\\") || value.starts_with("//")
5068}
5069
5070fn log_root_repair_rebuild(repair: &OpenRootRepair) {
5071 if let OpenRootRepair::NeedsRebuild {
5072 previous_roots,
5073 current_root,
5074 reason,
5075 } = repair
5076 {
5077 crate::slog_info!(
5078 "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
5079 previous_roots.join(", "),
5080 current_root,
5081 reason
5082 );
5083 }
5084}
5085
5086fn now_nanos() -> u128 {
5088 SystemTime::now()
5089 .duration_since(UNIX_EPOCH)
5090 .unwrap_or(Duration::ZERO)
5091 .as_nanos()
5092}
5093
5094fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
5099 callgraph_dir.join(format!("{project_key}.current"))
5100}
5101
5102fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
5106 callgraph_dir.join(format!("{project_key}.sqlite"))
5107}
5108
5109fn generation_file_name(project_key: &str) -> String {
5113 format!(
5114 "{project_key}.g{}.{}.sqlite",
5115 now_nanos(),
5116 std::process::id()
5117 )
5118}
5119
5120fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
5122 let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
5123 let name = text.trim();
5124 if name.is_empty() {
5125 None
5126 } else {
5127 Some(name.to_string())
5128 }
5129}
5130
5131fn db_path_ready(path: &Path) -> bool {
5134 (|| -> Result<bool> {
5135 let conn = open_readonly_connection(path)?;
5136 database_ready(&conn)
5137 })()
5138 .unwrap_or(false)
5139}
5140
5141fn resolve_ready_target(
5149 callgraph_dir: &Path,
5150 project_key: &str,
5151) -> Option<(PathBuf, Option<String>)> {
5152 for _ in 0..5 {
5153 if let Some(generation) = read_pointer(callgraph_dir, project_key) {
5154 let gen_path = callgraph_dir.join(&generation);
5155 if gen_path.is_file() {
5156 return (migration_manifest_valid(callgraph_dir, &generation)
5157 && db_path_ready(&gen_path))
5158 .then_some((gen_path, Some(generation)));
5159 }
5160 std::thread::sleep(Duration::from_millis(5));
5163 continue;
5164 }
5165 let legacy = legacy_sqlite_path(callgraph_dir, project_key);
5167 return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
5168 }
5169 None
5170}
5171
5172fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
5176 let pointer = pointer_path(callgraph_dir, project_key);
5177 let tmp = callgraph_dir.join(format!(
5178 "{project_key}.current.tmp.{}.{}",
5179 std::process::id(),
5180 now_nanos()
5181 ));
5182 {
5183 use std::io::Write as _;
5184 let mut file = std::fs::File::create(&tmp)?;
5185 file.write_all(generation.as_bytes())?;
5186 file.write_all(b"\n")?;
5187 file.sync_all()?;
5188 }
5189 if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
5190 let _ = std::fs::remove_file(&tmp);
5191 return Err(error.into());
5192 }
5193 crate::fs_lock::sync_parent(&pointer);
5194 Ok(())
5195}
5196
5197#[derive(Clone, Debug)]
5198struct GenerationGcCandidate {
5199 name: String,
5200 path: PathBuf,
5201 modified: SystemTime,
5202}
5203
5204fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
5210 let temp_grace = Duration::from_secs(60);
5211 let now = SystemTime::now();
5212 let pointer_current =
5213 read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
5214 let gen_prefix = format!("{project_key}.g");
5215 let tmp_prefixes = [
5216 format!("{project_key}.g"), format!("{project_key}.current."), format!("{project_key}.sqlite.tmp."), ];
5220 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
5221 return;
5222 };
5223 let mut gens: Vec<GenerationGcCandidate> = Vec::new();
5224 for entry in entries.flatten() {
5225 let name = entry.file_name();
5226 let name = name.to_string_lossy().to_string();
5227 let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
5228 let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
5229
5230 if name.contains(".tmp.") {
5232 if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
5233 let _ = std::fs::remove_file(entry.path());
5234 }
5235 continue;
5236 }
5237
5238 if name == format!("{project_key}.sqlite") {
5241 remove_sqlite_file_set(&entry.path());
5242 continue;
5243 }
5244
5245 if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
5246 gens.push(GenerationGcCandidate {
5247 name,
5248 path: entry.path(),
5249 modified: mtime,
5250 });
5251 }
5252 }
5253
5254 let mut superseded = gens
5255 .iter()
5256 .filter(|generation| generation.name != pointer_current)
5257 .collect::<Vec<_>>();
5258 superseded.sort_by(|left, right| {
5259 right
5260 .modified
5261 .cmp(&left.modified)
5262 .then_with(|| right.name.cmp(&left.name))
5263 });
5264 let previous = superseded.first().map(|generation| generation.name.clone());
5265
5266 for generation in gens {
5267 let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
5268 if generation.name == pointer_current
5269 || Some(generation.name.as_str()) == previous.as_deref()
5270 {
5271 continue;
5272 }
5273
5274 let age = now
5275 .duration_since(generation.modified)
5276 .unwrap_or(Duration::ZERO);
5277 if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
5278 continue;
5279 }
5280
5281 remove_sqlite_file_set(&generation.path);
5282 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
5283 let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
5284 callgraph_dir,
5285 &generation.name,
5286 ));
5287 }
5288}
5289
5290fn remove_sqlite_file_set(path: &Path) {
5291 let _ = std::fs::remove_file(path);
5292 remove_sqlite_sidecars(path);
5293}
5294
5295fn remove_sqlite_sidecars(path: &Path) {
5296 let path_text = path.to_string_lossy();
5297 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
5298 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
5299 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
5300}
5301
5302fn build_pool_size() -> usize {
5310 std::thread::available_parallelism()
5311 .map(|parallelism| parallelism.get())
5312 .unwrap_or(1)
5313 .div_ceil(2)
5314 .clamp(1, 8)
5315}
5316
5317fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
5318 let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
5319 Ok(extract) => Ok(extract),
5320 Err(error) => {
5321 let abs_path =
5322 normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
5323 let rel_path = relative_path(project_root, &abs_path);
5324 let freshness = cache_freshness::collect(&abs_path).ok();
5325 log::debug!(
5326 "callgraph store: skipping {} during cold build: {}",
5327 abs_path.display(),
5328 error
5329 );
5330 Err(ExtractFailure {
5331 rel_path,
5332 freshness,
5333 })
5334 }
5335 };
5336
5337 let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
5338 files.par_iter().map(extract_one).collect()
5339 };
5340
5341 let results = match rayon::ThreadPoolBuilder::new()
5344 .num_threads(build_pool_size())
5345 .thread_name(|index| format!("aft-callgraph-build-{index}"))
5346 .stack_size(8 * 1024 * 1024)
5347 .build()
5348 {
5349 Ok(pool) => pool.install(run),
5350 Err(error) => {
5351 log::warn!(
5352 "callgraph store: bounded build pool unavailable ({error}); using global pool"
5353 );
5354 run()
5355 }
5356 };
5357
5358 let mut extracts = Vec::new();
5359 let mut failures = Vec::new();
5360 for result in results {
5361 match result {
5362 Ok(extract) => extracts.push(extract),
5363 Err(failure) => failures.push(failure),
5364 }
5365 }
5366 BuildExtractsResult { extracts, failures }
5367}
5368
5369fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
5370 let metadata = std::fs::metadata(path)?;
5371 let size = metadata.len();
5372 let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
5373 cache_freshness::zero_hash()
5374 } else if source.len() as u64 == size {
5375 cache_freshness::hash_bytes(source.as_bytes())
5376 } else {
5377 cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
5378 };
5379 Ok(FileFreshness {
5380 mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
5381 size,
5382 content_hash,
5383 })
5384}
5385
5386fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
5387 let abs_path = normalize_file_path(project_root, path)?;
5388 let rel_path = relative_path(project_root, &abs_path);
5389 let source = std::fs::read_to_string(&abs_path)?;
5390 let freshness = collect_source_freshness(&abs_path, &source)?;
5391 let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
5392 let lang = data.lang;
5393 if lang == LangId::Rust {
5394 extend_rust_imports_with_nested_uses(&source, &mut data);
5395 }
5396 let mut nodes = build_node_records(&rel_path, &source, &data)?;
5397 let node_by_scoped: HashMap<String, String> = nodes
5398 .iter()
5399 .map(|node| (node.scoped_name.clone(), node.id.clone()))
5400 .collect();
5401 let import_dependencies =
5402 import_dependencies(project_root, &abs_path, &data.import_block.imports);
5403 let line_index = LineIndex::new(&source);
5404 let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
5405 let rust_reexports = if lang == LangId::Rust {
5406 collect_rust_pub_use_reexport_refs(
5407 project_root,
5408 &abs_path,
5409 &rel_path,
5410 &data.import_block.imports,
5411 &line_index,
5412 )
5413 } else {
5414 ReexportRefs {
5415 raw_refs: Vec::new(),
5416 surface_parts: Vec::new(),
5417 }
5418 };
5419 let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
5420 let mut raw_refs = Vec::new();
5421 raw_refs.extend(build_call_refs(
5422 &rel_path,
5423 &data,
5424 &node_by_scoped,
5425 &import_dependencies,
5426 ));
5427 raw_refs.extend(build_import_refs(
5428 project_root,
5429 &abs_path,
5430 &rel_path,
5431 &data.import_block.imports,
5432 &line_index,
5433 ));
5434 let mut surface_parts = reexports.surface_parts;
5435 surface_parts.extend(rust_reexports.surface_parts);
5436 surface_parts.extend(source_less_exports.surface_parts);
5437 raw_refs.extend(reexports.raw_refs);
5438 raw_refs.extend(rust_reexports.raw_refs);
5439 raw_refs.extend(source_less_exports.raw_refs);
5440 let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
5441 let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
5442
5443 Ok(FileExtract {
5444 rel_path,
5445 freshness,
5446 lang,
5447 data,
5448 nodes,
5449 raw_refs,
5450 dispatch_hints,
5451 surface_fingerprint,
5452 })
5453}
5454
5455fn build_node_records(
5456 rel_path: &str,
5457 source: &str,
5458 data: &FileCallData,
5459) -> Result<Vec<NodeRecord>> {
5460 let mut records = Vec::new();
5461 let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
5462 let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
5463 metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
5464
5465 for (scoped_name, meta) in metadata {
5466 let name = unqualified_name(scoped_name).to_string();
5467 let range = selection_range(source, scoped_name, &name, &meta.range);
5468 let range_key = (
5469 range.start_line,
5470 range.start_col,
5471 range.end_line,
5472 range.end_col,
5473 );
5474 let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
5475 let range_ordinal = *ordinal;
5476 *ordinal += 1;
5477 let id = node_id(rel_path, &range, range_ordinal, scoped_name);
5478 let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
5479 let is_default_export = data
5480 .default_export_symbol
5481 .as_deref()
5482 .map(|default| default == scoped_name || default == name)
5483 .unwrap_or(false);
5484 records.push(NodeRecord {
5485 id,
5486 file_path: rel_path.to_string(),
5487 name: name.clone(),
5488 scoped_name: scoped_name.clone(),
5489 kind: symbol_kind_label(&meta.kind).to_string(),
5490 range,
5491 range_ordinal,
5492 signature: meta.signature.clone(),
5493 exported,
5494 is_default_export,
5495 is_type_like: is_type_like(&meta.kind),
5496 is_callgraph_entry_point: meta.entry_point_attribute.is_some()
5497 || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
5498 });
5499 }
5500
5501 Ok(records)
5502}
5503
5504fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
5505 if scoped_name == TOP_LEVEL_SYMBOL {
5506 return Range {
5507 start_line: 0,
5508 start_col: 0,
5509 end_line: 0,
5510 end_col: 0,
5511 };
5512 }
5513 let Some(line) = source.lines().nth(fallback.start_line as usize) else {
5514 return fallback.clone();
5515 };
5516 let start_col = fallback.start_col as usize;
5517 let search_start = start_col.min(line.len());
5518 if let Some(offset) = line[search_start..].find(name) {
5519 let col = search_start + offset;
5520 return Range {
5521 start_line: fallback.start_line,
5522 start_col: col as u32,
5523 end_line: fallback.start_line,
5524 end_col: (col + name.len()) as u32,
5525 };
5526 }
5527 if let Some(offset) = line.find(name) {
5528 return Range {
5529 start_line: fallback.start_line,
5530 start_col: offset as u32,
5531 end_line: fallback.start_line,
5532 end_col: (offset + name.len()) as u32,
5533 };
5534 }
5535 Range {
5536 start_line: fallback.start_line,
5537 start_col: fallback.start_col,
5538 end_line: fallback.start_line,
5539 end_col: fallback.start_col.saturating_add(name.len() as u32),
5540 }
5541}
5542
5543fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
5544 if scoped_name == TOP_LEVEL_SYMBOL {
5545 return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
5546 }
5547 let input = format!(
5548 "{rel_path}:{}:{}:{}:{}:{ordinal}",
5549 range.start_line, range.start_col, range.end_line, range.end_col
5550 );
5551 format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
5552}
5553
5554fn build_call_refs(
5555 rel_path: &str,
5556 data: &FileCallData,
5557 node_by_scoped: &HashMap<String, String>,
5558 import_dependencies: &BTreeSet<String>,
5559) -> Vec<RawRef> {
5560 let mut refs = Vec::new();
5561 let mut ordinal = 0usize;
5562 let mut symbols: Vec<_> = data.calls_by_symbol.iter().collect();
5563 symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
5564 for (caller_symbol, call_sites) in symbols {
5565 let caller_node = node_by_scoped.get(caller_symbol).cloned();
5566 for call_site in call_sites {
5567 ordinal += 1;
5568 let ref_id = ref_id(&[
5569 rel_path,
5570 "call",
5571 caller_symbol,
5572 &call_site.line.to_string(),
5573 &call_site.byte_start.to_string(),
5574 &call_site.byte_end.to_string(),
5575 &call_site.full_callee,
5576 &ordinal.to_string(),
5577 ]);
5578 refs.push(RawRef {
5579 ref_id,
5580 caller_node: caller_node.clone(),
5581 caller_symbol: Some(caller_symbol.clone()),
5582 caller_file: rel_path.to_string(),
5583 kind: "call".to_string(),
5584 short_name: Some(call_site.callee_name.clone()),
5585 full_ref: Some(call_site.full_callee.clone()),
5586 module_path: None,
5587 import_kind: None,
5588 local_name: Some(call_site.callee_name.clone()),
5589 requested_name: Some(call_site.callee_name.clone()),
5590 namespace_alias: namespace_alias(&call_site.full_callee),
5591 wildcard: false,
5592 line: call_site.line,
5593 byte_start: call_site.byte_start,
5594 byte_end: call_site.byte_end,
5595 dependencies: import_dependencies.clone(),
5596 });
5597 }
5598 }
5599 refs
5600}
5601
5602fn build_import_refs(
5603 project_root: &Path,
5604 abs_path: &Path,
5605 rel_path: &str,
5606 imports: &[ImportStatement],
5607 line_index: &LineIndex,
5608) -> Vec<RawRef> {
5609 let mut refs = Vec::new();
5610 for (index, import) in imports.iter().enumerate() {
5611 let import_kind = import_kind_label(import.kind).to_string();
5612 let local_name = import_local_names(import).join(",");
5613 let requested_name = import_requested_names(import).join(",");
5614 let ref_id = ref_id(&[
5615 rel_path,
5616 "import",
5617 &import.byte_range.start.to_string(),
5618 &import.byte_range.end.to_string(),
5619 &import.module_path,
5620 &index.to_string(),
5621 ]);
5622 refs.push(RawRef {
5623 ref_id,
5624 caller_node: None,
5625 caller_symbol: None,
5626 caller_file: rel_path.to_string(),
5627 kind: "import".to_string(),
5628 short_name: None,
5629 full_ref: Some(import.raw_text.clone()),
5630 module_path: Some(import.module_path.clone()),
5631 import_kind: Some(import_kind),
5632 local_name: empty_to_none(local_name),
5633 requested_name: empty_to_none(requested_name),
5634 namespace_alias: import.namespace_import.clone(),
5635 wildcard: import_is_wildcard(import),
5636 line: line_index.byte_to_line(import.byte_range.start),
5637 byte_start: import.byte_range.start,
5638 byte_end: import.byte_range.end,
5639 dependencies: module_dependencies(project_root, abs_path, &import.module_path),
5640 });
5641 }
5642 refs
5643}
5644
5645fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
5646 let grammar = grammar_for(LangId::Rust);
5647 let mut parser = Parser::new();
5648 if parser.set_language(&grammar).is_err() {
5649 return;
5650 }
5651 let Some(tree) = parser.parse(source, None) else {
5652 return;
5653 };
5654
5655 let mut seen = data
5656 .import_block
5657 .imports
5658 .iter()
5659 .map(|import| (import.byte_range.start, import.byte_range.end))
5660 .collect::<HashSet<_>>();
5661 let mut nested_imports = Vec::new();
5662 collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
5663 if nested_imports.is_empty() {
5664 return;
5665 }
5666
5667 data.import_block.imports.extend(nested_imports);
5668 data.import_block
5669 .imports
5670 .sort_by_key(|import| import.byte_range.start);
5671 data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
5672}
5673
5674fn collect_rust_use_imports(
5675 source: &str,
5676 node: Node<'_>,
5677 seen: &mut HashSet<(usize, usize)>,
5678 imports: &mut Vec<ImportStatement>,
5679) {
5680 if node.kind() == "use_declaration" {
5681 let range = node.byte_range();
5682 if seen.insert((range.start, range.end)) {
5683 if let Some(import) = rust_import_from_use_node(source, node) {
5684 imports.push(import);
5685 }
5686 }
5687 }
5688
5689 let mut cursor = node.walk();
5690 if !cursor.goto_first_child() {
5691 return;
5692 }
5693 loop {
5694 collect_rust_use_imports(source, cursor.node(), seen, imports);
5695 if !cursor.goto_next_sibling() {
5696 break;
5697 }
5698 }
5699}
5700
5701fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
5702 let raw_text = source[node.byte_range()].to_string();
5703 let body = rust_use_body(&raw_text)?.to_string();
5704 let visibility = rust_use_visibility(&raw_text);
5705 let names = rust_use_list_names(&body);
5706 let group = classify_rust_import_group(&body);
5707 let byte_range = node.byte_range();
5708
5709 Some(ImportStatement {
5710 module_path: body,
5711 names: names.clone(),
5712 default_import: visibility.clone(),
5713 namespace_import: None,
5714 kind: ImportKind::Value,
5715 group,
5716 byte_range,
5717 raw_text,
5718 form: ImportForm::RustUse {
5719 visibility,
5720 named: names,
5721 },
5722 })
5723}
5724
5725fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
5726 let start = imports.iter().map(|import| import.byte_range.start).min()?;
5727 let end = imports.iter().map(|import| import.byte_range.end).max()?;
5728 Some(start..end)
5729}
5730
5731fn rust_use_visibility(raw_text: &str) -> Option<String> {
5732 let use_pos = raw_text.find("use ")?;
5733 let prefix = raw_text[..use_pos].trim();
5734 if prefix.is_empty() {
5735 None
5736 } else {
5737 Some(prefix.to_string())
5738 }
5739}
5740
5741fn rust_use_body(raw_text: &str) -> Option<&str> {
5742 let use_pos = raw_text.find("use ")?;
5743 Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
5744}
5745
5746fn rust_use_list_names(body: &str) -> Vec<String> {
5747 let Some(open) = body.find("::{") else {
5748 return Vec::new();
5749 };
5750 let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
5751 return Vec::new();
5752 };
5753 body[open + 3..close]
5754 .split(',')
5755 .filter_map(|spec| {
5756 let spec = spec.trim();
5757 if spec.is_empty() {
5758 None
5759 } else {
5760 Some(spec.to_string())
5761 }
5762 })
5763 .collect()
5764}
5765
5766fn classify_rust_import_group(body: &str) -> ImportGroup {
5767 let first = body
5768 .split("::")
5769 .next()
5770 .unwrap_or(body)
5771 .split_whitespace()
5772 .next()
5773 .unwrap_or(body);
5774 match first.trim() {
5775 "std" | "core" | "alloc" => ImportGroup::Stdlib,
5776 "crate" | "self" | "super" => ImportGroup::Internal,
5777 _ => ImportGroup::External,
5778 }
5779}
5780
5781#[derive(Debug, Clone)]
5782struct ReexportRefs {
5783 raw_refs: Vec<RawRef>,
5784 surface_parts: Vec<String>,
5785}
5786
5787fn collect_reexport_refs(
5788 project_root: &Path,
5789 abs_path: &Path,
5790 rel_path: &str,
5791 source: &str,
5792) -> ReexportRefs {
5793 let mut raw_refs = Vec::new();
5794 let mut surface_parts = Vec::new();
5795 let mut search_start = 0usize;
5796 let mut ordinal = 0usize;
5797 while let Some(export_offset) = source[search_start..].find("export") {
5798 let start = search_start + export_offset;
5799 let Some(statement_end_offset) = source[start..].find(';') else {
5800 break;
5801 };
5802 let end = start + statement_end_offset + 1;
5803 let statement = &source[start..end];
5804 search_start = end;
5805 if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
5806 continue;
5807 }
5808 let Some(module_path) = quoted_module_path(statement) else {
5809 continue;
5810 };
5811 ordinal += 1;
5812 let wildcard = statement.contains('*');
5813 let line = source[..start]
5814 .bytes()
5815 .filter(|byte| *byte == b'\n')
5816 .count() as u32
5817 + 1;
5818 let ref_id = ref_id(&[
5819 rel_path,
5820 "reexport",
5821 &start.to_string(),
5822 &end.to_string(),
5823 &module_path,
5824 &ordinal.to_string(),
5825 ]);
5826 surface_parts.push(format!("reexport\t{statement}"));
5827 raw_refs.push(RawRef {
5828 ref_id,
5829 caller_node: None,
5830 caller_symbol: None,
5831 caller_file: rel_path.to_string(),
5832 kind: "reexport".to_string(),
5833 short_name: None,
5834 full_ref: Some(statement.to_string()),
5835 module_path: Some(module_path.clone()),
5836 import_kind: Some("reexport".to_string()),
5837 local_name: None,
5838 requested_name: None,
5839 namespace_alias: None,
5840 wildcard,
5841 line,
5842 byte_start: start,
5843 byte_end: end,
5844 dependencies: module_dependencies(project_root, abs_path, &module_path),
5845 });
5846 }
5847 ReexportRefs {
5848 raw_refs,
5849 surface_parts,
5850 }
5851}
5852
5853fn collect_rust_pub_use_reexport_refs(
5854 project_root: &Path,
5855 abs_path: &Path,
5856 rel_path: &str,
5857 imports: &[ImportStatement],
5858 line_index: &LineIndex,
5859) -> ReexportRefs {
5860 let mut raw_refs = Vec::new();
5861 let mut surface_parts = Vec::new();
5862 let mut ordinal = 0usize;
5863
5864 for import in imports {
5865 let Some(visibility) = &import.default_import else {
5866 continue;
5867 };
5868 if !visibility.starts_with("pub") {
5869 continue;
5870 }
5871 let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
5872 continue;
5873 };
5874 ordinal += 1;
5875 let ref_id = ref_id(&[
5876 rel_path,
5877 "rust_reexport",
5878 &import.byte_range.start.to_string(),
5879 &import.byte_range.end.to_string(),
5880 &module_path,
5881 &ordinal.to_string(),
5882 ]);
5883 surface_parts.push(format!("reexport\t{}", import.raw_text));
5884 raw_refs.push(RawRef {
5885 ref_id,
5886 caller_node: None,
5887 caller_symbol: None,
5888 caller_file: rel_path.to_string(),
5889 kind: "reexport".to_string(),
5890 short_name: None,
5891 full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
5892 module_path: Some(module_path.clone()),
5893 import_kind: Some("reexport".to_string()),
5894 local_name: None,
5895 requested_name: None,
5896 namespace_alias: None,
5897 wildcard,
5898 line: line_index.byte_to_line(import.byte_range.start),
5899 byte_start: import.byte_range.start,
5900 byte_end: import.byte_range.end,
5901 dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
5902 });
5903 }
5904
5905 ReexportRefs {
5906 raw_refs,
5907 surface_parts,
5908 }
5909}
5910
5911fn rust_pub_use_reexport_parts(
5912 import: &ImportStatement,
5913) -> Option<(String, HashMap<String, String>, bool)> {
5914 let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
5915 let body = body.trim();
5916 if let Some(module_path) = body.strip_suffix("::*") {
5917 return Some((module_path.trim().to_string(), HashMap::new(), true));
5918 }
5919
5920 if let Some(brace_start) = body.find("::{") {
5921 let module_path = body[..brace_start].trim().to_string();
5922 let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
5923 if names.is_empty() {
5924 return None;
5925 }
5926 return Some((module_path, names, false));
5927 }
5928
5929 let (module_path, spec) = body.rsplit_once("::")?;
5930 let names = rust_reexport_names_from_specs(spec);
5931 if names.is_empty() {
5932 return None;
5933 }
5934 Some((module_path.trim().to_string(), names, false))
5935}
5936
5937fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
5938 let mut names = HashMap::new();
5939 for spec in specs.split(',') {
5940 let spec = spec.trim();
5941 if spec.is_empty() || spec == "self" {
5942 continue;
5943 }
5944 if let Some((source, local)) = spec.split_once(" as ") {
5945 let source = source.trim();
5946 let local = local.trim();
5947 if !source.is_empty() && !local.is_empty() && source != "self" {
5948 names.insert(local.to_string(), source.to_string());
5949 }
5950 } else {
5951 names.insert(spec.to_string(), spec.to_string());
5952 }
5953 }
5954 names
5955}
5956
5957fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
5958 if named.is_empty() {
5959 return fallback.to_string();
5960 }
5961 let mut specs = named
5962 .iter()
5963 .map(|(local, source)| {
5964 if local == source {
5965 source.clone()
5966 } else {
5967 format!("{source} as {local}")
5968 }
5969 })
5970 .collect::<Vec<_>>();
5971 specs.sort();
5972 format!("pub use {{{}}};", specs.join(", "))
5973}
5974
5975fn quoted_module_path(statement: &str) -> Option<String> {
5976 let quote = match (statement.find('\''), statement.find('"')) {
5977 (Some(single), Some(double)) if single < double => '\'',
5978 (Some(_), Some(_)) => '"',
5979 (Some(_), None) => '\'',
5980 (None, Some(_)) => '"',
5981 (None, None) => return None,
5982 };
5983 let start = statement.find(quote)? + 1;
5984 let end = statement[start..].find(quote)? + start;
5985 Some(statement[start..end].to_string())
5986}
5987
5988#[derive(Debug, Clone)]
5989struct SourceLessExportRefs {
5990 raw_refs: Vec<RawRef>,
5991 surface_parts: Vec<String>,
5992}
5993
5994fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
5995 let mut raw_refs = Vec::new();
5996 let mut surface_parts = Vec::new();
5997 let mut search_start = 0usize;
5998 let mut ordinal = 0usize;
5999 while let Some(export_offset) = source[search_start..].find("export") {
6000 let start = search_start + export_offset;
6001 let Some(statement_end_offset) = source[start..].find(';') else {
6002 break;
6003 };
6004 let end = start + statement_end_offset + 1;
6005 let statement = &source[start..end];
6006 search_start = end;
6007 if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
6008 continue;
6009 }
6010 let aliases = parse_reexport_names(statement);
6011 if aliases.is_empty() {
6012 continue;
6013 }
6014 let line = source[..start]
6015 .bytes()
6016 .filter(|byte| *byte == b'\n')
6017 .count() as u32
6018 + 1;
6019 for (exported, source_symbol) in aliases {
6020 ordinal += 1;
6021 let ref_id = ref_id(&[
6022 rel_path,
6023 "export_alias",
6024 &start.to_string(),
6025 &end.to_string(),
6026 &exported,
6027 &source_symbol,
6028 &ordinal.to_string(),
6029 ]);
6030 surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
6031 raw_refs.push(RawRef {
6032 ref_id,
6033 caller_node: None,
6034 caller_symbol: None,
6035 caller_file: rel_path.to_string(),
6036 kind: "export_alias".to_string(),
6037 short_name: None,
6038 full_ref: Some(statement.to_string()),
6039 module_path: None,
6040 import_kind: Some("export_alias".to_string()),
6041 local_name: Some(exported),
6042 requested_name: Some(source_symbol),
6043 namespace_alias: None,
6044 wildcard: false,
6045 line,
6046 byte_start: start,
6047 byte_end: end,
6048 dependencies: BTreeSet::new(),
6049 });
6050 }
6051 }
6052 SourceLessExportRefs {
6053 raw_refs,
6054 surface_parts,
6055 }
6056}
6057
6058fn build_dispatch_hints(
6059 rel_path: &str,
6060 data: &FileCallData,
6061 node_by_scoped: &HashMap<String, String>,
6062) -> Vec<DispatchHint> {
6063 let mut hints = Vec::new();
6064 let mut ordinal = 0usize;
6065 for (caller_symbol, call_sites) in &data.calls_by_symbol {
6066 let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
6067 continue;
6068 };
6069 for call_site in call_sites {
6070 if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
6071 continue;
6072 }
6073 ordinal += 1;
6074 hints.push(DispatchHint {
6075 id: ref_id(&[
6076 rel_path,
6077 "dispatch",
6078 caller_symbol,
6079 &call_site.line.to_string(),
6080 &call_site.byte_start.to_string(),
6081 &call_site.byte_end.to_string(),
6082 &ordinal.to_string(),
6083 ]),
6084 method_name: call_site.callee_name.clone(),
6085 caller_node: caller_node.clone(),
6086 file: rel_path.to_string(),
6087 line: call_site.line,
6088 byte_start: call_site.byte_start,
6089 byte_end: call_site.byte_end,
6090 });
6091 }
6092 }
6093 hints
6094}
6095
6096fn surface_fingerprint(
6097 nodes: &mut [NodeRecord],
6098 data: &FileCallData,
6099 reexport_parts: &[String],
6100) -> String {
6101 nodes.sort_by(|left, right| {
6102 (left.file_path.as_str(), left.scoped_name.as_str())
6103 .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
6104 });
6105 let mut parts = Vec::new();
6106 for node in nodes.iter() {
6107 parts.push(format!(
6108 "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
6109 node.scoped_name,
6110 node.name,
6111 node.kind,
6112 node.exported,
6113 node.range.start_line,
6114 node.range.start_col,
6115 node.range.end_line,
6116 node.range.end_col,
6117 node.range_ordinal,
6118 node.signature.as_deref().unwrap_or("")
6119 ));
6120 }
6121 let mut exports = data.exported_symbols.clone();
6122 exports.sort();
6123 for export in exports {
6124 parts.push(format!("export\t{export}"));
6125 }
6126 if let Some(default_export) = &data.default_export_symbol {
6127 parts.push(format!("default\t{default_export}"));
6128 }
6129 let mut imports: Vec<String> = data
6130 .import_block
6131 .imports
6132 .iter()
6133 .map(|import| {
6134 format!(
6135 "import\t{}\t{:?}\t{}",
6136 import.module_path, import.form, import.raw_text
6137 )
6138 })
6139 .collect();
6140 imports.sort();
6141 parts.extend(imports);
6142 parts.extend(reexport_parts.iter().cloned());
6143 hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
6144}
6145
6146fn resolve_ref(raw: RawRef, index: &ProjectIndex<'_>) -> Result<ResolvedRef> {
6147 if raw.kind != "call" {
6148 return Ok(ResolvedRef {
6149 dependencies: raw.dependencies.clone(),
6150 raw,
6151 status: "unresolved".to_string(),
6152 target_node: None,
6153 target_file: None,
6154 target_symbol: None,
6155 edge: None,
6156 });
6157 }
6158
6159 let caller_file = raw.caller_file.clone();
6160 let caller_data = index.caller_data.get(&caller_file).ok_or_else(|| {
6161 CallGraphStoreError::MissingCallerData {
6162 file: caller_file.clone(),
6163 }
6164 })?;
6165 let full_ref = raw.full_ref.as_deref().unwrap_or_default();
6166 let short_name = raw.short_name.as_deref().unwrap_or_default();
6167 let mut dependencies = raw.dependencies.clone();
6168
6169 let resolved = match index.lang_for(&caller_file) {
6170 Some(LangId::Rust) => {
6171 resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
6172 }
6173 Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
6174 resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
6175 }
6176 _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
6177 };
6178
6179 let Some((status, target_file, target_symbol)) = resolved else {
6180 return Ok(ResolvedRef {
6181 raw,
6182 status: "unresolved".to_string(),
6183 target_node: None,
6184 target_file: None,
6185 target_symbol: None,
6186 dependencies,
6187 edge: None,
6188 });
6189 };
6190
6191 dependencies.insert(target_file.clone());
6192 let target_node = index.node_for_symbol(&target_file, &target_symbol);
6193 let source_node = raw.caller_node.clone();
6194 let edge = if let Some(source_node) = source_node {
6195 if target_file == caller_file
6196 && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
6197 {
6198 None
6199 } else {
6200 Some(EdgeRecord {
6201 edge_id: ref_id(&[&raw.ref_id, "edge"]),
6202 source_node,
6203 target_node: target_node.clone(),
6204 target_file: target_file.clone(),
6205 target_symbol: target_symbol.clone(),
6206 kind: "call".to_string(),
6207 line: raw.line,
6208 })
6209 }
6210 } else {
6211 None
6212 };
6213
6214 Ok(ResolvedRef {
6215 raw,
6216 status,
6217 target_node,
6218 target_file: Some(target_file),
6219 target_symbol: Some(target_symbol),
6220 dependencies,
6221 edge,
6222 })
6223}
6224
6225fn resolve_js_ts_target(
6226 index: &ProjectIndex<'_>,
6227 caller_file: &str,
6228 full_ref: &str,
6229 short_name: &str,
6230 caller_data: &FileCallData,
6231) -> Option<(String, String, String)> {
6232 if let Some((namespace, member)) = full_ref.split_once('.') {
6233 for import in &caller_data.import_block.imports {
6234 if import.namespace_import.as_deref() == Some(namespace) {
6235 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6236 if let Some((file, symbol)) =
6237 resolve_exported_symbol(index, &target_file, member, 0)
6238 {
6239 return Some(("resolved".to_string(), file, symbol));
6240 }
6241 }
6242 }
6243 }
6244 }
6245
6246 for import in &caller_data.import_block.imports {
6247 for spec in &import.names {
6248 if crate::imports::specifier_local_name(spec) == short_name {
6249 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6250 let requested = crate::imports::specifier_imported_name(spec);
6251 let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
6252 .unwrap_or_else(|| (target_file, requested.to_string()));
6253 return Some(("resolved".to_string(), file, symbol));
6254 }
6255 }
6256 }
6257
6258 if import.default_import.as_deref() == Some(short_name) {
6259 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6260 let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
6261 .or_else(|| {
6262 index
6263 .files
6264 .get(&target_file)
6265 .and_then(|file| file.default_export.clone())
6266 .map(|symbol| (target_file.clone(), symbol))
6267 })
6268 .unwrap_or_else(|| {
6269 let file_name = Path::new(&target_file)
6270 .file_name()
6271 .and_then(|name| name.to_str())
6272 .unwrap_or("unknown")
6273 .to_string();
6274 (target_file, format!("<default:{file_name}>"))
6275 });
6276 return Some(("resolved".to_string(), file, symbol));
6277 }
6278 }
6279 }
6280
6281 for import in &caller_data.import_block.imports {
6282 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6283 if index
6284 .files
6285 .get(&target_file)
6286 .map(|file| file.exports.contains(short_name))
6287 .unwrap_or(false)
6288 {
6289 return Some(("resolved".to_string(), target_file, short_name.to_string()));
6290 }
6291 }
6292 }
6293
6294 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
6295}
6296
6297fn resolve_exported_symbol(
6298 index: &ProjectIndex<'_>,
6299 file: &str,
6300 requested: &str,
6301 depth: usize,
6302) -> Option<(String, String)> {
6303 let mut visited = std::collections::HashMap::new();
6304 resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
6305}
6306
6307fn resolve_exported_symbol_inner(
6316 index: &ProjectIndex<'_>,
6317 file: &str,
6318 requested: &str,
6319 depth: usize,
6320 visited: &mut std::collections::HashMap<(String, String), usize>,
6321) -> Option<(String, String)> {
6322 if depth > 16 {
6323 return None;
6324 }
6325 if requested != "default" {
6326 if let Some(source_symbol) = index
6327 .files
6328 .get(file)
6329 .and_then(|item| item.export_aliases.get(requested))
6330 {
6331 return Some((file.to_string(), source_symbol.clone()));
6332 }
6333 if index
6334 .files
6335 .get(file)
6336 .map(|item| item.exports.contains(requested))
6337 .unwrap_or(false)
6338 {
6339 return Some((file.to_string(), requested.to_string()));
6340 }
6341 } else if let Some(default) = index
6342 .files
6343 .get(file)
6344 .and_then(|item| item.default_export.clone())
6345 {
6346 return Some((file.to_string(), default));
6347 }
6348
6349 match visited.entry((file.to_string(), requested.to_string())) {
6353 std::collections::hash_map::Entry::Occupied(mut seen) => {
6354 if *seen.get() <= depth {
6355 return None;
6356 }
6357 seen.insert(depth);
6358 }
6359 std::collections::hash_map::Entry::Vacant(slot) => {
6360 slot.insert(depth);
6361 }
6362 }
6363
6364 for reexport in index.reexports_for(file) {
6365 let mut next_requested = requested.to_string();
6366 let matches = if reexport.wildcard {
6367 true
6368 } else if let Some(source_name) = reexport.named.get(requested) {
6369 next_requested = source_name.clone();
6370 true
6371 } else {
6372 false
6373 };
6374 if !matches {
6375 continue;
6376 }
6377 if let Some(target_file) = &reexport.target_file {
6378 if let Some(target) = resolve_exported_symbol_inner(
6379 index,
6380 target_file,
6381 &next_requested,
6382 depth + 1,
6383 visited,
6384 ) {
6385 return Some(target);
6386 }
6387 }
6388 }
6389 None
6390}
6391
6392fn resolve_rust_target(
6393 index: &ProjectIndex<'_>,
6394 caller_file: &str,
6395 full_ref: &str,
6396 short_name: &str,
6397 caller_data: &FileCallData,
6398 raw: &RawRef,
6399) -> Option<(String, String, String)> {
6400 if full_ref.contains("::") {
6401 if let Some((target_file, target_symbol)) =
6402 rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
6403 {
6404 return Some(("resolved".to_string(), target_file, target_symbol));
6405 }
6406 }
6407
6408 for import in &caller_data.import_block.imports {
6409 if let Some((target_file, target_symbol)) =
6410 rust_target_for_use(index, caller_file, import, short_name)
6411 {
6412 return Some(("resolved".to_string(), target_file, target_symbol));
6413 }
6414 }
6415
6416 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
6417}
6418
6419fn rust_target_for_qualified(
6420 index: &ProjectIndex<'_>,
6421 caller_file: &str,
6422 full_ref: &str,
6423 short_name: &str,
6424 caller_data: &FileCallData,
6425 raw: &RawRef,
6426) -> Option<(String, String)> {
6427 let mut segments: Vec<&str> = full_ref.split("::").collect();
6428 if segments.len() < 2 {
6429 return None;
6430 }
6431 segments.pop();
6432 let requested_symbol = rust_target_symbol(full_ref, short_name);
6433
6434 for path in rust_module_path_candidates(&segments, caller_data, raw) {
6435 let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
6436 if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
6437 if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
6438 return Some(rust_resolve_reexport_if_symbol_missing(
6439 index,
6440 target_file,
6441 requested_symbol.clone(),
6442 ));
6443 }
6444 }
6445
6446 let module_segments = rust_resolve_segments(caller_file, &path_refs)?;
6447 if let Some(target) =
6448 rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
6449 {
6450 return Some(target);
6451 }
6452 if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
6453 return Some(rust_resolve_reexport_if_symbol_missing(
6454 index,
6455 target_file,
6456 requested_symbol.clone(),
6457 ));
6458 }
6459 }
6460 None
6461}
6462
6463fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
6464 full_ref
6465 .rsplit("::")
6466 .next()
6467 .filter(|name| !name.is_empty())
6468 .unwrap_or(short_name)
6469 .to_string()
6470}
6471
6472fn rust_resolve_reexport_if_symbol_missing(
6473 index: &ProjectIndex<'_>,
6474 target_file: String,
6475 target_symbol: String,
6476) -> (String, String) {
6477 if index
6478 .node_for_symbol(&target_file, &target_symbol)
6479 .is_some()
6480 {
6481 return (target_file, target_symbol);
6482 }
6483 if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
6484 resolved
6485 } else {
6486 (target_file, target_symbol)
6487 }
6488}
6489
6490fn rust_module_path_candidates(
6491 segments: &[&str],
6492 caller_data: &FileCallData,
6493 raw: &RawRef,
6494) -> Vec<Vec<String>> {
6495 let mut candidates = Vec::new();
6496 if let Some(first) = segments.first().copied() {
6497 for import in &caller_data.import_block.imports {
6498 if !rust_import_is_visible_to_call(import, raw) {
6499 continue;
6500 }
6501 let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
6502 continue;
6503 };
6504 if local_name == first {
6505 path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
6506 rust_push_unique_path_candidate(&mut candidates, path_segments);
6507 }
6508 }
6509 }
6510 rust_push_unique_path_candidate(
6511 &mut candidates,
6512 segments
6513 .iter()
6514 .map(|segment| (*segment).to_string())
6515 .collect(),
6516 );
6517 candidates
6518}
6519
6520fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
6521 if !candidates.iter().any(|existing| existing == &candidate) {
6522 candidates.push(candidate);
6523 }
6524}
6525
6526fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
6527 import.byte_range.start <= raw.byte_start
6528}
6529
6530fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
6531 let path = import.module_path.trim().trim_end_matches(';').trim();
6532 if path.contains("::{") || path.contains('{') || path.contains('*') {
6533 return None;
6534 }
6535 let (path_without_alias, alias) = path
6536 .split_once(" as ")
6537 .map(|(left, right)| (left.trim(), Some(right.trim())))
6538 .unwrap_or((path, None));
6539 let segments = path_without_alias
6540 .split("::")
6541 .map(str::trim)
6542 .filter(|segment| !segment.is_empty())
6543 .collect::<Vec<_>>();
6544 let local_name = alias.or_else(|| segments.last().copied())?.to_string();
6545 if local_name.chars().next().is_some_and(char::is_uppercase) {
6546 return None;
6547 }
6548 Some((
6549 local_name,
6550 segments
6551 .into_iter()
6552 .map(|segment| segment.to_string())
6553 .collect(),
6554 ))
6555}
6556
6557fn rust_inline_scoped_target(
6558 index: &ProjectIndex<'_>,
6559 caller_file: &str,
6560 module_segments: &[String],
6561 short_name: &str,
6562) -> Option<(String, String)> {
6563 let src_prefix = rust_src_prefix(caller_file);
6564 let mut file_paths = index.files.keys().cloned().collect::<Vec<_>>();
6565 file_paths.sort();
6566 if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
6567 let caller = file_paths.remove(position);
6568 file_paths.insert(0, caller);
6569 }
6570
6571 for file_path in file_paths {
6572 if index.lang_for(&file_path) != Some(LangId::Rust)
6573 || rust_src_prefix(&file_path) != src_prefix
6574 {
6575 continue;
6576 }
6577 let file_module_segments = rust_module_segments_for_rel(&file_path);
6578 if !module_segments.starts_with(&file_module_segments) {
6579 continue;
6580 }
6581 let scoped_segments = &module_segments[file_module_segments.len()..];
6582 if scoped_segments.is_empty() {
6583 continue;
6584 }
6585 let mut scoped_symbol = scoped_segments.join("::");
6586 scoped_symbol.push_str("::");
6587 scoped_symbol.push_str(short_name);
6588 if index.node_for_symbol(&file_path, &scoped_symbol).is_some() {
6589 return Some((file_path, scoped_symbol));
6590 }
6591 }
6592 None
6593}
6594
6595fn rust_target_for_use(
6596 index: &ProjectIndex<'_>,
6597 caller_file: &str,
6598 import: &ImportStatement,
6599 short_name: &str,
6600) -> Option<(String, String)> {
6601 let path = import.module_path.trim().trim_end_matches(';');
6602 if let Some(brace_start) = path.find("::{") {
6603 let prefix = &path[..brace_start];
6604 if import.names.iter().any(|name| name == short_name) {
6605 let prefix_segments: Vec<&str> = prefix.split("::").collect();
6606 let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
6607 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
6608 return Some((file, short_name.to_string()));
6609 }
6610 return None;
6611 }
6612
6613 let (path_without_alias, alias) = path
6614 .split_once(" as ")
6615 .map(|(left, right)| (left.trim(), Some(right.trim())))
6616 .unwrap_or((path, None));
6617 let segments: Vec<&str> = path_without_alias.split("::").collect();
6618 let imported = alias.or_else(|| segments.last().copied())?;
6619 if imported != short_name {
6620 return None;
6621 }
6622 if segments.len() < 2 {
6623 return None;
6624 }
6625 let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
6626 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
6627 Some((file, segments.last().unwrap_or(&short_name).to_string()))
6628}
6629
6630fn rust_workspace_file_for_segments(index: &ProjectIndex<'_>, segments: &[&str]) -> Option<String> {
6631 let crate_name = segments.first().copied()?;
6632 let src_prefix = index.crate_src_prefix(crate_name)?;
6633 let module_segments = segments[1..]
6634 .iter()
6635 .map(|segment| segment.to_string())
6636 .collect::<Vec<_>>();
6637 rust_file_for_src_prefix(index, &src_prefix, &module_segments)
6638}
6639
6640fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
6645 let mut prefixes = HashMap::new();
6646 let mut stack = vec![project_root.to_path_buf()];
6647 while let Some(dir) = stack.pop() {
6648 let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
6649 if matches!(name, "target" | "node_modules" | ".git") {
6650 continue;
6651 }
6652 let manifest = dir.join("Cargo.toml");
6653 if manifest.is_file() {
6654 let crate_names = rust_manifest_crate_names(&manifest);
6655 if !crate_names.is_empty() {
6656 let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
6657 for crate_name in crate_names {
6658 prefixes
6659 .entry(crate_name)
6660 .or_insert_with(|| src_prefix.clone());
6661 }
6662 }
6663 }
6664 let Ok(entries) = std::fs::read_dir(&dir) else {
6665 continue;
6666 };
6667 for entry in entries.flatten() {
6668 let path = entry.path();
6669 if path.is_dir() {
6670 stack.push(path);
6671 }
6672 }
6673 }
6674 prefixes
6675}
6676
6677fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
6681 let Ok(source) = std::fs::read_to_string(manifest) else {
6682 return Vec::new();
6683 };
6684 let mut in_lib = false;
6685 let mut package_name = None;
6686 let mut lib_name = None;
6687 for line in source.lines() {
6688 let trimmed = line.trim();
6689 if trimmed.starts_with('[') {
6690 in_lib = trimmed == "[lib]";
6691 continue;
6692 }
6693 let Some((key, value)) = trimmed.split_once('=') else {
6694 continue;
6695 };
6696 let key = key.trim();
6697 let value = value.trim().trim_matches('"');
6698 if in_lib && key == "name" {
6699 lib_name = Some(value.to_string());
6700 } else if !in_lib && key == "name" && package_name.is_none() {
6701 package_name = Some(value.to_string());
6702 }
6703 }
6704 let mut names = Vec::new();
6705 if let Some(lib) = lib_name {
6706 names.push(lib);
6707 }
6708 if let Some(package) = package_name {
6709 let normalized = package.replace('-', "_");
6710 if !names.contains(&normalized) {
6711 names.push(normalized);
6712 }
6713 }
6714 names
6715}
6716
6717fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
6718 if segments.is_empty() {
6719 return Some(Vec::new());
6720 }
6721 let caller_segments = rust_module_segments_for_rel(caller_file);
6722 match segments[0] {
6723 "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
6724 "self" => {
6725 let mut resolved = caller_segments;
6726 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
6727 Some(resolved)
6728 }
6729 "super" => {
6730 let mut resolved = caller_segments;
6731 resolved.pop();
6732 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
6733 Some(resolved)
6734 }
6735 _ => {
6736 let mut resolved = caller_segments;
6737 resolved.pop();
6738 resolved.extend(segments.iter().map(|item| item.to_string()));
6739 Some(resolved)
6740 }
6741 }
6742}
6743
6744fn rust_file_for_segments(
6745 index: &ProjectIndex<'_>,
6746 caller_file: &str,
6747 segments: &[String],
6748) -> Option<String> {
6749 rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
6750}
6751
6752fn rust_file_for_src_prefix(
6753 index: &ProjectIndex<'_>,
6754 src_prefix: &str,
6755 segments: &[String],
6756) -> Option<String> {
6757 let candidate = if segments.is_empty() {
6758 [src_prefix, "lib.rs"].join("/")
6759 } else {
6760 format!("{}/{}.rs", src_prefix, segments.join("/"))
6761 };
6762 if index.files.contains_key(&candidate) {
6763 return Some(candidate);
6764 }
6765 if !segments.is_empty() {
6766 let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
6767 if index.files.contains_key(&mod_candidate) {
6768 return Some(mod_candidate);
6769 }
6770 }
6771 None
6772}
6773
6774fn rust_src_prefix(rel_path: &str) -> String {
6775 rel_path
6776 .split_once("/src/")
6777 .map(|(prefix, _)| format!("{prefix}/src"))
6778 .unwrap_or_else(|| "src".to_string())
6779}
6780
6781fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
6782 let after_src = rel_path
6783 .split_once("/src/")
6784 .map(|(_, rest)| rest)
6785 .or_else(|| rel_path.strip_prefix("src/"))
6786 .unwrap_or(rel_path);
6787 if matches!(after_src, "lib.rs" | "main.rs") {
6788 return Vec::new();
6789 }
6790 if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
6791 return prefix.split('/').map(|item| item.to_string()).collect();
6792 }
6793 after_src
6794 .strip_suffix(".rs")
6795 .unwrap_or(after_src)
6796 .split('/')
6797 .map(|item| item.to_string())
6798 .collect()
6799}
6800
6801fn resolve_local_target(
6802 _index: &ProjectIndex<'_>,
6803 caller_file: &str,
6804 full_ref: &str,
6805 short_name: &str,
6806 caller_data: &FileCallData,
6807) -> Option<(String, String, String)> {
6808 if !callgraph::is_bare_callee(full_ref, short_name) {
6809 return None;
6810 }
6811 callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
6812 .ok()
6813 .map(|symbol| {
6814 (
6815 "resolved_local".to_string(),
6816 caller_file.to_string(),
6817 symbol,
6818 )
6819 })
6820}
6821
6822impl<'a> ProjectIndex<'a> {
6823 fn from_parts(
6824 project_root: &Path,
6825 files: HashMap<String, DbFileIndex>,
6826 caller_data: HashMap<String, &'a FileCallData>,
6827 ) -> Self {
6828 Self {
6829 project_root: project_root.to_path_buf(),
6830 files,
6831 caller_data,
6832 workspace_crate_prefixes: std::sync::OnceLock::new(),
6833 }
6834 }
6835
6836 fn from_extracts(project_root: &Path, extracts: &'a [FileExtract]) -> Self {
6837 let mut files = HashMap::new();
6838 let mut caller_data = HashMap::new();
6839 for extract in extracts {
6840 let index = DbFileIndex::from_extract(project_root, extract);
6841 caller_data.insert(extract.rel_path.clone(), &extract.data);
6842 files.insert(extract.rel_path.clone(), index);
6843 }
6844 Self::from_parts(project_root, files, caller_data)
6845 }
6846
6847 fn from_db_and_callers(
6848 tx: &Transaction<'_>,
6849 project_root: &Path,
6850 caller_extracts: &'a HashMap<String, FileExtract>,
6851 ) -> Result<Self> {
6852 let mut files = load_db_file_indexes(tx, project_root)?;
6853 let mut caller_data = HashMap::new();
6854 for (rel_path, extract) in caller_extracts {
6855 files.insert(
6856 rel_path.clone(),
6857 DbFileIndex::from_extract(project_root, extract),
6858 );
6859 caller_data.insert(rel_path.clone(), &extract.data);
6860 }
6861 Ok(Self::from_parts(project_root, files, caller_data))
6862 }
6863
6864 fn lang_for(&self, rel_path: &str) -> Option<LangId> {
6865 self.files.get(rel_path).and_then(|file| file.lang)
6866 }
6867
6868 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
6869 self.files
6870 .get(caller_file)
6871 .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
6872 }
6873
6874 fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
6875 self.files
6876 .get(rel_path)
6877 .map(|file| file.reexports.as_slice())
6878 .unwrap_or(&[])
6879 }
6880
6881 fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
6882 self.files.get(rel_path).and_then(|file| {
6883 file.node_by_scoped
6884 .get(symbol)
6885 .cloned()
6886 .or_else(|| file.node_by_bare.get(symbol).cloned())
6887 })
6888 }
6889}
6890
6891impl DbFileIndex {
6892 fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
6893 let mut node_by_scoped = HashMap::new();
6894 let mut node_by_bare = HashMap::new();
6895 for node in &extract.nodes {
6896 node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
6897 node_by_bare
6898 .entry(node.name.clone())
6899 .or_insert(node.id.clone());
6900 }
6901 let mut export_aliases = HashMap::new();
6902 for raw_ref in &extract.raw_refs {
6903 if raw_ref.kind == "export_alias" {
6904 if let (Some(exported), Some(source_symbol)) =
6905 (&raw_ref.local_name, &raw_ref.requested_name)
6906 {
6907 export_aliases.insert(exported.clone(), source_symbol.clone());
6908 }
6909 }
6910 }
6911 let mut module_targets = HashMap::new();
6912 let mut reexports = Vec::new();
6913 for raw_ref in &extract.raw_refs {
6914 if !matches!(raw_ref.kind.as_str(), "import" | "reexport") {
6915 continue;
6916 }
6917 let Some(module_path) = &raw_ref.module_path else {
6918 continue;
6919 };
6920 let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
6921 module_targets
6922 .entry(module_path.clone())
6923 .or_insert_with(|| target_file.clone());
6924 if raw_ref.kind == "reexport" {
6925 reexports.push(reexport_index_from_raw(raw_ref, target_file));
6926 }
6927 }
6928 Self {
6929 lang: Some(extract.lang),
6930 exports: extract.data.exported_symbols.iter().cloned().collect(),
6931 default_export: extract.data.default_export_symbol.clone(),
6932 export_aliases,
6933 node_by_scoped,
6934 node_by_bare,
6935 module_targets,
6936 reexports,
6937 }
6938 }
6939}
6940
6941fn load_db_file_indexes(
6942 tx: &Transaction<'_>,
6943 project_root: &Path,
6944) -> Result<HashMap<String, DbFileIndex>> {
6945 let mut files = HashMap::new();
6946 let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
6947 let rows = stmt.query_map([], |row| {
6948 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6949 })?;
6950 for row in rows {
6951 let (rel_path, lang) = row?;
6952 files.insert(
6953 rel_path.clone(),
6954 DbFileIndex {
6955 lang: lang_from_label(&lang),
6956 exports: HashSet::new(),
6957 default_export: None,
6958 export_aliases: HashMap::new(),
6959 node_by_scoped: HashMap::new(),
6960 node_by_bare: HashMap::new(),
6961 module_targets: HashMap::new(),
6962 reexports: Vec::new(),
6963 },
6964 );
6965 }
6966
6967 let mut node_stmt = tx.prepare(
6968 "SELECT file_path, id, name, scoped_name, exported, is_default_export FROM nodes",
6969 )?;
6970 let nodes = node_stmt.query_map([], |row| {
6971 Ok((
6972 row.get::<_, String>(0)?,
6973 row.get::<_, String>(1)?,
6974 row.get::<_, String>(2)?,
6975 row.get::<_, String>(3)?,
6976 row.get::<_, i64>(4)? != 0,
6977 row.get::<_, i64>(5)? != 0,
6978 ))
6979 })?;
6980 for row in nodes {
6981 let (file_path, id, name, scoped_name, exported, is_default_export) = row?;
6982 let file = files
6983 .entry(file_path.clone())
6984 .or_insert_with(|| DbFileIndex {
6985 lang: None,
6986 exports: HashSet::new(),
6987 default_export: None,
6988 export_aliases: HashMap::new(),
6989 node_by_scoped: HashMap::new(),
6990 node_by_bare: HashMap::new(),
6991 module_targets: HashMap::new(),
6992 reexports: Vec::new(),
6993 });
6994 if exported {
6995 file.exports.insert(name.clone());
6996 file.exports.insert(scoped_name.clone());
6997 }
6998 if is_default_export {
6999 file.default_export = Some(scoped_name.clone());
7000 }
7001 file.node_by_scoped.insert(scoped_name, id.clone());
7002 file.node_by_bare.entry(name).or_insert(id);
7003 }
7004 let file_keys: HashSet<String> = files.keys().cloned().collect();
7005 let dependencies_by_file = load_file_dependencies_index(tx)?;
7009 let mut ref_stmt = tx.prepare(
7010 "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
7011 FROM refs WHERE kind IN ('reexport', 'export_alias')",
7012 )?;
7013 let ref_rows = ref_stmt.query_map([], |row| {
7014 Ok((
7015 row.get::<_, String>(0)?,
7016 row.get::<_, String>(1)?,
7017 row.get::<_, String>(2)?,
7018 row.get::<_, Option<String>>(3)?,
7019 row.get::<_, Option<String>>(4)?,
7020 row.get::<_, i64>(5)? != 0,
7021 row.get::<_, Option<String>>(6)?,
7022 row.get::<_, Option<String>>(7)?,
7023 ))
7024 })?;
7025 for row in ref_rows {
7026 let (
7027 ref_id,
7028 caller_file,
7029 kind,
7030 module_path,
7031 full_ref,
7032 wildcard,
7033 local_name,
7034 requested_name,
7035 ) = row?;
7036 if kind == "export_alias" {
7037 if let (Some(exported), Some(source_symbol), Some(file)) =
7038 (local_name, requested_name, files.get_mut(&caller_file))
7039 {
7040 file.export_aliases.insert(exported, source_symbol);
7041 }
7042 continue;
7043 }
7044 let Some(module_path) = module_path else {
7045 continue;
7046 };
7047 let file_deps = dependencies_by_file
7048 .get(&caller_file)
7049 .cloned()
7050 .unwrap_or_default();
7051 let deps = stored_dependencies_for_module(
7052 project_root,
7053 &caller_file,
7054 &module_path,
7055 &file_deps,
7056 &file_keys,
7057 );
7058 let target_file = deps
7059 .iter()
7060 .find(|dep| file_keys.contains(*dep))
7061 .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
7062 if let Some(file) = files.get_mut(&caller_file) {
7063 file.module_targets
7064 .entry(module_path.clone())
7065 .or_insert_with(|| target_file.clone());
7066 if kind == "reexport" {
7067 let raw = RawRef {
7068 ref_id,
7069 caller_node: None,
7070 caller_symbol: None,
7071 caller_file,
7072 kind,
7073 short_name: None,
7074 full_ref,
7075 module_path: Some(module_path),
7076 import_kind: Some("reexport".to_string()),
7077 local_name: None,
7078 requested_name: None,
7079 namespace_alias: None,
7080 wildcard,
7081 line: 0,
7082 byte_start: 0,
7083 byte_end: 0,
7084 dependencies: deps,
7085 };
7086 file.reexports
7087 .push(reexport_index_from_raw(&raw, target_file));
7088 }
7089 }
7090 }
7091
7092 Ok(files)
7093}
7094
7095fn stored_dependencies_for_module(
7096 project_root: &Path,
7097 caller_file: &str,
7098 module_path: &str,
7099 caller_dependencies: &BTreeSet<String>,
7100 indexed_files: &HashSet<String>,
7101) -> BTreeSet<String> {
7102 let caller_path = project_root.join(caller_file);
7103 let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
7104 if module_path.starts_with('.') {
7105 let caller_dir = caller_path.parent().unwrap_or(project_root);
7106 for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
7107 let normalized = if candidate.is_file() {
7108 canonicalize_path(&candidate)
7109 } else {
7110 candidate
7111 };
7112 candidates.insert(relative_path(project_root, &normalized));
7113 }
7114 }
7115 let exact = candidates
7116 .intersection(caller_dependencies)
7117 .filter(|dependency| indexed_files.contains(*dependency))
7118 .cloned()
7119 .collect::<BTreeSet<_>>();
7120 if !exact.is_empty() || module_path.starts_with('.') {
7121 return exact;
7122 }
7123
7124 let module_path = rust_module_path_without_alias_or_use_list(module_path)
7125 .trim_matches(|character| matches!(character, '\'' | '"'));
7126 let package_name = module_path
7127 .split('/')
7128 .next_back()
7129 .unwrap_or(module_path)
7130 .replace('_', "-");
7131 let matched = caller_dependencies
7132 .iter()
7133 .filter(|dependency| indexed_files.contains(*dependency))
7134 .filter(|dependency| {
7135 dependency.as_str() == module_path
7136 || dependency.ends_with(&format!("/{module_path}"))
7137 || Path::new(dependency).components().any(|component| {
7138 component.as_os_str().to_string_lossy().replace('_', "-") == package_name
7139 })
7140 })
7141 .cloned()
7142 .collect::<BTreeSet<_>>();
7143 if matched.len() == 1 {
7144 matched
7145 } else {
7146 BTreeSet::new()
7147 }
7148}
7149
7150fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
7151 let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
7152 let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
7153 let rows = stmt.query_map([], |row| {
7154 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7155 })?;
7156 for row in rows {
7157 let (file_path, dependency) = row?;
7158 by_file.entry(file_path).or_default().insert(dependency);
7159 }
7160 Ok(by_file)
7161}
7162
7163struct ColdBuildInsertStatements<'stmt> {
7164 file: Statement<'stmt>,
7165 node: Statement<'stmt>,
7166 file_dependency: Statement<'stmt>,
7167 dispatch_hint: Statement<'stmt>,
7168 backend_state: Statement<'stmt>,
7169 reference: Statement<'stmt>,
7170 edge: Statement<'stmt>,
7171}
7172
7173impl<'stmt> ColdBuildInsertStatements<'stmt> {
7174 fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
7175 Ok(Self {
7176 file: tx.prepare(
7177 "INSERT OR REPLACE INTO files(
7178 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
7179 is_public_api, surface_fingerprint, indexed_at
7180 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
7181 )?,
7182 node: tx.prepare(
7183 "INSERT OR REPLACE INTO nodes(
7184 id, file_path, name, scoped_name, kind, start_line, start_col,
7185 end_line, end_col, range_ordinal, signature, exported,
7186 is_default_export, is_type_like, is_callgraph_entry_point, provenance
7187 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
7188 )?,
7189 file_dependency: tx.prepare(
7190 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
7191 )?,
7192 dispatch_hint: tx.prepare(
7193 "INSERT OR REPLACE INTO dispatch_hints(
7194 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
7195 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
7196 )?,
7197 backend_state: tx.prepare(
7198 "INSERT OR REPLACE INTO backend_file_state(
7199 backend, workspace_root, file_path, content_hash, status, updated_at
7200 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
7201 )?,
7202 reference: tx.prepare(
7203 "INSERT OR REPLACE INTO refs(
7204 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
7205 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
7206 byte_start, byte_end, status, target_node, target_file, target_symbol,
7207 provenance
7208 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
7209 )?,
7210 edge: tx.prepare(
7211 "INSERT OR REPLACE INTO edges(
7212 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
7213 kind, line, provenance
7214 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
7215 )?,
7216 })
7217 }
7218}
7219
7220fn insert_file_extract_prepared(
7221 statements: &mut ColdBuildInsertStatements<'_>,
7222 workspace_root: &str,
7223 extract: &FileExtract,
7224) -> Result<()> {
7225 statements.file.execute(params![
7226 extract.rel_path,
7227 hash_to_hex(extract.freshness.content_hash),
7228 system_time_to_ns(extract.freshness.mtime),
7229 extract.freshness.size as i64,
7230 lang_label(extract.lang),
7231 extract.surface_fingerprint,
7232 unix_seconds_now(),
7233 ])?;
7234 for node in &extract.nodes {
7235 statements.node.execute(params![
7236 node.id,
7237 node.file_path,
7238 node.name,
7239 node.scoped_name,
7240 node.kind,
7241 node.range.start_line as i64,
7242 node.range.start_col as i64,
7243 node.range.end_line as i64,
7244 node.range.end_col as i64,
7245 node.range_ordinal as i64,
7246 node.signature,
7247 bool_int(node.exported),
7248 bool_int(node.is_default_export),
7249 bool_int(node.is_type_like),
7250 bool_int(node.is_callgraph_entry_point),
7251 PROVENANCE_TREESITTER,
7252 ])?;
7253 }
7254
7255 let mut dependencies = BTreeSet::new();
7256 for raw_ref in &extract.raw_refs {
7257 dependencies.extend(raw_ref.dependencies.iter().cloned());
7258 }
7259 for dep_file in &dependencies {
7260 statements
7261 .file_dependency
7262 .execute(params![extract.rel_path, dep_file])?;
7263 }
7264
7265 for hint in &extract.dispatch_hints {
7266 statements.dispatch_hint.execute(params![
7267 hint.id,
7268 hint.method_name,
7269 hint.caller_node,
7270 hint.file,
7271 hint.line as i64,
7272 hint.byte_start as i64,
7273 hint.byte_end as i64,
7274 PROVENANCE_TREESITTER,
7275 ])?;
7276 }
7277 insert_backend_state_prepared(
7278 &mut statements.backend_state,
7279 workspace_root,
7280 &extract.rel_path,
7281 Some(&extract.freshness.content_hash),
7282 "fresh",
7283 )?;
7284 Ok(())
7285}
7286
7287fn insert_backend_state_prepared(
7288 stmt: &mut Statement<'_>,
7289 workspace_root: &str,
7290 rel_path: &str,
7291 content_hash: Option<&blake3::Hash>,
7292 status: &str,
7293) -> Result<()> {
7294 let hash = content_hash
7295 .map(|hash| hash_to_hex(*hash))
7296 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
7297 stmt.execute(params![
7298 BACKEND_TREESITTER,
7299 workspace_root,
7300 rel_path,
7301 hash,
7302 status,
7303 unix_seconds_now(),
7304 ])?;
7305 Ok(())
7306}
7307
7308fn insert_resolved_ref_prepared(
7309 statements: &mut ColdBuildInsertStatements<'_>,
7310 resolved: &ResolvedRef,
7311) -> Result<()> {
7312 let raw = &resolved.raw;
7313 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
7314 statements.reference.execute(params![
7315 raw.ref_id,
7316 raw.caller_node,
7317 raw.caller_file,
7318 raw.kind,
7319 raw.short_name,
7320 raw.full_ref,
7321 raw.module_path,
7322 raw.import_kind,
7323 raw.local_name,
7324 raw.requested_name,
7325 raw.namespace_alias,
7326 bool_int(raw.wildcard),
7327 raw.line as i64,
7328 raw.byte_start as i64,
7329 raw.byte_end as i64,
7330 resolved.status,
7331 resolved.target_node,
7332 resolved.target_file,
7333 resolved.target_symbol,
7334 PROVENANCE_TREESITTER,
7335 ])?;
7336 if let Some(edge) = &resolved.edge {
7337 statements.edge.execute(params![
7338 edge.edge_id,
7339 raw.ref_id,
7340 edge.source_node,
7341 edge.target_node,
7342 edge.target_file,
7343 edge.target_symbol,
7344 edge.kind,
7345 edge.line as i64,
7346 PROVENANCE_TREESITTER,
7347 ])?;
7348 }
7349 Ok(())
7350}
7351
7352fn insert_file_extract(
7353 tx: &Transaction<'_>,
7354 project_root: &Path,
7355 extract: &FileExtract,
7356) -> Result<()> {
7357 tx.execute(
7358 "INSERT OR REPLACE INTO files(
7359 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
7360 is_public_api, surface_fingerprint, indexed_at
7361 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
7362 params![
7363 extract.rel_path,
7364 hash_to_hex(extract.freshness.content_hash),
7365 system_time_to_ns(extract.freshness.mtime),
7366 extract.freshness.size as i64,
7367 lang_label(extract.lang),
7368 extract.surface_fingerprint,
7369 unix_seconds_now(),
7370 ],
7371 )?;
7372 for node in &extract.nodes {
7373 tx.execute(
7374 "INSERT OR REPLACE INTO nodes(
7375 id, file_path, name, scoped_name, kind, start_line, start_col,
7376 end_line, end_col, range_ordinal, signature, exported,
7377 is_default_export, is_type_like, is_callgraph_entry_point, provenance
7378 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
7379 params![
7380 node.id,
7381 node.file_path,
7382 node.name,
7383 node.scoped_name,
7384 node.kind,
7385 node.range.start_line as i64,
7386 node.range.start_col as i64,
7387 node.range.end_line as i64,
7388 node.range.end_col as i64,
7389 node.range_ordinal as i64,
7390 node.signature,
7391 bool_int(node.exported),
7392 bool_int(node.is_default_export),
7393 bool_int(node.is_type_like),
7394 bool_int(node.is_callgraph_entry_point),
7395 PROVENANCE_TREESITTER,
7396 ],
7397 )?;
7398 }
7399 let mut dependencies = BTreeSet::new();
7400 for raw_ref in &extract.raw_refs {
7401 dependencies.extend(raw_ref.dependencies.iter().cloned());
7402 }
7403 insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
7404
7405 for hint in &extract.dispatch_hints {
7406 tx.execute(
7407 "INSERT OR REPLACE INTO dispatch_hints(
7408 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
7409 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
7410 params![
7411 hint.id,
7412 hint.method_name,
7413 hint.caller_node,
7414 hint.file,
7415 hint.line as i64,
7416 hint.byte_start as i64,
7417 hint.byte_end as i64,
7418 PROVENANCE_TREESITTER,
7419 ],
7420 )?;
7421 }
7422 mark_backend_state(
7423 tx,
7424 project_root,
7425 &extract.rel_path,
7426 Some(&extract.freshness.content_hash),
7427 "fresh",
7428 )?;
7429 Ok(())
7430}
7431
7432fn insert_file_dependencies(
7433 tx: &Transaction<'_>,
7434 file_path: &str,
7435 dependencies: &BTreeSet<String>,
7436) -> Result<()> {
7437 for dep_file in dependencies {
7438 tx.execute(
7439 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
7440 params![file_path, dep_file],
7441 )?;
7442 }
7443 Ok(())
7444}
7445
7446fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
7447 let raw = &resolved.raw;
7448 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
7449 tx.execute(
7450 "INSERT OR REPLACE INTO refs(
7451 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
7452 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
7453 byte_start, byte_end, status, target_node, target_file, target_symbol,
7454 provenance
7455 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
7456 params![
7457 raw.ref_id,
7458 raw.caller_node,
7459 raw.caller_file,
7460 raw.kind,
7461 raw.short_name,
7462 raw.full_ref,
7463 raw.module_path,
7464 raw.import_kind,
7465 raw.local_name,
7466 raw.requested_name,
7467 raw.namespace_alias,
7468 bool_int(raw.wildcard),
7469 raw.line as i64,
7470 raw.byte_start as i64,
7471 raw.byte_end as i64,
7472 resolved.status,
7473 resolved.target_node,
7474 resolved.target_file,
7475 resolved.target_symbol,
7476 PROVENANCE_TREESITTER,
7477 ],
7478 )?;
7479 if let Some(edge) = &resolved.edge {
7480 tx.execute(
7481 "INSERT OR REPLACE INTO edges(
7482 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
7483 kind, line, provenance
7484 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
7485 params![
7486 edge.edge_id,
7487 raw.ref_id,
7488 edge.source_node,
7489 edge.target_node,
7490 edge.target_file,
7491 edge.target_symbol,
7492 edge.kind,
7493 edge.line as i64,
7494 PROVENANCE_TREESITTER,
7495 ],
7496 )?;
7497 }
7498 Ok(())
7499}
7500
7501fn insert_method_dispatch_edges(
7502 tx: &Transaction<'_>,
7503 project_root: &Path,
7504 caller_files: Option<&BTreeSet<String>>,
7505) -> Result<usize> {
7506 let references = load_name_match_refs(tx, caller_files)?;
7507 if references.is_empty() {
7508 return Ok(0);
7509 }
7510
7511 let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
7512 let mut source_cache: DispatchSourceCache = HashMap::new();
7513 let mut inserted = 0usize;
7514 for reference in references {
7515 let key = (reference.method_name.clone(), reference.lang.clone());
7516 let candidates = match candidates_by_name.entry(key) {
7517 Entry::Occupied(entry) => entry.into_mut(),
7518 Entry::Vacant(entry) => {
7519 let candidates =
7520 load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
7521 entry.insert(candidates)
7522 }
7523 };
7524
7525 if let Some(receiver_type) =
7526 infer_receiver_type(project_root, &reference, &mut source_cache)
7527 {
7528 let Some(candidate) =
7529 select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
7530 else {
7531 continue;
7532 };
7533 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
7534 inserted += 1;
7535 continue;
7536 }
7537
7538 if method_name_match_denylisted(&reference.method_name) {
7539 continue;
7540 }
7541
7542 let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
7543 continue;
7544 };
7545 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
7546 inserted += 1;
7547 }
7548 Ok(inserted)
7549}
7550
7551fn insert_method_dispatch_edges_chunked(
7552 tx: &Transaction<'_>,
7553 project_root: &Path,
7554 caller_files: &BTreeSet<String>,
7555 chunk_size: usize,
7556) -> Result<usize> {
7557 if caller_files.is_empty() {
7558 return Ok(0);
7559 }
7560 if chunk_size == 0 || caller_files.len() <= chunk_size {
7561 return insert_method_dispatch_edges(tx, project_root, Some(caller_files));
7562 }
7563
7564 let mut inserted = 0usize;
7565 let mut batch = BTreeSet::new();
7566 for caller_file in caller_files {
7567 batch.insert(caller_file.clone());
7568 if batch.len() == chunk_size {
7569 inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
7570 batch.clear();
7571 }
7572 }
7573 if !batch.is_empty() {
7574 inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
7575 }
7576 Ok(inserted)
7577}
7578
7579fn insert_method_dispatch_edge(
7580 tx: &Transaction<'_>,
7581 reference: &NameMatchRef,
7582 candidate: &NameMatchCandidate,
7583 provenance: &str,
7584) -> Result<()> {
7585 tx.execute(
7586 "INSERT OR REPLACE INTO edges(
7587 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
7588 kind, line, provenance
7589 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
7590 params![
7591 ref_id(&[&reference.ref_id, provenance, "edge"]),
7592 &reference.ref_id,
7593 &reference.caller_node,
7594 &candidate.node_id,
7595 &candidate.file_path,
7596 &candidate.scoped_name,
7597 reference.line as i64,
7598 provenance,
7599 ],
7600 )?;
7601 Ok(())
7602}
7603
7604fn delete_method_dispatch_edges_for_callers(
7605 tx: &Transaction<'_>,
7606 caller_files: &BTreeSet<String>,
7607) -> Result<()> {
7608 if caller_files.is_empty() {
7609 return Ok(());
7610 }
7611
7612 let mut stmt = tx.prepare(
7613 "DELETE FROM edges
7614 WHERE provenance IN (?1, ?2)
7615 AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
7616 )?;
7617 for caller_file in caller_files {
7618 stmt.execute(params![
7619 PROVENANCE_NAME_MATCH,
7620 PROVENANCE_TYPE_MATCH,
7621 caller_file
7622 ])?;
7623 }
7624 Ok(())
7625}
7626
7627fn load_name_match_refs(
7628 tx: &Transaction<'_>,
7629 caller_files: Option<&BTreeSet<String>>,
7630) -> Result<Vec<NameMatchRef>> {
7631 let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
7632 n.signature, r.short_name, r.full_ref, r.line, f.lang
7633 FROM refs r
7634 JOIN files f ON f.path = r.caller_file
7635 JOIN nodes n ON n.id = r.caller_node
7636 WHERE r.kind = 'call'
7637 AND r.status = 'unresolved'
7638 AND r.caller_node IS NOT NULL
7639 AND r.full_ref IS NOT NULL
7640 AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
7641 AND NOT EXISTS (
7642 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
7643 )";
7644 let mut references = Vec::new();
7645
7646 if let Some(caller_files) = caller_files {
7647 if caller_files.is_empty() {
7648 return Ok(references);
7649 }
7650 let sql = format!(
7651 "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
7652 );
7653 let mut stmt = tx.prepare(&sql)?;
7654 for caller_file in caller_files {
7655 let rows = stmt.query_map(params![caller_file], |row| {
7656 Ok((
7657 row.get::<_, String>(0)?,
7658 row.get::<_, Option<String>>(1)?,
7659 row.get::<_, String>(2)?,
7660 row.get::<_, String>(3)?,
7661 row.get::<_, Option<String>>(4)?,
7662 row.get::<_, Option<String>>(5)?,
7663 row.get::<_, Option<String>>(6)?,
7664 row.get::<_, i64>(7)?,
7665 row.get::<_, String>(8)?,
7666 ))
7667 })?;
7668 for row in rows {
7669 let (
7670 ref_id,
7671 caller_node,
7672 caller_file,
7673 caller_symbol,
7674 caller_signature,
7675 short_name,
7676 full_ref,
7677 line,
7678 lang,
7679 ) = row?;
7680 if let Some(reference) = name_match_ref_from_parts(
7681 ref_id,
7682 caller_node,
7683 caller_file,
7684 caller_symbol,
7685 caller_signature,
7686 short_name,
7687 full_ref,
7688 line,
7689 lang,
7690 ) {
7691 references.push(reference);
7692 }
7693 }
7694 }
7695 return Ok(references);
7696 }
7697
7698 let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
7699 let mut stmt = tx.prepare(&sql)?;
7700 let rows = stmt.query_map([], |row| {
7701 Ok((
7702 row.get::<_, String>(0)?,
7703 row.get::<_, Option<String>>(1)?,
7704 row.get::<_, String>(2)?,
7705 row.get::<_, String>(3)?,
7706 row.get::<_, Option<String>>(4)?,
7707 row.get::<_, Option<String>>(5)?,
7708 row.get::<_, Option<String>>(6)?,
7709 row.get::<_, i64>(7)?,
7710 row.get::<_, String>(8)?,
7711 ))
7712 })?;
7713 for row in rows {
7714 let (
7715 ref_id,
7716 caller_node,
7717 caller_file,
7718 caller_symbol,
7719 caller_signature,
7720 short_name,
7721 full_ref,
7722 line,
7723 lang,
7724 ) = row?;
7725 if let Some(reference) = name_match_ref_from_parts(
7726 ref_id,
7727 caller_node,
7728 caller_file,
7729 caller_symbol,
7730 caller_signature,
7731 short_name,
7732 full_ref,
7733 line,
7734 lang,
7735 ) {
7736 references.push(reference);
7737 }
7738 }
7739 Ok(references)
7740}
7741
7742#[allow(clippy::too_many_arguments)]
7743fn name_match_ref_from_parts(
7744 ref_id: String,
7745 caller_node: Option<String>,
7746 caller_file: String,
7747 caller_symbol: String,
7748 caller_signature: Option<String>,
7749 short_name: Option<String>,
7750 full_ref: Option<String>,
7751 line: i64,
7752 lang: String,
7753) -> Option<NameMatchRef> {
7754 let caller_node = caller_node?;
7755 let full_ref = full_ref?;
7756 let (receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
7757 let method_name = if member.is_empty() {
7758 short_name.as_deref()?.to_string()
7759 } else {
7760 member
7761 };
7762 Some(NameMatchRef {
7763 ref_id,
7764 caller_node,
7765 caller_file,
7766 caller_symbol,
7767 caller_signature,
7768 receiver,
7769 method_name,
7770 colon_dispatch,
7771 line: line.max(0) as u32,
7772 lang,
7773 })
7774}
7775
7776fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, bool)> {
7777 let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
7778 let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
7779 let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
7780 let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
7781 .into_iter()
7782 .flatten()
7783 .max_by_key(|(index, _, _)| *index)?;
7784 if delimiter == 0 {
7785 return None;
7786 }
7787 let member_start = delimiter + delimiter_len;
7788 if member_start >= full_ref.len() {
7789 return None;
7790 }
7791 let receiver = last_name_segment(&full_ref[..delimiter]);
7792 let member = &full_ref[member_start..];
7793 if receiver.is_empty() || member.is_empty() {
7794 return None;
7795 }
7796 Some((receiver.to_string(), member.to_string(), colon_dispatch))
7797}
7798
7799fn last_name_segment(value: &str) -> &str {
7800 value
7801 .rsplit(['.', ':', '/', '\\', '-', '>'])
7802 .find(|segment| !segment.is_empty())
7803 .unwrap_or(value)
7804}
7805
7806fn load_name_match_candidates(
7807 tx: &Transaction<'_>,
7808 method_name: &str,
7809 lang: &str,
7810) -> Result<Vec<NameMatchCandidate>> {
7811 let mut stmt = tx.prepare(
7812 "SELECT n.id, n.file_path, n.scoped_name, n.kind
7813 FROM nodes n JOIN files f ON f.path = n.file_path
7814 WHERE n.name = ?1
7815 AND f.lang = ?2
7816 AND n.kind IN ('method', 'function')
7817 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
7818 )?;
7819 let rows = stmt.query_map(params![method_name, lang], |row| {
7820 Ok(NameMatchCandidate {
7821 node_id: row.get(0)?,
7822 file_path: row.get(1)?,
7823 scoped_name: row.get(2)?,
7824 kind: row.get(3)?,
7825 })
7826 })?;
7827 rows.collect::<std::result::Result<Vec<_>, _>>()
7828 .map_err(Into::into)
7829}
7830
7831struct ParsedDispatchSource {
7832 source: String,
7833 tree: tree_sitter::Tree,
7834}
7835
7836type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
7837
7838fn infer_receiver_type(
7839 project_root: &Path,
7840 reference: &NameMatchRef,
7841 source_cache: &mut DispatchSourceCache,
7842) -> Option<String> {
7843 match reference.lang.as_str() {
7844 "rust" => infer_rust_receiver_type(reference),
7845 "java" => {
7846 infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
7847 }
7848 "kotlin" => {
7849 infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
7850 }
7851 "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache),
7852 _ => None,
7853 }
7854}
7855
7856fn parse_dispatch_source(
7857 project_root: &Path,
7858 caller_file: &str,
7859 lang: LangId,
7860) -> Option<ParsedDispatchSource> {
7861 let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
7862 let grammar = crate::parser::grammar_for(lang);
7863 let mut parser = tree_sitter::Parser::new();
7864 parser.set_language(&grammar).ok()?;
7865 let tree = parser.parse(&source, None)?;
7866 Some(ParsedDispatchSource { source, tree })
7867}
7868
7869fn parsed_dispatch_source<'a>(
7870 project_root: &Path,
7871 reference: &NameMatchRef,
7872 lang: LangId,
7873 source_cache: &'a mut DispatchSourceCache,
7874) -> Option<&'a ParsedDispatchSource> {
7875 let key = (reference.caller_file.clone(), reference.lang.clone());
7876 source_cache
7877 .entry(key)
7878 .or_insert_with(|| parse_dispatch_source(project_root, &reference.caller_file, lang))
7879 .as_ref()
7880}
7881
7882fn infer_java_like_receiver_type(
7883 project_root: &Path,
7884 reference: &NameMatchRef,
7885 lang: LangId,
7886 source_cache: &mut DispatchSourceCache,
7887) -> Option<String> {
7888 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
7889 return None;
7890 }
7891
7892 let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
7893 let root = parsed.tree.root_node();
7894 let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
7895
7896 let callable_scope = type_node
7897 .and_then(|node| {
7898 find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
7899 })
7900 .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
7901
7902 if let Some(callable_scope) = callable_scope {
7903 if let Some(receiver_type) = infer_java_like_local_receiver_type(
7904 callable_scope,
7905 &parsed.source,
7906 &reference.receiver,
7907 reference.line.max(1),
7908 lang,
7909 ) {
7910 return Some(receiver_type);
7911 }
7912 }
7913
7914 type_node.and_then(|node| {
7915 infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
7916 })
7917}
7918
7919fn infer_cpp_receiver_type(
7920 project_root: &Path,
7921 reference: &NameMatchRef,
7922 source_cache: &mut DispatchSourceCache,
7923) -> Option<String> {
7924 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
7925 return None;
7926 }
7927
7928 let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
7929 let root = parsed.tree.root_node();
7930 let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
7931 infer_cpp_receiver_type_from_scope(
7932 scope,
7933 &parsed.source,
7934 &reference.receiver,
7935 reference.line.max(1),
7936 )
7937}
7938
7939fn find_enclosing_java_like_type_node<'tree>(
7940 root: tree_sitter::Node<'tree>,
7941 source: &str,
7942 reference: &NameMatchRef,
7943 lang: LangId,
7944) -> Option<tree_sitter::Node<'tree>> {
7945 let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
7946 .and_then(|name| simple_type_name(&name));
7947 let line = reference.line.max(1);
7948 let mut best = None;
7949 let mut stack = vec![root];
7950 while let Some(node) = stack.pop() {
7951 if !node_contains_line(node, line) {
7952 continue;
7953 }
7954 if is_java_like_type_kind(node.kind(), lang) {
7955 let name = declaration_name(node, source);
7956 if expected_type
7957 .as_deref()
7958 .is_none_or(|expected| name == Some(expected))
7959 {
7960 best = tighter_node(best, node);
7961 }
7962 }
7963 push_named_children(node, &mut stack);
7964 }
7965 best
7966}
7967
7968fn find_enclosing_java_like_callable_node<'tree>(
7969 root: tree_sitter::Node<'tree>,
7970 source: &str,
7971 reference: &NameMatchRef,
7972 lang: LangId,
7973) -> Option<tree_sitter::Node<'tree>> {
7974 let expected_name = reference.caller_symbol.rsplit("::").next();
7975 let line = reference.line.max(1);
7976 let mut best = None;
7977 let mut stack = vec![root];
7978 while let Some(node) = stack.pop() {
7979 if !node_contains_line(node, line) {
7980 continue;
7981 }
7982 if is_java_like_callable_kind(node.kind(), lang) {
7983 let name = declaration_name(node, source);
7984 if expected_name.is_none_or(|expected| name == Some(expected)) {
7985 best = tighter_node(best, node);
7986 }
7987 }
7988 push_named_children(node, &mut stack);
7989 }
7990 best
7991}
7992
7993fn find_enclosing_cpp_callable_node<'tree>(
7994 root: tree_sitter::Node<'tree>,
7995 _source: &str,
7996 reference: &NameMatchRef,
7997) -> Option<tree_sitter::Node<'tree>> {
7998 let line = reference.line.max(1);
7999 let mut best = None;
8000 let mut stack = vec![root];
8001 while let Some(node) = stack.pop() {
8002 if !node_contains_line(node, line) {
8003 continue;
8004 }
8005 if node.kind() == "function_definition" {
8006 best = tighter_node(best, node);
8007 }
8008 push_named_children(node, &mut stack);
8009 }
8010 best
8011}
8012
8013fn tighter_node<'tree>(
8014 current: Option<tree_sitter::Node<'tree>>,
8015 candidate: tree_sitter::Node<'tree>,
8016) -> Option<tree_sitter::Node<'tree>> {
8017 match current {
8018 Some(current)
8019 if current.start_byte() > candidate.start_byte()
8020 || (current.start_byte() == candidate.start_byte()
8021 && current.end_byte() <= candidate.end_byte()) =>
8022 {
8023 Some(current)
8024 }
8025 _ => Some(candidate),
8026 }
8027}
8028
8029fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
8030 let start = node.start_position().row as u32 + 1;
8031 let end = node.end_position().row as u32 + 1;
8032 start <= line && line <= end
8033}
8034
8035fn push_named_children<'tree>(
8036 node: tree_sitter::Node<'tree>,
8037 stack: &mut Vec<tree_sitter::Node<'tree>>,
8038) {
8039 for index in 0..node.named_child_count() {
8040 if let Some(child) = node.named_child(index as u32) {
8041 stack.push(child);
8042 }
8043 }
8044}
8045
8046fn declaration_name<'source>(
8047 node: tree_sitter::Node<'_>,
8048 source: &'source str,
8049) -> Option<&'source str> {
8050 node.child_by_field_name("name")
8051 .map(|name| node_text(name, source))
8052 .or_else(|| {
8053 first_named_child_text(
8054 node,
8055 source,
8056 &["identifier", "type_identifier", "simple_identifier"],
8057 )
8058 })
8059}
8060
8061fn first_named_child_text<'source>(
8062 node: tree_sitter::Node<'_>,
8063 source: &'source str,
8064 kinds: &[&str],
8065) -> Option<&'source str> {
8066 for index in 0..node.named_child_count() {
8067 let child = node.named_child(index as u32)?;
8068 if kinds.contains(&child.kind()) {
8069 return Some(node_text(child, source));
8070 }
8071 }
8072 None
8073}
8074
8075fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
8076 &source[node.byte_range()]
8077}
8078
8079fn infer_java_like_field_receiver_type(
8080 type_node: tree_sitter::Node<'_>,
8081 source: &str,
8082 receiver: &str,
8083 lang: LangId,
8084) -> Option<String> {
8085 let mut stack = Vec::new();
8086 push_named_children(type_node, &mut stack);
8087 while let Some(node) = stack.pop() {
8088 if is_java_like_field_kind(node.kind(), lang) {
8089 if let Some(receiver_type) =
8090 extract_java_like_declared_type(node_text(node, source), receiver, lang)
8091 {
8092 return Some(receiver_type);
8093 }
8094 }
8095 if is_java_like_type_kind(node.kind(), lang)
8096 || is_java_like_callable_kind(node.kind(), lang)
8097 {
8098 continue;
8099 }
8100 push_named_children(node, &mut stack);
8101 }
8102 None
8103}
8104
8105fn infer_java_like_local_receiver_type(
8106 callable_node: tree_sitter::Node<'_>,
8107 source: &str,
8108 receiver: &str,
8109 call_line: u32,
8110 lang: LangId,
8111) -> Option<String> {
8112 let mut best: Option<(u32, String)> = None;
8113 let mut stack = Vec::new();
8114 push_named_children(callable_node, &mut stack);
8115 while let Some(node) = stack.pop() {
8116 let start_line = node.start_position().row as u32 + 1;
8117 if start_line > call_line {
8118 continue;
8119 }
8120 if is_java_like_local_kind(node.kind(), lang) {
8121 if let Some(receiver_type) =
8122 extract_java_like_declared_type(node_text(node, source), receiver, lang)
8123 {
8124 if best
8125 .as_ref()
8126 .is_none_or(|(best_line, _)| start_line >= *best_line)
8127 {
8128 best = Some((start_line, receiver_type));
8129 }
8130 }
8131 }
8132 if is_java_like_type_kind(node.kind(), lang)
8133 || is_java_like_callable_kind(node.kind(), lang)
8134 {
8135 continue;
8136 }
8137 push_named_children(node, &mut stack);
8138 }
8139 best.map(|(_, receiver_type)| receiver_type)
8140}
8141
8142fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
8143 match lang {
8144 LangId::Java => matches!(
8145 kind,
8146 "class_declaration"
8147 | "interface_declaration"
8148 | "enum_declaration"
8149 | "record_declaration"
8150 | "annotation_type_declaration"
8151 ),
8152 LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
8153 _ => false,
8154 }
8155}
8156
8157fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
8158 match lang {
8159 LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
8160 LangId::Kotlin => kind == "function_declaration",
8161 _ => false,
8162 }
8163}
8164
8165fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
8166 match lang {
8167 LangId::Java => kind == "field_declaration",
8168 LangId::Kotlin => kind == "property_declaration",
8169 _ => false,
8170 }
8171}
8172
8173fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
8174 match lang {
8175 LangId::Java => kind == "local_variable_declaration",
8176 LangId::Kotlin => kind == "property_declaration",
8177 _ => false,
8178 }
8179}
8180
8181fn extract_java_like_declared_type(
8182 declaration: &str,
8183 receiver: &str,
8184 lang: LangId,
8185) -> Option<String> {
8186 match lang {
8187 LangId::Java => extract_java_declared_type(declaration, receiver),
8188 LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
8189 _ => None,
8190 }
8191}
8192
8193fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
8194 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
8195 let after = declaration[receiver_start + receiver.len()..].trim_start();
8196 if after
8197 .chars()
8198 .next()
8199 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
8200 {
8201 return None;
8202 }
8203
8204 let before = declaration[..receiver_start].trim_end();
8205 if before.contains(',') {
8206 return None;
8207 }
8208 normalize_receiver_type_name(strip_java_declaration_prefixes(before))
8209}
8210
8211fn strip_java_declaration_prefixes(mut value: &str) -> &str {
8212 loop {
8213 value = value.trim_start();
8214 if let Some(stripped) = strip_leading_java_annotation(value) {
8215 value = stripped;
8216 continue;
8217 }
8218 if let Some(stripped) = strip_leading_java_modifier(value) {
8219 value = stripped;
8220 continue;
8221 }
8222 return value.trim();
8223 }
8224}
8225
8226fn strip_leading_java_annotation(value: &str) -> Option<&str> {
8227 let value = value.trim_start();
8228 let mut chars = value.char_indices();
8229 let (_, first) = chars.next()?;
8230 if first != '@' {
8231 return None;
8232 }
8233 let mut end = first.len_utf8();
8234 for (index, ch) in chars {
8235 if !(is_code_ident_char(ch) || ch == '.') {
8236 end = index;
8237 break;
8238 }
8239 end = index + ch.len_utf8();
8240 }
8241 let rest = value[end..].trim_start();
8242 if let Some(stripped) = rest.strip_prefix('(') {
8243 let mut depth = 1usize;
8244 for (index, ch) in stripped.char_indices() {
8245 match ch {
8246 '(' => depth += 1,
8247 ')' => {
8248 depth = depth.saturating_sub(1);
8249 if depth == 0 {
8250 return Some(stripped[index + ch.len_utf8()..].trim_start());
8251 }
8252 }
8253 _ => {}
8254 }
8255 }
8256 return Some("");
8257 }
8258 Some(rest)
8259}
8260
8261fn strip_leading_java_modifier(value: &str) -> Option<&str> {
8262 const MODIFIERS: &[&str] = &[
8263 "public",
8264 "protected",
8265 "private",
8266 "abstract",
8267 "static",
8268 "final",
8269 "transient",
8270 "volatile",
8271 "synchronized",
8272 "native",
8273 "strictfp",
8274 ];
8275 MODIFIERS
8276 .iter()
8277 .find_map(|modifier| strip_leading_word(value, modifier))
8278}
8279
8280fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
8281 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
8282 let before = &declaration[..receiver_start];
8283 if find_identifier_occurrence(before, "val").is_none()
8284 && find_identifier_occurrence(before, "var").is_none()
8285 {
8286 return None;
8287 }
8288
8289 let after = declaration[receiver_start + receiver.len()..].trim_start();
8290 if let Some(type_text) = after.strip_prefix(':') {
8291 return normalize_receiver_type_name(read_type_prefix(type_text));
8292 }
8293 after
8294 .strip_prefix('=')
8295 .and_then(infer_kotlin_constructor_type)
8296}
8297
8298fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
8299 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
8300 if rest.trim_start().starts_with('(') {
8301 normalize_receiver_type_name(head)
8302 } else {
8303 None
8304 }
8305}
8306
8307fn read_type_prefix(value: &str) -> &str {
8308 let mut angle_depth = 0usize;
8309 for (index, ch) in value.char_indices() {
8310 match ch {
8311 '<' => angle_depth += 1,
8312 '>' => angle_depth = angle_depth.saturating_sub(1),
8313 '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
8314 return value[..index].trim();
8315 }
8316 _ => {}
8317 }
8318 }
8319 value.trim()
8320}
8321
8322fn infer_cpp_receiver_type_from_scope(
8323 scope: tree_sitter::Node<'_>,
8324 source: &str,
8325 receiver: &str,
8326 call_line: u32,
8327) -> Option<String> {
8328 let lines = source.lines().collect::<Vec<_>>();
8329 if lines.is_empty() {
8330 return None;
8331 }
8332 let scope_start = scope.start_position().row as usize;
8333 let call_index = (call_line as usize)
8334 .saturating_sub(1)
8335 .min(lines.len().saturating_sub(1));
8336 for index in (scope_start..=call_index).rev() {
8337 if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
8338 return Some(receiver_type);
8339 }
8340 }
8341 None
8342}
8343
8344fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
8345 for receiver_start in identifier_occurrences(line, receiver) {
8346 let after = line[receiver_start + receiver.len()..].trim_start();
8347 if after
8348 .chars()
8349 .next()
8350 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
8351 {
8352 continue;
8353 }
8354 let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
8355 let normalized = normalize_cpp_type_name(type_text)?;
8356 if normalized == "auto" {
8357 if let Some(rhs) = after.strip_prefix('=') {
8358 return infer_cpp_auto_receiver_type(rhs);
8359 }
8360 continue;
8361 }
8362 return Some(normalized);
8363 }
8364 None
8365}
8366
8367fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
8368 let candidate = prefix
8369 .rsplit([';', '{', '}', '('])
8370 .next()
8371 .unwrap_or(prefix)
8372 .trim();
8373 if candidate.is_empty() || candidate.ends_with(',') {
8374 None
8375 } else {
8376 Some(candidate)
8377 }
8378}
8379
8380fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
8381 let without_templates = strip_angle_groups(type_text);
8382 let mut cleaned = String::with_capacity(without_templates.len());
8383 for token in without_templates.split_whitespace() {
8384 if matches!(
8385 token,
8386 "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
8387 ) {
8388 continue;
8389 }
8390 if !cleaned.is_empty() {
8391 cleaned.push(' ');
8392 }
8393 cleaned.push_str(token);
8394 }
8395 let token = cleaned
8396 .split_whitespace()
8397 .last()
8398 .unwrap_or(cleaned.trim())
8399 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
8400 .trim_matches(['*', '&']);
8401 let simple = token.rsplit("::").next().unwrap_or(token).trim();
8402 if simple.is_empty() || cpp_non_type_token(simple) {
8403 None
8404 } else {
8405 Some(simple.to_string())
8406 }
8407}
8408
8409fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
8410 let rhs = rhs.trim_start();
8411 if let Some(after_new) = rhs.strip_prefix("new ") {
8412 return infer_cpp_constructor_type(after_new);
8413 }
8414 infer_cpp_make_template_type(rhs)
8415 .or_else(|| infer_cpp_constructor_type(rhs))
8416 .or_else(|| infer_cpp_factory_type(rhs))
8417}
8418
8419fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
8420 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
8421 let normalized = normalize_cpp_type_name(head)?;
8422 if !normalized
8423 .chars()
8424 .next()
8425 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
8426 {
8427 return None;
8428 }
8429 if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
8430 Some(normalized)
8431 } else {
8432 None
8433 }
8434}
8435
8436fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
8437 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
8438 if !rest.trim_start().starts_with('(') {
8439 return None;
8440 }
8441 let base = head.split('<').next().unwrap_or(head);
8442 let base_simple = base.rsplit("::").next().unwrap_or(base);
8443 if !matches!(base_simple, "make_unique" | "make_shared") {
8444 return None;
8445 }
8446 first_angle_arg(head).and_then(normalize_cpp_type_name)
8447}
8448
8449fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
8450 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
8451 if !rest.trim_start().starts_with('(') {
8452 return None;
8453 }
8454 let simple = head
8455 .split('<')
8456 .next()
8457 .unwrap_or(head)
8458 .rsplit("::")
8459 .next()
8460 .unwrap_or(head);
8461 for prefix in ["make", "create", "build"] {
8462 if let Some(suffix) = simple.strip_prefix(prefix) {
8463 if suffix
8464 .chars()
8465 .next()
8466 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
8467 {
8468 return normalize_cpp_type_name(suffix);
8469 }
8470 }
8471 }
8472 None
8473}
8474
8475#[derive(Debug, Clone, Copy)]
8476enum JavaLikeInvocation {
8477 Kotlin,
8478 Cpp,
8479}
8480
8481fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
8482 let value = value.trim_start();
8483 let mut end = 0usize;
8484 for (index, ch) in value.char_indices() {
8485 let allowed_separator = match flavor {
8486 JavaLikeInvocation::Kotlin => ch == '.',
8487 JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
8488 };
8489 if is_code_ident_char(ch) || allowed_separator {
8490 end = index + ch.len_utf8();
8491 continue;
8492 }
8493 break;
8494 }
8495 if end == 0 {
8496 return None;
8497 }
8498 let mut rest = &value[end..];
8499 if let Some(stripped) = rest.trim_start().strip_prefix('<') {
8500 let skipped = skip_balanced_angle(stripped)?;
8501 let rest_start = rest.len() - rest.trim_start().len();
8502 let angle_len = 1 + skipped;
8503 end += rest_start + angle_len;
8504 rest = &value[end..];
8505 }
8506 Some((value[..end].trim(), rest))
8507}
8508
8509fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
8510 let mut depth = 1usize;
8511 for (index, ch) in value_after_open.char_indices() {
8512 match ch {
8513 '<' => depth += 1,
8514 '>' => {
8515 depth = depth.saturating_sub(1);
8516 if depth == 0 {
8517 return Some(index + ch.len_utf8());
8518 }
8519 }
8520 _ => {}
8521 }
8522 }
8523 None
8524}
8525
8526fn first_angle_arg(value: &str) -> Option<&str> {
8527 let open = value.find('<')?;
8528 let inner_len = skip_balanced_angle(&value[open + 1..])?;
8529 let inner = &value[open + 1..open + inner_len];
8530 split_top_level_commas(inner).into_iter().next()
8531}
8532
8533fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
8534 let without_generics = strip_angle_groups(type_text);
8535 let cleaned = without_generics
8536 .replace("[]", " ")
8537 .replace("...", " ")
8538 .replace(['?', '&', '*'], " ");
8539 let token = cleaned
8540 .split_whitespace()
8541 .last()
8542 .unwrap_or(cleaned.trim())
8543 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
8544 let token = token.rsplit("::").next().unwrap_or(token);
8545 let simple = token.rsplit('.').next().unwrap_or(token).trim();
8546 if simple.is_empty()
8547 || java_like_primitive_type(simple)
8548 || !simple
8549 .chars()
8550 .next()
8551 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
8552 {
8553 None
8554 } else {
8555 Some(simple.to_string())
8556 }
8557}
8558
8559fn simple_type_name(scoped_name: &str) -> Option<String> {
8560 scoped_name
8561 .rsplit("::")
8562 .find(|segment| !segment.is_empty())
8563 .and_then(normalize_receiver_type_name)
8564}
8565
8566fn strip_angle_groups(value: &str) -> String {
8567 let mut output = String::with_capacity(value.len());
8568 let mut depth = 0usize;
8569 for ch in value.chars() {
8570 match ch {
8571 '<' => {
8572 if depth == 0 {
8573 output.push(' ');
8574 }
8575 depth += 1;
8576 }
8577 '>' => depth = depth.saturating_sub(1),
8578 _ if depth == 0 => output.push(ch),
8579 _ => {}
8580 }
8581 }
8582 output
8583}
8584
8585fn java_like_primitive_type(value: &str) -> bool {
8586 matches!(
8587 value,
8588 "boolean"
8589 | "byte"
8590 | "char"
8591 | "double"
8592 | "float"
8593 | "int"
8594 | "long"
8595 | "short"
8596 | "void"
8597 | "Boolean"
8598 | "Byte"
8599 | "Char"
8600 | "Double"
8601 | "Float"
8602 | "Int"
8603 | "Long"
8604 | "Short"
8605 | "Unit"
8606 )
8607}
8608
8609fn cpp_non_type_token(value: &str) -> bool {
8610 matches!(
8611 value,
8612 "return"
8613 | "if"
8614 | "else"
8615 | "for"
8616 | "while"
8617 | "do"
8618 | "switch"
8619 | "case"
8620 | "default"
8621 | "break"
8622 | "continue"
8623 | "goto"
8624 | "throw"
8625 | "new"
8626 | "delete"
8627 | "co_await"
8628 | "co_yield"
8629 | "co_return"
8630 | "static_cast"
8631 | "const_cast"
8632 | "dynamic_cast"
8633 | "reinterpret_cast"
8634 | "sizeof"
8635 | "alignof"
8636 | "typeid"
8637 | "and"
8638 | "or"
8639 | "not"
8640 | "xor"
8641 )
8642}
8643
8644fn receiver_is_bare_identifier(value: &str) -> bool {
8645 let mut chars = value.chars();
8646 let Some(first) = chars.next() else {
8647 return false;
8648 };
8649 (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
8650}
8651
8652fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
8653 identifier_occurrences(value, needle).into_iter().next()
8654}
8655
8656fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
8657 value
8658 .match_indices(needle)
8659 .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
8660 .collect()
8661}
8662
8663fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
8664 let before = value[..start].chars().next_back();
8665 let after = value[start + len..].chars().next();
8666 !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
8667}
8668
8669fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
8670 let stripped = value.strip_prefix(word)?;
8671 if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
8672 Some(stripped.trim_start())
8673 } else {
8674 None
8675 }
8676}
8677
8678fn is_code_ident_char(ch: char) -> bool {
8679 ch == '_' || ch.is_ascii_alphanumeric()
8680}
8681
8682fn infer_rust_receiver_type(reference: &NameMatchRef) -> Option<String> {
8683 if matches!(reference.receiver.as_str(), "self" | "Self") {
8684 return enclosing_type_from_scoped_name(&reference.caller_symbol);
8685 }
8686
8687 if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
8688 return Some(reference.receiver.clone());
8689 }
8690
8691 reference
8692 .caller_signature
8693 .as_deref()
8694 .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
8695}
8696
8697fn rust_receiver_looks_type_like(receiver: &str) -> bool {
8698 receiver
8699 .chars()
8700 .next()
8701 .is_some_and(|ch| ch == '_' || ch.is_uppercase())
8702}
8703
8704fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
8705 scoped_name
8706 .rsplit_once("::")
8707 .map(|(enclosing, _)| enclosing)
8708 .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
8709 .map(ToString::to_string)
8710}
8711
8712fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
8713 let params = signature_parameter_text(signature)?;
8714 for param in split_top_level_commas(params) {
8715 let Some((pattern, type_text)) = param.split_once(':') else {
8716 continue;
8717 };
8718 let Some(name) = rust_parameter_name(pattern) else {
8719 continue;
8720 };
8721 if name == receiver {
8722 return normalize_rust_receiver_type(type_text);
8723 }
8724 }
8725 None
8726}
8727
8728fn signature_parameter_text(signature: &str) -> Option<&str> {
8729 let open = signature.find('(')?;
8730 let mut depth = 0usize;
8731 for (offset, ch) in signature[open..].char_indices() {
8732 match ch {
8733 '(' => depth += 1,
8734 ')' => {
8735 depth = depth.saturating_sub(1);
8736 if depth == 0 {
8737 return Some(&signature[open + 1..open + offset]);
8738 }
8739 }
8740 _ => {}
8741 }
8742 }
8743 None
8744}
8745
8746fn split_top_level_commas(value: &str) -> Vec<&str> {
8747 let mut parts = Vec::new();
8748 let mut start = 0usize;
8749 let mut angle_depth = 0usize;
8750 let mut paren_depth = 0usize;
8751 let mut bracket_depth = 0usize;
8752 for (index, ch) in value.char_indices() {
8753 match ch {
8754 '<' => angle_depth += 1,
8755 '>' => angle_depth = angle_depth.saturating_sub(1),
8756 '(' => paren_depth += 1,
8757 ')' => paren_depth = paren_depth.saturating_sub(1),
8758 '[' => bracket_depth += 1,
8759 ']' => bracket_depth = bracket_depth.saturating_sub(1),
8760 ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
8761 let part = value[start..index].trim();
8762 if !part.is_empty() {
8763 parts.push(part);
8764 }
8765 start = index + ch.len_utf8();
8766 }
8767 _ => {}
8768 }
8769 }
8770 let part = value[start..].trim();
8771 if !part.is_empty() {
8772 parts.push(part);
8773 }
8774 parts
8775}
8776
8777fn rust_parameter_name(pattern: &str) -> Option<&str> {
8778 let mut pattern = pattern.trim();
8779 if let Some(stripped) = pattern.strip_prefix("mut ") {
8780 pattern = stripped.trim_start();
8781 }
8782 pattern
8783 .rsplit(|ch: char| !is_rust_ident_char(ch))
8784 .find(|part| !part.is_empty())
8785}
8786
8787fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
8788 let mut ty = strip_leading_rust_type_modifiers(type_text);
8789 let owned_inner;
8790 if let Some(inner) = single_outer_generic_arg(ty) {
8791 owned_inner = inner.trim().to_string();
8792 ty = strip_leading_rust_type_modifiers(&owned_inner);
8793 }
8794 rust_base_type_ident(ty)
8795}
8796
8797fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
8798 loop {
8799 ty = ty.trim_start();
8800 if let Some(stripped) = ty.strip_prefix('&') {
8801 ty = stripped.trim_start();
8802 if let Some(stripped) = strip_leading_lifetime(ty) {
8803 ty = stripped.trim_start();
8804 }
8805 if let Some(stripped) = ty.strip_prefix("mut ") {
8806 ty = stripped.trim_start();
8807 }
8808 continue;
8809 }
8810 if let Some(stripped) = ty.strip_prefix("mut ") {
8811 ty = stripped.trim_start();
8812 continue;
8813 }
8814 if let Some(stripped) = ty.strip_prefix("dyn ") {
8815 ty = stripped.trim_start();
8816 continue;
8817 }
8818 if let Some(stripped) = ty.strip_prefix("impl ") {
8819 ty = stripped.trim_start();
8820 continue;
8821 }
8822 break ty.trim();
8823 }
8824}
8825
8826fn strip_leading_lifetime(value: &str) -> Option<&str> {
8827 let mut chars = value.char_indices();
8828 let (_, first) = chars.next()?;
8829 if first != '\'' {
8830 return None;
8831 }
8832 for (index, ch) in chars {
8833 if !(ch == '_' || ch.is_ascii_alphanumeric()) {
8834 return Some(&value[index..]);
8835 }
8836 }
8837 Some("")
8838}
8839
8840fn single_outer_generic_arg(ty: &str) -> Option<&str> {
8841 let ty = ty.trim();
8842 let open = ty.find('<')?;
8843 let mut depth = 0usize;
8844 let mut close = None;
8845 for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
8846 match ch {
8847 '<' => depth += 1,
8848 '>' => {
8849 depth = depth.saturating_sub(1);
8850 if depth == 0 {
8851 close = Some(index);
8852 break;
8853 }
8854 }
8855 _ => {}
8856 }
8857 }
8858 let close = close?;
8859 if !ty[close + 1..].trim().is_empty() {
8860 return None;
8861 }
8862 let inner = &ty[open + 1..close];
8863 let args = split_top_level_commas(inner);
8864 match args.as_slice() {
8865 [arg] => Some(*arg),
8866 _ => None,
8867 }
8868}
8869
8870fn rust_base_type_ident(ty: &str) -> Option<String> {
8871 let ty = ty.trim();
8872 let head = ty
8873 .split([' ', '+', '='])
8874 .find(|part| !part.is_empty())
8875 .unwrap_or(ty);
8876 let head = head.split('<').next().unwrap_or(head).trim();
8877 let ident = head
8878 .rsplit("::")
8879 .next()
8880 .unwrap_or(head)
8881 .trim_matches(|ch: char| !is_rust_ident_char(ch));
8882 if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
8883 None
8884 } else {
8885 Some(ident.to_string())
8886 }
8887}
8888
8889fn is_rust_ident_char(ch: char) -> bool {
8890 ch == '_' || ch.is_ascii_alphanumeric()
8891}
8892
8893fn select_type_match_candidate(
8894 reference: &NameMatchRef,
8895 candidates: &[NameMatchCandidate],
8896 receiver_type: &str,
8897) -> Option<NameMatchCandidate> {
8898 let candidates = candidates
8899 .iter()
8900 .filter(|candidate| candidate.node_id != reference.caller_node)
8901 .filter(|candidate| {
8902 type_candidate_matches(candidate, receiver_type, &reference.method_name)
8903 })
8904 .collect::<Vec<_>>();
8905 match candidates.as_slice() {
8906 [candidate] => Some((**candidate).clone()),
8907 _ => None,
8908 }
8909}
8910
8911fn type_candidate_matches(
8912 candidate: &NameMatchCandidate,
8913 receiver_type: &str,
8914 method_name: &str,
8915) -> bool {
8916 let normalized_type = receiver_type.replace('.', "::");
8917 let suffix = format!("{normalized_type}::{method_name}");
8918 candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
8919}
8920
8921fn select_name_match_candidate(
8922 reference: &NameMatchRef,
8923 candidates: &[NameMatchCandidate],
8924) -> Option<NameMatchCandidate> {
8925 let candidates = candidates
8926 .iter()
8927 .filter(|candidate| candidate.node_id != reference.caller_node)
8928 .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
8929 .collect::<Vec<_>>();
8930 match candidates.as_slice() {
8931 [] => None,
8932 [candidate] => Some((**candidate).clone()),
8933 _ => select_scored_name_match_candidate(reference, &candidates),
8934 }
8935}
8936
8937fn candidate_allowed_for_reference(
8938 reference: &NameMatchRef,
8939 candidate: &NameMatchCandidate,
8940) -> bool {
8941 if !reference.colon_dispatch {
8942 return true;
8943 }
8944
8945 candidate.kind == "method"
8946 && candidate
8947 .scoped_name
8948 .split("::")
8949 .any(|segment| segment == reference.receiver)
8950}
8951
8952fn select_scored_name_match_candidate(
8953 reference: &NameMatchRef,
8954 candidates: &[&NameMatchCandidate],
8955) -> Option<NameMatchCandidate> {
8956 let receiver_words = split_camel_case(&reference.receiver);
8957 if receiver_words.is_empty() {
8958 return None;
8959 }
8960
8961 let mut best: Option<(&NameMatchCandidate, f64)> = None;
8962 let mut tied_best = false;
8963 for candidate in candidates {
8964 let candidate_words = split_camel_case(&candidate.scoped_name);
8965 let overlap = receiver_words
8966 .iter()
8967 .filter(|receiver_word| {
8968 candidate_words
8969 .iter()
8970 .any(|candidate_word| candidate_word == *receiver_word)
8971 })
8972 .count() as f64;
8973 let score =
8974 overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
8975 match best {
8976 None => {
8977 best = Some((*candidate, score));
8978 tied_best = false;
8979 }
8980 Some((_, best_score)) if score > best_score => {
8981 best = Some((*candidate, score));
8982 tied_best = false;
8983 }
8984 Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
8985 tied_best = true;
8986 }
8987 _ => {}
8988 }
8989 }
8990
8991 let (candidate, score) = best?;
8992 if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
8993 Some(candidate.clone())
8994 } else {
8995 None
8996 }
8997}
8998
8999fn method_name_match_denylisted(method_name: &str) -> bool {
9000 matches!(
9001 method_name,
9002 "and_then"
9003 | "as_bytes"
9004 | "as_deref"
9005 | "as_mut"
9006 | "as_ref"
9007 | "as_str"
9008 | "borrow"
9009 | "borrow_mut"
9010 | "clear"
9011 | "clone"
9012 | "collect"
9013 | "contains"
9014 | "contains_key"
9015 | "count"
9016 | "dedup"
9017 | "default"
9018 | "drain"
9019 | "ends_with"
9020 | "entry"
9021 | "err"
9022 | "expect"
9023 | "extend"
9024 | "filter"
9025 | "filter_map"
9026 | "find"
9027 | "from"
9028 | "get"
9029 | "get_mut"
9030 | "insert"
9031 | "into"
9032 | "into_iter"
9033 | "is_empty"
9034 | "is_err"
9035 | "is_none"
9036 | "is_ok"
9037 | "is_some"
9038 | "iter"
9039 | "iter_mut"
9040 | "join"
9041 | "len"
9042 | "lock"
9043 | "map"
9044 | "map_err"
9045 | "max"
9046 | "min"
9047 | "new"
9048 | "next"
9049 | "ok"
9050 | "or_default"
9051 | "or_else"
9052 | "or_insert"
9053 | "or_insert_with"
9054 | "parse"
9055 | "pop"
9056 | "position"
9057 | "push"
9058 | "read"
9059 | "recv"
9060 | "remove"
9061 | "replace"
9062 | "retain"
9063 | "send"
9064 | "sort"
9065 | "sort_by"
9066 | "split"
9067 | "starts_with"
9068 | "sum"
9069 | "take"
9070 | "to_owned"
9071 | "to_string"
9072 | "trim"
9073 | "try_from"
9074 | "try_into"
9075 | "unwrap"
9076 | "unwrap_or"
9077 | "unwrap_or_default"
9078 | "unwrap_or_else"
9079 | "with_capacity"
9080 | "write"
9081 )
9082}
9083
9084fn split_camel_case(value: &str) -> Vec<String> {
9085 let chars = value.chars().collect::<Vec<_>>();
9086 let mut normalized = String::with_capacity(value.len() + 8);
9087 for (index, ch) in chars.iter().enumerate() {
9088 let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
9089 let next = chars.get(index + 1);
9090 let is_separator = ch.is_whitespace()
9091 || matches!(
9092 ch,
9093 '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
9094 );
9095 if is_separator {
9096 normalized.push(' ');
9097 continue;
9098 }
9099 let camel_boundary = previous.is_some_and(|prev| {
9100 (prev.is_lowercase() && ch.is_uppercase())
9101 || (prev.is_ascii_digit() && ch.is_alphabetic())
9102 || (prev.is_uppercase()
9103 && ch.is_uppercase()
9104 && next.is_some_and(|next| next.is_lowercase()))
9105 });
9106 if camel_boundary {
9107 normalized.push(' ');
9108 }
9109 normalized.push(*ch);
9110 }
9111
9112 normalized
9113 .split_whitespace()
9114 .filter(|word| word.len() > 1)
9115 .map(|word| word.to_ascii_lowercase())
9116 .collect()
9117}
9118
9119fn compute_path_proximity(left: &str, right: &str) -> f64 {
9120 let left_dirs = left
9121 .rsplit_once('/')
9122 .map(|(dir, _)| dir)
9123 .unwrap_or_default()
9124 .split('/')
9125 .filter(|part| !part.is_empty());
9126 let right_dirs = right
9127 .rsplit_once('/')
9128 .map(|(dir, _)| dir)
9129 .unwrap_or_default()
9130 .split('/')
9131 .filter(|part| !part.is_empty());
9132
9133 let shared = left_dirs
9134 .zip(right_dirs)
9135 .take_while(|(left, right)| left == right)
9136 .count();
9137 ((shared as f64) * 0.05).min(0.5)
9138}
9139
9140fn mark_backend_state(
9141 tx: &Transaction<'_>,
9142 project_root: &Path,
9143 rel_path: &str,
9144 content_hash: Option<&blake3::Hash>,
9145 status: &str,
9146) -> Result<()> {
9147 clear_backend_state_for_file(tx, project_root, rel_path)?;
9148 let hash = content_hash
9149 .map(|hash| hash_to_hex(*hash))
9150 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
9151 tx.execute(
9152 "INSERT OR REPLACE INTO backend_file_state(
9153 backend, workspace_root, file_path, content_hash, status, updated_at
9154 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
9155 params![
9156 BACKEND_TREESITTER,
9157 project_root.display().to_string(),
9158 rel_path,
9159 hash,
9160 status,
9161 unix_seconds_now(),
9162 ],
9163 )?;
9164 Ok(())
9165}
9166
9167fn clear_backend_state_for_file(
9168 tx: &Transaction<'_>,
9169 project_root: &Path,
9170 rel_path: &str,
9171) -> Result<()> {
9172 tx.execute(
9173 "DELETE FROM backend_file_state
9174 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
9175 params![
9176 BACKEND_TREESITTER,
9177 project_root.display().to_string(),
9178 rel_path
9179 ],
9180 )?;
9181 Ok(())
9182}
9183
9184fn load_file_row(tx: &Transaction<'_>, rel_path: &str) -> Result<Option<FileRow>> {
9185 tx.query_row(
9186 "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
9187 params![rel_path],
9188 |row| {
9189 let hash_text: String = row.get(1)?;
9190 Ok(FileRow {
9191 surface_fingerprint: row.get(0)?,
9192 freshness: FileFreshness {
9193 content_hash: hash_from_hex(&hash_text)
9194 .unwrap_or_else(cache_freshness::zero_hash),
9195 mtime: ns_to_system_time(row.get::<_, i64>(2)?),
9196 size: row.get::<_, i64>(3)? as u64,
9197 },
9198 })
9199 },
9200 )
9201 .optional()
9202 .map_err(CallGraphStoreError::from)
9203}
9204
9205fn stored_node_ids_match_extract(
9206 tx: &Transaction<'_>,
9207 rel_path: &str,
9208 extract: &FileExtract,
9209) -> Result<bool> {
9210 let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
9211 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
9212 let mut stored = BTreeSet::new();
9213 for row in rows {
9214 stored.insert(row?);
9215 }
9216 let extracted = extract
9217 .nodes
9218 .iter()
9219 .map(|node| node.id.clone())
9220 .collect::<BTreeSet<_>>();
9221 Ok(stored == extracted)
9222}
9223
9224fn update_file_fresh_metadata(
9225 tx: &Transaction<'_>,
9226 rel_path: &str,
9227 hash: &blake3::Hash,
9228 mtime: SystemTime,
9229 size: u64,
9230) -> Result<()> {
9231 tx.execute(
9232 "UPDATE files SET mtime_ns = ?2, size = ?3, indexed_at = ?4 WHERE path = ?1",
9233 params![
9234 rel_path,
9235 system_time_to_ns(mtime),
9236 size as i64,
9237 unix_seconds_now()
9238 ],
9239 )?;
9240 tx.execute(
9241 "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
9242 WHERE backend = ?1 AND file_path = ?2 AND content_hash = ?3",
9243 params![
9244 BACKEND_TREESITTER,
9245 rel_path,
9246 hash_to_hex(*hash),
9247 unix_seconds_now(),
9248 ],
9249 )?;
9250 Ok(())
9251}
9252
9253#[derive(Debug, Clone, PartialEq, Eq)]
9254struct DependentRefSelection {
9255 ref_id: String,
9256 caller_file: String,
9257}
9258
9259fn ref_ids_depending_on(
9260 tx: &Transaction<'_>,
9261 project_root: &Path,
9262 rel_path: &str,
9263) -> Result<Vec<DependentRefSelection>> {
9264 let mut stmt = tx.prepare(
9265 "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
9266 FROM refs r
9267 WHERE r.caller_file IN (
9268 SELECT file_path FROM file_dependencies WHERE dep_file = ?1
9269 )
9270 OR r.target_file = ?1
9271 ORDER BY r.ref_id",
9272 )?;
9273 let rows = stmt.query_map(params![rel_path], |row| {
9274 Ok(RefDependencyRow {
9275 ref_id: row.get(0)?,
9276 kind: row.get(1)?,
9277 caller_file: row.get(2)?,
9278 module_path: row.get(3)?,
9279 target_file: row.get(4)?,
9280 })
9281 })?;
9282 let mut ids = Vec::new();
9283 for row in rows {
9284 let row = row?;
9285 if ref_dependency_row_depends_on(project_root, &row, rel_path) {
9286 ids.push(DependentRefSelection {
9287 ref_id: row.ref_id,
9288 caller_file: row.caller_file,
9289 });
9290 }
9291 }
9292 Ok(ids)
9293}
9294
9295fn record_dependent_refs(
9296 selected_ref_ids: &mut BTreeSet<String>,
9297 selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
9298 dependent_refs: Vec<DependentRefSelection>,
9299) {
9300 for dependent_ref in dependent_refs {
9301 let DependentRefSelection {
9302 ref_id,
9303 caller_file,
9304 } = dependent_ref;
9305 selected_ref_ids.insert(ref_id.clone());
9306 selected_refs_by_caller
9307 .entry(caller_file)
9308 .or_default()
9309 .insert(ref_id);
9310 }
9311}
9312
9313#[cfg(test)]
9314fn refs_by_caller_for_ref_ids(
9315 tx: &Transaction<'_>,
9316 ref_ids: &BTreeSet<String>,
9317) -> Result<BTreeMap<String, BTreeSet<String>>> {
9318 let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
9319 let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
9320 for ref_id in ref_ids {
9321 if let Some(caller) = stmt
9322 .query_row(params![ref_id], |row| row.get::<_, String>(0))
9323 .optional()?
9324 {
9325 by_caller.entry(caller).or_default().insert(ref_id.clone());
9326 }
9327 }
9328 Ok(by_caller)
9329}
9330
9331fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
9332 tx.execute(
9333 "DELETE FROM file_dependencies WHERE file_path = ?1",
9334 params![rel_path],
9335 )?;
9336 delete_refs_for_caller(tx, rel_path)?;
9337 tx.execute(
9338 "DELETE FROM dispatch_hints WHERE file = ?1",
9339 params![rel_path],
9340 )?;
9341 tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
9342 tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
9343 Ok(())
9344}
9345
9346fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
9347 let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
9348 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
9349 let mut ids = BTreeSet::new();
9350 for row in rows {
9351 ids.insert(row?);
9352 }
9353 delete_ref_ids(tx, &ids)
9354}
9355
9356fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
9357 for ref_id in ref_ids {
9358 tx.execute("DELETE FROM edges WHERE ref_id = ?1", params![ref_id])?;
9359 tx.execute("DELETE FROM refs WHERE ref_id = ?1", params![ref_id])?;
9360 }
9361 Ok(())
9362}
9363
9364fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
9365 let mut stmt = conn.prepare(
9366 "SELECT source.file_path, source.scoped_name, edges.target_file,
9367 edges.target_symbol, edges.kind, edges.line
9368 FROM edges
9369 JOIN nodes AS source ON source.id = edges.source_node
9370 ORDER BY source.file_path, source.scoped_name, edges.target_file,
9371 edges.target_symbol, edges.kind, edges.line",
9372 )?;
9373 let rows = stmt.query_map([], |row| {
9374 Ok(StoredEdge {
9375 source_file: row.get(0)?,
9376 source_symbol: row.get(1)?,
9377 target_file: row.get(2)?,
9378 target_symbol: row.get(3)?,
9379 kind: row.get(4)?,
9380 line: row.get::<_, i64>(5)? as u32,
9381 })
9382 })?;
9383 let mut edges = BTreeSet::new();
9384 for row in rows {
9385 edges.insert(row?);
9386 }
9387 Ok(edges)
9388}
9389
9390fn module_target_from_dependencies(
9391 project_root: &Path,
9392 dependencies: &BTreeSet<String>,
9393) -> Option<String> {
9394 dependencies.iter().find_map(|dep| {
9395 let path = project_root.join(dep);
9396 if path.is_file() {
9397 Some(relative_path(project_root, &canonicalize_path(&path)))
9398 } else {
9399 None
9400 }
9401 })
9402}
9403
9404fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
9405 let mut named = HashMap::new();
9406 if let Some(full_ref) = &raw_ref.full_ref {
9407 named = parse_reexport_names(full_ref);
9408 }
9409 ReexportIndex {
9410 target_file,
9411 named,
9412 wildcard: raw_ref.wildcard,
9413 }
9414}
9415
9416fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
9417 let mut names = HashMap::new();
9418 let Some(open) = statement.find('{') else {
9419 return names;
9420 };
9421 let Some(close) = statement[open + 1..]
9422 .find('}')
9423 .map(|offset| open + 1 + offset)
9424 else {
9425 return names;
9426 };
9427 for spec in statement[open + 1..close].split(',') {
9428 let spec = spec.trim();
9429 if spec.is_empty() {
9430 continue;
9431 }
9432 if let Some((source, local)) = spec.split_once(" as ") {
9433 names.insert(local.trim().to_string(), source.trim().to_string());
9434 } else {
9435 names.insert(spec.to_string(), spec.to_string());
9436 }
9437 }
9438 names
9439}
9440
9441#[derive(Debug)]
9442struct RefDependencyRow {
9443 ref_id: String,
9444 kind: String,
9445 caller_file: String,
9446 module_path: Option<String>,
9447 target_file: Option<String>,
9448}
9449
9450fn ref_dependency_row_depends_on(
9451 project_root: &Path,
9452 row: &RefDependencyRow,
9453 rel_path: &str,
9454) -> bool {
9455 if row.target_file.as_deref() == Some(rel_path) {
9456 return true;
9457 }
9458
9459 match row.kind.as_str() {
9460 "call" => true,
9461 "import" | "reexport" => row
9462 .module_path
9463 .as_deref()
9464 .map(|module_path| {
9465 module_dependencies_for_ref(project_root, &row.caller_file, module_path)
9466 .contains(rel_path)
9467 })
9468 .unwrap_or(false),
9469 "export_alias" => false,
9470 _ => false,
9471 }
9472}
9473
9474fn module_dependencies_for_ref(
9475 project_root: &Path,
9476 caller_file: &str,
9477 module_path: &str,
9478) -> BTreeSet<String> {
9479 module_dependencies(project_root, &project_root.join(caller_file), module_path)
9480}
9481
9482fn import_dependencies(
9483 project_root: &Path,
9484 abs_path: &Path,
9485 imports: &[ImportStatement],
9486) -> BTreeSet<String> {
9487 let mut deps = BTreeSet::new();
9488 for import in imports {
9489 deps.extend(module_dependencies(
9490 project_root,
9491 abs_path,
9492 &import.module_path,
9493 ));
9494 }
9495 deps
9496}
9497
9498fn module_dependencies(
9499 project_root: &Path,
9500 abs_path: &Path,
9501 module_path: &str,
9502) -> BTreeSet<String> {
9503 let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
9504 let caller_dir = abs_path.parent().unwrap_or(project_root);
9505 if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
9506 deps.insert(relative_path(project_root, &resolved));
9507 }
9508 if module_path.starts_with('.') {
9509 let base = caller_dir.join(module_path);
9510 for candidate in relative_module_candidates(&base) {
9511 deps.insert(relative_path(project_root, &candidate));
9512 }
9513 }
9514 deps
9515}
9516
9517fn rust_module_dependencies(
9518 project_root: &Path,
9519 abs_path: &Path,
9520 module_path: &str,
9521) -> BTreeSet<String> {
9522 let mut deps = BTreeSet::new();
9523 let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
9524 let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
9525 return deps;
9526 };
9527 let src_prefix = rust_src_prefix(&rel_path);
9528 rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
9529 if !path_segments.is_empty() {
9530 rust_push_module_dependency_candidate(
9531 project_root,
9532 &mut deps,
9533 &src_prefix,
9534 &path_segments[..path_segments.len() - 1],
9535 );
9536 }
9537 deps
9538}
9539
9540fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
9541 let path = rust_module_path_without_alias_or_use_list(module_path);
9542 let segments = path
9543 .split("::")
9544 .map(str::trim)
9545 .filter(|segment| !segment.is_empty())
9546 .collect::<Vec<_>>();
9547 if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
9548 return None;
9549 }
9550 rust_resolve_segments(rel_path, &segments)
9551}
9552
9553fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
9554 let path = module_path
9555 .trim()
9556 .trim_end_matches(';')
9557 .split_once(" as ")
9558 .map(|(left, _)| left.trim())
9559 .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
9560 path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
9561}
9562
9563fn rust_push_module_dependency_candidate(
9564 project_root: &Path,
9565 deps: &mut BTreeSet<String>,
9566 src_prefix: &str,
9567 segments: &[String],
9568) {
9569 let candidates = if segments.is_empty() {
9570 vec![
9571 format!("{src_prefix}/lib.rs"),
9572 format!("{src_prefix}/main.rs"),
9573 ]
9574 } else {
9575 vec![
9576 format!("{}/{}.rs", src_prefix, segments.join("/")),
9577 format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
9578 ]
9579 };
9580 for candidate in candidates {
9581 if project_root.join(&candidate).is_file() {
9582 deps.insert(candidate);
9583 }
9584 }
9585}
9586
9587fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
9588 let mut candidates = Vec::new();
9589 if base.extension().is_some() {
9590 candidates.push(base.to_path_buf());
9591 return candidates;
9592 }
9593 for ext in JS_TS_EXTENSIONS {
9594 candidates.push(base.with_extension(ext));
9595 }
9596 for ext in JS_TS_EXTENSIONS {
9597 candidates.push(base.join(format!("index.{ext}")));
9598 }
9599 candidates
9600}
9601
9602fn import_local_names(import: &ImportStatement) -> Vec<String> {
9603 let mut names = Vec::new();
9604 if let Some(default) = &import.default_import {
9605 names.push(default.clone());
9606 }
9607 if let Some(namespace) = &import.namespace_import {
9608 names.push(namespace.clone());
9609 }
9610 for name in &import.names {
9611 names.push(crate::imports::specifier_local_name(name).to_string());
9612 }
9613 names
9614}
9615
9616fn import_requested_names(import: &ImportStatement) -> Vec<String> {
9617 import
9618 .names
9619 .iter()
9620 .map(|name| crate::imports::specifier_imported_name(name).to_string())
9621 .collect()
9622}
9623
9624fn import_is_wildcard(import: &ImportStatement) -> bool {
9625 import.namespace_import.is_some() || import.raw_text.contains('*')
9626}
9627
9628fn namespace_alias(full_ref: &str) -> Option<String> {
9629 full_ref
9630 .split_once('.')
9631 .map(|(namespace, _)| namespace.to_string())
9632}
9633
9634fn import_kind_label(kind: ImportKind) -> &'static str {
9635 match kind {
9636 ImportKind::Value => "value",
9637 ImportKind::Type => "type",
9638 ImportKind::SideEffect => "side_effect",
9639 }
9640}
9641
9642fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
9643 match kind {
9644 SymbolKind::Function => "function",
9645 SymbolKind::Class => "class",
9646 SymbolKind::Method => "method",
9647 SymbolKind::Struct => "struct",
9648 SymbolKind::Interface => "interface",
9649 SymbolKind::Enum => "enum",
9650 SymbolKind::TypeAlias => "type_alias",
9651 SymbolKind::Variable => "variable",
9652 SymbolKind::Heading => "heading",
9653 SymbolKind::FileSummary => "file_summary",
9654 }
9655}
9656
9657fn is_type_like(kind: &SymbolKind) -> bool {
9658 matches!(
9659 kind,
9660 SymbolKind::Class
9661 | SymbolKind::Struct
9662 | SymbolKind::Interface
9663 | SymbolKind::Enum
9664 | SymbolKind::TypeAlias
9665 )
9666}
9667
9668fn lang_label(lang: LangId) -> &'static str {
9669 match lang {
9670 LangId::TypeScript => "typescript",
9671 LangId::Tsx => "tsx",
9672 LangId::JavaScript => "javascript",
9673 LangId::Python => "python",
9674 LangId::Rust => "rust",
9675 LangId::Go => "go",
9676 LangId::C => "c",
9677 LangId::Cpp => "cpp",
9678 LangId::Zig => "zig",
9679 LangId::CSharp => "csharp",
9680 LangId::Bash => "bash",
9681 LangId::Html => "html",
9682 LangId::Markdown => "markdown",
9683 LangId::Solidity => "solidity",
9684 LangId::Scss => "scss",
9685 LangId::Vue => "vue",
9686 LangId::Json => "json",
9687 LangId::Scala => "scala",
9688 LangId::Java => "java",
9689 LangId::Ruby => "ruby",
9690 LangId::Kotlin => "kotlin",
9691 LangId::Swift => "swift",
9692 LangId::Php => "php",
9693 LangId::Lua => "lua",
9694 LangId::Perl => "perl",
9695 LangId::Yaml => "yaml",
9696 LangId::Pascal => "pascal",
9697 LangId::R => "r",
9698 LangId::Groovy => "groovy",
9699 LangId::ObjC => "objc",
9700 }
9701}
9702
9703fn lang_from_label(label: &str) -> Option<LangId> {
9704 match label {
9705 "typescript" => Some(LangId::TypeScript),
9706 "tsx" => Some(LangId::Tsx),
9707 "javascript" => Some(LangId::JavaScript),
9708 "python" => Some(LangId::Python),
9709 "rust" => Some(LangId::Rust),
9710 "go" => Some(LangId::Go),
9711 "c" => Some(LangId::C),
9712 "cpp" => Some(LangId::Cpp),
9713 "zig" => Some(LangId::Zig),
9714 "csharp" => Some(LangId::CSharp),
9715 "bash" => Some(LangId::Bash),
9716 "html" => Some(LangId::Html),
9717 "markdown" => Some(LangId::Markdown),
9718 "solidity" => Some(LangId::Solidity),
9719 "scss" => Some(LangId::Scss),
9720 "vue" => Some(LangId::Vue),
9721 "json" => Some(LangId::Json),
9722 "scala" => Some(LangId::Scala),
9723 "java" => Some(LangId::Java),
9724 "ruby" => Some(LangId::Ruby),
9725 "kotlin" => Some(LangId::Kotlin),
9726 "swift" => Some(LangId::Swift),
9727 "php" => Some(LangId::Php),
9728 "lua" => Some(LangId::Lua),
9729 "perl" => Some(LangId::Perl),
9730 "yaml" => Some(LangId::Yaml),
9731 "pascal" => Some(LangId::Pascal),
9732 "r" => Some(LangId::R),
9733 "groovy" => Some(LangId::Groovy),
9734 "objc" => Some(LangId::ObjC),
9735 _ => None,
9736 }
9737}
9738
9739fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
9740 let mut normalized = if files.is_empty() {
9741 callgraph::walk_project_files(project_root).collect::<Vec<_>>()
9742 } else {
9743 files
9744 .iter()
9745 .map(|path| normalize_file_path(project_root, path))
9746 .collect::<Result<Vec<_>>>()?
9747 };
9748 normalized.sort();
9749 normalized.dedup();
9750 Ok(normalized)
9751}
9752
9753fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
9754 let full_path = if path.is_relative() {
9755 project_root.join(path)
9756 } else {
9757 path.to_path_buf()
9758 };
9759 Ok(canonicalize_path(&full_path))
9760}
9761
9762fn canonicalize_path(path: &Path) -> PathBuf {
9763 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
9764}
9765
9766fn relative_path(project_root: &Path, path: &Path) -> String {
9767 if let Ok(stripped) = path.strip_prefix(project_root) {
9768 return stripped.to_string_lossy().replace('\\', "/");
9769 }
9770 let canon_root = canonicalize_path(project_root);
9771 let canon_path = canonicalize_path(path);
9772 if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
9773 return stripped.to_string_lossy().replace('\\', "/");
9774 }
9775 canon_path.to_string_lossy().replace('\\', "/")
9776}
9777
9778fn unqualified_name(scoped: &str) -> &str {
9779 if scoped == TOP_LEVEL_SYMBOL {
9780 return scoped;
9781 }
9782 scoped
9783 .rsplit("::")
9784 .next()
9785 .unwrap_or(scoped)
9786 .rsplit('.')
9787 .next()
9788 .unwrap_or(scoped)
9789 .rsplit('#')
9790 .next()
9791 .unwrap_or(scoped)
9792}
9793
9794fn ref_id(parts: &[&str]) -> String {
9795 let joined = parts.join("\0");
9796 hash_to_hex(blake3::hash(joined.as_bytes()))
9797}
9798
9799fn hash_to_hex(hash: blake3::Hash) -> String {
9800 hash.to_hex().to_string()
9801}
9802
9803fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
9804 let bytes = hex_to_bytes(value)?;
9805 Some(blake3::Hash::from_bytes(bytes))
9806}
9807
9808fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
9809 if value.len() != 64 {
9810 return None;
9811 }
9812 let mut bytes = [0u8; 32];
9813 for (index, slot) in bytes.iter_mut().enumerate() {
9814 let start = index * 2;
9815 let end = start + 2;
9816 *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
9817 }
9818 Some(bytes)
9819}
9820
9821#[derive(Debug, Clone)]
9822struct LineIndex {
9823 newline_offsets: Vec<usize>,
9824 source_len: usize,
9825}
9826
9827impl LineIndex {
9828 fn new(source: &str) -> Self {
9829 Self {
9830 newline_offsets: source
9831 .bytes()
9832 .enumerate()
9833 .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
9834 .collect(),
9835 source_len: source.len(),
9836 }
9837 }
9838
9839 fn byte_to_line(&self, byte_offset: usize) -> u32 {
9840 let byte_offset = byte_offset.min(self.source_len);
9841 self.newline_offsets
9842 .partition_point(|offset| *offset < byte_offset) as u32
9843 + 1
9844 }
9845}
9846
9847fn empty_to_none(value: String) -> Option<String> {
9848 if value.is_empty() {
9849 None
9850 } else {
9851 Some(value)
9852 }
9853}
9854
9855fn bool_int(value: bool) -> i64 {
9856 if value {
9857 1
9858 } else {
9859 0
9860 }
9861}
9862
9863fn system_time_to_ns(time: SystemTime) -> i64 {
9864 time.duration_since(UNIX_EPOCH)
9865 .unwrap_or_default()
9866 .as_nanos()
9867 .min(i64::MAX as u128) as i64
9868}
9869
9870fn ns_to_system_time(value: i64) -> SystemTime {
9871 UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
9872}
9873
9874fn unix_seconds_now() -> i64 {
9875 SystemTime::now()
9876 .duration_since(UNIX_EPOCH)
9877 .unwrap_or_default()
9878 .as_secs() as i64
9879}
9880
9881#[cfg(test)]
9886pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
9887
9888#[cfg(test)]
9889mod refresh_worker_tests {
9890 use super::*;
9891 use std::fs;
9892 use tempfile::tempdir;
9893
9894 fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
9895 let temp = tempdir().unwrap();
9896 let root = temp.path().join("root");
9897 let callgraph_dir = temp
9898 .path()
9899 .join("storage")
9900 .join("callgraph")
9901 .join(crate::search_index::artifact_cache_key(&root));
9902 fs::create_dir_all(&root).unwrap();
9903 let source = root.join("main.rs");
9904 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
9905 let (store, _) = CallGraphStore::cold_build_with_lease(
9906 callgraph_dir.clone(),
9907 root.clone(),
9908 std::slice::from_ref(&source),
9909 )
9910 .unwrap();
9911 drop(store);
9912 (temp, root, callgraph_dir, source)
9913 }
9914
9915 fn pending_paths() -> PendingCallGraphStorePaths {
9916 Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
9917 }
9918
9919 fn wait_for_refresh_calls(root: &Path, expected: usize) {
9920 let deadline = Instant::now() + Duration::from_secs(12);
9921 while callgraph_refresh_worker_test_counts(root).0 < expected {
9922 assert!(
9923 Instant::now() < deadline,
9924 "timed out waiting for {expected} callgraph refresh worker call(s)"
9925 );
9926 std::thread::sleep(Duration::from_millis(5));
9927 }
9928 }
9929
9930 #[test]
9931 fn forced_rebuild_without_writer_capability_cannot_report_old_store_as_ready() {
9932 let _git_env = crate::test_env::hermetic_git_env_guard();
9933 let temp = tempdir().unwrap();
9934 let main = temp.path().join("main");
9935 let root = temp.path().join("worktree");
9936 fs::create_dir_all(&main).unwrap();
9937 let mut git = std::process::Command::new("git");
9938 assert!(
9939 crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
9940 .status()
9941 .unwrap()
9942 .success()
9943 );
9944 let source = main.join("lib.rs");
9945 fs::write(&source, "pub fn marker() {}\n").unwrap();
9946 for args in [
9947 vec![
9948 "-C",
9949 main.to_str().unwrap(),
9950 "config",
9951 "user.email",
9952 "test@example.com",
9953 ],
9954 vec![
9955 "-C",
9956 main.to_str().unwrap(),
9957 "config",
9958 "user.name",
9959 "AFT Test",
9960 ],
9961 vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
9962 vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
9963 ] {
9964 let mut command = std::process::Command::new("git");
9965 assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
9966 .status()
9967 .unwrap()
9968 .success());
9969 }
9970 let mut worktree = std::process::Command::new("git");
9971 assert!(crate::test_env::apply_hermetic_git_env(
9972 worktree
9973 .arg("-C")
9974 .arg(&main)
9975 .args(["worktree", "add", "--detach"])
9976 .arg(&root),
9977 )
9978 .status()
9979 .unwrap()
9980 .success());
9981
9982 let project_key = crate::search_index::artifact_cache_key(&root);
9983 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
9984 crate::root_cache::configure_artifact_access(&root, &project_key, true);
9985 let source = root.join("lib.rs");
9986 let error =
9987 CallGraphStore::force_cold_build_with_lease_chunked(callgraph_dir, root, &[source], 1)
9988 .expect_err("borrow-only forced rebuild must remain unsatisfied");
9989
9990 assert!(matches!(error, CallGraphStoreError::Unavailable(_)));
9991 }
9992
9993 #[test]
9994 fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
9995 let _guard = REFRESH_WORKER_TEST_LOCK
9996 .lock()
9997 .unwrap_or_else(std::sync::PoisonError::into_inner);
9998 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
9999 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10000 let pending = pending_paths();
10001 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10002
10003 let lifecycle = SubcLifecycleAdmission::default();
10004 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
10005 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
10006 let ticket = CallgraphRefreshTicket::new(
10007 lifecycle,
10008 Arc::clone(&generation),
10009 7,
10010 publish_epoch.clone(),
10011 publish_epoch.current(),
10012 );
10013 generation.store(8, std::sync::atomic::Ordering::SeqCst);
10015
10016 enqueue_callgraph_store_refresh_fenced(
10017 callgraph_dir,
10018 root.clone(),
10019 vec![source.clone()],
10020 Arc::clone(&pending),
10021 ticket,
10022 );
10023 assert!(flush_callgraph_store_refreshes_with_budget(
10024 Duration::from_secs(5)
10025 ));
10026 assert_eq!(
10027 callgraph_refresh_worker_test_counts(&root).0,
10028 0,
10029 "superseded batch must not reach refresh_files"
10030 );
10031 assert!(
10032 pending.lock().contains(&source),
10033 "superseded batch must defer its paths to the pending sink"
10034 );
10035 clear_callgraph_refresh_worker_test_seam(&root);
10036 }
10037
10038 #[test]
10039 fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
10040 let _guard = REFRESH_WORKER_TEST_LOCK
10041 .lock()
10042 .unwrap_or_else(std::sync::PoisonError::into_inner);
10043 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10044 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10045 let pending = pending_paths();
10046 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10047
10048 let lifecycle = SubcLifecycleAdmission::default();
10049 let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
10050 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
10051 let expected_epoch = publish_epoch.current();
10052 let ticket = CallgraphRefreshTicket::new(
10053 lifecycle,
10054 generation,
10055 3,
10056 publish_epoch.clone(),
10057 expected_epoch,
10058 );
10059 publish_epoch.next();
10061
10062 enqueue_callgraph_store_refresh_fenced(
10063 callgraph_dir,
10064 root.clone(),
10065 vec![source.clone()],
10066 Arc::clone(&pending),
10067 ticket,
10068 );
10069 assert!(flush_callgraph_store_refreshes_with_budget(
10070 Duration::from_secs(5)
10071 ));
10072 assert_eq!(
10073 callgraph_refresh_worker_test_counts(&root).0,
10074 0,
10075 "epoch-superseded batch must not reach refresh_files"
10076 );
10077 assert!(
10078 pending.lock().contains(&source),
10079 "epoch-superseded batch must defer its paths to the pending sink"
10080 );
10081 clear_callgraph_refresh_worker_test_seam(&root);
10082 }
10083
10084 #[test]
10085 fn fenced_refresh_with_current_ticket_commits_normally() {
10086 let _guard = REFRESH_WORKER_TEST_LOCK
10087 .lock()
10088 .unwrap_or_else(std::sync::PoisonError::into_inner);
10089 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10090 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10091 let pending = pending_paths();
10092 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10093
10094 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
10095
10096 let lifecycle = SubcLifecycleAdmission::default();
10097 let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
10098 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
10099 let ticket = CallgraphRefreshTicket::new(
10100 lifecycle,
10101 generation,
10102 5,
10103 publish_epoch.clone(),
10104 publish_epoch.current(),
10105 );
10106
10107 enqueue_callgraph_store_refresh_fenced(
10108 callgraph_dir.clone(),
10109 root.clone(),
10110 vec![source.clone()],
10111 Arc::clone(&pending),
10112 ticket,
10113 );
10114 assert!(flush_callgraph_store_refreshes_with_budget(
10115 Duration::from_secs(5)
10116 ));
10117 assert_eq!(
10118 callgraph_refresh_worker_test_counts(&root).0,
10119 1,
10120 "current ticket must run the refresh"
10121 );
10122 assert!(
10123 pending.lock().is_empty(),
10124 "committed batch must not defer paths"
10125 );
10126
10127 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
10128 .unwrap()
10129 .expect("published generation must remain readable");
10130 let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
10131 assert_eq!(
10132 tree.children[0].name, "new_leaf",
10133 "fenced commit must actually persist the refreshed content"
10134 );
10135 clear_callgraph_refresh_worker_test_seam(&root);
10136 }
10137
10138 #[test]
10139 fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
10140 let _guard = REFRESH_WORKER_TEST_LOCK
10141 .lock()
10142 .unwrap_or_else(std::sync::PoisonError::into_inner);
10143 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10148 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10149 let pending = pending_paths();
10150 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
10151
10152 enqueue_callgraph_store_refresh(
10153 callgraph_dir.clone(),
10154 root.clone(),
10155 vec![source.clone()],
10156 Arc::clone(&pending),
10157 );
10158 wait_for_refresh_calls(&root, 1);
10159 for _ in 0..3 {
10160 enqueue_callgraph_store_refresh(
10161 callgraph_dir.clone(),
10162 root.clone(),
10163 vec![source.clone()],
10164 Arc::clone(&pending),
10165 );
10166 }
10167
10168 assert!(flush_callgraph_store_refreshes_with_budget(
10169 Duration::from_secs(2)
10170 ));
10171 assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
10172 assert!(pending.lock().is_empty());
10173 clear_callgraph_refresh_worker_test_seam(&root);
10174 }
10175
10176 #[test]
10177 fn queued_refresh_opens_generation_published_after_enqueue() {
10178 let _guard = REFRESH_WORKER_TEST_LOCK
10179 .lock()
10180 .unwrap_or_else(std::sync::PoisonError::into_inner);
10181 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10186 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
10187 let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
10188 set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
10189 let (active_held_rx, active_release_tx) =
10190 install_refresh_worker_test_gate(active_root.clone());
10191 set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
10192 enqueue_callgraph_store_refresh(
10193 active_dir,
10194 active_root.clone(),
10195 vec![active_source],
10196 pending_paths(),
10197 );
10198 active_held_rx
10199 .recv_timeout(Duration::from_secs(12))
10200 .expect("active refresh worker holds the queue");
10201
10202 fs::write(
10203 &target_source,
10204 "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
10205 )
10206 .unwrap();
10207 enqueue_callgraph_store_refresh(
10208 target_dir.clone(),
10209 target_root.clone(),
10210 vec![target_source.clone()],
10211 pending_paths(),
10212 );
10213 let (new_generation, _) = CallGraphStore::cold_build_with_lease(
10214 target_dir.clone(),
10215 target_root.clone(),
10216 std::slice::from_ref(&target_source),
10217 )
10218 .unwrap();
10219 fs::write(
10220 &target_source,
10221 "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
10222 )
10223 .unwrap();
10224 drop(new_generation);
10225
10226 active_release_tx
10227 .send(())
10228 .expect("release active refresh worker");
10229 wait_for_refresh_calls(&target_root, 1);
10230 assert!(flush_callgraph_store_refreshes_with_budget(
10231 Duration::from_secs(12)
10232 ));
10233 let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
10234 .unwrap()
10235 .expect("current callgraph generation");
10236 let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
10237 assert_eq!(tree.children[0].name, "worker_leaf");
10238 assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
10239 clear_callgraph_refresh_worker_test_seam(&active_root);
10240 clear_callgraph_refresh_worker_test_seam(&target_root);
10241 }
10242
10243 #[test]
10244 fn refresh_failure_marks_files_stale() {
10245 let _guard = REFRESH_WORKER_TEST_LOCK
10246 .lock()
10247 .unwrap_or_else(std::sync::PoisonError::into_inner);
10248 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10253 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10254 let pending = pending_paths();
10255 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
10256
10257 enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
10258 assert!(flush_callgraph_store_refreshes_with_budget(
10259 Duration::from_secs(2)
10260 ));
10261
10262 assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
10263 let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
10264 .unwrap()
10265 .expect("ready callgraph store");
10266 assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
10267 clear_callgraph_refresh_worker_test_seam(&root);
10268 }
10269
10270 #[test]
10271 fn bounded_shutdown_defers_unprocessed_batches() {
10272 let _guard = REFRESH_WORKER_TEST_LOCK
10273 .lock()
10274 .unwrap_or_else(std::sync::PoisonError::into_inner);
10275 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10280 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
10281 let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
10282 let active_pending = pending_paths();
10283 let queued_pending = pending_paths();
10284 set_callgraph_refresh_worker_test_seam(
10285 active_root.clone(),
10286 Duration::from_millis(300),
10287 false,
10288 );
10289
10290 enqueue_callgraph_store_refresh(
10291 active_dir,
10292 active_root.clone(),
10293 vec![active_source.clone()],
10294 Arc::clone(&active_pending),
10295 );
10296 wait_for_refresh_calls(&active_root, 1);
10297 enqueue_callgraph_store_refresh(
10298 queued_dir,
10299 queued_root.clone(),
10300 vec![queued_source.clone()],
10301 Arc::clone(&queued_pending),
10302 );
10303
10304 assert!(!flush_callgraph_store_refreshes_with_budget(
10305 Duration::from_millis(20)
10306 ));
10307 assert!(active_pending.lock().contains(&active_source));
10308 assert!(queued_pending.lock().contains(&queued_source));
10309 assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
10310 clear_callgraph_refresh_worker_test_seam(&active_root);
10311 }
10312}
10313
10314#[cfg(test)]
10315mod cold_build_insert_tests {
10316 use super::*;
10317 use crate::imports::ImportBlock;
10318 use std::fs;
10319 use std::path::{Path, PathBuf};
10320 use tempfile::tempdir;
10321
10322 #[test]
10323 fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
10324 let dir = tempdir().unwrap();
10325 let previous_root = dir.path().join("previous-root");
10326 let current_root = dir.path().join("current-root");
10327 fs::create_dir_all(&previous_root).unwrap();
10328 fs::create_dir_all(¤t_root).unwrap();
10329 fs::remove_dir(&previous_root).unwrap();
10330 let mut conn = Connection::open_in_memory().unwrap();
10331 initialize_schema(&conn).unwrap();
10332 conn.execute(
10333 "INSERT INTO backend_file_state(
10334 backend, workspace_root, file_path, content_hash, status, updated_at
10335 ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
10336 params![previous_root.display().to_string()],
10337 )
10338 .unwrap();
10339
10340 let repair = reconcile_workspace_roots(&mut conn, ¤t_root, false).unwrap();
10341
10342 assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
10343 assert_eq!(
10344 stored_workspace_roots(&conn).unwrap(),
10345 vec![previous_root.display().to_string()]
10346 );
10347 }
10348
10349 #[test]
10350 fn sqlite_readonly_uri_percent_encodes_windows_paths() {
10351 assert_eq!(
10352 sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
10353 "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
10354 );
10355 }
10356
10357 #[test]
10358 fn legacy_migration_completion_log_has_operator_fields() {
10359 assert_eq!(
10360 legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
10361 "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
10362 );
10363 }
10364
10365 fn write_generation_with_age(
10366 dir: &Path,
10367 project_key: &str,
10368 ordinal: u64,
10369 age: Duration,
10370 ) -> String {
10371 let generation = format!("{project_key}.g{ordinal}.1.sqlite");
10372 let path = dir.join(&generation);
10373 fs::write(&path, b"sqlite placeholder").unwrap();
10374 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
10375 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
10376 generation
10377 }
10378
10379 #[test]
10380 fn gc_old_generations_preserves_live_reader_until_marker_drops() {
10381 let dir = tempfile::tempdir().unwrap();
10382 let project_key = "project";
10383 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
10384 let previous =
10385 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
10386 let pinned =
10387 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
10388 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
10389
10390 gc_old_generations(dir.path(), project_key, ¤t);
10391
10392 assert!(dir.path().join(&previous).is_file());
10393 assert!(dir.path().join(&pinned).is_file());
10394
10395 drop(marker);
10396 gc_old_generations(dir.path(), project_key, ¤t);
10397
10398 assert!(dir.path().join(&previous).is_file());
10399 assert!(!dir.path().join(&pinned).exists());
10400 }
10401
10402 #[test]
10403 fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
10404 let dir = tempfile::tempdir().unwrap();
10405 let project_key = "project";
10406 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
10407 let _previous =
10408 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
10409 let pinned =
10410 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
10411 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
10412 filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
10413
10414 gc_old_generations(dir.path(), project_key, ¤t);
10415
10416 assert!(dir.path().join(&pinned).is_file());
10417 }
10418
10419 #[test]
10420 fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
10421 let dir = tempfile::tempdir().unwrap();
10422 let project_key = "project";
10423 let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
10424 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
10425 let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
10426 let old = write_generation_with_age(
10427 dir.path(),
10428 project_key,
10429 200,
10430 expired + Duration::from_secs(60),
10431 );
10432 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
10433
10434 gc_old_generations(dir.path(), project_key, ¤t);
10435
10436 assert!(dir.path().join(¤t).is_file());
10437 assert!(dir.path().join(&previous).is_file());
10438 assert!(!dir.path().join(&old).exists());
10439 }
10440
10441 #[test]
10442 fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
10443 let dir = tempfile::tempdir().unwrap();
10444 let project_key = "project".to_string();
10445 let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
10446 let sqlite_path = dir.path().join(&generation);
10447 fs::remove_file(&sqlite_path).unwrap();
10448 let conn = Connection::open(&sqlite_path).unwrap();
10449 let store = CallGraphStore::from_connection(
10450 dir.path().to_path_buf(),
10451 project_key,
10452 sqlite_path,
10453 dir.path().to_path_buf(),
10454 false,
10455 Some(generation.clone()),
10456 None,
10457 None,
10458 conn,
10459 );
10460
10461 let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
10462 assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
10463
10464 drop(marker);
10465 assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
10466 }
10467
10468 #[test]
10469 fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
10470 let dir = tempdir().expect("temp dir");
10471 let file = dir.path().join("main.ts");
10472 fs::write(
10473 &file,
10474 r#"export function topA() {
10475 root();
10476}
10477
10478export function topB() {
10479 root();
10480}
10481
10482export function root() {
10483 leaf();
10484 missing();
10485}
10486
10487export function leaf() {}
10488"#,
10489 )
10490 .expect("write fixture");
10491
10492 let store = CallGraphStore::open(
10493 dir.path().join(".store-depth-boundary-counts"),
10494 dir.path().to_path_buf(),
10495 )
10496 .expect("open store");
10497 store
10498 .cold_build(std::slice::from_ref(&file))
10499 .expect("cold build");
10500
10501 let root = store
10502 .node_for(Path::new("main.ts"), "root")
10503 .expect("root node");
10504 let leaf = store
10505 .node_for(Path::new("main.ts"), "leaf")
10506 .expect("leaf node");
10507
10508 let (full_forward_len, full_direct_len) = {
10509 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
10510 conn.execute(
10511 "INSERT INTO edges (
10512 edge_id, ref_id, source_node, target_node, target_file,
10513 target_symbol, kind, line, provenance
10514 ) VALUES (
10515 'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
10516 ?2, ?3, 'call', 98, ?4
10517 )",
10518 rusqlite::params![
10519 &root.node_id,
10520 &leaf.file,
10521 &leaf.symbol,
10522 PROVENANCE_TREESITTER
10523 ],
10524 )
10525 .expect("insert dangling forward edge");
10526 conn.execute(
10527 "INSERT INTO edges (
10528 edge_id, ref_id, source_node, target_node, target_file,
10529 target_symbol, kind, line, provenance
10530 ) VALUES (
10531 'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
10532 ?1, ?2, ?3, 'call', 99, ?4
10533 )",
10534 rusqlite::params![
10535 &root.node_id,
10536 &root.file,
10537 &root.symbol,
10538 PROVENANCE_TREESITTER
10539 ],
10540 )
10541 .expect("insert dangling direct-caller edge");
10542
10543 let full_forward_len = forward_calls_for_node(&conn, &root)
10544 .expect("full forward calls")
10545 .len();
10546 let counted_forward_len =
10547 forward_call_count_for_node(&conn, &root).expect("counted forward calls");
10548 assert_eq!(
10549 counted_forward_len, full_forward_len,
10550 "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
10551 );
10552
10553 let full_direct_len = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
10554 .expect("full direct callers")
10555 .len();
10556 let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
10557 .expect("counted direct callers");
10558 assert_eq!(
10559 counted_direct_len, full_direct_len,
10560 "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
10561 );
10562
10563 (full_forward_len, full_direct_len)
10564 };
10565
10566 assert_eq!(
10567 full_forward_len, 2,
10568 "fixture root should have one resolved and one unresolved outgoing call"
10569 );
10570 assert_eq!(
10571 full_direct_len, 2,
10572 "fixture root should have two real direct callers"
10573 );
10574
10575 let tree = store
10576 .call_tree(Path::new("main.ts"), "root", 0)
10577 .expect("call tree");
10578 assert!(tree.depth_limited);
10579 assert_eq!(tree.children.len(), 0);
10580 assert_eq!(
10581 tree.truncated, full_forward_len,
10582 "call_tree depth boundary must report the full forward-call list length"
10583 );
10584
10585 let callers = store
10586 .callers_of(Path::new("main.ts"), "leaf", 0)
10587 .expect("callers");
10588 assert!(callers.depth_limited);
10589 assert_eq!(callers.callers.len(), 1);
10590 assert_eq!(callers.callers[0].caller.symbol, "root");
10591 assert_eq!(
10592 callers.truncated, full_direct_len,
10593 "callers depth boundary must report the full direct-caller list length"
10594 );
10595 }
10596
10597 #[test]
10598 fn source_freshness_matches_cache_collect_for_same_bytes() {
10599 let dir = tempdir().expect("temp dir");
10600 let path = dir.path().join("fixture.ts");
10601 let source = "export function main() { return helper(); }\n";
10602 fs::write(&path, source).expect("write fixture");
10603
10604 let expected = cache_freshness::collect(&path).expect("collect freshness from file");
10605 let actual =
10606 collect_source_freshness(&path, source).expect("collect freshness from source");
10607
10608 assert_eq!(actual, expected);
10609 }
10610
10611 #[test]
10612 fn superseded_cold_build_cannot_publish_after_newer_epoch() {
10613 let root = tempfile::tempdir().unwrap();
10614 let callgraph_dir = tempfile::tempdir().unwrap();
10615 let source_dir = root.path().join("src");
10616 std::fs::create_dir_all(&source_dir).unwrap();
10617 let source = source_dir.join("lib.rs");
10618 std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
10619 let files = vec![source.clone()];
10620 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
10621 let old_epoch = epoch.next();
10622 let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
10623 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
10624 let old_epoch_flag = epoch.clone();
10625 let old_dir = callgraph_dir.path().to_path_buf();
10626 let old_root = root.path().to_path_buf();
10627 let old_files = files.clone();
10628 let old = std::thread::spawn(move || {
10629 set_cold_build_before_publish_observer(Some(Arc::new(move || {
10630 reached_tx.send(()).unwrap();
10631 release_rx.recv().unwrap();
10632 })));
10633 let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
10634 CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
10635 });
10636 set_cold_build_before_publish_observer(None);
10637 result
10638 });
10639 reached_rx
10640 .recv_timeout(Duration::from_secs(5))
10641 .expect("older build did not reach its publication barrier");
10642
10643 std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
10644 let new_epoch = epoch.next();
10645 let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
10646 CallGraphStore::cold_build_with_lease(
10647 callgraph_dir.path().to_path_buf(),
10648 root.path().to_path_buf(),
10649 &files,
10650 )
10651 })
10652 .expect("newer build should publish");
10653 drop(new_store);
10654
10655 release_tx.send(()).unwrap();
10656 assert!(matches!(
10657 old.join().unwrap(),
10658 Err(CallGraphStoreError::Superseded)
10659 ));
10660
10661 let current = CallGraphStore::open_readonly(
10662 callgraph_dir.path().to_path_buf(),
10663 root.path().to_path_buf(),
10664 )
10665 .unwrap()
10666 .expect("current callgraph generation");
10667 assert_eq!(
10668 current
10669 .nodes_matching("new_generation_marker")
10670 .unwrap()
10671 .len(),
10672 1
10673 );
10674 assert!(current
10675 .nodes_matching("old_generation_marker")
10676 .unwrap()
10677 .is_empty());
10678 }
10679
10680 #[test]
10681 fn cold_build_prepared_bulk_insert_matches_reference_rows() {
10682 let dir = tempdir().expect("temp dir");
10683 let project_root = dir.path();
10684 let extract = fixture_extract(project_root);
10685 let resolved = fixture_resolved(&extract);
10686
10687 let reference = build_reference_connection(project_root, &extract, &resolved);
10688 let optimized = build_optimized_connection(project_root, &extract, &resolved);
10689
10690 for table in [
10691 "files",
10692 "nodes",
10693 "file_dependencies",
10694 "dispatch_hints",
10695 "refs",
10696 "edges",
10697 ] {
10698 let excluded: &[&str] = if table == "files" {
10705 &["indexed_at"]
10706 } else {
10707 &[]
10708 };
10709 assert_eq!(
10710 table_rows_without(&reference, table, excluded),
10711 table_rows_without(&optimized, table, excluded),
10712 "table `{table}` rows must match apart from wall-clock columns"
10713 );
10714 }
10715 assert_eq!(
10716 backend_state_rows(&reference),
10717 backend_state_rows(&optimized),
10718 "backend freshness rows must match apart from updated_at"
10719 );
10720 assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
10721 }
10722
10723 #[test]
10724 fn cold_build_chunked_matches_unchunked_logical_rows() {
10725 let dir = tempdir().expect("temp dir");
10726 let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
10727 write_chunked_equivalence_fixture(&project_root);
10728 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
10729 assert!(
10730 files.len() > 6,
10731 "fixture should be large enough to split into multiple chunks"
10732 );
10733
10734 let unchunked = CallGraphStore::open(
10735 project_root.join(".store-unchunked"),
10736 project_root.to_path_buf(),
10737 )
10738 .expect("open unchunked store");
10739 let unchunked_stats = unchunked
10740 .cold_build_chunked(&files, 0)
10741 .expect("unchunked cold build");
10742
10743 let chunked = CallGraphStore::open(
10744 project_root.join(".store-chunked"),
10745 project_root.to_path_buf(),
10746 )
10747 .expect("open chunked store");
10748 let chunked_stats = chunked
10749 .cold_build_chunked(&files, 3)
10750 .expect("chunked cold build");
10751
10752 assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
10753 assert_eq!(
10754 unchunked.edge_snapshot().expect("unchunked edge snapshot"),
10755 chunked.edge_snapshot().expect("chunked edge snapshot"),
10756 "public edge snapshots must match"
10757 );
10758
10759 let dispatch_edges = {
10760 let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
10761 conn.query_row(
10762 "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
10763 [],
10764 |row| row.get::<_, i64>(0),
10765 )
10766 .expect("count dispatch edges")
10767 };
10768 assert!(
10769 dispatch_edges > 0,
10770 "fixture must exercise method-dispatch edge insertion"
10771 );
10772
10773 for table in [
10774 "edges",
10775 "refs",
10776 "nodes",
10777 "file_dependencies",
10778 "dispatch_hints",
10779 ] {
10780 assert_eq!(
10781 graph_table_rows(&unchunked, table),
10782 graph_table_rows(&chunked, table),
10783 "chunked cold build must match unchunked rows for {table}"
10784 );
10785 }
10786 assert_eq!(
10787 graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
10788 graph_table_rows_without(&chunked, "files", &["indexed_at"]),
10789 "files rows must match apart from indexed_at"
10790 );
10791 assert_eq!(
10792 graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
10793 graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
10794 "backend freshness rows must match apart from updated_at"
10795 );
10796
10797 let published_dir = project_root.join(".store-published");
10798 let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
10799 published_dir.clone(),
10800 project_root.to_path_buf(),
10801 &files,
10802 0,
10803 )
10804 .expect("published unchunked cold build");
10805 assert!(
10806 !CallGraphStore::needs_cold_build(&published_dir, &project_root)
10807 .expect("needs_cold_build after publish"),
10808 "published store should be ready"
10809 );
10810 drop(_published);
10811 let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
10812 published_dir,
10813 project_root.to_path_buf(),
10814 &files,
10815 3,
10816 )
10817 .expect("ensure with a different chunk size");
10818 assert!(
10819 rebuild_stats.is_none(),
10820 "changing callgraph_chunk_size must not affect store identity or force a rebuild"
10821 );
10822 }
10823
10824 #[test]
10831 #[ignore]
10832 fn bench_cold_build_chunk() {
10833 let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
10834 let chunk: usize = std::env::var("AFT_PERF_CHUNK")
10835 .expect("AFT_PERF_CHUNK")
10836 .parse()
10837 .expect("AFT_PERF_CHUNK must be a non-negative integer");
10838 let project_root = fs::canonicalize(&repo).expect("canonical repo root");
10839 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
10840 let dir = tempdir().expect("temp dir");
10841 let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
10842 .expect("open store");
10843 let started = Instant::now();
10844 let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
10845 let ms = started.elapsed().as_millis();
10846 println!(
10847 "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
10848 stats.files, stats.nodes, stats.refs, stats.edges
10849 );
10850 }
10851
10852 #[test]
10853 fn persisted_workspace_reexport_selects_its_package_dependency() {
10854 let root = tempdir().expect("temp dir");
10855 let dependencies = BTreeSet::from([
10856 "packages/aft-bridge/src/index.ts".to_string(),
10857 "packages/opencode-plugin/src/types.ts".to_string(),
10858 ]);
10859 let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
10860
10861 assert_eq!(
10862 stored_dependencies_for_module(
10863 root.path(),
10864 "packages/opencode-plugin/src/shared/bash-hints.ts",
10865 "@cortexkit/aft-bridge",
10866 &dependencies,
10867 &indexed_files,
10868 ),
10869 BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
10870 );
10871 }
10872
10873 #[test]
10874 fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
10875 let dir = tempdir().expect("temp dir");
10876 let project_root = dir.path();
10877 let files =
10878 write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
10879 let index_path = project_root.join("src/index.ts");
10880
10881 let store = CallGraphStore::open(
10882 project_root.join(".store-incremental-barrel"),
10883 project_root.to_path_buf(),
10884 )
10885 .expect("open incremental store");
10886 store.cold_build(&files).expect("initial cold build");
10887
10888 {
10889 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
10890 let tx = conn.transaction().expect("dependency transaction");
10891 let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
10892 .expect("dependent refs for barrel");
10893 let selected_ref_ids = dependent_refs
10894 .iter()
10895 .map(|dependent_ref| dependent_ref.ref_id.clone())
10896 .collect::<BTreeSet<_>>();
10897 let mut threaded_ref_ids = BTreeSet::new();
10898 let mut threaded_by_caller = BTreeMap::new();
10899 record_dependent_refs(
10900 &mut threaded_ref_ids,
10901 &mut threaded_by_caller,
10902 dependent_refs,
10903 );
10904 let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
10905 .expect("old per-ref caller lookup");
10906
10907 assert_eq!(threaded_ref_ids, selected_ref_ids);
10908 assert_eq!(threaded_by_caller, old_by_caller);
10909 for consumer in [
10910 "src/consumer_a.ts",
10911 "src/consumer_b.ts",
10912 "src/consumer_c.ts",
10913 ] {
10914 assert!(
10915 threaded_by_caller.contains_key(consumer),
10916 "barrel edit should select dependent refs from {consumer}"
10917 );
10918 }
10919 }
10920
10921 fs::write(
10922 &index_path,
10923 "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
10924 )
10925 .expect("edit barrel");
10926 let stats = store
10927 .refresh_files(std::slice::from_ref(&index_path))
10928 .expect("incremental refresh");
10929 assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
10930 assert!(
10931 stats.dependency_selected_refs > 0,
10932 "barrel surface edit should select dependent refs"
10933 );
10934
10935 let cold_store = CallGraphStore::open(
10936 project_root.join(".store-cold-barrel"),
10937 project_root.to_path_buf(),
10938 )
10939 .expect("open cold rebuild store");
10940 cold_store
10941 .cold_build(&files)
10942 .expect("comparison cold build");
10943
10944 for table in [
10945 "nodes",
10946 "refs",
10947 "file_dependencies",
10948 "edges",
10949 "dispatch_hints",
10950 ] {
10951 assert_eq!(
10952 graph_table_rows(&store, table),
10953 graph_table_rows(&cold_store, table),
10954 "incremental refresh {table} rows must match cold rebuild"
10955 );
10956 }
10957
10958 let consumer_path = project_root.join("src/consumer_a.ts");
10959 fs::write(
10960 &consumer_path,
10961 "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
10962 )
10963 .expect("edit barrel consumer");
10964 store
10965 .refresh_files(std::slice::from_ref(&consumer_path))
10966 .expect("refresh consumer through unchanged barrel");
10967 cold_store
10968 .cold_build(&files)
10969 .expect("comparison cold rebuild after consumer refresh");
10970 for table in [
10971 "nodes",
10972 "refs",
10973 "file_dependencies",
10974 "edges",
10975 "dispatch_hints",
10976 ] {
10977 assert_eq!(
10978 graph_table_rows(&store, table),
10979 graph_table_rows(&cold_store, table),
10980 "refresh through a persisted barrel must preserve cold-build {table} rows"
10981 );
10982 }
10983 }
10984
10985 fn build_reference_connection(
10986 project_root: &Path,
10987 extract: &FileExtract,
10988 resolved: &ResolvedRef,
10989 ) -> Connection {
10990 let mut conn = Connection::open_in_memory().expect("open reference db");
10991 configure_build_connection(&conn).expect("configure reference db");
10992 initialize_schema(&conn).expect("initialize reference schema");
10993 {
10994 let tx = conn.transaction().expect("reference transaction");
10995 clear_tables(&tx).expect("reference clear");
10996 insert_meta(&tx).expect("reference meta");
10997 insert_file_extract(&tx, project_root, extract).expect("reference file extract");
10998 insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
10999 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
11000 .expect("reference dispatch edges");
11001 assert_eq!(supplemental, 0);
11002 tx.commit().expect("reference commit");
11003 }
11004 conn
11005 }
11006
11007 fn build_optimized_connection(
11008 project_root: &Path,
11009 extract: &FileExtract,
11010 resolved: &ResolvedRef,
11011 ) -> Connection {
11012 let mut conn = Connection::open_in_memory().expect("open optimized db");
11013 configure_build_connection(&conn).expect("configure optimized db");
11014 initialize_schema(&conn).expect("initialize optimized schema");
11015 {
11016 let tx = conn.transaction().expect("optimized transaction");
11017 clear_tables(&tx).expect("optimized clear");
11018 insert_meta(&tx).expect("optimized meta");
11019 drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
11020 {
11021 let workspace_root = project_root.display().to_string();
11022 let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
11023 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
11024 .expect("optimized file extract");
11025 insert_resolved_ref_prepared(&mut inserts, resolved)
11026 .expect("optimized resolved ref");
11027 }
11028 create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
11029 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
11030 .expect("optimized dispatch edges");
11031 assert_eq!(supplemental, 0);
11032 tx.commit().expect("optimized commit");
11033 }
11034 conn
11035 }
11036
11037 fn fixture_extract(_project_root: &Path) -> FileExtract {
11038 let rel_path = "src/main.ts".to_string();
11039 let target_path = "src/helper.ts".to_string();
11040 let node = NodeRecord {
11041 id: "node-main".to_string(),
11042 file_path: rel_path.clone(),
11043 name: "main".to_string(),
11044 scoped_name: "main".to_string(),
11045 kind: "function".to_string(),
11046 range: Range {
11047 start_line: 0,
11048 start_col: 0,
11049 end_line: 0,
11050 end_col: 32,
11051 },
11052 range_ordinal: 0,
11053 signature: Some("export function main()".to_string()),
11054 exported: true,
11055 is_default_export: false,
11056 is_type_like: false,
11057 is_callgraph_entry_point: true,
11058 };
11059 let mut dependencies = BTreeSet::new();
11060 dependencies.insert(target_path.clone());
11061 let raw_ref = RawRef {
11062 ref_id: "ref-main-helper".to_string(),
11063 caller_node: Some(node.id.clone()),
11064 caller_symbol: Some(node.scoped_name.clone()),
11065 caller_file: rel_path.clone(),
11066 kind: "call".to_string(),
11067 short_name: Some("helper".to_string()),
11068 full_ref: Some("helper".to_string()),
11069 module_path: None,
11070 import_kind: None,
11071 local_name: Some("helper".to_string()),
11072 requested_name: Some("helper".to_string()),
11073 namespace_alias: None,
11074 wildcard: false,
11075 line: 1,
11076 byte_start: 24,
11077 byte_end: 32,
11078 dependencies,
11079 };
11080 FileExtract {
11081 rel_path,
11082 freshness: FileFreshness {
11083 mtime: UNIX_EPOCH + Duration::from_secs(123),
11084 size: 40,
11085 content_hash: cache_freshness::hash_bytes(b"fixture source"),
11086 },
11087 lang: LangId::TypeScript,
11088 data: FileCallData {
11089 calls_by_symbol: HashMap::new(),
11090 exported_symbols: Vec::new(),
11091 symbol_metadata: HashMap::new(),
11092 default_export_symbol: None,
11093 import_block: ImportBlock::empty(),
11094 lang: LangId::TypeScript,
11095 },
11096 nodes: vec![node.clone()],
11097 raw_refs: vec![raw_ref],
11098 dispatch_hints: vec![DispatchHint {
11099 id: "dispatch-main-helper".to_string(),
11100 method_name: "helper".to_string(),
11101 caller_node: node.id,
11102 file: "src/main.ts".to_string(),
11103 line: 1,
11104 byte_start: 24,
11105 byte_end: 32,
11106 }],
11107 surface_fingerprint: "surface".to_string(),
11108 }
11109 }
11110
11111 fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
11112 let raw = extract.raw_refs[0].clone();
11113 let mut dependencies = raw.dependencies.clone();
11114 dependencies.insert("src/helper.ts".to_string());
11115 ResolvedRef {
11116 edge: Some(EdgeRecord {
11117 edge_id: "edge-main-helper".to_string(),
11118 source_node: raw.caller_node.clone().expect("caller node"),
11119 target_node: Some("node-helper".to_string()),
11120 target_file: "src/helper.ts".to_string(),
11121 target_symbol: "helper".to_string(),
11122 kind: "call".to_string(),
11123 line: raw.line,
11124 }),
11125 raw,
11126 status: "resolved".to_string(),
11127 target_node: Some("node-helper".to_string()),
11128 target_file: Some("src/helper.ts".to_string()),
11129 target_symbol: Some("helper".to_string()),
11130 dependencies,
11131 }
11132 }
11133
11134 fn write_chunked_equivalence_fixture(project_root: &Path) {
11135 let ts_dir = project_root.join("ts");
11136 fs::create_dir_all(&ts_dir).expect("create ts dir");
11137 fs::write(
11138 ts_dir.join("leaf.ts"),
11139 "export function leaf(value: number) {\n return value + 1;\n}\n",
11140 )
11141 .expect("write ts leaf");
11142 fs::write(
11143 ts_dir.join("mid.ts"),
11144 "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n return leaf(value);\n}\n",
11145 )
11146 .expect("write ts mid");
11147 fs::write(
11148 ts_dir.join("entry.ts"),
11149 "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n return mid(worker.run());\n}\n",
11150 )
11151 .expect("write ts entry");
11152 fs::write(
11153 ts_dir.join("worker.ts"),
11154 "export class Worker {\n run() {\n return 41;\n }\n}\n",
11155 )
11156 .expect("write ts worker");
11157 for idx in 0..4 {
11158 fs::write(
11159 ts_dir.join(format!("extra_{idx}.ts")),
11160 format!(
11161 "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n return entry(new Worker());\n}}\n"
11162 ),
11163 )
11164 .expect("write ts extra");
11165 }
11166
11167 let rust_dir = project_root.join("src");
11168 let commands_dir = rust_dir.join("commands");
11169 fs::create_dir_all(&commands_dir).expect("create rust commands dir");
11170 fs::write(
11171 rust_dir.join("context.rs"),
11172 r#"pub struct AppContext;
11173
11174impl AppContext {
11175 pub fn callgraph_store_for_ops(&self) -> usize {
11176 1
11177 }
11178}
11179"#,
11180 )
11181 .expect("write rust context");
11182 fs::write(
11183 rust_dir.join("lib.rs"),
11184 "pub mod context;\npub mod commands;\n",
11185 )
11186 .expect("write rust lib");
11187 fs::write(
11188 commands_dir.join("mod.rs"),
11189 "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
11190 )
11191 .expect("write rust commands mod");
11192 for name in ["callers", "impact", "trace_to"] {
11193 fs::write(
11194 commands_dir.join(format!("{name}.rs")),
11195 format!(
11196 r#"use crate::context::AppContext;
11197
11198pub fn handle_{name}(ctx: &AppContext) -> usize {{
11199 ctx.callgraph_store_for_ops()
11200}}
11201"#
11202 ),
11203 )
11204 .expect("write rust command");
11205 }
11206 }
11207
11208 fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
11209 let src_dir = project_root.join("src");
11210 fs::create_dir_all(&src_dir).expect("create src dir");
11211
11212 let target_path = src_dir.join("target.ts");
11213 fs::write(&target_path, "export function target() {\n return 1;\n}\n")
11214 .expect("write target");
11215
11216 let index_path = src_dir.join("index.ts");
11217 fs::write(&index_path, barrel_source).expect("write barrel");
11218
11219 let mut files = vec![target_path, index_path];
11220 for (file_name, function_name) in [
11221 ("consumer_a.ts", "consumerA"),
11222 ("consumer_b.ts", "consumerB"),
11223 ("consumer_c.ts", "consumerC"),
11224 ] {
11225 let path = src_dir.join(file_name);
11226 fs::write(
11227 &path,
11228 format!(
11229 "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n return target();\n}}\n"
11230 ),
11231 )
11232 .expect("write consumer");
11233 files.push(path);
11234 }
11235 files
11236 }
11237
11238 fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
11239 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
11240 table_rows(&conn, table)
11241 }
11242
11243 fn graph_table_rows_without(
11244 store: &CallGraphStore,
11245 table: &str,
11246 excluded_columns: &[&str],
11247 ) -> Vec<String> {
11248 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
11249 table_rows_without(&conn, table, excluded_columns)
11250 }
11251
11252 fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
11253 table_rows_without(conn, table, &[])
11254 }
11255
11256 fn table_rows_without(
11257 conn: &Connection,
11258 table: &str,
11259 excluded_columns: &[&str],
11260 ) -> Vec<String> {
11261 let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
11262 let columns: Vec<String> = conn
11263 .prepare(&format!("PRAGMA table_info({table})"))
11264 .expect("prepare table_info")
11265 .query_map([], |row| row.get::<_, String>(1))
11266 .expect("query table_info")
11267 .collect::<std::result::Result<Vec<String>, _>>()
11268 .expect("collect columns")
11269 .into_iter()
11270 .filter(|column| !excluded_columns.contains(column.as_str()))
11271 .collect();
11272 let sql = format!(
11273 "SELECT {} FROM {table} ORDER BY {}",
11274 columns.join(", "),
11275 columns.join(", ")
11276 );
11277 conn.prepare(&sql)
11278 .expect("prepare table rows")
11279 .query_map([], |row| row_to_strings(row, columns.len()))
11280 .expect("query table rows")
11281 .collect::<std::result::Result<_, _>>()
11282 .expect("collect table rows")
11283 }
11284
11285 fn assert_cold_build_stats_match_except_elapsed(
11286 expected: &ColdBuildStats,
11287 actual: &ColdBuildStats,
11288 ) {
11289 assert_eq!(actual.files, expected.files, "file counts must match");
11290 assert_eq!(actual.nodes, expected.nodes, "node counts must match");
11291 assert_eq!(actual.refs, expected.refs, "ref counts must match");
11292 assert_eq!(actual.edges, expected.edges, "edge counts must match");
11293 assert_eq!(
11294 actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
11295 expected
11296 .failed_files
11297 .iter()
11298 .cloned()
11299 .collect::<BTreeSet<_>>(),
11300 "failed file sets must match"
11301 );
11302 }
11303
11304 fn backend_state_rows(conn: &Connection) -> Vec<String> {
11305 conn.prepare(
11306 "SELECT backend, workspace_root, file_path, content_hash, status
11307 FROM backend_file_state
11308 ORDER BY backend, workspace_root, file_path, content_hash, status",
11309 )
11310 .expect("prepare backend rows")
11311 .query_map([], |row| row_to_strings(row, 5))
11312 .expect("query backend rows")
11313 .collect::<std::result::Result<_, _>>()
11314 .expect("collect backend rows")
11315 }
11316
11317 fn secondary_indexes(conn: &Connection) -> Vec<String> {
11318 let mut indexes = Vec::new();
11319 for table in [
11320 "files",
11321 "nodes",
11322 "refs",
11323 "file_dependencies",
11324 "edges",
11325 "dispatch_hints",
11326 "type_ref_names",
11327 "backend_file_state",
11328 "meta",
11329 ] {
11330 let sql = format!("PRAGMA index_list({table})");
11331 let mut stmt = conn.prepare(&sql).expect("prepare index list");
11332 let rows = stmt
11333 .query_map([], |row| row.get::<_, String>(1))
11334 .expect("query index list");
11335 for name in rows {
11336 let name = name.expect("index name");
11337 if name.starts_with("idx_") {
11338 indexes.push(format!("{table}:{name}"));
11339 }
11340 }
11341 }
11342 indexes.sort();
11343 indexes
11344 }
11345
11346 fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
11347 let mut values = Vec::with_capacity(len);
11348 for index in 0..len {
11349 let value = row.get_ref(index)?;
11350 values.push(match value {
11351 rusqlite::types::ValueRef::Null => "NULL".to_string(),
11352 rusqlite::types::ValueRef::Integer(value) => value.to_string(),
11353 rusqlite::types::ValueRef::Real(value) => value.to_string(),
11354 rusqlite::types::ValueRef::Text(value) => {
11355 String::from_utf8_lossy(value).into_owned()
11356 }
11357 rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
11358 });
11359 }
11360 Ok(values.join("\u{1f}"))
11361 }
11362}
11363
11364#[cfg(test)]
11365mod rust_resolution_tests {
11366 use super::*;
11367 use crate::inspect::job::CallgraphSnapshot;
11368 use std::fs;
11369 use tempfile::tempdir;
11370
11371 #[test]
11372 fn rust_function_scoped_module_alias_resolves_and_projects_live() {
11373 let dir = tempdir().expect("tempdir");
11374 let root = dir.path();
11375 write_rust_manifest(root, "scoped-alias-fixture");
11376 write_file(
11377 root,
11378 "src/lib.rs",
11379 r#"pub mod finalization_contract;
11380
11381pub fn run_alias() {
11382 use crate::finalization_contract as fc;
11383 fc::check_mason_contract();
11384}
11385"#,
11386 );
11387 write_file(
11388 root,
11389 "src/finalization_contract.rs",
11390 r#"pub fn check_mason_contract() {}
11391fn planted_dead() {}
11392"#,
11393 );
11394
11395 let (store, snapshot) = cold_build_twice(root);
11396 assert_direct_caller(
11397 &store,
11398 "src/finalization_contract.rs",
11399 "check_mason_contract",
11400 "src/lib.rs",
11401 "run_alias",
11402 );
11403 assert_projected_call(
11404 root,
11405 &snapshot,
11406 "src/finalization_contract.rs",
11407 "check_mason_contract",
11408 );
11409 assert_no_projected_call(
11410 root,
11411 &snapshot,
11412 "src/finalization_contract.rs",
11413 "planted_dead",
11414 );
11415 assert!(
11416 store
11417 .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
11418 .expect("planted dead callers")
11419 .is_empty(),
11420 "planted-dead guard should stay without callers"
11421 );
11422 }
11423
11424 #[test]
11425 fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
11426 let dir = tempdir().expect("tempdir");
11427 let root = dir.path();
11428 write_rust_manifest(root, "inline-module-fixture");
11429 write_file(
11430 root,
11431 "src/lib.rs",
11432 r#"mod work_graph { fn operations() {} }
11433mod manifest { fn operations() {} }
11434mod audit { fn operations() {} }
11435mod dispatch { fn operations() {} }
11436mod finalization { fn operations() {} }
11437
11438pub fn run_inline_operations() {
11439 work_graph::operations();
11440 manifest::operations();
11441 audit::operations();
11442 dispatch::operations();
11443 finalization::operations();
11444}
11445
11446fn planted_dead() {}
11447"#,
11448 );
11449
11450 let (store, snapshot) = cold_build_twice(root);
11451 for module in [
11452 "work_graph",
11453 "manifest",
11454 "audit",
11455 "dispatch",
11456 "finalization",
11457 ] {
11458 assert_direct_caller(
11459 &store,
11460 "src/lib.rs",
11461 &format!("{module}::operations"),
11462 "src/lib.rs",
11463 "run_inline_operations",
11464 );
11465 }
11466 assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
11467 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
11468 }
11469
11470 #[test]
11471 fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
11472 let dir = tempdir().expect("tempdir");
11473 let root = dir.path();
11474 fs::write(
11475 root.join("Cargo.toml"),
11476 "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
11477 )
11478 .expect("write workspace manifest");
11479 write_file(
11480 root,
11481 "crates/but-action/Cargo.toml",
11482 r#"[package]
11483name = "but-action"
11484version = "0.1.0"
11485edition = "2021"
11486"#,
11487 );
11488 write_file(
11489 root,
11490 "crates/but-action/src/lib.rs",
11491 "mod action;\npub use action::{list_actions};\n",
11492 );
11493 write_file(
11494 root,
11495 "crates/but-action/src/action.rs",
11496 "pub fn list_actions() {}\nfn planted_dead() {}\n",
11497 );
11498 write_file(
11499 root,
11500 "crates/app/Cargo.toml",
11501 r#"[package]
11502name = "app"
11503version = "0.1.0"
11504edition = "2021"
11505"#,
11506 );
11507 write_file(
11508 root,
11509 "crates/app/src/lib.rs",
11510 "pub fn run_actions() {\n but_action::list_actions();\n}\n",
11511 );
11512
11513 let (store, snapshot) = cold_build_twice(root);
11514 assert_direct_caller(
11515 &store,
11516 "crates/but-action/src/action.rs",
11517 "list_actions",
11518 "crates/app/src/lib.rs",
11519 "run_actions",
11520 );
11521 assert!(
11522 store
11523 .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
11524 .expect("lib reexport callers")
11525 .is_empty(),
11526 "call should target the reexported source function, not lib.rs"
11527 );
11528 assert_projected_call(
11529 root,
11530 &snapshot,
11531 "crates/but-action/src/action.rs",
11532 "list_actions",
11533 );
11534 assert_no_projected_call(
11535 root,
11536 &snapshot,
11537 "crates/but-action/src/action.rs",
11538 "planted_dead",
11539 );
11540 }
11541
11542 #[test]
11543 fn rust_generic_self_turbofish_method_dispatch_resolves() {
11544 let dir = tempdir().expect("tempdir");
11545 let root = dir.path();
11546 write_rust_manifest(root, "generic-self-fixture");
11547 write_file(
11548 root,
11549 "src/lib.rs",
11550 r#"pub struct Matcher;
11551
11552impl Matcher {
11553 pub fn run(&self) -> bool {
11554 self.fuzzy_match_optimal::<usize>("needle")
11555 }
11556
11557 fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
11558 let _ = std::marker::PhantomData::<T>;
11559 true
11560 }
11561
11562 fn planted_dead(&self) {}
11563}
11564
11565pub fn entry() -> bool {
11566 let matcher = Matcher;
11567 matcher.run()
11568}
11569"#,
11570 );
11571
11572 let (store, snapshot) = cold_build_twice(root);
11573 assert_direct_caller(
11574 &store,
11575 "src/lib.rs",
11576 "Matcher::fuzzy_match_optimal",
11577 "src/lib.rs",
11578 "Matcher::run",
11579 );
11580 assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
11581 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
11582 }
11583
11584 #[test]
11585 fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
11586 let dir = tempdir().expect("tempdir");
11587 let root = dir.path();
11588 write_rust_manifest(root, "manifest-operations-fixture");
11589 write_file(
11590 root,
11591 "src/main.rs",
11592 r#"mod dispatch;
11593use dispatch::{manifest_operations};
11594
11595fn main() {
11596 manifest_operations();
11597}
11598"#,
11599 );
11600 write_file(
11601 root,
11602 "src/dispatch.rs",
11603 r#"mod work_graph { fn operations() {} }
11604mod manifest { fn operations() {} }
11605mod audit { fn operations() {} }
11606mod descriptor { fn operations() {} }
11607mod writer { fn operations() {} }
11608
11609pub fn manifest_operations() {
11610 manifest::operations();
11611}
11612
11613pub fn work_graph_operations() {
11614 work_graph::operations();
11615}
11616
11617pub fn audit_operations() {
11618 audit::operations();
11619}
11620
11621pub fn descriptor_operations() {
11622 descriptor::operations();
11623}
11624
11625pub fn writer_operations() {
11626 writer::operations();
11627}
11628
11629fn planted_dead() {}
11630"#,
11631 );
11632
11633 let (store, snapshot) = cold_build_twice(root);
11634 assert_direct_caller(
11635 &store,
11636 "src/dispatch.rs",
11637 "manifest_operations",
11638 "src/main.rs",
11639 "main",
11640 );
11641 assert_direct_caller(
11642 &store,
11643 "src/dispatch.rs",
11644 "manifest::operations",
11645 "src/dispatch.rs",
11646 "manifest_operations",
11647 );
11648 assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
11649 assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
11650 assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
11651 }
11652
11653 fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
11654 let files = rust_files(root);
11655 let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
11656 .expect("open first store");
11657 first.cold_build(&files).expect("first cold build");
11658 let first_snapshot =
11659 project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
11660
11661 let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
11662 .expect("open second store");
11663 second.cold_build(&files).expect("second cold build");
11664 let second_snapshot =
11665 project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
11666
11667 assert_eq!(
11668 projection_rows(&first_snapshot),
11669 projection_rows(&second_snapshot),
11670 "cold-build projection should be deterministic"
11671 );
11672 (first, first_snapshot)
11673 }
11674
11675 fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
11676 let mut rows = Vec::new();
11677 for export in &snapshot.exported_symbols {
11678 rows.push(format!(
11679 "export\t{}\t{}\t{}\t{}",
11680 export.file.display(),
11681 export.symbol,
11682 export.kind,
11683 export.line
11684 ));
11685 }
11686 for call in &snapshot.outbound_calls {
11687 rows.push(format!(
11688 "call\t{}\t{}\t{}\t{}\t{}",
11689 call.caller_file.display(),
11690 call.caller_symbol,
11691 call.target,
11692 call.line,
11693 call.provenance
11694 ));
11695 }
11696 for file in &snapshot.entry_points {
11697 rows.push(format!("entry_file\t{}", file.display()));
11698 }
11699 for (file, symbols) in &snapshot.entry_point_symbols {
11700 for symbol in symbols {
11701 rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
11702 }
11703 }
11704 rows.sort();
11705 rows
11706 }
11707
11708 fn assert_direct_caller(
11709 store: &CallGraphStore,
11710 target_rel: &str,
11711 target_symbol: &str,
11712 caller_rel: &str,
11713 caller_symbol: &str,
11714 ) {
11715 let callers = store
11716 .direct_callers_of(Path::new(target_rel), target_symbol)
11717 .unwrap_or_else(|error| {
11718 panic!("direct callers for {target_rel}::{target_symbol}: {error}")
11719 });
11720 assert!(
11721 callers.iter().any(|site| {
11722 site.caller.file == caller_rel && site.caller.symbol == caller_symbol
11723 }),
11724 "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
11725 );
11726 }
11727
11728 fn assert_projected_call(
11729 root: &Path,
11730 snapshot: &CallgraphSnapshot,
11731 target_rel: &str,
11732 symbol: &str,
11733 ) {
11734 let target = projected_target(root, target_rel, symbol);
11735 assert!(
11736 snapshot.outbound_calls.iter().any(|call| {
11737 call.target == target
11738 || call.target.starts_with(&format!(
11739 "{target}{}",
11740 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
11741 ))
11742 }),
11743 "expected projected call to {target}; calls: {:#?}",
11744 snapshot.outbound_calls
11745 );
11746 }
11747
11748 fn assert_no_projected_call(
11749 root: &Path,
11750 snapshot: &CallgraphSnapshot,
11751 target_rel: &str,
11752 symbol: &str,
11753 ) {
11754 let target = projected_target(root, target_rel, symbol);
11755 assert!(
11756 snapshot.outbound_calls.iter().all(|call| {
11757 call.target != target
11758 && !call.target.starts_with(&format!(
11759 "{target}{}",
11760 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
11761 ))
11762 }),
11763 "did not expect projected call to {target}; calls: {:#?}",
11764 snapshot.outbound_calls
11765 );
11766 }
11767
11768 fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
11769 let path = fs::canonicalize(root.join(target_rel)).expect("canonical target path");
11770 format!("{}::{symbol}", path.display())
11771 }
11772
11773 fn write_rust_manifest(root: &Path, name: &str) {
11774 write_file(
11775 root,
11776 "Cargo.toml",
11777 &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
11778 );
11779 }
11780
11781 fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
11782 let path = root.join(rel_path);
11783 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
11784 fs::write(&path, source).expect("write fixture file");
11785 path
11786 }
11787
11788 fn rust_files(root: &Path) -> Vec<PathBuf> {
11789 let mut files = Vec::new();
11790 collect_rust_files(root, &mut files);
11791 files.sort();
11792 files
11793 }
11794
11795 fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
11796 for entry in fs::read_dir(dir).expect("read fixture dir") {
11797 let entry = entry.expect("read fixture entry");
11798 let path = entry.path();
11799 if path.is_dir() {
11800 let name = path
11801 .file_name()
11802 .and_then(|name| name.to_str())
11803 .unwrap_or("");
11804 if !name.starts_with(".store") {
11805 collect_rust_files(&path, files);
11806 }
11807 } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
11808 files.push(path);
11809 }
11810 }
11811 }
11812}
11813
11814#[cfg(test)]
11815mod build_pool_tests {
11816 use super::build_pool_size;
11817
11818 #[test]
11819 fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
11820 let size = build_pool_size();
11821 assert!(size >= 1, "pool size must be at least 1");
11824 assert!(size <= 8, "pool size must be capped at 8, got {size}");
11825
11826 let cores = std::thread::available_parallelism()
11827 .map(|p| p.get())
11828 .unwrap_or(1);
11829 let expected = cores.div_ceil(2).clamp(1, 8);
11830 assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
11831 }
11832}
11833
11834#[cfg(test)]
11835mod reexport_resolution_tests {
11836 use super::*;
11837
11838 fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
11839 ProjectIndex {
11840 project_root: PathBuf::from("/fixture"),
11841 files: files.into_iter().collect(),
11842 caller_data: HashMap::new(),
11843 workspace_crate_prefixes: std::sync::OnceLock::new(),
11844 }
11845 }
11846
11847 fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
11848 DbFileIndex {
11849 lang: None,
11850 exports: HashSet::new(),
11851 default_export: None,
11852 export_aliases: HashMap::new(),
11853 node_by_scoped: HashMap::new(),
11854 node_by_bare: HashMap::new(),
11855 module_targets: HashMap::new(),
11856 reexports: reexport_targets
11857 .iter()
11858 .map(|target| ReexportIndex {
11859 target_file: Some((*target).to_string()),
11860 named: HashMap::new(),
11861 wildcard: true,
11862 })
11863 .collect(),
11864 }
11865 }
11866
11867 #[test]
11874 fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
11875 let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
11876 let files = names
11877 .iter()
11878 .map(|name| {
11879 let targets: Vec<&str> = names
11880 .iter()
11881 .filter(|other| *other != name)
11882 .map(String::as_str)
11883 .collect();
11884 (name.clone(), barrel_file(&targets))
11885 })
11886 .collect();
11887 let index = barrel_index(files);
11888
11889 assert_eq!(
11890 resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
11891 None
11892 );
11893 }
11894
11895 #[test]
11901 fn shallow_revisit_after_deep_capped_visit_still_resolves() {
11902 let mut leaf = barrel_file(&[]);
11903 leaf.exports.insert("deep_symbol".to_string());
11904 let mut files: Vec<(String, DbFileIndex)> = Vec::new();
11905 files.push((
11908 "src/entry.ts".to_string(),
11909 barrel_file(&["src/chain0.ts", "src/shared.ts"]),
11910 ));
11911 for i in 0..15 {
11912 let next = if i == 14 {
11913 "src/shared.ts".to_string()
11914 } else {
11915 format!("src/chain{}.ts", i + 1)
11916 };
11917 files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
11918 }
11919 files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
11920 files.push(("src/leaf.ts".to_string(), leaf));
11921 let index = barrel_index(files);
11922
11923 assert_eq!(
11924 resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
11925 Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
11926 "a shallower re-visit must not be pruned by a deeper capped visit"
11927 );
11928 }
11929
11930 #[test]
11931 fn symbol_reachable_through_reexport_cycle_still_resolves() {
11932 let mut leaf = barrel_file(&[]);
11933 leaf.exports.insert("real_symbol".to_string());
11934 let index = barrel_index(vec![
11935 (
11936 "src/a.ts".to_string(),
11937 barrel_file(&["src/b.ts", "src/a.ts"]),
11938 ),
11939 (
11940 "src/b.ts".to_string(),
11941 barrel_file(&["src/a.ts", "src/leaf.ts"]),
11942 ),
11943 ("src/leaf.ts".to_string(), leaf),
11944 ]);
11945
11946 assert_eq!(
11947 resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
11948 Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
11949 );
11950 }
11951}
11952
11953#[cfg(test)]
11954mod method_dispatch_inference_tests {
11955 use super::*;
11956 use std::fs;
11957 use tempfile::tempdir;
11958
11959 #[test]
11960 fn java_field_receiver_type_selects_declared_class_method() {
11961 let source = r#"class EntryPoint {
11962 private UserService userService;
11963
11964 void handle() {
11965 userService.find();
11966 }
11967}
11968
11969class UserService {
11970 void find() {}
11971}
11972
11973class AuditService {
11974 void find() {}
11975}
11976"#;
11977 let dir = tempdir().expect("temp dir");
11978 let root = dir.path();
11979 write_fixture(root, "src/EntryPoint.java", source);
11980 let reference = reference(
11981 "java",
11982 "src/EntryPoint.java",
11983 "EntryPoint::handle",
11984 "userService",
11985 "find",
11986 line_of(source, "userService.find()"),
11987 );
11988 let mut cache = DispatchSourceCache::new();
11989
11990 let receiver_type =
11991 infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
11992 assert_eq!(receiver_type, "UserService");
11993
11994 let candidates = vec![
11995 method_candidate("audit", "AuditService::find"),
11996 method_candidate("user", "UserService::find"),
11997 ];
11998 let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
11999 .expect("type candidate");
12000 assert_eq!(selected.scoped_name, "UserService::find");
12001
12002 let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
12003 assert!(
12004 select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
12005 );
12006 }
12007
12008 #[test]
12009 fn kotlin_property_and_local_value_types_are_inferred() {
12010 let source = r#"class Handler {
12011 private val auditService: AuditService = AuditService()
12012
12013 fun handle() {
12014 auditService.find()
12015 val userService: UserService = UserService()
12016 userService.find()
12017 val billingService = BillingService()
12018 billingService.find()
12019 }
12020}
12021
12022class UserService { fun find() {} }
12023class AuditService { fun find() {} }
12024class BillingService { fun find() {} }
12025"#;
12026 let dir = tempdir().expect("temp dir");
12027 let root = dir.path();
12028 write_fixture(root, "src/Handler.kt", source);
12029 let mut cache = DispatchSourceCache::new();
12030
12031 let audit_ref = reference(
12032 "kotlin",
12033 "src/Handler.kt",
12034 "Handler::handle",
12035 "auditService",
12036 "find",
12037 line_of(source, "auditService.find()"),
12038 );
12039 assert_eq!(
12040 infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
12041 Some("AuditService")
12042 );
12043
12044 let user_ref = reference(
12045 "kotlin",
12046 "src/Handler.kt",
12047 "Handler::handle",
12048 "userService",
12049 "find",
12050 line_of(source, "userService.find()"),
12051 );
12052 assert_eq!(
12053 infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
12054 Some("UserService")
12055 );
12056
12057 let billing_ref = reference(
12058 "kotlin",
12059 "src/Handler.kt",
12060 "Handler::handle",
12061 "billingService",
12062 "find",
12063 line_of(source, "billingService.find()"),
12064 );
12065 assert_eq!(
12066 infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
12067 Some("BillingService")
12068 );
12069 }
12070
12071 #[test]
12072 fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
12073 let source = r#"struct Foo { void run(); };
12074struct PointerFoo { void run(); };
12075struct FactoryFoo { void run(); };
12076FactoryFoo makeFactoryFoo();
12077
12078void handle() {
12079 Foo foo;
12080 foo.run();
12081 PointerFoo* pointerFoo = nullptr;
12082 pointerFoo->run();
12083 auto factoryFoo = makeFactoryFoo();
12084 factoryFoo.run();
12085}
12086"#;
12087 let dir = tempdir().expect("temp dir");
12088 let root = dir.path();
12089 write_fixture(root, "src/fixture.cpp", source);
12090 let mut cache = DispatchSourceCache::new();
12091
12092 let foo_ref = reference(
12093 "cpp",
12094 "src/fixture.cpp",
12095 "handle",
12096 "foo",
12097 "run",
12098 line_of(source, "foo.run()"),
12099 );
12100 assert_eq!(
12101 infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
12102 Some("Foo")
12103 );
12104
12105 let pointer_ref = reference(
12106 "cpp",
12107 "src/fixture.cpp",
12108 "handle",
12109 "pointerFoo",
12110 "run",
12111 line_of(source, "pointerFoo->run()"),
12112 );
12113 assert_eq!(
12114 infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
12115 Some("PointerFoo")
12116 );
12117
12118 let factory_ref = reference(
12119 "cpp",
12120 "src/fixture.cpp",
12121 "handle",
12122 "factoryFoo",
12123 "run",
12124 line_of(source, "factoryFoo.run()"),
12125 );
12126 assert_eq!(
12127 infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
12128 Some("FactoryFoo")
12129 );
12130 }
12131
12132 #[test]
12133 fn unknown_java_receiver_still_uses_name_match_fallback() {
12134 let source = r#"class EntryPoint {
12135 void handle() {
12136 service.runSpecial();
12137 }
12138}
12139
12140class OnlyService {
12141 void runSpecial() {}
12142}
12143"#;
12144 let dir = tempdir().expect("temp dir");
12145 let root = dir.path();
12146 write_fixture(root, "src/EntryPoint.java", source);
12147 let reference = reference(
12148 "java",
12149 "src/EntryPoint.java",
12150 "EntryPoint::handle",
12151 "service",
12152 "runSpecial",
12153 line_of(source, "service.runSpecial()"),
12154 );
12155 let mut cache = DispatchSourceCache::new();
12156
12157 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
12158 let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
12159 let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
12160 assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
12161 }
12162
12163 fn reference(
12164 lang: &str,
12165 caller_file: &str,
12166 caller_symbol: &str,
12167 receiver: &str,
12168 method_name: &str,
12169 line: u32,
12170 ) -> NameMatchRef {
12171 NameMatchRef {
12172 ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
12173 caller_node: format!("{caller_symbol}:node"),
12174 caller_file: caller_file.to_string(),
12175 caller_symbol: caller_symbol.to_string(),
12176 caller_signature: None,
12177 receiver: receiver.to_string(),
12178 method_name: method_name.to_string(),
12179 colon_dispatch: false,
12180 line,
12181 lang: lang.to_string(),
12182 }
12183 }
12184
12185 fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
12186 NameMatchCandidate {
12187 node_id: node_id.to_string(),
12188 file_path: "src/targets.fixture".to_string(),
12189 scoped_name: scoped_name.to_string(),
12190 kind: "method".to_string(),
12191 }
12192 }
12193
12194 fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
12195 let path = root.join(rel_path);
12196 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
12197 fs::write(path, source).expect("write fixture");
12198 }
12199
12200 fn line_of(source: &str, needle: &str) -> u32 {
12201 source
12202 .lines()
12203 .position(|line| line.contains(needle))
12204 .map(|index| index as u32 + 1)
12205 .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
12206 }
12207}