1use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
9use crate::callgraph::{self, EdgeResolution, FileCallData, TraceToSymbolCandidate};
10use crate::context::SubcLifecycleAdmission;
11use crate::error::AftError;
12use crate::imports::{ImportForm, ImportGroup, ImportKind, ImportStatement};
13use crate::parser::{grammar_for, LangId};
14use crate::symbols::{Range, SymbolKind};
15use rayon::prelude::*;
16use rusqlite::{
17 params, params_from_iter, Connection, OpenFlags, OptionalExtension, Statement, Transaction,
18};
19use std::collections::{hash_map::Entry, BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
20use std::fmt;
21use std::io::Read;
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
24use std::sync::{Arc, Condvar, Mutex, OnceLock};
25use std::thread::JoinHandle;
26use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
27use tree_sitter::{Node, Parser};
28
29const SCHEMA_VERSION: i64 = 1;
30const BACKEND_TREESITTER: &str = "treesitter";
31const PROVENANCE_TREESITTER: &str = "treesitter+resolver";
32const PROVENANCE_NAME_MATCH: &str = "name_match";
33const PROVENANCE_TYPE_MATCH: &str = "type_match";
34const PROVENANCE_VALUE_REF: &str = "value_ref";
35const NAME_MATCH_SCORE_THRESHOLD: f64 = 2.0;
36const TOP_LEVEL_SYMBOL: &str = "<top-level>";
37const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
38const MIGRATION_MANIFEST_VERSION: u32 = 1;
39const MIGRATION_GENERATION_TAG: &str = ".migrated.";
40const MIGRATION_BACKUP_PAGES_PER_STEP: i32 = 128;
41const MIGRATION_BACKUP_RETRY_BUDGET: usize = 25;
42const MIGRATION_BACKUP_WALL_CLOCK_BUDGET: Duration = Duration::from_secs(10);
43const SQLITE_FILE_SET_SUFFIXES: &[&str] = &["", "-wal", "-shm", "-journal"];
44const MARKED_GENERATION_RETENTION_TTL: Duration = Duration::from_secs(6 * 60 * 60);
49const REFRESH_WORKER_WARN_AFTER: Duration = Duration::from_secs(5);
50const REFRESH_WORKER_FINAL_AFTER: Duration = Duration::from_secs(30);
51pub const REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET: Duration = Duration::from_millis(100);
52const REBUILD_COOLDOWN: Duration = Duration::from_secs(30);
53const ROOT_REPAIR_WARN_INTERVAL: Duration = Duration::from_secs(60);
54const CALLGRAPH_WRITE_METRIC_WINDOW: Duration = Duration::from_secs(60);
55const CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES: i64 = 4_000;
56const CALLGRAPH_SQLITE_CACHE_KIB: i64 = -8 * 1024;
59const REFRESH_IDLE_CHECKPOINT_INTERVAL: Duration = Duration::from_secs(60);
60
61const COLD_BUILD_EXTRACT_BATCH_FILES: usize = 256;
64const COLD_BUILD_EXTRACT_BATCH_BYTES: u64 = 32 * 1024 * 1024;
65const COLD_BUILD_RESOLVE_WINDOW: usize = 20_000;
68const STAGED_COMMITTED_EXTRACTED_BYTES: &str = "committed_extracted_bytes";
69const STAGED_RESOLVE_CURSOR: &str = "resolve_cursor";
70const STAGED_BUILD_PHASE: &str = "staged_build_phase";
71const STAGED_CORPUS_FINGERPRINT: &str = "staged_corpus_fingerprint";
72
73fn write_amplification_baseline_enabled() -> bool {
74 std::env::var_os("AFT_CALLGRAPH_WRITE_AMP_BASELINE").is_some()
75}
76
77type ColdBuildSwapObserver = dyn Fn(&Path, &Path) + Send + Sync + 'static;
78pub type ColdBuildPhaseObserver = dyn Fn(&'static str) + Send + Sync + 'static;
79
80static COLD_BUILD_PHASE_OBSERVER: OnceLock<Mutex<Option<Arc<ColdBuildPhaseObserver>>>> =
81 OnceLock::new();
82
83pub fn set_cold_build_phase_observer(observer: Option<Arc<ColdBuildPhaseObserver>>) {
87 *COLD_BUILD_PHASE_OBSERVER
88 .get_or_init(|| Mutex::new(None))
89 .lock()
90 .expect("cold build phase observer mutex poisoned") = observer;
91}
92
93fn note_cold_build_phase(phase: &'static str) {
94 if let Some(observer) = COLD_BUILD_PHASE_OBSERVER
95 .get_or_init(|| Mutex::new(None))
96 .lock()
97 .expect("cold build phase observer mutex poisoned")
98 .as_ref()
99 .cloned()
100 {
101 observer(phase);
102 }
103}
104
105#[cfg(test)]
106fn note_cold_build_commit_barrier(phase: &'static str) {
107 note_cold_build_phase(phase);
108}
109
110#[cfg(not(test))]
111fn note_cold_build_commit_barrier(_phase: &'static str) {}
112
113#[derive(Clone, Debug, Eq, Hash, PartialEq)]
114struct RebuildCooldownKey {
115 callgraph_dir: PathBuf,
116 project_key: String,
117}
118
119#[derive(Clone, Debug)]
120struct RebuildCooldownRecord {
121 project_root: PathBuf,
122 published_at: Instant,
123 cross_root_cooldown_armed: bool,
124}
125
126static SUCCESSFUL_REBUILDS: OnceLock<Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>> =
131 OnceLock::new();
132
133#[derive(Clone, Debug, Eq, Hash, PartialEq)]
134struct RootRepairWarningKey {
135 project_key: String,
136}
137
138#[derive(Clone, Debug)]
139struct RootRepairWarningRecord {
140 window_start: Instant,
141 last_emitted: Instant,
142 entry_count: u64,
143 suppressed: u64,
144}
145
146static ROOT_REPAIR_WARNINGS: OnceLock<
147 Mutex<HashMap<RootRepairWarningKey, RootRepairWarningRecord>>,
148> = OnceLock::new();
149
150#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
151pub(crate) struct CallgraphWriteMetricsSnapshot {
152 pub commits_60s: u64,
153 pub pages_or_bytes_written_60s: u64,
154}
155
156#[derive(Debug, Default)]
157struct CallgraphWriteMetrics {
158 window_start_ms: AtomicU64,
159 commits_60s: AtomicU64,
160 pages_or_bytes_written_60s: AtomicU64,
161}
162
163static CALLGRAPH_WRITE_METRICS: OnceLock<Mutex<HashMap<String, Arc<CallgraphWriteMetrics>>>> =
164 OnceLock::new();
165
166fn callgraph_write_metrics_for_key(project_key: &str) -> Arc<CallgraphWriteMetrics> {
167 let metrics = CALLGRAPH_WRITE_METRICS.get_or_init(|| Mutex::new(HashMap::new()));
168 let mut metrics = metrics
169 .lock()
170 .expect("callgraph write metrics mutex poisoned");
171 Arc::clone(
172 metrics
173 .entry(project_key.to_string())
174 .or_insert_with(|| Arc::new(CallgraphWriteMetrics::default())),
175 )
176}
177
178fn roll_callgraph_write_metric_window(metrics: &CallgraphWriteMetrics, now_ms: u64) {
179 let current_start = metrics.window_start_ms.load(AtomicOrdering::Acquire);
180 if current_start == 0 {
181 let _ = metrics.window_start_ms.compare_exchange(
182 0,
183 now_ms,
184 AtomicOrdering::AcqRel,
185 AtomicOrdering::Acquire,
186 );
187 return;
188 }
189 if now_ms.saturating_sub(current_start) < CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64 {
190 return;
191 }
192 if metrics
193 .window_start_ms
194 .compare_exchange(
195 current_start,
196 now_ms,
197 AtomicOrdering::AcqRel,
198 AtomicOrdering::Acquire,
199 )
200 .is_ok()
201 {
202 metrics.commits_60s.store(0, AtomicOrdering::Release);
203 metrics
204 .pages_or_bytes_written_60s
205 .store(0, AtomicOrdering::Release);
206 }
207}
208
209impl CallgraphWriteMetrics {
210 fn record_commit(&self, pages_or_bytes_written: u64) {
211 let now_ms = unix_millis_now();
212 roll_callgraph_write_metric_window(self, now_ms);
213 self.commits_60s.fetch_add(1, AtomicOrdering::Relaxed);
214 self.pages_or_bytes_written_60s
215 .fetch_add(pages_or_bytes_written, AtomicOrdering::Relaxed);
216 }
217
218 fn snapshot(&self) -> CallgraphWriteMetricsSnapshot {
219 roll_callgraph_write_metric_window(self, unix_millis_now());
220 CallgraphWriteMetricsSnapshot {
221 commits_60s: self.commits_60s.load(AtomicOrdering::Acquire),
222 pages_or_bytes_written_60s: self
223 .pages_or_bytes_written_60s
224 .load(AtomicOrdering::Acquire),
225 }
226 }
227}
228
229pub(crate) fn callgraph_write_metrics_for_project(
230 project_key: &str,
231) -> CallgraphWriteMetricsSnapshot {
232 callgraph_write_metrics_for_key(project_key).snapshot()
233}
234
235pub(crate) fn callgraph_write_metrics_total() -> CallgraphWriteMetricsSnapshot {
236 let Some(metrics) = CALLGRAPH_WRITE_METRICS.get() else {
237 return CallgraphWriteMetricsSnapshot::default();
238 };
239 let metrics = metrics
240 .lock()
241 .expect("callgraph write metrics mutex poisoned");
242 metrics.values().map(|metrics| metrics.snapshot()).fold(
243 CallgraphWriteMetricsSnapshot::default(),
244 |total, current| CallgraphWriteMetricsSnapshot {
245 commits_60s: total.commits_60s.saturating_add(current.commits_60s),
246 pages_or_bytes_written_60s: total
247 .pages_or_bytes_written_60s
248 .saturating_add(current.pages_or_bytes_written_60s),
249 },
250 )
251}
252
253const ROOT_REPAIR_WARNING_TEXT: &str =
254 "callgraph store root repair requires rebuild; open-only reader reports unavailable";
255
256fn next_root_repair_warning(key: RootRepairWarningKey, now: Instant) -> Option<String> {
257 let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
258 let mut warnings = warnings.lock().ok()?;
259 let entry = warnings.entry(key);
260 let record = match entry {
261 Entry::Vacant(entry) => {
262 entry.insert(RootRepairWarningRecord {
263 window_start: now,
264 last_emitted: now,
265 entry_count: 1,
266 suppressed: 0,
267 });
268 return Some(ROOT_REPAIR_WARNING_TEXT.to_string());
269 }
270 Entry::Occupied(entry) => entry.into_mut(),
271 };
272
273 if now.saturating_duration_since(record.window_start) >= ROOT_REPAIR_WARN_INTERVAL {
274 let suppressed = record.suppressed;
275 record.window_start = now;
276 record.last_emitted = now;
277 record.entry_count = 1;
278 record.suppressed = 0;
279 return Some(if suppressed == 0 {
280 ROOT_REPAIR_WARNING_TEXT.to_string()
281 } else {
282 format!("{ROOT_REPAIR_WARNING_TEXT} (repeated {suppressed}x in 60s)")
283 });
284 }
285
286 record.entry_count = record.entry_count.saturating_add(1);
287 if now.saturating_duration_since(record.last_emitted) < ROOT_REPAIR_WARN_INTERVAL {
288 record.suppressed = record.suppressed.saturating_add(1);
289 None
290 } else {
291 record.last_emitted = now;
292 Some(ROOT_REPAIR_WARNING_TEXT.to_string())
293 }
294}
295
296pub(crate) fn note_repair_entry(project_key: &str) -> Option<String> {
297 next_root_repair_warning(
298 RootRepairWarningKey {
299 project_key: project_key.to_string(),
300 },
301 Instant::now(),
302 )
303}
304
305pub(crate) fn repair_entry_rate(project_key: &str) -> Option<(u64, Instant)> {
310 let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
311 let warnings = warnings.lock().ok()?;
312 let record = warnings.get(&RootRepairWarningKey {
313 project_key: project_key.to_string(),
314 })?;
315 (Instant::now().saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL)
316 .then_some((record.entry_count, record.window_start))
317}
318
319pub(crate) fn repair_entry_rate_total() -> u64 {
320 let Ok(warnings) = ROOT_REPAIR_WARNINGS
321 .get_or_init(|| Mutex::new(HashMap::new()))
322 .lock()
323 else {
324 return 0;
325 };
326 let now = Instant::now();
327 warnings
328 .values()
329 .filter(|record| {
330 now.saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL
331 })
332 .map(|record| record.entry_count)
333 .sum()
334}
335
336#[cfg(test)]
337pub(crate) fn expire_repair_entry_window_for_test(project_key: &str) {
338 let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
339 let mut warnings = warnings.lock().unwrap();
340 if let Some(record) = warnings.get_mut(&RootRepairWarningKey {
341 project_key: project_key.to_string(),
342 }) {
343 record.window_start = Instant::now() - ROOT_REPAIR_WARN_INTERVAL;
344 }
345}
346
347#[cfg(test)]
348mod root_repair_warning_tests {
349 use super::*;
350
351 #[test]
352 fn repair_warning_emits_once_then_reemits_with_suppressed_count() {
353 let key = RootRepairWarningKey {
354 project_key: "test-project".to_string(),
355 };
356 let first_at = Instant::now();
357 let first = next_root_repair_warning(key.clone(), first_at).unwrap();
358 assert_eq!(first, ROOT_REPAIR_WARNING_TEXT);
359 assert!(next_root_repair_warning(key.clone(), first_at + Duration::from_secs(1)).is_none());
360 assert_eq!(
361 repair_entry_rate("test-project").map(|rate| rate.0),
362 Some(2)
363 );
364
365 let repeated = next_root_repair_warning(key, first_at + ROOT_REPAIR_WARN_INTERVAL).unwrap();
366 assert!(repeated.ends_with("(repeated 1x in 60s)"));
367 expire_repair_entry_window_for_test("test-project");
368 assert!(repair_entry_rate("test-project").is_none());
369 }
370}
371
372#[cfg(test)]
373mod write_amplification_tests {
374 use super::*;
375 use std::fs;
376 use tempfile::tempdir;
377
378 #[test]
379 fn callgraph_writer_waits_when_wal_setup_meets_a_write_lock() {
380 let temp = tempdir().unwrap();
381 let sqlite_path = temp.path().join("contended.sqlite");
382 let blocker = Connection::open(&sqlite_path).expect("open blocking connection");
383 blocker
384 .execute_batch(
385 "PRAGMA journal_mode=DELETE;
386 CREATE TABLE lock_probe (value INTEGER NOT NULL);
387 INSERT INTO lock_probe VALUES (1);
388 BEGIN EXCLUSIVE;
389 UPDATE lock_probe SET value = 2;",
390 )
391 .expect("hold exclusive write transaction");
392
393 let (started_tx, started_rx) = std::sync::mpsc::channel();
394 let configure = std::thread::spawn(move || {
395 let conn = Connection::open(sqlite_path).expect("open contending connection");
396 started_tx.send(()).expect("signal configure start");
397 configure_connection(&conn)
398 });
399 started_rx.recv().expect("configure thread started");
400 std::thread::sleep(Duration::from_millis(100));
401 blocker.execute_batch("COMMIT").expect("release write lock");
402
403 configure
404 .join()
405 .expect("configure thread joined")
406 .expect("WAL setup waits for the writer instead of failing locked");
407 }
408
409 #[test]
410 fn callgraph_writer_and_reader_use_bounded_normal_pragmas() {
411 let temp = tempdir().unwrap();
412 let root = temp.path().join("root");
413 fs::create_dir_all(&root).unwrap();
414 let source = root.join("main.ts");
415 fs::write(&source, "export function main() {}\n").unwrap();
416 let store_dir = temp.path().join("store");
417 let store = CallGraphStore::open(store_dir.clone(), root.clone()).unwrap();
418
419 let conn = store.conn.lock().unwrap();
420 let synchronous: i64 = conn
421 .pragma_query_value(None, "synchronous", |row| row.get(0))
422 .unwrap();
423 let autocheckpoint: i64 = conn
424 .pragma_query_value(None, "wal_autocheckpoint", |row| row.get(0))
425 .unwrap();
426 let cache_size: i64 = conn
427 .pragma_query_value(None, "cache_size", |row| row.get(0))
428 .unwrap();
429 assert_eq!(synchronous, 1, "NORMAL synchronous mode is value 1");
430 assert_eq!(autocheckpoint, CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES);
431 assert_eq!(cache_size, CALLGRAPH_SQLITE_CACHE_KIB);
432 drop(conn);
433 store.cold_build(std::slice::from_ref(&source)).unwrap();
434 drop(store);
435
436 let readonly = CallGraphStore::open_readonly(store_dir, root)
437 .unwrap()
438 .expect("writer-created empty schema should be readable");
439 let conn = readonly.inner.conn.lock().unwrap();
440 let synchronous: i64 = conn
441 .pragma_query_value(None, "synchronous", |row| row.get(0))
442 .unwrap();
443 assert_eq!(synchronous, 1);
444 }
445
446 #[test]
447 fn own_refresh_skips_identical_extract_but_not_position_shift() {
448 let temp = tempdir().unwrap();
449 let root = temp.path().join("root");
450 fs::create_dir_all(&root).unwrap();
451 let source = root.join("main.ts");
452 fs::write(&source, "export function main() { return 1; }\n").unwrap();
453 let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
454 store.cold_build(std::slice::from_ref(&source)).unwrap();
455 let write_metrics = callgraph_write_metrics_for_project(store.project_key());
456 assert!(write_metrics.commits_60s > 0);
457 assert!(write_metrics.pages_or_bytes_written_60s > 0);
458
459 let before = store.conn.lock().unwrap().total_changes();
460 fs::write(&source, "export function main() { return 1; }\n\n").unwrap();
461 let (stats, _) = store
462 .refresh_files_profiled(std::slice::from_ref(&source))
463 .unwrap();
464 let after = store.conn.lock().unwrap().total_changes();
465 assert_eq!(stats.unchanged_extract_files, 1);
466 assert_eq!(stats.refreshed_own_files, 0);
467 assert_eq!(
468 after - before,
469 3,
470 "files, backend freshness, and the durable projection revision update"
471 );
472
473 fs::write(&source, "\nexport function main() { return 1; }\n\n").unwrap();
474 let (shifted_stats, _) = store
475 .refresh_files_profiled(std::slice::from_ref(&source))
476 .unwrap();
477 assert_eq!(shifted_stats.unchanged_extract_files, 0);
478 assert_eq!(shifted_stats.refreshed_own_files, 1);
479 }
480
481 #[test]
482 fn idle_checkpoint_interval_prevents_checkpoint_storms() {
483 let now = Instant::now();
484 assert!(idle_checkpoint_due(None, now));
485 assert!(!idle_checkpoint_due(
486 Some(now),
487 now + Duration::from_secs(REFRESH_IDLE_CHECKPOINT_INTERVAL.as_secs() - 1),
488 ));
489 assert!(idle_checkpoint_due(
490 Some(now),
491 now + REFRESH_IDLE_CHECKPOINT_INTERVAL,
492 ));
493 }
494
495 #[test]
496 fn write_metrics_decay_after_the_sixty_second_window() {
497 let key = format!("metrics-test-{}", now_nanos());
498 let metrics = callgraph_write_metrics_for_key(&key);
499 metrics.record_commit(17);
500 assert_eq!(metrics.snapshot().commits_60s, 1);
501 assert_eq!(metrics.snapshot().pages_or_bytes_written_60s, 17);
502 metrics.window_start_ms.store(
503 unix_millis_now().saturating_sub(CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64),
504 AtomicOrdering::Release,
505 );
506 assert_eq!(metrics.snapshot(), CallgraphWriteMetricsSnapshot::default());
507 }
508}
509
510#[cfg(test)]
511type ColdBuildBeforePublishObserver = dyn Fn() + Send + Sync + 'static;
512thread_local! {
519 static COLD_BUILD_SWAP_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSwapObserver>>> =
520 const { std::cell::RefCell::new(None) };
521 #[cfg(test)]
522 static COLD_BUILD_BEFORE_PUBLISH_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildBeforePublishObserver>>> =
523 const { std::cell::RefCell::new(None) };
524 static MIGRATION_AVAILABLE_DISK_OVERRIDE: std::cell::RefCell<Option<u64>> =
525 const { std::cell::RefCell::new(None) };
526 static MIGRATION_FAIL_AFTER_TEMP_COPY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
527 static MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED: std::cell::Cell<bool> =
528 const { std::cell::Cell::new(false) };
529 static PUBLISH_ADMISSION: std::cell::RefCell<Option<(crate::root_cache::ArtifactPublishEpoch, u64)>> =
530 const { std::cell::RefCell::new(None) };
531 static REFRESH_COMMIT_ADMISSION: std::cell::RefCell<Option<(SubcLifecycleAdmission, Arc<std::sync::atomic::AtomicU64>, u64)>> =
532 const { std::cell::RefCell::new(None) };
533}
534
535mod dead_code_projection;
536pub use dead_code_projection::project_dead_code_snapshot;
537pub(crate) use dead_code_projection::project_dead_code_snapshot_with_revision;
538#[cfg(test)]
539pub(crate) use dead_code_projection::set_projection_before_open_observer;
540
541#[doc(hidden)]
542pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
543 COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
544}
545
546#[cfg(test)]
547fn set_cold_build_before_publish_observer(observer: Option<Arc<ColdBuildBeforePublishObserver>>) {
548 COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
549}
550
551#[cfg(test)]
552fn notify_cold_build_before_publish_observer() {
553 let observer = COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| slot.borrow().clone());
554 if let Some(observer) = observer {
555 observer();
556 }
557}
558
559#[cfg(not(test))]
560fn notify_cold_build_before_publish_observer() {}
561
562#[doc(hidden)]
563pub fn set_legacy_migration_available_disk_for_test(bytes: Option<u64>) {
564 MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow_mut() = bytes);
565}
566
567#[doc(hidden)]
568pub fn set_legacy_migration_fail_after_temp_copy_for_test(enabled: bool) {
569 MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.set(enabled));
570}
571
572#[doc(hidden)]
573pub fn set_legacy_migration_backup_budget_exhausted_for_test(enabled: bool) {
574 MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.set(enabled));
575}
576
577struct PublishAdmissionGuard {
578 previous: Option<(crate::root_cache::ArtifactPublishEpoch, u64)>,
579}
580
581impl Drop for PublishAdmissionGuard {
582 fn drop(&mut self) {
583 PUBLISH_ADMISSION.with(|slot| {
584 *slot.borrow_mut() = self.previous.take();
585 });
586 }
587}
588
589pub(crate) fn with_publish_epoch<R>(
590 epoch: crate::root_cache::ArtifactPublishEpoch,
591 expected: u64,
592 run: impl FnOnce() -> R,
593) -> R {
594 let previous = PUBLISH_ADMISSION.with(|slot| slot.replace(Some((epoch, expected))));
595 let _guard = PublishAdmissionGuard { previous };
596 run()
597}
598
599fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
600 let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
601 match admission {
602 Some((epoch, expected)) => epoch
603 .run_if_current(expected, publish)
604 .unwrap_or(Err(CallGraphStoreError::Superseded)),
605 None => publish(),
606 }
607}
608
609struct RefreshCommitAdmissionGuard {
610 previous: Option<(
611 SubcLifecycleAdmission,
612 Arc<std::sync::atomic::AtomicU64>,
613 u64,
614 )>,
615}
616
617impl Drop for RefreshCommitAdmissionGuard {
618 fn drop(&mut self) {
619 REFRESH_COMMIT_ADMISSION.with(|slot| {
620 *slot.borrow_mut() = self.previous.take();
621 });
622 }
623}
624
625fn with_refresh_commit_admission<R>(
626 lifecycle: SubcLifecycleAdmission,
627 generation_flag: Arc<std::sync::atomic::AtomicU64>,
628 expected_generation: u64,
629 run: impl FnOnce() -> R,
630) -> R {
631 let previous = REFRESH_COMMIT_ADMISSION
632 .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
633 let _guard = RefreshCommitAdmissionGuard { previous };
634 run()
635}
636
637fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
638 let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
639 let commit = || {
640 publish_if_current(|| {
641 tx.commit()?;
642 Ok(())
643 })
644 };
645 match admission {
646 Some((lifecycle, generation_flag, expected_generation)) => lifecycle
647 .run_if_current(generation_flag.as_ref(), expected_generation, commit)
648 .unwrap_or(Err(CallGraphStoreError::Superseded)),
649 None => commit(),
650 }
651}
652
653fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
654 let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
655 if let Some(observer) = observer {
656 observer(temp_path, target_path);
657 }
658}
659
660#[derive(Debug)]
661pub enum CallGraphStoreError {
662 Io(std::io::Error),
663 Sqlite(rusqlite::Error),
664 Json(serde_json::Error),
665 Aft(AftError),
666 Lock(crate::fs_lock::AcquireError),
667 MissingCallerData { file: String },
668 Unavailable(String),
669 Suspended(crate::build_breaker::BuildSuspension),
670 Superseded,
671 StaleFiles(Vec<String>),
672}
673
674impl CallGraphStoreError {
675 pub(crate) fn is_transient_lock_contention(&self) -> bool {
676 matches!(
677 self,
678 Self::Sqlite(rusqlite::Error::SqliteFailure(error, _))
679 if matches!(
680 error.code,
681 rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
682 )
683 )
684 }
685}
686
687impl fmt::Display for CallGraphStoreError {
688 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
689 match self {
690 Self::Io(error) => write!(formatter, "I/O error: {error}"),
691 Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
692 Self::Json(error) => write!(formatter, "json error: {error}"),
693 Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
694 Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
695 Self::MissingCallerData { file } => {
696 write!(formatter, "missing extracted caller data for {file}")
697 }
698 Self::Unavailable(message) => {
699 write!(formatter, "callgraph store unavailable: {message}")
700 }
701 Self::Suspended(suspension) => write!(
702 formatter,
703 "callgraph build suspended for {} after {} deaths ({})",
704 suspension.domain.as_str(),
705 suspension.death_count,
706 suspension.reason
707 ),
708 Self::Superseded => {
709 write!(formatter, "callgraph store build superseded before publish")
710 }
711 Self::StaleFiles(files) => {
712 write!(
713 formatter,
714 "callgraph store has stale files: {}",
715 files.join(", ")
716 )
717 }
718 }
719 }
720}
721
722impl std::error::Error for CallGraphStoreError {}
723
724impl From<std::io::Error> for CallGraphStoreError {
725 fn from(error: std::io::Error) -> Self {
726 Self::Io(error)
727 }
728}
729
730impl From<rusqlite::Error> for CallGraphStoreError {
731 fn from(error: rusqlite::Error) -> Self {
732 Self::Sqlite(error)
733 }
734}
735
736impl From<serde_json::Error> for CallGraphStoreError {
737 fn from(error: serde_json::Error) -> Self {
738 Self::Json(error)
739 }
740}
741
742impl From<AftError> for CallGraphStoreError {
743 fn from(error: AftError) -> Self {
744 Self::Aft(error)
745 }
746}
747
748impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
749 fn from(error: crate::fs_lock::AcquireError) -> Self {
750 Self::Lock(error)
751 }
752}
753
754pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
755
756pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
760
761#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
762pub struct CallGraphStoreOptions {
763 pub enabled: bool,
764}
765
766pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
767
768#[derive(Clone)]
772pub(crate) struct CallgraphRefreshState {
773 store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
774 heavy_root_work_allowed: Arc<AtomicBool>,
775}
776
777impl CallgraphRefreshState {
778 pub(crate) fn new(
779 store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
780 heavy_root_work_allowed: Arc<AtomicBool>,
781 ) -> Self {
782 Self {
783 store,
784 heavy_root_work_allowed,
785 }
786 }
787
788 fn installed_store_snapshot(&self) -> Option<Arc<ReadonlyCallGraphStore>> {
789 self.store
790 .read()
791 .unwrap_or_else(std::sync::PoisonError::into_inner)
792 .as_ref()
793 .map(Arc::clone)
794 }
795}
796
797type WorkspaceCratePrefixes = HashMap<String, String>;
798
799#[derive(Clone, Debug, Default)]
800struct WorkspaceCratePrefixCache(Arc<OnceLock<WorkspaceCratePrefixes>>);
801
802const REFRESH_WORKSPACE_CACHE_ROOT_CAP: usize = 128;
803
804pub(crate) fn invalidates_workspace_crate_prefix_cache(path: &Path) -> bool {
805 path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")
806}
807
808#[derive(Clone, Debug, Hash, PartialEq, Eq)]
809struct RefreshRoot {
810 callgraph_dir: PathBuf,
811 project_root: PathBuf,
812}
813
814#[derive(Clone)]
815pub(crate) struct CallgraphRefreshTicket {
816 lifecycle: SubcLifecycleAdmission,
817 generation_flag: Arc<std::sync::atomic::AtomicU64>,
818 expected_generation: u64,
819 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
820 expected_publish_epoch: u64,
821}
822
823impl CallgraphRefreshTicket {
824 pub(crate) fn new(
825 lifecycle: SubcLifecycleAdmission,
826 generation_flag: Arc<std::sync::atomic::AtomicU64>,
827 expected_generation: u64,
828 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
829 expected_publish_epoch: u64,
830 ) -> Self {
831 Self {
832 lifecycle,
833 generation_flag,
834 expected_generation,
835 publish_epoch,
836 expected_publish_epoch,
837 }
838 }
839
840 fn is_current(&self) -> bool {
841 self.lifecycle
842 .is_current(self.generation_flag.as_ref(), self.expected_generation)
843 && self.publish_epoch.current() == self.expected_publish_epoch
844 }
845}
846
847#[derive(Clone)]
848struct RefreshBatch {
849 root: RefreshRoot,
850 paths: BTreeSet<PathBuf>,
851 pending_sinks: Vec<PendingCallGraphStorePaths>,
852 refresh_states: Vec<CallgraphRefreshState>,
853 ticket: Option<CallgraphRefreshTicket>,
854}
855
856impl RefreshBatch {
857 fn defer(&self) {
858 for sink in &self.pending_sinks {
859 sink.lock().extend(self.paths.iter().cloned());
860 }
861 }
862
863 fn defer_after_open_failure(&self) {
864 self.defer();
865 if self
866 .ticket
867 .as_ref()
868 .is_some_and(|ticket| !ticket.is_current())
869 || !self
870 .refresh_states
871 .iter()
872 .any(|state| state.heavy_root_work_allowed.load(AtomicOrdering::SeqCst))
873 {
874 return;
875 }
876
877 let ready_store_installed = self.refresh_states.iter().any(|state| {
878 let store = state.installed_store_snapshot();
879 store.is_some_and(|store| {
880 store.project_root() == self.root.project_root
881 && !store.is_legacy_fallback()
882 && store.is_current()
883 })
884 });
885 if !ready_store_installed {
886 return;
887 }
888
889 for sink in &self.pending_sinks {
893 let paths = {
894 let mut pending = sink.lock();
895 self.paths
896 .iter()
897 .filter(|path| pending.remove(*path))
898 .cloned()
899 .collect::<Vec<_>>()
900 };
901 if paths.is_empty() {
902 continue;
903 }
904 let _ = enqueue_callgraph_store_refresh_inner(
905 self.root.callgraph_dir.clone(),
906 self.root.project_root.clone(),
907 paths,
908 Arc::clone(sink),
909 self.refresh_states.clone(),
910 self.ticket.clone(),
911 );
912 }
913 }
914
915 fn merge(
916 &mut self,
917 paths: impl IntoIterator<Item = PathBuf>,
918 sink: PendingCallGraphStorePaths,
919 refresh_states: Vec<CallgraphRefreshState>,
920 ticket: Option<CallgraphRefreshTicket>,
921 ) {
922 self.paths.extend(paths);
923 if ticket.is_some() {
924 self.ticket = ticket;
925 }
926 if !self
927 .pending_sinks
928 .iter()
929 .any(|existing| Arc::ptr_eq(existing, &sink))
930 {
931 self.pending_sinks.push(sink);
932 }
933 for refresh_state in refresh_states {
934 if !self.refresh_states.iter().any(|existing| {
935 Arc::ptr_eq(&existing.store, &refresh_state.store)
936 && Arc::ptr_eq(
937 &existing.heavy_root_work_allowed,
938 &refresh_state.heavy_root_work_allowed,
939 )
940 }) {
941 self.refresh_states.push(refresh_state);
942 }
943 }
944 }
945}
946
947#[derive(Default)]
948struct RefreshQueue {
949 order: VecDeque<RefreshRoot>,
950 queued: HashMap<RefreshRoot, RefreshBatch>,
951 active: Option<RefreshBatch>,
952 shutdown_requested: bool,
953}
954
955struct RefreshWorkerShared {
956 queue: Mutex<RefreshQueue>,
957 wake: Condvar,
958}
959
960struct RefreshWorker {
961 shared: Arc<RefreshWorkerShared>,
962 thread: Mutex<Option<JoinHandle<()>>>,
963}
964
965struct RefreshWorkerWatchdog {
966 first_path: PathBuf,
967 batch_len: usize,
968 started: Instant,
969}
970
971impl RefreshWorkerWatchdog {
972 fn start(paths: &[PathBuf]) -> Self {
973 Self {
974 first_path: paths
975 .first()
976 .expect("non-empty callgraph refresh batch has a first path")
977 .clone(),
978 batch_len: paths.len(),
979 started: Instant::now(),
980 }
981 }
982}
983
984impl Drop for RefreshWorkerWatchdog {
985 fn drop(&mut self) {
986 let elapsed = self.started.elapsed();
987 if elapsed < REFRESH_WORKER_WARN_AFTER {
988 return;
989 }
990 let path = if self.batch_len == 1 {
991 self.first_path.display().to_string()
992 } else {
993 format!(
994 "{} (+{} paths)",
995 self.first_path.display(),
996 self.batch_len - 1
997 )
998 };
999 log::warn!(
1000 "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
1001 path,
1002 elapsed.as_millis()
1003 );
1004 if elapsed >= REFRESH_WORKER_FINAL_AFTER {
1005 log::warn!(
1006 "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
1007 path,
1008 elapsed.as_millis()
1009 );
1010 }
1011 }
1012}
1013
1014impl RefreshWorker {
1015 fn spawn() -> Arc<Self> {
1016 let shared = Arc::new(RefreshWorkerShared {
1017 queue: Mutex::new(RefreshQueue::default()),
1018 wake: Condvar::new(),
1019 });
1020 let thread_shared = Arc::clone(&shared);
1021 let thread = std::thread::Builder::new()
1022 .name("aft-callgraph-refresh".to_string())
1023 .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
1024 .expect("failed to spawn callgraph refresh worker");
1025 Arc::new(Self {
1026 shared,
1027 thread: Mutex::new(Some(thread)),
1028 })
1029 }
1030
1031 fn enqueue(
1032 &self,
1033 root: RefreshRoot,
1034 paths: Vec<PathBuf>,
1035 pending_sink: PendingCallGraphStorePaths,
1036 refresh_states: Vec<CallgraphRefreshState>,
1037 ticket: Option<CallgraphRefreshTicket>,
1038 ) -> bool {
1039 let mut queue = self
1040 .shared
1041 .queue
1042 .lock()
1043 .expect("callgraph refresh queue mutex poisoned");
1044 if queue.shutdown_requested {
1045 pending_sink.lock().extend(paths);
1046 return false;
1047 }
1048 if let Some(batch) = queue.queued.get_mut(&root) {
1049 batch.merge(paths, pending_sink, refresh_states, ticket);
1050 } else {
1051 queue.order.push_back(root.clone());
1052 queue.queued.insert(
1053 root.clone(),
1054 RefreshBatch {
1055 root,
1056 paths: paths.into_iter().collect(),
1057 pending_sinks: vec![pending_sink],
1058 refresh_states,
1059 ticket,
1060 },
1061 );
1062 }
1063 self.shared.wake.notify_one();
1064 true
1065 }
1066
1067 fn shutdown_with_budget(&self, budget: Duration) -> bool {
1068 let deadline = Instant::now() + budget;
1069 let mut queue = self
1070 .shared
1071 .queue
1072 .lock()
1073 .expect("callgraph refresh queue mutex poisoned");
1074 queue.shutdown_requested = true;
1075 self.shared.wake.notify_one();
1076 while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
1077 let remaining = deadline.saturating_duration_since(Instant::now());
1078 let (next, _) = self
1079 .shared
1080 .wake
1081 .wait_timeout(queue, remaining)
1082 .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
1083 queue = next;
1084 }
1085 let drained = queue.active.is_none() && queue.order.is_empty();
1086 if !drained {
1087 if let Some(active) = queue.active.as_ref() {
1088 active.defer();
1089 }
1090 for batch in queue.queued.values() {
1091 batch.defer();
1092 }
1093 queue.order.clear();
1094 queue.queued.clear();
1095 }
1096 drop(queue);
1097
1098 if drained {
1099 if let Some(thread) = self
1100 .thread
1101 .lock()
1102 .expect("callgraph refresh worker thread mutex poisoned")
1103 .take()
1104 {
1105 let _ = thread.join();
1106 }
1107 }
1108 drained
1109 }
1110}
1111
1112static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
1113
1114pub fn enqueue_callgraph_store_refresh(
1115 callgraph_dir: PathBuf,
1116 project_root: PathBuf,
1117 paths: Vec<PathBuf>,
1118 pending_sink: PendingCallGraphStorePaths,
1119) -> bool {
1120 enqueue_callgraph_store_refresh_inner(
1121 callgraph_dir,
1122 project_root,
1123 paths,
1124 pending_sink,
1125 Vec::new(),
1126 None,
1127 )
1128}
1129
1130#[cfg(test)]
1131pub(crate) fn enqueue_callgraph_store_refresh_fenced(
1132 callgraph_dir: PathBuf,
1133 project_root: PathBuf,
1134 paths: Vec<PathBuf>,
1135 pending_sink: PendingCallGraphStorePaths,
1136 ticket: CallgraphRefreshTicket,
1137) -> bool {
1138 enqueue_callgraph_store_refresh_inner(
1139 callgraph_dir,
1140 project_root,
1141 paths,
1142 pending_sink,
1143 Vec::new(),
1144 Some(ticket),
1145 )
1146}
1147
1148pub(crate) fn enqueue_callgraph_store_refresh_fenced_with_state(
1149 callgraph_dir: PathBuf,
1150 project_root: PathBuf,
1151 paths: Vec<PathBuf>,
1152 pending_sink: PendingCallGraphStorePaths,
1153 refresh_state: CallgraphRefreshState,
1154 ticket: CallgraphRefreshTicket,
1155) -> bool {
1156 enqueue_callgraph_store_refresh_inner(
1157 callgraph_dir,
1158 project_root,
1159 paths,
1160 pending_sink,
1161 vec![refresh_state],
1162 Some(ticket),
1163 )
1164}
1165
1166fn enqueue_callgraph_store_refresh_inner(
1167 callgraph_dir: PathBuf,
1168 project_root: PathBuf,
1169 paths: Vec<PathBuf>,
1170 pending_sink: PendingCallGraphStorePaths,
1171 refresh_states: Vec<CallgraphRefreshState>,
1172 ticket: Option<CallgraphRefreshTicket>,
1173) -> bool {
1174 if paths.is_empty() {
1175 return true;
1176 }
1177 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1178 let worker = {
1179 let mut worker = slot
1180 .lock()
1181 .expect("callgraph refresh worker mutex poisoned");
1182 Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
1183 };
1184 worker.enqueue(
1185 RefreshRoot {
1186 callgraph_dir,
1187 project_root,
1188 },
1189 paths,
1190 pending_sink,
1191 refresh_states,
1192 ticket,
1193 )
1194}
1195
1196pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
1197 flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
1198}
1199
1200#[doc(hidden)]
1201pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
1202 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1203 let worker = slot
1204 .lock()
1205 .expect("callgraph refresh worker mutex poisoned")
1206 .clone();
1207 let Some(worker) = worker else {
1208 return true;
1209 };
1210 let drained = worker.shutdown_with_budget(budget);
1211 if drained {
1212 let mut current = slot
1213 .lock()
1214 .expect("callgraph refresh worker mutex poisoned");
1215 if current
1216 .as_ref()
1217 .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
1218 {
1219 *current = None;
1220 }
1221 }
1222 drained
1223}
1224
1225fn idle_checkpoint_due(last: Option<Instant>, now: Instant) -> bool {
1226 last.is_none_or(|last| now.saturating_duration_since(last) >= REFRESH_IDLE_CHECKPOINT_INTERVAL)
1227}
1228
1229fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
1230 let mut workspace_crate_prefixes = HashMap::new();
1233 let mut last_idle_checkpoints: HashMap<RefreshRoot, Instant> = HashMap::new();
1234 loop {
1235 let batch = {
1236 let mut queue = shared
1237 .queue
1238 .lock()
1239 .expect("callgraph refresh queue mutex poisoned");
1240 loop {
1241 if let Some(root) = queue.order.pop_front() {
1242 let batch = queue
1243 .queued
1244 .remove(&root)
1245 .expect("queued callgraph refresh root has a batch");
1246 queue.active = Some(batch.clone());
1247 break batch;
1248 }
1249 if queue.shutdown_requested {
1250 return;
1251 }
1252 queue = shared
1253 .wake
1254 .wait(queue)
1255 .expect("callgraph refresh queue mutex poisoned while waiting");
1256 }
1257 };
1258
1259 let store = process_callgraph_refresh_batch(&batch, &mut workspace_crate_prefixes);
1260
1261 let mut queue = shared
1262 .queue
1263 .lock()
1264 .expect("callgraph refresh queue mutex poisoned");
1265 queue.active = None;
1266 let became_idle = queue.order.is_empty();
1267 shared.wake.notify_all();
1268 drop(queue);
1269
1270 if became_idle {
1271 let checkpoint_due = idle_checkpoint_due(
1272 last_idle_checkpoints.get(&batch.root).copied(),
1273 Instant::now(),
1274 );
1275 if checkpoint_due {
1276 if let Some(store) = store {
1277 if store.checkpoint_wal_truncate() {
1278 last_idle_checkpoints.insert(batch.root.clone(), Instant::now());
1279 }
1280 }
1281 }
1282 }
1283 }
1284}
1285
1286fn process_callgraph_refresh_batch(
1287 batch: &RefreshBatch,
1288 workspace_crate_prefixes: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1289) -> Option<CallGraphStore> {
1290 if batch
1294 .paths
1295 .iter()
1296 .any(|path| invalidates_workspace_crate_prefix_cache(path))
1297 {
1298 workspace_crate_prefixes.remove(&batch.root);
1299 }
1300
1301 let paths = batch
1302 .paths
1303 .iter()
1304 .filter(|path| crate::parser::detect_language(path).is_some())
1305 .cloned()
1306 .collect::<Vec<_>>();
1307 if paths.is_empty() {
1308 return None;
1309 }
1310 note_refresh_worker_batch_for_test(&batch.root.project_root);
1311 if batch
1312 .ticket
1313 .as_ref()
1314 .is_some_and(|ticket| !ticket.is_current())
1315 {
1316 batch.defer();
1319 return None;
1320 }
1321 let workspace_crate_prefix_cache =
1322 workspace_crate_prefix_cache_for_root(workspace_crate_prefixes, &batch.root);
1323 let _watchdog = RefreshWorkerWatchdog::start(&paths);
1324 let test_seam = refresh_worker_test_seam(&batch.root.project_root);
1325 note_refresh_worker_call_for_test(&batch.root.project_root);
1326 let opened = if test_seam.fail_open {
1327 Ok(None)
1328 } else {
1329 CallGraphStore::open_ready(
1330 batch.root.callgraph_dir.clone(),
1331 batch.root.project_root.clone(),
1332 )
1333 };
1334 if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
1335 let _ = gate.held_tx.send(());
1338 let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
1339 }
1340 let store = match opened {
1341 Ok(Some(store)) => store,
1342 Ok(None) => {
1343 batch.defer_after_open_failure();
1344 return None;
1345 }
1346 Err(error) => {
1347 batch.defer_after_open_failure();
1348 crate::slog_warn!(
1349 "callgraph store writer open failed during refresh; deferred paths: {}",
1350 error
1351 );
1352 return None;
1353 }
1354 };
1355 if !test_seam.delay.is_zero() {
1356 std::thread::sleep(test_seam.delay);
1357 }
1358 if batch
1359 .ticket
1360 .as_ref()
1361 .is_some_and(|ticket| !ticket.is_current())
1362 {
1363 batch.defer();
1366 return Some(store);
1367 }
1368 let refresh_result = if test_seam.fail_refresh {
1369 Err(CallGraphStoreError::Unavailable(
1370 "injected refresh worker failure".to_string(),
1371 ))
1372 } else if let Some(ticket) = &batch.ticket {
1373 with_publish_epoch(
1374 ticket.publish_epoch.clone(),
1375 ticket.expected_publish_epoch,
1376 || {
1377 with_refresh_commit_admission(
1378 ticket.lifecycle.clone(),
1379 Arc::clone(&ticket.generation_flag),
1380 ticket.expected_generation,
1381 || {
1382 store
1383 .refresh_files_with_workspace_crate_prefix_cache(
1384 &paths,
1385 workspace_crate_prefix_cache.clone(),
1386 )
1387 .map(|_| ())
1388 },
1389 )
1390 },
1391 )
1392 } else {
1393 store
1394 .refresh_files_with_workspace_crate_prefix_cache(
1395 &paths,
1396 workspace_crate_prefix_cache.clone(),
1397 )
1398 .map(|_| ())
1399 };
1400 if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
1401 batch.defer();
1405 return Some(store);
1406 }
1407 if let Err(error) = refresh_result {
1408 crate::slog_warn!("callgraph store refresh failed: {}", error);
1409 match store.mark_files_stale(&paths) {
1410 Ok(marked) => {
1411 note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
1412 crate::slog_warn!(
1413 "marked {} callgraph store file(s) stale after refresh failure",
1414 marked.len()
1415 );
1416 }
1417 Err(mark_error) => crate::slog_warn!(
1418 "failed to mark callgraph store files stale after refresh failure: {}",
1419 mark_error
1420 ),
1421 }
1422 } else {
1423 crate::logging::note_callgraph_invalidations(paths.len());
1424 }
1425 Some(store)
1426}
1427
1428fn workspace_crate_prefix_cache_for_root(
1429 caches: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1430 root: &RefreshRoot,
1431) -> WorkspaceCratePrefixCache {
1432 if !caches.contains_key(root) && caches.len() >= REFRESH_WORKSPACE_CACHE_ROOT_CAP {
1433 if let Some(evicted) = caches.keys().next().cloned() {
1435 caches.remove(&evicted);
1436 }
1437 }
1438 caches.entry(root.clone()).or_default().clone()
1439}
1440
1441#[derive(Clone, Copy, Default)]
1442struct RefreshWorkerTestSeam {
1443 delay: Duration,
1444 fail_refresh: bool,
1445 fail_open: bool,
1446 refresh_calls: usize,
1447 worker_calls: usize,
1448 stale_marks: usize,
1449}
1450
1451static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
1452 OnceLock::new();
1453
1454struct RefreshWorkerTestGate {
1455 held_tx: crossbeam_channel::Sender<()>,
1456 release_rx: crossbeam_channel::Receiver<()>,
1457}
1458
1459static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
1460 OnceLock::new();
1461
1462#[doc(hidden)]
1463pub fn install_callgraph_refresh_worker_test_gate(
1464 project_root: PathBuf,
1465) -> (
1466 crossbeam_channel::Receiver<()>,
1467 crossbeam_channel::Sender<()>,
1468) {
1469 let (held_tx, held_rx) = crossbeam_channel::bounded(1);
1470 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1471 REFRESH_WORKER_TEST_GATES
1472 .get_or_init(|| Mutex::new(HashMap::new()))
1473 .lock()
1474 .expect("callgraph refresh test gate mutex poisoned")
1475 .insert(
1476 project_root,
1477 RefreshWorkerTestGate {
1478 held_tx,
1479 release_rx,
1480 },
1481 );
1482 (held_rx, release_tx)
1483}
1484
1485fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
1486 REFRESH_WORKER_TEST_GATES
1487 .get_or_init(|| Mutex::new(HashMap::new()))
1488 .lock()
1489 .expect("callgraph refresh test gate mutex poisoned")
1490 .remove(project_root)
1491}
1492
1493fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
1494 let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
1495 return RefreshWorkerTestSeam::default();
1496 };
1497 seams
1498 .lock()
1499 .expect("callgraph refresh test seam mutex poisoned")
1500 .get(project_root)
1501 .copied()
1502 .unwrap_or_default()
1503}
1504
1505fn note_refresh_worker_batch_for_test(project_root: &Path) {
1506 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1507 if let Some(seam) = seams
1508 .lock()
1509 .expect("callgraph refresh test seam mutex poisoned")
1510 .get_mut(project_root)
1511 {
1512 seam.worker_calls += 1;
1513 }
1514 }
1515}
1516
1517fn note_refresh_worker_call_for_test(project_root: &Path) {
1518 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1519 if let Some(seam) = seams
1520 .lock()
1521 .expect("callgraph refresh test seam mutex poisoned")
1522 .get_mut(project_root)
1523 {
1524 seam.refresh_calls += 1;
1525 }
1526 }
1527}
1528
1529fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
1530 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1531 if let Some(seam) = seams
1532 .lock()
1533 .expect("callgraph refresh test seam mutex poisoned")
1534 .get_mut(project_root)
1535 {
1536 seam.stale_marks += 1;
1537 }
1538 }
1539}
1540
1541#[doc(hidden)]
1542pub fn set_callgraph_refresh_worker_test_seam(
1543 project_root: PathBuf,
1544 delay: Duration,
1545 fail_refresh: bool,
1546) {
1547 REFRESH_WORKER_TEST_SEAMS
1548 .get_or_init(|| Mutex::new(HashMap::new()))
1549 .lock()
1550 .expect("callgraph refresh test seam mutex poisoned")
1551 .insert(
1552 project_root,
1553 RefreshWorkerTestSeam {
1554 delay,
1555 fail_refresh,
1556 ..RefreshWorkerTestSeam::default()
1557 },
1558 );
1559}
1560
1561#[doc(hidden)]
1562pub fn set_callgraph_refresh_worker_test_open_failure(project_root: PathBuf, enabled: bool) {
1563 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1564 if let Some(seam) = seams
1565 .lock()
1566 .expect("callgraph refresh test seam mutex poisoned")
1567 .get_mut(&project_root)
1568 {
1569 seam.fail_open = enabled;
1570 }
1571 }
1572}
1573
1574#[doc(hidden)]
1575pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
1576 let seam = refresh_worker_test_seam(project_root);
1577 (seam.refresh_calls, seam.stale_marks)
1578}
1579
1580#[doc(hidden)]
1581pub fn callgraph_refresh_worker_test_worker_calls(project_root: &Path) -> usize {
1582 refresh_worker_test_seam(project_root).worker_calls
1583}
1584
1585#[doc(hidden)]
1586pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
1587 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1588 seams
1589 .lock()
1590 .expect("callgraph refresh test seam mutex poisoned")
1591 .remove(project_root);
1592 }
1593}
1594
1595#[derive(Debug)]
1596pub struct CallGraphStore {
1597 project_root: PathBuf,
1598 project_key: String,
1599 sqlite_path: PathBuf,
1603 publication_dir: PathBuf,
1607 legacy_fallback: bool,
1611 generation: Option<String>,
1616 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1617 read_marker: Option<crate::root_cache::ReadMarker>,
1618 database_ready: AtomicBool,
1621 write_metrics: Arc<CallgraphWriteMetrics>,
1622 conn: Mutex<Connection>,
1623}
1624
1625#[derive(Debug)]
1626pub struct ReadonlyCallGraphStore {
1627 inner: CallGraphStore,
1628}
1629
1630pub trait CallGraphRead {
1631 fn project_root(&self) -> &Path;
1632 fn project_key(&self) -> &str;
1633 fn sqlite_path(&self) -> &Path;
1634 fn is_current(&self) -> bool;
1635 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
1636 fn indexed_file_count(&self) -> Result<usize>;
1637 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
1638 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
1639 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
1640 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
1641 fn direct_callers_for_symbols(
1642 &self,
1643 targets: &[(String, String)],
1644 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
1645 targets
1646 .iter()
1647 .cloned()
1648 .map(|target| {
1649 let callers = self.direct_callers_of(Path::new(&target.0), &target.1)?;
1650 Ok((target, callers))
1651 })
1652 .collect()
1653 }
1654 fn direct_caller_counts_of(
1655 &self,
1656 targets: &[(String, String)],
1657 ) -> Result<HashMap<(String, String), usize>>;
1658 fn outgoing_calls_for_symbols(
1659 &self,
1660 sources: &[(String, String)],
1661 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
1662 fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
1663 -> Result<StoreCallersResult>;
1664 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
1665 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1666 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1667 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
1668 fn call_tree(
1669 &self,
1670 file_rel: &Path,
1671 symbol: &str,
1672 depth: usize,
1673 ) -> Result<callgraph::CallTreeNode>;
1674 fn trace_to(
1675 &self,
1676 file_rel: &Path,
1677 symbol: &str,
1678 max_depth: usize,
1679 ) -> Result<callgraph::TraceToResult>;
1680 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
1681 fn trace_to_symbol(
1682 &self,
1683 file_rel: &Path,
1684 symbol: &str,
1685 to_symbol: &str,
1686 to_file: Option<&Path>,
1687 max_depth: usize,
1688 ) -> Result<callgraph::TraceToSymbolResult>;
1689}
1690
1691#[derive(Debug, Clone, PartialEq, Eq)]
1692enum OpenRootRepair {
1693 None,
1694 ReRooted,
1695 NeedsRebuild {
1696 previous_roots: Vec<String>,
1697 current_root: String,
1698 reason: String,
1699 },
1700}
1701
1702struct OpenedStore {
1703 store: CallGraphStore,
1704 root_repair: OpenRootRepair,
1705}
1706
1707#[derive(Clone, Debug)]
1708struct LegacyCallgraphPartition {
1709 harness: String,
1710 dir: PathBuf,
1711 key: String,
1712 bytes: u64,
1713 freshness: Option<SystemTime>,
1714}
1715
1716#[derive(Clone, Debug)]
1717struct LegacyCallgraphTarget {
1718 partition: LegacyCallgraphPartition,
1719 sqlite_path: PathBuf,
1720 generation: Option<String>,
1721 source_bytes: u64,
1722 source_blake3: String,
1723}
1724
1725#[derive(Clone, Debug)]
1726struct SourceFingerprint {
1727 bytes: u64,
1728 blake3: String,
1729}
1730
1731#[derive(Clone, Debug)]
1732struct PublishedLegacyMigration {
1733 generation: String,
1734 migrated_bytes: u64,
1735}
1736
1737#[derive(Debug, Clone)]
1738pub struct ColdBuildStats {
1739 pub files: usize,
1740 pub nodes: usize,
1741 pub refs: usize,
1742 pub edges: usize,
1743 pub failed_files: Vec<String>,
1744 pub elapsed_ms: u128,
1745}
1746
1747#[derive(Debug, Clone)]
1748pub struct IncrementalStats {
1749 pub changed_files: Vec<String>,
1750 pub surface_changed: Vec<String>,
1751 pub deleted_files: Vec<String>,
1752 pub dependency_selected_refs: usize,
1753 pub refreshed_own_files: usize,
1754 pub unchanged_extract_files: usize,
1755}
1756
1757#[doc(hidden)]
1759#[derive(Debug, Clone, Default, PartialEq, Eq)]
1760pub struct RefreshFilesProfile {
1761 pub parse: Duration,
1762 pub dependency_selection: Duration,
1763 pub row_deletes: Duration,
1764 pub row_inserts: Duration,
1765 pub dependent_parse: Duration,
1766 pub index_load: Duration,
1767 pub ref_resolution: Duration,
1768 pub method_dispatch: Duration,
1769 pub commit: Duration,
1770 pub total: Duration,
1771}
1772
1773impl RefreshFilesProfile {
1774 pub fn report(&self) -> String {
1775 format!(
1776 "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",
1777 self.parse.as_millis(),
1778 self.dependency_selection.as_millis(),
1779 self.row_deletes.as_millis(),
1780 self.row_inserts.as_millis(),
1781 self.dependent_parse.as_millis(),
1782 self.index_load.as_millis(),
1783 self.ref_resolution.as_millis(),
1784 self.method_dispatch.as_millis(),
1785 self.commit.as_millis(),
1786 self.total.as_millis(),
1787 )
1788 }
1789}
1790
1791#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1792pub struct StoredEdge {
1793 pub source_file: String,
1794 pub source_symbol: String,
1795 pub target_file: String,
1796 pub target_symbol: String,
1797 pub kind: String,
1798 pub line: u32,
1799}
1800
1801#[derive(Debug, Clone, PartialEq, Eq)]
1802pub struct StoreNode {
1803 node_id: String,
1804 pub file: String,
1805 pub symbol: String,
1806 pub name: String,
1807 pub kind: String,
1808 pub line: u32,
1809 pub end_line: u32,
1810 pub signature: Option<String>,
1811 pub exported: bool,
1812 pub is_entry_point: bool,
1813 pub lang: LangId,
1814}
1815
1816#[cfg(test)]
1817impl StoreNode {
1818 pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
1819 Self {
1820 node_id: format!("{file}:{symbol}"),
1821 file: file.to_string(),
1822 symbol: symbol.to_string(),
1823 name: symbol.to_string(),
1824 kind: "function".to_string(),
1825 line: 1,
1826 end_line: 1,
1827 signature: None,
1828 exported: is_entry_point,
1829 is_entry_point,
1830 lang: LangId::TypeScript,
1831 }
1832 }
1833}
1834
1835#[derive(Debug, Clone, PartialEq, Eq)]
1836pub struct StoreCallSite {
1837 pub caller: StoreNode,
1838 pub target_file: String,
1839 pub target_symbol: String,
1840 pub target: Option<StoreNode>,
1841 pub line: u32,
1842 pub byte_start: usize,
1843 pub byte_end: usize,
1844 pub resolved: bool,
1845 pub provenance: String,
1846}
1847
1848impl StoreCallSite {
1849 pub fn approximate(&self) -> bool {
1850 self.provenance == PROVENANCE_NAME_MATCH
1851 }
1852
1853 pub fn resolved_by(&self) -> &str {
1854 &self.provenance
1855 }
1856
1857 pub fn supplemental_resolution(&self) -> Option<&str> {
1858 match self.provenance.as_str() {
1859 PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
1860 _ => None,
1861 }
1862 }
1863}
1864
1865#[derive(Debug, Clone, PartialEq, Eq)]
1866pub struct StoreUnresolvedCall {
1867 pub caller: StoreNode,
1868 pub symbol: String,
1869 pub full_ref: Option<String>,
1870 pub line: u32,
1871 pub byte_start: usize,
1872 pub byte_end: usize,
1873}
1874
1875#[derive(Debug, Clone, PartialEq, Eq)]
1876pub struct StoreCallersResult {
1877 pub target: StoreNode,
1878 pub callers: Vec<StoreCallSite>,
1879 pub scanned_files: usize,
1880 pub depth_limited: bool,
1881 pub truncated: usize,
1882}
1883
1884#[derive(Debug, Clone, PartialEq, Eq)]
1885pub struct StoreImpactCaller {
1886 pub site: StoreCallSite,
1887 pub signature: Option<String>,
1888 pub is_entry_point: bool,
1889 pub call_expression: Option<String>,
1890 pub parameters: Vec<String>,
1891}
1892
1893#[derive(Debug, Clone, PartialEq, Eq)]
1894pub struct StoreImpactResult {
1895 pub target: StoreNode,
1896 pub parameters: Vec<String>,
1897 pub callers: Vec<StoreImpactCaller>,
1898 pub depth_limited: bool,
1899 pub truncated: usize,
1900}
1901
1902#[derive(Debug, Clone)]
1903struct ExtractFailure {
1904 rel_path: String,
1905 freshness: Option<FileFreshness>,
1906}
1907
1908#[derive(Debug, Clone)]
1909struct BuildExtractsResult {
1910 extracts: Vec<FileExtract>,
1911 failures: Vec<ExtractFailure>,
1912}
1913
1914#[derive(Debug, Clone)]
1915enum StoreForwardCall {
1916 Resolved(StoreCallSite),
1917 Unresolved(StoreUnresolvedCall),
1918}
1919
1920impl StoreForwardCall {
1921 fn byte_start(&self) -> usize {
1922 match self {
1923 Self::Resolved(site) => site.byte_start,
1924 Self::Unresolved(call) => call.byte_start,
1925 }
1926 }
1927
1928 fn line(&self) -> u32 {
1929 match self {
1930 Self::Resolved(site) => site.line,
1931 Self::Unresolved(call) => call.line,
1932 }
1933 }
1934}
1935
1936#[derive(Debug, Clone)]
1937struct FileExtract {
1938 rel_path: String,
1939 freshness: FileFreshness,
1940 lang: LangId,
1941 data: FileCallData,
1942 nodes: Vec<NodeRecord>,
1943 raw_refs: Vec<RawRef>,
1944 dispatch_hints: Vec<DispatchHint>,
1945 surface_fingerprint: String,
1946}
1947
1948#[derive(Debug, Clone)]
1949struct NodeRecord {
1950 id: String,
1951 file_path: String,
1952 name: String,
1953 scoped_name: String,
1954 kind: String,
1955 range: Range,
1956 range_ordinal: u32,
1957 signature: Option<String>,
1958 exported: bool,
1959 is_default_export: bool,
1960 is_type_like: bool,
1961 is_callgraph_entry_point: bool,
1962}
1963
1964#[derive(Debug, Clone)]
1965struct RawRef {
1966 ref_id: String,
1967 caller_node: Option<String>,
1968 caller_symbol: Option<String>,
1969 caller_file: String,
1970 kind: String,
1971 short_name: Option<String>,
1972 full_ref: Option<String>,
1973 module_path: Option<String>,
1974 import_kind: Option<String>,
1975 local_name: Option<String>,
1976 requested_name: Option<String>,
1977 namespace_alias: Option<String>,
1978 wildcard: bool,
1979 line: u32,
1980 byte_start: usize,
1981 byte_end: usize,
1982 dependencies: BTreeSet<String>,
1983}
1984
1985#[derive(Debug)]
1989struct StagedRef {
1990 rowid: u64,
1991 raw: RawRef,
1992}
1993
1994#[derive(Debug, Clone)]
1995struct ResolvedRef {
1996 raw: RawRef,
1997 status: String,
1998 target_node: Option<String>,
1999 target_file: Option<String>,
2000 target_symbol: Option<String>,
2001 dependencies: BTreeSet<String>,
2002 edge: Option<EdgeRecord>,
2003}
2004
2005#[derive(Debug, Clone)]
2006struct EdgeRecord {
2007 edge_id: String,
2008 source_node: String,
2009 target_node: Option<String>,
2010 target_file: String,
2011 target_symbol: String,
2012 kind: String,
2013 line: u32,
2014}
2015
2016#[derive(Debug, Clone)]
2017struct DispatchHint {
2018 id: String,
2019 method_name: String,
2020 caller_node: String,
2021 file: String,
2022 line: u32,
2023 byte_start: usize,
2024 byte_end: usize,
2025}
2026
2027#[derive(Debug, Clone)]
2028struct NameMatchRef {
2029 ref_id: String,
2030 caller_node: String,
2031 caller_file: String,
2032 caller_symbol: String,
2033 caller_signature: Option<String>,
2034 receiver_expression: String,
2035 receiver: String,
2036 method_name: String,
2037 colon_dispatch: bool,
2038 line: u32,
2039 lang: String,
2040}
2041
2042#[derive(Debug, Clone)]
2043struct NameMatchCandidate {
2044 node_id: String,
2045 file_path: String,
2046 scoped_name: String,
2047 kind: String,
2048 start_line: u32,
2050}
2051
2052#[derive(Debug, Clone)]
2053struct FileRow {
2054 surface_fingerprint: String,
2055 freshness: FileFreshness,
2056}
2057
2058#[derive(Debug, Clone)]
2059struct DbFileIndex {
2060 lang: Option<LangId>,
2061 exports: HashSet<String>,
2062 default_export: Option<String>,
2063 export_aliases: HashMap<String, String>,
2064 node_by_scoped: HashMap<String, String>,
2065 node_by_bare: HashMap<String, String>,
2066 node_kind_by_id: HashMap<String, String>,
2067 module_targets: HashMap<String, Option<String>>,
2068 reexports: Vec<ReexportIndex>,
2069}
2070
2071#[derive(Debug, Clone)]
2072struct ReexportIndex {
2073 target_file: Option<String>,
2074 named: HashMap<String, String>,
2075 wildcard: bool,
2076}
2077
2078#[derive(Debug, Clone)]
2079struct ProjectIndex<'a> {
2080 project_root: PathBuf,
2081 files: HashMap<String, DbFileIndex>,
2082 caller_data: HashMap<String, &'a FileCallData>,
2083 workspace_crate_prefixes: WorkspaceCratePrefixCache,
2088}
2089
2090trait ResolverIndex {
2094 fn caller_data(&self, file: &str) -> Option<&FileCallData>;
2095 fn lang_for(&self, file: &str) -> Option<LangId>;
2096 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String>;
2097 fn reexports_for(&self, file: &str) -> Vec<ReexportIndex>;
2098 fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String>;
2099 fn node_is_callable(&self, file: &str, node_id: &str) -> bool;
2100 fn export_alias(&self, file: &str, symbol: &str) -> Option<String>;
2101 fn has_export(&self, file: &str, symbol: &str) -> bool;
2102 fn default_export(&self, file: &str) -> Option<String>;
2103 fn contains_file(&self, file: &str) -> bool;
2104 fn crate_src_prefix(&self, crate_name: &str) -> Option<String>;
2105 fn inline_scoped_target(
2106 &self,
2107 caller_file: &str,
2108 module_segments: &[String],
2109 short_name: &str,
2110 ) -> Option<(String, String)>;
2111}
2112
2113impl ResolverIndex for ProjectIndex<'_> {
2114 fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2115 self.caller_data.get(file).copied()
2116 }
2117
2118 fn lang_for(&self, file: &str) -> Option<LangId> {
2119 self.lang_for(file)
2120 }
2121
2122 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2123 self.module_target(caller_file, module_path)
2124 }
2125
2126 fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2127 self.reexports_for(file).to_vec()
2128 }
2129
2130 fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2131 self.node_for_symbol(file, symbol)
2132 }
2133
2134 fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2135 self.node_is_callable(file, node_id)
2136 }
2137
2138 fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2139 self.files
2140 .get(file)
2141 .and_then(|item| item.export_aliases.get(symbol))
2142 .cloned()
2143 }
2144
2145 fn has_export(&self, file: &str, symbol: &str) -> bool {
2146 self.files
2147 .get(file)
2148 .is_some_and(|item| item.exports.contains(symbol))
2149 }
2150
2151 fn default_export(&self, file: &str) -> Option<String> {
2152 self.files
2153 .get(file)
2154 .and_then(|item| item.default_export.clone())
2155 }
2156
2157 fn contains_file(&self, file: &str) -> bool {
2158 self.files.contains_key(file)
2159 }
2160
2161 fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2162 self.workspace_crate_prefixes
2163 .0
2164 .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
2165 .get(crate_name)
2166 .cloned()
2167 }
2168
2169 fn inline_scoped_target(
2170 &self,
2171 caller_file: &str,
2172 module_segments: &[String],
2173 short_name: &str,
2174 ) -> Option<(String, String)> {
2175 let src_prefix = rust_src_prefix(caller_file);
2176 let mut file_paths = self.files.keys().cloned().collect::<Vec<_>>();
2177 file_paths.sort();
2178 if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
2179 let caller = file_paths.remove(position);
2180 file_paths.insert(0, caller);
2181 }
2182 for file_path in file_paths {
2183 if self.lang_for(&file_path) != Some(LangId::Rust)
2184 || rust_src_prefix(&file_path) != src_prefix
2185 {
2186 continue;
2187 }
2188 let file_module_segments = rust_module_segments_for_rel(&file_path);
2189 if !module_segments.starts_with(&file_module_segments) {
2190 continue;
2191 }
2192 let scoped_segments = &module_segments[file_module_segments.len()..];
2193 if scoped_segments.is_empty() {
2194 continue;
2195 }
2196 let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2197 if self.node_for_symbol(&file_path, &scoped_symbol).is_some() {
2198 return Some((file_path, scoped_symbol));
2199 }
2200 }
2201 None
2202 }
2203}
2204
2205struct DiskProjectIndex<'a> {
2209 project_root: &'a Path,
2210 conn: &'a Connection,
2211 caller_file: &'a str,
2212 caller_data: &'a FileCallData,
2213 workspace_crate_prefixes: WorkspaceCratePrefixCache,
2214}
2215
2216impl DiskProjectIndex<'_> {
2217 fn file_index(&self, rel_path: &str) -> Option<DbFileIndex> {
2218 let lang: String = self
2219 .conn
2220 .query_row(
2221 "SELECT lang FROM files WHERE path = ?1",
2222 params![rel_path],
2223 |row| row.get(0),
2224 )
2225 .optional()
2226 .ok()??;
2227 let mut index = DbFileIndex {
2228 lang: lang_from_label(&lang),
2229 exports: HashSet::new(),
2230 default_export: None,
2231 export_aliases: HashMap::new(),
2232 node_by_scoped: HashMap::new(),
2233 node_by_bare: HashMap::new(),
2234 node_kind_by_id: HashMap::new(),
2235 module_targets: HashMap::new(),
2236 reexports: Vec::new(),
2237 };
2238 let mut nodes = self
2239 .conn
2240 .prepare(
2241 "SELECT id, name, scoped_name, kind, exported, is_default_export
2242 FROM nodes WHERE file_path = ?1",
2243 )
2244 .ok()?;
2245 let rows = nodes
2246 .query_map(params![rel_path], |row| {
2247 Ok((
2248 row.get::<_, String>(0)?,
2249 row.get::<_, String>(1)?,
2250 row.get::<_, String>(2)?,
2251 row.get::<_, String>(3)?,
2252 row.get::<_, i64>(4)? != 0,
2253 row.get::<_, i64>(5)? != 0,
2254 ))
2255 })
2256 .ok()?
2257 .collect::<std::result::Result<Vec<_>, _>>()
2258 .ok()?;
2259 drop(nodes);
2260 for (id, name, scoped_name, kind, exported, is_default_export) in rows {
2261 if exported {
2262 index.exports.insert(name.clone());
2263 index.exports.insert(scoped_name.clone());
2264 }
2265 if is_default_export {
2266 index.default_export = Some(scoped_name.clone());
2267 }
2268 index.node_by_scoped.insert(scoped_name, id.clone());
2269 index.node_by_bare.entry(name).or_insert(id.clone());
2270 index.node_kind_by_id.insert(id, kind);
2271 }
2272
2273 let mut refs = self
2274 .conn
2275 .prepare(
2276 "SELECT ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name
2277 FROM refs
2278 WHERE caller_file = ?1 AND kind IN ('import', 'reexport', 'export_alias')",
2279 )
2280 .ok()?;
2281 let rows = refs
2282 .query_map(params![rel_path], |row| {
2283 Ok((
2284 row.get::<_, String>(0)?,
2285 row.get::<_, String>(1)?,
2286 row.get::<_, Option<String>>(2)?,
2287 row.get::<_, Option<String>>(3)?,
2288 row.get::<_, i64>(4)? != 0,
2289 row.get::<_, Option<String>>(5)?,
2290 row.get::<_, Option<String>>(6)?,
2291 ))
2292 })
2293 .ok()?
2294 .collect::<std::result::Result<Vec<_>, _>>()
2295 .ok()?;
2296 drop(refs);
2297 for (ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name) in rows {
2298 if kind == "export_alias" {
2299 if let (Some(exported), Some(source)) = (local_name, requested_name) {
2300 index.export_aliases.insert(exported, source);
2301 }
2302 continue;
2303 }
2304 let Some(module_path) = module_path else {
2305 continue;
2306 };
2307 let target_file = self.disk_module_target(rel_path, &module_path).or_else(|| {
2308 self.conn
2309 .query_row(
2310 "SELECT d.dep_file
2311 FROM file_dependencies d
2312 JOIN files f ON f.path = d.dep_file
2313 WHERE d.file_path = ?1
2314 ORDER BY d.dep_file
2315 LIMIT 1",
2316 params![rel_path],
2317 |row| row.get::<_, String>(0),
2318 )
2319 .optional()
2320 .ok()
2321 .flatten()
2322 });
2323 index
2324 .module_targets
2325 .entry(module_path.clone())
2326 .or_insert_with(|| target_file.clone());
2327 if kind == "reexport" {
2328 let raw = RawRef {
2329 ref_id,
2330 caller_node: None,
2331 caller_symbol: None,
2332 caller_file: rel_path.to_string(),
2333 kind,
2334 short_name: None,
2335 full_ref,
2336 module_path: Some(module_path),
2337 import_kind: Some("reexport".to_string()),
2338 local_name: None,
2339 requested_name: None,
2340 namespace_alias: None,
2341 wildcard,
2342 line: 0,
2343 byte_start: 0,
2344 byte_end: 0,
2345 dependencies: BTreeSet::new(),
2346 };
2347 index
2348 .reexports
2349 .push(reexport_index_from_raw(&raw, target_file));
2350 }
2351 }
2352 Some(index)
2353 }
2354
2355 fn disk_module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2356 let caller_dir = self.project_root.join(caller_file).parent()?.to_path_buf();
2357 let candidate = callgraph::resolve_module_path(&caller_dir, module_path)?;
2358 let rel_path = relative_path(self.project_root, &canonicalize_path(&candidate));
2359 self.contains_file(&rel_path).then_some(rel_path)
2360 }
2361}
2362
2363impl ResolverIndex for DiskProjectIndex<'_> {
2364 fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2365 (file == self.caller_file).then_some(self.caller_data)
2366 }
2367
2368 fn lang_for(&self, file: &str) -> Option<LangId> {
2369 self.file_index(file).and_then(|index| index.lang)
2370 }
2371
2372 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2373 self.file_index(caller_file)
2374 .and_then(|index| index.module_targets.get(module_path).cloned().flatten())
2375 }
2376
2377 fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2378 self.file_index(file)
2379 .map(|index| index.reexports)
2380 .unwrap_or_default()
2381 }
2382
2383 fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2384 self.file_index(file).and_then(|index| {
2385 index
2386 .node_by_scoped
2387 .get(symbol)
2388 .cloned()
2389 .or_else(|| index.node_by_bare.get(symbol).cloned())
2390 })
2391 }
2392
2393 fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2394 self.file_index(file)
2395 .and_then(|index| index.node_kind_by_id.get(node_id).cloned())
2396 .is_some_and(|kind| matches!(kind.as_str(), "function" | "method"))
2397 }
2398
2399 fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2400 self.file_index(file)
2401 .and_then(|index| index.export_aliases.get(symbol).cloned())
2402 }
2403
2404 fn has_export(&self, file: &str, symbol: &str) -> bool {
2405 self.file_index(file)
2406 .is_some_and(|index| index.exports.contains(symbol))
2407 }
2408
2409 fn default_export(&self, file: &str) -> Option<String> {
2410 self.file_index(file).and_then(|index| index.default_export)
2411 }
2412
2413 fn contains_file(&self, file: &str) -> bool {
2414 self.conn
2415 .query_row(
2416 "SELECT 1 FROM files WHERE path = ?1 LIMIT 1",
2417 params![file],
2418 |_| Ok(()),
2419 )
2420 .is_ok()
2421 }
2422
2423 fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2424 self.workspace_crate_prefixes
2425 .0
2426 .get_or_init(|| build_workspace_crate_prefixes(self.project_root))
2427 .get(crate_name)
2428 .cloned()
2429 }
2430
2431 fn inline_scoped_target(
2432 &self,
2433 caller_file: &str,
2434 module_segments: &[String],
2435 short_name: &str,
2436 ) -> Option<(String, String)> {
2437 let src_prefix = rust_src_prefix(caller_file);
2438 let check = |file_path: String| {
2439 let file_module_segments = rust_module_segments_for_rel(&file_path);
2440 if rust_src_prefix(&file_path) != src_prefix
2441 || !module_segments.starts_with(&file_module_segments)
2442 {
2443 return None;
2444 }
2445 let scoped_segments = &module_segments[file_module_segments.len()..];
2446 if scoped_segments.is_empty() {
2447 return None;
2448 }
2449 let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2450 self.node_for_symbol(&file_path, &scoped_symbol)
2451 .map(|_| (file_path, scoped_symbol))
2452 };
2453 if let Some(target) = check(caller_file.to_string()) {
2454 return Some(target);
2455 }
2456 let mut statement = self
2457 .conn
2458 .prepare("SELECT path FROM files WHERE lang = 'rust' AND path <> ?1 ORDER BY path")
2459 .ok()?;
2460 let rows = statement
2461 .query_map(params![caller_file], |row| row.get::<_, String>(0))
2462 .ok()?;
2463 for path in rows.flatten() {
2464 if let Some(target) = check(path) {
2465 return Some(target);
2466 }
2467 }
2468 None
2469 }
2470}
2471
2472impl CallGraphStore {
2473 pub fn open_if_enabled(
2474 options: CallGraphStoreOptions,
2475 callgraph_dir: PathBuf,
2476 project_root: PathBuf,
2477 ) -> Result<Option<Self>> {
2478 if !options.enabled {
2479 return Ok(None);
2480 }
2481 Self::open(callgraph_dir, project_root).map(Some)
2482 }
2483
2484 pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
2485 let project_key = crate::search_index::artifact_cache_key(&project_root);
2486 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2487 else {
2488 return Err(CallGraphStoreError::Unavailable(
2489 "writer capability denied; use the read-only callgraph opener".to_string(),
2490 ));
2491 };
2492 std::fs::create_dir_all(&callgraph_dir)?;
2493 let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
2497 .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
2498 let OpenedStore { store, root_repair } = Self::open_at_path(
2499 project_root.clone(),
2500 project_key,
2501 sqlite_path,
2502 generation,
2503 true,
2504 Some(Arc::clone(&writer_lease)),
2505 None,
2506 )?;
2507 match root_repair {
2508 OpenRootRepair::NeedsRebuild { .. } => {
2509 log_root_repair_rebuild(&root_repair);
2510 drop(store);
2511 drop(writer_lease);
2512 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2513 let (store, _stats) =
2514 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2515 Ok(store)
2516 }
2517 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
2518 }
2519 }
2520
2521 pub fn open_readonly(
2522 callgraph_dir: PathBuf,
2523 project_root: PathBuf,
2524 ) -> Result<Option<ReadonlyCallGraphStore>> {
2525 let project_key = crate::search_index::artifact_cache_key(&project_root);
2526 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2527 {
2528 let conn = open_readonly_connection(&sqlite_path)?;
2529 if !database_ready(&conn).unwrap_or(false) {
2530 return Ok(None);
2531 }
2532 let marker_label = generation.as_deref().unwrap_or("legacy");
2533 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
2534 return Ok(Some(ReadonlyCallGraphStore::from_inner(
2535 Self::from_connection(
2536 project_root,
2537 project_key,
2538 sqlite_path,
2539 callgraph_dir,
2540 false,
2541 generation,
2542 None,
2543 Some(read_marker),
2544 conn,
2545 ),
2546 )));
2547 }
2548
2549 let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
2550 return Ok(None);
2551 };
2552 crate::slog_warn!(
2553 "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
2554 target.partition.harness,
2555 target.sqlite_path.display()
2556 );
2557 let conn = open_readonly_connection(&target.sqlite_path)?;
2558 if !database_ready(&conn).unwrap_or(false) {
2559 return Ok(None);
2560 }
2561 let marker_label =
2562 legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
2563 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
2564 Ok(Some(ReadonlyCallGraphStore::from_inner(
2565 Self::from_connection(
2566 project_root,
2567 project_key,
2568 target.sqlite_path,
2569 callgraph_dir,
2570 true,
2571 target.generation,
2572 None,
2573 Some(read_marker),
2574 conn,
2575 ),
2576 )))
2577 }
2578
2579 pub fn open_ready_repairing(
2585 callgraph_dir: PathBuf,
2586 project_root: PathBuf,
2587 ) -> Result<Option<Self>> {
2588 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true)
2589 }
2590
2591 pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
2595 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false)
2596 }
2597
2598 pub fn open_ready_no_rebuild(
2599 callgraph_dir: PathBuf,
2600 project_root: PathBuf,
2601 ) -> Result<Option<Self>> {
2602 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true)
2603 }
2604
2605 fn open_ready_with_rebuild_policy(
2606 callgraph_dir: PathBuf,
2607 project_root: PathBuf,
2608 allow_cold_build: bool,
2609 allow_root_repair: bool,
2610 ) -> Result<Option<Self>> {
2611 let project_key = crate::search_index::artifact_cache_key(&project_root);
2612 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2613 else {
2614 return Ok(None);
2615 };
2616 let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2617 else {
2618 return Ok(None);
2619 };
2620 let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
2621 project_root.clone(),
2622 project_key.clone(),
2623 sqlite_path,
2624 generation,
2625 true,
2626 Some(Arc::clone(&writer_lease)),
2627 None,
2628 allow_root_repair,
2629 )?;
2630 match root_repair {
2631 OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
2632 log_root_repair_rebuild(&root_repair);
2633 drop(store);
2634 drop(writer_lease);
2635 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2636 let (store, _stats) =
2637 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2638 Ok(Some(store))
2639 }
2640 OpenRootRepair::NeedsRebuild { .. } => {
2641 if let Some(message) = note_repair_entry(&project_key) {
2642 crate::slog_warn!("{message}");
2643 }
2644 Ok(None)
2645 }
2646 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2647 }
2648 }
2649
2650 pub fn cold_build_with_lease(
2651 callgraph_dir: PathBuf,
2652 project_root: PathBuf,
2653 files: &[PathBuf],
2654 ) -> Result<(Self, ColdBuildStats)> {
2655 Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
2656 }
2657
2658 pub fn cold_build_with_lease_chunked(
2659 callgraph_dir: PathBuf,
2660 project_root: PathBuf,
2661 files: &[PathBuf],
2662 chunk_size: usize,
2663 ) -> Result<(Self, ColdBuildStats)> {
2664 Self::cold_build_with_lease_chunked_inner(
2665 callgraph_dir,
2666 project_root,
2667 files,
2668 chunk_size,
2669 false,
2670 )
2671 }
2672
2673 pub(crate) fn force_cold_build_with_lease_chunked(
2674 callgraph_dir: PathBuf,
2675 project_root: PathBuf,
2676 files: &[PathBuf],
2677 chunk_size: usize,
2678 ) -> Result<(Self, ColdBuildStats)> {
2679 Self::cold_build_with_lease_chunked_inner(
2680 callgraph_dir,
2681 project_root,
2682 files,
2683 chunk_size,
2684 true,
2685 )
2686 }
2687
2688 fn cold_build_with_lease_chunked_inner(
2689 callgraph_dir: PathBuf,
2690 project_root: PathBuf,
2691 files: &[PathBuf],
2692 chunk_size: usize,
2693 require_new_publication: bool,
2694 ) -> Result<(Self, ColdBuildStats)> {
2695 let project_key = crate::search_index::artifact_cache_key(&project_root);
2696 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2697 else {
2698 let operation = if require_new_publication {
2699 "forced rebuild"
2700 } else {
2701 "cold build"
2702 };
2703 return Err(CallGraphStoreError::Unavailable(format!(
2704 "{operation} could not acquire writer capability"
2705 )));
2706 };
2707 std::fs::create_dir_all(&callgraph_dir)?;
2708 let (stats, generation) = Self::cold_build_publish_locked(
2709 &callgraph_dir,
2710 &project_root,
2711 &project_key,
2712 files,
2713 chunk_size,
2714 Arc::clone(&writer_lease),
2715 )?;
2716 let store = Self::open_generation(
2717 &callgraph_dir,
2718 project_root,
2719 project_key,
2720 generation,
2721 writer_lease,
2722 )?;
2723 Ok((store, stats))
2724 }
2725
2726 pub fn ensure_built_with_lease(
2727 callgraph_dir: PathBuf,
2728 project_root: PathBuf,
2729 files: &[PathBuf],
2730 ) -> Result<(Self, Option<ColdBuildStats>)> {
2731 Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
2732 }
2733
2734 pub fn ensure_built_with_lease_chunked(
2735 callgraph_dir: PathBuf,
2736 project_root: PathBuf,
2737 files: &[PathBuf],
2738 chunk_size: usize,
2739 ) -> Result<(Self, Option<ColdBuildStats>)> {
2740 let project_key = crate::search_index::artifact_cache_key(&project_root);
2741 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2742 else {
2743 return Err(CallGraphStoreError::Unavailable(
2744 "callgraph ensure could not acquire writer capability".to_string(),
2745 ));
2746 };
2747 std::fs::create_dir_all(&callgraph_dir)?;
2748 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2749 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2756 {
2757 let OpenedStore { store, root_repair } = Self::open_at_path(
2758 project_root.clone(),
2759 project_key.clone(),
2760 sqlite_path,
2761 generation,
2762 true,
2763 Some(Arc::clone(&writer_lease)),
2764 None,
2765 )?;
2766 match root_repair {
2767 OpenRootRepair::NeedsRebuild { .. } => {
2768 log_root_repair_rebuild(&root_repair);
2769 drop(store);
2770 let (stats, generation) = Self::cold_build_publish_locked(
2771 &callgraph_dir,
2772 &project_root,
2773 &project_key,
2774 files,
2775 chunk_size,
2776 Arc::clone(&writer_lease),
2777 )?;
2778 let store = Self::open_generation(
2779 &callgraph_dir,
2780 project_root,
2781 project_key,
2782 generation,
2783 writer_lease,
2784 )?;
2785 return Ok((store, Some(stats)));
2786 }
2787 OpenRootRepair::None | OpenRootRepair::ReRooted => {
2788 return Ok((store, None));
2789 }
2790 }
2791 }
2792 if let Some(store) = try_legacy_migration_or_fallback(
2793 &callgraph_dir,
2794 &project_root,
2795 &project_key,
2796 Arc::clone(&writer_lease),
2797 )? {
2798 return Ok((store, None));
2799 }
2800 let (stats, generation) = Self::cold_build_publish_locked(
2801 &callgraph_dir,
2802 &project_root,
2803 &project_key,
2804 files,
2805 chunk_size,
2806 Arc::clone(&writer_lease),
2807 )?;
2808 let store = Self::open_generation(
2809 &callgraph_dir,
2810 project_root,
2811 project_key,
2812 generation,
2813 writer_lease,
2814 )?;
2815 Ok((store, Some(stats)))
2816 }
2817
2818 pub fn migrate_legacy_with_lease(
2825 callgraph_dir: PathBuf,
2826 project_root: PathBuf,
2827 ) -> Result<Option<Self>> {
2828 let project_key = crate::search_index::artifact_cache_key(&project_root);
2829 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2830 else {
2831 return Ok(None);
2832 };
2833 std::fs::create_dir_all(&callgraph_dir)?;
2834 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2835
2836 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2840 {
2841 let OpenedStore { store, root_repair } = Self::open_at_path(
2842 project_root,
2843 project_key,
2844 sqlite_path,
2845 generation,
2846 true,
2847 Some(writer_lease),
2848 None,
2849 )?;
2850 return match root_repair {
2851 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2852 OpenRootRepair::NeedsRebuild { reason, .. } => {
2853 Err(CallGraphStoreError::Unavailable(format!(
2854 "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
2855 )))
2856 }
2857 };
2858 }
2859
2860 let store = try_legacy_migration_or_fallback(
2861 &callgraph_dir,
2862 &project_root,
2863 &project_key,
2864 writer_lease,
2865 )?;
2866 Ok(store.filter(|store| !store.is_legacy_fallback()))
2870 }
2871
2872 fn cold_build_publish_locked(
2883 callgraph_dir: &Path,
2884 project_root: &Path,
2885 project_key: &str,
2886 files: &[PathBuf],
2887 chunk_size: usize,
2888 writer_lease: Arc<crate::root_cache::WriterLease>,
2889 ) -> Result<(ColdBuildStats, String)> {
2890 if let Some((previous_root, remaining)) =
2891 rebuild_cooldown_denial(callgraph_dir, project_key, project_root, Instant::now())
2892 {
2893 return Err(CallGraphStoreError::Unavailable(format!(
2894 "cache key {project_key} was rebuilt for {} too recently; retry {} ms after the per-key cooldown",
2895 previous_root.display(),
2896 remaining.as_millis()
2897 )));
2898 }
2899 let breaker = crate::build_breaker::BuildDeathBreaker::open(
2900 callgraph_dir.join("build-breaker.sqlite"),
2901 )
2902 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
2903
2904 let generation = generation_file_name(project_key);
2905 let gen_path = callgraph_dir.join(&generation);
2906 let temp_path = callgraph_dir.join(format!("{project_key}.staging.sqlite.tmp.resume"));
2910 let adopting_staging = temp_path.exists();
2911 if !adopting_staging {
2912 remove_sqlite_file_set(&temp_path);
2913 }
2914
2915 let (stats, breaker_key) = {
2916 if adopting_staging {
2917 crate::slog_info!(
2918 "resuming callgraph cold build from staged generation {}",
2919 temp_path.display()
2920 );
2921 }
2922 let temp_store = Self::open_at_path(
2923 project_root.to_path_buf(),
2924 project_key.to_string(),
2925 temp_path.clone(),
2926 None,
2927 false,
2928 Some(Arc::clone(&writer_lease)),
2929 None,
2930 )?
2931 .store;
2932 let admission_fingerprint = corpus_fingerprint_for(project_root, files)?;
2943 let breaker_key = crate::build_breaker::BreakerKey::new(
2944 project_root.display().to_string(),
2945 crate::build_breaker::BuildDomain::CallgraphCold,
2946 admission_fingerprint,
2947 );
2948 match breaker
2949 .admit(&breaker_key, 0)
2950 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?
2951 {
2952 crate::build_breaker::BreakerAdmission::Admitted(_) => {}
2953 crate::build_breaker::BreakerAdmission::Suspended(suspension) => {
2954 return Err(CallGraphStoreError::Suspended(suspension));
2955 }
2956 }
2957 let corpus_fingerprint = temp_store.stage_cold_build_file_inventory(files)?;
2958 let stats = temp_store
2959 .cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)?;
2960 let _ = temp_store.checkpoint_wal_truncate();
2961 temp_store.prepare_for_atomic_swap()?;
2962 (stats, breaker_key)
2963 };
2964
2965 notify_cold_build_before_publish_observer();
2966 let publication = publish_if_current(|| {
2967 verify_writer_lease(&writer_lease)?;
2968 remove_sqlite_file_set(&gen_path);
2971 crate::fs_lock::rename_over(&temp_path, &gen_path)?;
2972 crate::fs_lock::sync_parent(&gen_path);
2973 remove_sqlite_sidecars(&gen_path);
2974
2975 notify_cold_build_swap_observer(&temp_path, &gen_path);
2976
2977 verify_writer_lease(&writer_lease)?;
2979 publish_pointer(callgraph_dir, project_key, &generation)?;
2980 gc_old_generations(callgraph_dir, project_key, &generation);
2981 sweep_orphaned_build_temps_store_wide(callgraph_dir);
2985 if let Some(storage_root) = root_storage_dir(callgraph_dir) {
2986 let inspect_root =
2987 storage_root.join(crate::root_cache::RootCacheDomain::Inspect.as_str());
2988 let live_scope_keys = crate::root_cache::live_scope_keys_for_storage(&storage_root);
2989 crate::inspect::cache::sweep_inspect_scope_dirs(&inspect_root, &live_scope_keys);
2990 }
2991 Ok(())
2992 });
2993 if matches!(publication, Err(CallGraphStoreError::Superseded)) {
2994 remove_sqlite_file_set(&temp_path);
2995 }
2996 publication?;
2997 breaker
3000 .record_ready_publication(&breaker_key)
3001 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
3002 record_successful_rebuild(callgraph_dir, project_key, project_root, Instant::now());
3003 Ok((stats, generation))
3004 }
3005
3006 fn open_generation(
3009 callgraph_dir: &Path,
3010 project_root: PathBuf,
3011 project_key: String,
3012 generation: String,
3013 writer_lease: Arc<crate::root_cache::WriterLease>,
3014 ) -> Result<Self> {
3015 let gen_path = callgraph_dir.join(&generation);
3016 Ok(Self::open_at_path(
3017 project_root,
3018 project_key,
3019 gen_path,
3020 Some(generation),
3021 true,
3022 Some(writer_lease),
3023 None,
3024 )?
3025 .store)
3026 }
3027
3028 pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
3029 let project_key = crate::search_index::artifact_cache_key(project_root);
3030 Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
3033 }
3034
3035 pub fn cold_build_suspension(
3040 callgraph_dir: &Path,
3041 project_root: &Path,
3042 ) -> Result<Option<crate::build_breaker::BuildSuspension>> {
3043 let breaker_path = callgraph_dir.join("build-breaker.sqlite");
3044 if !breaker_path.exists() {
3045 return Ok(None);
3046 }
3047 let key = crate::build_breaker::BreakerKey::new(
3048 project_root.display().to_string(),
3049 crate::build_breaker::BuildDomain::CallgraphCold,
3050 callgraph_corpus_fingerprint(project_root)?,
3051 );
3052 crate::build_breaker::BuildDeathBreaker::open(breaker_path)
3053 .and_then(|breaker| breaker.suspension(&key))
3054 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))
3055 }
3056
3057 fn open_at_path(
3058 project_root: PathBuf,
3059 project_key: String,
3060 sqlite_path: PathBuf,
3061 generation: Option<String>,
3062 use_wal: bool,
3063 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3064 read_marker: Option<crate::root_cache::ReadMarker>,
3065 ) -> Result<OpenedStore> {
3066 Self::open_at_path_with_root_repair(
3067 project_root,
3068 project_key,
3069 sqlite_path,
3070 generation,
3071 use_wal,
3072 writer_lease,
3073 read_marker,
3074 true,
3075 )
3076 }
3077
3078 fn open_at_path_with_root_repair(
3079 project_root: PathBuf,
3080 project_key: String,
3081 sqlite_path: PathBuf,
3082 generation: Option<String>,
3083 use_wal: bool,
3084 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3085 read_marker: Option<crate::root_cache::ReadMarker>,
3086 allow_root_repair: bool,
3087 ) -> Result<OpenedStore> {
3088 if let Some(lease) = writer_lease.as_ref() {
3089 verify_writer_lease(lease)?;
3090 }
3091 if let Some(parent) = sqlite_path.parent() {
3092 std::fs::create_dir_all(parent)?;
3093 }
3094 let mut conn = Connection::open(&sqlite_path)?;
3095 if use_wal {
3096 configure_connection(&conn)?;
3097 } else {
3098 configure_build_connection(&conn)?;
3099 }
3100 if let Some(lease) = writer_lease.as_ref() {
3101 verify_writer_lease(lease)?;
3102 }
3103 initialize_schema(&conn)?;
3104 if let Some(lease) = writer_lease.as_ref() {
3105 verify_writer_lease(lease)?;
3106 }
3107 let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
3108 let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
3109 (Some(marker), _, _) => Some(marker),
3110 (None, Some(label), Some(cache_dir)) => {
3111 Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
3112 }
3113 (None, _, _) => None,
3114 };
3115 let publication_dir = sqlite_path
3116 .parent()
3117 .map(Path::to_path_buf)
3118 .unwrap_or_default();
3119 let store = Self::from_connection(
3120 project_root,
3121 project_key,
3122 sqlite_path,
3123 publication_dir,
3124 false,
3125 generation,
3126 writer_lease,
3127 read_marker,
3128 conn,
3129 );
3130 Ok(OpenedStore { store, root_repair })
3131 }
3132
3133 fn prepare_for_atomic_swap(&self) -> Result<()> {
3134 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3135 conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
3136 Ok(())
3137 }
3138
3139 fn atomic_swap_checkpoint_sql(&self) -> &'static str {
3140 let protected_reader = self.generation.as_deref().is_some_and(|generation| {
3141 self.sqlite_path
3142 .parent()
3143 .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
3144 });
3145 if protected_reader {
3146 "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
3147 } else {
3148 "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
3149 }
3150 }
3151
3152 fn from_connection(
3153 project_root: PathBuf,
3154 project_key: String,
3155 sqlite_path: PathBuf,
3156 publication_dir: PathBuf,
3157 legacy_fallback: bool,
3158 generation: Option<String>,
3159 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3160 read_marker: Option<crate::root_cache::ReadMarker>,
3161 conn: Connection,
3162 ) -> Self {
3163 let write_metrics = callgraph_write_metrics_for_key(&project_key);
3164 Self {
3165 project_root,
3166 project_key,
3167 sqlite_path,
3168 publication_dir,
3169 legacy_fallback,
3170 generation,
3171 writer_lease,
3172 read_marker,
3173 database_ready: AtomicBool::new(false),
3174 write_metrics,
3175 conn: Mutex::new(conn),
3176 }
3177 }
3178
3179 fn ensure_ready(&self, conn: &Connection) -> Result<()> {
3180 if self.database_ready.load(AtomicOrdering::Acquire) {
3181 return Ok(());
3182 }
3183 ensure_database_ready(conn)?;
3184 self.database_ready.store(true, AtomicOrdering::Release);
3185 Ok(())
3186 }
3187
3188 pub fn project_root(&self) -> &Path {
3189 &self.project_root
3190 }
3191
3192 pub fn project_key(&self) -> &str {
3193 &self.project_key
3194 }
3195
3196 pub fn sqlite_path(&self) -> &Path {
3197 &self.sqlite_path
3198 }
3199
3200 pub(crate) fn projection_generation(&self) -> Option<&str> {
3202 self.generation.as_deref()
3203 }
3204
3205 pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
3207 self.refresh_read_marker()?;
3208 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3209 self.ensure_ready(&conn)?;
3210 projection_write_revision(&conn)
3211 }
3212
3213 pub fn is_legacy_fallback(&self) -> bool {
3216 self.legacy_fallback
3217 }
3218
3219 pub(crate) fn is_legacy_migration(&self) -> bool {
3220 self.generation.as_deref().is_some_and(|generation| {
3221 migration_generation_requires_manifest(generation)
3222 && migration_manifest_valid(&self.publication_dir, generation)
3223 })
3224 }
3225
3226 pub fn writer_epoch_for_test(&self) -> Option<&str> {
3227 self.writer_lease.as_ref().map(|lease| lease.epoch())
3228 }
3229
3230 fn verify_writer_lease(&self) -> Result<()> {
3231 let Some(lease) = self.writer_lease.as_ref() else {
3232 return Err(CallGraphStoreError::Unavailable(
3233 "callgraph store opened read-only; write API is unavailable".to_string(),
3234 ));
3235 };
3236 verify_writer_lease(lease)
3237 }
3238
3239 fn refresh_read_marker(&self) -> Result<()> {
3240 if let Some(marker) = self.read_marker.as_ref() {
3241 marker.touch_if_due()?;
3242 }
3243 Ok(())
3244 }
3245
3246 fn record_commit(&self, total_changes_before: u64, conn: &Connection) {
3247 self.write_metrics
3248 .record_commit(conn.total_changes().saturating_sub(total_changes_before));
3249 }
3250
3251 fn checkpoint_wal_truncate(&self) -> bool {
3252 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3253 checkpoint_wal_truncate(&conn)
3254 }
3255
3256 pub fn is_current(&self) -> bool {
3262 let _ = self.refresh_read_marker();
3263 match (
3264 read_pointer(&self.publication_dir, &self.project_key),
3265 &self.generation,
3266 ) {
3267 (Some(_), _) if self.legacy_fallback => false,
3270 (Some(published), Some(opened)) => &published == opened,
3271 (Some(_), None) => false,
3273 (None, _) => true,
3276 }
3277 }
3278
3279 pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
3280 self.cold_build_chunked(files, COLD_BUILD_EXTRACT_BATCH_FILES)
3281 }
3282
3283 pub fn cold_build_chunked(
3287 &self,
3288 files: &[PathBuf],
3289 chunk_size: usize,
3290 ) -> Result<ColdBuildStats> {
3291 let corpus_fingerprint = self.stage_cold_build_file_inventory(files)?;
3292 self.cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)
3293 }
3294
3295 fn stage_cold_build_file_inventory(&self, files: &[PathBuf]) -> Result<String> {
3296 note_cold_build_phase("enumeration");
3297 if files.is_empty() {
3298 self.stage_cold_build_file_inventory_from(callgraph::walk_project_files(
3299 &self.project_root,
3300 ))
3301 } else {
3302 self.stage_cold_build_file_inventory_from(files.iter().cloned())
3303 }
3304 }
3305
3306 fn stage_cold_build_file_inventory_from<I>(&self, paths: I) -> Result<String>
3307 where
3308 I: IntoIterator<Item = PathBuf>,
3309 {
3310 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3311 self.verify_writer_lease()?;
3312 let total_changes_before = conn.total_changes();
3313 let tx = conn.transaction()?;
3314 tx.execute("DELETE FROM staging_file_inventory", [])?;
3315 tx.commit()?;
3316 self.record_commit(total_changes_before, &conn);
3317
3318 let mut batch = Vec::with_capacity(COLD_BUILD_EXTRACT_BATCH_FILES);
3319 for path in paths {
3320 let path = normalize_file_path(&self.project_root, &path)?;
3321 let rel_path = relative_path(&self.project_root, &path);
3322 let size = std::fs::metadata(&path)
3323 .map(|metadata| metadata.len())
3324 .unwrap_or(0);
3325 batch.push((rel_path, size));
3326 if batch.len() == COLD_BUILD_EXTRACT_BATCH_FILES {
3327 self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3328 batch.clear();
3329 }
3330 }
3331 if !batch.is_empty() {
3332 self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3333 }
3334
3335 staged_corpus_fingerprint(&conn, &self.project_root)
3336 }
3337
3338 fn insert_staged_file_inventory_batch(
3339 &self,
3340 conn: &mut Connection,
3341 batch: &[(String, u64)],
3342 ) -> Result<()> {
3343 self.verify_writer_lease()?;
3344 let total_changes_before = conn.total_changes();
3345 let tx = conn.transaction()?;
3346 {
3347 let mut insert = tx.prepare(
3348 "INSERT OR REPLACE INTO staging_file_inventory(path, size) VALUES(?1, ?2)",
3349 )?;
3350 for (path, size) in batch {
3351 insert.execute(params![path, *size as i64])?;
3352 }
3353 }
3354 tx.commit()?;
3355 self.record_commit(total_changes_before, conn);
3356 Ok(())
3357 }
3358
3359 fn cold_build_chunked_from_staged_inventory(
3360 &self,
3361 chunk_size: usize,
3362 corpus_fingerprint: &str,
3363 ) -> Result<ColdBuildStats> {
3364 let started = Instant::now();
3365 let batch_files = chunk_size.max(1).min(COLD_BUILD_EXTRACT_BATCH_FILES);
3366 let workspace_root = self.project_root.display().to_string();
3367 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3368
3369 self.verify_writer_lease()?;
3370 let mut phase = staged_build_phase(&conn)?;
3371 let staged_fingerprint = staged_string(&conn, STAGED_CORPUS_FINGERPRINT)?;
3372 if phase.as_deref().is_none_or(|phase| phase == "ready")
3373 || staged_fingerprint.as_deref() != Some(corpus_fingerprint)
3374 {
3375 let total_changes_before = conn.total_changes();
3376 let tx = conn.transaction()?;
3377 clear_tables(&tx)?;
3378 tx.execute("DELETE FROM staging_ref_context", [])?;
3379 insert_meta(&tx)?;
3380 drop_cold_build_secondary_indexes(&tx)?;
3381 set_meta_ready(&tx, false)?;
3382 set_staged_build_phase(&tx, "extracting")?;
3383 set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, corpus_fingerprint)?;
3384 set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0)?;
3385 set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, 0)?;
3386 tx.commit()?;
3387 self.record_commit(total_changes_before, &conn);
3388 phase = Some("extracting".to_string());
3389 }
3390
3391 note_cold_build_phase("extraction");
3395 if phase.as_deref() == Some("extracting") {
3396 prune_staged_files_not_in_inventory(&mut conn)?;
3397
3398 let mut after_path = String::new();
3399 loop {
3400 let Some(batch) = load_staged_file_batch(
3401 &conn,
3402 &self.project_root,
3403 &after_path,
3404 batch_files,
3405 COLD_BUILD_EXTRACT_BATCH_BYTES,
3406 )?
3407 else {
3408 break;
3409 };
3410 after_path = batch.last_path;
3411
3412 let mut needs_extract = Vec::with_capacity(batch.paths.len());
3413 for path in batch.paths {
3414 if !staged_content_matches(&conn, &self.project_root, &path)? {
3415 needs_extract.push(path);
3416 }
3417 }
3418 if needs_extract.is_empty() {
3419 continue;
3420 }
3421
3422 let build = build_extracts_parallel(&self.project_root, &needs_extract);
3423 self.verify_writer_lease()?;
3424 let total_changes_before = conn.total_changes();
3425 let tx = conn.transaction()?;
3426 let mut extracted_bytes = 0u64;
3427 {
3428 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3429 for extract in &build.extracts {
3430 delete_staged_file_rows(&tx, &extract.rel_path)?;
3431 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
3432 for raw in &extract.raw_refs {
3433 insert_staged_ref_prepared(&mut inserts, raw)?;
3434 }
3435 extracted_bytes = extracted_bytes.saturating_add(extract.freshness.size);
3436 }
3437 for failure in &build.failures {
3438 insert_backend_state_prepared(
3439 &mut inserts.backend_state,
3440 &workspace_root,
3441 &failure.rel_path,
3442 failure
3443 .freshness
3444 .as_ref()
3445 .map(|freshness| &freshness.content_hash),
3446 "stale",
3447 )?;
3448 }
3449 }
3450 increment_staged_extracted_bytes(&tx, extracted_bytes)?;
3451 note_cold_build_commit_barrier("extraction_batch_before_commit");
3452 tx.commit()?;
3453 note_cold_build_commit_barrier("extraction_batch_committed");
3454 self.record_commit(total_changes_before, &conn);
3455 }
3456
3457 let total_changes_before = conn.total_changes();
3458 let tx = conn.transaction()?;
3459 set_staged_build_phase(&tx, "indexing")?;
3460 tx.commit()?;
3461 self.record_commit(total_changes_before, &conn);
3462 phase = Some("indexing".to_string());
3463 }
3464
3465 note_cold_build_phase("symbol_export_index");
3469 if phase.as_deref() == Some("indexing") {
3470 self.verify_writer_lease()?;
3471 let total_changes_before = conn.total_changes();
3472 let tx = conn.transaction()?;
3473 create_cold_build_secondary_indexes(&tx)?;
3474 set_staged_build_phase(&tx, "resolving")?;
3475 tx.commit()?;
3476 self.record_commit(total_changes_before, &conn);
3477 }
3478
3479 note_cold_build_phase("resolution");
3480 let workspace_crate_prefixes = WorkspaceCratePrefixCache::default();
3481 let mut resolve_cursor = staged_u64(&conn, STAGED_RESOLVE_CURSOR)?;
3482 loop {
3483 let staged = load_staged_ref_window(&conn, resolve_cursor, COLD_BUILD_RESOLVE_WINDOW)?;
3484 let Some(last_rowid) = staged.last().map(|entry| entry.rowid) else {
3485 break;
3486 };
3487
3488 self.verify_writer_lease()?;
3489 let total_changes_before = conn.total_changes();
3490 let tx = conn.transaction()?;
3491 {
3492 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3493 let mut offset = 0;
3494 while offset < staged.len() {
3495 let caller_file = staged[offset].raw.caller_file.clone();
3496 let end = staged[offset..]
3497 .iter()
3498 .position(|entry| entry.raw.caller_file != caller_file)
3499 .map(|relative| offset + relative)
3500 .unwrap_or(staged.len());
3501 let caller_extract = build_file_extract(
3502 &self.project_root,
3503 &self.project_root.join(&caller_file),
3504 );
3505 if let Ok(caller_extract) = caller_extract {
3506 let index = DiskProjectIndex {
3507 project_root: &self.project_root,
3508 conn: &tx,
3509 caller_file: &caller_file,
3510 caller_data: &caller_extract.data,
3511 workspace_crate_prefixes: workspace_crate_prefixes.clone(),
3512 };
3513 for staged_ref in &staged[offset..end] {
3514 let resolved = resolve_ref(staged_ref.raw.clone(), &index)?;
3515 insert_resolved_ref_prepared(&mut inserts, &resolved)?;
3516 }
3517 } else {
3518 for staged_ref in &staged[offset..end] {
3519 let unresolved = unresolved_staged_ref(staged_ref.raw.clone());
3520 insert_resolved_ref_prepared(&mut inserts, &unresolved)?;
3521 }
3522 }
3523 offset = end;
3524 }
3525 }
3526 set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, last_rowid)?;
3527 tx.commit()?;
3528 self.record_commit(total_changes_before, &conn);
3529 resolve_cursor = last_rowid;
3530 }
3531
3532 note_cold_build_phase("publication");
3533 self.verify_writer_lease()?;
3534 let total_changes_before = conn.total_changes();
3535 let tx = conn.transaction()?;
3536 let _supplemental_edge_count =
3537 insert_method_dispatch_edges_chunked(&tx, &self.project_root, batch_files)?;
3538 set_meta_ready(&tx, true)?;
3539 set_staged_build_phase(&tx, "ready")?;
3540 tx.execute("DELETE FROM staging_file_inventory", [])?;
3541 tx.execute("DELETE FROM staging_ref_context", [])?;
3542 bump_projection_write_revision(&tx)?;
3543 tx.commit()?;
3544 self.record_commit(total_changes_before, &conn);
3545
3546 let files = query_count(&conn, "SELECT COUNT(*) FROM files")? as usize;
3547 let nodes = query_count(&conn, "SELECT COUNT(*) FROM nodes")? as usize;
3548 let refs = query_count(&conn, "SELECT COUNT(*) FROM refs")? as usize;
3549 let edges = query_count(&conn, "SELECT COUNT(*) FROM edges")? as usize;
3550 let failed_files = staged_failed_files(&conn)?;
3551 let elapsed_ms = started.elapsed().as_millis();
3552 crate::slog_info!(
3553 "perf callgraph_store bounded cold_build: files={} nodes={} refs={} edges={} committed_extracted_bytes={} ms={}",
3554 files,
3555 nodes,
3556 refs,
3557 edges,
3558 staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES)?,
3559 elapsed_ms
3560 );
3561 Ok(ColdBuildStats {
3562 files,
3563 nodes,
3564 refs,
3565 edges,
3566 failed_files,
3567 elapsed_ms,
3568 })
3569 }
3570
3571 pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
3572 self.refresh_files_with_workspace_crate_prefix_cache(
3573 changed_files,
3574 WorkspaceCratePrefixCache::default(),
3575 )
3576 }
3577
3578 fn refresh_files_with_workspace_crate_prefix_cache(
3579 &self,
3580 changed_files: &[PathBuf],
3581 workspace_crate_prefixes: WorkspaceCratePrefixCache,
3582 ) -> Result<IncrementalStats> {
3583 let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
3584 changed_files,
3585 workspace_crate_prefixes,
3586 )?;
3587 if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
3588 eprintln!("refresh_files phases: {}", profile.report());
3589 }
3590 Ok(stats)
3591 }
3592
3593 #[doc(hidden)]
3595 pub fn refresh_files_profiled(
3596 &self,
3597 changed_files: &[PathBuf],
3598 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
3599 self.refresh_files_profiled_with_workspace_crate_prefix_cache(
3600 changed_files,
3601 WorkspaceCratePrefixCache::default(),
3602 )
3603 }
3604
3605 fn refresh_files_profiled_with_workspace_crate_prefix_cache(
3606 &self,
3607 changed_files: &[PathBuf],
3608 workspace_crate_prefixes: WorkspaceCratePrefixCache,
3609 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
3610 let total_started = Instant::now();
3611 let mut profile = RefreshFilesProfile::default();
3612 self.verify_writer_lease()?;
3613 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3614 ensure_database_ready(&conn)?;
3615 let total_changes_before = conn.total_changes();
3616 let mut changed = Vec::new();
3617 let mut surface_changed = BTreeSet::new();
3618 let mut deleted = BTreeSet::new();
3619 let mut own_refresh = BTreeSet::new();
3620 let mut candidate_own_refresh = BTreeSet::new();
3621 let mut confirmed_fresh = BTreeSet::new();
3622 let mut unchanged_extracts = 0usize;
3623 let mut selected_ref_ids = BTreeSet::new();
3624 let mut selected_refs_by_caller = BTreeMap::new();
3625 let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
3626 let mut fresh_metadata = BTreeMap::new();
3627
3628 for input in changed_files {
3629 let abs_path = normalize_file_path(&self.project_root, input)?;
3630 let rel_path = relative_path(&self.project_root, &abs_path);
3631 changed.push(rel_path.clone());
3632 let old_row = load_file_row(&conn, &rel_path)?;
3633 if !abs_path.exists() {
3634 if old_row.is_some() && deleted.insert(rel_path.clone()) {
3635 surface_changed.insert(rel_path.clone());
3636 let started = Instant::now();
3637 let dependent_refs =
3638 ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3639 profile.dependency_selection += started.elapsed();
3640 record_dependent_refs(
3641 &mut selected_ref_ids,
3642 &mut selected_refs_by_caller,
3643 dependent_refs,
3644 );
3645 }
3646 continue;
3647 }
3648
3649 if let Some(row) = &old_row {
3650 match cache_freshness::verify_file(&abs_path, &row.freshness) {
3651 FreshnessVerdict::HotFresh => {
3652 confirmed_fresh.insert(rel_path.clone());
3657 continue;
3658 }
3659 FreshnessVerdict::ContentFresh {
3660 new_mtime,
3661 new_size,
3662 } => {
3663 fresh_metadata.insert(
3664 rel_path.clone(),
3665 FileFreshness {
3666 content_hash: row.freshness.content_hash,
3667 mtime: new_mtime,
3668 size: new_size,
3669 },
3670 );
3671 continue;
3672 }
3673 FreshnessVerdict::Deleted => {
3674 if deleted.insert(rel_path.clone()) {
3675 surface_changed.insert(rel_path.clone());
3676 let started = Instant::now();
3677 let dependent_refs =
3678 ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3679 profile.dependency_selection += started.elapsed();
3680 record_dependent_refs(
3681 &mut selected_ref_ids,
3682 &mut selected_refs_by_caller,
3683 dependent_refs,
3684 );
3685 }
3686 continue;
3687 }
3688 FreshnessVerdict::Stale => {}
3689 }
3690 }
3691
3692 let started = Instant::now();
3693 let extract = build_file_extract(&self.project_root, &abs_path)?;
3694 profile.parse += started.elapsed();
3695 let surface_is_changed = old_row
3696 .as_ref()
3697 .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
3698 .unwrap_or(true);
3699 if surface_is_changed {
3700 surface_changed.insert(rel_path.clone());
3701 let started = Instant::now();
3702 let dependent_refs = ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3703 profile.dependency_selection += started.elapsed();
3704 record_dependent_refs(
3705 &mut selected_ref_ids,
3706 &mut selected_refs_by_caller,
3707 dependent_refs,
3708 );
3709 }
3710 candidate_own_refresh.insert(rel_path.clone());
3711 changed_extracts.insert(rel_path, extract);
3712 }
3713
3714 let dependency_selected_refs = selected_ref_ids.len();
3715 let mut touched_callers: BTreeSet<String> =
3716 selected_refs_by_caller.keys().cloned().collect();
3717 touched_callers.extend(candidate_own_refresh.iter().cloned());
3718
3719 let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
3720 for rel_path in &touched_callers {
3721 if deleted.contains(rel_path) {
3722 continue;
3723 }
3724 if let Some(extract) = changed_extracts.get(rel_path) {
3725 caller_extracts.insert(rel_path.clone(), extract.clone());
3726 continue;
3727 }
3728 let abs_path = self.project_root.join(rel_path);
3729 if abs_path.exists() {
3730 let started = Instant::now();
3731 let extract = build_file_extract(&self.project_root, &abs_path)?;
3732 profile.dependent_parse += started.elapsed();
3733 caller_extracts.insert(rel_path.clone(), extract);
3734 }
3735 }
3736
3737 let tx = conn.transaction()?;
3738 for (rel_path, freshness) in fresh_metadata {
3739 update_file_fresh_metadata(
3740 &tx,
3741 &self.project_root,
3742 &rel_path,
3743 &freshness.content_hash,
3744 freshness.mtime,
3745 freshness.size,
3746 )?;
3747 }
3748 for rel_path in &confirmed_fresh {
3749 clear_stale_backend_status_for_file(&tx, &self.project_root, rel_path)?;
3750 }
3751 for rel_path in &deleted {
3752 let started = Instant::now();
3753 delete_file_rows(&tx, rel_path)?;
3754 clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
3755 profile.row_deletes += started.elapsed();
3756 }
3757
3758 let started = Instant::now();
3759 let index = ProjectIndex::from_db_and_callers(
3760 &tx,
3761 &self.project_root,
3762 &caller_extracts,
3763 workspace_crate_prefixes,
3764 )?;
3765 profile.index_load += started.elapsed();
3766
3767 let workspace_root = self.project_root.display().to_string();
3768 {
3769 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3770 for rel_path in &candidate_own_refresh {
3771 let Some(extract) = changed_extracts.get(rel_path) else {
3772 continue;
3773 };
3774 if !write_amplification_baseline_enabled()
3775 && stored_extract_matches(&tx, rel_path, extract, &index)?
3776 {
3777 unchanged_extracts += 1;
3778 update_file_fresh_metadata(
3779 &tx,
3780 &self.project_root,
3781 rel_path,
3782 &extract.freshness.content_hash,
3783 extract.freshness.mtime,
3784 extract.freshness.size,
3785 )?;
3786 continue;
3787 }
3788
3789 own_refresh.insert(rel_path.clone());
3790 let started = Instant::now();
3791 delete_file_rows(&tx, rel_path)?;
3792 clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
3793 profile.row_deletes += started.elapsed();
3794 let started = Instant::now();
3795 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
3796 profile.row_inserts += started.elapsed();
3797 }
3798
3799 let dependency_callers = touched_callers
3800 .iter()
3801 .filter(|rel_path| {
3802 !deleted.contains(*rel_path) && !candidate_own_refresh.contains(*rel_path)
3803 })
3804 .cloned()
3805 .collect::<Vec<_>>();
3806 for rel_path in dependency_callers {
3807 let Some(extract) = caller_extracts.get(&rel_path) else {
3808 continue;
3809 };
3810 if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
3811 continue;
3812 }
3813
3814 own_refresh.insert(rel_path.clone());
3815 let started = Instant::now();
3816 delete_file_rows(&tx, &rel_path)?;
3817 clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
3818 profile.row_deletes += started.elapsed();
3819 let started = Instant::now();
3820 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
3821 profile.row_inserts += started.elapsed();
3822 }
3823 let started = Instant::now();
3824 for rel_path in &touched_callers {
3825 if deleted.contains(rel_path) {
3826 continue;
3827 }
3828 let Some(extract) = caller_extracts.get(rel_path) else {
3829 continue;
3830 };
3831 if own_refresh.contains(rel_path) {
3832 delete_refs_for_caller(&tx, rel_path)?;
3833 for raw_ref in &extract.raw_refs {
3834 let resolved = resolve_ref(raw_ref.clone(), &index)?;
3835 insert_resolved_ref_prepared(&mut inserts, &resolved)?;
3836 }
3837 continue;
3838 }
3839
3840 let selected_for_caller = selected_refs_by_caller
3841 .get(rel_path)
3842 .cloned()
3843 .unwrap_or_default();
3844 delete_ref_ids(&tx, &selected_for_caller)?;
3845 for raw_ref in &extract.raw_refs {
3846 if selected_for_caller.contains(&raw_ref.ref_id) {
3847 let resolved = resolve_ref(raw_ref.clone(), &index)?;
3848 insert_resolved_ref_prepared(&mut inserts, &resolved)?;
3849 }
3850 }
3851 }
3852 profile.ref_resolution += started.elapsed();
3853 }
3854
3855 let started = Instant::now();
3856 delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
3857 insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
3858 profile.method_dispatch += started.elapsed();
3859
3860 bump_projection_write_revision(&tx)?;
3861 let started = Instant::now();
3862 commit_incremental_if_current(tx)?;
3863 self.record_commit(total_changes_before, &conn);
3864 profile.commit += started.elapsed();
3865 profile.total = total_started.elapsed();
3866 Ok((
3867 IncrementalStats {
3868 changed_files: changed,
3869 surface_changed: surface_changed.into_iter().collect(),
3870 deleted_files: deleted.into_iter().collect(),
3871 dependency_selected_refs,
3872 refreshed_own_files: own_refresh.len(),
3873 unchanged_extract_files: unchanged_extracts,
3874 },
3875 profile,
3876 ))
3877 }
3878
3879 pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
3880 self.cold_build(current_files)
3881 }
3882
3883 pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
3884 self.verify_writer_lease()?;
3885 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3886 let total_changes_before = conn.total_changes();
3887 let tx = conn.transaction()?;
3888 let mut marked = Vec::new();
3889 for path in files {
3890 let abs_path = normalize_file_path(&self.project_root, path)?;
3891 let rel_path = relative_path(&self.project_root, &abs_path);
3892 let freshness = cache_freshness::collect(&abs_path).ok();
3893 mark_backend_state(
3894 &tx,
3895 &self.project_root,
3896 &rel_path,
3897 freshness.as_ref().map(|freshness| &freshness.content_hash),
3898 "stale",
3899 )?;
3900 marked.push(rel_path);
3901 }
3902 bump_projection_write_revision(&tx)?;
3903 tx.commit()?;
3904 self.record_commit(total_changes_before, &conn);
3905 marked.sort();
3906 marked.dedup();
3907 Ok(marked)
3908 }
3909
3910 pub fn stale_files(&self) -> Result<Vec<String>> {
3911 self.refresh_read_marker()?;
3912 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3913 let mut stmt = conn.prepare(
3914 "SELECT DISTINCT file_path FROM backend_file_state
3915 WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
3916 ORDER BY file_path",
3917 )?;
3918 let rows = stmt.query_map(
3919 params![BACKEND_TREESITTER, self.project_root.display().to_string()],
3920 |row| row.get::<_, String>(0),
3921 )?;
3922 rows.collect::<std::result::Result<Vec<_>, _>>()
3923 .map_err(Into::into)
3924 }
3925
3926 pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
3927 self.refresh_read_marker()?;
3928 let rel_path = relative_path(
3929 &self.project_root,
3930 &normalize_file_path(&self.project_root, file)?,
3931 );
3932 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3933 conn.query_row(
3934 "SELECT status FROM backend_file_state
3935 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
3936 ORDER BY updated_at DESC LIMIT 1",
3937 params![
3938 BACKEND_TREESITTER,
3939 self.project_root.display().to_string(),
3940 rel_path
3941 ],
3942 |row| row.get(0),
3943 )
3944 .optional()
3945 .map_err(Into::into)
3946 }
3947
3948 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3949 self.refresh_read_marker()?;
3950 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3951 self.ensure_ready(&conn)?;
3952 edge_snapshot_with_conn(&conn)
3953 }
3954
3955 pub fn indexed_file_count(&self) -> Result<usize> {
3956 self.refresh_read_marker()?;
3957 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3958 self.ensure_ready(&conn)?;
3959 indexed_file_count(&conn)
3960 }
3961
3962 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3963 self.refresh_read_marker()?;
3964 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3965 let rel_path = relative_path(&self.project_root, &abs_path);
3966 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3967 self.ensure_ready(&conn)?;
3968 resolve_node_for_rel(&conn, &rel_path, symbol)
3969 }
3970
3971 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3976 self.refresh_read_marker()?;
3977 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3978 let rel_path = relative_path(&self.project_root, &abs_path);
3979 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3980 self.ensure_ready(&conn)?;
3981 nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
3982 }
3983
3984 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3986 self.refresh_read_marker()?;
3987 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3988 self.ensure_ready(&conn)?;
3989 nodes_matching_symbol(&conn, symbol)
3990 }
3991
3992 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3994 self.refresh_read_marker()?;
3995 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3996 let rel_path = relative_path(&self.project_root, &abs_path);
3997 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3998 self.ensure_ready(&conn)?;
3999 direct_callers_for_tuple(&conn, &rel_path, symbol)
4000 }
4001
4002 pub fn direct_callers_for_symbols(
4004 &self,
4005 targets: &[(String, String)],
4006 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4007 if targets.is_empty() {
4008 return Ok(HashMap::new());
4009 }
4010 self.refresh_read_marker()?;
4011 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4012 self.ensure_ready(&conn)?;
4013 direct_callers_for_tuples(&conn, targets)
4014 }
4015
4016 pub fn direct_caller_counts_of(
4018 &self,
4019 targets: &[(String, String)],
4020 ) -> Result<HashMap<(String, String), usize>> {
4021 if targets.is_empty() {
4022 return Ok(HashMap::new());
4023 }
4024 self.refresh_read_marker()?;
4025 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4026 self.ensure_ready(&conn)?;
4027 direct_caller_counts_for_tuples(&conn, targets)
4028 }
4029
4030 pub fn callers_of(
4031 &self,
4032 file_rel: &Path,
4033 symbol: &str,
4034 depth: usize,
4035 ) -> Result<StoreCallersResult> {
4036 let target = self.node_for(file_rel, symbol)?;
4037 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4038 self.ensure_ready(&conn)?;
4039 let effective_depth = depth.max(1);
4040 let mut visited = HashSet::new();
4041 let mut callers = Vec::new();
4042 let mut depth_limited = false;
4043 let mut truncated = 0usize;
4044 collect_callers_recursive(
4045 &conn,
4046 &target.file,
4047 &target.symbol,
4048 effective_depth,
4049 0,
4050 &mut visited,
4051 &mut callers,
4052 &mut depth_limited,
4053 &mut truncated,
4054 )?;
4055 Ok(StoreCallersResult {
4056 target,
4057 callers,
4058 scanned_files: indexed_file_count(&conn)?,
4059 depth_limited,
4060 truncated,
4061 })
4062 }
4063
4064 pub fn impact_of(
4065 &self,
4066 file_rel: &Path,
4067 symbol: &str,
4068 depth: usize,
4069 ) -> Result<StoreImpactResult> {
4070 let callers = self.callers_of(file_rel, symbol, depth)?;
4071 let target_parameters = callers
4072 .target
4073 .signature
4074 .as_deref()
4075 .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
4076 .unwrap_or_default();
4077 let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
4078 for site in &callers.callers {
4079 source_lines_by_file
4080 .entry(site.caller.file.clone())
4081 .or_insert_with(|| {
4082 read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
4083 });
4084 }
4085 let enriched = callers
4086 .callers
4087 .iter()
4088 .map(|site| StoreImpactCaller {
4089 site: site.clone(),
4090 signature: site.caller.signature.clone(),
4091 is_entry_point: site.caller.is_entry_point,
4092 call_expression: source_lines_by_file
4093 .get(&site.caller.file)
4094 .and_then(|lines| lines.as_ref())
4095 .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
4096 .cloned(),
4097 parameters: site
4098 .caller
4099 .signature
4100 .as_deref()
4101 .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
4102 .unwrap_or_default(),
4103 })
4104 .collect();
4105 Ok(StoreImpactResult {
4106 target: callers.target,
4107 parameters: target_parameters,
4108 callers: enriched,
4109 depth_limited: callers.depth_limited,
4110 truncated: callers.truncated,
4111 })
4112 }
4113
4114 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4115 self.refresh_read_marker()?;
4116 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4117 self.ensure_ready(&conn)?;
4118 outgoing_calls_for_node(&conn, node)
4119 }
4120
4121 pub fn outgoing_calls_for_symbols(
4123 &self,
4124 sources: &[(String, String)],
4125 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4126 if sources.is_empty() {
4127 return Ok(HashMap::new());
4128 }
4129 self.refresh_read_marker()?;
4130 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4131 self.ensure_ready(&conn)?;
4132 outgoing_calls_for_symbol_tuples(&conn, sources)
4133 }
4134
4135 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4137 self.refresh_read_marker()?;
4138 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4139 self.ensure_ready(&conn)?;
4140 resolved_self_calls_for_node(&conn, node)
4141 }
4142
4143 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4144 self.refresh_read_marker()?;
4145 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4146 self.ensure_ready(&conn)?;
4147 unresolved_calls_for_node(&conn, node)
4148 }
4149
4150 pub fn call_tree(
4151 &self,
4152 file_rel: &Path,
4153 symbol: &str,
4154 max_depth: usize,
4155 ) -> Result<callgraph::CallTreeNode> {
4156 let node = self.node_for(file_rel, symbol)?;
4157 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4158 self.ensure_ready(&conn)?;
4159 let mut visited = HashSet::new();
4160 call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
4161 }
4162
4163 pub fn trace_to(
4164 &self,
4165 file_rel: &Path,
4166 symbol: &str,
4167 max_depth: usize,
4168 ) -> Result<callgraph::TraceToResult> {
4169 let target = self.node_for(file_rel, symbol)?;
4170 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4171 self.ensure_ready(&conn)?;
4172 let effective_max = if max_depth == 0 { 10 } else { max_depth };
4173
4174 #[derive(Clone)]
4175 struct PathElem {
4176 node: StoreNode,
4177 }
4178
4179 let initial = vec![PathElem {
4180 node: target.clone(),
4181 }];
4182 let mut complete_paths = Vec::new();
4183 if target.is_entry_point {
4184 complete_paths.push(initial.clone());
4185 }
4186
4187 let mut queue = vec![(initial, 0usize)];
4188 let mut max_depth_reached = false;
4189 let mut truncated_paths = 0usize;
4190
4191 while let Some((path, depth)) = queue.pop() {
4192 if depth >= effective_max {
4193 max_depth_reached = true;
4194 continue;
4195 }
4196 let Some(current) = path.last() else {
4197 continue;
4198 };
4199 let callers =
4200 direct_callers_for_tuple(&conn, ¤t.node.file, ¤t.node.symbol)?;
4201 if callers.is_empty() {
4202 if path.len() > 1 {
4203 truncated_paths += 1;
4204 }
4205 continue;
4206 }
4207
4208 let mut has_new_path = false;
4209 for site in callers {
4210 if path.iter().any(|elem| {
4211 elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
4212 }) {
4213 continue;
4214 }
4215 has_new_path = true;
4216 let mut new_path = path.clone();
4217 new_path.push(PathElem {
4218 node: site.caller.clone(),
4219 });
4220 if site.caller.is_entry_point {
4221 complete_paths.push(new_path.clone());
4222 }
4223 queue.push((new_path, depth + 1));
4224 }
4225 if !has_new_path && path.len() > 1 {
4226 truncated_paths += 1;
4227 }
4228 }
4229
4230 let mut paths: Vec<callgraph::TracePath> = complete_paths
4231 .into_iter()
4232 .map(|mut elems| {
4233 elems.reverse();
4234 let hops = elems
4235 .iter()
4236 .enumerate()
4237 .map(|(index, elem)| callgraph::TraceHop {
4238 symbol: elem.node.symbol.clone(),
4239 file: elem.node.file.clone(),
4240 line: elem.node.line,
4241 signature: elem.node.signature.clone(),
4242 is_entry_point: index == 0 && elem.node.is_entry_point,
4243 })
4244 .collect();
4245 callgraph::TracePath { hops }
4246 })
4247 .collect();
4248 paths.sort_by(|left, right| {
4249 let left_entry = left
4250 .hops
4251 .first()
4252 .map(|hop| hop.symbol.as_str())
4253 .unwrap_or("");
4254 let right_entry = right
4255 .hops
4256 .first()
4257 .map(|hop| hop.symbol.as_str())
4258 .unwrap_or("");
4259 left_entry
4260 .cmp(right_entry)
4261 .then(left.hops.len().cmp(&right.hops.len()))
4262 });
4263 let entry_points_found = paths
4264 .iter()
4265 .filter_map(|path| path.hops.first())
4266 .filter(|hop| hop.is_entry_point)
4267 .map(|hop| (hop.file.clone(), hop.symbol.clone()))
4268 .collect::<HashSet<_>>()
4269 .len();
4270
4271 Ok(callgraph::TraceToResult {
4272 target_symbol: target.symbol,
4273 target_file: target.file,
4274 total_paths: paths.len(),
4275 paths,
4276 entry_points_found,
4277 max_depth_reached,
4278 truncated_paths,
4279 })
4280 }
4281
4282 pub fn trace_to_symbol_candidates(
4283 &self,
4284 to_symbol: &str,
4285 ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
4286 self.refresh_read_marker()?;
4287 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4288 self.ensure_ready(&conn)?;
4289 let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
4290 for node in nodes_matching_symbol(&conn, to_symbol)? {
4291 candidates_by_file
4292 .entry(node.file)
4293 .and_modify(|line| *line = (*line).min(node.line))
4294 .or_insert(node.line);
4295 }
4296 let mut candidates: Vec<_> = candidates_by_file
4297 .into_iter()
4298 .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
4299 .collect();
4300 candidates
4301 .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
4302 Ok(candidates)
4303 }
4304
4305 pub fn trace_to_symbol(
4306 &self,
4307 file_rel: &Path,
4308 symbol: &str,
4309 to_symbol: &str,
4310 to_file: Option<&Path>,
4311 max_depth: usize,
4312 ) -> Result<callgraph::TraceToSymbolResult> {
4313 let origin = self.node_for(file_rel, symbol)?;
4314 let target_file = to_file
4315 .map(|path| normalize_file_path(&self.project_root, path))
4316 .transpose()?
4317 .map(|path| relative_path(&self.project_root, &path));
4318 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4319 self.ensure_ready(&conn)?;
4320 let effective_max = if max_depth == 0 {
4321 10
4322 } else {
4323 max_depth.min(16)
4324 };
4325
4326 let start_hop = trace_to_symbol_hop(&origin);
4327 if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
4328 return Ok(callgraph::TraceToSymbolResult {
4329 path: Some(vec![start_hop]),
4330 complete: true,
4331 reason: None,
4332 });
4333 }
4334
4335 let mut queue = VecDeque::new();
4336 queue.push_back((origin.clone(), vec![start_hop], 0usize));
4337 let mut visited = HashSet::new();
4338 visited.insert((origin.file.clone(), origin.symbol.clone()));
4339 let mut max_depth_exhausted = false;
4340
4341 while let Some((current, path, depth)) = queue.pop_front() {
4342 let callees = outgoing_calls_for_node(&conn, ¤t)?
4343 .into_iter()
4344 .filter_map(|site| site.target)
4345 .collect::<Vec<_>>();
4346
4347 if depth >= effective_max {
4348 if callees
4349 .iter()
4350 .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
4351 {
4352 max_depth_exhausted = true;
4353 }
4354 continue;
4355 }
4356
4357 for callee in callees {
4358 if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
4359 continue;
4360 }
4361 let mut next_path = path.clone();
4362 next_path.push(trace_to_symbol_hop(&callee));
4363 if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
4364 return Ok(callgraph::TraceToSymbolResult {
4365 path: Some(next_path),
4366 complete: true,
4367 reason: None,
4368 });
4369 }
4370 queue.push_back((callee, next_path, depth + 1));
4371 }
4372 }
4373
4374 if max_depth_exhausted {
4375 Ok(callgraph::TraceToSymbolResult {
4376 path: None,
4377 complete: false,
4378 reason: Some("max_depth_exhausted".to_string()),
4379 })
4380 } else {
4381 Ok(callgraph::TraceToSymbolResult {
4382 path: None,
4383 complete: true,
4384 reason: Some("no_path_found".to_string()),
4385 })
4386 }
4387 }
4388}
4389
4390impl ReadonlyCallGraphStore {
4391 fn from_inner(inner: CallGraphStore) -> Self {
4392 Self { inner }
4393 }
4394
4395 pub fn project_root(&self) -> &Path {
4396 self.inner.project_root()
4397 }
4398
4399 pub fn project_key(&self) -> &str {
4400 self.inner.project_key()
4401 }
4402
4403 pub fn sqlite_path(&self) -> &Path {
4404 self.inner.sqlite_path()
4405 }
4406
4407 pub fn stale_files(&self) -> Result<Vec<String>> {
4408 self.inner.stale_files()
4409 }
4410
4411 pub(crate) fn projection_generation(&self) -> Option<&str> {
4412 self.inner.projection_generation()
4413 }
4414
4415 pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
4416 self.inner.projection_write_revision()
4417 }
4418
4419 pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
4422 crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
4423 }
4424
4425 pub fn is_legacy_fallback(&self) -> bool {
4427 self.inner.is_legacy_fallback()
4428 }
4429
4430 pub fn is_current(&self) -> bool {
4431 self.inner.is_current()
4432 }
4433
4434 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4435 self.inner.edge_snapshot()
4436 }
4437
4438 pub fn indexed_file_count(&self) -> Result<usize> {
4439 self.inner.indexed_file_count()
4440 }
4441
4442 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4443 self.inner.node_for(file_rel, symbol)
4444 }
4445
4446 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4447 self.inner.nodes_for(file_rel, symbol)
4448 }
4449
4450 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4451 self.inner.nodes_matching(symbol)
4452 }
4453
4454 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4455 self.inner.direct_callers_of(file_rel, symbol)
4456 }
4457
4458 pub fn direct_callers_for_symbols(
4459 &self,
4460 targets: &[(String, String)],
4461 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4462 self.inner.direct_callers_for_symbols(targets)
4463 }
4464
4465 pub fn direct_caller_counts_of(
4466 &self,
4467 targets: &[(String, String)],
4468 ) -> Result<HashMap<(String, String), usize>> {
4469 self.inner.direct_caller_counts_of(targets)
4470 }
4471
4472 pub fn callers_of(
4473 &self,
4474 file_rel: &Path,
4475 symbol: &str,
4476 depth: usize,
4477 ) -> Result<StoreCallersResult> {
4478 self.inner.callers_of(file_rel, symbol, depth)
4479 }
4480
4481 pub fn impact_of(
4482 &self,
4483 file_rel: &Path,
4484 symbol: &str,
4485 depth: usize,
4486 ) -> Result<StoreImpactResult> {
4487 self.inner.impact_of(file_rel, symbol, depth)
4488 }
4489
4490 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4491 self.inner.outgoing_calls_of(node)
4492 }
4493
4494 pub fn outgoing_calls_for_symbols(
4495 &self,
4496 sources: &[(String, String)],
4497 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4498 self.inner.outgoing_calls_for_symbols(sources)
4499 }
4500
4501 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4502 self.inner.resolved_self_calls_of(node)
4503 }
4504
4505 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4506 self.inner.unresolved_calls_of(node)
4507 }
4508
4509 pub fn call_tree(
4510 &self,
4511 file_rel: &Path,
4512 symbol: &str,
4513 depth: usize,
4514 ) -> Result<callgraph::CallTreeNode> {
4515 self.inner.call_tree(file_rel, symbol, depth)
4516 }
4517
4518 pub fn trace_to(
4519 &self,
4520 file_rel: &Path,
4521 symbol: &str,
4522 max_depth: usize,
4523 ) -> Result<callgraph::TraceToResult> {
4524 self.inner.trace_to(file_rel, symbol, max_depth)
4525 }
4526
4527 pub fn trace_to_symbol_candidates(
4528 &self,
4529 to_symbol: &str,
4530 ) -> Result<Vec<TraceToSymbolCandidate>> {
4531 self.inner.trace_to_symbol_candidates(to_symbol)
4532 }
4533
4534 pub fn trace_to_symbol(
4535 &self,
4536 file_rel: &Path,
4537 symbol: &str,
4538 to_symbol: &str,
4539 to_file: Option<&Path>,
4540 max_depth: usize,
4541 ) -> Result<callgraph::TraceToSymbolResult> {
4542 self.inner
4543 .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4544 }
4545}
4546
4547impl CallGraphRead for CallGraphStore {
4548 fn project_root(&self) -> &Path {
4549 CallGraphStore::project_root(self)
4550 }
4551 fn project_key(&self) -> &str {
4552 CallGraphStore::project_key(self)
4553 }
4554 fn sqlite_path(&self) -> &Path {
4555 CallGraphStore::sqlite_path(self)
4556 }
4557 fn is_current(&self) -> bool {
4558 CallGraphStore::is_current(self)
4559 }
4560 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4561 CallGraphStore::edge_snapshot(self)
4562 }
4563 fn indexed_file_count(&self) -> Result<usize> {
4564 CallGraphStore::indexed_file_count(self)
4565 }
4566 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4567 CallGraphStore::node_for(self, file_rel, symbol)
4568 }
4569 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4570 CallGraphStore::nodes_for(self, file_rel, symbol)
4571 }
4572 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4573 CallGraphStore::nodes_matching(self, symbol)
4574 }
4575 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4576 CallGraphStore::direct_callers_of(self, file_rel, symbol)
4577 }
4578 fn direct_callers_for_symbols(
4579 &self,
4580 targets: &[(String, String)],
4581 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4582 CallGraphStore::direct_callers_for_symbols(self, targets)
4583 }
4584 fn direct_caller_counts_of(
4585 &self,
4586 targets: &[(String, String)],
4587 ) -> Result<HashMap<(String, String), usize>> {
4588 CallGraphStore::direct_caller_counts_of(self, targets)
4589 }
4590 fn callers_of(
4591 &self,
4592 file_rel: &Path,
4593 symbol: &str,
4594 depth: usize,
4595 ) -> Result<StoreCallersResult> {
4596 CallGraphStore::callers_of(self, file_rel, symbol, depth)
4597 }
4598 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4599 CallGraphStore::impact_of(self, file_rel, symbol, depth)
4600 }
4601 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4602 CallGraphStore::outgoing_calls_of(self, node)
4603 }
4604 fn outgoing_calls_for_symbols(
4605 &self,
4606 sources: &[(String, String)],
4607 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4608 CallGraphStore::outgoing_calls_for_symbols(self, sources)
4609 }
4610 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4611 CallGraphStore::resolved_self_calls_of(self, node)
4612 }
4613 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4614 CallGraphStore::unresolved_calls_of(self, node)
4615 }
4616 fn call_tree(
4617 &self,
4618 file_rel: &Path,
4619 symbol: &str,
4620 depth: usize,
4621 ) -> Result<callgraph::CallTreeNode> {
4622 CallGraphStore::call_tree(self, file_rel, symbol, depth)
4623 }
4624 fn trace_to(
4625 &self,
4626 file_rel: &Path,
4627 symbol: &str,
4628 max_depth: usize,
4629 ) -> Result<callgraph::TraceToResult> {
4630 CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
4631 }
4632 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4633 CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
4634 }
4635 fn trace_to_symbol(
4636 &self,
4637 file_rel: &Path,
4638 symbol: &str,
4639 to_symbol: &str,
4640 to_file: Option<&Path>,
4641 max_depth: usize,
4642 ) -> Result<callgraph::TraceToSymbolResult> {
4643 CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
4644 }
4645}
4646
4647impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
4648 fn project_root(&self) -> &Path {
4649 (**self).project_root()
4650 }
4651 fn project_key(&self) -> &str {
4652 (**self).project_key()
4653 }
4654 fn sqlite_path(&self) -> &Path {
4655 (**self).sqlite_path()
4656 }
4657 fn is_current(&self) -> bool {
4658 (**self).is_current()
4659 }
4660 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4661 (**self).edge_snapshot()
4662 }
4663 fn indexed_file_count(&self) -> Result<usize> {
4664 (**self).indexed_file_count()
4665 }
4666 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4667 (**self).node_for(file_rel, symbol)
4668 }
4669 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4670 (**self).nodes_for(file_rel, symbol)
4671 }
4672 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4673 (**self).nodes_matching(symbol)
4674 }
4675 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4676 (**self).direct_callers_of(file_rel, symbol)
4677 }
4678 fn direct_callers_for_symbols(
4679 &self,
4680 targets: &[(String, String)],
4681 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4682 (**self).direct_callers_for_symbols(targets)
4683 }
4684 fn direct_caller_counts_of(
4685 &self,
4686 targets: &[(String, String)],
4687 ) -> Result<HashMap<(String, String), usize>> {
4688 (**self).direct_caller_counts_of(targets)
4689 }
4690 fn callers_of(
4691 &self,
4692 file_rel: &Path,
4693 symbol: &str,
4694 depth: usize,
4695 ) -> Result<StoreCallersResult> {
4696 (**self).callers_of(file_rel, symbol, depth)
4697 }
4698 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4699 (**self).impact_of(file_rel, symbol, depth)
4700 }
4701 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4702 (**self).outgoing_calls_of(node)
4703 }
4704 fn outgoing_calls_for_symbols(
4705 &self,
4706 sources: &[(String, String)],
4707 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4708 (**self).outgoing_calls_for_symbols(sources)
4709 }
4710 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4711 (**self).resolved_self_calls_of(node)
4712 }
4713 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4714 (**self).unresolved_calls_of(node)
4715 }
4716 fn call_tree(
4717 &self,
4718 file_rel: &Path,
4719 symbol: &str,
4720 depth: usize,
4721 ) -> Result<callgraph::CallTreeNode> {
4722 (**self).call_tree(file_rel, symbol, depth)
4723 }
4724 fn trace_to(
4725 &self,
4726 file_rel: &Path,
4727 symbol: &str,
4728 max_depth: usize,
4729 ) -> Result<callgraph::TraceToResult> {
4730 (**self).trace_to(file_rel, symbol, max_depth)
4731 }
4732 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4733 (**self).trace_to_symbol_candidates(to_symbol)
4734 }
4735 fn trace_to_symbol(
4736 &self,
4737 file_rel: &Path,
4738 symbol: &str,
4739 to_symbol: &str,
4740 to_file: Option<&Path>,
4741 max_depth: usize,
4742 ) -> Result<callgraph::TraceToSymbolResult> {
4743 (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4744 }
4745}
4746
4747impl CallGraphRead for ReadonlyCallGraphStore {
4748 fn project_root(&self) -> &Path {
4749 self.project_root()
4750 }
4751 fn project_key(&self) -> &str {
4752 self.project_key()
4753 }
4754 fn sqlite_path(&self) -> &Path {
4755 self.sqlite_path()
4756 }
4757 fn is_current(&self) -> bool {
4758 self.is_current()
4759 }
4760 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4761 self.edge_snapshot()
4762 }
4763 fn indexed_file_count(&self) -> Result<usize> {
4764 self.indexed_file_count()
4765 }
4766 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4767 self.node_for(file_rel, symbol)
4768 }
4769 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4770 self.nodes_for(file_rel, symbol)
4771 }
4772 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4773 self.nodes_matching(symbol)
4774 }
4775 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4776 self.direct_callers_of(file_rel, symbol)
4777 }
4778 fn direct_callers_for_symbols(
4779 &self,
4780 targets: &[(String, String)],
4781 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4782 self.direct_callers_for_symbols(targets)
4783 }
4784 fn direct_caller_counts_of(
4785 &self,
4786 targets: &[(String, String)],
4787 ) -> Result<HashMap<(String, String), usize>> {
4788 self.direct_caller_counts_of(targets)
4789 }
4790 fn callers_of(
4791 &self,
4792 file_rel: &Path,
4793 symbol: &str,
4794 depth: usize,
4795 ) -> Result<StoreCallersResult> {
4796 self.callers_of(file_rel, symbol, depth)
4797 }
4798 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4799 self.impact_of(file_rel, symbol, depth)
4800 }
4801 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4802 self.outgoing_calls_of(node)
4803 }
4804 fn outgoing_calls_for_symbols(
4805 &self,
4806 sources: &[(String, String)],
4807 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4808 self.outgoing_calls_for_symbols(sources)
4809 }
4810 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4811 self.resolved_self_calls_of(node)
4812 }
4813 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4814 self.unresolved_calls_of(node)
4815 }
4816 fn call_tree(
4817 &self,
4818 file_rel: &Path,
4819 symbol: &str,
4820 depth: usize,
4821 ) -> Result<callgraph::CallTreeNode> {
4822 self.call_tree(file_rel, symbol, depth)
4823 }
4824 fn trace_to(
4825 &self,
4826 file_rel: &Path,
4827 symbol: &str,
4828 max_depth: usize,
4829 ) -> Result<callgraph::TraceToResult> {
4830 self.trace_to(file_rel, symbol, max_depth)
4831 }
4832 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4833 self.trace_to_symbol_candidates(to_symbol)
4834 }
4835 fn trace_to_symbol(
4836 &self,
4837 file_rel: &Path,
4838 symbol: &str,
4839 to_symbol: &str,
4840 to_file: Option<&Path>,
4841 max_depth: usize,
4842 ) -> Result<callgraph::TraceToSymbolResult> {
4843 self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4844 }
4845}
4846
4847fn indexed_file_count(conn: &Connection) -> Result<usize> {
4848 let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
4849 Ok(count.max(0) as usize)
4850}
4851
4852fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
4853 let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
4854 match candidates.as_slice() {
4855 [candidate] => Ok(candidate.clone()),
4856 [] => Err(AftError::SymbolNotFound {
4857 name: symbol.to_string(),
4858 file: rel_path.to_string(),
4859 }
4860 .into()),
4861 _ => Err(AftError::AmbiguousSymbol {
4862 name: symbol.to_string(),
4863 candidates: candidates
4864 .iter()
4865 .map(|candidate| candidate.symbol.clone())
4866 .collect(),
4867 }
4868 .into()),
4869 }
4870}
4871
4872fn nodes_for_file_matching_symbol(
4873 conn: &Connection,
4874 rel_path: &str,
4875 symbol: &str,
4876) -> Result<Vec<StoreNode>> {
4877 let qualified_query = symbol.contains("::");
4878 let sql = if qualified_query {
4879 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4880 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4881 FROM nodes n JOIN files f ON f.path = n.file_path
4882 WHERE n.file_path = ?1 AND n.scoped_name = ?2
4883 ORDER BY n.scoped_name, n.start_line, n.start_col"
4884 } else {
4885 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4886 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4887 FROM nodes n JOIN files f ON f.path = n.file_path
4888 WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
4889 ORDER BY n.scoped_name, n.start_line, n.start_col"
4890 };
4891 let mut stmt = conn.prepare(sql)?;
4892 let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
4893 rows.collect::<std::result::Result<Vec<_>, _>>()
4894 .map_err(Into::into)
4895}
4896
4897fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
4898 let qualified_query = symbol.contains("::");
4899 let sql = if qualified_query {
4900 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4901 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4902 FROM nodes n JOIN files f ON f.path = n.file_path
4903 WHERE n.scoped_name = ?1
4904 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4905 } else {
4906 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4907 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4908 FROM nodes n JOIN files f ON f.path = n.file_path
4909 WHERE n.scoped_name = ?1 OR n.name = ?1
4910 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4911 };
4912 let mut stmt = conn.prepare(sql)?;
4913 let rows = stmt.query_map(params![symbol], store_node_from_row)?;
4914 rows.collect::<std::result::Result<Vec<_>, _>>()
4915 .map_err(Into::into)
4916}
4917
4918fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
4919 store_node_from_row_at(row, 0)
4920}
4921
4922fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
4923 let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
4924 let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
4925 let lang_label_value: String = row.get(offset + 10)?;
4926 Ok(StoreNode {
4927 node_id: row.get(offset)?,
4928 file: row.get(offset + 1)?,
4929 symbol: row.get(offset + 2)?,
4930 name: row.get(offset + 3)?,
4931 kind: row.get(offset + 4)?,
4932 line: start_line.saturating_add(1),
4933 end_line: end_line.saturating_add(1),
4934 signature: row.get(offset + 7)?,
4935 exported: row.get::<_, i64>(offset + 8)? != 0,
4936 is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
4937 lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
4938 })
4939}
4940
4941fn optional_store_node_from_row_at(
4942 row: &rusqlite::Row<'_>,
4943 offset: usize,
4944) -> rusqlite::Result<Option<StoreNode>> {
4945 if row.get::<_, Option<String>>(offset)?.is_some() {
4946 store_node_from_row_at(row, offset).map(Some)
4947 } else {
4948 Ok(None)
4949 }
4950}
4951
4952#[allow(clippy::too_many_arguments)]
4953fn collect_callers_recursive(
4954 conn: &Connection,
4955 file: &str,
4956 symbol: &str,
4957 max_depth: usize,
4958 current_depth: usize,
4959 visited: &mut HashSet<(String, String)>,
4960 result: &mut Vec<StoreCallSite>,
4961 depth_limited: &mut bool,
4962 truncated: &mut usize,
4963) -> Result<()> {
4964 if current_depth >= max_depth {
4965 let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
4966 if omitted > 0 {
4967 *depth_limited = true;
4968 *truncated += omitted;
4969 }
4970 return Ok(());
4971 }
4972
4973 if !visited.insert((file.to_string(), symbol.to_string())) {
4974 return Ok(());
4975 }
4976
4977 let sites = direct_callers_for_tuple(conn, file, symbol)?;
4978 for site in sites {
4979 result.push(site.clone());
4980 if current_depth + 1 < max_depth {
4981 collect_callers_recursive(
4982 conn,
4983 &site.caller.file,
4984 &site.caller.symbol,
4985 max_depth,
4986 current_depth + 1,
4987 visited,
4988 result,
4989 depth_limited,
4990 truncated,
4991 )?;
4992 } else {
4993 let omitted =
4994 direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
4995 if omitted > 0 {
4996 *depth_limited = true;
4997 *truncated += omitted;
4998 }
4999 }
5000 }
5001 Ok(())
5002}
5003
5004const DIRECT_CALLER_BATCH_SIZE: usize = 499;
5006
5007fn direct_caller_counts_for_tuples(
5008 conn: &Connection,
5009 targets: &[(String, String)],
5010) -> Result<HashMap<(String, String), usize>> {
5011 let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
5012 let mut counts = unique_targets
5013 .iter()
5014 .cloned()
5015 .map(|target| (target, 0usize))
5016 .collect::<HashMap<_, _>>();
5017
5018 let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
5019 for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
5020 let requested_values = (0..chunk.len())
5021 .map(|_| "(?, ?)")
5022 .collect::<Vec<_>>()
5023 .join(", ");
5024 let sql = format!(
5025 "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
5026 deduped AS (
5027 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
5028 FROM requested requested
5029 JOIN edges e
5030 ON e.target_file = requested.target_file
5031 AND e.target_symbol = requested.target_symbol
5032 AND e.kind = 'call'
5033 JOIN refs r ON r.ref_id = e.ref_id
5034 JOIN nodes src ON src.id = e.source_node
5035 JOIN files src_file ON src_file.path = src.file_path
5036 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
5037 )
5038 SELECT target_file, target_symbol, COUNT(*)
5039 FROM deduped
5040 GROUP BY target_file, target_symbol"
5041 );
5042 let bindings = chunk
5043 .iter()
5044 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5045 let mut stmt = conn.prepare(&sql)?;
5046 let rows = stmt.query_map(params_from_iter(bindings), |row| {
5047 Ok((
5048 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
5049 row.get::<_, i64>(2)?,
5050 ))
5051 })?;
5052 for row in rows {
5053 let (target, count) = row?;
5054 counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
5055 }
5056 }
5057
5058 Ok(counts)
5059}
5060
5061fn direct_caller_count_for_tuple(
5062 conn: &Connection,
5063 target_file: &str,
5064 target_symbol: &str,
5065) -> Result<usize> {
5066 let count: i64 = conn.query_row(
5067 "SELECT COUNT(*)
5068 FROM edges e
5069 JOIN refs r ON r.ref_id = e.ref_id
5070 JOIN nodes src ON src.id = e.source_node
5071 JOIN files src_file ON src_file.path = src.file_path
5072 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
5073 params![target_file, target_symbol],
5074 |row| row.get(0),
5075 )?;
5076 Ok(usize::try_from(count).unwrap_or(usize::MAX))
5077}
5078
5079fn direct_callers_for_tuple(
5080 conn: &Connection,
5081 target_file: &str,
5082 target_symbol: &str,
5083) -> Result<Vec<StoreCallSite>> {
5084 let mut stmt = conn.prepare(
5085 "SELECT e.target_file, e.target_symbol, e.line,
5086 r.byte_start, r.byte_end, r.status, e.provenance,
5087 src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5088 src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5089 src_file.lang,
5090 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5091 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5092 tgt_file.lang
5093 FROM edges e
5094 JOIN refs r ON r.ref_id = e.ref_id
5095 JOIN nodes src ON src.id = e.source_node
5096 JOIN files src_file ON src_file.path = src.file_path
5097 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5098 ON tgt.id = e.target_node
5099 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
5100 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
5101 )?;
5102 let rows = stmt.query_map(
5103 params![target_file, target_symbol],
5104 direct_call_site_from_row,
5105 )?;
5106 rows.collect::<std::result::Result<Vec<_>, _>>()
5107 .map_err(Into::into)
5108}
5109
5110fn direct_call_site_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreCallSite> {
5111 let caller = store_node_from_row_at(row, 7)?;
5112 let target = optional_store_node_from_row_at(row, 18)?;
5113 Ok(StoreCallSite {
5114 caller,
5115 target_file: row.get(0)?,
5116 target_symbol: row.get(1)?,
5117 target,
5118 line: row.get::<_, i64>(2)?.max(0) as u32,
5119 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5120 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5121 resolved: row.get::<_, String>(5)? == "resolved",
5122 provenance: row.get(6)?,
5123 })
5124}
5125
5126fn direct_callers_for_tuples(
5127 conn: &Connection,
5128 targets: &[(String, String)],
5129) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5130 let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
5131 let mut callers_by_target = unique_targets
5132 .iter()
5133 .cloned()
5134 .map(|target| (target, Vec::new()))
5135 .collect::<HashMap<_, _>>();
5136 let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
5137
5138 for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
5139 let requested_values = (0..chunk.len())
5140 .map(|_| "(?, ?)")
5141 .collect::<Vec<_>>()
5142 .join(", ");
5143 let sql = format!(
5144 "WITH requested(target_file, target_symbol) AS (VALUES {requested_values})
5145 SELECT e.target_file, e.target_symbol, e.line,
5146 r.byte_start, r.byte_end, r.status, e.provenance,
5147 src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5148 src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5149 src_file.lang,
5150 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5151 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5152 tgt_file.lang
5153 FROM requested requested
5154 JOIN edges e
5155 ON e.target_file = requested.target_file
5156 AND e.target_symbol = requested.target_symbol
5157 AND e.kind = 'call'
5158 JOIN refs r ON r.ref_id = e.ref_id
5159 JOIN nodes src ON src.id = e.source_node
5160 JOIN files src_file ON src_file.path = src.file_path
5161 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5162 ON tgt.id = e.target_node
5163 ORDER BY e.target_file, e.target_symbol, e.source_node,
5164 r.byte_start, r.line, r.ref_id"
5165 );
5166 let bindings = chunk
5167 .iter()
5168 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5169 let mut stmt = conn.prepare(&sql)?;
5170 let rows = stmt.query_map(params_from_iter(bindings), |row| {
5171 let call = direct_call_site_from_row(row)?;
5172 let target_key = (call.target_file.clone(), call.target_symbol.clone());
5173 Ok((target_key, call))
5174 })?;
5175 for row in rows {
5176 let (target, call) = row?;
5177 callers_by_target
5178 .get_mut(&target)
5179 .expect("batched caller row belongs to a requested target")
5180 .push(call);
5181 }
5182 }
5183
5184 Ok(callers_by_target)
5185}
5186
5187const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
5189const OUTGOING_NODE_BATCH_SIZE: usize = 999;
5191
5192fn outgoing_calls_for_symbol_tuples(
5193 conn: &Connection,
5194 sources: &[(String, String)],
5195) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5196 let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
5197 let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
5198 let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
5199 let source_nodes = unique_sources
5200 .iter()
5201 .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
5202 .cloned()
5203 .collect::<Vec<_>>();
5204 let source_nodes_by_id = source_nodes
5205 .iter()
5206 .cloned()
5207 .map(|node| (node.node_id.clone(), node))
5208 .collect::<HashMap<_, _>>();
5209 let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
5210
5211 for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
5212 let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
5213 let sql = format!(
5214 "SELECT e.source_node,
5215 e.target_file, e.target_symbol, e.line,
5216 r.byte_start, r.byte_end, r.status, e.provenance,
5217 CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
5218 tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5219 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5220 tgt_file.lang
5221 FROM edges e
5222 JOIN refs r ON r.ref_id = e.ref_id
5223 LEFT JOIN nodes tgt ON tgt.id = e.target_node
5224 LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
5225 WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
5226 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
5227 );
5228 let bindings = chunk.iter().map(|node| node.node_id.as_str());
5229 let mut stmt = conn.prepare(&sql)?;
5230 let rows = stmt.query_map(params_from_iter(bindings), |row| {
5231 let source_node_id = row.get::<_, String>(0)?;
5232 let caller = source_nodes_by_id
5233 .get(&source_node_id)
5234 .expect("batched outgoing row belongs to a requested source node")
5235 .clone();
5236 let target = optional_store_node_from_row_at(row, 8)?;
5237 Ok((
5238 source_node_id,
5239 StoreCallSite {
5240 caller,
5241 target_file: row.get(1)?,
5242 target_symbol: row.get(2)?,
5243 target,
5244 line: row.get::<_, i64>(3)?.max(0) as u32,
5245 byte_start: row.get::<_, i64>(4)?.max(0) as usize,
5246 byte_end: row.get::<_, i64>(5)?.max(0) as usize,
5247 resolved: row.get::<_, String>(6)? == "resolved",
5248 provenance: row.get(7)?,
5249 },
5250 ))
5251 })?;
5252 for row in rows {
5253 let (source_node_id, call) = row?;
5254 calls_by_node.entry(source_node_id).or_default().push(call);
5255 }
5256 }
5257
5258 let mut calls_by_source = HashMap::new();
5259 for source in &unique_sources {
5260 let mut calls = Vec::new();
5261 if let Some(nodes) = source_nodes_by_symbol.get(source) {
5262 for node in nodes {
5263 if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
5264 calls.extend(node_calls);
5265 }
5266 }
5267 }
5268 calls_by_source.insert(source.clone(), calls);
5269 }
5270
5271 let target_tuples = calls_by_source
5274 .values()
5275 .flatten()
5276 .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
5277 .collect::<Vec<_>>();
5278 let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
5279 for calls in calls_by_source.values_mut() {
5280 for call in calls {
5281 if let Some(target) = target_nodes
5282 .get(&(call.target_file.clone(), call.target_symbol.clone()))
5283 .and_then(|nodes| nodes.first())
5284 {
5285 call.target = Some(target.clone());
5286 }
5287 }
5288 }
5289
5290 Ok(calls_by_source)
5291}
5292
5293fn nodes_for_symbol_tuples(
5294 conn: &Connection,
5295 symbols: &[(String, String)],
5296) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
5297 let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
5298 let mut nodes_by_symbol = unique_symbols
5299 .iter()
5300 .cloned()
5301 .map(|symbol| (symbol, Vec::new()))
5302 .collect::<HashMap<_, _>>();
5303 let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
5304
5305 for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
5306 let requested_values = (0..chunk.len())
5307 .map(|_| "(?, ?)")
5308 .collect::<Vec<_>>()
5309 .join(", ");
5310 let sql = format!(
5311 "WITH requested(file, symbol) AS (VALUES {requested_values})
5312 SELECT requested.file, requested.symbol,
5313 node.id, node.file_path, node.scoped_name, node.name, node.kind,
5314 node.start_line, node.end_line, node.signature, node.exported,
5315 node.is_callgraph_entry_point, node_file.lang
5316 FROM requested
5317 JOIN nodes node INDEXED BY idx_nodes_file
5318 ON node.file_path = requested.file
5319 AND node.scoped_name = requested.symbol
5320 JOIN files node_file ON node_file.path = node.file_path
5321 ORDER BY requested.file, requested.symbol,
5322 node.scoped_name, node.start_line, node.end_line,
5323 node.start_col, node.range_ordinal"
5324 );
5325 let bindings = chunk
5326 .iter()
5327 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5328 let mut stmt = conn.prepare(&sql)?;
5329 let rows = stmt.query_map(params_from_iter(bindings), |row| {
5330 Ok((
5331 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
5332 store_node_from_row_at(row, 2)?,
5333 ))
5334 })?;
5335 for row in rows {
5336 let (symbol, node) = row?;
5337 nodes_by_symbol.entry(symbol).or_default().push(node);
5338 }
5339 }
5340
5341 Ok(nodes_by_symbol)
5342}
5343
5344fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5345 let mut stmt = conn.prepare(
5346 "SELECT e.target_file, e.target_symbol, e.line,
5347 r.byte_start, r.byte_end, r.status, e.provenance,
5348 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5349 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5350 tgt_file.lang
5351 FROM edges e
5352 JOIN refs r ON r.ref_id = e.ref_id
5353 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5354 ON tgt.id = e.target_node
5355 WHERE e.kind = 'call' AND e.source_node = ?1
5356 ORDER BY r.byte_start, r.line, r.ref_id",
5357 )?;
5358 let rows = stmt.query_map(params![node.node_id], |row| {
5359 let target = optional_store_node_from_row_at(row, 7)?;
5360 Ok(StoreCallSite {
5361 caller: node.clone(),
5362 target_file: row.get(0)?,
5363 target_symbol: row.get(1)?,
5364 target,
5365 line: row.get::<_, i64>(2)?.max(0) as u32,
5366 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5367 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5368 resolved: row.get::<_, String>(5)? == "resolved",
5369 provenance: row.get(6)?,
5370 })
5371 })?;
5372 rows.collect::<std::result::Result<Vec<_>, _>>()
5373 .map_err(Into::into)
5374}
5375
5376fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5377 let mut stmt = conn.prepare(
5378 "SELECT r.target_file, r.target_symbol, r.line,
5379 r.byte_start, r.byte_end, r.status, r.provenance,
5380 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5381 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5382 tgt_file.lang
5383 FROM refs r
5384 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5385 ON tgt.id = r.target_node
5386 WHERE r.caller_node = ?1
5387 AND r.kind = 'call'
5388 AND r.status <> 'unresolved'
5389 AND r.target_file = ?2
5390 AND r.target_symbol = ?3
5391 AND r.provenance = ?4
5392 AND NOT EXISTS (
5393 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
5394 )
5395 ORDER BY r.byte_start, r.line, r.ref_id",
5396 )?;
5397 let rows = stmt.query_map(
5398 params![
5399 &node.node_id,
5400 &node.file,
5401 &node.symbol,
5402 PROVENANCE_TREESITTER
5403 ],
5404 |row| {
5405 let target = optional_store_node_from_row_at(row, 7)?;
5406 Ok(StoreCallSite {
5407 caller: node.clone(),
5408 target_file: row.get(0)?,
5409 target_symbol: row.get(1)?,
5410 target,
5411 line: row.get::<_, i64>(2)?.max(0) as u32,
5412 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5413 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5414 resolved: row.get::<_, String>(5)? == "resolved",
5415 provenance: row.get(6)?,
5416 })
5417 },
5418 )?;
5419 rows.collect::<std::result::Result<Vec<_>, _>>()
5420 .map_err(Into::into)
5421}
5422
5423fn unresolved_calls_for_node(
5424 conn: &Connection,
5425 node: &StoreNode,
5426) -> Result<Vec<StoreUnresolvedCall>> {
5427 let mut stmt = conn.prepare(
5428 "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
5429 FROM refs
5430 WHERE caller_node = ?1
5431 AND kind = 'call'
5432 AND status = 'unresolved'
5433 AND NOT EXISTS (
5434 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
5435 )
5436 ORDER BY byte_start, line, ref_id",
5437 )?;
5438 let rows = stmt.query_map(params![node.node_id], |row| {
5439 Ok(StoreUnresolvedCall {
5440 caller: node.clone(),
5441 symbol: row.get(0)?,
5442 full_ref: row.get(1)?,
5443 line: row.get::<_, i64>(2)?.max(0) as u32,
5444 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5445 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5446 })
5447 })?;
5448 rows.collect::<std::result::Result<Vec<_>, _>>()
5449 .map_err(Into::into)
5450}
5451
5452fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
5453 let mut calls = Vec::new();
5454 calls.extend(
5455 outgoing_calls_for_node(conn, node)?
5456 .into_iter()
5457 .map(StoreForwardCall::Resolved),
5458 );
5459 calls.extend(
5460 unresolved_calls_for_node(conn, node)?
5461 .into_iter()
5462 .map(StoreForwardCall::Unresolved),
5463 );
5464 calls.sort_by(|left, right| {
5465 left.byte_start()
5466 .cmp(&right.byte_start())
5467 .then(left.line().cmp(&right.line()))
5468 });
5469 Ok(calls)
5470}
5471
5472fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
5473 let resolved_count: i64 = conn.query_row(
5474 "SELECT COUNT(*)
5475 FROM edges e
5476 JOIN refs r ON r.ref_id = e.ref_id
5477 WHERE e.kind = 'call' AND e.source_node = ?1",
5478 params![&node.node_id],
5479 |row| row.get(0),
5480 )?;
5481 let unresolved_count: i64 = conn.query_row(
5482 "SELECT COUNT(*)
5483 FROM refs
5484 WHERE caller_node = ?1
5485 AND kind = 'call'
5486 AND status = 'unresolved'
5487 AND NOT EXISTS (
5488 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
5489 )",
5490 params![&node.node_id],
5491 |row| row.get(0),
5492 )?;
5493 let total = resolved_count.saturating_add(unresolved_count);
5494 Ok(usize::try_from(total).unwrap_or(usize::MAX))
5495}
5496
5497fn call_tree_inner(
5498 conn: &Connection,
5499 node: &StoreNode,
5500 max_depth: usize,
5501 current_depth: usize,
5502 visited: &mut HashSet<(String, String)>,
5503) -> Result<callgraph::CallTreeNode> {
5504 let visit_key = (node.file.clone(), node.symbol.clone());
5505 if visited.contains(&visit_key) {
5506 return Ok(callgraph::CallTreeNode {
5507 name: node.symbol.clone(),
5508 file: node.file.clone(),
5509 line: node.line,
5510 signature: node.signature.clone(),
5511 resolved: true,
5512 children: Vec::new(),
5513 depth_limited: false,
5514 truncated: 0,
5515 });
5516 }
5517 visited.insert(visit_key.clone());
5518
5519 let mut children = Vec::new();
5520 let mut depth_limited = false;
5521 let mut truncated = 0usize;
5522
5523 if current_depth < max_depth {
5524 let calls = forward_calls_for_node(conn, node)?;
5525 for call in calls {
5526 match call {
5527 StoreForwardCall::Resolved(site) => {
5528 if let Some(target) = site.target {
5529 let child =
5530 call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
5531 depth_limited |= child.depth_limited;
5532 truncated += child.truncated;
5533 children.push(child);
5534 } else {
5535 children.push(callgraph::CallTreeNode {
5536 name: site.target_symbol,
5537 file: site.target_file,
5538 line: site.line,
5539 signature: None,
5540 resolved: false,
5541 children: Vec::new(),
5542 depth_limited: false,
5543 truncated: 0,
5544 });
5545 }
5546 }
5547 StoreForwardCall::Unresolved(call) => {
5548 children.push(callgraph::CallTreeNode {
5549 name: call.symbol,
5550 file: call.caller.file,
5551 line: call.line,
5552 signature: None,
5553 resolved: false,
5554 children: Vec::new(),
5555 depth_limited: false,
5556 truncated: 0,
5557 });
5558 }
5559 }
5560 }
5561 } else {
5562 truncated = forward_call_count_for_node(conn, node)?;
5563 depth_limited = truncated > 0;
5564 }
5565
5566 visited.remove(&visit_key);
5567 Ok(callgraph::CallTreeNode {
5568 name: node.symbol.clone(),
5569 file: node.file.clone(),
5570 line: node.line,
5571 signature: node.signature.clone(),
5572 resolved: true,
5573 children,
5574 depth_limited,
5575 truncated,
5576 })
5577}
5578
5579fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
5580 callgraph::TraceToSymbolHop {
5581 symbol: node.symbol.clone(),
5582 file: node.file.clone(),
5583 line: node.line,
5584 }
5585}
5586
5587fn trace_to_symbol_matches_target(
5588 node: &StoreNode,
5589 to_symbol: &str,
5590 to_file: Option<&str>,
5591) -> bool {
5592 if !symbol_query_matches(&node.symbol, to_symbol) {
5593 return false;
5594 }
5595 match to_file {
5596 Some(file) => node.file == file,
5597 None => true,
5598 }
5599}
5600
5601fn symbol_query_matches(symbol: &str, query: &str) -> bool {
5602 symbol == query || unqualified_name(symbol) == query
5603}
5604
5605fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
5606 let source = std::fs::read_to_string(path).ok()?;
5607 Some(source.lines().map(|line| line.trim().to_string()).collect())
5608}
5609
5610#[doc(hidden)]
5611pub fn live_callgraph_edge_snapshot(
5612 project_root: &Path,
5613 files: &[PathBuf],
5614) -> Result<BTreeSet<StoredEdge>> {
5615 let files = normalize_file_list(project_root, files)?;
5616 let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
5617 let mut file_data = Vec::new();
5618 for file in &files {
5619 let canon = canonicalize_path(file);
5620 let data = graph.build_file(&canon)?.clone();
5621 file_data.push((canon, data));
5622 }
5623
5624 let mut edges = BTreeSet::new();
5625 for (caller_file, data) in &file_data {
5626 for (caller_symbol, call_sites) in &data.calls_by_symbol {
5627 for call_site in call_sites {
5628 let resolution = graph.resolve_cross_file_edge(
5629 &call_site.full_callee,
5630 &call_site.callee_name,
5631 caller_file,
5632 &data.import_block,
5633 );
5634 let (target_file, target_symbol) = match resolution {
5635 EdgeResolution::Resolved { file, symbol } => (file, symbol),
5636 EdgeResolution::Unresolved { callee_name } => {
5637 if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
5638 continue;
5639 }
5640 let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
5641 data,
5642 caller_file,
5643 &callee_name,
5644 ) else {
5645 continue;
5646 };
5647 (caller_file.clone(), target_symbol)
5648 }
5649 };
5650 if target_file == *caller_file && target_symbol == *caller_symbol {
5651 continue;
5652 }
5653 edges.insert(StoredEdge {
5654 source_file: relative_path(project_root, caller_file),
5655 source_symbol: caller_symbol.clone(),
5656 target_file: relative_path(project_root, &target_file),
5657 target_symbol,
5658 kind: "call".to_string(),
5659 line: call_site.line,
5660 });
5661 }
5662 }
5663 }
5664 Ok(edges)
5665}
5666
5667fn rebuild_cooldown_records() -> &'static Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>
5668{
5669 SUCCESSFUL_REBUILDS.get_or_init(|| Mutex::new(HashMap::new()))
5670}
5671
5672fn rebuild_cooldown_key(callgraph_dir: &Path, project_key: &str) -> RebuildCooldownKey {
5673 RebuildCooldownKey {
5674 callgraph_dir: std::fs::canonicalize(callgraph_dir)
5675 .unwrap_or_else(|_| callgraph_dir.to_path_buf()),
5676 project_key: project_key.to_string(),
5677 }
5678}
5679
5680fn rebuild_cooldown_denial(
5681 callgraph_dir: &Path,
5682 project_key: &str,
5683 project_root: &Path,
5684 now: Instant,
5685) -> Option<(PathBuf, Duration)> {
5686 let key = rebuild_cooldown_key(callgraph_dir, project_key);
5687 let records = rebuild_cooldown_records()
5688 .lock()
5689 .unwrap_or_else(std::sync::PoisonError::into_inner);
5690 let record = records.get(&key)?;
5691 if record.project_root == project_root || !record.cross_root_cooldown_armed {
5692 return None;
5693 }
5694 let elapsed = now.saturating_duration_since(record.published_at);
5695 (elapsed < REBUILD_COOLDOWN).then(|| (record.project_root.clone(), REBUILD_COOLDOWN - elapsed))
5696}
5697
5698fn record_successful_rebuild(
5699 callgraph_dir: &Path,
5700 project_key: &str,
5701 project_root: &Path,
5702 published_at: Instant,
5703) {
5704 let key = rebuild_cooldown_key(callgraph_dir, project_key);
5705 let mut records = rebuild_cooldown_records()
5706 .lock()
5707 .unwrap_or_else(std::sync::PoisonError::into_inner);
5708 if records.len() >= 4_096 && !records.contains_key(&key) {
5709 if let Some(evict) = records.keys().next().cloned() {
5710 records.remove(&evict);
5711 }
5712 }
5713 let cross_root_cooldown_armed = records.get(&key).is_some_and(|previous| {
5714 previous.cross_root_cooldown_armed || previous.project_root != project_root
5715 });
5716 records.insert(
5717 key,
5718 RebuildCooldownRecord {
5719 project_root: project_root.to_path_buf(),
5720 published_at,
5721 cross_root_cooldown_armed,
5722 },
5723 );
5724}
5725
5726fn acquire_writer_lease(
5727 callgraph_dir: &Path,
5728 project_key: &str,
5729 project_root: &Path,
5730) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
5731 crate::root_cache::WriterLease::acquire_shared(
5732 crate::root_cache::RootCacheDomain::Callgraph,
5733 callgraph_dir,
5734 project_key,
5735 project_root,
5736 )
5737 .map_err(CallGraphStoreError::from)
5738}
5739
5740fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
5741 if lease.verify()? {
5742 Ok(())
5743 } else {
5744 Err(CallGraphStoreError::Unavailable(format!(
5745 "callgraph writer lease for key {} lost epoch {}; aborting write",
5746 lease.key(),
5747 lease.epoch()
5748 )))
5749 }
5750}
5751
5752fn legacy_migration_completion_line(
5753 project_key: &str,
5754 method: &str,
5755 legacy_bytes: u64,
5756 migrated_bytes: u64,
5757) -> String {
5758 format!(
5759 "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
5760 )
5761}
5762
5763fn log_legacy_migration_completion(
5764 project_key: &str,
5765 method: &str,
5766 legacy_bytes: u64,
5767 migrated_bytes: u64,
5768) {
5769 crate::slog_info!(
5770 "{}",
5771 legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
5772 );
5773}
5774
5775fn try_legacy_migration_or_fallback(
5776 callgraph_dir: &Path,
5777 project_root: &Path,
5778 project_key: &str,
5779 writer_lease: Arc<crate::root_cache::WriterLease>,
5780) -> Result<Option<CallGraphStore>> {
5781 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
5782 if partitions.is_empty() {
5783 return Ok(None);
5784 }
5785
5786 for partition in &partitions {
5787 if let Some(source) = newest_superseded_legacy_generation(partition)? {
5788 if !migration_disk_floor_allows(&source, callgraph_dir)? {
5789 return open_legacy_fallback_store(
5790 callgraph_dir,
5791 project_root,
5792 project_key,
5793 &partitions,
5794 );
5795 }
5796 match publish_generation_copy_migration(
5797 callgraph_dir,
5798 project_key,
5799 &source,
5800 Arc::clone(&writer_lease),
5801 ) {
5802 Ok(published) => {
5803 log_legacy_migration_completion(
5804 project_key,
5805 "generation_copy",
5806 source.source_bytes,
5807 published.migrated_bytes,
5808 );
5809 return CallGraphStore::open_generation(
5810 callgraph_dir,
5811 project_root.to_path_buf(),
5812 project_key.to_string(),
5813 published.generation,
5814 writer_lease,
5815 )
5816 .map(Some);
5817 }
5818 Err(error) => {
5819 crate::slog_warn!(
5820 "root-keyed callgraph generation-copy migration failed from {}: {}",
5821 source.sqlite_path.display(),
5822 error
5823 );
5824 return open_legacy_fallback_store(
5825 callgraph_dir,
5826 project_root,
5827 project_key,
5828 &partitions,
5829 );
5830 }
5831 }
5832 }
5833
5834 if let Some(source) = current_legacy_generation(partition)? {
5835 if !migration_disk_floor_allows(&source, callgraph_dir)? {
5836 return open_legacy_fallback_store(
5837 callgraph_dir,
5838 project_root,
5839 project_key,
5840 &partitions,
5841 );
5842 }
5843 match publish_backup_migration(
5844 callgraph_dir,
5845 project_key,
5846 &source,
5847 Arc::clone(&writer_lease),
5848 ) {
5849 Ok(published) => {
5850 log_legacy_migration_completion(
5851 project_key,
5852 "sqlite_backup",
5853 source.source_bytes,
5854 published.migrated_bytes,
5855 );
5856 return CallGraphStore::open_generation(
5857 callgraph_dir,
5858 project_root.to_path_buf(),
5859 project_key.to_string(),
5860 published.generation,
5861 writer_lease,
5862 )
5863 .map(Some);
5864 }
5865 Err(error) => {
5866 crate::slog_warn!(
5867 "root-keyed callgraph backup migration failed from {}: {}",
5868 source.sqlite_path.display(),
5869 error
5870 );
5871 return open_legacy_fallback_store(
5872 callgraph_dir,
5873 project_root,
5874 project_key,
5875 &partitions,
5876 );
5877 }
5878 }
5879 }
5880 }
5881
5882 open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
5883}
5884
5885fn open_legacy_fallback_store(
5886 callgraph_dir: &Path,
5887 project_root: &Path,
5888 project_key: &str,
5889 partitions: &[LegacyCallgraphPartition],
5890) -> Result<Option<CallGraphStore>> {
5891 let Some(target) = first_ready_legacy_target(partitions)? else {
5892 return Ok(None);
5893 };
5894 crate::slog_warn!(
5895 "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
5896 target.partition.harness,
5897 target.sqlite_path.display()
5898 );
5899 let conn = open_readonly_connection(&target.sqlite_path)?;
5900 if !database_ready(&conn).unwrap_or(false) {
5901 return Ok(None);
5902 }
5903 let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
5904 let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
5905 Ok(Some(CallGraphStore::from_connection(
5906 project_root.to_path_buf(),
5907 project_key.to_string(),
5908 target.sqlite_path,
5909 callgraph_dir.to_path_buf(),
5910 true,
5911 target.generation,
5912 None,
5913 Some(read_marker),
5914 conn,
5915 )))
5916}
5917
5918fn migration_disk_floor_allows(
5919 source: &LegacyCallgraphTarget,
5920 callgraph_dir: &Path,
5921) -> Result<bool> {
5922 let available = migration_available_disk(callgraph_dir)?;
5923 let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
5924 source.source_bytes,
5925 available,
5926 );
5927 if decision.should_skip_copy() {
5928 crate::slog_warn!(
5929 "{}",
5930 decision.warning_message(&source.sqlite_path, callgraph_dir)
5931 );
5932 return Ok(false);
5933 }
5934 Ok(true)
5935}
5936
5937fn migration_available_disk(path: &Path) -> Result<u64> {
5938 if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
5939 return Ok(bytes);
5940 }
5941 crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
5942}
5943
5944fn legacy_callgraph_partitions(
5945 callgraph_dir: &Path,
5946 project_key: &str,
5947) -> Result<Vec<LegacyCallgraphPartition>> {
5948 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5949 return Ok(Vec::new());
5950 };
5951 let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
5952 let mut partitions = inventory
5953 .into_iter()
5954 .filter(|entry| {
5955 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
5956 && entry.key == project_key
5957 })
5958 .map(|entry| {
5959 let dir = if entry.path.is_dir() {
5960 entry.path.clone()
5961 } else {
5962 entry
5963 .path
5964 .parent()
5965 .map(Path::to_path_buf)
5966 .unwrap_or_else(|| entry.path.clone())
5967 };
5968 LegacyCallgraphPartition {
5969 harness: entry.harness,
5970 dir,
5971 key: entry.key,
5972 bytes: entry.bytes,
5973 freshness: entry.callgraph_pointer_mtime,
5974 }
5975 })
5976 .collect::<Vec<_>>();
5977 partitions.sort_by(|left, right| {
5978 right
5979 .freshness
5980 .cmp(&left.freshness)
5981 .then_with(|| right.bytes.cmp(&left.bytes))
5982 .then_with(|| left.harness.cmp(&right.harness))
5983 });
5984 Ok(partitions)
5985}
5986
5987fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
5988 let domain_dir = callgraph_dir.parent()?;
5989 if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
5990 return None;
5991 }
5992 domain_dir.parent().map(Path::to_path_buf)
5993}
5994
5995pub(crate) fn all_legacy_partitions_migrated_for_keys(
5996 callgraph_dir: &Path,
5997 configured_keys: &BTreeSet<String>,
5998) -> Result<bool> {
5999 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6000 return Ok(false);
6001 };
6002 let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
6003 .into_iter()
6004 .filter(|entry| {
6005 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
6006 && configured_keys.contains(&entry.key)
6007 })
6008 .map(|entry| entry.key)
6009 .collect::<BTreeSet<_>>();
6010 if legacy_keys.is_empty() {
6011 return Ok(false);
6012 }
6013
6014 for key in legacy_keys {
6015 let migrated_dir = storage_root.join("callgraph").join(&key);
6016 let Some(generation) = read_pointer(&migrated_dir, &key) else {
6017 return Ok(false);
6018 };
6019 if !migration_generation_requires_manifest(&generation)
6020 || !migration_manifest_valid(&migrated_dir, &generation)
6021 {
6022 return Ok(false);
6023 }
6024 }
6025 Ok(true)
6026}
6027
6028fn newest_superseded_legacy_generation(
6029 partition: &LegacyCallgraphPartition,
6030) -> Result<Option<LegacyCallgraphTarget>> {
6031 let Some(current) = read_pointer(&partition.dir, &partition.key) else {
6032 return Ok(None);
6033 };
6034 let prefix = format!("{}.g", partition.key);
6035 let Ok(entries) = std::fs::read_dir(&partition.dir) else {
6036 return Ok(None);
6037 };
6038 let mut candidates = Vec::new();
6039 for entry in entries.flatten() {
6040 let name = entry.file_name().to_string_lossy().to_string();
6041 if name == current
6042 || name.contains(".tmp.")
6043 || !name.starts_with(&prefix)
6044 || !name.ends_with(".sqlite")
6045 {
6046 continue;
6047 }
6048 let path = entry.path();
6049 if !db_path_ready(&path) {
6050 continue;
6051 }
6052 let modified = entry
6053 .metadata()
6054 .and_then(|metadata| metadata.modified())
6055 .unwrap_or(SystemTime::UNIX_EPOCH);
6056 candidates.push((modified, path, name));
6057 }
6058 candidates.sort_by(|left, right| right.0.cmp(&left.0));
6059 let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
6060 return Ok(None);
6061 };
6062 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6063 Ok(Some(LegacyCallgraphTarget {
6064 partition: partition.clone(),
6065 sqlite_path,
6066 generation: Some(generation),
6067 source_bytes,
6068 source_blake3: String::new(),
6069 }))
6070}
6071
6072fn current_legacy_generation(
6073 partition: &LegacyCallgraphPartition,
6074) -> Result<Option<LegacyCallgraphTarget>> {
6075 let Some(target) = ready_legacy_target(partition)? else {
6076 return Ok(None);
6077 };
6078 let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
6079 if has_superseded {
6080 return Ok(None);
6081 }
6082 Ok(Some(target))
6083}
6084
6085fn freshest_legacy_fallback_target(
6086 callgraph_dir: &Path,
6087 project_key: &str,
6088) -> Result<Option<LegacyCallgraphTarget>> {
6089 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
6090 first_ready_legacy_target(&partitions)
6091}
6092
6093fn first_ready_legacy_target(
6094 partitions: &[LegacyCallgraphPartition],
6095) -> Result<Option<LegacyCallgraphTarget>> {
6096 for partition in partitions {
6097 if let Some(target) = ready_legacy_target(partition)? {
6098 return Ok(Some(target));
6099 }
6100 }
6101 Ok(None)
6102}
6103
6104fn ready_legacy_target(
6105 partition: &LegacyCallgraphPartition,
6106) -> Result<Option<LegacyCallgraphTarget>> {
6107 if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
6108 let sqlite_path = partition.dir.join(&generation);
6109 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6110 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6111 return Ok(Some(LegacyCallgraphTarget {
6112 partition: partition.clone(),
6113 sqlite_path,
6114 generation: Some(generation),
6115 source_bytes,
6116 source_blake3: String::new(),
6117 }));
6118 }
6119 }
6120
6121 let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
6122 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6123 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6124 return Ok(Some(LegacyCallgraphTarget {
6125 partition: partition.clone(),
6126 sqlite_path,
6127 generation: None,
6128 source_bytes,
6129 source_blake3: String::new(),
6130 }));
6131 }
6132 Ok(None)
6133}
6134
6135fn publish_generation_copy_migration(
6136 callgraph_dir: &Path,
6137 project_key: &str,
6138 source: &LegacyCallgraphTarget,
6139 writer_lease: Arc<crate::root_cache::WriterLease>,
6140) -> Result<PublishedLegacyMigration> {
6141 let generation = migration_generation_file_name(project_key, "copy");
6142 let temp_path = migration_temp_path(callgraph_dir, &generation);
6143 remove_sqlite_file_set(&temp_path);
6144 copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
6145 fail_after_temp_copy_for_test()?;
6146
6147 let mut source = source.clone();
6148 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
6149 source.source_blake3 = fingerprint.blake3;
6150 let generation = publish_migrated_generation(
6151 callgraph_dir,
6152 project_key,
6153 &generation,
6154 &temp_path,
6155 &source,
6156 fingerprint.bytes,
6157 writer_lease,
6158 "generation_copy",
6159 )?;
6160 Ok(PublishedLegacyMigration {
6161 generation,
6162 migrated_bytes: fingerprint.bytes,
6163 })
6164}
6165
6166fn publish_backup_migration(
6167 callgraph_dir: &Path,
6168 project_key: &str,
6169 source: &LegacyCallgraphTarget,
6170 writer_lease: Arc<crate::root_cache::WriterLease>,
6171) -> Result<PublishedLegacyMigration> {
6172 if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
6173 return Err(CallGraphStoreError::Unavailable(
6174 "legacy callgraph backup migration budget exhausted by test seam".to_string(),
6175 ));
6176 }
6177
6178 let generation = migration_generation_file_name(project_key, "backup");
6179 let temp_path = migration_temp_path(callgraph_dir, &generation);
6180 remove_sqlite_file_set(&temp_path);
6181
6182 let source_conn = open_readonly_connection(&source.sqlite_path)?;
6183 let mut destination = Connection::open(&temp_path)?;
6184 destination.busy_timeout(Duration::from_secs(5))?;
6185 let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
6186 let started = Instant::now();
6187 let mut retries = 0;
6188 loop {
6189 match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
6190 rusqlite::backup::StepResult::Done => break,
6191 rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
6192 rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
6193 retries += 1;
6194 if retries > MIGRATION_BACKUP_RETRY_BUDGET
6195 || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
6196 {
6197 return Err(CallGraphStoreError::Unavailable(format!(
6198 "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
6199 )));
6200 }
6201 std::thread::sleep(Duration::from_millis(20));
6202 }
6203 _ => {
6204 return Err(CallGraphStoreError::Unavailable(
6205 "legacy callgraph backup returned an unknown step result".to_string(),
6206 ));
6207 }
6208 }
6209 }
6210 drop(backup);
6211
6212 let integrity: String =
6213 destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
6214 if integrity != "ok" {
6215 return Err(CallGraphStoreError::Unavailable(format!(
6216 "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
6217 )));
6218 }
6219 if !database_ready(&destination)? {
6220 return Err(CallGraphStoreError::Unavailable(
6221 "legacy callgraph backup produced a database without ready metadata".to_string(),
6222 ));
6223 }
6224 destination.execute_batch("PRAGMA optimize;")?;
6225 drop(destination);
6226 sync_file(&temp_path)?;
6227 fail_after_temp_copy_for_test()?;
6228
6229 let mut source = source.clone();
6230 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
6231 source.source_blake3 = fingerprint.blake3;
6232 let generation = publish_migrated_generation(
6233 callgraph_dir,
6234 project_key,
6235 &generation,
6236 &temp_path,
6237 &source,
6238 fingerprint.bytes,
6239 writer_lease,
6240 "sqlite_backup",
6241 )?;
6242 Ok(PublishedLegacyMigration {
6243 generation,
6244 migrated_bytes: fingerprint.bytes,
6245 })
6246}
6247
6248fn publish_migrated_generation(
6249 callgraph_dir: &Path,
6250 project_key: &str,
6251 generation: &str,
6252 temp_path: &Path,
6253 source: &LegacyCallgraphTarget,
6254 migrated_bytes: u64,
6255 writer_lease: Arc<crate::root_cache::WriterLease>,
6256 method: &str,
6257) -> Result<String> {
6258 let gen_path = callgraph_dir.join(generation);
6259 checkpoint_sqlite_before_publication(temp_path);
6260 let publication = publish_if_current(|| {
6261 verify_writer_lease(&writer_lease)?;
6262 remove_sqlite_file_set(&gen_path);
6263 rename_sqlite_file_set(temp_path, &gen_path)?;
6264 crate::fs_lock::sync_parent(&gen_path);
6265
6266 verify_writer_lease(&writer_lease)?;
6267 publish_pointer(callgraph_dir, project_key, generation)?;
6268 write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
6269 Ok(generation.to_string())
6270 });
6271 if matches!(publication, Err(CallGraphStoreError::Superseded)) {
6272 remove_sqlite_file_set(temp_path);
6273 }
6274 publication
6275}
6276
6277fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
6278 if let Some(parent) = destination.parent() {
6279 std::fs::create_dir_all(parent)?;
6280 }
6281 for suffix in SQLITE_FILE_SET_SUFFIXES {
6282 let source_path = sqlite_file_set_path(source, suffix);
6283 if !source_path.is_file() {
6284 continue;
6285 }
6286 let destination_path = sqlite_file_set_path(destination, suffix);
6287 std::fs::copy(&source_path, &destination_path)?;
6288 sync_file(&destination_path)?;
6289 }
6290 Ok(())
6291}
6292
6293fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
6294 for suffix in SQLITE_FILE_SET_SUFFIXES {
6295 let source_path = sqlite_file_set_path(source, suffix);
6296 if !source_path.exists() {
6297 continue;
6298 }
6299 let destination_path = sqlite_file_set_path(destination, suffix);
6300 if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
6301 let _ = std::fs::remove_file(&source_path);
6302 return Err(error.into());
6303 }
6304 }
6305 Ok(())
6306}
6307
6308fn sqlite_file_set_size(path: &Path) -> Result<u64> {
6309 let mut bytes = 0_u64;
6310 for suffix in SQLITE_FILE_SET_SUFFIXES {
6311 let member = sqlite_file_set_path(path, suffix);
6312 if !member.is_file() {
6313 continue;
6314 }
6315 bytes = bytes.saturating_add(member.metadata()?.len());
6316 }
6317 Ok(bytes)
6318}
6319
6320fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
6321 let mut hasher = blake3::Hasher::new();
6322 let mut bytes = 0_u64;
6323 let mut buffer = [0_u8; 64 * 1024];
6324 for suffix in SQLITE_FILE_SET_SUFFIXES {
6325 let member = sqlite_file_set_path(path, suffix);
6326 if !member.is_file() {
6327 continue;
6328 }
6329 hasher.update(suffix.as_bytes());
6330 let mut file = std::fs::File::open(&member)?;
6331 loop {
6332 let read = file.read(&mut buffer)?;
6333 if read == 0 {
6334 break;
6335 }
6336 bytes = bytes.saturating_add(read as u64);
6337 hasher.update(&buffer[..read]);
6338 }
6339 }
6340 Ok(SourceFingerprint {
6341 bytes,
6342 blake3: hash_to_hex(hasher.finalize()),
6343 })
6344}
6345
6346fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
6347 if suffix.is_empty() {
6348 path.to_path_buf()
6349 } else {
6350 PathBuf::from(format!("{}{suffix}", path.display()))
6351 }
6352}
6353
6354fn sync_file(path: &Path) -> Result<()> {
6355 let file = std::fs::OpenOptions::new()
6356 .read(true)
6357 .write(true)
6358 .open(path)?;
6359 file.sync_all()?;
6360 Ok(())
6361}
6362
6363fn fail_after_temp_copy_for_test() -> Result<()> {
6364 if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
6365 return Err(CallGraphStoreError::Unavailable(
6366 "legacy callgraph migration stopped after temp copy by test seam".to_string(),
6367 ));
6368 }
6369 Ok(())
6370}
6371
6372fn migration_generation_file_name(project_key: &str, method: &str) -> String {
6373 format!(
6374 "{project_key}.g{}.{}{}{}.sqlite",
6375 now_nanos(),
6376 std::process::id(),
6377 MIGRATION_GENERATION_TAG,
6378 method
6379 )
6380}
6381
6382fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
6383 callgraph_dir.join(format!(
6384 "{generation}.tmp.{}.{}",
6385 std::process::id(),
6386 now_nanos()
6387 ))
6388}
6389
6390fn write_migration_manifest(
6391 callgraph_dir: &Path,
6392 generation: &str,
6393 source: &LegacyCallgraphTarget,
6394 migrated_bytes: u64,
6395 method: &str,
6396) -> Result<()> {
6397 let manifest_path = migration_manifest_path(callgraph_dir, generation);
6398 let temp_path = manifest_path.with_extension(format!(
6399 "migration.json.tmp.{}.{}",
6400 std::process::id(),
6401 now_nanos()
6402 ));
6403 let manifest = serde_json::json!({
6404 "version": MIGRATION_MANIFEST_VERSION,
6405 "method": method,
6406 "target_generation": generation,
6407 "source_harness": source.partition.harness,
6408 "source_path": source.sqlite_path.display().to_string(),
6409 "source_generation": source.generation,
6410 "source_bytes": source.source_bytes,
6411 "source_blake3": source.source_blake3,
6412 "migrated_bytes": migrated_bytes,
6413 });
6414 {
6415 use std::io::Write as _;
6416 let mut file = std::fs::File::create(&temp_path)?;
6417 file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
6418 file.write_all(b"\n")?;
6419 file.sync_all()?;
6420 }
6421 if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
6422 let _ = std::fs::remove_file(&temp_path);
6423 return Err(error.into());
6424 }
6425 crate::fs_lock::sync_parent(&manifest_path);
6426 Ok(())
6427}
6428
6429fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
6430 callgraph_dir.join(format!("{generation}.migration.json"))
6431}
6432
6433fn migration_generation_requires_manifest(generation: &str) -> bool {
6434 generation.contains(MIGRATION_GENERATION_TAG)
6435}
6436
6437fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
6438 if !migration_generation_requires_manifest(generation) {
6439 return true;
6440 }
6441 let path = migration_manifest_path(callgraph_dir, generation);
6442 let Ok(bytes) = std::fs::read(path) else {
6443 return false;
6444 };
6445 let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
6446 return false;
6447 };
6448 value.get("version").and_then(serde_json::Value::as_u64)
6449 == Some(MIGRATION_MANIFEST_VERSION as u64)
6450 && value
6451 .get("target_generation")
6452 .and_then(serde_json::Value::as_str)
6453 == Some(generation)
6454 && value
6455 .get("source_bytes")
6456 .and_then(serde_json::Value::as_u64)
6457 .is_some_and(|bytes| bytes > 0)
6458 && value
6459 .get("source_blake3")
6460 .and_then(serde_json::Value::as_str)
6461 .is_some_and(|hash| hash.len() == 64)
6462}
6463
6464fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
6465 let pointer_generation = read_pointer(callgraph_dir, project_key);
6466 if let Some(generation) = pointer_generation.as_deref() {
6467 if migration_generation_requires_manifest(generation)
6468 && !migration_manifest_valid(callgraph_dir, generation)
6469 {
6470 let path = callgraph_dir.join(generation);
6471 remove_sqlite_file_set(&path);
6472 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
6473 let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
6474 }
6475 }
6476
6477 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6478 return;
6479 };
6480 for entry in entries.flatten() {
6481 let name = entry.file_name().to_string_lossy().to_string();
6482 let path = entry.path();
6483 if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
6484 let _ = std::fs::remove_file(path);
6485 continue;
6486 }
6487 if name.starts_with(&format!("{project_key}.g"))
6488 && name.ends_with(".sqlite")
6489 && name.contains(MIGRATION_GENERATION_TAG)
6490 && pointer_generation.as_deref() != Some(&name)
6491 && !migration_manifest_valid(callgraph_dir, &name)
6492 {
6493 remove_sqlite_file_set(&path);
6494 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
6495 }
6496 }
6497 crate::fs_lock::sync_parent(callgraph_dir);
6498}
6499
6500fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
6501 let mut hasher = blake3::Hasher::new();
6502 hasher.update(path.to_string_lossy().as_bytes());
6503 if let Some(generation) = generation {
6504 hasher.update(generation.as_bytes());
6505 }
6506 let digest = hash_to_hex(hasher.finalize());
6507 format!("legacy-{}", &digest[..16])
6508}
6509
6510fn open_readonly_connection(path: &Path) -> Result<Connection> {
6511 let uri = sqlite_readonly_uri(path);
6512 let conn = Connection::open_with_flags(
6513 &uri,
6514 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
6515 )?;
6516 conn.pragma_update(
6517 None,
6518 "synchronous",
6519 if write_amplification_baseline_enabled() {
6520 "FULL"
6521 } else {
6522 "NORMAL"
6523 },
6524 )?;
6525 conn.busy_timeout(reader_busy_timeout())?;
6526 conn.execute_batch("PRAGMA query_only=ON;")?;
6527 Ok(conn)
6528}
6529
6530fn reader_busy_timeout() -> Duration {
6531 let jitter = (now_nanos() % 500) as u64;
6532 Duration::from_millis(250 + jitter)
6533}
6534
6535fn sqlite_readonly_uri(path: &Path) -> String {
6536 let raw = path.to_string_lossy().replace('\\', "/");
6537 let encoded = percent_encode_sqlite_uri_path(&raw);
6538 if raw.starts_with('/') {
6539 format!("file://{encoded}?mode=ro")
6540 } else if raw.as_bytes().get(1) == Some(&b':') {
6541 format!("file:///{encoded}?mode=ro")
6542 } else {
6543 format!("file:{encoded}?mode=ro")
6544 }
6545}
6546
6547fn percent_encode_sqlite_uri_path(path: &str) -> String {
6548 let mut encoded = String::with_capacity(path.len());
6549 for byte in path.bytes() {
6550 match byte {
6551 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
6552 encoded.push(byte as char)
6553 }
6554 _ => encoded.push_str(&format!("%{byte:02X}")),
6555 }
6556 }
6557 encoded
6558}
6559
6560fn configure_connection(conn: &Connection) -> Result<()> {
6561 conn.busy_timeout(Duration::from_secs(5))?;
6565 conn.pragma_update(None, "journal_mode", "WAL")?;
6566 let baseline = write_amplification_baseline_enabled();
6567 conn.pragma_update(
6568 None,
6569 "synchronous",
6570 if baseline { "FULL" } else { "NORMAL" },
6571 )?;
6572 conn.pragma_update(
6573 None,
6574 "wal_autocheckpoint",
6575 if baseline {
6576 1_000
6577 } else {
6578 CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES
6579 },
6580 )?;
6581 conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
6582 Ok(())
6583}
6584
6585fn configure_build_connection(conn: &Connection) -> Result<()> {
6586 conn.busy_timeout(Duration::from_secs(5))?;
6591 conn.pragma_update(None, "journal_mode", "WAL")?;
6592 conn.pragma_update(
6593 None,
6594 "synchronous",
6595 if write_amplification_baseline_enabled() {
6596 "FULL"
6597 } else {
6598 "NORMAL"
6599 },
6600 )?;
6601 conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
6602 Ok(())
6603}
6604
6605fn checkpoint_sqlite_before_publication(path: &Path) {
6609 let Ok(conn) = Connection::open(path) else {
6610 return;
6611 };
6612 let _ = conn.pragma_update(None, "synchronous", "NORMAL");
6613 let _ = conn.busy_timeout(Duration::from_secs(5));
6614 let _ = checkpoint_wal_truncate(&conn);
6615}
6616
6617fn checkpoint_wal_truncate(conn: &Connection) -> bool {
6618 match conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
6619 row.get::<_, i64>(0)
6620 }) {
6621 Ok(0) => true,
6622 Ok(_) => false,
6623 Err(rusqlite::Error::SqliteFailure(error, _))
6624 if matches!(
6625 error.code,
6626 rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
6627 ) =>
6628 {
6629 false
6630 }
6631 Err(error) => {
6632 log::debug!("callgraph WAL truncate checkpoint skipped: {error}");
6633 false
6634 }
6635 }
6636}
6637
6638fn initialize_schema(conn: &Connection) -> Result<()> {
6639 conn.execute_batch(
6640 "CREATE TABLE IF NOT EXISTS files (
6641 path TEXT PRIMARY KEY,
6642 content_hash TEXT NOT NULL,
6643 mtime_ns INTEGER NOT NULL,
6644 size INTEGER NOT NULL,
6645 lang TEXT NOT NULL,
6646 is_dead_code_root INTEGER NOT NULL DEFAULT 0,
6647 is_public_api INTEGER NOT NULL DEFAULT 0,
6648 surface_fingerprint TEXT NOT NULL,
6649 indexed_at INTEGER NOT NULL
6650 );
6651
6652 CREATE TABLE IF NOT EXISTS nodes (
6653 id TEXT PRIMARY KEY,
6654 file_path TEXT NOT NULL,
6655 name TEXT NOT NULL,
6656 scoped_name TEXT NOT NULL,
6657 kind TEXT NOT NULL,
6658 start_line INTEGER NOT NULL,
6659 start_col INTEGER NOT NULL,
6660 end_line INTEGER NOT NULL,
6661 end_col INTEGER NOT NULL,
6662 range_ordinal INTEGER NOT NULL,
6663 signature TEXT,
6664 exported INTEGER NOT NULL,
6665 is_default_export INTEGER NOT NULL,
6666 is_type_like INTEGER NOT NULL,
6667 is_callgraph_entry_point INTEGER NOT NULL,
6668 provenance TEXT NOT NULL,
6669 UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
6670 );
6671 CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
6672 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
6673 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
6674
6675 CREATE TABLE IF NOT EXISTS refs (
6676 ref_id TEXT PRIMARY KEY,
6677 caller_node TEXT,
6678 caller_file TEXT NOT NULL,
6679 kind TEXT NOT NULL,
6680 short_name TEXT,
6681 full_ref TEXT,
6682 module_path TEXT,
6683 import_kind TEXT,
6684 local_name TEXT,
6685 requested_name TEXT,
6686 namespace_alias TEXT,
6687 wildcard INTEGER NOT NULL DEFAULT 0,
6688 line INTEGER NOT NULL,
6689 byte_start INTEGER NOT NULL,
6690 byte_end INTEGER NOT NULL,
6691 status TEXT NOT NULL,
6692 target_node TEXT,
6693 target_file TEXT,
6694 target_symbol TEXT,
6695 provenance TEXT NOT NULL
6696 );
6697 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
6698 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
6699 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
6700 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
6701 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
6702
6703 CREATE TABLE IF NOT EXISTS file_dependencies (
6704 file_path TEXT NOT NULL,
6705 dep_file TEXT NOT NULL,
6706 PRIMARY KEY(file_path, dep_file)
6707 );
6708 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
6709
6710 CREATE TABLE IF NOT EXISTS edges (
6711 edge_id TEXT PRIMARY KEY,
6712 ref_id TEXT NOT NULL,
6713 source_node TEXT NOT NULL,
6714 target_node TEXT,
6715 target_file TEXT NOT NULL,
6716 target_symbol TEXT NOT NULL,
6717 kind TEXT NOT NULL,
6718 line INTEGER NOT NULL,
6719 provenance TEXT NOT NULL
6720 );
6721 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
6722 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
6723 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
6724 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
6725
6726 CREATE TABLE IF NOT EXISTS dispatch_hints (
6727 id TEXT PRIMARY KEY,
6728 method_name TEXT NOT NULL,
6729 caller_node TEXT NOT NULL,
6730 file TEXT NOT NULL,
6731 line INTEGER NOT NULL,
6732 byte_start INTEGER NOT NULL,
6733 byte_end INTEGER NOT NULL,
6734 provenance TEXT NOT NULL
6735 );
6736 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
6737 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
6738
6739 CREATE TABLE IF NOT EXISTS type_ref_names (
6740 name TEXT PRIMARY KEY
6741 );
6742
6743 CREATE TABLE IF NOT EXISTS backend_file_state (
6744 backend TEXT NOT NULL,
6745 workspace_root TEXT NOT NULL,
6746 file_path TEXT NOT NULL,
6747 content_hash TEXT NOT NULL,
6748 status TEXT NOT NULL,
6749 updated_at INTEGER NOT NULL,
6750 PRIMARY KEY(backend, workspace_root, file_path, content_hash)
6751 );
6752 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
6753
6754 CREATE TABLE IF NOT EXISTS meta (
6755 k TEXT PRIMARY KEY,
6756 v TEXT NOT NULL
6757 );
6758
6759 -- The file walk is staged on disk so extraction can page through a
6760 -- deterministic inventory without retaining every source path in heap.
6761 CREATE TABLE IF NOT EXISTS staging_file_inventory (
6762 path TEXT PRIMARY KEY,
6763 size INTEGER NOT NULL
6764 ) WITHOUT ROWID;
6765
6766 -- Context needed only while a generation is staged. Raw refs live in
6767 -- `refs` with status `staged`; this table preserves the caller symbol
6768 -- needed to avoid inventing self edges during the later resolve pass.
6769 CREATE TABLE IF NOT EXISTS staging_ref_context (
6770 ref_id TEXT PRIMARY KEY,
6771 caller_symbol TEXT
6772 );",
6773 )?;
6774 insert_meta(conn)?;
6775 Ok(())
6776}
6777
6778fn insert_meta(conn: &Connection) -> Result<()> {
6779 conn.execute(
6780 "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
6781 params![SCHEMA_VERSION.to_string()],
6782 )?;
6783 conn.execute(
6784 "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
6785 params![schema_fingerprint()],
6786 )?;
6787 conn.execute(
6788 "INSERT OR IGNORE INTO meta(k, v) VALUES('projection_write_revision', '0')",
6789 [],
6790 )?;
6791 Ok(())
6792}
6793
6794fn projection_write_revision(conn: &Connection) -> Result<Option<u64>> {
6798 let revision: Option<String> = conn
6799 .query_row(
6800 "SELECT v FROM meta WHERE k = 'projection_write_revision'",
6801 [],
6802 |row| row.get(0),
6803 )
6804 .optional()?;
6805 revision
6806 .map(|revision| {
6807 revision.parse::<u64>().map_err(|error| {
6808 CallGraphStoreError::Unavailable(format!(
6809 "callgraph projection write revision is invalid: {error}"
6810 ))
6811 })
6812 })
6813 .transpose()
6814}
6815
6816fn bump_projection_write_revision(tx: &Transaction<'_>) -> Result<()> {
6819 tx.execute(
6820 "INSERT INTO meta(k, v) VALUES('projection_write_revision', '1')
6821 ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1",
6822 [],
6823 )?;
6824 Ok(())
6825}
6826
6827fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
6828 conn.execute(
6829 "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
6830 params![if ready { "1" } else { "0" }],
6831 )?;
6832 Ok(())
6833}
6834
6835fn database_ready(conn: &Connection) -> Result<bool> {
6836 let schema_version: Option<String> = conn
6837 .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
6838 row.get(0)
6839 })
6840 .optional()?;
6841 let fingerprint: Option<String> = conn
6842 .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
6843 row.get(0)
6844 })
6845 .optional()?;
6846 let ready: Option<String> = conn
6847 .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
6848 .optional()?;
6849
6850 let expected_schema = SCHEMA_VERSION.to_string();
6851 let expected_fingerprint = schema_fingerprint();
6852 Ok(schema_version.as_deref() == Some(expected_schema.as_str())
6853 && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
6854 && ready.as_deref() == Some("1"))
6855}
6856
6857fn ensure_database_ready(conn: &Connection) -> Result<()> {
6858 if database_ready(conn)? {
6859 Ok(())
6860 } else {
6861 Err(CallGraphStoreError::Unavailable(
6862 "database is missing, stale, or mid-build".to_string(),
6863 ))
6864 }
6865}
6866
6867fn schema_fingerprint() -> String {
6868 let input =
6873 format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
6874 hash_to_hex(blake3::hash(input.as_bytes()))
6875}
6876
6877fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
6878 tx.execute_batch(
6879 "DELETE FROM staging_ref_context;
6880 DELETE FROM edges;
6881 DELETE FROM file_dependencies;
6882 DELETE FROM refs;
6883 DELETE FROM dispatch_hints;
6884 DELETE FROM type_ref_names;
6885 DELETE FROM backend_file_state;
6886 DELETE FROM nodes;
6887 DELETE FROM files;",
6888 )?;
6889 Ok(())
6890}
6891
6892fn staged_build_phase(conn: &Connection) -> Result<Option<String>> {
6893 conn.query_row(
6894 "SELECT v FROM meta WHERE k = ?1",
6895 params![STAGED_BUILD_PHASE],
6896 |row| row.get(0),
6897 )
6898 .optional()
6899 .map_err(Into::into)
6900}
6901
6902fn staged_u64(conn: &Connection, key: &str) -> Result<u64> {
6903 let value = staged_string(conn, key)?;
6904 Ok(value.and_then(|value| value.parse().ok()).unwrap_or(0))
6905}
6906
6907fn staged_string(conn: &Connection, key: &str) -> Result<Option<String>> {
6908 conn.query_row("SELECT v FROM meta WHERE k = ?1", params![key], |row| {
6909 row.get::<_, String>(0)
6910 })
6911 .optional()
6912 .map_err(Into::into)
6913}
6914
6915fn set_staged_build_phase(tx: &Transaction<'_>, phase: &str) -> Result<()> {
6916 tx.execute(
6917 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
6918 params![STAGED_BUILD_PHASE, phase],
6919 )?;
6920 Ok(())
6921}
6922
6923fn set_staged_u64(tx: &Transaction<'_>, key: &str, value: u64) -> Result<()> {
6924 set_staged_string(tx, key, &value.to_string())
6925}
6926
6927fn set_staged_string(tx: &Transaction<'_>, key: &str, value: &str) -> Result<()> {
6928 tx.execute(
6929 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
6930 params![key, value],
6931 )?;
6932 Ok(())
6933}
6934
6935fn increment_staged_extracted_bytes(tx: &Transaction<'_>, bytes: u64) -> Result<()> {
6939 tx.execute(
6940 "INSERT INTO meta(k, v) VALUES(?1, ?2)
6941 ON CONFLICT(k) DO UPDATE SET v = CAST(meta.v AS INTEGER) + excluded.v",
6942 params![STAGED_COMMITTED_EXTRACTED_BYTES, bytes.to_string()],
6943 )?;
6944 Ok(())
6945}
6946
6947fn staged_content_matches(conn: &Connection, project_root: &Path, path: &Path) -> Result<bool> {
6948 let Ok(source) = std::fs::read_to_string(path) else {
6949 return Ok(false);
6950 };
6951 let Ok(freshness) = collect_source_freshness(path, &source) else {
6952 return Ok(false);
6953 };
6954 let rel_path = relative_path(project_root, path);
6955 let staged_hash = conn
6956 .query_row(
6957 "SELECT content_hash FROM files WHERE path = ?1",
6958 params![rel_path],
6959 |row| row.get::<_, String>(0),
6960 )
6961 .optional()?;
6962 Ok(staged_hash.as_deref() == Some(hash_to_hex(freshness.content_hash).as_str()))
6963}
6964
6965fn delete_staged_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
6966 tx.execute(
6967 "DELETE FROM staging_ref_context
6968 WHERE ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?1)",
6969 params![rel_path],
6970 )?;
6971 delete_file_rows(tx, rel_path)
6972}
6973
6974fn prune_staged_files_not_in_inventory(conn: &mut Connection) -> Result<()> {
6975 loop {
6976 let removed = {
6977 let mut statement = conn.prepare(
6978 "SELECT path
6979 FROM files
6980 WHERE NOT EXISTS (
6981 SELECT 1 FROM staging_file_inventory inventory
6982 WHERE inventory.path = files.path
6983 )
6984 ORDER BY path
6985 LIMIT ?1",
6986 )?;
6987 let paths = statement
6988 .query_map(params![COLD_BUILD_EXTRACT_BATCH_FILES as i64], |row| {
6989 row.get::<_, String>(0)
6990 })?
6991 .collect::<std::result::Result<Vec<_>, _>>()?;
6992 paths
6993 };
6994 if removed.is_empty() {
6995 return Ok(());
6996 }
6997 let tx = conn.transaction()?;
6998 for path in removed {
6999 delete_staged_file_rows(&tx, &path)?;
7000 }
7001 tx.commit()?;
7002 }
7003}
7004
7005struct StagedFileBatch {
7006 paths: Vec<PathBuf>,
7007 last_path: String,
7008}
7009
7010fn load_staged_file_batch(
7011 conn: &Connection,
7012 project_root: &Path,
7013 after_path: &str,
7014 max_files: usize,
7015 max_bytes: u64,
7016) -> Result<Option<StagedFileBatch>> {
7017 let mut statement = conn.prepare(
7018 "SELECT path, size
7019 FROM staging_file_inventory
7020 WHERE path > ?1
7021 ORDER BY path
7022 LIMIT ?2",
7023 )?;
7024 let mut rows = statement.query(params![after_path, max_files.max(1) as i64])?;
7025 let mut paths = Vec::with_capacity(max_files.max(1));
7026 let mut last_path = String::new();
7027 let mut batch_bytes = 0u64;
7028 while let Some(row) = rows.next()? {
7029 let rel_path = row.get::<_, String>(0)?;
7030 let size = row.get::<_, i64>(1)?.max(0) as u64;
7031 if !paths.is_empty() && batch_bytes.saturating_add(size) > max_bytes {
7032 break;
7033 }
7034 batch_bytes = batch_bytes.saturating_add(size);
7035 last_path.clone_from(&rel_path);
7036 paths.push(project_root.join(rel_path));
7037 }
7038 if paths.is_empty() {
7039 Ok(None)
7040 } else {
7041 Ok(Some(StagedFileBatch { paths, last_path }))
7042 }
7043}
7044
7045fn staged_corpus_fingerprint(conn: &Connection, project_root: &Path) -> Result<String> {
7046 let mut statement = conn.prepare("SELECT path FROM staging_file_inventory ORDER BY path")?;
7047 let mut rows = statement.query([])?;
7048 let mut fingerprint = CorpusFingerprint::default();
7049 while let Some(row) = rows.next()? {
7050 let rel_path = row.get::<_, String>(0)?;
7051 fingerprint.add_path(project_root, &project_root.join(rel_path));
7052 }
7053 Ok(fingerprint.finish(project_root))
7054}
7055
7056fn load_staged_ref_window(
7057 conn: &Connection,
7058 after_rowid: u64,
7059 limit: usize,
7060) -> Result<Vec<StagedRef>> {
7061 let mut statement = conn.prepare(
7062 "SELECT refs.rowid, refs.ref_id, refs.caller_node, refs.caller_file, refs.kind,
7063 refs.short_name, refs.full_ref, refs.module_path, refs.import_kind,
7064 refs.local_name, refs.requested_name, refs.namespace_alias, refs.wildcard,
7065 refs.line, refs.byte_start, refs.byte_end, staging_ref_context.caller_symbol
7066 FROM refs
7067 LEFT JOIN staging_ref_context ON staging_ref_context.ref_id = refs.ref_id
7068 WHERE refs.status = 'staged' AND refs.rowid > ?1
7069 ORDER BY refs.rowid
7070 LIMIT ?2",
7071 )?;
7072 let rows = statement.query_map(params![after_rowid as i64, limit as i64], |row| {
7073 Ok(StagedRef {
7074 rowid: row.get::<_, i64>(0)? as u64,
7075 raw: RawRef {
7076 ref_id: row.get(1)?,
7077 caller_node: row.get(2)?,
7078 caller_file: row.get(3)?,
7079 kind: row.get(4)?,
7080 short_name: row.get(5)?,
7081 full_ref: row.get(6)?,
7082 module_path: row.get(7)?,
7083 import_kind: row.get(8)?,
7084 local_name: row.get(9)?,
7085 requested_name: row.get(10)?,
7086 namespace_alias: row.get(11)?,
7087 wildcard: row.get::<_, i64>(12)? != 0,
7088 line: row.get::<_, i64>(13)? as u32,
7089 byte_start: row.get::<_, i64>(14)? as usize,
7090 byte_end: row.get::<_, i64>(15)? as usize,
7091 caller_symbol: row.get(16)?,
7092 dependencies: BTreeSet::new(),
7093 },
7094 })
7095 })?;
7096 let mut refs = rows.collect::<std::result::Result<Vec<_>, _>>()?;
7097 drop(statement);
7098
7099 let mut dependencies = HashMap::<String, BTreeSet<String>>::new();
7100 let mut dependency_statement = conn
7101 .prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1 ORDER BY dep_file")?;
7102 for raw in refs.iter_mut().map(|entry| &mut entry.raw) {
7103 if !dependencies.contains_key(&raw.caller_file) {
7104 let rows =
7105 dependency_statement.query_map(params![raw.caller_file], |row| row.get(0))?;
7106 let values = rows.collect::<std::result::Result<BTreeSet<_>, _>>()?;
7107 dependencies.insert(raw.caller_file.clone(), values);
7108 }
7109 raw.dependencies = dependencies
7110 .get(&raw.caller_file)
7111 .cloned()
7112 .unwrap_or_default();
7113 }
7114 Ok(refs)
7115}
7116
7117fn unresolved_staged_ref(raw: RawRef) -> ResolvedRef {
7118 ResolvedRef {
7119 dependencies: raw.dependencies.clone(),
7120 raw,
7121 status: "unresolved".to_string(),
7122 target_node: None,
7123 target_file: None,
7124 target_symbol: None,
7125 edge: None,
7126 }
7127}
7128
7129fn query_count(conn: &Connection, query: &str) -> Result<u64> {
7130 conn.query_row(query, [], |row| row.get::<_, i64>(0))
7131 .map(|count| count.max(0) as u64)
7132 .map_err(Into::into)
7133}
7134
7135fn staged_failed_files(conn: &Connection) -> Result<Vec<String>> {
7136 let mut statement = conn.prepare(
7137 "SELECT DISTINCT file_path FROM backend_file_state WHERE status = 'stale' ORDER BY file_path",
7138 )?;
7139 let rows = statement.query_map([], |row| row.get(0))?;
7140 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
7141}
7142
7143fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
7144 tx.execute_batch(
7145 "DROP INDEX IF EXISTS idx_nodes_file;
7146 DROP INDEX IF EXISTS idx_nodes_name;
7147 DROP INDEX IF EXISTS idx_nodes_scoped;
7148 DROP INDEX IF EXISTS idx_refs_short_name;
7149 DROP INDEX IF EXISTS idx_refs_kind_caller_file;
7150 DROP INDEX IF EXISTS idx_refs_caller_file;
7151 DROP INDEX IF EXISTS idx_refs_caller_node_kind;
7152 DROP INDEX IF EXISTS idx_refs_target_file;
7153 DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
7154 DROP INDEX IF EXISTS idx_edges_source_kind;
7155 DROP INDEX IF EXISTS idx_edges_target_kind;
7156 DROP INDEX IF EXISTS idx_edges_target_file_symbol;
7157 DROP INDEX IF EXISTS idx_edges_ref_id;
7158 DROP INDEX IF EXISTS idx_dispatch_hints_method;
7159 DROP INDEX IF EXISTS idx_dispatch_hints_file;
7160 DROP INDEX IF EXISTS idx_backend_file_state_file;",
7161 )?;
7162 Ok(())
7163}
7164
7165fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
7166 tx.execute_batch(
7167 "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
7168 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
7169 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
7170 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
7171 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
7172 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
7173 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
7174 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
7175 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
7176 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
7177 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
7178 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
7179 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
7180 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
7181 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
7182 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
7183 )?;
7184 Ok(())
7185}
7186
7187const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
7188 ("files", "path"),
7189 ("nodes", "file_path"),
7190 ("refs", "caller_file"),
7191 ("refs", "target_file"),
7192 ("file_dependencies", "file_path"),
7193 ("file_dependencies", "dep_file"),
7194 ("edges", "target_file"),
7195 ("dispatch_hints", "file"),
7196 ("backend_file_state", "file_path"),
7197];
7198
7199fn reconcile_workspace_roots(
7212 conn: &mut Connection,
7213 project_root: &Path,
7214 allow_repair: bool,
7215) -> Result<OpenRootRepair> {
7216 let roots = stored_workspace_roots(conn)?;
7217 let current_root = project_root.display().to_string();
7218 if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
7219 return Ok(OpenRootRepair::None);
7220 }
7221
7222 if let Some(sample) = sample_absolute_data_path(conn)? {
7223 return Ok(OpenRootRepair::NeedsRebuild {
7224 previous_roots: roots,
7225 current_root,
7226 reason: format!("absolute store data path row {sample}"),
7227 });
7228 }
7229
7230 for stored_root in roots.iter() {
7231 if stored_root == ¤t_root {
7232 continue;
7233 }
7234 if Path::new(stored_root).exists() {
7235 let reason = format!(
7236 "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
7237 );
7238 return Ok(OpenRootRepair::NeedsRebuild {
7239 previous_roots: roots,
7240 current_root,
7241 reason,
7242 });
7243 }
7244 }
7245
7246 if !allow_repair {
7247 return Ok(OpenRootRepair::NeedsRebuild {
7248 previous_roots: roots,
7249 current_root,
7250 reason: "workspace root metadata requires deferred repair".to_string(),
7251 });
7252 }
7253
7254 publish_if_current(|| {
7255 let tx = conn.transaction()?;
7256 tx.execute(
7257 "UPDATE OR IGNORE backend_file_state
7258 SET workspace_root = ?1
7259 WHERE workspace_root <> ?1",
7260 params![¤t_root],
7261 )?;
7262 tx.execute(
7263 "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
7264 params![¤t_root],
7265 )?;
7266 tx.commit()?;
7267 Ok(())
7268 })?;
7269
7270 crate::slog_info!(
7271 "callgraph store re-rooted from {} to {}",
7272 roots.join(", "),
7273 current_root
7274 );
7275 Ok(OpenRootRepair::ReRooted)
7276}
7277
7278fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
7279 let mut stmt = conn.prepare(
7280 "SELECT DISTINCT workspace_root
7281 FROM backend_file_state
7282 ORDER BY workspace_root",
7283 )?;
7284 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
7285 rows.collect::<std::result::Result<Vec<_>, _>>()
7286 .map_err(Into::into)
7287}
7288
7289fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
7290 for (table, column) in STORE_DATA_PATH_COLUMNS {
7291 let sql = format!(
7292 "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
7293 );
7294 let mut stmt = conn.prepare(&sql)?;
7295 let mut rows = stmt.query([])?;
7296 while let Some(row) = rows.next()? {
7297 let value: String = row.get(0)?;
7298 if stored_path_is_absolute(&value) {
7299 return Ok(Some(format!("{table}.{column}={value}")));
7300 }
7301 }
7302 }
7303 Ok(None)
7304}
7305
7306fn stored_path_is_absolute(value: &str) -> bool {
7307 if value.is_empty() {
7308 return false;
7309 }
7310 if Path::new(value).is_absolute() || value.starts_with('/') {
7311 return true;
7312 }
7313 let bytes = value.as_bytes();
7314 if bytes.len() >= 3
7315 && bytes[1] == b':'
7316 && (bytes[2] == b'/' || bytes[2] == b'\\')
7317 && bytes[0].is_ascii_alphabetic()
7318 {
7319 return true;
7320 }
7321 value.starts_with("\\\\") || value.starts_with("//")
7322}
7323
7324fn log_root_repair_rebuild(repair: &OpenRootRepair) {
7325 if let OpenRootRepair::NeedsRebuild {
7326 previous_roots,
7327 current_root,
7328 reason,
7329 } = repair
7330 {
7331 crate::slog_info!(
7332 "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
7333 previous_roots.join(", "),
7334 current_root,
7335 reason
7336 );
7337 }
7338}
7339
7340fn now_nanos() -> u128 {
7342 SystemTime::now()
7343 .duration_since(UNIX_EPOCH)
7344 .unwrap_or(Duration::ZERO)
7345 .as_nanos()
7346}
7347
7348fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
7353 callgraph_dir.join(format!("{project_key}.current"))
7354}
7355
7356fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
7360 callgraph_dir.join(format!("{project_key}.sqlite"))
7361}
7362
7363fn generation_file_name(project_key: &str) -> String {
7367 format!(
7368 "{project_key}.g{}.{}.sqlite",
7369 now_nanos(),
7370 std::process::id()
7371 )
7372}
7373
7374fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
7376 let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
7377 let name = text.trim();
7378 if name.is_empty() {
7379 None
7380 } else {
7381 Some(name.to_string())
7382 }
7383}
7384
7385fn db_path_ready(path: &Path) -> bool {
7388 (|| -> Result<bool> {
7389 let conn = open_readonly_connection(path)?;
7390 database_ready(&conn)
7391 })()
7392 .unwrap_or(false)
7393}
7394
7395fn resolve_ready_target(
7403 callgraph_dir: &Path,
7404 project_key: &str,
7405) -> Option<(PathBuf, Option<String>)> {
7406 for _ in 0..5 {
7407 if let Some(generation) = read_pointer(callgraph_dir, project_key) {
7408 let gen_path = callgraph_dir.join(&generation);
7409 if gen_path.is_file() {
7410 return (migration_manifest_valid(callgraph_dir, &generation)
7411 && db_path_ready(&gen_path))
7412 .then_some((gen_path, Some(generation)));
7413 }
7414 std::thread::sleep(Duration::from_millis(5));
7417 continue;
7418 }
7419 let legacy = legacy_sqlite_path(callgraph_dir, project_key);
7421 return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
7422 }
7423 None
7424}
7425
7426fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
7430 let pointer = pointer_path(callgraph_dir, project_key);
7431 let tmp = callgraph_dir.join(format!(
7432 "{project_key}.current.tmp.{}.{}",
7433 std::process::id(),
7434 now_nanos()
7435 ));
7436 {
7437 use std::io::Write as _;
7438 let mut file = std::fs::File::create(&tmp)?;
7439 file.write_all(generation.as_bytes())?;
7440 file.write_all(b"\n")?;
7441 file.sync_all()?;
7442 }
7443 if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
7444 let _ = std::fs::remove_file(&tmp);
7445 return Err(error.into());
7446 }
7447 crate::fs_lock::sync_parent(&pointer);
7448 Ok(())
7449}
7450
7451#[derive(Clone, Debug)]
7452struct GenerationGcCandidate {
7453 name: String,
7454 path: PathBuf,
7455 modified: SystemTime,
7456}
7457
7458fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
7464 let temp_grace = Duration::from_secs(60);
7465 let now = SystemTime::now();
7466 let pointer_current =
7467 read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
7468 let gen_prefix = format!("{project_key}.g");
7469 let tmp_prefixes = [
7470 format!("{project_key}.g"), format!("{project_key}.current."), format!("{project_key}.sqlite.tmp."), ];
7474 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
7475 return;
7476 };
7477 let mut gens: Vec<GenerationGcCandidate> = Vec::new();
7478 for entry in entries.flatten() {
7479 let name = entry.file_name();
7480 let name = name.to_string_lossy().to_string();
7481 let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
7482 let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
7483
7484 if name.contains(".tmp.") {
7486 if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
7487 let _ = std::fs::remove_file(entry.path());
7488 }
7489 continue;
7490 }
7491
7492 if name == format!("{project_key}.sqlite") {
7495 remove_sqlite_file_set(&entry.path());
7496 continue;
7497 }
7498
7499 if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
7500 gens.push(GenerationGcCandidate {
7501 name,
7502 path: entry.path(),
7503 modified: mtime,
7504 });
7505 }
7506 }
7507
7508 let mut superseded = gens
7509 .iter()
7510 .filter(|generation| generation.name != pointer_current)
7511 .collect::<Vec<_>>();
7512 superseded.sort_by(|left, right| {
7513 right
7514 .modified
7515 .cmp(&left.modified)
7516 .then_with(|| right.name.cmp(&left.name))
7517 });
7518 let previous = superseded.first().map(|generation| generation.name.clone());
7519
7520 for generation in gens {
7521 let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
7522 if generation.name == pointer_current
7523 || Some(generation.name.as_str()) == previous.as_deref()
7524 {
7525 continue;
7526 }
7527
7528 let age = now
7529 .duration_since(generation.modified)
7530 .unwrap_or(Duration::ZERO);
7531 if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
7532 continue;
7533 }
7534
7535 remove_sqlite_file_set(&generation.path);
7536 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
7537 let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
7538 callgraph_dir,
7539 &generation.name,
7540 ));
7541 }
7542}
7543
7544fn remove_sqlite_file_set(path: &Path) {
7545 let _ = std::fs::remove_file(path);
7546 remove_sqlite_sidecars(path);
7547}
7548
7549fn remove_sqlite_sidecars(path: &Path) {
7550 let path_text = path.to_string_lossy();
7551 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
7552 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
7553 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
7554}
7555
7556const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
7570
7571fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
7583 sweep_orphaned_build_temps(callgraph_dir);
7584 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
7585 return;
7586 };
7587 let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
7588
7589 if let Ok(entries) = std::fs::read_dir(storage_root.join(domain)) {
7591 for entry in entries.flatten() {
7592 if entry.path().is_dir() {
7593 sweep_orphaned_build_temps(&entry.path());
7594 }
7595 }
7596 }
7597
7598 if let Ok(entries) = std::fs::read_dir(&storage_root) {
7600 for entry in entries.flatten() {
7601 let legacy_dir = entry.path().join(domain);
7602 if legacy_dir.is_dir() {
7603 sweep_orphaned_build_temps(&legacy_dir);
7604 }
7605 }
7606 }
7607}
7608
7609fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
7612 sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
7613}
7614
7615fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
7618 let now = SystemTime::now();
7619 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
7620 return;
7621 };
7622 let mut removed_any = false;
7623 for entry in entries.flatten() {
7624 let name = entry.file_name().to_string_lossy().to_string();
7625 if !name.contains(".sqlite.tmp.") {
7631 continue;
7632 }
7633 let mtime = entry
7634 .metadata()
7635 .and_then(|meta| meta.modified())
7636 .unwrap_or(now);
7637 if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
7638 continue;
7639 }
7640 match std::fs::remove_file(entry.path()) {
7646 Ok(()) => removed_any = true,
7647 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
7648 Err(_) => {}
7649 }
7650 }
7651 if removed_any {
7652 crate::fs_lock::sync_parent(callgraph_dir);
7653 }
7654}
7655
7656fn build_pool_size() -> usize {
7664 std::thread::available_parallelism()
7665 .map(|parallelism| parallelism.get())
7666 .unwrap_or(1)
7667 .div_ceil(2)
7668 .clamp(1, 8)
7669}
7670
7671fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
7672 let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
7673 Ok(extract) => Ok(extract),
7674 Err(error) => {
7675 let abs_path =
7676 normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
7677 let rel_path = relative_path(project_root, &abs_path);
7678 let freshness = cache_freshness::collect(&abs_path).ok();
7679 log::debug!(
7680 "callgraph store: skipping {} during cold build: {}",
7681 abs_path.display(),
7682 error
7683 );
7684 Err(ExtractFailure {
7685 rel_path,
7686 freshness,
7687 })
7688 }
7689 };
7690
7691 let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
7692 files.par_iter().map(extract_one).collect()
7693 };
7694
7695 let results = match rayon::ThreadPoolBuilder::new()
7698 .num_threads(build_pool_size())
7699 .thread_name(|index| format!("aft-callgraph-build-{index}"))
7700 .stack_size(8 * 1024 * 1024)
7701 .build()
7702 {
7703 Ok(pool) => pool.install(run),
7704 Err(error) => {
7705 log::warn!(
7706 "callgraph store: bounded build pool unavailable ({error}); using global pool"
7707 );
7708 run()
7709 }
7710 };
7711
7712 let mut extracts = Vec::new();
7713 let mut failures = Vec::new();
7714 for result in results {
7715 match result {
7716 Ok(extract) => extracts.push(extract),
7717 Err(failure) => failures.push(failure),
7718 }
7719 }
7720 BuildExtractsResult { extracts, failures }
7721}
7722
7723fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
7724 let metadata = std::fs::metadata(path)?;
7725 let size = metadata.len();
7726 let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
7727 cache_freshness::zero_hash()
7728 } else if source.len() as u64 == size {
7729 cache_freshness::hash_bytes(source.as_bytes())
7730 } else {
7731 cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
7732 };
7733 Ok(FileFreshness {
7734 mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
7735 size,
7736 content_hash,
7737 })
7738}
7739
7740fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
7741 let abs_path = normalize_file_path(project_root, path)?;
7742 let rel_path = relative_path(project_root, &abs_path);
7743 let source = std::fs::read_to_string(&abs_path)?;
7744 let freshness = collect_source_freshness(&abs_path, &source)?;
7745 let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
7746 let lang = data.lang;
7747 if lang == LangId::Rust {
7748 extend_rust_imports_with_nested_uses(&source, &mut data);
7749 }
7750 let mut nodes = build_node_records(&rel_path, &source, &data)?;
7751 let node_by_scoped: HashMap<String, String> = nodes
7752 .iter()
7753 .map(|node| (node.scoped_name.clone(), node.id.clone()))
7754 .collect();
7755 let import_dependencies =
7756 import_dependencies(project_root, &abs_path, &data.import_block.imports);
7757 let line_index = LineIndex::new(&source);
7758 let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
7759 let rust_reexports = if lang == LangId::Rust {
7760 collect_rust_pub_use_reexport_refs(
7761 project_root,
7762 &abs_path,
7763 &rel_path,
7764 &data.import_block.imports,
7765 &line_index,
7766 )
7767 } else {
7768 ReexportRefs {
7769 raw_refs: Vec::new(),
7770 surface_parts: Vec::new(),
7771 }
7772 };
7773 let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
7774 let mut raw_refs = Vec::new();
7775 raw_refs.extend(build_call_refs(
7776 &rel_path,
7777 &data,
7778 &node_by_scoped,
7779 &import_dependencies,
7780 ));
7781 raw_refs.extend(build_value_ref_refs(
7782 &rel_path,
7783 &data,
7784 &node_by_scoped,
7785 &import_dependencies,
7786 ));
7787 raw_refs.extend(build_import_refs(
7788 project_root,
7789 &abs_path,
7790 &rel_path,
7791 &data.import_block.imports,
7792 &line_index,
7793 ));
7794 let mut surface_parts = reexports.surface_parts;
7795 surface_parts.extend(rust_reexports.surface_parts);
7796 surface_parts.extend(source_less_exports.surface_parts);
7797 raw_refs.extend(reexports.raw_refs);
7798 raw_refs.extend(rust_reexports.raw_refs);
7799 raw_refs.extend(source_less_exports.raw_refs);
7800 let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
7801 let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
7802
7803 Ok(FileExtract {
7804 rel_path,
7805 freshness,
7806 lang,
7807 data,
7808 nodes,
7809 raw_refs,
7810 dispatch_hints,
7811 surface_fingerprint,
7812 })
7813}
7814
7815fn build_node_records(
7816 rel_path: &str,
7817 source: &str,
7818 data: &FileCallData,
7819) -> Result<Vec<NodeRecord>> {
7820 let mut records = Vec::new();
7821 let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
7822 let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
7823 metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
7824
7825 for (scoped_name, meta) in metadata {
7826 let name = unqualified_name(scoped_name).to_string();
7827 let range = selection_range(source, scoped_name, &name, &meta.range);
7828 let range_key = (
7829 range.start_line,
7830 range.start_col,
7831 range.end_line,
7832 range.end_col,
7833 );
7834 let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
7835 let range_ordinal = *ordinal;
7836 *ordinal += 1;
7837 let id = node_id(rel_path, &range, range_ordinal, scoped_name);
7838 let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
7839 let is_default_export = data
7840 .default_export_symbol
7841 .as_deref()
7842 .map(|default| default == scoped_name || default == name)
7843 .unwrap_or(false);
7844 records.push(NodeRecord {
7845 id,
7846 file_path: rel_path.to_string(),
7847 name: name.clone(),
7848 scoped_name: scoped_name.clone(),
7849 kind: symbol_kind_label(&meta.kind).to_string(),
7850 range,
7851 range_ordinal,
7852 signature: meta.signature.clone(),
7853 exported,
7854 is_default_export,
7855 is_type_like: is_type_like(&meta.kind),
7856 is_callgraph_entry_point: meta.entry_point_attribute.is_some()
7857 || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
7858 });
7859 }
7860
7861 Ok(records)
7862}
7863
7864fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
7865 if scoped_name == TOP_LEVEL_SYMBOL {
7866 return Range {
7867 start_line: 0,
7868 start_col: 0,
7869 end_line: 0,
7870 end_col: 0,
7871 };
7872 }
7873 let Some(line) = source.lines().nth(fallback.start_line as usize) else {
7874 return fallback.clone();
7875 };
7876 let start_col = fallback.start_col as usize;
7877 let search_start = start_col.min(line.len());
7878 if let Some(offset) = line[search_start..].find(name) {
7879 let col = search_start + offset;
7880 return Range {
7881 start_line: fallback.start_line,
7882 start_col: col as u32,
7883 end_line: fallback.start_line,
7884 end_col: (col + name.len()) as u32,
7885 };
7886 }
7887 if let Some(offset) = line.find(name) {
7888 return Range {
7889 start_line: fallback.start_line,
7890 start_col: offset as u32,
7891 end_line: fallback.start_line,
7892 end_col: (offset + name.len()) as u32,
7893 };
7894 }
7895 Range {
7896 start_line: fallback.start_line,
7897 start_col: fallback.start_col,
7898 end_line: fallback.start_line,
7899 end_col: fallback.start_col.saturating_add(name.len() as u32),
7900 }
7901}
7902
7903fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
7904 if scoped_name == TOP_LEVEL_SYMBOL {
7905 return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
7906 }
7907 let input = format!(
7908 "{rel_path}:{}:{}:{}:{}:{ordinal}",
7909 range.start_line, range.start_col, range.end_line, range.end_col
7910 );
7911 format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
7912}
7913
7914fn build_call_refs(
7915 rel_path: &str,
7916 data: &FileCallData,
7917 node_by_scoped: &HashMap<String, String>,
7918 import_dependencies: &BTreeSet<String>,
7919) -> Vec<RawRef> {
7920 build_callable_refs(
7921 rel_path,
7922 &data.calls_by_symbol,
7923 node_by_scoped,
7924 import_dependencies,
7925 "call",
7926 )
7927}
7928
7929fn build_value_ref_refs(
7930 rel_path: &str,
7931 data: &FileCallData,
7932 node_by_scoped: &HashMap<String, String>,
7933 import_dependencies: &BTreeSet<String>,
7934) -> Vec<RawRef> {
7935 build_callable_refs(
7936 rel_path,
7937 &data.value_refs_by_symbol,
7938 node_by_scoped,
7939 import_dependencies,
7940 "value_ref",
7941 )
7942}
7943
7944fn build_callable_refs(
7945 rel_path: &str,
7946 sites_by_symbol: &HashMap<String, Vec<callgraph::CallSite>>,
7947 node_by_scoped: &HashMap<String, String>,
7948 import_dependencies: &BTreeSet<String>,
7949 kind: &str,
7950) -> Vec<RawRef> {
7951 let mut refs = Vec::new();
7952 let mut ordinal = 0usize;
7953 let mut symbols: Vec<_> = sites_by_symbol.iter().collect();
7954 symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
7955 for (caller_symbol, call_sites) in symbols {
7956 let caller_node = node_by_scoped.get(caller_symbol).cloned();
7957 for call_site in call_sites {
7958 ordinal += 1;
7959 let ref_id = ref_id(&[
7960 rel_path,
7961 kind,
7962 caller_symbol,
7963 &call_site.line.to_string(),
7964 &call_site.byte_start.to_string(),
7965 &call_site.byte_end.to_string(),
7966 &call_site.full_callee,
7967 &ordinal.to_string(),
7968 ]);
7969 refs.push(RawRef {
7970 ref_id,
7971 caller_node: caller_node.clone(),
7972 caller_symbol: Some(caller_symbol.clone()),
7973 caller_file: rel_path.to_string(),
7974 kind: kind.to_string(),
7975 short_name: Some(call_site.callee_name.clone()),
7976 full_ref: Some(call_site.full_callee.clone()),
7977 module_path: None,
7978 import_kind: None,
7979 local_name: Some(call_site.callee_name.clone()),
7980 requested_name: Some(call_site.callee_name.clone()),
7981 namespace_alias: namespace_alias(&call_site.full_callee),
7982 wildcard: false,
7983 line: call_site.line,
7984 byte_start: call_site.byte_start,
7985 byte_end: call_site.byte_end,
7986 dependencies: import_dependencies.clone(),
7987 });
7988 }
7989 }
7990 refs
7991}
7992
7993fn build_import_refs(
7994 project_root: &Path,
7995 abs_path: &Path,
7996 rel_path: &str,
7997 imports: &[ImportStatement],
7998 line_index: &LineIndex,
7999) -> Vec<RawRef> {
8000 let mut refs = Vec::new();
8001 for (index, import) in imports.iter().enumerate() {
8002 let import_kind = import_kind_label(import.kind).to_string();
8003 let local_name = import_local_names(import).join(",");
8004 let requested_name = import_requested_names(import).join(",");
8005 let ref_id = ref_id(&[
8006 rel_path,
8007 "import",
8008 &import.byte_range.start.to_string(),
8009 &import.byte_range.end.to_string(),
8010 &import.module_path,
8011 &index.to_string(),
8012 ]);
8013 refs.push(RawRef {
8014 ref_id,
8015 caller_node: None,
8016 caller_symbol: None,
8017 caller_file: rel_path.to_string(),
8018 kind: "import".to_string(),
8019 short_name: None,
8020 full_ref: Some(import.raw_text.clone()),
8021 module_path: Some(import.module_path.clone()),
8022 import_kind: Some(import_kind),
8023 local_name: empty_to_none(local_name),
8024 requested_name: empty_to_none(requested_name),
8025 namespace_alias: import.namespace_import.clone(),
8026 wildcard: import_is_wildcard(import),
8027 line: line_index.byte_to_line(import.byte_range.start),
8028 byte_start: import.byte_range.start,
8029 byte_end: import.byte_range.end,
8030 dependencies: module_dependencies(project_root, abs_path, &import.module_path),
8031 });
8032 }
8033 refs
8034}
8035
8036fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
8037 let grammar = grammar_for(LangId::Rust);
8038 let mut parser = Parser::new();
8039 if parser.set_language(&grammar).is_err() {
8040 return;
8041 }
8042 let Some(tree) = parser.parse(source, None) else {
8043 return;
8044 };
8045
8046 let mut seen = data
8047 .import_block
8048 .imports
8049 .iter()
8050 .map(|import| (import.byte_range.start, import.byte_range.end))
8051 .collect::<HashSet<_>>();
8052 let mut nested_imports = Vec::new();
8053 collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
8054 if nested_imports.is_empty() {
8055 return;
8056 }
8057
8058 data.import_block.imports.extend(nested_imports);
8059 data.import_block
8060 .imports
8061 .sort_by_key(|import| import.byte_range.start);
8062 data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
8063}
8064
8065fn collect_rust_use_imports(
8066 source: &str,
8067 node: Node<'_>,
8068 seen: &mut HashSet<(usize, usize)>,
8069 imports: &mut Vec<ImportStatement>,
8070) {
8071 if node.kind() == "use_declaration" {
8072 let range = node.byte_range();
8073 if seen.insert((range.start, range.end)) {
8074 if let Some(import) = rust_import_from_use_node(source, node) {
8075 imports.push(import);
8076 }
8077 }
8078 }
8079
8080 let mut cursor = node.walk();
8081 if !cursor.goto_first_child() {
8082 return;
8083 }
8084 loop {
8085 collect_rust_use_imports(source, cursor.node(), seen, imports);
8086 if !cursor.goto_next_sibling() {
8087 break;
8088 }
8089 }
8090}
8091
8092fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
8093 let raw_text = source[node.byte_range()].to_string();
8094 let body = rust_use_body(&raw_text)?.to_string();
8095 let visibility = rust_use_visibility(&raw_text);
8096 let names = rust_use_list_names(&body);
8097 let group = classify_rust_import_group(&body);
8098 let byte_range = node.byte_range();
8099
8100 Some(ImportStatement {
8101 module_path: body,
8102 names: names.clone(),
8103 default_import: visibility.clone(),
8104 namespace_import: None,
8105 kind: ImportKind::Value,
8106 group,
8107 byte_range,
8108 raw_text,
8109 form: ImportForm::RustUse {
8110 visibility,
8111 named: names,
8112 },
8113 })
8114}
8115
8116fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
8117 let start = imports.iter().map(|import| import.byte_range.start).min()?;
8118 let end = imports.iter().map(|import| import.byte_range.end).max()?;
8119 Some(start..end)
8120}
8121
8122fn rust_use_visibility(raw_text: &str) -> Option<String> {
8123 let use_pos = raw_text.find("use ")?;
8124 let prefix = raw_text[..use_pos].trim();
8125 if prefix.is_empty() {
8126 None
8127 } else {
8128 Some(prefix.to_string())
8129 }
8130}
8131
8132fn rust_use_body(raw_text: &str) -> Option<&str> {
8133 let use_pos = raw_text.find("use ")?;
8134 Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
8135}
8136
8137fn rust_use_list_names(body: &str) -> Vec<String> {
8138 let Some(open) = body.find("::{") else {
8139 return Vec::new();
8140 };
8141 let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
8142 return Vec::new();
8143 };
8144 body[open + 3..close]
8145 .split(',')
8146 .filter_map(|spec| {
8147 let spec = spec.trim();
8148 if spec.is_empty() {
8149 None
8150 } else {
8151 Some(spec.to_string())
8152 }
8153 })
8154 .collect()
8155}
8156
8157fn classify_rust_import_group(body: &str) -> ImportGroup {
8158 let first = body
8159 .split("::")
8160 .next()
8161 .unwrap_or(body)
8162 .split_whitespace()
8163 .next()
8164 .unwrap_or(body);
8165 match first.trim() {
8166 "std" | "core" | "alloc" => ImportGroup::Stdlib,
8167 "crate" | "self" | "super" => ImportGroup::Internal,
8168 _ => ImportGroup::External,
8169 }
8170}
8171
8172#[derive(Debug, Clone)]
8173struct ReexportRefs {
8174 raw_refs: Vec<RawRef>,
8175 surface_parts: Vec<String>,
8176}
8177
8178fn collect_reexport_refs(
8179 project_root: &Path,
8180 abs_path: &Path,
8181 rel_path: &str,
8182 source: &str,
8183) -> ReexportRefs {
8184 let mut raw_refs = Vec::new();
8185 let mut surface_parts = Vec::new();
8186 let mut search_start = 0usize;
8187 let mut ordinal = 0usize;
8188 while let Some(export_offset) = source[search_start..].find("export") {
8189 let start = search_start + export_offset;
8190 let Some(statement_end_offset) = source[start..].find(';') else {
8191 break;
8192 };
8193 let end = start + statement_end_offset + 1;
8194 let statement = &source[start..end];
8195 search_start = end;
8196 if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
8197 continue;
8198 }
8199 let Some(module_path) = quoted_module_path(statement) else {
8200 continue;
8201 };
8202 ordinal += 1;
8203 let wildcard = statement.contains('*');
8204 let line = source[..start]
8205 .bytes()
8206 .filter(|byte| *byte == b'\n')
8207 .count() as u32
8208 + 1;
8209 let ref_id = ref_id(&[
8210 rel_path,
8211 "reexport",
8212 &start.to_string(),
8213 &end.to_string(),
8214 &module_path,
8215 &ordinal.to_string(),
8216 ]);
8217 surface_parts.push(format!("reexport\t{statement}"));
8218 raw_refs.push(RawRef {
8219 ref_id,
8220 caller_node: None,
8221 caller_symbol: None,
8222 caller_file: rel_path.to_string(),
8223 kind: "reexport".to_string(),
8224 short_name: None,
8225 full_ref: Some(statement.to_string()),
8226 module_path: Some(module_path.clone()),
8227 import_kind: Some("reexport".to_string()),
8228 local_name: None,
8229 requested_name: None,
8230 namespace_alias: None,
8231 wildcard,
8232 line,
8233 byte_start: start,
8234 byte_end: end,
8235 dependencies: module_dependencies(project_root, abs_path, &module_path),
8236 });
8237 }
8238 ReexportRefs {
8239 raw_refs,
8240 surface_parts,
8241 }
8242}
8243
8244fn collect_rust_pub_use_reexport_refs(
8245 project_root: &Path,
8246 abs_path: &Path,
8247 rel_path: &str,
8248 imports: &[ImportStatement],
8249 line_index: &LineIndex,
8250) -> ReexportRefs {
8251 let mut raw_refs = Vec::new();
8252 let mut surface_parts = Vec::new();
8253 let mut ordinal = 0usize;
8254
8255 for import in imports {
8256 let Some(visibility) = &import.default_import else {
8257 continue;
8258 };
8259 if !visibility.starts_with("pub") {
8260 continue;
8261 }
8262 let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
8263 continue;
8264 };
8265 ordinal += 1;
8266 let ref_id = ref_id(&[
8267 rel_path,
8268 "rust_reexport",
8269 &import.byte_range.start.to_string(),
8270 &import.byte_range.end.to_string(),
8271 &module_path,
8272 &ordinal.to_string(),
8273 ]);
8274 surface_parts.push(format!("reexport\t{}", import.raw_text));
8275 raw_refs.push(RawRef {
8276 ref_id,
8277 caller_node: None,
8278 caller_symbol: None,
8279 caller_file: rel_path.to_string(),
8280 kind: "reexport".to_string(),
8281 short_name: None,
8282 full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
8283 module_path: Some(module_path.clone()),
8284 import_kind: Some("reexport".to_string()),
8285 local_name: None,
8286 requested_name: None,
8287 namespace_alias: None,
8288 wildcard,
8289 line: line_index.byte_to_line(import.byte_range.start),
8290 byte_start: import.byte_range.start,
8291 byte_end: import.byte_range.end,
8292 dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
8293 });
8294 }
8295
8296 ReexportRefs {
8297 raw_refs,
8298 surface_parts,
8299 }
8300}
8301
8302fn rust_pub_use_reexport_parts(
8303 import: &ImportStatement,
8304) -> Option<(String, HashMap<String, String>, bool)> {
8305 let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
8306 let body = body.trim();
8307 if let Some(module_path) = body.strip_suffix("::*") {
8308 return Some((module_path.trim().to_string(), HashMap::new(), true));
8309 }
8310
8311 if let Some(brace_start) = body.find("::{") {
8312 let module_path = body[..brace_start].trim().to_string();
8313 let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
8314 if names.is_empty() {
8315 return None;
8316 }
8317 return Some((module_path, names, false));
8318 }
8319
8320 let (module_path, spec) = body.rsplit_once("::")?;
8321 let names = rust_reexport_names_from_specs(spec);
8322 if names.is_empty() {
8323 return None;
8324 }
8325 Some((module_path.trim().to_string(), names, false))
8326}
8327
8328fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
8329 let mut names = HashMap::new();
8330 for spec in specs.split(',') {
8331 let spec = spec.trim();
8332 if spec.is_empty() || spec == "self" {
8333 continue;
8334 }
8335 if let Some((source, local)) = spec.split_once(" as ") {
8336 let source = source.trim();
8337 let local = local.trim();
8338 if !source.is_empty() && !local.is_empty() && source != "self" {
8339 names.insert(local.to_string(), source.to_string());
8340 }
8341 } else {
8342 names.insert(spec.to_string(), spec.to_string());
8343 }
8344 }
8345 names
8346}
8347
8348fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
8349 if named.is_empty() {
8350 return fallback.to_string();
8351 }
8352 let mut specs = named
8353 .iter()
8354 .map(|(local, source)| {
8355 if local == source {
8356 source.clone()
8357 } else {
8358 format!("{source} as {local}")
8359 }
8360 })
8361 .collect::<Vec<_>>();
8362 specs.sort();
8363 format!("pub use {{{}}};", specs.join(", "))
8364}
8365
8366fn quoted_module_path(statement: &str) -> Option<String> {
8367 let quote = match (statement.find('\''), statement.find('"')) {
8368 (Some(single), Some(double)) if single < double => '\'',
8369 (Some(_), Some(_)) => '"',
8370 (Some(_), None) => '\'',
8371 (None, Some(_)) => '"',
8372 (None, None) => return None,
8373 };
8374 let start = statement.find(quote)? + 1;
8375 let end = statement[start..].find(quote)? + start;
8376 Some(statement[start..end].to_string())
8377}
8378
8379#[derive(Debug, Clone)]
8380struct SourceLessExportRefs {
8381 raw_refs: Vec<RawRef>,
8382 surface_parts: Vec<String>,
8383}
8384
8385fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
8386 let mut raw_refs = Vec::new();
8387 let mut surface_parts = Vec::new();
8388 let mut search_start = 0usize;
8389 let mut ordinal = 0usize;
8390 while let Some(export_offset) = source[search_start..].find("export") {
8391 let start = search_start + export_offset;
8392 let Some(statement_end_offset) = source[start..].find(';') else {
8393 break;
8394 };
8395 let end = start + statement_end_offset + 1;
8396 let statement = &source[start..end];
8397 search_start = end;
8398 if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
8399 continue;
8400 }
8401 let aliases = parse_reexport_names(statement);
8402 if aliases.is_empty() {
8403 continue;
8404 }
8405 let line = source[..start]
8406 .bytes()
8407 .filter(|byte| *byte == b'\n')
8408 .count() as u32
8409 + 1;
8410 for (exported, source_symbol) in aliases {
8411 ordinal += 1;
8412 let ref_id = ref_id(&[
8413 rel_path,
8414 "export_alias",
8415 &start.to_string(),
8416 &end.to_string(),
8417 &exported,
8418 &source_symbol,
8419 &ordinal.to_string(),
8420 ]);
8421 surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
8422 raw_refs.push(RawRef {
8423 ref_id,
8424 caller_node: None,
8425 caller_symbol: None,
8426 caller_file: rel_path.to_string(),
8427 kind: "export_alias".to_string(),
8428 short_name: None,
8429 full_ref: Some(statement.to_string()),
8430 module_path: None,
8431 import_kind: Some("export_alias".to_string()),
8432 local_name: Some(exported),
8433 requested_name: Some(source_symbol),
8434 namespace_alias: None,
8435 wildcard: false,
8436 line,
8437 byte_start: start,
8438 byte_end: end,
8439 dependencies: BTreeSet::new(),
8440 });
8441 }
8442 }
8443 SourceLessExportRefs {
8444 raw_refs,
8445 surface_parts,
8446 }
8447}
8448
8449fn build_dispatch_hints(
8450 rel_path: &str,
8451 data: &FileCallData,
8452 node_by_scoped: &HashMap<String, String>,
8453) -> Vec<DispatchHint> {
8454 let mut hints = Vec::new();
8455 let mut ordinal = 0usize;
8456 for (caller_symbol, call_sites) in &data.calls_by_symbol {
8457 let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
8458 continue;
8459 };
8460 for call_site in call_sites {
8461 if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
8462 continue;
8463 }
8464 ordinal += 1;
8465 hints.push(DispatchHint {
8466 id: ref_id(&[
8467 rel_path,
8468 "dispatch",
8469 caller_symbol,
8470 &call_site.line.to_string(),
8471 &call_site.byte_start.to_string(),
8472 &call_site.byte_end.to_string(),
8473 &ordinal.to_string(),
8474 ]),
8475 method_name: call_site.callee_name.clone(),
8476 caller_node: caller_node.clone(),
8477 file: rel_path.to_string(),
8478 line: call_site.line,
8479 byte_start: call_site.byte_start,
8480 byte_end: call_site.byte_end,
8481 });
8482 }
8483 }
8484 hints
8485}
8486
8487fn surface_fingerprint(
8488 nodes: &mut [NodeRecord],
8489 data: &FileCallData,
8490 reexport_parts: &[String],
8491) -> String {
8492 nodes.sort_by(|left, right| {
8493 (left.file_path.as_str(), left.scoped_name.as_str())
8494 .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
8495 });
8496 let mut parts = Vec::new();
8497 for node in nodes.iter() {
8498 parts.push(format!(
8499 "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
8500 node.scoped_name,
8501 node.name,
8502 node.kind,
8503 node.exported,
8504 node.range.start_line,
8505 node.range.start_col,
8506 node.range.end_line,
8507 node.range.end_col,
8508 node.range_ordinal,
8509 node.signature.as_deref().unwrap_or("")
8510 ));
8511 }
8512 let mut exports = data.exported_symbols.clone();
8513 exports.sort();
8514 for export in exports {
8515 parts.push(format!("export\t{export}"));
8516 }
8517 if let Some(default_export) = &data.default_export_symbol {
8518 parts.push(format!("default\t{default_export}"));
8519 }
8520 let mut imports: Vec<String> = data
8521 .import_block
8522 .imports
8523 .iter()
8524 .map(|import| {
8525 format!(
8526 "import\t{}\t{:?}\t{}",
8527 import.module_path, import.form, import.raw_text
8528 )
8529 })
8530 .collect();
8531 imports.sort();
8532 parts.extend(imports);
8533 parts.extend(reexport_parts.iter().cloned());
8534 hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
8535}
8536
8537fn resolve_ref<I: ResolverIndex>(raw: RawRef, index: &I) -> Result<ResolvedRef> {
8538 if !matches!(raw.kind.as_str(), "call" | "value_ref") {
8539 return Ok(ResolvedRef {
8540 dependencies: raw.dependencies.clone(),
8541 raw,
8542 status: "unresolved".to_string(),
8543 target_node: None,
8544 target_file: None,
8545 target_symbol: None,
8546 edge: None,
8547 });
8548 }
8549
8550 let caller_file = raw.caller_file.clone();
8551 let caller_data =
8552 index
8553 .caller_data(&caller_file)
8554 .ok_or_else(|| CallGraphStoreError::MissingCallerData {
8555 file: caller_file.clone(),
8556 })?;
8557 let full_ref = raw.full_ref.as_deref().unwrap_or_default();
8558 let short_name = raw.short_name.as_deref().unwrap_or_default();
8559 let mut dependencies = raw.dependencies.clone();
8560
8561 let resolved = match index.lang_for(&caller_file) {
8562 Some(LangId::Rust) => {
8563 resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
8564 }
8565 Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
8566 resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
8567 }
8568 _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
8569 };
8570
8571 let Some((status, target_file, target_symbol)) = resolved else {
8572 return Ok(ResolvedRef {
8573 raw,
8574 status: "unresolved".to_string(),
8575 target_node: None,
8576 target_file: None,
8577 target_symbol: None,
8578 dependencies,
8579 edge: None,
8580 });
8581 };
8582
8583 dependencies.insert(target_file.clone());
8584 let target_node = index.node_for_symbol(&target_file, &target_symbol);
8585 if raw.kind == "value_ref"
8586 && !target_node
8587 .as_deref()
8588 .is_some_and(|node_id| index.node_is_callable(&target_file, node_id))
8589 {
8590 return Ok(ResolvedRef {
8591 raw,
8592 status: "unresolved".to_string(),
8593 target_node: None,
8594 target_file: None,
8595 target_symbol: None,
8596 dependencies,
8597 edge: None,
8598 });
8599 }
8600 let source_node = raw.caller_node.clone();
8601 let edge = if let Some(source_node) = source_node {
8602 if target_file == caller_file
8603 && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
8604 {
8605 None
8606 } else {
8607 Some(EdgeRecord {
8608 edge_id: ref_id(&[&raw.ref_id, "edge"]),
8609 source_node,
8610 target_node: target_node.clone(),
8611 target_file: target_file.clone(),
8612 target_symbol: target_symbol.clone(),
8613 kind: raw.kind.clone(),
8614 line: raw.line,
8615 })
8616 }
8617 } else {
8618 None
8619 };
8620
8621 Ok(ResolvedRef {
8622 raw,
8623 status,
8624 target_node,
8625 target_file: Some(target_file),
8626 target_symbol: Some(target_symbol),
8627 dependencies,
8628 edge,
8629 })
8630}
8631
8632fn resolve_js_ts_target<I: ResolverIndex>(
8633 index: &I,
8634 caller_file: &str,
8635 full_ref: &str,
8636 short_name: &str,
8637 caller_data: &FileCallData,
8638) -> Option<(String, String, String)> {
8639 if let Some((namespace, member)) = full_ref.split_once('.') {
8640 for import in &caller_data.import_block.imports {
8641 if import.namespace_import.as_deref() == Some(namespace) {
8642 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
8643 if let Some((file, symbol)) =
8644 resolve_exported_symbol(index, &target_file, member, 0)
8645 {
8646 return Some(("resolved".to_string(), file, symbol));
8647 }
8648 }
8649 }
8650 }
8651 }
8652
8653 for import in &caller_data.import_block.imports {
8654 for spec in &import.names {
8655 if crate::imports::specifier_local_name(spec) == short_name {
8656 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
8657 let requested = crate::imports::specifier_imported_name(spec);
8658 let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
8659 .unwrap_or_else(|| (target_file, requested.to_string()));
8660 return Some(("resolved".to_string(), file, symbol));
8661 }
8662 }
8663 }
8664
8665 if import.default_import.as_deref() == Some(short_name) {
8666 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
8667 let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
8668 .or_else(|| {
8669 index
8670 .default_export(&target_file)
8671 .map(|symbol| (target_file.clone(), symbol))
8672 })
8673 .unwrap_or_else(|| {
8674 let file_name = Path::new(&target_file)
8675 .file_name()
8676 .and_then(|name| name.to_str())
8677 .unwrap_or("unknown")
8678 .to_string();
8679 (target_file, format!("<default:{file_name}>"))
8680 });
8681 return Some(("resolved".to_string(), file, symbol));
8682 }
8683 }
8684 }
8685
8686 for import in &caller_data.import_block.imports {
8687 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
8688 if index.has_export(&target_file, short_name) {
8689 return Some(("resolved".to_string(), target_file, short_name.to_string()));
8690 }
8691 }
8692 }
8693
8694 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
8695}
8696
8697fn resolve_exported_symbol<I: ResolverIndex>(
8698 index: &I,
8699 file: &str,
8700 requested: &str,
8701 depth: usize,
8702) -> Option<(String, String)> {
8703 let mut visited = std::collections::HashMap::new();
8704 resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
8705}
8706
8707fn resolve_exported_symbol_inner<I: ResolverIndex>(
8716 index: &I,
8717 file: &str,
8718 requested: &str,
8719 depth: usize,
8720 visited: &mut std::collections::HashMap<(String, String), usize>,
8721) -> Option<(String, String)> {
8722 if depth > 16 {
8723 return None;
8724 }
8725 if requested != "default" {
8726 if let Some(source_symbol) = index.export_alias(file, requested) {
8727 return Some((file.to_string(), source_symbol));
8728 }
8729 if index.has_export(file, requested) {
8730 return Some((file.to_string(), requested.to_string()));
8731 }
8732 } else if let Some(default) = index.default_export(file) {
8733 return Some((file.to_string(), default));
8734 }
8735
8736 match visited.entry((file.to_string(), requested.to_string())) {
8740 std::collections::hash_map::Entry::Occupied(mut seen) => {
8741 if *seen.get() <= depth {
8742 return None;
8743 }
8744 seen.insert(depth);
8745 }
8746 std::collections::hash_map::Entry::Vacant(slot) => {
8747 slot.insert(depth);
8748 }
8749 }
8750
8751 for reexport in index.reexports_for(file) {
8752 let mut next_requested = requested.to_string();
8753 let matches = if reexport.wildcard {
8754 true
8755 } else if let Some(source_name) = reexport.named.get(requested) {
8756 next_requested = source_name.clone();
8757 true
8758 } else {
8759 false
8760 };
8761 if !matches {
8762 continue;
8763 }
8764 if let Some(target_file) = &reexport.target_file {
8765 if let Some(target) = resolve_exported_symbol_inner(
8766 index,
8767 target_file,
8768 &next_requested,
8769 depth + 1,
8770 visited,
8771 ) {
8772 return Some(target);
8773 }
8774 }
8775 }
8776 None
8777}
8778
8779fn resolve_rust_target<I: ResolverIndex>(
8780 index: &I,
8781 caller_file: &str,
8782 full_ref: &str,
8783 short_name: &str,
8784 caller_data: &FileCallData,
8785 raw: &RawRef,
8786) -> Option<(String, String, String)> {
8787 if full_ref.contains("::") {
8788 if let Some((target_file, target_symbol)) =
8789 rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
8790 {
8791 return Some(("resolved".to_string(), target_file, target_symbol));
8792 }
8793 }
8794
8795 for import in &caller_data.import_block.imports {
8796 if let Some((target_file, target_symbol)) =
8797 rust_target_for_use(index, caller_file, import, short_name)
8798 {
8799 return Some(("resolved".to_string(), target_file, target_symbol));
8800 }
8801 }
8802
8803 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
8804}
8805
8806fn rust_target_for_qualified<I: ResolverIndex>(
8807 index: &I,
8808 caller_file: &str,
8809 full_ref: &str,
8810 short_name: &str,
8811 caller_data: &FileCallData,
8812 raw: &RawRef,
8813) -> Option<(String, String)> {
8814 let mut segments: Vec<&str> = full_ref.split("::").collect();
8815 if segments.len() < 2 {
8816 return None;
8817 }
8818 segments.pop();
8819 let requested_symbol = rust_target_symbol(full_ref, short_name);
8820
8821 for path in rust_module_path_candidates(&segments, caller_data, raw) {
8822 let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
8823 if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
8824 if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
8825 return Some(rust_resolve_reexport_if_symbol_missing(
8826 index,
8827 target_file,
8828 requested_symbol.clone(),
8829 ));
8830 }
8831 }
8832
8833 let module_segments = rust_resolve_segments(caller_file, &path_refs)?;
8834 if let Some(target) =
8835 rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
8836 {
8837 return Some(target);
8838 }
8839 if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
8840 return Some(rust_resolve_reexport_if_symbol_missing(
8841 index,
8842 target_file,
8843 requested_symbol.clone(),
8844 ));
8845 }
8846 }
8847 None
8848}
8849
8850fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
8851 full_ref
8852 .rsplit("::")
8853 .next()
8854 .filter(|name| !name.is_empty())
8855 .unwrap_or(short_name)
8856 .to_string()
8857}
8858
8859fn rust_resolve_reexport_if_symbol_missing<I: ResolverIndex>(
8860 index: &I,
8861 target_file: String,
8862 target_symbol: String,
8863) -> (String, String) {
8864 if index
8865 .node_for_symbol(&target_file, &target_symbol)
8866 .is_some()
8867 {
8868 return (target_file, target_symbol);
8869 }
8870 if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
8871 resolved
8872 } else {
8873 (target_file, target_symbol)
8874 }
8875}
8876
8877fn rust_module_path_candidates(
8878 segments: &[&str],
8879 caller_data: &FileCallData,
8880 raw: &RawRef,
8881) -> Vec<Vec<String>> {
8882 let mut candidates = Vec::new();
8883 if let Some(first) = segments.first().copied() {
8884 for import in &caller_data.import_block.imports {
8885 if !rust_import_is_visible_to_call(import, raw) {
8886 continue;
8887 }
8888 let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
8889 continue;
8890 };
8891 if local_name == first {
8892 path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
8893 rust_push_unique_path_candidate(&mut candidates, path_segments);
8894 }
8895 }
8896 }
8897 rust_push_unique_path_candidate(
8898 &mut candidates,
8899 segments
8900 .iter()
8901 .map(|segment| (*segment).to_string())
8902 .collect(),
8903 );
8904 candidates
8905}
8906
8907fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
8908 if !candidates.iter().any(|existing| existing == &candidate) {
8909 candidates.push(candidate);
8910 }
8911}
8912
8913fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
8914 import.byte_range.start <= raw.byte_start
8915}
8916
8917fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
8918 let path = import.module_path.trim().trim_end_matches(';').trim();
8919 if path.contains("::{") || path.contains('{') || path.contains('*') {
8920 return None;
8921 }
8922 let (path_without_alias, alias) = path
8923 .split_once(" as ")
8924 .map(|(left, right)| (left.trim(), Some(right.trim())))
8925 .unwrap_or((path, None));
8926 let segments = path_without_alias
8927 .split("::")
8928 .map(str::trim)
8929 .filter(|segment| !segment.is_empty())
8930 .collect::<Vec<_>>();
8931 let local_name = alias.or_else(|| segments.last().copied())?.to_string();
8932 if local_name.chars().next().is_some_and(char::is_uppercase) {
8933 return None;
8934 }
8935 Some((
8936 local_name,
8937 segments
8938 .into_iter()
8939 .map(|segment| segment.to_string())
8940 .collect(),
8941 ))
8942}
8943
8944fn rust_inline_scoped_target<I: ResolverIndex>(
8945 index: &I,
8946 caller_file: &str,
8947 module_segments: &[String],
8948 short_name: &str,
8949) -> Option<(String, String)> {
8950 index.inline_scoped_target(caller_file, module_segments, short_name)
8951}
8952
8953fn rust_target_for_use<I: ResolverIndex>(
8954 index: &I,
8955 caller_file: &str,
8956 import: &ImportStatement,
8957 short_name: &str,
8958) -> Option<(String, String)> {
8959 let path = import.module_path.trim().trim_end_matches(';');
8960 if let Some(brace_start) = path.find("::{") {
8961 let prefix = &path[..brace_start];
8962 if import.names.iter().any(|name| name == short_name) {
8963 let prefix_segments: Vec<&str> = prefix.split("::").collect();
8964 let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
8965 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
8966 return Some((file, short_name.to_string()));
8967 }
8968 return None;
8969 }
8970
8971 let (path_without_alias, alias) = path
8972 .split_once(" as ")
8973 .map(|(left, right)| (left.trim(), Some(right.trim())))
8974 .unwrap_or((path, None));
8975 let segments: Vec<&str> = path_without_alias.split("::").collect();
8976 let imported = alias.or_else(|| segments.last().copied())?;
8977 if imported != short_name {
8978 return None;
8979 }
8980 if segments.len() < 2 {
8981 return None;
8982 }
8983 let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
8984 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
8985 Some((file, segments.last().unwrap_or(&short_name).to_string()))
8986}
8987
8988fn rust_workspace_file_for_segments<I: ResolverIndex>(
8989 index: &I,
8990 segments: &[&str],
8991) -> Option<String> {
8992 let crate_name = segments.first().copied()?;
8993 let src_prefix = index.crate_src_prefix(crate_name)?;
8994 let module_segments = segments[1..]
8995 .iter()
8996 .map(|segment| segment.to_string())
8997 .collect::<Vec<_>>();
8998 rust_file_for_src_prefix(index, &src_prefix, &module_segments)
8999}
9000
9001#[cfg(test)]
9002static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
9003 OnceLock::new();
9004
9005#[cfg(test)]
9006fn note_workspace_crate_prefix_build(project_root: &Path) {
9007 let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
9008 .get_or_init(|| Mutex::new(HashMap::new()))
9009 .lock()
9010 .expect("workspace crate prefix build counts mutex poisoned");
9011 *counts.entry(project_root.to_path_buf()).or_default() += 1;
9012}
9013
9014#[cfg(not(test))]
9015fn note_workspace_crate_prefix_build(_project_root: &Path) {}
9016
9017#[cfg(test)]
9018fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
9019 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
9020 .get_or_init(|| Mutex::new(HashMap::new()))
9021 .lock()
9022 .expect("workspace crate prefix build counts mutex poisoned")
9023 .remove(project_root);
9024}
9025
9026#[cfg(test)]
9027fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
9028 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
9029 .get_or_init(|| Mutex::new(HashMap::new()))
9030 .lock()
9031 .expect("workspace crate prefix build counts mutex poisoned")
9032 .get(project_root)
9033 .copied()
9034 .unwrap_or(0)
9035}
9036
9037fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
9042 note_workspace_crate_prefix_build(project_root);
9043 let mut prefixes = HashMap::new();
9044 let mut stack = vec![project_root.to_path_buf()];
9045 while let Some(dir) = stack.pop() {
9046 let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
9047 if matches!(name, "target" | "node_modules" | ".git") {
9048 continue;
9049 }
9050 let manifest = dir.join("Cargo.toml");
9051 if manifest.is_file() {
9052 let crate_names = rust_manifest_crate_names(&manifest);
9053 if !crate_names.is_empty() {
9054 let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
9055 for crate_name in crate_names {
9056 prefixes
9057 .entry(crate_name)
9058 .or_insert_with(|| src_prefix.clone());
9059 }
9060 }
9061 }
9062 let Ok(entries) = std::fs::read_dir(&dir) else {
9063 continue;
9064 };
9065 for entry in entries.flatten() {
9066 let path = entry.path();
9067 if path.is_dir() {
9068 stack.push(path);
9069 }
9070 }
9071 }
9072 prefixes
9073}
9074
9075fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
9079 let Ok(source) = std::fs::read_to_string(manifest) else {
9080 return Vec::new();
9081 };
9082 let mut in_lib = false;
9083 let mut package_name = None;
9084 let mut lib_name = None;
9085 for line in source.lines() {
9086 let trimmed = line.trim();
9087 if trimmed.starts_with('[') {
9088 in_lib = trimmed == "[lib]";
9089 continue;
9090 }
9091 let Some((key, value)) = trimmed.split_once('=') else {
9092 continue;
9093 };
9094 let key = key.trim();
9095 let value = value.trim().trim_matches('"');
9096 if in_lib && key == "name" {
9097 lib_name = Some(value.to_string());
9098 } else if !in_lib && key == "name" && package_name.is_none() {
9099 package_name = Some(value.to_string());
9100 }
9101 }
9102 let mut names = Vec::new();
9103 if let Some(lib) = lib_name {
9104 names.push(lib);
9105 }
9106 if let Some(package) = package_name {
9107 let normalized = package.replace('-', "_");
9108 if !names.contains(&normalized) {
9109 names.push(normalized);
9110 }
9111 }
9112 names
9113}
9114
9115fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
9116 if segments.is_empty() {
9117 return Some(Vec::new());
9118 }
9119 let caller_segments = rust_module_segments_for_rel(caller_file);
9120 match segments[0] {
9121 "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
9122 "self" => {
9123 let mut resolved = caller_segments;
9124 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
9125 Some(resolved)
9126 }
9127 "super" => {
9128 let mut resolved = caller_segments;
9129 resolved.pop();
9130 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
9131 Some(resolved)
9132 }
9133 _ => {
9134 let mut resolved = caller_segments;
9135 resolved.pop();
9136 resolved.extend(segments.iter().map(|item| item.to_string()));
9137 Some(resolved)
9138 }
9139 }
9140}
9141
9142fn rust_file_for_segments<I: ResolverIndex>(
9143 index: &I,
9144 caller_file: &str,
9145 segments: &[String],
9146) -> Option<String> {
9147 rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
9148}
9149
9150fn rust_file_for_src_prefix<I: ResolverIndex>(
9151 index: &I,
9152 src_prefix: &str,
9153 segments: &[String],
9154) -> Option<String> {
9155 let candidate = if segments.is_empty() {
9156 [src_prefix, "lib.rs"].join("/")
9157 } else {
9158 format!("{}/{}.rs", src_prefix, segments.join("/"))
9159 };
9160 if index.contains_file(&candidate) {
9161 return Some(candidate);
9162 }
9163 if !segments.is_empty() {
9164 let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
9165 if index.contains_file(&mod_candidate) {
9166 return Some(mod_candidate);
9167 }
9168 }
9169 None
9170}
9171
9172fn rust_src_prefix(rel_path: &str) -> String {
9173 rel_path
9174 .split_once("/src/")
9175 .map(|(prefix, _)| format!("{prefix}/src"))
9176 .unwrap_or_else(|| "src".to_string())
9177}
9178
9179fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
9180 let after_src = rel_path
9181 .split_once("/src/")
9182 .map(|(_, rest)| rest)
9183 .or_else(|| rel_path.strip_prefix("src/"))
9184 .unwrap_or(rel_path);
9185 if matches!(after_src, "lib.rs" | "main.rs") {
9186 return Vec::new();
9187 }
9188 if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
9189 return prefix.split('/').map(|item| item.to_string()).collect();
9190 }
9191 after_src
9192 .strip_suffix(".rs")
9193 .unwrap_or(after_src)
9194 .split('/')
9195 .map(|item| item.to_string())
9196 .collect()
9197}
9198
9199fn resolve_local_target<I: ResolverIndex>(
9200 _index: &I,
9201 caller_file: &str,
9202 full_ref: &str,
9203 short_name: &str,
9204 caller_data: &FileCallData,
9205) -> Option<(String, String, String)> {
9206 if !callgraph::is_bare_callee(full_ref, short_name) {
9207 return None;
9208 }
9209 callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
9210 .ok()
9211 .map(|symbol| {
9212 (
9213 "resolved_local".to_string(),
9214 caller_file.to_string(),
9215 symbol,
9216 )
9217 })
9218}
9219
9220impl<'a> ProjectIndex<'a> {
9221 fn from_parts(
9222 project_root: &Path,
9223 files: HashMap<String, DbFileIndex>,
9224 caller_data: HashMap<String, &'a FileCallData>,
9225 workspace_crate_prefixes: WorkspaceCratePrefixCache,
9226 ) -> Self {
9227 Self {
9228 project_root: project_root.to_path_buf(),
9229 files,
9230 caller_data,
9231 workspace_crate_prefixes,
9232 }
9233 }
9234
9235 fn from_db_and_callers(
9236 tx: &Transaction<'_>,
9237 project_root: &Path,
9238 caller_extracts: &'a HashMap<String, FileExtract>,
9239 workspace_crate_prefixes: WorkspaceCratePrefixCache,
9240 ) -> Result<Self> {
9241 let mut files = load_db_file_indexes(tx, project_root)?;
9242 let mut caller_data = HashMap::new();
9243 for (rel_path, extract) in caller_extracts {
9244 files.insert(
9245 rel_path.clone(),
9246 DbFileIndex::from_extract(project_root, extract),
9247 );
9248 caller_data.insert(rel_path.clone(), &extract.data);
9249 }
9250 Ok(Self::from_parts(
9251 project_root,
9252 files,
9253 caller_data,
9254 workspace_crate_prefixes,
9255 ))
9256 }
9257
9258 fn lang_for(&self, rel_path: &str) -> Option<LangId> {
9259 self.files.get(rel_path).and_then(|file| file.lang)
9260 }
9261
9262 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
9263 self.files
9264 .get(caller_file)
9265 .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
9266 }
9267
9268 fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
9269 self.files
9270 .get(rel_path)
9271 .map(|file| file.reexports.as_slice())
9272 .unwrap_or(&[])
9273 }
9274
9275 fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
9276 self.files.get(rel_path).and_then(|file| {
9277 file.node_by_scoped
9278 .get(symbol)
9279 .cloned()
9280 .or_else(|| file.node_by_bare.get(symbol).cloned())
9281 })
9282 }
9283
9284 fn node_is_callable(&self, rel_path: &str, node_id: &str) -> bool {
9285 self.files
9286 .get(rel_path)
9287 .and_then(|file| file.node_kind_by_id.get(node_id))
9288 .is_some_and(|kind| matches!(kind.as_str(), "function" | "method"))
9289 }
9290}
9291
9292impl DbFileIndex {
9293 fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
9294 let mut node_by_scoped = HashMap::new();
9295 let mut node_by_bare = HashMap::new();
9296 for node in &extract.nodes {
9297 node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
9298 node_by_bare
9299 .entry(node.name.clone())
9300 .or_insert(node.id.clone());
9301 }
9302 let node_kind_by_id = extract
9303 .nodes
9304 .iter()
9305 .map(|node| (node.id.clone(), node.kind.clone()))
9306 .collect();
9307 let mut export_aliases = HashMap::new();
9308 for raw_ref in &extract.raw_refs {
9309 if raw_ref.kind == "export_alias" {
9310 if let (Some(exported), Some(source_symbol)) =
9311 (&raw_ref.local_name, &raw_ref.requested_name)
9312 {
9313 export_aliases.insert(exported.clone(), source_symbol.clone());
9314 }
9315 }
9316 }
9317 let mut module_targets = HashMap::new();
9318 let mut reexports = Vec::new();
9319 for raw_ref in &extract.raw_refs {
9320 if !matches!(raw_ref.kind.as_str(), "import" | "reexport") {
9321 continue;
9322 }
9323 let Some(module_path) = &raw_ref.module_path else {
9324 continue;
9325 };
9326 let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
9327 module_targets
9328 .entry(module_path.clone())
9329 .or_insert_with(|| target_file.clone());
9330 if raw_ref.kind == "reexport" {
9331 reexports.push(reexport_index_from_raw(raw_ref, target_file));
9332 }
9333 }
9334 Self {
9335 lang: Some(extract.lang),
9336 exports: extract.data.exported_symbols.iter().cloned().collect(),
9337 default_export: extract.data.default_export_symbol.clone(),
9338 export_aliases,
9339 node_by_scoped,
9340 node_by_bare,
9341 node_kind_by_id,
9342 module_targets,
9343 reexports,
9344 }
9345 }
9346}
9347
9348fn load_db_file_indexes(
9349 tx: &Transaction<'_>,
9350 project_root: &Path,
9351) -> Result<HashMap<String, DbFileIndex>> {
9352 let mut files = HashMap::new();
9353 let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
9354 let rows = stmt.query_map([], |row| {
9355 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
9356 })?;
9357 for row in rows {
9358 let (rel_path, lang) = row?;
9359 files.insert(
9360 rel_path.clone(),
9361 DbFileIndex {
9362 lang: lang_from_label(&lang),
9363 exports: HashSet::new(),
9364 default_export: None,
9365 export_aliases: HashMap::new(),
9366 node_by_scoped: HashMap::new(),
9367 node_by_bare: HashMap::new(),
9368 node_kind_by_id: HashMap::new(),
9369 module_targets: HashMap::new(),
9370 reexports: Vec::new(),
9371 },
9372 );
9373 }
9374
9375 let mut node_stmt = tx.prepare(
9376 "SELECT file_path, id, name, scoped_name, kind, exported, is_default_export FROM nodes",
9377 )?;
9378 let nodes = node_stmt.query_map([], |row| {
9379 Ok((
9380 row.get::<_, String>(0)?,
9381 row.get::<_, String>(1)?,
9382 row.get::<_, String>(2)?,
9383 row.get::<_, String>(3)?,
9384 row.get::<_, String>(4)?,
9385 row.get::<_, i64>(5)? != 0,
9386 row.get::<_, i64>(6)? != 0,
9387 ))
9388 })?;
9389 for row in nodes {
9390 let (file_path, id, name, scoped_name, kind, exported, is_default_export) = row?;
9391 let file = files
9392 .entry(file_path.clone())
9393 .or_insert_with(|| DbFileIndex {
9394 lang: None,
9395 exports: HashSet::new(),
9396 default_export: None,
9397 export_aliases: HashMap::new(),
9398 node_by_scoped: HashMap::new(),
9399 node_by_bare: HashMap::new(),
9400 node_kind_by_id: HashMap::new(),
9401 module_targets: HashMap::new(),
9402 reexports: Vec::new(),
9403 });
9404 if exported {
9405 file.exports.insert(name.clone());
9406 file.exports.insert(scoped_name.clone());
9407 }
9408 if is_default_export {
9409 file.default_export = Some(scoped_name.clone());
9410 }
9411 file.node_by_scoped.insert(scoped_name, id.clone());
9412 file.node_by_bare.entry(name).or_insert(id.clone());
9413 file.node_kind_by_id.insert(id, kind);
9414 }
9415 let file_keys: HashSet<String> = files.keys().cloned().collect();
9416 let dependencies_by_file = load_file_dependencies_index(tx)?;
9420 let mut ref_stmt = tx.prepare(
9421 "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
9422 FROM refs WHERE kind IN ('reexport', 'export_alias')",
9423 )?;
9424 let ref_rows = ref_stmt.query_map([], |row| {
9425 Ok((
9426 row.get::<_, String>(0)?,
9427 row.get::<_, String>(1)?,
9428 row.get::<_, String>(2)?,
9429 row.get::<_, Option<String>>(3)?,
9430 row.get::<_, Option<String>>(4)?,
9431 row.get::<_, i64>(5)? != 0,
9432 row.get::<_, Option<String>>(6)?,
9433 row.get::<_, Option<String>>(7)?,
9434 ))
9435 })?;
9436 for row in ref_rows {
9437 let (
9438 ref_id,
9439 caller_file,
9440 kind,
9441 module_path,
9442 full_ref,
9443 wildcard,
9444 local_name,
9445 requested_name,
9446 ) = row?;
9447 if kind == "export_alias" {
9448 if let (Some(exported), Some(source_symbol), Some(file)) =
9449 (local_name, requested_name, files.get_mut(&caller_file))
9450 {
9451 file.export_aliases.insert(exported, source_symbol);
9452 }
9453 continue;
9454 }
9455 let Some(module_path) = module_path else {
9456 continue;
9457 };
9458 let file_deps = dependencies_by_file
9459 .get(&caller_file)
9460 .cloned()
9461 .unwrap_or_default();
9462 let deps = stored_dependencies_for_module(
9463 project_root,
9464 &caller_file,
9465 &module_path,
9466 &file_deps,
9467 &file_keys,
9468 );
9469 let target_file = deps
9470 .iter()
9471 .find(|dep| file_keys.contains(*dep))
9472 .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
9473 if let Some(file) = files.get_mut(&caller_file) {
9474 file.module_targets
9475 .entry(module_path.clone())
9476 .or_insert_with(|| target_file.clone());
9477 if kind == "reexport" {
9478 let raw = RawRef {
9479 ref_id,
9480 caller_node: None,
9481 caller_symbol: None,
9482 caller_file,
9483 kind,
9484 short_name: None,
9485 full_ref,
9486 module_path: Some(module_path),
9487 import_kind: Some("reexport".to_string()),
9488 local_name: None,
9489 requested_name: None,
9490 namespace_alias: None,
9491 wildcard,
9492 line: 0,
9493 byte_start: 0,
9494 byte_end: 0,
9495 dependencies: deps,
9496 };
9497 file.reexports
9498 .push(reexport_index_from_raw(&raw, target_file));
9499 }
9500 }
9501 }
9502
9503 Ok(files)
9504}
9505
9506fn stored_dependencies_for_module(
9507 project_root: &Path,
9508 caller_file: &str,
9509 module_path: &str,
9510 caller_dependencies: &BTreeSet<String>,
9511 indexed_files: &HashSet<String>,
9512) -> BTreeSet<String> {
9513 let caller_path = project_root.join(caller_file);
9514 let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
9515 if module_path.starts_with('.') {
9516 let caller_dir = caller_path.parent().unwrap_or(project_root);
9517 for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
9518 let normalized = if candidate.is_file() {
9519 canonicalize_path(&candidate)
9520 } else {
9521 candidate
9522 };
9523 candidates.insert(relative_path(project_root, &normalized));
9524 }
9525 }
9526 let exact = candidates
9527 .intersection(caller_dependencies)
9528 .filter(|dependency| indexed_files.contains(*dependency))
9529 .cloned()
9530 .collect::<BTreeSet<_>>();
9531 if !exact.is_empty() || module_path.starts_with('.') {
9532 return exact;
9533 }
9534
9535 let module_path = rust_module_path_without_alias_or_use_list(module_path)
9536 .trim_matches(|character| matches!(character, '\'' | '"'));
9537 let package_name = module_path
9538 .split('/')
9539 .next_back()
9540 .unwrap_or(module_path)
9541 .replace('_', "-");
9542 let matched = caller_dependencies
9543 .iter()
9544 .filter(|dependency| indexed_files.contains(*dependency))
9545 .filter(|dependency| {
9546 dependency.as_str() == module_path
9547 || dependency.ends_with(&format!("/{module_path}"))
9548 || Path::new(dependency).components().any(|component| {
9549 component.as_os_str().to_string_lossy().replace('_', "-") == package_name
9550 })
9551 })
9552 .cloned()
9553 .collect::<BTreeSet<_>>();
9554 if matched.len() == 1 {
9555 matched
9556 } else {
9557 BTreeSet::new()
9558 }
9559}
9560
9561fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
9562 let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
9563 let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
9564 let rows = stmt.query_map([], |row| {
9565 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
9566 })?;
9567 for row in rows {
9568 let (file_path, dependency) = row?;
9569 by_file.entry(file_path).or_default().insert(dependency);
9570 }
9571 Ok(by_file)
9572}
9573
9574struct ColdBuildInsertStatements<'stmt> {
9575 file: Statement<'stmt>,
9576 node: Statement<'stmt>,
9577 file_dependency: Statement<'stmt>,
9578 dispatch_hint: Statement<'stmt>,
9579 backend_state: Statement<'stmt>,
9580 reference: Statement<'stmt>,
9581 staging_ref_context: Statement<'stmt>,
9582 edge: Statement<'stmt>,
9583}
9584
9585impl<'stmt> ColdBuildInsertStatements<'stmt> {
9586 fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
9587 Ok(Self {
9588 file: tx.prepare(
9589 "INSERT OR REPLACE INTO files(
9590 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
9591 is_public_api, surface_fingerprint, indexed_at
9592 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
9593 )?,
9594 node: tx.prepare(
9595 "INSERT OR REPLACE INTO nodes(
9596 id, file_path, name, scoped_name, kind, start_line, start_col,
9597 end_line, end_col, range_ordinal, signature, exported,
9598 is_default_export, is_type_like, is_callgraph_entry_point, provenance
9599 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
9600 )?,
9601 file_dependency: tx.prepare(
9602 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
9603 )?,
9604 dispatch_hint: tx.prepare(
9605 "INSERT OR REPLACE INTO dispatch_hints(
9606 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
9607 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
9608 )?,
9609 backend_state: tx.prepare(
9610 "INSERT OR REPLACE INTO backend_file_state(
9611 backend, workspace_root, file_path, content_hash, status, updated_at
9612 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
9613 )?,
9614 reference: tx.prepare(
9615 "INSERT OR REPLACE INTO refs(
9616 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
9617 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
9618 byte_start, byte_end, status, target_node, target_file, target_symbol,
9619 provenance
9620 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
9621 )?,
9622 staging_ref_context: tx.prepare(
9623 "INSERT OR REPLACE INTO staging_ref_context(ref_id, caller_symbol) VALUES(?1, ?2)",
9624 )?,
9625 edge: tx.prepare(
9626 "INSERT OR REPLACE INTO edges(
9627 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
9628 kind, line, provenance
9629 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
9630 )?,
9631 })
9632 }
9633}
9634
9635fn insert_file_extract_prepared(
9636 statements: &mut ColdBuildInsertStatements<'_>,
9637 workspace_root: &str,
9638 extract: &FileExtract,
9639) -> Result<()> {
9640 statements.file.execute(params![
9641 extract.rel_path,
9642 hash_to_hex(extract.freshness.content_hash),
9643 system_time_to_ns(extract.freshness.mtime),
9644 extract.freshness.size as i64,
9645 lang_label(extract.lang),
9646 extract.surface_fingerprint,
9647 unix_seconds_now(),
9648 ])?;
9649 for node in &extract.nodes {
9650 statements.node.execute(params![
9651 node.id,
9652 node.file_path,
9653 node.name,
9654 node.scoped_name,
9655 node.kind,
9656 node.range.start_line as i64,
9657 node.range.start_col as i64,
9658 node.range.end_line as i64,
9659 node.range.end_col as i64,
9660 node.range_ordinal as i64,
9661 node.signature,
9662 bool_int(node.exported),
9663 bool_int(node.is_default_export),
9664 bool_int(node.is_type_like),
9665 bool_int(node.is_callgraph_entry_point),
9666 PROVENANCE_TREESITTER,
9667 ])?;
9668 }
9669
9670 let mut dependencies = BTreeSet::new();
9671 for raw_ref in &extract.raw_refs {
9672 dependencies.extend(raw_ref.dependencies.iter().cloned());
9673 }
9674 for dep_file in &dependencies {
9675 statements
9676 .file_dependency
9677 .execute(params![extract.rel_path, dep_file])?;
9678 }
9679
9680 for hint in &extract.dispatch_hints {
9681 statements.dispatch_hint.execute(params![
9682 hint.id,
9683 hint.method_name,
9684 hint.caller_node,
9685 hint.file,
9686 hint.line as i64,
9687 hint.byte_start as i64,
9688 hint.byte_end as i64,
9689 PROVENANCE_TREESITTER,
9690 ])?;
9691 }
9692 insert_backend_state_prepared(
9693 &mut statements.backend_state,
9694 workspace_root,
9695 &extract.rel_path,
9696 Some(&extract.freshness.content_hash),
9697 "fresh",
9698 )?;
9699 Ok(())
9700}
9701
9702fn insert_backend_state_prepared(
9703 stmt: &mut Statement<'_>,
9704 workspace_root: &str,
9705 rel_path: &str,
9706 content_hash: Option<&blake3::Hash>,
9707 status: &str,
9708) -> Result<()> {
9709 let hash = content_hash
9710 .map(|hash| hash_to_hex(*hash))
9711 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
9712 stmt.execute(params![
9713 BACKEND_TREESITTER,
9714 workspace_root,
9715 rel_path,
9716 hash,
9717 status,
9718 unix_seconds_now(),
9719 ])?;
9720 Ok(())
9721}
9722
9723fn insert_staged_ref_prepared(
9724 statements: &mut ColdBuildInsertStatements<'_>,
9725 raw: &RawRef,
9726) -> Result<()> {
9727 statements.reference.execute(params![
9728 raw.ref_id,
9729 raw.caller_node,
9730 raw.caller_file,
9731 raw.kind,
9732 raw.short_name,
9733 raw.full_ref,
9734 raw.module_path,
9735 raw.import_kind,
9736 raw.local_name,
9737 raw.requested_name,
9738 raw.namespace_alias,
9739 bool_int(raw.wildcard),
9740 raw.line as i64,
9741 raw.byte_start as i64,
9742 raw.byte_end as i64,
9743 "staged",
9744 Option::<String>::None,
9745 Option::<String>::None,
9746 Option::<String>::None,
9747 ref_provenance(raw),
9748 ])?;
9749 statements
9750 .staging_ref_context
9751 .execute(params![raw.ref_id, raw.caller_symbol])?;
9752 Ok(())
9753}
9754
9755fn insert_resolved_ref_prepared(
9756 statements: &mut ColdBuildInsertStatements<'_>,
9757 resolved: &ResolvedRef,
9758) -> Result<()> {
9759 let raw = &resolved.raw;
9760 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
9761 statements.reference.execute(params![
9762 raw.ref_id,
9763 raw.caller_node,
9764 raw.caller_file,
9765 raw.kind,
9766 raw.short_name,
9767 raw.full_ref,
9768 raw.module_path,
9769 raw.import_kind,
9770 raw.local_name,
9771 raw.requested_name,
9772 raw.namespace_alias,
9773 bool_int(raw.wildcard),
9774 raw.line as i64,
9775 raw.byte_start as i64,
9776 raw.byte_end as i64,
9777 resolved.status,
9778 resolved.target_node,
9779 resolved.target_file,
9780 resolved.target_symbol,
9781 ref_provenance(raw),
9782 ])?;
9783 if let Some(edge) = &resolved.edge {
9784 statements.edge.execute(params![
9785 edge.edge_id,
9786 raw.ref_id,
9787 edge.source_node,
9788 edge.target_node,
9789 edge.target_file,
9790 edge.target_symbol,
9791 edge.kind,
9792 edge.line as i64,
9793 ref_provenance(raw),
9794 ])?;
9795 }
9796 Ok(())
9797}
9798
9799#[cfg(test)]
9800fn insert_file_extract(
9801 tx: &Transaction<'_>,
9802 project_root: &Path,
9803 extract: &FileExtract,
9804) -> Result<()> {
9805 tx.execute(
9806 "INSERT OR REPLACE INTO files(
9807 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
9808 is_public_api, surface_fingerprint, indexed_at
9809 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
9810 params![
9811 extract.rel_path,
9812 hash_to_hex(extract.freshness.content_hash),
9813 system_time_to_ns(extract.freshness.mtime),
9814 extract.freshness.size as i64,
9815 lang_label(extract.lang),
9816 extract.surface_fingerprint,
9817 unix_seconds_now(),
9818 ],
9819 )?;
9820 for node in &extract.nodes {
9821 tx.execute(
9822 "INSERT OR REPLACE INTO nodes(
9823 id, file_path, name, scoped_name, kind, start_line, start_col,
9824 end_line, end_col, range_ordinal, signature, exported,
9825 is_default_export, is_type_like, is_callgraph_entry_point, provenance
9826 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
9827 params![
9828 node.id,
9829 node.file_path,
9830 node.name,
9831 node.scoped_name,
9832 node.kind,
9833 node.range.start_line as i64,
9834 node.range.start_col as i64,
9835 node.range.end_line as i64,
9836 node.range.end_col as i64,
9837 node.range_ordinal as i64,
9838 node.signature,
9839 bool_int(node.exported),
9840 bool_int(node.is_default_export),
9841 bool_int(node.is_type_like),
9842 bool_int(node.is_callgraph_entry_point),
9843 PROVENANCE_TREESITTER,
9844 ],
9845 )?;
9846 }
9847 let mut dependencies = BTreeSet::new();
9848 for raw_ref in &extract.raw_refs {
9849 dependencies.extend(raw_ref.dependencies.iter().cloned());
9850 }
9851 insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
9852
9853 for hint in &extract.dispatch_hints {
9854 tx.execute(
9855 "INSERT OR REPLACE INTO dispatch_hints(
9856 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
9857 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
9858 params![
9859 hint.id,
9860 hint.method_name,
9861 hint.caller_node,
9862 hint.file,
9863 hint.line as i64,
9864 hint.byte_start as i64,
9865 hint.byte_end as i64,
9866 PROVENANCE_TREESITTER,
9867 ],
9868 )?;
9869 }
9870 mark_backend_state(
9871 tx,
9872 project_root,
9873 &extract.rel_path,
9874 Some(&extract.freshness.content_hash),
9875 "fresh",
9876 )?;
9877 Ok(())
9878}
9879
9880#[cfg(test)]
9881fn insert_file_dependencies(
9882 tx: &Transaction<'_>,
9883 file_path: &str,
9884 dependencies: &BTreeSet<String>,
9885) -> Result<()> {
9886 for dep_file in dependencies {
9887 tx.execute(
9888 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
9889 params![file_path, dep_file],
9890 )?;
9891 }
9892 Ok(())
9893}
9894
9895fn ref_provenance(raw: &RawRef) -> &'static str {
9896 if raw.kind == "value_ref" {
9897 PROVENANCE_VALUE_REF
9898 } else {
9899 PROVENANCE_TREESITTER
9900 }
9901}
9902
9903#[cfg(test)]
9904fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
9905 let raw = &resolved.raw;
9906 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
9907 tx.execute(
9908 "INSERT OR REPLACE INTO refs(
9909 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
9910 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
9911 byte_start, byte_end, status, target_node, target_file, target_symbol,
9912 provenance
9913 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
9914 params![
9915 raw.ref_id,
9916 raw.caller_node,
9917 raw.caller_file,
9918 raw.kind,
9919 raw.short_name,
9920 raw.full_ref,
9921 raw.module_path,
9922 raw.import_kind,
9923 raw.local_name,
9924 raw.requested_name,
9925 raw.namespace_alias,
9926 bool_int(raw.wildcard),
9927 raw.line as i64,
9928 raw.byte_start as i64,
9929 raw.byte_end as i64,
9930 resolved.status,
9931 resolved.target_node,
9932 resolved.target_file,
9933 resolved.target_symbol,
9934 ref_provenance(raw),
9935 ],
9936 )?;
9937 if let Some(edge) = &resolved.edge {
9938 tx.execute(
9939 "INSERT OR REPLACE INTO edges(
9940 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
9941 kind, line, provenance
9942 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
9943 params![
9944 edge.edge_id,
9945 raw.ref_id,
9946 edge.source_node,
9947 edge.target_node,
9948 edge.target_file,
9949 edge.target_symbol,
9950 edge.kind,
9951 edge.line as i64,
9952 ref_provenance(raw),
9953 ],
9954 )?;
9955 }
9956 Ok(())
9957}
9958
9959fn insert_method_dispatch_edges(
9960 tx: &Transaction<'_>,
9961 project_root: &Path,
9962 caller_files: Option<&BTreeSet<String>>,
9963) -> Result<usize> {
9964 let references = load_name_match_refs(tx, caller_files)?;
9965 if references.is_empty() {
9966 return Ok(0);
9967 }
9968
9969 let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
9970 let mut source_cache: DispatchSourceCache = HashMap::new();
9971 let mut inserted = 0usize;
9972 for reference in references {
9973 let key = (reference.method_name.clone(), reference.lang.clone());
9974 let candidates = match candidates_by_name.entry(key) {
9975 Entry::Occupied(entry) => entry.into_mut(),
9976 Entry::Vacant(entry) => {
9977 let candidates =
9978 load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
9979 entry.insert(candidates)
9980 }
9981 };
9982
9983 match infer_receiver_type_state(project_root, &reference, &mut source_cache) {
9984 ReceiverTypeInference::Known(receiver_type) => {
9985 let Some(candidate) =
9986 select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
9987 else {
9988 continue;
9989 };
9990 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
9991 inserted += 1;
9992 continue;
9993 }
9994 ReceiverTypeInference::RustDirectSelfField {
9995 receiver_type,
9996 declaration_file,
9997 module_scope,
9998 } => {
9999 let Some(candidate) = select_rust_direct_self_field_candidate(
10000 project_root,
10001 &reference,
10002 candidates.as_slice(),
10003 &receiver_type,
10004 &declaration_file,
10005 &module_scope,
10006 &mut source_cache,
10007 ) else {
10008 continue;
10009 };
10010 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
10011 inserted += 1;
10012 continue;
10013 }
10014 ReceiverTypeInference::KnownButUnresolved => continue,
10015 ReceiverTypeInference::Unknown => {}
10016 }
10017
10018 if method_name_match_denylisted(&reference.method_name) {
10019 continue;
10020 }
10021
10022 let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
10023 continue;
10024 };
10025 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
10026 inserted += 1;
10027 }
10028 Ok(inserted)
10029}
10030
10031fn insert_method_dispatch_edges_chunked(
10032 tx: &Transaction<'_>,
10033 project_root: &Path,
10034 chunk_size: usize,
10035) -> Result<usize> {
10036 let mut inserted = 0usize;
10037 let mut after_file = String::new();
10038 loop {
10039 let caller_files = {
10040 let mut statement = tx.prepare(
10041 "SELECT DISTINCT caller_file
10042 FROM refs
10043 WHERE caller_file > ?1
10044 ORDER BY caller_file
10045 LIMIT ?2",
10046 )?;
10047 let rows = statement
10048 .query_map(params![after_file, chunk_size.max(1) as i64], |row| {
10049 row.get::<_, String>(0)
10050 })?;
10051 rows.collect::<std::result::Result<BTreeSet<_>, _>>()?
10052 };
10053 let Some(last_file) = caller_files.last().cloned() else {
10054 break;
10055 };
10056 inserted += insert_method_dispatch_edges(tx, project_root, Some(&caller_files))?;
10057 after_file = last_file;
10058 }
10059 Ok(inserted)
10060}
10061
10062fn insert_method_dispatch_edge(
10063 tx: &Transaction<'_>,
10064 reference: &NameMatchRef,
10065 candidate: &NameMatchCandidate,
10066 provenance: &str,
10067) -> Result<()> {
10068 tx.execute(
10069 "INSERT OR REPLACE INTO edges(
10070 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
10071 kind, line, provenance
10072 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
10073 params![
10074 ref_id(&[&reference.ref_id, provenance, "edge"]),
10075 &reference.ref_id,
10076 &reference.caller_node,
10077 &candidate.node_id,
10078 &candidate.file_path,
10079 &candidate.scoped_name,
10080 reference.line as i64,
10081 provenance,
10082 ],
10083 )?;
10084 Ok(())
10085}
10086
10087fn delete_method_dispatch_edges_for_callers(
10088 tx: &Transaction<'_>,
10089 caller_files: &BTreeSet<String>,
10090) -> Result<()> {
10091 if caller_files.is_empty() {
10092 return Ok(());
10093 }
10094
10095 let mut stmt = tx.prepare(
10096 "DELETE FROM edges
10097 WHERE provenance IN (?1, ?2)
10098 AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
10099 )?;
10100 for caller_file in caller_files {
10101 stmt.execute(params![
10102 PROVENANCE_NAME_MATCH,
10103 PROVENANCE_TYPE_MATCH,
10104 caller_file
10105 ])?;
10106 }
10107 Ok(())
10108}
10109
10110fn load_name_match_refs(
10111 tx: &Transaction<'_>,
10112 caller_files: Option<&BTreeSet<String>>,
10113) -> Result<Vec<NameMatchRef>> {
10114 let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
10115 n.signature, r.short_name, r.full_ref, r.line, f.lang
10116 FROM refs r
10117 JOIN files f ON f.path = r.caller_file
10118 JOIN nodes n ON n.id = r.caller_node
10119 WHERE r.kind = 'call'
10120 AND r.status = 'unresolved'
10121 AND r.caller_node IS NOT NULL
10122 AND r.full_ref IS NOT NULL
10123 AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
10124 AND NOT EXISTS (
10125 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
10126 )";
10127 let mut references = Vec::new();
10128
10129 if let Some(caller_files) = caller_files {
10130 if caller_files.is_empty() {
10131 return Ok(references);
10132 }
10133 let sql = format!(
10134 "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
10135 );
10136 let mut stmt = tx.prepare(&sql)?;
10137 for caller_file in caller_files {
10138 let rows = stmt.query_map(params![caller_file], |row| {
10139 Ok((
10140 row.get::<_, String>(0)?,
10141 row.get::<_, Option<String>>(1)?,
10142 row.get::<_, String>(2)?,
10143 row.get::<_, String>(3)?,
10144 row.get::<_, Option<String>>(4)?,
10145 row.get::<_, Option<String>>(5)?,
10146 row.get::<_, Option<String>>(6)?,
10147 row.get::<_, i64>(7)?,
10148 row.get::<_, String>(8)?,
10149 ))
10150 })?;
10151 for row in rows {
10152 let (
10153 ref_id,
10154 caller_node,
10155 caller_file,
10156 caller_symbol,
10157 caller_signature,
10158 short_name,
10159 full_ref,
10160 line,
10161 lang,
10162 ) = row?;
10163 if let Some(reference) = name_match_ref_from_parts(
10164 ref_id,
10165 caller_node,
10166 caller_file,
10167 caller_symbol,
10168 caller_signature,
10169 short_name,
10170 full_ref,
10171 line,
10172 lang,
10173 ) {
10174 references.push(reference);
10175 }
10176 }
10177 }
10178 return Ok(references);
10179 }
10180
10181 let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
10182 let mut stmt = tx.prepare(&sql)?;
10183 let rows = stmt.query_map([], |row| {
10184 Ok((
10185 row.get::<_, String>(0)?,
10186 row.get::<_, Option<String>>(1)?,
10187 row.get::<_, String>(2)?,
10188 row.get::<_, String>(3)?,
10189 row.get::<_, Option<String>>(4)?,
10190 row.get::<_, Option<String>>(5)?,
10191 row.get::<_, Option<String>>(6)?,
10192 row.get::<_, i64>(7)?,
10193 row.get::<_, String>(8)?,
10194 ))
10195 })?;
10196 for row in rows {
10197 let (
10198 ref_id,
10199 caller_node,
10200 caller_file,
10201 caller_symbol,
10202 caller_signature,
10203 short_name,
10204 full_ref,
10205 line,
10206 lang,
10207 ) = row?;
10208 if let Some(reference) = name_match_ref_from_parts(
10209 ref_id,
10210 caller_node,
10211 caller_file,
10212 caller_symbol,
10213 caller_signature,
10214 short_name,
10215 full_ref,
10216 line,
10217 lang,
10218 ) {
10219 references.push(reference);
10220 }
10221 }
10222 Ok(references)
10223}
10224
10225#[allow(clippy::too_many_arguments)]
10226fn name_match_ref_from_parts(
10227 ref_id: String,
10228 caller_node: Option<String>,
10229 caller_file: String,
10230 caller_symbol: String,
10231 caller_signature: Option<String>,
10232 short_name: Option<String>,
10233 full_ref: Option<String>,
10234 line: i64,
10235 lang: String,
10236) -> Option<NameMatchRef> {
10237 let caller_node = caller_node?;
10238 let full_ref = full_ref?;
10239 let (receiver_expression, receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
10240 let method_name = if member.is_empty() {
10241 short_name.as_deref()?.to_string()
10242 } else {
10243 member
10244 };
10245 Some(NameMatchRef {
10246 ref_id,
10247 caller_node,
10248 caller_file,
10249 caller_symbol,
10250 caller_signature,
10251 receiver_expression,
10252 receiver,
10253 method_name,
10254 colon_dispatch,
10255 line: line.max(0) as u32,
10256 lang,
10257 })
10258}
10259
10260fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, String, bool)> {
10261 let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
10262 let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
10263 let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
10264 let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
10265 .into_iter()
10266 .flatten()
10267 .max_by_key(|(index, _, _)| *index)?;
10268 if delimiter == 0 {
10269 return None;
10270 }
10271 let member_start = delimiter + delimiter_len;
10272 if member_start >= full_ref.len() {
10273 return None;
10274 }
10275 let receiver_expression = full_ref[..delimiter].trim();
10276 let receiver = last_name_segment(receiver_expression).trim();
10277 let member = &full_ref[member_start..];
10278 if receiver.is_empty() || member.is_empty() {
10279 return None;
10280 }
10281 Some((
10282 receiver_expression.to_string(),
10283 receiver.to_string(),
10284 member.to_string(),
10285 colon_dispatch,
10286 ))
10287}
10288
10289fn last_name_segment(value: &str) -> &str {
10290 value
10291 .rsplit(['.', ':', '/', '\\', '-', '>'])
10292 .find(|segment| !segment.is_empty())
10293 .unwrap_or(value)
10294}
10295
10296fn load_name_match_candidates(
10297 tx: &Transaction<'_>,
10298 method_name: &str,
10299 lang: &str,
10300) -> Result<Vec<NameMatchCandidate>> {
10301 let mut stmt = tx.prepare(
10302 "SELECT n.id, n.file_path, n.scoped_name, n.kind, n.start_line
10303 FROM nodes n JOIN files f ON f.path = n.file_path
10304 WHERE n.name = ?1
10305 AND f.lang = ?2
10306 AND n.kind IN ('method', 'function')
10307 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
10308 )?;
10309 let rows = stmt.query_map(params![method_name, lang], |row| {
10310 Ok(NameMatchCandidate {
10311 node_id: row.get(0)?,
10312 file_path: row.get(1)?,
10313 scoped_name: row.get(2)?,
10314 kind: row.get(3)?,
10315 start_line: (row.get::<_, i64>(4)?.max(0) as u32).saturating_add(1),
10316 })
10317 })?;
10318 rows.collect::<std::result::Result<Vec<_>, _>>()
10319 .map_err(Into::into)
10320}
10321
10322struct ParsedDispatchSource {
10323 source: String,
10324 tree: tree_sitter::Tree,
10325}
10326
10327type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
10328
10329#[derive(Debug, Clone, PartialEq, Eq)]
10330enum ReceiverTypeInference {
10331 Unknown,
10332 Known(String),
10333 RustDirectSelfField {
10334 receiver_type: String,
10335 declaration_file: String,
10336 module_scope: Vec<(usize, usize)>,
10337 },
10338 KnownButUnresolved,
10339}
10340
10341#[cfg(test)]
10342fn infer_receiver_type(
10343 project_root: &Path,
10344 reference: &NameMatchRef,
10345 source_cache: &mut DispatchSourceCache,
10346) -> Option<String> {
10347 match infer_receiver_type_state(project_root, reference, source_cache) {
10348 ReceiverTypeInference::Known(receiver_type)
10349 | ReceiverTypeInference::RustDirectSelfField { receiver_type, .. } => Some(receiver_type),
10350 ReceiverTypeInference::Unknown | ReceiverTypeInference::KnownButUnresolved => None,
10351 }
10352}
10353
10354fn infer_receiver_type_state(
10355 project_root: &Path,
10356 reference: &NameMatchRef,
10357 source_cache: &mut DispatchSourceCache,
10358) -> ReceiverTypeInference {
10359 let known = |receiver_type| ReceiverTypeInference::Known(receiver_type);
10360 match reference.lang.as_str() {
10361 "rust" => infer_rust_receiver_type(project_root, reference, source_cache),
10362 "java" => {
10363 infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
10364 .map(known)
10365 .unwrap_or(ReceiverTypeInference::Unknown)
10366 }
10367 "kotlin" => {
10368 infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
10369 .map(known)
10370 .unwrap_or(ReceiverTypeInference::Unknown)
10371 }
10372 "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache)
10373 .map(known)
10374 .unwrap_or(ReceiverTypeInference::Unknown),
10375 _ => ReceiverTypeInference::Unknown,
10376 }
10377}
10378
10379fn parse_dispatch_source(
10380 project_root: &Path,
10381 caller_file: &str,
10382 lang: LangId,
10383) -> Option<ParsedDispatchSource> {
10384 let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
10385 let grammar = crate::parser::grammar_for(lang);
10386 let mut parser = tree_sitter::Parser::new();
10387 parser.set_language(&grammar).ok()?;
10388 let tree = parser.parse(&source, None)?;
10389 Some(ParsedDispatchSource { source, tree })
10390}
10391
10392fn parsed_dispatch_source<'a>(
10393 project_root: &Path,
10394 reference: &NameMatchRef,
10395 lang: LangId,
10396 source_cache: &'a mut DispatchSourceCache,
10397) -> Option<&'a ParsedDispatchSource> {
10398 parsed_dispatch_source_for_file(
10399 project_root,
10400 &reference.caller_file,
10401 &reference.lang,
10402 lang,
10403 source_cache,
10404 )
10405}
10406
10407fn parsed_dispatch_source_for_file<'a>(
10408 project_root: &Path,
10409 file_path: &str,
10410 lang_label: &str,
10411 lang: LangId,
10412 source_cache: &'a mut DispatchSourceCache,
10413) -> Option<&'a ParsedDispatchSource> {
10414 let key = (file_path.to_string(), lang_label.to_string());
10415 source_cache
10416 .entry(key)
10417 .or_insert_with(|| parse_dispatch_source(project_root, file_path, lang))
10418 .as_ref()
10419}
10420
10421fn infer_java_like_receiver_type(
10422 project_root: &Path,
10423 reference: &NameMatchRef,
10424 lang: LangId,
10425 source_cache: &mut DispatchSourceCache,
10426) -> Option<String> {
10427 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
10428 return None;
10429 }
10430
10431 let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
10432 let root = parsed.tree.root_node();
10433 let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
10434
10435 let callable_scope = type_node
10436 .and_then(|node| {
10437 find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
10438 })
10439 .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
10440
10441 if let Some(callable_scope) = callable_scope {
10442 if let Some(receiver_type) = infer_java_like_local_receiver_type(
10443 callable_scope,
10444 &parsed.source,
10445 &reference.receiver,
10446 reference.line.max(1),
10447 lang,
10448 ) {
10449 return Some(receiver_type);
10450 }
10451 }
10452
10453 type_node.and_then(|node| {
10454 infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
10455 })
10456}
10457
10458fn infer_cpp_receiver_type(
10459 project_root: &Path,
10460 reference: &NameMatchRef,
10461 source_cache: &mut DispatchSourceCache,
10462) -> Option<String> {
10463 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
10464 return None;
10465 }
10466
10467 let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
10468 let root = parsed.tree.root_node();
10469 let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
10470 infer_cpp_receiver_type_from_scope(
10471 scope,
10472 &parsed.source,
10473 &reference.receiver,
10474 reference.line.max(1),
10475 )
10476}
10477
10478fn find_enclosing_java_like_type_node<'tree>(
10479 root: tree_sitter::Node<'tree>,
10480 source: &str,
10481 reference: &NameMatchRef,
10482 lang: LangId,
10483) -> Option<tree_sitter::Node<'tree>> {
10484 let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
10485 .and_then(|name| simple_type_name(&name));
10486 let line = reference.line.max(1);
10487 let mut best = None;
10488 let mut stack = vec![root];
10489 while let Some(node) = stack.pop() {
10490 if !node_contains_line(node, line) {
10491 continue;
10492 }
10493 if is_java_like_type_kind(node.kind(), lang) {
10494 let name = declaration_name(node, source);
10495 if expected_type
10496 .as_deref()
10497 .is_none_or(|expected| name == Some(expected))
10498 {
10499 best = tighter_node(best, node);
10500 }
10501 }
10502 push_named_children(node, &mut stack);
10503 }
10504 best
10505}
10506
10507fn find_enclosing_java_like_callable_node<'tree>(
10508 root: tree_sitter::Node<'tree>,
10509 source: &str,
10510 reference: &NameMatchRef,
10511 lang: LangId,
10512) -> Option<tree_sitter::Node<'tree>> {
10513 let expected_name = reference.caller_symbol.rsplit("::").next();
10514 let line = reference.line.max(1);
10515 let mut best = None;
10516 let mut stack = vec![root];
10517 while let Some(node) = stack.pop() {
10518 if !node_contains_line(node, line) {
10519 continue;
10520 }
10521 if is_java_like_callable_kind(node.kind(), lang) {
10522 let name = declaration_name(node, source);
10523 if expected_name.is_none_or(|expected| name == Some(expected)) {
10524 best = tighter_node(best, node);
10525 }
10526 }
10527 push_named_children(node, &mut stack);
10528 }
10529 best
10530}
10531
10532fn find_enclosing_cpp_callable_node<'tree>(
10533 root: tree_sitter::Node<'tree>,
10534 _source: &str,
10535 reference: &NameMatchRef,
10536) -> Option<tree_sitter::Node<'tree>> {
10537 let line = reference.line.max(1);
10538 let mut best = None;
10539 let mut stack = vec![root];
10540 while let Some(node) = stack.pop() {
10541 if !node_contains_line(node, line) {
10542 continue;
10543 }
10544 if node.kind() == "function_definition" {
10545 best = tighter_node(best, node);
10546 }
10547 push_named_children(node, &mut stack);
10548 }
10549 best
10550}
10551
10552fn tighter_node<'tree>(
10553 current: Option<tree_sitter::Node<'tree>>,
10554 candidate: tree_sitter::Node<'tree>,
10555) -> Option<tree_sitter::Node<'tree>> {
10556 match current {
10557 Some(current)
10558 if current.start_byte() > candidate.start_byte()
10559 || (current.start_byte() == candidate.start_byte()
10560 && current.end_byte() <= candidate.end_byte()) =>
10561 {
10562 Some(current)
10563 }
10564 _ => Some(candidate),
10565 }
10566}
10567
10568fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
10569 let start = node.start_position().row as u32 + 1;
10570 let end = node.end_position().row as u32 + 1;
10571 start <= line && line <= end
10572}
10573
10574fn push_named_children<'tree>(
10575 node: tree_sitter::Node<'tree>,
10576 stack: &mut Vec<tree_sitter::Node<'tree>>,
10577) {
10578 for index in 0..node.named_child_count() {
10579 if let Some(child) = node.named_child(index as u32) {
10580 stack.push(child);
10581 }
10582 }
10583}
10584
10585fn declaration_name<'source>(
10586 node: tree_sitter::Node<'_>,
10587 source: &'source str,
10588) -> Option<&'source str> {
10589 node.child_by_field_name("name")
10590 .map(|name| node_text(name, source))
10591 .or_else(|| {
10592 first_named_child_text(
10593 node,
10594 source,
10595 &["identifier", "type_identifier", "simple_identifier"],
10596 )
10597 })
10598}
10599
10600fn first_named_child_text<'source>(
10601 node: tree_sitter::Node<'_>,
10602 source: &'source str,
10603 kinds: &[&str],
10604) -> Option<&'source str> {
10605 for index in 0..node.named_child_count() {
10606 let child = node.named_child(index as u32)?;
10607 if kinds.contains(&child.kind()) {
10608 return Some(node_text(child, source));
10609 }
10610 }
10611 None
10612}
10613
10614fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
10615 &source[node.byte_range()]
10616}
10617
10618fn infer_java_like_field_receiver_type(
10619 type_node: tree_sitter::Node<'_>,
10620 source: &str,
10621 receiver: &str,
10622 lang: LangId,
10623) -> Option<String> {
10624 let mut stack = Vec::new();
10625 push_named_children(type_node, &mut stack);
10626 while let Some(node) = stack.pop() {
10627 if is_java_like_field_kind(node.kind(), lang) {
10628 if let Some(receiver_type) =
10629 extract_java_like_declared_type(node_text(node, source), receiver, lang)
10630 {
10631 return Some(receiver_type);
10632 }
10633 }
10634 if is_java_like_type_kind(node.kind(), lang)
10635 || is_java_like_callable_kind(node.kind(), lang)
10636 {
10637 continue;
10638 }
10639 push_named_children(node, &mut stack);
10640 }
10641 None
10642}
10643
10644fn infer_java_like_local_receiver_type(
10645 callable_node: tree_sitter::Node<'_>,
10646 source: &str,
10647 receiver: &str,
10648 call_line: u32,
10649 lang: LangId,
10650) -> Option<String> {
10651 let mut best: Option<(u32, String)> = None;
10652 let mut stack = Vec::new();
10653 push_named_children(callable_node, &mut stack);
10654 while let Some(node) = stack.pop() {
10655 let start_line = node.start_position().row as u32 + 1;
10656 if start_line > call_line {
10657 continue;
10658 }
10659 if is_java_like_local_kind(node.kind(), lang) {
10660 if let Some(receiver_type) =
10661 extract_java_like_declared_type(node_text(node, source), receiver, lang)
10662 {
10663 if best
10664 .as_ref()
10665 .is_none_or(|(best_line, _)| start_line >= *best_line)
10666 {
10667 best = Some((start_line, receiver_type));
10668 }
10669 }
10670 }
10671 if is_java_like_type_kind(node.kind(), lang)
10672 || is_java_like_callable_kind(node.kind(), lang)
10673 {
10674 continue;
10675 }
10676 push_named_children(node, &mut stack);
10677 }
10678 best.map(|(_, receiver_type)| receiver_type)
10679}
10680
10681fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
10682 match lang {
10683 LangId::Java => matches!(
10684 kind,
10685 "class_declaration"
10686 | "interface_declaration"
10687 | "enum_declaration"
10688 | "record_declaration"
10689 | "annotation_type_declaration"
10690 ),
10691 LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
10692 _ => false,
10693 }
10694}
10695
10696fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
10697 match lang {
10698 LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
10699 LangId::Kotlin => kind == "function_declaration",
10700 _ => false,
10701 }
10702}
10703
10704fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
10705 match lang {
10706 LangId::Java => kind == "field_declaration",
10707 LangId::Kotlin => kind == "property_declaration",
10708 _ => false,
10709 }
10710}
10711
10712fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
10713 match lang {
10714 LangId::Java => kind == "local_variable_declaration",
10715 LangId::Kotlin => kind == "property_declaration",
10716 _ => false,
10717 }
10718}
10719
10720fn extract_java_like_declared_type(
10721 declaration: &str,
10722 receiver: &str,
10723 lang: LangId,
10724) -> Option<String> {
10725 match lang {
10726 LangId::Java => extract_java_declared_type(declaration, receiver),
10727 LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
10728 _ => None,
10729 }
10730}
10731
10732fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
10733 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
10734 let after = declaration[receiver_start + receiver.len()..].trim_start();
10735 if after
10736 .chars()
10737 .next()
10738 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
10739 {
10740 return None;
10741 }
10742
10743 let before = declaration[..receiver_start].trim_end();
10744 if before.contains(',') {
10745 return None;
10746 }
10747 normalize_receiver_type_name(strip_java_declaration_prefixes(before))
10748}
10749
10750fn strip_java_declaration_prefixes(mut value: &str) -> &str {
10751 loop {
10752 value = value.trim_start();
10753 if let Some(stripped) = strip_leading_java_annotation(value) {
10754 value = stripped;
10755 continue;
10756 }
10757 if let Some(stripped) = strip_leading_java_modifier(value) {
10758 value = stripped;
10759 continue;
10760 }
10761 return value.trim();
10762 }
10763}
10764
10765fn strip_leading_java_annotation(value: &str) -> Option<&str> {
10766 let value = value.trim_start();
10767 let mut chars = value.char_indices();
10768 let (_, first) = chars.next()?;
10769 if first != '@' {
10770 return None;
10771 }
10772 let mut end = first.len_utf8();
10773 for (index, ch) in chars {
10774 if !(is_code_ident_char(ch) || ch == '.') {
10775 end = index;
10776 break;
10777 }
10778 end = index + ch.len_utf8();
10779 }
10780 let rest = value[end..].trim_start();
10781 if let Some(stripped) = rest.strip_prefix('(') {
10782 let mut depth = 1usize;
10783 for (index, ch) in stripped.char_indices() {
10784 match ch {
10785 '(' => depth += 1,
10786 ')' => {
10787 depth = depth.saturating_sub(1);
10788 if depth == 0 {
10789 return Some(stripped[index + ch.len_utf8()..].trim_start());
10790 }
10791 }
10792 _ => {}
10793 }
10794 }
10795 return Some("");
10796 }
10797 Some(rest)
10798}
10799
10800fn strip_leading_java_modifier(value: &str) -> Option<&str> {
10801 const MODIFIERS: &[&str] = &[
10802 "public",
10803 "protected",
10804 "private",
10805 "abstract",
10806 "static",
10807 "final",
10808 "transient",
10809 "volatile",
10810 "synchronized",
10811 "native",
10812 "strictfp",
10813 ];
10814 MODIFIERS
10815 .iter()
10816 .find_map(|modifier| strip_leading_word(value, modifier))
10817}
10818
10819fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
10820 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
10821 let before = &declaration[..receiver_start];
10822 if find_identifier_occurrence(before, "val").is_none()
10823 && find_identifier_occurrence(before, "var").is_none()
10824 {
10825 return None;
10826 }
10827
10828 let after = declaration[receiver_start + receiver.len()..].trim_start();
10829 if let Some(type_text) = after.strip_prefix(':') {
10830 return normalize_receiver_type_name(read_type_prefix(type_text));
10831 }
10832 after
10833 .strip_prefix('=')
10834 .and_then(infer_kotlin_constructor_type)
10835}
10836
10837fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
10838 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
10839 if rest.trim_start().starts_with('(') {
10840 normalize_receiver_type_name(head)
10841 } else {
10842 None
10843 }
10844}
10845
10846fn read_type_prefix(value: &str) -> &str {
10847 let mut angle_depth = 0usize;
10848 for (index, ch) in value.char_indices() {
10849 match ch {
10850 '<' => angle_depth += 1,
10851 '>' => angle_depth = angle_depth.saturating_sub(1),
10852 '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
10853 return value[..index].trim();
10854 }
10855 _ => {}
10856 }
10857 }
10858 value.trim()
10859}
10860
10861fn infer_cpp_receiver_type_from_scope(
10862 scope: tree_sitter::Node<'_>,
10863 source: &str,
10864 receiver: &str,
10865 call_line: u32,
10866) -> Option<String> {
10867 let lines = source.lines().collect::<Vec<_>>();
10868 if lines.is_empty() {
10869 return None;
10870 }
10871 let scope_start = scope.start_position().row as usize;
10872 let call_index = (call_line as usize)
10873 .saturating_sub(1)
10874 .min(lines.len().saturating_sub(1));
10875 for index in (scope_start..=call_index).rev() {
10876 if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
10877 return Some(receiver_type);
10878 }
10879 }
10880 None
10881}
10882
10883fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
10884 for receiver_start in identifier_occurrences(line, receiver) {
10885 let after = line[receiver_start + receiver.len()..].trim_start();
10886 if after
10887 .chars()
10888 .next()
10889 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
10890 {
10891 continue;
10892 }
10893 let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
10894 let normalized = normalize_cpp_type_name(type_text)?;
10895 if normalized == "auto" {
10896 if let Some(rhs) = after.strip_prefix('=') {
10897 return infer_cpp_auto_receiver_type(rhs);
10898 }
10899 continue;
10900 }
10901 return Some(normalized);
10902 }
10903 None
10904}
10905
10906fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
10907 let candidate = prefix
10908 .rsplit([';', '{', '}', '('])
10909 .next()
10910 .unwrap_or(prefix)
10911 .trim();
10912 if candidate.is_empty() || candidate.ends_with(',') {
10913 None
10914 } else {
10915 Some(candidate)
10916 }
10917}
10918
10919fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
10920 let without_templates = strip_angle_groups(type_text);
10921 let mut cleaned = String::with_capacity(without_templates.len());
10922 for token in without_templates.split_whitespace() {
10923 if matches!(
10924 token,
10925 "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
10926 ) {
10927 continue;
10928 }
10929 if !cleaned.is_empty() {
10930 cleaned.push(' ');
10931 }
10932 cleaned.push_str(token);
10933 }
10934 let token = cleaned
10935 .split_whitespace()
10936 .last()
10937 .unwrap_or(cleaned.trim())
10938 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
10939 .trim_matches(['*', '&']);
10940 let simple = token.rsplit("::").next().unwrap_or(token).trim();
10941 if simple.is_empty() || cpp_non_type_token(simple) {
10942 None
10943 } else {
10944 Some(simple.to_string())
10945 }
10946}
10947
10948fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
10949 let rhs = rhs.trim_start();
10950 if let Some(after_new) = rhs.strip_prefix("new ") {
10951 return infer_cpp_constructor_type(after_new);
10952 }
10953 infer_cpp_make_template_type(rhs)
10954 .or_else(|| infer_cpp_constructor_type(rhs))
10955 .or_else(|| infer_cpp_factory_type(rhs))
10956}
10957
10958fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
10959 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
10960 let normalized = normalize_cpp_type_name(head)?;
10961 if !normalized
10962 .chars()
10963 .next()
10964 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
10965 {
10966 return None;
10967 }
10968 if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
10969 Some(normalized)
10970 } else {
10971 None
10972 }
10973}
10974
10975fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
10976 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
10977 if !rest.trim_start().starts_with('(') {
10978 return None;
10979 }
10980 let base = head.split('<').next().unwrap_or(head);
10981 let base_simple = base.rsplit("::").next().unwrap_or(base);
10982 if !matches!(base_simple, "make_unique" | "make_shared") {
10983 return None;
10984 }
10985 first_angle_arg(head).and_then(normalize_cpp_type_name)
10986}
10987
10988fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
10989 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
10990 if !rest.trim_start().starts_with('(') {
10991 return None;
10992 }
10993 let simple = head
10994 .split('<')
10995 .next()
10996 .unwrap_or(head)
10997 .rsplit("::")
10998 .next()
10999 .unwrap_or(head);
11000 for prefix in ["make", "create", "build"] {
11001 if let Some(suffix) = simple.strip_prefix(prefix) {
11002 if suffix
11003 .chars()
11004 .next()
11005 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
11006 {
11007 return normalize_cpp_type_name(suffix);
11008 }
11009 }
11010 }
11011 None
11012}
11013
11014#[derive(Debug, Clone, Copy)]
11015enum JavaLikeInvocation {
11016 Kotlin,
11017 Cpp,
11018}
11019
11020fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
11021 let value = value.trim_start();
11022 let mut end = 0usize;
11023 for (index, ch) in value.char_indices() {
11024 let allowed_separator = match flavor {
11025 JavaLikeInvocation::Kotlin => ch == '.',
11026 JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
11027 };
11028 if is_code_ident_char(ch) || allowed_separator {
11029 end = index + ch.len_utf8();
11030 continue;
11031 }
11032 break;
11033 }
11034 if end == 0 {
11035 return None;
11036 }
11037 let mut rest = &value[end..];
11038 if let Some(stripped) = rest.trim_start().strip_prefix('<') {
11039 let skipped = skip_balanced_angle(stripped)?;
11040 let rest_start = rest.len() - rest.trim_start().len();
11041 let angle_len = 1 + skipped;
11042 end += rest_start + angle_len;
11043 rest = &value[end..];
11044 }
11045 Some((value[..end].trim(), rest))
11046}
11047
11048fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
11049 let mut depth = 1usize;
11050 for (index, ch) in value_after_open.char_indices() {
11051 match ch {
11052 '<' => depth += 1,
11053 '>' => {
11054 depth = depth.saturating_sub(1);
11055 if depth == 0 {
11056 return Some(index + ch.len_utf8());
11057 }
11058 }
11059 _ => {}
11060 }
11061 }
11062 None
11063}
11064
11065fn first_angle_arg(value: &str) -> Option<&str> {
11066 let open = value.find('<')?;
11067 let inner_len = skip_balanced_angle(&value[open + 1..])?;
11068 let inner = &value[open + 1..open + inner_len];
11069 split_top_level_commas(inner).into_iter().next()
11070}
11071
11072fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
11073 let without_generics = strip_angle_groups(type_text);
11074 let cleaned = without_generics
11075 .replace("[]", " ")
11076 .replace("...", " ")
11077 .replace(['?', '&', '*'], " ");
11078 let token = cleaned
11079 .split_whitespace()
11080 .last()
11081 .unwrap_or(cleaned.trim())
11082 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
11083 let token = token.rsplit("::").next().unwrap_or(token);
11084 let simple = token.rsplit('.').next().unwrap_or(token).trim();
11085 if simple.is_empty()
11086 || java_like_primitive_type(simple)
11087 || !simple
11088 .chars()
11089 .next()
11090 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
11091 {
11092 None
11093 } else {
11094 Some(simple.to_string())
11095 }
11096}
11097
11098fn simple_type_name(scoped_name: &str) -> Option<String> {
11099 scoped_name
11100 .rsplit("::")
11101 .find(|segment| !segment.is_empty())
11102 .and_then(normalize_receiver_type_name)
11103}
11104
11105fn strip_angle_groups(value: &str) -> String {
11106 let mut output = String::with_capacity(value.len());
11107 let mut depth = 0usize;
11108 for ch in value.chars() {
11109 match ch {
11110 '<' => {
11111 if depth == 0 {
11112 output.push(' ');
11113 }
11114 depth += 1;
11115 }
11116 '>' => depth = depth.saturating_sub(1),
11117 _ if depth == 0 => output.push(ch),
11118 _ => {}
11119 }
11120 }
11121 output
11122}
11123
11124fn java_like_primitive_type(value: &str) -> bool {
11125 matches!(
11126 value,
11127 "boolean"
11128 | "byte"
11129 | "char"
11130 | "double"
11131 | "float"
11132 | "int"
11133 | "long"
11134 | "short"
11135 | "void"
11136 | "Boolean"
11137 | "Byte"
11138 | "Char"
11139 | "Double"
11140 | "Float"
11141 | "Int"
11142 | "Long"
11143 | "Short"
11144 | "Unit"
11145 )
11146}
11147
11148fn cpp_non_type_token(value: &str) -> bool {
11149 matches!(
11150 value,
11151 "return"
11152 | "if"
11153 | "else"
11154 | "for"
11155 | "while"
11156 | "do"
11157 | "switch"
11158 | "case"
11159 | "default"
11160 | "break"
11161 | "continue"
11162 | "goto"
11163 | "throw"
11164 | "new"
11165 | "delete"
11166 | "co_await"
11167 | "co_yield"
11168 | "co_return"
11169 | "static_cast"
11170 | "const_cast"
11171 | "dynamic_cast"
11172 | "reinterpret_cast"
11173 | "sizeof"
11174 | "alignof"
11175 | "typeid"
11176 | "and"
11177 | "or"
11178 | "not"
11179 | "xor"
11180 )
11181}
11182
11183fn receiver_is_bare_identifier(value: &str) -> bool {
11184 let mut chars = value.chars();
11185 let Some(first) = chars.next() else {
11186 return false;
11187 };
11188 (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
11189}
11190
11191fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
11192 identifier_occurrences(value, needle).into_iter().next()
11193}
11194
11195fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
11196 value
11197 .match_indices(needle)
11198 .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
11199 .collect()
11200}
11201
11202fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
11203 let before = value[..start].chars().next_back();
11204 let after = value[start + len..].chars().next();
11205 !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
11206}
11207
11208fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
11209 let stripped = value.strip_prefix(word)?;
11210 if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
11211 Some(stripped.trim_start())
11212 } else {
11213 None
11214 }
11215}
11216
11217fn is_code_ident_char(ch: char) -> bool {
11218 ch == '_' || ch.is_ascii_alphanumeric()
11219}
11220
11221fn infer_rust_receiver_type(
11222 project_root: &Path,
11223 reference: &NameMatchRef,
11224 source_cache: &mut DispatchSourceCache,
11225) -> ReceiverTypeInference {
11226 if matches!(reference.receiver.as_str(), "self" | "Self") {
11227 return enclosing_type_from_scoped_name(&reference.caller_symbol)
11228 .map(ReceiverTypeInference::Known)
11229 .unwrap_or(ReceiverTypeInference::Unknown);
11230 }
11231
11232 if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
11233 return ReceiverTypeInference::Known(reference.receiver.clone());
11234 }
11235
11236 if let Some(receiver_type) = reference
11237 .caller_signature
11238 .as_deref()
11239 .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
11240 {
11241 return ReceiverTypeInference::Known(receiver_type);
11242 }
11243
11244 infer_rust_direct_self_field_receiver_type(project_root, reference, source_cache)
11245}
11246
11247fn infer_rust_direct_self_field_receiver_type(
11248 project_root: &Path,
11249 reference: &NameMatchRef,
11250 source_cache: &mut DispatchSourceCache,
11251) -> ReceiverTypeInference {
11252 if reference.colon_dispatch {
11253 return ReceiverTypeInference::Unknown;
11254 }
11255 let Some(field_name) = rust_direct_self_field_name(&reference.receiver_expression) else {
11256 return ReceiverTypeInference::Unknown;
11257 };
11258 if field_name != reference.receiver {
11259 return ReceiverTypeInference::Unknown;
11260 }
11261
11262 let Some(impl_type) = enclosing_type_from_scoped_name(&reference.caller_symbol) else {
11263 return ReceiverTypeInference::Unknown;
11264 };
11265 let Some(struct_name) = rust_direct_nominal_type_name(&impl_type) else {
11266 return ReceiverTypeInference::KnownButUnresolved;
11267 };
11268 let Some(parsed) = parsed_dispatch_source(project_root, reference, LangId::Rust, source_cache)
11269 else {
11270 return ReceiverTypeInference::Unknown;
11271 };
11272 let Some(impl_node) =
11273 find_enclosing_rust_impl_node(parsed.tree.root_node(), reference.line.max(1))
11274 else {
11275 return ReceiverTypeInference::Unknown;
11276 };
11277 if impl_node.child_by_field_name("trait").is_some()
11278 || impl_node.child_by_field_name("type_parameters").is_some()
11279 {
11280 return ReceiverTypeInference::KnownButUnresolved;
11281 }
11282 let Some(impl_target) = impl_node.child_by_field_name("type") else {
11283 return ReceiverTypeInference::KnownButUnresolved;
11284 };
11285 if impl_target.kind() != "type_identifier"
11286 || node_text(impl_target, &parsed.source) != impl_type
11287 {
11288 return ReceiverTypeInference::KnownButUnresolved;
11289 }
11290
11291 let module_scope = rust_module_scope(impl_node);
11292 let Some(struct_node) = find_unique_rust_struct(
11293 parsed.tree.root_node(),
11294 &parsed.source,
11295 struct_name,
11296 &module_scope,
11297 ) else {
11298 return ReceiverTypeInference::KnownButUnresolved;
11299 };
11300 let Some(field_type) = rust_struct_field_type_node(struct_node, &parsed.source, field_name)
11301 else {
11302 return ReceiverTypeInference::KnownButUnresolved;
11303 };
11304 if field_type.kind() != "type_identifier" {
11305 return ReceiverTypeInference::KnownButUnresolved;
11306 }
11307 let field_type_name = node_text(field_type, &parsed.source);
11308 if find_unique_rust_struct(
11309 parsed.tree.root_node(),
11310 &parsed.source,
11311 field_type_name,
11312 &module_scope,
11313 )
11314 .is_none()
11315 {
11316 return ReceiverTypeInference::KnownButUnresolved;
11317 }
11318
11319 ReceiverTypeInference::RustDirectSelfField {
11320 receiver_type: field_type_name.to_string(),
11321 declaration_file: reference.caller_file.clone(),
11322 module_scope,
11323 }
11324}
11325
11326fn rust_direct_self_field_name(receiver_expression: &str) -> Option<&str> {
11327 let (base, field) = receiver_expression.split_once('.')?;
11328 let base = base.trim();
11329 let field = field.trim();
11330 (base == "self" && rust_direct_nominal_type_name(field).is_some()).then_some(field)
11331}
11332
11333fn rust_direct_nominal_type_name(value: &str) -> Option<&str> {
11334 let name = value.rsplit("::").next()?.trim();
11335 (!name.is_empty()
11336 && !name.chars().next().is_some_and(|ch| ch.is_ascii_digit())
11337 && name.chars().all(is_rust_ident_char))
11338 .then_some(name)
11339}
11340
11341fn find_enclosing_rust_impl_node<'tree>(
11342 root: tree_sitter::Node<'tree>,
11343 line: u32,
11344) -> Option<tree_sitter::Node<'tree>> {
11345 let mut best = None;
11346 let mut stack = vec![root];
11347 while let Some(node) = stack.pop() {
11348 if !node_contains_line(node, line) {
11349 continue;
11350 }
11351 if node.kind() == "impl_item" {
11352 best = tighter_node(best, node);
11353 }
11354 push_named_children(node, &mut stack);
11355 }
11356 best
11357}
11358
11359fn rust_module_scope(node: tree_sitter::Node<'_>) -> Vec<(usize, usize)> {
11360 let mut scope = Vec::new();
11361 let mut current = node.parent();
11362 while let Some(parent) = current {
11363 if parent.kind() == "mod_item" {
11364 scope.push((parent.start_byte(), parent.end_byte()));
11365 }
11366 current = parent.parent();
11367 }
11368 scope.reverse();
11369 scope
11370}
11371
11372fn find_unique_rust_struct<'tree>(
11373 root: tree_sitter::Node<'tree>,
11374 source: &str,
11375 expected_name: &str,
11376 module_scope: &[(usize, usize)],
11377) -> Option<tree_sitter::Node<'tree>> {
11378 let mut found = None;
11379 let mut stack = vec![root];
11380 while let Some(node) = stack.pop() {
11381 if node.kind() == "struct_item"
11382 && rust_module_scope(node) == module_scope
11383 && node.child_by_field_name("type_parameters").is_none()
11384 && declaration_name(node, source) == Some(expected_name)
11385 {
11386 if found.is_some() {
11387 return None;
11388 }
11389 found = Some(node);
11390 }
11391 push_named_children(node, &mut stack);
11392 }
11393 found
11394}
11395
11396fn rust_struct_field_type_node<'tree>(
11397 struct_node: tree_sitter::Node<'tree>,
11398 source: &str,
11399 field_name: &str,
11400) -> Option<tree_sitter::Node<'tree>> {
11401 let fields = struct_node.child_by_field_name("body")?;
11402 if fields.kind() != "field_declaration_list" {
11403 return None;
11404 }
11405 for index in 0..fields.named_child_count() {
11406 let field = fields.named_child(index as u32)?;
11407 if field.kind() != "field_declaration"
11408 || declaration_name(field, source) != Some(field_name)
11409 {
11410 continue;
11411 }
11412 return field.child_by_field_name("type");
11413 }
11414 None
11415}
11416
11417fn rust_receiver_looks_type_like(receiver: &str) -> bool {
11418 receiver
11419 .chars()
11420 .next()
11421 .is_some_and(|ch| ch == '_' || ch.is_uppercase())
11422}
11423
11424fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
11425 scoped_name
11426 .rsplit_once("::")
11427 .map(|(enclosing, _)| enclosing)
11428 .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
11429 .map(ToString::to_string)
11430}
11431
11432fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
11433 let params = signature_parameter_text(signature)?;
11434 for param in split_top_level_commas(params) {
11435 let Some((pattern, type_text)) = param.split_once(':') else {
11436 continue;
11437 };
11438 let Some(name) = rust_parameter_name(pattern) else {
11439 continue;
11440 };
11441 if name == receiver {
11442 return normalize_rust_receiver_type(type_text);
11443 }
11444 }
11445 None
11446}
11447
11448fn signature_parameter_text(signature: &str) -> Option<&str> {
11449 let open = signature.find('(')?;
11450 let mut depth = 0usize;
11451 for (offset, ch) in signature[open..].char_indices() {
11452 match ch {
11453 '(' => depth += 1,
11454 ')' => {
11455 depth = depth.saturating_sub(1);
11456 if depth == 0 {
11457 return Some(&signature[open + 1..open + offset]);
11458 }
11459 }
11460 _ => {}
11461 }
11462 }
11463 None
11464}
11465
11466fn split_top_level_commas(value: &str) -> Vec<&str> {
11467 let mut parts = Vec::new();
11468 let mut start = 0usize;
11469 let mut angle_depth = 0usize;
11470 let mut paren_depth = 0usize;
11471 let mut bracket_depth = 0usize;
11472 for (index, ch) in value.char_indices() {
11473 match ch {
11474 '<' => angle_depth += 1,
11475 '>' => angle_depth = angle_depth.saturating_sub(1),
11476 '(' => paren_depth += 1,
11477 ')' => paren_depth = paren_depth.saturating_sub(1),
11478 '[' => bracket_depth += 1,
11479 ']' => bracket_depth = bracket_depth.saturating_sub(1),
11480 ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
11481 let part = value[start..index].trim();
11482 if !part.is_empty() {
11483 parts.push(part);
11484 }
11485 start = index + ch.len_utf8();
11486 }
11487 _ => {}
11488 }
11489 }
11490 let part = value[start..].trim();
11491 if !part.is_empty() {
11492 parts.push(part);
11493 }
11494 parts
11495}
11496
11497fn rust_parameter_name(pattern: &str) -> Option<&str> {
11498 let mut pattern = pattern.trim();
11499 if let Some(stripped) = pattern.strip_prefix("mut ") {
11500 pattern = stripped.trim_start();
11501 }
11502 pattern
11503 .rsplit(|ch: char| !is_rust_ident_char(ch))
11504 .find(|part| !part.is_empty())
11505}
11506
11507fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
11508 let mut ty = strip_leading_rust_type_modifiers(type_text);
11509 let owned_inner;
11510 if let Some(inner) = single_outer_generic_arg(ty) {
11511 owned_inner = inner.trim().to_string();
11512 ty = strip_leading_rust_type_modifiers(&owned_inner);
11513 }
11514 rust_base_type_ident(ty)
11515}
11516
11517fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
11518 loop {
11519 ty = ty.trim_start();
11520 if let Some(stripped) = ty.strip_prefix('&') {
11521 ty = stripped.trim_start();
11522 if let Some(stripped) = strip_leading_lifetime(ty) {
11523 ty = stripped.trim_start();
11524 }
11525 if let Some(stripped) = ty.strip_prefix("mut ") {
11526 ty = stripped.trim_start();
11527 }
11528 continue;
11529 }
11530 if let Some(stripped) = ty.strip_prefix("mut ") {
11531 ty = stripped.trim_start();
11532 continue;
11533 }
11534 if let Some(stripped) = ty.strip_prefix("dyn ") {
11535 ty = stripped.trim_start();
11536 continue;
11537 }
11538 if let Some(stripped) = ty.strip_prefix("impl ") {
11539 ty = stripped.trim_start();
11540 continue;
11541 }
11542 break ty.trim();
11543 }
11544}
11545
11546fn strip_leading_lifetime(value: &str) -> Option<&str> {
11547 let mut chars = value.char_indices();
11548 let (_, first) = chars.next()?;
11549 if first != '\'' {
11550 return None;
11551 }
11552 for (index, ch) in chars {
11553 if !(ch == '_' || ch.is_ascii_alphanumeric()) {
11554 return Some(&value[index..]);
11555 }
11556 }
11557 Some("")
11558}
11559
11560fn single_outer_generic_arg(ty: &str) -> Option<&str> {
11561 let ty = ty.trim();
11562 let open = ty.find('<')?;
11563 let mut depth = 0usize;
11564 let mut close = None;
11565 for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
11566 match ch {
11567 '<' => depth += 1,
11568 '>' => {
11569 depth = depth.saturating_sub(1);
11570 if depth == 0 {
11571 close = Some(index);
11572 break;
11573 }
11574 }
11575 _ => {}
11576 }
11577 }
11578 let close = close?;
11579 if !ty[close + 1..].trim().is_empty() {
11580 return None;
11581 }
11582 let inner = &ty[open + 1..close];
11583 let args = split_top_level_commas(inner);
11584 match args.as_slice() {
11585 [arg] => Some(*arg),
11586 _ => None,
11587 }
11588}
11589
11590fn rust_base_type_ident(ty: &str) -> Option<String> {
11591 let ty = ty.trim();
11592 let head = ty
11593 .split([' ', '+', '='])
11594 .find(|part| !part.is_empty())
11595 .unwrap_or(ty);
11596 let head = head.split('<').next().unwrap_or(head).trim();
11597 let ident = head
11598 .rsplit("::")
11599 .next()
11600 .unwrap_or(head)
11601 .trim_matches(|ch: char| !is_rust_ident_char(ch));
11602 if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
11603 None
11604 } else {
11605 Some(ident.to_string())
11606 }
11607}
11608
11609fn is_rust_ident_char(ch: char) -> bool {
11610 ch == '_' || ch.is_ascii_alphanumeric()
11611}
11612
11613fn select_rust_direct_self_field_candidate(
11614 project_root: &Path,
11615 reference: &NameMatchRef,
11616 candidates: &[NameMatchCandidate],
11617 receiver_type: &str,
11618 declaration_file: &str,
11619 declaration_scope: &[(usize, usize)],
11620 source_cache: &mut DispatchSourceCache,
11621) -> Option<NameMatchCandidate> {
11622 let eligible = candidates
11623 .iter()
11624 .filter(|candidate| candidate.node_id != reference.caller_node)
11625 .filter(|candidate| {
11626 type_candidate_matches(candidate, receiver_type, &reference.method_name)
11627 })
11628 .filter(|candidate| {
11629 rust_direct_self_field_candidate_matches_scope(
11630 project_root,
11631 candidate,
11632 receiver_type,
11633 declaration_file,
11634 declaration_scope,
11635 source_cache,
11636 )
11637 })
11638 .collect::<Vec<_>>();
11639 match eligible.as_slice() {
11640 [candidate] => Some((**candidate).clone()),
11641 _ => None,
11642 }
11643}
11644
11645fn rust_direct_self_field_candidate_matches_scope(
11646 project_root: &Path,
11647 candidate: &NameMatchCandidate,
11648 receiver_type: &str,
11649 declaration_file: &str,
11650 declaration_scope: &[(usize, usize)],
11651 source_cache: &mut DispatchSourceCache,
11652) -> bool {
11653 if candidate.file_path != declaration_file {
11654 return false;
11655 }
11656 let Some(parsed) = parsed_dispatch_source_for_file(
11657 project_root,
11658 &candidate.file_path,
11659 "rust",
11660 LangId::Rust,
11661 source_cache,
11662 ) else {
11663 return false;
11664 };
11665 let Some(impl_node) =
11666 find_enclosing_rust_impl_node(parsed.tree.root_node(), candidate.start_line)
11667 else {
11668 return false;
11669 };
11670 if impl_node.child_by_field_name("trait").is_some()
11671 || impl_node.child_by_field_name("type_parameters").is_some()
11672 {
11673 return false;
11674 }
11675 let Some(impl_target) = impl_node.child_by_field_name("type") else {
11676 return false;
11677 };
11678 impl_target.kind() == "type_identifier"
11679 && node_text(impl_target, &parsed.source) == receiver_type
11680 && rust_module_scope(impl_node) == declaration_scope
11681}
11682
11683fn select_type_match_candidate(
11684 reference: &NameMatchRef,
11685 candidates: &[NameMatchCandidate],
11686 receiver_type: &str,
11687) -> Option<NameMatchCandidate> {
11688 let candidates = candidates
11689 .iter()
11690 .filter(|candidate| candidate.node_id != reference.caller_node)
11691 .filter(|candidate| {
11692 type_candidate_matches(candidate, receiver_type, &reference.method_name)
11693 })
11694 .collect::<Vec<_>>();
11695 match candidates.as_slice() {
11696 [candidate] => Some((**candidate).clone()),
11697 _ => None,
11698 }
11699}
11700
11701fn type_candidate_matches(
11702 candidate: &NameMatchCandidate,
11703 receiver_type: &str,
11704 method_name: &str,
11705) -> bool {
11706 let normalized_type = receiver_type.replace('.', "::");
11707 let suffix = format!("{normalized_type}::{method_name}");
11708 candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
11709}
11710
11711fn select_name_match_candidate(
11712 reference: &NameMatchRef,
11713 candidates: &[NameMatchCandidate],
11714) -> Option<NameMatchCandidate> {
11715 let candidates = candidates
11716 .iter()
11717 .filter(|candidate| candidate.node_id != reference.caller_node)
11718 .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
11719 .collect::<Vec<_>>();
11720 match candidates.as_slice() {
11721 [] => None,
11722 [candidate] => Some((**candidate).clone()),
11723 _ => select_scored_name_match_candidate(reference, &candidates),
11724 }
11725}
11726
11727fn candidate_allowed_for_reference(
11728 reference: &NameMatchRef,
11729 candidate: &NameMatchCandidate,
11730) -> bool {
11731 if !reference.colon_dispatch {
11732 return true;
11733 }
11734
11735 candidate.kind == "method"
11736 && candidate
11737 .scoped_name
11738 .split("::")
11739 .any(|segment| segment == reference.receiver)
11740}
11741
11742fn select_scored_name_match_candidate(
11743 reference: &NameMatchRef,
11744 candidates: &[&NameMatchCandidate],
11745) -> Option<NameMatchCandidate> {
11746 let receiver_words = split_camel_case(&reference.receiver);
11747 if receiver_words.is_empty() {
11748 return None;
11749 }
11750
11751 let mut best: Option<(&NameMatchCandidate, f64)> = None;
11752 let mut tied_best = false;
11753 for candidate in candidates {
11754 let candidate_words = split_camel_case(&candidate.scoped_name);
11755 let overlap = receiver_words
11756 .iter()
11757 .filter(|receiver_word| {
11758 candidate_words
11759 .iter()
11760 .any(|candidate_word| candidate_word == *receiver_word)
11761 })
11762 .count() as f64;
11763 let score =
11764 overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
11765 match best {
11766 None => {
11767 best = Some((*candidate, score));
11768 tied_best = false;
11769 }
11770 Some((_, best_score)) if score > best_score => {
11771 best = Some((*candidate, score));
11772 tied_best = false;
11773 }
11774 Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
11775 tied_best = true;
11776 }
11777 _ => {}
11778 }
11779 }
11780
11781 let (candidate, score) = best?;
11782 if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
11783 Some(candidate.clone())
11784 } else {
11785 None
11786 }
11787}
11788
11789fn method_name_match_denylisted(method_name: &str) -> bool {
11790 matches!(
11791 method_name,
11792 "and_then"
11793 | "as_bytes"
11794 | "as_deref"
11795 | "as_mut"
11796 | "as_ref"
11797 | "as_str"
11798 | "borrow"
11799 | "borrow_mut"
11800 | "clear"
11801 | "clone"
11802 | "collect"
11803 | "contains"
11804 | "contains_key"
11805 | "count"
11806 | "dedup"
11807 | "default"
11808 | "drain"
11809 | "ends_with"
11810 | "entry"
11811 | "err"
11812 | "expect"
11813 | "extend"
11814 | "filter"
11815 | "filter_map"
11816 | "find"
11817 | "from"
11818 | "get"
11819 | "get_mut"
11820 | "insert"
11821 | "into"
11822 | "into_iter"
11823 | "is_empty"
11824 | "is_err"
11825 | "is_none"
11826 | "is_ok"
11827 | "is_some"
11828 | "iter"
11829 | "iter_mut"
11830 | "join"
11831 | "len"
11832 | "lock"
11833 | "map"
11834 | "map_err"
11835 | "max"
11836 | "min"
11837 | "new"
11838 | "next"
11839 | "ok"
11840 | "or_default"
11841 | "or_else"
11842 | "or_insert"
11843 | "or_insert_with"
11844 | "parse"
11845 | "pop"
11846 | "position"
11847 | "push"
11848 | "read"
11849 | "recv"
11850 | "remove"
11851 | "replace"
11852 | "retain"
11853 | "send"
11854 | "sort"
11855 | "sort_by"
11856 | "split"
11857 | "starts_with"
11858 | "sum"
11859 | "take"
11860 | "to_owned"
11861 | "to_string"
11862 | "trim"
11863 | "try_from"
11864 | "try_into"
11865 | "unwrap"
11866 | "unwrap_or"
11867 | "unwrap_or_default"
11868 | "unwrap_or_else"
11869 | "with_capacity"
11870 | "write"
11871 )
11872}
11873
11874fn split_camel_case(value: &str) -> Vec<String> {
11875 let chars = value.chars().collect::<Vec<_>>();
11876 let mut normalized = String::with_capacity(value.len() + 8);
11877 for (index, ch) in chars.iter().enumerate() {
11878 let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
11879 let next = chars.get(index + 1);
11880 let is_separator = ch.is_whitespace()
11881 || matches!(
11882 ch,
11883 '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
11884 );
11885 if is_separator {
11886 normalized.push(' ');
11887 continue;
11888 }
11889 let camel_boundary = previous.is_some_and(|prev| {
11890 (prev.is_lowercase() && ch.is_uppercase())
11891 || (prev.is_ascii_digit() && ch.is_alphabetic())
11892 || (prev.is_uppercase()
11893 && ch.is_uppercase()
11894 && next.is_some_and(|next| next.is_lowercase()))
11895 });
11896 if camel_boundary {
11897 normalized.push(' ');
11898 }
11899 normalized.push(*ch);
11900 }
11901
11902 normalized
11903 .split_whitespace()
11904 .filter(|word| word.len() > 1)
11905 .map(|word| word.to_ascii_lowercase())
11906 .collect()
11907}
11908
11909fn compute_path_proximity(left: &str, right: &str) -> f64 {
11910 let left_dirs = left
11911 .rsplit_once('/')
11912 .map(|(dir, _)| dir)
11913 .unwrap_or_default()
11914 .split('/')
11915 .filter(|part| !part.is_empty());
11916 let right_dirs = right
11917 .rsplit_once('/')
11918 .map(|(dir, _)| dir)
11919 .unwrap_or_default()
11920 .split('/')
11921 .filter(|part| !part.is_empty());
11922
11923 let shared = left_dirs
11924 .zip(right_dirs)
11925 .take_while(|(left, right)| left == right)
11926 .count();
11927 ((shared as f64) * 0.05).min(0.5)
11928}
11929
11930fn mark_backend_state(
11931 tx: &Transaction<'_>,
11932 project_root: &Path,
11933 rel_path: &str,
11934 content_hash: Option<&blake3::Hash>,
11935 status: &str,
11936) -> Result<()> {
11937 clear_backend_state_for_file(tx, project_root, rel_path)?;
11938 let hash = content_hash
11939 .map(|hash| hash_to_hex(*hash))
11940 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
11941 tx.execute(
11942 "INSERT OR REPLACE INTO backend_file_state(
11943 backend, workspace_root, file_path, content_hash, status, updated_at
11944 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
11945 params![
11946 BACKEND_TREESITTER,
11947 project_root.display().to_string(),
11948 rel_path,
11949 hash,
11950 status,
11951 unix_seconds_now(),
11952 ],
11953 )?;
11954 Ok(())
11955}
11956
11957fn clear_backend_state_for_file(
11958 tx: &Transaction<'_>,
11959 project_root: &Path,
11960 rel_path: &str,
11961) -> Result<()> {
11962 tx.execute(
11963 "DELETE FROM backend_file_state
11964 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
11965 params![
11966 BACKEND_TREESITTER,
11967 project_root.display().to_string(),
11968 rel_path
11969 ],
11970 )?;
11971 Ok(())
11972}
11973
11974fn clear_stale_backend_status_for_file(
11980 tx: &Transaction<'_>,
11981 project_root: &Path,
11982 rel_path: &str,
11983) -> Result<()> {
11984 tx.execute(
11985 "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
11986 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3 AND status = 'stale'",
11987 params![
11988 BACKEND_TREESITTER,
11989 project_root.display().to_string(),
11990 rel_path,
11991 unix_seconds_now(),
11992 ],
11993 )?;
11994 Ok(())
11995}
11996
11997fn load_file_row(conn: &Connection, rel_path: &str) -> Result<Option<FileRow>> {
11998 conn.query_row(
11999 "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
12000 params![rel_path],
12001 |row| {
12002 let hash_text: String = row.get(1)?;
12003 Ok(FileRow {
12004 surface_fingerprint: row.get(0)?,
12005 freshness: FileFreshness {
12006 content_hash: hash_from_hex(&hash_text)
12007 .unwrap_or_else(cache_freshness::zero_hash),
12008 mtime: ns_to_system_time(row.get::<_, i64>(2)?),
12009 size: row.get::<_, i64>(3)? as u64,
12010 },
12011 })
12012 },
12013 )
12014 .optional()
12015 .map_err(CallGraphStoreError::from)
12016}
12017
12018fn stored_node_ids_match_extract(
12019 tx: &Transaction<'_>,
12020 rel_path: &str,
12021 extract: &FileExtract,
12022) -> Result<bool> {
12023 let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
12024 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
12025 let mut stored = BTreeSet::new();
12026 for row in rows {
12027 stored.insert(row?);
12028 }
12029 let extracted = extract
12030 .nodes
12031 .iter()
12032 .map(|node| node.id.clone())
12033 .collect::<BTreeSet<_>>();
12034 Ok(stored == extracted)
12035}
12036
12037fn stored_extract_matches(
12041 tx: &Transaction<'_>,
12042 rel_path: &str,
12043 extract: &FileExtract,
12044 index: &ProjectIndex<'_>,
12045) -> Result<bool> {
12046 let stored_file = tx
12047 .query_row(
12048 "SELECT lang, surface_fingerprint FROM files WHERE path = ?1",
12049 params![rel_path],
12050 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
12051 )
12052 .optional()?;
12053 if stored_file
12054 != Some((
12055 lang_label(extract.lang).to_string(),
12056 extract.surface_fingerprint.clone(),
12057 ))
12058 {
12059 return Ok(false);
12060 }
12061
12062 let mut stored_nodes_stmt = tx.prepare(
12063 "SELECT id, file_path, name, scoped_name, kind, start_line, start_col,
12064 end_line, end_col, range_ordinal, signature, exported,
12065 is_default_export, is_type_like, is_callgraph_entry_point, provenance
12066 FROM nodes WHERE file_path = ?1",
12067 )?;
12068 let stored_nodes = stored_nodes_stmt
12069 .query_map(params![rel_path], |row| {
12070 Ok(serde_json::json!([
12071 row.get::<_, String>(0)?,
12072 row.get::<_, String>(1)?,
12073 row.get::<_, String>(2)?,
12074 row.get::<_, String>(3)?,
12075 row.get::<_, String>(4)?,
12076 row.get::<_, i64>(5)?,
12077 row.get::<_, i64>(6)?,
12078 row.get::<_, i64>(7)?,
12079 row.get::<_, i64>(8)?,
12080 row.get::<_, i64>(9)?,
12081 row.get::<_, Option<String>>(10)?,
12082 row.get::<_, i64>(11)?,
12083 row.get::<_, i64>(12)?,
12084 row.get::<_, i64>(13)?,
12085 row.get::<_, i64>(14)?,
12086 row.get::<_, String>(15)?,
12087 ])
12088 .to_string())
12089 })?
12090 .collect::<rusqlite::Result<Vec<_>>>()?;
12091 let expected_nodes = extract
12092 .nodes
12093 .iter()
12094 .map(|node| {
12095 serde_json::json!([
12096 node.id,
12097 node.file_path,
12098 node.name,
12099 node.scoped_name,
12100 node.kind,
12101 node.range.start_line,
12102 node.range.start_col,
12103 node.range.end_line,
12104 node.range.end_col,
12105 node.range_ordinal,
12106 node.signature,
12107 bool_int(node.exported),
12108 bool_int(node.is_default_export),
12109 bool_int(node.is_type_like),
12110 bool_int(node.is_callgraph_entry_point),
12111 PROVENANCE_TREESITTER,
12112 ])
12113 .to_string()
12114 })
12115 .collect::<Vec<_>>();
12116 let mut stored_nodes = stored_nodes;
12117 let mut expected_nodes = expected_nodes;
12118 stored_nodes.sort();
12119 expected_nodes.sort();
12120 if stored_nodes != expected_nodes {
12121 return Ok(false);
12122 }
12123
12124 let resolved_refs = extract
12125 .raw_refs
12126 .iter()
12127 .cloned()
12128 .map(|raw| resolve_ref(raw, index))
12129 .collect::<Result<Vec<_>>>()?;
12130 let mut stored_refs_stmt = tx.prepare(
12131 "SELECT ref_id, caller_node, caller_file, kind, short_name, full_ref,
12132 module_path, import_kind, local_name, requested_name, namespace_alias,
12133 wildcard, line, byte_start, byte_end, status, target_node,
12134 target_file, target_symbol, provenance
12135 FROM refs WHERE caller_file = ?1",
12136 )?;
12137 let stored_refs = stored_refs_stmt
12138 .query_map(params![rel_path], |row| {
12139 Ok(serde_json::json!([
12140 row.get::<_, String>(0)?,
12141 row.get::<_, Option<String>>(1)?,
12142 row.get::<_, String>(2)?,
12143 row.get::<_, String>(3)?,
12144 row.get::<_, Option<String>>(4)?,
12145 row.get::<_, Option<String>>(5)?,
12146 row.get::<_, Option<String>>(6)?,
12147 row.get::<_, Option<String>>(7)?,
12148 row.get::<_, Option<String>>(8)?,
12149 row.get::<_, Option<String>>(9)?,
12150 row.get::<_, Option<String>>(10)?,
12151 row.get::<_, i64>(11)?,
12152 row.get::<_, i64>(12)?,
12153 row.get::<_, i64>(13)?,
12154 row.get::<_, i64>(14)?,
12155 row.get::<_, String>(15)?,
12156 row.get::<_, Option<String>>(16)?,
12157 row.get::<_, Option<String>>(17)?,
12158 row.get::<_, Option<String>>(18)?,
12159 row.get::<_, String>(19)?,
12160 ])
12161 .to_string())
12162 })?
12163 .collect::<rusqlite::Result<Vec<_>>>()?;
12164 let expected_refs = resolved_refs
12165 .iter()
12166 .map(|resolved| {
12167 let raw = &resolved.raw;
12168 serde_json::json!([
12169 raw.ref_id,
12170 raw.caller_node,
12171 raw.caller_file,
12172 raw.kind,
12173 raw.short_name,
12174 raw.full_ref,
12175 raw.module_path,
12176 raw.import_kind,
12177 raw.local_name,
12178 raw.requested_name,
12179 raw.namespace_alias,
12180 bool_int(raw.wildcard),
12181 raw.line,
12182 raw.byte_start,
12183 raw.byte_end,
12184 resolved.status,
12185 resolved.target_node,
12186 resolved.target_file,
12187 resolved.target_symbol,
12188 PROVENANCE_TREESITTER,
12189 ])
12190 .to_string()
12191 })
12192 .collect::<Vec<_>>();
12193 let mut stored_refs = stored_refs;
12194 let mut expected_refs = expected_refs;
12195 stored_refs.sort();
12196 expected_refs.sort();
12197 if stored_refs != expected_refs {
12198 return Ok(false);
12199 }
12200
12201 let mut stored_edges_stmt = tx.prepare(
12202 "SELECT e.edge_id, e.ref_id, e.source_node, e.target_node,
12203 e.target_file, e.target_symbol, e.kind, e.line, e.provenance
12204 FROM edges e JOIN refs r ON r.ref_id = e.ref_id
12205 WHERE r.caller_file = ?1 AND e.provenance = ?2",
12206 )?;
12207 let stored_edges = stored_edges_stmt
12208 .query_map(params![rel_path, PROVENANCE_TREESITTER], |row| {
12209 Ok(serde_json::json!([
12210 row.get::<_, String>(0)?,
12211 row.get::<_, String>(1)?,
12212 row.get::<_, String>(2)?,
12213 row.get::<_, Option<String>>(3)?,
12214 row.get::<_, String>(4)?,
12215 row.get::<_, String>(5)?,
12216 row.get::<_, String>(6)?,
12217 row.get::<_, i64>(7)?,
12218 row.get::<_, String>(8)?,
12219 ])
12220 .to_string())
12221 })?
12222 .collect::<rusqlite::Result<Vec<_>>>()?;
12223 let expected_edges = resolved_refs
12224 .iter()
12225 .filter_map(|resolved| {
12226 resolved.edge.as_ref().map(|edge| {
12227 serde_json::json!([
12228 edge.edge_id,
12229 resolved.raw.ref_id,
12230 edge.source_node,
12231 edge.target_node,
12232 edge.target_file,
12233 edge.target_symbol,
12234 edge.kind,
12235 edge.line,
12236 PROVENANCE_TREESITTER,
12237 ])
12238 .to_string()
12239 })
12240 })
12241 .collect::<Vec<_>>();
12242 let mut stored_edges = stored_edges;
12243 let mut expected_edges = expected_edges;
12244 stored_edges.sort();
12245 expected_edges.sort();
12246 if stored_edges != expected_edges {
12247 return Ok(false);
12248 }
12249
12250 let mut stored_dependencies_stmt =
12251 tx.prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1")?;
12252 let stored_dependencies = stored_dependencies_stmt
12253 .query_map(params![rel_path], |row| row.get::<_, String>(0))?
12254 .collect::<rusqlite::Result<BTreeSet<_>>>()?;
12255 let expected_dependencies = extract
12256 .raw_refs
12257 .iter()
12258 .flat_map(|raw| raw.dependencies.iter().cloned())
12259 .collect::<BTreeSet<_>>();
12260 if stored_dependencies != expected_dependencies {
12261 return Ok(false);
12262 }
12263
12264 let mut stored_hints_stmt = tx.prepare(
12265 "SELECT id, method_name, caller_node, file, line, byte_start, byte_end, provenance
12266 FROM dispatch_hints WHERE file = ?1",
12267 )?;
12268 let stored_hints = stored_hints_stmt
12269 .query_map(params![rel_path], |row| {
12270 Ok(serde_json::json!([
12271 row.get::<_, String>(0)?,
12272 row.get::<_, String>(1)?,
12273 row.get::<_, String>(2)?,
12274 row.get::<_, String>(3)?,
12275 row.get::<_, i64>(4)?,
12276 row.get::<_, i64>(5)?,
12277 row.get::<_, i64>(6)?,
12278 row.get::<_, String>(7)?,
12279 ])
12280 .to_string())
12281 })?
12282 .collect::<rusqlite::Result<Vec<_>>>()?;
12283 let expected_hints = extract
12284 .dispatch_hints
12285 .iter()
12286 .map(|hint| {
12287 serde_json::json!([
12288 hint.id,
12289 hint.method_name,
12290 hint.caller_node,
12291 hint.file,
12292 hint.line,
12293 hint.byte_start,
12294 hint.byte_end,
12295 PROVENANCE_TREESITTER,
12296 ])
12297 .to_string()
12298 })
12299 .collect::<Vec<_>>();
12300 let mut stored_hints = stored_hints;
12301 let mut expected_hints = expected_hints;
12302 stored_hints.sort();
12303 expected_hints.sort();
12304 Ok(stored_hints == expected_hints)
12305}
12306
12307fn update_file_fresh_metadata(
12308 tx: &Transaction<'_>,
12309 project_root: &Path,
12310 rel_path: &str,
12311 hash: &blake3::Hash,
12312 mtime: SystemTime,
12313 size: u64,
12314) -> Result<()> {
12315 tx.execute(
12316 "UPDATE files SET content_hash = ?2, mtime_ns = ?3, size = ?4, indexed_at = ?5
12317 WHERE path = ?1",
12318 params![
12319 rel_path,
12320 hash_to_hex(*hash),
12321 system_time_to_ns(mtime),
12322 size as i64,
12323 unix_seconds_now()
12324 ],
12325 )?;
12326 tx.execute(
12327 "UPDATE backend_file_state SET content_hash = ?3, status = 'fresh', updated_at = ?5
12328 WHERE backend = ?1 AND file_path = ?2 AND workspace_root = ?4",
12329 params![
12330 BACKEND_TREESITTER,
12331 rel_path,
12332 hash_to_hex(*hash),
12333 project_root.display().to_string(),
12334 unix_seconds_now(),
12335 ],
12336 )?;
12337 Ok(())
12338}
12339
12340#[derive(Debug, Clone, PartialEq, Eq)]
12341struct DependentRefSelection {
12342 ref_id: String,
12343 caller_file: String,
12344}
12345
12346fn ref_ids_depending_on(
12347 conn: &Connection,
12348 project_root: &Path,
12349 rel_path: &str,
12350) -> Result<Vec<DependentRefSelection>> {
12351 let mut stmt = conn.prepare(
12352 "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
12353 FROM refs r
12354 WHERE r.caller_file IN (
12355 SELECT file_path FROM file_dependencies WHERE dep_file = ?1
12356 )
12357 OR r.target_file = ?1
12358 ORDER BY r.ref_id",
12359 )?;
12360 let rows = stmt.query_map(params![rel_path], |row| {
12361 Ok(RefDependencyRow {
12362 ref_id: row.get(0)?,
12363 kind: row.get(1)?,
12364 caller_file: row.get(2)?,
12365 module_path: row.get(3)?,
12366 target_file: row.get(4)?,
12367 })
12368 })?;
12369 let mut ids = Vec::new();
12370 for row in rows {
12371 let row = row?;
12372 if ref_dependency_row_depends_on(project_root, &row, rel_path) {
12373 ids.push(DependentRefSelection {
12374 ref_id: row.ref_id,
12375 caller_file: row.caller_file,
12376 });
12377 }
12378 }
12379 Ok(ids)
12380}
12381
12382fn record_dependent_refs(
12383 selected_ref_ids: &mut BTreeSet<String>,
12384 selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
12385 dependent_refs: Vec<DependentRefSelection>,
12386) {
12387 for dependent_ref in dependent_refs {
12388 let DependentRefSelection {
12389 ref_id,
12390 caller_file,
12391 } = dependent_ref;
12392 selected_ref_ids.insert(ref_id.clone());
12393 selected_refs_by_caller
12394 .entry(caller_file)
12395 .or_default()
12396 .insert(ref_id);
12397 }
12398}
12399
12400#[cfg(test)]
12401fn refs_by_caller_for_ref_ids(
12402 tx: &Transaction<'_>,
12403 ref_ids: &BTreeSet<String>,
12404) -> Result<BTreeMap<String, BTreeSet<String>>> {
12405 let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12406 let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
12407 for ref_id in ref_ids {
12408 if let Some(caller) = stmt
12409 .query_row(params![ref_id], |row| row.get::<_, String>(0))
12410 .optional()?
12411 {
12412 by_caller.entry(caller).or_default().insert(ref_id.clone());
12413 }
12414 }
12415 Ok(by_caller)
12416}
12417
12418fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
12419 tx.execute(
12420 "DELETE FROM file_dependencies WHERE file_path = ?1",
12421 params![rel_path],
12422 )?;
12423 delete_refs_for_caller(tx, rel_path)?;
12424 tx.execute(
12425 "DELETE FROM dispatch_hints WHERE file = ?1",
12426 params![rel_path],
12427 )?;
12428 tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
12429 tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
12430 Ok(())
12431}
12432
12433fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
12434 let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
12435 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
12436 let mut ids = BTreeSet::new();
12437 for row in rows {
12438 ids.insert(row?);
12439 }
12440 delete_ref_ids(tx, &ids)
12441}
12442
12443fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
12444 let mut delete_edges = tx.prepare("DELETE FROM edges WHERE ref_id = ?1")?;
12445 let mut delete_refs = tx.prepare("DELETE FROM refs WHERE ref_id = ?1")?;
12446 for ref_id in ref_ids {
12447 delete_edges.execute(params![ref_id])?;
12448 delete_refs.execute(params![ref_id])?;
12449 }
12450 Ok(())
12451}
12452
12453fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
12454 let mut stmt = conn.prepare(
12455 "SELECT source.file_path, source.scoped_name, edges.target_file,
12456 edges.target_symbol, edges.kind, edges.line
12457 FROM edges
12458 JOIN nodes AS source ON source.id = edges.source_node
12459 ORDER BY source.file_path, source.scoped_name, edges.target_file,
12460 edges.target_symbol, edges.kind, edges.line",
12461 )?;
12462 let rows = stmt.query_map([], |row| {
12463 Ok(StoredEdge {
12464 source_file: row.get(0)?,
12465 source_symbol: row.get(1)?,
12466 target_file: row.get(2)?,
12467 target_symbol: row.get(3)?,
12468 kind: row.get(4)?,
12469 line: row.get::<_, i64>(5)? as u32,
12470 })
12471 })?;
12472 let mut edges = BTreeSet::new();
12473 for row in rows {
12474 edges.insert(row?);
12475 }
12476 Ok(edges)
12477}
12478
12479fn module_target_from_dependencies(
12480 project_root: &Path,
12481 dependencies: &BTreeSet<String>,
12482) -> Option<String> {
12483 dependencies.iter().find_map(|dep| {
12484 let path = project_root.join(dep);
12485 if path.is_file() {
12486 Some(relative_path(project_root, &canonicalize_path(&path)))
12487 } else {
12488 None
12489 }
12490 })
12491}
12492
12493fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
12494 let mut named = HashMap::new();
12495 if let Some(full_ref) = &raw_ref.full_ref {
12496 named = parse_reexport_names(full_ref);
12497 }
12498 ReexportIndex {
12499 target_file,
12500 named,
12501 wildcard: raw_ref.wildcard,
12502 }
12503}
12504
12505fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
12506 let mut names = HashMap::new();
12507 let Some(open) = statement.find('{') else {
12508 return names;
12509 };
12510 let Some(close) = statement[open + 1..]
12511 .find('}')
12512 .map(|offset| open + 1 + offset)
12513 else {
12514 return names;
12515 };
12516 for spec in statement[open + 1..close].split(',') {
12517 let spec = spec.trim();
12518 if spec.is_empty() {
12519 continue;
12520 }
12521 if let Some((source, local)) = spec.split_once(" as ") {
12522 names.insert(local.trim().to_string(), source.trim().to_string());
12523 } else {
12524 names.insert(spec.to_string(), spec.to_string());
12525 }
12526 }
12527 names
12528}
12529
12530#[derive(Debug)]
12531struct RefDependencyRow {
12532 ref_id: String,
12533 kind: String,
12534 caller_file: String,
12535 module_path: Option<String>,
12536 target_file: Option<String>,
12537}
12538
12539fn ref_dependency_row_depends_on(
12540 project_root: &Path,
12541 row: &RefDependencyRow,
12542 rel_path: &str,
12543) -> bool {
12544 if row.target_file.as_deref() == Some(rel_path) {
12545 return true;
12546 }
12547
12548 match row.kind.as_str() {
12549 "call" => true,
12550 "import" | "reexport" => row
12551 .module_path
12552 .as_deref()
12553 .map(|module_path| {
12554 module_dependencies_for_ref(project_root, &row.caller_file, module_path)
12555 .contains(rel_path)
12556 })
12557 .unwrap_or(false),
12558 "export_alias" => false,
12559 _ => false,
12560 }
12561}
12562
12563fn module_dependencies_for_ref(
12564 project_root: &Path,
12565 caller_file: &str,
12566 module_path: &str,
12567) -> BTreeSet<String> {
12568 module_dependencies(project_root, &project_root.join(caller_file), module_path)
12569}
12570
12571fn import_dependencies(
12572 project_root: &Path,
12573 abs_path: &Path,
12574 imports: &[ImportStatement],
12575) -> BTreeSet<String> {
12576 let mut deps = BTreeSet::new();
12577 for import in imports {
12578 deps.extend(module_dependencies(
12579 project_root,
12580 abs_path,
12581 &import.module_path,
12582 ));
12583 }
12584 deps
12585}
12586
12587fn module_dependencies(
12588 project_root: &Path,
12589 abs_path: &Path,
12590 module_path: &str,
12591) -> BTreeSet<String> {
12592 let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
12593 let caller_dir = abs_path.parent().unwrap_or(project_root);
12594 if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
12595 deps.insert(relative_path(project_root, &resolved));
12596 }
12597 if module_path.starts_with('.') {
12598 let base = caller_dir.join(module_path);
12599 for candidate in relative_module_candidates(&base) {
12600 deps.insert(relative_path(project_root, &candidate));
12601 }
12602 }
12603 deps
12604}
12605
12606fn rust_module_dependencies(
12607 project_root: &Path,
12608 abs_path: &Path,
12609 module_path: &str,
12610) -> BTreeSet<String> {
12611 let mut deps = BTreeSet::new();
12612 let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
12613 let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
12614 return deps;
12615 };
12616 let src_prefix = rust_src_prefix(&rel_path);
12617 rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
12618 if !path_segments.is_empty() {
12619 rust_push_module_dependency_candidate(
12620 project_root,
12621 &mut deps,
12622 &src_prefix,
12623 &path_segments[..path_segments.len() - 1],
12624 );
12625 }
12626 deps
12627}
12628
12629fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
12630 let path = rust_module_path_without_alias_or_use_list(module_path);
12631 let segments = path
12632 .split("::")
12633 .map(str::trim)
12634 .filter(|segment| !segment.is_empty())
12635 .collect::<Vec<_>>();
12636 if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
12637 return None;
12638 }
12639 rust_resolve_segments(rel_path, &segments)
12640}
12641
12642fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
12643 let path = module_path
12644 .trim()
12645 .trim_end_matches(';')
12646 .split_once(" as ")
12647 .map(|(left, _)| left.trim())
12648 .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
12649 path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
12650}
12651
12652fn rust_push_module_dependency_candidate(
12653 project_root: &Path,
12654 deps: &mut BTreeSet<String>,
12655 src_prefix: &str,
12656 segments: &[String],
12657) {
12658 let candidates = if segments.is_empty() {
12659 vec![
12660 format!("{src_prefix}/lib.rs"),
12661 format!("{src_prefix}/main.rs"),
12662 ]
12663 } else {
12664 vec![
12665 format!("{}/{}.rs", src_prefix, segments.join("/")),
12666 format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
12667 ]
12668 };
12669 for candidate in candidates {
12670 if project_root.join(&candidate).is_file() {
12671 deps.insert(candidate);
12672 }
12673 }
12674}
12675
12676fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
12677 let mut candidates = Vec::new();
12678 if base.extension().is_some() {
12679 candidates.push(base.to_path_buf());
12680 return candidates;
12681 }
12682 for ext in JS_TS_EXTENSIONS {
12683 candidates.push(base.with_extension(ext));
12684 }
12685 for ext in JS_TS_EXTENSIONS {
12686 candidates.push(base.join(format!("index.{ext}")));
12687 }
12688 candidates
12689}
12690
12691fn import_local_names(import: &ImportStatement) -> Vec<String> {
12692 let mut names = Vec::new();
12693 if let Some(default) = &import.default_import {
12694 names.push(default.clone());
12695 }
12696 if let Some(namespace) = &import.namespace_import {
12697 names.push(namespace.clone());
12698 }
12699 for name in &import.names {
12700 names.push(crate::imports::specifier_local_name(name).to_string());
12701 }
12702 names
12703}
12704
12705fn import_requested_names(import: &ImportStatement) -> Vec<String> {
12706 import
12707 .names
12708 .iter()
12709 .map(|name| crate::imports::specifier_imported_name(name).to_string())
12710 .collect()
12711}
12712
12713fn import_is_wildcard(import: &ImportStatement) -> bool {
12714 import.namespace_import.is_some() || import.raw_text.contains('*')
12715}
12716
12717fn namespace_alias(full_ref: &str) -> Option<String> {
12718 full_ref
12719 .split_once('.')
12720 .map(|(namespace, _)| namespace.to_string())
12721}
12722
12723fn import_kind_label(kind: ImportKind) -> &'static str {
12724 match kind {
12725 ImportKind::Value => "value",
12726 ImportKind::Type => "type",
12727 ImportKind::SideEffect => "side_effect",
12728 }
12729}
12730
12731fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
12732 match kind {
12733 SymbolKind::Function => "function",
12734 SymbolKind::Class => "class",
12735 SymbolKind::Method => "method",
12736 SymbolKind::Struct => "struct",
12737 SymbolKind::Interface => "interface",
12738 SymbolKind::Enum => "enum",
12739 SymbolKind::TypeAlias => "type_alias",
12740 SymbolKind::Variable => "variable",
12741 SymbolKind::Heading => "heading",
12742 SymbolKind::FileSummary => "file_summary",
12743 }
12744}
12745
12746fn is_type_like(kind: &SymbolKind) -> bool {
12747 matches!(
12748 kind,
12749 SymbolKind::Class
12750 | SymbolKind::Struct
12751 | SymbolKind::Interface
12752 | SymbolKind::Enum
12753 | SymbolKind::TypeAlias
12754 )
12755}
12756
12757fn lang_label(lang: LangId) -> &'static str {
12758 match lang {
12759 LangId::TypeScript => "typescript",
12760 LangId::Tsx => "tsx",
12761 LangId::JavaScript => "javascript",
12762 LangId::Python => "python",
12763 LangId::Rust => "rust",
12764 LangId::Go => "go",
12765 LangId::C => "c",
12766 LangId::Cpp => "cpp",
12767 LangId::Zig => "zig",
12768 LangId::CSharp => "csharp",
12769 LangId::Bash => "bash",
12770 LangId::Html => "html",
12771 LangId::Markdown => "markdown",
12772 LangId::Solidity => "solidity",
12773 LangId::Scss => "scss",
12774 LangId::Vue => "vue",
12775 LangId::Json => "json",
12776 LangId::Scala => "scala",
12777 LangId::Java => "java",
12778 LangId::Ruby => "ruby",
12779 LangId::Kotlin => "kotlin",
12780 LangId::Swift => "swift",
12781 LangId::Php => "php",
12782 LangId::Lua => "lua",
12783 LangId::Perl => "perl",
12784 LangId::Yaml => "yaml",
12785 LangId::Pascal => "pascal",
12786 LangId::R => "r",
12787 LangId::Groovy => "groovy",
12788 LangId::ObjC => "objc",
12789 }
12790}
12791
12792fn lang_from_label(label: &str) -> Option<LangId> {
12793 match label {
12794 "typescript" => Some(LangId::TypeScript),
12795 "tsx" => Some(LangId::Tsx),
12796 "javascript" => Some(LangId::JavaScript),
12797 "python" => Some(LangId::Python),
12798 "rust" => Some(LangId::Rust),
12799 "go" => Some(LangId::Go),
12800 "c" => Some(LangId::C),
12801 "cpp" => Some(LangId::Cpp),
12802 "zig" => Some(LangId::Zig),
12803 "csharp" => Some(LangId::CSharp),
12804 "bash" => Some(LangId::Bash),
12805 "html" => Some(LangId::Html),
12806 "markdown" => Some(LangId::Markdown),
12807 "solidity" => Some(LangId::Solidity),
12808 "scss" => Some(LangId::Scss),
12809 "vue" => Some(LangId::Vue),
12810 "json" => Some(LangId::Json),
12811 "scala" => Some(LangId::Scala),
12812 "java" => Some(LangId::Java),
12813 "ruby" => Some(LangId::Ruby),
12814 "kotlin" => Some(LangId::Kotlin),
12815 "swift" => Some(LangId::Swift),
12816 "php" => Some(LangId::Php),
12817 "lua" => Some(LangId::Lua),
12818 "perl" => Some(LangId::Perl),
12819 "yaml" => Some(LangId::Yaml),
12820 "pascal" => Some(LangId::Pascal),
12821 "r" => Some(LangId::R),
12822 "groovy" => Some(LangId::Groovy),
12823 "objc" => Some(LangId::ObjC),
12824 _ => None,
12825 }
12826}
12827
12828fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
12829 let mut normalized = if files.is_empty() {
12830 callgraph::walk_project_files(project_root).collect::<Vec<_>>()
12831 } else {
12832 files
12833 .iter()
12834 .map(|path| normalize_file_path(project_root, path))
12835 .collect::<Result<Vec<_>>>()?
12836 };
12837 normalized.sort();
12838 normalized.dedup();
12839 Ok(normalized)
12840}
12841
12842fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
12843 let full_path = if path.is_relative() {
12844 project_root.join(path)
12845 } else {
12846 path.to_path_buf()
12847 };
12848 Ok(canonicalize_path(&full_path))
12849}
12850
12851fn canonicalize_path(path: &Path) -> PathBuf {
12852 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
12853}
12854
12855fn relative_path(project_root: &Path, path: &Path) -> String {
12856 if let Ok(stripped) = path.strip_prefix(project_root) {
12857 return stripped.to_string_lossy().replace('\\', "/");
12858 }
12859 let canon_root = canonicalize_path(project_root);
12860 let canon_path = canonicalize_path(path);
12861 if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
12862 return stripped.to_string_lossy().replace('\\', "/");
12863 }
12864 canon_path.to_string_lossy().replace('\\', "/")
12865}
12866
12867fn unqualified_name(scoped: &str) -> &str {
12868 if scoped == TOP_LEVEL_SYMBOL {
12869 return scoped;
12870 }
12871 scoped
12872 .rsplit("::")
12873 .next()
12874 .unwrap_or(scoped)
12875 .rsplit('.')
12876 .next()
12877 .unwrap_or(scoped)
12878 .rsplit('#')
12879 .next()
12880 .unwrap_or(scoped)
12881}
12882
12883fn ref_id(parts: &[&str]) -> String {
12884 let joined = parts.join("\0");
12885 hash_to_hex(blake3::hash(joined.as_bytes()))
12886}
12887
12888fn callgraph_corpus_fingerprint(project_root: &Path) -> Result<String> {
12889 let mut fingerprint = CorpusFingerprint::default();
12890 for path in callgraph::walk_project_files(project_root) {
12891 fingerprint.add_path(project_root, &path);
12892 }
12893 Ok(fingerprint.finish(project_root))
12894}
12895
12896fn corpus_fingerprint_for(project_root: &Path, files: &[PathBuf]) -> Result<String> {
12900 if files.is_empty() {
12901 return callgraph_corpus_fingerprint(project_root);
12902 }
12903 let mut fingerprint = CorpusFingerprint::default();
12904 for path in files {
12905 fingerprint.add_path(project_root, path);
12906 }
12907 Ok(fingerprint.finish(project_root))
12908}
12909
12910#[derive(Default)]
12911struct CorpusFingerprint {
12912 xor: [u8; 32],
12913 sums: [u64; 4],
12914 files: u64,
12915}
12916
12917impl CorpusFingerprint {
12918 fn add_path(&mut self, project_root: &Path, path: &Path) {
12919 let mut record = blake3::Hasher::new();
12920 record.update(relative_path(project_root, path).as_bytes());
12921 record.update(&[0]);
12922 match hash_file_bounded(path) {
12923 Ok(content_hash) => record.update(content_hash.as_bytes()),
12924 Err(error) => record.update(format!("missing:{error}").as_bytes()),
12927 };
12928 record.update(&[0]);
12929 let record = record.finalize();
12930 for (index, byte) in record.as_bytes().iter().copied().enumerate() {
12931 self.xor[index] ^= byte;
12932 }
12933 for (index, chunk) in record.as_bytes().chunks_exact(8).enumerate() {
12934 let value = u64::from_le_bytes(chunk.try_into().expect("eight-byte digest chunk"));
12935 self.sums[index] = self.sums[index].wrapping_add(value);
12936 }
12937 self.files = self.files.saturating_add(1);
12938 }
12939
12940 fn finish(self, project_root: &Path) -> String {
12941 let mut hasher = blake3::Hasher::new();
12944 hasher.update(b"callgraph-corpus-fingerprint-v2\0");
12945 hasher.update(&self.files.to_le_bytes());
12946 hasher.update(&self.xor);
12947 for sum in self.sums {
12948 hasher.update(&sum.to_le_bytes());
12949 }
12950 let ignore_rules = project_root.join(".gitignore");
12951 if let Ok(contents) = std::fs::read(ignore_rules) {
12952 hasher.update(b".gitignore\0");
12953 hasher.update(blake3::hash(&contents).as_bytes());
12954 }
12955 hash_to_hex(hasher.finalize())
12956 }
12957}
12958
12959fn hash_file_bounded(path: &Path) -> std::io::Result<blake3::Hash> {
12960 let mut file = std::fs::File::open(path)?;
12961 let mut hasher = blake3::Hasher::new();
12962 let mut buffer = [0u8; 64 * 1024];
12963 loop {
12964 let read = file.read(&mut buffer)?;
12965 if read == 0 {
12966 break;
12967 }
12968 hasher.update(&buffer[..read]);
12969 }
12970 Ok(hasher.finalize())
12971}
12972
12973#[cfg(test)]
12974pub(crate) fn callgraph_corpus_fingerprint_for_test(
12975 project_root: &Path,
12976 _files: &[PathBuf],
12977) -> Result<String> {
12978 callgraph_corpus_fingerprint(project_root)
12982}
12983
12984fn hash_to_hex(hash: blake3::Hash) -> String {
12985 hash.to_hex().to_string()
12986}
12987
12988fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
12989 let bytes = hex_to_bytes(value)?;
12990 Some(blake3::Hash::from_bytes(bytes))
12991}
12992
12993fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
12994 if value.len() != 64 {
12995 return None;
12996 }
12997 let mut bytes = [0u8; 32];
12998 for (index, slot) in bytes.iter_mut().enumerate() {
12999 let start = index * 2;
13000 let end = start + 2;
13001 *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
13002 }
13003 Some(bytes)
13004}
13005
13006#[derive(Debug, Clone)]
13007struct LineIndex {
13008 newline_offsets: Vec<usize>,
13009 source_len: usize,
13010}
13011
13012impl LineIndex {
13013 fn new(source: &str) -> Self {
13014 Self {
13015 newline_offsets: source
13016 .bytes()
13017 .enumerate()
13018 .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
13019 .collect(),
13020 source_len: source.len(),
13021 }
13022 }
13023
13024 fn byte_to_line(&self, byte_offset: usize) -> u32 {
13025 let byte_offset = byte_offset.min(self.source_len);
13026 self.newline_offsets
13027 .partition_point(|offset| *offset < byte_offset) as u32
13028 + 1
13029 }
13030}
13031
13032fn empty_to_none(value: String) -> Option<String> {
13033 if value.is_empty() {
13034 None
13035 } else {
13036 Some(value)
13037 }
13038}
13039
13040fn bool_int(value: bool) -> i64 {
13041 if value {
13042 1
13043 } else {
13044 0
13045 }
13046}
13047
13048fn system_time_to_ns(time: SystemTime) -> i64 {
13049 time.duration_since(UNIX_EPOCH)
13050 .unwrap_or_default()
13051 .as_nanos()
13052 .min(i64::MAX as u128) as i64
13053}
13054
13055fn ns_to_system_time(value: i64) -> SystemTime {
13056 UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
13057}
13058
13059pub(crate) fn unix_millis_now() -> u64 {
13060 SystemTime::now()
13061 .duration_since(UNIX_EPOCH)
13062 .unwrap_or_default()
13063 .as_millis()
13064 .min(u128::from(u64::MAX)) as u64
13065}
13066
13067fn unix_seconds_now() -> i64 {
13068 SystemTime::now()
13069 .duration_since(UNIX_EPOCH)
13070 .unwrap_or_default()
13071 .as_secs() as i64
13072}
13073
13074#[cfg(test)]
13079pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
13080
13081#[cfg(test)]
13082mod refresh_worker_tests {
13083 use super::*;
13084 use std::fs;
13085 use tempfile::tempdir;
13086
13087 fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
13088 let temp = tempdir().unwrap();
13089 let root = temp.path().join("root");
13090 fs::create_dir_all(&root).unwrap();
13091 let artifact_key = crate::search_index::artifact_cache_key(&root);
13092 crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
13093 let callgraph_dir = temp
13094 .path()
13095 .join("storage")
13096 .join("callgraph")
13097 .join(artifact_key);
13098 let source = root.join("main.rs");
13099 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
13100 let (store, _) = CallGraphStore::cold_build_with_lease(
13101 callgraph_dir.clone(),
13102 root.clone(),
13103 std::slice::from_ref(&source),
13104 )
13105 .unwrap();
13106 drop(store);
13107 (temp, root, callgraph_dir, source)
13108 }
13109
13110 fn pending_paths() -> PendingCallGraphStorePaths {
13111 Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
13112 }
13113
13114 fn wait_for_refresh_calls(root: &Path, expected: usize) {
13115 let deadline = Instant::now() + Duration::from_secs(12);
13116 while callgraph_refresh_worker_test_counts(root).0 < expected {
13117 assert!(
13118 Instant::now() < deadline,
13119 "timed out waiting for {expected} callgraph refresh worker call(s)"
13120 );
13121 std::thread::sleep(Duration::from_millis(5));
13122 }
13123 }
13124
13125 fn wait_for_refresh_worker_idle() {
13126 let deadline = Instant::now() + Duration::from_secs(12);
13127 loop {
13128 let worker = CALLGRAPH_REFRESH_WORKER
13129 .get_or_init(|| Mutex::new(None))
13130 .lock()
13131 .expect("callgraph refresh worker mutex poisoned")
13132 .clone();
13133 let idle = worker.is_none_or(|worker| {
13134 let queue = worker
13135 .shared
13136 .queue
13137 .lock()
13138 .expect("callgraph refresh queue mutex poisoned");
13139 queue.active.is_none() && queue.order.is_empty()
13140 });
13141 if idle {
13142 return;
13143 }
13144 assert!(
13145 Instant::now() < deadline,
13146 "timed out waiting for callgraph refresh worker to become idle"
13147 );
13148 std::thread::sleep(Duration::from_millis(5));
13149 }
13150 }
13151
13152 fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
13153 let temp = tempdir().unwrap();
13154 let root = temp.path().join("workspace");
13155 fs::create_dir_all(root.join("app/src")).unwrap();
13156 let artifact_key = crate::search_index::artifact_cache_key(&root);
13157 crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
13158 let callgraph_dir = temp
13159 .path()
13160 .join("storage")
13161 .join("callgraph")
13162 .join(artifact_key);
13163 fs::write(
13164 root.join("Cargo.toml"),
13165 "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
13166 )
13167 .unwrap();
13168 fs::write(
13169 root.join("app/Cargo.toml"),
13170 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
13171 )
13172 .unwrap();
13173 let caller = root.join("app/src/lib.rs");
13174 fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
13175 let (store, _) = CallGraphStore::cold_build_with_lease(
13176 callgraph_dir.clone(),
13177 root.clone(),
13178 std::slice::from_ref(&caller),
13179 )
13180 .unwrap();
13181 drop(store);
13182 (temp, root, callgraph_dir, caller)
13183 }
13184
13185 #[test]
13186 fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
13187 let _guard = REFRESH_WORKER_TEST_LOCK
13188 .lock()
13189 .unwrap_or_else(std::sync::PoisonError::into_inner);
13190 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13191 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
13192 reset_workspace_crate_prefix_build_count(&root);
13193 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13194
13195 for revision in ["first", "second"] {
13196 fs::write(
13197 &caller,
13198 format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
13199 )
13200 .unwrap();
13201 enqueue_callgraph_store_refresh(
13202 callgraph_dir.clone(),
13203 root.clone(),
13204 vec![caller.clone()],
13205 pending_paths(),
13206 );
13207 wait_for_refresh_worker_idle();
13208 }
13209
13210 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
13211 assert!(flush_callgraph_store_refreshes_with_budget(
13212 Duration::from_secs(5)
13213 ));
13214 clear_callgraph_refresh_worker_test_seam(&root);
13215 }
13216
13217 #[test]
13218 fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
13219 let _guard = REFRESH_WORKER_TEST_LOCK
13220 .lock()
13221 .unwrap_or_else(std::sync::PoisonError::into_inner);
13222 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13223 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
13224 reset_workspace_crate_prefix_build_count(&root);
13225 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13226
13227 fs::write(
13228 &caller,
13229 "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
13230 )
13231 .unwrap();
13232 enqueue_callgraph_store_refresh(
13233 callgraph_dir.clone(),
13234 root.clone(),
13235 vec![caller.clone()],
13236 pending_paths(),
13237 );
13238 wait_for_refresh_worker_idle();
13239 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
13240
13241 let added_manifest = root.join("added/Cargo.toml");
13242 let added_source = root.join("added/src/lib.rs");
13243 fs::create_dir_all(added_source.parent().unwrap()).unwrap();
13244 fs::write(
13245 root.join("Cargo.toml"),
13246 "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
13247 )
13248 .unwrap();
13249 fs::write(
13250 &added_manifest,
13251 "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
13252 )
13253 .unwrap();
13254 fs::write(&added_source, "pub fn target() {}\n").unwrap();
13255 fs::write(
13256 &caller,
13257 "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
13258 )
13259 .unwrap();
13260
13261 enqueue_callgraph_store_refresh(
13262 callgraph_dir.clone(),
13263 root.clone(),
13264 vec![
13265 root.join("Cargo.toml"),
13266 added_manifest,
13267 added_source,
13268 caller,
13269 ],
13270 pending_paths(),
13271 );
13272 assert!(flush_callgraph_store_refreshes_with_budget(
13273 Duration::from_secs(12)
13274 ));
13275
13276 assert_eq!(workspace_crate_prefix_build_count(&root), 2);
13280 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
13281 .unwrap()
13282 .expect("refreshed workspace store");
13283 let tree = store
13284 .call_tree(Path::new("app/src/lib.rs"), "run", 1)
13285 .unwrap();
13286 assert_eq!(tree.children.len(), 1);
13287 assert_eq!(tree.children[0].file, "added/src/lib.rs");
13288 assert_eq!(tree.children[0].name, "target");
13289 assert!(tree.children[0].resolved);
13290 clear_callgraph_refresh_worker_test_seam(&root);
13291 }
13292
13293 fn linked_worktree_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, PathBuf) {
13294 let temp = tempdir().unwrap();
13295 let main = temp.path().join("main");
13296 let worktree = temp.path().join("worktree");
13297 fs::create_dir_all(&main).unwrap();
13298 let mut git = std::process::Command::new("git");
13299 assert!(
13300 crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
13301 .status()
13302 .unwrap()
13303 .success()
13304 );
13305 fs::write(main.join("lib.rs"), "pub fn marker() {}\n").unwrap();
13306 for args in [
13307 vec![
13308 "-C",
13309 main.to_str().unwrap(),
13310 "config",
13311 "user.email",
13312 "test@example.com",
13313 ],
13314 vec![
13315 "-C",
13316 main.to_str().unwrap(),
13317 "config",
13318 "user.name",
13319 "AFT Test",
13320 ],
13321 vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
13322 vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
13323 ] {
13324 let mut command = std::process::Command::new("git");
13325 assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
13326 .status()
13327 .unwrap()
13328 .success());
13329 }
13330 let mut add_worktree = std::process::Command::new("git");
13331 assert!(crate::test_env::apply_hermetic_git_env(
13332 add_worktree
13333 .arg("-C")
13334 .arg(&main)
13335 .args(["worktree", "add", "--detach"])
13336 .arg(&worktree),
13337 )
13338 .status()
13339 .unwrap()
13340 .success());
13341 let main = fs::canonicalize(main).unwrap();
13342 let worktree = fs::canonicalize(worktree).unwrap();
13343 let project_key = crate::search_index::artifact_cache_key(&main);
13344 assert_eq!(
13345 crate::search_index::artifact_cache_key(&worktree),
13346 project_key
13347 );
13348 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
13349 (temp, main, worktree, project_key, callgraph_dir)
13350 }
13351
13352 #[test]
13353 fn linked_worktree_never_acquires_writer_or_publishes_any_build_path() {
13354 let _git_env = crate::test_env::hermetic_git_env_guard();
13355 let (_temp, _main, root, project_key, callgraph_dir) = linked_worktree_fixture();
13356 crate::root_cache::configure_artifact_access(&root, &project_key, true);
13357 crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
13358 let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13359 let publications_for_observer = Arc::clone(&publications);
13360 set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
13361 publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
13362 })));
13363 let source = root.join("lib.rs");
13364
13365 let open_error = CallGraphStore::open(callgraph_dir.clone(), root.clone())
13366 .expect_err("borrow-only writable open must remain unavailable");
13367 assert!(matches!(open_error, CallGraphStoreError::Unavailable(_)));
13368 assert!(
13369 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), root.clone())
13370 .unwrap()
13371 .is_none()
13372 );
13373 assert!(
13374 CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), root.clone())
13375 .unwrap()
13376 .is_none()
13377 );
13378 assert!(matches!(
13379 CallGraphStore::cold_build_with_lease(
13380 callgraph_dir.clone(),
13381 root.clone(),
13382 std::slice::from_ref(&source),
13383 ),
13384 Err(CallGraphStoreError::Unavailable(_))
13385 ));
13386 assert!(matches!(
13387 CallGraphStore::ensure_built_with_lease(
13388 callgraph_dir.clone(),
13389 root.clone(),
13390 std::slice::from_ref(&source),
13391 ),
13392 Err(CallGraphStoreError::Unavailable(_))
13393 ));
13394 let force_error = CallGraphStore::force_cold_build_with_lease_chunked(
13395 callgraph_dir.clone(),
13396 root.clone(),
13397 &[source],
13398 1,
13399 )
13400 .expect_err("borrow-only forced rebuild must remain unsatisfied");
13401 set_cold_build_swap_observer(None);
13402
13403 assert!(matches!(force_error, CallGraphStoreError::Unavailable(_)));
13404 assert_eq!(
13405 crate::root_cache::writer_lease_acquisition_count_for_test(
13406 crate::root_cache::RootCacheDomain::Callgraph,
13407 &project_key,
13408 &root,
13409 ),
13410 0
13411 );
13412 assert_eq!(publications.load(AtomicOrdering::SeqCst), 0);
13413 assert!(!pointer_path(&callgraph_dir, &project_key).exists());
13414 }
13415
13416 #[test]
13417 fn owner_and_linked_worktree_alternation_rebuilds_storm_generation_once() {
13418 let _git_env = crate::test_env::hermetic_git_env_guard();
13419 let (_temp, owner, worktree, project_key, callgraph_dir) = linked_worktree_fixture();
13420 crate::root_cache::configure_artifact_access(&owner, &project_key, false);
13421 crate::root_cache::configure_artifact_access(&worktree, &project_key, true);
13422 let source = owner.join("lib.rs");
13423 let (store, _) = CallGraphStore::cold_build_with_lease(
13424 callgraph_dir.clone(),
13425 owner.clone(),
13426 std::slice::from_ref(&source),
13427 )
13428 .unwrap();
13429 let sqlite_path = store.sqlite_path().to_path_buf();
13430 drop(store);
13431
13432 let conn = Connection::open(&sqlite_path).unwrap();
13433 conn.execute(
13434 "UPDATE backend_file_state SET workspace_root = ?1",
13435 [worktree.display().to_string()],
13436 )
13437 .unwrap();
13438 drop(conn);
13439
13440 let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13441 let publications_for_observer = Arc::clone(&publications);
13442 set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
13443 publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
13444 })));
13445 crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
13446
13447 let repaired = CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
13448 .unwrap()
13449 .expect("owner should purge the storm-era worktree root");
13450 drop(repaired);
13451 for _ in 0..3 {
13452 let borrower = CallGraphStore::open_readonly(callgraph_dir.clone(), worktree.clone())
13453 .unwrap()
13454 .expect("linked worktree should borrow the owner generation");
13455 drop(borrower);
13456 assert!(
13457 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), worktree.clone())
13458 .unwrap()
13459 .is_none()
13460 );
13461 let owner_store =
13462 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
13463 .unwrap()
13464 .expect("owner generation should remain ready");
13465 drop(owner_store);
13466 }
13467 set_cold_build_swap_observer(None);
13468
13469 assert_eq!(
13470 publications.load(AtomicOrdering::SeqCst),
13471 1,
13472 "the owner performs one expected post-storm purge and alternation stays read-only"
13473 );
13474 assert_eq!(
13475 crate::root_cache::writer_lease_acquisition_count_for_test(
13476 crate::root_cache::RootCacheDomain::Callgraph,
13477 &project_key,
13478 &worktree,
13479 ),
13480 0
13481 );
13482 }
13483
13484 #[test]
13485 fn rebuild_cooldown_records_only_successful_publication_per_cache_key() {
13486 let temp = tempdir().unwrap();
13487 let root = temp.path().join("owner");
13488 let other_root = temp.path().join("other");
13489 fs::create_dir_all(&root).unwrap();
13490 fs::create_dir_all(&other_root).unwrap();
13491 let source = root.join("lib.rs");
13492 fs::write(&source, "pub fn marker() {}\n").unwrap();
13493 let project_key = crate::search_index::artifact_cache_key(&root);
13494 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
13495 crate::root_cache::configure_artifact_access(&root, &project_key, false);
13496 let cooldown_key = rebuild_cooldown_key(&callgraph_dir, &project_key);
13497 rebuild_cooldown_records()
13498 .lock()
13499 .unwrap_or_else(std::sync::PoisonError::into_inner)
13500 .remove(&cooldown_key);
13501 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
13502 let stale_epoch = epoch.current();
13503 epoch.next();
13504
13505 let failed = with_publish_epoch(epoch, stale_epoch, || {
13506 CallGraphStore::cold_build_with_lease(
13507 callgraph_dir.clone(),
13508 root.clone(),
13509 std::slice::from_ref(&source),
13510 )
13511 });
13512 assert!(matches!(failed, Err(CallGraphStoreError::Superseded)));
13513 assert!(
13514 rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
13515 .is_none()
13516 );
13517
13518 let (store, _) = CallGraphStore::cold_build_with_lease(
13519 callgraph_dir.clone(),
13520 root.clone(),
13521 std::slice::from_ref(&source),
13522 )
13523 .unwrap();
13524 drop(store);
13525 assert!(
13526 rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
13527 .is_none()
13528 );
13529
13530 record_successful_rebuild(&callgraph_dir, &project_key, &other_root, Instant::now());
13531 assert!(
13532 rebuild_cooldown_denial(&callgraph_dir, &project_key, &root, Instant::now(),).is_some()
13533 );
13534 }
13535
13536 #[test]
13537 fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
13538 let _guard = REFRESH_WORKER_TEST_LOCK
13539 .lock()
13540 .unwrap_or_else(std::sync::PoisonError::into_inner);
13541 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13542 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13543 let pending = pending_paths();
13544 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13545
13546 let lifecycle = SubcLifecycleAdmission::default();
13547 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
13548 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
13549 let ticket = CallgraphRefreshTicket::new(
13550 lifecycle,
13551 Arc::clone(&generation),
13552 7,
13553 publish_epoch.clone(),
13554 publish_epoch.current(),
13555 );
13556 generation.store(8, std::sync::atomic::Ordering::SeqCst);
13558 let installed = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
13559 .unwrap()
13560 .expect("ready store snapshot");
13561 let refresh_state = CallgraphRefreshState::new(
13562 Arc::new(std::sync::RwLock::new(Some(Arc::new(installed)))),
13563 Arc::new(AtomicBool::new(true)),
13564 );
13565
13566 enqueue_callgraph_store_refresh_fenced_with_state(
13567 callgraph_dir,
13568 root.clone(),
13569 vec![source.clone()],
13570 Arc::clone(&pending),
13571 refresh_state,
13572 ticket,
13573 );
13574 assert!(flush_callgraph_store_refreshes_with_budget(
13575 Duration::from_secs(5)
13576 ));
13577 assert_eq!(
13578 callgraph_refresh_worker_test_counts(&root).0,
13579 0,
13580 "superseded batch must not reach refresh_files or self-replay"
13581 );
13582 assert!(
13583 pending.lock().contains(&source),
13584 "superseded batch must defer its paths to the pending sink"
13585 );
13586 clear_callgraph_refresh_worker_test_seam(&root);
13587 }
13588
13589 #[test]
13590 fn superseded_open_failure_defers_without_self_replay() {
13591 let _guard = REFRESH_WORKER_TEST_LOCK
13592 .lock()
13593 .unwrap_or_else(std::sync::PoisonError::into_inner);
13594 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13595 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13596 let pending = pending_paths();
13597 let installed = Arc::new(
13598 CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
13599 .unwrap()
13600 .expect("ready store snapshot"),
13601 );
13602 let refresh_state = CallgraphRefreshState::new(
13603 Arc::new(std::sync::RwLock::new(Some(Arc::clone(&installed)))),
13604 Arc::new(AtomicBool::new(true)),
13605 );
13606 assert!(!installed.is_legacy_fallback());
13607 assert!(installed.is_current());
13608 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
13609 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13610 set_callgraph_refresh_worker_test_open_failure(root.clone(), true);
13611 let (held_rx, release_tx) = install_callgraph_refresh_worker_test_gate(root.clone());
13612
13613 let lifecycle = SubcLifecycleAdmission::default();
13614 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
13615 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
13616 let ticket = CallgraphRefreshTicket::new(
13617 lifecycle,
13618 Arc::clone(&generation),
13619 7,
13620 publish_epoch.clone(),
13621 publish_epoch.current(),
13622 );
13623 enqueue_callgraph_store_refresh_fenced_with_state(
13624 callgraph_dir,
13625 root.clone(),
13626 vec![source.clone()],
13627 Arc::clone(&pending),
13628 refresh_state,
13629 ticket,
13630 );
13631 held_rx
13632 .recv_timeout(Duration::from_secs(12))
13633 .expect("refresh worker must hold after injected open failure");
13634
13635 generation.store(8, std::sync::atomic::Ordering::SeqCst);
13638 set_callgraph_refresh_worker_test_open_failure(root.clone(), false);
13639 release_tx
13640 .send(())
13641 .expect("release superseded refresh worker");
13642 wait_for_refresh_worker_idle();
13643
13644 assert_eq!(
13645 callgraph_refresh_worker_test_counts(&root).0,
13646 1,
13647 "superseded open-failure batch must not self-replay"
13648 );
13649 assert_eq!(
13650 callgraph_refresh_worker_test_worker_calls(&root),
13651 1,
13652 "superseded open-failure batch must not create another worker call"
13653 );
13654 assert!(
13655 pending.lock().contains(&source),
13656 "superseded open-failure paths must remain in the pending sink"
13657 );
13658 let tree = installed
13659 .call_tree(Path::new("main.rs"), "entry", 1)
13660 .unwrap();
13661 assert_eq!(
13662 tree.children[0].name, "old_leaf",
13663 "superseded open-failure batch must not converge the store"
13664 );
13665 clear_callgraph_refresh_worker_test_seam(&root);
13666 }
13667
13668 #[test]
13669 fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
13670 let _guard = REFRESH_WORKER_TEST_LOCK
13671 .lock()
13672 .unwrap_or_else(std::sync::PoisonError::into_inner);
13673 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13674 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13675 let pending = pending_paths();
13676 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13677
13678 let lifecycle = SubcLifecycleAdmission::default();
13679 let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
13680 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
13681 let expected_epoch = publish_epoch.current();
13682 let ticket = CallgraphRefreshTicket::new(
13683 lifecycle,
13684 generation,
13685 3,
13686 publish_epoch.clone(),
13687 expected_epoch,
13688 );
13689 publish_epoch.next();
13691
13692 enqueue_callgraph_store_refresh_fenced(
13693 callgraph_dir,
13694 root.clone(),
13695 vec![source.clone()],
13696 Arc::clone(&pending),
13697 ticket,
13698 );
13699 assert!(flush_callgraph_store_refreshes_with_budget(
13700 Duration::from_secs(5)
13701 ));
13702 assert_eq!(
13703 callgraph_refresh_worker_test_counts(&root).0,
13704 0,
13705 "epoch-superseded batch must not reach refresh_files"
13706 );
13707 assert!(
13708 pending.lock().contains(&source),
13709 "epoch-superseded batch must defer its paths to the pending sink"
13710 );
13711 clear_callgraph_refresh_worker_test_seam(&root);
13712 }
13713
13714 #[test]
13715 fn fenced_refresh_with_current_ticket_commits_normally() {
13716 let _guard = REFRESH_WORKER_TEST_LOCK
13717 .lock()
13718 .unwrap_or_else(std::sync::PoisonError::into_inner);
13719 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13720 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13721 let pending = pending_paths();
13722 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13723
13724 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
13725
13726 let lifecycle = SubcLifecycleAdmission::default();
13727 let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
13728 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
13729 let ticket = CallgraphRefreshTicket::new(
13730 lifecycle,
13731 generation,
13732 5,
13733 publish_epoch.clone(),
13734 publish_epoch.current(),
13735 );
13736
13737 enqueue_callgraph_store_refresh_fenced(
13738 callgraph_dir.clone(),
13739 root.clone(),
13740 vec![source.clone()],
13741 Arc::clone(&pending),
13742 ticket,
13743 );
13744 assert!(flush_callgraph_store_refreshes_with_budget(
13745 Duration::from_secs(5)
13746 ));
13747 assert_eq!(
13748 callgraph_refresh_worker_test_counts(&root).0,
13749 1,
13750 "current ticket must run the refresh"
13751 );
13752 assert!(
13753 pending.lock().is_empty(),
13754 "committed batch must not defer paths"
13755 );
13756
13757 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
13758 .unwrap()
13759 .expect("published generation must remain readable");
13760 let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
13761 assert_eq!(
13762 tree.children[0].name, "new_leaf",
13763 "fenced commit must actually persist the refreshed content"
13764 );
13765 clear_callgraph_refresh_worker_test_seam(&root);
13766 }
13767
13768 #[test]
13769 fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
13770 let _guard = REFRESH_WORKER_TEST_LOCK
13771 .lock()
13772 .unwrap_or_else(std::sync::PoisonError::into_inner);
13773 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13778 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13779 let pending = pending_paths();
13780 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
13781
13782 enqueue_callgraph_store_refresh(
13783 callgraph_dir.clone(),
13784 root.clone(),
13785 vec![source.clone()],
13786 Arc::clone(&pending),
13787 );
13788 wait_for_refresh_calls(&root, 1);
13789 for _ in 0..3 {
13790 enqueue_callgraph_store_refresh(
13791 callgraph_dir.clone(),
13792 root.clone(),
13793 vec![source.clone()],
13794 Arc::clone(&pending),
13795 );
13796 }
13797
13798 assert!(flush_callgraph_store_refreshes_with_budget(
13799 Duration::from_secs(2)
13800 ));
13801 assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
13802 assert!(pending.lock().is_empty());
13803 clear_callgraph_refresh_worker_test_seam(&root);
13804 }
13805
13806 #[test]
13807 fn queued_refresh_opens_generation_published_after_enqueue() {
13808 let _guard = REFRESH_WORKER_TEST_LOCK
13809 .lock()
13810 .unwrap_or_else(std::sync::PoisonError::into_inner);
13811 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13816 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
13817 let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
13818 set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
13819 let (active_held_rx, active_release_tx) =
13820 install_callgraph_refresh_worker_test_gate(active_root.clone());
13821 set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
13822 enqueue_callgraph_store_refresh(
13823 active_dir,
13824 active_root.clone(),
13825 vec![active_source],
13826 pending_paths(),
13827 );
13828 active_held_rx
13829 .recv_timeout(Duration::from_secs(12))
13830 .expect("active refresh worker holds the queue");
13831
13832 fs::write(
13833 &target_source,
13834 "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
13835 )
13836 .unwrap();
13837 enqueue_callgraph_store_refresh(
13838 target_dir.clone(),
13839 target_root.clone(),
13840 vec![target_source.clone()],
13841 pending_paths(),
13842 );
13843 let (new_generation, _) = CallGraphStore::cold_build_with_lease(
13844 target_dir.clone(),
13845 target_root.clone(),
13846 std::slice::from_ref(&target_source),
13847 )
13848 .unwrap();
13849 fs::write(
13850 &target_source,
13851 "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
13852 )
13853 .unwrap();
13854 drop(new_generation);
13855
13856 active_release_tx
13857 .send(())
13858 .expect("release active refresh worker");
13859 wait_for_refresh_calls(&target_root, 1);
13860 assert!(flush_callgraph_store_refreshes_with_budget(
13861 Duration::from_secs(12)
13862 ));
13863 let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
13864 .unwrap()
13865 .expect("current callgraph generation");
13866 let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
13867 assert_eq!(tree.children[0].name, "worker_leaf");
13868 assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
13869 clear_callgraph_refresh_worker_test_seam(&active_root);
13870 clear_callgraph_refresh_worker_test_seam(&target_root);
13871 }
13872
13873 #[test]
13874 fn refresh_failure_marks_files_stale() {
13875 let _guard = REFRESH_WORKER_TEST_LOCK
13876 .lock()
13877 .unwrap_or_else(std::sync::PoisonError::into_inner);
13878 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13883 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13884 let pending = pending_paths();
13885 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
13886
13887 enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
13888 assert!(flush_callgraph_store_refreshes_with_budget(
13889 Duration::from_secs(2)
13890 ));
13891
13892 assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
13893 let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
13894 .unwrap()
13895 .expect("ready callgraph store");
13896 assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
13897 clear_callgraph_refresh_worker_test_seam(&root);
13898 }
13899
13900 #[test]
13901 fn idle_refresh_truncates_wal() {
13902 let _guard = REFRESH_WORKER_TEST_LOCK
13903 .lock()
13904 .unwrap_or_else(std::sync::PoisonError::into_inner);
13905 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13906 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13907 let generation = read_pointer(
13908 &callgraph_dir,
13909 &crate::search_index::artifact_cache_key(&root),
13910 )
13911 .expect("fixture publishes a generation");
13912 let wal_path = callgraph_dir.join(format!("{generation}-wal"));
13913 let pending = pending_paths();
13914 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13915
13916 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n\n").unwrap();
13917 enqueue_callgraph_store_refresh(
13918 callgraph_dir.clone(),
13919 root.clone(),
13920 vec![source.clone()],
13921 Arc::clone(&pending),
13922 );
13923 wait_for_refresh_calls(&root, 1);
13924 wait_for_refresh_worker_idle();
13925 let checkpoint_deadline = Instant::now() + Duration::from_secs(2);
13926 while fs::metadata(&wal_path)
13927 .map(|metadata| metadata.len())
13928 .unwrap_or(0)
13929 != 0
13930 {
13931 assert!(
13932 Instant::now() < checkpoint_deadline,
13933 "idle checkpoint did not truncate WAL"
13934 );
13935 std::thread::sleep(Duration::from_millis(5));
13936 }
13937 assert_eq!(
13938 fs::metadata(&wal_path)
13939 .map(|metadata| metadata.len())
13940 .unwrap_or(0),
13941 0,
13942 "idle transition truncates the refresh WAL"
13943 );
13944
13945 clear_callgraph_refresh_worker_test_seam(&root);
13946 }
13947
13948 #[test]
13949 fn bounded_shutdown_defers_unprocessed_batches() {
13950 let _guard = REFRESH_WORKER_TEST_LOCK
13951 .lock()
13952 .unwrap_or_else(std::sync::PoisonError::into_inner);
13953 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13958 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
13959 let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
13960 let active_pending = pending_paths();
13961 let queued_pending = pending_paths();
13962 set_callgraph_refresh_worker_test_seam(
13963 active_root.clone(),
13964 Duration::from_millis(300),
13965 false,
13966 );
13967
13968 enqueue_callgraph_store_refresh(
13969 active_dir,
13970 active_root.clone(),
13971 vec![active_source.clone()],
13972 Arc::clone(&active_pending),
13973 );
13974 wait_for_refresh_calls(&active_root, 1);
13975 enqueue_callgraph_store_refresh(
13976 queued_dir,
13977 queued_root.clone(),
13978 vec![queued_source.clone()],
13979 Arc::clone(&queued_pending),
13980 );
13981
13982 assert!(!flush_callgraph_store_refreshes_with_budget(
13983 Duration::from_millis(20)
13984 ));
13985 assert!(active_pending.lock().contains(&active_source));
13986 assert!(queued_pending.lock().contains(&queued_source));
13987 assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
13988 clear_callgraph_refresh_worker_test_seam(&active_root);
13989 }
13990}
13991
13992#[cfg(test)]
13993mod cold_build_insert_tests {
13994 use super::*;
13995 use crate::imports::ImportBlock;
13996 use std::cell::Cell;
13997 use std::fs;
13998 use std::path::{Path, PathBuf};
13999 use tempfile::tempdir;
14000
14001 thread_local! {
14002 static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
14003 static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
14004 static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
14005 }
14006
14007 fn count_caller_traversal_selects(sql: &str) {
14008 let sql = sql.trim_start();
14009 if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
14010 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
14011 }
14012 if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
14013 && sql.contains("e.target_file =")
14014 {
14015 CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
14016 }
14017 if sql.starts_with("WITH requested") && sql.contains("COUNT(*)") {
14018 BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
14019 }
14020 }
14021
14022 #[test]
14023 fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
14024 let dir = tempdir().unwrap();
14025 let previous_root = dir.path().join("previous-root");
14026 let current_root = dir.path().join("current-root");
14027 fs::create_dir_all(&previous_root).unwrap();
14028 fs::create_dir_all(¤t_root).unwrap();
14029 fs::remove_dir(&previous_root).unwrap();
14030 let mut conn = Connection::open_in_memory().unwrap();
14031 initialize_schema(&conn).unwrap();
14032 conn.execute(
14033 "INSERT INTO backend_file_state(
14034 backend, workspace_root, file_path, content_hash, status, updated_at
14035 ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
14036 params![previous_root.display().to_string()],
14037 )
14038 .unwrap();
14039
14040 let repair = reconcile_workspace_roots(&mut conn, ¤t_root, false).unwrap();
14041
14042 assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
14043 assert_eq!(
14044 stored_workspace_roots(&conn).unwrap(),
14045 vec![previous_root.display().to_string()]
14046 );
14047 }
14048
14049 #[test]
14050 fn sqlite_readonly_uri_percent_encodes_windows_paths() {
14051 assert_eq!(
14052 sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
14053 "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
14054 );
14055 }
14056
14057 #[test]
14058 fn legacy_migration_completion_log_has_operator_fields() {
14059 assert_eq!(
14060 legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
14061 "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
14062 );
14063 }
14064
14065 fn write_generation_with_age(
14066 dir: &Path,
14067 project_key: &str,
14068 ordinal: u64,
14069 age: Duration,
14070 ) -> String {
14071 let generation = format!("{project_key}.g{ordinal}.1.sqlite");
14072 let path = dir.join(&generation);
14073 fs::write(&path, b"sqlite placeholder").unwrap();
14074 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
14075 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
14076 generation
14077 }
14078
14079 #[test]
14080 fn gc_old_generations_preserves_live_reader_until_marker_drops() {
14081 let dir = tempfile::tempdir().unwrap();
14082 let project_key = "project";
14083 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
14084 let previous =
14085 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
14086 let pinned =
14087 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
14088 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
14089
14090 gc_old_generations(dir.path(), project_key, ¤t);
14091
14092 assert!(dir.path().join(&previous).is_file());
14093 assert!(dir.path().join(&pinned).is_file());
14094
14095 drop(marker);
14096 gc_old_generations(dir.path(), project_key, ¤t);
14097
14098 assert!(dir.path().join(&previous).is_file());
14099 assert!(!dir.path().join(&pinned).exists());
14100 }
14101
14102 #[test]
14103 fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
14104 let dir = tempfile::tempdir().unwrap();
14105 let project_key = "project";
14106 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
14107 let _previous =
14108 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
14109 let pinned =
14110 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
14111 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
14112 filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
14113
14114 gc_old_generations(dir.path(), project_key, ¤t);
14115
14116 assert!(dir.path().join(&pinned).is_file());
14117 }
14118
14119 #[test]
14120 fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
14121 let dir = tempfile::tempdir().unwrap();
14122 let project_key = "project";
14123 let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
14124 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
14125 let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
14126 let old = write_generation_with_age(
14127 dir.path(),
14128 project_key,
14129 200,
14130 expired + Duration::from_secs(60),
14131 );
14132 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
14133
14134 gc_old_generations(dir.path(), project_key, ¤t);
14135
14136 assert!(dir.path().join(¤t).is_file());
14137 assert!(dir.path().join(&previous).is_file());
14138 assert!(!dir.path().join(&old).exists());
14139 }
14140
14141 fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
14142 let path = dir.join(name);
14143 fs::write(&path, b"temp placeholder").unwrap();
14144 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
14145 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
14146 path
14147 }
14148
14149 #[test]
14150 fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
14151 let dir = tempdir().unwrap();
14152 let aged = "project.g100.1.sqlite.tmp.1.200";
14156 let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
14157 let fresh = "project.g300.1.sqlite.tmp.1.400";
14158 let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
14159 write_build_temp_with_age(dir.path(), aged, aged_age);
14160 write_build_temp_with_age(dir.path(), aged_journal, aged_age);
14161 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
14162
14163 sweep_orphaned_build_temps(dir.path());
14164
14165 assert!(
14166 !dir.path().join(aged).exists(),
14167 "aged orphan must be removed"
14168 );
14169 assert!(
14170 !dir.path().join(aged_journal).exists(),
14171 "aged journal sidecar must be removed"
14172 );
14173 assert!(
14174 dir.path().join(fresh).is_file(),
14175 "fresh temporary must survive"
14176 );
14177 }
14178
14179 #[test]
14180 fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
14181 let storage = tempdir().unwrap();
14182 let storage_root = storage.path();
14183 let legacy_dir = storage_root.join("opencode").join("callgraph");
14189 fs::create_dir_all(&legacy_dir).unwrap();
14190 let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
14191 write_build_temp_with_age(
14192 &legacy_dir,
14193 orphan,
14194 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
14195 );
14196 assert!(
14197 !legacy_dir.join("deadbeef.current").exists(),
14198 "the dead root has no current pointer"
14199 );
14200
14201 let root_keyed_dir = storage_root.join("callgraph").join("livekey");
14202 fs::create_dir_all(&root_keyed_dir).unwrap();
14203
14204 sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
14205
14206 assert!(
14207 !legacy_dir.join(orphan).exists(),
14208 "legacy orphan must be reclaimed by the store-wide sweep"
14209 );
14210 }
14211
14212 #[test]
14213 fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
14214 let dir = tempdir().unwrap();
14220 let fresh = "project.g300.1.sqlite.tmp.1.400";
14221 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
14222
14223 sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
14224
14225 assert!(
14226 !dir.path().join(fresh).exists(),
14227 "with the age predicate forced open, the fresh temporary is removed"
14228 );
14229 }
14230
14231 #[test]
14232 fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
14233 let dir = tempdir().unwrap();
14234 let generation = write_generation_with_age(
14238 dir.path(),
14239 "project",
14240 400,
14241 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
14242 );
14243 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
14244
14245 sweep_orphaned_build_temps(dir.path());
14246
14247 assert!(
14248 dir.path().join(&generation).is_file(),
14249 "completed generation must survive the orphan sweep"
14250 );
14251 assert!(
14252 crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
14253 "read marker must survive the orphan sweep"
14254 );
14255 }
14256
14257 #[test]
14258 fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
14259 let dir = tempfile::tempdir().unwrap();
14260 let project_key = "project".to_string();
14261 let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
14262 let sqlite_path = dir.path().join(&generation);
14263 fs::remove_file(&sqlite_path).unwrap();
14264 let conn = Connection::open(&sqlite_path).unwrap();
14265 let store = CallGraphStore::from_connection(
14266 dir.path().to_path_buf(),
14267 project_key,
14268 sqlite_path,
14269 dir.path().to_path_buf(),
14270 false,
14271 Some(generation.clone()),
14272 None,
14273 None,
14274 conn,
14275 );
14276
14277 let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
14278 assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
14279
14280 drop(marker);
14281 assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
14282 }
14283
14284 #[test]
14285 fn readiness_cache_only_skips_checks_after_a_successful_validation() {
14286 let dir = tempdir().expect("temp dir");
14287 let file = dir.path().join("main.ts");
14288 fs::write(&file, "export function main() {}\n").expect("write fixture");
14289 let store = CallGraphStore::open(
14290 dir.path().join(".store-readiness-cache"),
14291 dir.path().to_path_buf(),
14292 )
14293 .expect("open store");
14294 {
14295 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14296 conn.trace(Some(count_caller_traversal_selects));
14297 }
14298
14299 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
14300 assert!(store.indexed_file_count().is_err());
14301 assert!(store.indexed_file_count().is_err());
14302 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
14303
14304 store
14305 .cold_build(std::slice::from_ref(&file))
14306 .expect("cold build");
14307 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
14308 assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
14309 assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
14310 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
14311
14312 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14313 conn.trace(None);
14314 }
14315
14316 #[test]
14317 fn direct_caller_frontier_chunks_sqlite_selects() {
14318 let dir = tempdir().expect("temp dir");
14319 let file = dir.path().join("main.ts");
14320 fs::write(
14321 &file,
14322 "export function caller() { target(); }\nexport function target() {}\n",
14323 )
14324 .expect("write fixture");
14325 let store = CallGraphStore::open(
14326 dir.path().join(".store-caller-frontier-query"),
14327 dir.path().to_path_buf(),
14328 )
14329 .expect("open store");
14330 store
14331 .cold_build(std::slice::from_ref(&file))
14332 .expect("cold build");
14333 let mut targets = vec![("main.ts".to_string(), "target".to_string())];
14334 targets.extend((1..1_000).map(|index| ("main.ts".to_string(), format!("missing{index}"))));
14335
14336 CALLER_QUERY_SELECTS.with(|count| count.set(0));
14337 BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
14338 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
14339 {
14340 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14341 conn.trace(Some(count_caller_traversal_selects));
14342 }
14343 let callers = store
14344 .direct_callers_for_symbols(&targets)
14345 .expect("batched callers");
14346 {
14347 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14348 conn.trace(None);
14349 }
14350
14351 assert_eq!(callers.len(), 1_000);
14352 assert_eq!(callers.get(&targets[0]).unwrap().len(), 1);
14353 assert_eq!(CALLER_QUERY_SELECTS.with(Cell::get), 3);
14354 assert_eq!(BOUNDARY_COUNT_SELECTS.with(Cell::get), 0);
14355 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
14356 }
14357
14358 #[test]
14359 fn callers_depth_boundary_batches_sqlite_counts() {
14360 const CALLER_COUNT: usize = 1_000;
14361
14362 let dir = tempdir().expect("temp dir");
14363 let file = dir.path().join("main.ts");
14364 let mut source = String::from("export function sharedHotHelper() {}\n");
14365 for index in 0..CALLER_COUNT {
14366 source.push_str(&format!(
14367 "export function caller{index}() {{ sharedHotHelper(); }}\n"
14368 ));
14369 }
14370 fs::write(&file, source).expect("write fixture");
14371
14372 let store = CallGraphStore::open(
14373 dir.path().join(".store-callers-query-fanout"),
14374 dir.path().to_path_buf(),
14375 )
14376 .expect("open store");
14377 store
14378 .cold_build(std::slice::from_ref(&file))
14379 .expect("cold build");
14380
14381 CALLER_QUERY_SELECTS.with(|count| count.set(0));
14382 BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
14383 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
14384 {
14385 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14386 conn.trace(Some(count_caller_traversal_selects));
14387 }
14388
14389 let started = Instant::now();
14390 let result = crate::commands::callgraph_store_adapter::callers_result(
14391 &store,
14392 Path::new("main.ts"),
14393 "sharedHotHelper",
14394 1,
14395 true,
14396 )
14397 .expect("callers result");
14398 let elapsed = started.elapsed();
14399
14400 {
14401 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14402 conn.trace(None);
14403 }
14404 let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
14405 let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
14406 let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
14407 eprintln!(
14408 "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
14409 result.total_callers,
14410 caller_queries,
14411 boundary_queries,
14412 total_selects,
14413 elapsed.as_secs_f64() * 1_000.0
14414 );
14415
14416 assert_eq!(result.total_callers, CALLER_COUNT);
14417 assert_eq!(caller_queries, 1);
14418 assert_eq!(boundary_queries, 3);
14419 assert_eq!(total_selects, 9);
14420 }
14421
14422 #[test]
14423 fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
14424 let dir = tempdir().expect("temp dir");
14425 let file = dir.path().join("main.ts");
14426 fs::write(
14427 &file,
14428 r#"export function topA() {
14429 root();
14430}
14431
14432export function topB() {
14433 root();
14434}
14435
14436export function root() {
14437 leaf();
14438 missing();
14439}
14440
14441export function leaf() {}
14442"#,
14443 )
14444 .expect("write fixture");
14445
14446 let store = CallGraphStore::open(
14447 dir.path().join(".store-depth-boundary-counts"),
14448 dir.path().to_path_buf(),
14449 )
14450 .expect("open store");
14451 store
14452 .cold_build(std::slice::from_ref(&file))
14453 .expect("cold build");
14454
14455 let root = store
14456 .node_for(Path::new("main.ts"), "root")
14457 .expect("root node");
14458 let leaf = store
14459 .node_for(Path::new("main.ts"), "leaf")
14460 .expect("leaf node");
14461
14462 let (full_forward_len, full_direct_len) = {
14463 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
14464 conn.execute(
14465 "INSERT INTO edges (
14466 edge_id, ref_id, source_node, target_node, target_file,
14467 target_symbol, kind, line, provenance
14468 ) VALUES (
14469 'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
14470 ?2, ?3, 'call', 98, ?4
14471 )",
14472 rusqlite::params![
14473 &root.node_id,
14474 &leaf.file,
14475 &leaf.symbol,
14476 PROVENANCE_TREESITTER
14477 ],
14478 )
14479 .expect("insert dangling forward edge");
14480 conn.execute(
14481 "INSERT INTO edges (
14482 edge_id, ref_id, source_node, target_node, target_file,
14483 target_symbol, kind, line, provenance
14484 ) VALUES (
14485 'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
14486 ?1, ?2, ?3, 'call', 99, ?4
14487 )",
14488 rusqlite::params![
14489 &root.node_id,
14490 &root.file,
14491 &root.symbol,
14492 PROVENANCE_TREESITTER
14493 ],
14494 )
14495 .expect("insert dangling direct-caller edge");
14496
14497 let full_forward_len = forward_calls_for_node(&conn, &root)
14498 .expect("full forward calls")
14499 .len();
14500 let counted_forward_len =
14501 forward_call_count_for_node(&conn, &root).expect("counted forward calls");
14502 assert_eq!(
14503 counted_forward_len, full_forward_len,
14504 "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
14505 );
14506
14507 let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
14508 .expect("full direct callers");
14509 let full_direct_len = full_direct.len();
14510 let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
14511 .expect("counted direct callers");
14512 assert_eq!(
14513 counted_direct_len, full_direct_len,
14514 "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
14515 );
14516
14517 let distinct_direct_len = full_direct
14518 .iter()
14519 .map(|site| {
14520 (
14521 site.caller.file.clone(),
14522 site.line,
14523 site.target_file.clone(),
14524 site.target_symbol.clone(),
14525 )
14526 })
14527 .collect::<BTreeSet<_>>()
14528 .len();
14529 let batch_counts = direct_caller_counts_for_tuples(
14530 &conn,
14531 &[
14532 (root.file.clone(), root.symbol.clone()),
14533 (root.file.clone(), root.symbol.clone()),
14534 (leaf.file.clone(), leaf.symbol.clone()),
14535 ],
14536 )
14537 .expect("batched direct-caller counts");
14538 assert_eq!(batch_counts.len(), 2);
14539 assert_eq!(
14540 batch_counts.get(&(root.file.clone(), root.symbol.clone())),
14541 Some(&distinct_direct_len)
14542 );
14543
14544 (full_forward_len, full_direct_len)
14545 };
14546
14547 assert_eq!(
14548 full_forward_len, 2,
14549 "fixture root should have one resolved and one unresolved outgoing call"
14550 );
14551 assert_eq!(
14552 full_direct_len, 2,
14553 "fixture root should have two real direct callers"
14554 );
14555
14556 let tree = store
14557 .call_tree(Path::new("main.ts"), "root", 0)
14558 .expect("call tree");
14559 assert!(tree.depth_limited);
14560 assert_eq!(tree.children.len(), 0);
14561 assert_eq!(
14562 tree.truncated, full_forward_len,
14563 "call_tree depth boundary must report the full forward-call list length"
14564 );
14565
14566 let callers = store
14567 .callers_of(Path::new("main.ts"), "leaf", 0)
14568 .expect("callers");
14569 assert!(callers.depth_limited);
14570 assert_eq!(callers.callers.len(), 1);
14571 assert_eq!(callers.callers[0].caller.symbol, "root");
14572 assert_eq!(
14573 callers.truncated, full_direct_len,
14574 "callers depth boundary must report the full direct-caller list length"
14575 );
14576 }
14577
14578 #[test]
14579 fn source_freshness_matches_cache_collect_for_same_bytes() {
14580 let dir = tempdir().expect("temp dir");
14581 let path = dir.path().join("fixture.ts");
14582 let source = "export function main() { return helper(); }\n";
14583 fs::write(&path, source).expect("write fixture");
14584
14585 let expected = cache_freshness::collect(&path).expect("collect freshness from file");
14586 let actual =
14587 collect_source_freshness(&path, source).expect("collect freshness from source");
14588
14589 assert_eq!(actual, expected);
14590 }
14591
14592 #[test]
14593 fn superseded_cold_build_cannot_publish_after_newer_epoch() {
14594 let root = tempfile::tempdir().unwrap();
14595 let callgraph_dir = tempfile::tempdir().unwrap();
14596 let source_dir = root.path().join("src");
14597 std::fs::create_dir_all(&source_dir).unwrap();
14598 let source = source_dir.join("lib.rs");
14599 std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
14600 let files = vec![source.clone()];
14601 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
14602 let old_epoch = epoch.next();
14603 let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
14604 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
14605 let old_epoch_flag = epoch.clone();
14606 let old_dir = callgraph_dir.path().to_path_buf();
14607 let old_root = root.path().to_path_buf();
14608 let old_files = files.clone();
14609 let old = std::thread::spawn(move || {
14610 set_cold_build_before_publish_observer(Some(Arc::new(move || {
14611 reached_tx.send(()).unwrap();
14612 release_rx.recv().unwrap();
14613 })));
14614 let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
14615 CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
14616 });
14617 set_cold_build_before_publish_observer(None);
14618 result
14619 });
14620 reached_rx
14624 .recv_timeout(Duration::from_secs(30))
14625 .expect("older build did not reach its publication barrier");
14626
14627 std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
14628 let new_epoch = epoch.next();
14629 let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
14630 CallGraphStore::cold_build_with_lease(
14631 callgraph_dir.path().to_path_buf(),
14632 root.path().to_path_buf(),
14633 &files,
14634 )
14635 })
14636 .expect("newer build should publish");
14637 drop(new_store);
14638
14639 release_tx.send(()).unwrap();
14640 assert!(matches!(
14641 old.join().unwrap(),
14642 Err(CallGraphStoreError::Superseded)
14643 ));
14644
14645 let current = CallGraphStore::open_readonly(
14646 callgraph_dir.path().to_path_buf(),
14647 root.path().to_path_buf(),
14648 )
14649 .unwrap()
14650 .expect("current callgraph generation");
14651 assert_eq!(
14652 current
14653 .nodes_matching("new_generation_marker")
14654 .unwrap()
14655 .len(),
14656 1
14657 );
14658 assert!(current
14659 .nodes_matching("old_generation_marker")
14660 .unwrap()
14661 .is_empty());
14662 }
14663
14664 #[test]
14665 fn cold_build_prepared_bulk_insert_matches_reference_rows() {
14666 let dir = tempdir().expect("temp dir");
14667 let project_root = dir.path();
14668 let extract = fixture_extract(project_root);
14669 let resolved = fixture_resolved(&extract);
14670
14671 let reference = build_reference_connection(project_root, &extract, &resolved);
14672 let optimized = build_optimized_connection(project_root, &extract, &resolved);
14673
14674 for table in [
14675 "files",
14676 "nodes",
14677 "file_dependencies",
14678 "dispatch_hints",
14679 "refs",
14680 "edges",
14681 ] {
14682 let excluded: &[&str] = if table == "files" {
14689 &["indexed_at"]
14690 } else {
14691 &[]
14692 };
14693 assert_eq!(
14694 table_rows_without(&reference, table, excluded),
14695 table_rows_without(&optimized, table, excluded),
14696 "table `{table}` rows must match apart from wall-clock columns"
14697 );
14698 }
14699 assert_eq!(
14700 backend_state_rows(&reference),
14701 backend_state_rows(&optimized),
14702 "backend freshness rows must match apart from updated_at"
14703 );
14704 assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
14705 }
14706
14707 #[test]
14708 fn cold_build_chunked_matches_unchunked_logical_rows() {
14709 let dir = tempdir().expect("temp dir");
14710 let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
14711 write_chunked_equivalence_fixture(&project_root);
14712 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
14713 assert!(
14714 files.len() > 6,
14715 "fixture should be large enough to split into multiple chunks"
14716 );
14717
14718 let unchunked = CallGraphStore::open(
14719 project_root.join(".store-unchunked"),
14720 project_root.to_path_buf(),
14721 )
14722 .expect("open unchunked store");
14723 let unchunked_stats = unchunked
14724 .cold_build_chunked(&files, 0)
14725 .expect("unchunked cold build");
14726
14727 let chunked = CallGraphStore::open(
14728 project_root.join(".store-chunked"),
14729 project_root.to_path_buf(),
14730 )
14731 .expect("open chunked store");
14732 let chunked_stats = chunked
14733 .cold_build_chunked(&files, 3)
14734 .expect("chunked cold build");
14735
14736 assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
14737 assert_eq!(
14738 unchunked.edge_snapshot().expect("unchunked edge snapshot"),
14739 chunked.edge_snapshot().expect("chunked edge snapshot"),
14740 "public edge snapshots must match"
14741 );
14742
14743 let dispatch_edges = {
14744 let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
14745 conn.query_row(
14746 "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
14747 [],
14748 |row| row.get::<_, i64>(0),
14749 )
14750 .expect("count dispatch edges")
14751 };
14752 assert!(
14753 dispatch_edges > 0,
14754 "fixture must exercise method-dispatch edge insertion"
14755 );
14756
14757 for table in [
14758 "edges",
14759 "refs",
14760 "nodes",
14761 "file_dependencies",
14762 "dispatch_hints",
14763 ] {
14764 assert_eq!(
14765 graph_table_rows(&unchunked, table),
14766 graph_table_rows(&chunked, table),
14767 "chunked cold build must match unchunked rows for {table}"
14768 );
14769 }
14770 assert_eq!(
14771 graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
14772 graph_table_rows_without(&chunked, "files", &["indexed_at"]),
14773 "files rows must match apart from indexed_at"
14774 );
14775 assert_eq!(
14776 graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
14777 graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
14778 "backend freshness rows must match apart from updated_at"
14779 );
14780
14781 let published_dir = project_root.join(".store-published");
14782 let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
14783 published_dir.clone(),
14784 project_root.to_path_buf(),
14785 &files,
14786 0,
14787 )
14788 .expect("published unchunked cold build");
14789 assert!(
14790 !CallGraphStore::needs_cold_build(&published_dir, &project_root)
14791 .expect("needs_cold_build after publish"),
14792 "published store should be ready"
14793 );
14794 drop(_published);
14795 let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
14796 published_dir,
14797 project_root.to_path_buf(),
14798 &files,
14799 3,
14800 )
14801 .expect("ensure with a different chunk size");
14802 assert!(
14803 rebuild_stats.is_none(),
14804 "changing callgraph_chunk_size must not affect store identity or force a rebuild"
14805 );
14806 }
14807
14808 #[test]
14815 #[ignore]
14816 fn bench_cold_build_chunk() {
14817 let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
14818 let chunk: usize = std::env::var("AFT_PERF_CHUNK")
14819 .expect("AFT_PERF_CHUNK")
14820 .parse()
14821 .expect("AFT_PERF_CHUNK must be a non-negative integer");
14822 let project_root = fs::canonicalize(&repo).expect("canonical repo root");
14823 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
14824 let dir = tempdir().expect("temp dir");
14825 let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
14826 .expect("open store");
14827 let started = Instant::now();
14828 let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
14829 let ms = started.elapsed().as_millis();
14830 println!(
14831 "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
14832 stats.files, stats.nodes, stats.refs, stats.edges
14833 );
14834 }
14835
14836 #[test]
14837 fn persisted_workspace_reexport_selects_its_package_dependency() {
14838 let root = tempdir().expect("temp dir");
14839 let dependencies = BTreeSet::from([
14840 "packages/aft-bridge/src/index.ts".to_string(),
14841 "packages/opencode-plugin/src/types.ts".to_string(),
14842 ]);
14843 let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
14844
14845 assert_eq!(
14846 stored_dependencies_for_module(
14847 root.path(),
14848 "packages/opencode-plugin/src/shared/bash-hints.ts",
14849 "@cortexkit/aft-bridge",
14850 &dependencies,
14851 &indexed_files,
14852 ),
14853 BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
14854 );
14855 }
14856
14857 #[test]
14858 fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
14859 let dir = tempdir().expect("temp dir");
14860 let project_root = dir.path();
14861 let files =
14862 write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
14863 let index_path = project_root.join("src/index.ts");
14864
14865 let store = CallGraphStore::open(
14866 project_root.join(".store-incremental-barrel"),
14867 project_root.to_path_buf(),
14868 )
14869 .expect("open incremental store");
14870 store.cold_build(&files).expect("initial cold build");
14871
14872 {
14873 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14874 let tx = conn.transaction().expect("dependency transaction");
14875 let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
14876 .expect("dependent refs for barrel");
14877 let selected_ref_ids = dependent_refs
14878 .iter()
14879 .map(|dependent_ref| dependent_ref.ref_id.clone())
14880 .collect::<BTreeSet<_>>();
14881 let mut threaded_ref_ids = BTreeSet::new();
14882 let mut threaded_by_caller = BTreeMap::new();
14883 record_dependent_refs(
14884 &mut threaded_ref_ids,
14885 &mut threaded_by_caller,
14886 dependent_refs,
14887 );
14888 let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
14889 .expect("old per-ref caller lookup");
14890
14891 assert_eq!(threaded_ref_ids, selected_ref_ids);
14892 assert_eq!(threaded_by_caller, old_by_caller);
14893 for consumer in [
14894 "src/consumer_a.ts",
14895 "src/consumer_b.ts",
14896 "src/consumer_c.ts",
14897 ] {
14898 assert!(
14899 threaded_by_caller.contains_key(consumer),
14900 "barrel edit should select dependent refs from {consumer}"
14901 );
14902 }
14903 }
14904
14905 fs::write(
14906 &index_path,
14907 "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
14908 )
14909 .expect("edit barrel");
14910 let stats = store
14911 .refresh_files(std::slice::from_ref(&index_path))
14912 .expect("incremental refresh");
14913 assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
14914 assert!(
14915 stats.dependency_selected_refs > 0,
14916 "barrel surface edit should select dependent refs"
14917 );
14918
14919 let cold_store = CallGraphStore::open(
14920 project_root.join(".store-cold-barrel"),
14921 project_root.to_path_buf(),
14922 )
14923 .expect("open cold rebuild store");
14924 cold_store
14925 .cold_build(&files)
14926 .expect("comparison cold build");
14927
14928 for table in [
14929 "nodes",
14930 "refs",
14931 "file_dependencies",
14932 "edges",
14933 "dispatch_hints",
14934 ] {
14935 assert_eq!(
14936 graph_table_rows(&store, table),
14937 graph_table_rows(&cold_store, table),
14938 "incremental refresh {table} rows must match cold rebuild"
14939 );
14940 }
14941
14942 let consumer_path = project_root.join("src/consumer_a.ts");
14943 fs::write(
14944 &consumer_path,
14945 "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
14946 )
14947 .expect("edit barrel consumer");
14948 store
14949 .refresh_files(std::slice::from_ref(&consumer_path))
14950 .expect("refresh consumer through unchanged barrel");
14951 cold_store
14952 .cold_build(&files)
14953 .expect("comparison cold rebuild after consumer refresh");
14954 for table in [
14955 "nodes",
14956 "refs",
14957 "file_dependencies",
14958 "edges",
14959 "dispatch_hints",
14960 ] {
14961 assert_eq!(
14962 graph_table_rows(&store, table),
14963 graph_table_rows(&cold_store, table),
14964 "refresh through a persisted barrel must preserve cold-build {table} rows"
14965 );
14966 }
14967 }
14968
14969 fn build_reference_connection(
14970 project_root: &Path,
14971 extract: &FileExtract,
14972 resolved: &ResolvedRef,
14973 ) -> Connection {
14974 let mut conn = Connection::open_in_memory().expect("open reference db");
14975 configure_build_connection(&conn).expect("configure reference db");
14976 initialize_schema(&conn).expect("initialize reference schema");
14977 {
14978 let tx = conn.transaction().expect("reference transaction");
14979 clear_tables(&tx).expect("reference clear");
14980 insert_meta(&tx).expect("reference meta");
14981 insert_file_extract(&tx, project_root, extract).expect("reference file extract");
14982 insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
14983 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
14984 .expect("reference dispatch edges");
14985 assert_eq!(supplemental, 0);
14986 tx.commit().expect("reference commit");
14987 }
14988 conn
14989 }
14990
14991 fn build_optimized_connection(
14992 project_root: &Path,
14993 extract: &FileExtract,
14994 resolved: &ResolvedRef,
14995 ) -> Connection {
14996 let mut conn = Connection::open_in_memory().expect("open optimized db");
14997 configure_build_connection(&conn).expect("configure optimized db");
14998 initialize_schema(&conn).expect("initialize optimized schema");
14999 {
15000 let tx = conn.transaction().expect("optimized transaction");
15001 clear_tables(&tx).expect("optimized clear");
15002 insert_meta(&tx).expect("optimized meta");
15003 drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
15004 {
15005 let workspace_root = project_root.display().to_string();
15006 let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
15007 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
15008 .expect("optimized file extract");
15009 insert_resolved_ref_prepared(&mut inserts, resolved)
15010 .expect("optimized resolved ref");
15011 }
15012 create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
15013 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
15014 .expect("optimized dispatch edges");
15015 assert_eq!(supplemental, 0);
15016 tx.commit().expect("optimized commit");
15017 }
15018 conn
15019 }
15020
15021 fn fixture_extract(_project_root: &Path) -> FileExtract {
15022 let rel_path = "src/main.ts".to_string();
15023 let target_path = "src/helper.ts".to_string();
15024 let node = NodeRecord {
15025 id: "node-main".to_string(),
15026 file_path: rel_path.clone(),
15027 name: "main".to_string(),
15028 scoped_name: "main".to_string(),
15029 kind: "function".to_string(),
15030 range: Range {
15031 start_line: 0,
15032 start_col: 0,
15033 end_line: 0,
15034 end_col: 32,
15035 },
15036 range_ordinal: 0,
15037 signature: Some("export function main()".to_string()),
15038 exported: true,
15039 is_default_export: false,
15040 is_type_like: false,
15041 is_callgraph_entry_point: true,
15042 };
15043 let mut dependencies = BTreeSet::new();
15044 dependencies.insert(target_path.clone());
15045 let raw_ref = RawRef {
15046 ref_id: "ref-main-helper".to_string(),
15047 caller_node: Some(node.id.clone()),
15048 caller_symbol: Some(node.scoped_name.clone()),
15049 caller_file: rel_path.clone(),
15050 kind: "call".to_string(),
15051 short_name: Some("helper".to_string()),
15052 full_ref: Some("helper".to_string()),
15053 module_path: None,
15054 import_kind: None,
15055 local_name: Some("helper".to_string()),
15056 requested_name: Some("helper".to_string()),
15057 namespace_alias: None,
15058 wildcard: false,
15059 line: 1,
15060 byte_start: 24,
15061 byte_end: 32,
15062 dependencies,
15063 };
15064 FileExtract {
15065 rel_path,
15066 freshness: FileFreshness {
15067 mtime: UNIX_EPOCH + Duration::from_secs(123),
15068 size: 40,
15069 content_hash: cache_freshness::hash_bytes(b"fixture source"),
15070 },
15071 lang: LangId::TypeScript,
15072 data: FileCallData {
15073 calls_by_symbol: HashMap::new(),
15074 value_refs_by_symbol: HashMap::new(),
15075 exported_symbols: Vec::new(),
15076 symbol_metadata: HashMap::new(),
15077 default_export_symbol: None,
15078 import_block: ImportBlock::empty(),
15079 lang: LangId::TypeScript,
15080 },
15081 nodes: vec![node.clone()],
15082 raw_refs: vec![raw_ref],
15083 dispatch_hints: vec![DispatchHint {
15084 id: "dispatch-main-helper".to_string(),
15085 method_name: "helper".to_string(),
15086 caller_node: node.id,
15087 file: "src/main.ts".to_string(),
15088 line: 1,
15089 byte_start: 24,
15090 byte_end: 32,
15091 }],
15092 surface_fingerprint: "surface".to_string(),
15093 }
15094 }
15095
15096 fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
15097 let raw = extract.raw_refs[0].clone();
15098 let mut dependencies = raw.dependencies.clone();
15099 dependencies.insert("src/helper.ts".to_string());
15100 ResolvedRef {
15101 edge: Some(EdgeRecord {
15102 edge_id: "edge-main-helper".to_string(),
15103 source_node: raw.caller_node.clone().expect("caller node"),
15104 target_node: Some("node-helper".to_string()),
15105 target_file: "src/helper.ts".to_string(),
15106 target_symbol: "helper".to_string(),
15107 kind: "call".to_string(),
15108 line: raw.line,
15109 }),
15110 raw,
15111 status: "resolved".to_string(),
15112 target_node: Some("node-helper".to_string()),
15113 target_file: Some("src/helper.ts".to_string()),
15114 target_symbol: Some("helper".to_string()),
15115 dependencies,
15116 }
15117 }
15118
15119 fn write_chunked_equivalence_fixture(project_root: &Path) {
15120 let ts_dir = project_root.join("ts");
15121 fs::create_dir_all(&ts_dir).expect("create ts dir");
15122 fs::write(
15123 ts_dir.join("leaf.ts"),
15124 "export function leaf(value: number) {\n return value + 1;\n}\n",
15125 )
15126 .expect("write ts leaf");
15127 fs::write(
15128 ts_dir.join("mid.ts"),
15129 "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n return leaf(value);\n}\n",
15130 )
15131 .expect("write ts mid");
15132 fs::write(
15133 ts_dir.join("entry.ts"),
15134 "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n return mid(worker.run());\n}\n",
15135 )
15136 .expect("write ts entry");
15137 fs::write(
15138 ts_dir.join("worker.ts"),
15139 "export class Worker {\n run() {\n return 41;\n }\n}\n",
15140 )
15141 .expect("write ts worker");
15142 for idx in 0..4 {
15143 fs::write(
15144 ts_dir.join(format!("extra_{idx}.ts")),
15145 format!(
15146 "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n return entry(new Worker());\n}}\n"
15147 ),
15148 )
15149 .expect("write ts extra");
15150 }
15151
15152 let rust_dir = project_root.join("src");
15153 let commands_dir = rust_dir.join("commands");
15154 fs::create_dir_all(&commands_dir).expect("create rust commands dir");
15155 fs::write(
15156 rust_dir.join("context.rs"),
15157 r#"pub struct AppContext;
15158
15159impl AppContext {
15160 pub fn callgraph_store_for_ops(&self) -> usize {
15161 1
15162 }
15163}
15164"#,
15165 )
15166 .expect("write rust context");
15167 fs::write(
15168 rust_dir.join("lib.rs"),
15169 "pub mod context;\npub mod commands;\n",
15170 )
15171 .expect("write rust lib");
15172 fs::write(
15173 commands_dir.join("mod.rs"),
15174 "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
15175 )
15176 .expect("write rust commands mod");
15177 for name in ["callers", "impact", "trace_to"] {
15178 fs::write(
15179 commands_dir.join(format!("{name}.rs")),
15180 format!(
15181 r#"use crate::context::AppContext;
15182
15183pub fn handle_{name}(ctx: &AppContext) -> usize {{
15184 ctx.callgraph_store_for_ops()
15185}}
15186"#
15187 ),
15188 )
15189 .expect("write rust command");
15190 }
15191 }
15192
15193 fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
15194 let src_dir = project_root.join("src");
15195 fs::create_dir_all(&src_dir).expect("create src dir");
15196
15197 let target_path = src_dir.join("target.ts");
15198 fs::write(&target_path, "export function target() {\n return 1;\n}\n")
15199 .expect("write target");
15200
15201 let index_path = src_dir.join("index.ts");
15202 fs::write(&index_path, barrel_source).expect("write barrel");
15203
15204 let mut files = vec![target_path, index_path];
15205 for (file_name, function_name) in [
15206 ("consumer_a.ts", "consumerA"),
15207 ("consumer_b.ts", "consumerB"),
15208 ("consumer_c.ts", "consumerC"),
15209 ] {
15210 let path = src_dir.join(file_name);
15211 fs::write(
15212 &path,
15213 format!(
15214 "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n return target();\n}}\n"
15215 ),
15216 )
15217 .expect("write consumer");
15218 files.push(path);
15219 }
15220 files
15221 }
15222
15223 fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
15224 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
15225 table_rows(&conn, table)
15226 }
15227
15228 fn graph_table_rows_without(
15229 store: &CallGraphStore,
15230 table: &str,
15231 excluded_columns: &[&str],
15232 ) -> Vec<String> {
15233 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
15234 table_rows_without(&conn, table, excluded_columns)
15235 }
15236
15237 fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
15238 table_rows_without(conn, table, &[])
15239 }
15240
15241 fn table_rows_without(
15242 conn: &Connection,
15243 table: &str,
15244 excluded_columns: &[&str],
15245 ) -> Vec<String> {
15246 let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
15247 let columns: Vec<String> = conn
15248 .prepare(&format!("PRAGMA table_info({table})"))
15249 .expect("prepare table_info")
15250 .query_map([], |row| row.get::<_, String>(1))
15251 .expect("query table_info")
15252 .collect::<std::result::Result<Vec<String>, _>>()
15253 .expect("collect columns")
15254 .into_iter()
15255 .filter(|column| !excluded_columns.contains(column.as_str()))
15256 .collect();
15257 let sql = format!(
15258 "SELECT {} FROM {table} ORDER BY {}",
15259 columns.join(", "),
15260 columns.join(", ")
15261 );
15262 conn.prepare(&sql)
15263 .expect("prepare table rows")
15264 .query_map([], |row| row_to_strings(row, columns.len()))
15265 .expect("query table rows")
15266 .collect::<std::result::Result<_, _>>()
15267 .expect("collect table rows")
15268 }
15269
15270 fn assert_cold_build_stats_match_except_elapsed(
15271 expected: &ColdBuildStats,
15272 actual: &ColdBuildStats,
15273 ) {
15274 assert_eq!(actual.files, expected.files, "file counts must match");
15275 assert_eq!(actual.nodes, expected.nodes, "node counts must match");
15276 assert_eq!(actual.refs, expected.refs, "ref counts must match");
15277 assert_eq!(actual.edges, expected.edges, "edge counts must match");
15278 assert_eq!(
15279 actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
15280 expected
15281 .failed_files
15282 .iter()
15283 .cloned()
15284 .collect::<BTreeSet<_>>(),
15285 "failed file sets must match"
15286 );
15287 }
15288
15289 fn backend_state_rows(conn: &Connection) -> Vec<String> {
15290 conn.prepare(
15291 "SELECT backend, workspace_root, file_path, content_hash, status
15292 FROM backend_file_state
15293 ORDER BY backend, workspace_root, file_path, content_hash, status",
15294 )
15295 .expect("prepare backend rows")
15296 .query_map([], |row| row_to_strings(row, 5))
15297 .expect("query backend rows")
15298 .collect::<std::result::Result<_, _>>()
15299 .expect("collect backend rows")
15300 }
15301
15302 fn secondary_indexes(conn: &Connection) -> Vec<String> {
15303 let mut indexes = Vec::new();
15304 for table in [
15305 "files",
15306 "nodes",
15307 "refs",
15308 "file_dependencies",
15309 "edges",
15310 "dispatch_hints",
15311 "type_ref_names",
15312 "backend_file_state",
15313 "meta",
15314 ] {
15315 let sql = format!("PRAGMA index_list({table})");
15316 let mut stmt = conn.prepare(&sql).expect("prepare index list");
15317 let rows = stmt
15318 .query_map([], |row| row.get::<_, String>(1))
15319 .expect("query index list");
15320 for name in rows {
15321 let name = name.expect("index name");
15322 if name.starts_with("idx_") {
15323 indexes.push(format!("{table}:{name}"));
15324 }
15325 }
15326 }
15327 indexes.sort();
15328 indexes
15329 }
15330
15331 fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
15332 let mut values = Vec::with_capacity(len);
15333 for index in 0..len {
15334 let value = row.get_ref(index)?;
15335 values.push(match value {
15336 rusqlite::types::ValueRef::Null => "NULL".to_string(),
15337 rusqlite::types::ValueRef::Integer(value) => value.to_string(),
15338 rusqlite::types::ValueRef::Real(value) => value.to_string(),
15339 rusqlite::types::ValueRef::Text(value) => {
15340 String::from_utf8_lossy(value).into_owned()
15341 }
15342 rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
15343 });
15344 }
15345 Ok(values.join("\u{1f}"))
15346 }
15347}
15348
15349#[cfg(test)]
15350mod rust_resolution_tests {
15351 use super::*;
15352 use crate::inspect::job::CallgraphSnapshot;
15353 use std::fs;
15354 use tempfile::tempdir;
15355
15356 #[test]
15357 fn rust_function_scoped_module_alias_resolves_and_projects_live() {
15358 let dir = tempdir().expect("tempdir");
15359 let root = dir.path();
15360 write_rust_manifest(root, "scoped-alias-fixture");
15361 write_file(
15362 root,
15363 "src/lib.rs",
15364 r#"pub mod finalization_contract;
15365
15366pub fn run_alias() {
15367 use crate::finalization_contract as fc;
15368 fc::check_mason_contract();
15369}
15370"#,
15371 );
15372 write_file(
15373 root,
15374 "src/finalization_contract.rs",
15375 r#"pub fn check_mason_contract() {}
15376fn planted_dead() {}
15377"#,
15378 );
15379
15380 let (store, snapshot) = cold_build_twice(root);
15381 assert_direct_caller(
15382 &store,
15383 "src/finalization_contract.rs",
15384 "check_mason_contract",
15385 "src/lib.rs",
15386 "run_alias",
15387 );
15388 assert_projected_call(
15389 root,
15390 &snapshot,
15391 "src/finalization_contract.rs",
15392 "check_mason_contract",
15393 );
15394 assert_no_projected_call(
15395 root,
15396 &snapshot,
15397 "src/finalization_contract.rs",
15398 "planted_dead",
15399 );
15400 assert!(
15401 store
15402 .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
15403 .expect("planted dead callers")
15404 .is_empty(),
15405 "planted-dead guard should stay without callers"
15406 );
15407 }
15408
15409 #[test]
15410 fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
15411 let dir = tempdir().expect("tempdir");
15412 let root = dir.path();
15413 write_rust_manifest(root, "inline-module-fixture");
15414 write_file(
15415 root,
15416 "src/lib.rs",
15417 r#"mod work_graph { fn operations() {} }
15418mod manifest { fn operations() {} }
15419mod audit { fn operations() {} }
15420mod dispatch { fn operations() {} }
15421mod finalization { fn operations() {} }
15422
15423pub fn run_inline_operations() {
15424 work_graph::operations();
15425 manifest::operations();
15426 audit::operations();
15427 dispatch::operations();
15428 finalization::operations();
15429}
15430
15431fn planted_dead() {}
15432"#,
15433 );
15434
15435 let (store, snapshot) = cold_build_twice(root);
15436 for module in [
15437 "work_graph",
15438 "manifest",
15439 "audit",
15440 "dispatch",
15441 "finalization",
15442 ] {
15443 assert_direct_caller(
15444 &store,
15445 "src/lib.rs",
15446 &format!("{module}::operations"),
15447 "src/lib.rs",
15448 "run_inline_operations",
15449 );
15450 }
15451 assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
15452 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
15453 }
15454
15455 #[test]
15456 fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
15457 let dir = tempdir().expect("tempdir");
15458 let root = dir.path();
15459 fs::write(
15460 root.join("Cargo.toml"),
15461 "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
15462 )
15463 .expect("write workspace manifest");
15464 write_file(
15465 root,
15466 "crates/but-action/Cargo.toml",
15467 r#"[package]
15468name = "but-action"
15469version = "0.1.0"
15470edition = "2021"
15471"#,
15472 );
15473 write_file(
15474 root,
15475 "crates/but-action/src/lib.rs",
15476 "mod action;\npub use action::{list_actions};\n",
15477 );
15478 write_file(
15479 root,
15480 "crates/but-action/src/action.rs",
15481 "pub fn list_actions() {}\nfn planted_dead() {}\n",
15482 );
15483 write_file(
15484 root,
15485 "crates/app/Cargo.toml",
15486 r#"[package]
15487name = "app"
15488version = "0.1.0"
15489edition = "2021"
15490"#,
15491 );
15492 write_file(
15493 root,
15494 "crates/app/src/lib.rs",
15495 "pub fn run_actions() {\n but_action::list_actions();\n}\n",
15496 );
15497
15498 let (store, snapshot) = cold_build_twice(root);
15499 assert_direct_caller(
15500 &store,
15501 "crates/but-action/src/action.rs",
15502 "list_actions",
15503 "crates/app/src/lib.rs",
15504 "run_actions",
15505 );
15506 assert!(
15507 store
15508 .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
15509 .expect("lib reexport callers")
15510 .is_empty(),
15511 "call should target the reexported source function, not lib.rs"
15512 );
15513 assert_projected_call(
15514 root,
15515 &snapshot,
15516 "crates/but-action/src/action.rs",
15517 "list_actions",
15518 );
15519 assert_no_projected_call(
15520 root,
15521 &snapshot,
15522 "crates/but-action/src/action.rs",
15523 "planted_dead",
15524 );
15525 }
15526
15527 #[test]
15528 fn rust_generic_self_turbofish_method_dispatch_resolves() {
15529 let dir = tempdir().expect("tempdir");
15530 let root = dir.path();
15531 write_rust_manifest(root, "generic-self-fixture");
15532 write_file(
15533 root,
15534 "src/lib.rs",
15535 r#"pub struct Matcher;
15536
15537impl Matcher {
15538 pub fn run(&self) -> bool {
15539 self.fuzzy_match_optimal::<usize>("needle")
15540 }
15541
15542 fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
15543 let _ = std::marker::PhantomData::<T>;
15544 true
15545 }
15546
15547 fn planted_dead(&self) {}
15548}
15549
15550pub fn entry() -> bool {
15551 let matcher = Matcher;
15552 matcher.run()
15553}
15554"#,
15555 );
15556
15557 let (store, snapshot) = cold_build_twice(root);
15558 assert_direct_caller(
15559 &store,
15560 "src/lib.rs",
15561 "Matcher::fuzzy_match_optimal",
15562 "src/lib.rs",
15563 "Matcher::run",
15564 );
15565 assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
15566 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
15567 }
15568
15569 #[test]
15570 fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
15571 let dir = tempdir().expect("tempdir");
15572 let root = dir.path();
15573 write_rust_manifest(root, "manifest-operations-fixture");
15574 write_file(
15575 root,
15576 "src/main.rs",
15577 r#"mod dispatch;
15578use dispatch::{manifest_operations};
15579
15580fn main() {
15581 manifest_operations();
15582}
15583"#,
15584 );
15585 write_file(
15586 root,
15587 "src/dispatch.rs",
15588 r#"mod work_graph { fn operations() {} }
15589mod manifest { fn operations() {} }
15590mod audit { fn operations() {} }
15591mod descriptor { fn operations() {} }
15592mod writer { fn operations() {} }
15593
15594pub fn manifest_operations() {
15595 manifest::operations();
15596}
15597
15598pub fn work_graph_operations() {
15599 work_graph::operations();
15600}
15601
15602pub fn audit_operations() {
15603 audit::operations();
15604}
15605
15606pub fn descriptor_operations() {
15607 descriptor::operations();
15608}
15609
15610pub fn writer_operations() {
15611 writer::operations();
15612}
15613
15614fn planted_dead() {}
15615"#,
15616 );
15617
15618 let (store, snapshot) = cold_build_twice(root);
15619 assert_direct_caller(
15620 &store,
15621 "src/dispatch.rs",
15622 "manifest_operations",
15623 "src/main.rs",
15624 "main",
15625 );
15626 assert_direct_caller(
15627 &store,
15628 "src/dispatch.rs",
15629 "manifest::operations",
15630 "src/dispatch.rs",
15631 "manifest_operations",
15632 );
15633 assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
15634 assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
15635 assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
15636 }
15637
15638 fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
15639 let files = rust_files(root);
15640 let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
15641 .expect("open first store");
15642 first.cold_build(&files).expect("first cold build");
15643 let first_snapshot =
15644 project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
15645
15646 let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
15647 .expect("open second store");
15648 second.cold_build(&files).expect("second cold build");
15649 let second_snapshot =
15650 project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
15651
15652 assert_eq!(
15653 projection_rows(&first_snapshot),
15654 projection_rows(&second_snapshot),
15655 "cold-build projection should be deterministic"
15656 );
15657 (first, first_snapshot)
15658 }
15659
15660 fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
15661 let mut rows = Vec::new();
15662 for export in &snapshot.exported_symbols {
15663 rows.push(format!(
15664 "export\t{}\t{}\t{}\t{}",
15665 export.file.display(),
15666 export.symbol,
15667 export.kind,
15668 export.line
15669 ));
15670 }
15671 for call in &snapshot.outbound_calls {
15672 rows.push(format!(
15673 "call\t{}\t{}\t{}\t{}\t{}",
15674 call.caller_file.display(),
15675 call.caller_symbol,
15676 call.target,
15677 call.line,
15678 call.provenance
15679 ));
15680 }
15681 for file in &snapshot.entry_points {
15682 rows.push(format!("entry_file\t{}", file.display()));
15683 }
15684 for (file, symbols) in &snapshot.entry_point_symbols {
15685 for symbol in symbols {
15686 rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
15687 }
15688 }
15689 rows.sort();
15690 rows
15691 }
15692
15693 fn assert_direct_caller(
15694 store: &CallGraphStore,
15695 target_rel: &str,
15696 target_symbol: &str,
15697 caller_rel: &str,
15698 caller_symbol: &str,
15699 ) {
15700 let callers = store
15701 .direct_callers_of(Path::new(target_rel), target_symbol)
15702 .unwrap_or_else(|error| {
15703 panic!("direct callers for {target_rel}::{target_symbol}: {error}")
15704 });
15705 assert!(
15706 callers.iter().any(|site| {
15707 site.caller.file == caller_rel && site.caller.symbol == caller_symbol
15708 }),
15709 "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
15710 );
15711 }
15712
15713 fn assert_projected_call(
15714 root: &Path,
15715 snapshot: &CallgraphSnapshot,
15716 target_rel: &str,
15717 symbol: &str,
15718 ) {
15719 let target = projected_target(root, target_rel, symbol);
15720 assert!(
15721 snapshot.outbound_calls.iter().any(|call| {
15722 call.target == target
15723 || call.target.starts_with(&format!(
15724 "{target}{}",
15725 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
15726 ))
15727 }),
15728 "expected projected call to {target}; calls: {:#?}",
15729 snapshot.outbound_calls
15730 );
15731 }
15732
15733 fn assert_no_projected_call(
15734 root: &Path,
15735 snapshot: &CallgraphSnapshot,
15736 target_rel: &str,
15737 symbol: &str,
15738 ) {
15739 let target = projected_target(root, target_rel, symbol);
15740 assert!(
15741 snapshot.outbound_calls.iter().all(|call| {
15742 call.target != target
15743 && !call.target.starts_with(&format!(
15744 "{target}{}",
15745 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
15746 ))
15747 }),
15748 "did not expect projected call to {target}; calls: {:#?}",
15749 snapshot.outbound_calls
15750 );
15751 }
15752
15753 fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
15754 let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
15757 format!("{}::{symbol}", path.display())
15758 }
15759
15760 fn write_rust_manifest(root: &Path, name: &str) {
15761 write_file(
15762 root,
15763 "Cargo.toml",
15764 &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
15765 );
15766 }
15767
15768 fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
15769 let path = root.join(rel_path);
15770 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
15771 fs::write(&path, source).expect("write fixture file");
15772 path
15773 }
15774
15775 fn rust_files(root: &Path) -> Vec<PathBuf> {
15776 let mut files = Vec::new();
15777 collect_rust_files(root, &mut files);
15778 files.sort();
15779 files
15780 }
15781
15782 fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
15783 for entry in fs::read_dir(dir).expect("read fixture dir") {
15784 let entry = entry.expect("read fixture entry");
15785 let path = entry.path();
15786 if path.is_dir() {
15787 let name = path
15788 .file_name()
15789 .and_then(|name| name.to_str())
15790 .unwrap_or("");
15791 if !name.starts_with(".store") {
15792 collect_rust_files(&path, files);
15793 }
15794 } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
15795 files.push(path);
15796 }
15797 }
15798 }
15799}
15800
15801#[cfg(test)]
15802mod build_pool_tests {
15803 use super::build_pool_size;
15804
15805 #[test]
15806 fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
15807 let size = build_pool_size();
15808 assert!(size >= 1, "pool size must be at least 1");
15811 assert!(size <= 8, "pool size must be capped at 8, got {size}");
15812
15813 let cores = std::thread::available_parallelism()
15814 .map(|p| p.get())
15815 .unwrap_or(1);
15816 let expected = cores.div_ceil(2).clamp(1, 8);
15817 assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
15818 }
15819}
15820
15821#[cfg(test)]
15822mod reexport_resolution_tests {
15823 use super::*;
15824
15825 fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
15826 ProjectIndex {
15827 project_root: PathBuf::from("/fixture"),
15828 files: files.into_iter().collect(),
15829 caller_data: HashMap::new(),
15830 workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
15831 }
15832 }
15833
15834 fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
15835 DbFileIndex {
15836 lang: None,
15837 exports: HashSet::new(),
15838 default_export: None,
15839 export_aliases: HashMap::new(),
15840 node_by_scoped: HashMap::new(),
15841 node_by_bare: HashMap::new(),
15842 node_kind_by_id: HashMap::new(),
15843 module_targets: HashMap::new(),
15844 reexports: reexport_targets
15845 .iter()
15846 .map(|target| ReexportIndex {
15847 target_file: Some((*target).to_string()),
15848 named: HashMap::new(),
15849 wildcard: true,
15850 })
15851 .collect(),
15852 }
15853 }
15854
15855 #[test]
15862 fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
15863 let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
15864 let files = names
15865 .iter()
15866 .map(|name| {
15867 let targets: Vec<&str> = names
15868 .iter()
15869 .filter(|other| *other != name)
15870 .map(String::as_str)
15871 .collect();
15872 (name.clone(), barrel_file(&targets))
15873 })
15874 .collect();
15875 let index = barrel_index(files);
15876
15877 assert_eq!(
15878 resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
15879 None
15880 );
15881 }
15882
15883 #[test]
15889 fn shallow_revisit_after_deep_capped_visit_still_resolves() {
15890 let mut leaf = barrel_file(&[]);
15891 leaf.exports.insert("deep_symbol".to_string());
15892 let mut files: Vec<(String, DbFileIndex)> = Vec::new();
15893 files.push((
15896 "src/entry.ts".to_string(),
15897 barrel_file(&["src/chain0.ts", "src/shared.ts"]),
15898 ));
15899 for i in 0..15 {
15900 let next = if i == 14 {
15901 "src/shared.ts".to_string()
15902 } else {
15903 format!("src/chain{}.ts", i + 1)
15904 };
15905 files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
15906 }
15907 files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
15908 files.push(("src/leaf.ts".to_string(), leaf));
15909 let index = barrel_index(files);
15910
15911 assert_eq!(
15912 resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
15913 Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
15914 "a shallower re-visit must not be pruned by a deeper capped visit"
15915 );
15916 }
15917
15918 #[test]
15919 fn symbol_reachable_through_reexport_cycle_still_resolves() {
15920 let mut leaf = barrel_file(&[]);
15921 leaf.exports.insert("real_symbol".to_string());
15922 let index = barrel_index(vec![
15923 (
15924 "src/a.ts".to_string(),
15925 barrel_file(&["src/b.ts", "src/a.ts"]),
15926 ),
15927 (
15928 "src/b.ts".to_string(),
15929 barrel_file(&["src/a.ts", "src/leaf.ts"]),
15930 ),
15931 ("src/leaf.ts".to_string(), leaf),
15932 ]);
15933
15934 assert_eq!(
15935 resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
15936 Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
15937 );
15938 }
15939}
15940
15941#[cfg(test)]
15942mod method_dispatch_inference_tests {
15943 use super::*;
15944 use std::fs;
15945 use tempfile::tempdir;
15946
15947 #[test]
15948 fn java_field_receiver_type_selects_declared_class_method() {
15949 let source = r#"class EntryPoint {
15950 private UserService userService;
15951
15952 void handle() {
15953 userService.find();
15954 }
15955}
15956
15957class UserService {
15958 void find() {}
15959}
15960
15961class AuditService {
15962 void find() {}
15963}
15964"#;
15965 let dir = tempdir().expect("temp dir");
15966 let root = dir.path();
15967 write_fixture(root, "src/EntryPoint.java", source);
15968 let reference = reference(
15969 "java",
15970 "src/EntryPoint.java",
15971 "EntryPoint::handle",
15972 "userService",
15973 "find",
15974 line_of(source, "userService.find()"),
15975 );
15976 let mut cache = DispatchSourceCache::new();
15977
15978 let receiver_type =
15979 infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
15980 assert_eq!(receiver_type, "UserService");
15981
15982 let candidates = vec![
15983 method_candidate("audit", "AuditService::find"),
15984 method_candidate("user", "UserService::find"),
15985 ];
15986 let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
15987 .expect("type candidate");
15988 assert_eq!(selected.scoped_name, "UserService::find");
15989
15990 let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
15991 assert!(
15992 select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
15993 );
15994 }
15995
15996 #[test]
15997 fn kotlin_property_and_local_value_types_are_inferred() {
15998 let source = r#"class Handler {
15999 private val auditService: AuditService = AuditService()
16000
16001 fun handle() {
16002 auditService.find()
16003 val userService: UserService = UserService()
16004 userService.find()
16005 val billingService = BillingService()
16006 billingService.find()
16007 }
16008}
16009
16010class UserService { fun find() {} }
16011class AuditService { fun find() {} }
16012class BillingService { fun find() {} }
16013"#;
16014 let dir = tempdir().expect("temp dir");
16015 let root = dir.path();
16016 write_fixture(root, "src/Handler.kt", source);
16017 let mut cache = DispatchSourceCache::new();
16018
16019 let audit_ref = reference(
16020 "kotlin",
16021 "src/Handler.kt",
16022 "Handler::handle",
16023 "auditService",
16024 "find",
16025 line_of(source, "auditService.find()"),
16026 );
16027 assert_eq!(
16028 infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
16029 Some("AuditService")
16030 );
16031
16032 let user_ref = reference(
16033 "kotlin",
16034 "src/Handler.kt",
16035 "Handler::handle",
16036 "userService",
16037 "find",
16038 line_of(source, "userService.find()"),
16039 );
16040 assert_eq!(
16041 infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
16042 Some("UserService")
16043 );
16044
16045 let billing_ref = reference(
16046 "kotlin",
16047 "src/Handler.kt",
16048 "Handler::handle",
16049 "billingService",
16050 "find",
16051 line_of(source, "billingService.find()"),
16052 );
16053 assert_eq!(
16054 infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
16055 Some("BillingService")
16056 );
16057 }
16058
16059 #[test]
16060 fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
16061 let source = r#"struct Foo { void run(); };
16062struct PointerFoo { void run(); };
16063struct FactoryFoo { void run(); };
16064FactoryFoo makeFactoryFoo();
16065
16066void handle() {
16067 Foo foo;
16068 foo.run();
16069 PointerFoo* pointerFoo = nullptr;
16070 pointerFoo->run();
16071 auto factoryFoo = makeFactoryFoo();
16072 factoryFoo.run();
16073}
16074"#;
16075 let dir = tempdir().expect("temp dir");
16076 let root = dir.path();
16077 write_fixture(root, "src/fixture.cpp", source);
16078 let mut cache = DispatchSourceCache::new();
16079
16080 let foo_ref = reference(
16081 "cpp",
16082 "src/fixture.cpp",
16083 "handle",
16084 "foo",
16085 "run",
16086 line_of(source, "foo.run()"),
16087 );
16088 assert_eq!(
16089 infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
16090 Some("Foo")
16091 );
16092
16093 let pointer_ref = reference(
16094 "cpp",
16095 "src/fixture.cpp",
16096 "handle",
16097 "pointerFoo",
16098 "run",
16099 line_of(source, "pointerFoo->run()"),
16100 );
16101 assert_eq!(
16102 infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
16103 Some("PointerFoo")
16104 );
16105
16106 let factory_ref = reference(
16107 "cpp",
16108 "src/fixture.cpp",
16109 "handle",
16110 "factoryFoo",
16111 "run",
16112 line_of(source, "factoryFoo.run()"),
16113 );
16114 assert_eq!(
16115 infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
16116 Some("FactoryFoo")
16117 );
16118 }
16119
16120 #[test]
16121 fn rust_direct_self_field_name_trims_separator_whitespace() {
16122 for receiver_expression in ["self .engine", "self. engine", "self . engine"] {
16123 assert_eq!(
16124 rust_direct_self_field_name(receiver_expression),
16125 Some("engine")
16126 );
16127 }
16128 }
16129
16130 #[test]
16131 fn rust_direct_self_field_receiver_type_is_conservative() {
16132 let source = r#"struct Engine;
16133
16134struct Car {
16135 engine: Engine,
16136}
16137
16138impl Car {
16139 fn run(&self) {
16140 self.engine.start();
16141 }
16142}
16143
16144struct NestedCar {
16145 engine: Engine,
16146}
16147
16148impl NestedCar {
16149 fn run(&self) {
16150 self.inner.engine.start();
16151 }
16152}
16153
16154struct WrappedCar {
16155 engine: Option<Engine>,
16156}
16157
16158impl WrappedCar {
16159 fn run(&self) {
16160 self.engine.start(); // wrapped
16161 }
16162}
16163
16164struct GenericCar<T> {
16165 engine: T,
16166}
16167
16168impl<T> GenericCar<T> {
16169 fn run(&self) {
16170 self.engine.start(); // generic
16171 }
16172}
16173
16174type EngineAlias = Engine;
16175
16176struct AliasCar {
16177 engine: EngineAlias,
16178}
16179
16180impl AliasCar {
16181 fn run(&self) {
16182 self.engine.start(); // alias
16183 }
16184}
16185"#;
16186 let dir = tempdir().expect("temp dir");
16187 let root = dir.path();
16188 write_fixture(root, "src/lib.rs", source);
16189 let mut cache = DispatchSourceCache::new();
16190
16191 let mut direct = reference(
16192 "rust",
16193 "src/lib.rs",
16194 "Car::run",
16195 "engine",
16196 "start",
16197 line_of(source, "self.engine.start()"),
16198 );
16199 direct.receiver_expression = "self.engine".to_string();
16200 assert_eq!(
16201 infer_receiver_type(root, &direct, &mut cache).as_deref(),
16202 Some("Engine")
16203 );
16204
16205 let mut mismatched_impl_target = direct.clone();
16206 mismatched_impl_target.caller_symbol = "other::Car::run".to_string();
16207 assert!(infer_receiver_type(root, &mismatched_impl_target, &mut cache).is_none());
16208
16209 let mut nested = reference(
16210 "rust",
16211 "src/lib.rs",
16212 "NestedCar::run",
16213 "engine",
16214 "start",
16215 line_of(source, "self.inner.engine.start()"),
16216 );
16217 nested.receiver_expression = "self.inner.engine".to_string();
16218 assert!(infer_receiver_type(root, &nested, &mut cache).is_none());
16219
16220 let mut wrapped = reference(
16221 "rust",
16222 "src/lib.rs",
16223 "WrappedCar::run",
16224 "engine",
16225 "start",
16226 line_of(source, "self.engine.start(); // wrapped"),
16227 );
16228 wrapped.receiver_expression = "self.engine".to_string();
16229 assert!(infer_receiver_type(root, &wrapped, &mut cache).is_none());
16230
16231 let mut generic = reference(
16232 "rust",
16233 "src/lib.rs",
16234 "GenericCar::run",
16235 "engine",
16236 "start",
16237 line_of(source, "self.engine.start(); // generic"),
16238 );
16239 generic.receiver_expression = "self.engine".to_string();
16240 assert!(infer_receiver_type(root, &generic, &mut cache).is_none());
16241
16242 let mut alias = reference(
16243 "rust",
16244 "src/lib.rs",
16245 "AliasCar::run",
16246 "engine",
16247 "start",
16248 line_of(source, "self.engine.start(); // alias"),
16249 );
16250 alias.receiver_expression = "self.engine".to_string();
16251 assert!(infer_receiver_type(root, &alias, &mut cache).is_none());
16252 }
16253
16254 #[test]
16255 fn rust_direct_self_reference_field_receiver_is_not_inferred() {
16256 let source = r#"struct Engine;
16257
16258struct Car {
16259 engine: &'static Engine,
16260}
16261
16262impl Car {
16263 fn run(&self) {
16264 self.engine.start();
16265 }
16266}
16267"#;
16268 let dir = tempdir().expect("temp dir");
16269 let root = dir.path();
16270 write_fixture(root, "src/lib.rs", source);
16271 let mut cache = DispatchSourceCache::new();
16272 let mut reference = reference(
16273 "rust",
16274 "src/lib.rs",
16275 "Car::run",
16276 "engine",
16277 "start",
16278 line_of(source, "self.engine.start()"),
16279 );
16280 reference.receiver_expression = "self.engine".to_string();
16281
16282 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
16283 }
16284
16285 #[test]
16286 fn rust_trait_impl_self_field_receiver_is_not_inferred() {
16287 let source = r#"trait Drive {
16288 fn run(&self);
16289}
16290
16291struct Engine;
16292
16293struct Car {
16294 engine: Engine,
16295}
16296
16297impl Drive for Car {
16298 fn run(&self) {
16299 self.engine.start();
16300 }
16301}
16302"#;
16303 let dir = tempdir().expect("temp dir");
16304 let root = dir.path();
16305 write_fixture(root, "src/lib.rs", source);
16306 let mut cache = DispatchSourceCache::new();
16307 let mut reference = reference(
16308 "rust",
16309 "src/lib.rs",
16310 "Car::run",
16311 "engine",
16312 "start",
16313 line_of(source, "self.engine.start()"),
16314 );
16315 reference.receiver_expression = "self.engine".to_string();
16316
16317 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
16318 }
16319
16320 #[test]
16321 fn rust_self_field_does_not_bind_struct_from_another_module() {
16322 let source = r#"struct Engine;
16323
16324mod unrelated {
16325 struct Car {
16326 engine: Engine,
16327 }
16328}
16329
16330impl Car {
16331 fn run(&self) {
16332 self.engine.start();
16333 }
16334}
16335"#;
16336 let dir = tempdir().expect("temp dir");
16337 let root = dir.path();
16338 write_fixture(root, "src/lib.rs", source);
16339 let mut cache = DispatchSourceCache::new();
16340 let mut reference = reference(
16341 "rust",
16342 "src/lib.rs",
16343 "Car::run",
16344 "engine",
16345 "start",
16346 line_of(source, "self.engine.start()"),
16347 );
16348 reference.receiver_expression = "self.engine".to_string();
16349
16350 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
16351 }
16352
16353 #[test]
16354 fn unknown_java_receiver_still_uses_name_match_fallback() {
16355 let source = r#"class EntryPoint {
16356 void handle() {
16357 service.runSpecial();
16358 }
16359}
16360
16361class OnlyService {
16362 void runSpecial() {}
16363}
16364"#;
16365 let dir = tempdir().expect("temp dir");
16366 let root = dir.path();
16367 write_fixture(root, "src/EntryPoint.java", source);
16368 let reference = reference(
16369 "java",
16370 "src/EntryPoint.java",
16371 "EntryPoint::handle",
16372 "service",
16373 "runSpecial",
16374 line_of(source, "service.runSpecial()"),
16375 );
16376 let mut cache = DispatchSourceCache::new();
16377
16378 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
16379 let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
16380 let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
16381 assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
16382 }
16383
16384 fn reference(
16385 lang: &str,
16386 caller_file: &str,
16387 caller_symbol: &str,
16388 receiver: &str,
16389 method_name: &str,
16390 line: u32,
16391 ) -> NameMatchRef {
16392 NameMatchRef {
16393 ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
16394 caller_node: format!("{caller_symbol}:node"),
16395 caller_file: caller_file.to_string(),
16396 caller_symbol: caller_symbol.to_string(),
16397 caller_signature: None,
16398 receiver_expression: receiver.to_string(),
16399 receiver: receiver.to_string(),
16400 method_name: method_name.to_string(),
16401 colon_dispatch: false,
16402 line,
16403 lang: lang.to_string(),
16404 }
16405 }
16406
16407 fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
16408 NameMatchCandidate {
16409 node_id: node_id.to_string(),
16410 file_path: "src/targets.fixture".to_string(),
16411 scoped_name: scoped_name.to_string(),
16412 kind: "method".to_string(),
16413 start_line: 1,
16414 }
16415 }
16416
16417 fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
16418 let path = root.join(rel_path);
16419 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
16420 fs::write(path, source).expect("write fixture");
16421 }
16422
16423 fn line_of(source: &str, needle: &str) -> u32 {
16424 source
16425 .lines()
16426 .position(|line| line.contains(needle))
16427 .map(|index| index as u32 + 1)
16428 .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
16429 }
16430}
16431
16432#[cfg(test)]
16433mod bounded_build_breaker_tests {
16434 use super::*;
16435 use crate::build_breaker::{BreakerAdmission, BreakerKey, BuildDeathBreaker, BuildDomain};
16436 use tempfile::tempdir;
16437
16438 #[test]
16439 fn staged_inventory_drives_ordered_bounded_file_batches() {
16440 let temp = tempdir().unwrap();
16441 let root = temp.path().join("root");
16442 std::fs::create_dir_all(&root).unwrap();
16443 let first = root.join("a.ts");
16444 let second = root.join("b.ts");
16445 let third = root.join("c.ts");
16446 for path in [&first, &second, &third] {
16447 std::fs::write(path, "export function item() {}\n").unwrap();
16448 }
16449 let writer_lease = acquire_writer_lease(temp.path(), "inventory-key", &root)
16450 .unwrap()
16451 .expect("test root may write its private staging database");
16452 let store = CallGraphStore::open_at_path(
16453 root.clone(),
16454 "inventory-key".to_string(),
16455 temp.path().join("inventory.sqlite"),
16456 None,
16457 true,
16458 Some(writer_lease),
16459 None,
16460 )
16461 .unwrap()
16462 .store;
16463 let fingerprint = store
16464 .stage_cold_build_file_inventory(&[
16465 third.clone(),
16466 first.clone(),
16467 second.clone(),
16468 first.clone(),
16469 ])
16470 .unwrap();
16471
16472 let conn = store.conn.lock().unwrap();
16473 assert_eq!(
16474 query_count(&conn, "SELECT COUNT(*) FROM staging_file_inventory").unwrap(),
16475 3,
16476 "the primary key deduplicates caller-supplied paths on disk"
16477 );
16478 let first_batch = load_staged_file_batch(&conn, &root, "", 2, u64::MAX)
16479 .unwrap()
16480 .expect("first batch");
16481 assert_eq!(first_batch.paths, vec![first.clone(), second]);
16482 let second_batch =
16483 load_staged_file_batch(&conn, &root, &first_batch.last_path, 2, u64::MAX)
16484 .unwrap()
16485 .expect("second batch");
16486 assert_eq!(second_batch.paths, vec![third]);
16487 assert_eq!(
16488 fingerprint,
16489 callgraph_corpus_fingerprint(&root).unwrap(),
16490 "staged and direct streaming fingerprints agree without walk-order dependence"
16491 );
16492 }
16493
16494 #[test]
16495 fn resumed_stage_preserves_committed_batch_and_counter() {
16496 let temp = tempdir().unwrap();
16497 let root = temp.path().join("root");
16498 std::fs::create_dir_all(&root).unwrap();
16499 let first = root.join("first.ts");
16500 let second = root.join("second.ts");
16501 std::fs::write(&first, "export function first() {}\n").unwrap();
16502 std::fs::write(&second, "export function second() { first(); }\n").unwrap();
16503 let staging = temp.path().join("stage.sqlite");
16504 let writer_lease = acquire_writer_lease(temp.path(), "test-key", &root)
16505 .unwrap()
16506 .expect("test root may write its private staging database");
16507 let store = CallGraphStore::open_at_path(
16508 root.clone(),
16509 "test-key".to_string(),
16510 staging,
16511 None,
16512 true,
16513 Some(writer_lease),
16514 None,
16515 )
16516 .unwrap()
16517 .store;
16518 let corpus_fingerprint = store
16519 .stage_cold_build_file_inventory(&[first.clone(), second.clone()])
16520 .unwrap();
16521 let first_extract = build_file_extract(&root, &first).unwrap();
16522 let first_bytes = first_extract.freshness.size;
16523 {
16524 let mut conn = store.conn.lock().unwrap();
16525 let tx = conn.transaction().unwrap();
16526 clear_tables(&tx).unwrap();
16527 insert_meta(&tx).unwrap();
16528 drop_cold_build_secondary_indexes(&tx).unwrap();
16529 set_meta_ready(&tx, false).unwrap();
16530 set_staged_build_phase(&tx, "extracting").unwrap();
16531 set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, &corpus_fingerprint).unwrap();
16532 set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0).unwrap();
16533 {
16534 let mut inserts = ColdBuildInsertStatements::new(&tx).unwrap();
16535 insert_file_extract_prepared(
16536 &mut inserts,
16537 &root.display().to_string(),
16538 &first_extract,
16539 )
16540 .unwrap();
16541 for raw in &first_extract.raw_refs {
16542 insert_staged_ref_prepared(&mut inserts, raw).unwrap();
16543 }
16544 }
16545 increment_staged_extracted_bytes(&tx, first_bytes).unwrap();
16546 tx.commit().unwrap();
16547 }
16548
16549 store
16550 .cold_build_chunked(&[first.clone(), second.clone()], 1)
16551 .unwrap();
16552 let conn = store.conn.lock().unwrap();
16553 assert_eq!(query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(), 2);
16554 assert_eq!(
16555 staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
16556 first_bytes + std::fs::metadata(second).unwrap().len(),
16557 "the already committed batch and its credit survive adoption; only the new batch increments credit"
16558 );
16559 assert_eq!(staged_build_phase(&conn).unwrap().as_deref(), Some("ready"));
16560 }
16561
16562 const SPECIMEN_CHILD_TEST: &str =
16563 "callgraph_store::bounded_build_breaker_tests::respawn_loop_build_child";
16564 const SPECIMEN_CHILD_ROOT: &str = "AFT_SPECIMEN_CHILD_ROOT";
16565 const SPECIMEN_CHILD_STORE: &str = "AFT_SPECIMEN_CHILD_STORE";
16566 const SPECIMEN_CHILD_PHASE: &str = "AFT_SPECIMEN_CHILD_PHASE";
16567 const SPECIMEN_CHILD_SIGNAL: &str = "AFT_SPECIMEN_CHILD_SIGNAL";
16568
16569 fn wait_for_child_barrier(path: &Path) {
16570 let deadline = Instant::now() + Duration::from_secs(10);
16571 while !path.exists() {
16572 assert!(
16573 Instant::now() < deadline,
16574 "callgraph child did not reach barrier {}",
16575 path.display()
16576 );
16577 std::thread::sleep(Duration::from_millis(5));
16578 }
16579 }
16580
16581 fn spawn_build_child(root: &Path, store: &Path, phase: Option<&str>) -> std::process::Child {
16582 let signal = store.join("specimen-child.reached");
16583 let _ = std::fs::remove_file(&signal);
16584 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
16585 command
16586 .arg("--exact")
16587 .arg(SPECIMEN_CHILD_TEST)
16588 .arg("--nocapture")
16589 .arg("--test-threads=1")
16590 .env(SPECIMEN_CHILD_ROOT, root)
16591 .env(SPECIMEN_CHILD_STORE, store)
16592 .env(SPECIMEN_CHILD_SIGNAL, &signal)
16593 .stdout(std::process::Stdio::null())
16594 .stderr(std::process::Stdio::null());
16595 if let Some(phase) = phase {
16596 command.env(SPECIMEN_CHILD_PHASE, phase);
16597 }
16598 command.spawn().unwrap()
16599 }
16600
16601 fn staging_path(root: &Path, store: &Path) -> PathBuf {
16602 let project_key = crate::search_index::artifact_cache_key(root);
16603 store.join(format!("{project_key}.staging.sqlite.tmp.resume"))
16604 }
16605
16606 fn durable_staging_state(path: &Path) -> (u64, u64) {
16607 if !path.exists() {
16608 return (0, 0);
16609 }
16610 let conn = Connection::open(path).unwrap();
16611 (
16612 query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(),
16613 staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
16614 )
16615 }
16616
16617 fn kill_barrier_child(child: &mut std::process::Child, signal: &Path) {
16618 wait_for_child_barrier(signal);
16619 child.kill().unwrap();
16620 let _ = child.wait().unwrap();
16621 }
16622
16623 #[test]
16624 fn respawn_loop_build_child() {
16625 let Some(root) = std::env::var_os(SPECIMEN_CHILD_ROOT) else {
16626 return;
16627 };
16628 let root = PathBuf::from(root);
16629 let store = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_STORE).unwrap());
16630 if let Some(phase) = std::env::var_os(SPECIMEN_CHILD_PHASE) {
16631 let phase = phase.to_string_lossy().into_owned();
16632 let signal = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_SIGNAL).unwrap());
16633 set_cold_build_phase_observer(Some(Arc::new(move |observed| {
16634 if observed == phase {
16635 std::fs::write(&signal, observed.as_bytes()).unwrap();
16636 std::thread::sleep(Duration::from_secs(30));
16637 }
16638 })));
16639 }
16640 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
16641 CallGraphStore::cold_build_with_lease_chunked(store, root, &files, 1).unwrap();
16642 }
16643
16644 #[test]
16645 fn issue_250_respawn_loop_converges_or_trips_without_false_readiness() {
16646 let temp = tempdir().unwrap();
16647 let root = temp.path().join("resumable-root");
16648 let store = temp.path().join("resumable-store");
16649 std::fs::create_dir_all(&root).unwrap();
16650 std::fs::create_dir_all(&store).unwrap();
16651 for index in 0..3 {
16652 std::fs::write(
16653 root.join(format!("file-{index}.ts")),
16654 format!("export function specimen{index}() {{ return {index}; }}\n"),
16655 )
16656 .unwrap();
16657 }
16658 let stage = staging_path(&root, &store);
16659 let signal = store.join("specimen-child.reached");
16660
16661 let mut first = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
16662 kill_barrier_child(&mut first, &signal);
16663 let (first_rows, first_bytes) = durable_staging_state(&stage);
16664 assert_eq!(first_rows, 1);
16665 assert!(first_bytes > 0);
16666
16667 let mut second = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
16668 kill_barrier_child(&mut second, &signal);
16669 let (second_rows, second_bytes) = durable_staging_state(&stage);
16670 assert_eq!(second_rows, 2);
16671 assert!(
16672 second_bytes > first_bytes,
16673 "a replacement process must adopt committed bytes instead of restarting from zero"
16674 );
16675
16676 let status = spawn_build_child(&root, &store, None).wait().unwrap();
16677 assert!(status.success(), "uninterrupted replacement build failed");
16678 assert!(!stage.exists(), "published staging file must be renamed");
16679 let ready = CallGraphStore::open_readonly(store.clone(), root.clone())
16680 .unwrap()
16681 .expect("replacement attempts must converge to a published graph");
16682 assert_eq!(ready.indexed_file_count().unwrap(), 3);
16683
16684 let fast_root = temp.path().join("zero-credit-root");
16685 let fast_store = temp.path().join("zero-credit-store");
16686 std::fs::create_dir_all(&fast_root).unwrap();
16687 std::fs::create_dir_all(&fast_store).unwrap();
16688 std::fs::write(
16689 fast_root.join("main.ts"),
16690 "export function neverCommitted() {}\n",
16691 )
16692 .unwrap();
16693 let fast_stage = staging_path(&fast_root, &fast_store);
16694 let fast_signal = fast_store.join("specimen-child.reached");
16695 let breaker_path = fast_store.join("build-breaker.sqlite");
16696 let now = unix_millis_now();
16697
16698 for death in 0..3 {
16699 let mut child = spawn_build_child(&fast_root, &fast_store, Some("enumeration"));
16700 wait_for_child_barrier(&fast_signal);
16701 let attempt_id = Connection::open(&breaker_path)
16702 .unwrap()
16703 .query_row(
16704 "SELECT attempt_id FROM breaker_attempts
16705 WHERE death_charged = 0 ORDER BY rowid DESC LIMIT 1",
16706 [],
16707 |row| row.get::<_, String>(0),
16708 )
16709 .unwrap();
16710 let (_, committed_bytes) = durable_staging_state(&fast_stage);
16711 assert_eq!(
16712 committed_bytes, 0,
16713 "the fast-kill schedule must not cross an extraction commit"
16714 );
16715 child.kill().unwrap();
16716 let _ = child.wait().unwrap();
16717
16718 let key = BreakerKey::new(
16719 fast_root.display().to_string(),
16720 BuildDomain::CallgraphCold,
16721 callgraph_corpus_fingerprint(&fast_root).unwrap(),
16722 );
16723 BuildDeathBreaker::open(&breaker_path)
16724 .unwrap()
16725 .record_attributed_death_at(&key, &attempt_id, committed_bytes, 0, now + death)
16726 .unwrap();
16727 }
16728
16729 let files = crate::callgraph::walk_project_files(&fast_root).collect::<Vec<_>>();
16730 let suspension = CallGraphStore::cold_build_suspension(&fast_store, &fast_root)
16731 .unwrap()
16732 .expect("three zero-credit process deaths must suspend the root");
16733 assert_eq!(suspension.reason, "zero_credit_death_limit");
16734 assert_eq!(suspension.death_count, 3);
16735 let response = crate::commands::callgraph_store_adapter::suspended_response(
16736 "specimen",
16737 "callers",
16738 &suspension,
16739 );
16740 assert_eq!(response.data["code"], serde_json::json!("build_suspended"));
16741 let message = response.data["message"].as_str().unwrap();
16742 assert!(
16743 message.starts_with("callers: build_suspended domain=callgraph_cold deaths=3 age_ms=")
16744 );
16745 assert!(message.ends_with(
16746 " reason=zero_credit_death_limit; run doctor reset-build-breaker to resume"
16747 ));
16748 let refused =
16749 CallGraphStore::cold_build_with_lease_chunked(fast_store, fast_root, &files, 1)
16750 .expect_err("a suspended root must not report a perpetually building worker");
16751 assert!(matches!(refused, CallGraphStoreError::Suspended(_)));
16752 }
16753
16754 #[test]
16755 fn published_callgraph_build_respects_durable_domain_suspension() {
16756 let temp = tempdir().unwrap();
16757 let root = temp.path().join("root");
16758 let store_dir = temp.path().join("store");
16759 std::fs::create_dir_all(&root).unwrap();
16760 let source = root.join("main.ts");
16761 std::fs::write(&source, "export function marker() {}\n").unwrap();
16762 let files = vec![source];
16763 let key = BreakerKey::new(
16764 root.display().to_string(),
16765 BuildDomain::CallgraphCold,
16766 callgraph_corpus_fingerprint(&root).unwrap(),
16767 );
16768 let breaker = BuildDeathBreaker::open(store_dir.join("build-breaker.sqlite")).unwrap();
16769 for _ in 0..3 {
16770 let BreakerAdmission::Admitted(attempt) = breaker.admit(&key, 0).unwrap() else {
16771 panic!("unexpected early suspension");
16772 };
16773 breaker
16774 .record_attributed_death(&key, &attempt.attempt_id, 0, 0)
16775 .unwrap();
16776 }
16777
16778 let error = CallGraphStore::cold_build_with_lease_chunked(store_dir, root, &files, 1)
16779 .expect_err("durably tripped callgraph domain must refuse a new cold build");
16780 assert!(matches!(
16781 error,
16782 CallGraphStoreError::Suspended(ref suspension)
16783 if suspension.domain == BuildDomain::CallgraphCold
16784 && suspension.death_count == 3
16785 ));
16786 }
16787}