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 #[cfg(unix)]
482 #[test]
483 fn deleted_symlink_alias_refresh_removes_the_original_stale_row() {
484 let temp = tempdir().unwrap();
485 let root = temp.path().join("project");
486 let source = root.join("src/lib.ts");
487 fs::create_dir_all(source.parent().unwrap()).unwrap();
488 fs::write(&source, "export function live() {}\n").unwrap();
489 let alias = temp.path().join("project-alias");
490 std::os::unix::fs::symlink(&root, &alias).unwrap();
491 let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
492 store.cold_build(std::slice::from_ref(&source)).unwrap();
493 store
494 .mark_files_stale(std::slice::from_ref(&source))
495 .unwrap();
496
497 fs::remove_file(&source).unwrap();
498 let stats = store
499 .refresh_files(&[alias.join("src/lib.ts")])
500 .expect("deleted alias path must resolve through its existing parent");
501
502 assert_eq!(stats.deleted_files, vec!["src/lib.ts"]);
503 assert!(store.stale_files().unwrap().is_empty());
504 }
505
506 #[cfg(unix)]
507 #[test]
508 fn symlink_alias_refresh_preserves_real_mutation_detection() {
509 let temp = tempdir().unwrap();
510 let root = temp.path().join("project");
511 let source = root.join("src/lib.ts");
512 fs::create_dir_all(source.parent().unwrap()).unwrap();
513 fs::write(&source, "export function before() {}\n").unwrap();
514 let alias = temp.path().join("project-alias");
515 std::os::unix::fs::symlink(&root, &alias).unwrap();
516 let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
517 store.cold_build(std::slice::from_ref(&source)).unwrap();
518
519 fs::write(&source, "export function after() {}\n").unwrap();
520 let stats = store.refresh_files(&[alias.join("src/lib.ts")]).unwrap();
521
522 assert_eq!(stats.changed_files, vec!["src/lib.ts"]);
523 assert_eq!(stats.refreshed_own_files, 1);
524 assert!(store.node_for(Path::new("src/lib.ts"), "after").is_ok());
525 }
526
527 #[test]
528 fn unresolvable_refresh_path_records_a_path_identity_gap() {
529 let temp = tempdir().unwrap();
530 let root = temp.path().join("project");
531 let source = root.join("src/lib.ts");
532 fs::create_dir_all(source.parent().unwrap()).unwrap();
533 fs::write(&source, "export function live() {}\n").unwrap();
534 let foreign = temp.path().join("foreign.ts");
535 fs::write(&foreign, "export function foreign() {}\n").unwrap();
536 let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
537 store.cold_build(std::slice::from_ref(&source)).unwrap();
538
539 let error = store.refresh_files(&[foreign.clone()]).unwrap_err();
540 assert!(matches!(
541 error,
542 CallGraphStoreError::PathIdentityMismatch { .. }
543 ));
544 let conn = store.conn.lock().unwrap();
545 assert_eq!(
546 path_identity_mismatch_reason(&conn).unwrap(),
547 Some(format!(
548 "callgraph_path_identity_mismatch path={} project_root={}",
549 foreign.display(),
550 root.display()
551 ))
552 );
553 }
554
555 #[test]
556 fn idle_checkpoint_interval_prevents_checkpoint_storms() {
557 let now = Instant::now();
558 assert!(idle_checkpoint_due(None, now));
559 assert!(!idle_checkpoint_due(
560 Some(now),
561 now + Duration::from_secs(REFRESH_IDLE_CHECKPOINT_INTERVAL.as_secs() - 1),
562 ));
563 assert!(idle_checkpoint_due(
564 Some(now),
565 now + REFRESH_IDLE_CHECKPOINT_INTERVAL,
566 ));
567 }
568
569 #[test]
570 fn write_metrics_decay_after_the_sixty_second_window() {
571 let key = format!("metrics-test-{}", now_nanos());
572 let metrics = callgraph_write_metrics_for_key(&key);
573 metrics.record_commit(17);
574 assert_eq!(metrics.snapshot().commits_60s, 1);
575 assert_eq!(metrics.snapshot().pages_or_bytes_written_60s, 17);
576 metrics.window_start_ms.store(
577 unix_millis_now().saturating_sub(CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64),
578 AtomicOrdering::Release,
579 );
580 assert_eq!(metrics.snapshot(), CallgraphWriteMetricsSnapshot::default());
581 }
582}
583
584#[cfg(test)]
585type ColdBuildBeforePublishObserver = dyn Fn() + Send + Sync + 'static;
586thread_local! {
593 static COLD_BUILD_SWAP_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSwapObserver>>> =
594 const { std::cell::RefCell::new(None) };
595 #[cfg(test)]
596 static COLD_BUILD_BEFORE_PUBLISH_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildBeforePublishObserver>>> =
597 const { std::cell::RefCell::new(None) };
598 static MIGRATION_AVAILABLE_DISK_OVERRIDE: std::cell::RefCell<Option<u64>> =
599 const { std::cell::RefCell::new(None) };
600 static MIGRATION_FAIL_AFTER_TEMP_COPY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
601 static MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED: std::cell::Cell<bool> =
602 const { std::cell::Cell::new(false) };
603 static PUBLISH_ADMISSION: std::cell::RefCell<Option<(crate::root_cache::ArtifactPublishEpoch, u64)>> =
604 const { std::cell::RefCell::new(None) };
605 static REFRESH_COMMIT_ADMISSION: std::cell::RefCell<Option<(SubcLifecycleAdmission, Arc<std::sync::atomic::AtomicU64>, u64)>> =
606 const { std::cell::RefCell::new(None) };
607}
608
609mod dead_code_projection;
610pub use dead_code_projection::project_dead_code_snapshot;
611pub(crate) use dead_code_projection::project_dead_code_snapshot_with_revision;
612#[cfg(test)]
613pub(crate) use dead_code_projection::set_projection_before_open_observer;
614
615#[doc(hidden)]
616pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
617 COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
618}
619
620#[cfg(test)]
621fn set_cold_build_before_publish_observer(observer: Option<Arc<ColdBuildBeforePublishObserver>>) {
622 COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
623}
624
625#[cfg(test)]
626fn notify_cold_build_before_publish_observer() {
627 let observer = COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| slot.borrow().clone());
628 if let Some(observer) = observer {
629 observer();
630 }
631}
632
633#[cfg(not(test))]
634fn notify_cold_build_before_publish_observer() {}
635
636#[doc(hidden)]
637pub fn set_legacy_migration_available_disk_for_test(bytes: Option<u64>) {
638 MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow_mut() = bytes);
639}
640
641#[doc(hidden)]
642pub fn set_legacy_migration_fail_after_temp_copy_for_test(enabled: bool) {
643 MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.set(enabled));
644}
645
646#[doc(hidden)]
647pub fn set_legacy_migration_backup_budget_exhausted_for_test(enabled: bool) {
648 MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.set(enabled));
649}
650
651struct PublishAdmissionGuard {
652 previous: Option<(crate::root_cache::ArtifactPublishEpoch, u64)>,
653}
654
655impl Drop for PublishAdmissionGuard {
656 fn drop(&mut self) {
657 PUBLISH_ADMISSION.with(|slot| {
658 *slot.borrow_mut() = self.previous.take();
659 });
660 }
661}
662
663pub(crate) fn with_publish_epoch<R>(
664 epoch: crate::root_cache::ArtifactPublishEpoch,
665 expected: u64,
666 run: impl FnOnce() -> R,
667) -> R {
668 let previous = PUBLISH_ADMISSION.with(|slot| slot.replace(Some((epoch, expected))));
669 let _guard = PublishAdmissionGuard { previous };
670 run()
671}
672
673fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
674 let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
675 match admission {
676 Some((epoch, expected)) => epoch
677 .run_if_current(expected, publish)
678 .unwrap_or(Err(CallGraphStoreError::Superseded)),
679 None => publish(),
680 }
681}
682
683struct RefreshCommitAdmissionGuard {
684 previous: Option<(
685 SubcLifecycleAdmission,
686 Arc<std::sync::atomic::AtomicU64>,
687 u64,
688 )>,
689}
690
691impl Drop for RefreshCommitAdmissionGuard {
692 fn drop(&mut self) {
693 REFRESH_COMMIT_ADMISSION.with(|slot| {
694 *slot.borrow_mut() = self.previous.take();
695 });
696 }
697}
698
699fn with_refresh_commit_admission<R>(
700 lifecycle: SubcLifecycleAdmission,
701 generation_flag: Arc<std::sync::atomic::AtomicU64>,
702 expected_generation: u64,
703 run: impl FnOnce() -> R,
704) -> R {
705 let previous = REFRESH_COMMIT_ADMISSION
706 .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
707 let _guard = RefreshCommitAdmissionGuard { previous };
708 run()
709}
710
711fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
712 let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
713 let commit = || {
714 publish_if_current(|| {
715 tx.commit()?;
716 Ok(())
717 })
718 };
719 match admission {
720 Some((lifecycle, generation_flag, expected_generation)) => lifecycle
721 .run_if_current(generation_flag.as_ref(), expected_generation, commit)
722 .unwrap_or(Err(CallGraphStoreError::Superseded)),
723 None => commit(),
724 }
725}
726
727fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
728 let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
729 if let Some(observer) = observer {
730 observer(temp_path, target_path);
731 }
732}
733
734#[derive(Debug)]
735pub enum CallGraphStoreError {
736 Io(std::io::Error),
737 Sqlite(rusqlite::Error),
738 Json(serde_json::Error),
739 Aft(AftError),
740 Lock(crate::fs_lock::AcquireError),
741 MissingCallerData {
742 file: String,
743 },
744 Unavailable(String),
745 PathIdentityMismatch {
746 path: PathBuf,
747 project_root: PathBuf,
748 },
749 Suspended(crate::build_breaker::BuildSuspension),
750 Superseded,
751 StaleFiles(Vec<String>),
752}
753
754impl CallGraphStoreError {
755 pub(crate) fn is_transient_lock_contention(&self) -> bool {
756 matches!(
757 self,
758 Self::Sqlite(rusqlite::Error::SqliteFailure(error, _))
759 if matches!(
760 error.code,
761 rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
762 )
763 )
764 }
765}
766
767impl fmt::Display for CallGraphStoreError {
768 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
769 match self {
770 Self::Io(error) => write!(formatter, "I/O error: {error}"),
771 Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
772 Self::Json(error) => write!(formatter, "json error: {error}"),
773 Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
774 Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
775 Self::MissingCallerData { file } => {
776 write!(formatter, "missing extracted caller data for {file}")
777 }
778 Self::Unavailable(message) => {
779 write!(formatter, "callgraph store unavailable: {message}")
780 }
781 Self::PathIdentityMismatch { path, project_root } => write!(
782 formatter,
783 "callgraph path identity mismatch: {} is not under project root {}",
784 path.display(),
785 project_root.display()
786 ),
787 Self::Suspended(suspension) => write!(
788 formatter,
789 "callgraph build suspended for {} after {} deaths ({})",
790 suspension.domain.as_str(),
791 suspension.death_count,
792 suspension.reason
793 ),
794 Self::Superseded => {
795 write!(formatter, "callgraph store build superseded before publish")
796 }
797 Self::StaleFiles(files) => {
798 write!(
799 formatter,
800 "callgraph store has stale files: {}",
801 files.join(", ")
802 )
803 }
804 }
805 }
806}
807
808impl std::error::Error for CallGraphStoreError {}
809
810impl From<std::io::Error> for CallGraphStoreError {
811 fn from(error: std::io::Error) -> Self {
812 Self::Io(error)
813 }
814}
815
816impl From<rusqlite::Error> for CallGraphStoreError {
817 fn from(error: rusqlite::Error) -> Self {
818 Self::Sqlite(error)
819 }
820}
821
822impl From<serde_json::Error> for CallGraphStoreError {
823 fn from(error: serde_json::Error) -> Self {
824 Self::Json(error)
825 }
826}
827
828impl From<AftError> for CallGraphStoreError {
829 fn from(error: AftError) -> Self {
830 Self::Aft(error)
831 }
832}
833
834impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
835 fn from(error: crate::fs_lock::AcquireError) -> Self {
836 Self::Lock(error)
837 }
838}
839
840pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
841
842pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
846
847#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
848pub struct CallGraphStoreOptions {
849 pub enabled: bool,
850}
851
852pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
853
854#[derive(Clone)]
858pub(crate) struct CallgraphRefreshState {
859 store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
860 heavy_root_work_allowed: Arc<AtomicBool>,
861}
862
863impl CallgraphRefreshState {
864 pub(crate) fn new(
865 store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
866 heavy_root_work_allowed: Arc<AtomicBool>,
867 ) -> Self {
868 Self {
869 store,
870 heavy_root_work_allowed,
871 }
872 }
873
874 fn installed_store_snapshot(&self) -> Option<Arc<ReadonlyCallGraphStore>> {
875 self.store
876 .read()
877 .unwrap_or_else(std::sync::PoisonError::into_inner)
878 .as_ref()
879 .map(Arc::clone)
880 }
881}
882
883type WorkspaceCratePrefixes = HashMap<String, String>;
884
885#[derive(Clone, Debug, Default)]
886struct WorkspaceCratePrefixCache(Arc<OnceLock<WorkspaceCratePrefixes>>);
887
888const REFRESH_WORKSPACE_CACHE_ROOT_CAP: usize = 128;
889
890pub(crate) fn invalidates_workspace_crate_prefix_cache(path: &Path) -> bool {
891 path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")
892}
893
894#[derive(Clone, Debug, Hash, PartialEq, Eq)]
895struct RefreshRoot {
896 callgraph_dir: PathBuf,
897 project_root: PathBuf,
898}
899
900#[derive(Clone)]
901pub(crate) struct CallgraphRefreshTicket {
902 lifecycle: SubcLifecycleAdmission,
903 generation_flag: Arc<std::sync::atomic::AtomicU64>,
904 expected_generation: u64,
905 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
906 expected_publish_epoch: u64,
907}
908
909impl CallgraphRefreshTicket {
910 pub(crate) fn new(
911 lifecycle: SubcLifecycleAdmission,
912 generation_flag: Arc<std::sync::atomic::AtomicU64>,
913 expected_generation: u64,
914 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
915 expected_publish_epoch: u64,
916 ) -> Self {
917 Self {
918 lifecycle,
919 generation_flag,
920 expected_generation,
921 publish_epoch,
922 expected_publish_epoch,
923 }
924 }
925
926 fn is_current(&self) -> bool {
927 self.lifecycle
928 .is_current(self.generation_flag.as_ref(), self.expected_generation)
929 && self.publish_epoch.current() == self.expected_publish_epoch
930 }
931}
932
933#[derive(Clone)]
934struct RefreshBatch {
935 root: RefreshRoot,
936 paths: BTreeSet<PathBuf>,
937 pending_sinks: Vec<PendingCallGraphStorePaths>,
938 refresh_states: Vec<CallgraphRefreshState>,
939 ticket: Option<CallgraphRefreshTicket>,
940}
941
942impl RefreshBatch {
943 fn defer(&self) {
944 for sink in &self.pending_sinks {
945 sink.lock().extend(self.paths.iter().cloned());
946 }
947 }
948
949 fn defer_after_open_failure(&self) {
950 self.defer();
951 if self
952 .ticket
953 .as_ref()
954 .is_some_and(|ticket| !ticket.is_current())
955 || !self
956 .refresh_states
957 .iter()
958 .any(|state| state.heavy_root_work_allowed.load(AtomicOrdering::SeqCst))
959 {
960 return;
961 }
962
963 let ready_store_installed = self.refresh_states.iter().any(|state| {
964 let store = state.installed_store_snapshot();
965 store.is_some_and(|store| {
966 store.project_root() == self.root.project_root
967 && !store.is_legacy_fallback()
968 && store.is_current()
969 })
970 });
971 if !ready_store_installed {
972 return;
973 }
974
975 for sink in &self.pending_sinks {
979 let paths = {
980 let mut pending = sink.lock();
981 self.paths
982 .iter()
983 .filter(|path| pending.remove(*path))
984 .cloned()
985 .collect::<Vec<_>>()
986 };
987 if paths.is_empty() {
988 continue;
989 }
990 let _ = enqueue_callgraph_store_refresh_inner(
991 self.root.callgraph_dir.clone(),
992 self.root.project_root.clone(),
993 paths,
994 Arc::clone(sink),
995 self.refresh_states.clone(),
996 self.ticket.clone(),
997 );
998 }
999 }
1000
1001 fn merge(
1002 &mut self,
1003 paths: impl IntoIterator<Item = PathBuf>,
1004 sink: PendingCallGraphStorePaths,
1005 refresh_states: Vec<CallgraphRefreshState>,
1006 ticket: Option<CallgraphRefreshTicket>,
1007 ) {
1008 self.paths.extend(paths);
1009 if ticket.is_some() {
1010 self.ticket = ticket;
1011 }
1012 if !self
1013 .pending_sinks
1014 .iter()
1015 .any(|existing| Arc::ptr_eq(existing, &sink))
1016 {
1017 self.pending_sinks.push(sink);
1018 }
1019 for refresh_state in refresh_states {
1020 if !self.refresh_states.iter().any(|existing| {
1021 Arc::ptr_eq(&existing.store, &refresh_state.store)
1022 && Arc::ptr_eq(
1023 &existing.heavy_root_work_allowed,
1024 &refresh_state.heavy_root_work_allowed,
1025 )
1026 }) {
1027 self.refresh_states.push(refresh_state);
1028 }
1029 }
1030 }
1031}
1032
1033#[derive(Default)]
1034struct RefreshQueue {
1035 order: VecDeque<RefreshRoot>,
1036 queued: HashMap<RefreshRoot, RefreshBatch>,
1037 active: Option<RefreshBatch>,
1038 shutdown_requested: bool,
1039}
1040
1041struct RefreshWorkerShared {
1042 queue: Mutex<RefreshQueue>,
1043 wake: Condvar,
1044}
1045
1046struct RefreshWorker {
1047 shared: Arc<RefreshWorkerShared>,
1048 thread: Mutex<Option<JoinHandle<()>>>,
1049}
1050
1051struct RefreshWorkerWatchdog {
1052 first_path: PathBuf,
1053 batch_len: usize,
1054 started: Instant,
1055}
1056
1057impl RefreshWorkerWatchdog {
1058 fn start(paths: &[PathBuf]) -> Self {
1059 Self {
1060 first_path: paths
1061 .first()
1062 .expect("non-empty callgraph refresh batch has a first path")
1063 .clone(),
1064 batch_len: paths.len(),
1065 started: Instant::now(),
1066 }
1067 }
1068}
1069
1070impl Drop for RefreshWorkerWatchdog {
1071 fn drop(&mut self) {
1072 let elapsed = self.started.elapsed();
1073 if elapsed < REFRESH_WORKER_WARN_AFTER {
1074 return;
1075 }
1076 let path = if self.batch_len == 1 {
1077 self.first_path.display().to_string()
1078 } else {
1079 format!(
1080 "{} (+{} paths)",
1081 self.first_path.display(),
1082 self.batch_len - 1
1083 )
1084 };
1085 log::warn!(
1086 "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
1087 path,
1088 elapsed.as_millis()
1089 );
1090 if elapsed >= REFRESH_WORKER_FINAL_AFTER {
1091 log::warn!(
1092 "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
1093 path,
1094 elapsed.as_millis()
1095 );
1096 }
1097 }
1098}
1099
1100impl RefreshWorker {
1101 fn spawn() -> Arc<Self> {
1102 let shared = Arc::new(RefreshWorkerShared {
1103 queue: Mutex::new(RefreshQueue::default()),
1104 wake: Condvar::new(),
1105 });
1106 let thread_shared = Arc::clone(&shared);
1107 let thread = std::thread::Builder::new()
1108 .name("aft-callgraph-refresh".to_string())
1109 .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
1110 .expect("failed to spawn callgraph refresh worker");
1111 Arc::new(Self {
1112 shared,
1113 thread: Mutex::new(Some(thread)),
1114 })
1115 }
1116
1117 fn enqueue(
1118 &self,
1119 root: RefreshRoot,
1120 paths: Vec<PathBuf>,
1121 pending_sink: PendingCallGraphStorePaths,
1122 refresh_states: Vec<CallgraphRefreshState>,
1123 ticket: Option<CallgraphRefreshTicket>,
1124 ) -> bool {
1125 let mut queue = self
1126 .shared
1127 .queue
1128 .lock()
1129 .expect("callgraph refresh queue mutex poisoned");
1130 if queue.shutdown_requested {
1131 pending_sink.lock().extend(paths);
1132 return false;
1133 }
1134 if let Some(batch) = queue.queued.get_mut(&root) {
1135 batch.merge(paths, pending_sink, refresh_states, ticket);
1136 } else {
1137 queue.order.push_back(root.clone());
1138 queue.queued.insert(
1139 root.clone(),
1140 RefreshBatch {
1141 root,
1142 paths: paths.into_iter().collect(),
1143 pending_sinks: vec![pending_sink],
1144 refresh_states,
1145 ticket,
1146 },
1147 );
1148 }
1149 self.shared.wake.notify_one();
1150 true
1151 }
1152
1153 fn shutdown_with_budget(&self, budget: Duration) -> bool {
1154 let deadline = Instant::now() + budget;
1155 let mut queue = self
1156 .shared
1157 .queue
1158 .lock()
1159 .expect("callgraph refresh queue mutex poisoned");
1160 queue.shutdown_requested = true;
1161 self.shared.wake.notify_one();
1162 while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
1163 let remaining = deadline.saturating_duration_since(Instant::now());
1164 let (next, _) = self
1165 .shared
1166 .wake
1167 .wait_timeout(queue, remaining)
1168 .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
1169 queue = next;
1170 }
1171 let drained = queue.active.is_none() && queue.order.is_empty();
1172 if !drained {
1173 if let Some(active) = queue.active.as_ref() {
1174 active.defer();
1175 }
1176 for batch in queue.queued.values() {
1177 batch.defer();
1178 }
1179 queue.order.clear();
1180 queue.queued.clear();
1181 }
1182 drop(queue);
1183
1184 if drained {
1185 if let Some(thread) = self
1186 .thread
1187 .lock()
1188 .expect("callgraph refresh worker thread mutex poisoned")
1189 .take()
1190 {
1191 let _ = thread.join();
1192 }
1193 }
1194 drained
1195 }
1196}
1197
1198static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
1199
1200pub fn enqueue_callgraph_store_refresh(
1201 callgraph_dir: PathBuf,
1202 project_root: PathBuf,
1203 paths: Vec<PathBuf>,
1204 pending_sink: PendingCallGraphStorePaths,
1205) -> bool {
1206 enqueue_callgraph_store_refresh_inner(
1207 callgraph_dir,
1208 project_root,
1209 paths,
1210 pending_sink,
1211 Vec::new(),
1212 None,
1213 )
1214}
1215
1216#[cfg(test)]
1217pub(crate) fn enqueue_callgraph_store_refresh_fenced(
1218 callgraph_dir: PathBuf,
1219 project_root: PathBuf,
1220 paths: Vec<PathBuf>,
1221 pending_sink: PendingCallGraphStorePaths,
1222 ticket: CallgraphRefreshTicket,
1223) -> bool {
1224 enqueue_callgraph_store_refresh_inner(
1225 callgraph_dir,
1226 project_root,
1227 paths,
1228 pending_sink,
1229 Vec::new(),
1230 Some(ticket),
1231 )
1232}
1233
1234pub(crate) fn enqueue_callgraph_store_refresh_fenced_with_state(
1235 callgraph_dir: PathBuf,
1236 project_root: PathBuf,
1237 paths: Vec<PathBuf>,
1238 pending_sink: PendingCallGraphStorePaths,
1239 refresh_state: CallgraphRefreshState,
1240 ticket: CallgraphRefreshTicket,
1241) -> bool {
1242 enqueue_callgraph_store_refresh_inner(
1243 callgraph_dir,
1244 project_root,
1245 paths,
1246 pending_sink,
1247 vec![refresh_state],
1248 Some(ticket),
1249 )
1250}
1251
1252fn enqueue_callgraph_store_refresh_inner(
1253 callgraph_dir: PathBuf,
1254 project_root: PathBuf,
1255 paths: Vec<PathBuf>,
1256 pending_sink: PendingCallGraphStorePaths,
1257 refresh_states: Vec<CallgraphRefreshState>,
1258 ticket: Option<CallgraphRefreshTicket>,
1259) -> bool {
1260 if paths.is_empty() {
1261 return true;
1262 }
1263 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1264 let worker = {
1265 let mut worker = slot
1266 .lock()
1267 .expect("callgraph refresh worker mutex poisoned");
1268 Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
1269 };
1270 worker.enqueue(
1271 RefreshRoot {
1272 callgraph_dir,
1273 project_root,
1274 },
1275 paths,
1276 pending_sink,
1277 refresh_states,
1278 ticket,
1279 )
1280}
1281
1282pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
1283 flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
1284}
1285
1286#[doc(hidden)]
1287pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
1288 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1289 let worker = slot
1290 .lock()
1291 .expect("callgraph refresh worker mutex poisoned")
1292 .clone();
1293 let Some(worker) = worker else {
1294 return true;
1295 };
1296 let drained = worker.shutdown_with_budget(budget);
1297 if drained {
1298 let mut current = slot
1299 .lock()
1300 .expect("callgraph refresh worker mutex poisoned");
1301 if current
1302 .as_ref()
1303 .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
1304 {
1305 *current = None;
1306 }
1307 }
1308 drained
1309}
1310
1311fn idle_checkpoint_due(last: Option<Instant>, now: Instant) -> bool {
1312 last.is_none_or(|last| now.saturating_duration_since(last) >= REFRESH_IDLE_CHECKPOINT_INTERVAL)
1313}
1314
1315fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
1316 let mut workspace_crate_prefixes = HashMap::new();
1319 let mut last_idle_checkpoints: HashMap<RefreshRoot, Instant> = HashMap::new();
1320 loop {
1321 let batch = {
1322 let mut queue = shared
1323 .queue
1324 .lock()
1325 .expect("callgraph refresh queue mutex poisoned");
1326 loop {
1327 if let Some(root) = queue.order.pop_front() {
1328 let batch = queue
1329 .queued
1330 .remove(&root)
1331 .expect("queued callgraph refresh root has a batch");
1332 queue.active = Some(batch.clone());
1333 break batch;
1334 }
1335 if queue.shutdown_requested {
1336 return;
1337 }
1338 queue = shared
1339 .wake
1340 .wait(queue)
1341 .expect("callgraph refresh queue mutex poisoned while waiting");
1342 }
1343 };
1344
1345 let store = process_callgraph_refresh_batch(&batch, &mut workspace_crate_prefixes);
1346
1347 let mut queue = shared
1348 .queue
1349 .lock()
1350 .expect("callgraph refresh queue mutex poisoned");
1351 queue.active = None;
1352 let became_idle = queue.order.is_empty();
1353 shared.wake.notify_all();
1354 drop(queue);
1355
1356 if became_idle {
1357 let checkpoint_due = idle_checkpoint_due(
1358 last_idle_checkpoints.get(&batch.root).copied(),
1359 Instant::now(),
1360 );
1361 if checkpoint_due {
1362 if let Some(store) = store {
1363 if store.checkpoint_wal_truncate() {
1364 last_idle_checkpoints.insert(batch.root.clone(), Instant::now());
1365 }
1366 }
1367 }
1368 }
1369 }
1370}
1371
1372fn process_callgraph_refresh_batch(
1373 batch: &RefreshBatch,
1374 workspace_crate_prefixes: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1375) -> Option<CallGraphStore> {
1376 if batch
1380 .paths
1381 .iter()
1382 .any(|path| invalidates_workspace_crate_prefix_cache(path))
1383 {
1384 workspace_crate_prefixes.remove(&batch.root);
1385 }
1386
1387 let paths = batch
1388 .paths
1389 .iter()
1390 .filter(|path| crate::parser::detect_language(path).is_some())
1391 .cloned()
1392 .collect::<Vec<_>>();
1393 if paths.is_empty() {
1394 return None;
1395 }
1396 note_refresh_worker_batch_for_test(&batch.root.project_root);
1397 if batch
1398 .ticket
1399 .as_ref()
1400 .is_some_and(|ticket| !ticket.is_current())
1401 {
1402 batch.defer();
1405 return None;
1406 }
1407 let workspace_crate_prefix_cache =
1408 workspace_crate_prefix_cache_for_root(workspace_crate_prefixes, &batch.root);
1409 let _watchdog = RefreshWorkerWatchdog::start(&paths);
1410 let test_seam = refresh_worker_test_seam(&batch.root.project_root);
1411 note_refresh_worker_call_for_test(&batch.root.project_root);
1412 let opened = if test_seam.fail_open {
1413 Ok(None)
1414 } else {
1415 CallGraphStore::open_ready(
1416 batch.root.callgraph_dir.clone(),
1417 batch.root.project_root.clone(),
1418 )
1419 };
1420 if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
1421 let _ = gate.held_tx.send(());
1424 let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
1425 }
1426 let store = match opened {
1427 Ok(Some(store)) => store,
1428 Ok(None) => {
1429 batch.defer_after_open_failure();
1430 return None;
1431 }
1432 Err(error) => {
1433 batch.defer_after_open_failure();
1434 crate::slog_warn!(
1435 "callgraph store writer open failed during refresh; deferred paths: {}",
1436 error
1437 );
1438 return None;
1439 }
1440 };
1441 if !test_seam.delay.is_zero() {
1442 std::thread::sleep(test_seam.delay);
1443 }
1444 if batch
1445 .ticket
1446 .as_ref()
1447 .is_some_and(|ticket| !ticket.is_current())
1448 {
1449 batch.defer();
1452 return Some(store);
1453 }
1454 let refresh_result = if test_seam.fail_refresh {
1455 Err(CallGraphStoreError::Unavailable(
1456 "injected refresh worker failure".to_string(),
1457 ))
1458 } else if let Some(ticket) = &batch.ticket {
1459 with_publish_epoch(
1460 ticket.publish_epoch.clone(),
1461 ticket.expected_publish_epoch,
1462 || {
1463 with_refresh_commit_admission(
1464 ticket.lifecycle.clone(),
1465 Arc::clone(&ticket.generation_flag),
1466 ticket.expected_generation,
1467 || {
1468 store
1469 .refresh_files_with_workspace_crate_prefix_cache(
1470 &paths,
1471 workspace_crate_prefix_cache.clone(),
1472 )
1473 .map(|_| ())
1474 },
1475 )
1476 },
1477 )
1478 } else {
1479 store
1480 .refresh_files_with_workspace_crate_prefix_cache(
1481 &paths,
1482 workspace_crate_prefix_cache.clone(),
1483 )
1484 .map(|_| ())
1485 };
1486 if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
1487 batch.defer();
1491 return Some(store);
1492 }
1493 if let Err(error) = refresh_result {
1494 crate::slog_warn!("callgraph store refresh failed: {}", error);
1495 match store.mark_files_stale(&paths) {
1496 Ok(marked) => {
1497 note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
1498 crate::slog_warn!(
1499 "marked {} callgraph store file(s) stale after refresh failure",
1500 marked.len()
1501 );
1502 }
1503 Err(mark_error) => crate::slog_warn!(
1504 "failed to mark callgraph store files stale after refresh failure: {}",
1505 mark_error
1506 ),
1507 }
1508 } else {
1509 crate::logging::note_callgraph_invalidations(paths.len());
1510 }
1511 Some(store)
1512}
1513
1514fn workspace_crate_prefix_cache_for_root(
1515 caches: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1516 root: &RefreshRoot,
1517) -> WorkspaceCratePrefixCache {
1518 if !caches.contains_key(root) && caches.len() >= REFRESH_WORKSPACE_CACHE_ROOT_CAP {
1519 if let Some(evicted) = caches.keys().next().cloned() {
1521 caches.remove(&evicted);
1522 }
1523 }
1524 caches.entry(root.clone()).or_default().clone()
1525}
1526
1527#[derive(Clone, Copy, Default)]
1528struct RefreshWorkerTestSeam {
1529 delay: Duration,
1530 fail_refresh: bool,
1531 fail_open: bool,
1532 refresh_calls: usize,
1533 worker_calls: usize,
1534 stale_marks: usize,
1535}
1536
1537static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
1538 OnceLock::new();
1539
1540struct RefreshWorkerTestGate {
1541 held_tx: crossbeam_channel::Sender<()>,
1542 release_rx: crossbeam_channel::Receiver<()>,
1543}
1544
1545static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
1546 OnceLock::new();
1547
1548#[doc(hidden)]
1549pub fn install_callgraph_refresh_worker_test_gate(
1550 project_root: PathBuf,
1551) -> (
1552 crossbeam_channel::Receiver<()>,
1553 crossbeam_channel::Sender<()>,
1554) {
1555 let (held_tx, held_rx) = crossbeam_channel::bounded(1);
1556 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1557 REFRESH_WORKER_TEST_GATES
1558 .get_or_init(|| Mutex::new(HashMap::new()))
1559 .lock()
1560 .expect("callgraph refresh test gate mutex poisoned")
1561 .insert(
1562 project_root,
1563 RefreshWorkerTestGate {
1564 held_tx,
1565 release_rx,
1566 },
1567 );
1568 (held_rx, release_tx)
1569}
1570
1571fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
1572 REFRESH_WORKER_TEST_GATES
1573 .get_or_init(|| Mutex::new(HashMap::new()))
1574 .lock()
1575 .expect("callgraph refresh test gate mutex poisoned")
1576 .remove(project_root)
1577}
1578
1579fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
1580 let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
1581 return RefreshWorkerTestSeam::default();
1582 };
1583 seams
1584 .lock()
1585 .expect("callgraph refresh test seam mutex poisoned")
1586 .get(project_root)
1587 .copied()
1588 .unwrap_or_default()
1589}
1590
1591fn note_refresh_worker_batch_for_test(project_root: &Path) {
1592 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1593 if let Some(seam) = seams
1594 .lock()
1595 .expect("callgraph refresh test seam mutex poisoned")
1596 .get_mut(project_root)
1597 {
1598 seam.worker_calls += 1;
1599 }
1600 }
1601}
1602
1603fn note_refresh_worker_call_for_test(project_root: &Path) {
1604 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1605 if let Some(seam) = seams
1606 .lock()
1607 .expect("callgraph refresh test seam mutex poisoned")
1608 .get_mut(project_root)
1609 {
1610 seam.refresh_calls += 1;
1611 }
1612 }
1613}
1614
1615fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
1616 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1617 if let Some(seam) = seams
1618 .lock()
1619 .expect("callgraph refresh test seam mutex poisoned")
1620 .get_mut(project_root)
1621 {
1622 seam.stale_marks += 1;
1623 }
1624 }
1625}
1626
1627#[doc(hidden)]
1628pub fn set_callgraph_refresh_worker_test_seam(
1629 project_root: PathBuf,
1630 delay: Duration,
1631 fail_refresh: bool,
1632) {
1633 REFRESH_WORKER_TEST_SEAMS
1634 .get_or_init(|| Mutex::new(HashMap::new()))
1635 .lock()
1636 .expect("callgraph refresh test seam mutex poisoned")
1637 .insert(
1638 project_root,
1639 RefreshWorkerTestSeam {
1640 delay,
1641 fail_refresh,
1642 ..RefreshWorkerTestSeam::default()
1643 },
1644 );
1645}
1646
1647#[doc(hidden)]
1648pub fn set_callgraph_refresh_worker_test_open_failure(project_root: PathBuf, enabled: bool) {
1649 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1650 if let Some(seam) = seams
1651 .lock()
1652 .expect("callgraph refresh test seam mutex poisoned")
1653 .get_mut(&project_root)
1654 {
1655 seam.fail_open = enabled;
1656 }
1657 }
1658}
1659
1660#[doc(hidden)]
1661pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
1662 let seam = refresh_worker_test_seam(project_root);
1663 (seam.refresh_calls, seam.stale_marks)
1664}
1665
1666#[doc(hidden)]
1667pub fn callgraph_refresh_worker_test_worker_calls(project_root: &Path) -> usize {
1668 refresh_worker_test_seam(project_root).worker_calls
1669}
1670
1671#[doc(hidden)]
1672pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
1673 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1674 seams
1675 .lock()
1676 .expect("callgraph refresh test seam mutex poisoned")
1677 .remove(project_root);
1678 }
1679}
1680
1681#[derive(Debug)]
1682pub struct CallGraphStore {
1683 project_root: PathBuf,
1684 project_key: String,
1685 sqlite_path: PathBuf,
1689 publication_dir: PathBuf,
1693 legacy_fallback: bool,
1697 generation: Option<String>,
1702 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1703 read_marker: Option<crate::root_cache::ReadMarker>,
1704 database_ready: AtomicBool,
1707 write_metrics: Arc<CallgraphWriteMetrics>,
1708 conn: Mutex<Connection>,
1709}
1710
1711#[derive(Debug)]
1712pub struct ReadonlyCallGraphStore {
1713 inner: CallGraphStore,
1714}
1715
1716pub trait CallGraphRead {
1717 fn project_root(&self) -> &Path;
1718 fn project_key(&self) -> &str;
1719 fn sqlite_path(&self) -> &Path;
1720 fn is_current(&self) -> bool;
1721 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
1722 fn indexed_file_count(&self) -> Result<usize>;
1723 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
1724 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
1725 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
1726 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
1727 fn direct_callers_for_symbols(
1728 &self,
1729 targets: &[(String, String)],
1730 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
1731 targets
1732 .iter()
1733 .cloned()
1734 .map(|target| {
1735 let callers = self.direct_callers_of(Path::new(&target.0), &target.1)?;
1736 Ok((target, callers))
1737 })
1738 .collect()
1739 }
1740 fn direct_caller_counts_of(
1741 &self,
1742 targets: &[(String, String)],
1743 ) -> Result<HashMap<(String, String), usize>>;
1744 fn outgoing_calls_for_symbols(
1745 &self,
1746 sources: &[(String, String)],
1747 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
1748 fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
1749 -> Result<StoreCallersResult>;
1750 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
1751 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1752 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1753 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
1754 fn call_tree(
1755 &self,
1756 file_rel: &Path,
1757 symbol: &str,
1758 depth: usize,
1759 ) -> Result<callgraph::CallTreeNode>;
1760 fn trace_to(
1761 &self,
1762 file_rel: &Path,
1763 symbol: &str,
1764 max_depth: usize,
1765 ) -> Result<callgraph::TraceToResult>;
1766 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
1767 fn trace_to_symbol(
1768 &self,
1769 file_rel: &Path,
1770 symbol: &str,
1771 to_symbol: &str,
1772 to_file: Option<&Path>,
1773 max_depth: usize,
1774 ) -> Result<callgraph::TraceToSymbolResult>;
1775}
1776
1777#[derive(Debug, Clone, PartialEq, Eq)]
1778enum OpenRootRepair {
1779 None,
1780 ReRooted,
1781 NeedsRebuild {
1782 previous_roots: Vec<String>,
1783 current_root: String,
1784 reason: String,
1785 },
1786}
1787
1788struct OpenedStore {
1789 store: CallGraphStore,
1790 root_repair: OpenRootRepair,
1791}
1792
1793#[derive(Clone, Debug)]
1794struct LegacyCallgraphPartition {
1795 harness: String,
1796 dir: PathBuf,
1797 key: String,
1798 bytes: u64,
1799 freshness: Option<SystemTime>,
1800}
1801
1802#[derive(Clone, Debug)]
1803struct LegacyCallgraphTarget {
1804 partition: LegacyCallgraphPartition,
1805 sqlite_path: PathBuf,
1806 generation: Option<String>,
1807 source_bytes: u64,
1808 source_blake3: String,
1809}
1810
1811#[derive(Clone, Debug)]
1812struct SourceFingerprint {
1813 bytes: u64,
1814 blake3: String,
1815}
1816
1817#[derive(Clone, Debug)]
1818struct PublishedLegacyMigration {
1819 generation: String,
1820 migrated_bytes: u64,
1821}
1822
1823#[derive(Debug, Clone)]
1824pub struct ColdBuildStats {
1825 pub files: usize,
1826 pub nodes: usize,
1827 pub refs: usize,
1828 pub edges: usize,
1829 pub failed_files: Vec<String>,
1830 pub elapsed_ms: u128,
1831}
1832
1833#[derive(Debug, Clone)]
1834pub struct IncrementalStats {
1835 pub changed_files: Vec<String>,
1836 pub surface_changed: Vec<String>,
1837 pub deleted_files: Vec<String>,
1838 pub dependency_selected_refs: usize,
1839 pub refreshed_own_files: usize,
1840 pub unchanged_extract_files: usize,
1841}
1842
1843#[doc(hidden)]
1845#[derive(Debug, Clone, Default, PartialEq, Eq)]
1846pub struct RefreshFilesProfile {
1847 pub parse: Duration,
1848 pub dependency_selection: Duration,
1849 pub row_deletes: Duration,
1850 pub row_inserts: Duration,
1851 pub dependent_parse: Duration,
1852 pub index_load: Duration,
1853 pub ref_resolution: Duration,
1854 pub method_dispatch: Duration,
1855 pub commit: Duration,
1856 pub total: Duration,
1857}
1858
1859impl RefreshFilesProfile {
1860 pub fn report(&self) -> String {
1861 format!(
1862 "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",
1863 self.parse.as_millis(),
1864 self.dependency_selection.as_millis(),
1865 self.row_deletes.as_millis(),
1866 self.row_inserts.as_millis(),
1867 self.dependent_parse.as_millis(),
1868 self.index_load.as_millis(),
1869 self.ref_resolution.as_millis(),
1870 self.method_dispatch.as_millis(),
1871 self.commit.as_millis(),
1872 self.total.as_millis(),
1873 )
1874 }
1875}
1876
1877#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1878pub struct StoredEdge {
1879 pub source_file: String,
1880 pub source_symbol: String,
1881 pub target_file: String,
1882 pub target_symbol: String,
1883 pub kind: String,
1884 pub line: u32,
1885}
1886
1887#[derive(Debug, Clone, PartialEq, Eq)]
1888pub struct StoreNode {
1889 node_id: String,
1890 pub file: String,
1891 pub symbol: String,
1892 pub name: String,
1893 pub kind: String,
1894 pub line: u32,
1895 pub end_line: u32,
1896 pub signature: Option<String>,
1897 pub exported: bool,
1898 pub is_entry_point: bool,
1899 pub lang: LangId,
1900}
1901
1902#[cfg(test)]
1903impl StoreNode {
1904 pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
1905 Self {
1906 node_id: format!("{file}:{symbol}"),
1907 file: file.to_string(),
1908 symbol: symbol.to_string(),
1909 name: symbol.to_string(),
1910 kind: "function".to_string(),
1911 line: 1,
1912 end_line: 1,
1913 signature: None,
1914 exported: is_entry_point,
1915 is_entry_point,
1916 lang: LangId::TypeScript,
1917 }
1918 }
1919}
1920
1921#[derive(Debug, Clone, PartialEq, Eq)]
1922pub struct StoreCallSite {
1923 pub caller: StoreNode,
1924 pub target_file: String,
1925 pub target_symbol: String,
1926 pub target: Option<StoreNode>,
1927 pub line: u32,
1928 pub byte_start: usize,
1929 pub byte_end: usize,
1930 pub resolved: bool,
1931 pub provenance: String,
1932}
1933
1934impl StoreCallSite {
1935 pub fn approximate(&self) -> bool {
1936 self.provenance == PROVENANCE_NAME_MATCH
1937 }
1938
1939 pub fn resolved_by(&self) -> &str {
1940 &self.provenance
1941 }
1942
1943 pub fn supplemental_resolution(&self) -> Option<&str> {
1944 match self.provenance.as_str() {
1945 PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
1946 _ => None,
1947 }
1948 }
1949}
1950
1951#[derive(Debug, Clone, PartialEq, Eq)]
1952pub struct StoreUnresolvedCall {
1953 pub caller: StoreNode,
1954 pub symbol: String,
1955 pub full_ref: Option<String>,
1956 pub line: u32,
1957 pub byte_start: usize,
1958 pub byte_end: usize,
1959}
1960
1961#[derive(Debug, Clone, PartialEq, Eq)]
1962pub struct StoreCallersResult {
1963 pub target: StoreNode,
1964 pub callers: Vec<StoreCallSite>,
1965 pub scanned_files: usize,
1966 pub depth_limited: bool,
1967 pub truncated: usize,
1968}
1969
1970#[derive(Debug, Clone, PartialEq, Eq)]
1971pub struct StoreImpactCaller {
1972 pub site: StoreCallSite,
1973 pub signature: Option<String>,
1974 pub is_entry_point: bool,
1975 pub call_expression: Option<String>,
1976 pub parameters: Vec<String>,
1977}
1978
1979#[derive(Debug, Clone, PartialEq, Eq)]
1980pub struct StoreImpactResult {
1981 pub target: StoreNode,
1982 pub parameters: Vec<String>,
1983 pub callers: Vec<StoreImpactCaller>,
1984 pub depth_limited: bool,
1985 pub truncated: usize,
1986}
1987
1988#[derive(Debug, Clone)]
1989struct ExtractFailure {
1990 rel_path: String,
1991 freshness: Option<FileFreshness>,
1992}
1993
1994#[derive(Debug, Clone)]
1995struct BuildExtractsResult {
1996 extracts: Vec<FileExtract>,
1997 failures: Vec<ExtractFailure>,
1998}
1999
2000#[derive(Debug, Clone)]
2001enum StoreForwardCall {
2002 Resolved(StoreCallSite),
2003 Unresolved(StoreUnresolvedCall),
2004}
2005
2006impl StoreForwardCall {
2007 fn byte_start(&self) -> usize {
2008 match self {
2009 Self::Resolved(site) => site.byte_start,
2010 Self::Unresolved(call) => call.byte_start,
2011 }
2012 }
2013
2014 fn line(&self) -> u32 {
2015 match self {
2016 Self::Resolved(site) => site.line,
2017 Self::Unresolved(call) => call.line,
2018 }
2019 }
2020}
2021
2022#[derive(Debug, Clone)]
2023struct FileExtract {
2024 rel_path: String,
2025 freshness: FileFreshness,
2026 lang: LangId,
2027 data: FileCallData,
2028 nodes: Vec<NodeRecord>,
2029 raw_refs: Vec<RawRef>,
2030 dispatch_hints: Vec<DispatchHint>,
2031 surface_fingerprint: String,
2032}
2033
2034#[derive(Debug, Clone)]
2035struct NodeRecord {
2036 id: String,
2037 file_path: String,
2038 name: String,
2039 scoped_name: String,
2040 kind: String,
2041 range: Range,
2042 range_ordinal: u32,
2043 signature: Option<String>,
2044 exported: bool,
2045 is_default_export: bool,
2046 is_type_like: bool,
2047 is_callgraph_entry_point: bool,
2048}
2049
2050#[derive(Debug, Clone)]
2051struct RawRef {
2052 ref_id: String,
2053 caller_node: Option<String>,
2054 caller_symbol: Option<String>,
2055 caller_file: String,
2056 kind: String,
2057 short_name: Option<String>,
2058 full_ref: Option<String>,
2059 module_path: Option<String>,
2060 import_kind: Option<String>,
2061 local_name: Option<String>,
2062 requested_name: Option<String>,
2063 namespace_alias: Option<String>,
2064 wildcard: bool,
2065 line: u32,
2066 byte_start: usize,
2067 byte_end: usize,
2068 dependencies: BTreeSet<String>,
2069}
2070
2071#[derive(Debug)]
2075struct StagedRef {
2076 rowid: u64,
2077 raw: RawRef,
2078}
2079
2080#[derive(Debug, Clone)]
2081struct ResolvedRef {
2082 raw: RawRef,
2083 status: String,
2084 target_node: Option<String>,
2085 target_file: Option<String>,
2086 target_symbol: Option<String>,
2087 dependencies: BTreeSet<String>,
2088 edge: Option<EdgeRecord>,
2089}
2090
2091#[derive(Debug, Clone)]
2092struct EdgeRecord {
2093 edge_id: String,
2094 source_node: String,
2095 target_node: Option<String>,
2096 target_file: String,
2097 target_symbol: String,
2098 kind: String,
2099 line: u32,
2100}
2101
2102#[derive(Debug, Clone)]
2103struct DispatchHint {
2104 id: String,
2105 method_name: String,
2106 caller_node: String,
2107 file: String,
2108 line: u32,
2109 byte_start: usize,
2110 byte_end: usize,
2111}
2112
2113#[derive(Debug, Clone)]
2114struct NameMatchRef {
2115 ref_id: String,
2116 caller_node: String,
2117 caller_file: String,
2118 caller_symbol: String,
2119 caller_signature: Option<String>,
2120 receiver_expression: String,
2121 receiver: String,
2122 method_name: String,
2123 colon_dispatch: bool,
2124 line: u32,
2125 lang: String,
2126}
2127
2128#[derive(Debug, Clone)]
2129struct NameMatchCandidate {
2130 node_id: String,
2131 file_path: String,
2132 scoped_name: String,
2133 kind: String,
2134 start_line: u32,
2136}
2137
2138#[derive(Debug, Clone)]
2139struct FileRow {
2140 surface_fingerprint: String,
2141 freshness: FileFreshness,
2142}
2143
2144#[derive(Debug, Clone)]
2145struct DbFileIndex {
2146 lang: Option<LangId>,
2147 exports: HashSet<String>,
2148 default_export: Option<String>,
2149 export_aliases: HashMap<String, String>,
2150 node_by_scoped: HashMap<String, String>,
2151 node_by_bare: HashMap<String, String>,
2152 node_kind_by_id: HashMap<String, String>,
2153 module_targets: HashMap<String, Option<String>>,
2154 reexports: Vec<ReexportIndex>,
2155}
2156
2157#[derive(Debug, Clone)]
2158struct ReexportIndex {
2159 target_file: Option<String>,
2160 named: HashMap<String, String>,
2161 wildcard: bool,
2162}
2163
2164#[derive(Debug, Clone)]
2165struct ProjectIndex<'a> {
2166 project_root: PathBuf,
2167 files: HashMap<String, DbFileIndex>,
2168 caller_data: HashMap<String, &'a FileCallData>,
2169 workspace_crate_prefixes: WorkspaceCratePrefixCache,
2174}
2175
2176trait ResolverIndex {
2180 fn caller_data(&self, file: &str) -> Option<&FileCallData>;
2181 fn lang_for(&self, file: &str) -> Option<LangId>;
2182 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String>;
2183 fn reexports_for(&self, file: &str) -> Vec<ReexportIndex>;
2184 fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String>;
2185 fn node_is_callable(&self, file: &str, node_id: &str) -> bool;
2186 fn export_alias(&self, file: &str, symbol: &str) -> Option<String>;
2187 fn has_export(&self, file: &str, symbol: &str) -> bool;
2188 fn default_export(&self, file: &str) -> Option<String>;
2189 fn contains_file(&self, file: &str) -> bool;
2190 fn crate_src_prefix(&self, crate_name: &str) -> Option<String>;
2191 fn inline_scoped_target(
2192 &self,
2193 caller_file: &str,
2194 module_segments: &[String],
2195 short_name: &str,
2196 ) -> Option<(String, String)>;
2197}
2198
2199impl ResolverIndex for ProjectIndex<'_> {
2200 fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2201 self.caller_data.get(file).copied()
2202 }
2203
2204 fn lang_for(&self, file: &str) -> Option<LangId> {
2205 self.lang_for(file)
2206 }
2207
2208 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2209 self.module_target(caller_file, module_path)
2210 }
2211
2212 fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2213 self.reexports_for(file).to_vec()
2214 }
2215
2216 fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2217 self.node_for_symbol(file, symbol)
2218 }
2219
2220 fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2221 self.node_is_callable(file, node_id)
2222 }
2223
2224 fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2225 self.files
2226 .get(file)
2227 .and_then(|item| item.export_aliases.get(symbol))
2228 .cloned()
2229 }
2230
2231 fn has_export(&self, file: &str, symbol: &str) -> bool {
2232 self.files
2233 .get(file)
2234 .is_some_and(|item| item.exports.contains(symbol))
2235 }
2236
2237 fn default_export(&self, file: &str) -> Option<String> {
2238 self.files
2239 .get(file)
2240 .and_then(|item| item.default_export.clone())
2241 }
2242
2243 fn contains_file(&self, file: &str) -> bool {
2244 self.files.contains_key(file)
2245 }
2246
2247 fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2248 self.workspace_crate_prefixes
2249 .0
2250 .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
2251 .get(crate_name)
2252 .cloned()
2253 }
2254
2255 fn inline_scoped_target(
2256 &self,
2257 caller_file: &str,
2258 module_segments: &[String],
2259 short_name: &str,
2260 ) -> Option<(String, String)> {
2261 let src_prefix = rust_src_prefix(caller_file);
2262 let mut file_paths = self.files.keys().cloned().collect::<Vec<_>>();
2263 file_paths.sort();
2264 if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
2265 let caller = file_paths.remove(position);
2266 file_paths.insert(0, caller);
2267 }
2268 for file_path in file_paths {
2269 if self.lang_for(&file_path) != Some(LangId::Rust)
2270 || rust_src_prefix(&file_path) != src_prefix
2271 {
2272 continue;
2273 }
2274 let file_module_segments = rust_module_segments_for_rel(&file_path);
2275 if !module_segments.starts_with(&file_module_segments) {
2276 continue;
2277 }
2278 let scoped_segments = &module_segments[file_module_segments.len()..];
2279 if scoped_segments.is_empty() {
2280 continue;
2281 }
2282 let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2283 if self.node_for_symbol(&file_path, &scoped_symbol).is_some() {
2284 return Some((file_path, scoped_symbol));
2285 }
2286 }
2287 None
2288 }
2289}
2290
2291struct DiskProjectIndex<'a> {
2295 project_root: &'a Path,
2296 conn: &'a Connection,
2297 caller_file: &'a str,
2298 caller_data: &'a FileCallData,
2299 workspace_crate_prefixes: WorkspaceCratePrefixCache,
2300}
2301
2302impl DiskProjectIndex<'_> {
2303 fn file_index(&self, rel_path: &str) -> Option<DbFileIndex> {
2304 let lang: String = self
2305 .conn
2306 .query_row(
2307 "SELECT lang FROM files WHERE path = ?1",
2308 params![rel_path],
2309 |row| row.get(0),
2310 )
2311 .optional()
2312 .ok()??;
2313 let mut index = DbFileIndex {
2314 lang: lang_from_label(&lang),
2315 exports: HashSet::new(),
2316 default_export: None,
2317 export_aliases: HashMap::new(),
2318 node_by_scoped: HashMap::new(),
2319 node_by_bare: HashMap::new(),
2320 node_kind_by_id: HashMap::new(),
2321 module_targets: HashMap::new(),
2322 reexports: Vec::new(),
2323 };
2324 let mut nodes = self
2325 .conn
2326 .prepare(
2327 "SELECT id, name, scoped_name, kind, exported, is_default_export
2328 FROM nodes WHERE file_path = ?1",
2329 )
2330 .ok()?;
2331 let rows = nodes
2332 .query_map(params![rel_path], |row| {
2333 Ok((
2334 row.get::<_, String>(0)?,
2335 row.get::<_, String>(1)?,
2336 row.get::<_, String>(2)?,
2337 row.get::<_, String>(3)?,
2338 row.get::<_, i64>(4)? != 0,
2339 row.get::<_, i64>(5)? != 0,
2340 ))
2341 })
2342 .ok()?
2343 .collect::<std::result::Result<Vec<_>, _>>()
2344 .ok()?;
2345 drop(nodes);
2346 for (id, name, scoped_name, kind, exported, is_default_export) in rows {
2347 if exported {
2348 index.exports.insert(name.clone());
2349 index.exports.insert(scoped_name.clone());
2350 }
2351 if is_default_export {
2352 index.default_export = Some(scoped_name.clone());
2353 }
2354 index.node_by_scoped.insert(scoped_name, id.clone());
2355 index.node_by_bare.entry(name).or_insert(id.clone());
2356 index.node_kind_by_id.insert(id, kind);
2357 }
2358
2359 let mut refs = self
2360 .conn
2361 .prepare(
2362 "SELECT ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name
2363 FROM refs
2364 WHERE caller_file = ?1 AND kind IN ('import', 'reexport', 'export_alias')",
2365 )
2366 .ok()?;
2367 let rows = refs
2368 .query_map(params![rel_path], |row| {
2369 Ok((
2370 row.get::<_, String>(0)?,
2371 row.get::<_, String>(1)?,
2372 row.get::<_, Option<String>>(2)?,
2373 row.get::<_, Option<String>>(3)?,
2374 row.get::<_, i64>(4)? != 0,
2375 row.get::<_, Option<String>>(5)?,
2376 row.get::<_, Option<String>>(6)?,
2377 ))
2378 })
2379 .ok()?
2380 .collect::<std::result::Result<Vec<_>, _>>()
2381 .ok()?;
2382 drop(refs);
2383 for (ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name) in rows {
2384 if kind == "export_alias" {
2385 if let (Some(exported), Some(source)) = (local_name, requested_name) {
2386 index.export_aliases.insert(exported, source);
2387 }
2388 continue;
2389 }
2390 let Some(module_path) = module_path else {
2391 continue;
2392 };
2393 let target_file = self.disk_module_target(rel_path, &module_path).or_else(|| {
2394 self.conn
2395 .query_row(
2396 "SELECT d.dep_file
2397 FROM file_dependencies d
2398 JOIN files f ON f.path = d.dep_file
2399 WHERE d.file_path = ?1
2400 ORDER BY d.dep_file
2401 LIMIT 1",
2402 params![rel_path],
2403 |row| row.get::<_, String>(0),
2404 )
2405 .optional()
2406 .ok()
2407 .flatten()
2408 });
2409 index
2410 .module_targets
2411 .entry(module_path.clone())
2412 .or_insert_with(|| target_file.clone());
2413 if kind == "reexport" {
2414 let raw = RawRef {
2415 ref_id,
2416 caller_node: None,
2417 caller_symbol: None,
2418 caller_file: rel_path.to_string(),
2419 kind,
2420 short_name: None,
2421 full_ref,
2422 module_path: Some(module_path),
2423 import_kind: Some("reexport".to_string()),
2424 local_name: None,
2425 requested_name: None,
2426 namespace_alias: None,
2427 wildcard,
2428 line: 0,
2429 byte_start: 0,
2430 byte_end: 0,
2431 dependencies: BTreeSet::new(),
2432 };
2433 index
2434 .reexports
2435 .push(reexport_index_from_raw(&raw, target_file));
2436 }
2437 }
2438 Some(index)
2439 }
2440
2441 fn disk_module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2442 let caller_dir = self.project_root.join(caller_file).parent()?.to_path_buf();
2443 let candidate = callgraph::resolve_module_path(&caller_dir, module_path)?;
2444 let rel_path = relative_path(self.project_root, &canonicalize_path(&candidate));
2445 self.contains_file(&rel_path).then_some(rel_path)
2446 }
2447}
2448
2449impl ResolverIndex for DiskProjectIndex<'_> {
2450 fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2451 (file == self.caller_file).then_some(self.caller_data)
2452 }
2453
2454 fn lang_for(&self, file: &str) -> Option<LangId> {
2455 self.file_index(file).and_then(|index| index.lang)
2456 }
2457
2458 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2459 self.file_index(caller_file)
2460 .and_then(|index| index.module_targets.get(module_path).cloned().flatten())
2461 }
2462
2463 fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2464 self.file_index(file)
2465 .map(|index| index.reexports)
2466 .unwrap_or_default()
2467 }
2468
2469 fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2470 self.file_index(file).and_then(|index| {
2471 index
2472 .node_by_scoped
2473 .get(symbol)
2474 .cloned()
2475 .or_else(|| index.node_by_bare.get(symbol).cloned())
2476 })
2477 }
2478
2479 fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2480 self.file_index(file)
2481 .and_then(|index| index.node_kind_by_id.get(node_id).cloned())
2482 .is_some_and(|kind| matches!(kind.as_str(), "function" | "method"))
2483 }
2484
2485 fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2486 self.file_index(file)
2487 .and_then(|index| index.export_aliases.get(symbol).cloned())
2488 }
2489
2490 fn has_export(&self, file: &str, symbol: &str) -> bool {
2491 self.file_index(file)
2492 .is_some_and(|index| index.exports.contains(symbol))
2493 }
2494
2495 fn default_export(&self, file: &str) -> Option<String> {
2496 self.file_index(file).and_then(|index| index.default_export)
2497 }
2498
2499 fn contains_file(&self, file: &str) -> bool {
2500 self.conn
2501 .query_row(
2502 "SELECT 1 FROM files WHERE path = ?1 LIMIT 1",
2503 params![file],
2504 |_| Ok(()),
2505 )
2506 .is_ok()
2507 }
2508
2509 fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2510 self.workspace_crate_prefixes
2511 .0
2512 .get_or_init(|| build_workspace_crate_prefixes(self.project_root))
2513 .get(crate_name)
2514 .cloned()
2515 }
2516
2517 fn inline_scoped_target(
2518 &self,
2519 caller_file: &str,
2520 module_segments: &[String],
2521 short_name: &str,
2522 ) -> Option<(String, String)> {
2523 let src_prefix = rust_src_prefix(caller_file);
2524 let check = |file_path: String| {
2525 let file_module_segments = rust_module_segments_for_rel(&file_path);
2526 if rust_src_prefix(&file_path) != src_prefix
2527 || !module_segments.starts_with(&file_module_segments)
2528 {
2529 return None;
2530 }
2531 let scoped_segments = &module_segments[file_module_segments.len()..];
2532 if scoped_segments.is_empty() {
2533 return None;
2534 }
2535 let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2536 self.node_for_symbol(&file_path, &scoped_symbol)
2537 .map(|_| (file_path, scoped_symbol))
2538 };
2539 if let Some(target) = check(caller_file.to_string()) {
2540 return Some(target);
2541 }
2542 let mut statement = self
2543 .conn
2544 .prepare("SELECT path FROM files WHERE lang = 'rust' AND path <> ?1 ORDER BY path")
2545 .ok()?;
2546 let rows = statement
2547 .query_map(params![caller_file], |row| row.get::<_, String>(0))
2548 .ok()?;
2549 for path in rows.flatten() {
2550 if let Some(target) = check(path) {
2551 return Some(target);
2552 }
2553 }
2554 None
2555 }
2556}
2557
2558impl CallGraphStore {
2559 pub fn open_if_enabled(
2560 options: CallGraphStoreOptions,
2561 callgraph_dir: PathBuf,
2562 project_root: PathBuf,
2563 ) -> Result<Option<Self>> {
2564 if !options.enabled {
2565 return Ok(None);
2566 }
2567 Self::open(callgraph_dir, project_root).map(Some)
2568 }
2569
2570 pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
2571 let project_key = crate::search_index::artifact_cache_key(&project_root);
2572 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2573 else {
2574 return Err(CallGraphStoreError::Unavailable(
2575 "writer capability denied; use the read-only callgraph opener".to_string(),
2576 ));
2577 };
2578 std::fs::create_dir_all(&callgraph_dir)?;
2579 let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
2583 .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
2584 let OpenedStore { store, root_repair } = Self::open_at_path(
2585 project_root.clone(),
2586 project_key,
2587 sqlite_path,
2588 generation,
2589 true,
2590 Some(Arc::clone(&writer_lease)),
2591 None,
2592 )?;
2593 match root_repair {
2594 OpenRootRepair::NeedsRebuild { .. } => {
2595 log_root_repair_rebuild(&root_repair);
2596 drop(store);
2597 drop(writer_lease);
2598 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2599 let (store, _stats) =
2600 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2601 Ok(store)
2602 }
2603 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
2604 }
2605 }
2606
2607 pub fn open_readonly(
2608 callgraph_dir: PathBuf,
2609 project_root: PathBuf,
2610 ) -> Result<Option<ReadonlyCallGraphStore>> {
2611 let project_key = crate::search_index::artifact_cache_key(&project_root);
2612 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2613 {
2614 let conn = open_readonly_connection(&sqlite_path)?;
2615 if !database_ready(&conn).unwrap_or(false) {
2616 return Ok(None);
2617 }
2618 let marker_label = generation.as_deref().unwrap_or("legacy");
2619 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
2620 return Ok(Some(ReadonlyCallGraphStore::from_inner(
2621 Self::from_connection(
2622 project_root,
2623 project_key,
2624 sqlite_path,
2625 callgraph_dir,
2626 false,
2627 generation,
2628 None,
2629 Some(read_marker),
2630 conn,
2631 ),
2632 )));
2633 }
2634
2635 let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
2636 return Ok(None);
2637 };
2638 crate::slog_warn!(
2639 "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
2640 target.partition.harness,
2641 target.sqlite_path.display()
2642 );
2643 let conn = open_readonly_connection(&target.sqlite_path)?;
2644 if !database_ready(&conn).unwrap_or(false) {
2645 return Ok(None);
2646 }
2647 let marker_label =
2648 legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
2649 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
2650 Ok(Some(ReadonlyCallGraphStore::from_inner(
2651 Self::from_connection(
2652 project_root,
2653 project_key,
2654 target.sqlite_path,
2655 callgraph_dir,
2656 true,
2657 target.generation,
2658 None,
2659 Some(read_marker),
2660 conn,
2661 ),
2662 )))
2663 }
2664
2665 pub fn open_ready_repairing(
2671 callgraph_dir: PathBuf,
2672 project_root: PathBuf,
2673 ) -> Result<Option<Self>> {
2674 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true)
2675 }
2676
2677 pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
2681 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false)
2682 }
2683
2684 pub fn open_ready_no_rebuild(
2685 callgraph_dir: PathBuf,
2686 project_root: PathBuf,
2687 ) -> Result<Option<Self>> {
2688 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true)
2689 }
2690
2691 fn open_ready_with_rebuild_policy(
2692 callgraph_dir: PathBuf,
2693 project_root: PathBuf,
2694 allow_cold_build: bool,
2695 allow_root_repair: bool,
2696 ) -> Result<Option<Self>> {
2697 let project_key = crate::search_index::artifact_cache_key(&project_root);
2698 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2699 else {
2700 return Ok(None);
2701 };
2702 let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2703 else {
2704 return Ok(None);
2705 };
2706 let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
2707 project_root.clone(),
2708 project_key.clone(),
2709 sqlite_path,
2710 generation,
2711 true,
2712 Some(Arc::clone(&writer_lease)),
2713 None,
2714 allow_root_repair,
2715 )?;
2716 match root_repair {
2717 OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
2718 log_root_repair_rebuild(&root_repair);
2719 drop(store);
2720 drop(writer_lease);
2721 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2722 let (store, _stats) =
2723 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2724 Ok(Some(store))
2725 }
2726 OpenRootRepair::NeedsRebuild { .. } => {
2727 if let Some(message) = note_repair_entry(&project_key) {
2728 crate::slog_warn!("{message}");
2729 }
2730 Ok(None)
2731 }
2732 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2733 }
2734 }
2735
2736 pub fn cold_build_with_lease(
2737 callgraph_dir: PathBuf,
2738 project_root: PathBuf,
2739 files: &[PathBuf],
2740 ) -> Result<(Self, ColdBuildStats)> {
2741 Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
2742 }
2743
2744 pub fn cold_build_with_lease_chunked(
2745 callgraph_dir: PathBuf,
2746 project_root: PathBuf,
2747 files: &[PathBuf],
2748 chunk_size: usize,
2749 ) -> Result<(Self, ColdBuildStats)> {
2750 Self::cold_build_with_lease_chunked_inner(
2751 callgraph_dir,
2752 project_root,
2753 files,
2754 chunk_size,
2755 false,
2756 )
2757 }
2758
2759 pub(crate) fn force_cold_build_with_lease_chunked(
2760 callgraph_dir: PathBuf,
2761 project_root: PathBuf,
2762 files: &[PathBuf],
2763 chunk_size: usize,
2764 ) -> Result<(Self, ColdBuildStats)> {
2765 Self::cold_build_with_lease_chunked_inner(
2766 callgraph_dir,
2767 project_root,
2768 files,
2769 chunk_size,
2770 true,
2771 )
2772 }
2773
2774 fn cold_build_with_lease_chunked_inner(
2775 callgraph_dir: PathBuf,
2776 project_root: PathBuf,
2777 files: &[PathBuf],
2778 chunk_size: usize,
2779 require_new_publication: bool,
2780 ) -> Result<(Self, ColdBuildStats)> {
2781 let project_key = crate::search_index::artifact_cache_key(&project_root);
2782 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2783 else {
2784 let operation = if require_new_publication {
2785 "forced rebuild"
2786 } else {
2787 "cold build"
2788 };
2789 return Err(CallGraphStoreError::Unavailable(format!(
2790 "{operation} could not acquire writer capability"
2791 )));
2792 };
2793 std::fs::create_dir_all(&callgraph_dir)?;
2794 let (stats, generation) = Self::cold_build_publish_locked(
2795 &callgraph_dir,
2796 &project_root,
2797 &project_key,
2798 files,
2799 chunk_size,
2800 Arc::clone(&writer_lease),
2801 )?;
2802 let store = Self::open_generation(
2803 &callgraph_dir,
2804 project_root,
2805 project_key,
2806 generation,
2807 writer_lease,
2808 )?;
2809 Ok((store, stats))
2810 }
2811
2812 pub fn ensure_built_with_lease(
2813 callgraph_dir: PathBuf,
2814 project_root: PathBuf,
2815 files: &[PathBuf],
2816 ) -> Result<(Self, Option<ColdBuildStats>)> {
2817 Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
2818 }
2819
2820 pub fn ensure_built_with_lease_chunked(
2821 callgraph_dir: PathBuf,
2822 project_root: PathBuf,
2823 files: &[PathBuf],
2824 chunk_size: usize,
2825 ) -> Result<(Self, Option<ColdBuildStats>)> {
2826 let project_key = crate::search_index::artifact_cache_key(&project_root);
2827 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2828 else {
2829 return Err(CallGraphStoreError::Unavailable(
2830 "callgraph ensure could not acquire writer capability".to_string(),
2831 ));
2832 };
2833 std::fs::create_dir_all(&callgraph_dir)?;
2834 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2835 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2842 {
2843 let OpenedStore { store, root_repair } = Self::open_at_path(
2844 project_root.clone(),
2845 project_key.clone(),
2846 sqlite_path,
2847 generation,
2848 true,
2849 Some(Arc::clone(&writer_lease)),
2850 None,
2851 )?;
2852 match root_repair {
2853 OpenRootRepair::NeedsRebuild { .. } => {
2854 log_root_repair_rebuild(&root_repair);
2855 drop(store);
2856 let (stats, generation) = Self::cold_build_publish_locked(
2857 &callgraph_dir,
2858 &project_root,
2859 &project_key,
2860 files,
2861 chunk_size,
2862 Arc::clone(&writer_lease),
2863 )?;
2864 let store = Self::open_generation(
2865 &callgraph_dir,
2866 project_root,
2867 project_key,
2868 generation,
2869 writer_lease,
2870 )?;
2871 return Ok((store, Some(stats)));
2872 }
2873 OpenRootRepair::None | OpenRootRepair::ReRooted => {
2874 return Ok((store, None));
2875 }
2876 }
2877 }
2878 if let Some(store) = try_legacy_migration_or_fallback(
2879 &callgraph_dir,
2880 &project_root,
2881 &project_key,
2882 Arc::clone(&writer_lease),
2883 )? {
2884 return Ok((store, None));
2885 }
2886 let (stats, generation) = Self::cold_build_publish_locked(
2887 &callgraph_dir,
2888 &project_root,
2889 &project_key,
2890 files,
2891 chunk_size,
2892 Arc::clone(&writer_lease),
2893 )?;
2894 let store = Self::open_generation(
2895 &callgraph_dir,
2896 project_root,
2897 project_key,
2898 generation,
2899 writer_lease,
2900 )?;
2901 Ok((store, Some(stats)))
2902 }
2903
2904 pub fn migrate_legacy_with_lease(
2911 callgraph_dir: PathBuf,
2912 project_root: PathBuf,
2913 ) -> Result<Option<Self>> {
2914 let project_key = crate::search_index::artifact_cache_key(&project_root);
2915 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2916 else {
2917 return Ok(None);
2918 };
2919 std::fs::create_dir_all(&callgraph_dir)?;
2920 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2921
2922 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2926 {
2927 let OpenedStore { store, root_repair } = Self::open_at_path(
2928 project_root,
2929 project_key,
2930 sqlite_path,
2931 generation,
2932 true,
2933 Some(writer_lease),
2934 None,
2935 )?;
2936 return match root_repair {
2937 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2938 OpenRootRepair::NeedsRebuild { reason, .. } => {
2939 Err(CallGraphStoreError::Unavailable(format!(
2940 "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
2941 )))
2942 }
2943 };
2944 }
2945
2946 let store = try_legacy_migration_or_fallback(
2947 &callgraph_dir,
2948 &project_root,
2949 &project_key,
2950 writer_lease,
2951 )?;
2952 Ok(store.filter(|store| !store.is_legacy_fallback()))
2956 }
2957
2958 fn cold_build_publish_locked(
2969 callgraph_dir: &Path,
2970 project_root: &Path,
2971 project_key: &str,
2972 files: &[PathBuf],
2973 chunk_size: usize,
2974 writer_lease: Arc<crate::root_cache::WriterLease>,
2975 ) -> Result<(ColdBuildStats, String)> {
2976 if let Some((previous_root, remaining)) =
2977 rebuild_cooldown_denial(callgraph_dir, project_key, project_root, Instant::now())
2978 {
2979 return Err(CallGraphStoreError::Unavailable(format!(
2980 "cache key {project_key} was rebuilt for {} too recently; retry {} ms after the per-key cooldown",
2981 previous_root.display(),
2982 remaining.as_millis()
2983 )));
2984 }
2985 let breaker = crate::build_breaker::BuildDeathBreaker::open(
2986 callgraph_dir.join("build-breaker.sqlite"),
2987 )
2988 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
2989
2990 let generation = generation_file_name(project_key);
2991 let gen_path = callgraph_dir.join(&generation);
2992 let temp_path = callgraph_dir.join(format!("{project_key}.staging.sqlite.tmp.resume"));
2996 let adopting_staging = temp_path.exists();
2997 if !adopting_staging {
2998 remove_sqlite_file_set(&temp_path);
2999 }
3000
3001 let (stats, breaker_key) = {
3002 if adopting_staging {
3003 crate::slog_info!(
3004 "resuming callgraph cold build from staged generation {}",
3005 temp_path.display()
3006 );
3007 }
3008 let temp_store = Self::open_at_path(
3009 project_root.to_path_buf(),
3010 project_key.to_string(),
3011 temp_path.clone(),
3012 None,
3013 false,
3014 Some(Arc::clone(&writer_lease)),
3015 None,
3016 )?
3017 .store;
3018 let admission_fingerprint = corpus_fingerprint_for(project_root, files)?;
3029 let breaker_key = crate::build_breaker::BreakerKey::new(
3030 project_root.display().to_string(),
3031 crate::build_breaker::BuildDomain::CallgraphCold,
3032 admission_fingerprint,
3033 );
3034 match breaker
3035 .admit(&breaker_key, 0)
3036 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?
3037 {
3038 crate::build_breaker::BreakerAdmission::Admitted(_) => {}
3039 crate::build_breaker::BreakerAdmission::Suspended(suspension) => {
3040 return Err(CallGraphStoreError::Suspended(suspension));
3041 }
3042 }
3043 let corpus_fingerprint = temp_store.stage_cold_build_file_inventory(files)?;
3044 let stats = temp_store
3045 .cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)?;
3046 let _ = temp_store.checkpoint_wal_truncate();
3047 temp_store.prepare_for_atomic_swap()?;
3048 (stats, breaker_key)
3049 };
3050
3051 notify_cold_build_before_publish_observer();
3052 let publication = publish_if_current(|| {
3053 verify_writer_lease(&writer_lease)?;
3054 remove_sqlite_file_set(&gen_path);
3057 crate::fs_lock::rename_over(&temp_path, &gen_path)?;
3058 crate::fs_lock::sync_parent(&gen_path);
3059 remove_sqlite_sidecars(&gen_path);
3060
3061 notify_cold_build_swap_observer(&temp_path, &gen_path);
3062
3063 verify_writer_lease(&writer_lease)?;
3065 publish_pointer(callgraph_dir, project_key, &generation)?;
3066 gc_old_generations(callgraph_dir, project_key, &generation);
3067 sweep_orphaned_build_temps_store_wide(callgraph_dir);
3071 if let Some(storage_root) = root_storage_dir(callgraph_dir) {
3072 let inspect_root =
3073 storage_root.join(crate::root_cache::RootCacheDomain::Inspect.as_str());
3074 let live_scope_keys = crate::root_cache::live_scope_keys_for_storage(&storage_root);
3075 crate::inspect::cache::sweep_inspect_scope_dirs(&inspect_root, &live_scope_keys);
3076 }
3077 Ok(())
3078 });
3079 if matches!(publication, Err(CallGraphStoreError::Superseded)) {
3080 remove_sqlite_file_set(&temp_path);
3081 }
3082 publication?;
3083 breaker
3086 .record_ready_publication(&breaker_key)
3087 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
3088 record_successful_rebuild(callgraph_dir, project_key, project_root, Instant::now());
3089 Ok((stats, generation))
3090 }
3091
3092 fn open_generation(
3095 callgraph_dir: &Path,
3096 project_root: PathBuf,
3097 project_key: String,
3098 generation: String,
3099 writer_lease: Arc<crate::root_cache::WriterLease>,
3100 ) -> Result<Self> {
3101 let gen_path = callgraph_dir.join(&generation);
3102 Ok(Self::open_at_path(
3103 project_root,
3104 project_key,
3105 gen_path,
3106 Some(generation),
3107 true,
3108 Some(writer_lease),
3109 None,
3110 )?
3111 .store)
3112 }
3113
3114 pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
3115 let project_key = crate::search_index::artifact_cache_key(project_root);
3116 Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
3119 }
3120
3121 pub fn cold_build_suspension(
3126 callgraph_dir: &Path,
3127 project_root: &Path,
3128 ) -> Result<Option<crate::build_breaker::BuildSuspension>> {
3129 let breaker_path = callgraph_dir.join("build-breaker.sqlite");
3130 if !breaker_path.exists() {
3131 return Ok(None);
3132 }
3133 let key = crate::build_breaker::BreakerKey::new(
3134 project_root.display().to_string(),
3135 crate::build_breaker::BuildDomain::CallgraphCold,
3136 callgraph_corpus_fingerprint(project_root)?,
3137 );
3138 crate::build_breaker::BuildDeathBreaker::open(breaker_path)
3139 .and_then(|breaker| breaker.suspension(&key))
3140 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))
3141 }
3142
3143 fn open_at_path(
3144 project_root: PathBuf,
3145 project_key: String,
3146 sqlite_path: PathBuf,
3147 generation: Option<String>,
3148 use_wal: bool,
3149 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3150 read_marker: Option<crate::root_cache::ReadMarker>,
3151 ) -> Result<OpenedStore> {
3152 Self::open_at_path_with_root_repair(
3153 project_root,
3154 project_key,
3155 sqlite_path,
3156 generation,
3157 use_wal,
3158 writer_lease,
3159 read_marker,
3160 true,
3161 )
3162 }
3163
3164 fn open_at_path_with_root_repair(
3165 project_root: PathBuf,
3166 project_key: String,
3167 sqlite_path: PathBuf,
3168 generation: Option<String>,
3169 use_wal: bool,
3170 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3171 read_marker: Option<crate::root_cache::ReadMarker>,
3172 allow_root_repair: bool,
3173 ) -> Result<OpenedStore> {
3174 if let Some(lease) = writer_lease.as_ref() {
3175 verify_writer_lease(lease)?;
3176 }
3177 if let Some(parent) = sqlite_path.parent() {
3178 std::fs::create_dir_all(parent)?;
3179 }
3180 let mut conn = Connection::open(&sqlite_path)?;
3181 if use_wal {
3182 configure_connection(&conn)?;
3183 } else {
3184 configure_build_connection(&conn)?;
3185 }
3186 if let Some(lease) = writer_lease.as_ref() {
3187 verify_writer_lease(lease)?;
3188 }
3189 initialize_schema(&conn)?;
3190 if let Some(lease) = writer_lease.as_ref() {
3191 verify_writer_lease(lease)?;
3192 }
3193 let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
3194 let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
3195 (Some(marker), _, _) => Some(marker),
3196 (None, Some(label), Some(cache_dir)) => {
3197 Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
3198 }
3199 (None, _, _) => None,
3200 };
3201 let publication_dir = sqlite_path
3202 .parent()
3203 .map(Path::to_path_buf)
3204 .unwrap_or_default();
3205 let store = Self::from_connection(
3206 project_root,
3207 project_key,
3208 sqlite_path,
3209 publication_dir,
3210 false,
3211 generation,
3212 writer_lease,
3213 read_marker,
3214 conn,
3215 );
3216 Ok(OpenedStore { store, root_repair })
3217 }
3218
3219 fn prepare_for_atomic_swap(&self) -> Result<()> {
3220 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3221 conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
3222 Ok(())
3223 }
3224
3225 fn atomic_swap_checkpoint_sql(&self) -> &'static str {
3226 let protected_reader = self.generation.as_deref().is_some_and(|generation| {
3227 self.sqlite_path
3228 .parent()
3229 .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
3230 });
3231 if protected_reader {
3232 "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
3233 } else {
3234 "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
3235 }
3236 }
3237
3238 fn from_connection(
3239 project_root: PathBuf,
3240 project_key: String,
3241 sqlite_path: PathBuf,
3242 publication_dir: PathBuf,
3243 legacy_fallback: bool,
3244 generation: Option<String>,
3245 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3246 read_marker: Option<crate::root_cache::ReadMarker>,
3247 conn: Connection,
3248 ) -> Self {
3249 let write_metrics = callgraph_write_metrics_for_key(&project_key);
3250 Self {
3251 project_root,
3252 project_key,
3253 sqlite_path,
3254 publication_dir,
3255 legacy_fallback,
3256 generation,
3257 writer_lease,
3258 read_marker,
3259 database_ready: AtomicBool::new(false),
3260 write_metrics,
3261 conn: Mutex::new(conn),
3262 }
3263 }
3264
3265 fn ensure_ready(&self, conn: &Connection) -> Result<()> {
3266 if self.database_ready.load(AtomicOrdering::Acquire) {
3267 return Ok(());
3268 }
3269 ensure_database_ready(conn)?;
3270 self.database_ready.store(true, AtomicOrdering::Release);
3271 Ok(())
3272 }
3273
3274 pub fn project_root(&self) -> &Path {
3275 &self.project_root
3276 }
3277
3278 pub fn project_key(&self) -> &str {
3279 &self.project_key
3280 }
3281
3282 pub fn sqlite_path(&self) -> &Path {
3283 &self.sqlite_path
3284 }
3285
3286 pub(crate) fn projection_generation(&self) -> Option<&str> {
3288 self.generation.as_deref()
3289 }
3290
3291 pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
3293 self.refresh_read_marker()?;
3294 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3295 self.ensure_ready(&conn)?;
3296 projection_write_revision(&conn)
3297 }
3298
3299 pub fn is_legacy_fallback(&self) -> bool {
3302 self.legacy_fallback
3303 }
3304
3305 pub(crate) fn is_legacy_migration(&self) -> bool {
3306 self.generation.as_deref().is_some_and(|generation| {
3307 migration_generation_requires_manifest(generation)
3308 && migration_manifest_valid(&self.publication_dir, generation)
3309 })
3310 }
3311
3312 pub fn writer_epoch_for_test(&self) -> Option<&str> {
3313 self.writer_lease.as_ref().map(|lease| lease.epoch())
3314 }
3315
3316 fn verify_writer_lease(&self) -> Result<()> {
3317 let Some(lease) = self.writer_lease.as_ref() else {
3318 return Err(CallGraphStoreError::Unavailable(
3319 "callgraph store opened read-only; write API is unavailable".to_string(),
3320 ));
3321 };
3322 verify_writer_lease(lease)
3323 }
3324
3325 fn refresh_read_marker(&self) -> Result<()> {
3326 if let Some(marker) = self.read_marker.as_ref() {
3327 marker.touch_if_due()?;
3328 }
3329 Ok(())
3330 }
3331
3332 fn record_commit(&self, total_changes_before: u64, conn: &Connection) {
3333 self.write_metrics
3334 .record_commit(conn.total_changes().saturating_sub(total_changes_before));
3335 }
3336
3337 fn checkpoint_wal_truncate(&self) -> bool {
3338 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3339 checkpoint_wal_truncate(&conn)
3340 }
3341
3342 pub fn is_current(&self) -> bool {
3348 let _ = self.refresh_read_marker();
3349 match (
3350 read_pointer(&self.publication_dir, &self.project_key),
3351 &self.generation,
3352 ) {
3353 (Some(_), _) if self.legacy_fallback => false,
3356 (Some(published), Some(opened)) => &published == opened,
3357 (Some(_), None) => false,
3359 (None, _) => true,
3362 }
3363 }
3364
3365 pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
3366 self.cold_build_chunked(files, COLD_BUILD_EXTRACT_BATCH_FILES)
3367 }
3368
3369 pub fn cold_build_chunked(
3373 &self,
3374 files: &[PathBuf],
3375 chunk_size: usize,
3376 ) -> Result<ColdBuildStats> {
3377 let corpus_fingerprint = self.stage_cold_build_file_inventory(files)?;
3378 self.cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)
3379 }
3380
3381 fn stage_cold_build_file_inventory(&self, files: &[PathBuf]) -> Result<String> {
3382 note_cold_build_phase("enumeration");
3383 if files.is_empty() {
3384 self.stage_cold_build_file_inventory_from(callgraph::walk_project_files(
3385 &self.project_root,
3386 ))
3387 } else {
3388 self.stage_cold_build_file_inventory_from(files.iter().cloned())
3389 }
3390 }
3391
3392 fn stage_cold_build_file_inventory_from<I>(&self, paths: I) -> Result<String>
3393 where
3394 I: IntoIterator<Item = PathBuf>,
3395 {
3396 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3397 self.verify_writer_lease()?;
3398 let total_changes_before = conn.total_changes();
3399 let tx = conn.transaction()?;
3400 tx.execute("DELETE FROM staging_file_inventory", [])?;
3401 tx.commit()?;
3402 self.record_commit(total_changes_before, &conn);
3403
3404 let mut batch = Vec::with_capacity(COLD_BUILD_EXTRACT_BATCH_FILES);
3405 for path in paths {
3406 let path = normalize_file_path(&self.project_root, &path)?;
3407 let rel_path = relative_path(&self.project_root, &path);
3408 let size = std::fs::metadata(&path)
3409 .map(|metadata| metadata.len())
3410 .unwrap_or(0);
3411 batch.push((rel_path, size));
3412 if batch.len() == COLD_BUILD_EXTRACT_BATCH_FILES {
3413 self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3414 batch.clear();
3415 }
3416 }
3417 if !batch.is_empty() {
3418 self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3419 }
3420
3421 staged_corpus_fingerprint(&conn, &self.project_root)
3422 }
3423
3424 fn insert_staged_file_inventory_batch(
3425 &self,
3426 conn: &mut Connection,
3427 batch: &[(String, u64)],
3428 ) -> Result<()> {
3429 self.verify_writer_lease()?;
3430 let total_changes_before = conn.total_changes();
3431 let tx = conn.transaction()?;
3432 {
3433 let mut insert = tx.prepare(
3434 "INSERT OR REPLACE INTO staging_file_inventory(path, size) VALUES(?1, ?2)",
3435 )?;
3436 for (path, size) in batch {
3437 insert.execute(params![path, *size as i64])?;
3438 }
3439 }
3440 tx.commit()?;
3441 self.record_commit(total_changes_before, conn);
3442 Ok(())
3443 }
3444
3445 fn cold_build_chunked_from_staged_inventory(
3446 &self,
3447 chunk_size: usize,
3448 corpus_fingerprint: &str,
3449 ) -> Result<ColdBuildStats> {
3450 let started = Instant::now();
3451 let batch_files = chunk_size.max(1).min(COLD_BUILD_EXTRACT_BATCH_FILES);
3452 let workspace_root = self.project_root.display().to_string();
3453 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3454
3455 self.verify_writer_lease()?;
3456 let mut phase = staged_build_phase(&conn)?;
3457 let staged_fingerprint = staged_string(&conn, STAGED_CORPUS_FINGERPRINT)?;
3458 if phase.as_deref().is_none_or(|phase| phase == "ready")
3459 || staged_fingerprint.as_deref() != Some(corpus_fingerprint)
3460 {
3461 let total_changes_before = conn.total_changes();
3462 let tx = conn.transaction()?;
3463 clear_tables(&tx)?;
3464 tx.execute("DELETE FROM staging_ref_context", [])?;
3465 insert_meta(&tx)?;
3466 drop_cold_build_secondary_indexes(&tx)?;
3467 set_meta_ready(&tx, false)?;
3468 set_staged_build_phase(&tx, "extracting")?;
3469 set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, corpus_fingerprint)?;
3470 set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0)?;
3471 set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, 0)?;
3472 tx.commit()?;
3473 self.record_commit(total_changes_before, &conn);
3474 phase = Some("extracting".to_string());
3475 }
3476
3477 note_cold_build_phase("extraction");
3481 if phase.as_deref() == Some("extracting") {
3482 prune_staged_files_not_in_inventory(&mut conn)?;
3483
3484 let mut after_path = String::new();
3485 loop {
3486 let Some(batch) = load_staged_file_batch(
3487 &conn,
3488 &self.project_root,
3489 &after_path,
3490 batch_files,
3491 COLD_BUILD_EXTRACT_BATCH_BYTES,
3492 )?
3493 else {
3494 break;
3495 };
3496 after_path = batch.last_path;
3497
3498 let mut needs_extract = Vec::with_capacity(batch.paths.len());
3499 for path in batch.paths {
3500 if !staged_content_matches(&conn, &self.project_root, &path)? {
3501 needs_extract.push(path);
3502 }
3503 }
3504 if needs_extract.is_empty() {
3505 continue;
3506 }
3507
3508 let build = build_extracts_parallel(&self.project_root, &needs_extract);
3509 self.verify_writer_lease()?;
3510 let total_changes_before = conn.total_changes();
3511 let tx = conn.transaction()?;
3512 let mut extracted_bytes = 0u64;
3513 {
3514 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3515 for extract in &build.extracts {
3516 delete_staged_file_rows(&tx, &extract.rel_path)?;
3517 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
3518 for raw in &extract.raw_refs {
3519 insert_staged_ref_prepared(&mut inserts, raw)?;
3520 }
3521 extracted_bytes = extracted_bytes.saturating_add(extract.freshness.size);
3522 }
3523 for failure in &build.failures {
3524 insert_backend_state_prepared(
3525 &mut inserts.backend_state,
3526 &workspace_root,
3527 &failure.rel_path,
3528 failure
3529 .freshness
3530 .as_ref()
3531 .map(|freshness| &freshness.content_hash),
3532 "stale",
3533 )?;
3534 }
3535 }
3536 increment_staged_extracted_bytes(&tx, extracted_bytes)?;
3537 note_cold_build_commit_barrier("extraction_batch_before_commit");
3538 tx.commit()?;
3539 note_cold_build_commit_barrier("extraction_batch_committed");
3540 self.record_commit(total_changes_before, &conn);
3541 }
3542
3543 let total_changes_before = conn.total_changes();
3544 let tx = conn.transaction()?;
3545 set_staged_build_phase(&tx, "indexing")?;
3546 tx.commit()?;
3547 self.record_commit(total_changes_before, &conn);
3548 phase = Some("indexing".to_string());
3549 }
3550
3551 note_cold_build_phase("symbol_export_index");
3555 if phase.as_deref() == Some("indexing") {
3556 self.verify_writer_lease()?;
3557 let total_changes_before = conn.total_changes();
3558 let tx = conn.transaction()?;
3559 create_cold_build_secondary_indexes(&tx)?;
3560 set_staged_build_phase(&tx, "resolving")?;
3561 tx.commit()?;
3562 self.record_commit(total_changes_before, &conn);
3563 }
3564
3565 note_cold_build_phase("resolution");
3566 let workspace_crate_prefixes = WorkspaceCratePrefixCache::default();
3567 let mut resolve_cursor = staged_u64(&conn, STAGED_RESOLVE_CURSOR)?;
3568 loop {
3569 let staged = load_staged_ref_window(&conn, resolve_cursor, COLD_BUILD_RESOLVE_WINDOW)?;
3570 let Some(last_rowid) = staged.last().map(|entry| entry.rowid) else {
3571 break;
3572 };
3573
3574 self.verify_writer_lease()?;
3575 let total_changes_before = conn.total_changes();
3576 let tx = conn.transaction()?;
3577 {
3578 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3579 let mut offset = 0;
3580 while offset < staged.len() {
3581 let caller_file = staged[offset].raw.caller_file.clone();
3582 let end = staged[offset..]
3583 .iter()
3584 .position(|entry| entry.raw.caller_file != caller_file)
3585 .map(|relative| offset + relative)
3586 .unwrap_or(staged.len());
3587 let caller_extract = build_file_extract(
3588 &self.project_root,
3589 &self.project_root.join(&caller_file),
3590 );
3591 if let Ok(caller_extract) = caller_extract {
3592 let index = DiskProjectIndex {
3593 project_root: &self.project_root,
3594 conn: &tx,
3595 caller_file: &caller_file,
3596 caller_data: &caller_extract.data,
3597 workspace_crate_prefixes: workspace_crate_prefixes.clone(),
3598 };
3599 for staged_ref in &staged[offset..end] {
3600 let resolved = resolve_ref(staged_ref.raw.clone(), &index)?;
3601 insert_resolved_ref_prepared(&mut inserts, &resolved)?;
3602 }
3603 } else {
3604 for staged_ref in &staged[offset..end] {
3605 let unresolved = unresolved_staged_ref(staged_ref.raw.clone());
3606 insert_resolved_ref_prepared(&mut inserts, &unresolved)?;
3607 }
3608 }
3609 offset = end;
3610 }
3611 }
3612 set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, last_rowid)?;
3613 tx.commit()?;
3614 self.record_commit(total_changes_before, &conn);
3615 resolve_cursor = last_rowid;
3616 }
3617
3618 note_cold_build_phase("publication");
3619 self.verify_writer_lease()?;
3620 let total_changes_before = conn.total_changes();
3621 let tx = conn.transaction()?;
3622 let _supplemental_edge_count =
3623 insert_method_dispatch_edges_chunked(&tx, &self.project_root, batch_files)?;
3624 set_meta_ready(&tx, true)?;
3625 set_staged_build_phase(&tx, "ready")?;
3626 tx.execute("DELETE FROM staging_file_inventory", [])?;
3627 tx.execute("DELETE FROM staging_ref_context", [])?;
3628 bump_projection_write_revision(&tx)?;
3629 tx.commit()?;
3630 self.record_commit(total_changes_before, &conn);
3631
3632 let files = query_count(&conn, "SELECT COUNT(*) FROM files")? as usize;
3633 let nodes = query_count(&conn, "SELECT COUNT(*) FROM nodes")? as usize;
3634 let refs = query_count(&conn, "SELECT COUNT(*) FROM refs")? as usize;
3635 let edges = query_count(&conn, "SELECT COUNT(*) FROM edges")? as usize;
3636 let failed_files = staged_failed_files(&conn)?;
3637 let elapsed_ms = started.elapsed().as_millis();
3638 crate::slog_info!(
3639 "perf callgraph_store bounded cold_build: files={} nodes={} refs={} edges={} committed_extracted_bytes={} ms={}",
3640 files,
3641 nodes,
3642 refs,
3643 edges,
3644 staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES)?,
3645 elapsed_ms
3646 );
3647 Ok(ColdBuildStats {
3648 files,
3649 nodes,
3650 refs,
3651 edges,
3652 failed_files,
3653 elapsed_ms,
3654 })
3655 }
3656
3657 pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
3658 self.refresh_files_with_workspace_crate_prefix_cache(
3659 changed_files,
3660 WorkspaceCratePrefixCache::default(),
3661 )
3662 }
3663
3664 fn refresh_files_with_workspace_crate_prefix_cache(
3665 &self,
3666 changed_files: &[PathBuf],
3667 workspace_crate_prefixes: WorkspaceCratePrefixCache,
3668 ) -> Result<IncrementalStats> {
3669 let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
3670 changed_files,
3671 workspace_crate_prefixes,
3672 )?;
3673 if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
3674 eprintln!("refresh_files phases: {}", profile.report());
3675 }
3676 Ok(stats)
3677 }
3678
3679 #[doc(hidden)]
3681 pub fn refresh_files_profiled(
3682 &self,
3683 changed_files: &[PathBuf],
3684 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
3685 self.refresh_files_profiled_with_workspace_crate_prefix_cache(
3686 changed_files,
3687 WorkspaceCratePrefixCache::default(),
3688 )
3689 }
3690
3691 fn refresh_files_profiled_with_workspace_crate_prefix_cache(
3692 &self,
3693 changed_files: &[PathBuf],
3694 workspace_crate_prefixes: WorkspaceCratePrefixCache,
3695 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
3696 let total_started = Instant::now();
3697 let mut profile = RefreshFilesProfile::default();
3698 self.verify_writer_lease()?;
3699 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3700 ensure_database_ready(&conn)?;
3701 let total_changes_before = conn.total_changes();
3702 let mut changed = Vec::new();
3703 let mut surface_changed = BTreeSet::new();
3704 let mut deleted = BTreeSet::new();
3705 let mut own_refresh = BTreeSet::new();
3706 let mut candidate_own_refresh = BTreeSet::new();
3707 let mut confirmed_fresh = BTreeSet::new();
3708 let mut unchanged_extracts = 0usize;
3709 let mut selected_ref_ids = BTreeSet::new();
3710 let mut selected_refs_by_caller = BTreeMap::new();
3711 let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
3712 let mut fresh_metadata = BTreeMap::new();
3713
3714 for input in changed_files {
3715 let (abs_path, rel_path) = match normalize_project_file_path(&self.project_root, input)
3716 {
3717 Ok(path) => path,
3718 Err(error) => {
3719 record_path_identity_mismatch(&conn, &error)?;
3720 return Err(error);
3721 }
3722 };
3723 changed.push(rel_path.clone());
3724 let old_row = load_file_row(&conn, &rel_path)?;
3725 if !abs_path.exists() {
3726 if old_row.is_some() && deleted.insert(rel_path.clone()) {
3727 surface_changed.insert(rel_path.clone());
3728 let started = Instant::now();
3729 let dependent_refs =
3730 ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3731 profile.dependency_selection += started.elapsed();
3732 record_dependent_refs(
3733 &mut selected_ref_ids,
3734 &mut selected_refs_by_caller,
3735 dependent_refs,
3736 );
3737 }
3738 continue;
3739 }
3740
3741 if let Some(row) = &old_row {
3742 match cache_freshness::verify_file(&abs_path, &row.freshness) {
3743 FreshnessVerdict::HotFresh => {
3744 confirmed_fresh.insert(rel_path.clone());
3749 continue;
3750 }
3751 FreshnessVerdict::ContentFresh {
3752 new_mtime,
3753 new_size,
3754 } => {
3755 fresh_metadata.insert(
3756 rel_path.clone(),
3757 FileFreshness {
3758 content_hash: row.freshness.content_hash,
3759 mtime: new_mtime,
3760 size: new_size,
3761 },
3762 );
3763 continue;
3764 }
3765 FreshnessVerdict::Deleted => {
3766 if deleted.insert(rel_path.clone()) {
3767 surface_changed.insert(rel_path.clone());
3768 let started = Instant::now();
3769 let dependent_refs =
3770 ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3771 profile.dependency_selection += started.elapsed();
3772 record_dependent_refs(
3773 &mut selected_ref_ids,
3774 &mut selected_refs_by_caller,
3775 dependent_refs,
3776 );
3777 }
3778 continue;
3779 }
3780 FreshnessVerdict::Stale => {}
3781 }
3782 }
3783
3784 let started = Instant::now();
3785 let extract = build_file_extract(&self.project_root, &abs_path)?;
3786 profile.parse += started.elapsed();
3787 let surface_is_changed = old_row
3788 .as_ref()
3789 .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
3790 .unwrap_or(true);
3791 if surface_is_changed {
3792 surface_changed.insert(rel_path.clone());
3793 let started = Instant::now();
3794 let dependent_refs = ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3795 profile.dependency_selection += started.elapsed();
3796 record_dependent_refs(
3797 &mut selected_ref_ids,
3798 &mut selected_refs_by_caller,
3799 dependent_refs,
3800 );
3801 }
3802 candidate_own_refresh.insert(rel_path.clone());
3803 changed_extracts.insert(rel_path, extract);
3804 }
3805
3806 let dependency_selected_refs = selected_ref_ids.len();
3807 let mut touched_callers: BTreeSet<String> =
3808 selected_refs_by_caller.keys().cloned().collect();
3809 touched_callers.extend(candidate_own_refresh.iter().cloned());
3810
3811 let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
3812 for rel_path in &touched_callers {
3813 if deleted.contains(rel_path) {
3814 continue;
3815 }
3816 if let Some(extract) = changed_extracts.get(rel_path) {
3817 caller_extracts.insert(rel_path.clone(), extract.clone());
3818 continue;
3819 }
3820 let abs_path = self.project_root.join(rel_path);
3821 if abs_path.exists() {
3822 let started = Instant::now();
3823 let extract = build_file_extract(&self.project_root, &abs_path)?;
3824 profile.dependent_parse += started.elapsed();
3825 caller_extracts.insert(rel_path.clone(), extract);
3826 }
3827 }
3828
3829 let tx = conn.transaction()?;
3830 for (rel_path, freshness) in fresh_metadata {
3831 update_file_fresh_metadata(
3832 &tx,
3833 &self.project_root,
3834 &rel_path,
3835 &freshness.content_hash,
3836 freshness.mtime,
3837 freshness.size,
3838 )?;
3839 }
3840 for rel_path in &confirmed_fresh {
3841 clear_stale_backend_status_for_file(&tx, &self.project_root, rel_path)?;
3842 }
3843 for rel_path in &deleted {
3844 let started = Instant::now();
3845 delete_file_rows(&tx, rel_path)?;
3846 clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
3847 profile.row_deletes += started.elapsed();
3848 }
3849
3850 let started = Instant::now();
3851 let index = ProjectIndex::from_db_and_callers(
3852 &tx,
3853 &self.project_root,
3854 &caller_extracts,
3855 workspace_crate_prefixes,
3856 )?;
3857 profile.index_load += started.elapsed();
3858
3859 let workspace_root = self.project_root.display().to_string();
3860 {
3861 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3862 for rel_path in &candidate_own_refresh {
3863 let Some(extract) = changed_extracts.get(rel_path) else {
3864 continue;
3865 };
3866 if !write_amplification_baseline_enabled()
3867 && stored_extract_matches(&tx, rel_path, extract, &index)?
3868 {
3869 unchanged_extracts += 1;
3870 update_file_fresh_metadata(
3871 &tx,
3872 &self.project_root,
3873 rel_path,
3874 &extract.freshness.content_hash,
3875 extract.freshness.mtime,
3876 extract.freshness.size,
3877 )?;
3878 continue;
3879 }
3880
3881 own_refresh.insert(rel_path.clone());
3882 let started = Instant::now();
3883 delete_file_rows(&tx, rel_path)?;
3884 clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
3885 profile.row_deletes += started.elapsed();
3886 let started = Instant::now();
3887 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
3888 profile.row_inserts += started.elapsed();
3889 }
3890
3891 let dependency_callers = touched_callers
3892 .iter()
3893 .filter(|rel_path| {
3894 !deleted.contains(*rel_path) && !candidate_own_refresh.contains(*rel_path)
3895 })
3896 .cloned()
3897 .collect::<Vec<_>>();
3898 for rel_path in dependency_callers {
3899 let Some(extract) = caller_extracts.get(&rel_path) else {
3900 continue;
3901 };
3902 if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
3903 continue;
3904 }
3905
3906 own_refresh.insert(rel_path.clone());
3907 let started = Instant::now();
3908 delete_file_rows(&tx, &rel_path)?;
3909 clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
3910 profile.row_deletes += started.elapsed();
3911 let started = Instant::now();
3912 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
3913 profile.row_inserts += started.elapsed();
3914 }
3915 let started = Instant::now();
3916 for rel_path in &touched_callers {
3917 if deleted.contains(rel_path) {
3918 continue;
3919 }
3920 let Some(extract) = caller_extracts.get(rel_path) else {
3921 continue;
3922 };
3923 if own_refresh.contains(rel_path) {
3924 delete_refs_for_caller(&tx, rel_path)?;
3925 for raw_ref in &extract.raw_refs {
3926 let resolved = resolve_ref(raw_ref.clone(), &index)?;
3927 insert_resolved_ref_prepared(&mut inserts, &resolved)?;
3928 }
3929 continue;
3930 }
3931
3932 let selected_for_caller = selected_refs_by_caller
3933 .get(rel_path)
3934 .cloned()
3935 .unwrap_or_default();
3936 delete_ref_ids(&tx, &selected_for_caller)?;
3937 for raw_ref in &extract.raw_refs {
3938 if selected_for_caller.contains(&raw_ref.ref_id) {
3939 let resolved = resolve_ref(raw_ref.clone(), &index)?;
3940 insert_resolved_ref_prepared(&mut inserts, &resolved)?;
3941 }
3942 }
3943 }
3944 profile.ref_resolution += started.elapsed();
3945 }
3946
3947 let started = Instant::now();
3948 delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
3949 insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
3950 profile.method_dispatch += started.elapsed();
3951
3952 bump_projection_write_revision(&tx)?;
3953 let started = Instant::now();
3954 commit_incremental_if_current(tx)?;
3955 self.record_commit(total_changes_before, &conn);
3956 profile.commit += started.elapsed();
3957 profile.total = total_started.elapsed();
3958 Ok((
3959 IncrementalStats {
3960 changed_files: changed,
3961 surface_changed: surface_changed.into_iter().collect(),
3962 deleted_files: deleted.into_iter().collect(),
3963 dependency_selected_refs,
3964 refreshed_own_files: own_refresh.len(),
3965 unchanged_extract_files: unchanged_extracts,
3966 },
3967 profile,
3968 ))
3969 }
3970
3971 pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
3972 self.cold_build(current_files)
3973 }
3974
3975 pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
3976 self.verify_writer_lease()?;
3977 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3978 let total_changes_before = conn.total_changes();
3979 let tx = conn.transaction()?;
3980 let mut marked = Vec::new();
3981 for path in files {
3982 let (abs_path, rel_path) = match normalize_project_file_path(&self.project_root, path) {
3983 Ok(path) => path,
3984 Err(error) => {
3985 drop(tx);
3986 record_path_identity_mismatch(&conn, &error)?;
3987 return Err(error);
3988 }
3989 };
3990 let freshness = cache_freshness::collect(&abs_path).ok();
3991 mark_backend_state(
3992 &tx,
3993 &self.project_root,
3994 &rel_path,
3995 freshness.as_ref().map(|freshness| &freshness.content_hash),
3996 "stale",
3997 )?;
3998 marked.push(rel_path);
3999 }
4000 bump_projection_write_revision(&tx)?;
4001 tx.commit()?;
4002 self.record_commit(total_changes_before, &conn);
4003 marked.sort();
4004 marked.dedup();
4005 Ok(marked)
4006 }
4007
4008 pub fn stale_files(&self) -> Result<Vec<String>> {
4009 self.refresh_read_marker()?;
4010 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4011 let mut stmt = conn.prepare(
4012 "SELECT DISTINCT file_path FROM backend_file_state
4013 WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
4014 ORDER BY file_path",
4015 )?;
4016 let rows = stmt.query_map(
4017 params![BACKEND_TREESITTER, self.project_root.display().to_string()],
4018 |row| row.get::<_, String>(0),
4019 )?;
4020 rows.collect::<std::result::Result<Vec<_>, _>>()
4021 .map_err(Into::into)
4022 }
4023
4024 pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
4025 self.refresh_read_marker()?;
4026 let rel_path = relative_path(
4027 &self.project_root,
4028 &normalize_file_path(&self.project_root, file)?,
4029 );
4030 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4031 conn.query_row(
4032 "SELECT status FROM backend_file_state
4033 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
4034 ORDER BY updated_at DESC LIMIT 1",
4035 params![
4036 BACKEND_TREESITTER,
4037 self.project_root.display().to_string(),
4038 rel_path
4039 ],
4040 |row| row.get(0),
4041 )
4042 .optional()
4043 .map_err(Into::into)
4044 }
4045
4046 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4047 self.refresh_read_marker()?;
4048 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4049 self.ensure_ready(&conn)?;
4050 edge_snapshot_with_conn(&conn)
4051 }
4052
4053 pub fn indexed_file_count(&self) -> Result<usize> {
4054 self.refresh_read_marker()?;
4055 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4056 self.ensure_ready(&conn)?;
4057 indexed_file_count(&conn)
4058 }
4059
4060 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4061 self.refresh_read_marker()?;
4062 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4063 let rel_path = relative_path(&self.project_root, &abs_path);
4064 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4065 self.ensure_ready(&conn)?;
4066 resolve_node_for_rel(&conn, &rel_path, symbol)
4067 }
4068
4069 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4074 self.refresh_read_marker()?;
4075 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4076 let rel_path = relative_path(&self.project_root, &abs_path);
4077 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4078 self.ensure_ready(&conn)?;
4079 nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
4080 }
4081
4082 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4084 self.refresh_read_marker()?;
4085 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4086 self.ensure_ready(&conn)?;
4087 nodes_matching_symbol(&conn, symbol)
4088 }
4089
4090 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4092 self.refresh_read_marker()?;
4093 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4094 let rel_path = relative_path(&self.project_root, &abs_path);
4095 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4096 self.ensure_ready(&conn)?;
4097 direct_callers_for_tuple(&conn, &rel_path, symbol)
4098 }
4099
4100 pub fn direct_callers_for_symbols(
4102 &self,
4103 targets: &[(String, String)],
4104 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4105 if targets.is_empty() {
4106 return Ok(HashMap::new());
4107 }
4108 self.refresh_read_marker()?;
4109 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4110 self.ensure_ready(&conn)?;
4111 direct_callers_for_tuples(&conn, targets)
4112 }
4113
4114 pub fn direct_caller_counts_of(
4116 &self,
4117 targets: &[(String, String)],
4118 ) -> Result<HashMap<(String, String), usize>> {
4119 if targets.is_empty() {
4120 return Ok(HashMap::new());
4121 }
4122 self.refresh_read_marker()?;
4123 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4124 self.ensure_ready(&conn)?;
4125 direct_caller_counts_for_tuples(&conn, targets)
4126 }
4127
4128 pub fn callers_of(
4129 &self,
4130 file_rel: &Path,
4131 symbol: &str,
4132 depth: usize,
4133 ) -> Result<StoreCallersResult> {
4134 let target = self.node_for(file_rel, symbol)?;
4135 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4136 self.ensure_ready(&conn)?;
4137 let effective_depth = depth.max(1);
4138 let mut visited = HashSet::new();
4139 let mut callers = Vec::new();
4140 let mut depth_limited = false;
4141 let mut truncated = 0usize;
4142 collect_callers_recursive(
4143 &conn,
4144 &target.file,
4145 &target.symbol,
4146 effective_depth,
4147 0,
4148 &mut visited,
4149 &mut callers,
4150 &mut depth_limited,
4151 &mut truncated,
4152 )?;
4153 Ok(StoreCallersResult {
4154 target,
4155 callers,
4156 scanned_files: indexed_file_count(&conn)?,
4157 depth_limited,
4158 truncated,
4159 })
4160 }
4161
4162 pub fn impact_of(
4163 &self,
4164 file_rel: &Path,
4165 symbol: &str,
4166 depth: usize,
4167 ) -> Result<StoreImpactResult> {
4168 let callers = self.callers_of(file_rel, symbol, depth)?;
4169 let target_parameters = callers
4170 .target
4171 .signature
4172 .as_deref()
4173 .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
4174 .unwrap_or_default();
4175 let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
4176 for site in &callers.callers {
4177 source_lines_by_file
4178 .entry(site.caller.file.clone())
4179 .or_insert_with(|| {
4180 read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
4181 });
4182 }
4183 let enriched = callers
4184 .callers
4185 .iter()
4186 .map(|site| StoreImpactCaller {
4187 site: site.clone(),
4188 signature: site.caller.signature.clone(),
4189 is_entry_point: site.caller.is_entry_point,
4190 call_expression: source_lines_by_file
4191 .get(&site.caller.file)
4192 .and_then(|lines| lines.as_ref())
4193 .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
4194 .cloned(),
4195 parameters: site
4196 .caller
4197 .signature
4198 .as_deref()
4199 .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
4200 .unwrap_or_default(),
4201 })
4202 .collect();
4203 Ok(StoreImpactResult {
4204 target: callers.target,
4205 parameters: target_parameters,
4206 callers: enriched,
4207 depth_limited: callers.depth_limited,
4208 truncated: callers.truncated,
4209 })
4210 }
4211
4212 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4213 self.refresh_read_marker()?;
4214 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4215 self.ensure_ready(&conn)?;
4216 outgoing_calls_for_node(&conn, node)
4217 }
4218
4219 pub fn outgoing_calls_for_symbols(
4221 &self,
4222 sources: &[(String, String)],
4223 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4224 if sources.is_empty() {
4225 return Ok(HashMap::new());
4226 }
4227 self.refresh_read_marker()?;
4228 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4229 self.ensure_ready(&conn)?;
4230 outgoing_calls_for_symbol_tuples(&conn, sources)
4231 }
4232
4233 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4235 self.refresh_read_marker()?;
4236 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4237 self.ensure_ready(&conn)?;
4238 resolved_self_calls_for_node(&conn, node)
4239 }
4240
4241 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4242 self.refresh_read_marker()?;
4243 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4244 self.ensure_ready(&conn)?;
4245 unresolved_calls_for_node(&conn, node)
4246 }
4247
4248 pub fn call_tree(
4249 &self,
4250 file_rel: &Path,
4251 symbol: &str,
4252 max_depth: usize,
4253 ) -> Result<callgraph::CallTreeNode> {
4254 let node = self.node_for(file_rel, symbol)?;
4255 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4256 self.ensure_ready(&conn)?;
4257 let mut visited = HashSet::new();
4258 call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
4259 }
4260
4261 pub fn trace_to(
4262 &self,
4263 file_rel: &Path,
4264 symbol: &str,
4265 max_depth: usize,
4266 ) -> Result<callgraph::TraceToResult> {
4267 let target = self.node_for(file_rel, symbol)?;
4268 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4269 self.ensure_ready(&conn)?;
4270 let effective_max = if max_depth == 0 { 10 } else { max_depth };
4271
4272 #[derive(Clone)]
4273 struct PathElem {
4274 node: StoreNode,
4275 }
4276
4277 let initial = vec![PathElem {
4278 node: target.clone(),
4279 }];
4280 let mut complete_paths = Vec::new();
4281 if target.is_entry_point {
4282 complete_paths.push(initial.clone());
4283 }
4284
4285 let mut queue = vec![(initial, 0usize)];
4286 let mut max_depth_reached = false;
4287 let mut truncated_paths = 0usize;
4288
4289 while let Some((path, depth)) = queue.pop() {
4290 if depth >= effective_max {
4291 max_depth_reached = true;
4292 continue;
4293 }
4294 let Some(current) = path.last() else {
4295 continue;
4296 };
4297 let callers =
4298 direct_callers_for_tuple(&conn, ¤t.node.file, ¤t.node.symbol)?;
4299 if callers.is_empty() {
4300 if path.len() > 1 {
4301 truncated_paths += 1;
4302 }
4303 continue;
4304 }
4305
4306 let mut has_new_path = false;
4307 for site in callers {
4308 if path.iter().any(|elem| {
4309 elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
4310 }) {
4311 continue;
4312 }
4313 has_new_path = true;
4314 let mut new_path = path.clone();
4315 new_path.push(PathElem {
4316 node: site.caller.clone(),
4317 });
4318 if site.caller.is_entry_point {
4319 complete_paths.push(new_path.clone());
4320 }
4321 queue.push((new_path, depth + 1));
4322 }
4323 if !has_new_path && path.len() > 1 {
4324 truncated_paths += 1;
4325 }
4326 }
4327
4328 let mut paths: Vec<callgraph::TracePath> = complete_paths
4329 .into_iter()
4330 .map(|mut elems| {
4331 elems.reverse();
4332 let hops = elems
4333 .iter()
4334 .enumerate()
4335 .map(|(index, elem)| callgraph::TraceHop {
4336 symbol: elem.node.symbol.clone(),
4337 file: elem.node.file.clone(),
4338 line: elem.node.line,
4339 signature: elem.node.signature.clone(),
4340 is_entry_point: index == 0 && elem.node.is_entry_point,
4341 })
4342 .collect();
4343 callgraph::TracePath { hops }
4344 })
4345 .collect();
4346 paths.sort_by(|left, right| {
4347 let left_entry = left
4348 .hops
4349 .first()
4350 .map(|hop| hop.symbol.as_str())
4351 .unwrap_or("");
4352 let right_entry = right
4353 .hops
4354 .first()
4355 .map(|hop| hop.symbol.as_str())
4356 .unwrap_or("");
4357 left_entry
4358 .cmp(right_entry)
4359 .then(left.hops.len().cmp(&right.hops.len()))
4360 });
4361 let entry_points_found = paths
4362 .iter()
4363 .filter_map(|path| path.hops.first())
4364 .filter(|hop| hop.is_entry_point)
4365 .map(|hop| (hop.file.clone(), hop.symbol.clone()))
4366 .collect::<HashSet<_>>()
4367 .len();
4368
4369 Ok(callgraph::TraceToResult {
4370 target_symbol: target.symbol,
4371 target_file: target.file,
4372 total_paths: paths.len(),
4373 paths,
4374 entry_points_found,
4375 max_depth_reached,
4376 truncated_paths,
4377 })
4378 }
4379
4380 pub fn trace_to_symbol_candidates(
4381 &self,
4382 to_symbol: &str,
4383 ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
4384 self.refresh_read_marker()?;
4385 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4386 self.ensure_ready(&conn)?;
4387 let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
4388 for node in nodes_matching_symbol(&conn, to_symbol)? {
4389 candidates_by_file
4390 .entry(node.file)
4391 .and_modify(|line| *line = (*line).min(node.line))
4392 .or_insert(node.line);
4393 }
4394 let mut candidates: Vec<_> = candidates_by_file
4395 .into_iter()
4396 .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
4397 .collect();
4398 candidates
4399 .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
4400 Ok(candidates)
4401 }
4402
4403 pub fn trace_to_symbol(
4404 &self,
4405 file_rel: &Path,
4406 symbol: &str,
4407 to_symbol: &str,
4408 to_file: Option<&Path>,
4409 max_depth: usize,
4410 ) -> Result<callgraph::TraceToSymbolResult> {
4411 let origin = self.node_for(file_rel, symbol)?;
4412 let target_file = to_file
4413 .map(|path| normalize_file_path(&self.project_root, path))
4414 .transpose()?
4415 .map(|path| relative_path(&self.project_root, &path));
4416 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4417 self.ensure_ready(&conn)?;
4418 let effective_max = if max_depth == 0 {
4419 10
4420 } else {
4421 max_depth.min(16)
4422 };
4423
4424 let start_hop = trace_to_symbol_hop(&origin);
4425 if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
4426 return Ok(callgraph::TraceToSymbolResult {
4427 path: Some(vec![start_hop]),
4428 complete: true,
4429 reason: None,
4430 });
4431 }
4432
4433 let mut queue = VecDeque::new();
4434 queue.push_back((origin.clone(), vec![start_hop], 0usize));
4435 let mut visited = HashSet::new();
4436 visited.insert((origin.file.clone(), origin.symbol.clone()));
4437 let mut max_depth_exhausted = false;
4438
4439 while let Some((current, path, depth)) = queue.pop_front() {
4440 let callees = outgoing_calls_for_node(&conn, ¤t)?
4441 .into_iter()
4442 .filter_map(|site| site.target)
4443 .collect::<Vec<_>>();
4444
4445 if depth >= effective_max {
4446 if callees
4447 .iter()
4448 .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
4449 {
4450 max_depth_exhausted = true;
4451 }
4452 continue;
4453 }
4454
4455 for callee in callees {
4456 if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
4457 continue;
4458 }
4459 let mut next_path = path.clone();
4460 next_path.push(trace_to_symbol_hop(&callee));
4461 if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
4462 return Ok(callgraph::TraceToSymbolResult {
4463 path: Some(next_path),
4464 complete: true,
4465 reason: None,
4466 });
4467 }
4468 queue.push_back((callee, next_path, depth + 1));
4469 }
4470 }
4471
4472 if max_depth_exhausted {
4473 Ok(callgraph::TraceToSymbolResult {
4474 path: None,
4475 complete: false,
4476 reason: Some("max_depth_exhausted".to_string()),
4477 })
4478 } else {
4479 Ok(callgraph::TraceToSymbolResult {
4480 path: None,
4481 complete: true,
4482 reason: Some("no_path_found".to_string()),
4483 })
4484 }
4485 }
4486}
4487
4488impl ReadonlyCallGraphStore {
4489 fn from_inner(inner: CallGraphStore) -> Self {
4490 Self { inner }
4491 }
4492
4493 pub fn project_root(&self) -> &Path {
4494 self.inner.project_root()
4495 }
4496
4497 pub fn project_key(&self) -> &str {
4498 self.inner.project_key()
4499 }
4500
4501 pub fn sqlite_path(&self) -> &Path {
4502 self.inner.sqlite_path()
4503 }
4504
4505 pub fn stale_files(&self) -> Result<Vec<String>> {
4506 self.inner.stale_files()
4507 }
4508
4509 pub(crate) fn projection_generation(&self) -> Option<&str> {
4510 self.inner.projection_generation()
4511 }
4512
4513 pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
4514 self.inner.projection_write_revision()
4515 }
4516
4517 pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
4520 crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
4521 }
4522
4523 pub fn is_legacy_fallback(&self) -> bool {
4525 self.inner.is_legacy_fallback()
4526 }
4527
4528 pub fn is_current(&self) -> bool {
4529 self.inner.is_current()
4530 }
4531
4532 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4533 self.inner.edge_snapshot()
4534 }
4535
4536 pub fn indexed_file_count(&self) -> Result<usize> {
4537 self.inner.indexed_file_count()
4538 }
4539
4540 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4541 self.inner.node_for(file_rel, symbol)
4542 }
4543
4544 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4545 self.inner.nodes_for(file_rel, symbol)
4546 }
4547
4548 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4549 self.inner.nodes_matching(symbol)
4550 }
4551
4552 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4553 self.inner.direct_callers_of(file_rel, symbol)
4554 }
4555
4556 pub fn direct_callers_for_symbols(
4557 &self,
4558 targets: &[(String, String)],
4559 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4560 self.inner.direct_callers_for_symbols(targets)
4561 }
4562
4563 pub fn direct_caller_counts_of(
4564 &self,
4565 targets: &[(String, String)],
4566 ) -> Result<HashMap<(String, String), usize>> {
4567 self.inner.direct_caller_counts_of(targets)
4568 }
4569
4570 pub fn callers_of(
4571 &self,
4572 file_rel: &Path,
4573 symbol: &str,
4574 depth: usize,
4575 ) -> Result<StoreCallersResult> {
4576 self.inner.callers_of(file_rel, symbol, depth)
4577 }
4578
4579 pub fn impact_of(
4580 &self,
4581 file_rel: &Path,
4582 symbol: &str,
4583 depth: usize,
4584 ) -> Result<StoreImpactResult> {
4585 self.inner.impact_of(file_rel, symbol, depth)
4586 }
4587
4588 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4589 self.inner.outgoing_calls_of(node)
4590 }
4591
4592 pub fn outgoing_calls_for_symbols(
4593 &self,
4594 sources: &[(String, String)],
4595 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4596 self.inner.outgoing_calls_for_symbols(sources)
4597 }
4598
4599 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4600 self.inner.resolved_self_calls_of(node)
4601 }
4602
4603 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4604 self.inner.unresolved_calls_of(node)
4605 }
4606
4607 pub fn call_tree(
4608 &self,
4609 file_rel: &Path,
4610 symbol: &str,
4611 depth: usize,
4612 ) -> Result<callgraph::CallTreeNode> {
4613 self.inner.call_tree(file_rel, symbol, depth)
4614 }
4615
4616 pub fn trace_to(
4617 &self,
4618 file_rel: &Path,
4619 symbol: &str,
4620 max_depth: usize,
4621 ) -> Result<callgraph::TraceToResult> {
4622 self.inner.trace_to(file_rel, symbol, max_depth)
4623 }
4624
4625 pub fn trace_to_symbol_candidates(
4626 &self,
4627 to_symbol: &str,
4628 ) -> Result<Vec<TraceToSymbolCandidate>> {
4629 self.inner.trace_to_symbol_candidates(to_symbol)
4630 }
4631
4632 pub fn trace_to_symbol(
4633 &self,
4634 file_rel: &Path,
4635 symbol: &str,
4636 to_symbol: &str,
4637 to_file: Option<&Path>,
4638 max_depth: usize,
4639 ) -> Result<callgraph::TraceToSymbolResult> {
4640 self.inner
4641 .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4642 }
4643}
4644
4645impl CallGraphRead for CallGraphStore {
4646 fn project_root(&self) -> &Path {
4647 CallGraphStore::project_root(self)
4648 }
4649 fn project_key(&self) -> &str {
4650 CallGraphStore::project_key(self)
4651 }
4652 fn sqlite_path(&self) -> &Path {
4653 CallGraphStore::sqlite_path(self)
4654 }
4655 fn is_current(&self) -> bool {
4656 CallGraphStore::is_current(self)
4657 }
4658 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4659 CallGraphStore::edge_snapshot(self)
4660 }
4661 fn indexed_file_count(&self) -> Result<usize> {
4662 CallGraphStore::indexed_file_count(self)
4663 }
4664 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4665 CallGraphStore::node_for(self, file_rel, symbol)
4666 }
4667 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4668 CallGraphStore::nodes_for(self, file_rel, symbol)
4669 }
4670 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4671 CallGraphStore::nodes_matching(self, symbol)
4672 }
4673 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4674 CallGraphStore::direct_callers_of(self, file_rel, symbol)
4675 }
4676 fn direct_callers_for_symbols(
4677 &self,
4678 targets: &[(String, String)],
4679 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4680 CallGraphStore::direct_callers_for_symbols(self, targets)
4681 }
4682 fn direct_caller_counts_of(
4683 &self,
4684 targets: &[(String, String)],
4685 ) -> Result<HashMap<(String, String), usize>> {
4686 CallGraphStore::direct_caller_counts_of(self, targets)
4687 }
4688 fn callers_of(
4689 &self,
4690 file_rel: &Path,
4691 symbol: &str,
4692 depth: usize,
4693 ) -> Result<StoreCallersResult> {
4694 CallGraphStore::callers_of(self, file_rel, symbol, depth)
4695 }
4696 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4697 CallGraphStore::impact_of(self, file_rel, symbol, depth)
4698 }
4699 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4700 CallGraphStore::outgoing_calls_of(self, node)
4701 }
4702 fn outgoing_calls_for_symbols(
4703 &self,
4704 sources: &[(String, String)],
4705 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4706 CallGraphStore::outgoing_calls_for_symbols(self, sources)
4707 }
4708 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4709 CallGraphStore::resolved_self_calls_of(self, node)
4710 }
4711 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4712 CallGraphStore::unresolved_calls_of(self, node)
4713 }
4714 fn call_tree(
4715 &self,
4716 file_rel: &Path,
4717 symbol: &str,
4718 depth: usize,
4719 ) -> Result<callgraph::CallTreeNode> {
4720 CallGraphStore::call_tree(self, file_rel, symbol, depth)
4721 }
4722 fn trace_to(
4723 &self,
4724 file_rel: &Path,
4725 symbol: &str,
4726 max_depth: usize,
4727 ) -> Result<callgraph::TraceToResult> {
4728 CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
4729 }
4730 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4731 CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
4732 }
4733 fn trace_to_symbol(
4734 &self,
4735 file_rel: &Path,
4736 symbol: &str,
4737 to_symbol: &str,
4738 to_file: Option<&Path>,
4739 max_depth: usize,
4740 ) -> Result<callgraph::TraceToSymbolResult> {
4741 CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
4742 }
4743}
4744
4745impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
4746 fn project_root(&self) -> &Path {
4747 (**self).project_root()
4748 }
4749 fn project_key(&self) -> &str {
4750 (**self).project_key()
4751 }
4752 fn sqlite_path(&self) -> &Path {
4753 (**self).sqlite_path()
4754 }
4755 fn is_current(&self) -> bool {
4756 (**self).is_current()
4757 }
4758 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4759 (**self).edge_snapshot()
4760 }
4761 fn indexed_file_count(&self) -> Result<usize> {
4762 (**self).indexed_file_count()
4763 }
4764 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4765 (**self).node_for(file_rel, symbol)
4766 }
4767 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4768 (**self).nodes_for(file_rel, symbol)
4769 }
4770 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4771 (**self).nodes_matching(symbol)
4772 }
4773 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4774 (**self).direct_callers_of(file_rel, symbol)
4775 }
4776 fn direct_callers_for_symbols(
4777 &self,
4778 targets: &[(String, String)],
4779 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4780 (**self).direct_callers_for_symbols(targets)
4781 }
4782 fn direct_caller_counts_of(
4783 &self,
4784 targets: &[(String, String)],
4785 ) -> Result<HashMap<(String, String), usize>> {
4786 (**self).direct_caller_counts_of(targets)
4787 }
4788 fn callers_of(
4789 &self,
4790 file_rel: &Path,
4791 symbol: &str,
4792 depth: usize,
4793 ) -> Result<StoreCallersResult> {
4794 (**self).callers_of(file_rel, symbol, depth)
4795 }
4796 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4797 (**self).impact_of(file_rel, symbol, depth)
4798 }
4799 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4800 (**self).outgoing_calls_of(node)
4801 }
4802 fn outgoing_calls_for_symbols(
4803 &self,
4804 sources: &[(String, String)],
4805 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4806 (**self).outgoing_calls_for_symbols(sources)
4807 }
4808 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4809 (**self).resolved_self_calls_of(node)
4810 }
4811 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4812 (**self).unresolved_calls_of(node)
4813 }
4814 fn call_tree(
4815 &self,
4816 file_rel: &Path,
4817 symbol: &str,
4818 depth: usize,
4819 ) -> Result<callgraph::CallTreeNode> {
4820 (**self).call_tree(file_rel, symbol, depth)
4821 }
4822 fn trace_to(
4823 &self,
4824 file_rel: &Path,
4825 symbol: &str,
4826 max_depth: usize,
4827 ) -> Result<callgraph::TraceToResult> {
4828 (**self).trace_to(file_rel, symbol, max_depth)
4829 }
4830 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4831 (**self).trace_to_symbol_candidates(to_symbol)
4832 }
4833 fn trace_to_symbol(
4834 &self,
4835 file_rel: &Path,
4836 symbol: &str,
4837 to_symbol: &str,
4838 to_file: Option<&Path>,
4839 max_depth: usize,
4840 ) -> Result<callgraph::TraceToSymbolResult> {
4841 (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4842 }
4843}
4844
4845impl CallGraphRead for ReadonlyCallGraphStore {
4846 fn project_root(&self) -> &Path {
4847 self.project_root()
4848 }
4849 fn project_key(&self) -> &str {
4850 self.project_key()
4851 }
4852 fn sqlite_path(&self) -> &Path {
4853 self.sqlite_path()
4854 }
4855 fn is_current(&self) -> bool {
4856 self.is_current()
4857 }
4858 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4859 self.edge_snapshot()
4860 }
4861 fn indexed_file_count(&self) -> Result<usize> {
4862 self.indexed_file_count()
4863 }
4864 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4865 self.node_for(file_rel, symbol)
4866 }
4867 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4868 self.nodes_for(file_rel, symbol)
4869 }
4870 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4871 self.nodes_matching(symbol)
4872 }
4873 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4874 self.direct_callers_of(file_rel, symbol)
4875 }
4876 fn direct_callers_for_symbols(
4877 &self,
4878 targets: &[(String, String)],
4879 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4880 self.direct_callers_for_symbols(targets)
4881 }
4882 fn direct_caller_counts_of(
4883 &self,
4884 targets: &[(String, String)],
4885 ) -> Result<HashMap<(String, String), usize>> {
4886 self.direct_caller_counts_of(targets)
4887 }
4888 fn callers_of(
4889 &self,
4890 file_rel: &Path,
4891 symbol: &str,
4892 depth: usize,
4893 ) -> Result<StoreCallersResult> {
4894 self.callers_of(file_rel, symbol, depth)
4895 }
4896 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4897 self.impact_of(file_rel, symbol, depth)
4898 }
4899 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4900 self.outgoing_calls_of(node)
4901 }
4902 fn outgoing_calls_for_symbols(
4903 &self,
4904 sources: &[(String, String)],
4905 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4906 self.outgoing_calls_for_symbols(sources)
4907 }
4908 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4909 self.resolved_self_calls_of(node)
4910 }
4911 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4912 self.unresolved_calls_of(node)
4913 }
4914 fn call_tree(
4915 &self,
4916 file_rel: &Path,
4917 symbol: &str,
4918 depth: usize,
4919 ) -> Result<callgraph::CallTreeNode> {
4920 self.call_tree(file_rel, symbol, depth)
4921 }
4922 fn trace_to(
4923 &self,
4924 file_rel: &Path,
4925 symbol: &str,
4926 max_depth: usize,
4927 ) -> Result<callgraph::TraceToResult> {
4928 self.trace_to(file_rel, symbol, max_depth)
4929 }
4930 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4931 self.trace_to_symbol_candidates(to_symbol)
4932 }
4933 fn trace_to_symbol(
4934 &self,
4935 file_rel: &Path,
4936 symbol: &str,
4937 to_symbol: &str,
4938 to_file: Option<&Path>,
4939 max_depth: usize,
4940 ) -> Result<callgraph::TraceToSymbolResult> {
4941 self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4942 }
4943}
4944
4945fn indexed_file_count(conn: &Connection) -> Result<usize> {
4946 let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
4947 Ok(count.max(0) as usize)
4948}
4949
4950fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
4951 let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
4952 match candidates.as_slice() {
4953 [candidate] => Ok(candidate.clone()),
4954 [] => Err(AftError::SymbolNotFound {
4955 name: symbol.to_string(),
4956 file: rel_path.to_string(),
4957 }
4958 .into()),
4959 _ => Err(AftError::AmbiguousSymbol {
4960 name: symbol.to_string(),
4961 candidates: candidates
4962 .iter()
4963 .map(|candidate| candidate.symbol.clone())
4964 .collect(),
4965 }
4966 .into()),
4967 }
4968}
4969
4970fn nodes_for_file_matching_symbol(
4971 conn: &Connection,
4972 rel_path: &str,
4973 symbol: &str,
4974) -> Result<Vec<StoreNode>> {
4975 let qualified_query = symbol.contains("::");
4976 let sql = if qualified_query {
4977 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4978 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4979 FROM nodes n JOIN files f ON f.path = n.file_path
4980 WHERE n.file_path = ?1 AND n.scoped_name = ?2
4981 ORDER BY n.scoped_name, n.start_line, n.start_col"
4982 } else {
4983 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4984 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4985 FROM nodes n JOIN files f ON f.path = n.file_path
4986 WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
4987 ORDER BY n.scoped_name, n.start_line, n.start_col"
4988 };
4989 let mut stmt = conn.prepare(sql)?;
4990 let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
4991 rows.collect::<std::result::Result<Vec<_>, _>>()
4992 .map_err(Into::into)
4993}
4994
4995fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
4996 let qualified_query = symbol.contains("::");
4997 let sql = if qualified_query {
4998 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4999 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5000 FROM nodes n JOIN files f ON f.path = n.file_path
5001 WHERE n.scoped_name = ?1
5002 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
5003 } else {
5004 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5005 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5006 FROM nodes n JOIN files f ON f.path = n.file_path
5007 WHERE n.scoped_name = ?1 OR n.name = ?1
5008 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
5009 };
5010 let mut stmt = conn.prepare(sql)?;
5011 let rows = stmt.query_map(params![symbol], store_node_from_row)?;
5012 rows.collect::<std::result::Result<Vec<_>, _>>()
5013 .map_err(Into::into)
5014}
5015
5016fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
5017 store_node_from_row_at(row, 0)
5018}
5019
5020fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
5021 let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
5022 let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
5023 let lang_label_value: String = row.get(offset + 10)?;
5024 Ok(StoreNode {
5025 node_id: row.get(offset)?,
5026 file: row.get(offset + 1)?,
5027 symbol: row.get(offset + 2)?,
5028 name: row.get(offset + 3)?,
5029 kind: row.get(offset + 4)?,
5030 line: start_line.saturating_add(1),
5031 end_line: end_line.saturating_add(1),
5032 signature: row.get(offset + 7)?,
5033 exported: row.get::<_, i64>(offset + 8)? != 0,
5034 is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
5035 lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
5036 })
5037}
5038
5039fn optional_store_node_from_row_at(
5040 row: &rusqlite::Row<'_>,
5041 offset: usize,
5042) -> rusqlite::Result<Option<StoreNode>> {
5043 if row.get::<_, Option<String>>(offset)?.is_some() {
5044 store_node_from_row_at(row, offset).map(Some)
5045 } else {
5046 Ok(None)
5047 }
5048}
5049
5050#[allow(clippy::too_many_arguments)]
5051fn collect_callers_recursive(
5052 conn: &Connection,
5053 file: &str,
5054 symbol: &str,
5055 max_depth: usize,
5056 current_depth: usize,
5057 visited: &mut HashSet<(String, String)>,
5058 result: &mut Vec<StoreCallSite>,
5059 depth_limited: &mut bool,
5060 truncated: &mut usize,
5061) -> Result<()> {
5062 if current_depth >= max_depth {
5063 let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
5064 if omitted > 0 {
5065 *depth_limited = true;
5066 *truncated += omitted;
5067 }
5068 return Ok(());
5069 }
5070
5071 if !visited.insert((file.to_string(), symbol.to_string())) {
5072 return Ok(());
5073 }
5074
5075 let sites = direct_callers_for_tuple(conn, file, symbol)?;
5076 for site in sites {
5077 result.push(site.clone());
5078 if current_depth + 1 < max_depth {
5079 collect_callers_recursive(
5080 conn,
5081 &site.caller.file,
5082 &site.caller.symbol,
5083 max_depth,
5084 current_depth + 1,
5085 visited,
5086 result,
5087 depth_limited,
5088 truncated,
5089 )?;
5090 } else {
5091 let omitted =
5092 direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
5093 if omitted > 0 {
5094 *depth_limited = true;
5095 *truncated += omitted;
5096 }
5097 }
5098 }
5099 Ok(())
5100}
5101
5102const DIRECT_CALLER_BATCH_SIZE: usize = 499;
5104
5105fn direct_caller_counts_for_tuples(
5106 conn: &Connection,
5107 targets: &[(String, String)],
5108) -> Result<HashMap<(String, String), usize>> {
5109 let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
5110 let mut counts = unique_targets
5111 .iter()
5112 .cloned()
5113 .map(|target| (target, 0usize))
5114 .collect::<HashMap<_, _>>();
5115
5116 let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
5117 for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
5118 let requested_values = (0..chunk.len())
5119 .map(|_| "(?, ?)")
5120 .collect::<Vec<_>>()
5121 .join(", ");
5122 let sql = format!(
5123 "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
5124 deduped AS (
5125 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
5126 FROM requested requested
5127 JOIN edges e
5128 ON e.target_file = requested.target_file
5129 AND e.target_symbol = requested.target_symbol
5130 AND e.kind = 'call'
5131 JOIN refs r ON r.ref_id = e.ref_id
5132 JOIN nodes src ON src.id = e.source_node
5133 JOIN files src_file ON src_file.path = src.file_path
5134 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
5135 )
5136 SELECT target_file, target_symbol, COUNT(*)
5137 FROM deduped
5138 GROUP BY target_file, target_symbol"
5139 );
5140 let bindings = chunk
5141 .iter()
5142 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5143 let mut stmt = conn.prepare(&sql)?;
5144 let rows = stmt.query_map(params_from_iter(bindings), |row| {
5145 Ok((
5146 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
5147 row.get::<_, i64>(2)?,
5148 ))
5149 })?;
5150 for row in rows {
5151 let (target, count) = row?;
5152 counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
5153 }
5154 }
5155
5156 Ok(counts)
5157}
5158
5159fn direct_caller_count_for_tuple(
5160 conn: &Connection,
5161 target_file: &str,
5162 target_symbol: &str,
5163) -> Result<usize> {
5164 let count: i64 = conn.query_row(
5165 "SELECT COUNT(*)
5166 FROM edges e
5167 JOIN refs r ON r.ref_id = e.ref_id
5168 JOIN nodes src ON src.id = e.source_node
5169 JOIN files src_file ON src_file.path = src.file_path
5170 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
5171 params![target_file, target_symbol],
5172 |row| row.get(0),
5173 )?;
5174 Ok(usize::try_from(count).unwrap_or(usize::MAX))
5175}
5176
5177fn direct_callers_for_tuple(
5178 conn: &Connection,
5179 target_file: &str,
5180 target_symbol: &str,
5181) -> Result<Vec<StoreCallSite>> {
5182 let mut stmt = conn.prepare(
5183 "SELECT e.target_file, e.target_symbol, e.line,
5184 r.byte_start, r.byte_end, r.status, e.provenance,
5185 src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5186 src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5187 src_file.lang,
5188 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5189 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5190 tgt_file.lang
5191 FROM edges e
5192 JOIN refs r ON r.ref_id = e.ref_id
5193 JOIN nodes src ON src.id = e.source_node
5194 JOIN files src_file ON src_file.path = src.file_path
5195 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5196 ON tgt.id = e.target_node
5197 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
5198 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
5199 )?;
5200 let rows = stmt.query_map(
5201 params![target_file, target_symbol],
5202 direct_call_site_from_row,
5203 )?;
5204 rows.collect::<std::result::Result<Vec<_>, _>>()
5205 .map_err(Into::into)
5206}
5207
5208fn direct_call_site_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreCallSite> {
5209 let caller = store_node_from_row_at(row, 7)?;
5210 let target = optional_store_node_from_row_at(row, 18)?;
5211 Ok(StoreCallSite {
5212 caller,
5213 target_file: row.get(0)?,
5214 target_symbol: row.get(1)?,
5215 target,
5216 line: row.get::<_, i64>(2)?.max(0) as u32,
5217 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5218 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5219 resolved: row.get::<_, String>(5)? == "resolved",
5220 provenance: row.get(6)?,
5221 })
5222}
5223
5224fn direct_callers_for_tuples(
5225 conn: &Connection,
5226 targets: &[(String, String)],
5227) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5228 let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
5229 let mut callers_by_target = unique_targets
5230 .iter()
5231 .cloned()
5232 .map(|target| (target, Vec::new()))
5233 .collect::<HashMap<_, _>>();
5234 let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
5235
5236 for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
5237 let requested_values = (0..chunk.len())
5238 .map(|_| "(?, ?)")
5239 .collect::<Vec<_>>()
5240 .join(", ");
5241 let sql = format!(
5242 "WITH requested(target_file, target_symbol) AS (VALUES {requested_values})
5243 SELECT e.target_file, e.target_symbol, e.line,
5244 r.byte_start, r.byte_end, r.status, e.provenance,
5245 src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5246 src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5247 src_file.lang,
5248 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5249 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5250 tgt_file.lang
5251 FROM requested requested
5252 JOIN edges e
5253 ON e.target_file = requested.target_file
5254 AND e.target_symbol = requested.target_symbol
5255 AND e.kind = 'call'
5256 JOIN refs r ON r.ref_id = e.ref_id
5257 JOIN nodes src ON src.id = e.source_node
5258 JOIN files src_file ON src_file.path = src.file_path
5259 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5260 ON tgt.id = e.target_node
5261 ORDER BY e.target_file, e.target_symbol, e.source_node,
5262 r.byte_start, r.line, r.ref_id"
5263 );
5264 let bindings = chunk
5265 .iter()
5266 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5267 let mut stmt = conn.prepare(&sql)?;
5268 let rows = stmt.query_map(params_from_iter(bindings), |row| {
5269 let call = direct_call_site_from_row(row)?;
5270 let target_key = (call.target_file.clone(), call.target_symbol.clone());
5271 Ok((target_key, call))
5272 })?;
5273 for row in rows {
5274 let (target, call) = row?;
5275 callers_by_target
5276 .get_mut(&target)
5277 .expect("batched caller row belongs to a requested target")
5278 .push(call);
5279 }
5280 }
5281
5282 Ok(callers_by_target)
5283}
5284
5285const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
5287const OUTGOING_NODE_BATCH_SIZE: usize = 999;
5289
5290fn outgoing_calls_for_symbol_tuples(
5291 conn: &Connection,
5292 sources: &[(String, String)],
5293) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5294 let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
5295 let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
5296 let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
5297 let source_nodes = unique_sources
5298 .iter()
5299 .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
5300 .cloned()
5301 .collect::<Vec<_>>();
5302 let source_nodes_by_id = source_nodes
5303 .iter()
5304 .cloned()
5305 .map(|node| (node.node_id.clone(), node))
5306 .collect::<HashMap<_, _>>();
5307 let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
5308
5309 for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
5310 let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
5311 let sql = format!(
5312 "SELECT e.source_node,
5313 e.target_file, e.target_symbol, e.line,
5314 r.byte_start, r.byte_end, r.status, e.provenance,
5315 CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
5316 tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5317 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5318 tgt_file.lang
5319 FROM edges e
5320 JOIN refs r ON r.ref_id = e.ref_id
5321 LEFT JOIN nodes tgt ON tgt.id = e.target_node
5322 LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
5323 WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
5324 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
5325 );
5326 let bindings = chunk.iter().map(|node| node.node_id.as_str());
5327 let mut stmt = conn.prepare(&sql)?;
5328 let rows = stmt.query_map(params_from_iter(bindings), |row| {
5329 let source_node_id = row.get::<_, String>(0)?;
5330 let caller = source_nodes_by_id
5331 .get(&source_node_id)
5332 .expect("batched outgoing row belongs to a requested source node")
5333 .clone();
5334 let target = optional_store_node_from_row_at(row, 8)?;
5335 Ok((
5336 source_node_id,
5337 StoreCallSite {
5338 caller,
5339 target_file: row.get(1)?,
5340 target_symbol: row.get(2)?,
5341 target,
5342 line: row.get::<_, i64>(3)?.max(0) as u32,
5343 byte_start: row.get::<_, i64>(4)?.max(0) as usize,
5344 byte_end: row.get::<_, i64>(5)?.max(0) as usize,
5345 resolved: row.get::<_, String>(6)? == "resolved",
5346 provenance: row.get(7)?,
5347 },
5348 ))
5349 })?;
5350 for row in rows {
5351 let (source_node_id, call) = row?;
5352 calls_by_node.entry(source_node_id).or_default().push(call);
5353 }
5354 }
5355
5356 let mut calls_by_source = HashMap::new();
5357 for source in &unique_sources {
5358 let mut calls = Vec::new();
5359 if let Some(nodes) = source_nodes_by_symbol.get(source) {
5360 for node in nodes {
5361 if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
5362 calls.extend(node_calls);
5363 }
5364 }
5365 }
5366 calls_by_source.insert(source.clone(), calls);
5367 }
5368
5369 let target_tuples = calls_by_source
5372 .values()
5373 .flatten()
5374 .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
5375 .collect::<Vec<_>>();
5376 let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
5377 for calls in calls_by_source.values_mut() {
5378 for call in calls {
5379 if let Some(target) = target_nodes
5380 .get(&(call.target_file.clone(), call.target_symbol.clone()))
5381 .and_then(|nodes| nodes.first())
5382 {
5383 call.target = Some(target.clone());
5384 }
5385 }
5386 }
5387
5388 Ok(calls_by_source)
5389}
5390
5391fn nodes_for_symbol_tuples(
5392 conn: &Connection,
5393 symbols: &[(String, String)],
5394) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
5395 let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
5396 let mut nodes_by_symbol = unique_symbols
5397 .iter()
5398 .cloned()
5399 .map(|symbol| (symbol, Vec::new()))
5400 .collect::<HashMap<_, _>>();
5401 let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
5402
5403 for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
5404 let requested_values = (0..chunk.len())
5405 .map(|_| "(?, ?)")
5406 .collect::<Vec<_>>()
5407 .join(", ");
5408 let sql = format!(
5409 "WITH requested(file, symbol) AS (VALUES {requested_values})
5410 SELECT requested.file, requested.symbol,
5411 node.id, node.file_path, node.scoped_name, node.name, node.kind,
5412 node.start_line, node.end_line, node.signature, node.exported,
5413 node.is_callgraph_entry_point, node_file.lang
5414 FROM requested
5415 JOIN nodes node INDEXED BY idx_nodes_file
5416 ON node.file_path = requested.file
5417 AND node.scoped_name = requested.symbol
5418 JOIN files node_file ON node_file.path = node.file_path
5419 ORDER BY requested.file, requested.symbol,
5420 node.scoped_name, node.start_line, node.end_line,
5421 node.start_col, node.range_ordinal"
5422 );
5423 let bindings = chunk
5424 .iter()
5425 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5426 let mut stmt = conn.prepare(&sql)?;
5427 let rows = stmt.query_map(params_from_iter(bindings), |row| {
5428 Ok((
5429 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
5430 store_node_from_row_at(row, 2)?,
5431 ))
5432 })?;
5433 for row in rows {
5434 let (symbol, node) = row?;
5435 nodes_by_symbol.entry(symbol).or_default().push(node);
5436 }
5437 }
5438
5439 Ok(nodes_by_symbol)
5440}
5441
5442fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5443 let mut stmt = conn.prepare(
5444 "SELECT e.target_file, e.target_symbol, e.line,
5445 r.byte_start, r.byte_end, r.status, e.provenance,
5446 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5447 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5448 tgt_file.lang
5449 FROM edges e
5450 JOIN refs r ON r.ref_id = e.ref_id
5451 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5452 ON tgt.id = e.target_node
5453 WHERE e.kind = 'call' AND e.source_node = ?1
5454 ORDER BY r.byte_start, r.line, r.ref_id",
5455 )?;
5456 let rows = stmt.query_map(params![node.node_id], |row| {
5457 let target = optional_store_node_from_row_at(row, 7)?;
5458 Ok(StoreCallSite {
5459 caller: node.clone(),
5460 target_file: row.get(0)?,
5461 target_symbol: row.get(1)?,
5462 target,
5463 line: row.get::<_, i64>(2)?.max(0) as u32,
5464 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5465 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5466 resolved: row.get::<_, String>(5)? == "resolved",
5467 provenance: row.get(6)?,
5468 })
5469 })?;
5470 rows.collect::<std::result::Result<Vec<_>, _>>()
5471 .map_err(Into::into)
5472}
5473
5474fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5475 let mut stmt = conn.prepare(
5476 "SELECT r.target_file, r.target_symbol, r.line,
5477 r.byte_start, r.byte_end, r.status, r.provenance,
5478 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5479 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5480 tgt_file.lang
5481 FROM refs r
5482 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5483 ON tgt.id = r.target_node
5484 WHERE r.caller_node = ?1
5485 AND r.kind = 'call'
5486 AND r.status <> 'unresolved'
5487 AND r.target_file = ?2
5488 AND r.target_symbol = ?3
5489 AND r.provenance = ?4
5490 AND NOT EXISTS (
5491 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
5492 )
5493 ORDER BY r.byte_start, r.line, r.ref_id",
5494 )?;
5495 let rows = stmt.query_map(
5496 params![
5497 &node.node_id,
5498 &node.file,
5499 &node.symbol,
5500 PROVENANCE_TREESITTER
5501 ],
5502 |row| {
5503 let target = optional_store_node_from_row_at(row, 7)?;
5504 Ok(StoreCallSite {
5505 caller: node.clone(),
5506 target_file: row.get(0)?,
5507 target_symbol: row.get(1)?,
5508 target,
5509 line: row.get::<_, i64>(2)?.max(0) as u32,
5510 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5511 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5512 resolved: row.get::<_, String>(5)? == "resolved",
5513 provenance: row.get(6)?,
5514 })
5515 },
5516 )?;
5517 rows.collect::<std::result::Result<Vec<_>, _>>()
5518 .map_err(Into::into)
5519}
5520
5521fn unresolved_calls_for_node(
5522 conn: &Connection,
5523 node: &StoreNode,
5524) -> Result<Vec<StoreUnresolvedCall>> {
5525 let mut stmt = conn.prepare(
5526 "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
5527 FROM refs
5528 WHERE caller_node = ?1
5529 AND kind = 'call'
5530 AND status = 'unresolved'
5531 AND NOT EXISTS (
5532 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
5533 )
5534 ORDER BY byte_start, line, ref_id",
5535 )?;
5536 let rows = stmt.query_map(params![node.node_id], |row| {
5537 Ok(StoreUnresolvedCall {
5538 caller: node.clone(),
5539 symbol: row.get(0)?,
5540 full_ref: row.get(1)?,
5541 line: row.get::<_, i64>(2)?.max(0) as u32,
5542 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5543 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5544 })
5545 })?;
5546 rows.collect::<std::result::Result<Vec<_>, _>>()
5547 .map_err(Into::into)
5548}
5549
5550fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
5551 let mut calls = Vec::new();
5552 calls.extend(
5553 outgoing_calls_for_node(conn, node)?
5554 .into_iter()
5555 .map(StoreForwardCall::Resolved),
5556 );
5557 calls.extend(
5558 unresolved_calls_for_node(conn, node)?
5559 .into_iter()
5560 .map(StoreForwardCall::Unresolved),
5561 );
5562 calls.sort_by(|left, right| {
5563 left.byte_start()
5564 .cmp(&right.byte_start())
5565 .then(left.line().cmp(&right.line()))
5566 });
5567 Ok(calls)
5568}
5569
5570fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
5571 let resolved_count: i64 = conn.query_row(
5572 "SELECT COUNT(*)
5573 FROM edges e
5574 JOIN refs r ON r.ref_id = e.ref_id
5575 WHERE e.kind = 'call' AND e.source_node = ?1",
5576 params![&node.node_id],
5577 |row| row.get(0),
5578 )?;
5579 let unresolved_count: i64 = conn.query_row(
5580 "SELECT COUNT(*)
5581 FROM refs
5582 WHERE caller_node = ?1
5583 AND kind = 'call'
5584 AND status = 'unresolved'
5585 AND NOT EXISTS (
5586 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
5587 )",
5588 params![&node.node_id],
5589 |row| row.get(0),
5590 )?;
5591 let total = resolved_count.saturating_add(unresolved_count);
5592 Ok(usize::try_from(total).unwrap_or(usize::MAX))
5593}
5594
5595fn call_tree_inner(
5596 conn: &Connection,
5597 node: &StoreNode,
5598 max_depth: usize,
5599 current_depth: usize,
5600 visited: &mut HashSet<(String, String)>,
5601) -> Result<callgraph::CallTreeNode> {
5602 let visit_key = (node.file.clone(), node.symbol.clone());
5603 if visited.contains(&visit_key) {
5604 return Ok(callgraph::CallTreeNode {
5605 name: node.symbol.clone(),
5606 file: node.file.clone(),
5607 line: node.line,
5608 signature: node.signature.clone(),
5609 resolved: true,
5610 children: Vec::new(),
5611 depth_limited: false,
5612 truncated: 0,
5613 });
5614 }
5615 visited.insert(visit_key.clone());
5616
5617 let mut children = Vec::new();
5618 let mut depth_limited = false;
5619 let mut truncated = 0usize;
5620
5621 if current_depth < max_depth {
5622 let calls = forward_calls_for_node(conn, node)?;
5623 for call in calls {
5624 match call {
5625 StoreForwardCall::Resolved(site) => {
5626 if let Some(target) = site.target {
5627 let child =
5628 call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
5629 depth_limited |= child.depth_limited;
5630 truncated += child.truncated;
5631 children.push(child);
5632 } else {
5633 children.push(callgraph::CallTreeNode {
5634 name: site.target_symbol,
5635 file: site.target_file,
5636 line: site.line,
5637 signature: None,
5638 resolved: false,
5639 children: Vec::new(),
5640 depth_limited: false,
5641 truncated: 0,
5642 });
5643 }
5644 }
5645 StoreForwardCall::Unresolved(call) => {
5646 children.push(callgraph::CallTreeNode {
5647 name: call.symbol,
5648 file: call.caller.file,
5649 line: call.line,
5650 signature: None,
5651 resolved: false,
5652 children: Vec::new(),
5653 depth_limited: false,
5654 truncated: 0,
5655 });
5656 }
5657 }
5658 }
5659 } else {
5660 truncated = forward_call_count_for_node(conn, node)?;
5661 depth_limited = truncated > 0;
5662 }
5663
5664 visited.remove(&visit_key);
5665 Ok(callgraph::CallTreeNode {
5666 name: node.symbol.clone(),
5667 file: node.file.clone(),
5668 line: node.line,
5669 signature: node.signature.clone(),
5670 resolved: true,
5671 children,
5672 depth_limited,
5673 truncated,
5674 })
5675}
5676
5677fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
5678 callgraph::TraceToSymbolHop {
5679 symbol: node.symbol.clone(),
5680 file: node.file.clone(),
5681 line: node.line,
5682 }
5683}
5684
5685fn trace_to_symbol_matches_target(
5686 node: &StoreNode,
5687 to_symbol: &str,
5688 to_file: Option<&str>,
5689) -> bool {
5690 if !symbol_query_matches(&node.symbol, to_symbol) {
5691 return false;
5692 }
5693 match to_file {
5694 Some(file) => node.file == file,
5695 None => true,
5696 }
5697}
5698
5699fn symbol_query_matches(symbol: &str, query: &str) -> bool {
5700 symbol == query || unqualified_name(symbol) == query
5701}
5702
5703fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
5704 let source = std::fs::read_to_string(path).ok()?;
5705 Some(source.lines().map(|line| line.trim().to_string()).collect())
5706}
5707
5708#[doc(hidden)]
5709pub fn live_callgraph_edge_snapshot(
5710 project_root: &Path,
5711 files: &[PathBuf],
5712) -> Result<BTreeSet<StoredEdge>> {
5713 let files = normalize_file_list(project_root, files)?;
5714 let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
5715 let mut file_data = Vec::new();
5716 for file in &files {
5717 let canon = canonicalize_path(file);
5718 let data = graph.build_file(&canon)?.clone();
5719 file_data.push((canon, data));
5720 }
5721
5722 let mut edges = BTreeSet::new();
5723 for (caller_file, data) in &file_data {
5724 for (caller_symbol, call_sites) in &data.calls_by_symbol {
5725 for call_site in call_sites {
5726 let resolution = graph.resolve_cross_file_edge(
5727 &call_site.full_callee,
5728 &call_site.callee_name,
5729 caller_file,
5730 &data.import_block,
5731 );
5732 let (target_file, target_symbol) = match resolution {
5733 EdgeResolution::Resolved { file, symbol } => (file, symbol),
5734 EdgeResolution::Unresolved { callee_name } => {
5735 if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
5736 continue;
5737 }
5738 let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
5739 data,
5740 caller_file,
5741 &callee_name,
5742 ) else {
5743 continue;
5744 };
5745 (caller_file.clone(), target_symbol)
5746 }
5747 };
5748 if target_file == *caller_file && target_symbol == *caller_symbol {
5749 continue;
5750 }
5751 edges.insert(StoredEdge {
5752 source_file: relative_path(project_root, caller_file),
5753 source_symbol: caller_symbol.clone(),
5754 target_file: relative_path(project_root, &target_file),
5755 target_symbol,
5756 kind: "call".to_string(),
5757 line: call_site.line,
5758 });
5759 }
5760 }
5761 }
5762 Ok(edges)
5763}
5764
5765fn rebuild_cooldown_records() -> &'static Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>
5766{
5767 SUCCESSFUL_REBUILDS.get_or_init(|| Mutex::new(HashMap::new()))
5768}
5769
5770fn rebuild_cooldown_key(callgraph_dir: &Path, project_key: &str) -> RebuildCooldownKey {
5771 RebuildCooldownKey {
5772 callgraph_dir: std::fs::canonicalize(callgraph_dir)
5773 .unwrap_or_else(|_| callgraph_dir.to_path_buf()),
5774 project_key: project_key.to_string(),
5775 }
5776}
5777
5778fn rebuild_cooldown_denial(
5779 callgraph_dir: &Path,
5780 project_key: &str,
5781 project_root: &Path,
5782 now: Instant,
5783) -> Option<(PathBuf, Duration)> {
5784 let key = rebuild_cooldown_key(callgraph_dir, project_key);
5785 let records = rebuild_cooldown_records()
5786 .lock()
5787 .unwrap_or_else(std::sync::PoisonError::into_inner);
5788 let record = records.get(&key)?;
5789 if record.project_root == project_root || !record.cross_root_cooldown_armed {
5790 return None;
5791 }
5792 let elapsed = now.saturating_duration_since(record.published_at);
5793 (elapsed < REBUILD_COOLDOWN).then(|| (record.project_root.clone(), REBUILD_COOLDOWN - elapsed))
5794}
5795
5796fn record_successful_rebuild(
5797 callgraph_dir: &Path,
5798 project_key: &str,
5799 project_root: &Path,
5800 published_at: Instant,
5801) {
5802 let key = rebuild_cooldown_key(callgraph_dir, project_key);
5803 let mut records = rebuild_cooldown_records()
5804 .lock()
5805 .unwrap_or_else(std::sync::PoisonError::into_inner);
5806 if records.len() >= 4_096 && !records.contains_key(&key) {
5807 if let Some(evict) = records.keys().next().cloned() {
5808 records.remove(&evict);
5809 }
5810 }
5811 let cross_root_cooldown_armed = records.get(&key).is_some_and(|previous| {
5812 previous.cross_root_cooldown_armed || previous.project_root != project_root
5813 });
5814 records.insert(
5815 key,
5816 RebuildCooldownRecord {
5817 project_root: project_root.to_path_buf(),
5818 published_at,
5819 cross_root_cooldown_armed,
5820 },
5821 );
5822}
5823
5824fn acquire_writer_lease(
5825 callgraph_dir: &Path,
5826 project_key: &str,
5827 project_root: &Path,
5828) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
5829 crate::root_cache::WriterLease::acquire_shared(
5830 crate::root_cache::RootCacheDomain::Callgraph,
5831 callgraph_dir,
5832 project_key,
5833 project_root,
5834 )
5835 .map_err(CallGraphStoreError::from)
5836}
5837
5838fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
5839 if lease.verify()? {
5840 Ok(())
5841 } else {
5842 Err(CallGraphStoreError::Unavailable(format!(
5843 "callgraph writer lease for key {} lost epoch {}; aborting write",
5844 lease.key(),
5845 lease.epoch()
5846 )))
5847 }
5848}
5849
5850fn legacy_migration_completion_line(
5851 project_key: &str,
5852 method: &str,
5853 legacy_bytes: u64,
5854 migrated_bytes: u64,
5855) -> String {
5856 format!(
5857 "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
5858 )
5859}
5860
5861fn log_legacy_migration_completion(
5862 project_key: &str,
5863 method: &str,
5864 legacy_bytes: u64,
5865 migrated_bytes: u64,
5866) {
5867 crate::slog_info!(
5868 "{}",
5869 legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
5870 );
5871}
5872
5873fn try_legacy_migration_or_fallback(
5874 callgraph_dir: &Path,
5875 project_root: &Path,
5876 project_key: &str,
5877 writer_lease: Arc<crate::root_cache::WriterLease>,
5878) -> Result<Option<CallGraphStore>> {
5879 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
5880 if partitions.is_empty() {
5881 return Ok(None);
5882 }
5883
5884 for partition in &partitions {
5885 if let Some(source) = newest_superseded_legacy_generation(partition)? {
5886 if !migration_disk_floor_allows(&source, callgraph_dir)? {
5887 return open_legacy_fallback_store(
5888 callgraph_dir,
5889 project_root,
5890 project_key,
5891 &partitions,
5892 );
5893 }
5894 match publish_generation_copy_migration(
5895 callgraph_dir,
5896 project_key,
5897 &source,
5898 Arc::clone(&writer_lease),
5899 ) {
5900 Ok(published) => {
5901 log_legacy_migration_completion(
5902 project_key,
5903 "generation_copy",
5904 source.source_bytes,
5905 published.migrated_bytes,
5906 );
5907 return CallGraphStore::open_generation(
5908 callgraph_dir,
5909 project_root.to_path_buf(),
5910 project_key.to_string(),
5911 published.generation,
5912 writer_lease,
5913 )
5914 .map(Some);
5915 }
5916 Err(error) => {
5917 crate::slog_warn!(
5918 "root-keyed callgraph generation-copy migration failed from {}: {}",
5919 source.sqlite_path.display(),
5920 error
5921 );
5922 return open_legacy_fallback_store(
5923 callgraph_dir,
5924 project_root,
5925 project_key,
5926 &partitions,
5927 );
5928 }
5929 }
5930 }
5931
5932 if let Some(source) = current_legacy_generation(partition)? {
5933 if !migration_disk_floor_allows(&source, callgraph_dir)? {
5934 return open_legacy_fallback_store(
5935 callgraph_dir,
5936 project_root,
5937 project_key,
5938 &partitions,
5939 );
5940 }
5941 match publish_backup_migration(
5942 callgraph_dir,
5943 project_key,
5944 &source,
5945 Arc::clone(&writer_lease),
5946 ) {
5947 Ok(published) => {
5948 log_legacy_migration_completion(
5949 project_key,
5950 "sqlite_backup",
5951 source.source_bytes,
5952 published.migrated_bytes,
5953 );
5954 return CallGraphStore::open_generation(
5955 callgraph_dir,
5956 project_root.to_path_buf(),
5957 project_key.to_string(),
5958 published.generation,
5959 writer_lease,
5960 )
5961 .map(Some);
5962 }
5963 Err(error) => {
5964 crate::slog_warn!(
5965 "root-keyed callgraph backup migration failed from {}: {}",
5966 source.sqlite_path.display(),
5967 error
5968 );
5969 return open_legacy_fallback_store(
5970 callgraph_dir,
5971 project_root,
5972 project_key,
5973 &partitions,
5974 );
5975 }
5976 }
5977 }
5978 }
5979
5980 open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
5981}
5982
5983fn open_legacy_fallback_store(
5984 callgraph_dir: &Path,
5985 project_root: &Path,
5986 project_key: &str,
5987 partitions: &[LegacyCallgraphPartition],
5988) -> Result<Option<CallGraphStore>> {
5989 let Some(target) = first_ready_legacy_target(partitions)? else {
5990 return Ok(None);
5991 };
5992 crate::slog_warn!(
5993 "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
5994 target.partition.harness,
5995 target.sqlite_path.display()
5996 );
5997 let conn = open_readonly_connection(&target.sqlite_path)?;
5998 if !database_ready(&conn).unwrap_or(false) {
5999 return Ok(None);
6000 }
6001 let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
6002 let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
6003 Ok(Some(CallGraphStore::from_connection(
6004 project_root.to_path_buf(),
6005 project_key.to_string(),
6006 target.sqlite_path,
6007 callgraph_dir.to_path_buf(),
6008 true,
6009 target.generation,
6010 None,
6011 Some(read_marker),
6012 conn,
6013 )))
6014}
6015
6016fn migration_disk_floor_allows(
6017 source: &LegacyCallgraphTarget,
6018 callgraph_dir: &Path,
6019) -> Result<bool> {
6020 let available = migration_available_disk(callgraph_dir)?;
6021 let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
6022 source.source_bytes,
6023 available,
6024 );
6025 if decision.should_skip_copy() {
6026 crate::slog_warn!(
6027 "{}",
6028 decision.warning_message(&source.sqlite_path, callgraph_dir)
6029 );
6030 return Ok(false);
6031 }
6032 Ok(true)
6033}
6034
6035fn migration_available_disk(path: &Path) -> Result<u64> {
6036 if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
6037 return Ok(bytes);
6038 }
6039 crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
6040}
6041
6042fn legacy_callgraph_partitions(
6043 callgraph_dir: &Path,
6044 project_key: &str,
6045) -> Result<Vec<LegacyCallgraphPartition>> {
6046 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6047 return Ok(Vec::new());
6048 };
6049 let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
6050 let mut partitions = inventory
6051 .into_iter()
6052 .filter(|entry| {
6053 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
6054 && entry.key == project_key
6055 })
6056 .map(|entry| {
6057 let dir = if entry.path.is_dir() {
6058 entry.path.clone()
6059 } else {
6060 entry
6061 .path
6062 .parent()
6063 .map(Path::to_path_buf)
6064 .unwrap_or_else(|| entry.path.clone())
6065 };
6066 LegacyCallgraphPartition {
6067 harness: entry.harness,
6068 dir,
6069 key: entry.key,
6070 bytes: entry.bytes,
6071 freshness: entry.callgraph_pointer_mtime,
6072 }
6073 })
6074 .collect::<Vec<_>>();
6075 partitions.sort_by(|left, right| {
6076 right
6077 .freshness
6078 .cmp(&left.freshness)
6079 .then_with(|| right.bytes.cmp(&left.bytes))
6080 .then_with(|| left.harness.cmp(&right.harness))
6081 });
6082 Ok(partitions)
6083}
6084
6085fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
6086 let domain_dir = callgraph_dir.parent()?;
6087 if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
6088 return None;
6089 }
6090 domain_dir.parent().map(Path::to_path_buf)
6091}
6092
6093pub(crate) fn all_legacy_partitions_migrated_for_keys(
6094 callgraph_dir: &Path,
6095 configured_keys: &BTreeSet<String>,
6096) -> Result<bool> {
6097 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6098 return Ok(false);
6099 };
6100 let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
6101 .into_iter()
6102 .filter(|entry| {
6103 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
6104 && configured_keys.contains(&entry.key)
6105 })
6106 .map(|entry| entry.key)
6107 .collect::<BTreeSet<_>>();
6108 if legacy_keys.is_empty() {
6109 return Ok(false);
6110 }
6111
6112 for key in legacy_keys {
6113 let migrated_dir = storage_root.join("callgraph").join(&key);
6114 let Some(generation) = read_pointer(&migrated_dir, &key) else {
6115 return Ok(false);
6116 };
6117 if !migration_generation_requires_manifest(&generation)
6118 || !migration_manifest_valid(&migrated_dir, &generation)
6119 {
6120 return Ok(false);
6121 }
6122 }
6123 Ok(true)
6124}
6125
6126fn newest_superseded_legacy_generation(
6127 partition: &LegacyCallgraphPartition,
6128) -> Result<Option<LegacyCallgraphTarget>> {
6129 let Some(current) = read_pointer(&partition.dir, &partition.key) else {
6130 return Ok(None);
6131 };
6132 let prefix = format!("{}.g", partition.key);
6133 let Ok(entries) = std::fs::read_dir(&partition.dir) else {
6134 return Ok(None);
6135 };
6136 let mut candidates = Vec::new();
6137 for entry in entries.flatten() {
6138 let name = entry.file_name().to_string_lossy().to_string();
6139 if name == current
6140 || name.contains(".tmp.")
6141 || !name.starts_with(&prefix)
6142 || !name.ends_with(".sqlite")
6143 {
6144 continue;
6145 }
6146 let path = entry.path();
6147 if !db_path_ready(&path) {
6148 continue;
6149 }
6150 let modified = entry
6151 .metadata()
6152 .and_then(|metadata| metadata.modified())
6153 .unwrap_or(SystemTime::UNIX_EPOCH);
6154 candidates.push((modified, path, name));
6155 }
6156 candidates.sort_by(|left, right| right.0.cmp(&left.0));
6157 let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
6158 return Ok(None);
6159 };
6160 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6161 Ok(Some(LegacyCallgraphTarget {
6162 partition: partition.clone(),
6163 sqlite_path,
6164 generation: Some(generation),
6165 source_bytes,
6166 source_blake3: String::new(),
6167 }))
6168}
6169
6170fn current_legacy_generation(
6171 partition: &LegacyCallgraphPartition,
6172) -> Result<Option<LegacyCallgraphTarget>> {
6173 let Some(target) = ready_legacy_target(partition)? else {
6174 return Ok(None);
6175 };
6176 let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
6177 if has_superseded {
6178 return Ok(None);
6179 }
6180 Ok(Some(target))
6181}
6182
6183fn freshest_legacy_fallback_target(
6184 callgraph_dir: &Path,
6185 project_key: &str,
6186) -> Result<Option<LegacyCallgraphTarget>> {
6187 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
6188 first_ready_legacy_target(&partitions)
6189}
6190
6191fn first_ready_legacy_target(
6192 partitions: &[LegacyCallgraphPartition],
6193) -> Result<Option<LegacyCallgraphTarget>> {
6194 for partition in partitions {
6195 if let Some(target) = ready_legacy_target(partition)? {
6196 return Ok(Some(target));
6197 }
6198 }
6199 Ok(None)
6200}
6201
6202fn ready_legacy_target(
6203 partition: &LegacyCallgraphPartition,
6204) -> Result<Option<LegacyCallgraphTarget>> {
6205 if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
6206 let sqlite_path = partition.dir.join(&generation);
6207 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6208 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6209 return Ok(Some(LegacyCallgraphTarget {
6210 partition: partition.clone(),
6211 sqlite_path,
6212 generation: Some(generation),
6213 source_bytes,
6214 source_blake3: String::new(),
6215 }));
6216 }
6217 }
6218
6219 let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
6220 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6221 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6222 return Ok(Some(LegacyCallgraphTarget {
6223 partition: partition.clone(),
6224 sqlite_path,
6225 generation: None,
6226 source_bytes,
6227 source_blake3: String::new(),
6228 }));
6229 }
6230 Ok(None)
6231}
6232
6233fn publish_generation_copy_migration(
6234 callgraph_dir: &Path,
6235 project_key: &str,
6236 source: &LegacyCallgraphTarget,
6237 writer_lease: Arc<crate::root_cache::WriterLease>,
6238) -> Result<PublishedLegacyMigration> {
6239 let generation = migration_generation_file_name(project_key, "copy");
6240 let temp_path = migration_temp_path(callgraph_dir, &generation);
6241 remove_sqlite_file_set(&temp_path);
6242 copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
6243 fail_after_temp_copy_for_test()?;
6244
6245 let mut source = source.clone();
6246 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
6247 source.source_blake3 = fingerprint.blake3;
6248 let generation = publish_migrated_generation(
6249 callgraph_dir,
6250 project_key,
6251 &generation,
6252 &temp_path,
6253 &source,
6254 fingerprint.bytes,
6255 writer_lease,
6256 "generation_copy",
6257 )?;
6258 Ok(PublishedLegacyMigration {
6259 generation,
6260 migrated_bytes: fingerprint.bytes,
6261 })
6262}
6263
6264fn publish_backup_migration(
6265 callgraph_dir: &Path,
6266 project_key: &str,
6267 source: &LegacyCallgraphTarget,
6268 writer_lease: Arc<crate::root_cache::WriterLease>,
6269) -> Result<PublishedLegacyMigration> {
6270 if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
6271 return Err(CallGraphStoreError::Unavailable(
6272 "legacy callgraph backup migration budget exhausted by test seam".to_string(),
6273 ));
6274 }
6275
6276 let generation = migration_generation_file_name(project_key, "backup");
6277 let temp_path = migration_temp_path(callgraph_dir, &generation);
6278 remove_sqlite_file_set(&temp_path);
6279
6280 let source_conn = open_readonly_connection(&source.sqlite_path)?;
6281 let mut destination = Connection::open(&temp_path)?;
6282 destination.busy_timeout(Duration::from_secs(5))?;
6283 let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
6284 let started = Instant::now();
6285 let mut retries = 0;
6286 loop {
6287 match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
6288 rusqlite::backup::StepResult::Done => break,
6289 rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
6290 rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
6291 retries += 1;
6292 if retries > MIGRATION_BACKUP_RETRY_BUDGET
6293 || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
6294 {
6295 return Err(CallGraphStoreError::Unavailable(format!(
6296 "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
6297 )));
6298 }
6299 std::thread::sleep(Duration::from_millis(20));
6300 }
6301 _ => {
6302 return Err(CallGraphStoreError::Unavailable(
6303 "legacy callgraph backup returned an unknown step result".to_string(),
6304 ));
6305 }
6306 }
6307 }
6308 drop(backup);
6309
6310 let integrity: String =
6311 destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
6312 if integrity != "ok" {
6313 return Err(CallGraphStoreError::Unavailable(format!(
6314 "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
6315 )));
6316 }
6317 if !database_ready(&destination)? {
6318 return Err(CallGraphStoreError::Unavailable(
6319 "legacy callgraph backup produced a database without ready metadata".to_string(),
6320 ));
6321 }
6322 destination.execute_batch("PRAGMA optimize;")?;
6323 drop(destination);
6324 sync_file(&temp_path)?;
6325 fail_after_temp_copy_for_test()?;
6326
6327 let mut source = source.clone();
6328 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
6329 source.source_blake3 = fingerprint.blake3;
6330 let generation = publish_migrated_generation(
6331 callgraph_dir,
6332 project_key,
6333 &generation,
6334 &temp_path,
6335 &source,
6336 fingerprint.bytes,
6337 writer_lease,
6338 "sqlite_backup",
6339 )?;
6340 Ok(PublishedLegacyMigration {
6341 generation,
6342 migrated_bytes: fingerprint.bytes,
6343 })
6344}
6345
6346fn publish_migrated_generation(
6347 callgraph_dir: &Path,
6348 project_key: &str,
6349 generation: &str,
6350 temp_path: &Path,
6351 source: &LegacyCallgraphTarget,
6352 migrated_bytes: u64,
6353 writer_lease: Arc<crate::root_cache::WriterLease>,
6354 method: &str,
6355) -> Result<String> {
6356 let gen_path = callgraph_dir.join(generation);
6357 checkpoint_sqlite_before_publication(temp_path);
6358 let publication = publish_if_current(|| {
6359 verify_writer_lease(&writer_lease)?;
6360 remove_sqlite_file_set(&gen_path);
6361 rename_sqlite_file_set(temp_path, &gen_path)?;
6362 crate::fs_lock::sync_parent(&gen_path);
6363
6364 verify_writer_lease(&writer_lease)?;
6365 publish_pointer(callgraph_dir, project_key, generation)?;
6366 write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
6367 Ok(generation.to_string())
6368 });
6369 if matches!(publication, Err(CallGraphStoreError::Superseded)) {
6370 remove_sqlite_file_set(temp_path);
6371 }
6372 publication
6373}
6374
6375fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
6376 if let Some(parent) = destination.parent() {
6377 std::fs::create_dir_all(parent)?;
6378 }
6379 for suffix in SQLITE_FILE_SET_SUFFIXES {
6380 let source_path = sqlite_file_set_path(source, suffix);
6381 if !source_path.is_file() {
6382 continue;
6383 }
6384 let destination_path = sqlite_file_set_path(destination, suffix);
6385 std::fs::copy(&source_path, &destination_path)?;
6386 sync_file(&destination_path)?;
6387 }
6388 Ok(())
6389}
6390
6391fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
6392 for suffix in SQLITE_FILE_SET_SUFFIXES {
6393 let source_path = sqlite_file_set_path(source, suffix);
6394 if !source_path.exists() {
6395 continue;
6396 }
6397 let destination_path = sqlite_file_set_path(destination, suffix);
6398 if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
6399 let _ = std::fs::remove_file(&source_path);
6400 return Err(error.into());
6401 }
6402 }
6403 Ok(())
6404}
6405
6406fn sqlite_file_set_size(path: &Path) -> Result<u64> {
6407 let mut bytes = 0_u64;
6408 for suffix in SQLITE_FILE_SET_SUFFIXES {
6409 let member = sqlite_file_set_path(path, suffix);
6410 if !member.is_file() {
6411 continue;
6412 }
6413 bytes = bytes.saturating_add(member.metadata()?.len());
6414 }
6415 Ok(bytes)
6416}
6417
6418fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
6419 let mut hasher = blake3::Hasher::new();
6420 let mut bytes = 0_u64;
6421 let mut buffer = [0_u8; 64 * 1024];
6422 for suffix in SQLITE_FILE_SET_SUFFIXES {
6423 let member = sqlite_file_set_path(path, suffix);
6424 if !member.is_file() {
6425 continue;
6426 }
6427 hasher.update(suffix.as_bytes());
6428 let mut file = std::fs::File::open(&member)?;
6429 loop {
6430 let read = file.read(&mut buffer)?;
6431 if read == 0 {
6432 break;
6433 }
6434 bytes = bytes.saturating_add(read as u64);
6435 hasher.update(&buffer[..read]);
6436 }
6437 }
6438 Ok(SourceFingerprint {
6439 bytes,
6440 blake3: hash_to_hex(hasher.finalize()),
6441 })
6442}
6443
6444fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
6445 if suffix.is_empty() {
6446 path.to_path_buf()
6447 } else {
6448 PathBuf::from(format!("{}{suffix}", path.display()))
6449 }
6450}
6451
6452fn sync_file(path: &Path) -> Result<()> {
6453 let file = std::fs::OpenOptions::new()
6454 .read(true)
6455 .write(true)
6456 .open(path)?;
6457 file.sync_all()?;
6458 Ok(())
6459}
6460
6461fn fail_after_temp_copy_for_test() -> Result<()> {
6462 if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
6463 return Err(CallGraphStoreError::Unavailable(
6464 "legacy callgraph migration stopped after temp copy by test seam".to_string(),
6465 ));
6466 }
6467 Ok(())
6468}
6469
6470fn migration_generation_file_name(project_key: &str, method: &str) -> String {
6471 format!(
6472 "{project_key}.g{}.{}{}{}.sqlite",
6473 now_nanos(),
6474 std::process::id(),
6475 MIGRATION_GENERATION_TAG,
6476 method
6477 )
6478}
6479
6480fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
6481 callgraph_dir.join(format!(
6482 "{generation}.tmp.{}.{}",
6483 std::process::id(),
6484 now_nanos()
6485 ))
6486}
6487
6488fn write_migration_manifest(
6489 callgraph_dir: &Path,
6490 generation: &str,
6491 source: &LegacyCallgraphTarget,
6492 migrated_bytes: u64,
6493 method: &str,
6494) -> Result<()> {
6495 let manifest_path = migration_manifest_path(callgraph_dir, generation);
6496 let temp_path = manifest_path.with_extension(format!(
6497 "migration.json.tmp.{}.{}",
6498 std::process::id(),
6499 now_nanos()
6500 ));
6501 let manifest = serde_json::json!({
6502 "version": MIGRATION_MANIFEST_VERSION,
6503 "method": method,
6504 "target_generation": generation,
6505 "source_harness": source.partition.harness,
6506 "source_path": source.sqlite_path.display().to_string(),
6507 "source_generation": source.generation,
6508 "source_bytes": source.source_bytes,
6509 "source_blake3": source.source_blake3,
6510 "migrated_bytes": migrated_bytes,
6511 });
6512 {
6513 use std::io::Write as _;
6514 let mut file = std::fs::File::create(&temp_path)?;
6515 file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
6516 file.write_all(b"\n")?;
6517 file.sync_all()?;
6518 }
6519 if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
6520 let _ = std::fs::remove_file(&temp_path);
6521 return Err(error.into());
6522 }
6523 crate::fs_lock::sync_parent(&manifest_path);
6524 Ok(())
6525}
6526
6527fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
6528 callgraph_dir.join(format!("{generation}.migration.json"))
6529}
6530
6531fn migration_generation_requires_manifest(generation: &str) -> bool {
6532 generation.contains(MIGRATION_GENERATION_TAG)
6533}
6534
6535fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
6536 if !migration_generation_requires_manifest(generation) {
6537 return true;
6538 }
6539 let path = migration_manifest_path(callgraph_dir, generation);
6540 let Ok(bytes) = std::fs::read(path) else {
6541 return false;
6542 };
6543 let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
6544 return false;
6545 };
6546 value.get("version").and_then(serde_json::Value::as_u64)
6547 == Some(MIGRATION_MANIFEST_VERSION as u64)
6548 && value
6549 .get("target_generation")
6550 .and_then(serde_json::Value::as_str)
6551 == Some(generation)
6552 && value
6553 .get("source_bytes")
6554 .and_then(serde_json::Value::as_u64)
6555 .is_some_and(|bytes| bytes > 0)
6556 && value
6557 .get("source_blake3")
6558 .and_then(serde_json::Value::as_str)
6559 .is_some_and(|hash| hash.len() == 64)
6560}
6561
6562fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
6563 let pointer_generation = read_pointer(callgraph_dir, project_key);
6564 if let Some(generation) = pointer_generation.as_deref() {
6565 if migration_generation_requires_manifest(generation)
6566 && !migration_manifest_valid(callgraph_dir, generation)
6567 {
6568 let path = callgraph_dir.join(generation);
6569 remove_sqlite_file_set(&path);
6570 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
6571 let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
6572 }
6573 }
6574
6575 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6576 return;
6577 };
6578 for entry in entries.flatten() {
6579 let name = entry.file_name().to_string_lossy().to_string();
6580 let path = entry.path();
6581 if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
6582 let _ = std::fs::remove_file(path);
6583 continue;
6584 }
6585 if name.starts_with(&format!("{project_key}.g"))
6586 && name.ends_with(".sqlite")
6587 && name.contains(MIGRATION_GENERATION_TAG)
6588 && pointer_generation.as_deref() != Some(&name)
6589 && !migration_manifest_valid(callgraph_dir, &name)
6590 {
6591 remove_sqlite_file_set(&path);
6592 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
6593 }
6594 }
6595 crate::fs_lock::sync_parent(callgraph_dir);
6596}
6597
6598fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
6599 let mut hasher = blake3::Hasher::new();
6600 hasher.update(path.to_string_lossy().as_bytes());
6601 if let Some(generation) = generation {
6602 hasher.update(generation.as_bytes());
6603 }
6604 let digest = hash_to_hex(hasher.finalize());
6605 format!("legacy-{}", &digest[..16])
6606}
6607
6608fn open_readonly_connection(path: &Path) -> Result<Connection> {
6609 let uri = sqlite_readonly_uri(path);
6610 let conn = Connection::open_with_flags(
6611 &uri,
6612 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
6613 )?;
6614 conn.pragma_update(
6615 None,
6616 "synchronous",
6617 if write_amplification_baseline_enabled() {
6618 "FULL"
6619 } else {
6620 "NORMAL"
6621 },
6622 )?;
6623 conn.busy_timeout(reader_busy_timeout())?;
6624 conn.execute_batch("PRAGMA query_only=ON;")?;
6625 Ok(conn)
6626}
6627
6628fn reader_busy_timeout() -> Duration {
6629 let jitter = (now_nanos() % 500) as u64;
6630 Duration::from_millis(250 + jitter)
6631}
6632
6633fn sqlite_readonly_uri(path: &Path) -> String {
6634 let raw = path.to_string_lossy().replace('\\', "/");
6635 let encoded = percent_encode_sqlite_uri_path(&raw);
6636 if raw.starts_with('/') {
6637 format!("file://{encoded}?mode=ro")
6638 } else if raw.as_bytes().get(1) == Some(&b':') {
6639 format!("file:///{encoded}?mode=ro")
6640 } else {
6641 format!("file:{encoded}?mode=ro")
6642 }
6643}
6644
6645fn percent_encode_sqlite_uri_path(path: &str) -> String {
6646 let mut encoded = String::with_capacity(path.len());
6647 for byte in path.bytes() {
6648 match byte {
6649 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
6650 encoded.push(byte as char)
6651 }
6652 _ => encoded.push_str(&format!("%{byte:02X}")),
6653 }
6654 }
6655 encoded
6656}
6657
6658fn configure_connection(conn: &Connection) -> Result<()> {
6659 conn.busy_timeout(Duration::from_secs(5))?;
6663 conn.pragma_update(None, "journal_mode", "WAL")?;
6664 let baseline = write_amplification_baseline_enabled();
6665 conn.pragma_update(
6666 None,
6667 "synchronous",
6668 if baseline { "FULL" } else { "NORMAL" },
6669 )?;
6670 conn.pragma_update(
6671 None,
6672 "wal_autocheckpoint",
6673 if baseline {
6674 1_000
6675 } else {
6676 CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES
6677 },
6678 )?;
6679 conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
6680 Ok(())
6681}
6682
6683fn configure_build_connection(conn: &Connection) -> Result<()> {
6684 conn.busy_timeout(Duration::from_secs(5))?;
6689 conn.pragma_update(None, "journal_mode", "WAL")?;
6690 conn.pragma_update(
6691 None,
6692 "synchronous",
6693 if write_amplification_baseline_enabled() {
6694 "FULL"
6695 } else {
6696 "NORMAL"
6697 },
6698 )?;
6699 conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
6700 Ok(())
6701}
6702
6703fn checkpoint_sqlite_before_publication(path: &Path) {
6707 let Ok(conn) = Connection::open(path) else {
6708 return;
6709 };
6710 let _ = conn.pragma_update(None, "synchronous", "NORMAL");
6711 let _ = conn.busy_timeout(Duration::from_secs(5));
6712 let _ = checkpoint_wal_truncate(&conn);
6713}
6714
6715fn checkpoint_wal_truncate(conn: &Connection) -> bool {
6716 match conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
6717 row.get::<_, i64>(0)
6718 }) {
6719 Ok(0) => true,
6720 Ok(_) => false,
6721 Err(rusqlite::Error::SqliteFailure(error, _))
6722 if matches!(
6723 error.code,
6724 rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
6725 ) =>
6726 {
6727 false
6728 }
6729 Err(error) => {
6730 log::debug!("callgraph WAL truncate checkpoint skipped: {error}");
6731 false
6732 }
6733 }
6734}
6735
6736fn initialize_schema(conn: &Connection) -> Result<()> {
6737 conn.execute_batch(
6738 "CREATE TABLE IF NOT EXISTS files (
6739 path TEXT PRIMARY KEY,
6740 content_hash TEXT NOT NULL,
6741 mtime_ns INTEGER NOT NULL,
6742 size INTEGER NOT NULL,
6743 lang TEXT NOT NULL,
6744 is_dead_code_root INTEGER NOT NULL DEFAULT 0,
6745 is_public_api INTEGER NOT NULL DEFAULT 0,
6746 surface_fingerprint TEXT NOT NULL,
6747 indexed_at INTEGER NOT NULL
6748 );
6749
6750 CREATE TABLE IF NOT EXISTS nodes (
6751 id TEXT PRIMARY KEY,
6752 file_path TEXT NOT NULL,
6753 name TEXT NOT NULL,
6754 scoped_name TEXT NOT NULL,
6755 kind TEXT NOT NULL,
6756 start_line INTEGER NOT NULL,
6757 start_col INTEGER NOT NULL,
6758 end_line INTEGER NOT NULL,
6759 end_col INTEGER NOT NULL,
6760 range_ordinal INTEGER NOT NULL,
6761 signature TEXT,
6762 exported INTEGER NOT NULL,
6763 is_default_export INTEGER NOT NULL,
6764 is_type_like INTEGER NOT NULL,
6765 is_callgraph_entry_point INTEGER NOT NULL,
6766 provenance TEXT NOT NULL,
6767 UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
6768 );
6769 CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
6770 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
6771 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
6772
6773 CREATE TABLE IF NOT EXISTS refs (
6774 ref_id TEXT PRIMARY KEY,
6775 caller_node TEXT,
6776 caller_file TEXT NOT NULL,
6777 kind TEXT NOT NULL,
6778 short_name TEXT,
6779 full_ref TEXT,
6780 module_path TEXT,
6781 import_kind TEXT,
6782 local_name TEXT,
6783 requested_name TEXT,
6784 namespace_alias TEXT,
6785 wildcard INTEGER NOT NULL DEFAULT 0,
6786 line INTEGER NOT NULL,
6787 byte_start INTEGER NOT NULL,
6788 byte_end INTEGER NOT NULL,
6789 status TEXT NOT NULL,
6790 target_node TEXT,
6791 target_file TEXT,
6792 target_symbol TEXT,
6793 provenance TEXT NOT NULL
6794 );
6795 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
6796 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
6797 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
6798 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
6799 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
6800
6801 CREATE TABLE IF NOT EXISTS file_dependencies (
6802 file_path TEXT NOT NULL,
6803 dep_file TEXT NOT NULL,
6804 PRIMARY KEY(file_path, dep_file)
6805 );
6806 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
6807
6808 CREATE TABLE IF NOT EXISTS edges (
6809 edge_id TEXT PRIMARY KEY,
6810 ref_id TEXT NOT NULL,
6811 source_node TEXT NOT NULL,
6812 target_node TEXT,
6813 target_file TEXT NOT NULL,
6814 target_symbol TEXT NOT NULL,
6815 kind TEXT NOT NULL,
6816 line INTEGER NOT NULL,
6817 provenance TEXT NOT NULL
6818 );
6819 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
6820 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
6821 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
6822 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
6823
6824 CREATE TABLE IF NOT EXISTS dispatch_hints (
6825 id TEXT PRIMARY KEY,
6826 method_name TEXT NOT NULL,
6827 caller_node TEXT NOT NULL,
6828 file TEXT NOT NULL,
6829 line INTEGER NOT NULL,
6830 byte_start INTEGER NOT NULL,
6831 byte_end INTEGER NOT NULL,
6832 provenance TEXT NOT NULL
6833 );
6834 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
6835 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
6836
6837 CREATE TABLE IF NOT EXISTS type_ref_names (
6838 name TEXT PRIMARY KEY
6839 );
6840
6841 CREATE TABLE IF NOT EXISTS backend_file_state (
6842 backend TEXT NOT NULL,
6843 workspace_root TEXT NOT NULL,
6844 file_path TEXT NOT NULL,
6845 content_hash TEXT NOT NULL,
6846 status TEXT NOT NULL,
6847 updated_at INTEGER NOT NULL,
6848 PRIMARY KEY(backend, workspace_root, file_path, content_hash)
6849 );
6850 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
6851
6852 CREATE TABLE IF NOT EXISTS meta (
6853 k TEXT PRIMARY KEY,
6854 v TEXT NOT NULL
6855 );
6856
6857 -- The file walk is staged on disk so extraction can page through a
6858 -- deterministic inventory without retaining every source path in heap.
6859 CREATE TABLE IF NOT EXISTS staging_file_inventory (
6860 path TEXT PRIMARY KEY,
6861 size INTEGER NOT NULL
6862 ) WITHOUT ROWID;
6863
6864 -- Context needed only while a generation is staged. Raw refs live in
6865 -- `refs` with status `staged`; this table preserves the caller symbol
6866 -- needed to avoid inventing self edges during the later resolve pass.
6867 CREATE TABLE IF NOT EXISTS staging_ref_context (
6868 ref_id TEXT PRIMARY KEY,
6869 caller_symbol TEXT
6870 );",
6871 )?;
6872 insert_meta(conn)?;
6873 Ok(())
6874}
6875
6876fn insert_meta(conn: &Connection) -> Result<()> {
6877 conn.execute(
6878 "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
6879 params![SCHEMA_VERSION.to_string()],
6880 )?;
6881 conn.execute(
6882 "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
6883 params![schema_fingerprint()],
6884 )?;
6885 conn.execute(
6886 "INSERT OR IGNORE INTO meta(k, v) VALUES('projection_write_revision', '0')",
6887 [],
6888 )?;
6889 Ok(())
6890}
6891
6892const PATH_IDENTITY_MISMATCH_META_KEY: &str = "path_identity_mismatch";
6896
6897fn record_path_identity_mismatch(conn: &Connection, error: &CallGraphStoreError) -> Result<()> {
6898 let CallGraphStoreError::PathIdentityMismatch { path, project_root } = error else {
6899 return Ok(());
6900 };
6901 conn.execute(
6902 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
6903 params![
6904 PATH_IDENTITY_MISMATCH_META_KEY,
6905 format!(
6906 "callgraph_path_identity_mismatch path={} project_root={}",
6907 path.display(),
6908 project_root.display()
6909 )
6910 ],
6911 )?;
6912 Ok(())
6913}
6914
6915pub(super) fn path_identity_mismatch_reason(conn: &Connection) -> Result<Option<String>> {
6916 conn.query_row(
6917 "SELECT v FROM meta WHERE k = ?1",
6918 [PATH_IDENTITY_MISMATCH_META_KEY],
6919 |row| row.get(0),
6920 )
6921 .optional()
6922 .map_err(Into::into)
6923}
6924
6925fn projection_write_revision(conn: &Connection) -> Result<Option<u64>> {
6926 let revision: Option<String> = conn
6927 .query_row(
6928 "SELECT v FROM meta WHERE k = 'projection_write_revision'",
6929 [],
6930 |row| row.get(0),
6931 )
6932 .optional()?;
6933 revision
6934 .map(|revision| {
6935 revision.parse::<u64>().map_err(|error| {
6936 CallGraphStoreError::Unavailable(format!(
6937 "callgraph projection write revision is invalid: {error}"
6938 ))
6939 })
6940 })
6941 .transpose()
6942}
6943
6944fn bump_projection_write_revision(tx: &Transaction<'_>) -> Result<()> {
6947 tx.execute(
6948 "INSERT INTO meta(k, v) VALUES('projection_write_revision', '1')
6949 ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1",
6950 [],
6951 )?;
6952 Ok(())
6953}
6954
6955fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
6956 conn.execute(
6957 "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
6958 params![if ready { "1" } else { "0" }],
6959 )?;
6960 Ok(())
6961}
6962
6963fn database_ready(conn: &Connection) -> Result<bool> {
6964 let schema_version: Option<String> = conn
6965 .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
6966 row.get(0)
6967 })
6968 .optional()?;
6969 let fingerprint: Option<String> = conn
6970 .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
6971 row.get(0)
6972 })
6973 .optional()?;
6974 let ready: Option<String> = conn
6975 .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
6976 .optional()?;
6977
6978 let expected_schema = SCHEMA_VERSION.to_string();
6979 let expected_fingerprint = schema_fingerprint();
6980 Ok(schema_version.as_deref() == Some(expected_schema.as_str())
6981 && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
6982 && ready.as_deref() == Some("1"))
6983}
6984
6985fn ensure_database_ready(conn: &Connection) -> Result<()> {
6986 if database_ready(conn)? {
6987 Ok(())
6988 } else {
6989 Err(CallGraphStoreError::Unavailable(
6990 "database is missing, stale, or mid-build".to_string(),
6991 ))
6992 }
6993}
6994
6995fn schema_fingerprint() -> String {
6996 let input =
7001 format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
7002 hash_to_hex(blake3::hash(input.as_bytes()))
7003}
7004
7005fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
7006 tx.execute_batch(
7007 "DELETE FROM staging_ref_context;
7008 DELETE FROM edges;
7009 DELETE FROM file_dependencies;
7010 DELETE FROM refs;
7011 DELETE FROM dispatch_hints;
7012 DELETE FROM type_ref_names;
7013 DELETE FROM backend_file_state;
7014 DELETE FROM nodes;
7015 DELETE FROM files;",
7016 )?;
7017 Ok(())
7018}
7019
7020fn staged_build_phase(conn: &Connection) -> Result<Option<String>> {
7021 conn.query_row(
7022 "SELECT v FROM meta WHERE k = ?1",
7023 params![STAGED_BUILD_PHASE],
7024 |row| row.get(0),
7025 )
7026 .optional()
7027 .map_err(Into::into)
7028}
7029
7030fn staged_u64(conn: &Connection, key: &str) -> Result<u64> {
7031 let value = staged_string(conn, key)?;
7032 Ok(value.and_then(|value| value.parse().ok()).unwrap_or(0))
7033}
7034
7035fn staged_string(conn: &Connection, key: &str) -> Result<Option<String>> {
7036 conn.query_row("SELECT v FROM meta WHERE k = ?1", params![key], |row| {
7037 row.get::<_, String>(0)
7038 })
7039 .optional()
7040 .map_err(Into::into)
7041}
7042
7043fn set_staged_build_phase(tx: &Transaction<'_>, phase: &str) -> Result<()> {
7044 tx.execute(
7045 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7046 params![STAGED_BUILD_PHASE, phase],
7047 )?;
7048 Ok(())
7049}
7050
7051fn set_staged_u64(tx: &Transaction<'_>, key: &str, value: u64) -> Result<()> {
7052 set_staged_string(tx, key, &value.to_string())
7053}
7054
7055fn set_staged_string(tx: &Transaction<'_>, key: &str, value: &str) -> Result<()> {
7056 tx.execute(
7057 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7058 params![key, value],
7059 )?;
7060 Ok(())
7061}
7062
7063fn increment_staged_extracted_bytes(tx: &Transaction<'_>, bytes: u64) -> Result<()> {
7067 tx.execute(
7068 "INSERT INTO meta(k, v) VALUES(?1, ?2)
7069 ON CONFLICT(k) DO UPDATE SET v = CAST(meta.v AS INTEGER) + excluded.v",
7070 params![STAGED_COMMITTED_EXTRACTED_BYTES, bytes.to_string()],
7071 )?;
7072 Ok(())
7073}
7074
7075fn staged_content_matches(conn: &Connection, project_root: &Path, path: &Path) -> Result<bool> {
7076 let Ok(source) = std::fs::read_to_string(path) else {
7077 return Ok(false);
7078 };
7079 let Ok(freshness) = collect_source_freshness(path, &source) else {
7080 return Ok(false);
7081 };
7082 let rel_path = relative_path(project_root, path);
7083 let staged_hash = conn
7084 .query_row(
7085 "SELECT content_hash FROM files WHERE path = ?1",
7086 params![rel_path],
7087 |row| row.get::<_, String>(0),
7088 )
7089 .optional()?;
7090 Ok(staged_hash.as_deref() == Some(hash_to_hex(freshness.content_hash).as_str()))
7091}
7092
7093fn delete_staged_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
7094 tx.execute(
7095 "DELETE FROM staging_ref_context
7096 WHERE ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?1)",
7097 params![rel_path],
7098 )?;
7099 delete_file_rows(tx, rel_path)
7100}
7101
7102fn prune_staged_files_not_in_inventory(conn: &mut Connection) -> Result<()> {
7103 loop {
7104 let removed = {
7105 let mut statement = conn.prepare(
7106 "SELECT path
7107 FROM files
7108 WHERE NOT EXISTS (
7109 SELECT 1 FROM staging_file_inventory inventory
7110 WHERE inventory.path = files.path
7111 )
7112 ORDER BY path
7113 LIMIT ?1",
7114 )?;
7115 let paths = statement
7116 .query_map(params![COLD_BUILD_EXTRACT_BATCH_FILES as i64], |row| {
7117 row.get::<_, String>(0)
7118 })?
7119 .collect::<std::result::Result<Vec<_>, _>>()?;
7120 paths
7121 };
7122 if removed.is_empty() {
7123 return Ok(());
7124 }
7125 let tx = conn.transaction()?;
7126 for path in removed {
7127 delete_staged_file_rows(&tx, &path)?;
7128 }
7129 tx.commit()?;
7130 }
7131}
7132
7133struct StagedFileBatch {
7134 paths: Vec<PathBuf>,
7135 last_path: String,
7136}
7137
7138fn load_staged_file_batch(
7139 conn: &Connection,
7140 project_root: &Path,
7141 after_path: &str,
7142 max_files: usize,
7143 max_bytes: u64,
7144) -> Result<Option<StagedFileBatch>> {
7145 let mut statement = conn.prepare(
7146 "SELECT path, size
7147 FROM staging_file_inventory
7148 WHERE path > ?1
7149 ORDER BY path
7150 LIMIT ?2",
7151 )?;
7152 let mut rows = statement.query(params![after_path, max_files.max(1) as i64])?;
7153 let mut paths = Vec::with_capacity(max_files.max(1));
7154 let mut last_path = String::new();
7155 let mut batch_bytes = 0u64;
7156 while let Some(row) = rows.next()? {
7157 let rel_path = row.get::<_, String>(0)?;
7158 let size = row.get::<_, i64>(1)?.max(0) as u64;
7159 if !paths.is_empty() && batch_bytes.saturating_add(size) > max_bytes {
7160 break;
7161 }
7162 batch_bytes = batch_bytes.saturating_add(size);
7163 last_path.clone_from(&rel_path);
7164 paths.push(project_root.join(rel_path));
7165 }
7166 if paths.is_empty() {
7167 Ok(None)
7168 } else {
7169 Ok(Some(StagedFileBatch { paths, last_path }))
7170 }
7171}
7172
7173fn staged_corpus_fingerprint(conn: &Connection, project_root: &Path) -> Result<String> {
7174 let mut statement = conn.prepare("SELECT path FROM staging_file_inventory ORDER BY path")?;
7175 let mut rows = statement.query([])?;
7176 let mut fingerprint = CorpusFingerprint::default();
7177 while let Some(row) = rows.next()? {
7178 let rel_path = row.get::<_, String>(0)?;
7179 fingerprint.add_path(project_root, &project_root.join(rel_path));
7180 }
7181 Ok(fingerprint.finish(project_root))
7182}
7183
7184fn load_staged_ref_window(
7185 conn: &Connection,
7186 after_rowid: u64,
7187 limit: usize,
7188) -> Result<Vec<StagedRef>> {
7189 let mut statement = conn.prepare(
7190 "SELECT refs.rowid, refs.ref_id, refs.caller_node, refs.caller_file, refs.kind,
7191 refs.short_name, refs.full_ref, refs.module_path, refs.import_kind,
7192 refs.local_name, refs.requested_name, refs.namespace_alias, refs.wildcard,
7193 refs.line, refs.byte_start, refs.byte_end, staging_ref_context.caller_symbol
7194 FROM refs
7195 LEFT JOIN staging_ref_context ON staging_ref_context.ref_id = refs.ref_id
7196 WHERE refs.status = 'staged' AND refs.rowid > ?1
7197 ORDER BY refs.rowid
7198 LIMIT ?2",
7199 )?;
7200 let rows = statement.query_map(params![after_rowid as i64, limit as i64], |row| {
7201 Ok(StagedRef {
7202 rowid: row.get::<_, i64>(0)? as u64,
7203 raw: RawRef {
7204 ref_id: row.get(1)?,
7205 caller_node: row.get(2)?,
7206 caller_file: row.get(3)?,
7207 kind: row.get(4)?,
7208 short_name: row.get(5)?,
7209 full_ref: row.get(6)?,
7210 module_path: row.get(7)?,
7211 import_kind: row.get(8)?,
7212 local_name: row.get(9)?,
7213 requested_name: row.get(10)?,
7214 namespace_alias: row.get(11)?,
7215 wildcard: row.get::<_, i64>(12)? != 0,
7216 line: row.get::<_, i64>(13)? as u32,
7217 byte_start: row.get::<_, i64>(14)? as usize,
7218 byte_end: row.get::<_, i64>(15)? as usize,
7219 caller_symbol: row.get(16)?,
7220 dependencies: BTreeSet::new(),
7221 },
7222 })
7223 })?;
7224 let mut refs = rows.collect::<std::result::Result<Vec<_>, _>>()?;
7225 drop(statement);
7226
7227 let mut dependencies = HashMap::<String, BTreeSet<String>>::new();
7228 let mut dependency_statement = conn
7229 .prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1 ORDER BY dep_file")?;
7230 for raw in refs.iter_mut().map(|entry| &mut entry.raw) {
7231 if !dependencies.contains_key(&raw.caller_file) {
7232 let rows =
7233 dependency_statement.query_map(params![raw.caller_file], |row| row.get(0))?;
7234 let values = rows.collect::<std::result::Result<BTreeSet<_>, _>>()?;
7235 dependencies.insert(raw.caller_file.clone(), values);
7236 }
7237 raw.dependencies = dependencies
7238 .get(&raw.caller_file)
7239 .cloned()
7240 .unwrap_or_default();
7241 }
7242 Ok(refs)
7243}
7244
7245fn unresolved_staged_ref(raw: RawRef) -> ResolvedRef {
7246 ResolvedRef {
7247 dependencies: raw.dependencies.clone(),
7248 raw,
7249 status: "unresolved".to_string(),
7250 target_node: None,
7251 target_file: None,
7252 target_symbol: None,
7253 edge: None,
7254 }
7255}
7256
7257fn query_count(conn: &Connection, query: &str) -> Result<u64> {
7258 conn.query_row(query, [], |row| row.get::<_, i64>(0))
7259 .map(|count| count.max(0) as u64)
7260 .map_err(Into::into)
7261}
7262
7263fn staged_failed_files(conn: &Connection) -> Result<Vec<String>> {
7264 let mut statement = conn.prepare(
7265 "SELECT DISTINCT file_path FROM backend_file_state WHERE status = 'stale' ORDER BY file_path",
7266 )?;
7267 let rows = statement.query_map([], |row| row.get(0))?;
7268 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
7269}
7270
7271fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
7272 tx.execute_batch(
7273 "DROP INDEX IF EXISTS idx_nodes_file;
7274 DROP INDEX IF EXISTS idx_nodes_name;
7275 DROP INDEX IF EXISTS idx_nodes_scoped;
7276 DROP INDEX IF EXISTS idx_refs_short_name;
7277 DROP INDEX IF EXISTS idx_refs_kind_caller_file;
7278 DROP INDEX IF EXISTS idx_refs_caller_file;
7279 DROP INDEX IF EXISTS idx_refs_caller_node_kind;
7280 DROP INDEX IF EXISTS idx_refs_target_file;
7281 DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
7282 DROP INDEX IF EXISTS idx_edges_source_kind;
7283 DROP INDEX IF EXISTS idx_edges_target_kind;
7284 DROP INDEX IF EXISTS idx_edges_target_file_symbol;
7285 DROP INDEX IF EXISTS idx_edges_ref_id;
7286 DROP INDEX IF EXISTS idx_dispatch_hints_method;
7287 DROP INDEX IF EXISTS idx_dispatch_hints_file;
7288 DROP INDEX IF EXISTS idx_backend_file_state_file;",
7289 )?;
7290 Ok(())
7291}
7292
7293fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
7294 tx.execute_batch(
7295 "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
7296 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
7297 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
7298 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
7299 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
7300 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
7301 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
7302 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
7303 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
7304 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
7305 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
7306 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
7307 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
7308 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
7309 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
7310 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
7311 )?;
7312 Ok(())
7313}
7314
7315const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
7316 ("files", "path"),
7317 ("nodes", "file_path"),
7318 ("refs", "caller_file"),
7319 ("refs", "target_file"),
7320 ("file_dependencies", "file_path"),
7321 ("file_dependencies", "dep_file"),
7322 ("edges", "target_file"),
7323 ("dispatch_hints", "file"),
7324 ("backend_file_state", "file_path"),
7325];
7326
7327fn reconcile_workspace_roots(
7340 conn: &mut Connection,
7341 project_root: &Path,
7342 allow_repair: bool,
7343) -> Result<OpenRootRepair> {
7344 let roots = stored_workspace_roots(conn)?;
7345 let current_root = project_root.display().to_string();
7346 if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
7347 return Ok(OpenRootRepair::None);
7348 }
7349
7350 if let Some(sample) = sample_absolute_data_path(conn)? {
7351 return Ok(OpenRootRepair::NeedsRebuild {
7352 previous_roots: roots,
7353 current_root,
7354 reason: format!("absolute store data path row {sample}"),
7355 });
7356 }
7357
7358 for stored_root in roots.iter() {
7359 if stored_root == ¤t_root {
7360 continue;
7361 }
7362 if Path::new(stored_root).exists() {
7363 let reason = format!(
7364 "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
7365 );
7366 return Ok(OpenRootRepair::NeedsRebuild {
7367 previous_roots: roots,
7368 current_root,
7369 reason,
7370 });
7371 }
7372 }
7373
7374 if !allow_repair {
7375 return Ok(OpenRootRepair::NeedsRebuild {
7376 previous_roots: roots,
7377 current_root,
7378 reason: "workspace root metadata requires deferred repair".to_string(),
7379 });
7380 }
7381
7382 publish_if_current(|| {
7383 let tx = conn.transaction()?;
7384 tx.execute(
7385 "UPDATE OR IGNORE backend_file_state
7386 SET workspace_root = ?1
7387 WHERE workspace_root <> ?1",
7388 params![¤t_root],
7389 )?;
7390 tx.execute(
7391 "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
7392 params![¤t_root],
7393 )?;
7394 tx.commit()?;
7395 Ok(())
7396 })?;
7397
7398 crate::slog_info!(
7399 "callgraph store re-rooted from {} to {}",
7400 roots.join(", "),
7401 current_root
7402 );
7403 Ok(OpenRootRepair::ReRooted)
7404}
7405
7406fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
7407 let mut stmt = conn.prepare(
7408 "SELECT DISTINCT workspace_root
7409 FROM backend_file_state
7410 ORDER BY workspace_root",
7411 )?;
7412 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
7413 rows.collect::<std::result::Result<Vec<_>, _>>()
7414 .map_err(Into::into)
7415}
7416
7417fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
7418 for (table, column) in STORE_DATA_PATH_COLUMNS {
7419 let sql = format!(
7420 "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
7421 );
7422 let mut stmt = conn.prepare(&sql)?;
7423 let mut rows = stmt.query([])?;
7424 while let Some(row) = rows.next()? {
7425 let value: String = row.get(0)?;
7426 if stored_path_is_absolute(&value) {
7427 return Ok(Some(format!("{table}.{column}={value}")));
7428 }
7429 }
7430 }
7431 Ok(None)
7432}
7433
7434fn stored_path_is_absolute(value: &str) -> bool {
7435 if value.is_empty() {
7436 return false;
7437 }
7438 if Path::new(value).is_absolute() || value.starts_with('/') {
7439 return true;
7440 }
7441 let bytes = value.as_bytes();
7442 if bytes.len() >= 3
7443 && bytes[1] == b':'
7444 && (bytes[2] == b'/' || bytes[2] == b'\\')
7445 && bytes[0].is_ascii_alphabetic()
7446 {
7447 return true;
7448 }
7449 value.starts_with("\\\\") || value.starts_with("//")
7450}
7451
7452fn log_root_repair_rebuild(repair: &OpenRootRepair) {
7453 if let OpenRootRepair::NeedsRebuild {
7454 previous_roots,
7455 current_root,
7456 reason,
7457 } = repair
7458 {
7459 crate::slog_info!(
7460 "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
7461 previous_roots.join(", "),
7462 current_root,
7463 reason
7464 );
7465 }
7466}
7467
7468fn now_nanos() -> u128 {
7470 SystemTime::now()
7471 .duration_since(UNIX_EPOCH)
7472 .unwrap_or(Duration::ZERO)
7473 .as_nanos()
7474}
7475
7476fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
7481 callgraph_dir.join(format!("{project_key}.current"))
7482}
7483
7484fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
7488 callgraph_dir.join(format!("{project_key}.sqlite"))
7489}
7490
7491fn generation_file_name(project_key: &str) -> String {
7495 format!(
7496 "{project_key}.g{}.{}.sqlite",
7497 now_nanos(),
7498 std::process::id()
7499 )
7500}
7501
7502fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
7504 let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
7505 let name = text.trim();
7506 if name.is_empty() {
7507 None
7508 } else {
7509 Some(name.to_string())
7510 }
7511}
7512
7513fn db_path_ready(path: &Path) -> bool {
7516 (|| -> Result<bool> {
7517 let conn = open_readonly_connection(path)?;
7518 database_ready(&conn)
7519 })()
7520 .unwrap_or(false)
7521}
7522
7523fn resolve_ready_target(
7531 callgraph_dir: &Path,
7532 project_key: &str,
7533) -> Option<(PathBuf, Option<String>)> {
7534 for _ in 0..5 {
7535 if let Some(generation) = read_pointer(callgraph_dir, project_key) {
7536 let gen_path = callgraph_dir.join(&generation);
7537 if gen_path.is_file() {
7538 return (migration_manifest_valid(callgraph_dir, &generation)
7539 && db_path_ready(&gen_path))
7540 .then_some((gen_path, Some(generation)));
7541 }
7542 std::thread::sleep(Duration::from_millis(5));
7545 continue;
7546 }
7547 let legacy = legacy_sqlite_path(callgraph_dir, project_key);
7549 return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
7550 }
7551 None
7552}
7553
7554fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
7558 let pointer = pointer_path(callgraph_dir, project_key);
7559 let tmp = callgraph_dir.join(format!(
7560 "{project_key}.current.tmp.{}.{}",
7561 std::process::id(),
7562 now_nanos()
7563 ));
7564 {
7565 use std::io::Write as _;
7566 let mut file = std::fs::File::create(&tmp)?;
7567 file.write_all(generation.as_bytes())?;
7568 file.write_all(b"\n")?;
7569 file.sync_all()?;
7570 }
7571 if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
7572 let _ = std::fs::remove_file(&tmp);
7573 return Err(error.into());
7574 }
7575 crate::fs_lock::sync_parent(&pointer);
7576 Ok(())
7577}
7578
7579#[derive(Clone, Debug)]
7580struct GenerationGcCandidate {
7581 name: String,
7582 path: PathBuf,
7583 modified: SystemTime,
7584}
7585
7586fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
7592 let temp_grace = Duration::from_secs(60);
7593 let now = SystemTime::now();
7594 let pointer_current =
7595 read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
7596 let gen_prefix = format!("{project_key}.g");
7597 let tmp_prefixes = [
7598 format!("{project_key}.g"), format!("{project_key}.current."), format!("{project_key}.sqlite.tmp."), ];
7602 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
7603 return;
7604 };
7605 let mut gens: Vec<GenerationGcCandidate> = Vec::new();
7606 for entry in entries.flatten() {
7607 let name = entry.file_name();
7608 let name = name.to_string_lossy().to_string();
7609 let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
7610 let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
7611
7612 if name.contains(".tmp.") {
7614 if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
7615 let _ = std::fs::remove_file(entry.path());
7616 }
7617 continue;
7618 }
7619
7620 if name == format!("{project_key}.sqlite") {
7623 remove_sqlite_file_set(&entry.path());
7624 continue;
7625 }
7626
7627 if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
7628 gens.push(GenerationGcCandidate {
7629 name,
7630 path: entry.path(),
7631 modified: mtime,
7632 });
7633 }
7634 }
7635
7636 let mut superseded = gens
7637 .iter()
7638 .filter(|generation| generation.name != pointer_current)
7639 .collect::<Vec<_>>();
7640 superseded.sort_by(|left, right| {
7641 right
7642 .modified
7643 .cmp(&left.modified)
7644 .then_with(|| right.name.cmp(&left.name))
7645 });
7646 let previous = superseded.first().map(|generation| generation.name.clone());
7647
7648 for generation in gens {
7649 let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
7650 if generation.name == pointer_current
7651 || Some(generation.name.as_str()) == previous.as_deref()
7652 {
7653 continue;
7654 }
7655
7656 let age = now
7657 .duration_since(generation.modified)
7658 .unwrap_or(Duration::ZERO);
7659 if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
7660 continue;
7661 }
7662
7663 remove_sqlite_file_set(&generation.path);
7664 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
7665 let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
7666 callgraph_dir,
7667 &generation.name,
7668 ));
7669 }
7670}
7671
7672fn remove_sqlite_file_set(path: &Path) {
7673 let _ = std::fs::remove_file(path);
7674 remove_sqlite_sidecars(path);
7675}
7676
7677fn remove_sqlite_sidecars(path: &Path) {
7678 let path_text = path.to_string_lossy();
7679 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
7680 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
7681 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
7682}
7683
7684const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
7698
7699fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
7711 sweep_orphaned_build_temps(callgraph_dir);
7712 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
7713 return;
7714 };
7715 let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
7716 let Ok(boundary) = crate::walk_boundary::DeviceBoundary::for_root(&storage_root) else {
7720 crate::slog_warn!(
7721 "cannot establish filesystem boundary for callgraph sweep {}",
7722 storage_root.display()
7723 );
7724 return;
7725 };
7726 let mut skipped_foreign_mounts = 0usize;
7727
7728 let root_keyed_dir = storage_root.join(domain);
7730 if root_keyed_dir.is_dir() {
7731 if boundary.should_descend(&root_keyed_dir).unwrap_or(false) {
7732 if let Ok(entries) = std::fs::read_dir(&root_keyed_dir) {
7733 for entry in entries.flatten() {
7734 let path = entry.path();
7735 if path.is_dir() {
7736 if boundary.should_descend(&path).unwrap_or(false) {
7737 sweep_orphaned_build_temps(&path);
7738 } else {
7739 skipped_foreign_mounts += 1;
7740 }
7741 }
7742 }
7743 }
7744 } else {
7745 skipped_foreign_mounts += 1;
7746 }
7747 }
7748
7749 if let Ok(entries) = std::fs::read_dir(&storage_root) {
7751 for entry in entries.flatten() {
7752 let harness_dir = entry.path();
7753 if !harness_dir.is_dir() {
7754 continue;
7755 }
7756 if !boundary.should_descend(&harness_dir).unwrap_or(false) {
7757 skipped_foreign_mounts += 1;
7758 continue;
7759 }
7760 let legacy_dir = harness_dir.join(domain);
7761 if legacy_dir.is_dir() {
7762 if boundary.should_descend(&legacy_dir).unwrap_or(false) {
7763 sweep_orphaned_build_temps(&legacy_dir);
7764 } else {
7765 skipped_foreign_mounts += 1;
7766 }
7767 }
7768 }
7769 }
7770 if skipped_foreign_mounts > 0 {
7771 crate::slog_warn!(
7772 "callgraph sweep skipped {} foreign filesystem mount(s) below {}",
7773 skipped_foreign_mounts,
7774 storage_root.display()
7775 );
7776 }
7777}
7778
7779fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
7782 sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
7783}
7784
7785fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
7788 let now = SystemTime::now();
7789 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
7790 return;
7791 };
7792 let mut removed_any = false;
7793 for entry in entries.flatten() {
7794 let name = entry.file_name().to_string_lossy().to_string();
7795 if !name.contains(".sqlite.tmp.") {
7801 continue;
7802 }
7803 let mtime = entry
7804 .metadata()
7805 .and_then(|meta| meta.modified())
7806 .unwrap_or(now);
7807 if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
7808 continue;
7809 }
7810 match std::fs::remove_file(entry.path()) {
7816 Ok(()) => removed_any = true,
7817 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
7818 Err(_) => {}
7819 }
7820 }
7821 if removed_any {
7822 crate::fs_lock::sync_parent(callgraph_dir);
7823 }
7824}
7825
7826fn build_pool_size() -> usize {
7834 std::thread::available_parallelism()
7835 .map(|parallelism| parallelism.get())
7836 .unwrap_or(1)
7837 .div_ceil(2)
7838 .clamp(1, 8)
7839}
7840
7841fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
7842 let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
7843 Ok(extract) => Ok(extract),
7844 Err(error) => {
7845 let abs_path =
7846 normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
7847 let rel_path = relative_path(project_root, &abs_path);
7848 let freshness = cache_freshness::collect(&abs_path).ok();
7849 log::debug!(
7850 "callgraph store: skipping {} during cold build: {}",
7851 abs_path.display(),
7852 error
7853 );
7854 Err(ExtractFailure {
7855 rel_path,
7856 freshness,
7857 })
7858 }
7859 };
7860
7861 let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
7862 files.par_iter().map(extract_one).collect()
7863 };
7864
7865 let results = match rayon::ThreadPoolBuilder::new()
7868 .num_threads(build_pool_size())
7869 .thread_name(|index| format!("aft-callgraph-build-{index}"))
7870 .stack_size(8 * 1024 * 1024)
7871 .build()
7872 {
7873 Ok(pool) => pool.install(run),
7874 Err(error) => {
7875 log::warn!(
7876 "callgraph store: bounded build pool unavailable ({error}); using global pool"
7877 );
7878 run()
7879 }
7880 };
7881
7882 let mut extracts = Vec::new();
7883 let mut failures = Vec::new();
7884 for result in results {
7885 match result {
7886 Ok(extract) => extracts.push(extract),
7887 Err(failure) => failures.push(failure),
7888 }
7889 }
7890 BuildExtractsResult { extracts, failures }
7891}
7892
7893fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
7894 let metadata = std::fs::metadata(path)?;
7895 let size = metadata.len();
7896 let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
7897 cache_freshness::zero_hash()
7898 } else if source.len() as u64 == size {
7899 cache_freshness::hash_bytes(source.as_bytes())
7900 } else {
7901 cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
7902 };
7903 Ok(FileFreshness {
7904 mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
7905 size,
7906 content_hash,
7907 })
7908}
7909
7910fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
7911 let abs_path = normalize_file_path(project_root, path)?;
7912 let rel_path = relative_path(project_root, &abs_path);
7913 let source = std::fs::read_to_string(&abs_path)?;
7914 let freshness = collect_source_freshness(&abs_path, &source)?;
7915 let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
7916 let lang = data.lang;
7917 if lang == LangId::Rust {
7918 extend_rust_imports_with_nested_uses(&source, &mut data);
7919 }
7920 let mut nodes = build_node_records(&rel_path, &source, &data)?;
7921 let node_by_scoped: HashMap<String, String> = nodes
7922 .iter()
7923 .map(|node| (node.scoped_name.clone(), node.id.clone()))
7924 .collect();
7925 let import_dependencies =
7926 import_dependencies(project_root, &abs_path, &data.import_block.imports);
7927 let line_index = LineIndex::new(&source);
7928 let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
7929 let rust_reexports = if lang == LangId::Rust {
7930 collect_rust_pub_use_reexport_refs(
7931 project_root,
7932 &abs_path,
7933 &rel_path,
7934 &data.import_block.imports,
7935 &line_index,
7936 )
7937 } else {
7938 ReexportRefs {
7939 raw_refs: Vec::new(),
7940 surface_parts: Vec::new(),
7941 }
7942 };
7943 let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
7944 let mut raw_refs = Vec::new();
7945 raw_refs.extend(build_call_refs(
7946 &rel_path,
7947 &data,
7948 &node_by_scoped,
7949 &import_dependencies,
7950 ));
7951 raw_refs.extend(build_value_ref_refs(
7952 &rel_path,
7953 &data,
7954 &node_by_scoped,
7955 &import_dependencies,
7956 ));
7957 raw_refs.extend(build_import_refs(
7958 project_root,
7959 &abs_path,
7960 &rel_path,
7961 &data.import_block.imports,
7962 &line_index,
7963 ));
7964 let mut surface_parts = reexports.surface_parts;
7965 surface_parts.extend(rust_reexports.surface_parts);
7966 surface_parts.extend(source_less_exports.surface_parts);
7967 raw_refs.extend(reexports.raw_refs);
7968 raw_refs.extend(rust_reexports.raw_refs);
7969 raw_refs.extend(source_less_exports.raw_refs);
7970 let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
7971 let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
7972
7973 Ok(FileExtract {
7974 rel_path,
7975 freshness,
7976 lang,
7977 data,
7978 nodes,
7979 raw_refs,
7980 dispatch_hints,
7981 surface_fingerprint,
7982 })
7983}
7984
7985fn build_node_records(
7986 rel_path: &str,
7987 source: &str,
7988 data: &FileCallData,
7989) -> Result<Vec<NodeRecord>> {
7990 let mut records = Vec::new();
7991 let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
7992 let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
7993 metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
7994
7995 for (scoped_name, meta) in metadata {
7996 let name = unqualified_name(scoped_name).to_string();
7997 let range = selection_range(source, scoped_name, &name, &meta.range);
7998 let range_key = (
7999 range.start_line,
8000 range.start_col,
8001 range.end_line,
8002 range.end_col,
8003 );
8004 let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
8005 let range_ordinal = *ordinal;
8006 *ordinal += 1;
8007 let id = node_id(rel_path, &range, range_ordinal, scoped_name);
8008 let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
8009 let is_default_export = data
8010 .default_export_symbol
8011 .as_deref()
8012 .map(|default| default == scoped_name || default == name)
8013 .unwrap_or(false);
8014 records.push(NodeRecord {
8015 id,
8016 file_path: rel_path.to_string(),
8017 name: name.clone(),
8018 scoped_name: scoped_name.clone(),
8019 kind: symbol_kind_label(&meta.kind).to_string(),
8020 range,
8021 range_ordinal,
8022 signature: meta.signature.clone(),
8023 exported,
8024 is_default_export,
8025 is_type_like: is_type_like(&meta.kind),
8026 is_callgraph_entry_point: meta.entry_point_attribute.is_some()
8027 || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
8028 });
8029 }
8030
8031 Ok(records)
8032}
8033
8034fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
8035 if scoped_name == TOP_LEVEL_SYMBOL {
8036 return Range {
8037 start_line: 0,
8038 start_col: 0,
8039 end_line: 0,
8040 end_col: 0,
8041 };
8042 }
8043 let Some(line) = source.lines().nth(fallback.start_line as usize) else {
8044 return fallback.clone();
8045 };
8046 let start_col = fallback.start_col as usize;
8047 let search_start = start_col.min(line.len());
8048 if let Some(offset) = line[search_start..].find(name) {
8049 let col = search_start + offset;
8050 return Range {
8051 start_line: fallback.start_line,
8052 start_col: col as u32,
8053 end_line: fallback.start_line,
8054 end_col: (col + name.len()) as u32,
8055 };
8056 }
8057 if let Some(offset) = line.find(name) {
8058 return Range {
8059 start_line: fallback.start_line,
8060 start_col: offset as u32,
8061 end_line: fallback.start_line,
8062 end_col: (offset + name.len()) as u32,
8063 };
8064 }
8065 Range {
8066 start_line: fallback.start_line,
8067 start_col: fallback.start_col,
8068 end_line: fallback.start_line,
8069 end_col: fallback.start_col.saturating_add(name.len() as u32),
8070 }
8071}
8072
8073fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
8074 if scoped_name == TOP_LEVEL_SYMBOL {
8075 return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
8076 }
8077 let input = format!(
8078 "{rel_path}:{}:{}:{}:{}:{ordinal}",
8079 range.start_line, range.start_col, range.end_line, range.end_col
8080 );
8081 format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
8082}
8083
8084fn build_call_refs(
8085 rel_path: &str,
8086 data: &FileCallData,
8087 node_by_scoped: &HashMap<String, String>,
8088 import_dependencies: &BTreeSet<String>,
8089) -> Vec<RawRef> {
8090 build_callable_refs(
8091 rel_path,
8092 &data.calls_by_symbol,
8093 node_by_scoped,
8094 import_dependencies,
8095 "call",
8096 )
8097}
8098
8099fn build_value_ref_refs(
8100 rel_path: &str,
8101 data: &FileCallData,
8102 node_by_scoped: &HashMap<String, String>,
8103 import_dependencies: &BTreeSet<String>,
8104) -> Vec<RawRef> {
8105 build_callable_refs(
8106 rel_path,
8107 &data.value_refs_by_symbol,
8108 node_by_scoped,
8109 import_dependencies,
8110 "value_ref",
8111 )
8112}
8113
8114fn build_callable_refs(
8115 rel_path: &str,
8116 sites_by_symbol: &HashMap<String, Vec<callgraph::CallSite>>,
8117 node_by_scoped: &HashMap<String, String>,
8118 import_dependencies: &BTreeSet<String>,
8119 kind: &str,
8120) -> Vec<RawRef> {
8121 let mut refs = Vec::new();
8122 let mut ordinal = 0usize;
8123 let mut symbols: Vec<_> = sites_by_symbol.iter().collect();
8124 symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
8125 for (caller_symbol, call_sites) in symbols {
8126 let caller_node = node_by_scoped.get(caller_symbol).cloned();
8127 for call_site in call_sites {
8128 ordinal += 1;
8129 let ref_id = ref_id(&[
8130 rel_path,
8131 kind,
8132 caller_symbol,
8133 &call_site.line.to_string(),
8134 &call_site.byte_start.to_string(),
8135 &call_site.byte_end.to_string(),
8136 &call_site.full_callee,
8137 &ordinal.to_string(),
8138 ]);
8139 refs.push(RawRef {
8140 ref_id,
8141 caller_node: caller_node.clone(),
8142 caller_symbol: Some(caller_symbol.clone()),
8143 caller_file: rel_path.to_string(),
8144 kind: kind.to_string(),
8145 short_name: Some(call_site.callee_name.clone()),
8146 full_ref: Some(call_site.full_callee.clone()),
8147 module_path: None,
8148 import_kind: None,
8149 local_name: Some(call_site.callee_name.clone()),
8150 requested_name: Some(call_site.callee_name.clone()),
8151 namespace_alias: namespace_alias(&call_site.full_callee),
8152 wildcard: false,
8153 line: call_site.line,
8154 byte_start: call_site.byte_start,
8155 byte_end: call_site.byte_end,
8156 dependencies: import_dependencies.clone(),
8157 });
8158 }
8159 }
8160 refs
8161}
8162
8163fn build_import_refs(
8164 project_root: &Path,
8165 abs_path: &Path,
8166 rel_path: &str,
8167 imports: &[ImportStatement],
8168 line_index: &LineIndex,
8169) -> Vec<RawRef> {
8170 let mut refs = Vec::new();
8171 for (index, import) in imports.iter().enumerate() {
8172 let import_kind = import_kind_label(import.kind).to_string();
8173 let local_name = import_local_names(import).join(",");
8174 let requested_name = import_requested_names(import).join(",");
8175 let ref_id = ref_id(&[
8176 rel_path,
8177 "import",
8178 &import.byte_range.start.to_string(),
8179 &import.byte_range.end.to_string(),
8180 &import.module_path,
8181 &index.to_string(),
8182 ]);
8183 refs.push(RawRef {
8184 ref_id,
8185 caller_node: None,
8186 caller_symbol: None,
8187 caller_file: rel_path.to_string(),
8188 kind: "import".to_string(),
8189 short_name: None,
8190 full_ref: Some(import.raw_text.clone()),
8191 module_path: Some(import.module_path.clone()),
8192 import_kind: Some(import_kind),
8193 local_name: empty_to_none(local_name),
8194 requested_name: empty_to_none(requested_name),
8195 namespace_alias: import.namespace_import.clone(),
8196 wildcard: import_is_wildcard(import),
8197 line: line_index.byte_to_line(import.byte_range.start),
8198 byte_start: import.byte_range.start,
8199 byte_end: import.byte_range.end,
8200 dependencies: module_dependencies(project_root, abs_path, &import.module_path),
8201 });
8202 }
8203 refs
8204}
8205
8206fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
8207 let grammar = grammar_for(LangId::Rust);
8208 let mut parser = Parser::new();
8209 if parser.set_language(&grammar).is_err() {
8210 return;
8211 }
8212 let Some(tree) = parser.parse(source, None) else {
8213 return;
8214 };
8215
8216 let mut seen = data
8217 .import_block
8218 .imports
8219 .iter()
8220 .map(|import| (import.byte_range.start, import.byte_range.end))
8221 .collect::<HashSet<_>>();
8222 let mut nested_imports = Vec::new();
8223 collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
8224 if nested_imports.is_empty() {
8225 return;
8226 }
8227
8228 data.import_block.imports.extend(nested_imports);
8229 data.import_block
8230 .imports
8231 .sort_by_key(|import| import.byte_range.start);
8232 data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
8233}
8234
8235fn collect_rust_use_imports(
8236 source: &str,
8237 node: Node<'_>,
8238 seen: &mut HashSet<(usize, usize)>,
8239 imports: &mut Vec<ImportStatement>,
8240) {
8241 if node.kind() == "use_declaration" {
8242 let range = node.byte_range();
8243 if seen.insert((range.start, range.end)) {
8244 if let Some(import) = rust_import_from_use_node(source, node) {
8245 imports.push(import);
8246 }
8247 }
8248 }
8249
8250 let mut cursor = node.walk();
8251 if !cursor.goto_first_child() {
8252 return;
8253 }
8254 loop {
8255 collect_rust_use_imports(source, cursor.node(), seen, imports);
8256 if !cursor.goto_next_sibling() {
8257 break;
8258 }
8259 }
8260}
8261
8262fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
8263 let raw_text = source[node.byte_range()].to_string();
8264 let body = rust_use_body(&raw_text)?.to_string();
8265 let visibility = rust_use_visibility(&raw_text);
8266 let names = rust_use_list_names(&body);
8267 let group = classify_rust_import_group(&body);
8268 let byte_range = node.byte_range();
8269
8270 Some(ImportStatement {
8271 module_path: body,
8272 names: names.clone(),
8273 default_import: visibility.clone(),
8274 namespace_import: None,
8275 kind: ImportKind::Value,
8276 group,
8277 byte_range,
8278 raw_text,
8279 form: ImportForm::RustUse {
8280 visibility,
8281 named: names,
8282 },
8283 })
8284}
8285
8286fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
8287 let start = imports.iter().map(|import| import.byte_range.start).min()?;
8288 let end = imports.iter().map(|import| import.byte_range.end).max()?;
8289 Some(start..end)
8290}
8291
8292fn rust_use_visibility(raw_text: &str) -> Option<String> {
8293 let use_pos = raw_text.find("use ")?;
8294 let prefix = raw_text[..use_pos].trim();
8295 if prefix.is_empty() {
8296 None
8297 } else {
8298 Some(prefix.to_string())
8299 }
8300}
8301
8302fn rust_use_body(raw_text: &str) -> Option<&str> {
8303 let use_pos = raw_text.find("use ")?;
8304 Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
8305}
8306
8307fn rust_use_list_names(body: &str) -> Vec<String> {
8308 let Some(open) = body.find("::{") else {
8309 return Vec::new();
8310 };
8311 let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
8312 return Vec::new();
8313 };
8314 body[open + 3..close]
8315 .split(',')
8316 .filter_map(|spec| {
8317 let spec = spec.trim();
8318 if spec.is_empty() {
8319 None
8320 } else {
8321 Some(spec.to_string())
8322 }
8323 })
8324 .collect()
8325}
8326
8327fn classify_rust_import_group(body: &str) -> ImportGroup {
8328 let first = body
8329 .split("::")
8330 .next()
8331 .unwrap_or(body)
8332 .split_whitespace()
8333 .next()
8334 .unwrap_or(body);
8335 match first.trim() {
8336 "std" | "core" | "alloc" => ImportGroup::Stdlib,
8337 "crate" | "self" | "super" => ImportGroup::Internal,
8338 _ => ImportGroup::External,
8339 }
8340}
8341
8342#[derive(Debug, Clone)]
8343struct ReexportRefs {
8344 raw_refs: Vec<RawRef>,
8345 surface_parts: Vec<String>,
8346}
8347
8348fn collect_reexport_refs(
8349 project_root: &Path,
8350 abs_path: &Path,
8351 rel_path: &str,
8352 source: &str,
8353) -> ReexportRefs {
8354 let mut raw_refs = Vec::new();
8355 let mut surface_parts = Vec::new();
8356 let mut search_start = 0usize;
8357 let mut ordinal = 0usize;
8358 while let Some(export_offset) = source[search_start..].find("export") {
8359 let start = search_start + export_offset;
8360 let Some(statement_end_offset) = source[start..].find(';') else {
8361 break;
8362 };
8363 let end = start + statement_end_offset + 1;
8364 let statement = &source[start..end];
8365 search_start = end;
8366 if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
8367 continue;
8368 }
8369 let Some(module_path) = quoted_module_path(statement) else {
8370 continue;
8371 };
8372 ordinal += 1;
8373 let wildcard = statement.contains('*');
8374 let line = source[..start]
8375 .bytes()
8376 .filter(|byte| *byte == b'\n')
8377 .count() as u32
8378 + 1;
8379 let ref_id = ref_id(&[
8380 rel_path,
8381 "reexport",
8382 &start.to_string(),
8383 &end.to_string(),
8384 &module_path,
8385 &ordinal.to_string(),
8386 ]);
8387 surface_parts.push(format!("reexport\t{statement}"));
8388 raw_refs.push(RawRef {
8389 ref_id,
8390 caller_node: None,
8391 caller_symbol: None,
8392 caller_file: rel_path.to_string(),
8393 kind: "reexport".to_string(),
8394 short_name: None,
8395 full_ref: Some(statement.to_string()),
8396 module_path: Some(module_path.clone()),
8397 import_kind: Some("reexport".to_string()),
8398 local_name: None,
8399 requested_name: None,
8400 namespace_alias: None,
8401 wildcard,
8402 line,
8403 byte_start: start,
8404 byte_end: end,
8405 dependencies: module_dependencies(project_root, abs_path, &module_path),
8406 });
8407 }
8408 ReexportRefs {
8409 raw_refs,
8410 surface_parts,
8411 }
8412}
8413
8414fn collect_rust_pub_use_reexport_refs(
8415 project_root: &Path,
8416 abs_path: &Path,
8417 rel_path: &str,
8418 imports: &[ImportStatement],
8419 line_index: &LineIndex,
8420) -> ReexportRefs {
8421 let mut raw_refs = Vec::new();
8422 let mut surface_parts = Vec::new();
8423 let mut ordinal = 0usize;
8424
8425 for import in imports {
8426 let Some(visibility) = &import.default_import else {
8427 continue;
8428 };
8429 if !visibility.starts_with("pub") {
8430 continue;
8431 }
8432 let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
8433 continue;
8434 };
8435 ordinal += 1;
8436 let ref_id = ref_id(&[
8437 rel_path,
8438 "rust_reexport",
8439 &import.byte_range.start.to_string(),
8440 &import.byte_range.end.to_string(),
8441 &module_path,
8442 &ordinal.to_string(),
8443 ]);
8444 surface_parts.push(format!("reexport\t{}", import.raw_text));
8445 raw_refs.push(RawRef {
8446 ref_id,
8447 caller_node: None,
8448 caller_symbol: None,
8449 caller_file: rel_path.to_string(),
8450 kind: "reexport".to_string(),
8451 short_name: None,
8452 full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
8453 module_path: Some(module_path.clone()),
8454 import_kind: Some("reexport".to_string()),
8455 local_name: None,
8456 requested_name: None,
8457 namespace_alias: None,
8458 wildcard,
8459 line: line_index.byte_to_line(import.byte_range.start),
8460 byte_start: import.byte_range.start,
8461 byte_end: import.byte_range.end,
8462 dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
8463 });
8464 }
8465
8466 ReexportRefs {
8467 raw_refs,
8468 surface_parts,
8469 }
8470}
8471
8472fn rust_pub_use_reexport_parts(
8473 import: &ImportStatement,
8474) -> Option<(String, HashMap<String, String>, bool)> {
8475 let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
8476 let body = body.trim();
8477 if let Some(module_path) = body.strip_suffix("::*") {
8478 return Some((module_path.trim().to_string(), HashMap::new(), true));
8479 }
8480
8481 if let Some(brace_start) = body.find("::{") {
8482 let module_path = body[..brace_start].trim().to_string();
8483 let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
8484 if names.is_empty() {
8485 return None;
8486 }
8487 return Some((module_path, names, false));
8488 }
8489
8490 let (module_path, spec) = body.rsplit_once("::")?;
8491 let names = rust_reexport_names_from_specs(spec);
8492 if names.is_empty() {
8493 return None;
8494 }
8495 Some((module_path.trim().to_string(), names, false))
8496}
8497
8498fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
8499 let mut names = HashMap::new();
8500 for spec in specs.split(',') {
8501 let spec = spec.trim();
8502 if spec.is_empty() || spec == "self" {
8503 continue;
8504 }
8505 if let Some((source, local)) = spec.split_once(" as ") {
8506 let source = source.trim();
8507 let local = local.trim();
8508 if !source.is_empty() && !local.is_empty() && source != "self" {
8509 names.insert(local.to_string(), source.to_string());
8510 }
8511 } else {
8512 names.insert(spec.to_string(), spec.to_string());
8513 }
8514 }
8515 names
8516}
8517
8518fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
8519 if named.is_empty() {
8520 return fallback.to_string();
8521 }
8522 let mut specs = named
8523 .iter()
8524 .map(|(local, source)| {
8525 if local == source {
8526 source.clone()
8527 } else {
8528 format!("{source} as {local}")
8529 }
8530 })
8531 .collect::<Vec<_>>();
8532 specs.sort();
8533 format!("pub use {{{}}};", specs.join(", "))
8534}
8535
8536fn quoted_module_path(statement: &str) -> Option<String> {
8537 let quote = match (statement.find('\''), statement.find('"')) {
8538 (Some(single), Some(double)) if single < double => '\'',
8539 (Some(_), Some(_)) => '"',
8540 (Some(_), None) => '\'',
8541 (None, Some(_)) => '"',
8542 (None, None) => return None,
8543 };
8544 let start = statement.find(quote)? + 1;
8545 let end = statement[start..].find(quote)? + start;
8546 Some(statement[start..end].to_string())
8547}
8548
8549#[derive(Debug, Clone)]
8550struct SourceLessExportRefs {
8551 raw_refs: Vec<RawRef>,
8552 surface_parts: Vec<String>,
8553}
8554
8555fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
8556 let mut raw_refs = Vec::new();
8557 let mut surface_parts = Vec::new();
8558 let mut search_start = 0usize;
8559 let mut ordinal = 0usize;
8560 while let Some(export_offset) = source[search_start..].find("export") {
8561 let start = search_start + export_offset;
8562 let Some(statement_end_offset) = source[start..].find(';') else {
8563 break;
8564 };
8565 let end = start + statement_end_offset + 1;
8566 let statement = &source[start..end];
8567 search_start = end;
8568 if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
8569 continue;
8570 }
8571 let aliases = parse_reexport_names(statement);
8572 if aliases.is_empty() {
8573 continue;
8574 }
8575 let line = source[..start]
8576 .bytes()
8577 .filter(|byte| *byte == b'\n')
8578 .count() as u32
8579 + 1;
8580 for (exported, source_symbol) in aliases {
8581 ordinal += 1;
8582 let ref_id = ref_id(&[
8583 rel_path,
8584 "export_alias",
8585 &start.to_string(),
8586 &end.to_string(),
8587 &exported,
8588 &source_symbol,
8589 &ordinal.to_string(),
8590 ]);
8591 surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
8592 raw_refs.push(RawRef {
8593 ref_id,
8594 caller_node: None,
8595 caller_symbol: None,
8596 caller_file: rel_path.to_string(),
8597 kind: "export_alias".to_string(),
8598 short_name: None,
8599 full_ref: Some(statement.to_string()),
8600 module_path: None,
8601 import_kind: Some("export_alias".to_string()),
8602 local_name: Some(exported),
8603 requested_name: Some(source_symbol),
8604 namespace_alias: None,
8605 wildcard: false,
8606 line,
8607 byte_start: start,
8608 byte_end: end,
8609 dependencies: BTreeSet::new(),
8610 });
8611 }
8612 }
8613 SourceLessExportRefs {
8614 raw_refs,
8615 surface_parts,
8616 }
8617}
8618
8619fn build_dispatch_hints(
8620 rel_path: &str,
8621 data: &FileCallData,
8622 node_by_scoped: &HashMap<String, String>,
8623) -> Vec<DispatchHint> {
8624 let mut hints = Vec::new();
8625 let mut ordinal = 0usize;
8626 for (caller_symbol, call_sites) in &data.calls_by_symbol {
8627 let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
8628 continue;
8629 };
8630 for call_site in call_sites {
8631 if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
8632 continue;
8633 }
8634 ordinal += 1;
8635 hints.push(DispatchHint {
8636 id: ref_id(&[
8637 rel_path,
8638 "dispatch",
8639 caller_symbol,
8640 &call_site.line.to_string(),
8641 &call_site.byte_start.to_string(),
8642 &call_site.byte_end.to_string(),
8643 &ordinal.to_string(),
8644 ]),
8645 method_name: call_site.callee_name.clone(),
8646 caller_node: caller_node.clone(),
8647 file: rel_path.to_string(),
8648 line: call_site.line,
8649 byte_start: call_site.byte_start,
8650 byte_end: call_site.byte_end,
8651 });
8652 }
8653 }
8654 hints
8655}
8656
8657fn surface_fingerprint(
8658 nodes: &mut [NodeRecord],
8659 data: &FileCallData,
8660 reexport_parts: &[String],
8661) -> String {
8662 nodes.sort_by(|left, right| {
8663 (left.file_path.as_str(), left.scoped_name.as_str())
8664 .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
8665 });
8666 let mut parts = Vec::new();
8667 for node in nodes.iter() {
8668 parts.push(format!(
8669 "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
8670 node.scoped_name,
8671 node.name,
8672 node.kind,
8673 node.exported,
8674 node.range.start_line,
8675 node.range.start_col,
8676 node.range.end_line,
8677 node.range.end_col,
8678 node.range_ordinal,
8679 node.signature.as_deref().unwrap_or("")
8680 ));
8681 }
8682 let mut exports = data.exported_symbols.clone();
8683 exports.sort();
8684 for export in exports {
8685 parts.push(format!("export\t{export}"));
8686 }
8687 if let Some(default_export) = &data.default_export_symbol {
8688 parts.push(format!("default\t{default_export}"));
8689 }
8690 let mut imports: Vec<String> = data
8691 .import_block
8692 .imports
8693 .iter()
8694 .map(|import| {
8695 format!(
8696 "import\t{}\t{:?}\t{}",
8697 import.module_path, import.form, import.raw_text
8698 )
8699 })
8700 .collect();
8701 imports.sort();
8702 parts.extend(imports);
8703 parts.extend(reexport_parts.iter().cloned());
8704 hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
8705}
8706
8707fn resolve_ref<I: ResolverIndex>(raw: RawRef, index: &I) -> Result<ResolvedRef> {
8708 if !matches!(raw.kind.as_str(), "call" | "value_ref") {
8709 return Ok(ResolvedRef {
8710 dependencies: raw.dependencies.clone(),
8711 raw,
8712 status: "unresolved".to_string(),
8713 target_node: None,
8714 target_file: None,
8715 target_symbol: None,
8716 edge: None,
8717 });
8718 }
8719
8720 let caller_file = raw.caller_file.clone();
8721 let caller_data =
8722 index
8723 .caller_data(&caller_file)
8724 .ok_or_else(|| CallGraphStoreError::MissingCallerData {
8725 file: caller_file.clone(),
8726 })?;
8727 let full_ref = raw.full_ref.as_deref().unwrap_or_default();
8728 let short_name = raw.short_name.as_deref().unwrap_or_default();
8729 let mut dependencies = raw.dependencies.clone();
8730
8731 let resolved = match index.lang_for(&caller_file) {
8732 Some(LangId::Rust) => {
8733 resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
8734 }
8735 Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
8736 resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
8737 }
8738 _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
8739 };
8740
8741 let Some((status, target_file, target_symbol)) = resolved else {
8742 return Ok(ResolvedRef {
8743 raw,
8744 status: "unresolved".to_string(),
8745 target_node: None,
8746 target_file: None,
8747 target_symbol: None,
8748 dependencies,
8749 edge: None,
8750 });
8751 };
8752
8753 dependencies.insert(target_file.clone());
8754 let target_node = index.node_for_symbol(&target_file, &target_symbol);
8755 if raw.kind == "value_ref"
8756 && !target_node
8757 .as_deref()
8758 .is_some_and(|node_id| index.node_is_callable(&target_file, node_id))
8759 {
8760 return Ok(ResolvedRef {
8761 raw,
8762 status: "unresolved".to_string(),
8763 target_node: None,
8764 target_file: None,
8765 target_symbol: None,
8766 dependencies,
8767 edge: None,
8768 });
8769 }
8770 let source_node = raw.caller_node.clone();
8771 let edge = if let Some(source_node) = source_node {
8772 if target_file == caller_file
8773 && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
8774 {
8775 None
8776 } else {
8777 Some(EdgeRecord {
8778 edge_id: ref_id(&[&raw.ref_id, "edge"]),
8779 source_node,
8780 target_node: target_node.clone(),
8781 target_file: target_file.clone(),
8782 target_symbol: target_symbol.clone(),
8783 kind: raw.kind.clone(),
8784 line: raw.line,
8785 })
8786 }
8787 } else {
8788 None
8789 };
8790
8791 Ok(ResolvedRef {
8792 raw,
8793 status,
8794 target_node,
8795 target_file: Some(target_file),
8796 target_symbol: Some(target_symbol),
8797 dependencies,
8798 edge,
8799 })
8800}
8801
8802fn resolve_js_ts_target<I: ResolverIndex>(
8803 index: &I,
8804 caller_file: &str,
8805 full_ref: &str,
8806 short_name: &str,
8807 caller_data: &FileCallData,
8808) -> Option<(String, String, String)> {
8809 if let Some((namespace, member)) = full_ref.split_once('.') {
8810 for import in &caller_data.import_block.imports {
8811 if import.namespace_import.as_deref() == Some(namespace) {
8812 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
8813 if let Some((file, symbol)) =
8814 resolve_exported_symbol(index, &target_file, member, 0)
8815 {
8816 return Some(("resolved".to_string(), file, symbol));
8817 }
8818 }
8819 }
8820 }
8821 }
8822
8823 for import in &caller_data.import_block.imports {
8824 for spec in &import.names {
8825 if crate::imports::specifier_local_name(spec) == short_name {
8826 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
8827 let requested = crate::imports::specifier_imported_name(spec);
8828 let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
8829 .unwrap_or_else(|| (target_file, requested.to_string()));
8830 return Some(("resolved".to_string(), file, symbol));
8831 }
8832 }
8833 }
8834
8835 if import.default_import.as_deref() == Some(short_name) {
8836 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
8837 let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
8838 .or_else(|| {
8839 index
8840 .default_export(&target_file)
8841 .map(|symbol| (target_file.clone(), symbol))
8842 })
8843 .unwrap_or_else(|| {
8844 let file_name = Path::new(&target_file)
8845 .file_name()
8846 .and_then(|name| name.to_str())
8847 .unwrap_or("unknown")
8848 .to_string();
8849 (target_file, format!("<default:{file_name}>"))
8850 });
8851 return Some(("resolved".to_string(), file, symbol));
8852 }
8853 }
8854 }
8855
8856 for import in &caller_data.import_block.imports {
8857 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
8858 if index.has_export(&target_file, short_name) {
8859 return Some(("resolved".to_string(), target_file, short_name.to_string()));
8860 }
8861 }
8862 }
8863
8864 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
8865}
8866
8867fn resolve_exported_symbol<I: ResolverIndex>(
8868 index: &I,
8869 file: &str,
8870 requested: &str,
8871 depth: usize,
8872) -> Option<(String, String)> {
8873 let mut visited = std::collections::HashMap::new();
8874 resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
8875}
8876
8877fn resolve_exported_symbol_inner<I: ResolverIndex>(
8886 index: &I,
8887 file: &str,
8888 requested: &str,
8889 depth: usize,
8890 visited: &mut std::collections::HashMap<(String, String), usize>,
8891) -> Option<(String, String)> {
8892 if depth > 16 {
8893 return None;
8894 }
8895 if requested != "default" {
8896 if let Some(source_symbol) = index.export_alias(file, requested) {
8897 return Some((file.to_string(), source_symbol));
8898 }
8899 if index.has_export(file, requested) {
8900 return Some((file.to_string(), requested.to_string()));
8901 }
8902 } else if let Some(default) = index.default_export(file) {
8903 return Some((file.to_string(), default));
8904 }
8905
8906 match visited.entry((file.to_string(), requested.to_string())) {
8910 std::collections::hash_map::Entry::Occupied(mut seen) => {
8911 if *seen.get() <= depth {
8912 return None;
8913 }
8914 seen.insert(depth);
8915 }
8916 std::collections::hash_map::Entry::Vacant(slot) => {
8917 slot.insert(depth);
8918 }
8919 }
8920
8921 for reexport in index.reexports_for(file) {
8922 let mut next_requested = requested.to_string();
8923 let matches = if reexport.wildcard {
8924 true
8925 } else if let Some(source_name) = reexport.named.get(requested) {
8926 next_requested = source_name.clone();
8927 true
8928 } else {
8929 false
8930 };
8931 if !matches {
8932 continue;
8933 }
8934 if let Some(target_file) = &reexport.target_file {
8935 if let Some(target) = resolve_exported_symbol_inner(
8936 index,
8937 target_file,
8938 &next_requested,
8939 depth + 1,
8940 visited,
8941 ) {
8942 return Some(target);
8943 }
8944 }
8945 }
8946 None
8947}
8948
8949fn resolve_rust_target<I: ResolverIndex>(
8950 index: &I,
8951 caller_file: &str,
8952 full_ref: &str,
8953 short_name: &str,
8954 caller_data: &FileCallData,
8955 raw: &RawRef,
8956) -> Option<(String, String, String)> {
8957 if full_ref.contains("::") {
8958 if let Some((target_file, target_symbol)) =
8959 rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
8960 {
8961 return Some(("resolved".to_string(), target_file, target_symbol));
8962 }
8963 }
8964
8965 for import in &caller_data.import_block.imports {
8966 if let Some((target_file, target_symbol)) =
8967 rust_target_for_use(index, caller_file, import, short_name)
8968 {
8969 return Some(("resolved".to_string(), target_file, target_symbol));
8970 }
8971 }
8972
8973 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
8974}
8975
8976fn rust_target_for_qualified<I: ResolverIndex>(
8977 index: &I,
8978 caller_file: &str,
8979 full_ref: &str,
8980 short_name: &str,
8981 caller_data: &FileCallData,
8982 raw: &RawRef,
8983) -> Option<(String, String)> {
8984 let mut segments: Vec<&str> = full_ref.split("::").collect();
8985 if segments.len() < 2 {
8986 return None;
8987 }
8988 segments.pop();
8989 let requested_symbol = rust_target_symbol(full_ref, short_name);
8990
8991 for path in rust_module_path_candidates(&segments, caller_data, raw) {
8992 let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
8993 if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
8994 if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
8995 return Some(rust_resolve_reexport_if_symbol_missing(
8996 index,
8997 target_file,
8998 requested_symbol.clone(),
8999 ));
9000 }
9001 }
9002
9003 let module_segments = rust_resolve_segments(caller_file, &path_refs)?;
9004 if let Some(target) =
9005 rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
9006 {
9007 return Some(target);
9008 }
9009 if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
9010 return Some(rust_resolve_reexport_if_symbol_missing(
9011 index,
9012 target_file,
9013 requested_symbol.clone(),
9014 ));
9015 }
9016 }
9017 None
9018}
9019
9020fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
9021 full_ref
9022 .rsplit("::")
9023 .next()
9024 .filter(|name| !name.is_empty())
9025 .unwrap_or(short_name)
9026 .to_string()
9027}
9028
9029fn rust_resolve_reexport_if_symbol_missing<I: ResolverIndex>(
9030 index: &I,
9031 target_file: String,
9032 target_symbol: String,
9033) -> (String, String) {
9034 if index
9035 .node_for_symbol(&target_file, &target_symbol)
9036 .is_some()
9037 {
9038 return (target_file, target_symbol);
9039 }
9040 if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
9041 resolved
9042 } else {
9043 (target_file, target_symbol)
9044 }
9045}
9046
9047fn rust_module_path_candidates(
9048 segments: &[&str],
9049 caller_data: &FileCallData,
9050 raw: &RawRef,
9051) -> Vec<Vec<String>> {
9052 let mut candidates = Vec::new();
9053 if let Some(first) = segments.first().copied() {
9054 for import in &caller_data.import_block.imports {
9055 if !rust_import_is_visible_to_call(import, raw) {
9056 continue;
9057 }
9058 let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
9059 continue;
9060 };
9061 if local_name == first {
9062 path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
9063 rust_push_unique_path_candidate(&mut candidates, path_segments);
9064 }
9065 }
9066 }
9067 rust_push_unique_path_candidate(
9068 &mut candidates,
9069 segments
9070 .iter()
9071 .map(|segment| (*segment).to_string())
9072 .collect(),
9073 );
9074 candidates
9075}
9076
9077fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
9078 if !candidates.iter().any(|existing| existing == &candidate) {
9079 candidates.push(candidate);
9080 }
9081}
9082
9083fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
9084 import.byte_range.start <= raw.byte_start
9085}
9086
9087fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
9088 let path = import.module_path.trim().trim_end_matches(';').trim();
9089 if path.contains("::{") || path.contains('{') || path.contains('*') {
9090 return None;
9091 }
9092 let (path_without_alias, alias) = path
9093 .split_once(" as ")
9094 .map(|(left, right)| (left.trim(), Some(right.trim())))
9095 .unwrap_or((path, None));
9096 let segments = path_without_alias
9097 .split("::")
9098 .map(str::trim)
9099 .filter(|segment| !segment.is_empty())
9100 .collect::<Vec<_>>();
9101 let local_name = alias.or_else(|| segments.last().copied())?.to_string();
9102 if local_name.chars().next().is_some_and(char::is_uppercase) {
9103 return None;
9104 }
9105 Some((
9106 local_name,
9107 segments
9108 .into_iter()
9109 .map(|segment| segment.to_string())
9110 .collect(),
9111 ))
9112}
9113
9114fn rust_inline_scoped_target<I: ResolverIndex>(
9115 index: &I,
9116 caller_file: &str,
9117 module_segments: &[String],
9118 short_name: &str,
9119) -> Option<(String, String)> {
9120 index.inline_scoped_target(caller_file, module_segments, short_name)
9121}
9122
9123fn rust_target_for_use<I: ResolverIndex>(
9124 index: &I,
9125 caller_file: &str,
9126 import: &ImportStatement,
9127 short_name: &str,
9128) -> Option<(String, String)> {
9129 let path = import.module_path.trim().trim_end_matches(';');
9130 if let Some(brace_start) = path.find("::{") {
9131 let prefix = &path[..brace_start];
9132 if import.names.iter().any(|name| name == short_name) {
9133 let prefix_segments: Vec<&str> = prefix.split("::").collect();
9134 let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
9135 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
9136 return Some((file, short_name.to_string()));
9137 }
9138 return None;
9139 }
9140
9141 let (path_without_alias, alias) = path
9142 .split_once(" as ")
9143 .map(|(left, right)| (left.trim(), Some(right.trim())))
9144 .unwrap_or((path, None));
9145 let segments: Vec<&str> = path_without_alias.split("::").collect();
9146 let imported = alias.or_else(|| segments.last().copied())?;
9147 if imported != short_name {
9148 return None;
9149 }
9150 if segments.len() < 2 {
9151 return None;
9152 }
9153 let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
9154 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
9155 Some((file, segments.last().unwrap_or(&short_name).to_string()))
9156}
9157
9158fn rust_workspace_file_for_segments<I: ResolverIndex>(
9159 index: &I,
9160 segments: &[&str],
9161) -> Option<String> {
9162 let crate_name = segments.first().copied()?;
9163 let src_prefix = index.crate_src_prefix(crate_name)?;
9164 let module_segments = segments[1..]
9165 .iter()
9166 .map(|segment| segment.to_string())
9167 .collect::<Vec<_>>();
9168 rust_file_for_src_prefix(index, &src_prefix, &module_segments)
9169}
9170
9171#[cfg(test)]
9172static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
9173 OnceLock::new();
9174
9175#[cfg(test)]
9176fn note_workspace_crate_prefix_build(project_root: &Path) {
9177 let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
9178 .get_or_init(|| Mutex::new(HashMap::new()))
9179 .lock()
9180 .expect("workspace crate prefix build counts mutex poisoned");
9181 *counts.entry(project_root.to_path_buf()).or_default() += 1;
9182}
9183
9184#[cfg(not(test))]
9185fn note_workspace_crate_prefix_build(_project_root: &Path) {}
9186
9187#[cfg(test)]
9188fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
9189 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
9190 .get_or_init(|| Mutex::new(HashMap::new()))
9191 .lock()
9192 .expect("workspace crate prefix build counts mutex poisoned")
9193 .remove(project_root);
9194}
9195
9196#[cfg(test)]
9197fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
9198 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
9199 .get_or_init(|| Mutex::new(HashMap::new()))
9200 .lock()
9201 .expect("workspace crate prefix build counts mutex poisoned")
9202 .get(project_root)
9203 .copied()
9204 .unwrap_or(0)
9205}
9206
9207fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
9212 note_workspace_crate_prefix_build(project_root);
9213 let mut prefixes = HashMap::new();
9214 let mut stack = vec![project_root.to_path_buf()];
9215 while let Some(dir) = stack.pop() {
9216 let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
9217 if matches!(name, "target" | "node_modules" | ".git") {
9218 continue;
9219 }
9220 let manifest = dir.join("Cargo.toml");
9221 if manifest.is_file() {
9222 let crate_names = rust_manifest_crate_names(&manifest);
9223 if !crate_names.is_empty() {
9224 let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
9225 for crate_name in crate_names {
9226 prefixes
9227 .entry(crate_name)
9228 .or_insert_with(|| src_prefix.clone());
9229 }
9230 }
9231 }
9232 let Ok(entries) = std::fs::read_dir(&dir) else {
9233 continue;
9234 };
9235 for entry in entries.flatten() {
9236 let path = entry.path();
9237 if path.is_dir() {
9238 stack.push(path);
9239 }
9240 }
9241 }
9242 prefixes
9243}
9244
9245fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
9249 let Ok(source) = std::fs::read_to_string(manifest) else {
9250 return Vec::new();
9251 };
9252 let mut in_lib = false;
9253 let mut package_name = None;
9254 let mut lib_name = None;
9255 for line in source.lines() {
9256 let trimmed = line.trim();
9257 if trimmed.starts_with('[') {
9258 in_lib = trimmed == "[lib]";
9259 continue;
9260 }
9261 let Some((key, value)) = trimmed.split_once('=') else {
9262 continue;
9263 };
9264 let key = key.trim();
9265 let value = value.trim().trim_matches('"');
9266 if in_lib && key == "name" {
9267 lib_name = Some(value.to_string());
9268 } else if !in_lib && key == "name" && package_name.is_none() {
9269 package_name = Some(value.to_string());
9270 }
9271 }
9272 let mut names = Vec::new();
9273 if let Some(lib) = lib_name {
9274 names.push(lib);
9275 }
9276 if let Some(package) = package_name {
9277 let normalized = package.replace('-', "_");
9278 if !names.contains(&normalized) {
9279 names.push(normalized);
9280 }
9281 }
9282 names
9283}
9284
9285fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
9286 if segments.is_empty() {
9287 return Some(Vec::new());
9288 }
9289 let caller_segments = rust_module_segments_for_rel(caller_file);
9290 match segments[0] {
9291 "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
9292 "self" => {
9293 let mut resolved = caller_segments;
9294 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
9295 Some(resolved)
9296 }
9297 "super" => {
9298 let mut resolved = caller_segments;
9299 resolved.pop();
9300 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
9301 Some(resolved)
9302 }
9303 _ => {
9304 let mut resolved = caller_segments;
9305 resolved.pop();
9306 resolved.extend(segments.iter().map(|item| item.to_string()));
9307 Some(resolved)
9308 }
9309 }
9310}
9311
9312fn rust_file_for_segments<I: ResolverIndex>(
9313 index: &I,
9314 caller_file: &str,
9315 segments: &[String],
9316) -> Option<String> {
9317 rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
9318}
9319
9320fn rust_file_for_src_prefix<I: ResolverIndex>(
9321 index: &I,
9322 src_prefix: &str,
9323 segments: &[String],
9324) -> Option<String> {
9325 let candidate = if segments.is_empty() {
9326 [src_prefix, "lib.rs"].join("/")
9327 } else {
9328 format!("{}/{}.rs", src_prefix, segments.join("/"))
9329 };
9330 if index.contains_file(&candidate) {
9331 return Some(candidate);
9332 }
9333 if !segments.is_empty() {
9334 let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
9335 if index.contains_file(&mod_candidate) {
9336 return Some(mod_candidate);
9337 }
9338 }
9339 None
9340}
9341
9342fn rust_src_prefix(rel_path: &str) -> String {
9343 rel_path
9344 .split_once("/src/")
9345 .map(|(prefix, _)| format!("{prefix}/src"))
9346 .unwrap_or_else(|| "src".to_string())
9347}
9348
9349fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
9350 let after_src = rel_path
9351 .split_once("/src/")
9352 .map(|(_, rest)| rest)
9353 .or_else(|| rel_path.strip_prefix("src/"))
9354 .unwrap_or(rel_path);
9355 if matches!(after_src, "lib.rs" | "main.rs") {
9356 return Vec::new();
9357 }
9358 if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
9359 return prefix.split('/').map(|item| item.to_string()).collect();
9360 }
9361 after_src
9362 .strip_suffix(".rs")
9363 .unwrap_or(after_src)
9364 .split('/')
9365 .map(|item| item.to_string())
9366 .collect()
9367}
9368
9369fn resolve_local_target<I: ResolverIndex>(
9370 _index: &I,
9371 caller_file: &str,
9372 full_ref: &str,
9373 short_name: &str,
9374 caller_data: &FileCallData,
9375) -> Option<(String, String, String)> {
9376 if !callgraph::is_bare_callee(full_ref, short_name) {
9377 return None;
9378 }
9379 callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
9380 .ok()
9381 .map(|symbol| {
9382 (
9383 "resolved_local".to_string(),
9384 caller_file.to_string(),
9385 symbol,
9386 )
9387 })
9388}
9389
9390impl<'a> ProjectIndex<'a> {
9391 fn from_parts(
9392 project_root: &Path,
9393 files: HashMap<String, DbFileIndex>,
9394 caller_data: HashMap<String, &'a FileCallData>,
9395 workspace_crate_prefixes: WorkspaceCratePrefixCache,
9396 ) -> Self {
9397 Self {
9398 project_root: project_root.to_path_buf(),
9399 files,
9400 caller_data,
9401 workspace_crate_prefixes,
9402 }
9403 }
9404
9405 fn from_db_and_callers(
9406 tx: &Transaction<'_>,
9407 project_root: &Path,
9408 caller_extracts: &'a HashMap<String, FileExtract>,
9409 workspace_crate_prefixes: WorkspaceCratePrefixCache,
9410 ) -> Result<Self> {
9411 let mut files = load_db_file_indexes(tx, project_root)?;
9412 let mut caller_data = HashMap::new();
9413 for (rel_path, extract) in caller_extracts {
9414 files.insert(
9415 rel_path.clone(),
9416 DbFileIndex::from_extract(project_root, extract),
9417 );
9418 caller_data.insert(rel_path.clone(), &extract.data);
9419 }
9420 Ok(Self::from_parts(
9421 project_root,
9422 files,
9423 caller_data,
9424 workspace_crate_prefixes,
9425 ))
9426 }
9427
9428 fn lang_for(&self, rel_path: &str) -> Option<LangId> {
9429 self.files.get(rel_path).and_then(|file| file.lang)
9430 }
9431
9432 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
9433 self.files
9434 .get(caller_file)
9435 .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
9436 }
9437
9438 fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
9439 self.files
9440 .get(rel_path)
9441 .map(|file| file.reexports.as_slice())
9442 .unwrap_or(&[])
9443 }
9444
9445 fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
9446 self.files.get(rel_path).and_then(|file| {
9447 file.node_by_scoped
9448 .get(symbol)
9449 .cloned()
9450 .or_else(|| file.node_by_bare.get(symbol).cloned())
9451 })
9452 }
9453
9454 fn node_is_callable(&self, rel_path: &str, node_id: &str) -> bool {
9455 self.files
9456 .get(rel_path)
9457 .and_then(|file| file.node_kind_by_id.get(node_id))
9458 .is_some_and(|kind| matches!(kind.as_str(), "function" | "method"))
9459 }
9460}
9461
9462impl DbFileIndex {
9463 fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
9464 let mut node_by_scoped = HashMap::new();
9465 let mut node_by_bare = HashMap::new();
9466 for node in &extract.nodes {
9467 node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
9468 node_by_bare
9469 .entry(node.name.clone())
9470 .or_insert(node.id.clone());
9471 }
9472 let node_kind_by_id = extract
9473 .nodes
9474 .iter()
9475 .map(|node| (node.id.clone(), node.kind.clone()))
9476 .collect();
9477 let mut export_aliases = HashMap::new();
9478 for raw_ref in &extract.raw_refs {
9479 if raw_ref.kind == "export_alias" {
9480 if let (Some(exported), Some(source_symbol)) =
9481 (&raw_ref.local_name, &raw_ref.requested_name)
9482 {
9483 export_aliases.insert(exported.clone(), source_symbol.clone());
9484 }
9485 }
9486 }
9487 let mut module_targets = HashMap::new();
9488 let mut reexports = Vec::new();
9489 for raw_ref in &extract.raw_refs {
9490 if !matches!(raw_ref.kind.as_str(), "import" | "reexport") {
9491 continue;
9492 }
9493 let Some(module_path) = &raw_ref.module_path else {
9494 continue;
9495 };
9496 let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
9497 module_targets
9498 .entry(module_path.clone())
9499 .or_insert_with(|| target_file.clone());
9500 if raw_ref.kind == "reexport" {
9501 reexports.push(reexport_index_from_raw(raw_ref, target_file));
9502 }
9503 }
9504 Self {
9505 lang: Some(extract.lang),
9506 exports: extract.data.exported_symbols.iter().cloned().collect(),
9507 default_export: extract.data.default_export_symbol.clone(),
9508 export_aliases,
9509 node_by_scoped,
9510 node_by_bare,
9511 node_kind_by_id,
9512 module_targets,
9513 reexports,
9514 }
9515 }
9516}
9517
9518fn load_db_file_indexes(
9519 tx: &Transaction<'_>,
9520 project_root: &Path,
9521) -> Result<HashMap<String, DbFileIndex>> {
9522 let mut files = HashMap::new();
9523 let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
9524 let rows = stmt.query_map([], |row| {
9525 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
9526 })?;
9527 for row in rows {
9528 let (rel_path, lang) = row?;
9529 files.insert(
9530 rel_path.clone(),
9531 DbFileIndex {
9532 lang: lang_from_label(&lang),
9533 exports: HashSet::new(),
9534 default_export: None,
9535 export_aliases: HashMap::new(),
9536 node_by_scoped: HashMap::new(),
9537 node_by_bare: HashMap::new(),
9538 node_kind_by_id: HashMap::new(),
9539 module_targets: HashMap::new(),
9540 reexports: Vec::new(),
9541 },
9542 );
9543 }
9544
9545 let mut node_stmt = tx.prepare(
9546 "SELECT file_path, id, name, scoped_name, kind, exported, is_default_export FROM nodes",
9547 )?;
9548 let nodes = node_stmt.query_map([], |row| {
9549 Ok((
9550 row.get::<_, String>(0)?,
9551 row.get::<_, String>(1)?,
9552 row.get::<_, String>(2)?,
9553 row.get::<_, String>(3)?,
9554 row.get::<_, String>(4)?,
9555 row.get::<_, i64>(5)? != 0,
9556 row.get::<_, i64>(6)? != 0,
9557 ))
9558 })?;
9559 for row in nodes {
9560 let (file_path, id, name, scoped_name, kind, exported, is_default_export) = row?;
9561 let file = files
9562 .entry(file_path.clone())
9563 .or_insert_with(|| DbFileIndex {
9564 lang: None,
9565 exports: HashSet::new(),
9566 default_export: None,
9567 export_aliases: HashMap::new(),
9568 node_by_scoped: HashMap::new(),
9569 node_by_bare: HashMap::new(),
9570 node_kind_by_id: HashMap::new(),
9571 module_targets: HashMap::new(),
9572 reexports: Vec::new(),
9573 });
9574 if exported {
9575 file.exports.insert(name.clone());
9576 file.exports.insert(scoped_name.clone());
9577 }
9578 if is_default_export {
9579 file.default_export = Some(scoped_name.clone());
9580 }
9581 file.node_by_scoped.insert(scoped_name, id.clone());
9582 file.node_by_bare.entry(name).or_insert(id.clone());
9583 file.node_kind_by_id.insert(id, kind);
9584 }
9585 let file_keys: HashSet<String> = files.keys().cloned().collect();
9586 let dependencies_by_file = load_file_dependencies_index(tx)?;
9590 let mut ref_stmt = tx.prepare(
9591 "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
9592 FROM refs WHERE kind IN ('reexport', 'export_alias')",
9593 )?;
9594 let ref_rows = ref_stmt.query_map([], |row| {
9595 Ok((
9596 row.get::<_, String>(0)?,
9597 row.get::<_, String>(1)?,
9598 row.get::<_, String>(2)?,
9599 row.get::<_, Option<String>>(3)?,
9600 row.get::<_, Option<String>>(4)?,
9601 row.get::<_, i64>(5)? != 0,
9602 row.get::<_, Option<String>>(6)?,
9603 row.get::<_, Option<String>>(7)?,
9604 ))
9605 })?;
9606 for row in ref_rows {
9607 let (
9608 ref_id,
9609 caller_file,
9610 kind,
9611 module_path,
9612 full_ref,
9613 wildcard,
9614 local_name,
9615 requested_name,
9616 ) = row?;
9617 if kind == "export_alias" {
9618 if let (Some(exported), Some(source_symbol), Some(file)) =
9619 (local_name, requested_name, files.get_mut(&caller_file))
9620 {
9621 file.export_aliases.insert(exported, source_symbol);
9622 }
9623 continue;
9624 }
9625 let Some(module_path) = module_path else {
9626 continue;
9627 };
9628 let file_deps = dependencies_by_file
9629 .get(&caller_file)
9630 .cloned()
9631 .unwrap_or_default();
9632 let deps = stored_dependencies_for_module(
9633 project_root,
9634 &caller_file,
9635 &module_path,
9636 &file_deps,
9637 &file_keys,
9638 );
9639 let target_file = deps
9640 .iter()
9641 .find(|dep| file_keys.contains(*dep))
9642 .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
9643 if let Some(file) = files.get_mut(&caller_file) {
9644 file.module_targets
9645 .entry(module_path.clone())
9646 .or_insert_with(|| target_file.clone());
9647 if kind == "reexport" {
9648 let raw = RawRef {
9649 ref_id,
9650 caller_node: None,
9651 caller_symbol: None,
9652 caller_file,
9653 kind,
9654 short_name: None,
9655 full_ref,
9656 module_path: Some(module_path),
9657 import_kind: Some("reexport".to_string()),
9658 local_name: None,
9659 requested_name: None,
9660 namespace_alias: None,
9661 wildcard,
9662 line: 0,
9663 byte_start: 0,
9664 byte_end: 0,
9665 dependencies: deps,
9666 };
9667 file.reexports
9668 .push(reexport_index_from_raw(&raw, target_file));
9669 }
9670 }
9671 }
9672
9673 Ok(files)
9674}
9675
9676fn stored_dependencies_for_module(
9677 project_root: &Path,
9678 caller_file: &str,
9679 module_path: &str,
9680 caller_dependencies: &BTreeSet<String>,
9681 indexed_files: &HashSet<String>,
9682) -> BTreeSet<String> {
9683 let caller_path = project_root.join(caller_file);
9684 let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
9685 if module_path.starts_with('.') {
9686 let caller_dir = caller_path.parent().unwrap_or(project_root);
9687 for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
9688 let normalized = if candidate.is_file() {
9689 canonicalize_path(&candidate)
9690 } else {
9691 candidate
9692 };
9693 candidates.insert(relative_path(project_root, &normalized));
9694 }
9695 }
9696 let exact = candidates
9697 .intersection(caller_dependencies)
9698 .filter(|dependency| indexed_files.contains(*dependency))
9699 .cloned()
9700 .collect::<BTreeSet<_>>();
9701 if !exact.is_empty() || module_path.starts_with('.') {
9702 return exact;
9703 }
9704
9705 let module_path = rust_module_path_without_alias_or_use_list(module_path)
9706 .trim_matches(|character| matches!(character, '\'' | '"'));
9707 let package_name = module_path
9708 .split('/')
9709 .next_back()
9710 .unwrap_or(module_path)
9711 .replace('_', "-");
9712 let matched = caller_dependencies
9713 .iter()
9714 .filter(|dependency| indexed_files.contains(*dependency))
9715 .filter(|dependency| {
9716 dependency.as_str() == module_path
9717 || dependency.ends_with(&format!("/{module_path}"))
9718 || Path::new(dependency).components().any(|component| {
9719 component.as_os_str().to_string_lossy().replace('_', "-") == package_name
9720 })
9721 })
9722 .cloned()
9723 .collect::<BTreeSet<_>>();
9724 if matched.len() == 1 {
9725 matched
9726 } else {
9727 BTreeSet::new()
9728 }
9729}
9730
9731fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
9732 let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
9733 let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
9734 let rows = stmt.query_map([], |row| {
9735 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
9736 })?;
9737 for row in rows {
9738 let (file_path, dependency) = row?;
9739 by_file.entry(file_path).or_default().insert(dependency);
9740 }
9741 Ok(by_file)
9742}
9743
9744struct ColdBuildInsertStatements<'stmt> {
9745 file: Statement<'stmt>,
9746 node: Statement<'stmt>,
9747 file_dependency: Statement<'stmt>,
9748 dispatch_hint: Statement<'stmt>,
9749 backend_state: Statement<'stmt>,
9750 reference: Statement<'stmt>,
9751 staging_ref_context: Statement<'stmt>,
9752 edge: Statement<'stmt>,
9753}
9754
9755impl<'stmt> ColdBuildInsertStatements<'stmt> {
9756 fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
9757 Ok(Self {
9758 file: tx.prepare(
9759 "INSERT OR REPLACE INTO files(
9760 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
9761 is_public_api, surface_fingerprint, indexed_at
9762 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
9763 )?,
9764 node: tx.prepare(
9765 "INSERT OR REPLACE INTO nodes(
9766 id, file_path, name, scoped_name, kind, start_line, start_col,
9767 end_line, end_col, range_ordinal, signature, exported,
9768 is_default_export, is_type_like, is_callgraph_entry_point, provenance
9769 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
9770 )?,
9771 file_dependency: tx.prepare(
9772 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
9773 )?,
9774 dispatch_hint: tx.prepare(
9775 "INSERT OR REPLACE INTO dispatch_hints(
9776 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
9777 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
9778 )?,
9779 backend_state: tx.prepare(
9780 "INSERT OR REPLACE INTO backend_file_state(
9781 backend, workspace_root, file_path, content_hash, status, updated_at
9782 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
9783 )?,
9784 reference: tx.prepare(
9785 "INSERT OR REPLACE INTO refs(
9786 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
9787 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
9788 byte_start, byte_end, status, target_node, target_file, target_symbol,
9789 provenance
9790 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
9791 )?,
9792 staging_ref_context: tx.prepare(
9793 "INSERT OR REPLACE INTO staging_ref_context(ref_id, caller_symbol) VALUES(?1, ?2)",
9794 )?,
9795 edge: tx.prepare(
9796 "INSERT OR REPLACE INTO edges(
9797 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
9798 kind, line, provenance
9799 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
9800 )?,
9801 })
9802 }
9803}
9804
9805fn insert_file_extract_prepared(
9806 statements: &mut ColdBuildInsertStatements<'_>,
9807 workspace_root: &str,
9808 extract: &FileExtract,
9809) -> Result<()> {
9810 statements.file.execute(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 for node in &extract.nodes {
9820 statements.node.execute(params![
9821 node.id,
9822 node.file_path,
9823 node.name,
9824 node.scoped_name,
9825 node.kind,
9826 node.range.start_line as i64,
9827 node.range.start_col as i64,
9828 node.range.end_line as i64,
9829 node.range.end_col as i64,
9830 node.range_ordinal as i64,
9831 node.signature,
9832 bool_int(node.exported),
9833 bool_int(node.is_default_export),
9834 bool_int(node.is_type_like),
9835 bool_int(node.is_callgraph_entry_point),
9836 PROVENANCE_TREESITTER,
9837 ])?;
9838 }
9839
9840 let mut dependencies = BTreeSet::new();
9841 for raw_ref in &extract.raw_refs {
9842 dependencies.extend(raw_ref.dependencies.iter().cloned());
9843 }
9844 for dep_file in &dependencies {
9845 statements
9846 .file_dependency
9847 .execute(params![extract.rel_path, dep_file])?;
9848 }
9849
9850 for hint in &extract.dispatch_hints {
9851 statements.dispatch_hint.execute(params![
9852 hint.id,
9853 hint.method_name,
9854 hint.caller_node,
9855 hint.file,
9856 hint.line as i64,
9857 hint.byte_start as i64,
9858 hint.byte_end as i64,
9859 PROVENANCE_TREESITTER,
9860 ])?;
9861 }
9862 insert_backend_state_prepared(
9863 &mut statements.backend_state,
9864 workspace_root,
9865 &extract.rel_path,
9866 Some(&extract.freshness.content_hash),
9867 "fresh",
9868 )?;
9869 Ok(())
9870}
9871
9872fn insert_backend_state_prepared(
9873 stmt: &mut Statement<'_>,
9874 workspace_root: &str,
9875 rel_path: &str,
9876 content_hash: Option<&blake3::Hash>,
9877 status: &str,
9878) -> Result<()> {
9879 let hash = content_hash
9880 .map(|hash| hash_to_hex(*hash))
9881 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
9882 stmt.execute(params![
9883 BACKEND_TREESITTER,
9884 workspace_root,
9885 rel_path,
9886 hash,
9887 status,
9888 unix_seconds_now(),
9889 ])?;
9890 Ok(())
9891}
9892
9893fn insert_staged_ref_prepared(
9894 statements: &mut ColdBuildInsertStatements<'_>,
9895 raw: &RawRef,
9896) -> Result<()> {
9897 statements.reference.execute(params![
9898 raw.ref_id,
9899 raw.caller_node,
9900 raw.caller_file,
9901 raw.kind,
9902 raw.short_name,
9903 raw.full_ref,
9904 raw.module_path,
9905 raw.import_kind,
9906 raw.local_name,
9907 raw.requested_name,
9908 raw.namespace_alias,
9909 bool_int(raw.wildcard),
9910 raw.line as i64,
9911 raw.byte_start as i64,
9912 raw.byte_end as i64,
9913 "staged",
9914 Option::<String>::None,
9915 Option::<String>::None,
9916 Option::<String>::None,
9917 ref_provenance(raw),
9918 ])?;
9919 statements
9920 .staging_ref_context
9921 .execute(params![raw.ref_id, raw.caller_symbol])?;
9922 Ok(())
9923}
9924
9925fn insert_resolved_ref_prepared(
9926 statements: &mut ColdBuildInsertStatements<'_>,
9927 resolved: &ResolvedRef,
9928) -> Result<()> {
9929 let raw = &resolved.raw;
9930 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
9931 statements.reference.execute(params![
9932 raw.ref_id,
9933 raw.caller_node,
9934 raw.caller_file,
9935 raw.kind,
9936 raw.short_name,
9937 raw.full_ref,
9938 raw.module_path,
9939 raw.import_kind,
9940 raw.local_name,
9941 raw.requested_name,
9942 raw.namespace_alias,
9943 bool_int(raw.wildcard),
9944 raw.line as i64,
9945 raw.byte_start as i64,
9946 raw.byte_end as i64,
9947 resolved.status,
9948 resolved.target_node,
9949 resolved.target_file,
9950 resolved.target_symbol,
9951 ref_provenance(raw),
9952 ])?;
9953 if let Some(edge) = &resolved.edge {
9954 statements.edge.execute(params![
9955 edge.edge_id,
9956 raw.ref_id,
9957 edge.source_node,
9958 edge.target_node,
9959 edge.target_file,
9960 edge.target_symbol,
9961 edge.kind,
9962 edge.line as i64,
9963 ref_provenance(raw),
9964 ])?;
9965 }
9966 Ok(())
9967}
9968
9969#[cfg(test)]
9970fn insert_file_extract(
9971 tx: &Transaction<'_>,
9972 project_root: &Path,
9973 extract: &FileExtract,
9974) -> Result<()> {
9975 tx.execute(
9976 "INSERT OR REPLACE INTO files(
9977 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
9978 is_public_api, surface_fingerprint, indexed_at
9979 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
9980 params![
9981 extract.rel_path,
9982 hash_to_hex(extract.freshness.content_hash),
9983 system_time_to_ns(extract.freshness.mtime),
9984 extract.freshness.size as i64,
9985 lang_label(extract.lang),
9986 extract.surface_fingerprint,
9987 unix_seconds_now(),
9988 ],
9989 )?;
9990 for node in &extract.nodes {
9991 tx.execute(
9992 "INSERT OR REPLACE INTO nodes(
9993 id, file_path, name, scoped_name, kind, start_line, start_col,
9994 end_line, end_col, range_ordinal, signature, exported,
9995 is_default_export, is_type_like, is_callgraph_entry_point, provenance
9996 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
9997 params![
9998 node.id,
9999 node.file_path,
10000 node.name,
10001 node.scoped_name,
10002 node.kind,
10003 node.range.start_line as i64,
10004 node.range.start_col as i64,
10005 node.range.end_line as i64,
10006 node.range.end_col as i64,
10007 node.range_ordinal as i64,
10008 node.signature,
10009 bool_int(node.exported),
10010 bool_int(node.is_default_export),
10011 bool_int(node.is_type_like),
10012 bool_int(node.is_callgraph_entry_point),
10013 PROVENANCE_TREESITTER,
10014 ],
10015 )?;
10016 }
10017 let mut dependencies = BTreeSet::new();
10018 for raw_ref in &extract.raw_refs {
10019 dependencies.extend(raw_ref.dependencies.iter().cloned());
10020 }
10021 insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
10022
10023 for hint in &extract.dispatch_hints {
10024 tx.execute(
10025 "INSERT OR REPLACE INTO dispatch_hints(
10026 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
10027 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
10028 params![
10029 hint.id,
10030 hint.method_name,
10031 hint.caller_node,
10032 hint.file,
10033 hint.line as i64,
10034 hint.byte_start as i64,
10035 hint.byte_end as i64,
10036 PROVENANCE_TREESITTER,
10037 ],
10038 )?;
10039 }
10040 mark_backend_state(
10041 tx,
10042 project_root,
10043 &extract.rel_path,
10044 Some(&extract.freshness.content_hash),
10045 "fresh",
10046 )?;
10047 Ok(())
10048}
10049
10050#[cfg(test)]
10051fn insert_file_dependencies(
10052 tx: &Transaction<'_>,
10053 file_path: &str,
10054 dependencies: &BTreeSet<String>,
10055) -> Result<()> {
10056 for dep_file in dependencies {
10057 tx.execute(
10058 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
10059 params![file_path, dep_file],
10060 )?;
10061 }
10062 Ok(())
10063}
10064
10065fn ref_provenance(raw: &RawRef) -> &'static str {
10066 if raw.kind == "value_ref" {
10067 PROVENANCE_VALUE_REF
10068 } else {
10069 PROVENANCE_TREESITTER
10070 }
10071}
10072
10073#[cfg(test)]
10074fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
10075 let raw = &resolved.raw;
10076 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
10077 tx.execute(
10078 "INSERT OR REPLACE INTO refs(
10079 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
10080 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
10081 byte_start, byte_end, status, target_node, target_file, target_symbol,
10082 provenance
10083 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
10084 params![
10085 raw.ref_id,
10086 raw.caller_node,
10087 raw.caller_file,
10088 raw.kind,
10089 raw.short_name,
10090 raw.full_ref,
10091 raw.module_path,
10092 raw.import_kind,
10093 raw.local_name,
10094 raw.requested_name,
10095 raw.namespace_alias,
10096 bool_int(raw.wildcard),
10097 raw.line as i64,
10098 raw.byte_start as i64,
10099 raw.byte_end as i64,
10100 resolved.status,
10101 resolved.target_node,
10102 resolved.target_file,
10103 resolved.target_symbol,
10104 ref_provenance(raw),
10105 ],
10106 )?;
10107 if let Some(edge) = &resolved.edge {
10108 tx.execute(
10109 "INSERT OR REPLACE INTO edges(
10110 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
10111 kind, line, provenance
10112 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
10113 params![
10114 edge.edge_id,
10115 raw.ref_id,
10116 edge.source_node,
10117 edge.target_node,
10118 edge.target_file,
10119 edge.target_symbol,
10120 edge.kind,
10121 edge.line as i64,
10122 ref_provenance(raw),
10123 ],
10124 )?;
10125 }
10126 Ok(())
10127}
10128
10129fn insert_method_dispatch_edges(
10130 tx: &Transaction<'_>,
10131 project_root: &Path,
10132 caller_files: Option<&BTreeSet<String>>,
10133) -> Result<usize> {
10134 let references = load_name_match_refs(tx, caller_files)?;
10135 if references.is_empty() {
10136 return Ok(0);
10137 }
10138
10139 let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
10140 let mut source_cache: DispatchSourceCache = HashMap::new();
10141 let mut inserted = 0usize;
10142 for reference in references {
10143 let key = (reference.method_name.clone(), reference.lang.clone());
10144 let candidates = match candidates_by_name.entry(key) {
10145 Entry::Occupied(entry) => entry.into_mut(),
10146 Entry::Vacant(entry) => {
10147 let candidates =
10148 load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
10149 entry.insert(candidates)
10150 }
10151 };
10152
10153 match infer_receiver_type_state(project_root, &reference, &mut source_cache) {
10154 ReceiverTypeInference::Known(receiver_type) => {
10155 let Some(candidate) =
10156 select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
10157 else {
10158 continue;
10159 };
10160 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
10161 inserted += 1;
10162 continue;
10163 }
10164 ReceiverTypeInference::RustDirectSelfField {
10165 receiver_type,
10166 declaration_file,
10167 module_scope,
10168 } => {
10169 let Some(candidate) = select_rust_direct_self_field_candidate(
10170 project_root,
10171 &reference,
10172 candidates.as_slice(),
10173 &receiver_type,
10174 &declaration_file,
10175 &module_scope,
10176 &mut source_cache,
10177 ) else {
10178 continue;
10179 };
10180 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
10181 inserted += 1;
10182 continue;
10183 }
10184 ReceiverTypeInference::KnownButUnresolved => continue,
10185 ReceiverTypeInference::Unknown => {}
10186 }
10187
10188 if method_name_match_denylisted(&reference.method_name) {
10189 continue;
10190 }
10191
10192 let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
10193 continue;
10194 };
10195 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
10196 inserted += 1;
10197 }
10198 Ok(inserted)
10199}
10200
10201fn insert_method_dispatch_edges_chunked(
10202 tx: &Transaction<'_>,
10203 project_root: &Path,
10204 chunk_size: usize,
10205) -> Result<usize> {
10206 let mut inserted = 0usize;
10207 let mut after_file = String::new();
10208 loop {
10209 let caller_files = {
10210 let mut statement = tx.prepare(
10211 "SELECT DISTINCT caller_file
10212 FROM refs
10213 WHERE caller_file > ?1
10214 ORDER BY caller_file
10215 LIMIT ?2",
10216 )?;
10217 let rows = statement
10218 .query_map(params![after_file, chunk_size.max(1) as i64], |row| {
10219 row.get::<_, String>(0)
10220 })?;
10221 rows.collect::<std::result::Result<BTreeSet<_>, _>>()?
10222 };
10223 let Some(last_file) = caller_files.last().cloned() else {
10224 break;
10225 };
10226 inserted += insert_method_dispatch_edges(tx, project_root, Some(&caller_files))?;
10227 after_file = last_file;
10228 }
10229 Ok(inserted)
10230}
10231
10232fn insert_method_dispatch_edge(
10233 tx: &Transaction<'_>,
10234 reference: &NameMatchRef,
10235 candidate: &NameMatchCandidate,
10236 provenance: &str,
10237) -> Result<()> {
10238 tx.execute(
10239 "INSERT OR REPLACE INTO edges(
10240 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
10241 kind, line, provenance
10242 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
10243 params![
10244 ref_id(&[&reference.ref_id, provenance, "edge"]),
10245 &reference.ref_id,
10246 &reference.caller_node,
10247 &candidate.node_id,
10248 &candidate.file_path,
10249 &candidate.scoped_name,
10250 reference.line as i64,
10251 provenance,
10252 ],
10253 )?;
10254 Ok(())
10255}
10256
10257fn delete_method_dispatch_edges_for_callers(
10258 tx: &Transaction<'_>,
10259 caller_files: &BTreeSet<String>,
10260) -> Result<()> {
10261 if caller_files.is_empty() {
10262 return Ok(());
10263 }
10264
10265 let mut stmt = tx.prepare(
10266 "DELETE FROM edges
10267 WHERE provenance IN (?1, ?2)
10268 AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
10269 )?;
10270 for caller_file in caller_files {
10271 stmt.execute(params![
10272 PROVENANCE_NAME_MATCH,
10273 PROVENANCE_TYPE_MATCH,
10274 caller_file
10275 ])?;
10276 }
10277 Ok(())
10278}
10279
10280fn load_name_match_refs(
10281 tx: &Transaction<'_>,
10282 caller_files: Option<&BTreeSet<String>>,
10283) -> Result<Vec<NameMatchRef>> {
10284 let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
10285 n.signature, r.short_name, r.full_ref, r.line, f.lang
10286 FROM refs r
10287 JOIN files f ON f.path = r.caller_file
10288 JOIN nodes n ON n.id = r.caller_node
10289 WHERE r.kind = 'call'
10290 AND r.status = 'unresolved'
10291 AND r.caller_node IS NOT NULL
10292 AND r.full_ref IS NOT NULL
10293 AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
10294 AND NOT EXISTS (
10295 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
10296 )";
10297 let mut references = Vec::new();
10298
10299 if let Some(caller_files) = caller_files {
10300 if caller_files.is_empty() {
10301 return Ok(references);
10302 }
10303 let sql = format!(
10304 "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
10305 );
10306 let mut stmt = tx.prepare(&sql)?;
10307 for caller_file in caller_files {
10308 let rows = stmt.query_map(params![caller_file], |row| {
10309 Ok((
10310 row.get::<_, String>(0)?,
10311 row.get::<_, Option<String>>(1)?,
10312 row.get::<_, String>(2)?,
10313 row.get::<_, String>(3)?,
10314 row.get::<_, Option<String>>(4)?,
10315 row.get::<_, Option<String>>(5)?,
10316 row.get::<_, Option<String>>(6)?,
10317 row.get::<_, i64>(7)?,
10318 row.get::<_, String>(8)?,
10319 ))
10320 })?;
10321 for row in rows {
10322 let (
10323 ref_id,
10324 caller_node,
10325 caller_file,
10326 caller_symbol,
10327 caller_signature,
10328 short_name,
10329 full_ref,
10330 line,
10331 lang,
10332 ) = row?;
10333 if let Some(reference) = name_match_ref_from_parts(
10334 ref_id,
10335 caller_node,
10336 caller_file,
10337 caller_symbol,
10338 caller_signature,
10339 short_name,
10340 full_ref,
10341 line,
10342 lang,
10343 ) {
10344 references.push(reference);
10345 }
10346 }
10347 }
10348 return Ok(references);
10349 }
10350
10351 let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
10352 let mut stmt = tx.prepare(&sql)?;
10353 let rows = stmt.query_map([], |row| {
10354 Ok((
10355 row.get::<_, String>(0)?,
10356 row.get::<_, Option<String>>(1)?,
10357 row.get::<_, String>(2)?,
10358 row.get::<_, String>(3)?,
10359 row.get::<_, Option<String>>(4)?,
10360 row.get::<_, Option<String>>(5)?,
10361 row.get::<_, Option<String>>(6)?,
10362 row.get::<_, i64>(7)?,
10363 row.get::<_, String>(8)?,
10364 ))
10365 })?;
10366 for row in rows {
10367 let (
10368 ref_id,
10369 caller_node,
10370 caller_file,
10371 caller_symbol,
10372 caller_signature,
10373 short_name,
10374 full_ref,
10375 line,
10376 lang,
10377 ) = row?;
10378 if let Some(reference) = name_match_ref_from_parts(
10379 ref_id,
10380 caller_node,
10381 caller_file,
10382 caller_symbol,
10383 caller_signature,
10384 short_name,
10385 full_ref,
10386 line,
10387 lang,
10388 ) {
10389 references.push(reference);
10390 }
10391 }
10392 Ok(references)
10393}
10394
10395#[allow(clippy::too_many_arguments)]
10396fn name_match_ref_from_parts(
10397 ref_id: String,
10398 caller_node: Option<String>,
10399 caller_file: String,
10400 caller_symbol: String,
10401 caller_signature: Option<String>,
10402 short_name: Option<String>,
10403 full_ref: Option<String>,
10404 line: i64,
10405 lang: String,
10406) -> Option<NameMatchRef> {
10407 let caller_node = caller_node?;
10408 let full_ref = full_ref?;
10409 let (receiver_expression, receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
10410 let method_name = if member.is_empty() {
10411 short_name.as_deref()?.to_string()
10412 } else {
10413 member
10414 };
10415 Some(NameMatchRef {
10416 ref_id,
10417 caller_node,
10418 caller_file,
10419 caller_symbol,
10420 caller_signature,
10421 receiver_expression,
10422 receiver,
10423 method_name,
10424 colon_dispatch,
10425 line: line.max(0) as u32,
10426 lang,
10427 })
10428}
10429
10430fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, String, bool)> {
10431 let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
10432 let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
10433 let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
10434 let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
10435 .into_iter()
10436 .flatten()
10437 .max_by_key(|(index, _, _)| *index)?;
10438 if delimiter == 0 {
10439 return None;
10440 }
10441 let member_start = delimiter + delimiter_len;
10442 if member_start >= full_ref.len() {
10443 return None;
10444 }
10445 let receiver_expression = full_ref[..delimiter].trim();
10446 let receiver = last_name_segment(receiver_expression).trim();
10447 let member = &full_ref[member_start..];
10448 if receiver.is_empty() || member.is_empty() {
10449 return None;
10450 }
10451 Some((
10452 receiver_expression.to_string(),
10453 receiver.to_string(),
10454 member.to_string(),
10455 colon_dispatch,
10456 ))
10457}
10458
10459fn last_name_segment(value: &str) -> &str {
10460 value
10461 .rsplit(['.', ':', '/', '\\', '-', '>'])
10462 .find(|segment| !segment.is_empty())
10463 .unwrap_or(value)
10464}
10465
10466fn load_name_match_candidates(
10467 tx: &Transaction<'_>,
10468 method_name: &str,
10469 lang: &str,
10470) -> Result<Vec<NameMatchCandidate>> {
10471 let mut stmt = tx.prepare(
10472 "SELECT n.id, n.file_path, n.scoped_name, n.kind, n.start_line
10473 FROM nodes n JOIN files f ON f.path = n.file_path
10474 WHERE n.name = ?1
10475 AND f.lang = ?2
10476 AND n.kind IN ('method', 'function')
10477 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
10478 )?;
10479 let rows = stmt.query_map(params![method_name, lang], |row| {
10480 Ok(NameMatchCandidate {
10481 node_id: row.get(0)?,
10482 file_path: row.get(1)?,
10483 scoped_name: row.get(2)?,
10484 kind: row.get(3)?,
10485 start_line: (row.get::<_, i64>(4)?.max(0) as u32).saturating_add(1),
10486 })
10487 })?;
10488 rows.collect::<std::result::Result<Vec<_>, _>>()
10489 .map_err(Into::into)
10490}
10491
10492struct ParsedDispatchSource {
10493 source: String,
10494 tree: tree_sitter::Tree,
10495}
10496
10497type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
10498
10499#[derive(Debug, Clone, PartialEq, Eq)]
10500enum ReceiverTypeInference {
10501 Unknown,
10502 Known(String),
10503 RustDirectSelfField {
10504 receiver_type: String,
10505 declaration_file: String,
10506 module_scope: Vec<(usize, usize)>,
10507 },
10508 KnownButUnresolved,
10509}
10510
10511#[cfg(test)]
10512fn infer_receiver_type(
10513 project_root: &Path,
10514 reference: &NameMatchRef,
10515 source_cache: &mut DispatchSourceCache,
10516) -> Option<String> {
10517 match infer_receiver_type_state(project_root, reference, source_cache) {
10518 ReceiverTypeInference::Known(receiver_type)
10519 | ReceiverTypeInference::RustDirectSelfField { receiver_type, .. } => Some(receiver_type),
10520 ReceiverTypeInference::Unknown | ReceiverTypeInference::KnownButUnresolved => None,
10521 }
10522}
10523
10524fn infer_receiver_type_state(
10525 project_root: &Path,
10526 reference: &NameMatchRef,
10527 source_cache: &mut DispatchSourceCache,
10528) -> ReceiverTypeInference {
10529 let known = |receiver_type| ReceiverTypeInference::Known(receiver_type);
10530 match reference.lang.as_str() {
10531 "rust" => infer_rust_receiver_type(project_root, reference, source_cache),
10532 "java" => {
10533 infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
10534 .map(known)
10535 .unwrap_or(ReceiverTypeInference::Unknown)
10536 }
10537 "kotlin" => {
10538 infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
10539 .map(known)
10540 .unwrap_or(ReceiverTypeInference::Unknown)
10541 }
10542 "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache)
10543 .map(known)
10544 .unwrap_or(ReceiverTypeInference::Unknown),
10545 _ => ReceiverTypeInference::Unknown,
10546 }
10547}
10548
10549fn parse_dispatch_source(
10550 project_root: &Path,
10551 caller_file: &str,
10552 lang: LangId,
10553) -> Option<ParsedDispatchSource> {
10554 let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
10555 let grammar = crate::parser::grammar_for(lang);
10556 let mut parser = tree_sitter::Parser::new();
10557 parser.set_language(&grammar).ok()?;
10558 let tree = parser.parse(&source, None)?;
10559 Some(ParsedDispatchSource { source, tree })
10560}
10561
10562fn parsed_dispatch_source<'a>(
10563 project_root: &Path,
10564 reference: &NameMatchRef,
10565 lang: LangId,
10566 source_cache: &'a mut DispatchSourceCache,
10567) -> Option<&'a ParsedDispatchSource> {
10568 parsed_dispatch_source_for_file(
10569 project_root,
10570 &reference.caller_file,
10571 &reference.lang,
10572 lang,
10573 source_cache,
10574 )
10575}
10576
10577fn parsed_dispatch_source_for_file<'a>(
10578 project_root: &Path,
10579 file_path: &str,
10580 lang_label: &str,
10581 lang: LangId,
10582 source_cache: &'a mut DispatchSourceCache,
10583) -> Option<&'a ParsedDispatchSource> {
10584 let key = (file_path.to_string(), lang_label.to_string());
10585 source_cache
10586 .entry(key)
10587 .or_insert_with(|| parse_dispatch_source(project_root, file_path, lang))
10588 .as_ref()
10589}
10590
10591fn infer_java_like_receiver_type(
10592 project_root: &Path,
10593 reference: &NameMatchRef,
10594 lang: LangId,
10595 source_cache: &mut DispatchSourceCache,
10596) -> Option<String> {
10597 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
10598 return None;
10599 }
10600
10601 let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
10602 let root = parsed.tree.root_node();
10603 let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
10604
10605 let callable_scope = type_node
10606 .and_then(|node| {
10607 find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
10608 })
10609 .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
10610
10611 if let Some(callable_scope) = callable_scope {
10612 if let Some(receiver_type) = infer_java_like_local_receiver_type(
10613 callable_scope,
10614 &parsed.source,
10615 &reference.receiver,
10616 reference.line.max(1),
10617 lang,
10618 ) {
10619 return Some(receiver_type);
10620 }
10621 }
10622
10623 type_node.and_then(|node| {
10624 infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
10625 })
10626}
10627
10628fn infer_cpp_receiver_type(
10629 project_root: &Path,
10630 reference: &NameMatchRef,
10631 source_cache: &mut DispatchSourceCache,
10632) -> Option<String> {
10633 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
10634 return None;
10635 }
10636
10637 let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
10638 let root = parsed.tree.root_node();
10639 let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
10640 infer_cpp_receiver_type_from_scope(
10641 scope,
10642 &parsed.source,
10643 &reference.receiver,
10644 reference.line.max(1),
10645 )
10646}
10647
10648fn find_enclosing_java_like_type_node<'tree>(
10649 root: tree_sitter::Node<'tree>,
10650 source: &str,
10651 reference: &NameMatchRef,
10652 lang: LangId,
10653) -> Option<tree_sitter::Node<'tree>> {
10654 let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
10655 .and_then(|name| simple_type_name(&name));
10656 let line = reference.line.max(1);
10657 let mut best = None;
10658 let mut stack = vec![root];
10659 while let Some(node) = stack.pop() {
10660 if !node_contains_line(node, line) {
10661 continue;
10662 }
10663 if is_java_like_type_kind(node.kind(), lang) {
10664 let name = declaration_name(node, source);
10665 if expected_type
10666 .as_deref()
10667 .is_none_or(|expected| name == Some(expected))
10668 {
10669 best = tighter_node(best, node);
10670 }
10671 }
10672 push_named_children(node, &mut stack);
10673 }
10674 best
10675}
10676
10677fn find_enclosing_java_like_callable_node<'tree>(
10678 root: tree_sitter::Node<'tree>,
10679 source: &str,
10680 reference: &NameMatchRef,
10681 lang: LangId,
10682) -> Option<tree_sitter::Node<'tree>> {
10683 let expected_name = reference.caller_symbol.rsplit("::").next();
10684 let line = reference.line.max(1);
10685 let mut best = None;
10686 let mut stack = vec![root];
10687 while let Some(node) = stack.pop() {
10688 if !node_contains_line(node, line) {
10689 continue;
10690 }
10691 if is_java_like_callable_kind(node.kind(), lang) {
10692 let name = declaration_name(node, source);
10693 if expected_name.is_none_or(|expected| name == Some(expected)) {
10694 best = tighter_node(best, node);
10695 }
10696 }
10697 push_named_children(node, &mut stack);
10698 }
10699 best
10700}
10701
10702fn find_enclosing_cpp_callable_node<'tree>(
10703 root: tree_sitter::Node<'tree>,
10704 _source: &str,
10705 reference: &NameMatchRef,
10706) -> Option<tree_sitter::Node<'tree>> {
10707 let line = reference.line.max(1);
10708 let mut best = None;
10709 let mut stack = vec![root];
10710 while let Some(node) = stack.pop() {
10711 if !node_contains_line(node, line) {
10712 continue;
10713 }
10714 if node.kind() == "function_definition" {
10715 best = tighter_node(best, node);
10716 }
10717 push_named_children(node, &mut stack);
10718 }
10719 best
10720}
10721
10722fn tighter_node<'tree>(
10723 current: Option<tree_sitter::Node<'tree>>,
10724 candidate: tree_sitter::Node<'tree>,
10725) -> Option<tree_sitter::Node<'tree>> {
10726 match current {
10727 Some(current)
10728 if current.start_byte() > candidate.start_byte()
10729 || (current.start_byte() == candidate.start_byte()
10730 && current.end_byte() <= candidate.end_byte()) =>
10731 {
10732 Some(current)
10733 }
10734 _ => Some(candidate),
10735 }
10736}
10737
10738fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
10739 let start = node.start_position().row as u32 + 1;
10740 let end = node.end_position().row as u32 + 1;
10741 start <= line && line <= end
10742}
10743
10744fn push_named_children<'tree>(
10745 node: tree_sitter::Node<'tree>,
10746 stack: &mut Vec<tree_sitter::Node<'tree>>,
10747) {
10748 for index in 0..node.named_child_count() {
10749 if let Some(child) = node.named_child(index as u32) {
10750 stack.push(child);
10751 }
10752 }
10753}
10754
10755fn declaration_name<'source>(
10756 node: tree_sitter::Node<'_>,
10757 source: &'source str,
10758) -> Option<&'source str> {
10759 node.child_by_field_name("name")
10760 .map(|name| node_text(name, source))
10761 .or_else(|| {
10762 first_named_child_text(
10763 node,
10764 source,
10765 &["identifier", "type_identifier", "simple_identifier"],
10766 )
10767 })
10768}
10769
10770fn first_named_child_text<'source>(
10771 node: tree_sitter::Node<'_>,
10772 source: &'source str,
10773 kinds: &[&str],
10774) -> Option<&'source str> {
10775 for index in 0..node.named_child_count() {
10776 let child = node.named_child(index as u32)?;
10777 if kinds.contains(&child.kind()) {
10778 return Some(node_text(child, source));
10779 }
10780 }
10781 None
10782}
10783
10784fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
10785 &source[node.byte_range()]
10786}
10787
10788fn infer_java_like_field_receiver_type(
10789 type_node: tree_sitter::Node<'_>,
10790 source: &str,
10791 receiver: &str,
10792 lang: LangId,
10793) -> Option<String> {
10794 let mut stack = Vec::new();
10795 push_named_children(type_node, &mut stack);
10796 while let Some(node) = stack.pop() {
10797 if is_java_like_field_kind(node.kind(), lang) {
10798 if let Some(receiver_type) =
10799 extract_java_like_declared_type(node_text(node, source), receiver, lang)
10800 {
10801 return Some(receiver_type);
10802 }
10803 }
10804 if is_java_like_type_kind(node.kind(), lang)
10805 || is_java_like_callable_kind(node.kind(), lang)
10806 {
10807 continue;
10808 }
10809 push_named_children(node, &mut stack);
10810 }
10811 None
10812}
10813
10814fn infer_java_like_local_receiver_type(
10815 callable_node: tree_sitter::Node<'_>,
10816 source: &str,
10817 receiver: &str,
10818 call_line: u32,
10819 lang: LangId,
10820) -> Option<String> {
10821 let mut best: Option<(u32, String)> = None;
10822 let mut stack = Vec::new();
10823 push_named_children(callable_node, &mut stack);
10824 while let Some(node) = stack.pop() {
10825 let start_line = node.start_position().row as u32 + 1;
10826 if start_line > call_line {
10827 continue;
10828 }
10829 if is_java_like_local_kind(node.kind(), lang) {
10830 if let Some(receiver_type) =
10831 extract_java_like_declared_type(node_text(node, source), receiver, lang)
10832 {
10833 if best
10834 .as_ref()
10835 .is_none_or(|(best_line, _)| start_line >= *best_line)
10836 {
10837 best = Some((start_line, receiver_type));
10838 }
10839 }
10840 }
10841 if is_java_like_type_kind(node.kind(), lang)
10842 || is_java_like_callable_kind(node.kind(), lang)
10843 {
10844 continue;
10845 }
10846 push_named_children(node, &mut stack);
10847 }
10848 best.map(|(_, receiver_type)| receiver_type)
10849}
10850
10851fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
10852 match lang {
10853 LangId::Java => matches!(
10854 kind,
10855 "class_declaration"
10856 | "interface_declaration"
10857 | "enum_declaration"
10858 | "record_declaration"
10859 | "annotation_type_declaration"
10860 ),
10861 LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
10862 _ => false,
10863 }
10864}
10865
10866fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
10867 match lang {
10868 LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
10869 LangId::Kotlin => kind == "function_declaration",
10870 _ => false,
10871 }
10872}
10873
10874fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
10875 match lang {
10876 LangId::Java => kind == "field_declaration",
10877 LangId::Kotlin => kind == "property_declaration",
10878 _ => false,
10879 }
10880}
10881
10882fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
10883 match lang {
10884 LangId::Java => kind == "local_variable_declaration",
10885 LangId::Kotlin => kind == "property_declaration",
10886 _ => false,
10887 }
10888}
10889
10890fn extract_java_like_declared_type(
10891 declaration: &str,
10892 receiver: &str,
10893 lang: LangId,
10894) -> Option<String> {
10895 match lang {
10896 LangId::Java => extract_java_declared_type(declaration, receiver),
10897 LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
10898 _ => None,
10899 }
10900}
10901
10902fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
10903 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
10904 let after = declaration[receiver_start + receiver.len()..].trim_start();
10905 if after
10906 .chars()
10907 .next()
10908 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
10909 {
10910 return None;
10911 }
10912
10913 let before = declaration[..receiver_start].trim_end();
10914 if before.contains(',') {
10915 return None;
10916 }
10917 normalize_receiver_type_name(strip_java_declaration_prefixes(before))
10918}
10919
10920fn strip_java_declaration_prefixes(mut value: &str) -> &str {
10921 loop {
10922 value = value.trim_start();
10923 if let Some(stripped) = strip_leading_java_annotation(value) {
10924 value = stripped;
10925 continue;
10926 }
10927 if let Some(stripped) = strip_leading_java_modifier(value) {
10928 value = stripped;
10929 continue;
10930 }
10931 return value.trim();
10932 }
10933}
10934
10935fn strip_leading_java_annotation(value: &str) -> Option<&str> {
10936 let value = value.trim_start();
10937 let mut chars = value.char_indices();
10938 let (_, first) = chars.next()?;
10939 if first != '@' {
10940 return None;
10941 }
10942 let mut end = first.len_utf8();
10943 for (index, ch) in chars {
10944 if !(is_code_ident_char(ch) || ch == '.') {
10945 end = index;
10946 break;
10947 }
10948 end = index + ch.len_utf8();
10949 }
10950 let rest = value[end..].trim_start();
10951 if let Some(stripped) = rest.strip_prefix('(') {
10952 let mut depth = 1usize;
10953 for (index, ch) in stripped.char_indices() {
10954 match ch {
10955 '(' => depth += 1,
10956 ')' => {
10957 depth = depth.saturating_sub(1);
10958 if depth == 0 {
10959 return Some(stripped[index + ch.len_utf8()..].trim_start());
10960 }
10961 }
10962 _ => {}
10963 }
10964 }
10965 return Some("");
10966 }
10967 Some(rest)
10968}
10969
10970fn strip_leading_java_modifier(value: &str) -> Option<&str> {
10971 const MODIFIERS: &[&str] = &[
10972 "public",
10973 "protected",
10974 "private",
10975 "abstract",
10976 "static",
10977 "final",
10978 "transient",
10979 "volatile",
10980 "synchronized",
10981 "native",
10982 "strictfp",
10983 ];
10984 MODIFIERS
10985 .iter()
10986 .find_map(|modifier| strip_leading_word(value, modifier))
10987}
10988
10989fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
10990 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
10991 let before = &declaration[..receiver_start];
10992 if find_identifier_occurrence(before, "val").is_none()
10993 && find_identifier_occurrence(before, "var").is_none()
10994 {
10995 return None;
10996 }
10997
10998 let after = declaration[receiver_start + receiver.len()..].trim_start();
10999 if let Some(type_text) = after.strip_prefix(':') {
11000 return normalize_receiver_type_name(read_type_prefix(type_text));
11001 }
11002 after
11003 .strip_prefix('=')
11004 .and_then(infer_kotlin_constructor_type)
11005}
11006
11007fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
11008 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
11009 if rest.trim_start().starts_with('(') {
11010 normalize_receiver_type_name(head)
11011 } else {
11012 None
11013 }
11014}
11015
11016fn read_type_prefix(value: &str) -> &str {
11017 let mut angle_depth = 0usize;
11018 for (index, ch) in value.char_indices() {
11019 match ch {
11020 '<' => angle_depth += 1,
11021 '>' => angle_depth = angle_depth.saturating_sub(1),
11022 '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
11023 return value[..index].trim();
11024 }
11025 _ => {}
11026 }
11027 }
11028 value.trim()
11029}
11030
11031fn infer_cpp_receiver_type_from_scope(
11032 scope: tree_sitter::Node<'_>,
11033 source: &str,
11034 receiver: &str,
11035 call_line: u32,
11036) -> Option<String> {
11037 let lines = source.lines().collect::<Vec<_>>();
11038 if lines.is_empty() {
11039 return None;
11040 }
11041 let scope_start = scope.start_position().row as usize;
11042 let call_index = (call_line as usize)
11043 .saturating_sub(1)
11044 .min(lines.len().saturating_sub(1));
11045 for index in (scope_start..=call_index).rev() {
11046 if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
11047 return Some(receiver_type);
11048 }
11049 }
11050 None
11051}
11052
11053fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
11054 for receiver_start in identifier_occurrences(line, receiver) {
11055 let after = line[receiver_start + receiver.len()..].trim_start();
11056 if after
11057 .chars()
11058 .next()
11059 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
11060 {
11061 continue;
11062 }
11063 let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
11064 let normalized = normalize_cpp_type_name(type_text)?;
11065 if normalized == "auto" {
11066 if let Some(rhs) = after.strip_prefix('=') {
11067 return infer_cpp_auto_receiver_type(rhs);
11068 }
11069 continue;
11070 }
11071 return Some(normalized);
11072 }
11073 None
11074}
11075
11076fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
11077 let candidate = prefix
11078 .rsplit([';', '{', '}', '('])
11079 .next()
11080 .unwrap_or(prefix)
11081 .trim();
11082 if candidate.is_empty() || candidate.ends_with(',') {
11083 None
11084 } else {
11085 Some(candidate)
11086 }
11087}
11088
11089fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
11090 let without_templates = strip_angle_groups(type_text);
11091 let mut cleaned = String::with_capacity(without_templates.len());
11092 for token in without_templates.split_whitespace() {
11093 if matches!(
11094 token,
11095 "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
11096 ) {
11097 continue;
11098 }
11099 if !cleaned.is_empty() {
11100 cleaned.push(' ');
11101 }
11102 cleaned.push_str(token);
11103 }
11104 let token = cleaned
11105 .split_whitespace()
11106 .last()
11107 .unwrap_or(cleaned.trim())
11108 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
11109 .trim_matches(['*', '&']);
11110 let simple = token.rsplit("::").next().unwrap_or(token).trim();
11111 if simple.is_empty() || cpp_non_type_token(simple) {
11112 None
11113 } else {
11114 Some(simple.to_string())
11115 }
11116}
11117
11118fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
11119 let rhs = rhs.trim_start();
11120 if let Some(after_new) = rhs.strip_prefix("new ") {
11121 return infer_cpp_constructor_type(after_new);
11122 }
11123 infer_cpp_make_template_type(rhs)
11124 .or_else(|| infer_cpp_constructor_type(rhs))
11125 .or_else(|| infer_cpp_factory_type(rhs))
11126}
11127
11128fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
11129 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
11130 let normalized = normalize_cpp_type_name(head)?;
11131 if !normalized
11132 .chars()
11133 .next()
11134 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
11135 {
11136 return None;
11137 }
11138 if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
11139 Some(normalized)
11140 } else {
11141 None
11142 }
11143}
11144
11145fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
11146 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
11147 if !rest.trim_start().starts_with('(') {
11148 return None;
11149 }
11150 let base = head.split('<').next().unwrap_or(head);
11151 let base_simple = base.rsplit("::").next().unwrap_or(base);
11152 if !matches!(base_simple, "make_unique" | "make_shared") {
11153 return None;
11154 }
11155 first_angle_arg(head).and_then(normalize_cpp_type_name)
11156}
11157
11158fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
11159 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
11160 if !rest.trim_start().starts_with('(') {
11161 return None;
11162 }
11163 let simple = head
11164 .split('<')
11165 .next()
11166 .unwrap_or(head)
11167 .rsplit("::")
11168 .next()
11169 .unwrap_or(head);
11170 for prefix in ["make", "create", "build"] {
11171 if let Some(suffix) = simple.strip_prefix(prefix) {
11172 if suffix
11173 .chars()
11174 .next()
11175 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
11176 {
11177 return normalize_cpp_type_name(suffix);
11178 }
11179 }
11180 }
11181 None
11182}
11183
11184#[derive(Debug, Clone, Copy)]
11185enum JavaLikeInvocation {
11186 Kotlin,
11187 Cpp,
11188}
11189
11190fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
11191 let value = value.trim_start();
11192 let mut end = 0usize;
11193 for (index, ch) in value.char_indices() {
11194 let allowed_separator = match flavor {
11195 JavaLikeInvocation::Kotlin => ch == '.',
11196 JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
11197 };
11198 if is_code_ident_char(ch) || allowed_separator {
11199 end = index + ch.len_utf8();
11200 continue;
11201 }
11202 break;
11203 }
11204 if end == 0 {
11205 return None;
11206 }
11207 let mut rest = &value[end..];
11208 if let Some(stripped) = rest.trim_start().strip_prefix('<') {
11209 let skipped = skip_balanced_angle(stripped)?;
11210 let rest_start = rest.len() - rest.trim_start().len();
11211 let angle_len = 1 + skipped;
11212 end += rest_start + angle_len;
11213 rest = &value[end..];
11214 }
11215 Some((value[..end].trim(), rest))
11216}
11217
11218fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
11219 let mut depth = 1usize;
11220 for (index, ch) in value_after_open.char_indices() {
11221 match ch {
11222 '<' => depth += 1,
11223 '>' => {
11224 depth = depth.saturating_sub(1);
11225 if depth == 0 {
11226 return Some(index + ch.len_utf8());
11227 }
11228 }
11229 _ => {}
11230 }
11231 }
11232 None
11233}
11234
11235fn first_angle_arg(value: &str) -> Option<&str> {
11236 let open = value.find('<')?;
11237 let inner_len = skip_balanced_angle(&value[open + 1..])?;
11238 let inner = &value[open + 1..open + inner_len];
11239 split_top_level_commas(inner).into_iter().next()
11240}
11241
11242fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
11243 let without_generics = strip_angle_groups(type_text);
11244 let cleaned = without_generics
11245 .replace("[]", " ")
11246 .replace("...", " ")
11247 .replace(['?', '&', '*'], " ");
11248 let token = cleaned
11249 .split_whitespace()
11250 .last()
11251 .unwrap_or(cleaned.trim())
11252 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
11253 let token = token.rsplit("::").next().unwrap_or(token);
11254 let simple = token.rsplit('.').next().unwrap_or(token).trim();
11255 if simple.is_empty()
11256 || java_like_primitive_type(simple)
11257 || !simple
11258 .chars()
11259 .next()
11260 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
11261 {
11262 None
11263 } else {
11264 Some(simple.to_string())
11265 }
11266}
11267
11268fn simple_type_name(scoped_name: &str) -> Option<String> {
11269 scoped_name
11270 .rsplit("::")
11271 .find(|segment| !segment.is_empty())
11272 .and_then(normalize_receiver_type_name)
11273}
11274
11275fn strip_angle_groups(value: &str) -> String {
11276 let mut output = String::with_capacity(value.len());
11277 let mut depth = 0usize;
11278 for ch in value.chars() {
11279 match ch {
11280 '<' => {
11281 if depth == 0 {
11282 output.push(' ');
11283 }
11284 depth += 1;
11285 }
11286 '>' => depth = depth.saturating_sub(1),
11287 _ if depth == 0 => output.push(ch),
11288 _ => {}
11289 }
11290 }
11291 output
11292}
11293
11294fn java_like_primitive_type(value: &str) -> bool {
11295 matches!(
11296 value,
11297 "boolean"
11298 | "byte"
11299 | "char"
11300 | "double"
11301 | "float"
11302 | "int"
11303 | "long"
11304 | "short"
11305 | "void"
11306 | "Boolean"
11307 | "Byte"
11308 | "Char"
11309 | "Double"
11310 | "Float"
11311 | "Int"
11312 | "Long"
11313 | "Short"
11314 | "Unit"
11315 )
11316}
11317
11318fn cpp_non_type_token(value: &str) -> bool {
11319 matches!(
11320 value,
11321 "return"
11322 | "if"
11323 | "else"
11324 | "for"
11325 | "while"
11326 | "do"
11327 | "switch"
11328 | "case"
11329 | "default"
11330 | "break"
11331 | "continue"
11332 | "goto"
11333 | "throw"
11334 | "new"
11335 | "delete"
11336 | "co_await"
11337 | "co_yield"
11338 | "co_return"
11339 | "static_cast"
11340 | "const_cast"
11341 | "dynamic_cast"
11342 | "reinterpret_cast"
11343 | "sizeof"
11344 | "alignof"
11345 | "typeid"
11346 | "and"
11347 | "or"
11348 | "not"
11349 | "xor"
11350 )
11351}
11352
11353fn receiver_is_bare_identifier(value: &str) -> bool {
11354 let mut chars = value.chars();
11355 let Some(first) = chars.next() else {
11356 return false;
11357 };
11358 (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
11359}
11360
11361fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
11362 identifier_occurrences(value, needle).into_iter().next()
11363}
11364
11365fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
11366 value
11367 .match_indices(needle)
11368 .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
11369 .collect()
11370}
11371
11372fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
11373 let before = value[..start].chars().next_back();
11374 let after = value[start + len..].chars().next();
11375 !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
11376}
11377
11378fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
11379 let stripped = value.strip_prefix(word)?;
11380 if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
11381 Some(stripped.trim_start())
11382 } else {
11383 None
11384 }
11385}
11386
11387fn is_code_ident_char(ch: char) -> bool {
11388 ch == '_' || ch.is_ascii_alphanumeric()
11389}
11390
11391fn infer_rust_receiver_type(
11392 project_root: &Path,
11393 reference: &NameMatchRef,
11394 source_cache: &mut DispatchSourceCache,
11395) -> ReceiverTypeInference {
11396 if matches!(reference.receiver.as_str(), "self" | "Self") {
11397 return enclosing_type_from_scoped_name(&reference.caller_symbol)
11398 .map(ReceiverTypeInference::Known)
11399 .unwrap_or(ReceiverTypeInference::Unknown);
11400 }
11401
11402 if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
11403 return ReceiverTypeInference::Known(reference.receiver.clone());
11404 }
11405
11406 if let Some(receiver_type) = reference
11407 .caller_signature
11408 .as_deref()
11409 .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
11410 {
11411 return ReceiverTypeInference::Known(receiver_type);
11412 }
11413
11414 infer_rust_direct_self_field_receiver_type(project_root, reference, source_cache)
11415}
11416
11417fn infer_rust_direct_self_field_receiver_type(
11418 project_root: &Path,
11419 reference: &NameMatchRef,
11420 source_cache: &mut DispatchSourceCache,
11421) -> ReceiverTypeInference {
11422 if reference.colon_dispatch {
11423 return ReceiverTypeInference::Unknown;
11424 }
11425 let Some(field_name) = rust_direct_self_field_name(&reference.receiver_expression) else {
11426 return ReceiverTypeInference::Unknown;
11427 };
11428 if field_name != reference.receiver {
11429 return ReceiverTypeInference::Unknown;
11430 }
11431
11432 let Some(impl_type) = enclosing_type_from_scoped_name(&reference.caller_symbol) else {
11433 return ReceiverTypeInference::Unknown;
11434 };
11435 let Some(struct_name) = rust_direct_nominal_type_name(&impl_type) else {
11436 return ReceiverTypeInference::KnownButUnresolved;
11437 };
11438 let Some(parsed) = parsed_dispatch_source(project_root, reference, LangId::Rust, source_cache)
11439 else {
11440 return ReceiverTypeInference::Unknown;
11441 };
11442 let Some(impl_node) =
11443 find_enclosing_rust_impl_node(parsed.tree.root_node(), reference.line.max(1))
11444 else {
11445 return ReceiverTypeInference::Unknown;
11446 };
11447 if impl_node.child_by_field_name("trait").is_some()
11448 || impl_node.child_by_field_name("type_parameters").is_some()
11449 {
11450 return ReceiverTypeInference::KnownButUnresolved;
11451 }
11452 let Some(impl_target) = impl_node.child_by_field_name("type") else {
11453 return ReceiverTypeInference::KnownButUnresolved;
11454 };
11455 if impl_target.kind() != "type_identifier"
11456 || node_text(impl_target, &parsed.source) != impl_type
11457 {
11458 return ReceiverTypeInference::KnownButUnresolved;
11459 }
11460
11461 let module_scope = rust_module_scope(impl_node);
11462 let Some(struct_node) = find_unique_rust_struct(
11463 parsed.tree.root_node(),
11464 &parsed.source,
11465 struct_name,
11466 &module_scope,
11467 ) else {
11468 return ReceiverTypeInference::KnownButUnresolved;
11469 };
11470 let Some(field_type) = rust_struct_field_type_node(struct_node, &parsed.source, field_name)
11471 else {
11472 return ReceiverTypeInference::KnownButUnresolved;
11473 };
11474 if field_type.kind() != "type_identifier" {
11475 return ReceiverTypeInference::KnownButUnresolved;
11476 }
11477 let field_type_name = node_text(field_type, &parsed.source);
11478 if find_unique_rust_struct(
11479 parsed.tree.root_node(),
11480 &parsed.source,
11481 field_type_name,
11482 &module_scope,
11483 )
11484 .is_none()
11485 {
11486 return ReceiverTypeInference::KnownButUnresolved;
11487 }
11488
11489 ReceiverTypeInference::RustDirectSelfField {
11490 receiver_type: field_type_name.to_string(),
11491 declaration_file: reference.caller_file.clone(),
11492 module_scope,
11493 }
11494}
11495
11496fn rust_direct_self_field_name(receiver_expression: &str) -> Option<&str> {
11497 let (base, field) = receiver_expression.split_once('.')?;
11498 let base = base.trim();
11499 let field = field.trim();
11500 (base == "self" && rust_direct_nominal_type_name(field).is_some()).then_some(field)
11501}
11502
11503fn rust_direct_nominal_type_name(value: &str) -> Option<&str> {
11504 let name = value.rsplit("::").next()?.trim();
11505 (!name.is_empty()
11506 && !name.chars().next().is_some_and(|ch| ch.is_ascii_digit())
11507 && name.chars().all(is_rust_ident_char))
11508 .then_some(name)
11509}
11510
11511fn find_enclosing_rust_impl_node<'tree>(
11512 root: tree_sitter::Node<'tree>,
11513 line: u32,
11514) -> Option<tree_sitter::Node<'tree>> {
11515 let mut best = None;
11516 let mut stack = vec![root];
11517 while let Some(node) = stack.pop() {
11518 if !node_contains_line(node, line) {
11519 continue;
11520 }
11521 if node.kind() == "impl_item" {
11522 best = tighter_node(best, node);
11523 }
11524 push_named_children(node, &mut stack);
11525 }
11526 best
11527}
11528
11529fn rust_module_scope(node: tree_sitter::Node<'_>) -> Vec<(usize, usize)> {
11530 let mut scope = Vec::new();
11531 let mut current = node.parent();
11532 while let Some(parent) = current {
11533 if parent.kind() == "mod_item" {
11534 scope.push((parent.start_byte(), parent.end_byte()));
11535 }
11536 current = parent.parent();
11537 }
11538 scope.reverse();
11539 scope
11540}
11541
11542fn find_unique_rust_struct<'tree>(
11543 root: tree_sitter::Node<'tree>,
11544 source: &str,
11545 expected_name: &str,
11546 module_scope: &[(usize, usize)],
11547) -> Option<tree_sitter::Node<'tree>> {
11548 let mut found = None;
11549 let mut stack = vec![root];
11550 while let Some(node) = stack.pop() {
11551 if node.kind() == "struct_item"
11552 && rust_module_scope(node) == module_scope
11553 && node.child_by_field_name("type_parameters").is_none()
11554 && declaration_name(node, source) == Some(expected_name)
11555 {
11556 if found.is_some() {
11557 return None;
11558 }
11559 found = Some(node);
11560 }
11561 push_named_children(node, &mut stack);
11562 }
11563 found
11564}
11565
11566fn rust_struct_field_type_node<'tree>(
11567 struct_node: tree_sitter::Node<'tree>,
11568 source: &str,
11569 field_name: &str,
11570) -> Option<tree_sitter::Node<'tree>> {
11571 let fields = struct_node.child_by_field_name("body")?;
11572 if fields.kind() != "field_declaration_list" {
11573 return None;
11574 }
11575 for index in 0..fields.named_child_count() {
11576 let field = fields.named_child(index as u32)?;
11577 if field.kind() != "field_declaration"
11578 || declaration_name(field, source) != Some(field_name)
11579 {
11580 continue;
11581 }
11582 return field.child_by_field_name("type");
11583 }
11584 None
11585}
11586
11587fn rust_receiver_looks_type_like(receiver: &str) -> bool {
11588 receiver
11589 .chars()
11590 .next()
11591 .is_some_and(|ch| ch == '_' || ch.is_uppercase())
11592}
11593
11594fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
11595 scoped_name
11596 .rsplit_once("::")
11597 .map(|(enclosing, _)| enclosing)
11598 .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
11599 .map(ToString::to_string)
11600}
11601
11602fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
11603 let params = signature_parameter_text(signature)?;
11604 for param in split_top_level_commas(params) {
11605 let Some((pattern, type_text)) = param.split_once(':') else {
11606 continue;
11607 };
11608 let Some(name) = rust_parameter_name(pattern) else {
11609 continue;
11610 };
11611 if name == receiver {
11612 return normalize_rust_receiver_type(type_text);
11613 }
11614 }
11615 None
11616}
11617
11618fn signature_parameter_text(signature: &str) -> Option<&str> {
11619 let open = signature.find('(')?;
11620 let mut depth = 0usize;
11621 for (offset, ch) in signature[open..].char_indices() {
11622 match ch {
11623 '(' => depth += 1,
11624 ')' => {
11625 depth = depth.saturating_sub(1);
11626 if depth == 0 {
11627 return Some(&signature[open + 1..open + offset]);
11628 }
11629 }
11630 _ => {}
11631 }
11632 }
11633 None
11634}
11635
11636fn split_top_level_commas(value: &str) -> Vec<&str> {
11637 let mut parts = Vec::new();
11638 let mut start = 0usize;
11639 let mut angle_depth = 0usize;
11640 let mut paren_depth = 0usize;
11641 let mut bracket_depth = 0usize;
11642 for (index, ch) in value.char_indices() {
11643 match ch {
11644 '<' => angle_depth += 1,
11645 '>' => angle_depth = angle_depth.saturating_sub(1),
11646 '(' => paren_depth += 1,
11647 ')' => paren_depth = paren_depth.saturating_sub(1),
11648 '[' => bracket_depth += 1,
11649 ']' => bracket_depth = bracket_depth.saturating_sub(1),
11650 ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
11651 let part = value[start..index].trim();
11652 if !part.is_empty() {
11653 parts.push(part);
11654 }
11655 start = index + ch.len_utf8();
11656 }
11657 _ => {}
11658 }
11659 }
11660 let part = value[start..].trim();
11661 if !part.is_empty() {
11662 parts.push(part);
11663 }
11664 parts
11665}
11666
11667fn rust_parameter_name(pattern: &str) -> Option<&str> {
11668 let mut pattern = pattern.trim();
11669 if let Some(stripped) = pattern.strip_prefix("mut ") {
11670 pattern = stripped.trim_start();
11671 }
11672 pattern
11673 .rsplit(|ch: char| !is_rust_ident_char(ch))
11674 .find(|part| !part.is_empty())
11675}
11676
11677fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
11678 let mut ty = strip_leading_rust_type_modifiers(type_text);
11679 let owned_inner;
11680 if let Some(inner) = single_outer_generic_arg(ty) {
11681 owned_inner = inner.trim().to_string();
11682 ty = strip_leading_rust_type_modifiers(&owned_inner);
11683 }
11684 rust_base_type_ident(ty)
11685}
11686
11687fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
11688 loop {
11689 ty = ty.trim_start();
11690 if let Some(stripped) = ty.strip_prefix('&') {
11691 ty = stripped.trim_start();
11692 if let Some(stripped) = strip_leading_lifetime(ty) {
11693 ty = stripped.trim_start();
11694 }
11695 if let Some(stripped) = ty.strip_prefix("mut ") {
11696 ty = stripped.trim_start();
11697 }
11698 continue;
11699 }
11700 if let Some(stripped) = ty.strip_prefix("mut ") {
11701 ty = stripped.trim_start();
11702 continue;
11703 }
11704 if let Some(stripped) = ty.strip_prefix("dyn ") {
11705 ty = stripped.trim_start();
11706 continue;
11707 }
11708 if let Some(stripped) = ty.strip_prefix("impl ") {
11709 ty = stripped.trim_start();
11710 continue;
11711 }
11712 break ty.trim();
11713 }
11714}
11715
11716fn strip_leading_lifetime(value: &str) -> Option<&str> {
11717 let mut chars = value.char_indices();
11718 let (_, first) = chars.next()?;
11719 if first != '\'' {
11720 return None;
11721 }
11722 for (index, ch) in chars {
11723 if !(ch == '_' || ch.is_ascii_alphanumeric()) {
11724 return Some(&value[index..]);
11725 }
11726 }
11727 Some("")
11728}
11729
11730fn single_outer_generic_arg(ty: &str) -> Option<&str> {
11731 let ty = ty.trim();
11732 let open = ty.find('<')?;
11733 let mut depth = 0usize;
11734 let mut close = None;
11735 for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
11736 match ch {
11737 '<' => depth += 1,
11738 '>' => {
11739 depth = depth.saturating_sub(1);
11740 if depth == 0 {
11741 close = Some(index);
11742 break;
11743 }
11744 }
11745 _ => {}
11746 }
11747 }
11748 let close = close?;
11749 if !ty[close + 1..].trim().is_empty() {
11750 return None;
11751 }
11752 let inner = &ty[open + 1..close];
11753 let args = split_top_level_commas(inner);
11754 match args.as_slice() {
11755 [arg] => Some(*arg),
11756 _ => None,
11757 }
11758}
11759
11760fn rust_base_type_ident(ty: &str) -> Option<String> {
11761 let ty = ty.trim();
11762 let head = ty
11763 .split([' ', '+', '='])
11764 .find(|part| !part.is_empty())
11765 .unwrap_or(ty);
11766 let head = head.split('<').next().unwrap_or(head).trim();
11767 let ident = head
11768 .rsplit("::")
11769 .next()
11770 .unwrap_or(head)
11771 .trim_matches(|ch: char| !is_rust_ident_char(ch));
11772 if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
11773 None
11774 } else {
11775 Some(ident.to_string())
11776 }
11777}
11778
11779fn is_rust_ident_char(ch: char) -> bool {
11780 ch == '_' || ch.is_ascii_alphanumeric()
11781}
11782
11783fn select_rust_direct_self_field_candidate(
11784 project_root: &Path,
11785 reference: &NameMatchRef,
11786 candidates: &[NameMatchCandidate],
11787 receiver_type: &str,
11788 declaration_file: &str,
11789 declaration_scope: &[(usize, usize)],
11790 source_cache: &mut DispatchSourceCache,
11791) -> Option<NameMatchCandidate> {
11792 let eligible = candidates
11793 .iter()
11794 .filter(|candidate| candidate.node_id != reference.caller_node)
11795 .filter(|candidate| {
11796 type_candidate_matches(candidate, receiver_type, &reference.method_name)
11797 })
11798 .filter(|candidate| {
11799 rust_direct_self_field_candidate_matches_scope(
11800 project_root,
11801 candidate,
11802 receiver_type,
11803 declaration_file,
11804 declaration_scope,
11805 source_cache,
11806 )
11807 })
11808 .collect::<Vec<_>>();
11809 match eligible.as_slice() {
11810 [candidate] => Some((**candidate).clone()),
11811 _ => None,
11812 }
11813}
11814
11815fn rust_direct_self_field_candidate_matches_scope(
11816 project_root: &Path,
11817 candidate: &NameMatchCandidate,
11818 receiver_type: &str,
11819 declaration_file: &str,
11820 declaration_scope: &[(usize, usize)],
11821 source_cache: &mut DispatchSourceCache,
11822) -> bool {
11823 if candidate.file_path != declaration_file {
11824 return false;
11825 }
11826 let Some(parsed) = parsed_dispatch_source_for_file(
11827 project_root,
11828 &candidate.file_path,
11829 "rust",
11830 LangId::Rust,
11831 source_cache,
11832 ) else {
11833 return false;
11834 };
11835 let Some(impl_node) =
11836 find_enclosing_rust_impl_node(parsed.tree.root_node(), candidate.start_line)
11837 else {
11838 return false;
11839 };
11840 if impl_node.child_by_field_name("trait").is_some()
11841 || impl_node.child_by_field_name("type_parameters").is_some()
11842 {
11843 return false;
11844 }
11845 let Some(impl_target) = impl_node.child_by_field_name("type") else {
11846 return false;
11847 };
11848 impl_target.kind() == "type_identifier"
11849 && node_text(impl_target, &parsed.source) == receiver_type
11850 && rust_module_scope(impl_node) == declaration_scope
11851}
11852
11853fn select_type_match_candidate(
11854 reference: &NameMatchRef,
11855 candidates: &[NameMatchCandidate],
11856 receiver_type: &str,
11857) -> Option<NameMatchCandidate> {
11858 let candidates = candidates
11859 .iter()
11860 .filter(|candidate| candidate.node_id != reference.caller_node)
11861 .filter(|candidate| {
11862 type_candidate_matches(candidate, receiver_type, &reference.method_name)
11863 })
11864 .collect::<Vec<_>>();
11865 match candidates.as_slice() {
11866 [candidate] => Some((**candidate).clone()),
11867 _ => None,
11868 }
11869}
11870
11871fn type_candidate_matches(
11872 candidate: &NameMatchCandidate,
11873 receiver_type: &str,
11874 method_name: &str,
11875) -> bool {
11876 let normalized_type = receiver_type.replace('.', "::");
11877 let suffix = format!("{normalized_type}::{method_name}");
11878 candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
11879}
11880
11881fn select_name_match_candidate(
11882 reference: &NameMatchRef,
11883 candidates: &[NameMatchCandidate],
11884) -> Option<NameMatchCandidate> {
11885 let candidates = candidates
11886 .iter()
11887 .filter(|candidate| candidate.node_id != reference.caller_node)
11888 .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
11889 .collect::<Vec<_>>();
11890 match candidates.as_slice() {
11891 [] => None,
11892 [candidate] => Some((**candidate).clone()),
11893 _ => select_scored_name_match_candidate(reference, &candidates),
11894 }
11895}
11896
11897fn candidate_allowed_for_reference(
11898 reference: &NameMatchRef,
11899 candidate: &NameMatchCandidate,
11900) -> bool {
11901 if !reference.colon_dispatch {
11902 return true;
11903 }
11904
11905 candidate.kind == "method"
11906 && candidate
11907 .scoped_name
11908 .split("::")
11909 .any(|segment| segment == reference.receiver)
11910}
11911
11912fn select_scored_name_match_candidate(
11913 reference: &NameMatchRef,
11914 candidates: &[&NameMatchCandidate],
11915) -> Option<NameMatchCandidate> {
11916 let receiver_words = split_camel_case(&reference.receiver);
11917 if receiver_words.is_empty() {
11918 return None;
11919 }
11920
11921 let mut best: Option<(&NameMatchCandidate, f64)> = None;
11922 let mut tied_best = false;
11923 for candidate in candidates {
11924 let candidate_words = split_camel_case(&candidate.scoped_name);
11925 let overlap = receiver_words
11926 .iter()
11927 .filter(|receiver_word| {
11928 candidate_words
11929 .iter()
11930 .any(|candidate_word| candidate_word == *receiver_word)
11931 })
11932 .count() as f64;
11933 let score =
11934 overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
11935 match best {
11936 None => {
11937 best = Some((*candidate, score));
11938 tied_best = false;
11939 }
11940 Some((_, best_score)) if score > best_score => {
11941 best = Some((*candidate, score));
11942 tied_best = false;
11943 }
11944 Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
11945 tied_best = true;
11946 }
11947 _ => {}
11948 }
11949 }
11950
11951 let (candidate, score) = best?;
11952 if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
11953 Some(candidate.clone())
11954 } else {
11955 None
11956 }
11957}
11958
11959fn method_name_match_denylisted(method_name: &str) -> bool {
11960 matches!(
11961 method_name,
11962 "and_then"
11963 | "as_bytes"
11964 | "as_deref"
11965 | "as_mut"
11966 | "as_ref"
11967 | "as_str"
11968 | "borrow"
11969 | "borrow_mut"
11970 | "clear"
11971 | "clone"
11972 | "collect"
11973 | "contains"
11974 | "contains_key"
11975 | "count"
11976 | "dedup"
11977 | "default"
11978 | "drain"
11979 | "ends_with"
11980 | "entry"
11981 | "err"
11982 | "expect"
11983 | "extend"
11984 | "filter"
11985 | "filter_map"
11986 | "find"
11987 | "from"
11988 | "get"
11989 | "get_mut"
11990 | "insert"
11991 | "into"
11992 | "into_iter"
11993 | "is_empty"
11994 | "is_err"
11995 | "is_none"
11996 | "is_ok"
11997 | "is_some"
11998 | "iter"
11999 | "iter_mut"
12000 | "join"
12001 | "len"
12002 | "lock"
12003 | "map"
12004 | "map_err"
12005 | "max"
12006 | "min"
12007 | "new"
12008 | "next"
12009 | "ok"
12010 | "or_default"
12011 | "or_else"
12012 | "or_insert"
12013 | "or_insert_with"
12014 | "parse"
12015 | "pop"
12016 | "position"
12017 | "push"
12018 | "read"
12019 | "recv"
12020 | "remove"
12021 | "replace"
12022 | "retain"
12023 | "send"
12024 | "sort"
12025 | "sort_by"
12026 | "split"
12027 | "starts_with"
12028 | "sum"
12029 | "take"
12030 | "to_owned"
12031 | "to_string"
12032 | "trim"
12033 | "try_from"
12034 | "try_into"
12035 | "unwrap"
12036 | "unwrap_or"
12037 | "unwrap_or_default"
12038 | "unwrap_or_else"
12039 | "with_capacity"
12040 | "write"
12041 )
12042}
12043
12044fn split_camel_case(value: &str) -> Vec<String> {
12045 let chars = value.chars().collect::<Vec<_>>();
12046 let mut normalized = String::with_capacity(value.len() + 8);
12047 for (index, ch) in chars.iter().enumerate() {
12048 let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
12049 let next = chars.get(index + 1);
12050 let is_separator = ch.is_whitespace()
12051 || matches!(
12052 ch,
12053 '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
12054 );
12055 if is_separator {
12056 normalized.push(' ');
12057 continue;
12058 }
12059 let camel_boundary = previous.is_some_and(|prev| {
12060 (prev.is_lowercase() && ch.is_uppercase())
12061 || (prev.is_ascii_digit() && ch.is_alphabetic())
12062 || (prev.is_uppercase()
12063 && ch.is_uppercase()
12064 && next.is_some_and(|next| next.is_lowercase()))
12065 });
12066 if camel_boundary {
12067 normalized.push(' ');
12068 }
12069 normalized.push(*ch);
12070 }
12071
12072 normalized
12073 .split_whitespace()
12074 .filter(|word| word.len() > 1)
12075 .map(|word| word.to_ascii_lowercase())
12076 .collect()
12077}
12078
12079fn compute_path_proximity(left: &str, right: &str) -> f64 {
12080 let left_dirs = left
12081 .rsplit_once('/')
12082 .map(|(dir, _)| dir)
12083 .unwrap_or_default()
12084 .split('/')
12085 .filter(|part| !part.is_empty());
12086 let right_dirs = right
12087 .rsplit_once('/')
12088 .map(|(dir, _)| dir)
12089 .unwrap_or_default()
12090 .split('/')
12091 .filter(|part| !part.is_empty());
12092
12093 let shared = left_dirs
12094 .zip(right_dirs)
12095 .take_while(|(left, right)| left == right)
12096 .count();
12097 ((shared as f64) * 0.05).min(0.5)
12098}
12099
12100fn mark_backend_state(
12101 tx: &Transaction<'_>,
12102 project_root: &Path,
12103 rel_path: &str,
12104 content_hash: Option<&blake3::Hash>,
12105 status: &str,
12106) -> Result<()> {
12107 clear_backend_state_for_file(tx, project_root, rel_path)?;
12108 let hash = content_hash
12109 .map(|hash| hash_to_hex(*hash))
12110 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
12111 tx.execute(
12112 "INSERT OR REPLACE INTO backend_file_state(
12113 backend, workspace_root, file_path, content_hash, status, updated_at
12114 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
12115 params![
12116 BACKEND_TREESITTER,
12117 project_root.display().to_string(),
12118 rel_path,
12119 hash,
12120 status,
12121 unix_seconds_now(),
12122 ],
12123 )?;
12124 Ok(())
12125}
12126
12127fn clear_backend_state_for_file(
12128 tx: &Transaction<'_>,
12129 project_root: &Path,
12130 rel_path: &str,
12131) -> Result<()> {
12132 tx.execute(
12133 "DELETE FROM backend_file_state
12134 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
12135 params![
12136 BACKEND_TREESITTER,
12137 project_root.display().to_string(),
12138 rel_path
12139 ],
12140 )?;
12141 Ok(())
12142}
12143
12144fn clear_stale_backend_status_for_file(
12150 tx: &Transaction<'_>,
12151 project_root: &Path,
12152 rel_path: &str,
12153) -> Result<()> {
12154 tx.execute(
12155 "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
12156 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3 AND status = 'stale'",
12157 params![
12158 BACKEND_TREESITTER,
12159 project_root.display().to_string(),
12160 rel_path,
12161 unix_seconds_now(),
12162 ],
12163 )?;
12164 Ok(())
12165}
12166
12167fn load_file_row(conn: &Connection, rel_path: &str) -> Result<Option<FileRow>> {
12168 conn.query_row(
12169 "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
12170 params![rel_path],
12171 |row| {
12172 let hash_text: String = row.get(1)?;
12173 Ok(FileRow {
12174 surface_fingerprint: row.get(0)?,
12175 freshness: FileFreshness {
12176 content_hash: hash_from_hex(&hash_text)
12177 .unwrap_or_else(cache_freshness::zero_hash),
12178 mtime: ns_to_system_time(row.get::<_, i64>(2)?),
12179 size: row.get::<_, i64>(3)? as u64,
12180 },
12181 })
12182 },
12183 )
12184 .optional()
12185 .map_err(CallGraphStoreError::from)
12186}
12187
12188fn stored_node_ids_match_extract(
12189 tx: &Transaction<'_>,
12190 rel_path: &str,
12191 extract: &FileExtract,
12192) -> Result<bool> {
12193 let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
12194 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
12195 let mut stored = BTreeSet::new();
12196 for row in rows {
12197 stored.insert(row?);
12198 }
12199 let extracted = extract
12200 .nodes
12201 .iter()
12202 .map(|node| node.id.clone())
12203 .collect::<BTreeSet<_>>();
12204 Ok(stored == extracted)
12205}
12206
12207fn stored_extract_matches(
12211 tx: &Transaction<'_>,
12212 rel_path: &str,
12213 extract: &FileExtract,
12214 index: &ProjectIndex<'_>,
12215) -> Result<bool> {
12216 let stored_file = tx
12217 .query_row(
12218 "SELECT lang, surface_fingerprint FROM files WHERE path = ?1",
12219 params![rel_path],
12220 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
12221 )
12222 .optional()?;
12223 if stored_file
12224 != Some((
12225 lang_label(extract.lang).to_string(),
12226 extract.surface_fingerprint.clone(),
12227 ))
12228 {
12229 return Ok(false);
12230 }
12231
12232 let mut stored_nodes_stmt = tx.prepare(
12233 "SELECT id, file_path, name, scoped_name, kind, start_line, start_col,
12234 end_line, end_col, range_ordinal, signature, exported,
12235 is_default_export, is_type_like, is_callgraph_entry_point, provenance
12236 FROM nodes WHERE file_path = ?1",
12237 )?;
12238 let stored_nodes = stored_nodes_stmt
12239 .query_map(params![rel_path], |row| {
12240 Ok(serde_json::json!([
12241 row.get::<_, String>(0)?,
12242 row.get::<_, String>(1)?,
12243 row.get::<_, String>(2)?,
12244 row.get::<_, String>(3)?,
12245 row.get::<_, String>(4)?,
12246 row.get::<_, i64>(5)?,
12247 row.get::<_, i64>(6)?,
12248 row.get::<_, i64>(7)?,
12249 row.get::<_, i64>(8)?,
12250 row.get::<_, i64>(9)?,
12251 row.get::<_, Option<String>>(10)?,
12252 row.get::<_, i64>(11)?,
12253 row.get::<_, i64>(12)?,
12254 row.get::<_, i64>(13)?,
12255 row.get::<_, i64>(14)?,
12256 row.get::<_, String>(15)?,
12257 ])
12258 .to_string())
12259 })?
12260 .collect::<rusqlite::Result<Vec<_>>>()?;
12261 let expected_nodes = extract
12262 .nodes
12263 .iter()
12264 .map(|node| {
12265 serde_json::json!([
12266 node.id,
12267 node.file_path,
12268 node.name,
12269 node.scoped_name,
12270 node.kind,
12271 node.range.start_line,
12272 node.range.start_col,
12273 node.range.end_line,
12274 node.range.end_col,
12275 node.range_ordinal,
12276 node.signature,
12277 bool_int(node.exported),
12278 bool_int(node.is_default_export),
12279 bool_int(node.is_type_like),
12280 bool_int(node.is_callgraph_entry_point),
12281 PROVENANCE_TREESITTER,
12282 ])
12283 .to_string()
12284 })
12285 .collect::<Vec<_>>();
12286 let mut stored_nodes = stored_nodes;
12287 let mut expected_nodes = expected_nodes;
12288 stored_nodes.sort();
12289 expected_nodes.sort();
12290 if stored_nodes != expected_nodes {
12291 return Ok(false);
12292 }
12293
12294 let resolved_refs = extract
12295 .raw_refs
12296 .iter()
12297 .cloned()
12298 .map(|raw| resolve_ref(raw, index))
12299 .collect::<Result<Vec<_>>>()?;
12300 let mut stored_refs_stmt = tx.prepare(
12301 "SELECT ref_id, caller_node, caller_file, kind, short_name, full_ref,
12302 module_path, import_kind, local_name, requested_name, namespace_alias,
12303 wildcard, line, byte_start, byte_end, status, target_node,
12304 target_file, target_symbol, provenance
12305 FROM refs WHERE caller_file = ?1",
12306 )?;
12307 let stored_refs = stored_refs_stmt
12308 .query_map(params![rel_path], |row| {
12309 Ok(serde_json::json!([
12310 row.get::<_, String>(0)?,
12311 row.get::<_, Option<String>>(1)?,
12312 row.get::<_, String>(2)?,
12313 row.get::<_, String>(3)?,
12314 row.get::<_, Option<String>>(4)?,
12315 row.get::<_, Option<String>>(5)?,
12316 row.get::<_, Option<String>>(6)?,
12317 row.get::<_, Option<String>>(7)?,
12318 row.get::<_, Option<String>>(8)?,
12319 row.get::<_, Option<String>>(9)?,
12320 row.get::<_, Option<String>>(10)?,
12321 row.get::<_, i64>(11)?,
12322 row.get::<_, i64>(12)?,
12323 row.get::<_, i64>(13)?,
12324 row.get::<_, i64>(14)?,
12325 row.get::<_, String>(15)?,
12326 row.get::<_, Option<String>>(16)?,
12327 row.get::<_, Option<String>>(17)?,
12328 row.get::<_, Option<String>>(18)?,
12329 row.get::<_, String>(19)?,
12330 ])
12331 .to_string())
12332 })?
12333 .collect::<rusqlite::Result<Vec<_>>>()?;
12334 let expected_refs = resolved_refs
12335 .iter()
12336 .map(|resolved| {
12337 let raw = &resolved.raw;
12338 serde_json::json!([
12339 raw.ref_id,
12340 raw.caller_node,
12341 raw.caller_file,
12342 raw.kind,
12343 raw.short_name,
12344 raw.full_ref,
12345 raw.module_path,
12346 raw.import_kind,
12347 raw.local_name,
12348 raw.requested_name,
12349 raw.namespace_alias,
12350 bool_int(raw.wildcard),
12351 raw.line,
12352 raw.byte_start,
12353 raw.byte_end,
12354 resolved.status,
12355 resolved.target_node,
12356 resolved.target_file,
12357 resolved.target_symbol,
12358 PROVENANCE_TREESITTER,
12359 ])
12360 .to_string()
12361 })
12362 .collect::<Vec<_>>();
12363 let mut stored_refs = stored_refs;
12364 let mut expected_refs = expected_refs;
12365 stored_refs.sort();
12366 expected_refs.sort();
12367 if stored_refs != expected_refs {
12368 return Ok(false);
12369 }
12370
12371 let mut stored_edges_stmt = tx.prepare(
12372 "SELECT e.edge_id, e.ref_id, e.source_node, e.target_node,
12373 e.target_file, e.target_symbol, e.kind, e.line, e.provenance
12374 FROM edges e JOIN refs r ON r.ref_id = e.ref_id
12375 WHERE r.caller_file = ?1 AND e.provenance = ?2",
12376 )?;
12377 let stored_edges = stored_edges_stmt
12378 .query_map(params![rel_path, PROVENANCE_TREESITTER], |row| {
12379 Ok(serde_json::json!([
12380 row.get::<_, String>(0)?,
12381 row.get::<_, String>(1)?,
12382 row.get::<_, String>(2)?,
12383 row.get::<_, Option<String>>(3)?,
12384 row.get::<_, String>(4)?,
12385 row.get::<_, String>(5)?,
12386 row.get::<_, String>(6)?,
12387 row.get::<_, i64>(7)?,
12388 row.get::<_, String>(8)?,
12389 ])
12390 .to_string())
12391 })?
12392 .collect::<rusqlite::Result<Vec<_>>>()?;
12393 let expected_edges = resolved_refs
12394 .iter()
12395 .filter_map(|resolved| {
12396 resolved.edge.as_ref().map(|edge| {
12397 serde_json::json!([
12398 edge.edge_id,
12399 resolved.raw.ref_id,
12400 edge.source_node,
12401 edge.target_node,
12402 edge.target_file,
12403 edge.target_symbol,
12404 edge.kind,
12405 edge.line,
12406 PROVENANCE_TREESITTER,
12407 ])
12408 .to_string()
12409 })
12410 })
12411 .collect::<Vec<_>>();
12412 let mut stored_edges = stored_edges;
12413 let mut expected_edges = expected_edges;
12414 stored_edges.sort();
12415 expected_edges.sort();
12416 if stored_edges != expected_edges {
12417 return Ok(false);
12418 }
12419
12420 let mut stored_dependencies_stmt =
12421 tx.prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1")?;
12422 let stored_dependencies = stored_dependencies_stmt
12423 .query_map(params![rel_path], |row| row.get::<_, String>(0))?
12424 .collect::<rusqlite::Result<BTreeSet<_>>>()?;
12425 let expected_dependencies = extract
12426 .raw_refs
12427 .iter()
12428 .flat_map(|raw| raw.dependencies.iter().cloned())
12429 .collect::<BTreeSet<_>>();
12430 if stored_dependencies != expected_dependencies {
12431 return Ok(false);
12432 }
12433
12434 let mut stored_hints_stmt = tx.prepare(
12435 "SELECT id, method_name, caller_node, file, line, byte_start, byte_end, provenance
12436 FROM dispatch_hints WHERE file = ?1",
12437 )?;
12438 let stored_hints = stored_hints_stmt
12439 .query_map(params![rel_path], |row| {
12440 Ok(serde_json::json!([
12441 row.get::<_, String>(0)?,
12442 row.get::<_, String>(1)?,
12443 row.get::<_, String>(2)?,
12444 row.get::<_, String>(3)?,
12445 row.get::<_, i64>(4)?,
12446 row.get::<_, i64>(5)?,
12447 row.get::<_, i64>(6)?,
12448 row.get::<_, String>(7)?,
12449 ])
12450 .to_string())
12451 })?
12452 .collect::<rusqlite::Result<Vec<_>>>()?;
12453 let expected_hints = extract
12454 .dispatch_hints
12455 .iter()
12456 .map(|hint| {
12457 serde_json::json!([
12458 hint.id,
12459 hint.method_name,
12460 hint.caller_node,
12461 hint.file,
12462 hint.line,
12463 hint.byte_start,
12464 hint.byte_end,
12465 PROVENANCE_TREESITTER,
12466 ])
12467 .to_string()
12468 })
12469 .collect::<Vec<_>>();
12470 let mut stored_hints = stored_hints;
12471 let mut expected_hints = expected_hints;
12472 stored_hints.sort();
12473 expected_hints.sort();
12474 Ok(stored_hints == expected_hints)
12475}
12476
12477fn update_file_fresh_metadata(
12478 tx: &Transaction<'_>,
12479 project_root: &Path,
12480 rel_path: &str,
12481 hash: &blake3::Hash,
12482 mtime: SystemTime,
12483 size: u64,
12484) -> Result<()> {
12485 tx.execute(
12486 "UPDATE files SET content_hash = ?2, mtime_ns = ?3, size = ?4, indexed_at = ?5
12487 WHERE path = ?1",
12488 params![
12489 rel_path,
12490 hash_to_hex(*hash),
12491 system_time_to_ns(mtime),
12492 size as i64,
12493 unix_seconds_now()
12494 ],
12495 )?;
12496 tx.execute(
12497 "UPDATE backend_file_state SET content_hash = ?3, status = 'fresh', updated_at = ?5
12498 WHERE backend = ?1 AND file_path = ?2 AND workspace_root = ?4",
12499 params![
12500 BACKEND_TREESITTER,
12501 rel_path,
12502 hash_to_hex(*hash),
12503 project_root.display().to_string(),
12504 unix_seconds_now(),
12505 ],
12506 )?;
12507 Ok(())
12508}
12509
12510#[derive(Debug, Clone, PartialEq, Eq)]
12511struct DependentRefSelection {
12512 ref_id: String,
12513 caller_file: String,
12514}
12515
12516fn ref_ids_depending_on(
12517 conn: &Connection,
12518 project_root: &Path,
12519 rel_path: &str,
12520) -> Result<Vec<DependentRefSelection>> {
12521 let mut stmt = conn.prepare(
12522 "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
12523 FROM refs r
12524 WHERE r.caller_file IN (
12525 SELECT file_path FROM file_dependencies WHERE dep_file = ?1
12526 )
12527 OR r.target_file = ?1
12528 ORDER BY r.ref_id",
12529 )?;
12530 let rows = stmt.query_map(params![rel_path], |row| {
12531 Ok(RefDependencyRow {
12532 ref_id: row.get(0)?,
12533 kind: row.get(1)?,
12534 caller_file: row.get(2)?,
12535 module_path: row.get(3)?,
12536 target_file: row.get(4)?,
12537 })
12538 })?;
12539 let mut ids = Vec::new();
12540 for row in rows {
12541 let row = row?;
12542 if ref_dependency_row_depends_on(project_root, &row, rel_path) {
12543 ids.push(DependentRefSelection {
12544 ref_id: row.ref_id,
12545 caller_file: row.caller_file,
12546 });
12547 }
12548 }
12549 Ok(ids)
12550}
12551
12552fn record_dependent_refs(
12553 selected_ref_ids: &mut BTreeSet<String>,
12554 selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
12555 dependent_refs: Vec<DependentRefSelection>,
12556) {
12557 for dependent_ref in dependent_refs {
12558 let DependentRefSelection {
12559 ref_id,
12560 caller_file,
12561 } = dependent_ref;
12562 selected_ref_ids.insert(ref_id.clone());
12563 selected_refs_by_caller
12564 .entry(caller_file)
12565 .or_default()
12566 .insert(ref_id);
12567 }
12568}
12569
12570#[cfg(test)]
12571fn refs_by_caller_for_ref_ids(
12572 tx: &Transaction<'_>,
12573 ref_ids: &BTreeSet<String>,
12574) -> Result<BTreeMap<String, BTreeSet<String>>> {
12575 let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12576 let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
12577 for ref_id in ref_ids {
12578 if let Some(caller) = stmt
12579 .query_row(params![ref_id], |row| row.get::<_, String>(0))
12580 .optional()?
12581 {
12582 by_caller.entry(caller).or_default().insert(ref_id.clone());
12583 }
12584 }
12585 Ok(by_caller)
12586}
12587
12588fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
12589 tx.execute(
12590 "DELETE FROM file_dependencies WHERE file_path = ?1",
12591 params![rel_path],
12592 )?;
12593 delete_refs_for_caller(tx, rel_path)?;
12594 tx.execute(
12595 "DELETE FROM dispatch_hints WHERE file = ?1",
12596 params![rel_path],
12597 )?;
12598 tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
12599 tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
12600 Ok(())
12601}
12602
12603fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
12604 let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
12605 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
12606 let mut ids = BTreeSet::new();
12607 for row in rows {
12608 ids.insert(row?);
12609 }
12610 delete_ref_ids(tx, &ids)
12611}
12612
12613fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
12614 let mut delete_edges = tx.prepare("DELETE FROM edges WHERE ref_id = ?1")?;
12615 let mut delete_refs = tx.prepare("DELETE FROM refs WHERE ref_id = ?1")?;
12616 for ref_id in ref_ids {
12617 delete_edges.execute(params![ref_id])?;
12618 delete_refs.execute(params![ref_id])?;
12619 }
12620 Ok(())
12621}
12622
12623fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
12624 let mut stmt = conn.prepare(
12625 "SELECT source.file_path, source.scoped_name, edges.target_file,
12626 edges.target_symbol, edges.kind, edges.line
12627 FROM edges
12628 JOIN nodes AS source ON source.id = edges.source_node
12629 ORDER BY source.file_path, source.scoped_name, edges.target_file,
12630 edges.target_symbol, edges.kind, edges.line",
12631 )?;
12632 let rows = stmt.query_map([], |row| {
12633 Ok(StoredEdge {
12634 source_file: row.get(0)?,
12635 source_symbol: row.get(1)?,
12636 target_file: row.get(2)?,
12637 target_symbol: row.get(3)?,
12638 kind: row.get(4)?,
12639 line: row.get::<_, i64>(5)? as u32,
12640 })
12641 })?;
12642 let mut edges = BTreeSet::new();
12643 for row in rows {
12644 edges.insert(row?);
12645 }
12646 Ok(edges)
12647}
12648
12649fn module_target_from_dependencies(
12650 project_root: &Path,
12651 dependencies: &BTreeSet<String>,
12652) -> Option<String> {
12653 dependencies.iter().find_map(|dep| {
12654 let path = project_root.join(dep);
12655 if path.is_file() {
12656 Some(relative_path(project_root, &canonicalize_path(&path)))
12657 } else {
12658 None
12659 }
12660 })
12661}
12662
12663fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
12664 let mut named = HashMap::new();
12665 if let Some(full_ref) = &raw_ref.full_ref {
12666 named = parse_reexport_names(full_ref);
12667 }
12668 ReexportIndex {
12669 target_file,
12670 named,
12671 wildcard: raw_ref.wildcard,
12672 }
12673}
12674
12675fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
12676 let mut names = HashMap::new();
12677 let Some(open) = statement.find('{') else {
12678 return names;
12679 };
12680 let Some(close) = statement[open + 1..]
12681 .find('}')
12682 .map(|offset| open + 1 + offset)
12683 else {
12684 return names;
12685 };
12686 for spec in statement[open + 1..close].split(',') {
12687 let spec = spec.trim();
12688 if spec.is_empty() {
12689 continue;
12690 }
12691 if let Some((source, local)) = spec.split_once(" as ") {
12692 names.insert(local.trim().to_string(), source.trim().to_string());
12693 } else {
12694 names.insert(spec.to_string(), spec.to_string());
12695 }
12696 }
12697 names
12698}
12699
12700#[derive(Debug)]
12701struct RefDependencyRow {
12702 ref_id: String,
12703 kind: String,
12704 caller_file: String,
12705 module_path: Option<String>,
12706 target_file: Option<String>,
12707}
12708
12709fn ref_dependency_row_depends_on(
12710 project_root: &Path,
12711 row: &RefDependencyRow,
12712 rel_path: &str,
12713) -> bool {
12714 if row.target_file.as_deref() == Some(rel_path) {
12715 return true;
12716 }
12717
12718 match row.kind.as_str() {
12719 "call" => true,
12720 "import" | "reexport" => row
12721 .module_path
12722 .as_deref()
12723 .map(|module_path| {
12724 module_dependencies_for_ref(project_root, &row.caller_file, module_path)
12725 .contains(rel_path)
12726 })
12727 .unwrap_or(false),
12728 "export_alias" => false,
12729 _ => false,
12730 }
12731}
12732
12733fn module_dependencies_for_ref(
12734 project_root: &Path,
12735 caller_file: &str,
12736 module_path: &str,
12737) -> BTreeSet<String> {
12738 module_dependencies(project_root, &project_root.join(caller_file), module_path)
12739}
12740
12741fn import_dependencies(
12742 project_root: &Path,
12743 abs_path: &Path,
12744 imports: &[ImportStatement],
12745) -> BTreeSet<String> {
12746 let mut deps = BTreeSet::new();
12747 for import in imports {
12748 deps.extend(module_dependencies(
12749 project_root,
12750 abs_path,
12751 &import.module_path,
12752 ));
12753 }
12754 deps
12755}
12756
12757fn module_dependencies(
12758 project_root: &Path,
12759 abs_path: &Path,
12760 module_path: &str,
12761) -> BTreeSet<String> {
12762 let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
12763 let caller_dir = abs_path.parent().unwrap_or(project_root);
12764 if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
12765 deps.insert(relative_path(project_root, &resolved));
12766 }
12767 if module_path.starts_with('.') {
12768 let base = caller_dir.join(module_path);
12769 for candidate in relative_module_candidates(&base) {
12770 deps.insert(relative_path(project_root, &candidate));
12771 }
12772 }
12773 deps
12774}
12775
12776fn rust_module_dependencies(
12777 project_root: &Path,
12778 abs_path: &Path,
12779 module_path: &str,
12780) -> BTreeSet<String> {
12781 let mut deps = BTreeSet::new();
12782 let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
12783 let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
12784 return deps;
12785 };
12786 let src_prefix = rust_src_prefix(&rel_path);
12787 rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
12788 if !path_segments.is_empty() {
12789 rust_push_module_dependency_candidate(
12790 project_root,
12791 &mut deps,
12792 &src_prefix,
12793 &path_segments[..path_segments.len() - 1],
12794 );
12795 }
12796 deps
12797}
12798
12799fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
12800 let path = rust_module_path_without_alias_or_use_list(module_path);
12801 let segments = path
12802 .split("::")
12803 .map(str::trim)
12804 .filter(|segment| !segment.is_empty())
12805 .collect::<Vec<_>>();
12806 if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
12807 return None;
12808 }
12809 rust_resolve_segments(rel_path, &segments)
12810}
12811
12812fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
12813 let path = module_path
12814 .trim()
12815 .trim_end_matches(';')
12816 .split_once(" as ")
12817 .map(|(left, _)| left.trim())
12818 .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
12819 path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
12820}
12821
12822fn rust_push_module_dependency_candidate(
12823 project_root: &Path,
12824 deps: &mut BTreeSet<String>,
12825 src_prefix: &str,
12826 segments: &[String],
12827) {
12828 let candidates = if segments.is_empty() {
12829 vec![
12830 format!("{src_prefix}/lib.rs"),
12831 format!("{src_prefix}/main.rs"),
12832 ]
12833 } else {
12834 vec![
12835 format!("{}/{}.rs", src_prefix, segments.join("/")),
12836 format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
12837 ]
12838 };
12839 for candidate in candidates {
12840 if project_root.join(&candidate).is_file() {
12841 deps.insert(candidate);
12842 }
12843 }
12844}
12845
12846fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
12847 let mut candidates = Vec::new();
12848 if base.extension().is_some() {
12849 candidates.push(base.to_path_buf());
12850 return candidates;
12851 }
12852 for ext in JS_TS_EXTENSIONS {
12853 candidates.push(base.with_extension(ext));
12854 }
12855 for ext in JS_TS_EXTENSIONS {
12856 candidates.push(base.join(format!("index.{ext}")));
12857 }
12858 candidates
12859}
12860
12861fn import_local_names(import: &ImportStatement) -> Vec<String> {
12862 let mut names = Vec::new();
12863 if let Some(default) = &import.default_import {
12864 names.push(default.clone());
12865 }
12866 if let Some(namespace) = &import.namespace_import {
12867 names.push(namespace.clone());
12868 }
12869 for name in &import.names {
12870 names.push(crate::imports::specifier_local_name(name).to_string());
12871 }
12872 names
12873}
12874
12875fn import_requested_names(import: &ImportStatement) -> Vec<String> {
12876 import
12877 .names
12878 .iter()
12879 .map(|name| crate::imports::specifier_imported_name(name).to_string())
12880 .collect()
12881}
12882
12883fn import_is_wildcard(import: &ImportStatement) -> bool {
12884 import.namespace_import.is_some() || import.raw_text.contains('*')
12885}
12886
12887fn namespace_alias(full_ref: &str) -> Option<String> {
12888 full_ref
12889 .split_once('.')
12890 .map(|(namespace, _)| namespace.to_string())
12891}
12892
12893fn import_kind_label(kind: ImportKind) -> &'static str {
12894 match kind {
12895 ImportKind::Value => "value",
12896 ImportKind::Type => "type",
12897 ImportKind::SideEffect => "side_effect",
12898 }
12899}
12900
12901fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
12902 match kind {
12903 SymbolKind::Function => "function",
12904 SymbolKind::Class => "class",
12905 SymbolKind::Method => "method",
12906 SymbolKind::Struct => "struct",
12907 SymbolKind::Interface => "interface",
12908 SymbolKind::Enum => "enum",
12909 SymbolKind::TypeAlias => "type_alias",
12910 SymbolKind::Variable => "variable",
12911 SymbolKind::Heading => "heading",
12912 SymbolKind::FileSummary => "file_summary",
12913 }
12914}
12915
12916fn is_type_like(kind: &SymbolKind) -> bool {
12917 matches!(
12918 kind,
12919 SymbolKind::Class
12920 | SymbolKind::Struct
12921 | SymbolKind::Interface
12922 | SymbolKind::Enum
12923 | SymbolKind::TypeAlias
12924 )
12925}
12926
12927fn lang_label(lang: LangId) -> &'static str {
12928 match lang {
12929 LangId::TypeScript => "typescript",
12930 LangId::Tsx => "tsx",
12931 LangId::JavaScript => "javascript",
12932 LangId::Python => "python",
12933 LangId::Rust => "rust",
12934 LangId::Go => "go",
12935 LangId::C => "c",
12936 LangId::Cpp => "cpp",
12937 LangId::Zig => "zig",
12938 LangId::CSharp => "csharp",
12939 LangId::Bash => "bash",
12940 LangId::Html => "html",
12941 LangId::Markdown => "markdown",
12942 LangId::Solidity => "solidity",
12943 LangId::Scss => "scss",
12944 LangId::Vue => "vue",
12945 LangId::Json => "json",
12946 LangId::Scala => "scala",
12947 LangId::Java => "java",
12948 LangId::Ruby => "ruby",
12949 LangId::Kotlin => "kotlin",
12950 LangId::Swift => "swift",
12951 LangId::Php => "php",
12952 LangId::Lua => "lua",
12953 LangId::Perl => "perl",
12954 LangId::Yaml => "yaml",
12955 LangId::Pascal => "pascal",
12956 LangId::R => "r",
12957 LangId::Groovy => "groovy",
12958 LangId::ObjC => "objc",
12959 }
12960}
12961
12962fn lang_from_label(label: &str) -> Option<LangId> {
12963 match label {
12964 "typescript" => Some(LangId::TypeScript),
12965 "tsx" => Some(LangId::Tsx),
12966 "javascript" => Some(LangId::JavaScript),
12967 "python" => Some(LangId::Python),
12968 "rust" => Some(LangId::Rust),
12969 "go" => Some(LangId::Go),
12970 "c" => Some(LangId::C),
12971 "cpp" => Some(LangId::Cpp),
12972 "zig" => Some(LangId::Zig),
12973 "csharp" => Some(LangId::CSharp),
12974 "bash" => Some(LangId::Bash),
12975 "html" => Some(LangId::Html),
12976 "markdown" => Some(LangId::Markdown),
12977 "solidity" => Some(LangId::Solidity),
12978 "scss" => Some(LangId::Scss),
12979 "vue" => Some(LangId::Vue),
12980 "json" => Some(LangId::Json),
12981 "scala" => Some(LangId::Scala),
12982 "java" => Some(LangId::Java),
12983 "ruby" => Some(LangId::Ruby),
12984 "kotlin" => Some(LangId::Kotlin),
12985 "swift" => Some(LangId::Swift),
12986 "php" => Some(LangId::Php),
12987 "lua" => Some(LangId::Lua),
12988 "perl" => Some(LangId::Perl),
12989 "yaml" => Some(LangId::Yaml),
12990 "pascal" => Some(LangId::Pascal),
12991 "r" => Some(LangId::R),
12992 "groovy" => Some(LangId::Groovy),
12993 "objc" => Some(LangId::ObjC),
12994 _ => None,
12995 }
12996}
12997
12998fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
12999 let mut normalized = if files.is_empty() {
13000 callgraph::walk_project_files(project_root).collect::<Vec<_>>()
13001 } else {
13002 files
13003 .iter()
13004 .map(|path| normalize_file_path(project_root, path))
13005 .collect::<Result<Vec<_>>>()?
13006 };
13007 normalized.sort();
13008 normalized.dedup();
13009 Ok(normalized)
13010}
13011
13012fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
13013 let full_path = if path.is_relative() {
13014 project_root.join(path)
13015 } else {
13016 path.to_path_buf()
13017 };
13018 Ok(canonicalize_path(&full_path))
13019}
13020
13021fn normalize_project_file_path(project_root: &Path, path: &Path) -> Result<(PathBuf, String)> {
13025 let abs_path = normalize_file_path(project_root, path)?;
13026 let rel_path = relative_path(project_root, &abs_path);
13027 if Path::new(&rel_path).is_absolute() {
13028 return Err(CallGraphStoreError::PathIdentityMismatch {
13029 path: path.to_path_buf(),
13030 project_root: project_root.to_path_buf(),
13031 });
13032 }
13033 Ok((abs_path, rel_path))
13034}
13035
13036fn canonicalize_path(path: &Path) -> PathBuf {
13040 if let Ok(canonical) = std::fs::canonicalize(path) {
13041 return canonical;
13042 }
13043
13044 let mut resolved = PathBuf::new();
13045 let mut missing = Vec::new();
13046 for component in path.components() {
13047 match component {
13048 std::path::Component::Prefix(_) | std::path::Component::RootDir => {
13049 resolved.push(component.as_os_str());
13050 if let Ok(canonical) = std::fs::canonicalize(&resolved) {
13051 resolved = canonical;
13052 }
13053 }
13054 std::path::Component::CurDir => {}
13055 std::path::Component::ParentDir => {
13056 if missing.pop().is_none() {
13057 if !resolved.as_os_str().is_empty() && !resolved.is_dir() {
13058 return path.to_path_buf();
13059 }
13060 resolved.pop();
13061 }
13062 }
13063 std::path::Component::Normal(name) => {
13064 if missing.is_empty() {
13065 let candidate = resolved.join(name);
13066 match std::fs::canonicalize(&candidate) {
13067 Ok(canonical) => resolved = canonical,
13068 Err(_) => match std::fs::symlink_metadata(&candidate) {
13069 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
13070 missing.push(name.to_owned());
13071 }
13072 _ => return path.to_path_buf(),
13073 },
13074 }
13075 } else {
13076 missing.push(name.to_owned());
13077 }
13078 }
13079 }
13080 }
13081 resolved.extend(missing);
13082 resolved
13083}
13084
13085fn relative_path(project_root: &Path, path: &Path) -> String {
13086 if let Ok(stripped) = path.strip_prefix(project_root) {
13087 return stripped.to_string_lossy().replace('\\', "/");
13088 }
13089 let canon_root = canonicalize_path(project_root);
13090 let canon_path = canonicalize_path(path);
13091 if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
13092 return stripped.to_string_lossy().replace('\\', "/");
13093 }
13094 canon_path.to_string_lossy().replace('\\', "/")
13095}
13096
13097fn unqualified_name(scoped: &str) -> &str {
13098 if scoped == TOP_LEVEL_SYMBOL {
13099 return scoped;
13100 }
13101 scoped
13102 .rsplit("::")
13103 .next()
13104 .unwrap_or(scoped)
13105 .rsplit('.')
13106 .next()
13107 .unwrap_or(scoped)
13108 .rsplit('#')
13109 .next()
13110 .unwrap_or(scoped)
13111}
13112
13113fn ref_id(parts: &[&str]) -> String {
13114 let joined = parts.join("\0");
13115 hash_to_hex(blake3::hash(joined.as_bytes()))
13116}
13117
13118fn callgraph_corpus_fingerprint(project_root: &Path) -> Result<String> {
13119 let mut fingerprint = CorpusFingerprint::default();
13120 for path in callgraph::walk_project_files(project_root) {
13121 fingerprint.add_path(project_root, &path);
13122 }
13123 Ok(fingerprint.finish(project_root))
13124}
13125
13126fn corpus_fingerprint_for(project_root: &Path, files: &[PathBuf]) -> Result<String> {
13130 if files.is_empty() {
13131 return callgraph_corpus_fingerprint(project_root);
13132 }
13133 let mut fingerprint = CorpusFingerprint::default();
13134 for path in files {
13135 fingerprint.add_path(project_root, path);
13136 }
13137 Ok(fingerprint.finish(project_root))
13138}
13139
13140#[derive(Default)]
13141struct CorpusFingerprint {
13142 xor: [u8; 32],
13143 sums: [u64; 4],
13144 files: u64,
13145}
13146
13147impl CorpusFingerprint {
13148 fn add_path(&mut self, project_root: &Path, path: &Path) {
13149 let mut record = blake3::Hasher::new();
13150 record.update(relative_path(project_root, path).as_bytes());
13151 record.update(&[0]);
13152 match hash_file_bounded(path) {
13153 Ok(content_hash) => record.update(content_hash.as_bytes()),
13154 Err(error) => record.update(format!("missing:{error}").as_bytes()),
13157 };
13158 record.update(&[0]);
13159 let record = record.finalize();
13160 for (index, byte) in record.as_bytes().iter().copied().enumerate() {
13161 self.xor[index] ^= byte;
13162 }
13163 for (index, chunk) in record.as_bytes().chunks_exact(8).enumerate() {
13164 let value = u64::from_le_bytes(chunk.try_into().expect("eight-byte digest chunk"));
13165 self.sums[index] = self.sums[index].wrapping_add(value);
13166 }
13167 self.files = self.files.saturating_add(1);
13168 }
13169
13170 fn finish(self, project_root: &Path) -> String {
13171 let mut hasher = blake3::Hasher::new();
13174 hasher.update(b"callgraph-corpus-fingerprint-v2\0");
13175 hasher.update(&self.files.to_le_bytes());
13176 hasher.update(&self.xor);
13177 for sum in self.sums {
13178 hasher.update(&sum.to_le_bytes());
13179 }
13180 let ignore_rules = project_root.join(".gitignore");
13181 if let Ok(contents) = std::fs::read(ignore_rules) {
13182 hasher.update(b".gitignore\0");
13183 hasher.update(blake3::hash(&contents).as_bytes());
13184 }
13185 hash_to_hex(hasher.finalize())
13186 }
13187}
13188
13189fn hash_file_bounded(path: &Path) -> std::io::Result<blake3::Hash> {
13190 let mut file = std::fs::File::open(path)?;
13191 let mut hasher = blake3::Hasher::new();
13192 let mut buffer = [0u8; 64 * 1024];
13193 loop {
13194 let read = file.read(&mut buffer)?;
13195 if read == 0 {
13196 break;
13197 }
13198 hasher.update(&buffer[..read]);
13199 }
13200 Ok(hasher.finalize())
13201}
13202
13203#[cfg(test)]
13204pub(crate) fn callgraph_corpus_fingerprint_for_test(
13205 project_root: &Path,
13206 _files: &[PathBuf],
13207) -> Result<String> {
13208 callgraph_corpus_fingerprint(project_root)
13212}
13213
13214fn hash_to_hex(hash: blake3::Hash) -> String {
13215 hash.to_hex().to_string()
13216}
13217
13218fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
13219 let bytes = hex_to_bytes(value)?;
13220 Some(blake3::Hash::from_bytes(bytes))
13221}
13222
13223fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
13224 if value.len() != 64 {
13225 return None;
13226 }
13227 let mut bytes = [0u8; 32];
13228 for (index, slot) in bytes.iter_mut().enumerate() {
13229 let start = index * 2;
13230 let end = start + 2;
13231 *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
13232 }
13233 Some(bytes)
13234}
13235
13236#[derive(Debug, Clone)]
13237struct LineIndex {
13238 newline_offsets: Vec<usize>,
13239 source_len: usize,
13240}
13241
13242impl LineIndex {
13243 fn new(source: &str) -> Self {
13244 Self {
13245 newline_offsets: source
13246 .bytes()
13247 .enumerate()
13248 .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
13249 .collect(),
13250 source_len: source.len(),
13251 }
13252 }
13253
13254 fn byte_to_line(&self, byte_offset: usize) -> u32 {
13255 let byte_offset = byte_offset.min(self.source_len);
13256 self.newline_offsets
13257 .partition_point(|offset| *offset < byte_offset) as u32
13258 + 1
13259 }
13260}
13261
13262fn empty_to_none(value: String) -> Option<String> {
13263 if value.is_empty() {
13264 None
13265 } else {
13266 Some(value)
13267 }
13268}
13269
13270fn bool_int(value: bool) -> i64 {
13271 if value {
13272 1
13273 } else {
13274 0
13275 }
13276}
13277
13278fn system_time_to_ns(time: SystemTime) -> i64 {
13279 time.duration_since(UNIX_EPOCH)
13280 .unwrap_or_default()
13281 .as_nanos()
13282 .min(i64::MAX as u128) as i64
13283}
13284
13285fn ns_to_system_time(value: i64) -> SystemTime {
13286 UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
13287}
13288
13289pub(crate) fn unix_millis_now() -> u64 {
13290 SystemTime::now()
13291 .duration_since(UNIX_EPOCH)
13292 .unwrap_or_default()
13293 .as_millis()
13294 .min(u128::from(u64::MAX)) as u64
13295}
13296
13297fn unix_seconds_now() -> i64 {
13298 SystemTime::now()
13299 .duration_since(UNIX_EPOCH)
13300 .unwrap_or_default()
13301 .as_secs() as i64
13302}
13303
13304#[cfg(test)]
13309pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
13310
13311#[cfg(test)]
13312mod refresh_worker_tests {
13313 use super::*;
13314 use std::fs;
13315 use tempfile::tempdir;
13316
13317 fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
13318 let temp = tempdir().unwrap();
13319 let root = temp.path().join("root");
13320 fs::create_dir_all(&root).unwrap();
13321 let artifact_key = crate::search_index::artifact_cache_key(&root);
13322 crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
13323 let callgraph_dir = temp
13324 .path()
13325 .join("storage")
13326 .join("callgraph")
13327 .join(artifact_key);
13328 let source = root.join("main.rs");
13329 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
13330 let (store, _) = CallGraphStore::cold_build_with_lease(
13331 callgraph_dir.clone(),
13332 root.clone(),
13333 std::slice::from_ref(&source),
13334 )
13335 .unwrap();
13336 drop(store);
13337 (temp, root, callgraph_dir, source)
13338 }
13339
13340 fn pending_paths() -> PendingCallGraphStorePaths {
13341 Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
13342 }
13343
13344 fn wait_for_refresh_calls(root: &Path, expected: usize) {
13345 let deadline = Instant::now() + Duration::from_secs(12);
13346 while callgraph_refresh_worker_test_counts(root).0 < expected {
13347 assert!(
13348 Instant::now() < deadline,
13349 "timed out waiting for {expected} callgraph refresh worker call(s)"
13350 );
13351 std::thread::sleep(Duration::from_millis(5));
13352 }
13353 }
13354
13355 fn wait_for_refresh_worker_idle() {
13356 let deadline = Instant::now() + Duration::from_secs(12);
13357 loop {
13358 let worker = CALLGRAPH_REFRESH_WORKER
13359 .get_or_init(|| Mutex::new(None))
13360 .lock()
13361 .expect("callgraph refresh worker mutex poisoned")
13362 .clone();
13363 let idle = worker.is_none_or(|worker| {
13364 let queue = worker
13365 .shared
13366 .queue
13367 .lock()
13368 .expect("callgraph refresh queue mutex poisoned");
13369 queue.active.is_none() && queue.order.is_empty()
13370 });
13371 if idle {
13372 return;
13373 }
13374 assert!(
13375 Instant::now() < deadline,
13376 "timed out waiting for callgraph refresh worker to become idle"
13377 );
13378 std::thread::sleep(Duration::from_millis(5));
13379 }
13380 }
13381
13382 fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
13383 let temp = tempdir().unwrap();
13384 let root = temp.path().join("workspace");
13385 fs::create_dir_all(root.join("app/src")).unwrap();
13386 let artifact_key = crate::search_index::artifact_cache_key(&root);
13387 crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
13388 let callgraph_dir = temp
13389 .path()
13390 .join("storage")
13391 .join("callgraph")
13392 .join(artifact_key);
13393 fs::write(
13394 root.join("Cargo.toml"),
13395 "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
13396 )
13397 .unwrap();
13398 fs::write(
13399 root.join("app/Cargo.toml"),
13400 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
13401 )
13402 .unwrap();
13403 let caller = root.join("app/src/lib.rs");
13404 fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
13405 let (store, _) = CallGraphStore::cold_build_with_lease(
13406 callgraph_dir.clone(),
13407 root.clone(),
13408 std::slice::from_ref(&caller),
13409 )
13410 .unwrap();
13411 drop(store);
13412 (temp, root, callgraph_dir, caller)
13413 }
13414
13415 #[test]
13416 fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
13417 let _guard = REFRESH_WORKER_TEST_LOCK
13418 .lock()
13419 .unwrap_or_else(std::sync::PoisonError::into_inner);
13420 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13421 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
13422 reset_workspace_crate_prefix_build_count(&root);
13423 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13424
13425 for revision in ["first", "second"] {
13426 fs::write(
13427 &caller,
13428 format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
13429 )
13430 .unwrap();
13431 enqueue_callgraph_store_refresh(
13432 callgraph_dir.clone(),
13433 root.clone(),
13434 vec![caller.clone()],
13435 pending_paths(),
13436 );
13437 wait_for_refresh_worker_idle();
13438 }
13439
13440 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
13441 assert!(flush_callgraph_store_refreshes_with_budget(
13442 Duration::from_secs(5)
13443 ));
13444 clear_callgraph_refresh_worker_test_seam(&root);
13445 }
13446
13447 #[test]
13448 fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
13449 let _guard = REFRESH_WORKER_TEST_LOCK
13450 .lock()
13451 .unwrap_or_else(std::sync::PoisonError::into_inner);
13452 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13453 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
13454 reset_workspace_crate_prefix_build_count(&root);
13455 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13456
13457 fs::write(
13458 &caller,
13459 "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
13460 )
13461 .unwrap();
13462 enqueue_callgraph_store_refresh(
13463 callgraph_dir.clone(),
13464 root.clone(),
13465 vec![caller.clone()],
13466 pending_paths(),
13467 );
13468 wait_for_refresh_worker_idle();
13469 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
13470
13471 let added_manifest = root.join("added/Cargo.toml");
13472 let added_source = root.join("added/src/lib.rs");
13473 fs::create_dir_all(added_source.parent().unwrap()).unwrap();
13474 fs::write(
13475 root.join("Cargo.toml"),
13476 "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
13477 )
13478 .unwrap();
13479 fs::write(
13480 &added_manifest,
13481 "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
13482 )
13483 .unwrap();
13484 fs::write(&added_source, "pub fn target() {}\n").unwrap();
13485 fs::write(
13486 &caller,
13487 "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
13488 )
13489 .unwrap();
13490
13491 enqueue_callgraph_store_refresh(
13492 callgraph_dir.clone(),
13493 root.clone(),
13494 vec![
13495 root.join("Cargo.toml"),
13496 added_manifest,
13497 added_source,
13498 caller,
13499 ],
13500 pending_paths(),
13501 );
13502 assert!(flush_callgraph_store_refreshes_with_budget(
13503 Duration::from_secs(12)
13504 ));
13505
13506 assert_eq!(workspace_crate_prefix_build_count(&root), 2);
13510 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
13511 .unwrap()
13512 .expect("refreshed workspace store");
13513 let tree = store
13514 .call_tree(Path::new("app/src/lib.rs"), "run", 1)
13515 .unwrap();
13516 assert_eq!(tree.children.len(), 1);
13517 assert_eq!(tree.children[0].file, "added/src/lib.rs");
13518 assert_eq!(tree.children[0].name, "target");
13519 assert!(tree.children[0].resolved);
13520 clear_callgraph_refresh_worker_test_seam(&root);
13521 }
13522
13523 fn linked_worktree_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, PathBuf) {
13524 let temp = tempdir().unwrap();
13525 let main = temp.path().join("main");
13526 let worktree = temp.path().join("worktree");
13527 fs::create_dir_all(&main).unwrap();
13528 let mut git = std::process::Command::new("git");
13529 assert!(
13530 crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
13531 .status()
13532 .unwrap()
13533 .success()
13534 );
13535 fs::write(main.join("lib.rs"), "pub fn marker() {}\n").unwrap();
13536 for args in [
13537 vec![
13538 "-C",
13539 main.to_str().unwrap(),
13540 "config",
13541 "user.email",
13542 "test@example.com",
13543 ],
13544 vec![
13545 "-C",
13546 main.to_str().unwrap(),
13547 "config",
13548 "user.name",
13549 "AFT Test",
13550 ],
13551 vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
13552 vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
13553 ] {
13554 let mut command = std::process::Command::new("git");
13555 assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
13556 .status()
13557 .unwrap()
13558 .success());
13559 }
13560 let mut add_worktree = std::process::Command::new("git");
13561 assert!(crate::test_env::apply_hermetic_git_env(
13562 add_worktree
13563 .arg("-C")
13564 .arg(&main)
13565 .args(["worktree", "add", "--detach"])
13566 .arg(&worktree),
13567 )
13568 .status()
13569 .unwrap()
13570 .success());
13571 let main = fs::canonicalize(main).unwrap();
13572 let worktree = fs::canonicalize(worktree).unwrap();
13573 let project_key = crate::search_index::artifact_cache_key(&main);
13574 assert_eq!(
13575 crate::search_index::artifact_cache_key(&worktree),
13576 project_key
13577 );
13578 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
13579 (temp, main, worktree, project_key, callgraph_dir)
13580 }
13581
13582 #[test]
13583 fn linked_worktree_never_acquires_writer_or_publishes_any_build_path() {
13584 let _git_env = crate::test_env::hermetic_git_env_guard();
13585 let (_temp, _main, root, project_key, callgraph_dir) = linked_worktree_fixture();
13586 crate::root_cache::configure_artifact_access(&root, &project_key, true);
13587 crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
13588 let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13589 let publications_for_observer = Arc::clone(&publications);
13590 set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
13591 publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
13592 })));
13593 let source = root.join("lib.rs");
13594
13595 let open_error = CallGraphStore::open(callgraph_dir.clone(), root.clone())
13596 .expect_err("borrow-only writable open must remain unavailable");
13597 assert!(matches!(open_error, CallGraphStoreError::Unavailable(_)));
13598 assert!(
13599 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), root.clone())
13600 .unwrap()
13601 .is_none()
13602 );
13603 assert!(
13604 CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), root.clone())
13605 .unwrap()
13606 .is_none()
13607 );
13608 assert!(matches!(
13609 CallGraphStore::cold_build_with_lease(
13610 callgraph_dir.clone(),
13611 root.clone(),
13612 std::slice::from_ref(&source),
13613 ),
13614 Err(CallGraphStoreError::Unavailable(_))
13615 ));
13616 assert!(matches!(
13617 CallGraphStore::ensure_built_with_lease(
13618 callgraph_dir.clone(),
13619 root.clone(),
13620 std::slice::from_ref(&source),
13621 ),
13622 Err(CallGraphStoreError::Unavailable(_))
13623 ));
13624 let force_error = CallGraphStore::force_cold_build_with_lease_chunked(
13625 callgraph_dir.clone(),
13626 root.clone(),
13627 &[source],
13628 1,
13629 )
13630 .expect_err("borrow-only forced rebuild must remain unsatisfied");
13631 set_cold_build_swap_observer(None);
13632
13633 assert!(matches!(force_error, CallGraphStoreError::Unavailable(_)));
13634 assert_eq!(
13635 crate::root_cache::writer_lease_acquisition_count_for_test(
13636 crate::root_cache::RootCacheDomain::Callgraph,
13637 &project_key,
13638 &root,
13639 ),
13640 0
13641 );
13642 assert_eq!(publications.load(AtomicOrdering::SeqCst), 0);
13643 assert!(!pointer_path(&callgraph_dir, &project_key).exists());
13644 }
13645
13646 #[test]
13647 fn owner_and_linked_worktree_alternation_rebuilds_storm_generation_once() {
13648 let _git_env = crate::test_env::hermetic_git_env_guard();
13649 let (_temp, owner, worktree, project_key, callgraph_dir) = linked_worktree_fixture();
13650 crate::root_cache::configure_artifact_access(&owner, &project_key, false);
13651 crate::root_cache::configure_artifact_access(&worktree, &project_key, true);
13652 let source = owner.join("lib.rs");
13653 let (store, _) = CallGraphStore::cold_build_with_lease(
13654 callgraph_dir.clone(),
13655 owner.clone(),
13656 std::slice::from_ref(&source),
13657 )
13658 .unwrap();
13659 let sqlite_path = store.sqlite_path().to_path_buf();
13660 drop(store);
13661
13662 let conn = Connection::open(&sqlite_path).unwrap();
13663 conn.execute(
13664 "UPDATE backend_file_state SET workspace_root = ?1",
13665 [worktree.display().to_string()],
13666 )
13667 .unwrap();
13668 drop(conn);
13669
13670 let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13671 let publications_for_observer = Arc::clone(&publications);
13672 set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
13673 publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
13674 })));
13675 crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
13676
13677 let repaired = CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
13678 .unwrap()
13679 .expect("owner should purge the storm-era worktree root");
13680 drop(repaired);
13681 for _ in 0..3 {
13682 let borrower = CallGraphStore::open_readonly(callgraph_dir.clone(), worktree.clone())
13683 .unwrap()
13684 .expect("linked worktree should borrow the owner generation");
13685 drop(borrower);
13686 assert!(
13687 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), worktree.clone())
13688 .unwrap()
13689 .is_none()
13690 );
13691 let owner_store =
13692 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
13693 .unwrap()
13694 .expect("owner generation should remain ready");
13695 drop(owner_store);
13696 }
13697 set_cold_build_swap_observer(None);
13698
13699 assert_eq!(
13700 publications.load(AtomicOrdering::SeqCst),
13701 1,
13702 "the owner performs one expected post-storm purge and alternation stays read-only"
13703 );
13704 assert_eq!(
13705 crate::root_cache::writer_lease_acquisition_count_for_test(
13706 crate::root_cache::RootCacheDomain::Callgraph,
13707 &project_key,
13708 &worktree,
13709 ),
13710 0
13711 );
13712 }
13713
13714 #[test]
13715 fn rebuild_cooldown_records_only_successful_publication_per_cache_key() {
13716 let temp = tempdir().unwrap();
13717 let root = temp.path().join("owner");
13718 let other_root = temp.path().join("other");
13719 fs::create_dir_all(&root).unwrap();
13720 fs::create_dir_all(&other_root).unwrap();
13721 let source = root.join("lib.rs");
13722 fs::write(&source, "pub fn marker() {}\n").unwrap();
13723 let project_key = crate::search_index::artifact_cache_key(&root);
13724 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
13725 crate::root_cache::configure_artifact_access(&root, &project_key, false);
13726 let cooldown_key = rebuild_cooldown_key(&callgraph_dir, &project_key);
13727 rebuild_cooldown_records()
13728 .lock()
13729 .unwrap_or_else(std::sync::PoisonError::into_inner)
13730 .remove(&cooldown_key);
13731 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
13732 let stale_epoch = epoch.current();
13733 epoch.next();
13734
13735 let failed = with_publish_epoch(epoch, stale_epoch, || {
13736 CallGraphStore::cold_build_with_lease(
13737 callgraph_dir.clone(),
13738 root.clone(),
13739 std::slice::from_ref(&source),
13740 )
13741 });
13742 assert!(matches!(failed, Err(CallGraphStoreError::Superseded)));
13743 assert!(
13744 rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
13745 .is_none()
13746 );
13747
13748 let (store, _) = CallGraphStore::cold_build_with_lease(
13749 callgraph_dir.clone(),
13750 root.clone(),
13751 std::slice::from_ref(&source),
13752 )
13753 .unwrap();
13754 drop(store);
13755 assert!(
13756 rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
13757 .is_none()
13758 );
13759
13760 record_successful_rebuild(&callgraph_dir, &project_key, &other_root, Instant::now());
13761 assert!(
13762 rebuild_cooldown_denial(&callgraph_dir, &project_key, &root, Instant::now(),).is_some()
13763 );
13764 }
13765
13766 #[test]
13767 fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
13768 let _guard = REFRESH_WORKER_TEST_LOCK
13769 .lock()
13770 .unwrap_or_else(std::sync::PoisonError::into_inner);
13771 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13772 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13773 let pending = pending_paths();
13774 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13775
13776 let lifecycle = SubcLifecycleAdmission::default();
13777 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
13778 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
13779 let ticket = CallgraphRefreshTicket::new(
13780 lifecycle,
13781 Arc::clone(&generation),
13782 7,
13783 publish_epoch.clone(),
13784 publish_epoch.current(),
13785 );
13786 generation.store(8, std::sync::atomic::Ordering::SeqCst);
13788 let installed = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
13789 .unwrap()
13790 .expect("ready store snapshot");
13791 let refresh_state = CallgraphRefreshState::new(
13792 Arc::new(std::sync::RwLock::new(Some(Arc::new(installed)))),
13793 Arc::new(AtomicBool::new(true)),
13794 );
13795
13796 enqueue_callgraph_store_refresh_fenced_with_state(
13797 callgraph_dir,
13798 root.clone(),
13799 vec![source.clone()],
13800 Arc::clone(&pending),
13801 refresh_state,
13802 ticket,
13803 );
13804 assert!(flush_callgraph_store_refreshes_with_budget(
13805 Duration::from_secs(5)
13806 ));
13807 assert_eq!(
13808 callgraph_refresh_worker_test_counts(&root).0,
13809 0,
13810 "superseded batch must not reach refresh_files or self-replay"
13811 );
13812 assert!(
13813 pending.lock().contains(&source),
13814 "superseded batch must defer its paths to the pending sink"
13815 );
13816 clear_callgraph_refresh_worker_test_seam(&root);
13817 }
13818
13819 #[test]
13820 fn superseded_open_failure_defers_without_self_replay() {
13821 let _guard = REFRESH_WORKER_TEST_LOCK
13822 .lock()
13823 .unwrap_or_else(std::sync::PoisonError::into_inner);
13824 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13825 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13826 let pending = pending_paths();
13827 let installed = Arc::new(
13828 CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
13829 .unwrap()
13830 .expect("ready store snapshot"),
13831 );
13832 let refresh_state = CallgraphRefreshState::new(
13833 Arc::new(std::sync::RwLock::new(Some(Arc::clone(&installed)))),
13834 Arc::new(AtomicBool::new(true)),
13835 );
13836 assert!(!installed.is_legacy_fallback());
13837 assert!(installed.is_current());
13838 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
13839 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13840 set_callgraph_refresh_worker_test_open_failure(root.clone(), true);
13841 let (held_rx, release_tx) = install_callgraph_refresh_worker_test_gate(root.clone());
13842
13843 let lifecycle = SubcLifecycleAdmission::default();
13844 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
13845 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
13846 let ticket = CallgraphRefreshTicket::new(
13847 lifecycle,
13848 Arc::clone(&generation),
13849 7,
13850 publish_epoch.clone(),
13851 publish_epoch.current(),
13852 );
13853 enqueue_callgraph_store_refresh_fenced_with_state(
13854 callgraph_dir,
13855 root.clone(),
13856 vec![source.clone()],
13857 Arc::clone(&pending),
13858 refresh_state,
13859 ticket,
13860 );
13861 held_rx
13862 .recv_timeout(Duration::from_secs(12))
13863 .expect("refresh worker must hold after injected open failure");
13864
13865 generation.store(8, std::sync::atomic::Ordering::SeqCst);
13868 set_callgraph_refresh_worker_test_open_failure(root.clone(), false);
13869 release_tx
13870 .send(())
13871 .expect("release superseded refresh worker");
13872 wait_for_refresh_worker_idle();
13873
13874 assert_eq!(
13875 callgraph_refresh_worker_test_counts(&root).0,
13876 1,
13877 "superseded open-failure batch must not self-replay"
13878 );
13879 assert_eq!(
13880 callgraph_refresh_worker_test_worker_calls(&root),
13881 1,
13882 "superseded open-failure batch must not create another worker call"
13883 );
13884 assert!(
13885 pending.lock().contains(&source),
13886 "superseded open-failure paths must remain in the pending sink"
13887 );
13888 let tree = installed
13889 .call_tree(Path::new("main.rs"), "entry", 1)
13890 .unwrap();
13891 assert_eq!(
13892 tree.children[0].name, "old_leaf",
13893 "superseded open-failure batch must not converge the store"
13894 );
13895 clear_callgraph_refresh_worker_test_seam(&root);
13896 }
13897
13898 #[test]
13899 fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
13900 let _guard = REFRESH_WORKER_TEST_LOCK
13901 .lock()
13902 .unwrap_or_else(std::sync::PoisonError::into_inner);
13903 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13904 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13905 let pending = pending_paths();
13906 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13907
13908 let lifecycle = SubcLifecycleAdmission::default();
13909 let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
13910 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
13911 let expected_epoch = publish_epoch.current();
13912 let ticket = CallgraphRefreshTicket::new(
13913 lifecycle,
13914 generation,
13915 3,
13916 publish_epoch.clone(),
13917 expected_epoch,
13918 );
13919 publish_epoch.next();
13921
13922 enqueue_callgraph_store_refresh_fenced(
13923 callgraph_dir,
13924 root.clone(),
13925 vec![source.clone()],
13926 Arc::clone(&pending),
13927 ticket,
13928 );
13929 assert!(flush_callgraph_store_refreshes_with_budget(
13930 Duration::from_secs(5)
13931 ));
13932 assert_eq!(
13933 callgraph_refresh_worker_test_counts(&root).0,
13934 0,
13935 "epoch-superseded batch must not reach refresh_files"
13936 );
13937 assert!(
13938 pending.lock().contains(&source),
13939 "epoch-superseded batch must defer its paths to the pending sink"
13940 );
13941 clear_callgraph_refresh_worker_test_seam(&root);
13942 }
13943
13944 #[test]
13945 fn fenced_refresh_with_current_ticket_commits_normally() {
13946 let _guard = REFRESH_WORKER_TEST_LOCK
13947 .lock()
13948 .unwrap_or_else(std::sync::PoisonError::into_inner);
13949 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13950 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13951 let pending = pending_paths();
13952 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13953
13954 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
13955
13956 let lifecycle = SubcLifecycleAdmission::default();
13957 let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
13958 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
13959 let ticket = CallgraphRefreshTicket::new(
13960 lifecycle,
13961 generation,
13962 5,
13963 publish_epoch.clone(),
13964 publish_epoch.current(),
13965 );
13966
13967 enqueue_callgraph_store_refresh_fenced(
13968 callgraph_dir.clone(),
13969 root.clone(),
13970 vec![source.clone()],
13971 Arc::clone(&pending),
13972 ticket,
13973 );
13974 assert!(flush_callgraph_store_refreshes_with_budget(
13975 Duration::from_secs(5)
13976 ));
13977 assert_eq!(
13978 callgraph_refresh_worker_test_counts(&root).0,
13979 1,
13980 "current ticket must run the refresh"
13981 );
13982 assert!(
13983 pending.lock().is_empty(),
13984 "committed batch must not defer paths"
13985 );
13986
13987 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
13988 .unwrap()
13989 .expect("published generation must remain readable");
13990 let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
13991 assert_eq!(
13992 tree.children[0].name, "new_leaf",
13993 "fenced commit must actually persist the refreshed content"
13994 );
13995 clear_callgraph_refresh_worker_test_seam(&root);
13996 }
13997
13998 #[test]
13999 fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
14000 let _guard = REFRESH_WORKER_TEST_LOCK
14001 .lock()
14002 .unwrap_or_else(std::sync::PoisonError::into_inner);
14003 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14008 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
14009 let pending = pending_paths();
14010 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
14011
14012 enqueue_callgraph_store_refresh(
14013 callgraph_dir.clone(),
14014 root.clone(),
14015 vec![source.clone()],
14016 Arc::clone(&pending),
14017 );
14018 wait_for_refresh_calls(&root, 1);
14019 for _ in 0..3 {
14020 enqueue_callgraph_store_refresh(
14021 callgraph_dir.clone(),
14022 root.clone(),
14023 vec![source.clone()],
14024 Arc::clone(&pending),
14025 );
14026 }
14027
14028 assert!(flush_callgraph_store_refreshes_with_budget(
14029 Duration::from_secs(2)
14030 ));
14031 assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
14032 assert!(pending.lock().is_empty());
14033 clear_callgraph_refresh_worker_test_seam(&root);
14034 }
14035
14036 #[test]
14037 fn queued_refresh_opens_generation_published_after_enqueue() {
14038 let _guard = REFRESH_WORKER_TEST_LOCK
14039 .lock()
14040 .unwrap_or_else(std::sync::PoisonError::into_inner);
14041 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14046 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
14047 let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
14048 set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
14049 let (active_held_rx, active_release_tx) =
14050 install_callgraph_refresh_worker_test_gate(active_root.clone());
14051 set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
14052 enqueue_callgraph_store_refresh(
14053 active_dir,
14054 active_root.clone(),
14055 vec![active_source],
14056 pending_paths(),
14057 );
14058 active_held_rx
14059 .recv_timeout(Duration::from_secs(12))
14060 .expect("active refresh worker holds the queue");
14061
14062 fs::write(
14063 &target_source,
14064 "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
14065 )
14066 .unwrap();
14067 enqueue_callgraph_store_refresh(
14068 target_dir.clone(),
14069 target_root.clone(),
14070 vec![target_source.clone()],
14071 pending_paths(),
14072 );
14073 let (new_generation, _) = CallGraphStore::cold_build_with_lease(
14074 target_dir.clone(),
14075 target_root.clone(),
14076 std::slice::from_ref(&target_source),
14077 )
14078 .unwrap();
14079 fs::write(
14080 &target_source,
14081 "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
14082 )
14083 .unwrap();
14084 drop(new_generation);
14085
14086 active_release_tx
14087 .send(())
14088 .expect("release active refresh worker");
14089 wait_for_refresh_calls(&target_root, 1);
14090 assert!(flush_callgraph_store_refreshes_with_budget(
14091 Duration::from_secs(12)
14092 ));
14093 let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
14094 .unwrap()
14095 .expect("current callgraph generation");
14096 let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
14097 assert_eq!(tree.children[0].name, "worker_leaf");
14098 assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
14099 clear_callgraph_refresh_worker_test_seam(&active_root);
14100 clear_callgraph_refresh_worker_test_seam(&target_root);
14101 }
14102
14103 #[test]
14104 fn refresh_failure_marks_files_stale() {
14105 let _guard = REFRESH_WORKER_TEST_LOCK
14106 .lock()
14107 .unwrap_or_else(std::sync::PoisonError::into_inner);
14108 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14113 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
14114 let pending = pending_paths();
14115 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
14116
14117 enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
14118 assert!(flush_callgraph_store_refreshes_with_budget(
14119 Duration::from_secs(2)
14120 ));
14121
14122 assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
14123 let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
14124 .unwrap()
14125 .expect("ready callgraph store");
14126 assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
14127 clear_callgraph_refresh_worker_test_seam(&root);
14128 }
14129
14130 #[test]
14131 fn idle_refresh_truncates_wal() {
14132 let _guard = REFRESH_WORKER_TEST_LOCK
14133 .lock()
14134 .unwrap_or_else(std::sync::PoisonError::into_inner);
14135 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14136 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
14137 let generation = read_pointer(
14138 &callgraph_dir,
14139 &crate::search_index::artifact_cache_key(&root),
14140 )
14141 .expect("fixture publishes a generation");
14142 let wal_path = callgraph_dir.join(format!("{generation}-wal"));
14143 let pending = pending_paths();
14144 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14145
14146 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n\n").unwrap();
14147 enqueue_callgraph_store_refresh(
14148 callgraph_dir.clone(),
14149 root.clone(),
14150 vec![source.clone()],
14151 Arc::clone(&pending),
14152 );
14153 wait_for_refresh_calls(&root, 1);
14154 wait_for_refresh_worker_idle();
14155 let checkpoint_deadline = Instant::now() + Duration::from_secs(2);
14156 while fs::metadata(&wal_path)
14157 .map(|metadata| metadata.len())
14158 .unwrap_or(0)
14159 != 0
14160 {
14161 assert!(
14162 Instant::now() < checkpoint_deadline,
14163 "idle checkpoint did not truncate WAL"
14164 );
14165 std::thread::sleep(Duration::from_millis(5));
14166 }
14167 assert_eq!(
14168 fs::metadata(&wal_path)
14169 .map(|metadata| metadata.len())
14170 .unwrap_or(0),
14171 0,
14172 "idle transition truncates the refresh WAL"
14173 );
14174
14175 clear_callgraph_refresh_worker_test_seam(&root);
14176 }
14177
14178 #[test]
14179 fn bounded_shutdown_defers_unprocessed_batches() {
14180 let _guard = REFRESH_WORKER_TEST_LOCK
14181 .lock()
14182 .unwrap_or_else(std::sync::PoisonError::into_inner);
14183 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14188 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
14189 let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
14190 let active_pending = pending_paths();
14191 let queued_pending = pending_paths();
14192 set_callgraph_refresh_worker_test_seam(
14193 active_root.clone(),
14194 Duration::from_millis(300),
14195 false,
14196 );
14197
14198 enqueue_callgraph_store_refresh(
14199 active_dir,
14200 active_root.clone(),
14201 vec![active_source.clone()],
14202 Arc::clone(&active_pending),
14203 );
14204 wait_for_refresh_calls(&active_root, 1);
14205 enqueue_callgraph_store_refresh(
14206 queued_dir,
14207 queued_root.clone(),
14208 vec![queued_source.clone()],
14209 Arc::clone(&queued_pending),
14210 );
14211
14212 assert!(!flush_callgraph_store_refreshes_with_budget(
14213 Duration::from_millis(20)
14214 ));
14215 assert!(active_pending.lock().contains(&active_source));
14216 assert!(queued_pending.lock().contains(&queued_source));
14217 assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
14218 clear_callgraph_refresh_worker_test_seam(&active_root);
14219 }
14220}
14221
14222#[cfg(test)]
14223mod cold_build_insert_tests {
14224 use super::*;
14225 use crate::imports::ImportBlock;
14226 use std::cell::Cell;
14227 use std::fs;
14228 use std::path::{Path, PathBuf};
14229 use tempfile::tempdir;
14230
14231 thread_local! {
14232 static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
14233 static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
14234 static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
14235 }
14236
14237 fn count_caller_traversal_selects(sql: &str) {
14238 let sql = sql.trim_start();
14239 if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
14240 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
14241 }
14242 if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
14243 && sql.contains("e.target_file =")
14244 {
14245 CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
14246 }
14247 if sql.starts_with("WITH requested") && sql.contains("COUNT(*)") {
14248 BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
14249 }
14250 }
14251
14252 #[test]
14253 fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
14254 let dir = tempdir().unwrap();
14255 let previous_root = dir.path().join("previous-root");
14256 let current_root = dir.path().join("current-root");
14257 fs::create_dir_all(&previous_root).unwrap();
14258 fs::create_dir_all(¤t_root).unwrap();
14259 fs::remove_dir(&previous_root).unwrap();
14260 let mut conn = Connection::open_in_memory().unwrap();
14261 initialize_schema(&conn).unwrap();
14262 conn.execute(
14263 "INSERT INTO backend_file_state(
14264 backend, workspace_root, file_path, content_hash, status, updated_at
14265 ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
14266 params![previous_root.display().to_string()],
14267 )
14268 .unwrap();
14269
14270 let repair = reconcile_workspace_roots(&mut conn, ¤t_root, false).unwrap();
14271
14272 assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
14273 assert_eq!(
14274 stored_workspace_roots(&conn).unwrap(),
14275 vec![previous_root.display().to_string()]
14276 );
14277 }
14278
14279 #[test]
14280 fn sqlite_readonly_uri_percent_encodes_windows_paths() {
14281 assert_eq!(
14282 sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
14283 "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
14284 );
14285 }
14286
14287 #[test]
14288 fn legacy_migration_completion_log_has_operator_fields() {
14289 assert_eq!(
14290 legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
14291 "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
14292 );
14293 }
14294
14295 fn write_generation_with_age(
14296 dir: &Path,
14297 project_key: &str,
14298 ordinal: u64,
14299 age: Duration,
14300 ) -> String {
14301 let generation = format!("{project_key}.g{ordinal}.1.sqlite");
14302 let path = dir.join(&generation);
14303 fs::write(&path, b"sqlite placeholder").unwrap();
14304 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
14305 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
14306 generation
14307 }
14308
14309 #[test]
14310 fn gc_old_generations_preserves_live_reader_until_marker_drops() {
14311 let dir = tempfile::tempdir().unwrap();
14312 let project_key = "project";
14313 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
14314 let previous =
14315 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
14316 let pinned =
14317 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
14318 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
14319
14320 gc_old_generations(dir.path(), project_key, ¤t);
14321
14322 assert!(dir.path().join(&previous).is_file());
14323 assert!(dir.path().join(&pinned).is_file());
14324
14325 drop(marker);
14326 gc_old_generations(dir.path(), project_key, ¤t);
14327
14328 assert!(dir.path().join(&previous).is_file());
14329 assert!(!dir.path().join(&pinned).exists());
14330 }
14331
14332 #[test]
14333 fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
14334 let dir = tempfile::tempdir().unwrap();
14335 let project_key = "project";
14336 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
14337 let _previous =
14338 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
14339 let pinned =
14340 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
14341 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
14342 filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
14343
14344 gc_old_generations(dir.path(), project_key, ¤t);
14345
14346 assert!(dir.path().join(&pinned).is_file());
14347 }
14348
14349 #[test]
14350 fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
14351 let dir = tempfile::tempdir().unwrap();
14352 let project_key = "project";
14353 let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
14354 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
14355 let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
14356 let old = write_generation_with_age(
14357 dir.path(),
14358 project_key,
14359 200,
14360 expired + Duration::from_secs(60),
14361 );
14362 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
14363
14364 gc_old_generations(dir.path(), project_key, ¤t);
14365
14366 assert!(dir.path().join(¤t).is_file());
14367 assert!(dir.path().join(&previous).is_file());
14368 assert!(!dir.path().join(&old).exists());
14369 }
14370
14371 fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
14372 let path = dir.join(name);
14373 fs::write(&path, b"temp placeholder").unwrap();
14374 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
14375 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
14376 path
14377 }
14378
14379 #[test]
14380 fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
14381 let dir = tempdir().unwrap();
14382 let aged = "project.g100.1.sqlite.tmp.1.200";
14386 let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
14387 let fresh = "project.g300.1.sqlite.tmp.1.400";
14388 let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
14389 write_build_temp_with_age(dir.path(), aged, aged_age);
14390 write_build_temp_with_age(dir.path(), aged_journal, aged_age);
14391 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
14392
14393 sweep_orphaned_build_temps(dir.path());
14394
14395 assert!(
14396 !dir.path().join(aged).exists(),
14397 "aged orphan must be removed"
14398 );
14399 assert!(
14400 !dir.path().join(aged_journal).exists(),
14401 "aged journal sidecar must be removed"
14402 );
14403 assert!(
14404 dir.path().join(fresh).is_file(),
14405 "fresh temporary must survive"
14406 );
14407 }
14408
14409 #[test]
14410 fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
14411 let storage = tempdir().unwrap();
14412 let storage_root = storage.path();
14413 let legacy_dir = storage_root.join("opencode").join("callgraph");
14419 fs::create_dir_all(&legacy_dir).unwrap();
14420 let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
14421 write_build_temp_with_age(
14422 &legacy_dir,
14423 orphan,
14424 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
14425 );
14426 assert!(
14427 !legacy_dir.join("deadbeef.current").exists(),
14428 "the dead root has no current pointer"
14429 );
14430
14431 let root_keyed_dir = storage_root.join("callgraph").join("livekey");
14432 fs::create_dir_all(&root_keyed_dir).unwrap();
14433
14434 sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
14435
14436 assert!(
14437 !legacy_dir.join(orphan).exists(),
14438 "legacy orphan must be reclaimed by the store-wide sweep"
14439 );
14440 }
14441
14442 #[test]
14443 fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
14444 let dir = tempdir().unwrap();
14450 let fresh = "project.g300.1.sqlite.tmp.1.400";
14451 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
14452
14453 sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
14454
14455 assert!(
14456 !dir.path().join(fresh).exists(),
14457 "with the age predicate forced open, the fresh temporary is removed"
14458 );
14459 }
14460
14461 #[test]
14462 fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
14463 let dir = tempdir().unwrap();
14464 let generation = write_generation_with_age(
14468 dir.path(),
14469 "project",
14470 400,
14471 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
14472 );
14473 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
14474
14475 sweep_orphaned_build_temps(dir.path());
14476
14477 assert!(
14478 dir.path().join(&generation).is_file(),
14479 "completed generation must survive the orphan sweep"
14480 );
14481 assert!(
14482 crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
14483 "read marker must survive the orphan sweep"
14484 );
14485 }
14486
14487 #[test]
14488 fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
14489 let dir = tempfile::tempdir().unwrap();
14490 let project_key = "project".to_string();
14491 let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
14492 let sqlite_path = dir.path().join(&generation);
14493 fs::remove_file(&sqlite_path).unwrap();
14494 let conn = Connection::open(&sqlite_path).unwrap();
14495 let store = CallGraphStore::from_connection(
14496 dir.path().to_path_buf(),
14497 project_key,
14498 sqlite_path,
14499 dir.path().to_path_buf(),
14500 false,
14501 Some(generation.clone()),
14502 None,
14503 None,
14504 conn,
14505 );
14506
14507 let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
14508 assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
14509
14510 drop(marker);
14511 assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
14512 }
14513
14514 #[test]
14515 fn readiness_cache_only_skips_checks_after_a_successful_validation() {
14516 let dir = tempdir().expect("temp dir");
14517 let file = dir.path().join("main.ts");
14518 fs::write(&file, "export function main() {}\n").expect("write fixture");
14519 let store = CallGraphStore::open(
14520 dir.path().join(".store-readiness-cache"),
14521 dir.path().to_path_buf(),
14522 )
14523 .expect("open store");
14524 {
14525 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14526 conn.trace(Some(count_caller_traversal_selects));
14527 }
14528
14529 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
14530 assert!(store.indexed_file_count().is_err());
14531 assert!(store.indexed_file_count().is_err());
14532 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
14533
14534 store
14535 .cold_build(std::slice::from_ref(&file))
14536 .expect("cold build");
14537 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
14538 assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
14539 assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
14540 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
14541
14542 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14543 conn.trace(None);
14544 }
14545
14546 #[test]
14547 fn direct_caller_frontier_chunks_sqlite_selects() {
14548 let dir = tempdir().expect("temp dir");
14549 let file = dir.path().join("main.ts");
14550 fs::write(
14551 &file,
14552 "export function caller() { target(); }\nexport function target() {}\n",
14553 )
14554 .expect("write fixture");
14555 let store = CallGraphStore::open(
14556 dir.path().join(".store-caller-frontier-query"),
14557 dir.path().to_path_buf(),
14558 )
14559 .expect("open store");
14560 store
14561 .cold_build(std::slice::from_ref(&file))
14562 .expect("cold build");
14563 let mut targets = vec![("main.ts".to_string(), "target".to_string())];
14564 targets.extend((1..1_000).map(|index| ("main.ts".to_string(), format!("missing{index}"))));
14565
14566 CALLER_QUERY_SELECTS.with(|count| count.set(0));
14567 BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
14568 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
14569 {
14570 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14571 conn.trace(Some(count_caller_traversal_selects));
14572 }
14573 let callers = store
14574 .direct_callers_for_symbols(&targets)
14575 .expect("batched callers");
14576 {
14577 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14578 conn.trace(None);
14579 }
14580
14581 assert_eq!(callers.len(), 1_000);
14582 assert_eq!(callers.get(&targets[0]).unwrap().len(), 1);
14583 assert_eq!(CALLER_QUERY_SELECTS.with(Cell::get), 3);
14584 assert_eq!(BOUNDARY_COUNT_SELECTS.with(Cell::get), 0);
14585 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
14586 }
14587
14588 #[test]
14589 fn callers_depth_boundary_batches_sqlite_counts() {
14590 const CALLER_COUNT: usize = 1_000;
14591
14592 let dir = tempdir().expect("temp dir");
14593 let file = dir.path().join("main.ts");
14594 let mut source = String::from("export function sharedHotHelper() {}\n");
14595 for index in 0..CALLER_COUNT {
14596 source.push_str(&format!(
14597 "export function caller{index}() {{ sharedHotHelper(); }}\n"
14598 ));
14599 }
14600 fs::write(&file, source).expect("write fixture");
14601
14602 let store = CallGraphStore::open(
14603 dir.path().join(".store-callers-query-fanout"),
14604 dir.path().to_path_buf(),
14605 )
14606 .expect("open store");
14607 store
14608 .cold_build(std::slice::from_ref(&file))
14609 .expect("cold build");
14610
14611 CALLER_QUERY_SELECTS.with(|count| count.set(0));
14612 BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
14613 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
14614 {
14615 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14616 conn.trace(Some(count_caller_traversal_selects));
14617 }
14618
14619 let started = Instant::now();
14620 let result = crate::commands::callgraph_store_adapter::callers_result(
14621 &store,
14622 Path::new("main.ts"),
14623 "sharedHotHelper",
14624 1,
14625 true,
14626 )
14627 .expect("callers result");
14628 let elapsed = started.elapsed();
14629
14630 {
14631 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14632 conn.trace(None);
14633 }
14634 let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
14635 let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
14636 let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
14637 eprintln!(
14638 "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
14639 result.total_callers,
14640 caller_queries,
14641 boundary_queries,
14642 total_selects,
14643 elapsed.as_secs_f64() * 1_000.0
14644 );
14645
14646 assert_eq!(result.total_callers, CALLER_COUNT);
14647 assert_eq!(caller_queries, 1);
14648 assert_eq!(boundary_queries, 3);
14649 assert_eq!(total_selects, 9);
14650 }
14651
14652 #[test]
14653 fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
14654 let dir = tempdir().expect("temp dir");
14655 let file = dir.path().join("main.ts");
14656 fs::write(
14657 &file,
14658 r#"export function topA() {
14659 root();
14660}
14661
14662export function topB() {
14663 root();
14664}
14665
14666export function root() {
14667 leaf();
14668 missing();
14669}
14670
14671export function leaf() {}
14672"#,
14673 )
14674 .expect("write fixture");
14675
14676 let store = CallGraphStore::open(
14677 dir.path().join(".store-depth-boundary-counts"),
14678 dir.path().to_path_buf(),
14679 )
14680 .expect("open store");
14681 store
14682 .cold_build(std::slice::from_ref(&file))
14683 .expect("cold build");
14684
14685 let root = store
14686 .node_for(Path::new("main.ts"), "root")
14687 .expect("root node");
14688 let leaf = store
14689 .node_for(Path::new("main.ts"), "leaf")
14690 .expect("leaf node");
14691
14692 let (full_forward_len, full_direct_len) = {
14693 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
14694 conn.execute(
14695 "INSERT INTO edges (
14696 edge_id, ref_id, source_node, target_node, target_file,
14697 target_symbol, kind, line, provenance
14698 ) VALUES (
14699 'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
14700 ?2, ?3, 'call', 98, ?4
14701 )",
14702 rusqlite::params![
14703 &root.node_id,
14704 &leaf.file,
14705 &leaf.symbol,
14706 PROVENANCE_TREESITTER
14707 ],
14708 )
14709 .expect("insert dangling forward edge");
14710 conn.execute(
14711 "INSERT INTO edges (
14712 edge_id, ref_id, source_node, target_node, target_file,
14713 target_symbol, kind, line, provenance
14714 ) VALUES (
14715 'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
14716 ?1, ?2, ?3, 'call', 99, ?4
14717 )",
14718 rusqlite::params![
14719 &root.node_id,
14720 &root.file,
14721 &root.symbol,
14722 PROVENANCE_TREESITTER
14723 ],
14724 )
14725 .expect("insert dangling direct-caller edge");
14726
14727 let full_forward_len = forward_calls_for_node(&conn, &root)
14728 .expect("full forward calls")
14729 .len();
14730 let counted_forward_len =
14731 forward_call_count_for_node(&conn, &root).expect("counted forward calls");
14732 assert_eq!(
14733 counted_forward_len, full_forward_len,
14734 "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
14735 );
14736
14737 let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
14738 .expect("full direct callers");
14739 let full_direct_len = full_direct.len();
14740 let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
14741 .expect("counted direct callers");
14742 assert_eq!(
14743 counted_direct_len, full_direct_len,
14744 "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
14745 );
14746
14747 let distinct_direct_len = full_direct
14748 .iter()
14749 .map(|site| {
14750 (
14751 site.caller.file.clone(),
14752 site.line,
14753 site.target_file.clone(),
14754 site.target_symbol.clone(),
14755 )
14756 })
14757 .collect::<BTreeSet<_>>()
14758 .len();
14759 let batch_counts = direct_caller_counts_for_tuples(
14760 &conn,
14761 &[
14762 (root.file.clone(), root.symbol.clone()),
14763 (root.file.clone(), root.symbol.clone()),
14764 (leaf.file.clone(), leaf.symbol.clone()),
14765 ],
14766 )
14767 .expect("batched direct-caller counts");
14768 assert_eq!(batch_counts.len(), 2);
14769 assert_eq!(
14770 batch_counts.get(&(root.file.clone(), root.symbol.clone())),
14771 Some(&distinct_direct_len)
14772 );
14773
14774 (full_forward_len, full_direct_len)
14775 };
14776
14777 assert_eq!(
14778 full_forward_len, 2,
14779 "fixture root should have one resolved and one unresolved outgoing call"
14780 );
14781 assert_eq!(
14782 full_direct_len, 2,
14783 "fixture root should have two real direct callers"
14784 );
14785
14786 let tree = store
14787 .call_tree(Path::new("main.ts"), "root", 0)
14788 .expect("call tree");
14789 assert!(tree.depth_limited);
14790 assert_eq!(tree.children.len(), 0);
14791 assert_eq!(
14792 tree.truncated, full_forward_len,
14793 "call_tree depth boundary must report the full forward-call list length"
14794 );
14795
14796 let callers = store
14797 .callers_of(Path::new("main.ts"), "leaf", 0)
14798 .expect("callers");
14799 assert!(callers.depth_limited);
14800 assert_eq!(callers.callers.len(), 1);
14801 assert_eq!(callers.callers[0].caller.symbol, "root");
14802 assert_eq!(
14803 callers.truncated, full_direct_len,
14804 "callers depth boundary must report the full direct-caller list length"
14805 );
14806 }
14807
14808 #[test]
14809 fn source_freshness_matches_cache_collect_for_same_bytes() {
14810 let dir = tempdir().expect("temp dir");
14811 let path = dir.path().join("fixture.ts");
14812 let source = "export function main() { return helper(); }\n";
14813 fs::write(&path, source).expect("write fixture");
14814
14815 let expected = cache_freshness::collect(&path).expect("collect freshness from file");
14816 let actual =
14817 collect_source_freshness(&path, source).expect("collect freshness from source");
14818
14819 assert_eq!(actual, expected);
14820 }
14821
14822 #[test]
14823 fn superseded_cold_build_cannot_publish_after_newer_epoch() {
14824 let root = tempfile::tempdir().unwrap();
14825 let callgraph_dir = tempfile::tempdir().unwrap();
14826 let source_dir = root.path().join("src");
14827 std::fs::create_dir_all(&source_dir).unwrap();
14828 let source = source_dir.join("lib.rs");
14829 std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
14830 let files = vec![source.clone()];
14831 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
14832 let old_epoch = epoch.next();
14833 let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
14834 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
14835 let old_epoch_flag = epoch.clone();
14836 let old_dir = callgraph_dir.path().to_path_buf();
14837 let old_root = root.path().to_path_buf();
14838 let old_files = files.clone();
14839 let old = std::thread::spawn(move || {
14840 set_cold_build_before_publish_observer(Some(Arc::new(move || {
14841 reached_tx.send(()).unwrap();
14842 release_rx.recv().unwrap();
14843 })));
14844 let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
14845 CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
14846 });
14847 set_cold_build_before_publish_observer(None);
14848 result
14849 });
14850 reached_rx
14854 .recv_timeout(Duration::from_secs(30))
14855 .expect("older build did not reach its publication barrier");
14856
14857 std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
14858 let new_epoch = epoch.next();
14859 let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
14860 CallGraphStore::cold_build_with_lease(
14861 callgraph_dir.path().to_path_buf(),
14862 root.path().to_path_buf(),
14863 &files,
14864 )
14865 })
14866 .expect("newer build should publish");
14867 drop(new_store);
14868
14869 release_tx.send(()).unwrap();
14870 assert!(matches!(
14871 old.join().unwrap(),
14872 Err(CallGraphStoreError::Superseded)
14873 ));
14874
14875 let current = CallGraphStore::open_readonly(
14876 callgraph_dir.path().to_path_buf(),
14877 root.path().to_path_buf(),
14878 )
14879 .unwrap()
14880 .expect("current callgraph generation");
14881 assert_eq!(
14882 current
14883 .nodes_matching("new_generation_marker")
14884 .unwrap()
14885 .len(),
14886 1
14887 );
14888 assert!(current
14889 .nodes_matching("old_generation_marker")
14890 .unwrap()
14891 .is_empty());
14892 }
14893
14894 #[test]
14895 fn cold_build_prepared_bulk_insert_matches_reference_rows() {
14896 let dir = tempdir().expect("temp dir");
14897 let project_root = dir.path();
14898 let extract = fixture_extract(project_root);
14899 let resolved = fixture_resolved(&extract);
14900
14901 let reference = build_reference_connection(project_root, &extract, &resolved);
14902 let optimized = build_optimized_connection(project_root, &extract, &resolved);
14903
14904 for table in [
14905 "files",
14906 "nodes",
14907 "file_dependencies",
14908 "dispatch_hints",
14909 "refs",
14910 "edges",
14911 ] {
14912 let excluded: &[&str] = if table == "files" {
14919 &["indexed_at"]
14920 } else {
14921 &[]
14922 };
14923 assert_eq!(
14924 table_rows_without(&reference, table, excluded),
14925 table_rows_without(&optimized, table, excluded),
14926 "table `{table}` rows must match apart from wall-clock columns"
14927 );
14928 }
14929 assert_eq!(
14930 backend_state_rows(&reference),
14931 backend_state_rows(&optimized),
14932 "backend freshness rows must match apart from updated_at"
14933 );
14934 assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
14935 }
14936
14937 #[test]
14938 fn cold_build_chunked_matches_unchunked_logical_rows() {
14939 let dir = tempdir().expect("temp dir");
14940 let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
14941 write_chunked_equivalence_fixture(&project_root);
14942 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
14943 assert!(
14944 files.len() > 6,
14945 "fixture should be large enough to split into multiple chunks"
14946 );
14947
14948 let unchunked = CallGraphStore::open(
14949 project_root.join(".store-unchunked"),
14950 project_root.to_path_buf(),
14951 )
14952 .expect("open unchunked store");
14953 let unchunked_stats = unchunked
14954 .cold_build_chunked(&files, 0)
14955 .expect("unchunked cold build");
14956
14957 let chunked = CallGraphStore::open(
14958 project_root.join(".store-chunked"),
14959 project_root.to_path_buf(),
14960 )
14961 .expect("open chunked store");
14962 let chunked_stats = chunked
14963 .cold_build_chunked(&files, 3)
14964 .expect("chunked cold build");
14965
14966 assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
14967 assert_eq!(
14968 unchunked.edge_snapshot().expect("unchunked edge snapshot"),
14969 chunked.edge_snapshot().expect("chunked edge snapshot"),
14970 "public edge snapshots must match"
14971 );
14972
14973 let dispatch_edges = {
14974 let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
14975 conn.query_row(
14976 "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
14977 [],
14978 |row| row.get::<_, i64>(0),
14979 )
14980 .expect("count dispatch edges")
14981 };
14982 assert!(
14983 dispatch_edges > 0,
14984 "fixture must exercise method-dispatch edge insertion"
14985 );
14986
14987 for table in [
14988 "edges",
14989 "refs",
14990 "nodes",
14991 "file_dependencies",
14992 "dispatch_hints",
14993 ] {
14994 assert_eq!(
14995 graph_table_rows(&unchunked, table),
14996 graph_table_rows(&chunked, table),
14997 "chunked cold build must match unchunked rows for {table}"
14998 );
14999 }
15000 assert_eq!(
15001 graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
15002 graph_table_rows_without(&chunked, "files", &["indexed_at"]),
15003 "files rows must match apart from indexed_at"
15004 );
15005 assert_eq!(
15006 graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
15007 graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
15008 "backend freshness rows must match apart from updated_at"
15009 );
15010
15011 let published_dir = project_root.join(".store-published");
15012 let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
15013 published_dir.clone(),
15014 project_root.to_path_buf(),
15015 &files,
15016 0,
15017 )
15018 .expect("published unchunked cold build");
15019 assert!(
15020 !CallGraphStore::needs_cold_build(&published_dir, &project_root)
15021 .expect("needs_cold_build after publish"),
15022 "published store should be ready"
15023 );
15024 drop(_published);
15025 let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
15026 published_dir,
15027 project_root.to_path_buf(),
15028 &files,
15029 3,
15030 )
15031 .expect("ensure with a different chunk size");
15032 assert!(
15033 rebuild_stats.is_none(),
15034 "changing callgraph_chunk_size must not affect store identity or force a rebuild"
15035 );
15036 }
15037
15038 #[test]
15045 #[ignore]
15046 fn bench_cold_build_chunk() {
15047 let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
15048 let chunk: usize = std::env::var("AFT_PERF_CHUNK")
15049 .expect("AFT_PERF_CHUNK")
15050 .parse()
15051 .expect("AFT_PERF_CHUNK must be a non-negative integer");
15052 let project_root = fs::canonicalize(&repo).expect("canonical repo root");
15053 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
15054 let dir = tempdir().expect("temp dir");
15055 let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
15056 .expect("open store");
15057 let started = Instant::now();
15058 let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
15059 let ms = started.elapsed().as_millis();
15060 println!(
15061 "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
15062 stats.files, stats.nodes, stats.refs, stats.edges
15063 );
15064 }
15065
15066 #[test]
15067 fn persisted_workspace_reexport_selects_its_package_dependency() {
15068 let root = tempdir().expect("temp dir");
15069 let dependencies = BTreeSet::from([
15070 "packages/aft-bridge/src/index.ts".to_string(),
15071 "packages/opencode-plugin/src/types.ts".to_string(),
15072 ]);
15073 let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
15074
15075 assert_eq!(
15076 stored_dependencies_for_module(
15077 root.path(),
15078 "packages/opencode-plugin/src/shared/bash-hints.ts",
15079 "@cortexkit/aft-bridge",
15080 &dependencies,
15081 &indexed_files,
15082 ),
15083 BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
15084 );
15085 }
15086
15087 #[test]
15088 fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
15089 let dir = tempdir().expect("temp dir");
15090 let project_root = dir.path();
15091 let files =
15092 write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
15093 let index_path = project_root.join("src/index.ts");
15094
15095 let store = CallGraphStore::open(
15096 project_root.join(".store-incremental-barrel"),
15097 project_root.to_path_buf(),
15098 )
15099 .expect("open incremental store");
15100 store.cold_build(&files).expect("initial cold build");
15101
15102 {
15103 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
15104 let tx = conn.transaction().expect("dependency transaction");
15105 let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
15106 .expect("dependent refs for barrel");
15107 let selected_ref_ids = dependent_refs
15108 .iter()
15109 .map(|dependent_ref| dependent_ref.ref_id.clone())
15110 .collect::<BTreeSet<_>>();
15111 let mut threaded_ref_ids = BTreeSet::new();
15112 let mut threaded_by_caller = BTreeMap::new();
15113 record_dependent_refs(
15114 &mut threaded_ref_ids,
15115 &mut threaded_by_caller,
15116 dependent_refs,
15117 );
15118 let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
15119 .expect("old per-ref caller lookup");
15120
15121 assert_eq!(threaded_ref_ids, selected_ref_ids);
15122 assert_eq!(threaded_by_caller, old_by_caller);
15123 for consumer in [
15124 "src/consumer_a.ts",
15125 "src/consumer_b.ts",
15126 "src/consumer_c.ts",
15127 ] {
15128 assert!(
15129 threaded_by_caller.contains_key(consumer),
15130 "barrel edit should select dependent refs from {consumer}"
15131 );
15132 }
15133 }
15134
15135 fs::write(
15136 &index_path,
15137 "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
15138 )
15139 .expect("edit barrel");
15140 let stats = store
15141 .refresh_files(std::slice::from_ref(&index_path))
15142 .expect("incremental refresh");
15143 assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
15144 assert!(
15145 stats.dependency_selected_refs > 0,
15146 "barrel surface edit should select dependent refs"
15147 );
15148
15149 let cold_store = CallGraphStore::open(
15150 project_root.join(".store-cold-barrel"),
15151 project_root.to_path_buf(),
15152 )
15153 .expect("open cold rebuild store");
15154 cold_store
15155 .cold_build(&files)
15156 .expect("comparison cold build");
15157
15158 for table in [
15159 "nodes",
15160 "refs",
15161 "file_dependencies",
15162 "edges",
15163 "dispatch_hints",
15164 ] {
15165 assert_eq!(
15166 graph_table_rows(&store, table),
15167 graph_table_rows(&cold_store, table),
15168 "incremental refresh {table} rows must match cold rebuild"
15169 );
15170 }
15171
15172 let consumer_path = project_root.join("src/consumer_a.ts");
15173 fs::write(
15174 &consumer_path,
15175 "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
15176 )
15177 .expect("edit barrel consumer");
15178 store
15179 .refresh_files(std::slice::from_ref(&consumer_path))
15180 .expect("refresh consumer through unchanged barrel");
15181 cold_store
15182 .cold_build(&files)
15183 .expect("comparison cold rebuild after consumer refresh");
15184 for table in [
15185 "nodes",
15186 "refs",
15187 "file_dependencies",
15188 "edges",
15189 "dispatch_hints",
15190 ] {
15191 assert_eq!(
15192 graph_table_rows(&store, table),
15193 graph_table_rows(&cold_store, table),
15194 "refresh through a persisted barrel must preserve cold-build {table} rows"
15195 );
15196 }
15197 }
15198
15199 fn build_reference_connection(
15200 project_root: &Path,
15201 extract: &FileExtract,
15202 resolved: &ResolvedRef,
15203 ) -> Connection {
15204 let mut conn = Connection::open_in_memory().expect("open reference db");
15205 configure_build_connection(&conn).expect("configure reference db");
15206 initialize_schema(&conn).expect("initialize reference schema");
15207 {
15208 let tx = conn.transaction().expect("reference transaction");
15209 clear_tables(&tx).expect("reference clear");
15210 insert_meta(&tx).expect("reference meta");
15211 insert_file_extract(&tx, project_root, extract).expect("reference file extract");
15212 insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
15213 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
15214 .expect("reference dispatch edges");
15215 assert_eq!(supplemental, 0);
15216 tx.commit().expect("reference commit");
15217 }
15218 conn
15219 }
15220
15221 fn build_optimized_connection(
15222 project_root: &Path,
15223 extract: &FileExtract,
15224 resolved: &ResolvedRef,
15225 ) -> Connection {
15226 let mut conn = Connection::open_in_memory().expect("open optimized db");
15227 configure_build_connection(&conn).expect("configure optimized db");
15228 initialize_schema(&conn).expect("initialize optimized schema");
15229 {
15230 let tx = conn.transaction().expect("optimized transaction");
15231 clear_tables(&tx).expect("optimized clear");
15232 insert_meta(&tx).expect("optimized meta");
15233 drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
15234 {
15235 let workspace_root = project_root.display().to_string();
15236 let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
15237 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
15238 .expect("optimized file extract");
15239 insert_resolved_ref_prepared(&mut inserts, resolved)
15240 .expect("optimized resolved ref");
15241 }
15242 create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
15243 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
15244 .expect("optimized dispatch edges");
15245 assert_eq!(supplemental, 0);
15246 tx.commit().expect("optimized commit");
15247 }
15248 conn
15249 }
15250
15251 fn fixture_extract(_project_root: &Path) -> FileExtract {
15252 let rel_path = "src/main.ts".to_string();
15253 let target_path = "src/helper.ts".to_string();
15254 let node = NodeRecord {
15255 id: "node-main".to_string(),
15256 file_path: rel_path.clone(),
15257 name: "main".to_string(),
15258 scoped_name: "main".to_string(),
15259 kind: "function".to_string(),
15260 range: Range {
15261 start_line: 0,
15262 start_col: 0,
15263 end_line: 0,
15264 end_col: 32,
15265 },
15266 range_ordinal: 0,
15267 signature: Some("export function main()".to_string()),
15268 exported: true,
15269 is_default_export: false,
15270 is_type_like: false,
15271 is_callgraph_entry_point: true,
15272 };
15273 let mut dependencies = BTreeSet::new();
15274 dependencies.insert(target_path.clone());
15275 let raw_ref = RawRef {
15276 ref_id: "ref-main-helper".to_string(),
15277 caller_node: Some(node.id.clone()),
15278 caller_symbol: Some(node.scoped_name.clone()),
15279 caller_file: rel_path.clone(),
15280 kind: "call".to_string(),
15281 short_name: Some("helper".to_string()),
15282 full_ref: Some("helper".to_string()),
15283 module_path: None,
15284 import_kind: None,
15285 local_name: Some("helper".to_string()),
15286 requested_name: Some("helper".to_string()),
15287 namespace_alias: None,
15288 wildcard: false,
15289 line: 1,
15290 byte_start: 24,
15291 byte_end: 32,
15292 dependencies,
15293 };
15294 FileExtract {
15295 rel_path,
15296 freshness: FileFreshness {
15297 mtime: UNIX_EPOCH + Duration::from_secs(123),
15298 size: 40,
15299 content_hash: cache_freshness::hash_bytes(b"fixture source"),
15300 },
15301 lang: LangId::TypeScript,
15302 data: FileCallData {
15303 calls_by_symbol: HashMap::new(),
15304 value_refs_by_symbol: HashMap::new(),
15305 exported_symbols: Vec::new(),
15306 symbol_metadata: HashMap::new(),
15307 default_export_symbol: None,
15308 import_block: ImportBlock::empty(),
15309 lang: LangId::TypeScript,
15310 },
15311 nodes: vec![node.clone()],
15312 raw_refs: vec![raw_ref],
15313 dispatch_hints: vec![DispatchHint {
15314 id: "dispatch-main-helper".to_string(),
15315 method_name: "helper".to_string(),
15316 caller_node: node.id,
15317 file: "src/main.ts".to_string(),
15318 line: 1,
15319 byte_start: 24,
15320 byte_end: 32,
15321 }],
15322 surface_fingerprint: "surface".to_string(),
15323 }
15324 }
15325
15326 fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
15327 let raw = extract.raw_refs[0].clone();
15328 let mut dependencies = raw.dependencies.clone();
15329 dependencies.insert("src/helper.ts".to_string());
15330 ResolvedRef {
15331 edge: Some(EdgeRecord {
15332 edge_id: "edge-main-helper".to_string(),
15333 source_node: raw.caller_node.clone().expect("caller node"),
15334 target_node: Some("node-helper".to_string()),
15335 target_file: "src/helper.ts".to_string(),
15336 target_symbol: "helper".to_string(),
15337 kind: "call".to_string(),
15338 line: raw.line,
15339 }),
15340 raw,
15341 status: "resolved".to_string(),
15342 target_node: Some("node-helper".to_string()),
15343 target_file: Some("src/helper.ts".to_string()),
15344 target_symbol: Some("helper".to_string()),
15345 dependencies,
15346 }
15347 }
15348
15349 fn write_chunked_equivalence_fixture(project_root: &Path) {
15350 let ts_dir = project_root.join("ts");
15351 fs::create_dir_all(&ts_dir).expect("create ts dir");
15352 fs::write(
15353 ts_dir.join("leaf.ts"),
15354 "export function leaf(value: number) {\n return value + 1;\n}\n",
15355 )
15356 .expect("write ts leaf");
15357 fs::write(
15358 ts_dir.join("mid.ts"),
15359 "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n return leaf(value);\n}\n",
15360 )
15361 .expect("write ts mid");
15362 fs::write(
15363 ts_dir.join("entry.ts"),
15364 "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n return mid(worker.run());\n}\n",
15365 )
15366 .expect("write ts entry");
15367 fs::write(
15368 ts_dir.join("worker.ts"),
15369 "export class Worker {\n run() {\n return 41;\n }\n}\n",
15370 )
15371 .expect("write ts worker");
15372 for idx in 0..4 {
15373 fs::write(
15374 ts_dir.join(format!("extra_{idx}.ts")),
15375 format!(
15376 "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n return entry(new Worker());\n}}\n"
15377 ),
15378 )
15379 .expect("write ts extra");
15380 }
15381
15382 let rust_dir = project_root.join("src");
15383 let commands_dir = rust_dir.join("commands");
15384 fs::create_dir_all(&commands_dir).expect("create rust commands dir");
15385 fs::write(
15386 rust_dir.join("context.rs"),
15387 r#"pub struct AppContext;
15388
15389impl AppContext {
15390 pub fn callgraph_store_for_ops(&self) -> usize {
15391 1
15392 }
15393}
15394"#,
15395 )
15396 .expect("write rust context");
15397 fs::write(
15398 rust_dir.join("lib.rs"),
15399 "pub mod context;\npub mod commands;\n",
15400 )
15401 .expect("write rust lib");
15402 fs::write(
15403 commands_dir.join("mod.rs"),
15404 "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
15405 )
15406 .expect("write rust commands mod");
15407 for name in ["callers", "impact", "trace_to"] {
15408 fs::write(
15409 commands_dir.join(format!("{name}.rs")),
15410 format!(
15411 r#"use crate::context::AppContext;
15412
15413pub fn handle_{name}(ctx: &AppContext) -> usize {{
15414 ctx.callgraph_store_for_ops()
15415}}
15416"#
15417 ),
15418 )
15419 .expect("write rust command");
15420 }
15421 }
15422
15423 fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
15424 let src_dir = project_root.join("src");
15425 fs::create_dir_all(&src_dir).expect("create src dir");
15426
15427 let target_path = src_dir.join("target.ts");
15428 fs::write(&target_path, "export function target() {\n return 1;\n}\n")
15429 .expect("write target");
15430
15431 let index_path = src_dir.join("index.ts");
15432 fs::write(&index_path, barrel_source).expect("write barrel");
15433
15434 let mut files = vec![target_path, index_path];
15435 for (file_name, function_name) in [
15436 ("consumer_a.ts", "consumerA"),
15437 ("consumer_b.ts", "consumerB"),
15438 ("consumer_c.ts", "consumerC"),
15439 ] {
15440 let path = src_dir.join(file_name);
15441 fs::write(
15442 &path,
15443 format!(
15444 "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n return target();\n}}\n"
15445 ),
15446 )
15447 .expect("write consumer");
15448 files.push(path);
15449 }
15450 files
15451 }
15452
15453 fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
15454 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
15455 table_rows(&conn, table)
15456 }
15457
15458 fn graph_table_rows_without(
15459 store: &CallGraphStore,
15460 table: &str,
15461 excluded_columns: &[&str],
15462 ) -> Vec<String> {
15463 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
15464 table_rows_without(&conn, table, excluded_columns)
15465 }
15466
15467 fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
15468 table_rows_without(conn, table, &[])
15469 }
15470
15471 fn table_rows_without(
15472 conn: &Connection,
15473 table: &str,
15474 excluded_columns: &[&str],
15475 ) -> Vec<String> {
15476 let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
15477 let columns: Vec<String> = conn
15478 .prepare(&format!("PRAGMA table_info({table})"))
15479 .expect("prepare table_info")
15480 .query_map([], |row| row.get::<_, String>(1))
15481 .expect("query table_info")
15482 .collect::<std::result::Result<Vec<String>, _>>()
15483 .expect("collect columns")
15484 .into_iter()
15485 .filter(|column| !excluded_columns.contains(column.as_str()))
15486 .collect();
15487 let sql = format!(
15488 "SELECT {} FROM {table} ORDER BY {}",
15489 columns.join(", "),
15490 columns.join(", ")
15491 );
15492 conn.prepare(&sql)
15493 .expect("prepare table rows")
15494 .query_map([], |row| row_to_strings(row, columns.len()))
15495 .expect("query table rows")
15496 .collect::<std::result::Result<_, _>>()
15497 .expect("collect table rows")
15498 }
15499
15500 fn assert_cold_build_stats_match_except_elapsed(
15501 expected: &ColdBuildStats,
15502 actual: &ColdBuildStats,
15503 ) {
15504 assert_eq!(actual.files, expected.files, "file counts must match");
15505 assert_eq!(actual.nodes, expected.nodes, "node counts must match");
15506 assert_eq!(actual.refs, expected.refs, "ref counts must match");
15507 assert_eq!(actual.edges, expected.edges, "edge counts must match");
15508 assert_eq!(
15509 actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
15510 expected
15511 .failed_files
15512 .iter()
15513 .cloned()
15514 .collect::<BTreeSet<_>>(),
15515 "failed file sets must match"
15516 );
15517 }
15518
15519 fn backend_state_rows(conn: &Connection) -> Vec<String> {
15520 conn.prepare(
15521 "SELECT backend, workspace_root, file_path, content_hash, status
15522 FROM backend_file_state
15523 ORDER BY backend, workspace_root, file_path, content_hash, status",
15524 )
15525 .expect("prepare backend rows")
15526 .query_map([], |row| row_to_strings(row, 5))
15527 .expect("query backend rows")
15528 .collect::<std::result::Result<_, _>>()
15529 .expect("collect backend rows")
15530 }
15531
15532 fn secondary_indexes(conn: &Connection) -> Vec<String> {
15533 let mut indexes = Vec::new();
15534 for table in [
15535 "files",
15536 "nodes",
15537 "refs",
15538 "file_dependencies",
15539 "edges",
15540 "dispatch_hints",
15541 "type_ref_names",
15542 "backend_file_state",
15543 "meta",
15544 ] {
15545 let sql = format!("PRAGMA index_list({table})");
15546 let mut stmt = conn.prepare(&sql).expect("prepare index list");
15547 let rows = stmt
15548 .query_map([], |row| row.get::<_, String>(1))
15549 .expect("query index list");
15550 for name in rows {
15551 let name = name.expect("index name");
15552 if name.starts_with("idx_") {
15553 indexes.push(format!("{table}:{name}"));
15554 }
15555 }
15556 }
15557 indexes.sort();
15558 indexes
15559 }
15560
15561 fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
15562 let mut values = Vec::with_capacity(len);
15563 for index in 0..len {
15564 let value = row.get_ref(index)?;
15565 values.push(match value {
15566 rusqlite::types::ValueRef::Null => "NULL".to_string(),
15567 rusqlite::types::ValueRef::Integer(value) => value.to_string(),
15568 rusqlite::types::ValueRef::Real(value) => value.to_string(),
15569 rusqlite::types::ValueRef::Text(value) => {
15570 String::from_utf8_lossy(value).into_owned()
15571 }
15572 rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
15573 });
15574 }
15575 Ok(values.join("\u{1f}"))
15576 }
15577}
15578
15579#[cfg(test)]
15580mod rust_resolution_tests {
15581 use super::*;
15582 use crate::inspect::job::CallgraphSnapshot;
15583 use std::fs;
15584 use tempfile::tempdir;
15585
15586 #[test]
15587 fn rust_function_scoped_module_alias_resolves_and_projects_live() {
15588 let dir = tempdir().expect("tempdir");
15589 let root = dir.path();
15590 write_rust_manifest(root, "scoped-alias-fixture");
15591 write_file(
15592 root,
15593 "src/lib.rs",
15594 r#"pub mod finalization_contract;
15595
15596pub fn run_alias() {
15597 use crate::finalization_contract as fc;
15598 fc::check_mason_contract();
15599}
15600"#,
15601 );
15602 write_file(
15603 root,
15604 "src/finalization_contract.rs",
15605 r#"pub fn check_mason_contract() {}
15606fn planted_dead() {}
15607"#,
15608 );
15609
15610 let (store, snapshot) = cold_build_twice(root);
15611 assert_direct_caller(
15612 &store,
15613 "src/finalization_contract.rs",
15614 "check_mason_contract",
15615 "src/lib.rs",
15616 "run_alias",
15617 );
15618 assert_projected_call(
15619 root,
15620 &snapshot,
15621 "src/finalization_contract.rs",
15622 "check_mason_contract",
15623 );
15624 assert_no_projected_call(
15625 root,
15626 &snapshot,
15627 "src/finalization_contract.rs",
15628 "planted_dead",
15629 );
15630 assert!(
15631 store
15632 .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
15633 .expect("planted dead callers")
15634 .is_empty(),
15635 "planted-dead guard should stay without callers"
15636 );
15637 }
15638
15639 #[test]
15640 fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
15641 let dir = tempdir().expect("tempdir");
15642 let root = dir.path();
15643 write_rust_manifest(root, "inline-module-fixture");
15644 write_file(
15645 root,
15646 "src/lib.rs",
15647 r#"mod work_graph { fn operations() {} }
15648mod manifest { fn operations() {} }
15649mod audit { fn operations() {} }
15650mod dispatch { fn operations() {} }
15651mod finalization { fn operations() {} }
15652
15653pub fn run_inline_operations() {
15654 work_graph::operations();
15655 manifest::operations();
15656 audit::operations();
15657 dispatch::operations();
15658 finalization::operations();
15659}
15660
15661fn planted_dead() {}
15662"#,
15663 );
15664
15665 let (store, snapshot) = cold_build_twice(root);
15666 for module in [
15667 "work_graph",
15668 "manifest",
15669 "audit",
15670 "dispatch",
15671 "finalization",
15672 ] {
15673 assert_direct_caller(
15674 &store,
15675 "src/lib.rs",
15676 &format!("{module}::operations"),
15677 "src/lib.rs",
15678 "run_inline_operations",
15679 );
15680 }
15681 assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
15682 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
15683 }
15684
15685 #[test]
15686 fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
15687 let dir = tempdir().expect("tempdir");
15688 let root = dir.path();
15689 fs::write(
15690 root.join("Cargo.toml"),
15691 "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
15692 )
15693 .expect("write workspace manifest");
15694 write_file(
15695 root,
15696 "crates/but-action/Cargo.toml",
15697 r#"[package]
15698name = "but-action"
15699version = "0.1.0"
15700edition = "2021"
15701"#,
15702 );
15703 write_file(
15704 root,
15705 "crates/but-action/src/lib.rs",
15706 "mod action;\npub use action::{list_actions};\n",
15707 );
15708 write_file(
15709 root,
15710 "crates/but-action/src/action.rs",
15711 "pub fn list_actions() {}\nfn planted_dead() {}\n",
15712 );
15713 write_file(
15714 root,
15715 "crates/app/Cargo.toml",
15716 r#"[package]
15717name = "app"
15718version = "0.1.0"
15719edition = "2021"
15720"#,
15721 );
15722 write_file(
15723 root,
15724 "crates/app/src/lib.rs",
15725 "pub fn run_actions() {\n but_action::list_actions();\n}\n",
15726 );
15727
15728 let (store, snapshot) = cold_build_twice(root);
15729 assert_direct_caller(
15730 &store,
15731 "crates/but-action/src/action.rs",
15732 "list_actions",
15733 "crates/app/src/lib.rs",
15734 "run_actions",
15735 );
15736 assert!(
15737 store
15738 .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
15739 .expect("lib reexport callers")
15740 .is_empty(),
15741 "call should target the reexported source function, not lib.rs"
15742 );
15743 assert_projected_call(
15744 root,
15745 &snapshot,
15746 "crates/but-action/src/action.rs",
15747 "list_actions",
15748 );
15749 assert_no_projected_call(
15750 root,
15751 &snapshot,
15752 "crates/but-action/src/action.rs",
15753 "planted_dead",
15754 );
15755 }
15756
15757 #[test]
15758 fn rust_generic_self_turbofish_method_dispatch_resolves() {
15759 let dir = tempdir().expect("tempdir");
15760 let root = dir.path();
15761 write_rust_manifest(root, "generic-self-fixture");
15762 write_file(
15763 root,
15764 "src/lib.rs",
15765 r#"pub struct Matcher;
15766
15767impl Matcher {
15768 pub fn run(&self) -> bool {
15769 self.fuzzy_match_optimal::<usize>("needle")
15770 }
15771
15772 fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
15773 let _ = std::marker::PhantomData::<T>;
15774 true
15775 }
15776
15777 fn planted_dead(&self) {}
15778}
15779
15780pub fn entry() -> bool {
15781 let matcher = Matcher;
15782 matcher.run()
15783}
15784"#,
15785 );
15786
15787 let (store, snapshot) = cold_build_twice(root);
15788 assert_direct_caller(
15789 &store,
15790 "src/lib.rs",
15791 "Matcher::fuzzy_match_optimal",
15792 "src/lib.rs",
15793 "Matcher::run",
15794 );
15795 assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
15796 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
15797 }
15798
15799 #[test]
15800 fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
15801 let dir = tempdir().expect("tempdir");
15802 let root = dir.path();
15803 write_rust_manifest(root, "manifest-operations-fixture");
15804 write_file(
15805 root,
15806 "src/main.rs",
15807 r#"mod dispatch;
15808use dispatch::{manifest_operations};
15809
15810fn main() {
15811 manifest_operations();
15812}
15813"#,
15814 );
15815 write_file(
15816 root,
15817 "src/dispatch.rs",
15818 r#"mod work_graph { fn operations() {} }
15819mod manifest { fn operations() {} }
15820mod audit { fn operations() {} }
15821mod descriptor { fn operations() {} }
15822mod writer { fn operations() {} }
15823
15824pub fn manifest_operations() {
15825 manifest::operations();
15826}
15827
15828pub fn work_graph_operations() {
15829 work_graph::operations();
15830}
15831
15832pub fn audit_operations() {
15833 audit::operations();
15834}
15835
15836pub fn descriptor_operations() {
15837 descriptor::operations();
15838}
15839
15840pub fn writer_operations() {
15841 writer::operations();
15842}
15843
15844fn planted_dead() {}
15845"#,
15846 );
15847
15848 let (store, snapshot) = cold_build_twice(root);
15849 assert_direct_caller(
15850 &store,
15851 "src/dispatch.rs",
15852 "manifest_operations",
15853 "src/main.rs",
15854 "main",
15855 );
15856 assert_direct_caller(
15857 &store,
15858 "src/dispatch.rs",
15859 "manifest::operations",
15860 "src/dispatch.rs",
15861 "manifest_operations",
15862 );
15863 assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
15864 assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
15865 assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
15866 }
15867
15868 fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
15869 let files = rust_files(root);
15870 let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
15871 .expect("open first store");
15872 first.cold_build(&files).expect("first cold build");
15873 let first_snapshot =
15874 project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
15875
15876 let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
15877 .expect("open second store");
15878 second.cold_build(&files).expect("second cold build");
15879 let second_snapshot =
15880 project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
15881
15882 assert_eq!(
15883 projection_rows(&first_snapshot),
15884 projection_rows(&second_snapshot),
15885 "cold-build projection should be deterministic"
15886 );
15887 (first, first_snapshot)
15888 }
15889
15890 fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
15891 let mut rows = Vec::new();
15892 for export in &snapshot.exported_symbols {
15893 rows.push(format!(
15894 "export\t{}\t{}\t{}\t{}",
15895 export.file.display(),
15896 export.symbol,
15897 export.kind,
15898 export.line
15899 ));
15900 }
15901 for call in &snapshot.outbound_calls {
15902 rows.push(format!(
15903 "call\t{}\t{}\t{}\t{}\t{}",
15904 call.caller_file.display(),
15905 call.caller_symbol,
15906 call.target,
15907 call.line,
15908 call.provenance
15909 ));
15910 }
15911 for file in &snapshot.entry_points {
15912 rows.push(format!("entry_file\t{}", file.display()));
15913 }
15914 for (file, symbols) in &snapshot.entry_point_symbols {
15915 for symbol in symbols {
15916 rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
15917 }
15918 }
15919 rows.sort();
15920 rows
15921 }
15922
15923 fn assert_direct_caller(
15924 store: &CallGraphStore,
15925 target_rel: &str,
15926 target_symbol: &str,
15927 caller_rel: &str,
15928 caller_symbol: &str,
15929 ) {
15930 let callers = store
15931 .direct_callers_of(Path::new(target_rel), target_symbol)
15932 .unwrap_or_else(|error| {
15933 panic!("direct callers for {target_rel}::{target_symbol}: {error}")
15934 });
15935 assert!(
15936 callers.iter().any(|site| {
15937 site.caller.file == caller_rel && site.caller.symbol == caller_symbol
15938 }),
15939 "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
15940 );
15941 }
15942
15943 fn assert_projected_call(
15944 root: &Path,
15945 snapshot: &CallgraphSnapshot,
15946 target_rel: &str,
15947 symbol: &str,
15948 ) {
15949 let target = projected_target(root, target_rel, symbol);
15950 assert!(
15951 snapshot.outbound_calls.iter().any(|call| {
15952 call.target == target
15953 || call.target.starts_with(&format!(
15954 "{target}{}",
15955 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
15956 ))
15957 }),
15958 "expected projected call to {target}; calls: {:#?}",
15959 snapshot.outbound_calls
15960 );
15961 }
15962
15963 fn assert_no_projected_call(
15964 root: &Path,
15965 snapshot: &CallgraphSnapshot,
15966 target_rel: &str,
15967 symbol: &str,
15968 ) {
15969 let target = projected_target(root, target_rel, symbol);
15970 assert!(
15971 snapshot.outbound_calls.iter().all(|call| {
15972 call.target != target
15973 && !call.target.starts_with(&format!(
15974 "{target}{}",
15975 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
15976 ))
15977 }),
15978 "did not expect projected call to {target}; calls: {:#?}",
15979 snapshot.outbound_calls
15980 );
15981 }
15982
15983 fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
15984 let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
15987 format!("{}::{symbol}", path.display())
15988 }
15989
15990 fn write_rust_manifest(root: &Path, name: &str) {
15991 write_file(
15992 root,
15993 "Cargo.toml",
15994 &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
15995 );
15996 }
15997
15998 fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
15999 let path = root.join(rel_path);
16000 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
16001 fs::write(&path, source).expect("write fixture file");
16002 path
16003 }
16004
16005 fn rust_files(root: &Path) -> Vec<PathBuf> {
16006 let mut files = Vec::new();
16007 collect_rust_files(root, &mut files);
16008 files.sort();
16009 files
16010 }
16011
16012 fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
16013 for entry in fs::read_dir(dir).expect("read fixture dir") {
16014 let entry = entry.expect("read fixture entry");
16015 let path = entry.path();
16016 if path.is_dir() {
16017 let name = path
16018 .file_name()
16019 .and_then(|name| name.to_str())
16020 .unwrap_or("");
16021 if !name.starts_with(".store") {
16022 collect_rust_files(&path, files);
16023 }
16024 } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
16025 files.push(path);
16026 }
16027 }
16028 }
16029}
16030
16031#[cfg(test)]
16032mod build_pool_tests {
16033 use super::build_pool_size;
16034
16035 #[test]
16036 fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
16037 let size = build_pool_size();
16038 assert!(size >= 1, "pool size must be at least 1");
16041 assert!(size <= 8, "pool size must be capped at 8, got {size}");
16042
16043 let cores = std::thread::available_parallelism()
16044 .map(|p| p.get())
16045 .unwrap_or(1);
16046 let expected = cores.div_ceil(2).clamp(1, 8);
16047 assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
16048 }
16049}
16050
16051#[cfg(test)]
16052mod reexport_resolution_tests {
16053 use super::*;
16054
16055 fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
16056 ProjectIndex {
16057 project_root: PathBuf::from("/fixture"),
16058 files: files.into_iter().collect(),
16059 caller_data: HashMap::new(),
16060 workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
16061 }
16062 }
16063
16064 fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
16065 DbFileIndex {
16066 lang: None,
16067 exports: HashSet::new(),
16068 default_export: None,
16069 export_aliases: HashMap::new(),
16070 node_by_scoped: HashMap::new(),
16071 node_by_bare: HashMap::new(),
16072 node_kind_by_id: HashMap::new(),
16073 module_targets: HashMap::new(),
16074 reexports: reexport_targets
16075 .iter()
16076 .map(|target| ReexportIndex {
16077 target_file: Some((*target).to_string()),
16078 named: HashMap::new(),
16079 wildcard: true,
16080 })
16081 .collect(),
16082 }
16083 }
16084
16085 #[test]
16092 fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
16093 let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
16094 let files = names
16095 .iter()
16096 .map(|name| {
16097 let targets: Vec<&str> = names
16098 .iter()
16099 .filter(|other| *other != name)
16100 .map(String::as_str)
16101 .collect();
16102 (name.clone(), barrel_file(&targets))
16103 })
16104 .collect();
16105 let index = barrel_index(files);
16106
16107 assert_eq!(
16108 resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
16109 None
16110 );
16111 }
16112
16113 #[test]
16119 fn shallow_revisit_after_deep_capped_visit_still_resolves() {
16120 let mut leaf = barrel_file(&[]);
16121 leaf.exports.insert("deep_symbol".to_string());
16122 let mut files: Vec<(String, DbFileIndex)> = Vec::new();
16123 files.push((
16126 "src/entry.ts".to_string(),
16127 barrel_file(&["src/chain0.ts", "src/shared.ts"]),
16128 ));
16129 for i in 0..15 {
16130 let next = if i == 14 {
16131 "src/shared.ts".to_string()
16132 } else {
16133 format!("src/chain{}.ts", i + 1)
16134 };
16135 files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
16136 }
16137 files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
16138 files.push(("src/leaf.ts".to_string(), leaf));
16139 let index = barrel_index(files);
16140
16141 assert_eq!(
16142 resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
16143 Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
16144 "a shallower re-visit must not be pruned by a deeper capped visit"
16145 );
16146 }
16147
16148 #[test]
16149 fn symbol_reachable_through_reexport_cycle_still_resolves() {
16150 let mut leaf = barrel_file(&[]);
16151 leaf.exports.insert("real_symbol".to_string());
16152 let index = barrel_index(vec![
16153 (
16154 "src/a.ts".to_string(),
16155 barrel_file(&["src/b.ts", "src/a.ts"]),
16156 ),
16157 (
16158 "src/b.ts".to_string(),
16159 barrel_file(&["src/a.ts", "src/leaf.ts"]),
16160 ),
16161 ("src/leaf.ts".to_string(), leaf),
16162 ]);
16163
16164 assert_eq!(
16165 resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
16166 Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
16167 );
16168 }
16169}
16170
16171#[cfg(test)]
16172mod method_dispatch_inference_tests {
16173 use super::*;
16174 use std::fs;
16175 use tempfile::tempdir;
16176
16177 #[test]
16178 fn java_field_receiver_type_selects_declared_class_method() {
16179 let source = r#"class EntryPoint {
16180 private UserService userService;
16181
16182 void handle() {
16183 userService.find();
16184 }
16185}
16186
16187class UserService {
16188 void find() {}
16189}
16190
16191class AuditService {
16192 void find() {}
16193}
16194"#;
16195 let dir = tempdir().expect("temp dir");
16196 let root = dir.path();
16197 write_fixture(root, "src/EntryPoint.java", source);
16198 let reference = reference(
16199 "java",
16200 "src/EntryPoint.java",
16201 "EntryPoint::handle",
16202 "userService",
16203 "find",
16204 line_of(source, "userService.find()"),
16205 );
16206 let mut cache = DispatchSourceCache::new();
16207
16208 let receiver_type =
16209 infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
16210 assert_eq!(receiver_type, "UserService");
16211
16212 let candidates = vec![
16213 method_candidate("audit", "AuditService::find"),
16214 method_candidate("user", "UserService::find"),
16215 ];
16216 let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
16217 .expect("type candidate");
16218 assert_eq!(selected.scoped_name, "UserService::find");
16219
16220 let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
16221 assert!(
16222 select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
16223 );
16224 }
16225
16226 #[test]
16227 fn kotlin_property_and_local_value_types_are_inferred() {
16228 let source = r#"class Handler {
16229 private val auditService: AuditService = AuditService()
16230
16231 fun handle() {
16232 auditService.find()
16233 val userService: UserService = UserService()
16234 userService.find()
16235 val billingService = BillingService()
16236 billingService.find()
16237 }
16238}
16239
16240class UserService { fun find() {} }
16241class AuditService { fun find() {} }
16242class BillingService { fun find() {} }
16243"#;
16244 let dir = tempdir().expect("temp dir");
16245 let root = dir.path();
16246 write_fixture(root, "src/Handler.kt", source);
16247 let mut cache = DispatchSourceCache::new();
16248
16249 let audit_ref = reference(
16250 "kotlin",
16251 "src/Handler.kt",
16252 "Handler::handle",
16253 "auditService",
16254 "find",
16255 line_of(source, "auditService.find()"),
16256 );
16257 assert_eq!(
16258 infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
16259 Some("AuditService")
16260 );
16261
16262 let user_ref = reference(
16263 "kotlin",
16264 "src/Handler.kt",
16265 "Handler::handle",
16266 "userService",
16267 "find",
16268 line_of(source, "userService.find()"),
16269 );
16270 assert_eq!(
16271 infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
16272 Some("UserService")
16273 );
16274
16275 let billing_ref = reference(
16276 "kotlin",
16277 "src/Handler.kt",
16278 "Handler::handle",
16279 "billingService",
16280 "find",
16281 line_of(source, "billingService.find()"),
16282 );
16283 assert_eq!(
16284 infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
16285 Some("BillingService")
16286 );
16287 }
16288
16289 #[test]
16290 fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
16291 let source = r#"struct Foo { void run(); };
16292struct PointerFoo { void run(); };
16293struct FactoryFoo { void run(); };
16294FactoryFoo makeFactoryFoo();
16295
16296void handle() {
16297 Foo foo;
16298 foo.run();
16299 PointerFoo* pointerFoo = nullptr;
16300 pointerFoo->run();
16301 auto factoryFoo = makeFactoryFoo();
16302 factoryFoo.run();
16303}
16304"#;
16305 let dir = tempdir().expect("temp dir");
16306 let root = dir.path();
16307 write_fixture(root, "src/fixture.cpp", source);
16308 let mut cache = DispatchSourceCache::new();
16309
16310 let foo_ref = reference(
16311 "cpp",
16312 "src/fixture.cpp",
16313 "handle",
16314 "foo",
16315 "run",
16316 line_of(source, "foo.run()"),
16317 );
16318 assert_eq!(
16319 infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
16320 Some("Foo")
16321 );
16322
16323 let pointer_ref = reference(
16324 "cpp",
16325 "src/fixture.cpp",
16326 "handle",
16327 "pointerFoo",
16328 "run",
16329 line_of(source, "pointerFoo->run()"),
16330 );
16331 assert_eq!(
16332 infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
16333 Some("PointerFoo")
16334 );
16335
16336 let factory_ref = reference(
16337 "cpp",
16338 "src/fixture.cpp",
16339 "handle",
16340 "factoryFoo",
16341 "run",
16342 line_of(source, "factoryFoo.run()"),
16343 );
16344 assert_eq!(
16345 infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
16346 Some("FactoryFoo")
16347 );
16348 }
16349
16350 #[test]
16351 fn rust_direct_self_field_name_trims_separator_whitespace() {
16352 for receiver_expression in ["self .engine", "self. engine", "self . engine"] {
16353 assert_eq!(
16354 rust_direct_self_field_name(receiver_expression),
16355 Some("engine")
16356 );
16357 }
16358 }
16359
16360 #[test]
16361 fn rust_direct_self_field_receiver_type_is_conservative() {
16362 let source = r#"struct Engine;
16363
16364struct Car {
16365 engine: Engine,
16366}
16367
16368impl Car {
16369 fn run(&self) {
16370 self.engine.start();
16371 }
16372}
16373
16374struct NestedCar {
16375 engine: Engine,
16376}
16377
16378impl NestedCar {
16379 fn run(&self) {
16380 self.inner.engine.start();
16381 }
16382}
16383
16384struct WrappedCar {
16385 engine: Option<Engine>,
16386}
16387
16388impl WrappedCar {
16389 fn run(&self) {
16390 self.engine.start(); // wrapped
16391 }
16392}
16393
16394struct GenericCar<T> {
16395 engine: T,
16396}
16397
16398impl<T> GenericCar<T> {
16399 fn run(&self) {
16400 self.engine.start(); // generic
16401 }
16402}
16403
16404type EngineAlias = Engine;
16405
16406struct AliasCar {
16407 engine: EngineAlias,
16408}
16409
16410impl AliasCar {
16411 fn run(&self) {
16412 self.engine.start(); // alias
16413 }
16414}
16415"#;
16416 let dir = tempdir().expect("temp dir");
16417 let root = dir.path();
16418 write_fixture(root, "src/lib.rs", source);
16419 let mut cache = DispatchSourceCache::new();
16420
16421 let mut direct = reference(
16422 "rust",
16423 "src/lib.rs",
16424 "Car::run",
16425 "engine",
16426 "start",
16427 line_of(source, "self.engine.start()"),
16428 );
16429 direct.receiver_expression = "self.engine".to_string();
16430 assert_eq!(
16431 infer_receiver_type(root, &direct, &mut cache).as_deref(),
16432 Some("Engine")
16433 );
16434
16435 let mut mismatched_impl_target = direct.clone();
16436 mismatched_impl_target.caller_symbol = "other::Car::run".to_string();
16437 assert!(infer_receiver_type(root, &mismatched_impl_target, &mut cache).is_none());
16438
16439 let mut nested = reference(
16440 "rust",
16441 "src/lib.rs",
16442 "NestedCar::run",
16443 "engine",
16444 "start",
16445 line_of(source, "self.inner.engine.start()"),
16446 );
16447 nested.receiver_expression = "self.inner.engine".to_string();
16448 assert!(infer_receiver_type(root, &nested, &mut cache).is_none());
16449
16450 let mut wrapped = reference(
16451 "rust",
16452 "src/lib.rs",
16453 "WrappedCar::run",
16454 "engine",
16455 "start",
16456 line_of(source, "self.engine.start(); // wrapped"),
16457 );
16458 wrapped.receiver_expression = "self.engine".to_string();
16459 assert!(infer_receiver_type(root, &wrapped, &mut cache).is_none());
16460
16461 let mut generic = reference(
16462 "rust",
16463 "src/lib.rs",
16464 "GenericCar::run",
16465 "engine",
16466 "start",
16467 line_of(source, "self.engine.start(); // generic"),
16468 );
16469 generic.receiver_expression = "self.engine".to_string();
16470 assert!(infer_receiver_type(root, &generic, &mut cache).is_none());
16471
16472 let mut alias = reference(
16473 "rust",
16474 "src/lib.rs",
16475 "AliasCar::run",
16476 "engine",
16477 "start",
16478 line_of(source, "self.engine.start(); // alias"),
16479 );
16480 alias.receiver_expression = "self.engine".to_string();
16481 assert!(infer_receiver_type(root, &alias, &mut cache).is_none());
16482 }
16483
16484 #[test]
16485 fn rust_direct_self_reference_field_receiver_is_not_inferred() {
16486 let source = r#"struct Engine;
16487
16488struct Car {
16489 engine: &'static Engine,
16490}
16491
16492impl Car {
16493 fn run(&self) {
16494 self.engine.start();
16495 }
16496}
16497"#;
16498 let dir = tempdir().expect("temp dir");
16499 let root = dir.path();
16500 write_fixture(root, "src/lib.rs", source);
16501 let mut cache = DispatchSourceCache::new();
16502 let mut reference = reference(
16503 "rust",
16504 "src/lib.rs",
16505 "Car::run",
16506 "engine",
16507 "start",
16508 line_of(source, "self.engine.start()"),
16509 );
16510 reference.receiver_expression = "self.engine".to_string();
16511
16512 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
16513 }
16514
16515 #[test]
16516 fn rust_trait_impl_self_field_receiver_is_not_inferred() {
16517 let source = r#"trait Drive {
16518 fn run(&self);
16519}
16520
16521struct Engine;
16522
16523struct Car {
16524 engine: Engine,
16525}
16526
16527impl Drive for Car {
16528 fn run(&self) {
16529 self.engine.start();
16530 }
16531}
16532"#;
16533 let dir = tempdir().expect("temp dir");
16534 let root = dir.path();
16535 write_fixture(root, "src/lib.rs", source);
16536 let mut cache = DispatchSourceCache::new();
16537 let mut reference = reference(
16538 "rust",
16539 "src/lib.rs",
16540 "Car::run",
16541 "engine",
16542 "start",
16543 line_of(source, "self.engine.start()"),
16544 );
16545 reference.receiver_expression = "self.engine".to_string();
16546
16547 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
16548 }
16549
16550 #[test]
16551 fn rust_self_field_does_not_bind_struct_from_another_module() {
16552 let source = r#"struct Engine;
16553
16554mod unrelated {
16555 struct Car {
16556 engine: Engine,
16557 }
16558}
16559
16560impl Car {
16561 fn run(&self) {
16562 self.engine.start();
16563 }
16564}
16565"#;
16566 let dir = tempdir().expect("temp dir");
16567 let root = dir.path();
16568 write_fixture(root, "src/lib.rs", source);
16569 let mut cache = DispatchSourceCache::new();
16570 let mut reference = reference(
16571 "rust",
16572 "src/lib.rs",
16573 "Car::run",
16574 "engine",
16575 "start",
16576 line_of(source, "self.engine.start()"),
16577 );
16578 reference.receiver_expression = "self.engine".to_string();
16579
16580 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
16581 }
16582
16583 #[test]
16584 fn unknown_java_receiver_still_uses_name_match_fallback() {
16585 let source = r#"class EntryPoint {
16586 void handle() {
16587 service.runSpecial();
16588 }
16589}
16590
16591class OnlyService {
16592 void runSpecial() {}
16593}
16594"#;
16595 let dir = tempdir().expect("temp dir");
16596 let root = dir.path();
16597 write_fixture(root, "src/EntryPoint.java", source);
16598 let reference = reference(
16599 "java",
16600 "src/EntryPoint.java",
16601 "EntryPoint::handle",
16602 "service",
16603 "runSpecial",
16604 line_of(source, "service.runSpecial()"),
16605 );
16606 let mut cache = DispatchSourceCache::new();
16607
16608 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
16609 let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
16610 let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
16611 assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
16612 }
16613
16614 fn reference(
16615 lang: &str,
16616 caller_file: &str,
16617 caller_symbol: &str,
16618 receiver: &str,
16619 method_name: &str,
16620 line: u32,
16621 ) -> NameMatchRef {
16622 NameMatchRef {
16623 ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
16624 caller_node: format!("{caller_symbol}:node"),
16625 caller_file: caller_file.to_string(),
16626 caller_symbol: caller_symbol.to_string(),
16627 caller_signature: None,
16628 receiver_expression: receiver.to_string(),
16629 receiver: receiver.to_string(),
16630 method_name: method_name.to_string(),
16631 colon_dispatch: false,
16632 line,
16633 lang: lang.to_string(),
16634 }
16635 }
16636
16637 fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
16638 NameMatchCandidate {
16639 node_id: node_id.to_string(),
16640 file_path: "src/targets.fixture".to_string(),
16641 scoped_name: scoped_name.to_string(),
16642 kind: "method".to_string(),
16643 start_line: 1,
16644 }
16645 }
16646
16647 fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
16648 let path = root.join(rel_path);
16649 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
16650 fs::write(path, source).expect("write fixture");
16651 }
16652
16653 fn line_of(source: &str, needle: &str) -> u32 {
16654 source
16655 .lines()
16656 .position(|line| line.contains(needle))
16657 .map(|index| index as u32 + 1)
16658 .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
16659 }
16660}
16661
16662#[cfg(test)]
16663mod bounded_build_breaker_tests {
16664 use super::*;
16665 use crate::build_breaker::{BreakerAdmission, BreakerKey, BuildDeathBreaker, BuildDomain};
16666 use tempfile::tempdir;
16667
16668 #[test]
16669 fn staged_inventory_drives_ordered_bounded_file_batches() {
16670 let temp = tempdir().unwrap();
16671 let root = temp.path().join("root");
16672 std::fs::create_dir_all(&root).unwrap();
16673 let first = root.join("a.ts");
16674 let second = root.join("b.ts");
16675 let third = root.join("c.ts");
16676 for path in [&first, &second, &third] {
16677 std::fs::write(path, "export function item() {}\n").unwrap();
16678 }
16679 let writer_lease = acquire_writer_lease(temp.path(), "inventory-key", &root)
16680 .unwrap()
16681 .expect("test root may write its private staging database");
16682 let store = CallGraphStore::open_at_path(
16683 root.clone(),
16684 "inventory-key".to_string(),
16685 temp.path().join("inventory.sqlite"),
16686 None,
16687 true,
16688 Some(writer_lease),
16689 None,
16690 )
16691 .unwrap()
16692 .store;
16693 let fingerprint = store
16694 .stage_cold_build_file_inventory(&[
16695 third.clone(),
16696 first.clone(),
16697 second.clone(),
16698 first.clone(),
16699 ])
16700 .unwrap();
16701
16702 let conn = store.conn.lock().unwrap();
16703 assert_eq!(
16704 query_count(&conn, "SELECT COUNT(*) FROM staging_file_inventory").unwrap(),
16705 3,
16706 "the primary key deduplicates caller-supplied paths on disk"
16707 );
16708 let first_batch = load_staged_file_batch(&conn, &root, "", 2, u64::MAX)
16709 .unwrap()
16710 .expect("first batch");
16711 assert_eq!(first_batch.paths, vec![first.clone(), second]);
16712 let second_batch =
16713 load_staged_file_batch(&conn, &root, &first_batch.last_path, 2, u64::MAX)
16714 .unwrap()
16715 .expect("second batch");
16716 assert_eq!(second_batch.paths, vec![third]);
16717 assert_eq!(
16718 fingerprint,
16719 callgraph_corpus_fingerprint(&root).unwrap(),
16720 "staged and direct streaming fingerprints agree without walk-order dependence"
16721 );
16722 }
16723
16724 #[test]
16725 fn resumed_stage_preserves_committed_batch_and_counter() {
16726 let temp = tempdir().unwrap();
16727 let root = temp.path().join("root");
16728 std::fs::create_dir_all(&root).unwrap();
16729 let first = root.join("first.ts");
16730 let second = root.join("second.ts");
16731 std::fs::write(&first, "export function first() {}\n").unwrap();
16732 std::fs::write(&second, "export function second() { first(); }\n").unwrap();
16733 let staging = temp.path().join("stage.sqlite");
16734 let writer_lease = acquire_writer_lease(temp.path(), "test-key", &root)
16735 .unwrap()
16736 .expect("test root may write its private staging database");
16737 let store = CallGraphStore::open_at_path(
16738 root.clone(),
16739 "test-key".to_string(),
16740 staging,
16741 None,
16742 true,
16743 Some(writer_lease),
16744 None,
16745 )
16746 .unwrap()
16747 .store;
16748 let corpus_fingerprint = store
16749 .stage_cold_build_file_inventory(&[first.clone(), second.clone()])
16750 .unwrap();
16751 let first_extract = build_file_extract(&root, &first).unwrap();
16752 let first_bytes = first_extract.freshness.size;
16753 {
16754 let mut conn = store.conn.lock().unwrap();
16755 let tx = conn.transaction().unwrap();
16756 clear_tables(&tx).unwrap();
16757 insert_meta(&tx).unwrap();
16758 drop_cold_build_secondary_indexes(&tx).unwrap();
16759 set_meta_ready(&tx, false).unwrap();
16760 set_staged_build_phase(&tx, "extracting").unwrap();
16761 set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, &corpus_fingerprint).unwrap();
16762 set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0).unwrap();
16763 {
16764 let mut inserts = ColdBuildInsertStatements::new(&tx).unwrap();
16765 insert_file_extract_prepared(
16766 &mut inserts,
16767 &root.display().to_string(),
16768 &first_extract,
16769 )
16770 .unwrap();
16771 for raw in &first_extract.raw_refs {
16772 insert_staged_ref_prepared(&mut inserts, raw).unwrap();
16773 }
16774 }
16775 increment_staged_extracted_bytes(&tx, first_bytes).unwrap();
16776 tx.commit().unwrap();
16777 }
16778
16779 store
16780 .cold_build_chunked(&[first.clone(), second.clone()], 1)
16781 .unwrap();
16782 let conn = store.conn.lock().unwrap();
16783 assert_eq!(query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(), 2);
16784 assert_eq!(
16785 staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
16786 first_bytes + std::fs::metadata(second).unwrap().len(),
16787 "the already committed batch and its credit survive adoption; only the new batch increments credit"
16788 );
16789 assert_eq!(staged_build_phase(&conn).unwrap().as_deref(), Some("ready"));
16790 }
16791
16792 const SPECIMEN_CHILD_TEST: &str =
16793 "callgraph_store::bounded_build_breaker_tests::respawn_loop_build_child";
16794 const SPECIMEN_CHILD_ROOT: &str = "AFT_SPECIMEN_CHILD_ROOT";
16795 const SPECIMEN_CHILD_STORE: &str = "AFT_SPECIMEN_CHILD_STORE";
16796 const SPECIMEN_CHILD_PHASE: &str = "AFT_SPECIMEN_CHILD_PHASE";
16797 const SPECIMEN_CHILD_SIGNAL: &str = "AFT_SPECIMEN_CHILD_SIGNAL";
16798
16799 fn wait_for_child_barrier(path: &Path) {
16800 let deadline = Instant::now() + Duration::from_secs(10);
16801 while !path.exists() {
16802 assert!(
16803 Instant::now() < deadline,
16804 "callgraph child did not reach barrier {}",
16805 path.display()
16806 );
16807 std::thread::sleep(Duration::from_millis(5));
16808 }
16809 }
16810
16811 fn spawn_build_child(root: &Path, store: &Path, phase: Option<&str>) -> std::process::Child {
16812 let signal = store.join("specimen-child.reached");
16813 let _ = std::fs::remove_file(&signal);
16814 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
16815 command
16816 .arg("--exact")
16817 .arg(SPECIMEN_CHILD_TEST)
16818 .arg("--nocapture")
16819 .arg("--test-threads=1")
16820 .env(SPECIMEN_CHILD_ROOT, root)
16821 .env(SPECIMEN_CHILD_STORE, store)
16822 .env(SPECIMEN_CHILD_SIGNAL, &signal)
16823 .stdout(std::process::Stdio::null())
16824 .stderr(std::process::Stdio::null());
16825 if let Some(phase) = phase {
16826 command.env(SPECIMEN_CHILD_PHASE, phase);
16827 }
16828 command.spawn().unwrap()
16829 }
16830
16831 fn staging_path(root: &Path, store: &Path) -> PathBuf {
16832 let project_key = crate::search_index::artifact_cache_key(root);
16833 store.join(format!("{project_key}.staging.sqlite.tmp.resume"))
16834 }
16835
16836 fn durable_staging_state(path: &Path) -> (u64, u64) {
16837 if !path.exists() {
16838 return (0, 0);
16839 }
16840 let conn = Connection::open(path).unwrap();
16841 (
16842 query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(),
16843 staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
16844 )
16845 }
16846
16847 fn kill_barrier_child(child: &mut std::process::Child, signal: &Path) {
16848 wait_for_child_barrier(signal);
16849 child.kill().unwrap();
16850 let _ = child.wait().unwrap();
16851 }
16852
16853 #[test]
16854 fn respawn_loop_build_child() {
16855 let Some(root) = std::env::var_os(SPECIMEN_CHILD_ROOT) else {
16856 return;
16857 };
16858 let root = PathBuf::from(root);
16859 let store = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_STORE).unwrap());
16860 if let Some(phase) = std::env::var_os(SPECIMEN_CHILD_PHASE) {
16861 let phase = phase.to_string_lossy().into_owned();
16862 let signal = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_SIGNAL).unwrap());
16863 set_cold_build_phase_observer(Some(Arc::new(move |observed| {
16864 if observed == phase {
16865 std::fs::write(&signal, observed.as_bytes()).unwrap();
16866 std::thread::sleep(Duration::from_secs(30));
16867 }
16868 })));
16869 }
16870 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
16871 CallGraphStore::cold_build_with_lease_chunked(store, root, &files, 1).unwrap();
16872 }
16873
16874 #[test]
16875 fn issue_250_respawn_loop_converges_or_trips_without_false_readiness() {
16876 let temp = tempdir().unwrap();
16877 let root = temp.path().join("resumable-root");
16878 let store = temp.path().join("resumable-store");
16879 std::fs::create_dir_all(&root).unwrap();
16880 std::fs::create_dir_all(&store).unwrap();
16881 for index in 0..3 {
16882 std::fs::write(
16883 root.join(format!("file-{index}.ts")),
16884 format!("export function specimen{index}() {{ return {index}; }}\n"),
16885 )
16886 .unwrap();
16887 }
16888 let stage = staging_path(&root, &store);
16889 let signal = store.join("specimen-child.reached");
16890
16891 let mut first = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
16892 kill_barrier_child(&mut first, &signal);
16893 let (first_rows, first_bytes) = durable_staging_state(&stage);
16894 assert_eq!(first_rows, 1);
16895 assert!(first_bytes > 0);
16896
16897 let mut second = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
16898 kill_barrier_child(&mut second, &signal);
16899 let (second_rows, second_bytes) = durable_staging_state(&stage);
16900 assert_eq!(second_rows, 2);
16901 assert!(
16902 second_bytes > first_bytes,
16903 "a replacement process must adopt committed bytes instead of restarting from zero"
16904 );
16905
16906 let status = spawn_build_child(&root, &store, None).wait().unwrap();
16907 assert!(status.success(), "uninterrupted replacement build failed");
16908 assert!(!stage.exists(), "published staging file must be renamed");
16909 let ready = CallGraphStore::open_readonly(store.clone(), root.clone())
16910 .unwrap()
16911 .expect("replacement attempts must converge to a published graph");
16912 assert_eq!(ready.indexed_file_count().unwrap(), 3);
16913
16914 let fast_root = temp.path().join("zero-credit-root");
16915 let fast_store = temp.path().join("zero-credit-store");
16916 std::fs::create_dir_all(&fast_root).unwrap();
16917 std::fs::create_dir_all(&fast_store).unwrap();
16918 std::fs::write(
16919 fast_root.join("main.ts"),
16920 "export function neverCommitted() {}\n",
16921 )
16922 .unwrap();
16923 let fast_stage = staging_path(&fast_root, &fast_store);
16924 let fast_signal = fast_store.join("specimen-child.reached");
16925 let breaker_path = fast_store.join("build-breaker.sqlite");
16926 let now = unix_millis_now();
16927
16928 for death in 0..3 {
16929 let mut child = spawn_build_child(&fast_root, &fast_store, Some("enumeration"));
16930 wait_for_child_barrier(&fast_signal);
16931 let attempt_id = Connection::open(&breaker_path)
16932 .unwrap()
16933 .query_row(
16934 "SELECT attempt_id FROM breaker_attempts
16935 WHERE death_charged = 0 ORDER BY rowid DESC LIMIT 1",
16936 [],
16937 |row| row.get::<_, String>(0),
16938 )
16939 .unwrap();
16940 let (_, committed_bytes) = durable_staging_state(&fast_stage);
16941 assert_eq!(
16942 committed_bytes, 0,
16943 "the fast-kill schedule must not cross an extraction commit"
16944 );
16945 child.kill().unwrap();
16946 let _ = child.wait().unwrap();
16947
16948 let key = BreakerKey::new(
16949 fast_root.display().to_string(),
16950 BuildDomain::CallgraphCold,
16951 callgraph_corpus_fingerprint(&fast_root).unwrap(),
16952 );
16953 BuildDeathBreaker::open(&breaker_path)
16954 .unwrap()
16955 .record_attributed_death_at(&key, &attempt_id, committed_bytes, 0, now + death)
16956 .unwrap();
16957 }
16958
16959 let files = crate::callgraph::walk_project_files(&fast_root).collect::<Vec<_>>();
16960 let suspension = CallGraphStore::cold_build_suspension(&fast_store, &fast_root)
16961 .unwrap()
16962 .expect("three zero-credit process deaths must suspend the root");
16963 assert_eq!(suspension.reason, "zero_credit_death_limit");
16964 assert_eq!(suspension.death_count, 3);
16965 let response = crate::commands::callgraph_store_adapter::suspended_response(
16966 "specimen",
16967 "callers",
16968 &suspension,
16969 );
16970 assert_eq!(response.data["code"], serde_json::json!("build_suspended"));
16971 let message = response.data["message"].as_str().unwrap();
16972 assert!(
16973 message.starts_with("callers: build_suspended domain=callgraph_cold deaths=3 age_ms=")
16974 );
16975 assert!(message.ends_with(
16976 " reason=zero_credit_death_limit; run doctor reset-build-breaker to resume"
16977 ));
16978 let refused =
16979 CallGraphStore::cold_build_with_lease_chunked(fast_store, fast_root, &files, 1)
16980 .expect_err("a suspended root must not report a perpetually building worker");
16981 assert!(matches!(refused, CallGraphStoreError::Suspended(_)));
16982 }
16983
16984 #[test]
16985 fn published_callgraph_build_respects_durable_domain_suspension() {
16986 let temp = tempdir().unwrap();
16987 let root = temp.path().join("root");
16988 let store_dir = temp.path().join("store");
16989 std::fs::create_dir_all(&root).unwrap();
16990 let source = root.join("main.ts");
16991 std::fs::write(&source, "export function marker() {}\n").unwrap();
16992 let files = vec![source];
16993 let key = BreakerKey::new(
16994 root.display().to_string(),
16995 BuildDomain::CallgraphCold,
16996 callgraph_corpus_fingerprint(&root).unwrap(),
16997 );
16998 let breaker = BuildDeathBreaker::open(store_dir.join("build-breaker.sqlite")).unwrap();
16999 for _ in 0..3 {
17000 let BreakerAdmission::Admitted(attempt) = breaker.admit(&key, 0).unwrap() else {
17001 panic!("unexpected early suspension");
17002 };
17003 breaker
17004 .record_attributed_death(&key, &attempt.attempt_id, 0, 0)
17005 .unwrap();
17006 }
17007
17008 let error = CallGraphStore::cold_build_with_lease_chunked(store_dir, root, &files, 1)
17009 .expect_err("durably tripped callgraph domain must refuse a new cold build");
17010 assert!(matches!(
17011 error,
17012 CallGraphStoreError::Suspended(ref suspension)
17013 if suspension.domain == BuildDomain::CallgraphCold
17014 && suspension.death_count == 3
17015 ));
17016 }
17017}