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);
60const CALLGRAPH_ROOT_ORPHAN_MIN_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
64const CALLGRAPH_ROOT_SWEEP_LIMIT: usize = 200;
67const CALLGRAPH_ROOT_SWEEP_BUDGET: Duration = Duration::from_secs(5);
68static CALLGRAPH_ROOT_SWEEP_CURSORS: OnceLock<Mutex<HashMap<PathBuf, String>>> = OnceLock::new();
69
70const COLD_BUILD_EXTRACT_BATCH_FILES: usize = 256;
73const COLD_BUILD_EXTRACT_BATCH_BYTES: u64 = 32 * 1024 * 1024;
74const COLD_BUILD_RESOLVE_WINDOW: usize = 20_000;
77const STAGED_COMMITTED_EXTRACTED_BYTES: &str = "committed_extracted_bytes";
78const STAGED_RESOLVE_CURSOR: &str = "resolve_cursor";
79const STAGED_BUILD_PHASE: &str = "staged_build_phase";
80const STAGED_CORPUS_FINGERPRINT: &str = "staged_corpus_fingerprint";
81
82fn write_amplification_baseline_enabled() -> bool {
83 std::env::var_os("AFT_CALLGRAPH_WRITE_AMP_BASELINE").is_some()
84}
85
86type ColdBuildSwapObserver = dyn Fn(&Path, &Path) + Send + Sync + 'static;
87pub type ColdBuildPhaseObserver = dyn Fn(&'static str) + Send + Sync + 'static;
88#[cfg(test)]
89type ColdBuildSliceObserver = dyn Fn(&'static str, usize, usize) + Send + Sync + 'static;
90#[cfg(test)]
91type ColdBuildExtractObserver = dyn Fn(&[PathBuf]) + Send + Sync + 'static;
92
93static COLD_BUILD_PHASE_OBSERVER: OnceLock<Mutex<Option<Arc<ColdBuildPhaseObserver>>>> =
94 OnceLock::new();
95
96pub fn set_cold_build_phase_observer(observer: Option<Arc<ColdBuildPhaseObserver>>) {
100 *COLD_BUILD_PHASE_OBSERVER
101 .get_or_init(|| Mutex::new(None))
102 .lock()
103 .expect("cold build phase observer mutex poisoned") = observer;
104}
105
106fn note_cold_build_phase(phase: &'static str) {
107 if let Some(observer) = COLD_BUILD_PHASE_OBSERVER
108 .get_or_init(|| Mutex::new(None))
109 .lock()
110 .expect("cold build phase observer mutex poisoned")
111 .as_ref()
112 .cloned()
113 {
114 observer(phase);
115 }
116}
117
118#[cfg(test)]
119fn note_cold_build_commit_barrier(phase: &'static str) {
120 note_cold_build_phase(phase);
121}
122
123#[cfg(not(test))]
124fn note_cold_build_commit_barrier(_phase: &'static str) {}
125
126#[derive(Clone, Debug, Eq, Hash, PartialEq)]
127struct RebuildCooldownKey {
128 callgraph_dir: PathBuf,
129 project_key: String,
130}
131
132#[derive(Clone, Debug)]
133struct RebuildCooldownRecord {
134 project_root: PathBuf,
135 published_at: Instant,
136 cross_root_cooldown_armed: bool,
137}
138
139static SUCCESSFUL_REBUILDS: OnceLock<Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>> =
144 OnceLock::new();
145
146#[derive(Clone, Debug, Eq, Hash, PartialEq)]
147struct RootRepairWarningKey {
148 project_key: String,
149}
150
151#[derive(Clone, Debug)]
152struct RootRepairWarningRecord {
153 window_start: Instant,
154 last_emitted: Instant,
155 entry_count: u64,
156 suppressed: u64,
157}
158
159static ROOT_REPAIR_WARNINGS: OnceLock<
160 Mutex<HashMap<RootRepairWarningKey, RootRepairWarningRecord>>,
161> = OnceLock::new();
162
163#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
164pub(crate) struct CallgraphWriteMetricsSnapshot {
165 pub commits_60s: u64,
166 pub pages_or_bytes_written_60s: u64,
167}
168
169#[derive(Debug, Default)]
170struct CallgraphWriteMetrics {
171 window_start_ms: AtomicU64,
172 commits_60s: AtomicU64,
173 pages_or_bytes_written_60s: AtomicU64,
174}
175
176static CALLGRAPH_WRITE_METRICS: OnceLock<Mutex<HashMap<String, Arc<CallgraphWriteMetrics>>>> =
177 OnceLock::new();
178
179fn callgraph_write_metrics_for_key(project_key: &str) -> Arc<CallgraphWriteMetrics> {
180 let metrics = CALLGRAPH_WRITE_METRICS.get_or_init(|| Mutex::new(HashMap::new()));
181 let mut metrics = metrics
182 .lock()
183 .expect("callgraph write metrics mutex poisoned");
184 Arc::clone(
185 metrics
186 .entry(project_key.to_string())
187 .or_insert_with(|| Arc::new(CallgraphWriteMetrics::default())),
188 )
189}
190
191fn roll_callgraph_write_metric_window(metrics: &CallgraphWriteMetrics, now_ms: u64) {
192 let current_start = metrics.window_start_ms.load(AtomicOrdering::Acquire);
193 if current_start == 0 {
194 let _ = metrics.window_start_ms.compare_exchange(
195 0,
196 now_ms,
197 AtomicOrdering::AcqRel,
198 AtomicOrdering::Acquire,
199 );
200 return;
201 }
202 if now_ms.saturating_sub(current_start) < CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64 {
203 return;
204 }
205 if metrics
206 .window_start_ms
207 .compare_exchange(
208 current_start,
209 now_ms,
210 AtomicOrdering::AcqRel,
211 AtomicOrdering::Acquire,
212 )
213 .is_ok()
214 {
215 metrics.commits_60s.store(0, AtomicOrdering::Release);
216 metrics
217 .pages_or_bytes_written_60s
218 .store(0, AtomicOrdering::Release);
219 }
220}
221
222impl CallgraphWriteMetrics {
223 fn record_commit(&self, pages_or_bytes_written: u64) {
224 let now_ms = unix_millis_now();
225 roll_callgraph_write_metric_window(self, now_ms);
226 self.commits_60s.fetch_add(1, AtomicOrdering::Relaxed);
227 self.pages_or_bytes_written_60s
228 .fetch_add(pages_or_bytes_written, AtomicOrdering::Relaxed);
229 }
230
231 fn snapshot(&self) -> CallgraphWriteMetricsSnapshot {
232 roll_callgraph_write_metric_window(self, unix_millis_now());
233 CallgraphWriteMetricsSnapshot {
234 commits_60s: self.commits_60s.load(AtomicOrdering::Acquire),
235 pages_or_bytes_written_60s: self
236 .pages_or_bytes_written_60s
237 .load(AtomicOrdering::Acquire),
238 }
239 }
240}
241
242pub(crate) fn callgraph_write_metrics_for_project(
243 project_key: &str,
244) -> CallgraphWriteMetricsSnapshot {
245 callgraph_write_metrics_for_key(project_key).snapshot()
246}
247
248pub(crate) fn callgraph_write_metrics_total() -> CallgraphWriteMetricsSnapshot {
249 let Some(metrics) = CALLGRAPH_WRITE_METRICS.get() else {
250 return CallgraphWriteMetricsSnapshot::default();
251 };
252 let metrics = metrics
253 .lock()
254 .expect("callgraph write metrics mutex poisoned");
255 metrics.values().map(|metrics| metrics.snapshot()).fold(
256 CallgraphWriteMetricsSnapshot::default(),
257 |total, current| CallgraphWriteMetricsSnapshot {
258 commits_60s: total.commits_60s.saturating_add(current.commits_60s),
259 pages_or_bytes_written_60s: total
260 .pages_or_bytes_written_60s
261 .saturating_add(current.pages_or_bytes_written_60s),
262 },
263 )
264}
265
266const ROOT_REPAIR_WARNING_TEXT: &str =
267 "callgraph store root repair requires rebuild; open-only reader reports unavailable";
268
269fn next_root_repair_warning(key: RootRepairWarningKey, now: Instant) -> Option<String> {
270 let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
271 let mut warnings = warnings.lock().ok()?;
272 let entry = warnings.entry(key);
273 let record = match entry {
274 Entry::Vacant(entry) => {
275 entry.insert(RootRepairWarningRecord {
276 window_start: now,
277 last_emitted: now,
278 entry_count: 1,
279 suppressed: 0,
280 });
281 return Some(ROOT_REPAIR_WARNING_TEXT.to_string());
282 }
283 Entry::Occupied(entry) => entry.into_mut(),
284 };
285
286 if now.saturating_duration_since(record.window_start) >= ROOT_REPAIR_WARN_INTERVAL {
287 let suppressed = record.suppressed;
288 record.window_start = now;
289 record.last_emitted = now;
290 record.entry_count = 1;
291 record.suppressed = 0;
292 return Some(if suppressed == 0 {
293 ROOT_REPAIR_WARNING_TEXT.to_string()
294 } else {
295 format!("{ROOT_REPAIR_WARNING_TEXT} (repeated {suppressed}x in 60s)")
296 });
297 }
298
299 record.entry_count = record.entry_count.saturating_add(1);
300 if now.saturating_duration_since(record.last_emitted) < ROOT_REPAIR_WARN_INTERVAL {
301 record.suppressed = record.suppressed.saturating_add(1);
302 None
303 } else {
304 record.last_emitted = now;
305 Some(ROOT_REPAIR_WARNING_TEXT.to_string())
306 }
307}
308
309pub(crate) fn note_repair_entry(project_key: &str) -> Option<String> {
310 next_root_repair_warning(
311 RootRepairWarningKey {
312 project_key: project_key.to_string(),
313 },
314 Instant::now(),
315 )
316}
317
318pub(crate) fn repair_entry_rate(project_key: &str) -> Option<(u64, Instant)> {
323 let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
324 let warnings = warnings.lock().ok()?;
325 let record = warnings.get(&RootRepairWarningKey {
326 project_key: project_key.to_string(),
327 })?;
328 (Instant::now().saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL)
329 .then_some((record.entry_count, record.window_start))
330}
331
332pub(crate) fn repair_entry_rate_total() -> u64 {
333 let Ok(warnings) = ROOT_REPAIR_WARNINGS
334 .get_or_init(|| Mutex::new(HashMap::new()))
335 .lock()
336 else {
337 return 0;
338 };
339 let now = Instant::now();
340 warnings
341 .values()
342 .filter(|record| {
343 now.saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL
344 })
345 .map(|record| record.entry_count)
346 .sum()
347}
348
349#[cfg(test)]
350pub(crate) fn expire_repair_entry_window_for_test(project_key: &str) {
351 let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
352 let mut warnings = warnings.lock().unwrap();
353 if let Some(record) = warnings.get_mut(&RootRepairWarningKey {
354 project_key: project_key.to_string(),
355 }) {
356 record.window_start = Instant::now() - ROOT_REPAIR_WARN_INTERVAL;
357 }
358}
359
360#[cfg(test)]
361mod root_repair_warning_tests {
362 use super::*;
363
364 #[test]
365 fn repair_warning_emits_once_then_reemits_with_suppressed_count() {
366 let key = RootRepairWarningKey {
367 project_key: "test-project".to_string(),
368 };
369 let first_at = Instant::now();
370 let first = next_root_repair_warning(key.clone(), first_at).unwrap();
371 assert_eq!(first, ROOT_REPAIR_WARNING_TEXT);
372 assert!(next_root_repair_warning(key.clone(), first_at + Duration::from_secs(1)).is_none());
373 assert_eq!(
374 repair_entry_rate("test-project").map(|rate| rate.0),
375 Some(2)
376 );
377
378 let repeated = next_root_repair_warning(key, first_at + ROOT_REPAIR_WARN_INTERVAL).unwrap();
379 assert!(repeated.ends_with("(repeated 1x in 60s)"));
380 expire_repair_entry_window_for_test("test-project");
381 assert!(repair_entry_rate("test-project").is_none());
382 }
383}
384
385#[cfg(test)]
386mod write_amplification_tests {
387 use super::*;
388 use std::fs;
389 use tempfile::tempdir;
390
391 #[test]
392 fn callgraph_writer_waits_when_wal_setup_meets_a_write_lock() {
393 let temp = tempdir().unwrap();
394 let sqlite_path = temp.path().join("contended.sqlite");
395 let blocker = Connection::open(&sqlite_path).expect("open blocking connection");
396 blocker
397 .execute_batch(
398 "PRAGMA journal_mode=DELETE;
399 CREATE TABLE lock_probe (value INTEGER NOT NULL);
400 INSERT INTO lock_probe VALUES (1);
401 BEGIN EXCLUSIVE;
402 UPDATE lock_probe SET value = 2;",
403 )
404 .expect("hold exclusive write transaction");
405
406 let (started_tx, started_rx) = std::sync::mpsc::channel();
407 let configure = std::thread::spawn(move || {
408 let conn = Connection::open(sqlite_path).expect("open contending connection");
409 started_tx.send(()).expect("signal configure start");
410 configure_connection(&conn)
411 });
412 started_rx.recv().expect("configure thread started");
413 std::thread::sleep(Duration::from_millis(100));
414 blocker.execute_batch("COMMIT").expect("release write lock");
415
416 configure
417 .join()
418 .expect("configure thread joined")
419 .expect("WAL setup waits for the writer instead of failing locked");
420 }
421
422 #[test]
423 fn callgraph_writer_and_reader_use_bounded_normal_pragmas() {
424 let temp = tempdir().unwrap();
425 let root = temp.path().join("root");
426 fs::create_dir_all(&root).unwrap();
427 let source = root.join("main.ts");
428 fs::write(&source, "export function main() {}\n").unwrap();
429 let store_dir = temp.path().join("store");
430 let store = CallGraphStore::open(store_dir.clone(), root.clone()).unwrap();
431
432 let conn = store.conn.lock().unwrap();
433 let synchronous: i64 = conn
434 .pragma_query_value(None, "synchronous", |row| row.get(0))
435 .unwrap();
436 let autocheckpoint: i64 = conn
437 .pragma_query_value(None, "wal_autocheckpoint", |row| row.get(0))
438 .unwrap();
439 let cache_size: i64 = conn
440 .pragma_query_value(None, "cache_size", |row| row.get(0))
441 .unwrap();
442 assert_eq!(synchronous, 1, "NORMAL synchronous mode is value 1");
443 assert_eq!(autocheckpoint, CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES);
444 assert_eq!(cache_size, CALLGRAPH_SQLITE_CACHE_KIB);
445 drop(conn);
446 store.cold_build(std::slice::from_ref(&source)).unwrap();
447 drop(store);
448
449 let readonly = CallGraphStore::open_readonly(store_dir, root)
450 .unwrap()
451 .expect("writer-created empty schema should be readable");
452 let conn = readonly.inner.conn.lock().unwrap();
453 let synchronous: i64 = conn
454 .pragma_query_value(None, "synchronous", |row| row.get(0))
455 .unwrap();
456 assert_eq!(synchronous, 1);
457 }
458
459 #[test]
460 fn own_refresh_skips_identical_extract_but_not_position_shift() {
461 let temp = tempdir().unwrap();
462 let root = temp.path().join("root");
463 fs::create_dir_all(&root).unwrap();
464 let source = root.join("main.ts");
465 fs::write(&source, "export function main() { return 1; }\n").unwrap();
466 let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
467 store.cold_build(std::slice::from_ref(&source)).unwrap();
468 let write_metrics = callgraph_write_metrics_for_project(store.project_key());
469 assert!(write_metrics.commits_60s > 0);
470 assert!(write_metrics.pages_or_bytes_written_60s > 0);
471
472 let before = store.conn.lock().unwrap().total_changes();
473 fs::write(&source, "export function main() { return 1; }\n\n").unwrap();
474 let (stats, _) = store
475 .refresh_files_profiled(std::slice::from_ref(&source))
476 .unwrap();
477 let after = store.conn.lock().unwrap().total_changes();
478 assert_eq!(stats.unchanged_extract_files, 1);
479 assert_eq!(stats.refreshed_own_files, 0);
480 assert_eq!(
481 after - before,
482 3,
483 "files, backend freshness, and the durable projection revision update"
484 );
485
486 fs::write(&source, "\nexport function main() { return 1; }\n\n").unwrap();
487 let (shifted_stats, _) = store
488 .refresh_files_profiled(std::slice::from_ref(&source))
489 .unwrap();
490 assert_eq!(shifted_stats.unchanged_extract_files, 0);
491 assert_eq!(shifted_stats.refreshed_own_files, 1);
492 }
493
494 #[cfg(unix)]
495 #[test]
496 fn deleted_symlink_alias_refresh_removes_the_original_stale_row() {
497 let temp = tempdir().unwrap();
498 let root = temp.path().join("project");
499 let source = root.join("src/lib.ts");
500 fs::create_dir_all(source.parent().unwrap()).unwrap();
501 fs::write(&source, "export function live() {}\n").unwrap();
502 let alias = temp.path().join("project-alias");
503 std::os::unix::fs::symlink(&root, &alias).unwrap();
504 let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
505 store.cold_build(std::slice::from_ref(&source)).unwrap();
506 store
507 .mark_files_stale(std::slice::from_ref(&source))
508 .unwrap();
509
510 fs::remove_file(&source).unwrap();
511 let stats = store
512 .refresh_files(&[alias.join("src/lib.ts")])
513 .expect("deleted alias path must resolve through its existing parent");
514
515 assert_eq!(stats.deleted_files, vec!["src/lib.ts"]);
516 assert!(store.stale_files().unwrap().is_empty());
517 }
518
519 #[cfg(unix)]
520 #[test]
521 fn symlink_alias_refresh_preserves_real_mutation_detection() {
522 let temp = tempdir().unwrap();
523 let root = temp.path().join("project");
524 let source = root.join("src/lib.ts");
525 fs::create_dir_all(source.parent().unwrap()).unwrap();
526 fs::write(&source, "export function before() {}\n").unwrap();
527 let alias = temp.path().join("project-alias");
528 std::os::unix::fs::symlink(&root, &alias).unwrap();
529 let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
530 store.cold_build(std::slice::from_ref(&source)).unwrap();
531
532 fs::write(&source, "export function after() {}\n").unwrap();
533 let stats = store.refresh_files(&[alias.join("src/lib.ts")]).unwrap();
534
535 assert_eq!(stats.changed_files, vec!["src/lib.ts"]);
536 assert_eq!(stats.refreshed_own_files, 1);
537 assert!(store.node_for(Path::new("src/lib.ts"), "after").is_ok());
538 }
539
540 #[test]
541 fn unresolvable_refresh_path_records_a_path_identity_gap() {
542 let temp = tempdir().unwrap();
543 let root = temp.path().join("project");
544 let source = root.join("src/lib.ts");
545 fs::create_dir_all(source.parent().unwrap()).unwrap();
546 fs::write(&source, "export function live() {}\n").unwrap();
547 let foreign = temp.path().join("foreign.ts");
548 fs::write(&foreign, "export function foreign() {}\n").unwrap();
549 let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
550 store.cold_build(std::slice::from_ref(&source)).unwrap();
551
552 let error = store.refresh_files(&[foreign.clone()]).unwrap_err();
553 assert!(matches!(
554 error,
555 CallGraphStoreError::PathIdentityMismatch { .. }
556 ));
557 let conn = store.conn.lock().unwrap();
558 assert_eq!(
559 path_identity_mismatch_reason(&conn).unwrap(),
560 Some(format!(
561 "callgraph_path_identity_mismatch path={} project_root={}",
562 foreign.display(),
563 root.display()
564 ))
565 );
566 }
567
568 #[test]
569 fn idle_checkpoint_interval_prevents_checkpoint_storms() {
570 let now = Instant::now();
571 assert!(idle_checkpoint_due(None, now));
572 assert!(!idle_checkpoint_due(
573 Some(now),
574 now + Duration::from_secs(REFRESH_IDLE_CHECKPOINT_INTERVAL.as_secs() - 1),
575 ));
576 assert!(idle_checkpoint_due(
577 Some(now),
578 now + REFRESH_IDLE_CHECKPOINT_INTERVAL,
579 ));
580 }
581
582 #[test]
583 fn write_metrics_decay_after_the_sixty_second_window() {
584 let key = format!("metrics-test-{}", now_nanos());
585 let metrics = callgraph_write_metrics_for_key(&key);
586 metrics.record_commit(17);
587 assert_eq!(metrics.snapshot().commits_60s, 1);
588 assert_eq!(metrics.snapshot().pages_or_bytes_written_60s, 17);
589 metrics.window_start_ms.store(
590 unix_millis_now().saturating_sub(CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64),
591 AtomicOrdering::Release,
592 );
593 assert_eq!(metrics.snapshot(), CallgraphWriteMetricsSnapshot::default());
594 }
595}
596
597#[cfg(test)]
598type ColdBuildBeforePublishObserver = dyn Fn() + Send + Sync + 'static;
599thread_local! {
606 static COLD_BUILD_SWAP_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSwapObserver>>> =
607 const { std::cell::RefCell::new(None) };
608 #[cfg(test)]
609 static COLD_BUILD_BEFORE_PUBLISH_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildBeforePublishObserver>>> =
610 const { std::cell::RefCell::new(None) };
611 #[cfg(test)]
612 static COLD_BUILD_SLICE_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSliceObserver>>> =
613 const { std::cell::RefCell::new(None) };
614 #[cfg(test)]
615 static COLD_BUILD_EXTRACT_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildExtractObserver>>> =
616 const { std::cell::RefCell::new(None) };
617 static MIGRATION_AVAILABLE_DISK_OVERRIDE: std::cell::RefCell<Option<u64>> =
618 const { std::cell::RefCell::new(None) };
619 static MIGRATION_FAIL_AFTER_TEMP_COPY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
620 static MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED: std::cell::Cell<bool> =
621 const { std::cell::Cell::new(false) };
622 static PUBLISH_ADMISSION: std::cell::RefCell<Option<(crate::root_cache::ArtifactPublishEpoch, u64)>> =
623 const { std::cell::RefCell::new(None) };
624 static REFRESH_COMMIT_ADMISSION: std::cell::RefCell<Option<(SubcLifecycleAdmission, Arc<std::sync::atomic::AtomicU64>, u64)>> =
625 const { std::cell::RefCell::new(None) };
626}
627
628mod dead_code_projection;
629pub use dead_code_projection::project_dead_code_snapshot;
630pub(crate) use dead_code_projection::project_dead_code_snapshot_with_revision;
631#[cfg(test)]
632pub(crate) use dead_code_projection::set_projection_before_open_observer;
633
634#[doc(hidden)]
635pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
636 COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
637}
638
639#[cfg(test)]
640fn set_cold_build_before_publish_observer(observer: Option<Arc<ColdBuildBeforePublishObserver>>) {
641 COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
642}
643
644#[cfg(test)]
645fn notify_cold_build_before_publish_observer() {
646 let observer = COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| slot.borrow().clone());
647 if let Some(observer) = observer {
648 observer();
649 }
650}
651
652#[cfg(not(test))]
653fn notify_cold_build_before_publish_observer() {}
654
655#[cfg(test)]
656fn set_cold_build_slice_observer(observer: Option<Arc<ColdBuildSliceObserver>>) {
657 COLD_BUILD_SLICE_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
658}
659
660#[cfg(test)]
661fn notify_cold_build_slice_observer(stage: &'static str, completed: usize, total: usize) {
662 let observer = COLD_BUILD_SLICE_OBSERVER.with(|slot| slot.borrow().clone());
663 if let Some(observer) = observer {
664 observer(stage, completed, total);
665 }
666}
667
668#[cfg(not(test))]
669fn notify_cold_build_slice_observer(_stage: &'static str, _completed: usize, _total: usize) {}
670
671#[cfg(test)]
672fn set_cold_build_extract_observer(observer: Option<Arc<ColdBuildExtractObserver>>) {
673 COLD_BUILD_EXTRACT_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
674}
675
676#[cfg(test)]
677fn notify_cold_build_extract_observer(paths: &[PathBuf]) {
678 let observer = COLD_BUILD_EXTRACT_OBSERVER.with(|slot| slot.borrow().clone());
679 if let Some(observer) = observer {
680 observer(paths);
681 }
682}
683
684#[cfg(not(test))]
685fn notify_cold_build_extract_observer(_paths: &[PathBuf]) {}
686
687#[doc(hidden)]
688pub fn set_legacy_migration_available_disk_for_test(bytes: Option<u64>) {
689 MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow_mut() = bytes);
690}
691
692#[doc(hidden)]
693pub fn set_legacy_migration_fail_after_temp_copy_for_test(enabled: bool) {
694 MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.set(enabled));
695}
696
697#[doc(hidden)]
698pub fn set_legacy_migration_backup_budget_exhausted_for_test(enabled: bool) {
699 MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.set(enabled));
700}
701
702struct PublishAdmissionGuard {
703 previous: Option<(crate::root_cache::ArtifactPublishEpoch, u64)>,
704}
705
706impl Drop for PublishAdmissionGuard {
707 fn drop(&mut self) {
708 PUBLISH_ADMISSION.with(|slot| {
709 *slot.borrow_mut() = self.previous.take();
710 });
711 }
712}
713
714pub(crate) fn with_publish_epoch<R>(
715 epoch: crate::root_cache::ArtifactPublishEpoch,
716 expected: u64,
717 run: impl FnOnce() -> R,
718) -> R {
719 let previous = PUBLISH_ADMISSION.with(|slot| slot.replace(Some((epoch, expected))));
720 let _guard = PublishAdmissionGuard { previous };
721 run()
722}
723
724fn ensure_cold_build_current(stage: &'static str, completed: usize, total: usize) -> Result<()> {
725 notify_cold_build_slice_observer(stage, completed, total);
726 let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
727 if admission.is_none_or(|(epoch, expected)| epoch.is_current(expected)) {
728 return Ok(());
729 }
730 crate::slog_info!(
731 "callgraph cold build superseded, stopping after {}/{} ({})",
732 completed,
733 total,
734 stage
735 );
736 Err(CallGraphStoreError::Superseded)
737}
738
739fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
740 let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
741 match admission {
742 Some((epoch, expected)) => epoch
743 .run_if_current(expected, publish)
744 .unwrap_or(Err(CallGraphStoreError::Superseded)),
745 None => publish(),
746 }
747}
748
749struct RefreshCommitAdmissionGuard {
750 previous: Option<(
751 SubcLifecycleAdmission,
752 Arc<std::sync::atomic::AtomicU64>,
753 u64,
754 )>,
755}
756
757impl Drop for RefreshCommitAdmissionGuard {
758 fn drop(&mut self) {
759 REFRESH_COMMIT_ADMISSION.with(|slot| {
760 *slot.borrow_mut() = self.previous.take();
761 });
762 }
763}
764
765fn with_refresh_commit_admission<R>(
766 lifecycle: SubcLifecycleAdmission,
767 generation_flag: Arc<std::sync::atomic::AtomicU64>,
768 expected_generation: u64,
769 run: impl FnOnce() -> R,
770) -> R {
771 let previous = REFRESH_COMMIT_ADMISSION
772 .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
773 let _guard = RefreshCommitAdmissionGuard { previous };
774 run()
775}
776
777fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
778 let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
779 let commit = || {
780 publish_if_current(|| {
781 tx.commit()?;
782 Ok(())
783 })
784 };
785 match admission {
786 Some((lifecycle, generation_flag, expected_generation)) => lifecycle
787 .run_if_current(generation_flag.as_ref(), expected_generation, commit)
788 .unwrap_or(Err(CallGraphStoreError::Superseded)),
789 None => commit(),
790 }
791}
792
793fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
794 let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
795 if let Some(observer) = observer {
796 observer(temp_path, target_path);
797 }
798}
799
800#[derive(Debug)]
801pub enum CallGraphStoreError {
802 Io(std::io::Error),
803 Sqlite(rusqlite::Error),
804 Json(serde_json::Error),
805 Aft(AftError),
806 Lock(crate::fs_lock::AcquireError),
807 MissingCallerData {
808 file: String,
809 },
810 Unavailable(String),
811 PathIdentityMismatch {
812 path: PathBuf,
813 project_root: PathBuf,
814 },
815 Suspended(crate::build_breaker::BuildSuspension),
816 Superseded,
817 StaleFiles(Vec<String>),
818}
819
820impl CallGraphStoreError {
821 pub(crate) fn is_transient_lock_contention(&self) -> bool {
822 matches!(
823 self,
824 Self::Sqlite(rusqlite::Error::SqliteFailure(error, _))
825 if matches!(
826 error.code,
827 rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
828 )
829 )
830 }
831}
832
833impl fmt::Display for CallGraphStoreError {
834 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
835 match self {
836 Self::Io(error) => write!(formatter, "I/O error: {error}"),
837 Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
838 Self::Json(error) => write!(formatter, "json error: {error}"),
839 Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
840 Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
841 Self::MissingCallerData { file } => {
842 write!(formatter, "missing extracted caller data for {file}")
843 }
844 Self::Unavailable(message) => {
845 write!(formatter, "callgraph store unavailable: {message}")
846 }
847 Self::PathIdentityMismatch { path, project_root } => write!(
848 formatter,
849 "callgraph path identity mismatch: {} is not under project root {}",
850 path.display(),
851 project_root.display()
852 ),
853 Self::Suspended(suspension) => write!(
854 formatter,
855 "callgraph build suspended for {} after {} deaths ({})",
856 suspension.domain.as_str(),
857 suspension.death_count,
858 suspension.reason
859 ),
860 Self::Superseded => {
861 write!(formatter, "callgraph store build superseded before publish")
862 }
863 Self::StaleFiles(files) => {
864 write!(
865 formatter,
866 "callgraph store has stale files: {}",
867 files.join(", ")
868 )
869 }
870 }
871 }
872}
873
874impl std::error::Error for CallGraphStoreError {}
875
876impl From<std::io::Error> for CallGraphStoreError {
877 fn from(error: std::io::Error) -> Self {
878 Self::Io(error)
879 }
880}
881
882impl From<rusqlite::Error> for CallGraphStoreError {
883 fn from(error: rusqlite::Error) -> Self {
884 Self::Sqlite(error)
885 }
886}
887
888impl From<serde_json::Error> for CallGraphStoreError {
889 fn from(error: serde_json::Error) -> Self {
890 Self::Json(error)
891 }
892}
893
894impl From<AftError> for CallGraphStoreError {
895 fn from(error: AftError) -> Self {
896 Self::Aft(error)
897 }
898}
899
900impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
901 fn from(error: crate::fs_lock::AcquireError) -> Self {
902 Self::Lock(error)
903 }
904}
905
906pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
907
908pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
912
913#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
914pub struct CallGraphStoreOptions {
915 pub enabled: bool,
916}
917
918pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
919
920#[derive(Clone)]
924pub(crate) struct CallgraphRefreshState {
925 store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
926 heavy_root_work_allowed: Arc<AtomicBool>,
927}
928
929impl CallgraphRefreshState {
930 pub(crate) fn new(
931 store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
932 heavy_root_work_allowed: Arc<AtomicBool>,
933 ) -> Self {
934 Self {
935 store,
936 heavy_root_work_allowed,
937 }
938 }
939
940 fn installed_store_snapshot(&self) -> Option<Arc<ReadonlyCallGraphStore>> {
941 self.store
942 .read()
943 .unwrap_or_else(std::sync::PoisonError::into_inner)
944 .as_ref()
945 .map(Arc::clone)
946 }
947}
948
949type WorkspaceCratePrefixes = HashMap<String, String>;
950
951#[derive(Clone, Debug, Default)]
952struct WorkspaceCratePrefixCache(Arc<OnceLock<WorkspaceCratePrefixes>>);
953
954const REFRESH_WORKSPACE_CACHE_ROOT_CAP: usize = 128;
955
956pub(crate) fn invalidates_workspace_crate_prefix_cache(path: &Path) -> bool {
957 path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")
958}
959
960#[derive(Clone, Debug, Hash, PartialEq, Eq)]
961struct RefreshRoot {
962 callgraph_dir: PathBuf,
963 project_root: PathBuf,
964}
965
966#[derive(Clone)]
967pub(crate) struct CallgraphRefreshTicket {
968 lifecycle: SubcLifecycleAdmission,
969 generation_flag: Arc<std::sync::atomic::AtomicU64>,
970 expected_generation: u64,
971 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
972 expected_publish_epoch: u64,
973}
974
975impl CallgraphRefreshTicket {
976 pub(crate) fn new(
977 lifecycle: SubcLifecycleAdmission,
978 generation_flag: Arc<std::sync::atomic::AtomicU64>,
979 expected_generation: u64,
980 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
981 expected_publish_epoch: u64,
982 ) -> Self {
983 Self {
984 lifecycle,
985 generation_flag,
986 expected_generation,
987 publish_epoch,
988 expected_publish_epoch,
989 }
990 }
991
992 fn is_current(&self) -> bool {
993 self.lifecycle
994 .is_current(self.generation_flag.as_ref(), self.expected_generation)
995 && self.publish_epoch.current() == self.expected_publish_epoch
996 }
997}
998
999#[derive(Clone)]
1000struct RefreshBatch {
1001 root: RefreshRoot,
1002 paths: BTreeSet<PathBuf>,
1003 pending_sinks: Vec<PendingCallGraphStorePaths>,
1004 refresh_states: Vec<CallgraphRefreshState>,
1005 ticket: Option<CallgraphRefreshTicket>,
1006}
1007
1008impl RefreshBatch {
1009 fn defer(&self) {
1010 for sink in &self.pending_sinks {
1011 sink.lock().extend(self.paths.iter().cloned());
1012 }
1013 }
1014
1015 fn defer_after_open_failure(&self) {
1016 self.defer();
1017 if self
1018 .ticket
1019 .as_ref()
1020 .is_some_and(|ticket| !ticket.is_current())
1021 || !self
1022 .refresh_states
1023 .iter()
1024 .any(|state| state.heavy_root_work_allowed.load(AtomicOrdering::SeqCst))
1025 {
1026 return;
1027 }
1028
1029 let ready_store_installed = self.refresh_states.iter().any(|state| {
1030 let store = state.installed_store_snapshot();
1031 store.is_some_and(|store| {
1032 store.project_root() == self.root.project_root
1033 && !store.is_legacy_fallback()
1034 && store.is_current()
1035 })
1036 });
1037 if !ready_store_installed {
1038 return;
1039 }
1040
1041 for sink in &self.pending_sinks {
1045 let paths = {
1046 let mut pending = sink.lock();
1047 self.paths
1048 .iter()
1049 .filter(|path| pending.remove(*path))
1050 .cloned()
1051 .collect::<Vec<_>>()
1052 };
1053 if paths.is_empty() {
1054 continue;
1055 }
1056 let _ = enqueue_callgraph_store_refresh_inner(
1057 self.root.callgraph_dir.clone(),
1058 self.root.project_root.clone(),
1059 paths,
1060 Arc::clone(sink),
1061 self.refresh_states.clone(),
1062 self.ticket.clone(),
1063 );
1064 }
1065 }
1066
1067 fn merge(
1068 &mut self,
1069 paths: impl IntoIterator<Item = PathBuf>,
1070 sink: PendingCallGraphStorePaths,
1071 refresh_states: Vec<CallgraphRefreshState>,
1072 ticket: Option<CallgraphRefreshTicket>,
1073 ) {
1074 self.paths.extend(paths);
1075 if ticket.is_some() {
1076 self.ticket = ticket;
1077 }
1078 if !self
1079 .pending_sinks
1080 .iter()
1081 .any(|existing| Arc::ptr_eq(existing, &sink))
1082 {
1083 self.pending_sinks.push(sink);
1084 }
1085 for refresh_state in refresh_states {
1086 if !self.refresh_states.iter().any(|existing| {
1087 Arc::ptr_eq(&existing.store, &refresh_state.store)
1088 && Arc::ptr_eq(
1089 &existing.heavy_root_work_allowed,
1090 &refresh_state.heavy_root_work_allowed,
1091 )
1092 }) {
1093 self.refresh_states.push(refresh_state);
1094 }
1095 }
1096 }
1097}
1098
1099#[derive(Default)]
1100struct RefreshQueue {
1101 order: VecDeque<RefreshRoot>,
1102 queued: HashMap<RefreshRoot, RefreshBatch>,
1103 active: Option<RefreshBatch>,
1104 shutdown_requested: bool,
1105}
1106
1107struct RefreshWorkerShared {
1108 queue: Mutex<RefreshQueue>,
1109 wake: Condvar,
1110}
1111
1112struct RefreshWorker {
1113 shared: Arc<RefreshWorkerShared>,
1114 thread: Mutex<Option<JoinHandle<()>>>,
1115}
1116
1117struct RefreshWorkerWatchdog {
1118 first_path: PathBuf,
1119 batch_len: usize,
1120 started: Instant,
1121}
1122
1123impl RefreshWorkerWatchdog {
1124 fn start(paths: &[PathBuf]) -> Self {
1125 Self {
1126 first_path: paths
1127 .first()
1128 .expect("non-empty callgraph refresh batch has a first path")
1129 .clone(),
1130 batch_len: paths.len(),
1131 started: Instant::now(),
1132 }
1133 }
1134}
1135
1136impl Drop for RefreshWorkerWatchdog {
1137 fn drop(&mut self) {
1138 let elapsed = self.started.elapsed();
1139 if elapsed < REFRESH_WORKER_WARN_AFTER {
1140 return;
1141 }
1142 let path = if self.batch_len == 1 {
1143 self.first_path.display().to_string()
1144 } else {
1145 format!(
1146 "{} (+{} paths)",
1147 self.first_path.display(),
1148 self.batch_len - 1
1149 )
1150 };
1151 log::warn!(
1152 "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
1153 path,
1154 elapsed.as_millis()
1155 );
1156 if elapsed >= REFRESH_WORKER_FINAL_AFTER {
1157 log::warn!(
1158 "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
1159 path,
1160 elapsed.as_millis()
1161 );
1162 }
1163 }
1164}
1165
1166impl RefreshWorker {
1167 fn spawn() -> Arc<Self> {
1168 let shared = Arc::new(RefreshWorkerShared {
1169 queue: Mutex::new(RefreshQueue::default()),
1170 wake: Condvar::new(),
1171 });
1172 let thread_shared = Arc::clone(&shared);
1173 let thread = std::thread::Builder::new()
1174 .name("aft-callgraph-refresh".to_string())
1175 .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
1176 .expect("failed to spawn callgraph refresh worker");
1177 Arc::new(Self {
1178 shared,
1179 thread: Mutex::new(Some(thread)),
1180 })
1181 }
1182
1183 fn enqueue(
1184 &self,
1185 root: RefreshRoot,
1186 paths: Vec<PathBuf>,
1187 pending_sink: PendingCallGraphStorePaths,
1188 refresh_states: Vec<CallgraphRefreshState>,
1189 ticket: Option<CallgraphRefreshTicket>,
1190 ) -> bool {
1191 let mut queue = self
1192 .shared
1193 .queue
1194 .lock()
1195 .expect("callgraph refresh queue mutex poisoned");
1196 if queue.shutdown_requested {
1197 pending_sink.lock().extend(paths);
1198 return false;
1199 }
1200 if let Some(batch) = queue.queued.get_mut(&root) {
1201 batch.merge(paths, pending_sink, refresh_states, ticket);
1202 } else {
1203 queue.order.push_back(root.clone());
1204 queue.queued.insert(
1205 root.clone(),
1206 RefreshBatch {
1207 root,
1208 paths: paths.into_iter().collect(),
1209 pending_sinks: vec![pending_sink],
1210 refresh_states,
1211 ticket,
1212 },
1213 );
1214 }
1215 self.shared.wake.notify_one();
1216 true
1217 }
1218
1219 fn shutdown_with_budget(&self, budget: Duration) -> bool {
1220 let deadline = Instant::now() + budget;
1221 let mut queue = self
1222 .shared
1223 .queue
1224 .lock()
1225 .expect("callgraph refresh queue mutex poisoned");
1226 queue.shutdown_requested = true;
1227 self.shared.wake.notify_one();
1228 while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
1229 let remaining = deadline.saturating_duration_since(Instant::now());
1230 let (next, _) = self
1231 .shared
1232 .wake
1233 .wait_timeout(queue, remaining)
1234 .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
1235 queue = next;
1236 }
1237 let drained = queue.active.is_none() && queue.order.is_empty();
1238 if !drained {
1239 if let Some(active) = queue.active.as_ref() {
1240 active.defer();
1241 }
1242 for batch in queue.queued.values() {
1243 batch.defer();
1244 }
1245 queue.order.clear();
1246 queue.queued.clear();
1247 }
1248 drop(queue);
1249
1250 if drained {
1251 if let Some(thread) = self
1252 .thread
1253 .lock()
1254 .expect("callgraph refresh worker thread mutex poisoned")
1255 .take()
1256 {
1257 let _ = thread.join();
1258 }
1259 }
1260 drained
1261 }
1262}
1263
1264static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
1265
1266pub fn enqueue_callgraph_store_refresh(
1267 callgraph_dir: PathBuf,
1268 project_root: PathBuf,
1269 paths: Vec<PathBuf>,
1270 pending_sink: PendingCallGraphStorePaths,
1271) -> bool {
1272 enqueue_callgraph_store_refresh_inner(
1273 callgraph_dir,
1274 project_root,
1275 paths,
1276 pending_sink,
1277 Vec::new(),
1278 None,
1279 )
1280}
1281
1282#[cfg(test)]
1283pub(crate) fn enqueue_callgraph_store_refresh_fenced(
1284 callgraph_dir: PathBuf,
1285 project_root: PathBuf,
1286 paths: Vec<PathBuf>,
1287 pending_sink: PendingCallGraphStorePaths,
1288 ticket: CallgraphRefreshTicket,
1289) -> bool {
1290 enqueue_callgraph_store_refresh_inner(
1291 callgraph_dir,
1292 project_root,
1293 paths,
1294 pending_sink,
1295 Vec::new(),
1296 Some(ticket),
1297 )
1298}
1299
1300pub(crate) fn enqueue_callgraph_store_refresh_fenced_with_state(
1301 callgraph_dir: PathBuf,
1302 project_root: PathBuf,
1303 paths: Vec<PathBuf>,
1304 pending_sink: PendingCallGraphStorePaths,
1305 refresh_state: CallgraphRefreshState,
1306 ticket: CallgraphRefreshTicket,
1307) -> bool {
1308 enqueue_callgraph_store_refresh_inner(
1309 callgraph_dir,
1310 project_root,
1311 paths,
1312 pending_sink,
1313 vec![refresh_state],
1314 Some(ticket),
1315 )
1316}
1317
1318fn enqueue_callgraph_store_refresh_inner(
1319 callgraph_dir: PathBuf,
1320 project_root: PathBuf,
1321 paths: Vec<PathBuf>,
1322 pending_sink: PendingCallGraphStorePaths,
1323 refresh_states: Vec<CallgraphRefreshState>,
1324 ticket: Option<CallgraphRefreshTicket>,
1325) -> bool {
1326 if paths.is_empty() {
1327 return true;
1328 }
1329 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1330 let worker = {
1331 let mut worker = slot
1332 .lock()
1333 .expect("callgraph refresh worker mutex poisoned");
1334 Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
1335 };
1336 worker.enqueue(
1337 RefreshRoot {
1338 callgraph_dir,
1339 project_root,
1340 },
1341 paths,
1342 pending_sink,
1343 refresh_states,
1344 ticket,
1345 )
1346}
1347
1348pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
1349 flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
1350}
1351
1352#[doc(hidden)]
1353pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
1354 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1355 let worker = slot
1356 .lock()
1357 .expect("callgraph refresh worker mutex poisoned")
1358 .clone();
1359 let Some(worker) = worker else {
1360 return true;
1361 };
1362 let drained = worker.shutdown_with_budget(budget);
1363 if drained {
1364 let mut current = slot
1365 .lock()
1366 .expect("callgraph refresh worker mutex poisoned");
1367 if current
1368 .as_ref()
1369 .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
1370 {
1371 *current = None;
1372 }
1373 }
1374 drained
1375}
1376
1377fn idle_checkpoint_due(last: Option<Instant>, now: Instant) -> bool {
1378 last.is_none_or(|last| now.saturating_duration_since(last) >= REFRESH_IDLE_CHECKPOINT_INTERVAL)
1379}
1380
1381fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
1382 let mut workspace_crate_prefixes = HashMap::new();
1385 let mut last_idle_checkpoints: HashMap<RefreshRoot, Instant> = HashMap::new();
1386 loop {
1387 let batch = {
1388 let mut queue = shared
1389 .queue
1390 .lock()
1391 .expect("callgraph refresh queue mutex poisoned");
1392 loop {
1393 if let Some(root) = queue.order.pop_front() {
1394 let batch = queue
1395 .queued
1396 .remove(&root)
1397 .expect("queued callgraph refresh root has a batch");
1398 queue.active = Some(batch.clone());
1399 break batch;
1400 }
1401 if queue.shutdown_requested {
1402 return;
1403 }
1404 queue = shared
1405 .wake
1406 .wait(queue)
1407 .expect("callgraph refresh queue mutex poisoned while waiting");
1408 }
1409 };
1410
1411 let store = process_callgraph_refresh_batch(&batch, &mut workspace_crate_prefixes);
1412
1413 let mut queue = shared
1414 .queue
1415 .lock()
1416 .expect("callgraph refresh queue mutex poisoned");
1417 queue.active = None;
1418 let became_idle = queue.order.is_empty();
1419 shared.wake.notify_all();
1420 drop(queue);
1421
1422 if became_idle {
1423 let checkpoint_due = idle_checkpoint_due(
1424 last_idle_checkpoints.get(&batch.root).copied(),
1425 Instant::now(),
1426 );
1427 if checkpoint_due {
1428 if let Some(store) = store {
1429 if store.checkpoint_wal_truncate() {
1430 last_idle_checkpoints.insert(batch.root.clone(), Instant::now());
1431 }
1432 }
1433 }
1434 }
1435 }
1436}
1437
1438fn process_callgraph_refresh_batch(
1439 batch: &RefreshBatch,
1440 workspace_crate_prefixes: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1441) -> Option<CallGraphStore> {
1442 if batch
1446 .paths
1447 .iter()
1448 .any(|path| invalidates_workspace_crate_prefix_cache(path))
1449 {
1450 workspace_crate_prefixes.remove(&batch.root);
1451 }
1452
1453 let paths = batch
1454 .paths
1455 .iter()
1456 .filter(|path| crate::parser::detect_language(path).is_some())
1457 .cloned()
1458 .collect::<Vec<_>>();
1459 if paths.is_empty() {
1460 return None;
1461 }
1462 note_refresh_worker_batch_for_test(&batch.root.project_root);
1463 if batch
1464 .ticket
1465 .as_ref()
1466 .is_some_and(|ticket| !ticket.is_current())
1467 {
1468 batch.defer();
1471 return None;
1472 }
1473 let workspace_crate_prefix_cache =
1474 workspace_crate_prefix_cache_for_root(workspace_crate_prefixes, &batch.root);
1475 let _watchdog = RefreshWorkerWatchdog::start(&paths);
1476 let test_seam = refresh_worker_test_seam(&batch.root.project_root);
1477 note_refresh_worker_call_for_test(&batch.root.project_root);
1478 let opened = if test_seam.fail_open {
1479 Ok(None)
1480 } else {
1481 CallGraphStore::open_ready(
1482 batch.root.callgraph_dir.clone(),
1483 batch.root.project_root.clone(),
1484 )
1485 };
1486 if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
1487 let _ = gate.held_tx.send(());
1490 let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
1491 }
1492 let store = match opened {
1493 Ok(Some(store)) => store,
1494 Ok(None) => {
1495 batch.defer_after_open_failure();
1496 return None;
1497 }
1498 Err(error) => {
1499 batch.defer_after_open_failure();
1500 crate::slog_warn!(
1501 "callgraph store writer open failed during refresh; deferred paths: {}",
1502 error
1503 );
1504 return None;
1505 }
1506 };
1507 if !test_seam.delay.is_zero() {
1508 std::thread::sleep(test_seam.delay);
1509 }
1510 if batch
1511 .ticket
1512 .as_ref()
1513 .is_some_and(|ticket| !ticket.is_current())
1514 {
1515 batch.defer();
1518 return Some(store);
1519 }
1520 let refresh_result = if test_seam.fail_refresh {
1521 Err(CallGraphStoreError::Unavailable(
1522 "injected refresh worker failure".to_string(),
1523 ))
1524 } else if let Some(ticket) = &batch.ticket {
1525 with_publish_epoch(
1526 ticket.publish_epoch.clone(),
1527 ticket.expected_publish_epoch,
1528 || {
1529 with_refresh_commit_admission(
1530 ticket.lifecycle.clone(),
1531 Arc::clone(&ticket.generation_flag),
1532 ticket.expected_generation,
1533 || {
1534 store
1535 .refresh_files_with_workspace_crate_prefix_cache(
1536 &paths,
1537 workspace_crate_prefix_cache.clone(),
1538 )
1539 .map(|_| ())
1540 },
1541 )
1542 },
1543 )
1544 } else {
1545 store
1546 .refresh_files_with_workspace_crate_prefix_cache(
1547 &paths,
1548 workspace_crate_prefix_cache.clone(),
1549 )
1550 .map(|_| ())
1551 };
1552 if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
1553 batch.defer();
1557 return Some(store);
1558 }
1559 if let Err(error) = refresh_result {
1560 crate::slog_warn!("callgraph store refresh failed: {}", error);
1561 match store.mark_files_stale(&paths) {
1562 Ok(marked) => {
1563 note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
1564 crate::slog_warn!(
1565 "marked {} callgraph store file(s) stale after refresh failure",
1566 marked.len()
1567 );
1568 }
1569 Err(mark_error) => crate::slog_warn!(
1570 "failed to mark callgraph store files stale after refresh failure: {}",
1571 mark_error
1572 ),
1573 }
1574 } else {
1575 crate::logging::note_callgraph_invalidations(paths.len());
1576 }
1577 Some(store)
1578}
1579
1580fn workspace_crate_prefix_cache_for_root(
1581 caches: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1582 root: &RefreshRoot,
1583) -> WorkspaceCratePrefixCache {
1584 if !caches.contains_key(root) && caches.len() >= REFRESH_WORKSPACE_CACHE_ROOT_CAP {
1585 if let Some(evicted) = caches.keys().next().cloned() {
1587 caches.remove(&evicted);
1588 }
1589 }
1590 caches.entry(root.clone()).or_default().clone()
1591}
1592
1593#[derive(Clone, Copy, Default)]
1594struct RefreshWorkerTestSeam {
1595 delay: Duration,
1596 fail_refresh: bool,
1597 fail_open: bool,
1598 refresh_calls: usize,
1599 worker_calls: usize,
1600 stale_marks: usize,
1601}
1602
1603static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
1604 OnceLock::new();
1605
1606struct RefreshWorkerTestGate {
1607 held_tx: crossbeam_channel::Sender<()>,
1608 release_rx: crossbeam_channel::Receiver<()>,
1609}
1610
1611static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
1612 OnceLock::new();
1613
1614#[doc(hidden)]
1615pub fn install_callgraph_refresh_worker_test_gate(
1616 project_root: PathBuf,
1617) -> (
1618 crossbeam_channel::Receiver<()>,
1619 crossbeam_channel::Sender<()>,
1620) {
1621 let (held_tx, held_rx) = crossbeam_channel::bounded(1);
1622 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1623 REFRESH_WORKER_TEST_GATES
1624 .get_or_init(|| Mutex::new(HashMap::new()))
1625 .lock()
1626 .expect("callgraph refresh test gate mutex poisoned")
1627 .insert(
1628 project_root,
1629 RefreshWorkerTestGate {
1630 held_tx,
1631 release_rx,
1632 },
1633 );
1634 (held_rx, release_tx)
1635}
1636
1637fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
1638 REFRESH_WORKER_TEST_GATES
1639 .get_or_init(|| Mutex::new(HashMap::new()))
1640 .lock()
1641 .expect("callgraph refresh test gate mutex poisoned")
1642 .remove(project_root)
1643}
1644
1645fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
1646 let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
1647 return RefreshWorkerTestSeam::default();
1648 };
1649 seams
1650 .lock()
1651 .expect("callgraph refresh test seam mutex poisoned")
1652 .get(project_root)
1653 .copied()
1654 .unwrap_or_default()
1655}
1656
1657fn note_refresh_worker_batch_for_test(project_root: &Path) {
1658 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1659 if let Some(seam) = seams
1660 .lock()
1661 .expect("callgraph refresh test seam mutex poisoned")
1662 .get_mut(project_root)
1663 {
1664 seam.worker_calls += 1;
1665 }
1666 }
1667}
1668
1669fn note_refresh_worker_call_for_test(project_root: &Path) {
1670 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1671 if let Some(seam) = seams
1672 .lock()
1673 .expect("callgraph refresh test seam mutex poisoned")
1674 .get_mut(project_root)
1675 {
1676 seam.refresh_calls += 1;
1677 }
1678 }
1679}
1680
1681fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
1682 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1683 if let Some(seam) = seams
1684 .lock()
1685 .expect("callgraph refresh test seam mutex poisoned")
1686 .get_mut(project_root)
1687 {
1688 seam.stale_marks += 1;
1689 }
1690 }
1691}
1692
1693#[doc(hidden)]
1694pub fn set_callgraph_refresh_worker_test_seam(
1695 project_root: PathBuf,
1696 delay: Duration,
1697 fail_refresh: bool,
1698) {
1699 REFRESH_WORKER_TEST_SEAMS
1700 .get_or_init(|| Mutex::new(HashMap::new()))
1701 .lock()
1702 .expect("callgraph refresh test seam mutex poisoned")
1703 .insert(
1704 project_root,
1705 RefreshWorkerTestSeam {
1706 delay,
1707 fail_refresh,
1708 ..RefreshWorkerTestSeam::default()
1709 },
1710 );
1711}
1712
1713#[doc(hidden)]
1714pub fn set_callgraph_refresh_worker_test_open_failure(project_root: PathBuf, enabled: bool) {
1715 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1716 if let Some(seam) = seams
1717 .lock()
1718 .expect("callgraph refresh test seam mutex poisoned")
1719 .get_mut(&project_root)
1720 {
1721 seam.fail_open = enabled;
1722 }
1723 }
1724}
1725
1726#[doc(hidden)]
1727pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
1728 let seam = refresh_worker_test_seam(project_root);
1729 (seam.refresh_calls, seam.stale_marks)
1730}
1731
1732#[doc(hidden)]
1733pub fn callgraph_refresh_worker_test_worker_calls(project_root: &Path) -> usize {
1734 refresh_worker_test_seam(project_root).worker_calls
1735}
1736
1737#[doc(hidden)]
1738pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
1739 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1740 seams
1741 .lock()
1742 .expect("callgraph refresh test seam mutex poisoned")
1743 .remove(project_root);
1744 }
1745}
1746
1747#[derive(Debug)]
1748pub struct CallGraphStore {
1749 project_root: PathBuf,
1750 project_key: String,
1751 sqlite_path: PathBuf,
1755 publication_dir: PathBuf,
1759 legacy_fallback: bool,
1763 generation: Option<String>,
1768 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1769 read_marker: Option<crate::root_cache::ReadMarker>,
1770 database_ready: AtomicBool,
1773 write_metrics: Arc<CallgraphWriteMetrics>,
1774 conn: Mutex<Connection>,
1775}
1776
1777#[derive(Debug)]
1778pub struct ReadonlyCallGraphStore {
1779 inner: CallGraphStore,
1780}
1781
1782pub trait CallGraphRead {
1783 fn project_root(&self) -> &Path;
1784 fn project_key(&self) -> &str;
1785 fn sqlite_path(&self) -> &Path;
1786 fn is_current(&self) -> bool;
1787 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
1788 fn indexed_file_count(&self) -> Result<usize>;
1789 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
1790 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
1791 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
1792 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
1793 fn direct_callers_for_symbols(
1794 &self,
1795 targets: &[(String, String)],
1796 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
1797 targets
1798 .iter()
1799 .cloned()
1800 .map(|target| {
1801 let callers = self.direct_callers_of(Path::new(&target.0), &target.1)?;
1802 Ok((target, callers))
1803 })
1804 .collect()
1805 }
1806 fn direct_caller_counts_of(
1807 &self,
1808 targets: &[(String, String)],
1809 ) -> Result<HashMap<(String, String), usize>>;
1810 fn outgoing_calls_for_symbols(
1811 &self,
1812 sources: &[(String, String)],
1813 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
1814 fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
1815 -> Result<StoreCallersResult>;
1816 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
1817 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1818 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1819 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
1820 fn call_tree(
1821 &self,
1822 file_rel: &Path,
1823 symbol: &str,
1824 depth: usize,
1825 ) -> Result<callgraph::CallTreeNode>;
1826 fn trace_to(
1827 &self,
1828 file_rel: &Path,
1829 symbol: &str,
1830 max_depth: usize,
1831 ) -> Result<callgraph::TraceToResult>;
1832 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
1833 fn trace_to_symbol(
1834 &self,
1835 file_rel: &Path,
1836 symbol: &str,
1837 to_symbol: &str,
1838 to_file: Option<&Path>,
1839 max_depth: usize,
1840 ) -> Result<callgraph::TraceToSymbolResult>;
1841}
1842
1843#[derive(Debug, Clone, PartialEq, Eq)]
1844enum OpenRootRepair {
1845 None,
1846 ReRooted,
1847 NeedsRebuild {
1848 previous_roots: Vec<String>,
1849 current_root: String,
1850 reason: String,
1851 },
1852}
1853
1854struct OpenedStore {
1855 store: CallGraphStore,
1856 root_repair: OpenRootRepair,
1857}
1858
1859#[derive(Clone, Debug)]
1860struct LegacyCallgraphPartition {
1861 harness: String,
1862 dir: PathBuf,
1863 key: String,
1864 bytes: u64,
1865 freshness: Option<SystemTime>,
1866}
1867
1868#[derive(Clone, Debug)]
1869struct LegacyCallgraphTarget {
1870 partition: LegacyCallgraphPartition,
1871 sqlite_path: PathBuf,
1872 generation: Option<String>,
1873 source_bytes: u64,
1874 source_blake3: String,
1875}
1876
1877#[derive(Clone, Debug)]
1878struct SourceFingerprint {
1879 bytes: u64,
1880 blake3: String,
1881}
1882
1883#[derive(Clone, Debug)]
1884struct PublishedLegacyMigration {
1885 generation: String,
1886 migrated_bytes: u64,
1887}
1888
1889#[derive(Debug, Clone)]
1890pub struct ColdBuildStats {
1891 pub files: usize,
1892 pub nodes: usize,
1893 pub refs: usize,
1894 pub edges: usize,
1895 pub failed_files: Vec<String>,
1896 pub elapsed_ms: u128,
1897}
1898
1899#[derive(Debug, Clone)]
1900pub struct IncrementalStats {
1901 pub changed_files: Vec<String>,
1902 pub surface_changed: Vec<String>,
1903 pub deleted_files: Vec<String>,
1904 pub dependency_selected_refs: usize,
1905 pub refreshed_own_files: usize,
1906 pub unchanged_extract_files: usize,
1907}
1908
1909#[doc(hidden)]
1911#[derive(Debug, Clone, Default, PartialEq, Eq)]
1912pub struct RefreshFilesProfile {
1913 pub parse: Duration,
1914 pub dependency_selection: Duration,
1915 pub row_deletes: Duration,
1916 pub row_inserts: Duration,
1917 pub dependent_parse: Duration,
1918 pub index_load: Duration,
1919 pub ref_resolution: Duration,
1920 pub method_dispatch: Duration,
1921 pub commit: Duration,
1922 pub total: Duration,
1923}
1924
1925impl RefreshFilesProfile {
1926 pub fn report(&self) -> String {
1927 format!(
1928 "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",
1929 self.parse.as_millis(),
1930 self.dependency_selection.as_millis(),
1931 self.row_deletes.as_millis(),
1932 self.row_inserts.as_millis(),
1933 self.dependent_parse.as_millis(),
1934 self.index_load.as_millis(),
1935 self.ref_resolution.as_millis(),
1936 self.method_dispatch.as_millis(),
1937 self.commit.as_millis(),
1938 self.total.as_millis(),
1939 )
1940 }
1941}
1942
1943#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1944pub struct StoredEdge {
1945 pub source_file: String,
1946 pub source_symbol: String,
1947 pub target_file: String,
1948 pub target_symbol: String,
1949 pub kind: String,
1950 pub line: u32,
1951}
1952
1953#[derive(Debug, Clone, PartialEq, Eq)]
1954pub struct StoreNode {
1955 node_id: String,
1956 pub file: String,
1957 pub symbol: String,
1958 pub name: String,
1959 pub kind: String,
1960 pub line: u32,
1961 pub end_line: u32,
1962 pub signature: Option<String>,
1963 pub exported: bool,
1964 pub is_entry_point: bool,
1965 pub lang: LangId,
1966}
1967
1968#[cfg(test)]
1969impl StoreNode {
1970 pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
1971 Self {
1972 node_id: format!("{file}:{symbol}"),
1973 file: file.to_string(),
1974 symbol: symbol.to_string(),
1975 name: symbol.to_string(),
1976 kind: "function".to_string(),
1977 line: 1,
1978 end_line: 1,
1979 signature: None,
1980 exported: is_entry_point,
1981 is_entry_point,
1982 lang: LangId::TypeScript,
1983 }
1984 }
1985}
1986
1987#[derive(Debug, Clone, PartialEq, Eq)]
1988pub struct StoreCallSite {
1989 pub caller: StoreNode,
1990 pub target_file: String,
1991 pub target_symbol: String,
1992 pub target: Option<StoreNode>,
1993 pub line: u32,
1994 pub byte_start: usize,
1995 pub byte_end: usize,
1996 pub resolved: bool,
1997 pub provenance: String,
1998}
1999
2000impl StoreCallSite {
2001 pub fn approximate(&self) -> bool {
2002 self.provenance == PROVENANCE_NAME_MATCH
2003 }
2004
2005 pub fn resolved_by(&self) -> &str {
2006 &self.provenance
2007 }
2008
2009 pub fn supplemental_resolution(&self) -> Option<&str> {
2010 match self.provenance.as_str() {
2011 PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
2012 _ => None,
2013 }
2014 }
2015}
2016
2017#[derive(Debug, Clone, PartialEq, Eq)]
2018pub struct StoreUnresolvedCall {
2019 pub caller: StoreNode,
2020 pub symbol: String,
2021 pub full_ref: Option<String>,
2022 pub line: u32,
2023 pub byte_start: usize,
2024 pub byte_end: usize,
2025}
2026
2027#[derive(Debug, Clone, PartialEq, Eq)]
2028pub struct StoreCallersResult {
2029 pub target: StoreNode,
2030 pub callers: Vec<StoreCallSite>,
2031 pub scanned_files: usize,
2032 pub depth_limited: bool,
2033 pub truncated: usize,
2034}
2035
2036#[derive(Debug, Clone, PartialEq, Eq)]
2037pub struct StoreImpactCaller {
2038 pub site: StoreCallSite,
2039 pub signature: Option<String>,
2040 pub is_entry_point: bool,
2041 pub call_expression: Option<String>,
2042 pub parameters: Vec<String>,
2043}
2044
2045#[derive(Debug, Clone, PartialEq, Eq)]
2046pub struct StoreImpactResult {
2047 pub target: StoreNode,
2048 pub parameters: Vec<String>,
2049 pub callers: Vec<StoreImpactCaller>,
2050 pub depth_limited: bool,
2051 pub truncated: usize,
2052}
2053
2054#[derive(Debug, Clone)]
2055struct ExtractFailure {
2056 rel_path: String,
2057 freshness: Option<FileFreshness>,
2058}
2059
2060#[derive(Debug, Clone)]
2061struct BuildExtractsResult {
2062 extracts: Vec<FileExtract>,
2063 failures: Vec<ExtractFailure>,
2064}
2065
2066#[derive(Debug, Clone)]
2067enum StoreForwardCall {
2068 Resolved(StoreCallSite),
2069 Unresolved(StoreUnresolvedCall),
2070}
2071
2072impl StoreForwardCall {
2073 fn byte_start(&self) -> usize {
2074 match self {
2075 Self::Resolved(site) => site.byte_start,
2076 Self::Unresolved(call) => call.byte_start,
2077 }
2078 }
2079
2080 fn line(&self) -> u32 {
2081 match self {
2082 Self::Resolved(site) => site.line,
2083 Self::Unresolved(call) => call.line,
2084 }
2085 }
2086}
2087
2088#[derive(Debug, Clone)]
2089struct FileExtract {
2090 rel_path: String,
2091 freshness: FileFreshness,
2092 lang: LangId,
2093 data: FileCallData,
2094 nodes: Vec<NodeRecord>,
2095 raw_refs: Vec<RawRef>,
2096 dispatch_hints: Vec<DispatchHint>,
2097 surface_fingerprint: String,
2098}
2099
2100#[derive(Debug, Clone)]
2101struct NodeRecord {
2102 id: String,
2103 file_path: String,
2104 name: String,
2105 scoped_name: String,
2106 kind: String,
2107 range: Range,
2108 range_ordinal: u32,
2109 signature: Option<String>,
2110 exported: bool,
2111 is_default_export: bool,
2112 is_type_like: bool,
2113 is_callgraph_entry_point: bool,
2114}
2115
2116#[derive(Debug, Clone)]
2117struct RawRef {
2118 ref_id: String,
2119 caller_node: Option<String>,
2120 caller_symbol: Option<String>,
2121 caller_file: String,
2122 kind: String,
2123 short_name: Option<String>,
2124 full_ref: Option<String>,
2125 module_path: Option<String>,
2126 import_kind: Option<String>,
2127 local_name: Option<String>,
2128 requested_name: Option<String>,
2129 namespace_alias: Option<String>,
2130 wildcard: bool,
2131 line: u32,
2132 byte_start: usize,
2133 byte_end: usize,
2134 dependencies: BTreeSet<String>,
2135}
2136
2137#[derive(Debug)]
2141struct StagedRef {
2142 rowid: u64,
2143 raw: RawRef,
2144}
2145
2146#[derive(Debug, Clone)]
2147struct ResolvedRef {
2148 raw: RawRef,
2149 status: String,
2150 target_node: Option<String>,
2151 target_file: Option<String>,
2152 target_symbol: Option<String>,
2153 dependencies: BTreeSet<String>,
2154 edge: Option<EdgeRecord>,
2155}
2156
2157#[derive(Debug, Clone)]
2158struct EdgeRecord {
2159 edge_id: String,
2160 source_node: String,
2161 target_node: Option<String>,
2162 target_file: String,
2163 target_symbol: String,
2164 kind: String,
2165 line: u32,
2166}
2167
2168#[derive(Debug, Clone)]
2169struct DispatchHint {
2170 id: String,
2171 method_name: String,
2172 caller_node: String,
2173 file: String,
2174 line: u32,
2175 byte_start: usize,
2176 byte_end: usize,
2177}
2178
2179#[derive(Debug, Clone)]
2180struct NameMatchRef {
2181 ref_id: String,
2182 caller_node: String,
2183 caller_file: String,
2184 caller_symbol: String,
2185 caller_signature: Option<String>,
2186 receiver_expression: String,
2187 receiver: String,
2188 method_name: String,
2189 colon_dispatch: bool,
2190 line: u32,
2191 lang: String,
2192}
2193
2194#[derive(Debug, Clone)]
2195struct NameMatchCandidate {
2196 node_id: String,
2197 file_path: String,
2198 scoped_name: String,
2199 kind: String,
2200 start_line: u32,
2202}
2203
2204#[derive(Debug, Clone)]
2205struct FileRow {
2206 surface_fingerprint: String,
2207 freshness: FileFreshness,
2208}
2209
2210#[derive(Debug, Clone)]
2211struct DbFileIndex {
2212 lang: Option<LangId>,
2213 exports: HashSet<String>,
2214 default_export: Option<String>,
2215 export_aliases: HashMap<String, String>,
2216 node_by_scoped: HashMap<String, String>,
2217 node_by_bare: HashMap<String, String>,
2218 node_kind_by_id: HashMap<String, String>,
2219 module_targets: HashMap<String, Option<String>>,
2220 declared_module_targets: HashMap<String, Option<String>>,
2221 reexports: Vec<ReexportIndex>,
2222}
2223
2224#[derive(Debug, Clone)]
2225struct ReexportIndex {
2226 target_file: Option<String>,
2227 named: HashMap<String, String>,
2228 wildcard: bool,
2229}
2230
2231#[derive(Debug, Clone)]
2232struct ProjectIndex<'a> {
2233 project_root: PathBuf,
2234 files: HashMap<String, DbFileIndex>,
2235 caller_data: HashMap<String, &'a FileCallData>,
2236 workspace_crate_prefixes: WorkspaceCratePrefixCache,
2241}
2242
2243trait ResolverIndex {
2247 fn caller_data(&self, file: &str) -> Option<&FileCallData>;
2248 fn lang_for(&self, file: &str) -> Option<LangId>;
2249 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String>;
2250 fn module_parent(&self, target_file: &str) -> Option<(String, String)>;
2251 fn reexports_for(&self, file: &str) -> Vec<ReexportIndex>;
2252 fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String>;
2253 fn node_is_callable(&self, file: &str, node_id: &str) -> bool;
2254 fn export_alias(&self, file: &str, symbol: &str) -> Option<String>;
2255 fn has_export(&self, file: &str, symbol: &str) -> bool;
2256 fn default_export(&self, file: &str) -> Option<String>;
2257 fn contains_file(&self, file: &str) -> bool;
2258 fn crate_src_prefix(&self, crate_name: &str) -> Option<String>;
2259 fn inline_scoped_target(
2260 &self,
2261 caller_file: &str,
2262 module_segments: &[String],
2263 short_name: &str,
2264 ) -> Option<(String, String)>;
2265}
2266
2267impl ResolverIndex for ProjectIndex<'_> {
2268 fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2269 self.caller_data.get(file).copied()
2270 }
2271
2272 fn lang_for(&self, file: &str) -> Option<LangId> {
2273 self.lang_for(file)
2274 }
2275
2276 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2277 self.module_target(caller_file, module_path)
2278 }
2279
2280 fn module_parent(&self, target_file: &str) -> Option<(String, String)> {
2281 let mut parents = self
2282 .files
2283 .iter()
2284 .flat_map(|(file, index)| {
2285 index
2286 .declared_module_targets
2287 .iter()
2288 .filter_map(move |(module, target)| {
2289 (target.as_deref() == Some(target_file))
2290 .then(|| (file.clone(), module.clone()))
2291 })
2292 })
2293 .collect::<Vec<_>>();
2294 parents.sort();
2295 parents.into_iter().next()
2296 }
2297
2298 fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2299 self.reexports_for(file).to_vec()
2300 }
2301
2302 fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2303 self.node_for_symbol(file, symbol)
2304 }
2305
2306 fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2307 self.node_is_callable(file, node_id)
2308 }
2309
2310 fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2311 self.files
2312 .get(file)
2313 .and_then(|item| item.export_aliases.get(symbol))
2314 .cloned()
2315 }
2316
2317 fn has_export(&self, file: &str, symbol: &str) -> bool {
2318 self.files
2319 .get(file)
2320 .is_some_and(|item| item.exports.contains(symbol))
2321 }
2322
2323 fn default_export(&self, file: &str) -> Option<String> {
2324 self.files
2325 .get(file)
2326 .and_then(|item| item.default_export.clone())
2327 }
2328
2329 fn contains_file(&self, file: &str) -> bool {
2330 self.files.contains_key(file)
2331 }
2332
2333 fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2334 self.workspace_crate_prefixes
2335 .0
2336 .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
2337 .get(crate_name)
2338 .cloned()
2339 }
2340
2341 fn inline_scoped_target(
2342 &self,
2343 caller_file: &str,
2344 module_segments: &[String],
2345 short_name: &str,
2346 ) -> Option<(String, String)> {
2347 let src_prefix = rust_src_prefix(caller_file);
2348 let mut file_paths = self.files.keys().cloned().collect::<Vec<_>>();
2349 file_paths.sort();
2350 if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
2351 let caller = file_paths.remove(position);
2352 file_paths.insert(0, caller);
2353 }
2354 for file_path in file_paths {
2355 if self.lang_for(&file_path) != Some(LangId::Rust)
2356 || rust_src_prefix(&file_path) != src_prefix
2357 {
2358 continue;
2359 }
2360 let file_module_segments = rust_module_segments_for_rel(&file_path);
2361 if !module_segments.starts_with(&file_module_segments) {
2362 continue;
2363 }
2364 let scoped_segments = &module_segments[file_module_segments.len()..];
2365 if scoped_segments.is_empty() {
2366 continue;
2367 }
2368 let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2369 if self.node_for_symbol(&file_path, &scoped_symbol).is_some() {
2370 return Some((file_path, scoped_symbol));
2371 }
2372 }
2373 None
2374 }
2375}
2376
2377struct DiskProjectIndex<'a> {
2381 project_root: &'a Path,
2382 conn: &'a Connection,
2383 caller_file: &'a str,
2384 caller_data: &'a FileCallData,
2385 workspace_crate_prefixes: WorkspaceCratePrefixCache,
2386 module_resolution_memo: &'a callgraph::ModuleResolutionMemo,
2387}
2388
2389impl DiskProjectIndex<'_> {
2390 fn file_index(&self, rel_path: &str) -> Option<DbFileIndex> {
2391 let lang: String = self
2392 .conn
2393 .query_row(
2394 "SELECT lang FROM files WHERE path = ?1",
2395 params![rel_path],
2396 |row| row.get(0),
2397 )
2398 .optional()
2399 .ok()??;
2400 let mut index = DbFileIndex {
2401 lang: lang_from_label(&lang),
2402 exports: HashSet::new(),
2403 default_export: None,
2404 export_aliases: HashMap::new(),
2405 node_by_scoped: HashMap::new(),
2406 node_by_bare: HashMap::new(),
2407 node_kind_by_id: HashMap::new(),
2408 module_targets: HashMap::new(),
2409 declared_module_targets: HashMap::new(),
2410 reexports: Vec::new(),
2411 };
2412 let mut nodes = self
2413 .conn
2414 .prepare(
2415 "SELECT id, name, scoped_name, kind, exported, is_default_export
2416 FROM nodes WHERE file_path = ?1",
2417 )
2418 .ok()?;
2419 let rows = nodes
2420 .query_map(params![rel_path], |row| {
2421 Ok((
2422 row.get::<_, String>(0)?,
2423 row.get::<_, String>(1)?,
2424 row.get::<_, String>(2)?,
2425 row.get::<_, String>(3)?,
2426 row.get::<_, i64>(4)? != 0,
2427 row.get::<_, i64>(5)? != 0,
2428 ))
2429 })
2430 .ok()?
2431 .collect::<std::result::Result<Vec<_>, _>>()
2432 .ok()?;
2433 drop(nodes);
2434 for (id, name, scoped_name, kind, exported, is_default_export) in rows {
2435 if exported {
2436 index.exports.insert(name.clone());
2437 index.exports.insert(scoped_name.clone());
2438 }
2439 if is_default_export {
2440 index.default_export = Some(scoped_name.clone());
2441 }
2442 index.node_by_scoped.insert(scoped_name, id.clone());
2443 index.node_by_bare.entry(name).or_insert(id.clone());
2444 index.node_kind_by_id.insert(id, kind);
2445 }
2446
2447 let mut refs = self
2448 .conn
2449 .prepare(
2450 "SELECT ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name
2451 FROM refs
2452 WHERE caller_file = ?1 AND kind IN ('import', 'module', 'reexport', 'export_alias')",
2453 )
2454 .ok()?;
2455 let rows = refs
2456 .query_map(params![rel_path], |row| {
2457 Ok((
2458 row.get::<_, String>(0)?,
2459 row.get::<_, String>(1)?,
2460 row.get::<_, Option<String>>(2)?,
2461 row.get::<_, Option<String>>(3)?,
2462 row.get::<_, i64>(4)? != 0,
2463 row.get::<_, Option<String>>(5)?,
2464 row.get::<_, Option<String>>(6)?,
2465 ))
2466 })
2467 .ok()?
2468 .collect::<std::result::Result<Vec<_>, _>>()
2469 .ok()?;
2470 drop(refs);
2471 for (ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name) in rows {
2472 if kind == "export_alias" {
2473 if let (Some(exported), Some(source)) = (local_name, requested_name) {
2474 index.export_aliases.insert(exported, source);
2475 }
2476 continue;
2477 }
2478 let Some(module_path) = module_path else {
2479 continue;
2480 };
2481 let target_file = if kind == "module" {
2482 rust_declared_module_target(&self.project_root, rel_path, &module_path)
2483 } else {
2484 self.disk_module_target(rel_path, &module_path)
2485 }
2486 .or_else(|| {
2487 self.conn
2488 .query_row(
2489 "SELECT d.dep_file
2490 FROM file_dependencies d
2491 JOIN files f ON f.path = d.dep_file
2492 WHERE d.file_path = ?1
2493 ORDER BY d.dep_file
2494 LIMIT 1",
2495 params![rel_path],
2496 |row| row.get::<_, String>(0),
2497 )
2498 .optional()
2499 .ok()
2500 .flatten()
2501 });
2502 index
2503 .module_targets
2504 .entry(module_path.clone())
2505 .or_insert_with(|| target_file.clone());
2506 if kind == "module" {
2507 index
2508 .declared_module_targets
2509 .entry(module_path.clone())
2510 .or_insert_with(|| target_file.clone());
2511 }
2512 if kind == "reexport" {
2513 let raw = RawRef {
2514 ref_id,
2515 caller_node: None,
2516 caller_symbol: None,
2517 caller_file: rel_path.to_string(),
2518 kind,
2519 short_name: None,
2520 full_ref,
2521 module_path: Some(module_path),
2522 import_kind: Some("reexport".to_string()),
2523 local_name: None,
2524 requested_name: None,
2525 namespace_alias: None,
2526 wildcard,
2527 line: 0,
2528 byte_start: 0,
2529 byte_end: 0,
2530 dependencies: BTreeSet::new(),
2531 };
2532 index
2533 .reexports
2534 .push(reexport_index_from_raw(&raw, target_file));
2535 }
2536 }
2537 Some(index)
2538 }
2539
2540 fn disk_module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2541 let caller_dir = self.project_root.join(caller_file).parent()?.to_path_buf();
2542 let candidate = callgraph::resolve_module_path_with_memo(
2543 &caller_dir,
2544 module_path,
2545 self.module_resolution_memo,
2546 )?;
2547 let rel_path = relative_path(self.project_root, &candidate);
2548 self.contains_file(&rel_path).then_some(rel_path)
2549 }
2550}
2551
2552impl ResolverIndex for DiskProjectIndex<'_> {
2553 fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2554 (file == self.caller_file).then_some(self.caller_data)
2555 }
2556
2557 fn lang_for(&self, file: &str) -> Option<LangId> {
2558 self.file_index(file).and_then(|index| index.lang)
2559 }
2560
2561 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2562 self.file_index(caller_file)
2563 .and_then(|index| index.module_targets.get(module_path).cloned().flatten())
2564 }
2565
2566 fn module_parent(&self, target_file: &str) -> Option<(String, String)> {
2567 let mut stmt = self
2568 .conn
2569 .prepare(
2570 "SELECT caller_file, module_path FROM refs
2571 WHERE kind = 'module' AND module_path IS NOT NULL
2572 ORDER BY caller_file, module_path",
2573 )
2574 .ok()?;
2575 let rows = stmt
2576 .query_map([], |row| {
2577 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2578 })
2579 .ok()?;
2580 for row in rows.flatten() {
2581 if self.module_target(&row.0, &row.1).as_deref() == Some(target_file) {
2582 return Some(row);
2583 }
2584 }
2585 None
2586 }
2587
2588 fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2589 self.file_index(file)
2590 .map(|index| index.reexports)
2591 .unwrap_or_default()
2592 }
2593
2594 fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2595 self.file_index(file).and_then(|index| {
2596 index
2597 .node_by_scoped
2598 .get(symbol)
2599 .cloned()
2600 .or_else(|| index.node_by_bare.get(symbol).cloned())
2601 })
2602 }
2603
2604 fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2605 self.file_index(file)
2606 .and_then(|index| index.node_kind_by_id.get(node_id).cloned())
2607 .is_some_and(|kind| matches!(kind.as_str(), "function" | "method"))
2608 }
2609
2610 fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2611 self.file_index(file)
2612 .and_then(|index| index.export_aliases.get(symbol).cloned())
2613 }
2614
2615 fn has_export(&self, file: &str, symbol: &str) -> bool {
2616 self.file_index(file)
2617 .is_some_and(|index| index.exports.contains(symbol))
2618 }
2619
2620 fn default_export(&self, file: &str) -> Option<String> {
2621 self.file_index(file).and_then(|index| index.default_export)
2622 }
2623
2624 fn contains_file(&self, file: &str) -> bool {
2625 self.conn
2626 .query_row(
2627 "SELECT 1 FROM files WHERE path = ?1 LIMIT 1",
2628 params![file],
2629 |_| Ok(()),
2630 )
2631 .is_ok()
2632 }
2633
2634 fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2635 self.workspace_crate_prefixes
2636 .0
2637 .get_or_init(|| build_workspace_crate_prefixes(self.project_root))
2638 .get(crate_name)
2639 .cloned()
2640 }
2641
2642 fn inline_scoped_target(
2643 &self,
2644 caller_file: &str,
2645 module_segments: &[String],
2646 short_name: &str,
2647 ) -> Option<(String, String)> {
2648 let src_prefix = rust_src_prefix(caller_file);
2649 let check = |file_path: String| {
2650 let file_module_segments = rust_module_segments_for_rel(&file_path);
2651 if rust_src_prefix(&file_path) != src_prefix
2652 || !module_segments.starts_with(&file_module_segments)
2653 {
2654 return None;
2655 }
2656 let scoped_segments = &module_segments[file_module_segments.len()..];
2657 if scoped_segments.is_empty() {
2658 return None;
2659 }
2660 let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2661 self.node_for_symbol(&file_path, &scoped_symbol)
2662 .map(|_| (file_path, scoped_symbol))
2663 };
2664 if let Some(target) = check(caller_file.to_string()) {
2665 return Some(target);
2666 }
2667 let mut statement = self
2668 .conn
2669 .prepare("SELECT path FROM files WHERE lang = 'rust' AND path <> ?1 ORDER BY path")
2670 .ok()?;
2671 let rows = statement
2672 .query_map(params![caller_file], |row| row.get::<_, String>(0))
2673 .ok()?;
2674 for path in rows.flatten() {
2675 if let Some(target) = check(path) {
2676 return Some(target);
2677 }
2678 }
2679 None
2680 }
2681}
2682
2683impl CallGraphStore {
2684 pub fn open_if_enabled(
2685 options: CallGraphStoreOptions,
2686 callgraph_dir: PathBuf,
2687 project_root: PathBuf,
2688 ) -> Result<Option<Self>> {
2689 if !options.enabled {
2690 return Ok(None);
2691 }
2692 Self::open(callgraph_dir, project_root).map(Some)
2693 }
2694
2695 pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
2696 let project_key = crate::search_index::artifact_cache_key(&project_root);
2697 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2698 else {
2699 return Err(CallGraphStoreError::Unavailable(
2700 "writer capability denied; use the read-only callgraph opener".to_string(),
2701 ));
2702 };
2703 std::fs::create_dir_all(&callgraph_dir)?;
2704 let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
2708 .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
2709 let OpenedStore { store, root_repair } = Self::open_at_path(
2710 project_root.clone(),
2711 project_key,
2712 sqlite_path,
2713 generation,
2714 true,
2715 Some(Arc::clone(&writer_lease)),
2716 None,
2717 )?;
2718 match root_repair {
2719 OpenRootRepair::NeedsRebuild { .. } => {
2720 log_root_repair_rebuild(&root_repair);
2721 drop(store);
2722 drop(writer_lease);
2723 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2724 let (store, _stats) =
2725 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2726 Ok(store)
2727 }
2728 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
2729 }
2730 }
2731
2732 pub fn open_readonly(
2733 callgraph_dir: PathBuf,
2734 project_root: PathBuf,
2735 ) -> Result<Option<ReadonlyCallGraphStore>> {
2736 let project_key = crate::search_index::artifact_cache_key(&project_root);
2737 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2738 {
2739 let conn = open_readonly_connection(&sqlite_path)?;
2740 if !database_ready(&conn).unwrap_or(false) {
2741 return Ok(None);
2742 }
2743 let marker_label = generation.as_deref().unwrap_or("legacy");
2744 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
2745 return Ok(Some(ReadonlyCallGraphStore::from_inner(
2746 Self::from_connection(
2747 project_root,
2748 project_key,
2749 sqlite_path,
2750 callgraph_dir,
2751 false,
2752 generation,
2753 None,
2754 Some(read_marker),
2755 conn,
2756 ),
2757 )));
2758 }
2759
2760 let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
2761 return Ok(None);
2762 };
2763 crate::slog_warn!(
2764 "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
2765 target.partition.harness,
2766 target.sqlite_path.display()
2767 );
2768 let conn = open_readonly_connection(&target.sqlite_path)?;
2769 if !database_ready(&conn).unwrap_or(false) {
2770 return Ok(None);
2771 }
2772 let marker_label =
2773 legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
2774 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
2775 Ok(Some(ReadonlyCallGraphStore::from_inner(
2776 Self::from_connection(
2777 project_root,
2778 project_key,
2779 target.sqlite_path,
2780 callgraph_dir,
2781 true,
2782 target.generation,
2783 None,
2784 Some(read_marker),
2785 conn,
2786 ),
2787 )))
2788 }
2789
2790 pub fn open_ready_repairing(
2796 callgraph_dir: PathBuf,
2797 project_root: PathBuf,
2798 ) -> Result<Option<Self>> {
2799 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true)
2800 }
2801
2802 pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
2806 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false)
2807 }
2808
2809 pub fn open_ready_no_rebuild(
2810 callgraph_dir: PathBuf,
2811 project_root: PathBuf,
2812 ) -> Result<Option<Self>> {
2813 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true)
2814 }
2815
2816 fn open_ready_with_rebuild_policy(
2817 callgraph_dir: PathBuf,
2818 project_root: PathBuf,
2819 allow_cold_build: bool,
2820 allow_root_repair: bool,
2821 ) -> Result<Option<Self>> {
2822 let project_key = crate::search_index::artifact_cache_key(&project_root);
2823 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2824 else {
2825 return Ok(None);
2826 };
2827 let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2828 else {
2829 return Ok(None);
2830 };
2831 let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
2832 project_root.clone(),
2833 project_key.clone(),
2834 sqlite_path,
2835 generation,
2836 true,
2837 Some(Arc::clone(&writer_lease)),
2838 None,
2839 allow_root_repair,
2840 )?;
2841 match root_repair {
2842 OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
2843 log_root_repair_rebuild(&root_repair);
2844 drop(store);
2845 drop(writer_lease);
2846 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2847 let (store, _stats) =
2848 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2849 Ok(Some(store))
2850 }
2851 OpenRootRepair::NeedsRebuild { .. } => {
2852 if let Some(message) = note_repair_entry(&project_key) {
2853 crate::slog_warn!("{message}");
2854 }
2855 Ok(None)
2856 }
2857 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2858 }
2859 }
2860
2861 pub fn cold_build_with_lease(
2862 callgraph_dir: PathBuf,
2863 project_root: PathBuf,
2864 files: &[PathBuf],
2865 ) -> Result<(Self, ColdBuildStats)> {
2866 Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
2867 }
2868
2869 pub fn cold_build_with_lease_chunked(
2870 callgraph_dir: PathBuf,
2871 project_root: PathBuf,
2872 files: &[PathBuf],
2873 chunk_size: usize,
2874 ) -> Result<(Self, ColdBuildStats)> {
2875 Self::cold_build_with_lease_chunked_inner(
2876 callgraph_dir,
2877 project_root,
2878 files,
2879 chunk_size,
2880 false,
2881 )
2882 }
2883
2884 pub(crate) fn force_cold_build_with_lease_chunked(
2885 callgraph_dir: PathBuf,
2886 project_root: PathBuf,
2887 files: &[PathBuf],
2888 chunk_size: usize,
2889 ) -> Result<(Self, ColdBuildStats)> {
2890 Self::cold_build_with_lease_chunked_inner(
2891 callgraph_dir,
2892 project_root,
2893 files,
2894 chunk_size,
2895 true,
2896 )
2897 }
2898
2899 fn cold_build_with_lease_chunked_inner(
2900 callgraph_dir: PathBuf,
2901 project_root: PathBuf,
2902 files: &[PathBuf],
2903 chunk_size: usize,
2904 require_new_publication: bool,
2905 ) -> Result<(Self, ColdBuildStats)> {
2906 let project_key = crate::search_index::artifact_cache_key(&project_root);
2907 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2908 else {
2909 let operation = if require_new_publication {
2910 "forced rebuild"
2911 } else {
2912 "cold build"
2913 };
2914 return Err(CallGraphStoreError::Unavailable(format!(
2915 "{operation} could not acquire writer capability"
2916 )));
2917 };
2918 std::fs::create_dir_all(&callgraph_dir)?;
2919 let (stats, generation) = Self::cold_build_publish_locked(
2920 &callgraph_dir,
2921 &project_root,
2922 &project_key,
2923 files,
2924 chunk_size,
2925 Arc::clone(&writer_lease),
2926 )?;
2927 let store = Self::open_generation(
2928 &callgraph_dir,
2929 project_root,
2930 project_key,
2931 generation,
2932 writer_lease,
2933 )?;
2934 Ok((store, stats))
2935 }
2936
2937 pub fn ensure_built_with_lease(
2938 callgraph_dir: PathBuf,
2939 project_root: PathBuf,
2940 files: &[PathBuf],
2941 ) -> Result<(Self, Option<ColdBuildStats>)> {
2942 Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
2943 }
2944
2945 pub fn ensure_built_with_lease_chunked(
2946 callgraph_dir: PathBuf,
2947 project_root: PathBuf,
2948 files: &[PathBuf],
2949 chunk_size: usize,
2950 ) -> Result<(Self, Option<ColdBuildStats>)> {
2951 let project_key = crate::search_index::artifact_cache_key(&project_root);
2952 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2953 else {
2954 return Err(CallGraphStoreError::Unavailable(
2955 "callgraph ensure could not acquire writer capability".to_string(),
2956 ));
2957 };
2958 std::fs::create_dir_all(&callgraph_dir)?;
2959 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2960 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2967 {
2968 let OpenedStore { store, root_repair } = Self::open_at_path(
2969 project_root.clone(),
2970 project_key.clone(),
2971 sqlite_path,
2972 generation,
2973 true,
2974 Some(Arc::clone(&writer_lease)),
2975 None,
2976 )?;
2977 match root_repair {
2978 OpenRootRepair::NeedsRebuild { .. } => {
2979 log_root_repair_rebuild(&root_repair);
2980 drop(store);
2981 let (stats, generation) = Self::cold_build_publish_locked(
2982 &callgraph_dir,
2983 &project_root,
2984 &project_key,
2985 files,
2986 chunk_size,
2987 Arc::clone(&writer_lease),
2988 )?;
2989 let store = Self::open_generation(
2990 &callgraph_dir,
2991 project_root,
2992 project_key,
2993 generation,
2994 writer_lease,
2995 )?;
2996 return Ok((store, Some(stats)));
2997 }
2998 OpenRootRepair::None | OpenRootRepair::ReRooted => {
2999 return Ok((store, None));
3000 }
3001 }
3002 }
3003 if let Some(store) = try_legacy_migration_or_fallback(
3004 &callgraph_dir,
3005 &project_root,
3006 &project_key,
3007 Arc::clone(&writer_lease),
3008 )? {
3009 return Ok((store, None));
3010 }
3011 let (stats, generation) = Self::cold_build_publish_locked(
3012 &callgraph_dir,
3013 &project_root,
3014 &project_key,
3015 files,
3016 chunk_size,
3017 Arc::clone(&writer_lease),
3018 )?;
3019 let store = Self::open_generation(
3020 &callgraph_dir,
3021 project_root,
3022 project_key,
3023 generation,
3024 writer_lease,
3025 )?;
3026 Ok((store, Some(stats)))
3027 }
3028
3029 pub fn migrate_legacy_with_lease(
3036 callgraph_dir: PathBuf,
3037 project_root: PathBuf,
3038 ) -> Result<Option<Self>> {
3039 let project_key = crate::search_index::artifact_cache_key(&project_root);
3040 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
3041 else {
3042 return Ok(None);
3043 };
3044 std::fs::create_dir_all(&callgraph_dir)?;
3045 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
3046
3047 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
3051 {
3052 let OpenedStore { store, root_repair } = Self::open_at_path(
3053 project_root,
3054 project_key,
3055 sqlite_path,
3056 generation,
3057 true,
3058 Some(writer_lease),
3059 None,
3060 )?;
3061 return match root_repair {
3062 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
3063 OpenRootRepair::NeedsRebuild { reason, .. } => {
3064 Err(CallGraphStoreError::Unavailable(format!(
3065 "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
3066 )))
3067 }
3068 };
3069 }
3070
3071 let store = try_legacy_migration_or_fallback(
3072 &callgraph_dir,
3073 &project_root,
3074 &project_key,
3075 writer_lease,
3076 )?;
3077 Ok(store.filter(|store| !store.is_legacy_fallback()))
3081 }
3082
3083 fn cold_build_publish_locked(
3094 callgraph_dir: &Path,
3095 project_root: &Path,
3096 project_key: &str,
3097 files: &[PathBuf],
3098 chunk_size: usize,
3099 writer_lease: Arc<crate::root_cache::WriterLease>,
3100 ) -> Result<(ColdBuildStats, String)> {
3101 if let Some((previous_root, remaining)) =
3102 rebuild_cooldown_denial(callgraph_dir, project_key, project_root, Instant::now())
3103 {
3104 return Err(CallGraphStoreError::Unavailable(format!(
3105 "cache key {project_key} was rebuilt for {} too recently; retry {} ms after the per-key cooldown",
3106 previous_root.display(),
3107 remaining.as_millis()
3108 )));
3109 }
3110 let breaker = crate::build_breaker::BuildDeathBreaker::open(
3111 callgraph_dir.join("build-breaker.sqlite"),
3112 )
3113 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
3114
3115 let generation = generation_file_name(project_key);
3116 let gen_path = callgraph_dir.join(&generation);
3117 let temp_path = callgraph_dir.join(format!("{project_key}.staging.sqlite.tmp.resume"));
3121 let adopting_staging = temp_path.exists();
3122 if !adopting_staging {
3123 remove_sqlite_file_set(&temp_path);
3124 }
3125
3126 let (stats, breaker_key) = {
3127 if adopting_staging {
3128 crate::slog_info!(
3129 "resuming callgraph cold build from staged generation {}",
3130 temp_path.display()
3131 );
3132 }
3133 let temp_store = Self::open_at_path(
3134 project_root.to_path_buf(),
3135 project_key.to_string(),
3136 temp_path.clone(),
3137 None,
3138 false,
3139 Some(Arc::clone(&writer_lease)),
3140 None,
3141 )?
3142 .store;
3143 let admission_fingerprint = corpus_fingerprint_for(project_root, files)?;
3154 let breaker_key = crate::build_breaker::BreakerKey::new(
3155 project_root.display().to_string(),
3156 crate::build_breaker::BuildDomain::CallgraphCold,
3157 admission_fingerprint,
3158 );
3159 match breaker
3160 .admit(&breaker_key, 0)
3161 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?
3162 {
3163 crate::build_breaker::BreakerAdmission::Admitted(_) => {}
3164 crate::build_breaker::BreakerAdmission::Suspended(suspension) => {
3165 return Err(CallGraphStoreError::Suspended(suspension));
3166 }
3167 }
3168 ensure_cold_build_current("inventory", 0, 1)?;
3169 let corpus_fingerprint = temp_store.stage_cold_build_file_inventory(files)?;
3170 ensure_cold_build_current("inventory", 1, 1)?;
3171 let stats = temp_store
3172 .cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)?;
3173 let _ = temp_store.checkpoint_wal_truncate();
3174 temp_store.prepare_for_atomic_swap()?;
3175 (stats, breaker_key)
3176 };
3177
3178 notify_cold_build_before_publish_observer();
3179 let publication = publish_if_current(|| {
3180 verify_writer_lease(&writer_lease)?;
3181 remove_sqlite_file_set(&gen_path);
3184 crate::fs_lock::rename_over(&temp_path, &gen_path)?;
3185 crate::fs_lock::sync_parent(&gen_path);
3186 remove_sqlite_sidecars(&gen_path);
3187
3188 notify_cold_build_swap_observer(&temp_path, &gen_path);
3189
3190 verify_writer_lease(&writer_lease)?;
3192 publish_pointer(callgraph_dir, project_key, &generation)?;
3193 gc_old_generations(callgraph_dir, project_key, &generation);
3194 sweep_orphaned_build_temps_store_wide(callgraph_dir);
3198 sweep_orphaned_callgraph_root_dirs(callgraph_dir);
3199 crate::search_index::sweep_transient_search_cache_dirs();
3200 if let Some(storage_root) = root_storage_dir(callgraph_dir) {
3201 let inspect_root =
3202 storage_root.join(crate::root_cache::RootCacheDomain::Inspect.as_str());
3203 let live_scope_keys = crate::root_cache::live_scope_keys_for_storage(&storage_root);
3204 crate::inspect::cache::sweep_inspect_scope_dirs(&inspect_root, &live_scope_keys);
3205 }
3206 Ok(())
3207 });
3208 publication?;
3212 breaker
3215 .record_ready_publication(&breaker_key)
3216 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
3217 record_successful_rebuild(callgraph_dir, project_key, project_root, Instant::now());
3218 Ok((stats, generation))
3219 }
3220
3221 fn open_generation(
3224 callgraph_dir: &Path,
3225 project_root: PathBuf,
3226 project_key: String,
3227 generation: String,
3228 writer_lease: Arc<crate::root_cache::WriterLease>,
3229 ) -> Result<Self> {
3230 let gen_path = callgraph_dir.join(&generation);
3231 Ok(Self::open_at_path(
3232 project_root,
3233 project_key,
3234 gen_path,
3235 Some(generation),
3236 true,
3237 Some(writer_lease),
3238 None,
3239 )?
3240 .store)
3241 }
3242
3243 pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
3244 let project_key = crate::search_index::artifact_cache_key(project_root);
3245 Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
3248 }
3249
3250 pub fn cold_build_suspension(
3255 callgraph_dir: &Path,
3256 project_root: &Path,
3257 ) -> Result<Option<crate::build_breaker::BuildSuspension>> {
3258 let breaker_path = callgraph_dir.join("build-breaker.sqlite");
3259 if !breaker_path.exists() {
3260 return Ok(None);
3261 }
3262 let key = crate::build_breaker::BreakerKey::new(
3263 project_root.display().to_string(),
3264 crate::build_breaker::BuildDomain::CallgraphCold,
3265 callgraph_corpus_fingerprint(project_root)?,
3266 );
3267 crate::build_breaker::BuildDeathBreaker::open(breaker_path)
3268 .and_then(|breaker| breaker.suspension(&key))
3269 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))
3270 }
3271
3272 fn open_at_path(
3273 project_root: PathBuf,
3274 project_key: String,
3275 sqlite_path: PathBuf,
3276 generation: Option<String>,
3277 use_wal: bool,
3278 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3279 read_marker: Option<crate::root_cache::ReadMarker>,
3280 ) -> Result<OpenedStore> {
3281 Self::open_at_path_with_root_repair(
3282 project_root,
3283 project_key,
3284 sqlite_path,
3285 generation,
3286 use_wal,
3287 writer_lease,
3288 read_marker,
3289 true,
3290 )
3291 }
3292
3293 fn open_at_path_with_root_repair(
3294 project_root: PathBuf,
3295 project_key: String,
3296 sqlite_path: PathBuf,
3297 generation: Option<String>,
3298 use_wal: bool,
3299 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3300 read_marker: Option<crate::root_cache::ReadMarker>,
3301 allow_root_repair: bool,
3302 ) -> Result<OpenedStore> {
3303 if let Some(lease) = writer_lease.as_ref() {
3304 verify_writer_lease(lease)?;
3305 }
3306 if let Some(parent) = sqlite_path.parent() {
3307 std::fs::create_dir_all(parent)?;
3308 }
3309 let mut conn = Connection::open(&sqlite_path)?;
3310 if use_wal {
3311 configure_connection(&conn)?;
3312 } else {
3313 configure_build_connection(&conn)?;
3314 }
3315 if let Some(lease) = writer_lease.as_ref() {
3316 verify_writer_lease(lease)?;
3317 }
3318 initialize_schema(&conn)?;
3319 if let Some(lease) = writer_lease.as_ref() {
3320 verify_writer_lease(lease)?;
3321 }
3322 let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
3323 let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
3324 (Some(marker), _, _) => Some(marker),
3325 (None, Some(label), Some(cache_dir)) => {
3326 Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
3327 }
3328 (None, _, _) => None,
3329 };
3330 let publication_dir = sqlite_path
3331 .parent()
3332 .map(Path::to_path_buf)
3333 .unwrap_or_default();
3334 let store = Self::from_connection(
3335 project_root,
3336 project_key,
3337 sqlite_path,
3338 publication_dir,
3339 false,
3340 generation,
3341 writer_lease,
3342 read_marker,
3343 conn,
3344 );
3345 Ok(OpenedStore { store, root_repair })
3346 }
3347
3348 fn prepare_for_atomic_swap(&self) -> Result<()> {
3349 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3350 conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
3351 Ok(())
3352 }
3353
3354 fn atomic_swap_checkpoint_sql(&self) -> &'static str {
3355 let protected_reader = self.generation.as_deref().is_some_and(|generation| {
3356 self.sqlite_path
3357 .parent()
3358 .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
3359 });
3360 if protected_reader {
3361 "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
3362 } else {
3363 "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
3364 }
3365 }
3366
3367 fn from_connection(
3368 project_root: PathBuf,
3369 project_key: String,
3370 sqlite_path: PathBuf,
3371 publication_dir: PathBuf,
3372 legacy_fallback: bool,
3373 generation: Option<String>,
3374 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3375 read_marker: Option<crate::root_cache::ReadMarker>,
3376 conn: Connection,
3377 ) -> Self {
3378 let write_metrics = callgraph_write_metrics_for_key(&project_key);
3379 Self {
3380 project_root,
3381 project_key,
3382 sqlite_path,
3383 publication_dir,
3384 legacy_fallback,
3385 generation,
3386 writer_lease,
3387 read_marker,
3388 database_ready: AtomicBool::new(false),
3389 write_metrics,
3390 conn: Mutex::new(conn),
3391 }
3392 }
3393
3394 fn ensure_ready(&self, conn: &Connection) -> Result<()> {
3395 if self.database_ready.load(AtomicOrdering::Acquire) {
3396 return Ok(());
3397 }
3398 ensure_database_ready(conn)?;
3399 self.database_ready.store(true, AtomicOrdering::Release);
3400 Ok(())
3401 }
3402
3403 pub fn project_root(&self) -> &Path {
3404 &self.project_root
3405 }
3406
3407 pub fn project_key(&self) -> &str {
3408 &self.project_key
3409 }
3410
3411 pub fn sqlite_path(&self) -> &Path {
3412 &self.sqlite_path
3413 }
3414
3415 pub(crate) fn projection_generation(&self) -> Option<&str> {
3417 self.generation.as_deref()
3418 }
3419
3420 pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
3422 self.refresh_read_marker()?;
3423 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3424 self.ensure_ready(&conn)?;
3425 projection_write_revision(&conn)
3426 }
3427
3428 pub fn is_legacy_fallback(&self) -> bool {
3431 self.legacy_fallback
3432 }
3433
3434 pub(crate) fn is_legacy_migration(&self) -> bool {
3435 self.generation.as_deref().is_some_and(|generation| {
3436 migration_generation_requires_manifest(generation)
3437 && migration_manifest_valid(&self.publication_dir, generation)
3438 })
3439 }
3440
3441 pub fn writer_epoch_for_test(&self) -> Option<&str> {
3442 self.writer_lease.as_ref().map(|lease| lease.epoch())
3443 }
3444
3445 fn verify_writer_lease(&self) -> Result<()> {
3446 let Some(lease) = self.writer_lease.as_ref() else {
3447 return Err(CallGraphStoreError::Unavailable(
3448 "callgraph store opened read-only; write API is unavailable".to_string(),
3449 ));
3450 };
3451 verify_writer_lease(lease)
3452 }
3453
3454 fn refresh_read_marker(&self) -> Result<()> {
3455 if let Some(marker) = self.read_marker.as_ref() {
3456 marker.touch_if_due()?;
3457 }
3458 Ok(())
3459 }
3460
3461 fn record_commit(&self, total_changes_before: u64, conn: &Connection) {
3462 self.write_metrics
3463 .record_commit(conn.total_changes().saturating_sub(total_changes_before));
3464 }
3465
3466 fn checkpoint_wal_truncate(&self) -> bool {
3467 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3468 checkpoint_wal_truncate(&conn)
3469 }
3470
3471 pub fn is_current(&self) -> bool {
3477 let _ = self.refresh_read_marker();
3478 match (
3479 read_pointer(&self.publication_dir, &self.project_key),
3480 &self.generation,
3481 ) {
3482 (Some(_), _) if self.legacy_fallback => false,
3485 (Some(published), Some(opened)) => &published == opened,
3486 (Some(_), None) => false,
3488 (None, _) => true,
3491 }
3492 }
3493
3494 pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
3495 self.cold_build_chunked(files, COLD_BUILD_EXTRACT_BATCH_FILES)
3496 }
3497
3498 pub fn cold_build_chunked(
3502 &self,
3503 files: &[PathBuf],
3504 chunk_size: usize,
3505 ) -> Result<ColdBuildStats> {
3506 let corpus_fingerprint = self.stage_cold_build_file_inventory(files)?;
3507 self.cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)
3508 }
3509
3510 fn stage_cold_build_file_inventory(&self, files: &[PathBuf]) -> Result<String> {
3511 note_cold_build_phase("enumeration");
3512 if files.is_empty() {
3513 self.stage_cold_build_file_inventory_from(callgraph::walk_project_files(
3514 &self.project_root,
3515 ))
3516 } else {
3517 self.stage_cold_build_file_inventory_from(files.iter().cloned())
3518 }
3519 }
3520
3521 fn stage_cold_build_file_inventory_from<I>(&self, paths: I) -> Result<String>
3522 where
3523 I: IntoIterator<Item = PathBuf>,
3524 {
3525 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3526 self.verify_writer_lease()?;
3527 let total_changes_before = conn.total_changes();
3528 let tx = conn.transaction()?;
3529 tx.execute("DELETE FROM staging_file_inventory", [])?;
3530 tx.commit()?;
3531 self.record_commit(total_changes_before, &conn);
3532
3533 let mut batch = Vec::with_capacity(COLD_BUILD_EXTRACT_BATCH_FILES);
3534 for path in paths {
3535 let path = normalize_file_path(&self.project_root, &path)?;
3536 let rel_path = relative_path(&self.project_root, &path);
3537 let size = std::fs::metadata(&path)
3538 .map(|metadata| metadata.len())
3539 .unwrap_or(0);
3540 batch.push((rel_path, size));
3541 if batch.len() == COLD_BUILD_EXTRACT_BATCH_FILES {
3542 self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3543 batch.clear();
3544 }
3545 }
3546 if !batch.is_empty() {
3547 self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3548 }
3549
3550 staged_corpus_fingerprint(&conn, &self.project_root)
3551 }
3552
3553 fn insert_staged_file_inventory_batch(
3554 &self,
3555 conn: &mut Connection,
3556 batch: &[(String, u64)],
3557 ) -> Result<()> {
3558 self.verify_writer_lease()?;
3559 let total_changes_before = conn.total_changes();
3560 let tx = conn.transaction()?;
3561 {
3562 let mut insert = tx.prepare(
3563 "INSERT OR REPLACE INTO staging_file_inventory(path, size) VALUES(?1, ?2)",
3564 )?;
3565 for (path, size) in batch {
3566 insert.execute(params![path, *size as i64])?;
3567 }
3568 }
3569 tx.commit()?;
3570 self.record_commit(total_changes_before, conn);
3571 Ok(())
3572 }
3573
3574 fn cold_build_chunked_from_staged_inventory(
3575 &self,
3576 chunk_size: usize,
3577 corpus_fingerprint: &str,
3578 ) -> Result<ColdBuildStats> {
3579 let module_resolution_memo = callgraph::ModuleResolutionMemo::default();
3580 self.cold_build_chunked_from_staged_inventory_with_resolution_memo(
3581 chunk_size,
3582 corpus_fingerprint,
3583 COLD_BUILD_RESOLVE_WINDOW,
3584 &module_resolution_memo,
3585 )
3586 }
3587
3588 #[cfg(test)]
3589 fn cold_build_chunked_with_resolution_memo_for_test(
3590 &self,
3591 files: &[PathBuf],
3592 chunk_size: usize,
3593 resolve_window: usize,
3594 module_resolution_memo: &callgraph::ModuleResolutionMemo,
3595 ) -> Result<ColdBuildStats> {
3596 let corpus_fingerprint = self.stage_cold_build_file_inventory(files)?;
3597 self.cold_build_chunked_from_staged_inventory_with_resolution_memo(
3598 chunk_size,
3599 &corpus_fingerprint,
3600 resolve_window.max(1),
3601 module_resolution_memo,
3602 )
3603 }
3604
3605 fn cold_build_chunked_from_staged_inventory_with_resolution_memo(
3606 &self,
3607 chunk_size: usize,
3608 corpus_fingerprint: &str,
3609 resolve_window: usize,
3610 module_resolution_memo: &callgraph::ModuleResolutionMemo,
3611 ) -> Result<ColdBuildStats> {
3612 let started = Instant::now();
3613 let batch_files = chunk_size.max(1).min(COLD_BUILD_EXTRACT_BATCH_FILES);
3614 let workspace_root = self.project_root.display().to_string();
3615 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3616
3617 self.verify_writer_lease()?;
3618 ensure_cold_build_current("staging-admission", 0, 1)?;
3619 let mut phase = staged_build_phase(&conn)?;
3620 let staged_fingerprint = staged_string(&conn, STAGED_CORPUS_FINGERPRINT)?;
3621 let fingerprint_matches = staged_fingerprint.as_deref() == Some(corpus_fingerprint);
3622 if phase.as_deref() == Some("ready") && fingerprint_matches {
3623 ensure_cold_build_current("completed-staging", 1, 1)?;
3624 crate::slog_info!(
3625 "callgraph cold-build decision: reason=matching completed staging; action=publish"
3626 );
3627 conn.execute("DELETE FROM staging_file_inventory", [])?;
3628 return cold_build_stats_from_connection(&conn, started);
3629 }
3630 if phase.is_none() || !fingerprint_matches {
3631 if staged_fingerprint.is_some() && !fingerprint_matches {
3632 crate::slog_info!(
3633 "callgraph cold-build decision: reason=fingerprint mismatch; action=restart staging"
3634 );
3635 }
3636 let total_changes_before = conn.total_changes();
3637 let tx = conn.transaction()?;
3638 clear_tables(&tx)?;
3639 tx.execute("DELETE FROM staging_ref_context", [])?;
3640 insert_meta(&tx)?;
3641 drop_cold_build_secondary_indexes(&tx)?;
3642 set_meta_ready(&tx, false)?;
3643 set_staged_build_phase(&tx, "extracting")?;
3644 set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, corpus_fingerprint)?;
3645 set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0)?;
3646 set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, 0)?;
3647 tx.commit()?;
3648 self.record_commit(total_changes_before, &conn);
3649 phase = Some("extracting".to_string());
3650 }
3651
3652 note_cold_build_phase("extraction");
3656 if phase.as_deref() == Some("extracting") {
3657 prune_staged_files_not_in_inventory(&mut conn)?;
3658
3659 let total_files =
3660 query_count(&conn, "SELECT COUNT(*) FROM staging_file_inventory")? as usize;
3661 let mut completed_files = 0usize;
3662 ensure_cold_build_current("extraction", completed_files, total_files)?;
3663 let mut after_path = String::new();
3664 loop {
3665 let Some(batch) = load_staged_file_batch(
3666 &conn,
3667 &self.project_root,
3668 &after_path,
3669 batch_files,
3670 COLD_BUILD_EXTRACT_BATCH_BYTES,
3671 )?
3672 else {
3673 break;
3674 };
3675 after_path = batch.last_path;
3676 let batch_files = batch.paths.len();
3677
3678 let mut needs_extract = Vec::with_capacity(batch_files);
3679 for path in batch.paths {
3680 if !staged_content_matches(&conn, &self.project_root, &path)? {
3681 needs_extract.push(path);
3682 }
3683 }
3684 if needs_extract.is_empty() {
3685 completed_files = completed_files.saturating_add(batch_files);
3686 ensure_cold_build_current("extraction", completed_files, total_files)?;
3687 continue;
3688 }
3689
3690 notify_cold_build_extract_observer(&needs_extract);
3691 let build = build_extracts_parallel(&self.project_root, &needs_extract);
3692 self.verify_writer_lease()?;
3693 let total_changes_before = conn.total_changes();
3694 let tx = conn.transaction()?;
3695 let mut extracted_bytes = 0u64;
3696 {
3697 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3698 for extract in &build.extracts {
3699 delete_staged_file_rows(&tx, &extract.rel_path)?;
3700 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
3701 for raw in &extract.raw_refs {
3702 insert_staged_ref_prepared(&mut inserts, raw)?;
3703 }
3704 extracted_bytes = extracted_bytes.saturating_add(extract.freshness.size);
3705 }
3706 for failure in &build.failures {
3707 insert_backend_state_prepared(
3708 &mut inserts.backend_state,
3709 &workspace_root,
3710 &failure.rel_path,
3711 failure
3712 .freshness
3713 .as_ref()
3714 .map(|freshness| &freshness.content_hash),
3715 "stale",
3716 )?;
3717 }
3718 }
3719 increment_staged_extracted_bytes(&tx, extracted_bytes)?;
3720 note_cold_build_commit_barrier("extraction_batch_before_commit");
3721 tx.commit()?;
3722 note_cold_build_commit_barrier("extraction_batch_committed");
3723 self.record_commit(total_changes_before, &conn);
3724 completed_files = completed_files.saturating_add(batch_files);
3725 ensure_cold_build_current("extraction", completed_files, total_files)?;
3726 }
3727
3728 ensure_cold_build_current("extraction", completed_files, total_files)?;
3729 let total_changes_before = conn.total_changes();
3730 let tx = conn.transaction()?;
3731 set_staged_build_phase(&tx, "indexing")?;
3732 tx.commit()?;
3733 self.record_commit(total_changes_before, &conn);
3734 phase = Some("indexing".to_string());
3735 ensure_cold_build_current("extraction", total_files, total_files)?;
3736 }
3737
3738 note_cold_build_phase("symbol_export_index");
3742 if phase.as_deref() == Some("indexing") {
3743 ensure_cold_build_current("symbol-export-index", 0, 1)?;
3744 self.verify_writer_lease()?;
3745 let total_changes_before = conn.total_changes();
3746 let tx = conn.transaction()?;
3747 create_cold_build_secondary_indexes(&tx)?;
3748 set_staged_build_phase(&tx, "resolving")?;
3749 tx.commit()?;
3750 self.record_commit(total_changes_before, &conn);
3751 ensure_cold_build_current("symbol-export-index", 1, 1)?;
3752 }
3753
3754 note_cold_build_phase("resolution");
3755 let workspace_crate_prefixes = WorkspaceCratePrefixCache::default();
3756 let total_refs = query_count(&conn, "SELECT COUNT(*) FROM refs")? as usize;
3757 let mut resolved_refs =
3758 query_count(&conn, "SELECT COUNT(*) FROM refs WHERE status <> 'staged'")? as usize;
3759 ensure_cold_build_current("resolution", resolved_refs, total_refs)?;
3760 let mut resolve_cursor = staged_u64(&conn, STAGED_RESOLVE_CURSOR)?;
3761 loop {
3762 let staged = load_staged_ref_window(&conn, resolve_cursor, resolve_window)?;
3763 let Some(last_rowid) = staged.last().map(|entry| entry.rowid) else {
3764 break;
3765 };
3766
3767 self.verify_writer_lease()?;
3768 let total_changes_before = conn.total_changes();
3769 let tx = conn.transaction()?;
3770 {
3771 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3772 let mut offset = 0;
3773 while offset < staged.len() {
3774 let caller_file = staged[offset].raw.caller_file.clone();
3775 let end = staged[offset..]
3776 .iter()
3777 .position(|entry| entry.raw.caller_file != caller_file)
3778 .map(|relative| offset + relative)
3779 .unwrap_or(staged.len());
3780 let caller_extract = build_file_extract(
3781 &self.project_root,
3782 &self.project_root.join(&caller_file),
3783 );
3784 if let Ok(caller_extract) = caller_extract {
3785 let index = DiskProjectIndex {
3786 project_root: &self.project_root,
3787 conn: &tx,
3788 caller_file: &caller_file,
3789 caller_data: &caller_extract.data,
3790 workspace_crate_prefixes: workspace_crate_prefixes.clone(),
3791 module_resolution_memo,
3792 };
3793 for staged_ref in &staged[offset..end] {
3794 let resolved = resolve_ref(staged_ref.raw.clone(), &index)?;
3795 insert_resolved_ref_prepared(&mut inserts, &resolved)?;
3796 }
3797 } else {
3798 for staged_ref in &staged[offset..end] {
3799 let unresolved = unresolved_staged_ref(staged_ref.raw.clone());
3800 insert_resolved_ref_prepared(&mut inserts, &unresolved)?;
3801 }
3802 }
3803 offset = end;
3804 }
3805 }
3806 set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, last_rowid)?;
3807 tx.commit()?;
3808 self.record_commit(total_changes_before, &conn);
3809 resolve_cursor = last_rowid;
3810 resolved_refs = resolved_refs.saturating_add(staged.len()).min(total_refs);
3811 ensure_cold_build_current("resolution", resolved_refs, total_refs)?;
3812 }
3813
3814 ensure_cold_build_current("resolution", resolved_refs, total_refs)?;
3815 note_cold_build_phase("publication");
3816 self.verify_writer_lease()?;
3817 let total_changes_before = conn.total_changes();
3818 let tx = conn.transaction()?;
3819 let _supplemental_edge_count =
3820 insert_method_dispatch_edges_chunked(&tx, &self.project_root, batch_files)?;
3821 set_meta_ready(&tx, true)?;
3822 set_staged_build_phase(&tx, "ready")?;
3823 tx.execute("DELETE FROM staging_file_inventory", [])?;
3824 tx.execute("DELETE FROM staging_ref_context", [])?;
3825 bump_projection_write_revision(&tx)?;
3826 tx.commit()?;
3827 self.record_commit(total_changes_before, &conn);
3828
3829 cold_build_stats_from_connection(&conn, started)
3830 }
3831
3832 pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
3833 self.refresh_files_with_workspace_crate_prefix_cache(
3834 changed_files,
3835 WorkspaceCratePrefixCache::default(),
3836 )
3837 }
3838
3839 fn refresh_files_with_workspace_crate_prefix_cache(
3840 &self,
3841 changed_files: &[PathBuf],
3842 workspace_crate_prefixes: WorkspaceCratePrefixCache,
3843 ) -> Result<IncrementalStats> {
3844 let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
3845 changed_files,
3846 workspace_crate_prefixes,
3847 )?;
3848 if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
3849 eprintln!("refresh_files phases: {}", profile.report());
3850 }
3851 Ok(stats)
3852 }
3853
3854 #[doc(hidden)]
3856 pub fn refresh_files_profiled(
3857 &self,
3858 changed_files: &[PathBuf],
3859 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
3860 self.refresh_files_profiled_with_workspace_crate_prefix_cache(
3861 changed_files,
3862 WorkspaceCratePrefixCache::default(),
3863 )
3864 }
3865
3866 fn refresh_files_profiled_with_workspace_crate_prefix_cache(
3867 &self,
3868 changed_files: &[PathBuf],
3869 workspace_crate_prefixes: WorkspaceCratePrefixCache,
3870 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
3871 let total_started = Instant::now();
3872 let mut profile = RefreshFilesProfile::default();
3873 self.verify_writer_lease()?;
3874 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3875 ensure_database_ready(&conn)?;
3876 let total_changes_before = conn.total_changes();
3877 let mut changed = Vec::new();
3878 let mut surface_changed = BTreeSet::new();
3879 let mut deleted = BTreeSet::new();
3880 let mut own_refresh = BTreeSet::new();
3881 let mut candidate_own_refresh = BTreeSet::new();
3882 let mut confirmed_fresh = BTreeSet::new();
3883 let mut unchanged_extracts = 0usize;
3884 let mut selected_ref_ids = BTreeSet::new();
3885 let mut selected_refs_by_caller = BTreeMap::new();
3886 let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
3887 let mut fresh_metadata = BTreeMap::new();
3888
3889 for input in changed_files {
3890 let (abs_path, rel_path) = match normalize_project_file_path(&self.project_root, input)
3891 {
3892 Ok(path) => path,
3893 Err(error) => {
3894 record_path_identity_mismatch(&conn, &error)?;
3895 return Err(error);
3896 }
3897 };
3898 changed.push(rel_path.clone());
3899 let old_row = load_file_row(&conn, &rel_path)?;
3900 if !abs_path.exists() {
3901 if old_row.is_some() && deleted.insert(rel_path.clone()) {
3902 surface_changed.insert(rel_path.clone());
3903 let started = Instant::now();
3904 let dependent_refs =
3905 ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3906 profile.dependency_selection += started.elapsed();
3907 record_dependent_refs(
3908 &mut selected_ref_ids,
3909 &mut selected_refs_by_caller,
3910 dependent_refs,
3911 );
3912 }
3913 continue;
3914 }
3915
3916 if let Some(row) = &old_row {
3917 match cache_freshness::verify_file(&abs_path, &row.freshness) {
3918 FreshnessVerdict::HotFresh => {
3919 confirmed_fresh.insert(rel_path.clone());
3924 continue;
3925 }
3926 FreshnessVerdict::ContentFresh {
3927 new_mtime,
3928 new_size,
3929 } => {
3930 fresh_metadata.insert(
3931 rel_path.clone(),
3932 FileFreshness {
3933 content_hash: row.freshness.content_hash,
3934 mtime: new_mtime,
3935 size: new_size,
3936 },
3937 );
3938 continue;
3939 }
3940 FreshnessVerdict::Deleted => {
3941 if deleted.insert(rel_path.clone()) {
3942 surface_changed.insert(rel_path.clone());
3943 let started = Instant::now();
3944 let dependent_refs =
3945 ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3946 profile.dependency_selection += started.elapsed();
3947 record_dependent_refs(
3948 &mut selected_ref_ids,
3949 &mut selected_refs_by_caller,
3950 dependent_refs,
3951 );
3952 }
3953 continue;
3954 }
3955 FreshnessVerdict::Stale => {}
3956 }
3957 }
3958
3959 let started = Instant::now();
3960 let extract = build_file_extract(&self.project_root, &abs_path)?;
3961 profile.parse += started.elapsed();
3962 let surface_is_changed = old_row
3963 .as_ref()
3964 .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
3965 .unwrap_or(true);
3966 if surface_is_changed {
3967 surface_changed.insert(rel_path.clone());
3968 let started = Instant::now();
3969 let dependent_refs = ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3970 profile.dependency_selection += started.elapsed();
3971 record_dependent_refs(
3972 &mut selected_ref_ids,
3973 &mut selected_refs_by_caller,
3974 dependent_refs,
3975 );
3976 }
3977 candidate_own_refresh.insert(rel_path.clone());
3978 changed_extracts.insert(rel_path, extract);
3979 }
3980
3981 let dependency_selected_refs = selected_ref_ids.len();
3982 let mut touched_callers: BTreeSet<String> =
3983 selected_refs_by_caller.keys().cloned().collect();
3984 touched_callers.extend(candidate_own_refresh.iter().cloned());
3985
3986 let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
3987 for rel_path in &touched_callers {
3988 if deleted.contains(rel_path) {
3989 continue;
3990 }
3991 if let Some(extract) = changed_extracts.get(rel_path) {
3992 caller_extracts.insert(rel_path.clone(), extract.clone());
3993 continue;
3994 }
3995 let abs_path = self.project_root.join(rel_path);
3996 if abs_path.exists() {
3997 let started = Instant::now();
3998 let extract = build_file_extract(&self.project_root, &abs_path)?;
3999 profile.dependent_parse += started.elapsed();
4000 caller_extracts.insert(rel_path.clone(), extract);
4001 }
4002 }
4003
4004 let tx = conn.transaction()?;
4005 for (rel_path, freshness) in fresh_metadata {
4006 update_file_fresh_metadata(
4007 &tx,
4008 &self.project_root,
4009 &rel_path,
4010 &freshness.content_hash,
4011 freshness.mtime,
4012 freshness.size,
4013 )?;
4014 }
4015 for rel_path in &confirmed_fresh {
4016 clear_stale_backend_status_for_file(&tx, &self.project_root, rel_path)?;
4017 }
4018 for rel_path in &deleted {
4019 let started = Instant::now();
4020 delete_file_rows(&tx, rel_path)?;
4021 clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
4022 profile.row_deletes += started.elapsed();
4023 }
4024
4025 let started = Instant::now();
4026 let index = ProjectIndex::from_db_and_callers(
4027 &tx,
4028 &self.project_root,
4029 &caller_extracts,
4030 workspace_crate_prefixes,
4031 )?;
4032 profile.index_load += started.elapsed();
4033
4034 let workspace_root = self.project_root.display().to_string();
4035 {
4036 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
4037 for rel_path in &candidate_own_refresh {
4038 let Some(extract) = changed_extracts.get(rel_path) else {
4039 continue;
4040 };
4041 if !write_amplification_baseline_enabled()
4042 && stored_extract_matches(&tx, rel_path, extract, &index)?
4043 {
4044 unchanged_extracts += 1;
4045 update_file_fresh_metadata(
4046 &tx,
4047 &self.project_root,
4048 rel_path,
4049 &extract.freshness.content_hash,
4050 extract.freshness.mtime,
4051 extract.freshness.size,
4052 )?;
4053 continue;
4054 }
4055
4056 own_refresh.insert(rel_path.clone());
4057 let started = Instant::now();
4058 delete_file_rows(&tx, rel_path)?;
4059 clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
4060 profile.row_deletes += started.elapsed();
4061 let started = Instant::now();
4062 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
4063 profile.row_inserts += started.elapsed();
4064 }
4065
4066 let dependency_callers = touched_callers
4067 .iter()
4068 .filter(|rel_path| {
4069 !deleted.contains(*rel_path) && !candidate_own_refresh.contains(*rel_path)
4070 })
4071 .cloned()
4072 .collect::<Vec<_>>();
4073 for rel_path in dependency_callers {
4074 let Some(extract) = caller_extracts.get(&rel_path) else {
4075 continue;
4076 };
4077 if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
4078 continue;
4079 }
4080
4081 own_refresh.insert(rel_path.clone());
4082 let started = Instant::now();
4083 delete_file_rows(&tx, &rel_path)?;
4084 clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
4085 profile.row_deletes += started.elapsed();
4086 let started = Instant::now();
4087 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
4088 profile.row_inserts += started.elapsed();
4089 }
4090 let started = Instant::now();
4091 for rel_path in &touched_callers {
4092 if deleted.contains(rel_path) {
4093 continue;
4094 }
4095 let Some(extract) = caller_extracts.get(rel_path) else {
4096 continue;
4097 };
4098 if own_refresh.contains(rel_path) {
4099 delete_refs_for_caller(&tx, rel_path)?;
4100 for raw_ref in &extract.raw_refs {
4101 let resolved = resolve_ref(raw_ref.clone(), &index)?;
4102 insert_resolved_ref_prepared(&mut inserts, &resolved)?;
4103 }
4104 continue;
4105 }
4106
4107 let selected_for_caller = selected_refs_by_caller
4108 .get(rel_path)
4109 .cloned()
4110 .unwrap_or_default();
4111 delete_ref_ids(&tx, &selected_for_caller)?;
4112 for raw_ref in &extract.raw_refs {
4113 if selected_for_caller.contains(&raw_ref.ref_id) {
4114 let resolved = resolve_ref(raw_ref.clone(), &index)?;
4115 insert_resolved_ref_prepared(&mut inserts, &resolved)?;
4116 }
4117 }
4118 }
4119 profile.ref_resolution += started.elapsed();
4120 }
4121
4122 let started = Instant::now();
4123 delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
4124 insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
4125 profile.method_dispatch += started.elapsed();
4126
4127 bump_projection_write_revision(&tx)?;
4128 let started = Instant::now();
4129 commit_incremental_if_current(tx)?;
4130 self.record_commit(total_changes_before, &conn);
4131 profile.commit += started.elapsed();
4132 profile.total = total_started.elapsed();
4133 Ok((
4134 IncrementalStats {
4135 changed_files: changed,
4136 surface_changed: surface_changed.into_iter().collect(),
4137 deleted_files: deleted.into_iter().collect(),
4138 dependency_selected_refs,
4139 refreshed_own_files: own_refresh.len(),
4140 unchanged_extract_files: unchanged_extracts,
4141 },
4142 profile,
4143 ))
4144 }
4145
4146 pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
4147 self.cold_build(current_files)
4148 }
4149
4150 pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
4151 self.verify_writer_lease()?;
4152 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
4153 let total_changes_before = conn.total_changes();
4154 let tx = conn.transaction()?;
4155 let mut marked = Vec::new();
4156 for path in files {
4157 let (abs_path, rel_path) = match normalize_project_file_path(&self.project_root, path) {
4158 Ok(path) => path,
4159 Err(error) => {
4160 drop(tx);
4161 record_path_identity_mismatch(&conn, &error)?;
4162 return Err(error);
4163 }
4164 };
4165 let freshness = cache_freshness::collect(&abs_path).ok();
4166 mark_backend_state(
4167 &tx,
4168 &self.project_root,
4169 &rel_path,
4170 freshness.as_ref().map(|freshness| &freshness.content_hash),
4171 "stale",
4172 )?;
4173 marked.push(rel_path);
4174 }
4175 bump_projection_write_revision(&tx)?;
4176 tx.commit()?;
4177 self.record_commit(total_changes_before, &conn);
4178 marked.sort();
4179 marked.dedup();
4180 Ok(marked)
4181 }
4182
4183 pub fn stale_files(&self) -> Result<Vec<String>> {
4184 self.refresh_read_marker()?;
4185 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4186 let mut stmt = conn.prepare(
4187 "SELECT DISTINCT file_path FROM backend_file_state
4188 WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
4189 ORDER BY file_path",
4190 )?;
4191 let rows = stmt.query_map(
4192 params![BACKEND_TREESITTER, self.project_root.display().to_string()],
4193 |row| row.get::<_, String>(0),
4194 )?;
4195 rows.collect::<std::result::Result<Vec<_>, _>>()
4196 .map_err(Into::into)
4197 }
4198
4199 pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
4200 self.refresh_read_marker()?;
4201 let rel_path = relative_path(
4202 &self.project_root,
4203 &normalize_file_path(&self.project_root, file)?,
4204 );
4205 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4206 conn.query_row(
4207 "SELECT status FROM backend_file_state
4208 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
4209 ORDER BY updated_at DESC LIMIT 1",
4210 params![
4211 BACKEND_TREESITTER,
4212 self.project_root.display().to_string(),
4213 rel_path
4214 ],
4215 |row| row.get(0),
4216 )
4217 .optional()
4218 .map_err(Into::into)
4219 }
4220
4221 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4222 self.refresh_read_marker()?;
4223 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4224 self.ensure_ready(&conn)?;
4225 edge_snapshot_with_conn(&conn)
4226 }
4227
4228 pub fn indexed_file_count(&self) -> Result<usize> {
4229 self.refresh_read_marker()?;
4230 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4231 self.ensure_ready(&conn)?;
4232 indexed_file_count(&conn)
4233 }
4234
4235 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4236 self.refresh_read_marker()?;
4237 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4238 let rel_path = relative_path(&self.project_root, &abs_path);
4239 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4240 self.ensure_ready(&conn)?;
4241 resolve_node_for_rel(&conn, &rel_path, symbol)
4242 }
4243
4244 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4249 self.refresh_read_marker()?;
4250 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4251 let rel_path = relative_path(&self.project_root, &abs_path);
4252 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4253 self.ensure_ready(&conn)?;
4254 nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
4255 }
4256
4257 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4259 self.refresh_read_marker()?;
4260 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4261 self.ensure_ready(&conn)?;
4262 nodes_matching_symbol(&conn, symbol)
4263 }
4264
4265 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4267 self.refresh_read_marker()?;
4268 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4269 let rel_path = relative_path(&self.project_root, &abs_path);
4270 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4271 self.ensure_ready(&conn)?;
4272 direct_callers_for_tuple(&conn, &rel_path, symbol)
4273 }
4274
4275 pub fn direct_callers_for_symbols(
4277 &self,
4278 targets: &[(String, String)],
4279 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4280 if targets.is_empty() {
4281 return Ok(HashMap::new());
4282 }
4283 self.refresh_read_marker()?;
4284 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4285 self.ensure_ready(&conn)?;
4286 direct_callers_for_tuples(&conn, targets)
4287 }
4288
4289 pub fn direct_caller_counts_of(
4291 &self,
4292 targets: &[(String, String)],
4293 ) -> Result<HashMap<(String, String), usize>> {
4294 if targets.is_empty() {
4295 return Ok(HashMap::new());
4296 }
4297 self.refresh_read_marker()?;
4298 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4299 self.ensure_ready(&conn)?;
4300 direct_caller_counts_for_tuples(&conn, targets)
4301 }
4302
4303 pub fn callers_of(
4304 &self,
4305 file_rel: &Path,
4306 symbol: &str,
4307 depth: usize,
4308 ) -> Result<StoreCallersResult> {
4309 let target = self.node_for(file_rel, symbol)?;
4310 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4311 self.ensure_ready(&conn)?;
4312 let effective_depth = depth.max(1);
4313 let mut visited = HashSet::new();
4314 let mut callers = Vec::new();
4315 let mut depth_limited = false;
4316 let mut truncated = 0usize;
4317 collect_callers_recursive(
4318 &conn,
4319 &target.file,
4320 &target.symbol,
4321 effective_depth,
4322 0,
4323 &mut visited,
4324 &mut callers,
4325 &mut depth_limited,
4326 &mut truncated,
4327 )?;
4328 Ok(StoreCallersResult {
4329 target,
4330 callers,
4331 scanned_files: indexed_file_count(&conn)?,
4332 depth_limited,
4333 truncated,
4334 })
4335 }
4336
4337 pub fn impact_of(
4338 &self,
4339 file_rel: &Path,
4340 symbol: &str,
4341 depth: usize,
4342 ) -> Result<StoreImpactResult> {
4343 let callers = self.callers_of(file_rel, symbol, depth)?;
4344 let target_parameters = callers
4345 .target
4346 .signature
4347 .as_deref()
4348 .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
4349 .unwrap_or_default();
4350 let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
4351 for site in &callers.callers {
4352 source_lines_by_file
4353 .entry(site.caller.file.clone())
4354 .or_insert_with(|| {
4355 read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
4356 });
4357 }
4358 let enriched = callers
4359 .callers
4360 .iter()
4361 .map(|site| StoreImpactCaller {
4362 site: site.clone(),
4363 signature: site.caller.signature.clone(),
4364 is_entry_point: site.caller.is_entry_point,
4365 call_expression: source_lines_by_file
4366 .get(&site.caller.file)
4367 .and_then(|lines| lines.as_ref())
4368 .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
4369 .cloned(),
4370 parameters: site
4371 .caller
4372 .signature
4373 .as_deref()
4374 .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
4375 .unwrap_or_default(),
4376 })
4377 .collect();
4378 Ok(StoreImpactResult {
4379 target: callers.target,
4380 parameters: target_parameters,
4381 callers: enriched,
4382 depth_limited: callers.depth_limited,
4383 truncated: callers.truncated,
4384 })
4385 }
4386
4387 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4388 self.refresh_read_marker()?;
4389 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4390 self.ensure_ready(&conn)?;
4391 outgoing_calls_for_node(&conn, node)
4392 }
4393
4394 pub fn outgoing_calls_for_symbols(
4396 &self,
4397 sources: &[(String, String)],
4398 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4399 if sources.is_empty() {
4400 return Ok(HashMap::new());
4401 }
4402 self.refresh_read_marker()?;
4403 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4404 self.ensure_ready(&conn)?;
4405 outgoing_calls_for_symbol_tuples(&conn, sources)
4406 }
4407
4408 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4410 self.refresh_read_marker()?;
4411 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4412 self.ensure_ready(&conn)?;
4413 resolved_self_calls_for_node(&conn, node)
4414 }
4415
4416 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4417 self.refresh_read_marker()?;
4418 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4419 self.ensure_ready(&conn)?;
4420 unresolved_calls_for_node(&conn, node)
4421 }
4422
4423 pub fn call_tree(
4424 &self,
4425 file_rel: &Path,
4426 symbol: &str,
4427 max_depth: usize,
4428 ) -> Result<callgraph::CallTreeNode> {
4429 let node = self.node_for(file_rel, symbol)?;
4430 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4431 self.ensure_ready(&conn)?;
4432 let mut visited = HashSet::new();
4433 call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
4434 }
4435
4436 pub fn trace_to(
4437 &self,
4438 file_rel: &Path,
4439 symbol: &str,
4440 max_depth: usize,
4441 ) -> Result<callgraph::TraceToResult> {
4442 let target = self.node_for(file_rel, symbol)?;
4443 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4444 self.ensure_ready(&conn)?;
4445 let effective_max = if max_depth == 0 { 10 } else { max_depth };
4446
4447 #[derive(Clone)]
4448 struct PathElem {
4449 node: StoreNode,
4450 }
4451
4452 let initial = vec![PathElem {
4453 node: target.clone(),
4454 }];
4455 let mut complete_paths = Vec::new();
4456 if target.is_entry_point {
4457 complete_paths.push(initial.clone());
4458 }
4459
4460 let mut queue = vec![(initial, 0usize)];
4461 let mut max_depth_reached = false;
4462 let mut truncated_paths = 0usize;
4463
4464 while let Some((path, depth)) = queue.pop() {
4465 if depth >= effective_max {
4466 max_depth_reached = true;
4467 continue;
4468 }
4469 let Some(current) = path.last() else {
4470 continue;
4471 };
4472 let callers =
4473 direct_callers_for_tuple(&conn, ¤t.node.file, ¤t.node.symbol)?;
4474 if callers.is_empty() {
4475 if path.len() > 1 {
4476 truncated_paths += 1;
4477 }
4478 continue;
4479 }
4480
4481 let mut has_new_path = false;
4482 for site in callers {
4483 if path.iter().any(|elem| {
4484 elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
4485 }) {
4486 continue;
4487 }
4488 has_new_path = true;
4489 let mut new_path = path.clone();
4490 new_path.push(PathElem {
4491 node: site.caller.clone(),
4492 });
4493 if site.caller.is_entry_point {
4494 complete_paths.push(new_path.clone());
4495 }
4496 queue.push((new_path, depth + 1));
4497 }
4498 if !has_new_path && path.len() > 1 {
4499 truncated_paths += 1;
4500 }
4501 }
4502
4503 let mut paths: Vec<callgraph::TracePath> = complete_paths
4504 .into_iter()
4505 .map(|mut elems| {
4506 elems.reverse();
4507 let hops = elems
4508 .iter()
4509 .enumerate()
4510 .map(|(index, elem)| callgraph::TraceHop {
4511 symbol: elem.node.symbol.clone(),
4512 file: elem.node.file.clone(),
4513 line: elem.node.line,
4514 signature: elem.node.signature.clone(),
4515 is_entry_point: index == 0 && elem.node.is_entry_point,
4516 })
4517 .collect();
4518 callgraph::TracePath { hops }
4519 })
4520 .collect();
4521 paths.sort_by(|left, right| {
4522 let left_entry = left
4523 .hops
4524 .first()
4525 .map(|hop| hop.symbol.as_str())
4526 .unwrap_or("");
4527 let right_entry = right
4528 .hops
4529 .first()
4530 .map(|hop| hop.symbol.as_str())
4531 .unwrap_or("");
4532 left_entry
4533 .cmp(right_entry)
4534 .then(left.hops.len().cmp(&right.hops.len()))
4535 });
4536 let entry_points_found = paths
4537 .iter()
4538 .filter_map(|path| path.hops.first())
4539 .filter(|hop| hop.is_entry_point)
4540 .map(|hop| (hop.file.clone(), hop.symbol.clone()))
4541 .collect::<HashSet<_>>()
4542 .len();
4543
4544 Ok(callgraph::TraceToResult {
4545 target_symbol: target.symbol,
4546 target_file: target.file,
4547 total_paths: paths.len(),
4548 paths,
4549 entry_points_found,
4550 max_depth_reached,
4551 truncated_paths,
4552 })
4553 }
4554
4555 pub fn trace_to_symbol_candidates(
4556 &self,
4557 to_symbol: &str,
4558 ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
4559 self.refresh_read_marker()?;
4560 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4561 self.ensure_ready(&conn)?;
4562 let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
4563 for node in nodes_matching_symbol(&conn, to_symbol)? {
4564 candidates_by_file
4565 .entry(node.file)
4566 .and_modify(|line| *line = (*line).min(node.line))
4567 .or_insert(node.line);
4568 }
4569 let mut candidates: Vec<_> = candidates_by_file
4570 .into_iter()
4571 .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
4572 .collect();
4573 candidates
4574 .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
4575 Ok(candidates)
4576 }
4577
4578 pub fn trace_to_symbol(
4579 &self,
4580 file_rel: &Path,
4581 symbol: &str,
4582 to_symbol: &str,
4583 to_file: Option<&Path>,
4584 max_depth: usize,
4585 ) -> Result<callgraph::TraceToSymbolResult> {
4586 let origin = self.node_for(file_rel, symbol)?;
4587 let target_file = to_file
4588 .map(|path| normalize_file_path(&self.project_root, path))
4589 .transpose()?
4590 .map(|path| relative_path(&self.project_root, &path));
4591 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4592 self.ensure_ready(&conn)?;
4593 let effective_max = if max_depth == 0 {
4594 10
4595 } else {
4596 max_depth.min(16)
4597 };
4598
4599 let start_hop = trace_to_symbol_hop(&origin);
4600 if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
4601 return Ok(callgraph::TraceToSymbolResult {
4602 path: Some(vec![start_hop]),
4603 complete: true,
4604 reason: None,
4605 });
4606 }
4607
4608 let mut queue = VecDeque::new();
4609 queue.push_back((origin.clone(), vec![start_hop], 0usize));
4610 let mut visited = HashSet::new();
4611 visited.insert((origin.file.clone(), origin.symbol.clone()));
4612 let mut max_depth_exhausted = false;
4613
4614 while let Some((current, path, depth)) = queue.pop_front() {
4615 let callees = outgoing_calls_for_node(&conn, ¤t)?
4616 .into_iter()
4617 .filter_map(|site| site.target)
4618 .collect::<Vec<_>>();
4619
4620 if depth >= effective_max {
4621 if callees
4622 .iter()
4623 .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
4624 {
4625 max_depth_exhausted = true;
4626 }
4627 continue;
4628 }
4629
4630 for callee in callees {
4631 if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
4632 continue;
4633 }
4634 let mut next_path = path.clone();
4635 next_path.push(trace_to_symbol_hop(&callee));
4636 if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
4637 return Ok(callgraph::TraceToSymbolResult {
4638 path: Some(next_path),
4639 complete: true,
4640 reason: None,
4641 });
4642 }
4643 queue.push_back((callee, next_path, depth + 1));
4644 }
4645 }
4646
4647 if max_depth_exhausted {
4648 Ok(callgraph::TraceToSymbolResult {
4649 path: None,
4650 complete: false,
4651 reason: Some("max_depth_exhausted".to_string()),
4652 })
4653 } else {
4654 Ok(callgraph::TraceToSymbolResult {
4655 path: None,
4656 complete: true,
4657 reason: Some("no_path_found".to_string()),
4658 })
4659 }
4660 }
4661}
4662
4663impl ReadonlyCallGraphStore {
4664 fn from_inner(inner: CallGraphStore) -> Self {
4665 Self { inner }
4666 }
4667
4668 pub fn project_root(&self) -> &Path {
4669 self.inner.project_root()
4670 }
4671
4672 pub fn project_key(&self) -> &str {
4673 self.inner.project_key()
4674 }
4675
4676 pub fn sqlite_path(&self) -> &Path {
4677 self.inner.sqlite_path()
4678 }
4679
4680 pub fn stale_files(&self) -> Result<Vec<String>> {
4681 self.inner.stale_files()
4682 }
4683
4684 pub(crate) fn projection_generation(&self) -> Option<&str> {
4685 self.inner.projection_generation()
4686 }
4687
4688 pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
4689 self.inner.projection_write_revision()
4690 }
4691
4692 pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
4695 crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
4696 }
4697
4698 pub fn is_legacy_fallback(&self) -> bool {
4700 self.inner.is_legacy_fallback()
4701 }
4702
4703 pub fn is_current(&self) -> bool {
4704 self.inner.is_current()
4705 }
4706
4707 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4708 self.inner.edge_snapshot()
4709 }
4710
4711 pub fn indexed_file_count(&self) -> Result<usize> {
4712 self.inner.indexed_file_count()
4713 }
4714
4715 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4716 self.inner.node_for(file_rel, symbol)
4717 }
4718
4719 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4720 self.inner.nodes_for(file_rel, symbol)
4721 }
4722
4723 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4724 self.inner.nodes_matching(symbol)
4725 }
4726
4727 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4728 self.inner.direct_callers_of(file_rel, symbol)
4729 }
4730
4731 pub fn direct_callers_for_symbols(
4732 &self,
4733 targets: &[(String, String)],
4734 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4735 self.inner.direct_callers_for_symbols(targets)
4736 }
4737
4738 pub fn direct_caller_counts_of(
4739 &self,
4740 targets: &[(String, String)],
4741 ) -> Result<HashMap<(String, String), usize>> {
4742 self.inner.direct_caller_counts_of(targets)
4743 }
4744
4745 pub fn callers_of(
4746 &self,
4747 file_rel: &Path,
4748 symbol: &str,
4749 depth: usize,
4750 ) -> Result<StoreCallersResult> {
4751 self.inner.callers_of(file_rel, symbol, depth)
4752 }
4753
4754 pub fn impact_of(
4755 &self,
4756 file_rel: &Path,
4757 symbol: &str,
4758 depth: usize,
4759 ) -> Result<StoreImpactResult> {
4760 self.inner.impact_of(file_rel, symbol, depth)
4761 }
4762
4763 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4764 self.inner.outgoing_calls_of(node)
4765 }
4766
4767 pub fn outgoing_calls_for_symbols(
4768 &self,
4769 sources: &[(String, String)],
4770 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4771 self.inner.outgoing_calls_for_symbols(sources)
4772 }
4773
4774 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4775 self.inner.resolved_self_calls_of(node)
4776 }
4777
4778 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4779 self.inner.unresolved_calls_of(node)
4780 }
4781
4782 pub fn call_tree(
4783 &self,
4784 file_rel: &Path,
4785 symbol: &str,
4786 depth: usize,
4787 ) -> Result<callgraph::CallTreeNode> {
4788 self.inner.call_tree(file_rel, symbol, depth)
4789 }
4790
4791 pub fn trace_to(
4792 &self,
4793 file_rel: &Path,
4794 symbol: &str,
4795 max_depth: usize,
4796 ) -> Result<callgraph::TraceToResult> {
4797 self.inner.trace_to(file_rel, symbol, max_depth)
4798 }
4799
4800 pub fn trace_to_symbol_candidates(
4801 &self,
4802 to_symbol: &str,
4803 ) -> Result<Vec<TraceToSymbolCandidate>> {
4804 self.inner.trace_to_symbol_candidates(to_symbol)
4805 }
4806
4807 pub fn trace_to_symbol(
4808 &self,
4809 file_rel: &Path,
4810 symbol: &str,
4811 to_symbol: &str,
4812 to_file: Option<&Path>,
4813 max_depth: usize,
4814 ) -> Result<callgraph::TraceToSymbolResult> {
4815 self.inner
4816 .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4817 }
4818}
4819
4820impl CallGraphRead for CallGraphStore {
4821 fn project_root(&self) -> &Path {
4822 CallGraphStore::project_root(self)
4823 }
4824 fn project_key(&self) -> &str {
4825 CallGraphStore::project_key(self)
4826 }
4827 fn sqlite_path(&self) -> &Path {
4828 CallGraphStore::sqlite_path(self)
4829 }
4830 fn is_current(&self) -> bool {
4831 CallGraphStore::is_current(self)
4832 }
4833 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4834 CallGraphStore::edge_snapshot(self)
4835 }
4836 fn indexed_file_count(&self) -> Result<usize> {
4837 CallGraphStore::indexed_file_count(self)
4838 }
4839 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4840 CallGraphStore::node_for(self, file_rel, symbol)
4841 }
4842 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4843 CallGraphStore::nodes_for(self, file_rel, symbol)
4844 }
4845 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4846 CallGraphStore::nodes_matching(self, symbol)
4847 }
4848 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4849 CallGraphStore::direct_callers_of(self, file_rel, symbol)
4850 }
4851 fn direct_callers_for_symbols(
4852 &self,
4853 targets: &[(String, String)],
4854 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4855 CallGraphStore::direct_callers_for_symbols(self, targets)
4856 }
4857 fn direct_caller_counts_of(
4858 &self,
4859 targets: &[(String, String)],
4860 ) -> Result<HashMap<(String, String), usize>> {
4861 CallGraphStore::direct_caller_counts_of(self, targets)
4862 }
4863 fn callers_of(
4864 &self,
4865 file_rel: &Path,
4866 symbol: &str,
4867 depth: usize,
4868 ) -> Result<StoreCallersResult> {
4869 CallGraphStore::callers_of(self, file_rel, symbol, depth)
4870 }
4871 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4872 CallGraphStore::impact_of(self, file_rel, symbol, depth)
4873 }
4874 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4875 CallGraphStore::outgoing_calls_of(self, node)
4876 }
4877 fn outgoing_calls_for_symbols(
4878 &self,
4879 sources: &[(String, String)],
4880 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4881 CallGraphStore::outgoing_calls_for_symbols(self, sources)
4882 }
4883 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4884 CallGraphStore::resolved_self_calls_of(self, node)
4885 }
4886 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4887 CallGraphStore::unresolved_calls_of(self, node)
4888 }
4889 fn call_tree(
4890 &self,
4891 file_rel: &Path,
4892 symbol: &str,
4893 depth: usize,
4894 ) -> Result<callgraph::CallTreeNode> {
4895 CallGraphStore::call_tree(self, file_rel, symbol, depth)
4896 }
4897 fn trace_to(
4898 &self,
4899 file_rel: &Path,
4900 symbol: &str,
4901 max_depth: usize,
4902 ) -> Result<callgraph::TraceToResult> {
4903 CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
4904 }
4905 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4906 CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
4907 }
4908 fn trace_to_symbol(
4909 &self,
4910 file_rel: &Path,
4911 symbol: &str,
4912 to_symbol: &str,
4913 to_file: Option<&Path>,
4914 max_depth: usize,
4915 ) -> Result<callgraph::TraceToSymbolResult> {
4916 CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
4917 }
4918}
4919
4920impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
4921 fn project_root(&self) -> &Path {
4922 (**self).project_root()
4923 }
4924 fn project_key(&self) -> &str {
4925 (**self).project_key()
4926 }
4927 fn sqlite_path(&self) -> &Path {
4928 (**self).sqlite_path()
4929 }
4930 fn is_current(&self) -> bool {
4931 (**self).is_current()
4932 }
4933 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4934 (**self).edge_snapshot()
4935 }
4936 fn indexed_file_count(&self) -> Result<usize> {
4937 (**self).indexed_file_count()
4938 }
4939 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4940 (**self).node_for(file_rel, symbol)
4941 }
4942 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4943 (**self).nodes_for(file_rel, symbol)
4944 }
4945 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4946 (**self).nodes_matching(symbol)
4947 }
4948 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4949 (**self).direct_callers_of(file_rel, symbol)
4950 }
4951 fn direct_callers_for_symbols(
4952 &self,
4953 targets: &[(String, String)],
4954 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4955 (**self).direct_callers_for_symbols(targets)
4956 }
4957 fn direct_caller_counts_of(
4958 &self,
4959 targets: &[(String, String)],
4960 ) -> Result<HashMap<(String, String), usize>> {
4961 (**self).direct_caller_counts_of(targets)
4962 }
4963 fn callers_of(
4964 &self,
4965 file_rel: &Path,
4966 symbol: &str,
4967 depth: usize,
4968 ) -> Result<StoreCallersResult> {
4969 (**self).callers_of(file_rel, symbol, depth)
4970 }
4971 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4972 (**self).impact_of(file_rel, symbol, depth)
4973 }
4974 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4975 (**self).outgoing_calls_of(node)
4976 }
4977 fn outgoing_calls_for_symbols(
4978 &self,
4979 sources: &[(String, String)],
4980 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4981 (**self).outgoing_calls_for_symbols(sources)
4982 }
4983 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4984 (**self).resolved_self_calls_of(node)
4985 }
4986 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4987 (**self).unresolved_calls_of(node)
4988 }
4989 fn call_tree(
4990 &self,
4991 file_rel: &Path,
4992 symbol: &str,
4993 depth: usize,
4994 ) -> Result<callgraph::CallTreeNode> {
4995 (**self).call_tree(file_rel, symbol, depth)
4996 }
4997 fn trace_to(
4998 &self,
4999 file_rel: &Path,
5000 symbol: &str,
5001 max_depth: usize,
5002 ) -> Result<callgraph::TraceToResult> {
5003 (**self).trace_to(file_rel, symbol, max_depth)
5004 }
5005 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
5006 (**self).trace_to_symbol_candidates(to_symbol)
5007 }
5008 fn trace_to_symbol(
5009 &self,
5010 file_rel: &Path,
5011 symbol: &str,
5012 to_symbol: &str,
5013 to_file: Option<&Path>,
5014 max_depth: usize,
5015 ) -> Result<callgraph::TraceToSymbolResult> {
5016 (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
5017 }
5018}
5019
5020impl CallGraphRead for ReadonlyCallGraphStore {
5021 fn project_root(&self) -> &Path {
5022 self.project_root()
5023 }
5024 fn project_key(&self) -> &str {
5025 self.project_key()
5026 }
5027 fn sqlite_path(&self) -> &Path {
5028 self.sqlite_path()
5029 }
5030 fn is_current(&self) -> bool {
5031 self.is_current()
5032 }
5033 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
5034 self.edge_snapshot()
5035 }
5036 fn indexed_file_count(&self) -> Result<usize> {
5037 self.indexed_file_count()
5038 }
5039 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
5040 self.node_for(file_rel, symbol)
5041 }
5042 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
5043 self.nodes_for(file_rel, symbol)
5044 }
5045 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
5046 self.nodes_matching(symbol)
5047 }
5048 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
5049 self.direct_callers_of(file_rel, symbol)
5050 }
5051 fn direct_callers_for_symbols(
5052 &self,
5053 targets: &[(String, String)],
5054 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5055 self.direct_callers_for_symbols(targets)
5056 }
5057 fn direct_caller_counts_of(
5058 &self,
5059 targets: &[(String, String)],
5060 ) -> Result<HashMap<(String, String), usize>> {
5061 self.direct_caller_counts_of(targets)
5062 }
5063 fn callers_of(
5064 &self,
5065 file_rel: &Path,
5066 symbol: &str,
5067 depth: usize,
5068 ) -> Result<StoreCallersResult> {
5069 self.callers_of(file_rel, symbol, depth)
5070 }
5071 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
5072 self.impact_of(file_rel, symbol, depth)
5073 }
5074 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5075 self.outgoing_calls_of(node)
5076 }
5077 fn outgoing_calls_for_symbols(
5078 &self,
5079 sources: &[(String, String)],
5080 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5081 self.outgoing_calls_for_symbols(sources)
5082 }
5083 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5084 self.resolved_self_calls_of(node)
5085 }
5086 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
5087 self.unresolved_calls_of(node)
5088 }
5089 fn call_tree(
5090 &self,
5091 file_rel: &Path,
5092 symbol: &str,
5093 depth: usize,
5094 ) -> Result<callgraph::CallTreeNode> {
5095 self.call_tree(file_rel, symbol, depth)
5096 }
5097 fn trace_to(
5098 &self,
5099 file_rel: &Path,
5100 symbol: &str,
5101 max_depth: usize,
5102 ) -> Result<callgraph::TraceToResult> {
5103 self.trace_to(file_rel, symbol, max_depth)
5104 }
5105 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
5106 self.trace_to_symbol_candidates(to_symbol)
5107 }
5108 fn trace_to_symbol(
5109 &self,
5110 file_rel: &Path,
5111 symbol: &str,
5112 to_symbol: &str,
5113 to_file: Option<&Path>,
5114 max_depth: usize,
5115 ) -> Result<callgraph::TraceToSymbolResult> {
5116 self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
5117 }
5118}
5119
5120fn indexed_file_count(conn: &Connection) -> Result<usize> {
5121 let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
5122 Ok(count.max(0) as usize)
5123}
5124
5125fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
5126 let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
5127 match candidates.as_slice() {
5128 [candidate] => Ok(candidate.clone()),
5129 [] => Err(AftError::SymbolNotFound {
5130 name: symbol.to_string(),
5131 file: rel_path.to_string(),
5132 }
5133 .into()),
5134 _ => Err(AftError::AmbiguousSymbol {
5135 name: symbol.to_string(),
5136 candidates: candidates
5137 .iter()
5138 .map(|candidate| candidate.symbol.clone())
5139 .collect(),
5140 }
5141 .into()),
5142 }
5143}
5144
5145fn nodes_for_file_matching_symbol(
5146 conn: &Connection,
5147 rel_path: &str,
5148 symbol: &str,
5149) -> Result<Vec<StoreNode>> {
5150 let qualified_query = symbol.contains("::");
5151 let sql = if qualified_query {
5152 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5153 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5154 FROM nodes n JOIN files f ON f.path = n.file_path
5155 WHERE n.file_path = ?1 AND n.scoped_name = ?2
5156 ORDER BY n.scoped_name, n.start_line, n.start_col"
5157 } else {
5158 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5159 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5160 FROM nodes n JOIN files f ON f.path = n.file_path
5161 WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
5162 ORDER BY n.scoped_name, n.start_line, n.start_col"
5163 };
5164 let mut stmt = conn.prepare(sql)?;
5165 let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
5166 rows.collect::<std::result::Result<Vec<_>, _>>()
5167 .map_err(Into::into)
5168}
5169
5170fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
5171 let qualified_query = symbol.contains("::");
5172 let sql = if qualified_query {
5173 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5174 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5175 FROM nodes n JOIN files f ON f.path = n.file_path
5176 WHERE n.scoped_name = ?1
5177 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
5178 } else {
5179 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5180 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5181 FROM nodes n JOIN files f ON f.path = n.file_path
5182 WHERE n.scoped_name = ?1 OR n.name = ?1
5183 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
5184 };
5185 let mut stmt = conn.prepare(sql)?;
5186 let rows = stmt.query_map(params![symbol], store_node_from_row)?;
5187 rows.collect::<std::result::Result<Vec<_>, _>>()
5188 .map_err(Into::into)
5189}
5190
5191fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
5192 store_node_from_row_at(row, 0)
5193}
5194
5195fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
5196 let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
5197 let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
5198 let lang_label_value: String = row.get(offset + 10)?;
5199 Ok(StoreNode {
5200 node_id: row.get(offset)?,
5201 file: row.get(offset + 1)?,
5202 symbol: row.get(offset + 2)?,
5203 name: row.get(offset + 3)?,
5204 kind: row.get(offset + 4)?,
5205 line: start_line.saturating_add(1),
5206 end_line: end_line.saturating_add(1),
5207 signature: row.get(offset + 7)?,
5208 exported: row.get::<_, i64>(offset + 8)? != 0,
5209 is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
5210 lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
5211 })
5212}
5213
5214fn optional_store_node_from_row_at(
5215 row: &rusqlite::Row<'_>,
5216 offset: usize,
5217) -> rusqlite::Result<Option<StoreNode>> {
5218 if row.get::<_, Option<String>>(offset)?.is_some() {
5219 store_node_from_row_at(row, offset).map(Some)
5220 } else {
5221 Ok(None)
5222 }
5223}
5224
5225#[allow(clippy::too_many_arguments)]
5226fn collect_callers_recursive(
5227 conn: &Connection,
5228 file: &str,
5229 symbol: &str,
5230 max_depth: usize,
5231 current_depth: usize,
5232 visited: &mut HashSet<(String, String)>,
5233 result: &mut Vec<StoreCallSite>,
5234 depth_limited: &mut bool,
5235 truncated: &mut usize,
5236) -> Result<()> {
5237 if current_depth >= max_depth {
5238 let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
5239 if omitted > 0 {
5240 *depth_limited = true;
5241 *truncated += omitted;
5242 }
5243 return Ok(());
5244 }
5245
5246 if !visited.insert((file.to_string(), symbol.to_string())) {
5247 return Ok(());
5248 }
5249
5250 let sites = direct_callers_for_tuple(conn, file, symbol)?;
5251 for site in sites {
5252 result.push(site.clone());
5253 if current_depth + 1 < max_depth {
5254 collect_callers_recursive(
5255 conn,
5256 &site.caller.file,
5257 &site.caller.symbol,
5258 max_depth,
5259 current_depth + 1,
5260 visited,
5261 result,
5262 depth_limited,
5263 truncated,
5264 )?;
5265 } else {
5266 let omitted =
5267 direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
5268 if omitted > 0 {
5269 *depth_limited = true;
5270 *truncated += omitted;
5271 }
5272 }
5273 }
5274 Ok(())
5275}
5276
5277const DIRECT_CALLER_BATCH_SIZE: usize = 499;
5279
5280fn direct_caller_counts_for_tuples(
5281 conn: &Connection,
5282 targets: &[(String, String)],
5283) -> Result<HashMap<(String, String), usize>> {
5284 let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
5285 let mut counts = unique_targets
5286 .iter()
5287 .cloned()
5288 .map(|target| (target, 0usize))
5289 .collect::<HashMap<_, _>>();
5290
5291 let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
5292 for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
5293 let requested_values = (0..chunk.len())
5294 .map(|_| "(?, ?)")
5295 .collect::<Vec<_>>()
5296 .join(", ");
5297 let sql = format!(
5298 "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
5299 deduped AS (
5300 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
5301 FROM requested requested
5302 JOIN edges e
5303 ON e.target_file = requested.target_file
5304 AND e.target_symbol = requested.target_symbol
5305 AND e.kind = 'call'
5306 JOIN refs r ON r.ref_id = e.ref_id
5307 JOIN nodes src ON src.id = e.source_node
5308 JOIN files src_file ON src_file.path = src.file_path
5309 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
5310 )
5311 SELECT target_file, target_symbol, COUNT(*)
5312 FROM deduped
5313 GROUP BY target_file, target_symbol"
5314 );
5315 let bindings = chunk
5316 .iter()
5317 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5318 let mut stmt = conn.prepare(&sql)?;
5319 let rows = stmt.query_map(params_from_iter(bindings), |row| {
5320 Ok((
5321 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
5322 row.get::<_, i64>(2)?,
5323 ))
5324 })?;
5325 for row in rows {
5326 let (target, count) = row?;
5327 counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
5328 }
5329 }
5330
5331 Ok(counts)
5332}
5333
5334fn direct_caller_count_for_tuple(
5335 conn: &Connection,
5336 target_file: &str,
5337 target_symbol: &str,
5338) -> Result<usize> {
5339 let count: i64 = conn.query_row(
5340 "SELECT COUNT(*)
5341 FROM edges e
5342 JOIN refs r ON r.ref_id = e.ref_id
5343 JOIN nodes src ON src.id = e.source_node
5344 JOIN files src_file ON src_file.path = src.file_path
5345 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
5346 params![target_file, target_symbol],
5347 |row| row.get(0),
5348 )?;
5349 Ok(usize::try_from(count).unwrap_or(usize::MAX))
5350}
5351
5352fn direct_callers_for_tuple(
5353 conn: &Connection,
5354 target_file: &str,
5355 target_symbol: &str,
5356) -> Result<Vec<StoreCallSite>> {
5357 let mut stmt = conn.prepare(
5358 "SELECT e.target_file, e.target_symbol, e.line,
5359 r.byte_start, r.byte_end, r.status, e.provenance,
5360 src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5361 src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5362 src_file.lang,
5363 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5364 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5365 tgt_file.lang
5366 FROM edges e
5367 JOIN refs r ON r.ref_id = e.ref_id
5368 JOIN nodes src ON src.id = e.source_node
5369 JOIN files src_file ON src_file.path = src.file_path
5370 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5371 ON tgt.id = e.target_node
5372 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
5373 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
5374 )?;
5375 let rows = stmt.query_map(
5376 params![target_file, target_symbol],
5377 direct_call_site_from_row,
5378 )?;
5379 rows.collect::<std::result::Result<Vec<_>, _>>()
5380 .map_err(Into::into)
5381}
5382
5383fn direct_call_site_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreCallSite> {
5384 let caller = store_node_from_row_at(row, 7)?;
5385 let target = optional_store_node_from_row_at(row, 18)?;
5386 Ok(StoreCallSite {
5387 caller,
5388 target_file: row.get(0)?,
5389 target_symbol: row.get(1)?,
5390 target,
5391 line: row.get::<_, i64>(2)?.max(0) as u32,
5392 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5393 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5394 resolved: row.get::<_, String>(5)? == "resolved",
5395 provenance: row.get(6)?,
5396 })
5397}
5398
5399fn direct_callers_for_tuples(
5400 conn: &Connection,
5401 targets: &[(String, String)],
5402) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5403 let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
5404 let mut callers_by_target = unique_targets
5405 .iter()
5406 .cloned()
5407 .map(|target| (target, Vec::new()))
5408 .collect::<HashMap<_, _>>();
5409 let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
5410
5411 for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
5412 let requested_values = (0..chunk.len())
5413 .map(|_| "(?, ?)")
5414 .collect::<Vec<_>>()
5415 .join(", ");
5416 let sql = format!(
5417 "WITH requested(target_file, target_symbol) AS (VALUES {requested_values})
5418 SELECT e.target_file, e.target_symbol, e.line,
5419 r.byte_start, r.byte_end, r.status, e.provenance,
5420 src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5421 src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5422 src_file.lang,
5423 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5424 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5425 tgt_file.lang
5426 FROM requested requested
5427 JOIN edges e
5428 ON e.target_file = requested.target_file
5429 AND e.target_symbol = requested.target_symbol
5430 AND e.kind = 'call'
5431 JOIN refs r ON r.ref_id = e.ref_id
5432 JOIN nodes src ON src.id = e.source_node
5433 JOIN files src_file ON src_file.path = src.file_path
5434 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5435 ON tgt.id = e.target_node
5436 ORDER BY e.target_file, e.target_symbol, e.source_node,
5437 r.byte_start, r.line, r.ref_id"
5438 );
5439 let bindings = chunk
5440 .iter()
5441 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5442 let mut stmt = conn.prepare(&sql)?;
5443 let rows = stmt.query_map(params_from_iter(bindings), |row| {
5444 let call = direct_call_site_from_row(row)?;
5445 let target_key = (call.target_file.clone(), call.target_symbol.clone());
5446 Ok((target_key, call))
5447 })?;
5448 for row in rows {
5449 let (target, call) = row?;
5450 callers_by_target
5451 .get_mut(&target)
5452 .expect("batched caller row belongs to a requested target")
5453 .push(call);
5454 }
5455 }
5456
5457 Ok(callers_by_target)
5458}
5459
5460const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
5462const OUTGOING_NODE_BATCH_SIZE: usize = 999;
5464
5465fn outgoing_calls_for_symbol_tuples(
5466 conn: &Connection,
5467 sources: &[(String, String)],
5468) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5469 let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
5470 let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
5471 let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
5472 let source_nodes = unique_sources
5473 .iter()
5474 .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
5475 .cloned()
5476 .collect::<Vec<_>>();
5477 let source_nodes_by_id = source_nodes
5478 .iter()
5479 .cloned()
5480 .map(|node| (node.node_id.clone(), node))
5481 .collect::<HashMap<_, _>>();
5482 let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
5483
5484 for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
5485 let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
5486 let sql = format!(
5487 "SELECT e.source_node,
5488 e.target_file, e.target_symbol, e.line,
5489 r.byte_start, r.byte_end, r.status, e.provenance,
5490 CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
5491 tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5492 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5493 tgt_file.lang
5494 FROM edges e
5495 JOIN refs r ON r.ref_id = e.ref_id
5496 LEFT JOIN nodes tgt ON tgt.id = e.target_node
5497 LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
5498 WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
5499 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
5500 );
5501 let bindings = chunk.iter().map(|node| node.node_id.as_str());
5502 let mut stmt = conn.prepare(&sql)?;
5503 let rows = stmt.query_map(params_from_iter(bindings), |row| {
5504 let source_node_id = row.get::<_, String>(0)?;
5505 let caller = source_nodes_by_id
5506 .get(&source_node_id)
5507 .expect("batched outgoing row belongs to a requested source node")
5508 .clone();
5509 let target = optional_store_node_from_row_at(row, 8)?;
5510 Ok((
5511 source_node_id,
5512 StoreCallSite {
5513 caller,
5514 target_file: row.get(1)?,
5515 target_symbol: row.get(2)?,
5516 target,
5517 line: row.get::<_, i64>(3)?.max(0) as u32,
5518 byte_start: row.get::<_, i64>(4)?.max(0) as usize,
5519 byte_end: row.get::<_, i64>(5)?.max(0) as usize,
5520 resolved: row.get::<_, String>(6)? == "resolved",
5521 provenance: row.get(7)?,
5522 },
5523 ))
5524 })?;
5525 for row in rows {
5526 let (source_node_id, call) = row?;
5527 calls_by_node.entry(source_node_id).or_default().push(call);
5528 }
5529 }
5530
5531 let mut calls_by_source = HashMap::new();
5532 for source in &unique_sources {
5533 let mut calls = Vec::new();
5534 if let Some(nodes) = source_nodes_by_symbol.get(source) {
5535 for node in nodes {
5536 if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
5537 calls.extend(node_calls);
5538 }
5539 }
5540 }
5541 calls_by_source.insert(source.clone(), calls);
5542 }
5543
5544 let target_tuples = calls_by_source
5547 .values()
5548 .flatten()
5549 .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
5550 .collect::<Vec<_>>();
5551 let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
5552 for calls in calls_by_source.values_mut() {
5553 for call in calls {
5554 if let Some(target) = target_nodes
5555 .get(&(call.target_file.clone(), call.target_symbol.clone()))
5556 .and_then(|nodes| nodes.first())
5557 {
5558 call.target = Some(target.clone());
5559 }
5560 }
5561 }
5562
5563 Ok(calls_by_source)
5564}
5565
5566fn nodes_for_symbol_tuples(
5567 conn: &Connection,
5568 symbols: &[(String, String)],
5569) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
5570 let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
5571 let mut nodes_by_symbol = unique_symbols
5572 .iter()
5573 .cloned()
5574 .map(|symbol| (symbol, Vec::new()))
5575 .collect::<HashMap<_, _>>();
5576 let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
5577
5578 for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
5579 let requested_values = (0..chunk.len())
5580 .map(|_| "(?, ?)")
5581 .collect::<Vec<_>>()
5582 .join(", ");
5583 let sql = format!(
5584 "WITH requested(file, symbol) AS (VALUES {requested_values})
5585 SELECT requested.file, requested.symbol,
5586 node.id, node.file_path, node.scoped_name, node.name, node.kind,
5587 node.start_line, node.end_line, node.signature, node.exported,
5588 node.is_callgraph_entry_point, node_file.lang
5589 FROM requested
5590 JOIN nodes node INDEXED BY idx_nodes_file
5591 ON node.file_path = requested.file
5592 AND node.scoped_name = requested.symbol
5593 JOIN files node_file ON node_file.path = node.file_path
5594 ORDER BY requested.file, requested.symbol,
5595 node.scoped_name, node.start_line, node.end_line,
5596 node.start_col, node.range_ordinal"
5597 );
5598 let bindings = chunk
5599 .iter()
5600 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5601 let mut stmt = conn.prepare(&sql)?;
5602 let rows = stmt.query_map(params_from_iter(bindings), |row| {
5603 Ok((
5604 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
5605 store_node_from_row_at(row, 2)?,
5606 ))
5607 })?;
5608 for row in rows {
5609 let (symbol, node) = row?;
5610 nodes_by_symbol.entry(symbol).or_default().push(node);
5611 }
5612 }
5613
5614 Ok(nodes_by_symbol)
5615}
5616
5617fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5618 let mut stmt = conn.prepare(
5619 "SELECT e.target_file, e.target_symbol, e.line,
5620 r.byte_start, r.byte_end, r.status, e.provenance,
5621 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5622 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5623 tgt_file.lang
5624 FROM edges e
5625 JOIN refs r ON r.ref_id = e.ref_id
5626 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5627 ON tgt.id = e.target_node
5628 WHERE e.kind = 'call' AND e.source_node = ?1
5629 ORDER BY r.byte_start, r.line, r.ref_id",
5630 )?;
5631 let rows = stmt.query_map(params![node.node_id], |row| {
5632 let target = optional_store_node_from_row_at(row, 7)?;
5633 Ok(StoreCallSite {
5634 caller: node.clone(),
5635 target_file: row.get(0)?,
5636 target_symbol: row.get(1)?,
5637 target,
5638 line: row.get::<_, i64>(2)?.max(0) as u32,
5639 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5640 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5641 resolved: row.get::<_, String>(5)? == "resolved",
5642 provenance: row.get(6)?,
5643 })
5644 })?;
5645 rows.collect::<std::result::Result<Vec<_>, _>>()
5646 .map_err(Into::into)
5647}
5648
5649fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5650 let mut stmt = conn.prepare(
5651 "SELECT r.target_file, r.target_symbol, r.line,
5652 r.byte_start, r.byte_end, r.status, r.provenance,
5653 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5654 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5655 tgt_file.lang
5656 FROM refs r
5657 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5658 ON tgt.id = r.target_node
5659 WHERE r.caller_node = ?1
5660 AND r.kind = 'call'
5661 AND r.status <> 'unresolved'
5662 AND r.target_file = ?2
5663 AND r.target_symbol = ?3
5664 AND r.provenance = ?4
5665 AND NOT EXISTS (
5666 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
5667 )
5668 ORDER BY r.byte_start, r.line, r.ref_id",
5669 )?;
5670 let rows = stmt.query_map(
5671 params![
5672 &node.node_id,
5673 &node.file,
5674 &node.symbol,
5675 PROVENANCE_TREESITTER
5676 ],
5677 |row| {
5678 let target = optional_store_node_from_row_at(row, 7)?;
5679 Ok(StoreCallSite {
5680 caller: node.clone(),
5681 target_file: row.get(0)?,
5682 target_symbol: row.get(1)?,
5683 target,
5684 line: row.get::<_, i64>(2)?.max(0) as u32,
5685 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5686 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5687 resolved: row.get::<_, String>(5)? == "resolved",
5688 provenance: row.get(6)?,
5689 })
5690 },
5691 )?;
5692 rows.collect::<std::result::Result<Vec<_>, _>>()
5693 .map_err(Into::into)
5694}
5695
5696fn unresolved_calls_for_node(
5697 conn: &Connection,
5698 node: &StoreNode,
5699) -> Result<Vec<StoreUnresolvedCall>> {
5700 let mut stmt = conn.prepare(
5701 "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
5702 FROM refs
5703 WHERE caller_node = ?1
5704 AND kind = 'call'
5705 AND status = 'unresolved'
5706 AND NOT EXISTS (
5707 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
5708 )
5709 ORDER BY byte_start, line, ref_id",
5710 )?;
5711 let rows = stmt.query_map(params![node.node_id], |row| {
5712 Ok(StoreUnresolvedCall {
5713 caller: node.clone(),
5714 symbol: row.get(0)?,
5715 full_ref: row.get(1)?,
5716 line: row.get::<_, i64>(2)?.max(0) as u32,
5717 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5718 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5719 })
5720 })?;
5721 rows.collect::<std::result::Result<Vec<_>, _>>()
5722 .map_err(Into::into)
5723}
5724
5725fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
5726 let mut calls = Vec::new();
5727 calls.extend(
5728 outgoing_calls_for_node(conn, node)?
5729 .into_iter()
5730 .map(StoreForwardCall::Resolved),
5731 );
5732 calls.extend(
5733 unresolved_calls_for_node(conn, node)?
5734 .into_iter()
5735 .map(StoreForwardCall::Unresolved),
5736 );
5737 calls.sort_by(|left, right| {
5738 left.byte_start()
5739 .cmp(&right.byte_start())
5740 .then(left.line().cmp(&right.line()))
5741 });
5742 Ok(calls)
5743}
5744
5745fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
5746 let resolved_count: i64 = conn.query_row(
5747 "SELECT COUNT(*)
5748 FROM edges e
5749 JOIN refs r ON r.ref_id = e.ref_id
5750 WHERE e.kind = 'call' AND e.source_node = ?1",
5751 params![&node.node_id],
5752 |row| row.get(0),
5753 )?;
5754 let unresolved_count: i64 = conn.query_row(
5755 "SELECT COUNT(*)
5756 FROM refs
5757 WHERE caller_node = ?1
5758 AND kind = 'call'
5759 AND status = 'unresolved'
5760 AND NOT EXISTS (
5761 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
5762 )",
5763 params![&node.node_id],
5764 |row| row.get(0),
5765 )?;
5766 let total = resolved_count.saturating_add(unresolved_count);
5767 Ok(usize::try_from(total).unwrap_or(usize::MAX))
5768}
5769
5770fn call_tree_inner(
5771 conn: &Connection,
5772 node: &StoreNode,
5773 max_depth: usize,
5774 current_depth: usize,
5775 visited: &mut HashSet<(String, String)>,
5776) -> Result<callgraph::CallTreeNode> {
5777 let visit_key = (node.file.clone(), node.symbol.clone());
5778 if visited.contains(&visit_key) {
5779 return Ok(callgraph::CallTreeNode {
5780 name: node.symbol.clone(),
5781 file: node.file.clone(),
5782 line: node.line,
5783 signature: node.signature.clone(),
5784 resolved: true,
5785 children: Vec::new(),
5786 depth_limited: false,
5787 truncated: 0,
5788 });
5789 }
5790 visited.insert(visit_key.clone());
5791
5792 let mut children = Vec::new();
5793 let mut depth_limited = false;
5794 let mut truncated = 0usize;
5795
5796 if current_depth < max_depth {
5797 let calls = forward_calls_for_node(conn, node)?;
5798 for call in calls {
5799 match call {
5800 StoreForwardCall::Resolved(site) => {
5801 if let Some(target) = site.target {
5802 let child =
5803 call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
5804 depth_limited |= child.depth_limited;
5805 truncated += child.truncated;
5806 children.push(child);
5807 } else {
5808 children.push(callgraph::CallTreeNode {
5809 name: site.target_symbol,
5810 file: site.target_file,
5811 line: site.line,
5812 signature: None,
5813 resolved: false,
5814 children: Vec::new(),
5815 depth_limited: false,
5816 truncated: 0,
5817 });
5818 }
5819 }
5820 StoreForwardCall::Unresolved(call) => {
5821 children.push(callgraph::CallTreeNode {
5822 name: call.symbol,
5823 file: call.caller.file,
5824 line: call.line,
5825 signature: None,
5826 resolved: false,
5827 children: Vec::new(),
5828 depth_limited: false,
5829 truncated: 0,
5830 });
5831 }
5832 }
5833 }
5834 } else {
5835 truncated = forward_call_count_for_node(conn, node)?;
5836 depth_limited = truncated > 0;
5837 }
5838
5839 visited.remove(&visit_key);
5840 Ok(callgraph::CallTreeNode {
5841 name: node.symbol.clone(),
5842 file: node.file.clone(),
5843 line: node.line,
5844 signature: node.signature.clone(),
5845 resolved: true,
5846 children,
5847 depth_limited,
5848 truncated,
5849 })
5850}
5851
5852fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
5853 callgraph::TraceToSymbolHop {
5854 symbol: node.symbol.clone(),
5855 file: node.file.clone(),
5856 line: node.line,
5857 }
5858}
5859
5860fn trace_to_symbol_matches_target(
5861 node: &StoreNode,
5862 to_symbol: &str,
5863 to_file: Option<&str>,
5864) -> bool {
5865 if !symbol_query_matches(&node.symbol, to_symbol) {
5866 return false;
5867 }
5868 match to_file {
5869 Some(file) => node.file == file,
5870 None => true,
5871 }
5872}
5873
5874fn symbol_query_matches(symbol: &str, query: &str) -> bool {
5875 symbol == query || unqualified_name(symbol) == query
5876}
5877
5878fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
5879 let source = std::fs::read_to_string(path).ok()?;
5880 Some(source.lines().map(|line| line.trim().to_string()).collect())
5881}
5882
5883#[doc(hidden)]
5884pub fn live_callgraph_edge_snapshot(
5885 project_root: &Path,
5886 files: &[PathBuf],
5887) -> Result<BTreeSet<StoredEdge>> {
5888 let files = normalize_file_list(project_root, files)?;
5889 let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
5890 let mut file_data = Vec::new();
5891 for file in &files {
5892 let canon = canonicalize_path(file);
5893 let data = graph.build_file(&canon)?.clone();
5894 file_data.push((canon, data));
5895 }
5896
5897 let mut edges = BTreeSet::new();
5898 for (caller_file, data) in &file_data {
5899 for (caller_symbol, call_sites) in &data.calls_by_symbol {
5900 for call_site in call_sites {
5901 let resolution = graph.resolve_cross_file_edge(
5902 &call_site.full_callee,
5903 &call_site.callee_name,
5904 caller_file,
5905 &data.import_block,
5906 );
5907 let (target_file, target_symbol) = match resolution {
5908 EdgeResolution::Resolved { file, symbol } => (file, symbol),
5909 EdgeResolution::Unresolved { callee_name } => {
5910 if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
5911 continue;
5912 }
5913 let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
5914 data,
5915 caller_file,
5916 &callee_name,
5917 ) else {
5918 continue;
5919 };
5920 (caller_file.clone(), target_symbol)
5921 }
5922 };
5923 if target_file == *caller_file && target_symbol == *caller_symbol {
5924 continue;
5925 }
5926 edges.insert(StoredEdge {
5927 source_file: relative_path(project_root, caller_file),
5928 source_symbol: caller_symbol.clone(),
5929 target_file: relative_path(project_root, &target_file),
5930 target_symbol,
5931 kind: "call".to_string(),
5932 line: call_site.line,
5933 });
5934 }
5935 }
5936 }
5937 Ok(edges)
5938}
5939
5940fn rebuild_cooldown_records() -> &'static Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>
5941{
5942 SUCCESSFUL_REBUILDS.get_or_init(|| Mutex::new(HashMap::new()))
5943}
5944
5945fn rebuild_cooldown_key(callgraph_dir: &Path, project_key: &str) -> RebuildCooldownKey {
5946 RebuildCooldownKey {
5947 callgraph_dir: std::fs::canonicalize(callgraph_dir)
5948 .unwrap_or_else(|_| callgraph_dir.to_path_buf()),
5949 project_key: project_key.to_string(),
5950 }
5951}
5952
5953fn rebuild_cooldown_denial(
5954 callgraph_dir: &Path,
5955 project_key: &str,
5956 project_root: &Path,
5957 now: Instant,
5958) -> Option<(PathBuf, Duration)> {
5959 let key = rebuild_cooldown_key(callgraph_dir, project_key);
5960 let records = rebuild_cooldown_records()
5961 .lock()
5962 .unwrap_or_else(std::sync::PoisonError::into_inner);
5963 let record = records.get(&key)?;
5964 if record.project_root == project_root || !record.cross_root_cooldown_armed {
5965 return None;
5966 }
5967 let elapsed = now.saturating_duration_since(record.published_at);
5968 (elapsed < REBUILD_COOLDOWN).then(|| (record.project_root.clone(), REBUILD_COOLDOWN - elapsed))
5969}
5970
5971fn record_successful_rebuild(
5972 callgraph_dir: &Path,
5973 project_key: &str,
5974 project_root: &Path,
5975 published_at: Instant,
5976) {
5977 let key = rebuild_cooldown_key(callgraph_dir, project_key);
5978 let mut records = rebuild_cooldown_records()
5979 .lock()
5980 .unwrap_or_else(std::sync::PoisonError::into_inner);
5981 if records.len() >= 4_096 && !records.contains_key(&key) {
5982 if let Some(evict) = records.keys().next().cloned() {
5983 records.remove(&evict);
5984 }
5985 }
5986 let cross_root_cooldown_armed = records.get(&key).is_some_and(|previous| {
5987 previous.cross_root_cooldown_armed || previous.project_root != project_root
5988 });
5989 records.insert(
5990 key,
5991 RebuildCooldownRecord {
5992 project_root: project_root.to_path_buf(),
5993 published_at,
5994 cross_root_cooldown_armed,
5995 },
5996 );
5997}
5998
5999fn acquire_writer_lease(
6000 callgraph_dir: &Path,
6001 project_key: &str,
6002 project_root: &Path,
6003) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
6004 crate::root_cache::WriterLease::acquire_shared(
6005 crate::root_cache::RootCacheDomain::Callgraph,
6006 callgraph_dir,
6007 project_key,
6008 project_root,
6009 )
6010 .map_err(CallGraphStoreError::from)
6011}
6012
6013fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
6014 if lease.verify()? {
6015 Ok(())
6016 } else {
6017 Err(CallGraphStoreError::Unavailable(format!(
6018 "callgraph writer lease for key {} lost epoch {}; aborting write",
6019 lease.key(),
6020 lease.epoch()
6021 )))
6022 }
6023}
6024
6025fn legacy_migration_completion_line(
6026 project_key: &str,
6027 method: &str,
6028 legacy_bytes: u64,
6029 migrated_bytes: u64,
6030) -> String {
6031 format!(
6032 "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
6033 )
6034}
6035
6036fn log_legacy_migration_completion(
6037 project_key: &str,
6038 method: &str,
6039 legacy_bytes: u64,
6040 migrated_bytes: u64,
6041) {
6042 crate::slog_info!(
6043 "{}",
6044 legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
6045 );
6046}
6047
6048fn try_legacy_migration_or_fallback(
6049 callgraph_dir: &Path,
6050 project_root: &Path,
6051 project_key: &str,
6052 writer_lease: Arc<crate::root_cache::WriterLease>,
6053) -> Result<Option<CallGraphStore>> {
6054 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
6055 if partitions.is_empty() {
6056 return Ok(None);
6057 }
6058
6059 for partition in &partitions {
6060 if let Some(source) = newest_superseded_legacy_generation(partition)? {
6061 if !migration_disk_floor_allows(&source, callgraph_dir)? {
6062 return open_legacy_fallback_store(
6063 callgraph_dir,
6064 project_root,
6065 project_key,
6066 &partitions,
6067 );
6068 }
6069 match publish_generation_copy_migration(
6070 callgraph_dir,
6071 project_key,
6072 &source,
6073 Arc::clone(&writer_lease),
6074 ) {
6075 Ok(published) => {
6076 log_legacy_migration_completion(
6077 project_key,
6078 "generation_copy",
6079 source.source_bytes,
6080 published.migrated_bytes,
6081 );
6082 return CallGraphStore::open_generation(
6083 callgraph_dir,
6084 project_root.to_path_buf(),
6085 project_key.to_string(),
6086 published.generation,
6087 writer_lease,
6088 )
6089 .map(Some);
6090 }
6091 Err(error) => {
6092 crate::slog_warn!(
6093 "root-keyed callgraph generation-copy migration failed from {}: {}",
6094 source.sqlite_path.display(),
6095 error
6096 );
6097 return open_legacy_fallback_store(
6098 callgraph_dir,
6099 project_root,
6100 project_key,
6101 &partitions,
6102 );
6103 }
6104 }
6105 }
6106
6107 if let Some(source) = current_legacy_generation(partition)? {
6108 if !migration_disk_floor_allows(&source, callgraph_dir)? {
6109 return open_legacy_fallback_store(
6110 callgraph_dir,
6111 project_root,
6112 project_key,
6113 &partitions,
6114 );
6115 }
6116 match publish_backup_migration(
6117 callgraph_dir,
6118 project_key,
6119 &source,
6120 Arc::clone(&writer_lease),
6121 ) {
6122 Ok(published) => {
6123 log_legacy_migration_completion(
6124 project_key,
6125 "sqlite_backup",
6126 source.source_bytes,
6127 published.migrated_bytes,
6128 );
6129 return CallGraphStore::open_generation(
6130 callgraph_dir,
6131 project_root.to_path_buf(),
6132 project_key.to_string(),
6133 published.generation,
6134 writer_lease,
6135 )
6136 .map(Some);
6137 }
6138 Err(error) => {
6139 crate::slog_warn!(
6140 "root-keyed callgraph backup migration failed from {}: {}",
6141 source.sqlite_path.display(),
6142 error
6143 );
6144 return open_legacy_fallback_store(
6145 callgraph_dir,
6146 project_root,
6147 project_key,
6148 &partitions,
6149 );
6150 }
6151 }
6152 }
6153 }
6154
6155 open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
6156}
6157
6158fn open_legacy_fallback_store(
6159 callgraph_dir: &Path,
6160 project_root: &Path,
6161 project_key: &str,
6162 partitions: &[LegacyCallgraphPartition],
6163) -> Result<Option<CallGraphStore>> {
6164 let Some(target) = first_ready_legacy_target(partitions)? else {
6165 return Ok(None);
6166 };
6167 crate::slog_warn!(
6168 "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
6169 target.partition.harness,
6170 target.sqlite_path.display()
6171 );
6172 let conn = open_readonly_connection(&target.sqlite_path)?;
6173 if !database_ready(&conn).unwrap_or(false) {
6174 return Ok(None);
6175 }
6176 let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
6177 let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
6178 Ok(Some(CallGraphStore::from_connection(
6179 project_root.to_path_buf(),
6180 project_key.to_string(),
6181 target.sqlite_path,
6182 callgraph_dir.to_path_buf(),
6183 true,
6184 target.generation,
6185 None,
6186 Some(read_marker),
6187 conn,
6188 )))
6189}
6190
6191fn migration_disk_floor_allows(
6192 source: &LegacyCallgraphTarget,
6193 callgraph_dir: &Path,
6194) -> Result<bool> {
6195 let available = migration_available_disk(callgraph_dir)?;
6196 let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
6197 source.source_bytes,
6198 available,
6199 );
6200 if decision.should_skip_copy() {
6201 crate::slog_warn!(
6202 "{}",
6203 decision.warning_message(&source.sqlite_path, callgraph_dir)
6204 );
6205 return Ok(false);
6206 }
6207 Ok(true)
6208}
6209
6210fn migration_available_disk(path: &Path) -> Result<u64> {
6211 if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
6212 return Ok(bytes);
6213 }
6214 crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
6215}
6216
6217fn legacy_callgraph_partitions(
6218 callgraph_dir: &Path,
6219 project_key: &str,
6220) -> Result<Vec<LegacyCallgraphPartition>> {
6221 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6222 return Ok(Vec::new());
6223 };
6224 let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
6225 let mut partitions = inventory
6226 .into_iter()
6227 .filter(|entry| {
6228 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
6229 && entry.key == project_key
6230 })
6231 .map(|entry| {
6232 let dir = if entry.path.is_dir() {
6233 entry.path.clone()
6234 } else {
6235 entry
6236 .path
6237 .parent()
6238 .map(Path::to_path_buf)
6239 .unwrap_or_else(|| entry.path.clone())
6240 };
6241 LegacyCallgraphPartition {
6242 harness: entry.harness,
6243 dir,
6244 key: entry.key,
6245 bytes: entry.bytes,
6246 freshness: entry.callgraph_pointer_mtime,
6247 }
6248 })
6249 .collect::<Vec<_>>();
6250 partitions.sort_by(|left, right| {
6251 right
6252 .freshness
6253 .cmp(&left.freshness)
6254 .then_with(|| right.bytes.cmp(&left.bytes))
6255 .then_with(|| left.harness.cmp(&right.harness))
6256 });
6257 Ok(partitions)
6258}
6259
6260fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
6261 let domain_dir = callgraph_dir.parent()?;
6262 if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
6263 return None;
6264 }
6265 domain_dir.parent().map(Path::to_path_buf)
6266}
6267
6268pub(crate) fn all_legacy_partitions_migrated_for_keys(
6269 callgraph_dir: &Path,
6270 configured_keys: &BTreeSet<String>,
6271) -> Result<bool> {
6272 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6273 return Ok(false);
6274 };
6275 let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
6276 .into_iter()
6277 .filter(|entry| {
6278 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
6279 && configured_keys.contains(&entry.key)
6280 })
6281 .map(|entry| entry.key)
6282 .collect::<BTreeSet<_>>();
6283 if legacy_keys.is_empty() {
6284 return Ok(false);
6285 }
6286
6287 for key in legacy_keys {
6288 let migrated_dir = storage_root.join("callgraph").join(&key);
6289 let Some(generation) = read_pointer(&migrated_dir, &key) else {
6290 return Ok(false);
6291 };
6292 if !migration_generation_requires_manifest(&generation)
6293 || !migration_manifest_valid(&migrated_dir, &generation)
6294 {
6295 return Ok(false);
6296 }
6297 }
6298 Ok(true)
6299}
6300
6301fn newest_superseded_legacy_generation(
6302 partition: &LegacyCallgraphPartition,
6303) -> Result<Option<LegacyCallgraphTarget>> {
6304 let Some(current) = read_pointer(&partition.dir, &partition.key) else {
6305 return Ok(None);
6306 };
6307 let prefix = format!("{}.g", partition.key);
6308 let Ok(entries) = std::fs::read_dir(&partition.dir) else {
6309 return Ok(None);
6310 };
6311 let mut candidates = Vec::new();
6312 for entry in entries.flatten() {
6313 let name = entry.file_name().to_string_lossy().to_string();
6314 if name == current
6315 || name.contains(".tmp.")
6316 || !name.starts_with(&prefix)
6317 || !name.ends_with(".sqlite")
6318 {
6319 continue;
6320 }
6321 let path = entry.path();
6322 if !db_path_ready(&path) {
6323 continue;
6324 }
6325 let modified = entry
6326 .metadata()
6327 .and_then(|metadata| metadata.modified())
6328 .unwrap_or(SystemTime::UNIX_EPOCH);
6329 candidates.push((modified, path, name));
6330 }
6331 candidates.sort_by(|left, right| right.0.cmp(&left.0));
6332 let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
6333 return Ok(None);
6334 };
6335 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6336 Ok(Some(LegacyCallgraphTarget {
6337 partition: partition.clone(),
6338 sqlite_path,
6339 generation: Some(generation),
6340 source_bytes,
6341 source_blake3: String::new(),
6342 }))
6343}
6344
6345fn current_legacy_generation(
6346 partition: &LegacyCallgraphPartition,
6347) -> Result<Option<LegacyCallgraphTarget>> {
6348 let Some(target) = ready_legacy_target(partition)? else {
6349 return Ok(None);
6350 };
6351 let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
6352 if has_superseded {
6353 return Ok(None);
6354 }
6355 Ok(Some(target))
6356}
6357
6358fn freshest_legacy_fallback_target(
6359 callgraph_dir: &Path,
6360 project_key: &str,
6361) -> Result<Option<LegacyCallgraphTarget>> {
6362 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
6363 first_ready_legacy_target(&partitions)
6364}
6365
6366fn first_ready_legacy_target(
6367 partitions: &[LegacyCallgraphPartition],
6368) -> Result<Option<LegacyCallgraphTarget>> {
6369 for partition in partitions {
6370 if let Some(target) = ready_legacy_target(partition)? {
6371 return Ok(Some(target));
6372 }
6373 }
6374 Ok(None)
6375}
6376
6377fn ready_legacy_target(
6378 partition: &LegacyCallgraphPartition,
6379) -> Result<Option<LegacyCallgraphTarget>> {
6380 if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
6381 let sqlite_path = partition.dir.join(&generation);
6382 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6383 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6384 return Ok(Some(LegacyCallgraphTarget {
6385 partition: partition.clone(),
6386 sqlite_path,
6387 generation: Some(generation),
6388 source_bytes,
6389 source_blake3: String::new(),
6390 }));
6391 }
6392 }
6393
6394 let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
6395 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6396 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6397 return Ok(Some(LegacyCallgraphTarget {
6398 partition: partition.clone(),
6399 sqlite_path,
6400 generation: None,
6401 source_bytes,
6402 source_blake3: String::new(),
6403 }));
6404 }
6405 Ok(None)
6406}
6407
6408fn publish_generation_copy_migration(
6409 callgraph_dir: &Path,
6410 project_key: &str,
6411 source: &LegacyCallgraphTarget,
6412 writer_lease: Arc<crate::root_cache::WriterLease>,
6413) -> Result<PublishedLegacyMigration> {
6414 let generation = migration_generation_file_name(project_key, "copy");
6415 let temp_path = migration_temp_path(callgraph_dir, &generation);
6416 remove_sqlite_file_set(&temp_path);
6417 copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
6418 fail_after_temp_copy_for_test()?;
6419
6420 let mut source = source.clone();
6421 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
6422 source.source_blake3 = fingerprint.blake3;
6423 let generation = publish_migrated_generation(
6424 callgraph_dir,
6425 project_key,
6426 &generation,
6427 &temp_path,
6428 &source,
6429 fingerprint.bytes,
6430 writer_lease,
6431 "generation_copy",
6432 )?;
6433 Ok(PublishedLegacyMigration {
6434 generation,
6435 migrated_bytes: fingerprint.bytes,
6436 })
6437}
6438
6439fn publish_backup_migration(
6440 callgraph_dir: &Path,
6441 project_key: &str,
6442 source: &LegacyCallgraphTarget,
6443 writer_lease: Arc<crate::root_cache::WriterLease>,
6444) -> Result<PublishedLegacyMigration> {
6445 if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
6446 return Err(CallGraphStoreError::Unavailable(
6447 "legacy callgraph backup migration budget exhausted by test seam".to_string(),
6448 ));
6449 }
6450
6451 let generation = migration_generation_file_name(project_key, "backup");
6452 let temp_path = migration_temp_path(callgraph_dir, &generation);
6453 remove_sqlite_file_set(&temp_path);
6454
6455 let source_conn = open_readonly_connection(&source.sqlite_path)?;
6456 let mut destination = Connection::open(&temp_path)?;
6457 destination.busy_timeout(Duration::from_secs(5))?;
6458 let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
6459 let started = Instant::now();
6460 let mut retries = 0;
6461 loop {
6462 match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
6463 rusqlite::backup::StepResult::Done => break,
6464 rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
6465 rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
6466 retries += 1;
6467 if retries > MIGRATION_BACKUP_RETRY_BUDGET
6468 || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
6469 {
6470 return Err(CallGraphStoreError::Unavailable(format!(
6471 "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
6472 )));
6473 }
6474 std::thread::sleep(Duration::from_millis(20));
6475 }
6476 _ => {
6477 return Err(CallGraphStoreError::Unavailable(
6478 "legacy callgraph backup returned an unknown step result".to_string(),
6479 ));
6480 }
6481 }
6482 }
6483 drop(backup);
6484
6485 let integrity: String =
6486 destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
6487 if integrity != "ok" {
6488 return Err(CallGraphStoreError::Unavailable(format!(
6489 "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
6490 )));
6491 }
6492 if !database_ready(&destination)? {
6493 return Err(CallGraphStoreError::Unavailable(
6494 "legacy callgraph backup produced a database without ready metadata".to_string(),
6495 ));
6496 }
6497 destination.execute_batch("PRAGMA optimize;")?;
6498 drop(destination);
6499 sync_file(&temp_path)?;
6500 fail_after_temp_copy_for_test()?;
6501
6502 let mut source = source.clone();
6503 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
6504 source.source_blake3 = fingerprint.blake3;
6505 let generation = publish_migrated_generation(
6506 callgraph_dir,
6507 project_key,
6508 &generation,
6509 &temp_path,
6510 &source,
6511 fingerprint.bytes,
6512 writer_lease,
6513 "sqlite_backup",
6514 )?;
6515 Ok(PublishedLegacyMigration {
6516 generation,
6517 migrated_bytes: fingerprint.bytes,
6518 })
6519}
6520
6521fn publish_migrated_generation(
6522 callgraph_dir: &Path,
6523 project_key: &str,
6524 generation: &str,
6525 temp_path: &Path,
6526 source: &LegacyCallgraphTarget,
6527 migrated_bytes: u64,
6528 writer_lease: Arc<crate::root_cache::WriterLease>,
6529 method: &str,
6530) -> Result<String> {
6531 let gen_path = callgraph_dir.join(generation);
6532 checkpoint_sqlite_before_publication(temp_path);
6533 let publication = publish_if_current(|| {
6534 verify_writer_lease(&writer_lease)?;
6535 remove_sqlite_file_set(&gen_path);
6536 rename_sqlite_file_set(temp_path, &gen_path)?;
6537 crate::fs_lock::sync_parent(&gen_path);
6538
6539 verify_writer_lease(&writer_lease)?;
6540 publish_pointer(callgraph_dir, project_key, generation)?;
6541 write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
6542 Ok(generation.to_string())
6543 });
6544 if matches!(publication, Err(CallGraphStoreError::Superseded)) {
6545 remove_sqlite_file_set(temp_path);
6546 }
6547 publication
6548}
6549
6550fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
6551 if let Some(parent) = destination.parent() {
6552 std::fs::create_dir_all(parent)?;
6553 }
6554 for suffix in SQLITE_FILE_SET_SUFFIXES {
6555 let source_path = sqlite_file_set_path(source, suffix);
6556 if !source_path.is_file() {
6557 continue;
6558 }
6559 let destination_path = sqlite_file_set_path(destination, suffix);
6560 std::fs::copy(&source_path, &destination_path)?;
6561 sync_file(&destination_path)?;
6562 }
6563 Ok(())
6564}
6565
6566fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
6567 for suffix in SQLITE_FILE_SET_SUFFIXES {
6568 let source_path = sqlite_file_set_path(source, suffix);
6569 if !source_path.exists() {
6570 continue;
6571 }
6572 let destination_path = sqlite_file_set_path(destination, suffix);
6573 if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
6574 let _ = std::fs::remove_file(&source_path);
6575 return Err(error.into());
6576 }
6577 }
6578 Ok(())
6579}
6580
6581fn sqlite_file_set_size(path: &Path) -> Result<u64> {
6582 let mut bytes = 0_u64;
6583 for suffix in SQLITE_FILE_SET_SUFFIXES {
6584 let member = sqlite_file_set_path(path, suffix);
6585 if !member.is_file() {
6586 continue;
6587 }
6588 bytes = bytes.saturating_add(member.metadata()?.len());
6589 }
6590 Ok(bytes)
6591}
6592
6593fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
6594 let mut hasher = blake3::Hasher::new();
6595 let mut bytes = 0_u64;
6596 let mut buffer = [0_u8; 64 * 1024];
6597 for suffix in SQLITE_FILE_SET_SUFFIXES {
6598 let member = sqlite_file_set_path(path, suffix);
6599 if !member.is_file() {
6600 continue;
6601 }
6602 hasher.update(suffix.as_bytes());
6603 let mut file = std::fs::File::open(&member)?;
6604 loop {
6605 let read = file.read(&mut buffer)?;
6606 if read == 0 {
6607 break;
6608 }
6609 bytes = bytes.saturating_add(read as u64);
6610 hasher.update(&buffer[..read]);
6611 }
6612 }
6613 Ok(SourceFingerprint {
6614 bytes,
6615 blake3: hash_to_hex(hasher.finalize()),
6616 })
6617}
6618
6619fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
6620 if suffix.is_empty() {
6621 path.to_path_buf()
6622 } else {
6623 PathBuf::from(format!("{}{suffix}", path.display()))
6624 }
6625}
6626
6627fn sync_file(path: &Path) -> Result<()> {
6628 let file = std::fs::OpenOptions::new()
6629 .read(true)
6630 .write(true)
6631 .open(path)?;
6632 file.sync_all()?;
6633 Ok(())
6634}
6635
6636fn fail_after_temp_copy_for_test() -> Result<()> {
6637 if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
6638 return Err(CallGraphStoreError::Unavailable(
6639 "legacy callgraph migration stopped after temp copy by test seam".to_string(),
6640 ));
6641 }
6642 Ok(())
6643}
6644
6645fn migration_generation_file_name(project_key: &str, method: &str) -> String {
6646 format!(
6647 "{project_key}.g{}.{}{}{}.sqlite",
6648 now_nanos(),
6649 std::process::id(),
6650 MIGRATION_GENERATION_TAG,
6651 method
6652 )
6653}
6654
6655fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
6656 callgraph_dir.join(format!(
6657 "{generation}.tmp.{}.{}",
6658 std::process::id(),
6659 now_nanos()
6660 ))
6661}
6662
6663fn write_migration_manifest(
6664 callgraph_dir: &Path,
6665 generation: &str,
6666 source: &LegacyCallgraphTarget,
6667 migrated_bytes: u64,
6668 method: &str,
6669) -> Result<()> {
6670 let manifest_path = migration_manifest_path(callgraph_dir, generation);
6671 let temp_path = manifest_path.with_extension(format!(
6672 "migration.json.tmp.{}.{}",
6673 std::process::id(),
6674 now_nanos()
6675 ));
6676 let manifest = serde_json::json!({
6677 "version": MIGRATION_MANIFEST_VERSION,
6678 "method": method,
6679 "target_generation": generation,
6680 "source_harness": source.partition.harness,
6681 "source_path": source.sqlite_path.display().to_string(),
6682 "source_generation": source.generation,
6683 "source_bytes": source.source_bytes,
6684 "source_blake3": source.source_blake3,
6685 "migrated_bytes": migrated_bytes,
6686 });
6687 {
6688 use std::io::Write as _;
6689 let mut file = std::fs::File::create(&temp_path)?;
6690 file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
6691 file.write_all(b"\n")?;
6692 file.sync_all()?;
6693 }
6694 if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
6695 let _ = std::fs::remove_file(&temp_path);
6696 return Err(error.into());
6697 }
6698 crate::fs_lock::sync_parent(&manifest_path);
6699 Ok(())
6700}
6701
6702fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
6703 callgraph_dir.join(format!("{generation}.migration.json"))
6704}
6705
6706fn migration_generation_requires_manifest(generation: &str) -> bool {
6707 generation.contains(MIGRATION_GENERATION_TAG)
6708}
6709
6710fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
6711 if !migration_generation_requires_manifest(generation) {
6712 return true;
6713 }
6714 let path = migration_manifest_path(callgraph_dir, generation);
6715 let Ok(bytes) = std::fs::read(path) else {
6716 return false;
6717 };
6718 let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
6719 return false;
6720 };
6721 value.get("version").and_then(serde_json::Value::as_u64)
6722 == Some(MIGRATION_MANIFEST_VERSION as u64)
6723 && value
6724 .get("target_generation")
6725 .and_then(serde_json::Value::as_str)
6726 == Some(generation)
6727 && value
6728 .get("source_bytes")
6729 .and_then(serde_json::Value::as_u64)
6730 .is_some_and(|bytes| bytes > 0)
6731 && value
6732 .get("source_blake3")
6733 .and_then(serde_json::Value::as_str)
6734 .is_some_and(|hash| hash.len() == 64)
6735}
6736
6737fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
6738 let pointer_generation = read_pointer(callgraph_dir, project_key);
6739 if let Some(generation) = pointer_generation.as_deref() {
6740 if migration_generation_requires_manifest(generation)
6741 && !migration_manifest_valid(callgraph_dir, generation)
6742 {
6743 let path = callgraph_dir.join(generation);
6744 remove_sqlite_file_set(&path);
6745 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
6746 let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
6747 }
6748 }
6749
6750 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6751 return;
6752 };
6753 for entry in entries.flatten() {
6754 let name = entry.file_name().to_string_lossy().to_string();
6755 let path = entry.path();
6756 if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
6757 let _ = std::fs::remove_file(path);
6758 continue;
6759 }
6760 if name.starts_with(&format!("{project_key}.g"))
6761 && name.ends_with(".sqlite")
6762 && name.contains(MIGRATION_GENERATION_TAG)
6763 && pointer_generation.as_deref() != Some(&name)
6764 && !migration_manifest_valid(callgraph_dir, &name)
6765 {
6766 remove_sqlite_file_set(&path);
6767 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
6768 }
6769 }
6770 crate::fs_lock::sync_parent(callgraph_dir);
6771}
6772
6773fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
6774 let mut hasher = blake3::Hasher::new();
6775 hasher.update(path.to_string_lossy().as_bytes());
6776 if let Some(generation) = generation {
6777 hasher.update(generation.as_bytes());
6778 }
6779 let digest = hash_to_hex(hasher.finalize());
6780 format!("legacy-{}", &digest[..16])
6781}
6782
6783fn open_readonly_connection(path: &Path) -> Result<Connection> {
6784 let uri = sqlite_readonly_uri(path);
6785 let conn = Connection::open_with_flags(
6786 &uri,
6787 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
6788 )?;
6789 conn.pragma_update(
6790 None,
6791 "synchronous",
6792 if write_amplification_baseline_enabled() {
6793 "FULL"
6794 } else {
6795 "NORMAL"
6796 },
6797 )?;
6798 conn.busy_timeout(reader_busy_timeout())?;
6799 conn.execute_batch("PRAGMA query_only=ON;")?;
6800 Ok(conn)
6801}
6802
6803fn reader_busy_timeout() -> Duration {
6804 let jitter = (now_nanos() % 500) as u64;
6805 Duration::from_millis(250 + jitter)
6806}
6807
6808fn sqlite_readonly_uri(path: &Path) -> String {
6809 let raw = path.to_string_lossy().replace('\\', "/");
6810 let encoded = percent_encode_sqlite_uri_path(&raw);
6811 if raw.starts_with('/') {
6812 format!("file://{encoded}?mode=ro")
6813 } else if raw.as_bytes().get(1) == Some(&b':') {
6814 format!("file:///{encoded}?mode=ro")
6815 } else {
6816 format!("file:{encoded}?mode=ro")
6817 }
6818}
6819
6820fn percent_encode_sqlite_uri_path(path: &str) -> String {
6821 let mut encoded = String::with_capacity(path.len());
6822 for byte in path.bytes() {
6823 match byte {
6824 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
6825 encoded.push(byte as char)
6826 }
6827 _ => encoded.push_str(&format!("%{byte:02X}")),
6828 }
6829 }
6830 encoded
6831}
6832
6833fn configure_connection(conn: &Connection) -> Result<()> {
6834 conn.busy_timeout(Duration::from_secs(5))?;
6838 conn.pragma_update(None, "journal_mode", "WAL")?;
6839 let baseline = write_amplification_baseline_enabled();
6840 conn.pragma_update(
6841 None,
6842 "synchronous",
6843 if baseline { "FULL" } else { "NORMAL" },
6844 )?;
6845 conn.pragma_update(
6846 None,
6847 "wal_autocheckpoint",
6848 if baseline {
6849 1_000
6850 } else {
6851 CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES
6852 },
6853 )?;
6854 conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
6855 Ok(())
6856}
6857
6858fn configure_build_connection(conn: &Connection) -> Result<()> {
6859 conn.busy_timeout(Duration::from_secs(5))?;
6864 conn.pragma_update(None, "journal_mode", "WAL")?;
6865 conn.pragma_update(
6866 None,
6867 "synchronous",
6868 if write_amplification_baseline_enabled() {
6869 "FULL"
6870 } else {
6871 "NORMAL"
6872 },
6873 )?;
6874 conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
6875 Ok(())
6876}
6877
6878fn checkpoint_sqlite_before_publication(path: &Path) {
6882 let Ok(conn) = Connection::open(path) else {
6883 return;
6884 };
6885 let _ = conn.pragma_update(None, "synchronous", "NORMAL");
6886 let _ = conn.busy_timeout(Duration::from_secs(5));
6887 let _ = checkpoint_wal_truncate(&conn);
6888}
6889
6890fn checkpoint_wal_truncate(conn: &Connection) -> bool {
6891 match conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
6892 row.get::<_, i64>(0)
6893 }) {
6894 Ok(0) => true,
6895 Ok(_) => false,
6896 Err(rusqlite::Error::SqliteFailure(error, _))
6897 if matches!(
6898 error.code,
6899 rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
6900 ) =>
6901 {
6902 false
6903 }
6904 Err(error) => {
6905 log::debug!("callgraph WAL truncate checkpoint skipped: {error}");
6906 false
6907 }
6908 }
6909}
6910
6911fn initialize_schema(conn: &Connection) -> Result<()> {
6912 conn.execute_batch(
6913 "CREATE TABLE IF NOT EXISTS files (
6914 path TEXT PRIMARY KEY,
6915 content_hash TEXT NOT NULL,
6916 mtime_ns INTEGER NOT NULL,
6917 size INTEGER NOT NULL,
6918 lang TEXT NOT NULL,
6919 is_dead_code_root INTEGER NOT NULL DEFAULT 0,
6920 is_public_api INTEGER NOT NULL DEFAULT 0,
6921 surface_fingerprint TEXT NOT NULL,
6922 indexed_at INTEGER NOT NULL
6923 );
6924
6925 CREATE TABLE IF NOT EXISTS nodes (
6926 id TEXT PRIMARY KEY,
6927 file_path TEXT NOT NULL,
6928 name TEXT NOT NULL,
6929 scoped_name TEXT NOT NULL,
6930 kind TEXT NOT NULL,
6931 start_line INTEGER NOT NULL,
6932 start_col INTEGER NOT NULL,
6933 end_line INTEGER NOT NULL,
6934 end_col INTEGER NOT NULL,
6935 range_ordinal INTEGER NOT NULL,
6936 signature TEXT,
6937 exported INTEGER NOT NULL,
6938 is_default_export INTEGER NOT NULL,
6939 is_type_like INTEGER NOT NULL,
6940 is_callgraph_entry_point INTEGER NOT NULL,
6941 provenance TEXT NOT NULL,
6942 UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
6943 );
6944 CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
6945 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
6946 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
6947
6948 CREATE TABLE IF NOT EXISTS refs (
6949 ref_id TEXT PRIMARY KEY,
6950 caller_node TEXT,
6951 caller_file TEXT NOT NULL,
6952 kind TEXT NOT NULL,
6953 short_name TEXT,
6954 full_ref TEXT,
6955 module_path TEXT,
6956 import_kind TEXT,
6957 local_name TEXT,
6958 requested_name TEXT,
6959 namespace_alias TEXT,
6960 wildcard INTEGER NOT NULL DEFAULT 0,
6961 line INTEGER NOT NULL,
6962 byte_start INTEGER NOT NULL,
6963 byte_end INTEGER NOT NULL,
6964 status TEXT NOT NULL,
6965 target_node TEXT,
6966 target_file TEXT,
6967 target_symbol TEXT,
6968 provenance TEXT NOT NULL
6969 );
6970 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
6971 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
6972 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
6973 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
6974 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
6975
6976 CREATE TABLE IF NOT EXISTS file_dependencies (
6977 file_path TEXT NOT NULL,
6978 dep_file TEXT NOT NULL,
6979 PRIMARY KEY(file_path, dep_file)
6980 );
6981 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
6982
6983 CREATE TABLE IF NOT EXISTS edges (
6984 edge_id TEXT PRIMARY KEY,
6985 ref_id TEXT NOT NULL,
6986 source_node TEXT NOT NULL,
6987 target_node TEXT,
6988 target_file TEXT NOT NULL,
6989 target_symbol TEXT NOT NULL,
6990 kind TEXT NOT NULL,
6991 line INTEGER NOT NULL,
6992 provenance TEXT NOT NULL
6993 );
6994 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
6995 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
6996 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
6997 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
6998
6999 CREATE TABLE IF NOT EXISTS dispatch_hints (
7000 id TEXT PRIMARY KEY,
7001 method_name TEXT NOT NULL,
7002 caller_node TEXT NOT NULL,
7003 file TEXT NOT NULL,
7004 line INTEGER NOT NULL,
7005 byte_start INTEGER NOT NULL,
7006 byte_end INTEGER NOT NULL,
7007 provenance TEXT NOT NULL
7008 );
7009 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
7010 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
7011
7012 CREATE TABLE IF NOT EXISTS type_ref_names (
7013 name TEXT PRIMARY KEY
7014 );
7015
7016 CREATE TABLE IF NOT EXISTS backend_file_state (
7017 backend TEXT NOT NULL,
7018 workspace_root TEXT NOT NULL,
7019 file_path TEXT NOT NULL,
7020 content_hash TEXT NOT NULL,
7021 status TEXT NOT NULL,
7022 updated_at INTEGER NOT NULL,
7023 PRIMARY KEY(backend, workspace_root, file_path, content_hash)
7024 );
7025 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
7026
7027 CREATE TABLE IF NOT EXISTS meta (
7028 k TEXT PRIMARY KEY,
7029 v TEXT NOT NULL
7030 );
7031
7032 -- The file walk is staged on disk so extraction can page through a
7033 -- deterministic inventory without retaining every source path in heap.
7034 CREATE TABLE IF NOT EXISTS staging_file_inventory (
7035 path TEXT PRIMARY KEY,
7036 size INTEGER NOT NULL
7037 ) WITHOUT ROWID;
7038
7039 -- Context needed only while a generation is staged. Raw refs live in
7040 -- `refs` with status `staged`; this table preserves the caller symbol
7041 -- needed to avoid inventing self edges during the later resolve pass.
7042 CREATE TABLE IF NOT EXISTS staging_ref_context (
7043 ref_id TEXT PRIMARY KEY,
7044 caller_symbol TEXT
7045 );",
7046 )?;
7047 insert_meta(conn)?;
7048 Ok(())
7049}
7050
7051fn insert_meta(conn: &Connection) -> Result<()> {
7052 conn.execute(
7053 "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
7054 params![SCHEMA_VERSION.to_string()],
7055 )?;
7056 conn.execute(
7057 "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
7058 params![schema_fingerprint()],
7059 )?;
7060 conn.execute(
7061 "INSERT OR IGNORE INTO meta(k, v) VALUES('projection_write_revision', '0')",
7062 [],
7063 )?;
7064 Ok(())
7065}
7066
7067const PATH_IDENTITY_MISMATCH_META_KEY: &str = "path_identity_mismatch";
7071
7072fn record_path_identity_mismatch(conn: &Connection, error: &CallGraphStoreError) -> Result<()> {
7073 let CallGraphStoreError::PathIdentityMismatch { path, project_root } = error else {
7074 return Ok(());
7075 };
7076 conn.execute(
7077 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7078 params![
7079 PATH_IDENTITY_MISMATCH_META_KEY,
7080 format!(
7081 "callgraph_path_identity_mismatch path={} project_root={}",
7082 path.display(),
7083 project_root.display()
7084 )
7085 ],
7086 )?;
7087 Ok(())
7088}
7089
7090pub(super) fn path_identity_mismatch_reason(conn: &Connection) -> Result<Option<String>> {
7091 conn.query_row(
7092 "SELECT v FROM meta WHERE k = ?1",
7093 [PATH_IDENTITY_MISMATCH_META_KEY],
7094 |row| row.get(0),
7095 )
7096 .optional()
7097 .map_err(Into::into)
7098}
7099
7100fn projection_write_revision(conn: &Connection) -> Result<Option<u64>> {
7101 let revision: Option<String> = conn
7102 .query_row(
7103 "SELECT v FROM meta WHERE k = 'projection_write_revision'",
7104 [],
7105 |row| row.get(0),
7106 )
7107 .optional()?;
7108 revision
7109 .map(|revision| {
7110 revision.parse::<u64>().map_err(|error| {
7111 CallGraphStoreError::Unavailable(format!(
7112 "callgraph projection write revision is invalid: {error}"
7113 ))
7114 })
7115 })
7116 .transpose()
7117}
7118
7119fn bump_projection_write_revision(tx: &Transaction<'_>) -> Result<()> {
7122 tx.execute(
7123 "INSERT INTO meta(k, v) VALUES('projection_write_revision', '1')
7124 ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1",
7125 [],
7126 )?;
7127 Ok(())
7128}
7129
7130fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
7131 conn.execute(
7132 "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
7133 params![if ready { "1" } else { "0" }],
7134 )?;
7135 Ok(())
7136}
7137
7138fn database_ready(conn: &Connection) -> Result<bool> {
7139 let schema_version: Option<String> = conn
7140 .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
7141 row.get(0)
7142 })
7143 .optional()?;
7144 let fingerprint: Option<String> = conn
7145 .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
7146 row.get(0)
7147 })
7148 .optional()?;
7149 let ready: Option<String> = conn
7150 .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
7151 .optional()?;
7152
7153 let expected_schema = SCHEMA_VERSION.to_string();
7154 let expected_fingerprint = schema_fingerprint();
7155 Ok(schema_version.as_deref() == Some(expected_schema.as_str())
7156 && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
7157 && ready.as_deref() == Some("1"))
7158}
7159
7160fn ensure_database_ready(conn: &Connection) -> Result<()> {
7161 if database_ready(conn)? {
7162 Ok(())
7163 } else {
7164 Err(CallGraphStoreError::Unavailable(
7165 "database is missing, stale, or mid-build".to_string(),
7166 ))
7167 }
7168}
7169
7170fn schema_fingerprint() -> String {
7171 let input =
7176 format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
7177 hash_to_hex(blake3::hash(input.as_bytes()))
7178}
7179
7180fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
7181 tx.execute_batch(
7182 "DELETE FROM staging_ref_context;
7183 DELETE FROM edges;
7184 DELETE FROM file_dependencies;
7185 DELETE FROM refs;
7186 DELETE FROM dispatch_hints;
7187 DELETE FROM type_ref_names;
7188 DELETE FROM backend_file_state;
7189 DELETE FROM nodes;
7190 DELETE FROM files;",
7191 )?;
7192 Ok(())
7193}
7194
7195fn staged_build_phase(conn: &Connection) -> Result<Option<String>> {
7196 conn.query_row(
7197 "SELECT v FROM meta WHERE k = ?1",
7198 params![STAGED_BUILD_PHASE],
7199 |row| row.get(0),
7200 )
7201 .optional()
7202 .map_err(Into::into)
7203}
7204
7205fn staged_u64(conn: &Connection, key: &str) -> Result<u64> {
7206 let value = staged_string(conn, key)?;
7207 Ok(value.and_then(|value| value.parse().ok()).unwrap_or(0))
7208}
7209
7210fn staged_string(conn: &Connection, key: &str) -> Result<Option<String>> {
7211 conn.query_row("SELECT v FROM meta WHERE k = ?1", params![key], |row| {
7212 row.get::<_, String>(0)
7213 })
7214 .optional()
7215 .map_err(Into::into)
7216}
7217
7218fn set_staged_build_phase(tx: &Transaction<'_>, phase: &str) -> Result<()> {
7219 tx.execute(
7220 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7221 params![STAGED_BUILD_PHASE, phase],
7222 )?;
7223 Ok(())
7224}
7225
7226fn set_staged_u64(tx: &Transaction<'_>, key: &str, value: u64) -> Result<()> {
7227 set_staged_string(tx, key, &value.to_string())
7228}
7229
7230fn set_staged_string(tx: &Transaction<'_>, key: &str, value: &str) -> Result<()> {
7231 tx.execute(
7232 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7233 params![key, value],
7234 )?;
7235 Ok(())
7236}
7237
7238fn increment_staged_extracted_bytes(tx: &Transaction<'_>, bytes: u64) -> Result<()> {
7242 tx.execute(
7243 "INSERT INTO meta(k, v) VALUES(?1, ?2)
7244 ON CONFLICT(k) DO UPDATE SET v = CAST(meta.v AS INTEGER) + excluded.v",
7245 params![STAGED_COMMITTED_EXTRACTED_BYTES, bytes.to_string()],
7246 )?;
7247 Ok(())
7248}
7249
7250fn staged_content_matches(conn: &Connection, project_root: &Path, path: &Path) -> Result<bool> {
7251 let Ok(source) = std::fs::read_to_string(path) else {
7252 return Ok(false);
7253 };
7254 let Ok(freshness) = collect_source_freshness(path, &source) else {
7255 return Ok(false);
7256 };
7257 let rel_path = relative_path(project_root, path);
7258 let staged_hash = conn
7259 .query_row(
7260 "SELECT content_hash FROM files WHERE path = ?1",
7261 params![rel_path],
7262 |row| row.get::<_, String>(0),
7263 )
7264 .optional()?;
7265 Ok(staged_hash.as_deref() == Some(hash_to_hex(freshness.content_hash).as_str()))
7266}
7267
7268fn delete_staged_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
7269 tx.execute(
7270 "DELETE FROM staging_ref_context
7271 WHERE ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?1)",
7272 params![rel_path],
7273 )?;
7274 delete_file_rows(tx, rel_path)
7275}
7276
7277fn prune_staged_files_not_in_inventory(conn: &mut Connection) -> Result<()> {
7278 loop {
7279 let removed = {
7280 let mut statement = conn.prepare(
7281 "SELECT path
7282 FROM files
7283 WHERE NOT EXISTS (
7284 SELECT 1 FROM staging_file_inventory inventory
7285 WHERE inventory.path = files.path
7286 )
7287 ORDER BY path
7288 LIMIT ?1",
7289 )?;
7290 let paths = statement
7291 .query_map(params![COLD_BUILD_EXTRACT_BATCH_FILES as i64], |row| {
7292 row.get::<_, String>(0)
7293 })?
7294 .collect::<std::result::Result<Vec<_>, _>>()?;
7295 paths
7296 };
7297 if removed.is_empty() {
7298 return Ok(());
7299 }
7300 let tx = conn.transaction()?;
7301 for path in removed {
7302 delete_staged_file_rows(&tx, &path)?;
7303 }
7304 tx.commit()?;
7305 }
7306}
7307
7308struct StagedFileBatch {
7309 paths: Vec<PathBuf>,
7310 last_path: String,
7311}
7312
7313fn load_staged_file_batch(
7314 conn: &Connection,
7315 project_root: &Path,
7316 after_path: &str,
7317 max_files: usize,
7318 max_bytes: u64,
7319) -> Result<Option<StagedFileBatch>> {
7320 let mut statement = conn.prepare(
7321 "SELECT path, size
7322 FROM staging_file_inventory
7323 WHERE path > ?1
7324 ORDER BY path
7325 LIMIT ?2",
7326 )?;
7327 let mut rows = statement.query(params![after_path, max_files.max(1) as i64])?;
7328 let mut paths = Vec::with_capacity(max_files.max(1));
7329 let mut last_path = String::new();
7330 let mut batch_bytes = 0u64;
7331 while let Some(row) = rows.next()? {
7332 let rel_path = row.get::<_, String>(0)?;
7333 let size = row.get::<_, i64>(1)?.max(0) as u64;
7334 if !paths.is_empty() && batch_bytes.saturating_add(size) > max_bytes {
7335 break;
7336 }
7337 batch_bytes = batch_bytes.saturating_add(size);
7338 last_path.clone_from(&rel_path);
7339 paths.push(project_root.join(rel_path));
7340 }
7341 if paths.is_empty() {
7342 Ok(None)
7343 } else {
7344 Ok(Some(StagedFileBatch { paths, last_path }))
7345 }
7346}
7347
7348fn staged_corpus_fingerprint(conn: &Connection, project_root: &Path) -> Result<String> {
7349 let mut statement = conn.prepare("SELECT path FROM staging_file_inventory ORDER BY path")?;
7350 let mut rows = statement.query([])?;
7351 let mut fingerprint = CorpusFingerprint::default();
7352 while let Some(row) = rows.next()? {
7353 let rel_path = row.get::<_, String>(0)?;
7354 fingerprint.add_path(project_root, &project_root.join(rel_path));
7355 }
7356 Ok(fingerprint.finish(project_root))
7357}
7358
7359fn load_staged_ref_window(
7360 conn: &Connection,
7361 after_rowid: u64,
7362 limit: usize,
7363) -> Result<Vec<StagedRef>> {
7364 let mut statement = conn.prepare(
7365 "SELECT refs.rowid, refs.ref_id, refs.caller_node, refs.caller_file, refs.kind,
7366 refs.short_name, refs.full_ref, refs.module_path, refs.import_kind,
7367 refs.local_name, refs.requested_name, refs.namespace_alias, refs.wildcard,
7368 refs.line, refs.byte_start, refs.byte_end, staging_ref_context.caller_symbol
7369 FROM refs
7370 LEFT JOIN staging_ref_context ON staging_ref_context.ref_id = refs.ref_id
7371 WHERE refs.status = 'staged' AND refs.rowid > ?1
7372 ORDER BY refs.rowid
7373 LIMIT ?2",
7374 )?;
7375 let rows = statement.query_map(params![after_rowid as i64, limit as i64], |row| {
7376 Ok(StagedRef {
7377 rowid: row.get::<_, i64>(0)? as u64,
7378 raw: RawRef {
7379 ref_id: row.get(1)?,
7380 caller_node: row.get(2)?,
7381 caller_file: row.get(3)?,
7382 kind: row.get(4)?,
7383 short_name: row.get(5)?,
7384 full_ref: row.get(6)?,
7385 module_path: row.get(7)?,
7386 import_kind: row.get(8)?,
7387 local_name: row.get(9)?,
7388 requested_name: row.get(10)?,
7389 namespace_alias: row.get(11)?,
7390 wildcard: row.get::<_, i64>(12)? != 0,
7391 line: row.get::<_, i64>(13)? as u32,
7392 byte_start: row.get::<_, i64>(14)? as usize,
7393 byte_end: row.get::<_, i64>(15)? as usize,
7394 caller_symbol: row.get(16)?,
7395 dependencies: BTreeSet::new(),
7396 },
7397 })
7398 })?;
7399 let mut refs = rows.collect::<std::result::Result<Vec<_>, _>>()?;
7400 drop(statement);
7401
7402 let mut dependencies = HashMap::<String, BTreeSet<String>>::new();
7403 let mut dependency_statement = conn
7404 .prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1 ORDER BY dep_file")?;
7405 for raw in refs.iter_mut().map(|entry| &mut entry.raw) {
7406 if !dependencies.contains_key(&raw.caller_file) {
7407 let rows =
7408 dependency_statement.query_map(params![raw.caller_file], |row| row.get(0))?;
7409 let values = rows.collect::<std::result::Result<BTreeSet<_>, _>>()?;
7410 dependencies.insert(raw.caller_file.clone(), values);
7411 }
7412 raw.dependencies = dependencies
7413 .get(&raw.caller_file)
7414 .cloned()
7415 .unwrap_or_default();
7416 }
7417 Ok(refs)
7418}
7419
7420fn unresolved_staged_ref(raw: RawRef) -> ResolvedRef {
7421 ResolvedRef {
7422 dependencies: raw.dependencies.clone(),
7423 raw,
7424 status: "unresolved".to_string(),
7425 target_node: None,
7426 target_file: None,
7427 target_symbol: None,
7428 edge: None,
7429 }
7430}
7431
7432fn query_count(conn: &Connection, query: &str) -> Result<u64> {
7433 conn.query_row(query, [], |row| row.get::<_, i64>(0))
7434 .map(|count| count.max(0) as u64)
7435 .map_err(Into::into)
7436}
7437
7438fn cold_build_stats_from_connection(conn: &Connection, started: Instant) -> Result<ColdBuildStats> {
7439 let files = query_count(conn, "SELECT COUNT(*) FROM files")? as usize;
7440 let nodes = query_count(conn, "SELECT COUNT(*) FROM nodes")? as usize;
7441 let refs = query_count(conn, "SELECT COUNT(*) FROM refs")? as usize;
7442 let edges = query_count(conn, "SELECT COUNT(*) FROM edges")? as usize;
7443 let failed_files = staged_failed_files(conn)?;
7444 let elapsed_ms = started.elapsed().as_millis();
7445 crate::slog_info!(
7446 "perf callgraph_store bounded cold_build: files={} nodes={} refs={} edges={} committed_extracted_bytes={} ms={}",
7447 files,
7448 nodes,
7449 refs,
7450 edges,
7451 staged_u64(conn, STAGED_COMMITTED_EXTRACTED_BYTES)?,
7452 elapsed_ms
7453 );
7454 Ok(ColdBuildStats {
7455 files,
7456 nodes,
7457 refs,
7458 edges,
7459 failed_files,
7460 elapsed_ms,
7461 })
7462}
7463
7464fn staged_failed_files(conn: &Connection) -> Result<Vec<String>> {
7465 let mut statement = conn.prepare(
7466 "SELECT DISTINCT file_path FROM backend_file_state WHERE status = 'stale' ORDER BY file_path",
7467 )?;
7468 let rows = statement.query_map([], |row| row.get(0))?;
7469 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
7470}
7471
7472fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
7473 tx.execute_batch(
7474 "DROP INDEX IF EXISTS idx_nodes_file;
7475 DROP INDEX IF EXISTS idx_nodes_name;
7476 DROP INDEX IF EXISTS idx_nodes_scoped;
7477 DROP INDEX IF EXISTS idx_refs_short_name;
7478 DROP INDEX IF EXISTS idx_refs_kind_caller_file;
7479 DROP INDEX IF EXISTS idx_refs_caller_file;
7480 DROP INDEX IF EXISTS idx_refs_caller_node_kind;
7481 DROP INDEX IF EXISTS idx_refs_target_file;
7482 DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
7483 DROP INDEX IF EXISTS idx_edges_source_kind;
7484 DROP INDEX IF EXISTS idx_edges_target_kind;
7485 DROP INDEX IF EXISTS idx_edges_target_file_symbol;
7486 DROP INDEX IF EXISTS idx_edges_ref_id;
7487 DROP INDEX IF EXISTS idx_dispatch_hints_method;
7488 DROP INDEX IF EXISTS idx_dispatch_hints_file;
7489 DROP INDEX IF EXISTS idx_backend_file_state_file;",
7490 )?;
7491 Ok(())
7492}
7493
7494fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
7495 tx.execute_batch(
7496 "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
7497 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
7498 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
7499 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
7500 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
7501 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
7502 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
7503 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
7504 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
7505 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
7506 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
7507 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
7508 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
7509 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
7510 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
7511 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
7512 )?;
7513 Ok(())
7514}
7515
7516const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
7517 ("files", "path"),
7518 ("nodes", "file_path"),
7519 ("refs", "caller_file"),
7520 ("refs", "target_file"),
7521 ("file_dependencies", "file_path"),
7522 ("file_dependencies", "dep_file"),
7523 ("edges", "target_file"),
7524 ("dispatch_hints", "file"),
7525 ("backend_file_state", "file_path"),
7526];
7527
7528fn reconcile_workspace_roots(
7541 conn: &mut Connection,
7542 project_root: &Path,
7543 allow_repair: bool,
7544) -> Result<OpenRootRepair> {
7545 let roots = stored_workspace_roots(conn)?;
7546 let current_root = project_root.display().to_string();
7547 if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
7548 return Ok(OpenRootRepair::None);
7549 }
7550
7551 if let Some(sample) = sample_absolute_data_path(conn)? {
7552 return Ok(OpenRootRepair::NeedsRebuild {
7553 previous_roots: roots,
7554 current_root,
7555 reason: format!("absolute store data path row {sample}"),
7556 });
7557 }
7558
7559 for stored_root in roots.iter() {
7560 if stored_root == ¤t_root {
7561 continue;
7562 }
7563 if Path::new(stored_root).exists() {
7564 let reason = format!(
7565 "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
7566 );
7567 return Ok(OpenRootRepair::NeedsRebuild {
7568 previous_roots: roots,
7569 current_root,
7570 reason,
7571 });
7572 }
7573 }
7574
7575 if !allow_repair {
7576 return Ok(OpenRootRepair::NeedsRebuild {
7577 previous_roots: roots,
7578 current_root,
7579 reason: "workspace root metadata requires deferred repair".to_string(),
7580 });
7581 }
7582
7583 publish_if_current(|| {
7584 let tx = conn.transaction()?;
7585 tx.execute(
7586 "UPDATE OR IGNORE backend_file_state
7587 SET workspace_root = ?1
7588 WHERE workspace_root <> ?1",
7589 params![¤t_root],
7590 )?;
7591 tx.execute(
7592 "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
7593 params![¤t_root],
7594 )?;
7595 tx.commit()?;
7596 Ok(())
7597 })?;
7598
7599 crate::slog_info!(
7600 "callgraph store re-rooted from {} to {}",
7601 roots.join(", "),
7602 current_root
7603 );
7604 Ok(OpenRootRepair::ReRooted)
7605}
7606
7607fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
7608 let mut stmt = conn.prepare(
7609 "SELECT DISTINCT workspace_root
7610 FROM backend_file_state
7611 ORDER BY workspace_root",
7612 )?;
7613 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
7614 rows.collect::<std::result::Result<Vec<_>, _>>()
7615 .map_err(Into::into)
7616}
7617
7618fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
7619 for (table, column) in STORE_DATA_PATH_COLUMNS {
7620 let sql = format!(
7621 "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
7622 );
7623 let mut stmt = conn.prepare(&sql)?;
7624 let mut rows = stmt.query([])?;
7625 while let Some(row) = rows.next()? {
7626 let value: String = row.get(0)?;
7627 if stored_path_is_absolute(&value) {
7628 return Ok(Some(format!("{table}.{column}={value}")));
7629 }
7630 }
7631 }
7632 Ok(None)
7633}
7634
7635fn stored_path_is_absolute(value: &str) -> bool {
7636 if value.is_empty() {
7637 return false;
7638 }
7639 if Path::new(value).is_absolute() || value.starts_with('/') {
7640 return true;
7641 }
7642 let bytes = value.as_bytes();
7643 if bytes.len() >= 3
7644 && bytes[1] == b':'
7645 && (bytes[2] == b'/' || bytes[2] == b'\\')
7646 && bytes[0].is_ascii_alphabetic()
7647 {
7648 return true;
7649 }
7650 value.starts_with("\\\\") || value.starts_with("//")
7651}
7652
7653fn log_root_repair_rebuild(repair: &OpenRootRepair) {
7654 if let OpenRootRepair::NeedsRebuild {
7655 previous_roots,
7656 current_root,
7657 reason,
7658 } = repair
7659 {
7660 crate::slog_info!(
7661 "callgraph cold-build decision: reason=re-rooting refused; from={}; to={}; detail={}",
7662 previous_roots.join(", "),
7663 current_root,
7664 reason
7665 );
7666 }
7667}
7668
7669fn now_nanos() -> u128 {
7671 SystemTime::now()
7672 .duration_since(UNIX_EPOCH)
7673 .unwrap_or(Duration::ZERO)
7674 .as_nanos()
7675}
7676
7677fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
7682 callgraph_dir.join(format!("{project_key}.current"))
7683}
7684
7685fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
7689 callgraph_dir.join(format!("{project_key}.sqlite"))
7690}
7691
7692fn generation_file_name(project_key: &str) -> String {
7696 format!(
7697 "{project_key}.g{}.{}.sqlite",
7698 now_nanos(),
7699 std::process::id()
7700 )
7701}
7702
7703fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
7705 let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
7706 let name = text.trim();
7707 if name.is_empty() {
7708 None
7709 } else {
7710 Some(name.to_string())
7711 }
7712}
7713
7714fn db_path_ready(path: &Path) -> bool {
7717 (|| -> Result<bool> {
7718 let conn = open_readonly_connection(path)?;
7719 database_ready(&conn)
7720 })()
7721 .unwrap_or(false)
7722}
7723
7724fn resolve_ready_target(
7732 callgraph_dir: &Path,
7733 project_key: &str,
7734) -> Option<(PathBuf, Option<String>)> {
7735 for _ in 0..5 {
7736 if let Some(generation) = read_pointer(callgraph_dir, project_key) {
7737 let gen_path = callgraph_dir.join(&generation);
7738 if gen_path.is_file() {
7739 return (migration_manifest_valid(callgraph_dir, &generation)
7740 && db_path_ready(&gen_path))
7741 .then_some((gen_path, Some(generation)));
7742 }
7743 std::thread::sleep(Duration::from_millis(5));
7746 continue;
7747 }
7748 let legacy = legacy_sqlite_path(callgraph_dir, project_key);
7750 return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
7751 }
7752 None
7753}
7754
7755fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
7759 let pointer = pointer_path(callgraph_dir, project_key);
7760 let tmp = callgraph_dir.join(format!(
7761 "{project_key}.current.tmp.{}.{}",
7762 std::process::id(),
7763 now_nanos()
7764 ));
7765 {
7766 use std::io::Write as _;
7767 let mut file = std::fs::File::create(&tmp)?;
7768 file.write_all(generation.as_bytes())?;
7769 file.write_all(b"\n")?;
7770 file.sync_all()?;
7771 }
7772 if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
7773 let _ = std::fs::remove_file(&tmp);
7774 return Err(error.into());
7775 }
7776 crate::fs_lock::sync_parent(&pointer);
7777 Ok(())
7778}
7779
7780#[derive(Clone, Debug)]
7781struct GenerationGcCandidate {
7782 name: String,
7783 path: PathBuf,
7784 modified: SystemTime,
7785}
7786
7787fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
7793 let temp_grace = Duration::from_secs(60);
7794 let now = SystemTime::now();
7795 let pointer_current =
7796 read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
7797 let gen_prefix = format!("{project_key}.g");
7798 let tmp_prefixes = [
7799 format!("{project_key}.g"), format!("{project_key}.current."), format!("{project_key}.sqlite.tmp."), ];
7803 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
7804 return;
7805 };
7806 let mut gens: Vec<GenerationGcCandidate> = Vec::new();
7807 for entry in entries.flatten() {
7808 let name = entry.file_name();
7809 let name = name.to_string_lossy().to_string();
7810 let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
7811 let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
7812
7813 if name.contains(".tmp.") {
7815 if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
7816 let _ = std::fs::remove_file(entry.path());
7817 }
7818 continue;
7819 }
7820
7821 if name == format!("{project_key}.sqlite") {
7824 remove_sqlite_file_set(&entry.path());
7825 continue;
7826 }
7827
7828 if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
7829 gens.push(GenerationGcCandidate {
7830 name,
7831 path: entry.path(),
7832 modified: mtime,
7833 });
7834 }
7835 }
7836
7837 let mut superseded = gens
7838 .iter()
7839 .filter(|generation| generation.name != pointer_current)
7840 .collect::<Vec<_>>();
7841 superseded.sort_by(|left, right| {
7842 right
7843 .modified
7844 .cmp(&left.modified)
7845 .then_with(|| right.name.cmp(&left.name))
7846 });
7847 let previous = superseded.first().map(|generation| generation.name.clone());
7848
7849 for generation in gens {
7850 let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
7851 if generation.name == pointer_current
7852 || Some(generation.name.as_str()) == previous.as_deref()
7853 {
7854 continue;
7855 }
7856
7857 let age = now
7858 .duration_since(generation.modified)
7859 .unwrap_or(Duration::ZERO);
7860 if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
7861 continue;
7862 }
7863
7864 remove_sqlite_file_set(&generation.path);
7865 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
7866 let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
7867 callgraph_dir,
7868 &generation.name,
7869 ));
7870 }
7871}
7872
7873fn remove_sqlite_file_set(path: &Path) {
7874 let _ = std::fs::remove_file(path);
7875 remove_sqlite_sidecars(path);
7876}
7877
7878fn remove_sqlite_sidecars(path: &Path) {
7879 let path_text = path.to_string_lossy();
7880 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
7881 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
7882 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
7883}
7884
7885#[derive(Clone, Copy, Debug, Default)]
7886struct CallgraphRootSweepSummary {
7887 scanned: usize,
7888 removed: usize,
7889 bytes: u64,
7890 generation_gc: usize,
7891 skipped_memo: usize,
7892 skipped_derived: usize,
7893 skipped_fresh: usize,
7894 skipped_reader: usize,
7895 skipped_lease: usize,
7896 skipped_unreadable: usize,
7897 budget_exhausted: bool,
7898}
7899
7900#[derive(Clone, Copy, Debug, Default)]
7901struct CallgraphRootFileStats {
7902 newest: Option<SystemTime>,
7903 bytes: u64,
7904}
7905
7906enum CallgraphRootWalk {
7907 Complete(CallgraphRootFileStats),
7908 BudgetExceeded,
7909 Failed,
7910}
7911
7912enum CallgraphRootCandidate {
7913 Removed { bytes: u64 },
7914 GenerationGc,
7915 SkippedMemo,
7916 SkippedDerived,
7917 SkippedFresh,
7918 SkippedReader,
7919 SkippedLease,
7920 SkippedUnreadable,
7921 BudgetExceeded,
7922}
7923
7924fn sweep_orphaned_callgraph_root_dirs(callgraph_dir: &Path) {
7933 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
7934 return;
7935 };
7936 let root_dir = storage_root.join(crate::root_cache::RootCacheDomain::Callgraph.as_str());
7937 let memo_keys = match crate::search_index::referenced_artifact_cache_keys(&storage_root) {
7938 Ok(keys) => keys,
7939 Err(error) => {
7940 crate::slog_warn!(
7941 "callgraph root sweep root={} scanned=0 removed=0 bytes=0 generation_gc=0 skipped_memo=0 skipped_derived=0 skipped_fresh=0 skipped_reader=0 skipped_lease=0 skipped_unreadable=0 budget_exhausted=false memo_unreadable=true error={}",
7942 root_dir.display(),
7943 error
7944 );
7945 return;
7946 }
7947 };
7948 let derived_keys = crate::search_index::derived_artifact_cache_keys();
7949 let summary = sweep_callgraph_root_dirs_with_limits(
7950 &root_dir,
7951 &memo_keys,
7952 &derived_keys,
7953 CALLGRAPH_ROOT_SWEEP_BUDGET,
7954 CALLGRAPH_ROOT_SWEEP_LIMIT,
7955 );
7956 crate::slog_info!(
7957 "callgraph root sweep root={} scanned={} removed={} bytes={} generation_gc={} skipped_memo={} skipped_derived={} skipped_fresh={} skipped_reader={} skipped_lease={} skipped_unreadable={} budget_exhausted={}",
7958 root_dir.display(),
7959 summary.scanned,
7960 summary.removed,
7961 summary.bytes,
7962 summary.generation_gc,
7963 summary.skipped_memo,
7964 summary.skipped_derived,
7965 summary.skipped_fresh,
7966 summary.skipped_reader,
7967 summary.skipped_lease,
7968 summary.skipped_unreadable,
7969 summary.budget_exhausted
7970 );
7971}
7972
7973fn sweep_callgraph_root_dirs_with_limits(
7974 root_dir: &Path,
7975 memo_keys: &HashSet<String>,
7976 derived_keys: &HashSet<String>,
7977 wall_clock_budget: Duration,
7978 entry_limit: usize,
7979) -> CallgraphRootSweepSummary {
7980 let started = Instant::now();
7981 let deadline = started + wall_clock_budget;
7982 let boundary = match crate::walk_boundary::DeviceBoundary::for_root(root_dir) {
7983 Ok(boundary) => boundary,
7984 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
7985 return CallgraphRootSweepSummary::default();
7986 }
7987 Err(error) => {
7988 crate::slog_warn!(
7989 "cannot establish filesystem boundary for callgraph root sweep {}: {}",
7990 root_dir.display(),
7991 error
7992 );
7993 return CallgraphRootSweepSummary {
7994 skipped_unreadable: 1,
7995 ..CallgraphRootSweepSummary::default()
7996 };
7997 }
7998 };
7999 let mut entries = match std::fs::read_dir(root_dir) {
8000 Ok(entries) => entries
8001 .filter_map(|entry| entry.ok())
8002 .filter_map(|entry| {
8003 let name = entry.file_name().to_string_lossy().into_owned();
8004 entry
8005 .file_type()
8006 .ok()
8007 .filter(|file_type| file_type.is_dir() && artifact_key_looks_valid(&name))
8008 .map(|_| (name, entry.path()))
8009 })
8010 .collect::<Vec<_>>(),
8011 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
8012 Err(error) => {
8013 crate::slog_warn!(
8014 "cannot read callgraph root sweep directory {}: {}",
8015 root_dir.display(),
8016 error
8017 );
8018 return CallgraphRootSweepSummary {
8019 skipped_unreadable: 1,
8020 ..CallgraphRootSweepSummary::default()
8021 };
8022 }
8023 };
8024 entries.sort_by(|left, right| left.0.cmp(&right.0));
8025
8026 let cursor_store = CALLGRAPH_ROOT_SWEEP_CURSORS.get_or_init(|| Mutex::new(HashMap::new()));
8027 let last_name = cursor_store
8028 .lock()
8029 .ok()
8030 .and_then(|cursors| cursors.get(root_dir).cloned());
8031 if let Some(start) = last_name
8032 .as_deref()
8033 .and_then(|last| entries.iter().position(|(name, _)| name.as_str() > last))
8034 {
8035 entries.rotate_left(start);
8036 }
8037
8038 let mut summary = CallgraphRootSweepSummary::default();
8039 let mut cursor_name = last_name;
8040 for (processed, (key, cache_dir)) in entries.into_iter().enumerate() {
8041 if processed >= entry_limit || Instant::now() >= deadline {
8042 summary.budget_exhausted = true;
8043 break;
8044 }
8045 summary.scanned += 1;
8046 cursor_name = Some(key.clone());
8047 match callgraph_root_candidate(
8048 &cache_dir,
8049 &key,
8050 memo_keys.contains(&key),
8051 derived_keys.contains(&key),
8052 &boundary,
8053 deadline,
8054 ) {
8055 CallgraphRootCandidate::Removed { bytes } => {
8056 summary.removed += 1;
8057 summary.bytes = summary.bytes.saturating_add(bytes);
8058 }
8059 CallgraphRootCandidate::GenerationGc => summary.generation_gc += 1,
8060 CallgraphRootCandidate::SkippedMemo => summary.skipped_memo += 1,
8061 CallgraphRootCandidate::SkippedDerived => summary.skipped_derived += 1,
8062 CallgraphRootCandidate::SkippedFresh => summary.skipped_fresh += 1,
8063 CallgraphRootCandidate::SkippedReader => summary.skipped_reader += 1,
8064 CallgraphRootCandidate::SkippedLease => summary.skipped_lease += 1,
8065 CallgraphRootCandidate::SkippedUnreadable => summary.skipped_unreadable += 1,
8066 CallgraphRootCandidate::BudgetExceeded => {
8067 summary.budget_exhausted = true;
8068 break;
8069 }
8070 }
8071 }
8072
8073 if let Ok(mut cursors) = cursor_store.lock() {
8074 if summary.budget_exhausted {
8075 if let Some(cursor_name) = cursor_name {
8076 cursors.insert(root_dir.to_path_buf(), cursor_name);
8077 }
8078 } else {
8079 cursors.remove(root_dir);
8080 }
8081 }
8082 if summary.removed > 0 {
8083 crate::fs_lock::sync_parent(root_dir);
8084 }
8085 summary
8086}
8087
8088fn callgraph_root_candidate(
8089 cache_dir: &Path,
8090 project_key: &str,
8091 memo_referenced: bool,
8092 derived_in_process: bool,
8093 boundary: &crate::walk_boundary::DeviceBoundary,
8094 deadline: Instant,
8095) -> CallgraphRootCandidate {
8096 if !boundary.should_descend(cache_dir).unwrap_or(false) {
8097 return CallgraphRootCandidate::SkippedUnreadable;
8098 }
8099 if memo_referenced || derived_in_process {
8100 return sweep_live_callgraph_root_generations(
8101 cache_dir,
8102 project_key,
8103 memo_referenced,
8104 boundary,
8105 deadline,
8106 );
8107 }
8108
8109 let stats = match callgraph_root_file_stats(cache_dir, boundary, deadline) {
8110 CallgraphRootWalk::Complete(stats) => stats,
8111 CallgraphRootWalk::BudgetExceeded => return CallgraphRootCandidate::BudgetExceeded,
8112 CallgraphRootWalk::Failed => return CallgraphRootCandidate::SkippedUnreadable,
8113 };
8114 let Some(newest) = stats.newest else {
8115 return CallgraphRootCandidate::SkippedUnreadable;
8116 };
8117 if SystemTime::now()
8118 .duration_since(newest)
8119 .unwrap_or(Duration::ZERO)
8120 < CALLGRAPH_ROOT_ORPHAN_MIN_AGE
8121 {
8122 return CallgraphRootCandidate::SkippedFresh;
8123 }
8124
8125 let _writer_lease = match crate::fs_lock::try_acquire(
8128 &crate::root_cache::writer_lease_path(cache_dir),
8129 Duration::ZERO,
8130 ) {
8131 Ok(lease) => lease,
8132 Err(_) => return CallgraphRootCandidate::SkippedLease,
8133 };
8134 if crate::root_cache::sweep_all_read_markers(cache_dir).protected {
8135 return CallgraphRootCandidate::SkippedReader;
8136 }
8137
8138 match std::fs::remove_dir_all(cache_dir) {
8139 Ok(()) => {
8140 crate::slog_info!(
8141 "callgraph root sweep reaped dir={} key={} bytes={}",
8142 cache_dir.display(),
8143 project_key,
8144 stats.bytes
8145 );
8146 CallgraphRootCandidate::Removed { bytes: stats.bytes }
8147 }
8148 Err(error) if error.kind() == std::io::ErrorKind::NotFound && !cache_dir.exists() => {
8149 crate::slog_info!(
8150 "callgraph root sweep reaped dir={} key={} bytes={}",
8151 cache_dir.display(),
8152 project_key,
8153 stats.bytes
8154 );
8155 CallgraphRootCandidate::Removed { bytes: stats.bytes }
8156 }
8157 Err(_) => CallgraphRootCandidate::SkippedUnreadable,
8158 }
8159}
8160
8161fn sweep_live_callgraph_root_generations(
8162 cache_dir: &Path,
8163 project_key: &str,
8164 memo_referenced: bool,
8165 boundary: &crate::walk_boundary::DeviceBoundary,
8166 deadline: Instant,
8167) -> CallgraphRootCandidate {
8168 if Instant::now() >= deadline {
8169 return CallgraphRootCandidate::BudgetExceeded;
8170 }
8171 let stats = match callgraph_root_file_stats(cache_dir, boundary, deadline) {
8172 CallgraphRootWalk::Complete(stats) => stats,
8173 CallgraphRootWalk::BudgetExceeded => return CallgraphRootCandidate::BudgetExceeded,
8174 CallgraphRootWalk::Failed => return CallgraphRootCandidate::SkippedUnreadable,
8175 };
8176 let Some(newest) = stats.newest else {
8177 return CallgraphRootCandidate::SkippedUnreadable;
8178 };
8179 if SystemTime::now()
8180 .duration_since(newest)
8181 .unwrap_or(Duration::ZERO)
8182 < CALLGRAPH_ROOT_ORPHAN_MIN_AGE
8183 {
8184 return CallgraphRootCandidate::SkippedFresh;
8185 }
8186 let _writer_lease = match crate::fs_lock::try_acquire(
8187 &crate::root_cache::writer_lease_path(cache_dir),
8188 Duration::ZERO,
8189 ) {
8190 Ok(lease) => lease,
8191 Err(_) => return CallgraphRootCandidate::SkippedLease,
8192 };
8193 if crate::root_cache::sweep_all_read_markers(cache_dir).protected {
8194 return CallgraphRootCandidate::SkippedReader;
8195 }
8196 if let Some(current) = read_pointer(cache_dir, project_key) {
8197 gc_old_generations(cache_dir, project_key, ¤t);
8198 return CallgraphRootCandidate::GenerationGc;
8199 }
8200 if memo_referenced {
8201 CallgraphRootCandidate::SkippedMemo
8202 } else {
8203 CallgraphRootCandidate::SkippedDerived
8204 }
8205}
8206
8207fn callgraph_root_file_stats(
8208 cache_dir: &Path,
8209 boundary: &crate::walk_boundary::DeviceBoundary,
8210 deadline: Instant,
8211) -> CallgraphRootWalk {
8212 let metadata = match std::fs::metadata(cache_dir) {
8213 Ok(metadata) => metadata,
8214 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
8215 return CallgraphRootWalk::Complete(CallgraphRootFileStats::default());
8216 }
8217 Err(_) => return CallgraphRootWalk::Failed,
8218 };
8219 let mut stats = CallgraphRootFileStats {
8220 newest: metadata.modified().ok(),
8221 bytes: 0,
8222 };
8223 match callgraph_root_file_stats_inner(cache_dir, boundary, deadline, &mut stats) {
8224 Ok(()) => CallgraphRootWalk::Complete(stats),
8225 Err(CallgraphRootWalkError::BudgetExceeded) => CallgraphRootWalk::BudgetExceeded,
8226 Err(CallgraphRootWalkError::Failed) => CallgraphRootWalk::Failed,
8227 }
8228}
8229
8230enum CallgraphRootWalkError {
8231 BudgetExceeded,
8232 Failed,
8233}
8234
8235fn callgraph_root_file_stats_inner(
8236 directory: &Path,
8237 boundary: &crate::walk_boundary::DeviceBoundary,
8238 deadline: Instant,
8239 stats: &mut CallgraphRootFileStats,
8240) -> std::result::Result<(), CallgraphRootWalkError> {
8241 if Instant::now() >= deadline {
8242 return Err(CallgraphRootWalkError::BudgetExceeded);
8243 }
8244 let entries = std::fs::read_dir(directory).map_err(|_| CallgraphRootWalkError::Failed)?;
8245 for entry in entries {
8246 if Instant::now() >= deadline {
8247 return Err(CallgraphRootWalkError::BudgetExceeded);
8248 }
8249 let entry = entry.map_err(|_| CallgraphRootWalkError::Failed)?;
8250 let file_type = entry
8251 .file_type()
8252 .map_err(|_| CallgraphRootWalkError::Failed)?;
8253 if file_type.is_symlink() {
8254 return Err(CallgraphRootWalkError::Failed);
8255 }
8256 let path = entry.path();
8257 if file_type.is_dir() {
8258 if !boundary
8259 .should_descend(&path)
8260 .map_err(|_| CallgraphRootWalkError::Failed)?
8261 {
8262 return Err(CallgraphRootWalkError::Failed);
8263 }
8264 let metadata = entry
8265 .metadata()
8266 .map_err(|_| CallgraphRootWalkError::Failed)?;
8267 merge_newest_callgraph_root_mtime(stats, metadata.modified().ok());
8268 callgraph_root_file_stats_inner(&path, boundary, deadline, stats)?;
8269 continue;
8270 }
8271 if !file_type.is_file() {
8272 return Err(CallgraphRootWalkError::Failed);
8273 }
8274 let metadata = entry
8275 .metadata()
8276 .map_err(|_| CallgraphRootWalkError::Failed)?;
8277 stats.bytes = stats.bytes.saturating_add(metadata.len());
8278 merge_newest_callgraph_root_mtime(stats, metadata.modified().ok());
8279 }
8280 Ok(())
8281}
8282
8283fn merge_newest_callgraph_root_mtime(
8284 stats: &mut CallgraphRootFileStats,
8285 modified: Option<SystemTime>,
8286) {
8287 if let Some(modified) = modified {
8288 if stats.newest.is_none_or(|newest| modified > newest) {
8289 stats.newest = Some(modified);
8290 }
8291 }
8292}
8293
8294fn artifact_key_looks_valid(key: &str) -> bool {
8295 key.len() == 16 && key.bytes().all(|byte| byte.is_ascii_hexdigit())
8296}
8297
8298#[cfg(test)]
8299fn reset_callgraph_root_sweep_cursor_for_test() {
8300 if let Some(cursors) = CALLGRAPH_ROOT_SWEEP_CURSORS.get() {
8301 cursors.lock().unwrap().clear();
8302 }
8303}
8304
8305const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
8319
8320fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
8332 sweep_orphaned_build_temps(callgraph_dir);
8333 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
8334 return;
8335 };
8336 let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
8337 let Ok(boundary) = crate::walk_boundary::DeviceBoundary::for_root(&storage_root) else {
8341 crate::slog_warn!(
8342 "cannot establish filesystem boundary for callgraph sweep {}",
8343 storage_root.display()
8344 );
8345 return;
8346 };
8347 let mut skipped_foreign_mounts = 0usize;
8348
8349 let root_keyed_dir = storage_root.join(domain);
8351 if root_keyed_dir.is_dir() {
8352 if boundary.should_descend(&root_keyed_dir).unwrap_or(false) {
8353 if let Ok(entries) = std::fs::read_dir(&root_keyed_dir) {
8354 for entry in entries.flatten() {
8355 let path = entry.path();
8356 if path.is_dir() {
8357 if boundary.should_descend(&path).unwrap_or(false) {
8358 sweep_orphaned_build_temps(&path);
8359 } else {
8360 skipped_foreign_mounts += 1;
8361 }
8362 }
8363 }
8364 }
8365 } else {
8366 skipped_foreign_mounts += 1;
8367 }
8368 }
8369
8370 if let Ok(entries) = std::fs::read_dir(&storage_root) {
8372 for entry in entries.flatten() {
8373 let harness_dir = entry.path();
8374 if !harness_dir.is_dir() {
8375 continue;
8376 }
8377 if !boundary.should_descend(&harness_dir).unwrap_or(false) {
8378 skipped_foreign_mounts += 1;
8379 continue;
8380 }
8381 let legacy_dir = harness_dir.join(domain);
8382 if legacy_dir.is_dir() {
8383 if boundary.should_descend(&legacy_dir).unwrap_or(false) {
8384 sweep_orphaned_build_temps(&legacy_dir);
8385 } else {
8386 skipped_foreign_mounts += 1;
8387 }
8388 }
8389 }
8390 }
8391 if skipped_foreign_mounts > 0 {
8392 crate::slog_warn!(
8393 "callgraph sweep skipped {} foreign filesystem mount(s) below {}",
8394 skipped_foreign_mounts,
8395 storage_root.display()
8396 );
8397 }
8398}
8399
8400fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
8403 sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
8404}
8405
8406fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
8409 let now = SystemTime::now();
8410 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
8411 return;
8412 };
8413 let mut removed_any = false;
8414 for entry in entries.flatten() {
8415 let name = entry.file_name().to_string_lossy().to_string();
8416 if !name.contains(".sqlite.tmp.") {
8422 continue;
8423 }
8424 let mtime = entry
8425 .metadata()
8426 .and_then(|meta| meta.modified())
8427 .unwrap_or(now);
8428 if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
8429 continue;
8430 }
8431 match std::fs::remove_file(entry.path()) {
8437 Ok(()) => removed_any = true,
8438 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
8439 Err(_) => {}
8440 }
8441 }
8442 if removed_any {
8443 crate::fs_lock::sync_parent(callgraph_dir);
8444 }
8445}
8446
8447fn build_pool_size() -> usize {
8455 std::thread::available_parallelism()
8456 .map(|parallelism| parallelism.get())
8457 .unwrap_or(1)
8458 .div_ceil(2)
8459 .clamp(1, 8)
8460}
8461
8462fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
8463 let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
8464 Ok(extract) => Ok(extract),
8465 Err(error) => {
8466 let abs_path =
8467 normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
8468 let rel_path = relative_path(project_root, &abs_path);
8469 let freshness = cache_freshness::collect(&abs_path).ok();
8470 log::debug!(
8471 "callgraph store: skipping {} during cold build: {}",
8472 abs_path.display(),
8473 error
8474 );
8475 Err(ExtractFailure {
8476 rel_path,
8477 freshness,
8478 })
8479 }
8480 };
8481
8482 let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
8483 files.par_iter().map(extract_one).collect()
8484 };
8485
8486 let results = match rayon::ThreadPoolBuilder::new()
8489 .num_threads(build_pool_size())
8490 .thread_name(|index| format!("aft-callgraph-build-{index}"))
8491 .stack_size(8 * 1024 * 1024)
8492 .build()
8493 {
8494 Ok(pool) => pool.install(run),
8495 Err(error) => {
8496 log::warn!(
8497 "callgraph store: bounded build pool unavailable ({error}); using global pool"
8498 );
8499 run()
8500 }
8501 };
8502
8503 let mut extracts = Vec::new();
8504 let mut failures = Vec::new();
8505 for result in results {
8506 match result {
8507 Ok(extract) => extracts.push(extract),
8508 Err(failure) => failures.push(failure),
8509 }
8510 }
8511 BuildExtractsResult { extracts, failures }
8512}
8513
8514fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
8515 let metadata = std::fs::metadata(path)?;
8516 let size = metadata.len();
8517 let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
8518 cache_freshness::zero_hash()
8519 } else if source.len() as u64 == size {
8520 cache_freshness::hash_bytes(source.as_bytes())
8521 } else {
8522 cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
8523 };
8524 Ok(FileFreshness {
8525 mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
8526 size,
8527 content_hash,
8528 })
8529}
8530
8531fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
8532 let abs_path = normalize_file_path(project_root, path)?;
8533 let rel_path = relative_path(project_root, &abs_path);
8534 let source = std::fs::read_to_string(&abs_path)?;
8535 let freshness = collect_source_freshness(&abs_path, &source)?;
8536 let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
8537 let lang = data.lang;
8538 if lang == LangId::Rust {
8539 extend_rust_imports_with_nested_uses(&source, &mut data);
8540 }
8541 let mut nodes = build_node_records(&rel_path, &source, &data)?;
8542 let node_by_scoped: HashMap<String, String> = nodes
8543 .iter()
8544 .map(|node| (node.scoped_name.clone(), node.id.clone()))
8545 .collect();
8546 let import_dependencies =
8547 import_dependencies(project_root, &abs_path, &data.import_block.imports);
8548 let line_index = LineIndex::new(&source);
8549 let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
8550 let rust_reexports = if lang == LangId::Rust {
8551 collect_rust_pub_use_reexport_refs(
8552 project_root,
8553 &abs_path,
8554 &rel_path,
8555 &data.import_block.imports,
8556 &line_index,
8557 )
8558 } else {
8559 ReexportRefs {
8560 raw_refs: Vec::new(),
8561 surface_parts: Vec::new(),
8562 }
8563 };
8564 let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
8565 let mut raw_refs = Vec::new();
8566 raw_refs.extend(build_call_refs(
8567 &rel_path,
8568 &data,
8569 &node_by_scoped,
8570 &import_dependencies,
8571 ));
8572 raw_refs.extend(build_value_ref_refs(
8573 &rel_path,
8574 &data,
8575 &node_by_scoped,
8576 &import_dependencies,
8577 ));
8578 raw_refs.extend(build_import_refs(
8579 project_root,
8580 &abs_path,
8581 &rel_path,
8582 &data.import_block.imports,
8583 &line_index,
8584 ));
8585 if lang == LangId::Rust {
8586 raw_refs.extend(build_rust_module_refs(
8587 project_root,
8588 &abs_path,
8589 &rel_path,
8590 &source,
8591 ));
8592 }
8593 let mut surface_parts = reexports.surface_parts;
8594 surface_parts.extend(rust_reexports.surface_parts);
8595 surface_parts.extend(source_less_exports.surface_parts);
8596 raw_refs.extend(reexports.raw_refs);
8597 raw_refs.extend(rust_reexports.raw_refs);
8598 raw_refs.extend(source_less_exports.raw_refs);
8599 let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
8600 let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
8601
8602 Ok(FileExtract {
8603 rel_path,
8604 freshness,
8605 lang,
8606 data,
8607 nodes,
8608 raw_refs,
8609 dispatch_hints,
8610 surface_fingerprint,
8611 })
8612}
8613
8614fn build_node_records(
8615 rel_path: &str,
8616 source: &str,
8617 data: &FileCallData,
8618) -> Result<Vec<NodeRecord>> {
8619 let mut records = Vec::new();
8620 let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
8621 let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
8622 metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
8623
8624 for (scoped_name, meta) in metadata {
8625 let name = unqualified_name(scoped_name).to_string();
8626 let range = selection_range(source, scoped_name, &name, &meta.range);
8627 let range_key = (
8628 range.start_line,
8629 range.start_col,
8630 range.end_line,
8631 range.end_col,
8632 );
8633 let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
8634 let range_ordinal = *ordinal;
8635 *ordinal += 1;
8636 let id = node_id(rel_path, &range, range_ordinal, scoped_name);
8637 let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
8638 let is_default_export = data
8639 .default_export_symbol
8640 .as_deref()
8641 .map(|default| default == scoped_name || default == name)
8642 .unwrap_or(false);
8643 records.push(NodeRecord {
8644 id,
8645 file_path: rel_path.to_string(),
8646 name: name.clone(),
8647 scoped_name: scoped_name.clone(),
8648 kind: symbol_kind_label(&meta.kind).to_string(),
8649 range,
8650 range_ordinal,
8651 signature: meta.signature.clone(),
8652 exported,
8653 is_default_export,
8654 is_type_like: is_type_like(&meta.kind),
8655 is_callgraph_entry_point: meta.entry_point_attribute.is_some()
8656 || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
8657 });
8658 }
8659
8660 Ok(records)
8661}
8662
8663fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
8664 if scoped_name == TOP_LEVEL_SYMBOL {
8665 return Range {
8666 start_line: 0,
8667 start_col: 0,
8668 end_line: 0,
8669 end_col: 0,
8670 };
8671 }
8672 let Some(line) = source.lines().nth(fallback.start_line as usize) else {
8673 return fallback.clone();
8674 };
8675 let start_col = fallback.start_col as usize;
8676 let search_start = start_col.min(line.len());
8677 if let Some(offset) = line[search_start..].find(name) {
8678 let col = search_start + offset;
8679 return Range {
8680 start_line: fallback.start_line,
8681 start_col: col as u32,
8682 end_line: fallback.start_line,
8683 end_col: (col + name.len()) as u32,
8684 };
8685 }
8686 if let Some(offset) = line.find(name) {
8687 return Range {
8688 start_line: fallback.start_line,
8689 start_col: offset as u32,
8690 end_line: fallback.start_line,
8691 end_col: (offset + name.len()) as u32,
8692 };
8693 }
8694 Range {
8695 start_line: fallback.start_line,
8696 start_col: fallback.start_col,
8697 end_line: fallback.start_line,
8698 end_col: fallback.start_col.saturating_add(name.len() as u32),
8699 }
8700}
8701
8702fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
8703 if scoped_name == TOP_LEVEL_SYMBOL {
8704 return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
8705 }
8706 let input = format!(
8707 "{rel_path}:{}:{}:{}:{}:{ordinal}",
8708 range.start_line, range.start_col, range.end_line, range.end_col
8709 );
8710 format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
8711}
8712
8713fn build_call_refs(
8714 rel_path: &str,
8715 data: &FileCallData,
8716 node_by_scoped: &HashMap<String, String>,
8717 import_dependencies: &BTreeSet<String>,
8718) -> Vec<RawRef> {
8719 build_callable_refs(
8720 rel_path,
8721 &data.calls_by_symbol,
8722 node_by_scoped,
8723 import_dependencies,
8724 "call",
8725 )
8726}
8727
8728fn build_value_ref_refs(
8729 rel_path: &str,
8730 data: &FileCallData,
8731 node_by_scoped: &HashMap<String, String>,
8732 import_dependencies: &BTreeSet<String>,
8733) -> Vec<RawRef> {
8734 build_callable_refs(
8735 rel_path,
8736 &data.value_refs_by_symbol,
8737 node_by_scoped,
8738 import_dependencies,
8739 "value_ref",
8740 )
8741}
8742
8743fn build_callable_refs(
8744 rel_path: &str,
8745 sites_by_symbol: &HashMap<String, Vec<callgraph::CallSite>>,
8746 node_by_scoped: &HashMap<String, String>,
8747 import_dependencies: &BTreeSet<String>,
8748 kind: &str,
8749) -> Vec<RawRef> {
8750 let mut refs = Vec::new();
8751 let mut ordinal = 0usize;
8752 let mut symbols: Vec<_> = sites_by_symbol.iter().collect();
8753 symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
8754 for (caller_symbol, call_sites) in symbols {
8755 let caller_node = node_by_scoped.get(caller_symbol).cloned();
8756 for call_site in call_sites {
8757 ordinal += 1;
8758 let ref_id = ref_id(&[
8759 rel_path,
8760 kind,
8761 caller_symbol,
8762 &call_site.line.to_string(),
8763 &call_site.byte_start.to_string(),
8764 &call_site.byte_end.to_string(),
8765 &call_site.full_callee,
8766 &ordinal.to_string(),
8767 ]);
8768 refs.push(RawRef {
8769 ref_id,
8770 caller_node: caller_node.clone(),
8771 caller_symbol: Some(caller_symbol.clone()),
8772 caller_file: rel_path.to_string(),
8773 kind: kind.to_string(),
8774 short_name: Some(call_site.callee_name.clone()),
8775 full_ref: Some(call_site.full_callee.clone()),
8776 module_path: None,
8777 import_kind: None,
8778 local_name: Some(call_site.callee_name.clone()),
8779 requested_name: Some(call_site.callee_name.clone()),
8780 namespace_alias: namespace_alias(&call_site.full_callee),
8781 wildcard: false,
8782 line: call_site.line,
8783 byte_start: call_site.byte_start,
8784 byte_end: call_site.byte_end,
8785 dependencies: import_dependencies.clone(),
8786 });
8787 }
8788 }
8789 refs
8790}
8791
8792fn build_import_refs(
8793 project_root: &Path,
8794 abs_path: &Path,
8795 rel_path: &str,
8796 imports: &[ImportStatement],
8797 line_index: &LineIndex,
8798) -> Vec<RawRef> {
8799 let mut refs = Vec::new();
8800 for (index, import) in imports.iter().enumerate() {
8801 let import_kind = import_kind_label(import.kind).to_string();
8802 let local_name = import_local_names(import).join(",");
8803 let requested_name = import_requested_names(import).join(",");
8804 let ref_id = ref_id(&[
8805 rel_path,
8806 "import",
8807 &import.byte_range.start.to_string(),
8808 &import.byte_range.end.to_string(),
8809 &import.module_path,
8810 &index.to_string(),
8811 ]);
8812 refs.push(RawRef {
8813 ref_id,
8814 caller_node: None,
8815 caller_symbol: None,
8816 caller_file: rel_path.to_string(),
8817 kind: "import".to_string(),
8818 short_name: None,
8819 full_ref: Some(import.raw_text.clone()),
8820 module_path: Some(import.module_path.clone()),
8821 import_kind: Some(import_kind),
8822 local_name: empty_to_none(local_name),
8823 requested_name: empty_to_none(requested_name),
8824 namespace_alias: import.namespace_import.clone(),
8825 wildcard: import_is_wildcard(import),
8826 line: line_index.byte_to_line(import.byte_range.start),
8827 byte_start: import.byte_range.start,
8828 byte_end: import.byte_range.end,
8829 dependencies: module_dependencies(project_root, abs_path, &import.module_path),
8830 });
8831 }
8832 refs
8833}
8834
8835fn build_rust_module_refs(
8836 project_root: &Path,
8837 abs_path: &Path,
8838 rel_path: &str,
8839 source: &str,
8840) -> Vec<RawRef> {
8841 let grammar = grammar_for(LangId::Rust);
8842 let mut parser = Parser::new();
8843 if parser.set_language(&grammar).is_err() {
8844 return Vec::new();
8845 }
8846 let Some(tree) = parser.parse(source, None) else {
8847 return Vec::new();
8848 };
8849
8850 let mut refs = Vec::new();
8851 let mut stack = vec![tree.root_node()];
8852 while let Some(node) = stack.pop() {
8853 if node.kind() == "mod_item"
8854 && node
8855 .named_children(&mut node.walk())
8856 .all(|child| child.kind() != "declaration_list")
8857 {
8858 if let Some(name_node) = node.child_by_field_name("name") {
8859 let module_name = node_text(name_node, source).to_string();
8860 let target = rust_external_module_target(abs_path, source, node, &module_name);
8861 let mut dependencies = BTreeSet::new();
8862 if let Some(target) = target {
8863 dependencies.insert(relative_path(project_root, &canonicalize_path(&target)));
8864 }
8865 refs.push(RawRef {
8866 ref_id: ref_id(&[
8867 rel_path,
8868 "module",
8869 &module_name,
8870 &node.start_byte().to_string(),
8871 ]),
8872 caller_node: None,
8873 caller_symbol: None,
8874 caller_file: rel_path.to_string(),
8875 kind: "module".to_string(),
8876 short_name: Some(module_name.clone()),
8877 full_ref: Some(module_name.clone()),
8878 module_path: Some(module_name.clone()),
8879 import_kind: Some("module".to_string()),
8880 local_name: Some(module_name.clone()),
8881 requested_name: Some(module_name),
8882 namespace_alias: None,
8883 wildcard: false,
8884 line: node.start_position().row as u32 + 1,
8885 byte_start: node.start_byte(),
8886 byte_end: node.end_byte(),
8887 dependencies,
8888 });
8889 }
8890 }
8891
8892 let mut cursor = node.walk();
8893 if cursor.goto_first_child() {
8894 loop {
8895 stack.push(cursor.node());
8896 if !cursor.goto_next_sibling() {
8897 break;
8898 }
8899 }
8900 }
8901 }
8902 refs.sort_by_key(|raw| (raw.byte_start, raw.byte_end));
8903 refs
8904}
8905
8906fn rust_declared_module_target(
8907 project_root: &Path,
8908 caller_file: &str,
8909 module_name: &str,
8910) -> Option<String> {
8911 let declaring_file = project_root.join(caller_file);
8912 let source = std::fs::read_to_string(&declaring_file).ok()?;
8913 let grammar = grammar_for(LangId::Rust);
8914 let mut parser = Parser::new();
8915 parser.set_language(&grammar).ok()?;
8916 let tree = parser.parse(&source, None)?;
8917 let mut stack = vec![tree.root_node()];
8918 while let Some(node) = stack.pop() {
8919 if node.kind() == "mod_item"
8920 && node
8921 .child_by_field_name("name")
8922 .is_some_and(|name| node_text(name, &source) == module_name)
8923 && node
8924 .named_children(&mut node.walk())
8925 .all(|child| child.kind() != "declaration_list")
8926 {
8927 let target = rust_external_module_target(&declaring_file, &source, node, module_name)?;
8928 return Some(relative_path(project_root, &canonicalize_path(&target)));
8929 }
8930 let mut cursor = node.walk();
8931 if cursor.goto_first_child() {
8932 loop {
8933 stack.push(cursor.node());
8934 if !cursor.goto_next_sibling() {
8935 break;
8936 }
8937 }
8938 }
8939 }
8940 None
8941}
8942
8943fn rust_external_module_target(
8944 declaring_file: &Path,
8945 source: &str,
8946 module: Node<'_>,
8947 module_name: &str,
8948) -> Option<PathBuf> {
8949 let parent = declaring_file.parent()?;
8950 let mut previous = module.prev_sibling();
8951 while let Some(attribute) = previous {
8952 if attribute.kind() != "attribute_item" {
8953 break;
8954 }
8955 let text = source.get(attribute.byte_range())?;
8956 if let Some(path) = rust_path_attribute(text) {
8957 let candidate = parent.join(path);
8958 return candidate.is_file().then_some(candidate);
8959 }
8960 previous = attribute.prev_sibling();
8961 }
8962
8963 let stem = declaring_file.file_stem().and_then(|stem| stem.to_str())?;
8964 let module_dir = if matches!(stem, "lib" | "main" | "mod") {
8965 parent.to_path_buf()
8966 } else {
8967 parent.join(stem)
8968 };
8969 [
8970 module_dir.join(format!("{module_name}.rs")),
8971 module_dir.join(module_name).join("mod.rs"),
8972 ]
8973 .into_iter()
8974 .find(|candidate| candidate.is_file())
8975}
8976
8977fn rust_path_attribute(attribute: &str) -> Option<&str> {
8978 let body = attribute.trim().strip_prefix("#[")?.strip_suffix(']')?;
8979 let (name, value) = body.split_once('=')?;
8980 (name.trim() == "path")
8981 .then(|| value.trim().trim_matches('"'))
8982 .filter(|path| !path.is_empty())
8983}
8984
8985fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
8986 let grammar = grammar_for(LangId::Rust);
8987 let mut parser = Parser::new();
8988 if parser.set_language(&grammar).is_err() {
8989 return;
8990 }
8991 let Some(tree) = parser.parse(source, None) else {
8992 return;
8993 };
8994
8995 let mut seen = data
8996 .import_block
8997 .imports
8998 .iter()
8999 .map(|import| (import.byte_range.start, import.byte_range.end))
9000 .collect::<HashSet<_>>();
9001 let mut nested_imports = Vec::new();
9002 collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
9003 if nested_imports.is_empty() {
9004 return;
9005 }
9006
9007 data.import_block.imports.extend(nested_imports);
9008 data.import_block
9009 .imports
9010 .sort_by_key(|import| import.byte_range.start);
9011 data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
9012}
9013
9014fn collect_rust_use_imports(
9015 source: &str,
9016 node: Node<'_>,
9017 seen: &mut HashSet<(usize, usize)>,
9018 imports: &mut Vec<ImportStatement>,
9019) {
9020 if node.kind() == "use_declaration" {
9021 let range = node.byte_range();
9022 if seen.insert((range.start, range.end)) {
9023 if let Some(import) = rust_import_from_use_node(source, node) {
9024 imports.push(import);
9025 }
9026 }
9027 }
9028
9029 let mut cursor = node.walk();
9030 if !cursor.goto_first_child() {
9031 return;
9032 }
9033 loop {
9034 collect_rust_use_imports(source, cursor.node(), seen, imports);
9035 if !cursor.goto_next_sibling() {
9036 break;
9037 }
9038 }
9039}
9040
9041fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
9042 let raw_text = source[node.byte_range()].to_string();
9043 let body = rust_use_body(&raw_text)?.to_string();
9044 let visibility = rust_use_visibility(&raw_text);
9045 let names = rust_use_list_names(&body);
9046 let group = classify_rust_import_group(&body);
9047 let byte_range = node.byte_range();
9048
9049 Some(ImportStatement {
9050 module_path: body,
9051 names: names.clone(),
9052 default_import: visibility.clone(),
9053 namespace_import: None,
9054 kind: ImportKind::Value,
9055 group,
9056 byte_range,
9057 raw_text,
9058 form: ImportForm::RustUse {
9059 visibility,
9060 named: names,
9061 },
9062 })
9063}
9064
9065fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
9066 let start = imports.iter().map(|import| import.byte_range.start).min()?;
9067 let end = imports.iter().map(|import| import.byte_range.end).max()?;
9068 Some(start..end)
9069}
9070
9071fn rust_use_visibility(raw_text: &str) -> Option<String> {
9072 let use_pos = raw_text.find("use ")?;
9073 let prefix = raw_text[..use_pos].trim();
9074 if prefix.is_empty() {
9075 None
9076 } else {
9077 Some(prefix.to_string())
9078 }
9079}
9080
9081fn rust_use_body(raw_text: &str) -> Option<&str> {
9082 let use_pos = raw_text.find("use ")?;
9083 Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
9084}
9085
9086fn rust_use_list_names(body: &str) -> Vec<String> {
9087 let Some(open) = body.find("::{") else {
9088 return Vec::new();
9089 };
9090 let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
9091 return Vec::new();
9092 };
9093 body[open + 3..close]
9094 .split(',')
9095 .filter_map(|spec| {
9096 let spec = spec.trim();
9097 if spec.is_empty() {
9098 None
9099 } else {
9100 Some(spec.to_string())
9101 }
9102 })
9103 .collect()
9104}
9105
9106fn classify_rust_import_group(body: &str) -> ImportGroup {
9107 let first = body
9108 .split("::")
9109 .next()
9110 .unwrap_or(body)
9111 .split_whitespace()
9112 .next()
9113 .unwrap_or(body);
9114 match first.trim() {
9115 "std" | "core" | "alloc" => ImportGroup::Stdlib,
9116 "crate" | "self" | "super" => ImportGroup::Internal,
9117 _ => ImportGroup::External,
9118 }
9119}
9120
9121#[derive(Debug, Clone)]
9122struct ReexportRefs {
9123 raw_refs: Vec<RawRef>,
9124 surface_parts: Vec<String>,
9125}
9126
9127fn collect_reexport_refs(
9128 project_root: &Path,
9129 abs_path: &Path,
9130 rel_path: &str,
9131 source: &str,
9132) -> ReexportRefs {
9133 let mut raw_refs = Vec::new();
9134 let mut surface_parts = Vec::new();
9135 let mut search_start = 0usize;
9136 let mut ordinal = 0usize;
9137 while let Some(export_offset) = source[search_start..].find("export") {
9138 let start = search_start + export_offset;
9139 let Some(statement_end_offset) = source[start..].find(';') else {
9140 break;
9141 };
9142 let end = start + statement_end_offset + 1;
9143 let statement = &source[start..end];
9144 search_start = end;
9145 if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
9146 continue;
9147 }
9148 let Some(module_path) = quoted_module_path(statement) else {
9149 continue;
9150 };
9151 ordinal += 1;
9152 let wildcard = statement.contains('*');
9153 let line = source[..start]
9154 .bytes()
9155 .filter(|byte| *byte == b'\n')
9156 .count() as u32
9157 + 1;
9158 let ref_id = ref_id(&[
9159 rel_path,
9160 "reexport",
9161 &start.to_string(),
9162 &end.to_string(),
9163 &module_path,
9164 &ordinal.to_string(),
9165 ]);
9166 surface_parts.push(format!("reexport\t{statement}"));
9167 raw_refs.push(RawRef {
9168 ref_id,
9169 caller_node: None,
9170 caller_symbol: None,
9171 caller_file: rel_path.to_string(),
9172 kind: "reexport".to_string(),
9173 short_name: None,
9174 full_ref: Some(statement.to_string()),
9175 module_path: Some(module_path.clone()),
9176 import_kind: Some("reexport".to_string()),
9177 local_name: None,
9178 requested_name: None,
9179 namespace_alias: None,
9180 wildcard,
9181 line,
9182 byte_start: start,
9183 byte_end: end,
9184 dependencies: module_dependencies(project_root, abs_path, &module_path),
9185 });
9186 }
9187 ReexportRefs {
9188 raw_refs,
9189 surface_parts,
9190 }
9191}
9192
9193fn collect_rust_pub_use_reexport_refs(
9194 project_root: &Path,
9195 abs_path: &Path,
9196 rel_path: &str,
9197 imports: &[ImportStatement],
9198 line_index: &LineIndex,
9199) -> ReexportRefs {
9200 let mut raw_refs = Vec::new();
9201 let mut surface_parts = Vec::new();
9202 let mut ordinal = 0usize;
9203
9204 for import in imports {
9205 let Some(visibility) = &import.default_import else {
9206 continue;
9207 };
9208 if !visibility.starts_with("pub") {
9209 continue;
9210 }
9211 let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
9212 continue;
9213 };
9214 ordinal += 1;
9215 let ref_id = ref_id(&[
9216 rel_path,
9217 "rust_reexport",
9218 &import.byte_range.start.to_string(),
9219 &import.byte_range.end.to_string(),
9220 &module_path,
9221 &ordinal.to_string(),
9222 ]);
9223 surface_parts.push(format!("reexport\t{}", import.raw_text));
9224 raw_refs.push(RawRef {
9225 ref_id,
9226 caller_node: None,
9227 caller_symbol: None,
9228 caller_file: rel_path.to_string(),
9229 kind: "reexport".to_string(),
9230 short_name: None,
9231 full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
9232 module_path: Some(module_path.clone()),
9233 import_kind: Some("reexport".to_string()),
9234 local_name: None,
9235 requested_name: None,
9236 namespace_alias: None,
9237 wildcard,
9238 line: line_index.byte_to_line(import.byte_range.start),
9239 byte_start: import.byte_range.start,
9240 byte_end: import.byte_range.end,
9241 dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
9242 });
9243 }
9244
9245 ReexportRefs {
9246 raw_refs,
9247 surface_parts,
9248 }
9249}
9250
9251fn rust_pub_use_reexport_parts(
9252 import: &ImportStatement,
9253) -> Option<(String, HashMap<String, String>, bool)> {
9254 let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
9255 let body = body.trim();
9256 if let Some(module_path) = body.strip_suffix("::*") {
9257 return Some((module_path.trim().to_string(), HashMap::new(), true));
9258 }
9259
9260 if let Some(brace_start) = body.find("::{") {
9261 let module_path = body[..brace_start].trim().to_string();
9262 let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
9263 if names.is_empty() {
9264 return None;
9265 }
9266 return Some((module_path, names, false));
9267 }
9268
9269 let (module_path, spec) = body.rsplit_once("::")?;
9270 let names = rust_reexport_names_from_specs(spec);
9271 if names.is_empty() {
9272 return None;
9273 }
9274 Some((module_path.trim().to_string(), names, false))
9275}
9276
9277fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
9278 let mut names = HashMap::new();
9279 for spec in specs.split(',') {
9280 let spec = spec.trim();
9281 if spec.is_empty() || spec == "self" {
9282 continue;
9283 }
9284 if let Some((source, local)) = spec.split_once(" as ") {
9285 let source = source.trim();
9286 let local = local.trim();
9287 if !source.is_empty() && !local.is_empty() && source != "self" {
9288 names.insert(local.to_string(), source.to_string());
9289 }
9290 } else {
9291 names.insert(spec.to_string(), spec.to_string());
9292 }
9293 }
9294 names
9295}
9296
9297fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
9298 if named.is_empty() {
9299 return fallback.to_string();
9300 }
9301 let mut specs = named
9302 .iter()
9303 .map(|(local, source)| {
9304 if local == source {
9305 source.clone()
9306 } else {
9307 format!("{source} as {local}")
9308 }
9309 })
9310 .collect::<Vec<_>>();
9311 specs.sort();
9312 format!("pub use {{{}}};", specs.join(", "))
9313}
9314
9315fn quoted_module_path(statement: &str) -> Option<String> {
9316 let quote = match (statement.find('\''), statement.find('"')) {
9317 (Some(single), Some(double)) if single < double => '\'',
9318 (Some(_), Some(_)) => '"',
9319 (Some(_), None) => '\'',
9320 (None, Some(_)) => '"',
9321 (None, None) => return None,
9322 };
9323 let start = statement.find(quote)? + 1;
9324 let end = statement[start..].find(quote)? + start;
9325 Some(statement[start..end].to_string())
9326}
9327
9328#[derive(Debug, Clone)]
9329struct SourceLessExportRefs {
9330 raw_refs: Vec<RawRef>,
9331 surface_parts: Vec<String>,
9332}
9333
9334fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
9335 let mut raw_refs = Vec::new();
9336 let mut surface_parts = Vec::new();
9337 let mut search_start = 0usize;
9338 let mut ordinal = 0usize;
9339 while let Some(export_offset) = source[search_start..].find("export") {
9340 let start = search_start + export_offset;
9341 let Some(statement_end_offset) = source[start..].find(';') else {
9342 break;
9343 };
9344 let end = start + statement_end_offset + 1;
9345 let statement = &source[start..end];
9346 search_start = end;
9347 if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
9348 continue;
9349 }
9350 let aliases = parse_reexport_names(statement);
9351 if aliases.is_empty() {
9352 continue;
9353 }
9354 let line = source[..start]
9355 .bytes()
9356 .filter(|byte| *byte == b'\n')
9357 .count() as u32
9358 + 1;
9359 for (exported, source_symbol) in aliases {
9360 ordinal += 1;
9361 let ref_id = ref_id(&[
9362 rel_path,
9363 "export_alias",
9364 &start.to_string(),
9365 &end.to_string(),
9366 &exported,
9367 &source_symbol,
9368 &ordinal.to_string(),
9369 ]);
9370 surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
9371 raw_refs.push(RawRef {
9372 ref_id,
9373 caller_node: None,
9374 caller_symbol: None,
9375 caller_file: rel_path.to_string(),
9376 kind: "export_alias".to_string(),
9377 short_name: None,
9378 full_ref: Some(statement.to_string()),
9379 module_path: None,
9380 import_kind: Some("export_alias".to_string()),
9381 local_name: Some(exported),
9382 requested_name: Some(source_symbol),
9383 namespace_alias: None,
9384 wildcard: false,
9385 line,
9386 byte_start: start,
9387 byte_end: end,
9388 dependencies: BTreeSet::new(),
9389 });
9390 }
9391 }
9392 SourceLessExportRefs {
9393 raw_refs,
9394 surface_parts,
9395 }
9396}
9397
9398fn build_dispatch_hints(
9399 rel_path: &str,
9400 data: &FileCallData,
9401 node_by_scoped: &HashMap<String, String>,
9402) -> Vec<DispatchHint> {
9403 let mut hints = Vec::new();
9404 let mut ordinal = 0usize;
9405 for (caller_symbol, call_sites) in &data.calls_by_symbol {
9406 let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
9407 continue;
9408 };
9409 for call_site in call_sites {
9410 if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
9411 continue;
9412 }
9413 ordinal += 1;
9414 hints.push(DispatchHint {
9415 id: ref_id(&[
9416 rel_path,
9417 "dispatch",
9418 caller_symbol,
9419 &call_site.line.to_string(),
9420 &call_site.byte_start.to_string(),
9421 &call_site.byte_end.to_string(),
9422 &ordinal.to_string(),
9423 ]),
9424 method_name: call_site.callee_name.clone(),
9425 caller_node: caller_node.clone(),
9426 file: rel_path.to_string(),
9427 line: call_site.line,
9428 byte_start: call_site.byte_start,
9429 byte_end: call_site.byte_end,
9430 });
9431 }
9432 }
9433 hints
9434}
9435
9436fn surface_fingerprint(
9437 nodes: &mut [NodeRecord],
9438 data: &FileCallData,
9439 reexport_parts: &[String],
9440) -> String {
9441 nodes.sort_by(|left, right| {
9442 (left.file_path.as_str(), left.scoped_name.as_str())
9443 .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
9444 });
9445 let mut parts = Vec::new();
9446 for node in nodes.iter() {
9447 parts.push(format!(
9448 "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
9449 node.scoped_name,
9450 node.name,
9451 node.kind,
9452 node.exported,
9453 node.range.start_line,
9454 node.range.start_col,
9455 node.range.end_line,
9456 node.range.end_col,
9457 node.range_ordinal,
9458 node.signature.as_deref().unwrap_or("")
9459 ));
9460 }
9461 let mut exports = data.exported_symbols.clone();
9462 exports.sort();
9463 for export in exports {
9464 parts.push(format!("export\t{export}"));
9465 }
9466 if let Some(default_export) = &data.default_export_symbol {
9467 parts.push(format!("default\t{default_export}"));
9468 }
9469 let mut imports: Vec<String> = data
9470 .import_block
9471 .imports
9472 .iter()
9473 .map(|import| {
9474 format!(
9475 "import\t{}\t{:?}\t{}",
9476 import.module_path, import.form, import.raw_text
9477 )
9478 })
9479 .collect();
9480 imports.sort();
9481 parts.extend(imports);
9482 parts.extend(reexport_parts.iter().cloned());
9483 hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
9484}
9485
9486fn resolve_ref<I: ResolverIndex>(raw: RawRef, index: &I) -> Result<ResolvedRef> {
9487 if !matches!(raw.kind.as_str(), "call" | "value_ref") {
9488 return Ok(ResolvedRef {
9489 dependencies: raw.dependencies.clone(),
9490 raw,
9491 status: "unresolved".to_string(),
9492 target_node: None,
9493 target_file: None,
9494 target_symbol: None,
9495 edge: None,
9496 });
9497 }
9498
9499 let caller_file = raw.caller_file.clone();
9500 let caller_data =
9501 index
9502 .caller_data(&caller_file)
9503 .ok_or_else(|| CallGraphStoreError::MissingCallerData {
9504 file: caller_file.clone(),
9505 })?;
9506 let full_ref = raw.full_ref.as_deref().unwrap_or_default();
9507 let short_name = raw.short_name.as_deref().unwrap_or_default();
9508 let mut dependencies = raw.dependencies.clone();
9509
9510 let resolved = match index.lang_for(&caller_file) {
9511 Some(LangId::Rust) => {
9512 resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
9513 }
9514 Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
9515 resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
9516 }
9517 _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
9518 };
9519
9520 let Some((status, target_file, target_symbol)) = resolved else {
9521 return Ok(ResolvedRef {
9522 raw,
9523 status: "unresolved".to_string(),
9524 target_node: None,
9525 target_file: None,
9526 target_symbol: None,
9527 dependencies,
9528 edge: None,
9529 });
9530 };
9531
9532 dependencies.insert(target_file.clone());
9533 let target_node = index.node_for_symbol(&target_file, &target_symbol);
9534 if raw.kind == "value_ref"
9535 && !target_node
9536 .as_deref()
9537 .is_some_and(|node_id| index.node_is_callable(&target_file, node_id))
9538 {
9539 return Ok(ResolvedRef {
9540 raw,
9541 status: "unresolved".to_string(),
9542 target_node: None,
9543 target_file: None,
9544 target_symbol: None,
9545 dependencies,
9546 edge: None,
9547 });
9548 }
9549 let source_node = raw.caller_node.clone();
9550 let edge = if let Some(source_node) = source_node {
9551 if target_file == caller_file
9552 && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
9553 {
9554 None
9555 } else {
9556 Some(EdgeRecord {
9557 edge_id: ref_id(&[&raw.ref_id, "edge"]),
9558 source_node,
9559 target_node: target_node.clone(),
9560 target_file: target_file.clone(),
9561 target_symbol: target_symbol.clone(),
9562 kind: raw.kind.clone(),
9563 line: raw.line,
9564 })
9565 }
9566 } else {
9567 None
9568 };
9569
9570 Ok(ResolvedRef {
9571 raw,
9572 status,
9573 target_node,
9574 target_file: Some(target_file),
9575 target_symbol: Some(target_symbol),
9576 dependencies,
9577 edge,
9578 })
9579}
9580
9581fn resolve_js_ts_target<I: ResolverIndex>(
9582 index: &I,
9583 caller_file: &str,
9584 full_ref: &str,
9585 short_name: &str,
9586 caller_data: &FileCallData,
9587) -> Option<(String, String, String)> {
9588 if let Some((namespace, member)) = full_ref.split_once('.') {
9589 for import in &caller_data.import_block.imports {
9590 if import.namespace_import.as_deref() == Some(namespace) {
9591 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
9592 if let Some((file, symbol)) =
9593 resolve_exported_symbol(index, &target_file, member, 0)
9594 {
9595 return Some(("resolved".to_string(), file, symbol));
9596 }
9597 }
9598 }
9599 }
9600 }
9601
9602 for import in &caller_data.import_block.imports {
9603 for spec in &import.names {
9604 if crate::imports::specifier_local_name(spec) == short_name {
9605 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
9606 let requested = crate::imports::specifier_imported_name(spec);
9607 let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
9608 .unwrap_or_else(|| (target_file, requested.to_string()));
9609 return Some(("resolved".to_string(), file, symbol));
9610 }
9611 }
9612 }
9613
9614 if import.default_import.as_deref() == Some(short_name) {
9615 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
9616 let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
9617 .or_else(|| {
9618 index
9619 .default_export(&target_file)
9620 .map(|symbol| (target_file.clone(), symbol))
9621 })
9622 .unwrap_or_else(|| {
9623 let file_name = Path::new(&target_file)
9624 .file_name()
9625 .and_then(|name| name.to_str())
9626 .unwrap_or("unknown")
9627 .to_string();
9628 (target_file, format!("<default:{file_name}>"))
9629 });
9630 return Some(("resolved".to_string(), file, symbol));
9631 }
9632 }
9633 }
9634
9635 for import in &caller_data.import_block.imports {
9636 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
9637 if index.has_export(&target_file, short_name) {
9638 return Some(("resolved".to_string(), target_file, short_name.to_string()));
9639 }
9640 }
9641 }
9642
9643 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
9644}
9645
9646fn resolve_exported_symbol<I: ResolverIndex>(
9647 index: &I,
9648 file: &str,
9649 requested: &str,
9650 depth: usize,
9651) -> Option<(String, String)> {
9652 let mut visited = std::collections::HashMap::new();
9653 resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
9654}
9655
9656fn resolve_exported_symbol_inner<I: ResolverIndex>(
9665 index: &I,
9666 file: &str,
9667 requested: &str,
9668 depth: usize,
9669 visited: &mut std::collections::HashMap<(String, String), usize>,
9670) -> Option<(String, String)> {
9671 if depth > 16 {
9672 return None;
9673 }
9674 if requested != "default" {
9675 if let Some(source_symbol) = index.export_alias(file, requested) {
9676 return Some((file.to_string(), source_symbol));
9677 }
9678 if index.has_export(file, requested) {
9679 return Some((file.to_string(), requested.to_string()));
9680 }
9681 } else if let Some(default) = index.default_export(file) {
9682 return Some((file.to_string(), default));
9683 }
9684
9685 match visited.entry((file.to_string(), requested.to_string())) {
9689 std::collections::hash_map::Entry::Occupied(mut seen) => {
9690 if *seen.get() <= depth {
9691 return None;
9692 }
9693 seen.insert(depth);
9694 }
9695 std::collections::hash_map::Entry::Vacant(slot) => {
9696 slot.insert(depth);
9697 }
9698 }
9699
9700 for reexport in index.reexports_for(file) {
9701 let mut next_requested = requested.to_string();
9702 let matches = if reexport.wildcard {
9703 true
9704 } else if let Some(source_name) = reexport.named.get(requested) {
9705 next_requested = source_name.clone();
9706 true
9707 } else {
9708 false
9709 };
9710 if !matches {
9711 continue;
9712 }
9713 if let Some(target_file) = &reexport.target_file {
9714 if let Some(target) = resolve_exported_symbol_inner(
9715 index,
9716 target_file,
9717 &next_requested,
9718 depth + 1,
9719 visited,
9720 ) {
9721 return Some(target);
9722 }
9723 }
9724 }
9725 None
9726}
9727
9728fn resolve_rust_target<I: ResolverIndex>(
9729 index: &I,
9730 caller_file: &str,
9731 full_ref: &str,
9732 short_name: &str,
9733 caller_data: &FileCallData,
9734 raw: &RawRef,
9735) -> Option<(String, String, String)> {
9736 if full_ref.contains("::") {
9737 if let Some((target_file, target_symbol)) =
9738 rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
9739 {
9740 return Some(("resolved".to_string(), target_file, target_symbol));
9741 }
9742 }
9743
9744 for import in &caller_data.import_block.imports {
9745 if let Some((target_file, target_symbol)) =
9746 rust_target_for_use(index, caller_file, import, short_name)
9747 {
9748 return Some(("resolved".to_string(), target_file, target_symbol));
9749 }
9750 }
9751
9752 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
9753}
9754
9755fn rust_target_for_qualified<I: ResolverIndex>(
9756 index: &I,
9757 caller_file: &str,
9758 full_ref: &str,
9759 short_name: &str,
9760 caller_data: &FileCallData,
9761 raw: &RawRef,
9762) -> Option<(String, String)> {
9763 let mut segments: Vec<&str> = full_ref.split("::").collect();
9764 if segments.len() < 2 {
9765 return None;
9766 }
9767 segments.pop();
9768 let requested_symbol = rust_target_symbol(full_ref, short_name);
9769
9770 for path in rust_module_path_candidates(&segments, caller_data, raw) {
9771 let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
9772 if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
9773 if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
9774 return Some(rust_resolve_reexport_if_symbol_missing(
9775 index,
9776 target_file,
9777 requested_symbol.clone(),
9778 ));
9779 }
9780 }
9781
9782 let module_segments = rust_resolve_segments_with_index(index, caller_file, &path_refs)?;
9783 if let Some(target) =
9784 rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
9785 {
9786 return Some(target);
9787 }
9788 if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
9789 return Some(rust_resolve_reexport_if_symbol_missing(
9790 index,
9791 target_file,
9792 requested_symbol.clone(),
9793 ));
9794 }
9795 }
9796 None
9797}
9798
9799fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
9800 full_ref
9801 .rsplit("::")
9802 .next()
9803 .filter(|name| !name.is_empty())
9804 .unwrap_or(short_name)
9805 .to_string()
9806}
9807
9808fn rust_resolve_reexport_if_symbol_missing<I: ResolverIndex>(
9809 index: &I,
9810 target_file: String,
9811 target_symbol: String,
9812) -> (String, String) {
9813 if index
9814 .node_for_symbol(&target_file, &target_symbol)
9815 .is_some()
9816 {
9817 return (target_file, target_symbol);
9818 }
9819 if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
9820 resolved
9821 } else {
9822 (target_file, target_symbol)
9823 }
9824}
9825
9826fn rust_module_path_candidates(
9827 segments: &[&str],
9828 caller_data: &FileCallData,
9829 raw: &RawRef,
9830) -> Vec<Vec<String>> {
9831 let mut candidates = Vec::new();
9832 if let Some(first) = segments.first().copied() {
9833 for import in &caller_data.import_block.imports {
9834 if !rust_import_is_visible_to_call(import, raw) {
9835 continue;
9836 }
9837 let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
9838 continue;
9839 };
9840 if local_name == first {
9841 path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
9842 rust_push_unique_path_candidate(&mut candidates, path_segments);
9843 }
9844 }
9845 }
9846 rust_push_unique_path_candidate(
9847 &mut candidates,
9848 segments
9849 .iter()
9850 .map(|segment| (*segment).to_string())
9851 .collect(),
9852 );
9853 candidates
9854}
9855
9856fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
9857 if !candidates.iter().any(|existing| existing == &candidate) {
9858 candidates.push(candidate);
9859 }
9860}
9861
9862fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
9863 import.byte_range.start <= raw.byte_start
9864}
9865
9866fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
9867 let path = import.module_path.trim().trim_end_matches(';').trim();
9868 if path.contains("::{") || path.contains('{') || path.contains('*') {
9869 return None;
9870 }
9871 let (path_without_alias, alias) = path
9872 .split_once(" as ")
9873 .map(|(left, right)| (left.trim(), Some(right.trim())))
9874 .unwrap_or((path, None));
9875 let segments = path_without_alias
9876 .split("::")
9877 .map(str::trim)
9878 .filter(|segment| !segment.is_empty())
9879 .collect::<Vec<_>>();
9880 let local_name = alias.or_else(|| segments.last().copied())?.to_string();
9881 if local_name.chars().next().is_some_and(char::is_uppercase) {
9882 return None;
9883 }
9884 Some((
9885 local_name,
9886 segments
9887 .into_iter()
9888 .map(|segment| segment.to_string())
9889 .collect(),
9890 ))
9891}
9892
9893fn rust_inline_scoped_target<I: ResolverIndex>(
9894 index: &I,
9895 caller_file: &str,
9896 module_segments: &[String],
9897 short_name: &str,
9898) -> Option<(String, String)> {
9899 index.inline_scoped_target(caller_file, module_segments, short_name)
9900}
9901
9902fn rust_target_for_use<I: ResolverIndex>(
9903 index: &I,
9904 caller_file: &str,
9905 import: &ImportStatement,
9906 short_name: &str,
9907) -> Option<(String, String)> {
9908 let path = import.module_path.trim().trim_end_matches(';');
9909 if let Some(brace_start) = path.find("::{") {
9910 let prefix = &path[..brace_start];
9911 if import.names.iter().any(|name| name == short_name) {
9912 let prefix_segments: Vec<&str> = prefix.split("::").collect();
9913 let module_segments =
9914 rust_resolve_segments_with_index(index, caller_file, &prefix_segments)?;
9915 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
9916 return Some((file, short_name.to_string()));
9917 }
9918 return None;
9919 }
9920
9921 let (path_without_alias, alias) = path
9922 .split_once(" as ")
9923 .map(|(left, right)| (left.trim(), Some(right.trim())))
9924 .unwrap_or((path, None));
9925 let segments: Vec<&str> = path_without_alias.split("::").collect();
9926 let imported = alias.or_else(|| segments.last().copied())?;
9927 if imported != short_name {
9928 return None;
9929 }
9930 if segments.len() < 2 {
9931 return None;
9932 }
9933 let module_segments =
9934 rust_resolve_segments_with_index(index, caller_file, &segments[..segments.len() - 1])?;
9935 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
9936 Some((file, segments.last().unwrap_or(&short_name).to_string()))
9937}
9938
9939fn rust_workspace_file_for_segments<I: ResolverIndex>(
9940 index: &I,
9941 segments: &[&str],
9942) -> Option<String> {
9943 let crate_name = segments.first().copied()?;
9944 let src_prefix = index.crate_src_prefix(crate_name)?;
9945 let module_segments = segments[1..]
9946 .iter()
9947 .map(|segment| segment.to_string())
9948 .collect::<Vec<_>>();
9949 rust_file_for_src_prefix(index, &src_prefix, &module_segments)
9950}
9951
9952#[cfg(test)]
9953static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
9954 OnceLock::new();
9955
9956#[cfg(test)]
9957fn note_workspace_crate_prefix_build(project_root: &Path) {
9958 let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
9959 .get_or_init(|| Mutex::new(HashMap::new()))
9960 .lock()
9961 .expect("workspace crate prefix build counts mutex poisoned");
9962 *counts.entry(project_root.to_path_buf()).or_default() += 1;
9963}
9964
9965#[cfg(not(test))]
9966fn note_workspace_crate_prefix_build(_project_root: &Path) {}
9967
9968#[cfg(test)]
9969fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
9970 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
9971 .get_or_init(|| Mutex::new(HashMap::new()))
9972 .lock()
9973 .expect("workspace crate prefix build counts mutex poisoned")
9974 .remove(project_root);
9975}
9976
9977#[cfg(test)]
9978fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
9979 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
9980 .get_or_init(|| Mutex::new(HashMap::new()))
9981 .lock()
9982 .expect("workspace crate prefix build counts mutex poisoned")
9983 .get(project_root)
9984 .copied()
9985 .unwrap_or(0)
9986}
9987
9988fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
9993 note_workspace_crate_prefix_build(project_root);
9994 let mut prefixes = HashMap::new();
9995 let mut stack = vec![project_root.to_path_buf()];
9996 while let Some(dir) = stack.pop() {
9997 let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
9998 if matches!(name, "target" | "node_modules" | ".git") {
9999 continue;
10000 }
10001 let manifest = dir.join("Cargo.toml");
10002 if manifest.is_file() {
10003 let crate_names = rust_manifest_crate_names(&manifest);
10004 if !crate_names.is_empty() {
10005 let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
10006 for crate_name in crate_names {
10007 prefixes
10008 .entry(crate_name)
10009 .or_insert_with(|| src_prefix.clone());
10010 }
10011 }
10012 }
10013 let Ok(entries) = std::fs::read_dir(&dir) else {
10014 continue;
10015 };
10016 for entry in entries.flatten() {
10017 let path = entry.path();
10018 if path.is_dir() {
10019 stack.push(path);
10020 }
10021 }
10022 }
10023 prefixes
10024}
10025
10026fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
10030 let Ok(source) = std::fs::read_to_string(manifest) else {
10031 return Vec::new();
10032 };
10033 let mut in_lib = false;
10034 let mut package_name = None;
10035 let mut lib_name = None;
10036 for line in source.lines() {
10037 let trimmed = line.trim();
10038 if trimmed.starts_with('[') {
10039 in_lib = trimmed == "[lib]";
10040 continue;
10041 }
10042 let Some((key, value)) = trimmed.split_once('=') else {
10043 continue;
10044 };
10045 let key = key.trim();
10046 let value = value.trim().trim_matches('"');
10047 if in_lib && key == "name" {
10048 lib_name = Some(value.to_string());
10049 } else if !in_lib && key == "name" && package_name.is_none() {
10050 package_name = Some(value.to_string());
10051 }
10052 }
10053 let mut names = Vec::new();
10054 if let Some(lib) = lib_name {
10055 names.push(lib);
10056 }
10057 if let Some(package) = package_name {
10058 let normalized = package.replace('-', "_");
10059 if !names.contains(&normalized) {
10060 names.push(normalized);
10061 }
10062 }
10063 names
10064}
10065
10066fn rust_resolve_segments_with_index<I: ResolverIndex>(
10067 index: &I,
10068 caller_file: &str,
10069 segments: &[&str],
10070) -> Option<Vec<String>> {
10071 let caller_segments = rust_registered_module_segments(index, caller_file)
10072 .unwrap_or_else(|| rust_module_segments_for_rel(caller_file));
10073 rust_resolve_segments_from(caller_segments, segments)
10074}
10075
10076fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
10077 rust_resolve_segments_from(rust_module_segments_for_rel(caller_file), segments)
10078}
10079
10080fn rust_resolve_segments_from(
10081 caller_segments: Vec<String>,
10082 segments: &[&str],
10083) -> Option<Vec<String>> {
10084 if segments.is_empty() {
10085 return Some(Vec::new());
10086 }
10087 match segments[0] {
10088 "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
10089 "self" => {
10090 let mut resolved = caller_segments;
10091 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
10092 Some(resolved)
10093 }
10094 "super" => {
10095 let mut resolved = caller_segments;
10096 resolved.pop();
10097 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
10098 Some(resolved)
10099 }
10100 _ => {
10101 let mut resolved = caller_segments;
10102 resolved.pop();
10103 resolved.extend(segments.iter().map(|item| item.to_string()));
10104 Some(resolved)
10105 }
10106 }
10107}
10108
10109fn rust_registered_module_segments<I: ResolverIndex>(
10110 index: &I,
10111 caller_file: &str,
10112) -> Option<Vec<String>> {
10113 let mut current = caller_file.to_string();
10114 let mut segments = Vec::new();
10115 let mut seen = HashSet::new();
10116 while seen.insert(current.clone()) {
10117 let Some((parent, module)) = index.module_parent(¤t) else {
10118 break;
10119 };
10120 segments.push(module);
10121 current = parent;
10122 }
10123 if segments.is_empty() {
10124 None
10125 } else {
10126 segments.reverse();
10127 Some(segments)
10128 }
10129}
10130
10131fn rust_file_for_segments<I: ResolverIndex>(
10132 index: &I,
10133 caller_file: &str,
10134 segments: &[String],
10135) -> Option<String> {
10136 let src_prefix = rust_src_prefix(caller_file);
10137 if let Some(target) = rust_file_from_module_declarations(index, &src_prefix, segments) {
10138 return Some(target);
10139 }
10140 rust_file_for_src_prefix(index, &src_prefix, segments)
10141}
10142
10143fn rust_file_from_module_declarations<I: ResolverIndex>(
10144 index: &I,
10145 src_prefix: &str,
10146 segments: &[String],
10147) -> Option<String> {
10148 let mut current = [
10149 format!("{src_prefix}/lib.rs"),
10150 format!("{src_prefix}/main.rs"),
10151 ]
10152 .into_iter()
10153 .find(|candidate| index.contains_file(candidate))?;
10154 for segment in segments {
10155 current = index.module_target(¤t, segment)?;
10156 }
10157 Some(current)
10158}
10159
10160fn rust_file_for_src_prefix<I: ResolverIndex>(
10161 index: &I,
10162 src_prefix: &str,
10163 segments: &[String],
10164) -> Option<String> {
10165 let candidate = if segments.is_empty() {
10166 [src_prefix, "lib.rs"].join("/")
10167 } else {
10168 format!("{}/{}.rs", src_prefix, segments.join("/"))
10169 };
10170 if index.contains_file(&candidate) {
10171 return Some(candidate);
10172 }
10173 if !segments.is_empty() {
10174 let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
10175 if index.contains_file(&mod_candidate) {
10176 return Some(mod_candidate);
10177 }
10178 }
10179 None
10180}
10181
10182fn rust_src_prefix(rel_path: &str) -> String {
10183 rel_path
10184 .split_once("/src/")
10185 .map(|(prefix, _)| format!("{prefix}/src"))
10186 .unwrap_or_else(|| "src".to_string())
10187}
10188
10189fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
10190 let after_src = rel_path
10191 .split_once("/src/")
10192 .map(|(_, rest)| rest)
10193 .or_else(|| rel_path.strip_prefix("src/"))
10194 .unwrap_or(rel_path);
10195 if matches!(after_src, "lib.rs" | "main.rs") {
10196 return Vec::new();
10197 }
10198 if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
10199 return prefix.split('/').map(|item| item.to_string()).collect();
10200 }
10201 after_src
10202 .strip_suffix(".rs")
10203 .unwrap_or(after_src)
10204 .split('/')
10205 .map(|item| item.to_string())
10206 .collect()
10207}
10208
10209fn resolve_local_target<I: ResolverIndex>(
10210 _index: &I,
10211 caller_file: &str,
10212 full_ref: &str,
10213 short_name: &str,
10214 caller_data: &FileCallData,
10215) -> Option<(String, String, String)> {
10216 if !callgraph::is_bare_callee(full_ref, short_name) {
10217 return None;
10218 }
10219 callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
10220 .ok()
10221 .map(|symbol| {
10222 (
10223 "resolved_local".to_string(),
10224 caller_file.to_string(),
10225 symbol,
10226 )
10227 })
10228}
10229
10230impl<'a> ProjectIndex<'a> {
10231 fn from_parts(
10232 project_root: &Path,
10233 files: HashMap<String, DbFileIndex>,
10234 caller_data: HashMap<String, &'a FileCallData>,
10235 workspace_crate_prefixes: WorkspaceCratePrefixCache,
10236 ) -> Self {
10237 Self {
10238 project_root: project_root.to_path_buf(),
10239 files,
10240 caller_data,
10241 workspace_crate_prefixes,
10242 }
10243 }
10244
10245 fn from_db_and_callers(
10246 tx: &Transaction<'_>,
10247 project_root: &Path,
10248 caller_extracts: &'a HashMap<String, FileExtract>,
10249 workspace_crate_prefixes: WorkspaceCratePrefixCache,
10250 ) -> Result<Self> {
10251 let mut files = load_db_file_indexes(tx, project_root)?;
10252 let mut caller_data = HashMap::new();
10253 for (rel_path, extract) in caller_extracts {
10254 files.insert(
10255 rel_path.clone(),
10256 DbFileIndex::from_extract(project_root, extract),
10257 );
10258 caller_data.insert(rel_path.clone(), &extract.data);
10259 }
10260 Ok(Self::from_parts(
10261 project_root,
10262 files,
10263 caller_data,
10264 workspace_crate_prefixes,
10265 ))
10266 }
10267
10268 fn lang_for(&self, rel_path: &str) -> Option<LangId> {
10269 self.files.get(rel_path).and_then(|file| file.lang)
10270 }
10271
10272 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
10273 self.files
10274 .get(caller_file)
10275 .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
10276 }
10277
10278 fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
10279 self.files
10280 .get(rel_path)
10281 .map(|file| file.reexports.as_slice())
10282 .unwrap_or(&[])
10283 }
10284
10285 fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
10286 self.files.get(rel_path).and_then(|file| {
10287 file.node_by_scoped
10288 .get(symbol)
10289 .cloned()
10290 .or_else(|| file.node_by_bare.get(symbol).cloned())
10291 })
10292 }
10293
10294 fn node_is_callable(&self, rel_path: &str, node_id: &str) -> bool {
10295 self.files
10296 .get(rel_path)
10297 .and_then(|file| file.node_kind_by_id.get(node_id))
10298 .is_some_and(|kind| matches!(kind.as_str(), "function" | "method"))
10299 }
10300}
10301
10302impl DbFileIndex {
10303 fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
10304 let mut node_by_scoped = HashMap::new();
10305 let mut node_by_bare = HashMap::new();
10306 for node in &extract.nodes {
10307 node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
10308 node_by_bare
10309 .entry(node.name.clone())
10310 .or_insert(node.id.clone());
10311 }
10312 let node_kind_by_id = extract
10313 .nodes
10314 .iter()
10315 .map(|node| (node.id.clone(), node.kind.clone()))
10316 .collect();
10317 let mut export_aliases = HashMap::new();
10318 for raw_ref in &extract.raw_refs {
10319 if raw_ref.kind == "export_alias" {
10320 if let (Some(exported), Some(source_symbol)) =
10321 (&raw_ref.local_name, &raw_ref.requested_name)
10322 {
10323 export_aliases.insert(exported.clone(), source_symbol.clone());
10324 }
10325 }
10326 }
10327 let mut module_targets = HashMap::new();
10328 let mut declared_module_targets = HashMap::new();
10329 let mut reexports = Vec::new();
10330 for raw_ref in &extract.raw_refs {
10331 if !matches!(raw_ref.kind.as_str(), "import" | "reexport" | "module") {
10332 continue;
10333 }
10334 let Some(module_path) = &raw_ref.module_path else {
10335 continue;
10336 };
10337 let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
10338 module_targets
10339 .entry(module_path.clone())
10340 .or_insert_with(|| target_file.clone());
10341 if raw_ref.kind == "module" {
10342 declared_module_targets
10343 .entry(module_path.clone())
10344 .or_insert_with(|| target_file.clone());
10345 }
10346 if raw_ref.kind == "reexport" {
10347 reexports.push(reexport_index_from_raw(raw_ref, target_file));
10348 }
10349 }
10350 Self {
10351 lang: Some(extract.lang),
10352 exports: extract.data.exported_symbols.iter().cloned().collect(),
10353 default_export: extract.data.default_export_symbol.clone(),
10354 export_aliases,
10355 node_by_scoped,
10356 node_by_bare,
10357 node_kind_by_id,
10358 module_targets,
10359 declared_module_targets,
10360 reexports,
10361 }
10362 }
10363}
10364
10365fn load_db_file_indexes(
10366 tx: &Transaction<'_>,
10367 project_root: &Path,
10368) -> Result<HashMap<String, DbFileIndex>> {
10369 let mut files = HashMap::new();
10370 let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
10371 let rows = stmt.query_map([], |row| {
10372 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
10373 })?;
10374 for row in rows {
10375 let (rel_path, lang) = row?;
10376 files.insert(
10377 rel_path.clone(),
10378 DbFileIndex {
10379 lang: lang_from_label(&lang),
10380 exports: HashSet::new(),
10381 default_export: None,
10382 export_aliases: HashMap::new(),
10383 node_by_scoped: HashMap::new(),
10384 node_by_bare: HashMap::new(),
10385 node_kind_by_id: HashMap::new(),
10386 module_targets: HashMap::new(),
10387 declared_module_targets: HashMap::new(),
10388 reexports: Vec::new(),
10389 },
10390 );
10391 }
10392
10393 let mut node_stmt = tx.prepare(
10394 "SELECT file_path, id, name, scoped_name, kind, exported, is_default_export FROM nodes",
10395 )?;
10396 let nodes = node_stmt.query_map([], |row| {
10397 Ok((
10398 row.get::<_, String>(0)?,
10399 row.get::<_, String>(1)?,
10400 row.get::<_, String>(2)?,
10401 row.get::<_, String>(3)?,
10402 row.get::<_, String>(4)?,
10403 row.get::<_, i64>(5)? != 0,
10404 row.get::<_, i64>(6)? != 0,
10405 ))
10406 })?;
10407 for row in nodes {
10408 let (file_path, id, name, scoped_name, kind, exported, is_default_export) = row?;
10409 let file = files
10410 .entry(file_path.clone())
10411 .or_insert_with(|| DbFileIndex {
10412 lang: None,
10413 exports: HashSet::new(),
10414 default_export: None,
10415 export_aliases: HashMap::new(),
10416 node_by_scoped: HashMap::new(),
10417 node_by_bare: HashMap::new(),
10418 node_kind_by_id: HashMap::new(),
10419 module_targets: HashMap::new(),
10420 declared_module_targets: HashMap::new(),
10421 reexports: Vec::new(),
10422 });
10423 if exported {
10424 file.exports.insert(name.clone());
10425 file.exports.insert(scoped_name.clone());
10426 }
10427 if is_default_export {
10428 file.default_export = Some(scoped_name.clone());
10429 }
10430 file.node_by_scoped.insert(scoped_name, id.clone());
10431 file.node_by_bare.entry(name).or_insert(id.clone());
10432 file.node_kind_by_id.insert(id, kind);
10433 }
10434 let file_keys: HashSet<String> = files.keys().cloned().collect();
10435 let dependencies_by_file = load_file_dependencies_index(tx)?;
10439 let mut ref_stmt = tx.prepare(
10440 "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
10441 FROM refs WHERE kind IN ('module', 'reexport', 'export_alias')",
10442 )?;
10443 let ref_rows = ref_stmt.query_map([], |row| {
10444 Ok((
10445 row.get::<_, String>(0)?,
10446 row.get::<_, String>(1)?,
10447 row.get::<_, String>(2)?,
10448 row.get::<_, Option<String>>(3)?,
10449 row.get::<_, Option<String>>(4)?,
10450 row.get::<_, i64>(5)? != 0,
10451 row.get::<_, Option<String>>(6)?,
10452 row.get::<_, Option<String>>(7)?,
10453 ))
10454 })?;
10455 for row in ref_rows {
10456 let (
10457 ref_id,
10458 caller_file,
10459 kind,
10460 module_path,
10461 full_ref,
10462 wildcard,
10463 local_name,
10464 requested_name,
10465 ) = row?;
10466 if kind == "export_alias" {
10467 if let (Some(exported), Some(source_symbol), Some(file)) =
10468 (local_name, requested_name, files.get_mut(&caller_file))
10469 {
10470 file.export_aliases.insert(exported, source_symbol);
10471 }
10472 continue;
10473 }
10474 let Some(module_path) = module_path else {
10475 continue;
10476 };
10477 let file_deps = dependencies_by_file
10478 .get(&caller_file)
10479 .cloned()
10480 .unwrap_or_default();
10481 let deps = stored_dependencies_for_module(
10482 project_root,
10483 &caller_file,
10484 &module_path,
10485 &file_deps,
10486 &file_keys,
10487 );
10488 let target_file = if kind == "module" {
10489 rust_declared_module_target(project_root, &caller_file, &module_path)
10490 } else {
10491 deps.iter()
10492 .find(|dep| file_keys.contains(*dep))
10493 .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))))
10494 };
10495 if let Some(file) = files.get_mut(&caller_file) {
10496 file.module_targets
10497 .entry(module_path.clone())
10498 .or_insert_with(|| target_file.clone());
10499 if kind == "module" {
10500 file.declared_module_targets
10501 .entry(module_path.clone())
10502 .or_insert_with(|| target_file.clone());
10503 }
10504 if kind == "reexport" {
10505 let raw = RawRef {
10506 ref_id,
10507 caller_node: None,
10508 caller_symbol: None,
10509 caller_file,
10510 kind,
10511 short_name: None,
10512 full_ref,
10513 module_path: Some(module_path),
10514 import_kind: Some("reexport".to_string()),
10515 local_name: None,
10516 requested_name: None,
10517 namespace_alias: None,
10518 wildcard,
10519 line: 0,
10520 byte_start: 0,
10521 byte_end: 0,
10522 dependencies: deps,
10523 };
10524 file.reexports
10525 .push(reexport_index_from_raw(&raw, target_file));
10526 }
10527 }
10528 }
10529
10530 Ok(files)
10531}
10532
10533fn stored_dependencies_for_module(
10534 project_root: &Path,
10535 caller_file: &str,
10536 module_path: &str,
10537 caller_dependencies: &BTreeSet<String>,
10538 indexed_files: &HashSet<String>,
10539) -> BTreeSet<String> {
10540 let caller_path = project_root.join(caller_file);
10541 let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
10542 if module_path.starts_with('.') {
10543 let caller_dir = caller_path.parent().unwrap_or(project_root);
10544 for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
10545 let normalized = if candidate.is_file() {
10546 canonicalize_path(&candidate)
10547 } else {
10548 candidate
10549 };
10550 candidates.insert(relative_path(project_root, &normalized));
10551 }
10552 }
10553 let exact = candidates
10554 .intersection(caller_dependencies)
10555 .filter(|dependency| indexed_files.contains(*dependency))
10556 .cloned()
10557 .collect::<BTreeSet<_>>();
10558 if !exact.is_empty() || module_path.starts_with('.') {
10559 return exact;
10560 }
10561
10562 let module_path = rust_module_path_without_alias_or_use_list(module_path)
10563 .trim_matches(|character| matches!(character, '\'' | '"'));
10564 let package_name = module_path
10565 .split('/')
10566 .next_back()
10567 .unwrap_or(module_path)
10568 .replace('_', "-");
10569 let matched = caller_dependencies
10570 .iter()
10571 .filter(|dependency| indexed_files.contains(*dependency))
10572 .filter(|dependency| {
10573 dependency.as_str() == module_path
10574 || dependency.ends_with(&format!("/{module_path}"))
10575 || Path::new(dependency).components().any(|component| {
10576 component.as_os_str().to_string_lossy().replace('_', "-") == package_name
10577 })
10578 })
10579 .cloned()
10580 .collect::<BTreeSet<_>>();
10581 if matched.len() == 1 {
10582 matched
10583 } else {
10584 BTreeSet::new()
10585 }
10586}
10587
10588fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
10589 let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
10590 let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
10591 let rows = stmt.query_map([], |row| {
10592 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
10593 })?;
10594 for row in rows {
10595 let (file_path, dependency) = row?;
10596 by_file.entry(file_path).or_default().insert(dependency);
10597 }
10598 Ok(by_file)
10599}
10600
10601struct ColdBuildInsertStatements<'stmt> {
10602 file: Statement<'stmt>,
10603 node: Statement<'stmt>,
10604 file_dependency: Statement<'stmt>,
10605 dispatch_hint: Statement<'stmt>,
10606 backend_state: Statement<'stmt>,
10607 reference: Statement<'stmt>,
10608 staging_ref_context: Statement<'stmt>,
10609 edge: Statement<'stmt>,
10610}
10611
10612impl<'stmt> ColdBuildInsertStatements<'stmt> {
10613 fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
10614 Ok(Self {
10615 file: tx.prepare(
10616 "INSERT OR REPLACE INTO files(
10617 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
10618 is_public_api, surface_fingerprint, indexed_at
10619 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
10620 )?,
10621 node: tx.prepare(
10622 "INSERT OR REPLACE INTO nodes(
10623 id, file_path, name, scoped_name, kind, start_line, start_col,
10624 end_line, end_col, range_ordinal, signature, exported,
10625 is_default_export, is_type_like, is_callgraph_entry_point, provenance
10626 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
10627 )?,
10628 file_dependency: tx.prepare(
10629 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
10630 )?,
10631 dispatch_hint: tx.prepare(
10632 "INSERT OR REPLACE INTO dispatch_hints(
10633 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
10634 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
10635 )?,
10636 backend_state: tx.prepare(
10637 "INSERT OR REPLACE INTO backend_file_state(
10638 backend, workspace_root, file_path, content_hash, status, updated_at
10639 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
10640 )?,
10641 reference: tx.prepare(
10642 "INSERT OR REPLACE INTO refs(
10643 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
10644 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
10645 byte_start, byte_end, status, target_node, target_file, target_symbol,
10646 provenance
10647 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
10648 )?,
10649 staging_ref_context: tx.prepare(
10650 "INSERT OR REPLACE INTO staging_ref_context(ref_id, caller_symbol) VALUES(?1, ?2)",
10651 )?,
10652 edge: tx.prepare(
10653 "INSERT OR REPLACE INTO edges(
10654 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
10655 kind, line, provenance
10656 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
10657 )?,
10658 })
10659 }
10660}
10661
10662fn insert_file_extract_prepared(
10663 statements: &mut ColdBuildInsertStatements<'_>,
10664 workspace_root: &str,
10665 extract: &FileExtract,
10666) -> Result<()> {
10667 statements.file.execute(params![
10668 extract.rel_path,
10669 hash_to_hex(extract.freshness.content_hash),
10670 system_time_to_ns(extract.freshness.mtime),
10671 extract.freshness.size as i64,
10672 lang_label(extract.lang),
10673 extract.surface_fingerprint,
10674 unix_seconds_now(),
10675 ])?;
10676 for node in &extract.nodes {
10677 statements.node.execute(params![
10678 node.id,
10679 node.file_path,
10680 node.name,
10681 node.scoped_name,
10682 node.kind,
10683 node.range.start_line as i64,
10684 node.range.start_col as i64,
10685 node.range.end_line as i64,
10686 node.range.end_col as i64,
10687 node.range_ordinal as i64,
10688 node.signature,
10689 bool_int(node.exported),
10690 bool_int(node.is_default_export),
10691 bool_int(node.is_type_like),
10692 bool_int(node.is_callgraph_entry_point),
10693 PROVENANCE_TREESITTER,
10694 ])?;
10695 }
10696
10697 let mut dependencies = BTreeSet::new();
10698 for raw_ref in &extract.raw_refs {
10699 dependencies.extend(raw_ref.dependencies.iter().cloned());
10700 }
10701 for dep_file in &dependencies {
10702 statements
10703 .file_dependency
10704 .execute(params![extract.rel_path, dep_file])?;
10705 }
10706
10707 for hint in &extract.dispatch_hints {
10708 statements.dispatch_hint.execute(params![
10709 hint.id,
10710 hint.method_name,
10711 hint.caller_node,
10712 hint.file,
10713 hint.line as i64,
10714 hint.byte_start as i64,
10715 hint.byte_end as i64,
10716 PROVENANCE_TREESITTER,
10717 ])?;
10718 }
10719 insert_backend_state_prepared(
10720 &mut statements.backend_state,
10721 workspace_root,
10722 &extract.rel_path,
10723 Some(&extract.freshness.content_hash),
10724 "fresh",
10725 )?;
10726 Ok(())
10727}
10728
10729fn insert_backend_state_prepared(
10730 stmt: &mut Statement<'_>,
10731 workspace_root: &str,
10732 rel_path: &str,
10733 content_hash: Option<&blake3::Hash>,
10734 status: &str,
10735) -> Result<()> {
10736 let hash = content_hash
10737 .map(|hash| hash_to_hex(*hash))
10738 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
10739 stmt.execute(params![
10740 BACKEND_TREESITTER,
10741 workspace_root,
10742 rel_path,
10743 hash,
10744 status,
10745 unix_seconds_now(),
10746 ])?;
10747 Ok(())
10748}
10749
10750fn insert_staged_ref_prepared(
10751 statements: &mut ColdBuildInsertStatements<'_>,
10752 raw: &RawRef,
10753) -> Result<()> {
10754 statements.reference.execute(params![
10755 raw.ref_id,
10756 raw.caller_node,
10757 raw.caller_file,
10758 raw.kind,
10759 raw.short_name,
10760 raw.full_ref,
10761 raw.module_path,
10762 raw.import_kind,
10763 raw.local_name,
10764 raw.requested_name,
10765 raw.namespace_alias,
10766 bool_int(raw.wildcard),
10767 raw.line as i64,
10768 raw.byte_start as i64,
10769 raw.byte_end as i64,
10770 "staged",
10771 Option::<String>::None,
10772 Option::<String>::None,
10773 Option::<String>::None,
10774 ref_provenance(raw),
10775 ])?;
10776 statements
10777 .staging_ref_context
10778 .execute(params![raw.ref_id, raw.caller_symbol])?;
10779 Ok(())
10780}
10781
10782fn insert_resolved_ref_prepared(
10783 statements: &mut ColdBuildInsertStatements<'_>,
10784 resolved: &ResolvedRef,
10785) -> Result<()> {
10786 let raw = &resolved.raw;
10787 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
10788 statements.reference.execute(params![
10789 raw.ref_id,
10790 raw.caller_node,
10791 raw.caller_file,
10792 raw.kind,
10793 raw.short_name,
10794 raw.full_ref,
10795 raw.module_path,
10796 raw.import_kind,
10797 raw.local_name,
10798 raw.requested_name,
10799 raw.namespace_alias,
10800 bool_int(raw.wildcard),
10801 raw.line as i64,
10802 raw.byte_start as i64,
10803 raw.byte_end as i64,
10804 resolved.status,
10805 resolved.target_node,
10806 resolved.target_file,
10807 resolved.target_symbol,
10808 ref_provenance(raw),
10809 ])?;
10810 if let Some(edge) = &resolved.edge {
10811 statements.edge.execute(params![
10812 edge.edge_id,
10813 raw.ref_id,
10814 edge.source_node,
10815 edge.target_node,
10816 edge.target_file,
10817 edge.target_symbol,
10818 edge.kind,
10819 edge.line as i64,
10820 ref_provenance(raw),
10821 ])?;
10822 }
10823 Ok(())
10824}
10825
10826#[cfg(test)]
10827fn insert_file_extract(
10828 tx: &Transaction<'_>,
10829 project_root: &Path,
10830 extract: &FileExtract,
10831) -> Result<()> {
10832 tx.execute(
10833 "INSERT OR REPLACE INTO files(
10834 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
10835 is_public_api, surface_fingerprint, indexed_at
10836 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
10837 params![
10838 extract.rel_path,
10839 hash_to_hex(extract.freshness.content_hash),
10840 system_time_to_ns(extract.freshness.mtime),
10841 extract.freshness.size as i64,
10842 lang_label(extract.lang),
10843 extract.surface_fingerprint,
10844 unix_seconds_now(),
10845 ],
10846 )?;
10847 for node in &extract.nodes {
10848 tx.execute(
10849 "INSERT OR REPLACE INTO nodes(
10850 id, file_path, name, scoped_name, kind, start_line, start_col,
10851 end_line, end_col, range_ordinal, signature, exported,
10852 is_default_export, is_type_like, is_callgraph_entry_point, provenance
10853 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
10854 params![
10855 node.id,
10856 node.file_path,
10857 node.name,
10858 node.scoped_name,
10859 node.kind,
10860 node.range.start_line as i64,
10861 node.range.start_col as i64,
10862 node.range.end_line as i64,
10863 node.range.end_col as i64,
10864 node.range_ordinal as i64,
10865 node.signature,
10866 bool_int(node.exported),
10867 bool_int(node.is_default_export),
10868 bool_int(node.is_type_like),
10869 bool_int(node.is_callgraph_entry_point),
10870 PROVENANCE_TREESITTER,
10871 ],
10872 )?;
10873 }
10874 let mut dependencies = BTreeSet::new();
10875 for raw_ref in &extract.raw_refs {
10876 dependencies.extend(raw_ref.dependencies.iter().cloned());
10877 }
10878 insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
10879
10880 for hint in &extract.dispatch_hints {
10881 tx.execute(
10882 "INSERT OR REPLACE INTO dispatch_hints(
10883 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
10884 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
10885 params![
10886 hint.id,
10887 hint.method_name,
10888 hint.caller_node,
10889 hint.file,
10890 hint.line as i64,
10891 hint.byte_start as i64,
10892 hint.byte_end as i64,
10893 PROVENANCE_TREESITTER,
10894 ],
10895 )?;
10896 }
10897 mark_backend_state(
10898 tx,
10899 project_root,
10900 &extract.rel_path,
10901 Some(&extract.freshness.content_hash),
10902 "fresh",
10903 )?;
10904 Ok(())
10905}
10906
10907#[cfg(test)]
10908fn insert_file_dependencies(
10909 tx: &Transaction<'_>,
10910 file_path: &str,
10911 dependencies: &BTreeSet<String>,
10912) -> Result<()> {
10913 for dep_file in dependencies {
10914 tx.execute(
10915 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
10916 params![file_path, dep_file],
10917 )?;
10918 }
10919 Ok(())
10920}
10921
10922fn ref_provenance(raw: &RawRef) -> &'static str {
10923 if raw.kind == "value_ref" {
10924 PROVENANCE_VALUE_REF
10925 } else {
10926 PROVENANCE_TREESITTER
10927 }
10928}
10929
10930#[cfg(test)]
10931fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
10932 let raw = &resolved.raw;
10933 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
10934 tx.execute(
10935 "INSERT OR REPLACE INTO refs(
10936 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
10937 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
10938 byte_start, byte_end, status, target_node, target_file, target_symbol,
10939 provenance
10940 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
10941 params![
10942 raw.ref_id,
10943 raw.caller_node,
10944 raw.caller_file,
10945 raw.kind,
10946 raw.short_name,
10947 raw.full_ref,
10948 raw.module_path,
10949 raw.import_kind,
10950 raw.local_name,
10951 raw.requested_name,
10952 raw.namespace_alias,
10953 bool_int(raw.wildcard),
10954 raw.line as i64,
10955 raw.byte_start as i64,
10956 raw.byte_end as i64,
10957 resolved.status,
10958 resolved.target_node,
10959 resolved.target_file,
10960 resolved.target_symbol,
10961 ref_provenance(raw),
10962 ],
10963 )?;
10964 if let Some(edge) = &resolved.edge {
10965 tx.execute(
10966 "INSERT OR REPLACE INTO edges(
10967 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
10968 kind, line, provenance
10969 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
10970 params![
10971 edge.edge_id,
10972 raw.ref_id,
10973 edge.source_node,
10974 edge.target_node,
10975 edge.target_file,
10976 edge.target_symbol,
10977 edge.kind,
10978 edge.line as i64,
10979 ref_provenance(raw),
10980 ],
10981 )?;
10982 }
10983 Ok(())
10984}
10985
10986fn insert_method_dispatch_edges(
10987 tx: &Transaction<'_>,
10988 project_root: &Path,
10989 caller_files: Option<&BTreeSet<String>>,
10990) -> Result<usize> {
10991 let references = load_name_match_refs(tx, caller_files)?;
10992 if references.is_empty() {
10993 return Ok(0);
10994 }
10995
10996 let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
10997 let mut source_cache: DispatchSourceCache = HashMap::new();
10998 let mut inserted = 0usize;
10999 for reference in references {
11000 let key = (reference.method_name.clone(), reference.lang.clone());
11001 let candidates = match candidates_by_name.entry(key) {
11002 Entry::Occupied(entry) => entry.into_mut(),
11003 Entry::Vacant(entry) => {
11004 let candidates =
11005 load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
11006 entry.insert(candidates)
11007 }
11008 };
11009
11010 match infer_receiver_type_state(project_root, &reference, &mut source_cache) {
11011 ReceiverTypeInference::Known(receiver_type) => {
11012 let Some(candidate) =
11013 select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
11014 else {
11015 continue;
11016 };
11017 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
11018 inserted += 1;
11019 continue;
11020 }
11021 ReceiverTypeInference::RustDirectSelfField {
11022 receiver_type,
11023 declaration_file,
11024 module_scope,
11025 } => {
11026 let Some(candidate) = select_rust_direct_self_field_candidate(
11027 project_root,
11028 &reference,
11029 candidates.as_slice(),
11030 &receiver_type,
11031 &declaration_file,
11032 &module_scope,
11033 &mut source_cache,
11034 ) else {
11035 continue;
11036 };
11037 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
11038 inserted += 1;
11039 continue;
11040 }
11041 ReceiverTypeInference::KnownButUnresolved => continue,
11042 ReceiverTypeInference::Unknown => {}
11043 }
11044
11045 if method_name_match_denylisted(&reference.method_name) {
11046 continue;
11047 }
11048
11049 let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
11050 continue;
11051 };
11052 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
11053 inserted += 1;
11054 }
11055 Ok(inserted)
11056}
11057
11058fn insert_method_dispatch_edges_chunked(
11059 tx: &Transaction<'_>,
11060 project_root: &Path,
11061 chunk_size: usize,
11062) -> Result<usize> {
11063 let total_files = query_count(
11064 tx,
11065 "SELECT COUNT(*) FROM (SELECT DISTINCT caller_file FROM refs)",
11066 )? as usize;
11067 let mut completed_files = 0usize;
11068 ensure_cold_build_current("method-dispatch", completed_files, total_files)?;
11069 let mut inserted = 0usize;
11070 let mut after_file = String::new();
11071 loop {
11072 let caller_files = {
11073 let mut statement = tx.prepare(
11074 "SELECT DISTINCT caller_file
11075 FROM refs
11076 WHERE caller_file > ?1
11077 ORDER BY caller_file
11078 LIMIT ?2",
11079 )?;
11080 let rows = statement
11081 .query_map(params![after_file, chunk_size.max(1) as i64], |row| {
11082 row.get::<_, String>(0)
11083 })?;
11084 rows.collect::<std::result::Result<BTreeSet<_>, _>>()?
11085 };
11086 let Some(last_file) = caller_files.last().cloned() else {
11087 break;
11088 };
11089 inserted += insert_method_dispatch_edges(tx, project_root, Some(&caller_files))?;
11090 after_file = last_file;
11091 completed_files = completed_files
11092 .saturating_add(caller_files.len())
11093 .min(total_files);
11094 ensure_cold_build_current("method-dispatch", completed_files, total_files)?;
11095 }
11096 ensure_cold_build_current("method-dispatch", completed_files, total_files)?;
11097 Ok(inserted)
11098}
11099
11100fn insert_method_dispatch_edge(
11101 tx: &Transaction<'_>,
11102 reference: &NameMatchRef,
11103 candidate: &NameMatchCandidate,
11104 provenance: &str,
11105) -> Result<()> {
11106 tx.execute(
11107 "INSERT OR REPLACE INTO edges(
11108 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
11109 kind, line, provenance
11110 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
11111 params![
11112 ref_id(&[&reference.ref_id, provenance, "edge"]),
11113 &reference.ref_id,
11114 &reference.caller_node,
11115 &candidate.node_id,
11116 &candidate.file_path,
11117 &candidate.scoped_name,
11118 reference.line as i64,
11119 provenance,
11120 ],
11121 )?;
11122 Ok(())
11123}
11124
11125fn delete_method_dispatch_edges_for_callers(
11126 tx: &Transaction<'_>,
11127 caller_files: &BTreeSet<String>,
11128) -> Result<()> {
11129 if caller_files.is_empty() {
11130 return Ok(());
11131 }
11132
11133 let mut stmt = tx.prepare(
11134 "DELETE FROM edges
11135 WHERE provenance IN (?1, ?2)
11136 AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
11137 )?;
11138 for caller_file in caller_files {
11139 stmt.execute(params![
11140 PROVENANCE_NAME_MATCH,
11141 PROVENANCE_TYPE_MATCH,
11142 caller_file
11143 ])?;
11144 }
11145 Ok(())
11146}
11147
11148fn load_name_match_refs(
11149 tx: &Transaction<'_>,
11150 caller_files: Option<&BTreeSet<String>>,
11151) -> Result<Vec<NameMatchRef>> {
11152 let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
11153 n.signature, r.short_name, r.full_ref, r.line, f.lang
11154 FROM refs r
11155 JOIN files f ON f.path = r.caller_file
11156 JOIN nodes n ON n.id = r.caller_node
11157 WHERE r.kind = 'call'
11158 AND r.status = 'unresolved'
11159 AND r.caller_node IS NOT NULL
11160 AND r.full_ref IS NOT NULL
11161 AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
11162 AND NOT EXISTS (
11163 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
11164 )";
11165 let mut references = Vec::new();
11166
11167 if let Some(caller_files) = caller_files {
11168 if caller_files.is_empty() {
11169 return Ok(references);
11170 }
11171 let sql = format!(
11172 "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
11173 );
11174 let mut stmt = tx.prepare(&sql)?;
11175 for caller_file in caller_files {
11176 let rows = stmt.query_map(params![caller_file], |row| {
11177 Ok((
11178 row.get::<_, String>(0)?,
11179 row.get::<_, Option<String>>(1)?,
11180 row.get::<_, String>(2)?,
11181 row.get::<_, String>(3)?,
11182 row.get::<_, Option<String>>(4)?,
11183 row.get::<_, Option<String>>(5)?,
11184 row.get::<_, Option<String>>(6)?,
11185 row.get::<_, i64>(7)?,
11186 row.get::<_, String>(8)?,
11187 ))
11188 })?;
11189 for row in rows {
11190 let (
11191 ref_id,
11192 caller_node,
11193 caller_file,
11194 caller_symbol,
11195 caller_signature,
11196 short_name,
11197 full_ref,
11198 line,
11199 lang,
11200 ) = row?;
11201 if let Some(reference) = name_match_ref_from_parts(
11202 ref_id,
11203 caller_node,
11204 caller_file,
11205 caller_symbol,
11206 caller_signature,
11207 short_name,
11208 full_ref,
11209 line,
11210 lang,
11211 ) {
11212 references.push(reference);
11213 }
11214 }
11215 }
11216 return Ok(references);
11217 }
11218
11219 let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
11220 let mut stmt = tx.prepare(&sql)?;
11221 let rows = stmt.query_map([], |row| {
11222 Ok((
11223 row.get::<_, String>(0)?,
11224 row.get::<_, Option<String>>(1)?,
11225 row.get::<_, String>(2)?,
11226 row.get::<_, String>(3)?,
11227 row.get::<_, Option<String>>(4)?,
11228 row.get::<_, Option<String>>(5)?,
11229 row.get::<_, Option<String>>(6)?,
11230 row.get::<_, i64>(7)?,
11231 row.get::<_, String>(8)?,
11232 ))
11233 })?;
11234 for row in rows {
11235 let (
11236 ref_id,
11237 caller_node,
11238 caller_file,
11239 caller_symbol,
11240 caller_signature,
11241 short_name,
11242 full_ref,
11243 line,
11244 lang,
11245 ) = row?;
11246 if let Some(reference) = name_match_ref_from_parts(
11247 ref_id,
11248 caller_node,
11249 caller_file,
11250 caller_symbol,
11251 caller_signature,
11252 short_name,
11253 full_ref,
11254 line,
11255 lang,
11256 ) {
11257 references.push(reference);
11258 }
11259 }
11260 Ok(references)
11261}
11262
11263#[allow(clippy::too_many_arguments)]
11264fn name_match_ref_from_parts(
11265 ref_id: String,
11266 caller_node: Option<String>,
11267 caller_file: String,
11268 caller_symbol: String,
11269 caller_signature: Option<String>,
11270 short_name: Option<String>,
11271 full_ref: Option<String>,
11272 line: i64,
11273 lang: String,
11274) -> Option<NameMatchRef> {
11275 let caller_node = caller_node?;
11276 let full_ref = full_ref?;
11277 let (receiver_expression, receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
11278 let method_name = if member.is_empty() {
11279 short_name.as_deref()?.to_string()
11280 } else {
11281 member
11282 };
11283 Some(NameMatchRef {
11284 ref_id,
11285 caller_node,
11286 caller_file,
11287 caller_symbol,
11288 caller_signature,
11289 receiver_expression,
11290 receiver,
11291 method_name,
11292 colon_dispatch,
11293 line: line.max(0) as u32,
11294 lang,
11295 })
11296}
11297
11298fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, String, bool)> {
11299 let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
11300 let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
11301 let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
11302 let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
11303 .into_iter()
11304 .flatten()
11305 .max_by_key(|(index, _, _)| *index)?;
11306 if delimiter == 0 {
11307 return None;
11308 }
11309 let member_start = delimiter + delimiter_len;
11310 if member_start >= full_ref.len() {
11311 return None;
11312 }
11313 let receiver_expression = full_ref[..delimiter].trim();
11314 let receiver = last_name_segment(receiver_expression).trim();
11315 let member = &full_ref[member_start..];
11316 if receiver.is_empty() || member.is_empty() {
11317 return None;
11318 }
11319 Some((
11320 receiver_expression.to_string(),
11321 receiver.to_string(),
11322 member.to_string(),
11323 colon_dispatch,
11324 ))
11325}
11326
11327fn last_name_segment(value: &str) -> &str {
11328 value
11329 .rsplit(['.', ':', '/', '\\', '-', '>'])
11330 .find(|segment| !segment.is_empty())
11331 .unwrap_or(value)
11332}
11333
11334fn load_name_match_candidates(
11335 tx: &Transaction<'_>,
11336 method_name: &str,
11337 lang: &str,
11338) -> Result<Vec<NameMatchCandidate>> {
11339 let mut stmt = tx.prepare(
11340 "SELECT n.id, n.file_path, n.scoped_name, n.kind, n.start_line
11341 FROM nodes n JOIN files f ON f.path = n.file_path
11342 WHERE n.name = ?1
11343 AND f.lang = ?2
11344 AND n.kind IN ('method', 'function')
11345 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
11346 )?;
11347 let rows = stmt.query_map(params![method_name, lang], |row| {
11348 Ok(NameMatchCandidate {
11349 node_id: row.get(0)?,
11350 file_path: row.get(1)?,
11351 scoped_name: row.get(2)?,
11352 kind: row.get(3)?,
11353 start_line: (row.get::<_, i64>(4)?.max(0) as u32).saturating_add(1),
11354 })
11355 })?;
11356 rows.collect::<std::result::Result<Vec<_>, _>>()
11357 .map_err(Into::into)
11358}
11359
11360struct ParsedDispatchSource {
11361 source: String,
11362 tree: tree_sitter::Tree,
11363}
11364
11365type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
11366
11367#[derive(Debug, Clone, PartialEq, Eq)]
11368enum ReceiverTypeInference {
11369 Unknown,
11370 Known(String),
11371 RustDirectSelfField {
11372 receiver_type: String,
11373 declaration_file: String,
11374 module_scope: Vec<(usize, usize)>,
11375 },
11376 KnownButUnresolved,
11377}
11378
11379#[cfg(test)]
11380fn infer_receiver_type(
11381 project_root: &Path,
11382 reference: &NameMatchRef,
11383 source_cache: &mut DispatchSourceCache,
11384) -> Option<String> {
11385 match infer_receiver_type_state(project_root, reference, source_cache) {
11386 ReceiverTypeInference::Known(receiver_type)
11387 | ReceiverTypeInference::RustDirectSelfField { receiver_type, .. } => Some(receiver_type),
11388 ReceiverTypeInference::Unknown | ReceiverTypeInference::KnownButUnresolved => None,
11389 }
11390}
11391
11392fn infer_receiver_type_state(
11393 project_root: &Path,
11394 reference: &NameMatchRef,
11395 source_cache: &mut DispatchSourceCache,
11396) -> ReceiverTypeInference {
11397 let known = |receiver_type| ReceiverTypeInference::Known(receiver_type);
11398 match reference.lang.as_str() {
11399 "rust" => infer_rust_receiver_type(project_root, reference, source_cache),
11400 "java" => {
11401 infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
11402 .map(known)
11403 .unwrap_or(ReceiverTypeInference::Unknown)
11404 }
11405 "kotlin" => {
11406 infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
11407 .map(known)
11408 .unwrap_or(ReceiverTypeInference::Unknown)
11409 }
11410 "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache)
11411 .map(known)
11412 .unwrap_or(ReceiverTypeInference::Unknown),
11413 _ => ReceiverTypeInference::Unknown,
11414 }
11415}
11416
11417fn parse_dispatch_source(
11418 project_root: &Path,
11419 caller_file: &str,
11420 lang: LangId,
11421) -> Option<ParsedDispatchSource> {
11422 let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
11423 let grammar = crate::parser::grammar_for(lang);
11424 let mut parser = tree_sitter::Parser::new();
11425 parser.set_language(&grammar).ok()?;
11426 let tree = parser.parse(&source, None)?;
11427 Some(ParsedDispatchSource { source, tree })
11428}
11429
11430fn parsed_dispatch_source<'a>(
11431 project_root: &Path,
11432 reference: &NameMatchRef,
11433 lang: LangId,
11434 source_cache: &'a mut DispatchSourceCache,
11435) -> Option<&'a ParsedDispatchSource> {
11436 parsed_dispatch_source_for_file(
11437 project_root,
11438 &reference.caller_file,
11439 &reference.lang,
11440 lang,
11441 source_cache,
11442 )
11443}
11444
11445fn parsed_dispatch_source_for_file<'a>(
11446 project_root: &Path,
11447 file_path: &str,
11448 lang_label: &str,
11449 lang: LangId,
11450 source_cache: &'a mut DispatchSourceCache,
11451) -> Option<&'a ParsedDispatchSource> {
11452 let key = (file_path.to_string(), lang_label.to_string());
11453 source_cache
11454 .entry(key)
11455 .or_insert_with(|| parse_dispatch_source(project_root, file_path, lang))
11456 .as_ref()
11457}
11458
11459fn infer_java_like_receiver_type(
11460 project_root: &Path,
11461 reference: &NameMatchRef,
11462 lang: LangId,
11463 source_cache: &mut DispatchSourceCache,
11464) -> Option<String> {
11465 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
11466 return None;
11467 }
11468
11469 let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
11470 let root = parsed.tree.root_node();
11471 let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
11472
11473 let callable_scope = type_node
11474 .and_then(|node| {
11475 find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
11476 })
11477 .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
11478
11479 if let Some(callable_scope) = callable_scope {
11480 if let Some(receiver_type) = infer_java_like_local_receiver_type(
11481 callable_scope,
11482 &parsed.source,
11483 &reference.receiver,
11484 reference.line.max(1),
11485 lang,
11486 ) {
11487 return Some(receiver_type);
11488 }
11489 }
11490
11491 type_node.and_then(|node| {
11492 infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
11493 })
11494}
11495
11496fn infer_cpp_receiver_type(
11497 project_root: &Path,
11498 reference: &NameMatchRef,
11499 source_cache: &mut DispatchSourceCache,
11500) -> Option<String> {
11501 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
11502 return None;
11503 }
11504
11505 let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
11506 let root = parsed.tree.root_node();
11507 let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
11508 infer_cpp_receiver_type_from_scope(
11509 scope,
11510 &parsed.source,
11511 &reference.receiver,
11512 reference.line.max(1),
11513 )
11514}
11515
11516fn find_enclosing_java_like_type_node<'tree>(
11517 root: tree_sitter::Node<'tree>,
11518 source: &str,
11519 reference: &NameMatchRef,
11520 lang: LangId,
11521) -> Option<tree_sitter::Node<'tree>> {
11522 let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
11523 .and_then(|name| simple_type_name(&name));
11524 let line = reference.line.max(1);
11525 let mut best = None;
11526 let mut stack = vec![root];
11527 while let Some(node) = stack.pop() {
11528 if !node_contains_line(node, line) {
11529 continue;
11530 }
11531 if is_java_like_type_kind(node.kind(), lang) {
11532 let name = declaration_name(node, source);
11533 if expected_type
11534 .as_deref()
11535 .is_none_or(|expected| name == Some(expected))
11536 {
11537 best = tighter_node(best, node);
11538 }
11539 }
11540 push_named_children(node, &mut stack);
11541 }
11542 best
11543}
11544
11545fn find_enclosing_java_like_callable_node<'tree>(
11546 root: tree_sitter::Node<'tree>,
11547 source: &str,
11548 reference: &NameMatchRef,
11549 lang: LangId,
11550) -> Option<tree_sitter::Node<'tree>> {
11551 let expected_name = reference.caller_symbol.rsplit("::").next();
11552 let line = reference.line.max(1);
11553 let mut best = None;
11554 let mut stack = vec![root];
11555 while let Some(node) = stack.pop() {
11556 if !node_contains_line(node, line) {
11557 continue;
11558 }
11559 if is_java_like_callable_kind(node.kind(), lang) {
11560 let name = declaration_name(node, source);
11561 if expected_name.is_none_or(|expected| name == Some(expected)) {
11562 best = tighter_node(best, node);
11563 }
11564 }
11565 push_named_children(node, &mut stack);
11566 }
11567 best
11568}
11569
11570fn find_enclosing_cpp_callable_node<'tree>(
11571 root: tree_sitter::Node<'tree>,
11572 _source: &str,
11573 reference: &NameMatchRef,
11574) -> Option<tree_sitter::Node<'tree>> {
11575 let line = reference.line.max(1);
11576 let mut best = None;
11577 let mut stack = vec![root];
11578 while let Some(node) = stack.pop() {
11579 if !node_contains_line(node, line) {
11580 continue;
11581 }
11582 if node.kind() == "function_definition" {
11583 best = tighter_node(best, node);
11584 }
11585 push_named_children(node, &mut stack);
11586 }
11587 best
11588}
11589
11590fn tighter_node<'tree>(
11591 current: Option<tree_sitter::Node<'tree>>,
11592 candidate: tree_sitter::Node<'tree>,
11593) -> Option<tree_sitter::Node<'tree>> {
11594 match current {
11595 Some(current)
11596 if current.start_byte() > candidate.start_byte()
11597 || (current.start_byte() == candidate.start_byte()
11598 && current.end_byte() <= candidate.end_byte()) =>
11599 {
11600 Some(current)
11601 }
11602 _ => Some(candidate),
11603 }
11604}
11605
11606fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
11607 let start = node.start_position().row as u32 + 1;
11608 let end = node.end_position().row as u32 + 1;
11609 start <= line && line <= end
11610}
11611
11612fn push_named_children<'tree>(
11613 node: tree_sitter::Node<'tree>,
11614 stack: &mut Vec<tree_sitter::Node<'tree>>,
11615) {
11616 for index in 0..node.named_child_count() {
11617 if let Some(child) = node.named_child(index as u32) {
11618 stack.push(child);
11619 }
11620 }
11621}
11622
11623fn declaration_name<'source>(
11624 node: tree_sitter::Node<'_>,
11625 source: &'source str,
11626) -> Option<&'source str> {
11627 node.child_by_field_name("name")
11628 .map(|name| node_text(name, source))
11629 .or_else(|| {
11630 first_named_child_text(
11631 node,
11632 source,
11633 &["identifier", "type_identifier", "simple_identifier"],
11634 )
11635 })
11636}
11637
11638fn first_named_child_text<'source>(
11639 node: tree_sitter::Node<'_>,
11640 source: &'source str,
11641 kinds: &[&str],
11642) -> Option<&'source str> {
11643 for index in 0..node.named_child_count() {
11644 let child = node.named_child(index as u32)?;
11645 if kinds.contains(&child.kind()) {
11646 return Some(node_text(child, source));
11647 }
11648 }
11649 None
11650}
11651
11652fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
11653 &source[node.byte_range()]
11654}
11655
11656fn infer_java_like_field_receiver_type(
11657 type_node: tree_sitter::Node<'_>,
11658 source: &str,
11659 receiver: &str,
11660 lang: LangId,
11661) -> Option<String> {
11662 let mut stack = Vec::new();
11663 push_named_children(type_node, &mut stack);
11664 while let Some(node) = stack.pop() {
11665 if is_java_like_field_kind(node.kind(), lang) {
11666 if let Some(receiver_type) =
11667 extract_java_like_declared_type(node_text(node, source), receiver, lang)
11668 {
11669 return Some(receiver_type);
11670 }
11671 }
11672 if is_java_like_type_kind(node.kind(), lang)
11673 || is_java_like_callable_kind(node.kind(), lang)
11674 {
11675 continue;
11676 }
11677 push_named_children(node, &mut stack);
11678 }
11679 None
11680}
11681
11682fn infer_java_like_local_receiver_type(
11683 callable_node: tree_sitter::Node<'_>,
11684 source: &str,
11685 receiver: &str,
11686 call_line: u32,
11687 lang: LangId,
11688) -> Option<String> {
11689 let mut best: Option<(u32, String)> = None;
11690 let mut stack = Vec::new();
11691 push_named_children(callable_node, &mut stack);
11692 while let Some(node) = stack.pop() {
11693 let start_line = node.start_position().row as u32 + 1;
11694 if start_line > call_line {
11695 continue;
11696 }
11697 if is_java_like_local_kind(node.kind(), lang) {
11698 if let Some(receiver_type) =
11699 extract_java_like_declared_type(node_text(node, source), receiver, lang)
11700 {
11701 if best
11702 .as_ref()
11703 .is_none_or(|(best_line, _)| start_line >= *best_line)
11704 {
11705 best = Some((start_line, receiver_type));
11706 }
11707 }
11708 }
11709 if is_java_like_type_kind(node.kind(), lang)
11710 || is_java_like_callable_kind(node.kind(), lang)
11711 {
11712 continue;
11713 }
11714 push_named_children(node, &mut stack);
11715 }
11716 best.map(|(_, receiver_type)| receiver_type)
11717}
11718
11719fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
11720 match lang {
11721 LangId::Java => matches!(
11722 kind,
11723 "class_declaration"
11724 | "interface_declaration"
11725 | "enum_declaration"
11726 | "record_declaration"
11727 | "annotation_type_declaration"
11728 ),
11729 LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
11730 _ => false,
11731 }
11732}
11733
11734fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
11735 match lang {
11736 LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
11737 LangId::Kotlin => kind == "function_declaration",
11738 _ => false,
11739 }
11740}
11741
11742fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
11743 match lang {
11744 LangId::Java => kind == "field_declaration",
11745 LangId::Kotlin => kind == "property_declaration",
11746 _ => false,
11747 }
11748}
11749
11750fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
11751 match lang {
11752 LangId::Java => kind == "local_variable_declaration",
11753 LangId::Kotlin => kind == "property_declaration",
11754 _ => false,
11755 }
11756}
11757
11758fn extract_java_like_declared_type(
11759 declaration: &str,
11760 receiver: &str,
11761 lang: LangId,
11762) -> Option<String> {
11763 match lang {
11764 LangId::Java => extract_java_declared_type(declaration, receiver),
11765 LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
11766 _ => None,
11767 }
11768}
11769
11770fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
11771 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
11772 let after = declaration[receiver_start + receiver.len()..].trim_start();
11773 if after
11774 .chars()
11775 .next()
11776 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
11777 {
11778 return None;
11779 }
11780
11781 let before = declaration[..receiver_start].trim_end();
11782 if before.contains(',') {
11783 return None;
11784 }
11785 normalize_receiver_type_name(strip_java_declaration_prefixes(before))
11786}
11787
11788fn strip_java_declaration_prefixes(mut value: &str) -> &str {
11789 loop {
11790 value = value.trim_start();
11791 if let Some(stripped) = strip_leading_java_annotation(value) {
11792 value = stripped;
11793 continue;
11794 }
11795 if let Some(stripped) = strip_leading_java_modifier(value) {
11796 value = stripped;
11797 continue;
11798 }
11799 return value.trim();
11800 }
11801}
11802
11803fn strip_leading_java_annotation(value: &str) -> Option<&str> {
11804 let value = value.trim_start();
11805 let mut chars = value.char_indices();
11806 let (_, first) = chars.next()?;
11807 if first != '@' {
11808 return None;
11809 }
11810 let mut end = first.len_utf8();
11811 for (index, ch) in chars {
11812 if !(is_code_ident_char(ch) || ch == '.') {
11813 end = index;
11814 break;
11815 }
11816 end = index + ch.len_utf8();
11817 }
11818 let rest = value[end..].trim_start();
11819 if let Some(stripped) = rest.strip_prefix('(') {
11820 let mut depth = 1usize;
11821 for (index, ch) in stripped.char_indices() {
11822 match ch {
11823 '(' => depth += 1,
11824 ')' => {
11825 depth = depth.saturating_sub(1);
11826 if depth == 0 {
11827 return Some(stripped[index + ch.len_utf8()..].trim_start());
11828 }
11829 }
11830 _ => {}
11831 }
11832 }
11833 return Some("");
11834 }
11835 Some(rest)
11836}
11837
11838fn strip_leading_java_modifier(value: &str) -> Option<&str> {
11839 const MODIFIERS: &[&str] = &[
11840 "public",
11841 "protected",
11842 "private",
11843 "abstract",
11844 "static",
11845 "final",
11846 "transient",
11847 "volatile",
11848 "synchronized",
11849 "native",
11850 "strictfp",
11851 ];
11852 MODIFIERS
11853 .iter()
11854 .find_map(|modifier| strip_leading_word(value, modifier))
11855}
11856
11857fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
11858 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
11859 let before = &declaration[..receiver_start];
11860 if find_identifier_occurrence(before, "val").is_none()
11861 && find_identifier_occurrence(before, "var").is_none()
11862 {
11863 return None;
11864 }
11865
11866 let after = declaration[receiver_start + receiver.len()..].trim_start();
11867 if let Some(type_text) = after.strip_prefix(':') {
11868 return normalize_receiver_type_name(read_type_prefix(type_text));
11869 }
11870 after
11871 .strip_prefix('=')
11872 .and_then(infer_kotlin_constructor_type)
11873}
11874
11875fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
11876 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
11877 if rest.trim_start().starts_with('(') {
11878 normalize_receiver_type_name(head)
11879 } else {
11880 None
11881 }
11882}
11883
11884fn read_type_prefix(value: &str) -> &str {
11885 let mut angle_depth = 0usize;
11886 for (index, ch) in value.char_indices() {
11887 match ch {
11888 '<' => angle_depth += 1,
11889 '>' => angle_depth = angle_depth.saturating_sub(1),
11890 '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
11891 return value[..index].trim();
11892 }
11893 _ => {}
11894 }
11895 }
11896 value.trim()
11897}
11898
11899fn infer_cpp_receiver_type_from_scope(
11900 scope: tree_sitter::Node<'_>,
11901 source: &str,
11902 receiver: &str,
11903 call_line: u32,
11904) -> Option<String> {
11905 let lines = source.lines().collect::<Vec<_>>();
11906 if lines.is_empty() {
11907 return None;
11908 }
11909 let scope_start = scope.start_position().row as usize;
11910 let call_index = (call_line as usize)
11911 .saturating_sub(1)
11912 .min(lines.len().saturating_sub(1));
11913 for index in (scope_start..=call_index).rev() {
11914 if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
11915 return Some(receiver_type);
11916 }
11917 }
11918 None
11919}
11920
11921fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
11922 for receiver_start in identifier_occurrences(line, receiver) {
11923 let after = line[receiver_start + receiver.len()..].trim_start();
11924 if after
11925 .chars()
11926 .next()
11927 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
11928 {
11929 continue;
11930 }
11931 let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
11932 let normalized = normalize_cpp_type_name(type_text)?;
11933 if normalized == "auto" {
11934 if let Some(rhs) = after.strip_prefix('=') {
11935 return infer_cpp_auto_receiver_type(rhs);
11936 }
11937 continue;
11938 }
11939 return Some(normalized);
11940 }
11941 None
11942}
11943
11944fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
11945 let candidate = prefix
11946 .rsplit([';', '{', '}', '('])
11947 .next()
11948 .unwrap_or(prefix)
11949 .trim();
11950 if candidate.is_empty() || candidate.ends_with(',') {
11951 None
11952 } else {
11953 Some(candidate)
11954 }
11955}
11956
11957fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
11958 let without_templates = strip_angle_groups(type_text);
11959 let mut cleaned = String::with_capacity(without_templates.len());
11960 for token in without_templates.split_whitespace() {
11961 if matches!(
11962 token,
11963 "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
11964 ) {
11965 continue;
11966 }
11967 if !cleaned.is_empty() {
11968 cleaned.push(' ');
11969 }
11970 cleaned.push_str(token);
11971 }
11972 let token = cleaned
11973 .split_whitespace()
11974 .last()
11975 .unwrap_or(cleaned.trim())
11976 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
11977 .trim_matches(['*', '&']);
11978 let simple = token.rsplit("::").next().unwrap_or(token).trim();
11979 if simple.is_empty() || cpp_non_type_token(simple) {
11980 None
11981 } else {
11982 Some(simple.to_string())
11983 }
11984}
11985
11986fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
11987 let rhs = rhs.trim_start();
11988 if let Some(after_new) = rhs.strip_prefix("new ") {
11989 return infer_cpp_constructor_type(after_new);
11990 }
11991 infer_cpp_make_template_type(rhs)
11992 .or_else(|| infer_cpp_constructor_type(rhs))
11993 .or_else(|| infer_cpp_factory_type(rhs))
11994}
11995
11996fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
11997 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
11998 let normalized = normalize_cpp_type_name(head)?;
11999 if !normalized
12000 .chars()
12001 .next()
12002 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
12003 {
12004 return None;
12005 }
12006 if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
12007 Some(normalized)
12008 } else {
12009 None
12010 }
12011}
12012
12013fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
12014 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
12015 if !rest.trim_start().starts_with('(') {
12016 return None;
12017 }
12018 let base = head.split('<').next().unwrap_or(head);
12019 let base_simple = base.rsplit("::").next().unwrap_or(base);
12020 if !matches!(base_simple, "make_unique" | "make_shared") {
12021 return None;
12022 }
12023 first_angle_arg(head).and_then(normalize_cpp_type_name)
12024}
12025
12026fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
12027 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
12028 if !rest.trim_start().starts_with('(') {
12029 return None;
12030 }
12031 let simple = head
12032 .split('<')
12033 .next()
12034 .unwrap_or(head)
12035 .rsplit("::")
12036 .next()
12037 .unwrap_or(head);
12038 for prefix in ["make", "create", "build"] {
12039 if let Some(suffix) = simple.strip_prefix(prefix) {
12040 if suffix
12041 .chars()
12042 .next()
12043 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
12044 {
12045 return normalize_cpp_type_name(suffix);
12046 }
12047 }
12048 }
12049 None
12050}
12051
12052#[derive(Debug, Clone, Copy)]
12053enum JavaLikeInvocation {
12054 Kotlin,
12055 Cpp,
12056}
12057
12058fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
12059 let value = value.trim_start();
12060 let mut end = 0usize;
12061 for (index, ch) in value.char_indices() {
12062 let allowed_separator = match flavor {
12063 JavaLikeInvocation::Kotlin => ch == '.',
12064 JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
12065 };
12066 if is_code_ident_char(ch) || allowed_separator {
12067 end = index + ch.len_utf8();
12068 continue;
12069 }
12070 break;
12071 }
12072 if end == 0 {
12073 return None;
12074 }
12075 let mut rest = &value[end..];
12076 if let Some(stripped) = rest.trim_start().strip_prefix('<') {
12077 let skipped = skip_balanced_angle(stripped)?;
12078 let rest_start = rest.len() - rest.trim_start().len();
12079 let angle_len = 1 + skipped;
12080 end += rest_start + angle_len;
12081 rest = &value[end..];
12082 }
12083 Some((value[..end].trim(), rest))
12084}
12085
12086fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
12087 let mut depth = 1usize;
12088 for (index, ch) in value_after_open.char_indices() {
12089 match ch {
12090 '<' => depth += 1,
12091 '>' => {
12092 depth = depth.saturating_sub(1);
12093 if depth == 0 {
12094 return Some(index + ch.len_utf8());
12095 }
12096 }
12097 _ => {}
12098 }
12099 }
12100 None
12101}
12102
12103fn first_angle_arg(value: &str) -> Option<&str> {
12104 let open = value.find('<')?;
12105 let inner_len = skip_balanced_angle(&value[open + 1..])?;
12106 let inner = &value[open + 1..open + inner_len];
12107 split_top_level_commas(inner).into_iter().next()
12108}
12109
12110fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
12111 let without_generics = strip_angle_groups(type_text);
12112 let cleaned = without_generics
12113 .replace("[]", " ")
12114 .replace("...", " ")
12115 .replace(['?', '&', '*'], " ");
12116 let token = cleaned
12117 .split_whitespace()
12118 .last()
12119 .unwrap_or(cleaned.trim())
12120 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
12121 let token = token.rsplit("::").next().unwrap_or(token);
12122 let simple = token.rsplit('.').next().unwrap_or(token).trim();
12123 if simple.is_empty()
12124 || java_like_primitive_type(simple)
12125 || !simple
12126 .chars()
12127 .next()
12128 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
12129 {
12130 None
12131 } else {
12132 Some(simple.to_string())
12133 }
12134}
12135
12136fn simple_type_name(scoped_name: &str) -> Option<String> {
12137 scoped_name
12138 .rsplit("::")
12139 .find(|segment| !segment.is_empty())
12140 .and_then(normalize_receiver_type_name)
12141}
12142
12143fn strip_angle_groups(value: &str) -> String {
12144 let mut output = String::with_capacity(value.len());
12145 let mut depth = 0usize;
12146 for ch in value.chars() {
12147 match ch {
12148 '<' => {
12149 if depth == 0 {
12150 output.push(' ');
12151 }
12152 depth += 1;
12153 }
12154 '>' => depth = depth.saturating_sub(1),
12155 _ if depth == 0 => output.push(ch),
12156 _ => {}
12157 }
12158 }
12159 output
12160}
12161
12162fn java_like_primitive_type(value: &str) -> bool {
12163 matches!(
12164 value,
12165 "boolean"
12166 | "byte"
12167 | "char"
12168 | "double"
12169 | "float"
12170 | "int"
12171 | "long"
12172 | "short"
12173 | "void"
12174 | "Boolean"
12175 | "Byte"
12176 | "Char"
12177 | "Double"
12178 | "Float"
12179 | "Int"
12180 | "Long"
12181 | "Short"
12182 | "Unit"
12183 )
12184}
12185
12186fn cpp_non_type_token(value: &str) -> bool {
12187 matches!(
12188 value,
12189 "return"
12190 | "if"
12191 | "else"
12192 | "for"
12193 | "while"
12194 | "do"
12195 | "switch"
12196 | "case"
12197 | "default"
12198 | "break"
12199 | "continue"
12200 | "goto"
12201 | "throw"
12202 | "new"
12203 | "delete"
12204 | "co_await"
12205 | "co_yield"
12206 | "co_return"
12207 | "static_cast"
12208 | "const_cast"
12209 | "dynamic_cast"
12210 | "reinterpret_cast"
12211 | "sizeof"
12212 | "alignof"
12213 | "typeid"
12214 | "and"
12215 | "or"
12216 | "not"
12217 | "xor"
12218 )
12219}
12220
12221fn receiver_is_bare_identifier(value: &str) -> bool {
12222 let mut chars = value.chars();
12223 let Some(first) = chars.next() else {
12224 return false;
12225 };
12226 (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
12227}
12228
12229fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
12230 identifier_occurrences(value, needle).into_iter().next()
12231}
12232
12233fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
12234 value
12235 .match_indices(needle)
12236 .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
12237 .collect()
12238}
12239
12240fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
12241 let before = value[..start].chars().next_back();
12242 let after = value[start + len..].chars().next();
12243 !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
12244}
12245
12246fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
12247 let stripped = value.strip_prefix(word)?;
12248 if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
12249 Some(stripped.trim_start())
12250 } else {
12251 None
12252 }
12253}
12254
12255fn is_code_ident_char(ch: char) -> bool {
12256 ch == '_' || ch.is_ascii_alphanumeric()
12257}
12258
12259fn infer_rust_receiver_type(
12260 project_root: &Path,
12261 reference: &NameMatchRef,
12262 source_cache: &mut DispatchSourceCache,
12263) -> ReceiverTypeInference {
12264 if matches!(reference.receiver.as_str(), "self" | "Self") {
12265 return enclosing_type_from_scoped_name(&reference.caller_symbol)
12266 .map(ReceiverTypeInference::Known)
12267 .unwrap_or(ReceiverTypeInference::Unknown);
12268 }
12269
12270 if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
12271 return ReceiverTypeInference::Known(reference.receiver.clone());
12272 }
12273
12274 if let Some(receiver_type) = reference
12275 .caller_signature
12276 .as_deref()
12277 .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
12278 {
12279 return ReceiverTypeInference::Known(receiver_type);
12280 }
12281
12282 infer_rust_direct_self_field_receiver_type(project_root, reference, source_cache)
12283}
12284
12285fn infer_rust_direct_self_field_receiver_type(
12286 project_root: &Path,
12287 reference: &NameMatchRef,
12288 source_cache: &mut DispatchSourceCache,
12289) -> ReceiverTypeInference {
12290 if reference.colon_dispatch {
12291 return ReceiverTypeInference::Unknown;
12292 }
12293 let Some(field_name) = rust_direct_self_field_name(&reference.receiver_expression) else {
12294 return ReceiverTypeInference::Unknown;
12295 };
12296 if field_name != reference.receiver {
12297 return ReceiverTypeInference::Unknown;
12298 }
12299
12300 let Some(impl_type) = enclosing_type_from_scoped_name(&reference.caller_symbol) else {
12301 return ReceiverTypeInference::Unknown;
12302 };
12303 let Some(struct_name) = rust_direct_nominal_type_name(&impl_type) else {
12304 return ReceiverTypeInference::KnownButUnresolved;
12305 };
12306 let Some(parsed) = parsed_dispatch_source(project_root, reference, LangId::Rust, source_cache)
12307 else {
12308 return ReceiverTypeInference::Unknown;
12309 };
12310 let Some(impl_node) =
12311 find_enclosing_rust_impl_node(parsed.tree.root_node(), reference.line.max(1))
12312 else {
12313 return ReceiverTypeInference::Unknown;
12314 };
12315 if impl_node.child_by_field_name("trait").is_some()
12316 || impl_node.child_by_field_name("type_parameters").is_some()
12317 {
12318 return ReceiverTypeInference::KnownButUnresolved;
12319 }
12320 let Some(impl_target) = impl_node.child_by_field_name("type") else {
12321 return ReceiverTypeInference::KnownButUnresolved;
12322 };
12323 if impl_target.kind() != "type_identifier"
12324 || node_text(impl_target, &parsed.source) != impl_type
12325 {
12326 return ReceiverTypeInference::KnownButUnresolved;
12327 }
12328
12329 let module_scope = rust_module_scope(impl_node);
12330 let Some(struct_node) = find_unique_rust_struct(
12331 parsed.tree.root_node(),
12332 &parsed.source,
12333 struct_name,
12334 &module_scope,
12335 ) else {
12336 return ReceiverTypeInference::KnownButUnresolved;
12337 };
12338 let Some(field_type) = rust_struct_field_type_node(struct_node, &parsed.source, field_name)
12339 else {
12340 return ReceiverTypeInference::KnownButUnresolved;
12341 };
12342 if field_type.kind() != "type_identifier" {
12343 return ReceiverTypeInference::KnownButUnresolved;
12344 }
12345 let field_type_name = node_text(field_type, &parsed.source);
12346 if find_unique_rust_struct(
12347 parsed.tree.root_node(),
12348 &parsed.source,
12349 field_type_name,
12350 &module_scope,
12351 )
12352 .is_none()
12353 {
12354 return ReceiverTypeInference::KnownButUnresolved;
12355 }
12356
12357 ReceiverTypeInference::RustDirectSelfField {
12358 receiver_type: field_type_name.to_string(),
12359 declaration_file: reference.caller_file.clone(),
12360 module_scope,
12361 }
12362}
12363
12364fn rust_direct_self_field_name(receiver_expression: &str) -> Option<&str> {
12365 let (base, field) = receiver_expression.split_once('.')?;
12366 let base = base.trim();
12367 let field = field.trim();
12368 (base == "self" && rust_direct_nominal_type_name(field).is_some()).then_some(field)
12369}
12370
12371fn rust_direct_nominal_type_name(value: &str) -> Option<&str> {
12372 let name = value.rsplit("::").next()?.trim();
12373 (!name.is_empty()
12374 && !name.chars().next().is_some_and(|ch| ch.is_ascii_digit())
12375 && name.chars().all(is_rust_ident_char))
12376 .then_some(name)
12377}
12378
12379fn find_enclosing_rust_impl_node<'tree>(
12380 root: tree_sitter::Node<'tree>,
12381 line: u32,
12382) -> Option<tree_sitter::Node<'tree>> {
12383 let mut best = None;
12384 let mut stack = vec![root];
12385 while let Some(node) = stack.pop() {
12386 if !node_contains_line(node, line) {
12387 continue;
12388 }
12389 if node.kind() == "impl_item" {
12390 best = tighter_node(best, node);
12391 }
12392 push_named_children(node, &mut stack);
12393 }
12394 best
12395}
12396
12397fn rust_module_scope(node: tree_sitter::Node<'_>) -> Vec<(usize, usize)> {
12398 let mut scope = Vec::new();
12399 let mut current = node.parent();
12400 while let Some(parent) = current {
12401 if parent.kind() == "mod_item" {
12402 scope.push((parent.start_byte(), parent.end_byte()));
12403 }
12404 current = parent.parent();
12405 }
12406 scope.reverse();
12407 scope
12408}
12409
12410fn find_unique_rust_struct<'tree>(
12411 root: tree_sitter::Node<'tree>,
12412 source: &str,
12413 expected_name: &str,
12414 module_scope: &[(usize, usize)],
12415) -> Option<tree_sitter::Node<'tree>> {
12416 let mut found = None;
12417 let mut stack = vec![root];
12418 while let Some(node) = stack.pop() {
12419 if node.kind() == "struct_item"
12420 && rust_module_scope(node) == module_scope
12421 && node.child_by_field_name("type_parameters").is_none()
12422 && declaration_name(node, source) == Some(expected_name)
12423 {
12424 if found.is_some() {
12425 return None;
12426 }
12427 found = Some(node);
12428 }
12429 push_named_children(node, &mut stack);
12430 }
12431 found
12432}
12433
12434fn rust_struct_field_type_node<'tree>(
12435 struct_node: tree_sitter::Node<'tree>,
12436 source: &str,
12437 field_name: &str,
12438) -> Option<tree_sitter::Node<'tree>> {
12439 let fields = struct_node.child_by_field_name("body")?;
12440 if fields.kind() != "field_declaration_list" {
12441 return None;
12442 }
12443 for index in 0..fields.named_child_count() {
12444 let field = fields.named_child(index as u32)?;
12445 if field.kind() != "field_declaration"
12446 || declaration_name(field, source) != Some(field_name)
12447 {
12448 continue;
12449 }
12450 return field.child_by_field_name("type");
12451 }
12452 None
12453}
12454
12455fn rust_receiver_looks_type_like(receiver: &str) -> bool {
12456 receiver
12457 .chars()
12458 .next()
12459 .is_some_and(|ch| ch == '_' || ch.is_uppercase())
12460}
12461
12462fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
12463 scoped_name
12464 .rsplit_once("::")
12465 .map(|(enclosing, _)| enclosing)
12466 .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
12467 .map(ToString::to_string)
12468}
12469
12470fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
12471 let params = signature_parameter_text(signature)?;
12472 for param in split_top_level_commas(params) {
12473 let Some((pattern, type_text)) = param.split_once(':') else {
12474 continue;
12475 };
12476 let Some(name) = rust_parameter_name(pattern) else {
12477 continue;
12478 };
12479 if name == receiver {
12480 return normalize_rust_receiver_type(type_text);
12481 }
12482 }
12483 None
12484}
12485
12486fn signature_parameter_text(signature: &str) -> Option<&str> {
12487 let open = signature.find('(')?;
12488 let mut depth = 0usize;
12489 for (offset, ch) in signature[open..].char_indices() {
12490 match ch {
12491 '(' => depth += 1,
12492 ')' => {
12493 depth = depth.saturating_sub(1);
12494 if depth == 0 {
12495 return Some(&signature[open + 1..open + offset]);
12496 }
12497 }
12498 _ => {}
12499 }
12500 }
12501 None
12502}
12503
12504fn split_top_level_commas(value: &str) -> Vec<&str> {
12505 let mut parts = Vec::new();
12506 let mut start = 0usize;
12507 let mut angle_depth = 0usize;
12508 let mut paren_depth = 0usize;
12509 let mut bracket_depth = 0usize;
12510 for (index, ch) in value.char_indices() {
12511 match ch {
12512 '<' => angle_depth += 1,
12513 '>' => angle_depth = angle_depth.saturating_sub(1),
12514 '(' => paren_depth += 1,
12515 ')' => paren_depth = paren_depth.saturating_sub(1),
12516 '[' => bracket_depth += 1,
12517 ']' => bracket_depth = bracket_depth.saturating_sub(1),
12518 ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
12519 let part = value[start..index].trim();
12520 if !part.is_empty() {
12521 parts.push(part);
12522 }
12523 start = index + ch.len_utf8();
12524 }
12525 _ => {}
12526 }
12527 }
12528 let part = value[start..].trim();
12529 if !part.is_empty() {
12530 parts.push(part);
12531 }
12532 parts
12533}
12534
12535fn rust_parameter_name(pattern: &str) -> Option<&str> {
12536 let mut pattern = pattern.trim();
12537 if let Some(stripped) = pattern.strip_prefix("mut ") {
12538 pattern = stripped.trim_start();
12539 }
12540 pattern
12541 .rsplit(|ch: char| !is_rust_ident_char(ch))
12542 .find(|part| !part.is_empty())
12543}
12544
12545fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
12546 let mut ty = strip_leading_rust_type_modifiers(type_text);
12547 let owned_inner;
12548 if let Some(inner) = single_outer_generic_arg(ty) {
12549 owned_inner = inner.trim().to_string();
12550 ty = strip_leading_rust_type_modifiers(&owned_inner);
12551 }
12552 rust_base_type_ident(ty)
12553}
12554
12555fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
12556 loop {
12557 ty = ty.trim_start();
12558 if let Some(stripped) = ty.strip_prefix('&') {
12559 ty = stripped.trim_start();
12560 if let Some(stripped) = strip_leading_lifetime(ty) {
12561 ty = stripped.trim_start();
12562 }
12563 if let Some(stripped) = ty.strip_prefix("mut ") {
12564 ty = stripped.trim_start();
12565 }
12566 continue;
12567 }
12568 if let Some(stripped) = ty.strip_prefix("mut ") {
12569 ty = stripped.trim_start();
12570 continue;
12571 }
12572 if let Some(stripped) = ty.strip_prefix("dyn ") {
12573 ty = stripped.trim_start();
12574 continue;
12575 }
12576 if let Some(stripped) = ty.strip_prefix("impl ") {
12577 ty = stripped.trim_start();
12578 continue;
12579 }
12580 break ty.trim();
12581 }
12582}
12583
12584fn strip_leading_lifetime(value: &str) -> Option<&str> {
12585 let mut chars = value.char_indices();
12586 let (_, first) = chars.next()?;
12587 if first != '\'' {
12588 return None;
12589 }
12590 for (index, ch) in chars {
12591 if !(ch == '_' || ch.is_ascii_alphanumeric()) {
12592 return Some(&value[index..]);
12593 }
12594 }
12595 Some("")
12596}
12597
12598fn single_outer_generic_arg(ty: &str) -> Option<&str> {
12599 let ty = ty.trim();
12600 let open = ty.find('<')?;
12601 let mut depth = 0usize;
12602 let mut close = None;
12603 for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
12604 match ch {
12605 '<' => depth += 1,
12606 '>' => {
12607 depth = depth.saturating_sub(1);
12608 if depth == 0 {
12609 close = Some(index);
12610 break;
12611 }
12612 }
12613 _ => {}
12614 }
12615 }
12616 let close = close?;
12617 if !ty[close + 1..].trim().is_empty() {
12618 return None;
12619 }
12620 let inner = &ty[open + 1..close];
12621 let args = split_top_level_commas(inner);
12622 match args.as_slice() {
12623 [arg] => Some(*arg),
12624 _ => None,
12625 }
12626}
12627
12628fn rust_base_type_ident(ty: &str) -> Option<String> {
12629 let ty = ty.trim();
12630 let head = ty
12631 .split([' ', '+', '='])
12632 .find(|part| !part.is_empty())
12633 .unwrap_or(ty);
12634 let head = head.split('<').next().unwrap_or(head).trim();
12635 let ident = head
12636 .rsplit("::")
12637 .next()
12638 .unwrap_or(head)
12639 .trim_matches(|ch: char| !is_rust_ident_char(ch));
12640 if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
12641 None
12642 } else {
12643 Some(ident.to_string())
12644 }
12645}
12646
12647fn is_rust_ident_char(ch: char) -> bool {
12648 ch == '_' || ch.is_ascii_alphanumeric()
12649}
12650
12651fn select_rust_direct_self_field_candidate(
12652 project_root: &Path,
12653 reference: &NameMatchRef,
12654 candidates: &[NameMatchCandidate],
12655 receiver_type: &str,
12656 declaration_file: &str,
12657 declaration_scope: &[(usize, usize)],
12658 source_cache: &mut DispatchSourceCache,
12659) -> Option<NameMatchCandidate> {
12660 let eligible = candidates
12661 .iter()
12662 .filter(|candidate| candidate.node_id != reference.caller_node)
12663 .filter(|candidate| {
12664 type_candidate_matches(candidate, receiver_type, &reference.method_name)
12665 })
12666 .filter(|candidate| {
12667 rust_direct_self_field_candidate_matches_scope(
12668 project_root,
12669 candidate,
12670 receiver_type,
12671 declaration_file,
12672 declaration_scope,
12673 source_cache,
12674 )
12675 })
12676 .collect::<Vec<_>>();
12677 match eligible.as_slice() {
12678 [candidate] => Some((**candidate).clone()),
12679 _ => None,
12680 }
12681}
12682
12683fn rust_direct_self_field_candidate_matches_scope(
12684 project_root: &Path,
12685 candidate: &NameMatchCandidate,
12686 receiver_type: &str,
12687 declaration_file: &str,
12688 declaration_scope: &[(usize, usize)],
12689 source_cache: &mut DispatchSourceCache,
12690) -> bool {
12691 if candidate.file_path != declaration_file {
12692 return false;
12693 }
12694 let Some(parsed) = parsed_dispatch_source_for_file(
12695 project_root,
12696 &candidate.file_path,
12697 "rust",
12698 LangId::Rust,
12699 source_cache,
12700 ) else {
12701 return false;
12702 };
12703 let Some(impl_node) =
12704 find_enclosing_rust_impl_node(parsed.tree.root_node(), candidate.start_line)
12705 else {
12706 return false;
12707 };
12708 if impl_node.child_by_field_name("trait").is_some()
12709 || impl_node.child_by_field_name("type_parameters").is_some()
12710 {
12711 return false;
12712 }
12713 let Some(impl_target) = impl_node.child_by_field_name("type") else {
12714 return false;
12715 };
12716 impl_target.kind() == "type_identifier"
12717 && node_text(impl_target, &parsed.source) == receiver_type
12718 && rust_module_scope(impl_node) == declaration_scope
12719}
12720
12721fn select_type_match_candidate(
12722 reference: &NameMatchRef,
12723 candidates: &[NameMatchCandidate],
12724 receiver_type: &str,
12725) -> Option<NameMatchCandidate> {
12726 let candidates = candidates
12727 .iter()
12728 .filter(|candidate| candidate.node_id != reference.caller_node)
12729 .filter(|candidate| {
12730 type_candidate_matches(candidate, receiver_type, &reference.method_name)
12731 })
12732 .collect::<Vec<_>>();
12733 match candidates.as_slice() {
12734 [candidate] => Some((**candidate).clone()),
12735 _ => None,
12736 }
12737}
12738
12739fn type_candidate_matches(
12740 candidate: &NameMatchCandidate,
12741 receiver_type: &str,
12742 method_name: &str,
12743) -> bool {
12744 let normalized_type = receiver_type.replace('.', "::");
12745 let suffix = format!("{normalized_type}::{method_name}");
12746 candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
12747}
12748
12749fn select_name_match_candidate(
12750 reference: &NameMatchRef,
12751 candidates: &[NameMatchCandidate],
12752) -> Option<NameMatchCandidate> {
12753 let candidates = candidates
12754 .iter()
12755 .filter(|candidate| candidate.node_id != reference.caller_node)
12756 .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
12757 .collect::<Vec<_>>();
12758 match candidates.as_slice() {
12759 [] => None,
12760 [candidate] => Some((**candidate).clone()),
12761 _ => select_scored_name_match_candidate(reference, &candidates),
12762 }
12763}
12764
12765fn candidate_allowed_for_reference(
12766 reference: &NameMatchRef,
12767 candidate: &NameMatchCandidate,
12768) -> bool {
12769 if !reference.colon_dispatch {
12770 return true;
12771 }
12772
12773 candidate.kind == "method"
12774 && candidate
12775 .scoped_name
12776 .split("::")
12777 .any(|segment| segment == reference.receiver)
12778}
12779
12780fn select_scored_name_match_candidate(
12781 reference: &NameMatchRef,
12782 candidates: &[&NameMatchCandidate],
12783) -> Option<NameMatchCandidate> {
12784 let receiver_words = split_camel_case(&reference.receiver);
12785 if receiver_words.is_empty() {
12786 return None;
12787 }
12788
12789 let mut best: Option<(&NameMatchCandidate, f64)> = None;
12790 let mut tied_best = false;
12791 for candidate in candidates {
12792 let candidate_words = split_camel_case(&candidate.scoped_name);
12793 let overlap = receiver_words
12794 .iter()
12795 .filter(|receiver_word| {
12796 candidate_words
12797 .iter()
12798 .any(|candidate_word| candidate_word == *receiver_word)
12799 })
12800 .count() as f64;
12801 let score =
12802 overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
12803 match best {
12804 None => {
12805 best = Some((*candidate, score));
12806 tied_best = false;
12807 }
12808 Some((_, best_score)) if score > best_score => {
12809 best = Some((*candidate, score));
12810 tied_best = false;
12811 }
12812 Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
12813 tied_best = true;
12814 }
12815 _ => {}
12816 }
12817 }
12818
12819 let (candidate, score) = best?;
12820 if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
12821 Some(candidate.clone())
12822 } else {
12823 None
12824 }
12825}
12826
12827fn method_name_match_denylisted(method_name: &str) -> bool {
12828 matches!(
12829 method_name,
12830 "and_then"
12831 | "as_bytes"
12832 | "as_deref"
12833 | "as_mut"
12834 | "as_ref"
12835 | "as_str"
12836 | "borrow"
12837 | "borrow_mut"
12838 | "clear"
12839 | "clone"
12840 | "collect"
12841 | "contains"
12842 | "contains_key"
12843 | "count"
12844 | "dedup"
12845 | "default"
12846 | "drain"
12847 | "ends_with"
12848 | "entry"
12849 | "err"
12850 | "expect"
12851 | "extend"
12852 | "filter"
12853 | "filter_map"
12854 | "find"
12855 | "from"
12856 | "get"
12857 | "get_mut"
12858 | "insert"
12859 | "into"
12860 | "into_iter"
12861 | "is_empty"
12862 | "is_err"
12863 | "is_none"
12864 | "is_ok"
12865 | "is_some"
12866 | "iter"
12867 | "iter_mut"
12868 | "join"
12869 | "len"
12870 | "lock"
12871 | "map"
12872 | "map_err"
12873 | "max"
12874 | "min"
12875 | "new"
12876 | "next"
12877 | "ok"
12878 | "or_default"
12879 | "or_else"
12880 | "or_insert"
12881 | "or_insert_with"
12882 | "parse"
12883 | "pop"
12884 | "position"
12885 | "push"
12886 | "read"
12887 | "recv"
12888 | "remove"
12889 | "replace"
12890 | "retain"
12891 | "send"
12892 | "sort"
12893 | "sort_by"
12894 | "split"
12895 | "starts_with"
12896 | "sum"
12897 | "take"
12898 | "to_owned"
12899 | "to_string"
12900 | "trim"
12901 | "try_from"
12902 | "try_into"
12903 | "unwrap"
12904 | "unwrap_or"
12905 | "unwrap_or_default"
12906 | "unwrap_or_else"
12907 | "with_capacity"
12908 | "write"
12909 )
12910}
12911
12912fn split_camel_case(value: &str) -> Vec<String> {
12913 let chars = value.chars().collect::<Vec<_>>();
12914 let mut normalized = String::with_capacity(value.len() + 8);
12915 for (index, ch) in chars.iter().enumerate() {
12916 let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
12917 let next = chars.get(index + 1);
12918 let is_separator = ch.is_whitespace()
12919 || matches!(
12920 ch,
12921 '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
12922 );
12923 if is_separator {
12924 normalized.push(' ');
12925 continue;
12926 }
12927 let camel_boundary = previous.is_some_and(|prev| {
12928 (prev.is_lowercase() && ch.is_uppercase())
12929 || (prev.is_ascii_digit() && ch.is_alphabetic())
12930 || (prev.is_uppercase()
12931 && ch.is_uppercase()
12932 && next.is_some_and(|next| next.is_lowercase()))
12933 });
12934 if camel_boundary {
12935 normalized.push(' ');
12936 }
12937 normalized.push(*ch);
12938 }
12939
12940 normalized
12941 .split_whitespace()
12942 .filter(|word| word.len() > 1)
12943 .map(|word| word.to_ascii_lowercase())
12944 .collect()
12945}
12946
12947fn compute_path_proximity(left: &str, right: &str) -> f64 {
12948 let left_dirs = left
12949 .rsplit_once('/')
12950 .map(|(dir, _)| dir)
12951 .unwrap_or_default()
12952 .split('/')
12953 .filter(|part| !part.is_empty());
12954 let right_dirs = right
12955 .rsplit_once('/')
12956 .map(|(dir, _)| dir)
12957 .unwrap_or_default()
12958 .split('/')
12959 .filter(|part| !part.is_empty());
12960
12961 let shared = left_dirs
12962 .zip(right_dirs)
12963 .take_while(|(left, right)| left == right)
12964 .count();
12965 ((shared as f64) * 0.05).min(0.5)
12966}
12967
12968fn mark_backend_state(
12969 tx: &Transaction<'_>,
12970 project_root: &Path,
12971 rel_path: &str,
12972 content_hash: Option<&blake3::Hash>,
12973 status: &str,
12974) -> Result<()> {
12975 clear_backend_state_for_file(tx, project_root, rel_path)?;
12976 let hash = content_hash
12977 .map(|hash| hash_to_hex(*hash))
12978 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
12979 tx.execute(
12980 "INSERT OR REPLACE INTO backend_file_state(
12981 backend, workspace_root, file_path, content_hash, status, updated_at
12982 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
12983 params![
12984 BACKEND_TREESITTER,
12985 project_root.display().to_string(),
12986 rel_path,
12987 hash,
12988 status,
12989 unix_seconds_now(),
12990 ],
12991 )?;
12992 Ok(())
12993}
12994
12995fn clear_backend_state_for_file(
12996 tx: &Transaction<'_>,
12997 project_root: &Path,
12998 rel_path: &str,
12999) -> Result<()> {
13000 tx.execute(
13001 "DELETE FROM backend_file_state
13002 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
13003 params![
13004 BACKEND_TREESITTER,
13005 project_root.display().to_string(),
13006 rel_path
13007 ],
13008 )?;
13009 Ok(())
13010}
13011
13012fn clear_stale_backend_status_for_file(
13018 tx: &Transaction<'_>,
13019 project_root: &Path,
13020 rel_path: &str,
13021) -> Result<()> {
13022 tx.execute(
13023 "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
13024 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3 AND status = 'stale'",
13025 params![
13026 BACKEND_TREESITTER,
13027 project_root.display().to_string(),
13028 rel_path,
13029 unix_seconds_now(),
13030 ],
13031 )?;
13032 Ok(())
13033}
13034
13035fn load_file_row(conn: &Connection, rel_path: &str) -> Result<Option<FileRow>> {
13036 conn.query_row(
13037 "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
13038 params![rel_path],
13039 |row| {
13040 let hash_text: String = row.get(1)?;
13041 Ok(FileRow {
13042 surface_fingerprint: row.get(0)?,
13043 freshness: FileFreshness {
13044 content_hash: hash_from_hex(&hash_text)
13045 .unwrap_or_else(cache_freshness::zero_hash),
13046 mtime: ns_to_system_time(row.get::<_, i64>(2)?),
13047 size: row.get::<_, i64>(3)? as u64,
13048 },
13049 })
13050 },
13051 )
13052 .optional()
13053 .map_err(CallGraphStoreError::from)
13054}
13055
13056fn stored_node_ids_match_extract(
13057 tx: &Transaction<'_>,
13058 rel_path: &str,
13059 extract: &FileExtract,
13060) -> Result<bool> {
13061 let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
13062 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
13063 let mut stored = BTreeSet::new();
13064 for row in rows {
13065 stored.insert(row?);
13066 }
13067 let extracted = extract
13068 .nodes
13069 .iter()
13070 .map(|node| node.id.clone())
13071 .collect::<BTreeSet<_>>();
13072 Ok(stored == extracted)
13073}
13074
13075fn stored_extract_matches(
13079 tx: &Transaction<'_>,
13080 rel_path: &str,
13081 extract: &FileExtract,
13082 index: &ProjectIndex<'_>,
13083) -> Result<bool> {
13084 let stored_file = tx
13085 .query_row(
13086 "SELECT lang, surface_fingerprint FROM files WHERE path = ?1",
13087 params![rel_path],
13088 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
13089 )
13090 .optional()?;
13091 if stored_file
13092 != Some((
13093 lang_label(extract.lang).to_string(),
13094 extract.surface_fingerprint.clone(),
13095 ))
13096 {
13097 return Ok(false);
13098 }
13099
13100 let mut stored_nodes_stmt = tx.prepare(
13101 "SELECT id, file_path, name, scoped_name, kind, start_line, start_col,
13102 end_line, end_col, range_ordinal, signature, exported,
13103 is_default_export, is_type_like, is_callgraph_entry_point, provenance
13104 FROM nodes WHERE file_path = ?1",
13105 )?;
13106 let stored_nodes = stored_nodes_stmt
13107 .query_map(params![rel_path], |row| {
13108 Ok(serde_json::json!([
13109 row.get::<_, String>(0)?,
13110 row.get::<_, String>(1)?,
13111 row.get::<_, String>(2)?,
13112 row.get::<_, String>(3)?,
13113 row.get::<_, String>(4)?,
13114 row.get::<_, i64>(5)?,
13115 row.get::<_, i64>(6)?,
13116 row.get::<_, i64>(7)?,
13117 row.get::<_, i64>(8)?,
13118 row.get::<_, i64>(9)?,
13119 row.get::<_, Option<String>>(10)?,
13120 row.get::<_, i64>(11)?,
13121 row.get::<_, i64>(12)?,
13122 row.get::<_, i64>(13)?,
13123 row.get::<_, i64>(14)?,
13124 row.get::<_, String>(15)?,
13125 ])
13126 .to_string())
13127 })?
13128 .collect::<rusqlite::Result<Vec<_>>>()?;
13129 let expected_nodes = extract
13130 .nodes
13131 .iter()
13132 .map(|node| {
13133 serde_json::json!([
13134 node.id,
13135 node.file_path,
13136 node.name,
13137 node.scoped_name,
13138 node.kind,
13139 node.range.start_line,
13140 node.range.start_col,
13141 node.range.end_line,
13142 node.range.end_col,
13143 node.range_ordinal,
13144 node.signature,
13145 bool_int(node.exported),
13146 bool_int(node.is_default_export),
13147 bool_int(node.is_type_like),
13148 bool_int(node.is_callgraph_entry_point),
13149 PROVENANCE_TREESITTER,
13150 ])
13151 .to_string()
13152 })
13153 .collect::<Vec<_>>();
13154 let mut stored_nodes = stored_nodes;
13155 let mut expected_nodes = expected_nodes;
13156 stored_nodes.sort();
13157 expected_nodes.sort();
13158 if stored_nodes != expected_nodes {
13159 return Ok(false);
13160 }
13161
13162 let resolved_refs = extract
13163 .raw_refs
13164 .iter()
13165 .cloned()
13166 .map(|raw| resolve_ref(raw, index))
13167 .collect::<Result<Vec<_>>>()?;
13168 let mut stored_refs_stmt = tx.prepare(
13169 "SELECT ref_id, caller_node, caller_file, kind, short_name, full_ref,
13170 module_path, import_kind, local_name, requested_name, namespace_alias,
13171 wildcard, line, byte_start, byte_end, status, target_node,
13172 target_file, target_symbol, provenance
13173 FROM refs WHERE caller_file = ?1",
13174 )?;
13175 let stored_refs = stored_refs_stmt
13176 .query_map(params![rel_path], |row| {
13177 Ok(serde_json::json!([
13178 row.get::<_, String>(0)?,
13179 row.get::<_, Option<String>>(1)?,
13180 row.get::<_, String>(2)?,
13181 row.get::<_, String>(3)?,
13182 row.get::<_, Option<String>>(4)?,
13183 row.get::<_, Option<String>>(5)?,
13184 row.get::<_, Option<String>>(6)?,
13185 row.get::<_, Option<String>>(7)?,
13186 row.get::<_, Option<String>>(8)?,
13187 row.get::<_, Option<String>>(9)?,
13188 row.get::<_, Option<String>>(10)?,
13189 row.get::<_, i64>(11)?,
13190 row.get::<_, i64>(12)?,
13191 row.get::<_, i64>(13)?,
13192 row.get::<_, i64>(14)?,
13193 row.get::<_, String>(15)?,
13194 row.get::<_, Option<String>>(16)?,
13195 row.get::<_, Option<String>>(17)?,
13196 row.get::<_, Option<String>>(18)?,
13197 row.get::<_, String>(19)?,
13198 ])
13199 .to_string())
13200 })?
13201 .collect::<rusqlite::Result<Vec<_>>>()?;
13202 let expected_refs = resolved_refs
13203 .iter()
13204 .map(|resolved| {
13205 let raw = &resolved.raw;
13206 serde_json::json!([
13207 raw.ref_id,
13208 raw.caller_node,
13209 raw.caller_file,
13210 raw.kind,
13211 raw.short_name,
13212 raw.full_ref,
13213 raw.module_path,
13214 raw.import_kind,
13215 raw.local_name,
13216 raw.requested_name,
13217 raw.namespace_alias,
13218 bool_int(raw.wildcard),
13219 raw.line,
13220 raw.byte_start,
13221 raw.byte_end,
13222 resolved.status,
13223 resolved.target_node,
13224 resolved.target_file,
13225 resolved.target_symbol,
13226 PROVENANCE_TREESITTER,
13227 ])
13228 .to_string()
13229 })
13230 .collect::<Vec<_>>();
13231 let mut stored_refs = stored_refs;
13232 let mut expected_refs = expected_refs;
13233 stored_refs.sort();
13234 expected_refs.sort();
13235 if stored_refs != expected_refs {
13236 return Ok(false);
13237 }
13238
13239 let mut stored_edges_stmt = tx.prepare(
13240 "SELECT e.edge_id, e.ref_id, e.source_node, e.target_node,
13241 e.target_file, e.target_symbol, e.kind, e.line, e.provenance
13242 FROM edges e JOIN refs r ON r.ref_id = e.ref_id
13243 WHERE r.caller_file = ?1 AND e.provenance = ?2",
13244 )?;
13245 let stored_edges = stored_edges_stmt
13246 .query_map(params![rel_path, PROVENANCE_TREESITTER], |row| {
13247 Ok(serde_json::json!([
13248 row.get::<_, String>(0)?,
13249 row.get::<_, String>(1)?,
13250 row.get::<_, String>(2)?,
13251 row.get::<_, Option<String>>(3)?,
13252 row.get::<_, String>(4)?,
13253 row.get::<_, String>(5)?,
13254 row.get::<_, String>(6)?,
13255 row.get::<_, i64>(7)?,
13256 row.get::<_, String>(8)?,
13257 ])
13258 .to_string())
13259 })?
13260 .collect::<rusqlite::Result<Vec<_>>>()?;
13261 let expected_edges = resolved_refs
13262 .iter()
13263 .filter_map(|resolved| {
13264 resolved.edge.as_ref().map(|edge| {
13265 serde_json::json!([
13266 edge.edge_id,
13267 resolved.raw.ref_id,
13268 edge.source_node,
13269 edge.target_node,
13270 edge.target_file,
13271 edge.target_symbol,
13272 edge.kind,
13273 edge.line,
13274 PROVENANCE_TREESITTER,
13275 ])
13276 .to_string()
13277 })
13278 })
13279 .collect::<Vec<_>>();
13280 let mut stored_edges = stored_edges;
13281 let mut expected_edges = expected_edges;
13282 stored_edges.sort();
13283 expected_edges.sort();
13284 if stored_edges != expected_edges {
13285 return Ok(false);
13286 }
13287
13288 let mut stored_dependencies_stmt =
13289 tx.prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1")?;
13290 let stored_dependencies = stored_dependencies_stmt
13291 .query_map(params![rel_path], |row| row.get::<_, String>(0))?
13292 .collect::<rusqlite::Result<BTreeSet<_>>>()?;
13293 let expected_dependencies = extract
13294 .raw_refs
13295 .iter()
13296 .flat_map(|raw| raw.dependencies.iter().cloned())
13297 .collect::<BTreeSet<_>>();
13298 if stored_dependencies != expected_dependencies {
13299 return Ok(false);
13300 }
13301
13302 let mut stored_hints_stmt = tx.prepare(
13303 "SELECT id, method_name, caller_node, file, line, byte_start, byte_end, provenance
13304 FROM dispatch_hints WHERE file = ?1",
13305 )?;
13306 let stored_hints = stored_hints_stmt
13307 .query_map(params![rel_path], |row| {
13308 Ok(serde_json::json!([
13309 row.get::<_, String>(0)?,
13310 row.get::<_, String>(1)?,
13311 row.get::<_, String>(2)?,
13312 row.get::<_, String>(3)?,
13313 row.get::<_, i64>(4)?,
13314 row.get::<_, i64>(5)?,
13315 row.get::<_, i64>(6)?,
13316 row.get::<_, String>(7)?,
13317 ])
13318 .to_string())
13319 })?
13320 .collect::<rusqlite::Result<Vec<_>>>()?;
13321 let expected_hints = extract
13322 .dispatch_hints
13323 .iter()
13324 .map(|hint| {
13325 serde_json::json!([
13326 hint.id,
13327 hint.method_name,
13328 hint.caller_node,
13329 hint.file,
13330 hint.line,
13331 hint.byte_start,
13332 hint.byte_end,
13333 PROVENANCE_TREESITTER,
13334 ])
13335 .to_string()
13336 })
13337 .collect::<Vec<_>>();
13338 let mut stored_hints = stored_hints;
13339 let mut expected_hints = expected_hints;
13340 stored_hints.sort();
13341 expected_hints.sort();
13342 Ok(stored_hints == expected_hints)
13343}
13344
13345fn update_file_fresh_metadata(
13346 tx: &Transaction<'_>,
13347 project_root: &Path,
13348 rel_path: &str,
13349 hash: &blake3::Hash,
13350 mtime: SystemTime,
13351 size: u64,
13352) -> Result<()> {
13353 tx.execute(
13354 "UPDATE files SET content_hash = ?2, mtime_ns = ?3, size = ?4, indexed_at = ?5
13355 WHERE path = ?1",
13356 params![
13357 rel_path,
13358 hash_to_hex(*hash),
13359 system_time_to_ns(mtime),
13360 size as i64,
13361 unix_seconds_now()
13362 ],
13363 )?;
13364 tx.execute(
13365 "UPDATE backend_file_state SET content_hash = ?3, status = 'fresh', updated_at = ?5
13366 WHERE backend = ?1 AND file_path = ?2 AND workspace_root = ?4",
13367 params![
13368 BACKEND_TREESITTER,
13369 rel_path,
13370 hash_to_hex(*hash),
13371 project_root.display().to_string(),
13372 unix_seconds_now(),
13373 ],
13374 )?;
13375 Ok(())
13376}
13377
13378#[derive(Debug, Clone, PartialEq, Eq)]
13379struct DependentRefSelection {
13380 ref_id: String,
13381 caller_file: String,
13382}
13383
13384fn ref_ids_depending_on(
13385 conn: &Connection,
13386 project_root: &Path,
13387 rel_path: &str,
13388) -> Result<Vec<DependentRefSelection>> {
13389 let mut stmt = conn.prepare(
13390 "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
13391 FROM refs r
13392 WHERE r.caller_file IN (
13393 SELECT file_path FROM file_dependencies WHERE dep_file = ?1
13394 )
13395 OR r.target_file = ?1
13396 ORDER BY r.ref_id",
13397 )?;
13398 let rows = stmt.query_map(params![rel_path], |row| {
13399 Ok(RefDependencyRow {
13400 ref_id: row.get(0)?,
13401 kind: row.get(1)?,
13402 caller_file: row.get(2)?,
13403 module_path: row.get(3)?,
13404 target_file: row.get(4)?,
13405 })
13406 })?;
13407 let mut ids = Vec::new();
13408 for row in rows {
13409 let row = row?;
13410 if ref_dependency_row_depends_on(project_root, &row, rel_path) {
13411 ids.push(DependentRefSelection {
13412 ref_id: row.ref_id,
13413 caller_file: row.caller_file,
13414 });
13415 }
13416 }
13417 Ok(ids)
13418}
13419
13420fn record_dependent_refs(
13421 selected_ref_ids: &mut BTreeSet<String>,
13422 selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
13423 dependent_refs: Vec<DependentRefSelection>,
13424) {
13425 for dependent_ref in dependent_refs {
13426 let DependentRefSelection {
13427 ref_id,
13428 caller_file,
13429 } = dependent_ref;
13430 selected_ref_ids.insert(ref_id.clone());
13431 selected_refs_by_caller
13432 .entry(caller_file)
13433 .or_default()
13434 .insert(ref_id);
13435 }
13436}
13437
13438#[cfg(test)]
13439fn refs_by_caller_for_ref_ids(
13440 tx: &Transaction<'_>,
13441 ref_ids: &BTreeSet<String>,
13442) -> Result<BTreeMap<String, BTreeSet<String>>> {
13443 let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
13444 let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
13445 for ref_id in ref_ids {
13446 if let Some(caller) = stmt
13447 .query_row(params![ref_id], |row| row.get::<_, String>(0))
13448 .optional()?
13449 {
13450 by_caller.entry(caller).or_default().insert(ref_id.clone());
13451 }
13452 }
13453 Ok(by_caller)
13454}
13455
13456fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
13457 tx.execute(
13458 "DELETE FROM file_dependencies WHERE file_path = ?1",
13459 params![rel_path],
13460 )?;
13461 delete_refs_for_caller(tx, rel_path)?;
13462 tx.execute(
13463 "DELETE FROM dispatch_hints WHERE file = ?1",
13464 params![rel_path],
13465 )?;
13466 tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
13467 tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
13468 Ok(())
13469}
13470
13471fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
13472 let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
13473 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
13474 let mut ids = BTreeSet::new();
13475 for row in rows {
13476 ids.insert(row?);
13477 }
13478 delete_ref_ids(tx, &ids)
13479}
13480
13481fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
13482 let mut delete_edges = tx.prepare("DELETE FROM edges WHERE ref_id = ?1")?;
13483 let mut delete_refs = tx.prepare("DELETE FROM refs WHERE ref_id = ?1")?;
13484 for ref_id in ref_ids {
13485 delete_edges.execute(params![ref_id])?;
13486 delete_refs.execute(params![ref_id])?;
13487 }
13488 Ok(())
13489}
13490
13491fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
13492 let mut stmt = conn.prepare(
13493 "SELECT source.file_path, source.scoped_name, edges.target_file,
13494 edges.target_symbol, edges.kind, edges.line
13495 FROM edges
13496 JOIN nodes AS source ON source.id = edges.source_node
13497 ORDER BY source.file_path, source.scoped_name, edges.target_file,
13498 edges.target_symbol, edges.kind, edges.line",
13499 )?;
13500 let rows = stmt.query_map([], |row| {
13501 Ok(StoredEdge {
13502 source_file: row.get(0)?,
13503 source_symbol: row.get(1)?,
13504 target_file: row.get(2)?,
13505 target_symbol: row.get(3)?,
13506 kind: row.get(4)?,
13507 line: row.get::<_, i64>(5)? as u32,
13508 })
13509 })?;
13510 let mut edges = BTreeSet::new();
13511 for row in rows {
13512 edges.insert(row?);
13513 }
13514 Ok(edges)
13515}
13516
13517fn module_target_from_dependencies(
13518 project_root: &Path,
13519 dependencies: &BTreeSet<String>,
13520) -> Option<String> {
13521 dependencies.iter().find_map(|dep| {
13522 let path = project_root.join(dep);
13523 if path.is_file() {
13524 Some(relative_path(project_root, &canonicalize_path(&path)))
13525 } else {
13526 None
13527 }
13528 })
13529}
13530
13531fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
13532 let mut named = HashMap::new();
13533 if let Some(full_ref) = &raw_ref.full_ref {
13534 named = parse_reexport_names(full_ref);
13535 }
13536 ReexportIndex {
13537 target_file,
13538 named,
13539 wildcard: raw_ref.wildcard,
13540 }
13541}
13542
13543fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
13544 let mut names = HashMap::new();
13545 let Some(open) = statement.find('{') else {
13546 return names;
13547 };
13548 let Some(close) = statement[open + 1..]
13549 .find('}')
13550 .map(|offset| open + 1 + offset)
13551 else {
13552 return names;
13553 };
13554 for spec in statement[open + 1..close].split(',') {
13555 let spec = spec.trim();
13556 if spec.is_empty() {
13557 continue;
13558 }
13559 if let Some((source, local)) = spec.split_once(" as ") {
13560 names.insert(local.trim().to_string(), source.trim().to_string());
13561 } else {
13562 names.insert(spec.to_string(), spec.to_string());
13563 }
13564 }
13565 names
13566}
13567
13568#[derive(Debug)]
13569struct RefDependencyRow {
13570 ref_id: String,
13571 kind: String,
13572 caller_file: String,
13573 module_path: Option<String>,
13574 target_file: Option<String>,
13575}
13576
13577fn ref_dependency_row_depends_on(
13578 project_root: &Path,
13579 row: &RefDependencyRow,
13580 rel_path: &str,
13581) -> bool {
13582 if row.target_file.as_deref() == Some(rel_path) {
13583 return true;
13584 }
13585
13586 match row.kind.as_str() {
13587 "call" => true,
13588 "import" | "reexport" => row
13589 .module_path
13590 .as_deref()
13591 .map(|module_path| {
13592 module_dependencies_for_ref(project_root, &row.caller_file, module_path)
13593 .contains(rel_path)
13594 })
13595 .unwrap_or(false),
13596 "export_alias" => false,
13597 _ => false,
13598 }
13599}
13600
13601fn module_dependencies_for_ref(
13602 project_root: &Path,
13603 caller_file: &str,
13604 module_path: &str,
13605) -> BTreeSet<String> {
13606 module_dependencies(project_root, &project_root.join(caller_file), module_path)
13607}
13608
13609fn import_dependencies(
13610 project_root: &Path,
13611 abs_path: &Path,
13612 imports: &[ImportStatement],
13613) -> BTreeSet<String> {
13614 let mut deps = BTreeSet::new();
13615 for import in imports {
13616 deps.extend(module_dependencies(
13617 project_root,
13618 abs_path,
13619 &import.module_path,
13620 ));
13621 }
13622 deps
13623}
13624
13625fn module_dependencies(
13626 project_root: &Path,
13627 abs_path: &Path,
13628 module_path: &str,
13629) -> BTreeSet<String> {
13630 let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
13631 let caller_dir = abs_path.parent().unwrap_or(project_root);
13632 if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
13633 deps.insert(relative_path(project_root, &resolved));
13634 }
13635 if module_path.starts_with('.') {
13636 let base = caller_dir.join(module_path);
13637 for candidate in relative_module_candidates(&base) {
13638 deps.insert(relative_path(project_root, &candidate));
13639 }
13640 }
13641 deps
13642}
13643
13644fn rust_module_dependencies(
13645 project_root: &Path,
13646 abs_path: &Path,
13647 module_path: &str,
13648) -> BTreeSet<String> {
13649 let mut deps = BTreeSet::new();
13650 let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
13651 let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
13652 return deps;
13653 };
13654 let src_prefix = rust_src_prefix(&rel_path);
13655 rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
13656 if !path_segments.is_empty() {
13657 rust_push_module_dependency_candidate(
13658 project_root,
13659 &mut deps,
13660 &src_prefix,
13661 &path_segments[..path_segments.len() - 1],
13662 );
13663 }
13664 deps
13665}
13666
13667fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
13668 let path = rust_module_path_without_alias_or_use_list(module_path);
13669 let segments = path
13670 .split("::")
13671 .map(str::trim)
13672 .filter(|segment| !segment.is_empty())
13673 .collect::<Vec<_>>();
13674 if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
13675 return None;
13676 }
13677 rust_resolve_segments(rel_path, &segments)
13678}
13679
13680fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
13681 let path = module_path
13682 .trim()
13683 .trim_end_matches(';')
13684 .split_once(" as ")
13685 .map(|(left, _)| left.trim())
13686 .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
13687 path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
13688}
13689
13690fn rust_push_module_dependency_candidate(
13691 project_root: &Path,
13692 deps: &mut BTreeSet<String>,
13693 src_prefix: &str,
13694 segments: &[String],
13695) {
13696 let candidates = if segments.is_empty() {
13697 vec![
13698 format!("{src_prefix}/lib.rs"),
13699 format!("{src_prefix}/main.rs"),
13700 ]
13701 } else {
13702 vec![
13703 format!("{}/{}.rs", src_prefix, segments.join("/")),
13704 format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
13705 ]
13706 };
13707 for candidate in candidates {
13708 if project_root.join(&candidate).is_file() {
13709 deps.insert(candidate);
13710 }
13711 }
13712}
13713
13714fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
13715 let mut candidates = Vec::new();
13716 if base.extension().is_some() {
13717 candidates.push(base.to_path_buf());
13718 return candidates;
13719 }
13720 for ext in JS_TS_EXTENSIONS {
13721 candidates.push(base.with_extension(ext));
13722 }
13723 for ext in JS_TS_EXTENSIONS {
13724 candidates.push(base.join(format!("index.{ext}")));
13725 }
13726 candidates
13727}
13728
13729fn import_local_names(import: &ImportStatement) -> Vec<String> {
13730 let mut names = Vec::new();
13731 if let Some(default) = &import.default_import {
13732 names.push(default.clone());
13733 }
13734 if let Some(namespace) = &import.namespace_import {
13735 names.push(namespace.clone());
13736 }
13737 for name in &import.names {
13738 names.push(crate::imports::specifier_local_name(name).to_string());
13739 }
13740 names
13741}
13742
13743fn import_requested_names(import: &ImportStatement) -> Vec<String> {
13744 import
13745 .names
13746 .iter()
13747 .map(|name| crate::imports::specifier_imported_name(name).to_string())
13748 .collect()
13749}
13750
13751fn import_is_wildcard(import: &ImportStatement) -> bool {
13752 import.namespace_import.is_some() || import.raw_text.contains('*')
13753}
13754
13755fn namespace_alias(full_ref: &str) -> Option<String> {
13756 full_ref
13757 .split_once('.')
13758 .map(|(namespace, _)| namespace.to_string())
13759}
13760
13761fn import_kind_label(kind: ImportKind) -> &'static str {
13762 match kind {
13763 ImportKind::Value => "value",
13764 ImportKind::Type => "type",
13765 ImportKind::SideEffect => "side_effect",
13766 }
13767}
13768
13769fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
13770 match kind {
13771 SymbolKind::Function => "function",
13772 SymbolKind::Class => "class",
13773 SymbolKind::Method => "method",
13774 SymbolKind::Struct => "struct",
13775 SymbolKind::Interface => "interface",
13776 SymbolKind::Enum => "enum",
13777 SymbolKind::TypeAlias => "type_alias",
13778 SymbolKind::Variable => "variable",
13779 SymbolKind::Heading => "heading",
13780 SymbolKind::FileSummary => "file_summary",
13781 }
13782}
13783
13784fn is_type_like(kind: &SymbolKind) -> bool {
13785 matches!(
13786 kind,
13787 SymbolKind::Class
13788 | SymbolKind::Struct
13789 | SymbolKind::Interface
13790 | SymbolKind::Enum
13791 | SymbolKind::TypeAlias
13792 )
13793}
13794
13795fn lang_label(lang: LangId) -> &'static str {
13796 match lang {
13797 LangId::TypeScript => "typescript",
13798 LangId::Tsx => "tsx",
13799 LangId::JavaScript => "javascript",
13800 LangId::Python => "python",
13801 LangId::Rust => "rust",
13802 LangId::Go => "go",
13803 LangId::C => "c",
13804 LangId::Cpp => "cpp",
13805 LangId::Zig => "zig",
13806 LangId::CSharp => "csharp",
13807 LangId::Bash => "bash",
13808 LangId::Html => "html",
13809 LangId::Markdown => "markdown",
13810 LangId::Solidity => "solidity",
13811 LangId::Scss => "scss",
13812 LangId::Vue => "vue",
13813 LangId::Json => "json",
13814 LangId::Scala => "scala",
13815 LangId::Java => "java",
13816 LangId::Ruby => "ruby",
13817 LangId::Kotlin => "kotlin",
13818 LangId::Swift => "swift",
13819 LangId::Php => "php",
13820 LangId::Lua => "lua",
13821 LangId::Perl => "perl",
13822 LangId::Yaml => "yaml",
13823 LangId::Pascal => "pascal",
13824 LangId::R => "r",
13825 LangId::Groovy => "groovy",
13826 LangId::ObjC => "objc",
13827 }
13828}
13829
13830fn lang_from_label(label: &str) -> Option<LangId> {
13831 match label {
13832 "typescript" => Some(LangId::TypeScript),
13833 "tsx" => Some(LangId::Tsx),
13834 "javascript" => Some(LangId::JavaScript),
13835 "python" => Some(LangId::Python),
13836 "rust" => Some(LangId::Rust),
13837 "go" => Some(LangId::Go),
13838 "c" => Some(LangId::C),
13839 "cpp" => Some(LangId::Cpp),
13840 "zig" => Some(LangId::Zig),
13841 "csharp" => Some(LangId::CSharp),
13842 "bash" => Some(LangId::Bash),
13843 "html" => Some(LangId::Html),
13844 "markdown" => Some(LangId::Markdown),
13845 "solidity" => Some(LangId::Solidity),
13846 "scss" => Some(LangId::Scss),
13847 "vue" => Some(LangId::Vue),
13848 "json" => Some(LangId::Json),
13849 "scala" => Some(LangId::Scala),
13850 "java" => Some(LangId::Java),
13851 "ruby" => Some(LangId::Ruby),
13852 "kotlin" => Some(LangId::Kotlin),
13853 "swift" => Some(LangId::Swift),
13854 "php" => Some(LangId::Php),
13855 "lua" => Some(LangId::Lua),
13856 "perl" => Some(LangId::Perl),
13857 "yaml" => Some(LangId::Yaml),
13858 "pascal" => Some(LangId::Pascal),
13859 "r" => Some(LangId::R),
13860 "groovy" => Some(LangId::Groovy),
13861 "objc" => Some(LangId::ObjC),
13862 _ => None,
13863 }
13864}
13865
13866fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
13867 let mut normalized = if files.is_empty() {
13868 callgraph::walk_project_files(project_root).collect::<Vec<_>>()
13869 } else {
13870 files
13871 .iter()
13872 .map(|path| normalize_file_path(project_root, path))
13873 .collect::<Result<Vec<_>>>()?
13874 };
13875 normalized.sort();
13876 normalized.dedup();
13877 Ok(normalized)
13878}
13879
13880fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
13881 let full_path = if path.is_relative() {
13882 project_root.join(path)
13883 } else {
13884 path.to_path_buf()
13885 };
13886 Ok(canonicalize_path(&full_path))
13887}
13888
13889fn normalize_project_file_path(project_root: &Path, path: &Path) -> Result<(PathBuf, String)> {
13893 let abs_path = normalize_file_path(project_root, path)?;
13894 let rel_path = relative_path(project_root, &abs_path);
13895 if Path::new(&rel_path).is_absolute() {
13896 return Err(CallGraphStoreError::PathIdentityMismatch {
13897 path: path.to_path_buf(),
13898 project_root: project_root.to_path_buf(),
13899 });
13900 }
13901 Ok((abs_path, rel_path))
13902}
13903
13904fn canonicalize_path(path: &Path) -> PathBuf {
13908 if let Ok(canonical) = std::fs::canonicalize(path) {
13909 return canonical;
13910 }
13911
13912 let mut resolved = PathBuf::new();
13913 let mut missing = Vec::new();
13914 for component in path.components() {
13915 match component {
13916 std::path::Component::Prefix(_) | std::path::Component::RootDir => {
13917 resolved.push(component.as_os_str());
13918 if let Ok(canonical) = std::fs::canonicalize(&resolved) {
13919 resolved = canonical;
13920 }
13921 }
13922 std::path::Component::CurDir => {}
13923 std::path::Component::ParentDir => {
13924 if missing.pop().is_none() {
13925 if !resolved.as_os_str().is_empty() && !resolved.is_dir() {
13926 return path.to_path_buf();
13927 }
13928 resolved.pop();
13929 }
13930 }
13931 std::path::Component::Normal(name) => {
13932 if missing.is_empty() {
13933 let candidate = resolved.join(name);
13934 match std::fs::canonicalize(&candidate) {
13935 Ok(canonical) => resolved = canonical,
13936 Err(_) => match std::fs::symlink_metadata(&candidate) {
13937 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
13938 missing.push(name.to_owned());
13939 }
13940 _ => return path.to_path_buf(),
13941 },
13942 }
13943 } else {
13944 missing.push(name.to_owned());
13945 }
13946 }
13947 }
13948 }
13949 resolved.extend(missing);
13950 resolved
13951}
13952
13953fn relative_path(project_root: &Path, path: &Path) -> String {
13954 if let Ok(stripped) = path.strip_prefix(project_root) {
13955 return stripped.to_string_lossy().replace('\\', "/");
13956 }
13957 let canon_root = canonicalize_path(project_root);
13958 let canon_path = canonicalize_path(path);
13959 if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
13960 return stripped.to_string_lossy().replace('\\', "/");
13961 }
13962 canon_path.to_string_lossy().replace('\\', "/")
13963}
13964
13965fn unqualified_name(scoped: &str) -> &str {
13966 if scoped == TOP_LEVEL_SYMBOL {
13967 return scoped;
13968 }
13969 scoped
13970 .rsplit("::")
13971 .next()
13972 .unwrap_or(scoped)
13973 .rsplit('.')
13974 .next()
13975 .unwrap_or(scoped)
13976 .rsplit('#')
13977 .next()
13978 .unwrap_or(scoped)
13979}
13980
13981fn ref_id(parts: &[&str]) -> String {
13982 let joined = parts.join("\0");
13983 hash_to_hex(blake3::hash(joined.as_bytes()))
13984}
13985
13986fn callgraph_corpus_fingerprint(project_root: &Path) -> Result<String> {
13987 let mut fingerprint = CorpusFingerprint::default();
13988 for path in callgraph::walk_project_files(project_root) {
13989 fingerprint.add_path(project_root, &path);
13990 }
13991 Ok(fingerprint.finish(project_root))
13992}
13993
13994fn corpus_fingerprint_for(project_root: &Path, files: &[PathBuf]) -> Result<String> {
13998 if files.is_empty() {
13999 return callgraph_corpus_fingerprint(project_root);
14000 }
14001 let mut fingerprint = CorpusFingerprint::default();
14002 for path in files {
14003 fingerprint.add_path(project_root, path);
14004 }
14005 Ok(fingerprint.finish(project_root))
14006}
14007
14008#[derive(Default)]
14009struct CorpusFingerprint {
14010 xor: [u8; 32],
14011 sums: [u64; 4],
14012 files: u64,
14013}
14014
14015impl CorpusFingerprint {
14016 fn add_path(&mut self, project_root: &Path, path: &Path) {
14017 let mut record = blake3::Hasher::new();
14018 record.update(relative_path(project_root, path).as_bytes());
14019 record.update(&[0]);
14020 match hash_file_bounded(path) {
14021 Ok(content_hash) => record.update(content_hash.as_bytes()),
14022 Err(error) => record.update(format!("missing:{error}").as_bytes()),
14025 };
14026 record.update(&[0]);
14027 let record = record.finalize();
14028 for (index, byte) in record.as_bytes().iter().copied().enumerate() {
14029 self.xor[index] ^= byte;
14030 }
14031 for (index, chunk) in record.as_bytes().chunks_exact(8).enumerate() {
14032 let value = u64::from_le_bytes(chunk.try_into().expect("eight-byte digest chunk"));
14033 self.sums[index] = self.sums[index].wrapping_add(value);
14034 }
14035 self.files = self.files.saturating_add(1);
14036 }
14037
14038 fn finish(self, project_root: &Path) -> String {
14039 let mut hasher = blake3::Hasher::new();
14042 hasher.update(b"callgraph-corpus-fingerprint-v2\0");
14043 hasher.update(&self.files.to_le_bytes());
14044 hasher.update(&self.xor);
14045 for sum in self.sums {
14046 hasher.update(&sum.to_le_bytes());
14047 }
14048 let ignore_rules = project_root.join(".gitignore");
14049 if let Ok(contents) = std::fs::read(ignore_rules) {
14050 hasher.update(b".gitignore\0");
14051 hasher.update(blake3::hash(&contents).as_bytes());
14052 }
14053 hash_to_hex(hasher.finalize())
14054 }
14055}
14056
14057fn hash_file_bounded(path: &Path) -> std::io::Result<blake3::Hash> {
14058 let mut file = std::fs::File::open(path)?;
14059 let mut hasher = blake3::Hasher::new();
14060 let mut buffer = [0u8; 64 * 1024];
14061 loop {
14062 let read = file.read(&mut buffer)?;
14063 if read == 0 {
14064 break;
14065 }
14066 hasher.update(&buffer[..read]);
14067 }
14068 Ok(hasher.finalize())
14069}
14070
14071#[cfg(test)]
14072pub(crate) fn callgraph_corpus_fingerprint_for_test(
14073 project_root: &Path,
14074 _files: &[PathBuf],
14075) -> Result<String> {
14076 callgraph_corpus_fingerprint(project_root)
14080}
14081
14082fn hash_to_hex(hash: blake3::Hash) -> String {
14083 hash.to_hex().to_string()
14084}
14085
14086fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
14087 let bytes = hex_to_bytes(value)?;
14088 Some(blake3::Hash::from_bytes(bytes))
14089}
14090
14091fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
14092 if value.len() != 64 {
14093 return None;
14094 }
14095 let mut bytes = [0u8; 32];
14096 for (index, slot) in bytes.iter_mut().enumerate() {
14097 let start = index * 2;
14098 let end = start + 2;
14099 *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
14100 }
14101 Some(bytes)
14102}
14103
14104#[derive(Debug, Clone)]
14105struct LineIndex {
14106 newline_offsets: Vec<usize>,
14107 source_len: usize,
14108}
14109
14110impl LineIndex {
14111 fn new(source: &str) -> Self {
14112 Self {
14113 newline_offsets: source
14114 .bytes()
14115 .enumerate()
14116 .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
14117 .collect(),
14118 source_len: source.len(),
14119 }
14120 }
14121
14122 fn byte_to_line(&self, byte_offset: usize) -> u32 {
14123 let byte_offset = byte_offset.min(self.source_len);
14124 self.newline_offsets
14125 .partition_point(|offset| *offset < byte_offset) as u32
14126 + 1
14127 }
14128}
14129
14130fn empty_to_none(value: String) -> Option<String> {
14131 if value.is_empty() {
14132 None
14133 } else {
14134 Some(value)
14135 }
14136}
14137
14138fn bool_int(value: bool) -> i64 {
14139 if value {
14140 1
14141 } else {
14142 0
14143 }
14144}
14145
14146fn system_time_to_ns(time: SystemTime) -> i64 {
14147 time.duration_since(UNIX_EPOCH)
14148 .unwrap_or_default()
14149 .as_nanos()
14150 .min(i64::MAX as u128) as i64
14151}
14152
14153fn ns_to_system_time(value: i64) -> SystemTime {
14154 UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
14155}
14156
14157pub(crate) fn unix_millis_now() -> u64 {
14158 SystemTime::now()
14159 .duration_since(UNIX_EPOCH)
14160 .unwrap_or_default()
14161 .as_millis()
14162 .min(u128::from(u64::MAX)) as u64
14163}
14164
14165fn unix_seconds_now() -> i64 {
14166 SystemTime::now()
14167 .duration_since(UNIX_EPOCH)
14168 .unwrap_or_default()
14169 .as_secs() as i64
14170}
14171
14172#[cfg(test)]
14177pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
14178
14179#[cfg(test)]
14180mod refresh_worker_tests {
14181 use super::*;
14182 use std::fs;
14183 use tempfile::tempdir;
14184
14185 fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
14186 let temp = tempdir().unwrap();
14187 let root = temp.path().join("root");
14188 fs::create_dir_all(&root).unwrap();
14189 let artifact_key = crate::search_index::artifact_cache_key(&root);
14190 crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
14191 let callgraph_dir = temp
14192 .path()
14193 .join("storage")
14194 .join("callgraph")
14195 .join(artifact_key);
14196 let source = root.join("main.rs");
14197 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
14198 let (store, _) = CallGraphStore::cold_build_with_lease(
14199 callgraph_dir.clone(),
14200 root.clone(),
14201 std::slice::from_ref(&source),
14202 )
14203 .unwrap();
14204 drop(store);
14205 (temp, root, callgraph_dir, source)
14206 }
14207
14208 fn pending_paths() -> PendingCallGraphStorePaths {
14209 Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
14210 }
14211
14212 fn wait_for_refresh_calls(root: &Path, expected: usize) {
14213 let deadline = Instant::now() + Duration::from_secs(12);
14214 while callgraph_refresh_worker_test_counts(root).0 < expected {
14215 assert!(
14216 Instant::now() < deadline,
14217 "timed out waiting for {expected} callgraph refresh worker call(s)"
14218 );
14219 std::thread::sleep(Duration::from_millis(5));
14220 }
14221 }
14222
14223 fn wait_for_refresh_worker_idle() {
14224 let deadline = Instant::now() + Duration::from_secs(12);
14225 loop {
14226 let worker = CALLGRAPH_REFRESH_WORKER
14227 .get_or_init(|| Mutex::new(None))
14228 .lock()
14229 .expect("callgraph refresh worker mutex poisoned")
14230 .clone();
14231 let idle = worker.is_none_or(|worker| {
14232 let queue = worker
14233 .shared
14234 .queue
14235 .lock()
14236 .expect("callgraph refresh queue mutex poisoned");
14237 queue.active.is_none() && queue.order.is_empty()
14238 });
14239 if idle {
14240 return;
14241 }
14242 assert!(
14243 Instant::now() < deadline,
14244 "timed out waiting for callgraph refresh worker to become idle"
14245 );
14246 std::thread::sleep(Duration::from_millis(5));
14247 }
14248 }
14249
14250 fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
14251 let temp = tempdir().unwrap();
14252 let root = temp.path().join("workspace");
14253 fs::create_dir_all(root.join("app/src")).unwrap();
14254 let artifact_key = crate::search_index::artifact_cache_key(&root);
14255 crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
14256 let callgraph_dir = temp
14257 .path()
14258 .join("storage")
14259 .join("callgraph")
14260 .join(artifact_key);
14261 fs::write(
14262 root.join("Cargo.toml"),
14263 "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
14264 )
14265 .unwrap();
14266 fs::write(
14267 root.join("app/Cargo.toml"),
14268 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
14269 )
14270 .unwrap();
14271 let caller = root.join("app/src/lib.rs");
14272 fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
14273 let (store, _) = CallGraphStore::cold_build_with_lease(
14274 callgraph_dir.clone(),
14275 root.clone(),
14276 std::slice::from_ref(&caller),
14277 )
14278 .unwrap();
14279 drop(store);
14280 (temp, root, callgraph_dir, caller)
14281 }
14282
14283 #[test]
14284 fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
14285 let _guard = REFRESH_WORKER_TEST_LOCK
14286 .lock()
14287 .unwrap_or_else(std::sync::PoisonError::into_inner);
14288 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14289 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
14290 reset_workspace_crate_prefix_build_count(&root);
14291 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14292
14293 for revision in ["first", "second"] {
14294 fs::write(
14295 &caller,
14296 format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
14297 )
14298 .unwrap();
14299 enqueue_callgraph_store_refresh(
14300 callgraph_dir.clone(),
14301 root.clone(),
14302 vec![caller.clone()],
14303 pending_paths(),
14304 );
14305 wait_for_refresh_worker_idle();
14306 }
14307
14308 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
14309 assert!(flush_callgraph_store_refreshes_with_budget(
14310 Duration::from_secs(5)
14311 ));
14312 clear_callgraph_refresh_worker_test_seam(&root);
14313 }
14314
14315 #[test]
14316 fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
14317 let _guard = REFRESH_WORKER_TEST_LOCK
14318 .lock()
14319 .unwrap_or_else(std::sync::PoisonError::into_inner);
14320 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14321 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
14322 reset_workspace_crate_prefix_build_count(&root);
14323 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14324
14325 fs::write(
14326 &caller,
14327 "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
14328 )
14329 .unwrap();
14330 enqueue_callgraph_store_refresh(
14331 callgraph_dir.clone(),
14332 root.clone(),
14333 vec![caller.clone()],
14334 pending_paths(),
14335 );
14336 wait_for_refresh_worker_idle();
14337 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
14338
14339 let added_manifest = root.join("added/Cargo.toml");
14340 let added_source = root.join("added/src/lib.rs");
14341 fs::create_dir_all(added_source.parent().unwrap()).unwrap();
14342 fs::write(
14343 root.join("Cargo.toml"),
14344 "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
14345 )
14346 .unwrap();
14347 fs::write(
14348 &added_manifest,
14349 "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
14350 )
14351 .unwrap();
14352 fs::write(&added_source, "pub fn target() {}\n").unwrap();
14353 fs::write(
14354 &caller,
14355 "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
14356 )
14357 .unwrap();
14358
14359 enqueue_callgraph_store_refresh(
14360 callgraph_dir.clone(),
14361 root.clone(),
14362 vec![
14363 root.join("Cargo.toml"),
14364 added_manifest,
14365 added_source,
14366 caller,
14367 ],
14368 pending_paths(),
14369 );
14370 assert!(flush_callgraph_store_refreshes_with_budget(
14371 Duration::from_secs(12)
14372 ));
14373
14374 assert_eq!(workspace_crate_prefix_build_count(&root), 2);
14378 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
14379 .unwrap()
14380 .expect("refreshed workspace store");
14381 let tree = store
14382 .call_tree(Path::new("app/src/lib.rs"), "run", 1)
14383 .unwrap();
14384 assert_eq!(tree.children.len(), 1);
14385 assert_eq!(tree.children[0].file, "added/src/lib.rs");
14386 assert_eq!(tree.children[0].name, "target");
14387 assert!(tree.children[0].resolved);
14388 clear_callgraph_refresh_worker_test_seam(&root);
14389 }
14390
14391 fn linked_worktree_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, PathBuf) {
14392 let temp = tempdir().unwrap();
14393 let main = temp.path().join("main");
14394 let worktree = temp.path().join("worktree");
14395 fs::create_dir_all(&main).unwrap();
14396 let mut git = std::process::Command::new("git");
14397 assert!(
14398 crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
14399 .status()
14400 .unwrap()
14401 .success()
14402 );
14403 fs::write(main.join("lib.rs"), "pub fn marker() {}\n").unwrap();
14404 for args in [
14405 vec![
14406 "-C",
14407 main.to_str().unwrap(),
14408 "config",
14409 "user.email",
14410 "test@example.com",
14411 ],
14412 vec![
14413 "-C",
14414 main.to_str().unwrap(),
14415 "config",
14416 "user.name",
14417 "AFT Test",
14418 ],
14419 vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
14420 vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
14421 ] {
14422 let mut command = std::process::Command::new("git");
14423 assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
14424 .status()
14425 .unwrap()
14426 .success());
14427 }
14428 let mut add_worktree = std::process::Command::new("git");
14429 assert!(crate::test_env::apply_hermetic_git_env(
14430 add_worktree
14431 .arg("-C")
14432 .arg(&main)
14433 .args(["worktree", "add", "--detach"])
14434 .arg(&worktree),
14435 )
14436 .status()
14437 .unwrap()
14438 .success());
14439 let main = fs::canonicalize(main).unwrap();
14440 let worktree = fs::canonicalize(worktree).unwrap();
14441 let project_key = crate::search_index::artifact_cache_key(&main);
14442 assert_eq!(
14443 crate::search_index::artifact_cache_key(&worktree),
14444 project_key
14445 );
14446 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
14447 (temp, main, worktree, project_key, callgraph_dir)
14448 }
14449
14450 #[test]
14451 fn linked_worktree_never_acquires_writer_or_publishes_any_build_path() {
14452 let _git_env = crate::test_env::hermetic_git_env_guard();
14453 let (_temp, _main, root, project_key, callgraph_dir) = linked_worktree_fixture();
14454 crate::root_cache::configure_artifact_access(&root, &project_key, true);
14455 crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
14456 let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14457 let publications_for_observer = Arc::clone(&publications);
14458 set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
14459 publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
14460 })));
14461 let source = root.join("lib.rs");
14462
14463 let open_error = CallGraphStore::open(callgraph_dir.clone(), root.clone())
14464 .expect_err("borrow-only writable open must remain unavailable");
14465 assert!(matches!(open_error, CallGraphStoreError::Unavailable(_)));
14466 assert!(
14467 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), root.clone())
14468 .unwrap()
14469 .is_none()
14470 );
14471 assert!(
14472 CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), root.clone())
14473 .unwrap()
14474 .is_none()
14475 );
14476 assert!(matches!(
14477 CallGraphStore::cold_build_with_lease(
14478 callgraph_dir.clone(),
14479 root.clone(),
14480 std::slice::from_ref(&source),
14481 ),
14482 Err(CallGraphStoreError::Unavailable(_))
14483 ));
14484 assert!(matches!(
14485 CallGraphStore::ensure_built_with_lease(
14486 callgraph_dir.clone(),
14487 root.clone(),
14488 std::slice::from_ref(&source),
14489 ),
14490 Err(CallGraphStoreError::Unavailable(_))
14491 ));
14492 let force_error = CallGraphStore::force_cold_build_with_lease_chunked(
14493 callgraph_dir.clone(),
14494 root.clone(),
14495 &[source],
14496 1,
14497 )
14498 .expect_err("borrow-only forced rebuild must remain unsatisfied");
14499 set_cold_build_swap_observer(None);
14500
14501 assert!(matches!(force_error, CallGraphStoreError::Unavailable(_)));
14502 assert_eq!(
14503 crate::root_cache::writer_lease_acquisition_count_for_test(
14504 crate::root_cache::RootCacheDomain::Callgraph,
14505 &project_key,
14506 &root,
14507 ),
14508 0
14509 );
14510 assert_eq!(publications.load(AtomicOrdering::SeqCst), 0);
14511 assert!(!pointer_path(&callgraph_dir, &project_key).exists());
14512 }
14513
14514 #[test]
14515 fn owner_and_linked_worktree_alternation_rebuilds_storm_generation_once() {
14516 let _git_env = crate::test_env::hermetic_git_env_guard();
14517 let (_temp, owner, worktree, project_key, callgraph_dir) = linked_worktree_fixture();
14518 crate::root_cache::configure_artifact_access(&owner, &project_key, false);
14519 crate::root_cache::configure_artifact_access(&worktree, &project_key, true);
14520 let source = owner.join("lib.rs");
14521 let (store, _) = CallGraphStore::cold_build_with_lease(
14522 callgraph_dir.clone(),
14523 owner.clone(),
14524 std::slice::from_ref(&source),
14525 )
14526 .unwrap();
14527 let sqlite_path = store.sqlite_path().to_path_buf();
14528 drop(store);
14529
14530 let conn = Connection::open(&sqlite_path).unwrap();
14531 conn.execute(
14532 "UPDATE backend_file_state SET workspace_root = ?1",
14533 [worktree.display().to_string()],
14534 )
14535 .unwrap();
14536 drop(conn);
14537
14538 let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14539 let publications_for_observer = Arc::clone(&publications);
14540 set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
14541 publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
14542 })));
14543 crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
14544
14545 let repaired = CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
14546 .unwrap()
14547 .expect("owner should purge the storm-era worktree root");
14548 drop(repaired);
14549 for _ in 0..3 {
14550 let borrower = CallGraphStore::open_readonly(callgraph_dir.clone(), worktree.clone())
14551 .unwrap()
14552 .expect("linked worktree should borrow the owner generation");
14553 drop(borrower);
14554 assert!(
14555 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), worktree.clone())
14556 .unwrap()
14557 .is_none()
14558 );
14559 let owner_store =
14560 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
14561 .unwrap()
14562 .expect("owner generation should remain ready");
14563 drop(owner_store);
14564 }
14565 set_cold_build_swap_observer(None);
14566
14567 assert_eq!(
14568 publications.load(AtomicOrdering::SeqCst),
14569 1,
14570 "the owner performs one expected post-storm purge and alternation stays read-only"
14571 );
14572 assert_eq!(
14573 crate::root_cache::writer_lease_acquisition_count_for_test(
14574 crate::root_cache::RootCacheDomain::Callgraph,
14575 &project_key,
14576 &worktree,
14577 ),
14578 0
14579 );
14580 }
14581
14582 #[test]
14583 fn rebuild_cooldown_records_only_successful_publication_per_cache_key() {
14584 let temp = tempdir().unwrap();
14585 let root = temp.path().join("owner");
14586 let other_root = temp.path().join("other");
14587 fs::create_dir_all(&root).unwrap();
14588 fs::create_dir_all(&other_root).unwrap();
14589 let source = root.join("lib.rs");
14590 fs::write(&source, "pub fn marker() {}\n").unwrap();
14591 let project_key = crate::search_index::artifact_cache_key(&root);
14592 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
14593 crate::root_cache::configure_artifact_access(&root, &project_key, false);
14594 let cooldown_key = rebuild_cooldown_key(&callgraph_dir, &project_key);
14595 rebuild_cooldown_records()
14596 .lock()
14597 .unwrap_or_else(std::sync::PoisonError::into_inner)
14598 .remove(&cooldown_key);
14599 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
14600 let stale_epoch = epoch.current();
14601 epoch.next();
14602
14603 let failed = with_publish_epoch(epoch, stale_epoch, || {
14604 CallGraphStore::cold_build_with_lease(
14605 callgraph_dir.clone(),
14606 root.clone(),
14607 std::slice::from_ref(&source),
14608 )
14609 });
14610 assert!(matches!(failed, Err(CallGraphStoreError::Superseded)));
14611 assert!(
14612 rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
14613 .is_none()
14614 );
14615
14616 let (store, _) = CallGraphStore::cold_build_with_lease(
14617 callgraph_dir.clone(),
14618 root.clone(),
14619 std::slice::from_ref(&source),
14620 )
14621 .unwrap();
14622 drop(store);
14623 assert!(
14624 rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
14625 .is_none()
14626 );
14627
14628 record_successful_rebuild(&callgraph_dir, &project_key, &other_root, Instant::now());
14629 assert!(
14630 rebuild_cooldown_denial(&callgraph_dir, &project_key, &root, Instant::now(),).is_some()
14631 );
14632 }
14633
14634 #[test]
14635 fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
14636 let _guard = REFRESH_WORKER_TEST_LOCK
14637 .lock()
14638 .unwrap_or_else(std::sync::PoisonError::into_inner);
14639 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14640 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
14641 let pending = pending_paths();
14642 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14643
14644 let lifecycle = SubcLifecycleAdmission::default();
14645 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
14646 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
14647 let ticket = CallgraphRefreshTicket::new(
14648 lifecycle,
14649 Arc::clone(&generation),
14650 7,
14651 publish_epoch.clone(),
14652 publish_epoch.current(),
14653 );
14654 generation.store(8, std::sync::atomic::Ordering::SeqCst);
14656 let installed = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
14657 .unwrap()
14658 .expect("ready store snapshot");
14659 let refresh_state = CallgraphRefreshState::new(
14660 Arc::new(std::sync::RwLock::new(Some(Arc::new(installed)))),
14661 Arc::new(AtomicBool::new(true)),
14662 );
14663
14664 enqueue_callgraph_store_refresh_fenced_with_state(
14665 callgraph_dir,
14666 root.clone(),
14667 vec![source.clone()],
14668 Arc::clone(&pending),
14669 refresh_state,
14670 ticket,
14671 );
14672 assert!(flush_callgraph_store_refreshes_with_budget(
14673 Duration::from_secs(5)
14674 ));
14675 assert_eq!(
14676 callgraph_refresh_worker_test_counts(&root).0,
14677 0,
14678 "superseded batch must not reach refresh_files or self-replay"
14679 );
14680 assert!(
14681 pending.lock().contains(&source),
14682 "superseded batch must defer its paths to the pending sink"
14683 );
14684 clear_callgraph_refresh_worker_test_seam(&root);
14685 }
14686
14687 #[test]
14688 fn superseded_open_failure_defers_without_self_replay() {
14689 let _guard = REFRESH_WORKER_TEST_LOCK
14690 .lock()
14691 .unwrap_or_else(std::sync::PoisonError::into_inner);
14692 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14693 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
14694 let pending = pending_paths();
14695 let installed = Arc::new(
14696 CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
14697 .unwrap()
14698 .expect("ready store snapshot"),
14699 );
14700 let refresh_state = CallgraphRefreshState::new(
14701 Arc::new(std::sync::RwLock::new(Some(Arc::clone(&installed)))),
14702 Arc::new(AtomicBool::new(true)),
14703 );
14704 assert!(!installed.is_legacy_fallback());
14705 assert!(installed.is_current());
14706 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
14707 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14708 set_callgraph_refresh_worker_test_open_failure(root.clone(), true);
14709 let (held_rx, release_tx) = install_callgraph_refresh_worker_test_gate(root.clone());
14710
14711 let lifecycle = SubcLifecycleAdmission::default();
14712 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
14713 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
14714 let ticket = CallgraphRefreshTicket::new(
14715 lifecycle,
14716 Arc::clone(&generation),
14717 7,
14718 publish_epoch.clone(),
14719 publish_epoch.current(),
14720 );
14721 enqueue_callgraph_store_refresh_fenced_with_state(
14722 callgraph_dir,
14723 root.clone(),
14724 vec![source.clone()],
14725 Arc::clone(&pending),
14726 refresh_state,
14727 ticket,
14728 );
14729 held_rx
14730 .recv_timeout(Duration::from_secs(12))
14731 .expect("refresh worker must hold after injected open failure");
14732
14733 generation.store(8, std::sync::atomic::Ordering::SeqCst);
14736 set_callgraph_refresh_worker_test_open_failure(root.clone(), false);
14737 release_tx
14738 .send(())
14739 .expect("release superseded refresh worker");
14740 wait_for_refresh_worker_idle();
14741
14742 assert_eq!(
14743 callgraph_refresh_worker_test_counts(&root).0,
14744 1,
14745 "superseded open-failure batch must not self-replay"
14746 );
14747 assert_eq!(
14748 callgraph_refresh_worker_test_worker_calls(&root),
14749 1,
14750 "superseded open-failure batch must not create another worker call"
14751 );
14752 assert!(
14753 pending.lock().contains(&source),
14754 "superseded open-failure paths must remain in the pending sink"
14755 );
14756 let tree = installed
14757 .call_tree(Path::new("main.rs"), "entry", 1)
14758 .unwrap();
14759 assert_eq!(
14760 tree.children[0].name, "old_leaf",
14761 "superseded open-failure batch must not converge the store"
14762 );
14763 clear_callgraph_refresh_worker_test_seam(&root);
14764 }
14765
14766 #[test]
14767 fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
14768 let _guard = REFRESH_WORKER_TEST_LOCK
14769 .lock()
14770 .unwrap_or_else(std::sync::PoisonError::into_inner);
14771 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14772 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
14773 let pending = pending_paths();
14774 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14775
14776 let lifecycle = SubcLifecycleAdmission::default();
14777 let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
14778 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
14779 let expected_epoch = publish_epoch.current();
14780 let ticket = CallgraphRefreshTicket::new(
14781 lifecycle,
14782 generation,
14783 3,
14784 publish_epoch.clone(),
14785 expected_epoch,
14786 );
14787 publish_epoch.next();
14789
14790 enqueue_callgraph_store_refresh_fenced(
14791 callgraph_dir,
14792 root.clone(),
14793 vec![source.clone()],
14794 Arc::clone(&pending),
14795 ticket,
14796 );
14797 assert!(flush_callgraph_store_refreshes_with_budget(
14798 Duration::from_secs(5)
14799 ));
14800 assert_eq!(
14801 callgraph_refresh_worker_test_counts(&root).0,
14802 0,
14803 "epoch-superseded batch must not reach refresh_files"
14804 );
14805 assert!(
14806 pending.lock().contains(&source),
14807 "epoch-superseded batch must defer its paths to the pending sink"
14808 );
14809 clear_callgraph_refresh_worker_test_seam(&root);
14810 }
14811
14812 #[test]
14813 fn fenced_refresh_with_current_ticket_commits_normally() {
14814 let _guard = REFRESH_WORKER_TEST_LOCK
14815 .lock()
14816 .unwrap_or_else(std::sync::PoisonError::into_inner);
14817 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14818 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
14819 let pending = pending_paths();
14820 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14821
14822 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
14823
14824 let lifecycle = SubcLifecycleAdmission::default();
14825 let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
14826 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
14827 let ticket = CallgraphRefreshTicket::new(
14828 lifecycle,
14829 generation,
14830 5,
14831 publish_epoch.clone(),
14832 publish_epoch.current(),
14833 );
14834
14835 enqueue_callgraph_store_refresh_fenced(
14836 callgraph_dir.clone(),
14837 root.clone(),
14838 vec![source.clone()],
14839 Arc::clone(&pending),
14840 ticket,
14841 );
14842 assert!(flush_callgraph_store_refreshes_with_budget(
14843 Duration::from_secs(5)
14844 ));
14845 assert_eq!(
14846 callgraph_refresh_worker_test_counts(&root).0,
14847 1,
14848 "current ticket must run the refresh"
14849 );
14850 assert!(
14851 pending.lock().is_empty(),
14852 "committed batch must not defer paths"
14853 );
14854
14855 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
14856 .unwrap()
14857 .expect("published generation must remain readable");
14858 let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
14859 assert_eq!(
14860 tree.children[0].name, "new_leaf",
14861 "fenced commit must actually persist the refreshed content"
14862 );
14863 clear_callgraph_refresh_worker_test_seam(&root);
14864 }
14865
14866 #[test]
14867 fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
14868 let _guard = REFRESH_WORKER_TEST_LOCK
14869 .lock()
14870 .unwrap_or_else(std::sync::PoisonError::into_inner);
14871 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14876 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
14877 let pending = pending_paths();
14878 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
14879
14880 enqueue_callgraph_store_refresh(
14881 callgraph_dir.clone(),
14882 root.clone(),
14883 vec![source.clone()],
14884 Arc::clone(&pending),
14885 );
14886 wait_for_refresh_calls(&root, 1);
14887 for _ in 0..3 {
14888 enqueue_callgraph_store_refresh(
14889 callgraph_dir.clone(),
14890 root.clone(),
14891 vec![source.clone()],
14892 Arc::clone(&pending),
14893 );
14894 }
14895
14896 assert!(flush_callgraph_store_refreshes_with_budget(
14897 Duration::from_secs(2)
14898 ));
14899 assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
14900 assert!(pending.lock().is_empty());
14901 clear_callgraph_refresh_worker_test_seam(&root);
14902 }
14903
14904 #[test]
14905 fn queued_refresh_opens_generation_published_after_enqueue() {
14906 let _guard = REFRESH_WORKER_TEST_LOCK
14907 .lock()
14908 .unwrap_or_else(std::sync::PoisonError::into_inner);
14909 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14914 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
14915 let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
14916 set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
14917 let (active_held_rx, active_release_tx) =
14918 install_callgraph_refresh_worker_test_gate(active_root.clone());
14919 set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
14920 enqueue_callgraph_store_refresh(
14921 active_dir,
14922 active_root.clone(),
14923 vec![active_source],
14924 pending_paths(),
14925 );
14926 active_held_rx
14927 .recv_timeout(Duration::from_secs(12))
14928 .expect("active refresh worker holds the queue");
14929
14930 fs::write(
14931 &target_source,
14932 "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
14933 )
14934 .unwrap();
14935 enqueue_callgraph_store_refresh(
14936 target_dir.clone(),
14937 target_root.clone(),
14938 vec![target_source.clone()],
14939 pending_paths(),
14940 );
14941 let (new_generation, _) = CallGraphStore::cold_build_with_lease(
14942 target_dir.clone(),
14943 target_root.clone(),
14944 std::slice::from_ref(&target_source),
14945 )
14946 .unwrap();
14947 fs::write(
14948 &target_source,
14949 "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
14950 )
14951 .unwrap();
14952 drop(new_generation);
14953
14954 active_release_tx
14955 .send(())
14956 .expect("release active refresh worker");
14957 wait_for_refresh_calls(&target_root, 1);
14958 assert!(flush_callgraph_store_refreshes_with_budget(
14959 Duration::from_secs(12)
14960 ));
14961 let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
14962 .unwrap()
14963 .expect("current callgraph generation");
14964 let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
14965 assert_eq!(tree.children[0].name, "worker_leaf");
14966 assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
14967 clear_callgraph_refresh_worker_test_seam(&active_root);
14968 clear_callgraph_refresh_worker_test_seam(&target_root);
14969 }
14970
14971 #[test]
14972 fn refresh_failure_marks_files_stale() {
14973 let _guard = REFRESH_WORKER_TEST_LOCK
14974 .lock()
14975 .unwrap_or_else(std::sync::PoisonError::into_inner);
14976 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14981 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
14982 let pending = pending_paths();
14983 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
14984
14985 enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
14986 assert!(flush_callgraph_store_refreshes_with_budget(
14987 Duration::from_secs(2)
14988 ));
14989
14990 assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
14991 let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
14992 .unwrap()
14993 .expect("ready callgraph store");
14994 assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
14995 clear_callgraph_refresh_worker_test_seam(&root);
14996 }
14997
14998 #[test]
14999 fn idle_refresh_truncates_wal() {
15000 let _guard = REFRESH_WORKER_TEST_LOCK
15001 .lock()
15002 .unwrap_or_else(std::sync::PoisonError::into_inner);
15003 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15004 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15005 let generation = read_pointer(
15006 &callgraph_dir,
15007 &crate::search_index::artifact_cache_key(&root),
15008 )
15009 .expect("fixture publishes a generation");
15010 let wal_path = callgraph_dir.join(format!("{generation}-wal"));
15011 let pending = pending_paths();
15012 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15013
15014 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n\n").unwrap();
15015 enqueue_callgraph_store_refresh(
15016 callgraph_dir.clone(),
15017 root.clone(),
15018 vec![source.clone()],
15019 Arc::clone(&pending),
15020 );
15021 wait_for_refresh_calls(&root, 1);
15022 wait_for_refresh_worker_idle();
15023 let checkpoint_deadline = Instant::now() + Duration::from_secs(2);
15024 while fs::metadata(&wal_path)
15025 .map(|metadata| metadata.len())
15026 .unwrap_or(0)
15027 != 0
15028 {
15029 assert!(
15030 Instant::now() < checkpoint_deadline,
15031 "idle checkpoint did not truncate WAL"
15032 );
15033 std::thread::sleep(Duration::from_millis(5));
15034 }
15035 assert_eq!(
15036 fs::metadata(&wal_path)
15037 .map(|metadata| metadata.len())
15038 .unwrap_or(0),
15039 0,
15040 "idle transition truncates the refresh WAL"
15041 );
15042
15043 clear_callgraph_refresh_worker_test_seam(&root);
15044 }
15045
15046 #[test]
15047 fn bounded_shutdown_defers_unprocessed_batches() {
15048 let _guard = REFRESH_WORKER_TEST_LOCK
15049 .lock()
15050 .unwrap_or_else(std::sync::PoisonError::into_inner);
15051 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15056 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
15057 let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
15058 let active_pending = pending_paths();
15059 let queued_pending = pending_paths();
15060 set_callgraph_refresh_worker_test_seam(
15061 active_root.clone(),
15062 Duration::from_millis(300),
15063 false,
15064 );
15065
15066 enqueue_callgraph_store_refresh(
15067 active_dir,
15068 active_root.clone(),
15069 vec![active_source.clone()],
15070 Arc::clone(&active_pending),
15071 );
15072 wait_for_refresh_calls(&active_root, 1);
15073 enqueue_callgraph_store_refresh(
15074 queued_dir,
15075 queued_root.clone(),
15076 vec![queued_source.clone()],
15077 Arc::clone(&queued_pending),
15078 );
15079
15080 assert!(!flush_callgraph_store_refreshes_with_budget(
15081 Duration::from_millis(20)
15082 ));
15083 assert!(active_pending.lock().contains(&active_source));
15084 assert!(queued_pending.lock().contains(&queued_source));
15085 assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
15086 clear_callgraph_refresh_worker_test_seam(&active_root);
15087 }
15088}
15089
15090#[cfg(test)]
15091mod cold_build_insert_tests {
15092 use super::*;
15093 use crate::imports::ImportBlock;
15094 use std::cell::Cell;
15095 use std::fs;
15096 use std::path::{Path, PathBuf};
15097 use tempfile::tempdir;
15098
15099 thread_local! {
15100 static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
15101 static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
15102 static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
15103 }
15104
15105 fn count_caller_traversal_selects(sql: &str) {
15106 let sql = sql.trim_start();
15107 if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
15108 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
15109 }
15110 if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
15111 && sql.contains("e.target_file =")
15112 {
15113 CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
15114 }
15115 if sql.starts_with("WITH requested") && sql.contains("COUNT(*)") {
15116 BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
15117 }
15118 }
15119
15120 #[test]
15121 fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
15122 let dir = tempdir().unwrap();
15123 let previous_root = dir.path().join("previous-root");
15124 let current_root = dir.path().join("current-root");
15125 fs::create_dir_all(&previous_root).unwrap();
15126 fs::create_dir_all(¤t_root).unwrap();
15127 fs::remove_dir(&previous_root).unwrap();
15128 let mut conn = Connection::open_in_memory().unwrap();
15129 initialize_schema(&conn).unwrap();
15130 conn.execute(
15131 "INSERT INTO backend_file_state(
15132 backend, workspace_root, file_path, content_hash, status, updated_at
15133 ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
15134 params![previous_root.display().to_string()],
15135 )
15136 .unwrap();
15137
15138 let repair = reconcile_workspace_roots(&mut conn, ¤t_root, false).unwrap();
15139
15140 assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
15141 assert_eq!(
15142 stored_workspace_roots(&conn).unwrap(),
15143 vec![previous_root.display().to_string()]
15144 );
15145 }
15146
15147 #[test]
15148 fn sqlite_readonly_uri_percent_encodes_windows_paths() {
15149 assert_eq!(
15150 sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
15151 "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
15152 );
15153 }
15154
15155 #[test]
15156 fn legacy_migration_completion_log_has_operator_fields() {
15157 assert_eq!(
15158 legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
15159 "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
15160 );
15161 }
15162
15163 fn write_generation_with_age(
15164 dir: &Path,
15165 project_key: &str,
15166 ordinal: u64,
15167 age: Duration,
15168 ) -> String {
15169 let generation = format!("{project_key}.g{ordinal}.1.sqlite");
15170 let path = dir.join(&generation);
15171 fs::write(&path, b"sqlite placeholder").unwrap();
15172 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
15173 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
15174 generation
15175 }
15176
15177 #[test]
15178 fn gc_old_generations_preserves_live_reader_until_marker_drops() {
15179 let dir = tempfile::tempdir().unwrap();
15180 let project_key = "project";
15181 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
15182 let previous =
15183 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
15184 let pinned =
15185 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
15186 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
15187
15188 gc_old_generations(dir.path(), project_key, ¤t);
15189
15190 assert!(dir.path().join(&previous).is_file());
15191 assert!(dir.path().join(&pinned).is_file());
15192
15193 drop(marker);
15194 gc_old_generations(dir.path(), project_key, ¤t);
15195
15196 assert!(dir.path().join(&previous).is_file());
15197 assert!(!dir.path().join(&pinned).exists());
15198 }
15199
15200 #[test]
15201 fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
15202 let dir = tempfile::tempdir().unwrap();
15203 let project_key = "project";
15204 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
15205 let _previous =
15206 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
15207 let pinned =
15208 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
15209 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
15210 filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
15211
15212 gc_old_generations(dir.path(), project_key, ¤t);
15213
15214 assert!(dir.path().join(&pinned).is_file());
15215 }
15216
15217 #[test]
15218 fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
15219 let dir = tempfile::tempdir().unwrap();
15220 let project_key = "project";
15221 let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
15222 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
15223 let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
15224 let old = write_generation_with_age(
15225 dir.path(),
15226 project_key,
15227 200,
15228 expired + Duration::from_secs(60),
15229 );
15230 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
15231
15232 gc_old_generations(dir.path(), project_key, ¤t);
15233
15234 assert!(dir.path().join(¤t).is_file());
15235 assert!(dir.path().join(&previous).is_file());
15236 assert!(!dir.path().join(&old).exists());
15237 }
15238
15239 fn write_aged_callgraph_root(callgraph_root: &Path, key: &str) -> PathBuf {
15240 let cache_dir = callgraph_root.join(key);
15241 fs::create_dir_all(cache_dir.join("nested")).unwrap();
15242 fs::write(
15243 cache_dir.join("nested").join("payload.sqlite"),
15244 b"old cache payload",
15245 )
15246 .unwrap();
15247 age_callgraph_root_tree(&cache_dir);
15248 cache_dir
15249 }
15250
15251 fn age_callgraph_root_tree(path: &Path) {
15252 let old = SystemTime::now()
15253 .checked_sub(CALLGRAPH_ROOT_ORPHAN_MIN_AGE + Duration::from_secs(60))
15254 .unwrap_or(UNIX_EPOCH);
15255 let entries = fs::read_dir(path)
15256 .unwrap()
15257 .collect::<std::io::Result<Vec<_>>>()
15258 .unwrap();
15259 for entry in entries {
15260 let child = entry.path();
15261 if entry.file_type().unwrap().is_dir() {
15262 age_callgraph_root_tree(&child);
15263 } else {
15264 filetime::set_file_mtime(&child, filetime::FileTime::from_system_time(old))
15265 .unwrap();
15266 }
15267 }
15268 filetime::set_file_mtime(path, filetime::FileTime::from_system_time(old)).unwrap();
15269 }
15270
15271 #[test]
15272 fn callgraph_root_sweep_reaps_only_aged_unprotected_dead_roots() {
15273 reset_callgraph_root_sweep_cursor_for_test();
15274 let storage = tempdir().unwrap();
15275 let callgraph_root = storage.path().join("callgraph");
15276 let dead = write_aged_callgraph_root(&callgraph_root, "f1e2d3c4b5a69788");
15277 let leased = write_aged_callgraph_root(&callgraph_root, "e1d2c3b4a5968778");
15278 let fresh = callgraph_root.join("d1c2b3a495867768");
15279 fs::create_dir_all(&fresh).unwrap();
15280 fs::write(fresh.join("payload.sqlite"), b"fresh cache payload").unwrap();
15281 let marked = write_aged_callgraph_root(&callgraph_root, "c1b2a39485766758");
15282
15283 let writer_lease = crate::fs_lock::try_acquire(
15284 &crate::root_cache::writer_lease_path(&leased),
15285 Duration::ZERO,
15286 )
15287 .unwrap();
15288 age_callgraph_root_tree(&leased);
15289 let marker = crate::root_cache::ReadMarker::create(&marked, "generation").unwrap();
15290 age_callgraph_root_tree(&marked);
15293
15294 let first = sweep_callgraph_root_dirs_with_limits(
15295 &callgraph_root,
15296 &HashSet::new(),
15297 &HashSet::new(),
15298 CALLGRAPH_ROOT_SWEEP_BUDGET,
15299 usize::MAX,
15300 );
15301
15302 assert_eq!(first.removed, 1);
15303 assert!(first.bytes > 0, "the reaped byte count must be reported");
15304 assert!(!dead.exists(), "an aged dead root must be reaped");
15305 assert_eq!(first.skipped_lease, 1, "a held writer lease must win");
15306 assert_eq!(first.skipped_reader, 1, "a live reader marker must win");
15307 assert_eq!(first.skipped_fresh, 1, "a recent root must win");
15308 assert!(leased.is_dir(), "the leased root must survive");
15309 assert!(marked.is_dir(), "the reader-marked root must survive");
15310 assert!(fresh.is_dir(), "the recent root must survive");
15311
15312 drop(writer_lease);
15313 drop(marker);
15314 for cache_dir in [&leased, &marked, &fresh] {
15317 age_callgraph_root_tree(cache_dir);
15318 }
15319 let second = sweep_callgraph_root_dirs_with_limits(
15320 &callgraph_root,
15321 &HashSet::new(),
15322 &HashSet::new(),
15323 CALLGRAPH_ROOT_SWEEP_BUDGET,
15324 usize::MAX,
15325 );
15326
15327 assert_eq!(second.removed, 3);
15328 for cache_dir in [&leased, &marked, &fresh] {
15329 assert!(
15330 !cache_dir.exists(),
15331 "the decoy must be reaped after its guard or freshness changes"
15332 );
15333 }
15334 reset_callgraph_root_sweep_cursor_for_test();
15335 }
15336
15337 #[test]
15338 fn callgraph_root_sweep_resumes_after_entry_budget() {
15339 reset_callgraph_root_sweep_cursor_for_test();
15340 let storage = tempdir().unwrap();
15341 let callgraph_root = storage.path().join("callgraph");
15342 let first = write_aged_callgraph_root(&callgraph_root, "1111111111111111");
15343 let second = write_aged_callgraph_root(&callgraph_root, "2222222222222222");
15344 let third = write_aged_callgraph_root(&callgraph_root, "3333333333333333");
15345
15346 let first_pass = sweep_callgraph_root_dirs_with_limits(
15347 &callgraph_root,
15348 &HashSet::new(),
15349 &HashSet::new(),
15350 CALLGRAPH_ROOT_SWEEP_BUDGET,
15351 1,
15352 );
15353 assert!(first_pass.budget_exhausted);
15354 assert_eq!(first_pass.scanned, 1);
15355 assert!(!first.exists());
15356 assert!(second.exists());
15357 assert!(third.exists());
15358
15359 let second_pass = sweep_callgraph_root_dirs_with_limits(
15360 &callgraph_root,
15361 &HashSet::new(),
15362 &HashSet::new(),
15363 CALLGRAPH_ROOT_SWEEP_BUDGET,
15364 1,
15365 );
15366 assert!(second_pass.budget_exhausted);
15367 assert!(!second.exists());
15368 assert!(third.exists());
15369
15370 let third_pass = sweep_callgraph_root_dirs_with_limits(
15371 &callgraph_root,
15372 &HashSet::new(),
15373 &HashSet::new(),
15374 CALLGRAPH_ROOT_SWEEP_BUDGET,
15375 1,
15376 );
15377 assert!(!third_pass.budget_exhausted);
15378 assert!(!third.exists());
15379 reset_callgraph_root_sweep_cursor_for_test();
15380 }
15381
15382 #[test]
15383 fn callgraph_root_sweep_runs_generation_gc_for_memoized_root() {
15384 reset_callgraph_root_sweep_cursor_for_test();
15385 let storage = tempdir().unwrap();
15386 let callgraph_root = storage.path().join("callgraph");
15387 let key = "a1b2c3d4e5f60718";
15388 let cache_dir = callgraph_root.join(key);
15389 fs::create_dir_all(&cache_dir).unwrap();
15390 let current = write_generation_with_age(&cache_dir, key, 400, Duration::ZERO);
15391 let previous = write_generation_with_age(&cache_dir, key, 300, Duration::from_secs(1));
15392 let obsolete = write_generation_with_age(&cache_dir, key, 200, Duration::from_secs(2));
15393 publish_pointer(&cache_dir, key, ¤t).unwrap();
15394 age_callgraph_root_tree(&cache_dir);
15395 let memo_keys = HashSet::from([key.to_string()]);
15396
15397 let summary = sweep_callgraph_root_dirs_with_limits(
15398 &callgraph_root,
15399 &memo_keys,
15400 &HashSet::new(),
15401 CALLGRAPH_ROOT_SWEEP_BUDGET,
15402 usize::MAX,
15403 );
15404
15405 assert_eq!(summary.generation_gc, 1);
15406 assert!(cache_dir.join(¤t).is_file());
15407 assert!(cache_dir.join(&previous).is_file());
15408 assert!(
15409 !cache_dir.join(&obsolete).exists(),
15410 "the store-wide sweep must collect an inactive live root's obsolete generation"
15411 );
15412 reset_callgraph_root_sweep_cursor_for_test();
15413 }
15414
15415 fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
15416 let path = dir.join(name);
15417 fs::write(&path, b"temp placeholder").unwrap();
15418 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
15419 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
15420 path
15421 }
15422
15423 #[test]
15424 fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
15425 let dir = tempdir().unwrap();
15426 let aged = "project.g100.1.sqlite.tmp.1.200";
15430 let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
15431 let fresh = "project.g300.1.sqlite.tmp.1.400";
15432 let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
15433 write_build_temp_with_age(dir.path(), aged, aged_age);
15434 write_build_temp_with_age(dir.path(), aged_journal, aged_age);
15435 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
15436
15437 sweep_orphaned_build_temps(dir.path());
15438
15439 assert!(
15440 !dir.path().join(aged).exists(),
15441 "aged orphan must be removed"
15442 );
15443 assert!(
15444 !dir.path().join(aged_journal).exists(),
15445 "aged journal sidecar must be removed"
15446 );
15447 assert!(
15448 dir.path().join(fresh).is_file(),
15449 "fresh temporary must survive"
15450 );
15451 }
15452
15453 #[test]
15454 fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
15455 let storage = tempdir().unwrap();
15456 let storage_root = storage.path();
15457 let legacy_dir = storage_root.join("opencode").join("callgraph");
15463 fs::create_dir_all(&legacy_dir).unwrap();
15464 let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
15465 write_build_temp_with_age(
15466 &legacy_dir,
15467 orphan,
15468 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
15469 );
15470 assert!(
15471 !legacy_dir.join("deadbeef.current").exists(),
15472 "the dead root has no current pointer"
15473 );
15474
15475 let root_keyed_dir = storage_root.join("callgraph").join("livekey");
15476 fs::create_dir_all(&root_keyed_dir).unwrap();
15477
15478 sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
15479
15480 assert!(
15481 !legacy_dir.join(orphan).exists(),
15482 "legacy orphan must be reclaimed by the store-wide sweep"
15483 );
15484 }
15485
15486 #[test]
15487 fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
15488 let dir = tempdir().unwrap();
15494 let fresh = "project.g300.1.sqlite.tmp.1.400";
15495 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
15496
15497 sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
15498
15499 assert!(
15500 !dir.path().join(fresh).exists(),
15501 "with the age predicate forced open, the fresh temporary is removed"
15502 );
15503 }
15504
15505 #[test]
15506 fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
15507 let dir = tempdir().unwrap();
15508 let generation = write_generation_with_age(
15512 dir.path(),
15513 "project",
15514 400,
15515 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
15516 );
15517 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
15518
15519 sweep_orphaned_build_temps(dir.path());
15520
15521 assert!(
15522 dir.path().join(&generation).is_file(),
15523 "completed generation must survive the orphan sweep"
15524 );
15525 assert!(
15526 crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
15527 "read marker must survive the orphan sweep"
15528 );
15529 }
15530
15531 #[test]
15532 fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
15533 let dir = tempfile::tempdir().unwrap();
15534 let project_key = "project".to_string();
15535 let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
15536 let sqlite_path = dir.path().join(&generation);
15537 fs::remove_file(&sqlite_path).unwrap();
15538 let conn = Connection::open(&sqlite_path).unwrap();
15539 let store = CallGraphStore::from_connection(
15540 dir.path().to_path_buf(),
15541 project_key,
15542 sqlite_path,
15543 dir.path().to_path_buf(),
15544 false,
15545 Some(generation.clone()),
15546 None,
15547 None,
15548 conn,
15549 );
15550
15551 let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
15552 assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
15553
15554 drop(marker);
15555 assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
15556 }
15557
15558 #[test]
15559 fn readiness_cache_only_skips_checks_after_a_successful_validation() {
15560 let dir = tempdir().expect("temp dir");
15561 let file = dir.path().join("main.ts");
15562 fs::write(&file, "export function main() {}\n").expect("write fixture");
15563 let store = CallGraphStore::open(
15564 dir.path().join(".store-readiness-cache"),
15565 dir.path().to_path_buf(),
15566 )
15567 .expect("open store");
15568 {
15569 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
15570 conn.trace(Some(count_caller_traversal_selects));
15571 }
15572
15573 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
15574 assert!(store.indexed_file_count().is_err());
15575 assert!(store.indexed_file_count().is_err());
15576 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
15577
15578 store
15579 .cold_build(std::slice::from_ref(&file))
15580 .expect("cold build");
15581 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
15582 assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
15583 assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
15584 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
15585
15586 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
15587 conn.trace(None);
15588 }
15589
15590 #[test]
15591 fn direct_caller_frontier_chunks_sqlite_selects() {
15592 let dir = tempdir().expect("temp dir");
15593 let file = dir.path().join("main.ts");
15594 fs::write(
15595 &file,
15596 "export function caller() { target(); }\nexport function target() {}\n",
15597 )
15598 .expect("write fixture");
15599 let store = CallGraphStore::open(
15600 dir.path().join(".store-caller-frontier-query"),
15601 dir.path().to_path_buf(),
15602 )
15603 .expect("open store");
15604 store
15605 .cold_build(std::slice::from_ref(&file))
15606 .expect("cold build");
15607 let mut targets = vec![("main.ts".to_string(), "target".to_string())];
15608 targets.extend((1..1_000).map(|index| ("main.ts".to_string(), format!("missing{index}"))));
15609
15610 CALLER_QUERY_SELECTS.with(|count| count.set(0));
15611 BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
15612 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
15613 {
15614 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
15615 conn.trace(Some(count_caller_traversal_selects));
15616 }
15617 let callers = store
15618 .direct_callers_for_symbols(&targets)
15619 .expect("batched callers");
15620 {
15621 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
15622 conn.trace(None);
15623 }
15624
15625 assert_eq!(callers.len(), 1_000);
15626 assert_eq!(callers.get(&targets[0]).unwrap().len(), 1);
15627 assert_eq!(CALLER_QUERY_SELECTS.with(Cell::get), 3);
15628 assert_eq!(BOUNDARY_COUNT_SELECTS.with(Cell::get), 0);
15629 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
15630 }
15631
15632 #[test]
15633 fn callers_depth_boundary_batches_sqlite_counts() {
15634 const CALLER_COUNT: usize = 1_000;
15635
15636 let dir = tempdir().expect("temp dir");
15637 let file = dir.path().join("main.ts");
15638 let mut source = String::from("export function sharedHotHelper() {}\n");
15639 for index in 0..CALLER_COUNT {
15640 source.push_str(&format!(
15641 "export function caller{index}() {{ sharedHotHelper(); }}\n"
15642 ));
15643 }
15644 fs::write(&file, source).expect("write fixture");
15645
15646 let store = CallGraphStore::open(
15647 dir.path().join(".store-callers-query-fanout"),
15648 dir.path().to_path_buf(),
15649 )
15650 .expect("open store");
15651 store
15652 .cold_build(std::slice::from_ref(&file))
15653 .expect("cold build");
15654
15655 CALLER_QUERY_SELECTS.with(|count| count.set(0));
15656 BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
15657 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
15658 {
15659 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
15660 conn.trace(Some(count_caller_traversal_selects));
15661 }
15662
15663 let started = Instant::now();
15664 let result = crate::commands::callgraph_store_adapter::callers_result(
15665 &store,
15666 Path::new("main.ts"),
15667 "sharedHotHelper",
15668 1,
15669 true,
15670 )
15671 .expect("callers result");
15672 let elapsed = started.elapsed();
15673
15674 {
15675 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
15676 conn.trace(None);
15677 }
15678 let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
15679 let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
15680 let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
15681 eprintln!(
15682 "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
15683 result.total_callers,
15684 caller_queries,
15685 boundary_queries,
15686 total_selects,
15687 elapsed.as_secs_f64() * 1_000.0
15688 );
15689
15690 assert_eq!(result.total_callers, CALLER_COUNT);
15691 assert_eq!(caller_queries, 1);
15692 assert_eq!(boundary_queries, 3);
15693 assert_eq!(total_selects, 9);
15694 }
15695
15696 #[test]
15697 fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
15698 let dir = tempdir().expect("temp dir");
15699 let file = dir.path().join("main.ts");
15700 fs::write(
15701 &file,
15702 r#"export function topA() {
15703 root();
15704}
15705
15706export function topB() {
15707 root();
15708}
15709
15710export function root() {
15711 leaf();
15712 missing();
15713}
15714
15715export function leaf() {}
15716"#,
15717 )
15718 .expect("write fixture");
15719
15720 let store = CallGraphStore::open(
15721 dir.path().join(".store-depth-boundary-counts"),
15722 dir.path().to_path_buf(),
15723 )
15724 .expect("open store");
15725 store
15726 .cold_build(std::slice::from_ref(&file))
15727 .expect("cold build");
15728
15729 let root = store
15730 .node_for(Path::new("main.ts"), "root")
15731 .expect("root node");
15732 let leaf = store
15733 .node_for(Path::new("main.ts"), "leaf")
15734 .expect("leaf node");
15735
15736 let (full_forward_len, full_direct_len) = {
15737 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
15738 conn.execute(
15739 "INSERT INTO edges (
15740 edge_id, ref_id, source_node, target_node, target_file,
15741 target_symbol, kind, line, provenance
15742 ) VALUES (
15743 'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
15744 ?2, ?3, 'call', 98, ?4
15745 )",
15746 rusqlite::params![
15747 &root.node_id,
15748 &leaf.file,
15749 &leaf.symbol,
15750 PROVENANCE_TREESITTER
15751 ],
15752 )
15753 .expect("insert dangling forward edge");
15754 conn.execute(
15755 "INSERT INTO edges (
15756 edge_id, ref_id, source_node, target_node, target_file,
15757 target_symbol, kind, line, provenance
15758 ) VALUES (
15759 'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
15760 ?1, ?2, ?3, 'call', 99, ?4
15761 )",
15762 rusqlite::params![
15763 &root.node_id,
15764 &root.file,
15765 &root.symbol,
15766 PROVENANCE_TREESITTER
15767 ],
15768 )
15769 .expect("insert dangling direct-caller edge");
15770
15771 let full_forward_len = forward_calls_for_node(&conn, &root)
15772 .expect("full forward calls")
15773 .len();
15774 let counted_forward_len =
15775 forward_call_count_for_node(&conn, &root).expect("counted forward calls");
15776 assert_eq!(
15777 counted_forward_len, full_forward_len,
15778 "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
15779 );
15780
15781 let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
15782 .expect("full direct callers");
15783 let full_direct_len = full_direct.len();
15784 let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
15785 .expect("counted direct callers");
15786 assert_eq!(
15787 counted_direct_len, full_direct_len,
15788 "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
15789 );
15790
15791 let distinct_direct_len = full_direct
15792 .iter()
15793 .map(|site| {
15794 (
15795 site.caller.file.clone(),
15796 site.line,
15797 site.target_file.clone(),
15798 site.target_symbol.clone(),
15799 )
15800 })
15801 .collect::<BTreeSet<_>>()
15802 .len();
15803 let batch_counts = direct_caller_counts_for_tuples(
15804 &conn,
15805 &[
15806 (root.file.clone(), root.symbol.clone()),
15807 (root.file.clone(), root.symbol.clone()),
15808 (leaf.file.clone(), leaf.symbol.clone()),
15809 ],
15810 )
15811 .expect("batched direct-caller counts");
15812 assert_eq!(batch_counts.len(), 2);
15813 assert_eq!(
15814 batch_counts.get(&(root.file.clone(), root.symbol.clone())),
15815 Some(&distinct_direct_len)
15816 );
15817
15818 (full_forward_len, full_direct_len)
15819 };
15820
15821 assert_eq!(
15822 full_forward_len, 2,
15823 "fixture root should have one resolved and one unresolved outgoing call"
15824 );
15825 assert_eq!(
15826 full_direct_len, 2,
15827 "fixture root should have two real direct callers"
15828 );
15829
15830 let tree = store
15831 .call_tree(Path::new("main.ts"), "root", 0)
15832 .expect("call tree");
15833 assert!(tree.depth_limited);
15834 assert_eq!(tree.children.len(), 0);
15835 assert_eq!(
15836 tree.truncated, full_forward_len,
15837 "call_tree depth boundary must report the full forward-call list length"
15838 );
15839
15840 let callers = store
15841 .callers_of(Path::new("main.ts"), "leaf", 0)
15842 .expect("callers");
15843 assert!(callers.depth_limited);
15844 assert_eq!(callers.callers.len(), 1);
15845 assert_eq!(callers.callers[0].caller.symbol, "root");
15846 assert_eq!(
15847 callers.truncated, full_direct_len,
15848 "callers depth boundary must report the full direct-caller list length"
15849 );
15850 }
15851
15852 #[test]
15853 fn source_freshness_matches_cache_collect_for_same_bytes() {
15854 let dir = tempdir().expect("temp dir");
15855 let path = dir.path().join("fixture.ts");
15856 let source = "export function main() { return helper(); }\n";
15857 fs::write(&path, source).expect("write fixture");
15858
15859 let expected = cache_freshness::collect(&path).expect("collect freshness from file");
15860 let actual =
15861 collect_source_freshness(&path, source).expect("collect freshness from source");
15862
15863 assert_eq!(actual, expected);
15864 }
15865
15866 #[test]
15867 fn superseded_cold_build_cannot_publish_after_newer_epoch() {
15868 let root = tempfile::tempdir().unwrap();
15869 let callgraph_dir = tempfile::tempdir().unwrap();
15870 let source_dir = root.path().join("src");
15871 std::fs::create_dir_all(&source_dir).unwrap();
15872 let source = source_dir.join("lib.rs");
15873 std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
15874 let files = vec![source.clone()];
15875 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
15876 let old_epoch = epoch.next();
15877 let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
15878 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
15879 let old_epoch_flag = epoch.clone();
15880 let old_dir = callgraph_dir.path().to_path_buf();
15881 let old_root = root.path().to_path_buf();
15882 let old_files = files.clone();
15883 let old = std::thread::spawn(move || {
15884 set_cold_build_before_publish_observer(Some(Arc::new(move || {
15885 reached_tx.send(()).unwrap();
15886 release_rx.recv().unwrap();
15887 })));
15888 let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
15889 CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
15890 });
15891 set_cold_build_before_publish_observer(None);
15892 result
15893 });
15894 reached_rx
15898 .recv_timeout(Duration::from_secs(30))
15899 .expect("older build did not reach its publication barrier");
15900
15901 std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
15902 let new_epoch = epoch.next();
15903 let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
15904 CallGraphStore::cold_build_with_lease(
15905 callgraph_dir.path().to_path_buf(),
15906 root.path().to_path_buf(),
15907 &files,
15908 )
15909 })
15910 .expect("newer build should publish");
15911 drop(new_store);
15912
15913 release_tx.send(()).unwrap();
15914 assert!(matches!(
15915 old.join().unwrap(),
15916 Err(CallGraphStoreError::Superseded)
15917 ));
15918
15919 let current = CallGraphStore::open_readonly(
15920 callgraph_dir.path().to_path_buf(),
15921 root.path().to_path_buf(),
15922 )
15923 .unwrap()
15924 .expect("current callgraph generation");
15925 assert_eq!(
15926 current
15927 .nodes_matching("new_generation_marker")
15928 .unwrap()
15929 .len(),
15930 1
15931 );
15932 assert!(current
15933 .nodes_matching("old_generation_marker")
15934 .unwrap()
15935 .is_empty());
15936 }
15937
15938 #[test]
15939 fn publish_fence_supersession_keeps_completed_staging_for_zero_work_adoption() {
15940 let root = tempfile::tempdir().unwrap();
15941 let callgraph_dir = tempfile::tempdir().unwrap();
15942 let source = root.path().join("lib.rs");
15943 std::fs::write(&source, "pub fn completed_marker() {}\n").unwrap();
15944 let files = vec![source];
15945 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
15946 let old_epoch = epoch.next();
15947 let epoch_for_observer = epoch.clone();
15948 set_cold_build_before_publish_observer(Some(Arc::new(move || {
15949 epoch_for_observer.next();
15950 })));
15951 let result = with_publish_epoch(epoch.clone(), old_epoch, || {
15952 CallGraphStore::cold_build_with_lease_chunked(
15953 callgraph_dir.path().to_path_buf(),
15954 root.path().to_path_buf(),
15955 &files,
15956 1,
15957 )
15958 });
15959 set_cold_build_before_publish_observer(None);
15960 assert!(matches!(result, Err(CallGraphStoreError::Superseded)));
15961
15962 let project_key = crate::search_index::artifact_cache_key(root.path());
15963 let staging = callgraph_dir
15964 .path()
15965 .join(format!("{project_key}.staging.sqlite.tmp.resume"));
15966 let staged = Connection::open(&staging).unwrap();
15967 assert_eq!(
15968 staged_build_phase(&staged).unwrap().as_deref(),
15969 Some("ready")
15970 );
15971 drop(staged);
15972
15973 let extracted = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15974 let extracted_for_observer = Arc::clone(&extracted);
15975 set_cold_build_extract_observer(Some(Arc::new(move |paths| {
15976 extracted_for_observer.fetch_add(paths.len(), AtomicOrdering::SeqCst);
15977 })));
15978 let successor_epoch = epoch.next();
15979 let (store, stats) = with_publish_epoch(epoch, successor_epoch, || {
15980 CallGraphStore::cold_build_with_lease_chunked(
15981 callgraph_dir.path().to_path_buf(),
15982 root.path().to_path_buf(),
15983 &files,
15984 1,
15985 )
15986 })
15987 .expect("completed same-corpus staging publishes without rebuilding");
15988 set_cold_build_extract_observer(None);
15989
15990 assert_eq!(stats.files, 1);
15991 assert_eq!(
15992 extracted.load(AtomicOrdering::SeqCst),
15993 0,
15994 "completed staging must not repeat extraction"
15995 );
15996 drop(store);
15997 }
15998
15999 #[test]
16000 fn superseded_slice_preserves_staging_and_same_corpus_successor_resumes() {
16001 let root = tempfile::tempdir().unwrap();
16002 let callgraph_dir = tempfile::tempdir().unwrap();
16003 let files = ["a.rs", "b.rs", "c.rs"]
16004 .into_iter()
16005 .map(|name| {
16006 let path = root.path().join(name);
16007 std::fs::write(&path, format!("pub fn {}() {{}}\n", name.replace('.', "_")))
16008 .unwrap();
16009 path
16010 })
16011 .collect::<Vec<_>>();
16012 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
16013 let old_epoch = epoch.next();
16014 let superseded = Arc::new(AtomicBool::new(false));
16015 let epoch_for_observer = epoch.clone();
16016 let superseded_for_observer = Arc::clone(&superseded);
16017 set_cold_build_slice_observer(Some(Arc::new(move |stage, completed, _total| {
16018 if stage == "extraction"
16019 && completed == 1
16020 && !superseded_for_observer.swap(true, AtomicOrdering::SeqCst)
16021 {
16022 epoch_for_observer.next();
16023 }
16024 })));
16025
16026 let result = with_publish_epoch(epoch.clone(), old_epoch, || {
16027 CallGraphStore::cold_build_with_lease_chunked(
16028 callgraph_dir.path().to_path_buf(),
16029 root.path().to_path_buf(),
16030 &files,
16031 1,
16032 )
16033 });
16034 set_cold_build_slice_observer(None);
16035 assert!(matches!(result, Err(CallGraphStoreError::Superseded)));
16036 assert!(superseded.load(AtomicOrdering::SeqCst));
16037
16038 let project_key = crate::search_index::artifact_cache_key(root.path());
16039 let staging = callgraph_dir
16040 .path()
16041 .join(format!("{project_key}.staging.sqlite.tmp.resume"));
16042 assert!(staging.exists(), "supersession must retain durable staging");
16043 let staged = Connection::open(&staging).unwrap();
16044 assert_eq!(
16045 staged_build_phase(&staged).unwrap().as_deref(),
16046 Some("extracting")
16047 );
16048 assert_eq!(
16049 query_count(&staged, "SELECT COUNT(*) FROM files").unwrap(),
16050 1
16051 );
16052 drop(staged);
16053
16054 let extracted = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
16055 let extracted_for_observer = Arc::clone(&extracted);
16056 set_cold_build_extract_observer(Some(Arc::new(move |paths| {
16057 extracted_for_observer
16058 .lock()
16059 .unwrap()
16060 .extend(paths.iter().filter_map(|path| {
16061 path.file_name()
16062 .map(|name| name.to_string_lossy().into_owned())
16063 }));
16064 })));
16065 let successor_epoch = epoch.next();
16066 let (store, stats) = with_publish_epoch(epoch.clone(), successor_epoch, || {
16067 CallGraphStore::cold_build_with_lease_chunked(
16068 callgraph_dir.path().to_path_buf(),
16069 root.path().to_path_buf(),
16070 &files,
16071 1,
16072 )
16073 })
16074 .expect("same-corpus successor resumes and publishes");
16075 set_cold_build_extract_observer(None);
16076
16077 assert_eq!(stats.files, 3);
16078 assert_eq!(
16079 *extracted.lock().unwrap(),
16080 vec!["b.rs".to_string(), "c.rs".to_string()],
16081 "the successor must not repeat the committed first slice"
16082 );
16083 drop(store);
16084 assert!(
16085 !staging.exists(),
16086 "published staging moves to its generation"
16087 );
16088 }
16089
16090 #[test]
16091 fn changed_corpus_restarts_instead_of_adopting_staged_progress() {
16092 let root = tempfile::tempdir().unwrap();
16093 let callgraph_dir = tempfile::tempdir().unwrap();
16094 let first = root.path().join("a.rs");
16095 let second = root.path().join("b.rs");
16096 std::fs::write(&first, "pub fn a() {}\n").unwrap();
16097 std::fs::write(&second, "pub fn b() {}\n").unwrap();
16098 let mut files = vec![first.clone(), second.clone()];
16099 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
16100 let old_epoch = epoch.next();
16101 let advanced = Arc::new(AtomicBool::new(false));
16102 let epoch_for_observer = epoch.clone();
16103 let advanced_for_observer = Arc::clone(&advanced);
16104 set_cold_build_slice_observer(Some(Arc::new(move |stage, completed, _total| {
16105 if stage == "extraction"
16106 && completed == 1
16107 && !advanced_for_observer.swap(true, AtomicOrdering::SeqCst)
16108 {
16109 epoch_for_observer.next();
16110 }
16111 })));
16112 let result = with_publish_epoch(epoch.clone(), old_epoch, || {
16113 CallGraphStore::cold_build_with_lease_chunked(
16114 callgraph_dir.path().to_path_buf(),
16115 root.path().to_path_buf(),
16116 &files,
16117 1,
16118 )
16119 });
16120 set_cold_build_slice_observer(None);
16121 assert!(matches!(result, Err(CallGraphStoreError::Superseded)));
16122
16123 std::fs::write(&first, "pub fn a_changed() { b(); }\n").unwrap();
16124 let third = root.path().join("c.rs");
16125 std::fs::write(&third, "pub fn c() {}\n").unwrap();
16126 files.push(third);
16127 let extracted = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
16128 let extracted_for_observer = Arc::clone(&extracted);
16129 set_cold_build_extract_observer(Some(Arc::new(move |paths| {
16130 extracted_for_observer
16131 .lock()
16132 .unwrap()
16133 .extend(paths.iter().filter_map(|path| {
16134 path.file_name()
16135 .map(|name| name.to_string_lossy().into_owned())
16136 }));
16137 })));
16138 let successor_epoch = epoch.next();
16139 let (store, stats) = with_publish_epoch(epoch, successor_epoch, || {
16140 CallGraphStore::cold_build_with_lease_chunked(
16141 callgraph_dir.path().to_path_buf(),
16142 root.path().to_path_buf(),
16143 &files,
16144 1,
16145 )
16146 })
16147 .expect("changed-corpus successor restarts and publishes");
16148 set_cold_build_extract_observer(None);
16149
16150 assert_eq!(stats.files, 3);
16151 assert_eq!(
16152 *extracted.lock().unwrap(),
16153 vec!["a.rs".to_string(), "b.rs".to_string(), "c.rs".to_string()],
16154 "fingerprint mismatch must invalidate every old extraction slice"
16155 );
16156 drop(store);
16157 }
16158
16159 #[test]
16160 fn cold_build_prepared_bulk_insert_matches_reference_rows() {
16161 let dir = tempdir().expect("temp dir");
16162 let project_root = dir.path();
16163 let extract = fixture_extract(project_root);
16164 let resolved = fixture_resolved(&extract);
16165
16166 let reference = build_reference_connection(project_root, &extract, &resolved);
16167 let optimized = build_optimized_connection(project_root, &extract, &resolved);
16168
16169 for table in [
16170 "files",
16171 "nodes",
16172 "file_dependencies",
16173 "dispatch_hints",
16174 "refs",
16175 "edges",
16176 ] {
16177 let excluded: &[&str] = if table == "files" {
16184 &["indexed_at"]
16185 } else {
16186 &[]
16187 };
16188 assert_eq!(
16189 table_rows_without(&reference, table, excluded),
16190 table_rows_without(&optimized, table, excluded),
16191 "table `{table}` rows must match apart from wall-clock columns"
16192 );
16193 }
16194 assert_eq!(
16195 backend_state_rows(&reference),
16196 backend_state_rows(&optimized),
16197 "backend freshness rows must match apart from updated_at"
16198 );
16199 assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
16200 }
16201
16202 #[test]
16203 fn cold_build_chunked_matches_unchunked_logical_rows() {
16204 let dir = tempdir().expect("temp dir");
16205 let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
16206 write_chunked_equivalence_fixture(&project_root);
16207 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
16208 assert!(
16209 files.len() > 6,
16210 "fixture should be large enough to split into multiple chunks"
16211 );
16212
16213 let unchunked = CallGraphStore::open(
16214 project_root.join(".store-unchunked"),
16215 project_root.to_path_buf(),
16216 )
16217 .expect("open unchunked store");
16218 let unchunked_stats = unchunked
16219 .cold_build_chunked(&files, 0)
16220 .expect("unchunked cold build");
16221
16222 let chunked = CallGraphStore::open(
16223 project_root.join(".store-chunked"),
16224 project_root.to_path_buf(),
16225 )
16226 .expect("open chunked store");
16227 let chunked_stats = chunked
16228 .cold_build_chunked(&files, 3)
16229 .expect("chunked cold build");
16230
16231 assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
16232 assert_eq!(
16233 unchunked.edge_snapshot().expect("unchunked edge snapshot"),
16234 chunked.edge_snapshot().expect("chunked edge snapshot"),
16235 "public edge snapshots must match"
16236 );
16237
16238 let dispatch_edges = {
16239 let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
16240 conn.query_row(
16241 "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
16242 [],
16243 |row| row.get::<_, i64>(0),
16244 )
16245 .expect("count dispatch edges")
16246 };
16247 assert!(
16248 dispatch_edges > 0,
16249 "fixture must exercise method-dispatch edge insertion"
16250 );
16251
16252 for table in [
16253 "edges",
16254 "refs",
16255 "nodes",
16256 "file_dependencies",
16257 "dispatch_hints",
16258 ] {
16259 assert_eq!(
16260 graph_table_rows(&unchunked, table),
16261 graph_table_rows(&chunked, table),
16262 "chunked cold build must match unchunked rows for {table}"
16263 );
16264 }
16265 assert_eq!(
16266 graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
16267 graph_table_rows_without(&chunked, "files", &["indexed_at"]),
16268 "files rows must match apart from indexed_at"
16269 );
16270 assert_eq!(
16271 graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
16272 graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
16273 "backend freshness rows must match apart from updated_at"
16274 );
16275
16276 let published_dir = project_root.join(".store-published");
16277 let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
16278 published_dir.clone(),
16279 project_root.to_path_buf(),
16280 &files,
16281 0,
16282 )
16283 .expect("published unchunked cold build");
16284 assert!(
16285 !CallGraphStore::needs_cold_build(&published_dir, &project_root)
16286 .expect("needs_cold_build after publish"),
16287 "published store should be ready"
16288 );
16289 drop(_published);
16290 let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
16291 published_dir,
16292 project_root.to_path_buf(),
16293 &files,
16294 3,
16295 )
16296 .expect("ensure with a different chunk size");
16297 assert!(
16298 rebuild_stats.is_none(),
16299 "changing callgraph_chunk_size must not affect store identity or force a rebuild"
16300 );
16301 }
16302
16303 #[test]
16304 fn cold_build_resolution_memo_bounds_filesystem_probes_and_preserves_rows() {
16305 let dir = tempdir().expect("temp dir");
16306 let project_root = dir.path().join("project");
16307 fs::create_dir_all(&project_root).expect("create project root");
16308 let project_root = fs::canonicalize(project_root).expect("canonical project root");
16309 let files = write_ts_resolution_memo_fixture(&project_root, 8, 8, 4);
16310 let resolve_window = 19;
16311
16312 callgraph::clear_workspace_package_cache();
16313 let uncached_memo = callgraph::ModuleResolutionMemo::new_for_test(false, true);
16314 let uncached = CallGraphStore::open(
16315 dir.path().join("store-uncached"),
16316 project_root.to_path_buf(),
16317 )
16318 .expect("open uncached store");
16319 let uncached_stats = uncached
16320 .cold_build_chunked_with_resolution_memo_for_test(
16321 &files,
16322 7,
16323 resolve_window,
16324 &uncached_memo,
16325 )
16326 .expect("uncached comparison build");
16327 assert!(
16328 uncached_stats.refs > resolve_window * 2,
16329 "fixture must cross several staged reference windows"
16330 );
16331
16332 callgraph::clear_workspace_package_cache();
16333 let cached_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
16334 let cached =
16335 CallGraphStore::open(dir.path().join("store-cached"), project_root.to_path_buf())
16336 .expect("open cached store");
16337 let cached_stats = cached
16338 .cold_build_chunked_with_resolution_memo_for_test(
16339 &files,
16340 7,
16341 resolve_window,
16342 &cached_memo,
16343 )
16344 .expect("cached build");
16345
16346 assert_cold_build_stats_match_except_elapsed(&uncached_stats, &cached_stats);
16347 for table in [
16348 "nodes",
16349 "refs",
16350 "file_dependencies",
16351 "edges",
16352 "dispatch_hints",
16353 "type_ref_names",
16354 "meta",
16355 "staging_file_inventory",
16356 "staging_ref_context",
16357 ] {
16358 assert_eq!(
16359 graph_table_rows(&uncached, table),
16360 graph_table_rows(&cached, table),
16361 "memoized and uncached cold builds must produce identical {table} rows"
16362 );
16363 }
16364 assert_eq!(
16365 graph_table_rows_without(&uncached, "files", &["indexed_at"]),
16366 graph_table_rows_without(&cached, "files", &["indexed_at"]),
16367 "files rows must match apart from indexed_at"
16368 );
16369 assert_eq!(
16370 graph_table_rows_without(&uncached, "backend_file_state", &["updated_at"]),
16371 graph_table_rows_without(&cached, "backend_file_state", &["updated_at"]),
16372 "backend rows must match apart from updated_at"
16373 );
16374
16375 let cached_module_computations = cached_memo.module_computations_for_test();
16376 assert!(
16377 !cached_module_computations.is_empty(),
16378 "fixture must exercise module resolution"
16379 );
16380 assert!(
16381 cached_module_computations.values().all(|count| *count == 1),
16382 "each importing-directory/specifier pair must reach the filesystem once"
16383 );
16384 let uncached_module_computations = uncached_memo.module_computations_for_test();
16385 assert!(
16386 uncached_module_computations
16387 .values()
16388 .copied()
16389 .max()
16390 .unwrap_or_default()
16391 > 16,
16392 "mutation control: disabling the memo must recompute a hot module target"
16393 );
16394
16395 let cached_package_probes = cached_memo
16396 .json_probes_for_test()
16397 .into_iter()
16398 .filter(|(path, _)| {
16399 path.file_name().and_then(|name| name.to_str()) == Some("package.json")
16400 })
16401 .collect::<HashMap<_, _>>();
16402 assert!(
16403 !cached_package_probes.is_empty(),
16404 "fixture must exercise package.json lookup"
16405 );
16406 assert!(
16407 cached_package_probes.values().all(|count| *count == 1),
16408 "every package.json path must be probed at most once per cold build"
16409 );
16410 let uncached_package_probes = uncached_memo
16411 .json_probes_for_test()
16412 .into_iter()
16413 .filter(|(path, _)| {
16414 path.file_name().and_then(|name| name.to_str()) == Some("package.json")
16415 })
16416 .collect::<HashMap<_, _>>();
16417 let cached_probe_total: usize = cached_package_probes.values().sum();
16418 let uncached_probe_total: usize = uncached_package_probes.values().sum();
16419 assert!(
16420 uncached_probe_total > cached_probe_total * 20,
16421 "mutation control: disabled memo should repeat the package ladder ({uncached_probe_total} vs {cached_probe_total})"
16422 );
16423 }
16424
16425 #[test]
16430 #[ignore]
16431 fn bench_cold_build_resolution_memo() {
16432 let dir = tempdir().expect("temp dir");
16433 let project_root = dir.path().join("project");
16434 fs::create_dir_all(&project_root).expect("create benchmark root");
16435 let project_root = fs::canonicalize(project_root).expect("canonical benchmark root");
16436 let files = write_ts_resolution_memo_fixture(&project_root, 24, 12, 20);
16437 assert!(
16438 files.len() > 250,
16439 "benchmark fixture must contain hundreds of files"
16440 );
16441
16442 for enabled in [false, true] {
16443 callgraph::clear_workspace_package_cache();
16444 let memo = callgraph::ModuleResolutionMemo::new_for_test(enabled, false);
16445 let store = CallGraphStore::open(
16446 dir.path().join(if enabled {
16447 "store-cached"
16448 } else {
16449 "store-uncached"
16450 }),
16451 project_root.to_path_buf(),
16452 )
16453 .expect("open benchmark store");
16454 let cpu_started = process_cpu_time();
16455 let wall_started = Instant::now();
16456 let stats = store
16457 .cold_build_chunked_with_resolution_memo_for_test(&files, 32, 257, &memo)
16458 .expect("benchmark cold build");
16459 let wall_ms = wall_started.elapsed().as_millis();
16460 let cpu_ms = process_cpu_time()
16461 .checked_sub(cpu_started)
16462 .unwrap_or_default()
16463 .as_millis();
16464 println!(
16465 "BENCH_COLD_BUILD_RESOLUTION_MEMO memo={} files={} refs={} edges={} wall_ms={} cpu_ms={}",
16466 if enabled { "on" } else { "off" },
16467 stats.files,
16468 stats.refs,
16469 stats.edges,
16470 wall_ms,
16471 cpu_ms
16472 );
16473 }
16474 }
16475
16476 #[test]
16483 #[ignore]
16484 fn bench_cold_build_chunk() {
16485 let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
16486 let chunk: usize = std::env::var("AFT_PERF_CHUNK")
16487 .expect("AFT_PERF_CHUNK")
16488 .parse()
16489 .expect("AFT_PERF_CHUNK must be a non-negative integer");
16490 let project_root = fs::canonicalize(&repo).expect("canonical repo root");
16491 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
16492 let dir = tempdir().expect("temp dir");
16493 let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
16494 .expect("open store");
16495 let started = Instant::now();
16496 let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
16497 let ms = started.elapsed().as_millis();
16498 println!(
16499 "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
16500 stats.files, stats.nodes, stats.refs, stats.edges
16501 );
16502 }
16503
16504 #[test]
16505 fn persisted_workspace_reexport_selects_its_package_dependency() {
16506 let root = tempdir().expect("temp dir");
16507 let dependencies = BTreeSet::from([
16508 "packages/aft-bridge/src/index.ts".to_string(),
16509 "packages/opencode-plugin/src/types.ts".to_string(),
16510 ]);
16511 let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
16512
16513 assert_eq!(
16514 stored_dependencies_for_module(
16515 root.path(),
16516 "packages/opencode-plugin/src/shared/bash-hints.ts",
16517 "@cortexkit/aft-bridge",
16518 &dependencies,
16519 &indexed_files,
16520 ),
16521 BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
16522 );
16523 }
16524
16525 #[test]
16526 fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
16527 let dir = tempdir().expect("temp dir");
16528 let project_root = dir.path();
16529 let files =
16530 write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
16531 let index_path = project_root.join("src/index.ts");
16532
16533 let store = CallGraphStore::open(
16534 project_root.join(".store-incremental-barrel"),
16535 project_root.to_path_buf(),
16536 )
16537 .expect("open incremental store");
16538 store.cold_build(&files).expect("initial cold build");
16539
16540 {
16541 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16542 let tx = conn.transaction().expect("dependency transaction");
16543 let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
16544 .expect("dependent refs for barrel");
16545 let selected_ref_ids = dependent_refs
16546 .iter()
16547 .map(|dependent_ref| dependent_ref.ref_id.clone())
16548 .collect::<BTreeSet<_>>();
16549 let mut threaded_ref_ids = BTreeSet::new();
16550 let mut threaded_by_caller = BTreeMap::new();
16551 record_dependent_refs(
16552 &mut threaded_ref_ids,
16553 &mut threaded_by_caller,
16554 dependent_refs,
16555 );
16556 let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
16557 .expect("old per-ref caller lookup");
16558
16559 assert_eq!(threaded_ref_ids, selected_ref_ids);
16560 assert_eq!(threaded_by_caller, old_by_caller);
16561 for consumer in [
16562 "src/consumer_a.ts",
16563 "src/consumer_b.ts",
16564 "src/consumer_c.ts",
16565 ] {
16566 assert!(
16567 threaded_by_caller.contains_key(consumer),
16568 "barrel edit should select dependent refs from {consumer}"
16569 );
16570 }
16571 }
16572
16573 fs::write(
16574 &index_path,
16575 "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
16576 )
16577 .expect("edit barrel");
16578 let stats = store
16579 .refresh_files(std::slice::from_ref(&index_path))
16580 .expect("incremental refresh");
16581 assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
16582 assert!(
16583 stats.dependency_selected_refs > 0,
16584 "barrel surface edit should select dependent refs"
16585 );
16586
16587 let cold_store = CallGraphStore::open(
16588 project_root.join(".store-cold-barrel"),
16589 project_root.to_path_buf(),
16590 )
16591 .expect("open cold rebuild store");
16592 cold_store
16593 .cold_build(&files)
16594 .expect("comparison cold build");
16595
16596 for table in [
16597 "nodes",
16598 "refs",
16599 "file_dependencies",
16600 "edges",
16601 "dispatch_hints",
16602 ] {
16603 assert_eq!(
16604 graph_table_rows(&store, table),
16605 graph_table_rows(&cold_store, table),
16606 "incremental refresh {table} rows must match cold rebuild"
16607 );
16608 }
16609
16610 let consumer_path = project_root.join("src/consumer_a.ts");
16611 fs::write(
16612 &consumer_path,
16613 "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
16614 )
16615 .expect("edit barrel consumer");
16616 store
16617 .refresh_files(std::slice::from_ref(&consumer_path))
16618 .expect("refresh consumer through unchanged barrel");
16619 cold_store
16620 .cold_build(&files)
16621 .expect("comparison cold rebuild after consumer refresh");
16622 for table in [
16623 "nodes",
16624 "refs",
16625 "file_dependencies",
16626 "edges",
16627 "dispatch_hints",
16628 ] {
16629 assert_eq!(
16630 graph_table_rows(&store, table),
16631 graph_table_rows(&cold_store, table),
16632 "refresh through a persisted barrel must preserve cold-build {table} rows"
16633 );
16634 }
16635 }
16636
16637 fn build_reference_connection(
16638 project_root: &Path,
16639 extract: &FileExtract,
16640 resolved: &ResolvedRef,
16641 ) -> Connection {
16642 let mut conn = Connection::open_in_memory().expect("open reference db");
16643 configure_build_connection(&conn).expect("configure reference db");
16644 initialize_schema(&conn).expect("initialize reference schema");
16645 {
16646 let tx = conn.transaction().expect("reference transaction");
16647 clear_tables(&tx).expect("reference clear");
16648 insert_meta(&tx).expect("reference meta");
16649 insert_file_extract(&tx, project_root, extract).expect("reference file extract");
16650 insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
16651 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
16652 .expect("reference dispatch edges");
16653 assert_eq!(supplemental, 0);
16654 tx.commit().expect("reference commit");
16655 }
16656 conn
16657 }
16658
16659 fn build_optimized_connection(
16660 project_root: &Path,
16661 extract: &FileExtract,
16662 resolved: &ResolvedRef,
16663 ) -> Connection {
16664 let mut conn = Connection::open_in_memory().expect("open optimized db");
16665 configure_build_connection(&conn).expect("configure optimized db");
16666 initialize_schema(&conn).expect("initialize optimized schema");
16667 {
16668 let tx = conn.transaction().expect("optimized transaction");
16669 clear_tables(&tx).expect("optimized clear");
16670 insert_meta(&tx).expect("optimized meta");
16671 drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
16672 {
16673 let workspace_root = project_root.display().to_string();
16674 let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
16675 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
16676 .expect("optimized file extract");
16677 insert_resolved_ref_prepared(&mut inserts, resolved)
16678 .expect("optimized resolved ref");
16679 }
16680 create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
16681 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
16682 .expect("optimized dispatch edges");
16683 assert_eq!(supplemental, 0);
16684 tx.commit().expect("optimized commit");
16685 }
16686 conn
16687 }
16688
16689 fn fixture_extract(_project_root: &Path) -> FileExtract {
16690 let rel_path = "src/main.ts".to_string();
16691 let target_path = "src/helper.ts".to_string();
16692 let node = NodeRecord {
16693 id: "node-main".to_string(),
16694 file_path: rel_path.clone(),
16695 name: "main".to_string(),
16696 scoped_name: "main".to_string(),
16697 kind: "function".to_string(),
16698 range: Range {
16699 start_line: 0,
16700 start_col: 0,
16701 end_line: 0,
16702 end_col: 32,
16703 },
16704 range_ordinal: 0,
16705 signature: Some("export function main()".to_string()),
16706 exported: true,
16707 is_default_export: false,
16708 is_type_like: false,
16709 is_callgraph_entry_point: true,
16710 };
16711 let mut dependencies = BTreeSet::new();
16712 dependencies.insert(target_path.clone());
16713 let raw_ref = RawRef {
16714 ref_id: "ref-main-helper".to_string(),
16715 caller_node: Some(node.id.clone()),
16716 caller_symbol: Some(node.scoped_name.clone()),
16717 caller_file: rel_path.clone(),
16718 kind: "call".to_string(),
16719 short_name: Some("helper".to_string()),
16720 full_ref: Some("helper".to_string()),
16721 module_path: None,
16722 import_kind: None,
16723 local_name: Some("helper".to_string()),
16724 requested_name: Some("helper".to_string()),
16725 namespace_alias: None,
16726 wildcard: false,
16727 line: 1,
16728 byte_start: 24,
16729 byte_end: 32,
16730 dependencies,
16731 };
16732 FileExtract {
16733 rel_path,
16734 freshness: FileFreshness {
16735 mtime: UNIX_EPOCH + Duration::from_secs(123),
16736 size: 40,
16737 content_hash: cache_freshness::hash_bytes(b"fixture source"),
16738 },
16739 lang: LangId::TypeScript,
16740 data: FileCallData {
16741 calls_by_symbol: HashMap::new(),
16742 value_refs_by_symbol: HashMap::new(),
16743 exported_symbols: Vec::new(),
16744 symbol_metadata: HashMap::new(),
16745 default_export_symbol: None,
16746 import_block: ImportBlock::empty(),
16747 lang: LangId::TypeScript,
16748 },
16749 nodes: vec![node.clone()],
16750 raw_refs: vec![raw_ref],
16751 dispatch_hints: vec![DispatchHint {
16752 id: "dispatch-main-helper".to_string(),
16753 method_name: "helper".to_string(),
16754 caller_node: node.id,
16755 file: "src/main.ts".to_string(),
16756 line: 1,
16757 byte_start: 24,
16758 byte_end: 32,
16759 }],
16760 surface_fingerprint: "surface".to_string(),
16761 }
16762 }
16763
16764 fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
16765 let raw = extract.raw_refs[0].clone();
16766 let mut dependencies = raw.dependencies.clone();
16767 dependencies.insert("src/helper.ts".to_string());
16768 ResolvedRef {
16769 edge: Some(EdgeRecord {
16770 edge_id: "edge-main-helper".to_string(),
16771 source_node: raw.caller_node.clone().expect("caller node"),
16772 target_node: Some("node-helper".to_string()),
16773 target_file: "src/helper.ts".to_string(),
16774 target_symbol: "helper".to_string(),
16775 kind: "call".to_string(),
16776 line: raw.line,
16777 }),
16778 raw,
16779 status: "resolved".to_string(),
16780 target_node: Some("node-helper".to_string()),
16781 target_file: Some("src/helper.ts".to_string()),
16782 target_symbol: Some("helper".to_string()),
16783 dependencies,
16784 }
16785 }
16786
16787 fn write_ts_resolution_memo_fixture(
16788 project_root: &Path,
16789 package_count: usize,
16790 files_per_package: usize,
16791 calls_per_file: usize,
16792 ) -> Vec<PathBuf> {
16793 fs::create_dir_all(project_root).expect("create memo fixture root");
16794 fs::write(
16795 project_root.join("package.json"),
16796 r#"{"name":"fixture-root","private":true,"workspaces":["packages/*"]}"#,
16797 )
16798 .expect("write workspace package manifest");
16799 fs::write(
16800 project_root.join("tsconfig.json"),
16801 r#"{"compilerOptions":{"baseUrl":".","paths":{}}}"#,
16802 )
16803 .expect("write fixture tsconfig");
16804
16805 let shared_root = project_root.join("packages/shared");
16806 let shared_source = shared_root.join("src/index.ts");
16807 fs::create_dir_all(shared_source.parent().expect("shared source parent"))
16808 .expect("create shared package");
16809 fs::write(
16810 shared_root.join("package.json"),
16811 r#"{"name":"@fixture/shared","exports":{".":{"source":"./src/index.ts"}}}"#,
16812 )
16813 .expect("write shared package manifest");
16814 fs::write(
16815 &shared_source,
16816 "export function shared(value: number) { return value + 1; }\n",
16817 )
16818 .expect("write shared source");
16819 let mut files = vec![shared_source];
16820
16821 for package in 0..package_count {
16822 let package_root = project_root.join(format!("packages/app-{package:02}"));
16823 fs::create_dir_all(&package_root).expect("create app package");
16824 fs::write(
16825 package_root.join("package.json"),
16826 format!(r#"{{"name":"@fixture/app-{package:02}"}}"#),
16827 )
16828 .expect("write app package manifest");
16829 let source_dir = package_root.join("src/features/deep/nested/leaf");
16830 fs::create_dir_all(&source_dir).expect("create deep app source dir");
16831
16832 for file in 0..files_per_package {
16833 let source_path = source_dir.join(format!("caller_{file:03}.ts"));
16834 let mut source = "import { shared } from \"@fixture/shared\";\n".to_string();
16835 for call in 0..calls_per_file {
16836 source.push_str(&format!(
16837 "export function caller_{package}_{file}_{call}() {{ return shared({call}); }}\n"
16838 ));
16839 }
16840 fs::write(&source_path, source).expect("write app source");
16841 files.push(source_path);
16842 }
16843 }
16844
16845 files
16846 }
16847
16848 #[cfg(unix)]
16849 fn process_cpu_time() -> Duration {
16850 let mut value = std::mem::MaybeUninit::<libc::timespec>::uninit();
16851 let result =
16852 unsafe { libc::clock_gettime(libc::CLOCK_PROCESS_CPUTIME_ID, value.as_mut_ptr()) };
16853 if result != 0 {
16854 return Duration::ZERO;
16855 }
16856 let value = unsafe { value.assume_init() };
16857 Duration::new(value.tv_sec.max(0) as u64, value.tv_nsec.max(0) as u32)
16858 }
16859
16860 #[cfg(not(unix))]
16861 fn process_cpu_time() -> Duration {
16862 Duration::ZERO
16863 }
16864
16865 fn write_chunked_equivalence_fixture(project_root: &Path) {
16866 let ts_dir = project_root.join("ts");
16867 fs::create_dir_all(&ts_dir).expect("create ts dir");
16868 fs::write(
16869 ts_dir.join("leaf.ts"),
16870 "export function leaf(value: number) {\n return value + 1;\n}\n",
16871 )
16872 .expect("write ts leaf");
16873 fs::write(
16874 ts_dir.join("mid.ts"),
16875 "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n return leaf(value);\n}\n",
16876 )
16877 .expect("write ts mid");
16878 fs::write(
16879 ts_dir.join("entry.ts"),
16880 "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n return mid(worker.run());\n}\n",
16881 )
16882 .expect("write ts entry");
16883 fs::write(
16884 ts_dir.join("worker.ts"),
16885 "export class Worker {\n run() {\n return 41;\n }\n}\n",
16886 )
16887 .expect("write ts worker");
16888 for idx in 0..4 {
16889 fs::write(
16890 ts_dir.join(format!("extra_{idx}.ts")),
16891 format!(
16892 "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n return entry(new Worker());\n}}\n"
16893 ),
16894 )
16895 .expect("write ts extra");
16896 }
16897
16898 let rust_dir = project_root.join("src");
16899 let commands_dir = rust_dir.join("commands");
16900 fs::create_dir_all(&commands_dir).expect("create rust commands dir");
16901 fs::write(
16902 rust_dir.join("context.rs"),
16903 r#"pub struct AppContext;
16904
16905impl AppContext {
16906 pub fn callgraph_store_for_ops(&self) -> usize {
16907 1
16908 }
16909}
16910"#,
16911 )
16912 .expect("write rust context");
16913 fs::write(
16914 rust_dir.join("lib.rs"),
16915 "pub mod context;\npub mod commands;\n",
16916 )
16917 .expect("write rust lib");
16918 fs::write(
16919 commands_dir.join("mod.rs"),
16920 "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
16921 )
16922 .expect("write rust commands mod");
16923 for name in ["callers", "impact", "trace_to"] {
16924 fs::write(
16925 commands_dir.join(format!("{name}.rs")),
16926 format!(
16927 r#"use crate::context::AppContext;
16928
16929pub fn handle_{name}(ctx: &AppContext) -> usize {{
16930 ctx.callgraph_store_for_ops()
16931}}
16932"#
16933 ),
16934 )
16935 .expect("write rust command");
16936 }
16937 }
16938
16939 fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
16940 let src_dir = project_root.join("src");
16941 fs::create_dir_all(&src_dir).expect("create src dir");
16942
16943 let target_path = src_dir.join("target.ts");
16944 fs::write(&target_path, "export function target() {\n return 1;\n}\n")
16945 .expect("write target");
16946
16947 let index_path = src_dir.join("index.ts");
16948 fs::write(&index_path, barrel_source).expect("write barrel");
16949
16950 let mut files = vec![target_path, index_path];
16951 for (file_name, function_name) in [
16952 ("consumer_a.ts", "consumerA"),
16953 ("consumer_b.ts", "consumerB"),
16954 ("consumer_c.ts", "consumerC"),
16955 ] {
16956 let path = src_dir.join(file_name);
16957 fs::write(
16958 &path,
16959 format!(
16960 "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n return target();\n}}\n"
16961 ),
16962 )
16963 .expect("write consumer");
16964 files.push(path);
16965 }
16966 files
16967 }
16968
16969 fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
16970 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
16971 table_rows(&conn, table)
16972 }
16973
16974 fn graph_table_rows_without(
16975 store: &CallGraphStore,
16976 table: &str,
16977 excluded_columns: &[&str],
16978 ) -> Vec<String> {
16979 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
16980 table_rows_without(&conn, table, excluded_columns)
16981 }
16982
16983 fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
16984 table_rows_without(conn, table, &[])
16985 }
16986
16987 fn table_rows_without(
16988 conn: &Connection,
16989 table: &str,
16990 excluded_columns: &[&str],
16991 ) -> Vec<String> {
16992 let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
16993 let columns: Vec<String> = conn
16994 .prepare(&format!("PRAGMA table_info({table})"))
16995 .expect("prepare table_info")
16996 .query_map([], |row| row.get::<_, String>(1))
16997 .expect("query table_info")
16998 .collect::<std::result::Result<Vec<String>, _>>()
16999 .expect("collect columns")
17000 .into_iter()
17001 .filter(|column| !excluded_columns.contains(column.as_str()))
17002 .collect();
17003 let sql = format!(
17004 "SELECT {} FROM {table} ORDER BY {}",
17005 columns.join(", "),
17006 columns.join(", ")
17007 );
17008 conn.prepare(&sql)
17009 .expect("prepare table rows")
17010 .query_map([], |row| row_to_strings(row, columns.len()))
17011 .expect("query table rows")
17012 .collect::<std::result::Result<_, _>>()
17013 .expect("collect table rows")
17014 }
17015
17016 fn assert_cold_build_stats_match_except_elapsed(
17017 expected: &ColdBuildStats,
17018 actual: &ColdBuildStats,
17019 ) {
17020 assert_eq!(actual.files, expected.files, "file counts must match");
17021 assert_eq!(actual.nodes, expected.nodes, "node counts must match");
17022 assert_eq!(actual.refs, expected.refs, "ref counts must match");
17023 assert_eq!(actual.edges, expected.edges, "edge counts must match");
17024 assert_eq!(
17025 actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
17026 expected
17027 .failed_files
17028 .iter()
17029 .cloned()
17030 .collect::<BTreeSet<_>>(),
17031 "failed file sets must match"
17032 );
17033 }
17034
17035 fn backend_state_rows(conn: &Connection) -> Vec<String> {
17036 conn.prepare(
17037 "SELECT backend, workspace_root, file_path, content_hash, status
17038 FROM backend_file_state
17039 ORDER BY backend, workspace_root, file_path, content_hash, status",
17040 )
17041 .expect("prepare backend rows")
17042 .query_map([], |row| row_to_strings(row, 5))
17043 .expect("query backend rows")
17044 .collect::<std::result::Result<_, _>>()
17045 .expect("collect backend rows")
17046 }
17047
17048 fn secondary_indexes(conn: &Connection) -> Vec<String> {
17049 let mut indexes = Vec::new();
17050 for table in [
17051 "files",
17052 "nodes",
17053 "refs",
17054 "file_dependencies",
17055 "edges",
17056 "dispatch_hints",
17057 "type_ref_names",
17058 "backend_file_state",
17059 "meta",
17060 ] {
17061 let sql = format!("PRAGMA index_list({table})");
17062 let mut stmt = conn.prepare(&sql).expect("prepare index list");
17063 let rows = stmt
17064 .query_map([], |row| row.get::<_, String>(1))
17065 .expect("query index list");
17066 for name in rows {
17067 let name = name.expect("index name");
17068 if name.starts_with("idx_") {
17069 indexes.push(format!("{table}:{name}"));
17070 }
17071 }
17072 }
17073 indexes.sort();
17074 indexes
17075 }
17076
17077 fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
17078 let mut values = Vec::with_capacity(len);
17079 for index in 0..len {
17080 let value = row.get_ref(index)?;
17081 values.push(match value {
17082 rusqlite::types::ValueRef::Null => "NULL".to_string(),
17083 rusqlite::types::ValueRef::Integer(value) => value.to_string(),
17084 rusqlite::types::ValueRef::Real(value) => value.to_string(),
17085 rusqlite::types::ValueRef::Text(value) => {
17086 String::from_utf8_lossy(value).into_owned()
17087 }
17088 rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
17089 });
17090 }
17091 Ok(values.join("\u{1f}"))
17092 }
17093}
17094
17095#[cfg(test)]
17096mod rust_resolution_tests {
17097 use super::*;
17098 use crate::inspect::job::CallgraphSnapshot;
17099 use std::fs;
17100 use tempfile::tempdir;
17101
17102 #[test]
17103 fn rust_function_scoped_module_alias_resolves_and_projects_live() {
17104 let dir = tempdir().expect("tempdir");
17105 let root = dir.path();
17106 write_rust_manifest(root, "scoped-alias-fixture");
17107 write_file(
17108 root,
17109 "src/lib.rs",
17110 r#"pub mod finalization_contract;
17111
17112pub fn run_alias() {
17113 use crate::finalization_contract as fc;
17114 fc::check_mason_contract();
17115}
17116"#,
17117 );
17118 write_file(
17119 root,
17120 "src/finalization_contract.rs",
17121 r#"pub fn check_mason_contract() {}
17122fn planted_dead() {}
17123"#,
17124 );
17125
17126 let (store, snapshot) = cold_build_twice(root);
17127 assert_direct_caller(
17128 &store,
17129 "src/finalization_contract.rs",
17130 "check_mason_contract",
17131 "src/lib.rs",
17132 "run_alias",
17133 );
17134 assert_projected_call(
17135 root,
17136 &snapshot,
17137 "src/finalization_contract.rs",
17138 "check_mason_contract",
17139 );
17140 assert_no_projected_call(
17141 root,
17142 &snapshot,
17143 "src/finalization_contract.rs",
17144 "planted_dead",
17145 );
17146 assert!(
17147 store
17148 .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
17149 .expect("planted dead callers")
17150 .is_empty(),
17151 "planted-dead guard should stay without callers"
17152 );
17153 }
17154
17155 #[test]
17156 fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
17157 let dir = tempdir().expect("tempdir");
17158 let root = dir.path();
17159 write_rust_manifest(root, "inline-module-fixture");
17160 write_file(
17161 root,
17162 "src/lib.rs",
17163 r#"mod work_graph { fn operations() {} }
17164mod manifest { fn operations() {} }
17165mod audit { fn operations() {} }
17166mod dispatch { fn operations() {} }
17167mod finalization { fn operations() {} }
17168
17169pub fn run_inline_operations() {
17170 work_graph::operations();
17171 manifest::operations();
17172 audit::operations();
17173 dispatch::operations();
17174 finalization::operations();
17175}
17176
17177fn planted_dead() {}
17178"#,
17179 );
17180
17181 let (store, snapshot) = cold_build_twice(root);
17182 for module in [
17183 "work_graph",
17184 "manifest",
17185 "audit",
17186 "dispatch",
17187 "finalization",
17188 ] {
17189 assert_direct_caller(
17190 &store,
17191 "src/lib.rs",
17192 &format!("{module}::operations"),
17193 "src/lib.rs",
17194 "run_inline_operations",
17195 );
17196 }
17197 assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
17198 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
17199 }
17200
17201 #[test]
17202 fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
17203 let dir = tempdir().expect("tempdir");
17204 let root = dir.path();
17205 fs::write(
17206 root.join("Cargo.toml"),
17207 "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
17208 )
17209 .expect("write workspace manifest");
17210 write_file(
17211 root,
17212 "crates/but-action/Cargo.toml",
17213 r#"[package]
17214name = "but-action"
17215version = "0.1.0"
17216edition = "2021"
17217"#,
17218 );
17219 write_file(
17220 root,
17221 "crates/but-action/src/lib.rs",
17222 "mod action;\npub use action::{list_actions};\n",
17223 );
17224 write_file(
17225 root,
17226 "crates/but-action/src/action.rs",
17227 "pub fn list_actions() {}\nfn planted_dead() {}\n",
17228 );
17229 write_file(
17230 root,
17231 "crates/app/Cargo.toml",
17232 r#"[package]
17233name = "app"
17234version = "0.1.0"
17235edition = "2021"
17236"#,
17237 );
17238 write_file(
17239 root,
17240 "crates/app/src/lib.rs",
17241 "pub fn run_actions() {\n but_action::list_actions();\n}\n",
17242 );
17243
17244 let (store, snapshot) = cold_build_twice(root);
17245 assert_direct_caller(
17246 &store,
17247 "crates/but-action/src/action.rs",
17248 "list_actions",
17249 "crates/app/src/lib.rs",
17250 "run_actions",
17251 );
17252 assert!(
17253 store
17254 .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
17255 .expect("lib reexport callers")
17256 .is_empty(),
17257 "call should target the reexported source function, not lib.rs"
17258 );
17259 assert_projected_call(
17260 root,
17261 &snapshot,
17262 "crates/but-action/src/action.rs",
17263 "list_actions",
17264 );
17265 assert_no_projected_call(
17266 root,
17267 &snapshot,
17268 "crates/but-action/src/action.rs",
17269 "planted_dead",
17270 );
17271 }
17272
17273 #[test]
17274 fn rust_cfg_attributed_module_resolves_outgoing_calls() {
17275 let dir = tempdir().expect("tempdir");
17276 let root = dir.path();
17277 write_rust_manifest(root, "cfg-module-outgoing-fixture");
17278 write_file(
17279 root,
17280 "src/lib.rs",
17281 "pub fn project_range() {}\n\n#[cfg(any(test, feature = \"test-conformance\"))]\npub mod conformance;\npub mod ordinary;\n",
17282 );
17283 for module in ["conformance", "ordinary"] {
17284 write_file(
17285 root,
17286 &format!("src/{module}.rs"),
17287 "use crate::project_range;\n\npub fn local_target() {}\n\npub fn run() {\n local_target();\n project_range();\n}\n",
17288 );
17289 }
17290
17291 let (store, _) = cold_build_twice(root);
17292 for module in ["conformance", "ordinary"] {
17293 assert_direct_caller(
17294 &store,
17295 &format!("src/{module}.rs"),
17296 "local_target",
17297 &format!("src/{module}.rs"),
17298 "run",
17299 );
17300 assert_direct_caller(
17301 &store,
17302 "src/lib.rs",
17303 "project_range",
17304 &format!("src/{module}.rs"),
17305 "run",
17306 );
17307 }
17308 }
17309
17310 #[test]
17311 fn rust_registered_modules_preserve_import_alias_resolution() {
17312 let dir = tempdir().expect("tempdir");
17313 let root = dir.path();
17314 write_rust_manifest(root, "registered-module-import-control");
17315 write_file(
17316 root,
17317 "src/main.rs",
17318 "mod commands;\nmod db;\nfn main() {}\n",
17319 );
17320 write_file(
17321 root,
17322 "src/commands.rs",
17323 "use crate::db;\n\npub fn run() {\n db::helper();\n}\n",
17324 );
17325 write_file(root, "src/db.rs", "pub fn helper() {}\n");
17326
17327 let main_extract =
17328 build_file_extract(root, &root.join("src/main.rs")).expect("main extract");
17329 let commands_extract =
17330 build_file_extract(root, &root.join("src/commands.rs")).expect("commands extract");
17331 let db_extract = build_file_extract(root, &root.join("src/db.rs")).expect("db extract");
17332 let files = [&main_extract, &commands_extract, &db_extract]
17333 .into_iter()
17334 .map(|extract| {
17335 (
17336 extract.rel_path.clone(),
17337 DbFileIndex::from_extract(root, extract),
17338 )
17339 })
17340 .collect::<HashMap<_, _>>();
17341 let caller_data = [&main_extract, &commands_extract, &db_extract]
17342 .into_iter()
17343 .map(|extract| (extract.rel_path.clone(), &extract.data))
17344 .collect::<HashMap<_, _>>();
17345 let index = ProjectIndex::from_parts(
17346 root,
17347 files,
17348 caller_data,
17349 WorkspaceCratePrefixCache::default(),
17350 );
17351 assert_eq!(
17352 index.module_parent("src/commands.rs"),
17353 Some(("src/main.rs".to_string(), "commands".to_string()))
17354 );
17355 assert_eq!(
17356 index.module_target("src/main.rs", "db").as_deref(),
17357 Some("src/db.rs")
17358 );
17359 let call = commands_extract
17360 .raw_refs
17361 .iter()
17362 .find(|raw| raw.kind == "call" && raw.full_ref.as_deref() == Some("db::helper"))
17363 .expect("db helper call")
17364 .clone();
17365 let resolved = resolve_ref(call, &index).expect("resolve db helper");
17366 assert_eq!(resolved.target_file.as_deref(), Some("src/db.rs"));
17367 assert_eq!(resolved.target_symbol.as_deref(), Some("helper"));
17368
17369 let (store, _) = cold_build_twice(root);
17370 assert_direct_caller(&store, "src/db.rs", "helper", "src/commands.rs", "run");
17371 }
17372
17373 #[test]
17374 fn rust_path_attributed_module_uses_declared_logical_parent() {
17375 let dir = tempdir().expect("tempdir");
17376 let root = dir.path();
17377 write_rust_manifest(root, "path-module-outgoing-fixture");
17378 write_file(
17379 root,
17380 "src/lib.rs",
17381 "pub fn project_range() {}\n\n#[cfg(test)]\n#[path = \"alternate/custom.rs\"]\npub mod conformance;\n",
17382 );
17383 write_file(
17384 root,
17385 "src/alternate/custom.rs",
17386 "pub fn run() {\n super::project_range();\n}\n",
17387 );
17388
17389 let (store, _) = cold_build_twice(root);
17390 assert_direct_caller(
17391 &store,
17392 "src/lib.rs",
17393 "project_range",
17394 "src/alternate/custom.rs",
17395 "run",
17396 );
17397 }
17398
17399 #[test]
17400 fn rust_same_file_test_module_receiver_method_dispatch_resolves() {
17401 let dir = tempdir().expect("tempdir");
17402 let root = dir.path();
17403 write_rust_manifest(root, "same-file-test-module-fixture");
17404 write_file(
17405 root,
17406 "src/lib.rs",
17407 r#"pub struct Index(u32);
17408
17409impl Index {
17410 pub fn shares_index_with(&self, other: &Self) -> bool {
17411 self.0 == other.0
17412 }
17413}
17414
17415#[cfg(test)]
17416mod tests {
17417 use super::Index;
17418
17419 #[test]
17420 fn compares_indexes() {
17421 let before = Index(1);
17422 let after = Index(1);
17423 assert!(before.shares_index_with(&after));
17424 }
17425}
17426"#,
17427 );
17428
17429 let (store, snapshot) = cold_build_twice(root);
17430 assert_direct_caller(
17431 &store,
17432 "src/lib.rs",
17433 "Index::shares_index_with",
17434 "src/lib.rs",
17435 "tests::compares_indexes",
17436 );
17437 assert!(
17438 snapshot.outbound_calls.iter().any(|call| {
17439 call.caller_symbol == "compares_indexes"
17440 && call.line == 17
17441 && call.target.starts_with(&format!(
17442 "shares_index_with{}before.shares_index_with",
17443 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
17444 ))
17445 }),
17446 "expected projected macro receiver call; calls: {:#?}",
17447 snapshot.outbound_calls
17448 );
17449 }
17450
17451 #[test]
17452 fn rust_generic_self_turbofish_method_dispatch_resolves() {
17453 let dir = tempdir().expect("tempdir");
17454 let root = dir.path();
17455 write_rust_manifest(root, "generic-self-fixture");
17456 write_file(
17457 root,
17458 "src/lib.rs",
17459 r#"pub struct Matcher;
17460
17461impl Matcher {
17462 pub fn run(&self) -> bool {
17463 self.fuzzy_match_optimal::<usize>("needle")
17464 }
17465
17466 fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
17467 let _ = std::marker::PhantomData::<T>;
17468 true
17469 }
17470
17471 fn planted_dead(&self) {}
17472}
17473
17474pub fn entry() -> bool {
17475 let matcher = Matcher;
17476 matcher.run()
17477}
17478"#,
17479 );
17480
17481 let (store, snapshot) = cold_build_twice(root);
17482 assert_direct_caller(
17483 &store,
17484 "src/lib.rs",
17485 "Matcher::fuzzy_match_optimal",
17486 "src/lib.rs",
17487 "Matcher::run",
17488 );
17489 assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
17490 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
17491 }
17492
17493 #[test]
17494 fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
17495 let dir = tempdir().expect("tempdir");
17496 let root = dir.path();
17497 write_rust_manifest(root, "manifest-operations-fixture");
17498 write_file(
17499 root,
17500 "src/main.rs",
17501 r#"mod dispatch;
17502use dispatch::{manifest_operations};
17503
17504fn main() {
17505 manifest_operations();
17506}
17507"#,
17508 );
17509 write_file(
17510 root,
17511 "src/dispatch.rs",
17512 r#"mod work_graph { fn operations() {} }
17513mod manifest { fn operations() {} }
17514mod audit { fn operations() {} }
17515mod descriptor { fn operations() {} }
17516mod writer { fn operations() {} }
17517
17518pub fn manifest_operations() {
17519 manifest::operations();
17520}
17521
17522pub fn work_graph_operations() {
17523 work_graph::operations();
17524}
17525
17526pub fn audit_operations() {
17527 audit::operations();
17528}
17529
17530pub fn descriptor_operations() {
17531 descriptor::operations();
17532}
17533
17534pub fn writer_operations() {
17535 writer::operations();
17536}
17537
17538fn planted_dead() {}
17539"#,
17540 );
17541
17542 let (store, snapshot) = cold_build_twice(root);
17543 assert_direct_caller(
17544 &store,
17545 "src/dispatch.rs",
17546 "manifest_operations",
17547 "src/main.rs",
17548 "main",
17549 );
17550 assert_direct_caller(
17551 &store,
17552 "src/dispatch.rs",
17553 "manifest::operations",
17554 "src/dispatch.rs",
17555 "manifest_operations",
17556 );
17557 assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
17558 assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
17559 assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
17560 }
17561
17562 fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
17563 let files = rust_files(root);
17564 let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
17565 .expect("open first store");
17566 first.cold_build(&files).expect("first cold build");
17567 let first_snapshot =
17568 project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
17569
17570 let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
17571 .expect("open second store");
17572 second.cold_build(&files).expect("second cold build");
17573 let second_snapshot =
17574 project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
17575
17576 assert_eq!(
17577 projection_rows(&first_snapshot),
17578 projection_rows(&second_snapshot),
17579 "cold-build projection should be deterministic"
17580 );
17581 (first, first_snapshot)
17582 }
17583
17584 fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
17585 let mut rows = Vec::new();
17586 for export in &snapshot.exported_symbols {
17587 rows.push(format!(
17588 "export\t{}\t{}\t{}\t{}",
17589 export.file.display(),
17590 export.symbol,
17591 export.kind,
17592 export.line
17593 ));
17594 }
17595 for call in &snapshot.outbound_calls {
17596 rows.push(format!(
17597 "call\t{}\t{}\t{}\t{}\t{}",
17598 call.caller_file.display(),
17599 call.caller_symbol,
17600 call.target,
17601 call.line,
17602 call.provenance
17603 ));
17604 }
17605 for file in &snapshot.entry_points {
17606 rows.push(format!("entry_file\t{}", file.display()));
17607 }
17608 for (file, symbols) in &snapshot.entry_point_symbols {
17609 for symbol in symbols {
17610 rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
17611 }
17612 }
17613 rows.sort();
17614 rows
17615 }
17616
17617 fn assert_direct_caller(
17618 store: &CallGraphStore,
17619 target_rel: &str,
17620 target_symbol: &str,
17621 caller_rel: &str,
17622 caller_symbol: &str,
17623 ) {
17624 let callers = store
17625 .direct_callers_of(Path::new(target_rel), target_symbol)
17626 .unwrap_or_else(|error| {
17627 panic!("direct callers for {target_rel}::{target_symbol}: {error}")
17628 });
17629 assert!(
17630 callers.iter().any(|site| {
17631 site.caller.file == caller_rel && site.caller.symbol == caller_symbol
17632 }),
17633 "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
17634 );
17635 }
17636
17637 fn assert_projected_call(
17638 root: &Path,
17639 snapshot: &CallgraphSnapshot,
17640 target_rel: &str,
17641 symbol: &str,
17642 ) {
17643 let target = projected_target(root, target_rel, symbol);
17644 assert!(
17645 snapshot.outbound_calls.iter().any(|call| {
17646 call.target == target
17647 || call.target.starts_with(&format!(
17648 "{target}{}",
17649 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
17650 ))
17651 }),
17652 "expected projected call to {target}; calls: {:#?}",
17653 snapshot.outbound_calls
17654 );
17655 }
17656
17657 fn assert_no_projected_call(
17658 root: &Path,
17659 snapshot: &CallgraphSnapshot,
17660 target_rel: &str,
17661 symbol: &str,
17662 ) {
17663 let target = projected_target(root, target_rel, symbol);
17664 assert!(
17665 snapshot.outbound_calls.iter().all(|call| {
17666 call.target != target
17667 && !call.target.starts_with(&format!(
17668 "{target}{}",
17669 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
17670 ))
17671 }),
17672 "did not expect projected call to {target}; calls: {:#?}",
17673 snapshot.outbound_calls
17674 );
17675 }
17676
17677 fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
17678 let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
17681 format!("{}::{symbol}", path.display())
17682 }
17683
17684 fn write_rust_manifest(root: &Path, name: &str) {
17685 write_file(
17686 root,
17687 "Cargo.toml",
17688 &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
17689 );
17690 }
17691
17692 fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
17693 let path = root.join(rel_path);
17694 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
17695 fs::write(&path, source).expect("write fixture file");
17696 path
17697 }
17698
17699 fn rust_files(root: &Path) -> Vec<PathBuf> {
17700 let mut files = Vec::new();
17701 collect_rust_files(root, &mut files);
17702 files.sort();
17703 files
17704 }
17705
17706 fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
17707 for entry in fs::read_dir(dir).expect("read fixture dir") {
17708 let entry = entry.expect("read fixture entry");
17709 let path = entry.path();
17710 if path.is_dir() {
17711 let name = path
17712 .file_name()
17713 .and_then(|name| name.to_str())
17714 .unwrap_or("");
17715 if !name.starts_with(".store") {
17716 collect_rust_files(&path, files);
17717 }
17718 } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
17719 files.push(path);
17720 }
17721 }
17722 }
17723}
17724
17725#[cfg(test)]
17726mod build_pool_tests {
17727 use super::build_pool_size;
17728
17729 #[test]
17730 fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
17731 let size = build_pool_size();
17732 assert!(size >= 1, "pool size must be at least 1");
17735 assert!(size <= 8, "pool size must be capped at 8, got {size}");
17736
17737 let cores = std::thread::available_parallelism()
17738 .map(|p| p.get())
17739 .unwrap_or(1);
17740 let expected = cores.div_ceil(2).clamp(1, 8);
17741 assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
17742 }
17743}
17744
17745#[cfg(test)]
17746mod reexport_resolution_tests {
17747 use super::*;
17748
17749 fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
17750 ProjectIndex {
17751 project_root: PathBuf::from("/fixture"),
17752 files: files.into_iter().collect(),
17753 caller_data: HashMap::new(),
17754 workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
17755 }
17756 }
17757
17758 fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
17759 DbFileIndex {
17760 lang: None,
17761 exports: HashSet::new(),
17762 default_export: None,
17763 export_aliases: HashMap::new(),
17764 node_by_scoped: HashMap::new(),
17765 node_by_bare: HashMap::new(),
17766 node_kind_by_id: HashMap::new(),
17767 module_targets: HashMap::new(),
17768 declared_module_targets: HashMap::new(),
17769 reexports: reexport_targets
17770 .iter()
17771 .map(|target| ReexportIndex {
17772 target_file: Some((*target).to_string()),
17773 named: HashMap::new(),
17774 wildcard: true,
17775 })
17776 .collect(),
17777 }
17778 }
17779
17780 #[test]
17787 fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
17788 let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
17789 let files = names
17790 .iter()
17791 .map(|name| {
17792 let targets: Vec<&str> = names
17793 .iter()
17794 .filter(|other| *other != name)
17795 .map(String::as_str)
17796 .collect();
17797 (name.clone(), barrel_file(&targets))
17798 })
17799 .collect();
17800 let index = barrel_index(files);
17801
17802 assert_eq!(
17803 resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
17804 None
17805 );
17806 }
17807
17808 #[test]
17814 fn shallow_revisit_after_deep_capped_visit_still_resolves() {
17815 let mut leaf = barrel_file(&[]);
17816 leaf.exports.insert("deep_symbol".to_string());
17817 let mut files: Vec<(String, DbFileIndex)> = Vec::new();
17818 files.push((
17821 "src/entry.ts".to_string(),
17822 barrel_file(&["src/chain0.ts", "src/shared.ts"]),
17823 ));
17824 for i in 0..15 {
17825 let next = if i == 14 {
17826 "src/shared.ts".to_string()
17827 } else {
17828 format!("src/chain{}.ts", i + 1)
17829 };
17830 files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
17831 }
17832 files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
17833 files.push(("src/leaf.ts".to_string(), leaf));
17834 let index = barrel_index(files);
17835
17836 assert_eq!(
17837 resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
17838 Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
17839 "a shallower re-visit must not be pruned by a deeper capped visit"
17840 );
17841 }
17842
17843 #[test]
17844 fn symbol_reachable_through_reexport_cycle_still_resolves() {
17845 let mut leaf = barrel_file(&[]);
17846 leaf.exports.insert("real_symbol".to_string());
17847 let index = barrel_index(vec![
17848 (
17849 "src/a.ts".to_string(),
17850 barrel_file(&["src/b.ts", "src/a.ts"]),
17851 ),
17852 (
17853 "src/b.ts".to_string(),
17854 barrel_file(&["src/a.ts", "src/leaf.ts"]),
17855 ),
17856 ("src/leaf.ts".to_string(), leaf),
17857 ]);
17858
17859 assert_eq!(
17860 resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
17861 Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
17862 );
17863 }
17864}
17865
17866#[cfg(test)]
17867mod method_dispatch_inference_tests {
17868 use super::*;
17869 use std::fs;
17870 use tempfile::tempdir;
17871
17872 #[test]
17873 fn java_field_receiver_type_selects_declared_class_method() {
17874 let source = r#"class EntryPoint {
17875 private UserService userService;
17876
17877 void handle() {
17878 userService.find();
17879 }
17880}
17881
17882class UserService {
17883 void find() {}
17884}
17885
17886class AuditService {
17887 void find() {}
17888}
17889"#;
17890 let dir = tempdir().expect("temp dir");
17891 let root = dir.path();
17892 write_fixture(root, "src/EntryPoint.java", source);
17893 let reference = reference(
17894 "java",
17895 "src/EntryPoint.java",
17896 "EntryPoint::handle",
17897 "userService",
17898 "find",
17899 line_of(source, "userService.find()"),
17900 );
17901 let mut cache = DispatchSourceCache::new();
17902
17903 let receiver_type =
17904 infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
17905 assert_eq!(receiver_type, "UserService");
17906
17907 let candidates = vec![
17908 method_candidate("audit", "AuditService::find"),
17909 method_candidate("user", "UserService::find"),
17910 ];
17911 let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
17912 .expect("type candidate");
17913 assert_eq!(selected.scoped_name, "UserService::find");
17914
17915 let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
17916 assert!(
17917 select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
17918 );
17919 }
17920
17921 #[test]
17922 fn kotlin_property_and_local_value_types_are_inferred() {
17923 let source = r#"class Handler {
17924 private val auditService: AuditService = AuditService()
17925
17926 fun handle() {
17927 auditService.find()
17928 val userService: UserService = UserService()
17929 userService.find()
17930 val billingService = BillingService()
17931 billingService.find()
17932 }
17933}
17934
17935class UserService { fun find() {} }
17936class AuditService { fun find() {} }
17937class BillingService { fun find() {} }
17938"#;
17939 let dir = tempdir().expect("temp dir");
17940 let root = dir.path();
17941 write_fixture(root, "src/Handler.kt", source);
17942 let mut cache = DispatchSourceCache::new();
17943
17944 let audit_ref = reference(
17945 "kotlin",
17946 "src/Handler.kt",
17947 "Handler::handle",
17948 "auditService",
17949 "find",
17950 line_of(source, "auditService.find()"),
17951 );
17952 assert_eq!(
17953 infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
17954 Some("AuditService")
17955 );
17956
17957 let user_ref = reference(
17958 "kotlin",
17959 "src/Handler.kt",
17960 "Handler::handle",
17961 "userService",
17962 "find",
17963 line_of(source, "userService.find()"),
17964 );
17965 assert_eq!(
17966 infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
17967 Some("UserService")
17968 );
17969
17970 let billing_ref = reference(
17971 "kotlin",
17972 "src/Handler.kt",
17973 "Handler::handle",
17974 "billingService",
17975 "find",
17976 line_of(source, "billingService.find()"),
17977 );
17978 assert_eq!(
17979 infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
17980 Some("BillingService")
17981 );
17982 }
17983
17984 #[test]
17985 fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
17986 let source = r#"struct Foo { void run(); };
17987struct PointerFoo { void run(); };
17988struct FactoryFoo { void run(); };
17989FactoryFoo makeFactoryFoo();
17990
17991void handle() {
17992 Foo foo;
17993 foo.run();
17994 PointerFoo* pointerFoo = nullptr;
17995 pointerFoo->run();
17996 auto factoryFoo = makeFactoryFoo();
17997 factoryFoo.run();
17998}
17999"#;
18000 let dir = tempdir().expect("temp dir");
18001 let root = dir.path();
18002 write_fixture(root, "src/fixture.cpp", source);
18003 let mut cache = DispatchSourceCache::new();
18004
18005 let foo_ref = reference(
18006 "cpp",
18007 "src/fixture.cpp",
18008 "handle",
18009 "foo",
18010 "run",
18011 line_of(source, "foo.run()"),
18012 );
18013 assert_eq!(
18014 infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
18015 Some("Foo")
18016 );
18017
18018 let pointer_ref = reference(
18019 "cpp",
18020 "src/fixture.cpp",
18021 "handle",
18022 "pointerFoo",
18023 "run",
18024 line_of(source, "pointerFoo->run()"),
18025 );
18026 assert_eq!(
18027 infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
18028 Some("PointerFoo")
18029 );
18030
18031 let factory_ref = reference(
18032 "cpp",
18033 "src/fixture.cpp",
18034 "handle",
18035 "factoryFoo",
18036 "run",
18037 line_of(source, "factoryFoo.run()"),
18038 );
18039 assert_eq!(
18040 infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
18041 Some("FactoryFoo")
18042 );
18043 }
18044
18045 #[test]
18046 fn rust_direct_self_field_name_trims_separator_whitespace() {
18047 for receiver_expression in ["self .engine", "self. engine", "self . engine"] {
18048 assert_eq!(
18049 rust_direct_self_field_name(receiver_expression),
18050 Some("engine")
18051 );
18052 }
18053 }
18054
18055 #[test]
18056 fn rust_direct_self_field_receiver_type_is_conservative() {
18057 let source = r#"struct Engine;
18058
18059struct Car {
18060 engine: Engine,
18061}
18062
18063impl Car {
18064 fn run(&self) {
18065 self.engine.start();
18066 }
18067}
18068
18069struct NestedCar {
18070 engine: Engine,
18071}
18072
18073impl NestedCar {
18074 fn run(&self) {
18075 self.inner.engine.start();
18076 }
18077}
18078
18079struct WrappedCar {
18080 engine: Option<Engine>,
18081}
18082
18083impl WrappedCar {
18084 fn run(&self) {
18085 self.engine.start(); // wrapped
18086 }
18087}
18088
18089struct GenericCar<T> {
18090 engine: T,
18091}
18092
18093impl<T> GenericCar<T> {
18094 fn run(&self) {
18095 self.engine.start(); // generic
18096 }
18097}
18098
18099type EngineAlias = Engine;
18100
18101struct AliasCar {
18102 engine: EngineAlias,
18103}
18104
18105impl AliasCar {
18106 fn run(&self) {
18107 self.engine.start(); // alias
18108 }
18109}
18110"#;
18111 let dir = tempdir().expect("temp dir");
18112 let root = dir.path();
18113 write_fixture(root, "src/lib.rs", source);
18114 let mut cache = DispatchSourceCache::new();
18115
18116 let mut direct = reference(
18117 "rust",
18118 "src/lib.rs",
18119 "Car::run",
18120 "engine",
18121 "start",
18122 line_of(source, "self.engine.start()"),
18123 );
18124 direct.receiver_expression = "self.engine".to_string();
18125 assert_eq!(
18126 infer_receiver_type(root, &direct, &mut cache).as_deref(),
18127 Some("Engine")
18128 );
18129
18130 let mut mismatched_impl_target = direct.clone();
18131 mismatched_impl_target.caller_symbol = "other::Car::run".to_string();
18132 assert!(infer_receiver_type(root, &mismatched_impl_target, &mut cache).is_none());
18133
18134 let mut nested = reference(
18135 "rust",
18136 "src/lib.rs",
18137 "NestedCar::run",
18138 "engine",
18139 "start",
18140 line_of(source, "self.inner.engine.start()"),
18141 );
18142 nested.receiver_expression = "self.inner.engine".to_string();
18143 assert!(infer_receiver_type(root, &nested, &mut cache).is_none());
18144
18145 let mut wrapped = reference(
18146 "rust",
18147 "src/lib.rs",
18148 "WrappedCar::run",
18149 "engine",
18150 "start",
18151 line_of(source, "self.engine.start(); // wrapped"),
18152 );
18153 wrapped.receiver_expression = "self.engine".to_string();
18154 assert!(infer_receiver_type(root, &wrapped, &mut cache).is_none());
18155
18156 let mut generic = reference(
18157 "rust",
18158 "src/lib.rs",
18159 "GenericCar::run",
18160 "engine",
18161 "start",
18162 line_of(source, "self.engine.start(); // generic"),
18163 );
18164 generic.receiver_expression = "self.engine".to_string();
18165 assert!(infer_receiver_type(root, &generic, &mut cache).is_none());
18166
18167 let mut alias = reference(
18168 "rust",
18169 "src/lib.rs",
18170 "AliasCar::run",
18171 "engine",
18172 "start",
18173 line_of(source, "self.engine.start(); // alias"),
18174 );
18175 alias.receiver_expression = "self.engine".to_string();
18176 assert!(infer_receiver_type(root, &alias, &mut cache).is_none());
18177 }
18178
18179 #[test]
18180 fn rust_direct_self_reference_field_receiver_is_not_inferred() {
18181 let source = r#"struct Engine;
18182
18183struct Car {
18184 engine: &'static Engine,
18185}
18186
18187impl Car {
18188 fn run(&self) {
18189 self.engine.start();
18190 }
18191}
18192"#;
18193 let dir = tempdir().expect("temp dir");
18194 let root = dir.path();
18195 write_fixture(root, "src/lib.rs", source);
18196 let mut cache = DispatchSourceCache::new();
18197 let mut reference = reference(
18198 "rust",
18199 "src/lib.rs",
18200 "Car::run",
18201 "engine",
18202 "start",
18203 line_of(source, "self.engine.start()"),
18204 );
18205 reference.receiver_expression = "self.engine".to_string();
18206
18207 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
18208 }
18209
18210 #[test]
18211 fn rust_trait_impl_self_field_receiver_is_not_inferred() {
18212 let source = r#"trait Drive {
18213 fn run(&self);
18214}
18215
18216struct Engine;
18217
18218struct Car {
18219 engine: Engine,
18220}
18221
18222impl Drive for Car {
18223 fn run(&self) {
18224 self.engine.start();
18225 }
18226}
18227"#;
18228 let dir = tempdir().expect("temp dir");
18229 let root = dir.path();
18230 write_fixture(root, "src/lib.rs", source);
18231 let mut cache = DispatchSourceCache::new();
18232 let mut reference = reference(
18233 "rust",
18234 "src/lib.rs",
18235 "Car::run",
18236 "engine",
18237 "start",
18238 line_of(source, "self.engine.start()"),
18239 );
18240 reference.receiver_expression = "self.engine".to_string();
18241
18242 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
18243 }
18244
18245 #[test]
18246 fn rust_self_field_does_not_bind_struct_from_another_module() {
18247 let source = r#"struct Engine;
18248
18249mod unrelated {
18250 struct Car {
18251 engine: Engine,
18252 }
18253}
18254
18255impl Car {
18256 fn run(&self) {
18257 self.engine.start();
18258 }
18259}
18260"#;
18261 let dir = tempdir().expect("temp dir");
18262 let root = dir.path();
18263 write_fixture(root, "src/lib.rs", source);
18264 let mut cache = DispatchSourceCache::new();
18265 let mut reference = reference(
18266 "rust",
18267 "src/lib.rs",
18268 "Car::run",
18269 "engine",
18270 "start",
18271 line_of(source, "self.engine.start()"),
18272 );
18273 reference.receiver_expression = "self.engine".to_string();
18274
18275 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
18276 }
18277
18278 #[test]
18279 fn unknown_java_receiver_still_uses_name_match_fallback() {
18280 let source = r#"class EntryPoint {
18281 void handle() {
18282 service.runSpecial();
18283 }
18284}
18285
18286class OnlyService {
18287 void runSpecial() {}
18288}
18289"#;
18290 let dir = tempdir().expect("temp dir");
18291 let root = dir.path();
18292 write_fixture(root, "src/EntryPoint.java", source);
18293 let reference = reference(
18294 "java",
18295 "src/EntryPoint.java",
18296 "EntryPoint::handle",
18297 "service",
18298 "runSpecial",
18299 line_of(source, "service.runSpecial()"),
18300 );
18301 let mut cache = DispatchSourceCache::new();
18302
18303 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
18304 let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
18305 let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
18306 assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
18307 }
18308
18309 fn reference(
18310 lang: &str,
18311 caller_file: &str,
18312 caller_symbol: &str,
18313 receiver: &str,
18314 method_name: &str,
18315 line: u32,
18316 ) -> NameMatchRef {
18317 NameMatchRef {
18318 ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
18319 caller_node: format!("{caller_symbol}:node"),
18320 caller_file: caller_file.to_string(),
18321 caller_symbol: caller_symbol.to_string(),
18322 caller_signature: None,
18323 receiver_expression: receiver.to_string(),
18324 receiver: receiver.to_string(),
18325 method_name: method_name.to_string(),
18326 colon_dispatch: false,
18327 line,
18328 lang: lang.to_string(),
18329 }
18330 }
18331
18332 fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
18333 NameMatchCandidate {
18334 node_id: node_id.to_string(),
18335 file_path: "src/targets.fixture".to_string(),
18336 scoped_name: scoped_name.to_string(),
18337 kind: "method".to_string(),
18338 start_line: 1,
18339 }
18340 }
18341
18342 fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
18343 let path = root.join(rel_path);
18344 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
18345 fs::write(path, source).expect("write fixture");
18346 }
18347
18348 fn line_of(source: &str, needle: &str) -> u32 {
18349 source
18350 .lines()
18351 .position(|line| line.contains(needle))
18352 .map(|index| index as u32 + 1)
18353 .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
18354 }
18355}
18356
18357#[cfg(test)]
18358mod bounded_build_breaker_tests {
18359 use super::*;
18360 use crate::build_breaker::{BreakerAdmission, BreakerKey, BuildDeathBreaker, BuildDomain};
18361 use tempfile::tempdir;
18362
18363 #[test]
18364 fn staged_inventory_drives_ordered_bounded_file_batches() {
18365 let temp = tempdir().unwrap();
18366 let root = temp.path().join("root");
18367 std::fs::create_dir_all(&root).unwrap();
18368 let first = root.join("a.ts");
18369 let second = root.join("b.ts");
18370 let third = root.join("c.ts");
18371 for path in [&first, &second, &third] {
18372 std::fs::write(path, "export function item() {}\n").unwrap();
18373 }
18374 let writer_lease = acquire_writer_lease(temp.path(), "inventory-key", &root)
18375 .unwrap()
18376 .expect("test root may write its private staging database");
18377 let store = CallGraphStore::open_at_path(
18378 root.clone(),
18379 "inventory-key".to_string(),
18380 temp.path().join("inventory.sqlite"),
18381 None,
18382 true,
18383 Some(writer_lease),
18384 None,
18385 )
18386 .unwrap()
18387 .store;
18388 let fingerprint = store
18389 .stage_cold_build_file_inventory(&[
18390 third.clone(),
18391 first.clone(),
18392 second.clone(),
18393 first.clone(),
18394 ])
18395 .unwrap();
18396
18397 let conn = store.conn.lock().unwrap();
18398 assert_eq!(
18399 query_count(&conn, "SELECT COUNT(*) FROM staging_file_inventory").unwrap(),
18400 3,
18401 "the primary key deduplicates caller-supplied paths on disk"
18402 );
18403 let first_batch = load_staged_file_batch(&conn, &root, "", 2, u64::MAX)
18404 .unwrap()
18405 .expect("first batch");
18406 assert_eq!(first_batch.paths, vec![first.clone(), second]);
18407 let second_batch =
18408 load_staged_file_batch(&conn, &root, &first_batch.last_path, 2, u64::MAX)
18409 .unwrap()
18410 .expect("second batch");
18411 assert_eq!(second_batch.paths, vec![third]);
18412 assert_eq!(
18413 fingerprint,
18414 callgraph_corpus_fingerprint(&root).unwrap(),
18415 "staged and direct streaming fingerprints agree without walk-order dependence"
18416 );
18417 }
18418
18419 #[test]
18420 fn resumed_stage_preserves_committed_batch_and_counter() {
18421 let temp = tempdir().unwrap();
18422 let root = temp.path().join("root");
18423 std::fs::create_dir_all(&root).unwrap();
18424 let first = root.join("first.ts");
18425 let second = root.join("second.ts");
18426 std::fs::write(&first, "export function first() {}\n").unwrap();
18427 std::fs::write(&second, "export function second() { first(); }\n").unwrap();
18428 let staging = temp.path().join("stage.sqlite");
18429 let writer_lease = acquire_writer_lease(temp.path(), "test-key", &root)
18430 .unwrap()
18431 .expect("test root may write its private staging database");
18432 let store = CallGraphStore::open_at_path(
18433 root.clone(),
18434 "test-key".to_string(),
18435 staging,
18436 None,
18437 true,
18438 Some(writer_lease),
18439 None,
18440 )
18441 .unwrap()
18442 .store;
18443 let corpus_fingerprint = store
18444 .stage_cold_build_file_inventory(&[first.clone(), second.clone()])
18445 .unwrap();
18446 let first_extract = build_file_extract(&root, &first).unwrap();
18447 let first_bytes = first_extract.freshness.size;
18448 {
18449 let mut conn = store.conn.lock().unwrap();
18450 let tx = conn.transaction().unwrap();
18451 clear_tables(&tx).unwrap();
18452 insert_meta(&tx).unwrap();
18453 drop_cold_build_secondary_indexes(&tx).unwrap();
18454 set_meta_ready(&tx, false).unwrap();
18455 set_staged_build_phase(&tx, "extracting").unwrap();
18456 set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, &corpus_fingerprint).unwrap();
18457 set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0).unwrap();
18458 {
18459 let mut inserts = ColdBuildInsertStatements::new(&tx).unwrap();
18460 insert_file_extract_prepared(
18461 &mut inserts,
18462 &root.display().to_string(),
18463 &first_extract,
18464 )
18465 .unwrap();
18466 for raw in &first_extract.raw_refs {
18467 insert_staged_ref_prepared(&mut inserts, raw).unwrap();
18468 }
18469 }
18470 increment_staged_extracted_bytes(&tx, first_bytes).unwrap();
18471 tx.commit().unwrap();
18472 }
18473
18474 store
18475 .cold_build_chunked(&[first.clone(), second.clone()], 1)
18476 .unwrap();
18477 let conn = store.conn.lock().unwrap();
18478 assert_eq!(query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(), 2);
18479 assert_eq!(
18480 staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
18481 first_bytes + std::fs::metadata(second).unwrap().len(),
18482 "the already committed batch and its credit survive adoption; only the new batch increments credit"
18483 );
18484 assert_eq!(staged_build_phase(&conn).unwrap().as_deref(), Some("ready"));
18485 }
18486
18487 const SPECIMEN_CHILD_TEST: &str =
18488 "callgraph_store::bounded_build_breaker_tests::respawn_loop_build_child";
18489 const SPECIMEN_CHILD_ROOT: &str = "AFT_SPECIMEN_CHILD_ROOT";
18490 const SPECIMEN_CHILD_STORE: &str = "AFT_SPECIMEN_CHILD_STORE";
18491 const SPECIMEN_CHILD_PHASE: &str = "AFT_SPECIMEN_CHILD_PHASE";
18492 const SPECIMEN_CHILD_SIGNAL: &str = "AFT_SPECIMEN_CHILD_SIGNAL";
18493
18494 fn wait_for_child_barrier(path: &Path) {
18495 let deadline = Instant::now() + Duration::from_secs(10);
18496 while !path.exists() {
18497 assert!(
18498 Instant::now() < deadline,
18499 "callgraph child did not reach barrier {}",
18500 path.display()
18501 );
18502 std::thread::sleep(Duration::from_millis(5));
18503 }
18504 }
18505
18506 fn spawn_build_child(root: &Path, store: &Path, phase: Option<&str>) -> std::process::Child {
18507 let signal = store.join("specimen-child.reached");
18508 let _ = std::fs::remove_file(&signal);
18509 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
18510 command
18511 .arg("--exact")
18512 .arg(SPECIMEN_CHILD_TEST)
18513 .arg("--nocapture")
18514 .arg("--test-threads=1")
18515 .env(SPECIMEN_CHILD_ROOT, root)
18516 .env(SPECIMEN_CHILD_STORE, store)
18517 .env(SPECIMEN_CHILD_SIGNAL, &signal)
18518 .stdout(std::process::Stdio::null())
18519 .stderr(std::process::Stdio::null());
18520 if let Some(phase) = phase {
18521 command.env(SPECIMEN_CHILD_PHASE, phase);
18522 }
18523 command.spawn().unwrap()
18524 }
18525
18526 fn staging_path(root: &Path, store: &Path) -> PathBuf {
18527 let project_key = crate::search_index::artifact_cache_key(root);
18528 store.join(format!("{project_key}.staging.sqlite.tmp.resume"))
18529 }
18530
18531 fn durable_staging_state(path: &Path) -> (u64, u64) {
18532 if !path.exists() {
18533 return (0, 0);
18534 }
18535 let conn = Connection::open(path).unwrap();
18536 (
18537 query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(),
18538 staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
18539 )
18540 }
18541
18542 fn kill_barrier_child(child: &mut std::process::Child, signal: &Path) {
18543 wait_for_child_barrier(signal);
18544 child.kill().unwrap();
18545 let _ = child.wait().unwrap();
18546 }
18547
18548 #[test]
18549 fn respawn_loop_build_child() {
18550 let Some(root) = std::env::var_os(SPECIMEN_CHILD_ROOT) else {
18551 return;
18552 };
18553 let root = PathBuf::from(root);
18554 let store = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_STORE).unwrap());
18555 if let Some(phase) = std::env::var_os(SPECIMEN_CHILD_PHASE) {
18556 let phase = phase.to_string_lossy().into_owned();
18557 let signal = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_SIGNAL).unwrap());
18558 set_cold_build_phase_observer(Some(Arc::new(move |observed| {
18559 if observed == phase {
18560 std::fs::write(&signal, observed.as_bytes()).unwrap();
18561 std::thread::sleep(Duration::from_secs(30));
18562 }
18563 })));
18564 }
18565 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
18566 CallGraphStore::cold_build_with_lease_chunked(store, root, &files, 1).unwrap();
18567 }
18568
18569 #[test]
18570 fn issue_250_respawn_loop_converges_or_trips_without_false_readiness() {
18571 let temp = tempdir().unwrap();
18572 let root = temp.path().join("resumable-root");
18573 let store = temp.path().join("resumable-store");
18574 std::fs::create_dir_all(&root).unwrap();
18575 std::fs::create_dir_all(&store).unwrap();
18576 for index in 0..3 {
18577 std::fs::write(
18578 root.join(format!("file-{index}.ts")),
18579 format!("export function specimen{index}() {{ return {index}; }}\n"),
18580 )
18581 .unwrap();
18582 }
18583 let stage = staging_path(&root, &store);
18584 let signal = store.join("specimen-child.reached");
18585
18586 let mut first = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
18587 kill_barrier_child(&mut first, &signal);
18588 let (first_rows, first_bytes) = durable_staging_state(&stage);
18589 assert_eq!(first_rows, 1);
18590 assert!(first_bytes > 0);
18591
18592 let mut second = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
18593 kill_barrier_child(&mut second, &signal);
18594 let (second_rows, second_bytes) = durable_staging_state(&stage);
18595 assert_eq!(second_rows, 2);
18596 assert!(
18597 second_bytes > first_bytes,
18598 "a replacement process must adopt committed bytes instead of restarting from zero"
18599 );
18600
18601 let status = spawn_build_child(&root, &store, None).wait().unwrap();
18602 assert!(status.success(), "uninterrupted replacement build failed");
18603 assert!(!stage.exists(), "published staging file must be renamed");
18604 let ready = CallGraphStore::open_readonly(store.clone(), root.clone())
18605 .unwrap()
18606 .expect("replacement attempts must converge to a published graph");
18607 assert_eq!(ready.indexed_file_count().unwrap(), 3);
18608
18609 let fast_root = temp.path().join("zero-credit-root");
18610 let fast_store = temp.path().join("zero-credit-store");
18611 std::fs::create_dir_all(&fast_root).unwrap();
18612 std::fs::create_dir_all(&fast_store).unwrap();
18613 std::fs::write(
18614 fast_root.join("main.ts"),
18615 "export function neverCommitted() {}\n",
18616 )
18617 .unwrap();
18618 let fast_stage = staging_path(&fast_root, &fast_store);
18619 let fast_signal = fast_store.join("specimen-child.reached");
18620 let breaker_path = fast_store.join("build-breaker.sqlite");
18621 let now = unix_millis_now();
18622
18623 for death in 0..3 {
18624 let mut child = spawn_build_child(&fast_root, &fast_store, Some("enumeration"));
18625 wait_for_child_barrier(&fast_signal);
18626 let attempt_id = Connection::open(&breaker_path)
18627 .unwrap()
18628 .query_row(
18629 "SELECT attempt_id FROM breaker_attempts
18630 WHERE death_charged = 0 ORDER BY rowid DESC LIMIT 1",
18631 [],
18632 |row| row.get::<_, String>(0),
18633 )
18634 .unwrap();
18635 let (_, committed_bytes) = durable_staging_state(&fast_stage);
18636 assert_eq!(
18637 committed_bytes, 0,
18638 "the fast-kill schedule must not cross an extraction commit"
18639 );
18640 child.kill().unwrap();
18641 let _ = child.wait().unwrap();
18642
18643 let key = BreakerKey::new(
18644 fast_root.display().to_string(),
18645 BuildDomain::CallgraphCold,
18646 callgraph_corpus_fingerprint(&fast_root).unwrap(),
18647 );
18648 BuildDeathBreaker::open(&breaker_path)
18649 .unwrap()
18650 .record_attributed_death_at(&key, &attempt_id, committed_bytes, 0, now + death)
18651 .unwrap();
18652 }
18653
18654 let files = crate::callgraph::walk_project_files(&fast_root).collect::<Vec<_>>();
18655 let suspension = CallGraphStore::cold_build_suspension(&fast_store, &fast_root)
18656 .unwrap()
18657 .expect("three zero-credit process deaths must suspend the root");
18658 assert_eq!(suspension.reason, "zero_credit_death_limit");
18659 assert_eq!(suspension.death_count, 3);
18660 let response = crate::commands::callgraph_store_adapter::suspended_response(
18661 "specimen",
18662 "callers",
18663 &suspension,
18664 );
18665 assert_eq!(response.data["code"], serde_json::json!("build_suspended"));
18666 let message = response.data["message"].as_str().unwrap();
18667 assert!(
18668 message.starts_with("callers: build_suspended domain=callgraph_cold deaths=3 age_ms=")
18669 );
18670 assert!(message.ends_with(
18671 " reason=zero_credit_death_limit; run doctor reset-build-breaker to resume"
18672 ));
18673 let refused =
18674 CallGraphStore::cold_build_with_lease_chunked(fast_store, fast_root, &files, 1)
18675 .expect_err("a suspended root must not report a perpetually building worker");
18676 assert!(matches!(refused, CallGraphStoreError::Suspended(_)));
18677 }
18678
18679 #[test]
18680 fn published_callgraph_build_respects_durable_domain_suspension() {
18681 let temp = tempdir().unwrap();
18682 let root = temp.path().join("root");
18683 let store_dir = temp.path().join("store");
18684 std::fs::create_dir_all(&root).unwrap();
18685 let source = root.join("main.ts");
18686 std::fs::write(&source, "export function marker() {}\n").unwrap();
18687 let files = vec![source];
18688 let key = BreakerKey::new(
18689 root.display().to_string(),
18690 BuildDomain::CallgraphCold,
18691 callgraph_corpus_fingerprint(&root).unwrap(),
18692 );
18693 let breaker = BuildDeathBreaker::open(store_dir.join("build-breaker.sqlite")).unwrap();
18694 for _ in 0..3 {
18695 let BreakerAdmission::Admitted(attempt) = breaker.admit(&key, 0).unwrap() else {
18696 panic!("unexpected early suspension");
18697 };
18698 breaker
18699 .record_attributed_death(&key, &attempt.attempt_id, 0, 0)
18700 .unwrap();
18701 }
18702
18703 let error = CallGraphStore::cold_build_with_lease_chunked(store_dir, root, &files, 1)
18704 .expect_err("durably tripped callgraph domain must refuse a new cold build");
18705 assert!(matches!(
18706 error,
18707 CallGraphStoreError::Suspended(ref suspension)
18708 if suspension.domain == BuildDomain::CallgraphCold
18709 && suspension.death_count == 3
18710 ));
18711 }
18712}