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