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