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, parse_source_with_cached_parser, 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 if let Some(scope) = crate::logging::current_index_build() {
729 crate::logging::log_index_event(
730 crate::logging::IndexEvent::from_scope(
731 crate::logging::IndexEventKind::BuildProgress,
732 &scope,
733 )
734 .field("stage", stage)
735 .field("completed", completed)
736 .field("total", total)
737 .field("elapsed_ms", scope.elapsed_ms()),
738 );
739 }
740 return Ok(());
741 }
742 crate::slog_info!(
743 "callgraph cold build superseded, stopping after {}/{} ({})",
744 completed,
745 total,
746 stage
747 );
748 if let Some(scope) = crate::logging::current_index_build() {
749 crate::logging::log_index_event(
750 crate::logging::IndexEvent::from_scope(
751 crate::logging::IndexEventKind::BuildSuperseded,
752 &scope,
753 )
754 .field("stage", stage)
755 .field("completed", completed)
756 .field("total", total),
757 );
758 }
759 Err(CallGraphStoreError::Superseded)
760}
761
762fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
763 let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
764 match admission {
765 Some((epoch, expected)) => epoch
766 .run_if_current(expected, publish)
767 .unwrap_or(Err(CallGraphStoreError::Superseded)),
768 None => publish(),
769 }
770}
771
772struct RefreshCommitAdmissionGuard {
773 previous: Option<(
774 SubcLifecycleAdmission,
775 Arc<std::sync::atomic::AtomicU64>,
776 u64,
777 )>,
778}
779
780impl Drop for RefreshCommitAdmissionGuard {
781 fn drop(&mut self) {
782 REFRESH_COMMIT_ADMISSION.with(|slot| {
783 *slot.borrow_mut() = self.previous.take();
784 });
785 }
786}
787
788fn with_refresh_commit_admission<R>(
789 lifecycle: SubcLifecycleAdmission,
790 generation_flag: Arc<std::sync::atomic::AtomicU64>,
791 expected_generation: u64,
792 run: impl FnOnce() -> R,
793) -> R {
794 let previous = REFRESH_COMMIT_ADMISSION
795 .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
796 let _guard = RefreshCommitAdmissionGuard { previous };
797 run()
798}
799
800fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
801 let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
802 let commit = || {
803 publish_if_current(|| {
804 tx.commit()?;
805 Ok(())
806 })
807 };
808 match admission {
809 Some((lifecycle, generation_flag, expected_generation)) => lifecycle
810 .run_if_current(generation_flag.as_ref(), expected_generation, commit)
811 .unwrap_or(Err(CallGraphStoreError::Superseded)),
812 None => commit(),
813 }
814}
815
816fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
817 let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
818 if let Some(observer) = observer {
819 observer(temp_path, target_path);
820 }
821}
822
823#[derive(Debug)]
824pub enum CallGraphStoreError {
825 Io(std::io::Error),
826 Sqlite(rusqlite::Error),
827 Json(serde_json::Error),
828 Aft(AftError),
829 Lock(crate::fs_lock::AcquireError),
830 MissingCallerData {
831 file: String,
832 },
833 Unavailable(String),
834 PathIdentityMismatch {
835 path: PathBuf,
836 project_root: PathBuf,
837 },
838 Suspended(crate::build_breaker::BuildSuspension),
839 Superseded,
840 StaleFiles(Vec<String>),
841}
842
843impl CallGraphStoreError {
844 pub(crate) fn is_transient_lock_contention(&self) -> bool {
845 matches!(
846 self,
847 Self::Sqlite(rusqlite::Error::SqliteFailure(error, _))
848 if matches!(
849 error.code,
850 rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
851 )
852 )
853 }
854}
855
856impl fmt::Display for CallGraphStoreError {
857 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
858 match self {
859 Self::Io(error) => write!(formatter, "I/O error: {error}"),
860 Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
861 Self::Json(error) => write!(formatter, "json error: {error}"),
862 Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
863 Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
864 Self::MissingCallerData { file } => {
865 write!(formatter, "missing extracted caller data for {file}")
866 }
867 Self::Unavailable(message) => {
868 write!(formatter, "callgraph store unavailable: {message}")
869 }
870 Self::PathIdentityMismatch { path, project_root } => write!(
871 formatter,
872 "callgraph path identity mismatch: {} is not under project root {}",
873 path.display(),
874 project_root.display()
875 ),
876 Self::Suspended(suspension) => write!(
877 formatter,
878 "callgraph build suspended for {} after {} deaths ({})",
879 suspension.domain.as_str(),
880 suspension.death_count,
881 suspension.reason
882 ),
883 Self::Superseded => {
884 write!(formatter, "callgraph store build superseded before publish")
885 }
886 Self::StaleFiles(files) => {
887 write!(
888 formatter,
889 "callgraph store has stale files: {}",
890 files.join(", ")
891 )
892 }
893 }
894 }
895}
896
897impl std::error::Error for CallGraphStoreError {}
898
899impl From<std::io::Error> for CallGraphStoreError {
900 fn from(error: std::io::Error) -> Self {
901 Self::Io(error)
902 }
903}
904
905impl From<rusqlite::Error> for CallGraphStoreError {
906 fn from(error: rusqlite::Error) -> Self {
907 Self::Sqlite(error)
908 }
909}
910
911impl From<serde_json::Error> for CallGraphStoreError {
912 fn from(error: serde_json::Error) -> Self {
913 Self::Json(error)
914 }
915}
916
917impl From<AftError> for CallGraphStoreError {
918 fn from(error: AftError) -> Self {
919 Self::Aft(error)
920 }
921}
922
923impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
924 fn from(error: crate::fs_lock::AcquireError) -> Self {
925 Self::Lock(error)
926 }
927}
928
929pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
930
931pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
935
936#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
937pub struct CallGraphStoreOptions {
938 pub enabled: bool,
939}
940
941pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
942
943#[derive(Clone)]
947pub(crate) struct CallgraphRefreshState {
948 store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
949 heavy_root_work_allowed: Arc<AtomicBool>,
950}
951
952impl CallgraphRefreshState {
953 pub(crate) fn new(
954 store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
955 heavy_root_work_allowed: Arc<AtomicBool>,
956 ) -> Self {
957 Self {
958 store,
959 heavy_root_work_allowed,
960 }
961 }
962
963 fn installed_store_snapshot(&self) -> Option<Arc<ReadonlyCallGraphStore>> {
964 self.store
965 .read()
966 .unwrap_or_else(std::sync::PoisonError::into_inner)
967 .as_ref()
968 .map(Arc::clone)
969 }
970}
971
972type WorkspaceCratePrefixes = HashMap<String, String>;
973
974#[derive(Clone, Debug, Default)]
975struct WorkspaceCratePrefixCache(Arc<OnceLock<WorkspaceCratePrefixes>>);
976
977const REFRESH_WORKSPACE_CACHE_ROOT_CAP: usize = 128;
978
979pub(crate) fn invalidates_workspace_crate_prefix_cache(path: &Path) -> bool {
980 path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")
981}
982
983#[derive(Clone, Debug, Hash, PartialEq, Eq)]
984struct RefreshRoot {
985 callgraph_dir: PathBuf,
986 project_root: PathBuf,
987}
988
989#[derive(Clone)]
990pub(crate) struct CallgraphRefreshTicket {
991 lifecycle: SubcLifecycleAdmission,
992 generation_flag: Arc<std::sync::atomic::AtomicU64>,
993 expected_generation: u64,
994 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
995 expected_publish_epoch: u64,
996}
997
998impl CallgraphRefreshTicket {
999 pub(crate) fn new(
1000 lifecycle: SubcLifecycleAdmission,
1001 generation_flag: Arc<std::sync::atomic::AtomicU64>,
1002 expected_generation: u64,
1003 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
1004 expected_publish_epoch: u64,
1005 ) -> Self {
1006 Self {
1007 lifecycle,
1008 generation_flag,
1009 expected_generation,
1010 publish_epoch,
1011 expected_publish_epoch,
1012 }
1013 }
1014
1015 fn is_current(&self) -> bool {
1016 self.lifecycle
1017 .is_current(self.generation_flag.as_ref(), self.expected_generation)
1018 && self.publish_epoch.current() == self.expected_publish_epoch
1019 }
1020}
1021
1022#[derive(Clone)]
1023struct RefreshBatch {
1024 root: RefreshRoot,
1025 paths: BTreeSet<PathBuf>,
1026 pending_sinks: Vec<PendingCallGraphStorePaths>,
1027 refresh_states: Vec<CallgraphRefreshState>,
1028 ticket: Option<CallgraphRefreshTicket>,
1029}
1030
1031impl RefreshBatch {
1032 fn defer(&self) {
1033 for sink in &self.pending_sinks {
1034 sink.lock().extend(self.paths.iter().cloned());
1035 }
1036 }
1037
1038 fn defer_after_open_failure(&self) {
1039 self.defer();
1040 if self
1041 .ticket
1042 .as_ref()
1043 .is_some_and(|ticket| !ticket.is_current())
1044 || !self
1045 .refresh_states
1046 .iter()
1047 .any(|state| state.heavy_root_work_allowed.load(AtomicOrdering::SeqCst))
1048 {
1049 return;
1050 }
1051
1052 let ready_store_installed = self.refresh_states.iter().any(|state| {
1053 let store = state.installed_store_snapshot();
1054 store.is_some_and(|store| {
1055 store.project_root() == self.root.project_root
1056 && !store.is_legacy_fallback()
1057 && store.is_current()
1058 })
1059 });
1060 if !ready_store_installed {
1061 return;
1062 }
1063
1064 for sink in &self.pending_sinks {
1068 let paths = {
1069 let mut pending = sink.lock();
1070 self.paths
1071 .iter()
1072 .filter(|path| pending.remove(*path))
1073 .cloned()
1074 .collect::<Vec<_>>()
1075 };
1076 if paths.is_empty() {
1077 continue;
1078 }
1079 let _ = enqueue_callgraph_store_refresh_inner(
1080 self.root.callgraph_dir.clone(),
1081 self.root.project_root.clone(),
1082 paths,
1083 Arc::clone(sink),
1084 self.refresh_states.clone(),
1085 self.ticket.clone(),
1086 );
1087 }
1088 }
1089
1090 fn merge(
1091 &mut self,
1092 paths: impl IntoIterator<Item = PathBuf>,
1093 sink: PendingCallGraphStorePaths,
1094 refresh_states: Vec<CallgraphRefreshState>,
1095 ticket: Option<CallgraphRefreshTicket>,
1096 ) {
1097 self.paths.extend(paths);
1098 if ticket.is_some() {
1099 self.ticket = ticket;
1100 }
1101 if !self
1102 .pending_sinks
1103 .iter()
1104 .any(|existing| Arc::ptr_eq(existing, &sink))
1105 {
1106 self.pending_sinks.push(sink);
1107 }
1108 for refresh_state in refresh_states {
1109 if !self.refresh_states.iter().any(|existing| {
1110 Arc::ptr_eq(&existing.store, &refresh_state.store)
1111 && Arc::ptr_eq(
1112 &existing.heavy_root_work_allowed,
1113 &refresh_state.heavy_root_work_allowed,
1114 )
1115 }) {
1116 self.refresh_states.push(refresh_state);
1117 }
1118 }
1119 }
1120}
1121
1122#[derive(Default)]
1123struct RefreshQueue {
1124 order: VecDeque<RefreshRoot>,
1125 queued: HashMap<RefreshRoot, RefreshBatch>,
1126 active: Option<RefreshBatch>,
1127 shutdown_requested: bool,
1128}
1129
1130struct RefreshWorkerShared {
1131 queue: Mutex<RefreshQueue>,
1132 wake: Condvar,
1133}
1134
1135struct RefreshWorker {
1136 shared: Arc<RefreshWorkerShared>,
1137 thread: Mutex<Option<JoinHandle<()>>>,
1138}
1139
1140struct RefreshWorkerWatchdog {
1141 first_path: PathBuf,
1142 batch_len: usize,
1143 started: Instant,
1144}
1145
1146impl RefreshWorkerWatchdog {
1147 fn start(paths: &[PathBuf]) -> Self {
1148 Self {
1149 first_path: paths
1150 .first()
1151 .expect("non-empty callgraph refresh batch has a first path")
1152 .clone(),
1153 batch_len: paths.len(),
1154 started: Instant::now(),
1155 }
1156 }
1157}
1158
1159impl Drop for RefreshWorkerWatchdog {
1160 fn drop(&mut self) {
1161 let elapsed = self.started.elapsed();
1162 if elapsed < REFRESH_WORKER_WARN_AFTER {
1163 return;
1164 }
1165 let path = if self.batch_len == 1 {
1166 self.first_path.display().to_string()
1167 } else {
1168 format!(
1169 "{} (+{} paths)",
1170 self.first_path.display(),
1171 self.batch_len - 1
1172 )
1173 };
1174 log::warn!(
1175 "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
1176 path,
1177 elapsed.as_millis()
1178 );
1179 if elapsed >= REFRESH_WORKER_FINAL_AFTER {
1180 log::warn!(
1181 "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
1182 path,
1183 elapsed.as_millis()
1184 );
1185 }
1186 }
1187}
1188
1189impl RefreshWorker {
1190 fn spawn() -> Arc<Self> {
1191 let shared = Arc::new(RefreshWorkerShared {
1192 queue: Mutex::new(RefreshQueue::default()),
1193 wake: Condvar::new(),
1194 });
1195 let thread_shared = Arc::clone(&shared);
1196 let thread = std::thread::Builder::new()
1197 .name("aft-callgraph-refresh".to_string())
1198 .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
1199 .expect("failed to spawn callgraph refresh worker");
1200 Arc::new(Self {
1201 shared,
1202 thread: Mutex::new(Some(thread)),
1203 })
1204 }
1205
1206 fn enqueue(
1207 &self,
1208 root: RefreshRoot,
1209 paths: Vec<PathBuf>,
1210 pending_sink: PendingCallGraphStorePaths,
1211 refresh_states: Vec<CallgraphRefreshState>,
1212 ticket: Option<CallgraphRefreshTicket>,
1213 ) -> bool {
1214 let mut queue = self
1215 .shared
1216 .queue
1217 .lock()
1218 .expect("callgraph refresh queue mutex poisoned");
1219 if queue.shutdown_requested {
1220 pending_sink.lock().extend(paths);
1221 return false;
1222 }
1223 if let Some(batch) = queue.queued.get_mut(&root) {
1224 batch.merge(paths, pending_sink, refresh_states, ticket);
1225 } else {
1226 queue.order.push_back(root.clone());
1227 queue.queued.insert(
1228 root.clone(),
1229 RefreshBatch {
1230 root,
1231 paths: paths.into_iter().collect(),
1232 pending_sinks: vec![pending_sink],
1233 refresh_states,
1234 ticket,
1235 },
1236 );
1237 }
1238 self.shared.wake.notify_one();
1239 true
1240 }
1241
1242 fn shutdown_with_budget(&self, budget: Duration) -> bool {
1243 let deadline = Instant::now() + budget;
1244 let mut queue = self
1245 .shared
1246 .queue
1247 .lock()
1248 .expect("callgraph refresh queue mutex poisoned");
1249 queue.shutdown_requested = true;
1250 self.shared.wake.notify_one();
1251 while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
1252 let remaining = deadline.saturating_duration_since(Instant::now());
1253 let (next, _) = self
1254 .shared
1255 .wake
1256 .wait_timeout(queue, remaining)
1257 .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
1258 queue = next;
1259 }
1260 let drained = queue.active.is_none() && queue.order.is_empty();
1261 if !drained {
1262 if let Some(active) = queue.active.as_ref() {
1263 active.defer();
1264 }
1265 for batch in queue.queued.values() {
1266 batch.defer();
1267 }
1268 queue.order.clear();
1269 queue.queued.clear();
1270 }
1271 drop(queue);
1272
1273 if drained {
1274 if let Some(thread) = self
1275 .thread
1276 .lock()
1277 .expect("callgraph refresh worker thread mutex poisoned")
1278 .take()
1279 {
1280 let _ = thread.join();
1281 }
1282 }
1283 drained
1284 }
1285}
1286
1287static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
1288
1289pub fn enqueue_callgraph_store_refresh(
1290 callgraph_dir: PathBuf,
1291 project_root: PathBuf,
1292 paths: Vec<PathBuf>,
1293 pending_sink: PendingCallGraphStorePaths,
1294) -> bool {
1295 enqueue_callgraph_store_refresh_inner(
1296 callgraph_dir,
1297 project_root,
1298 paths,
1299 pending_sink,
1300 Vec::new(),
1301 None,
1302 )
1303}
1304
1305#[cfg(test)]
1306pub(crate) fn enqueue_callgraph_store_refresh_fenced(
1307 callgraph_dir: PathBuf,
1308 project_root: PathBuf,
1309 paths: Vec<PathBuf>,
1310 pending_sink: PendingCallGraphStorePaths,
1311 ticket: CallgraphRefreshTicket,
1312) -> bool {
1313 enqueue_callgraph_store_refresh_inner(
1314 callgraph_dir,
1315 project_root,
1316 paths,
1317 pending_sink,
1318 Vec::new(),
1319 Some(ticket),
1320 )
1321}
1322
1323pub(crate) fn enqueue_callgraph_store_refresh_fenced_with_state(
1324 callgraph_dir: PathBuf,
1325 project_root: PathBuf,
1326 paths: Vec<PathBuf>,
1327 pending_sink: PendingCallGraphStorePaths,
1328 refresh_state: CallgraphRefreshState,
1329 ticket: CallgraphRefreshTicket,
1330) -> bool {
1331 enqueue_callgraph_store_refresh_inner(
1332 callgraph_dir,
1333 project_root,
1334 paths,
1335 pending_sink,
1336 vec![refresh_state],
1337 Some(ticket),
1338 )
1339}
1340
1341fn enqueue_callgraph_store_refresh_inner(
1342 callgraph_dir: PathBuf,
1343 project_root: PathBuf,
1344 paths: Vec<PathBuf>,
1345 pending_sink: PendingCallGraphStorePaths,
1346 refresh_states: Vec<CallgraphRefreshState>,
1347 ticket: Option<CallgraphRefreshTicket>,
1348) -> bool {
1349 if paths.is_empty() {
1350 return true;
1351 }
1352 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1353 let worker = {
1354 let mut worker = slot
1355 .lock()
1356 .expect("callgraph refresh worker mutex poisoned");
1357 Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
1358 };
1359 worker.enqueue(
1360 RefreshRoot {
1361 callgraph_dir,
1362 project_root,
1363 },
1364 paths,
1365 pending_sink,
1366 refresh_states,
1367 ticket,
1368 )
1369}
1370
1371pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
1372 flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
1373}
1374
1375#[doc(hidden)]
1376pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
1377 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1378 let worker = slot
1379 .lock()
1380 .expect("callgraph refresh worker mutex poisoned")
1381 .clone();
1382 let Some(worker) = worker else {
1383 return true;
1384 };
1385 let drained = worker.shutdown_with_budget(budget);
1386 if drained {
1387 let mut current = slot
1388 .lock()
1389 .expect("callgraph refresh worker mutex poisoned");
1390 if current
1391 .as_ref()
1392 .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
1393 {
1394 *current = None;
1395 }
1396 }
1397 drained
1398}
1399
1400fn idle_checkpoint_due(last: Option<Instant>, now: Instant) -> bool {
1401 last.is_none_or(|last| now.saturating_duration_since(last) >= REFRESH_IDLE_CHECKPOINT_INTERVAL)
1402}
1403
1404fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
1405 let mut workspace_crate_prefixes = HashMap::new();
1408 let mut last_idle_checkpoints: HashMap<RefreshRoot, Instant> = HashMap::new();
1409 loop {
1410 let batch = {
1411 let mut queue = shared
1412 .queue
1413 .lock()
1414 .expect("callgraph refresh queue mutex poisoned");
1415 loop {
1416 if let Some(root) = queue.order.pop_front() {
1417 let batch = queue
1418 .queued
1419 .remove(&root)
1420 .expect("queued callgraph refresh root has a batch");
1421 queue.active = Some(batch.clone());
1422 break batch;
1423 }
1424 if queue.shutdown_requested {
1425 return;
1426 }
1427 queue = shared
1428 .wake
1429 .wait(queue)
1430 .expect("callgraph refresh queue mutex poisoned while waiting");
1431 }
1432 };
1433
1434 let store = process_callgraph_refresh_batch(&batch, &mut workspace_crate_prefixes);
1435
1436 let mut queue = shared
1437 .queue
1438 .lock()
1439 .expect("callgraph refresh queue mutex poisoned");
1440 queue.active = None;
1441 let became_idle = queue.order.is_empty();
1442 shared.wake.notify_all();
1443 drop(queue);
1444
1445 if became_idle {
1446 let checkpoint_due = idle_checkpoint_due(
1447 last_idle_checkpoints.get(&batch.root).copied(),
1448 Instant::now(),
1449 );
1450 if checkpoint_due {
1451 if let Some(store) = store {
1452 if store.checkpoint_wal_truncate() {
1453 last_idle_checkpoints.insert(batch.root.clone(), Instant::now());
1454 }
1455 }
1456 }
1457 }
1458 }
1459}
1460
1461fn process_callgraph_refresh_batch(
1462 batch: &RefreshBatch,
1463 workspace_crate_prefixes: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1464) -> Option<CallGraphStore> {
1465 if batch
1469 .paths
1470 .iter()
1471 .any(|path| invalidates_workspace_crate_prefix_cache(path))
1472 {
1473 workspace_crate_prefixes.remove(&batch.root);
1474 }
1475
1476 let paths = batch
1477 .paths
1478 .iter()
1479 .filter(|path| crate::parser::detect_language(path).is_some())
1480 .cloned()
1481 .collect::<Vec<_>>();
1482 if paths.is_empty() {
1483 return None;
1484 }
1485 note_refresh_worker_batch_for_test(&batch.root.project_root);
1486 if batch
1487 .ticket
1488 .as_ref()
1489 .is_some_and(|ticket| !ticket.is_current())
1490 {
1491 batch.defer();
1494 return None;
1495 }
1496 let workspace_crate_prefix_cache =
1497 workspace_crate_prefix_cache_for_root(workspace_crate_prefixes, &batch.root);
1498 let _watchdog = RefreshWorkerWatchdog::start(&paths);
1499 let test_seam = refresh_worker_test_seam(&batch.root.project_root);
1500 note_refresh_worker_call_for_test(&batch.root.project_root);
1501 let opened = if test_seam.fail_open {
1502 Ok(None)
1503 } else {
1504 CallGraphStore::open_ready(
1505 batch.root.callgraph_dir.clone(),
1506 batch.root.project_root.clone(),
1507 )
1508 };
1509 if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
1510 let _ = gate.held_tx.send(());
1513 let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
1514 }
1515 let store = match opened {
1516 Ok(Some(store)) => store,
1517 Ok(None) => {
1518 batch.defer_after_open_failure();
1519 return None;
1520 }
1521 Err(error) => {
1522 batch.defer_after_open_failure();
1523 crate::slog_warn!(
1524 "callgraph store writer open failed during refresh; deferred paths: {}",
1525 error
1526 );
1527 return None;
1528 }
1529 };
1530 if !test_seam.delay.is_zero() {
1531 std::thread::sleep(test_seam.delay);
1532 }
1533 if batch
1534 .ticket
1535 .as_ref()
1536 .is_some_and(|ticket| !ticket.is_current())
1537 {
1538 batch.defer();
1541 return Some(store);
1542 }
1543 let refresh_result = if test_seam.fail_refresh {
1544 Err(CallGraphStoreError::Unavailable(
1545 "injected refresh worker failure".to_string(),
1546 ))
1547 } else if let Some(ticket) = &batch.ticket {
1548 with_publish_epoch(
1549 ticket.publish_epoch.clone(),
1550 ticket.expected_publish_epoch,
1551 || {
1552 with_refresh_commit_admission(
1553 ticket.lifecycle.clone(),
1554 Arc::clone(&ticket.generation_flag),
1555 ticket.expected_generation,
1556 || {
1557 store
1558 .refresh_files_with_workspace_crate_prefix_cache(
1559 &paths,
1560 workspace_crate_prefix_cache.clone(),
1561 )
1562 .map(|_| ())
1563 },
1564 )
1565 },
1566 )
1567 } else {
1568 store
1569 .refresh_files_with_workspace_crate_prefix_cache(
1570 &paths,
1571 workspace_crate_prefix_cache.clone(),
1572 )
1573 .map(|_| ())
1574 };
1575 if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
1576 batch.defer();
1580 return Some(store);
1581 }
1582 if let Err(error) = refresh_result {
1583 crate::slog_warn!("callgraph store refresh failed: {}", error);
1584 match store.mark_files_stale(&paths) {
1585 Ok(marked) => {
1586 note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
1587 crate::slog_warn!(
1588 "marked {} callgraph store file(s) stale after refresh failure",
1589 marked.len()
1590 );
1591 }
1592 Err(mark_error) => crate::slog_warn!(
1593 "failed to mark callgraph store files stale after refresh failure: {}",
1594 mark_error
1595 ),
1596 }
1597 } else {
1598 crate::logging::note_callgraph_invalidations(paths.len());
1599 }
1600 Some(store)
1601}
1602
1603fn workspace_crate_prefix_cache_for_root(
1604 caches: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1605 root: &RefreshRoot,
1606) -> WorkspaceCratePrefixCache {
1607 if !caches.contains_key(root) && caches.len() >= REFRESH_WORKSPACE_CACHE_ROOT_CAP {
1608 if let Some(evicted) = caches.keys().next().cloned() {
1610 caches.remove(&evicted);
1611 }
1612 }
1613 caches.entry(root.clone()).or_default().clone()
1614}
1615
1616#[derive(Clone, Copy, Default)]
1617struct RefreshWorkerTestSeam {
1618 delay: Duration,
1619 fail_refresh: bool,
1620 fail_open: bool,
1621 refresh_calls: usize,
1622 worker_calls: usize,
1623 stale_marks: usize,
1624}
1625
1626static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
1627 OnceLock::new();
1628
1629struct RefreshWorkerTestGate {
1630 held_tx: crossbeam_channel::Sender<()>,
1631 release_rx: crossbeam_channel::Receiver<()>,
1632}
1633
1634static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
1635 OnceLock::new();
1636
1637#[doc(hidden)]
1638pub fn install_callgraph_refresh_worker_test_gate(
1639 project_root: PathBuf,
1640) -> (
1641 crossbeam_channel::Receiver<()>,
1642 crossbeam_channel::Sender<()>,
1643) {
1644 let (held_tx, held_rx) = crossbeam_channel::bounded(1);
1645 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1646 REFRESH_WORKER_TEST_GATES
1647 .get_or_init(|| Mutex::new(HashMap::new()))
1648 .lock()
1649 .expect("callgraph refresh test gate mutex poisoned")
1650 .insert(
1651 project_root,
1652 RefreshWorkerTestGate {
1653 held_tx,
1654 release_rx,
1655 },
1656 );
1657 (held_rx, release_tx)
1658}
1659
1660fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
1661 REFRESH_WORKER_TEST_GATES
1662 .get_or_init(|| Mutex::new(HashMap::new()))
1663 .lock()
1664 .expect("callgraph refresh test gate mutex poisoned")
1665 .remove(project_root)
1666}
1667
1668fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
1669 let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
1670 return RefreshWorkerTestSeam::default();
1671 };
1672 seams
1673 .lock()
1674 .expect("callgraph refresh test seam mutex poisoned")
1675 .get(project_root)
1676 .copied()
1677 .unwrap_or_default()
1678}
1679
1680fn note_refresh_worker_batch_for_test(project_root: &Path) {
1681 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1682 if let Some(seam) = seams
1683 .lock()
1684 .expect("callgraph refresh test seam mutex poisoned")
1685 .get_mut(project_root)
1686 {
1687 seam.worker_calls += 1;
1688 }
1689 }
1690}
1691
1692fn note_refresh_worker_call_for_test(project_root: &Path) {
1693 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1694 if let Some(seam) = seams
1695 .lock()
1696 .expect("callgraph refresh test seam mutex poisoned")
1697 .get_mut(project_root)
1698 {
1699 seam.refresh_calls += 1;
1700 }
1701 }
1702}
1703
1704fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
1705 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1706 if let Some(seam) = seams
1707 .lock()
1708 .expect("callgraph refresh test seam mutex poisoned")
1709 .get_mut(project_root)
1710 {
1711 seam.stale_marks += 1;
1712 }
1713 }
1714}
1715
1716#[doc(hidden)]
1717pub fn set_callgraph_refresh_worker_test_seam(
1718 project_root: PathBuf,
1719 delay: Duration,
1720 fail_refresh: bool,
1721) {
1722 REFRESH_WORKER_TEST_SEAMS
1723 .get_or_init(|| Mutex::new(HashMap::new()))
1724 .lock()
1725 .expect("callgraph refresh test seam mutex poisoned")
1726 .insert(
1727 project_root,
1728 RefreshWorkerTestSeam {
1729 delay,
1730 fail_refresh,
1731 ..RefreshWorkerTestSeam::default()
1732 },
1733 );
1734}
1735
1736#[doc(hidden)]
1737pub fn set_callgraph_refresh_worker_test_open_failure(project_root: PathBuf, enabled: bool) {
1738 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1739 if let Some(seam) = seams
1740 .lock()
1741 .expect("callgraph refresh test seam mutex poisoned")
1742 .get_mut(&project_root)
1743 {
1744 seam.fail_open = enabled;
1745 }
1746 }
1747}
1748
1749#[doc(hidden)]
1750pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
1751 let seam = refresh_worker_test_seam(project_root);
1752 (seam.refresh_calls, seam.stale_marks)
1753}
1754
1755#[doc(hidden)]
1756pub fn callgraph_refresh_worker_test_worker_calls(project_root: &Path) -> usize {
1757 refresh_worker_test_seam(project_root).worker_calls
1758}
1759
1760#[doc(hidden)]
1761pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
1762 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1763 seams
1764 .lock()
1765 .expect("callgraph refresh test seam mutex poisoned")
1766 .remove(project_root);
1767 }
1768}
1769
1770#[derive(Debug)]
1771pub struct CallGraphStore {
1772 project_root: PathBuf,
1773 project_key: String,
1774 sqlite_path: PathBuf,
1778 publication_dir: PathBuf,
1782 legacy_fallback: bool,
1786 generation: Option<String>,
1791 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1792 read_marker: Option<crate::root_cache::ReadMarker>,
1793 database_ready: AtomicBool,
1796 write_metrics: Arc<CallgraphWriteMetrics>,
1797 conn: Mutex<Connection>,
1798}
1799
1800#[derive(Debug)]
1801pub struct ReadonlyCallGraphStore {
1802 inner: CallGraphStore,
1803}
1804
1805pub trait CallGraphRead {
1806 fn project_root(&self) -> &Path;
1807 fn project_key(&self) -> &str;
1808 fn sqlite_path(&self) -> &Path;
1809 fn is_current(&self) -> bool;
1810 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
1811 fn indexed_file_count(&self) -> Result<usize>;
1812 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
1813 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
1814 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
1815 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
1816 fn direct_callers_for_symbols(
1817 &self,
1818 targets: &[(String, String)],
1819 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
1820 targets
1821 .iter()
1822 .cloned()
1823 .map(|target| {
1824 let callers = self.direct_callers_of(Path::new(&target.0), &target.1)?;
1825 Ok((target, callers))
1826 })
1827 .collect()
1828 }
1829 fn direct_caller_counts_of(
1830 &self,
1831 targets: &[(String, String)],
1832 ) -> Result<HashMap<(String, String), usize>>;
1833 fn outgoing_calls_for_symbols(
1834 &self,
1835 sources: &[(String, String)],
1836 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
1837 fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
1838 -> Result<StoreCallersResult>;
1839 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
1840 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1841 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1842 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
1843 fn call_tree(
1844 &self,
1845 file_rel: &Path,
1846 symbol: &str,
1847 depth: usize,
1848 ) -> Result<callgraph::CallTreeNode>;
1849 fn trace_to(
1850 &self,
1851 file_rel: &Path,
1852 symbol: &str,
1853 max_depth: usize,
1854 ) -> Result<callgraph::TraceToResult>;
1855 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
1856 fn trace_to_symbol(
1857 &self,
1858 file_rel: &Path,
1859 symbol: &str,
1860 to_symbol: &str,
1861 to_file: Option<&Path>,
1862 max_depth: usize,
1863 ) -> Result<callgraph::TraceToSymbolResult>;
1864}
1865
1866#[derive(Debug, Clone, PartialEq, Eq)]
1867enum OpenRootRepair {
1868 None,
1869 ReRooted,
1870 NeedsRebuild {
1871 previous_roots: Vec<String>,
1872 current_root: String,
1873 reason: String,
1874 },
1875}
1876
1877struct OpenedStore {
1878 store: CallGraphStore,
1879 root_repair: OpenRootRepair,
1880}
1881
1882#[derive(Clone, Debug)]
1883struct LegacyCallgraphPartition {
1884 harness: String,
1885 dir: PathBuf,
1886 key: String,
1887 bytes: u64,
1888 freshness: Option<SystemTime>,
1889}
1890
1891#[derive(Clone, Debug)]
1892struct LegacyCallgraphTarget {
1893 partition: LegacyCallgraphPartition,
1894 sqlite_path: PathBuf,
1895 generation: Option<String>,
1896 source_bytes: u64,
1897 source_blake3: String,
1898}
1899
1900#[derive(Clone, Debug)]
1901struct SourceFingerprint {
1902 bytes: u64,
1903 blake3: String,
1904}
1905
1906#[derive(Clone, Debug)]
1907struct PublishedLegacyMigration {
1908 generation: String,
1909 migrated_bytes: u64,
1910}
1911
1912#[derive(Debug, Clone)]
1913pub struct ColdBuildStats {
1914 pub files: usize,
1915 pub nodes: usize,
1916 pub refs: usize,
1917 pub edges: usize,
1918 pub failed_files: Vec<String>,
1919 pub elapsed_ms: u128,
1920}
1921
1922#[derive(Debug, Clone)]
1923pub struct IncrementalStats {
1924 pub changed_files: Vec<String>,
1925 pub surface_changed: Vec<String>,
1926 pub deleted_files: Vec<String>,
1927 pub dependency_selected_refs: usize,
1928 pub refreshed_own_files: usize,
1929 pub unchanged_extract_files: usize,
1930}
1931
1932#[doc(hidden)]
1934#[derive(Debug, Clone, Default, PartialEq, Eq)]
1935pub struct RefreshFilesProfile {
1936 pub parse: Duration,
1937 pub dependency_selection: Duration,
1938 pub row_deletes: Duration,
1939 pub row_inserts: Duration,
1940 pub dependent_parse: Duration,
1941 pub index_load: Duration,
1942 pub ref_resolution: Duration,
1943 pub method_dispatch: Duration,
1944 pub commit: Duration,
1945 pub total: Duration,
1946}
1947
1948impl RefreshFilesProfile {
1949 pub fn report(&self) -> String {
1950 format!(
1951 "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",
1952 self.parse.as_millis(),
1953 self.dependency_selection.as_millis(),
1954 self.row_deletes.as_millis(),
1955 self.row_inserts.as_millis(),
1956 self.dependent_parse.as_millis(),
1957 self.index_load.as_millis(),
1958 self.ref_resolution.as_millis(),
1959 self.method_dispatch.as_millis(),
1960 self.commit.as_millis(),
1961 self.total.as_millis(),
1962 )
1963 }
1964}
1965
1966#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1967pub struct StoredEdge {
1968 pub source_file: String,
1969 pub source_symbol: String,
1970 pub target_file: String,
1971 pub target_symbol: String,
1972 pub kind: String,
1973 pub line: u32,
1974}
1975
1976#[derive(Debug, Clone, PartialEq, Eq)]
1977pub struct StoreNode {
1978 node_id: String,
1979 pub file: String,
1980 pub symbol: String,
1981 pub name: String,
1982 pub kind: String,
1983 pub line: u32,
1984 pub end_line: u32,
1985 pub signature: Option<String>,
1986 pub exported: bool,
1987 pub is_entry_point: bool,
1988 pub lang: LangId,
1989}
1990
1991#[cfg(test)]
1992impl StoreNode {
1993 pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
1994 Self {
1995 node_id: format!("{file}:{symbol}"),
1996 file: file.to_string(),
1997 symbol: symbol.to_string(),
1998 name: symbol.to_string(),
1999 kind: "function".to_string(),
2000 line: 1,
2001 end_line: 1,
2002 signature: None,
2003 exported: is_entry_point,
2004 is_entry_point,
2005 lang: LangId::TypeScript,
2006 }
2007 }
2008}
2009
2010#[derive(Debug, Clone, PartialEq, Eq)]
2011pub struct StoreCallSite {
2012 pub caller: StoreNode,
2013 pub target_file: String,
2014 pub target_symbol: String,
2015 pub target: Option<StoreNode>,
2016 pub line: u32,
2017 pub byte_start: usize,
2018 pub byte_end: usize,
2019 pub resolved: bool,
2020 pub provenance: String,
2021}
2022
2023impl StoreCallSite {
2024 pub fn approximate(&self) -> bool {
2025 self.provenance == PROVENANCE_NAME_MATCH
2026 }
2027
2028 pub fn resolved_by(&self) -> &str {
2029 &self.provenance
2030 }
2031
2032 pub fn supplemental_resolution(&self) -> Option<&str> {
2033 match self.provenance.as_str() {
2034 PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
2035 _ => None,
2036 }
2037 }
2038}
2039
2040#[derive(Debug, Clone, PartialEq, Eq)]
2041pub struct StoreUnresolvedCall {
2042 pub caller: StoreNode,
2043 pub symbol: String,
2044 pub full_ref: Option<String>,
2045 pub line: u32,
2046 pub byte_start: usize,
2047 pub byte_end: usize,
2048}
2049
2050#[derive(Debug, Clone, PartialEq, Eq)]
2051pub struct StoreCallersResult {
2052 pub target: StoreNode,
2053 pub callers: Vec<StoreCallSite>,
2054 pub scanned_files: usize,
2055 pub depth_limited: bool,
2056 pub truncated: usize,
2057}
2058
2059#[derive(Debug, Clone, PartialEq, Eq)]
2060pub struct StoreImpactCaller {
2061 pub site: StoreCallSite,
2062 pub signature: Option<String>,
2063 pub is_entry_point: bool,
2064 pub call_expression: Option<String>,
2065 pub parameters: Vec<String>,
2066}
2067
2068#[derive(Debug, Clone, PartialEq, Eq)]
2069pub struct StoreImpactResult {
2070 pub target: StoreNode,
2071 pub parameters: Vec<String>,
2072 pub callers: Vec<StoreImpactCaller>,
2073 pub depth_limited: bool,
2074 pub truncated: usize,
2075}
2076
2077#[derive(Debug, Clone)]
2078struct ExtractFailure {
2079 rel_path: String,
2080 freshness: Option<FileFreshness>,
2081}
2082
2083#[derive(Debug, Clone)]
2084struct BuildExtractsResult {
2085 extracts: Vec<FileExtract>,
2086 failures: Vec<ExtractFailure>,
2087}
2088
2089#[derive(Debug, Clone)]
2090enum StoreForwardCall {
2091 Resolved(StoreCallSite),
2092 Unresolved(StoreUnresolvedCall),
2093}
2094
2095impl StoreForwardCall {
2096 fn byte_start(&self) -> usize {
2097 match self {
2098 Self::Resolved(site) => site.byte_start,
2099 Self::Unresolved(call) => call.byte_start,
2100 }
2101 }
2102
2103 fn line(&self) -> u32 {
2104 match self {
2105 Self::Resolved(site) => site.line,
2106 Self::Unresolved(call) => call.line,
2107 }
2108 }
2109}
2110
2111#[derive(Debug, Clone)]
2112struct FileExtract {
2113 rel_path: String,
2114 freshness: FileFreshness,
2115 lang: LangId,
2116 data: FileCallData,
2117 nodes: Vec<NodeRecord>,
2118 raw_refs: Vec<RawRef>,
2119 dispatch_hints: Vec<DispatchHint>,
2120 surface_fingerprint: String,
2121}
2122
2123#[derive(Debug, Clone)]
2124struct NodeRecord {
2125 id: String,
2126 file_path: String,
2127 name: String,
2128 scoped_name: String,
2129 kind: String,
2130 range: Range,
2131 range_ordinal: u32,
2132 signature: Option<String>,
2133 exported: bool,
2134 is_default_export: bool,
2135 is_type_like: bool,
2136 is_callgraph_entry_point: bool,
2137}
2138
2139#[derive(Debug, Clone)]
2140struct RawRef {
2141 ref_id: String,
2142 caller_node: Option<String>,
2143 caller_symbol: Option<String>,
2144 caller_file: String,
2145 kind: String,
2146 short_name: Option<String>,
2147 full_ref: Option<String>,
2148 module_path: Option<String>,
2149 import_kind: Option<String>,
2150 local_name: Option<String>,
2151 requested_name: Option<String>,
2152 namespace_alias: Option<String>,
2153 wildcard: bool,
2154 line: u32,
2155 byte_start: usize,
2156 byte_end: usize,
2157 dependencies: BTreeSet<String>,
2158}
2159
2160#[derive(Debug)]
2164struct StagedRef {
2165 rowid: u64,
2166 raw: RawRef,
2167}
2168
2169#[derive(Debug, Clone)]
2170struct ResolvedRef {
2171 raw: RawRef,
2172 status: String,
2173 target_node: Option<String>,
2174 target_file: Option<String>,
2175 target_symbol: Option<String>,
2176 dependencies: BTreeSet<String>,
2177 edge: Option<EdgeRecord>,
2178}
2179
2180#[derive(Debug, Clone)]
2181struct EdgeRecord {
2182 edge_id: String,
2183 source_node: String,
2184 target_node: Option<String>,
2185 target_file: String,
2186 target_symbol: String,
2187 kind: String,
2188 line: u32,
2189}
2190
2191#[derive(Debug, Clone)]
2192struct DispatchHint {
2193 id: String,
2194 method_name: String,
2195 caller_node: String,
2196 file: String,
2197 line: u32,
2198 byte_start: usize,
2199 byte_end: usize,
2200}
2201
2202#[derive(Debug, Clone)]
2203struct NameMatchRef {
2204 ref_id: String,
2205 caller_node: String,
2206 caller_file: String,
2207 caller_symbol: String,
2208 caller_signature: Option<String>,
2209 receiver_expression: String,
2210 receiver: String,
2211 method_name: String,
2212 colon_dispatch: bool,
2213 line: u32,
2214 lang: String,
2215}
2216
2217#[derive(Debug, Clone)]
2218struct NameMatchCandidate {
2219 node_id: String,
2220 file_path: String,
2221 scoped_name: String,
2222 kind: String,
2223 start_line: u32,
2225}
2226
2227#[derive(Debug, Clone)]
2228struct FileRow {
2229 surface_fingerprint: String,
2230 freshness: FileFreshness,
2231}
2232
2233#[derive(Debug, Clone)]
2234struct DbFileIndex {
2235 lang: Option<LangId>,
2236 exports: HashSet<String>,
2237 default_export: Option<String>,
2238 export_aliases: HashMap<String, String>,
2239 node_by_scoped: HashMap<String, String>,
2240 node_by_bare: HashMap<String, String>,
2241 node_kind_by_id: HashMap<String, String>,
2242 module_targets: HashMap<String, Option<String>>,
2243 declared_module_targets: HashMap<String, Option<String>>,
2244 reexports: Vec<ReexportIndex>,
2245}
2246
2247#[derive(Debug, Clone)]
2248struct ReexportIndex {
2249 target_file: Option<String>,
2250 named: HashMap<String, String>,
2251 wildcard: bool,
2252}
2253
2254#[derive(Debug, Clone)]
2255struct ProjectIndex<'a> {
2256 project_root: PathBuf,
2257 files: HashMap<String, DbFileIndex>,
2258 caller_data: HashMap<String, &'a FileCallData>,
2259 workspace_crate_prefixes: WorkspaceCratePrefixCache,
2264}
2265
2266trait ResolverIndex {
2270 fn caller_data(&self, file: &str) -> Option<&FileCallData>;
2271 fn lang_for(&self, file: &str) -> Option<LangId>;
2272 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String>;
2273 fn module_parent(&self, target_file: &str) -> Option<(String, String)>;
2274 fn reexports_for(&self, file: &str) -> Vec<ReexportIndex>;
2275 fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String>;
2276 fn node_is_callable(&self, file: &str, node_id: &str) -> bool;
2277 fn export_alias(&self, file: &str, symbol: &str) -> Option<String>;
2278 fn has_export(&self, file: &str, symbol: &str) -> bool;
2279 fn default_export(&self, file: &str) -> Option<String>;
2280 fn contains_file(&self, file: &str) -> bool;
2281 fn crate_src_prefix(&self, crate_name: &str) -> Option<String>;
2282 fn inline_scoped_target(
2283 &self,
2284 caller_file: &str,
2285 module_segments: &[String],
2286 short_name: &str,
2287 ) -> Option<(String, String)>;
2288}
2289
2290impl ResolverIndex for ProjectIndex<'_> {
2291 fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2292 self.caller_data.get(file).copied()
2293 }
2294
2295 fn lang_for(&self, file: &str) -> Option<LangId> {
2296 self.lang_for(file)
2297 }
2298
2299 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2300 self.module_target(caller_file, module_path)
2301 }
2302
2303 fn module_parent(&self, target_file: &str) -> Option<(String, String)> {
2304 let mut parents = self
2305 .files
2306 .iter()
2307 .flat_map(|(file, index)| {
2308 index
2309 .declared_module_targets
2310 .iter()
2311 .filter_map(move |(module, target)| {
2312 (target.as_deref() == Some(target_file))
2313 .then(|| (file.clone(), module.clone()))
2314 })
2315 })
2316 .collect::<Vec<_>>();
2317 parents.sort();
2318 parents.into_iter().next()
2319 }
2320
2321 fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2322 self.reexports_for(file).to_vec()
2323 }
2324
2325 fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2326 self.node_for_symbol(file, symbol)
2327 }
2328
2329 fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2330 self.node_is_callable(file, node_id)
2331 }
2332
2333 fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2334 self.files
2335 .get(file)
2336 .and_then(|item| item.export_aliases.get(symbol))
2337 .cloned()
2338 }
2339
2340 fn has_export(&self, file: &str, symbol: &str) -> bool {
2341 self.files
2342 .get(file)
2343 .is_some_and(|item| item.exports.contains(symbol))
2344 }
2345
2346 fn default_export(&self, file: &str) -> Option<String> {
2347 self.files
2348 .get(file)
2349 .and_then(|item| item.default_export.clone())
2350 }
2351
2352 fn contains_file(&self, file: &str) -> bool {
2353 self.files.contains_key(file)
2354 }
2355
2356 fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2357 self.workspace_crate_prefixes
2358 .0
2359 .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
2360 .get(crate_name)
2361 .cloned()
2362 }
2363
2364 fn inline_scoped_target(
2365 &self,
2366 caller_file: &str,
2367 module_segments: &[String],
2368 short_name: &str,
2369 ) -> Option<(String, String)> {
2370 let src_prefix = rust_src_prefix(caller_file);
2371 let mut file_paths = self.files.keys().cloned().collect::<Vec<_>>();
2372 file_paths.sort();
2373 if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
2374 let caller = file_paths.remove(position);
2375 file_paths.insert(0, caller);
2376 }
2377 for file_path in file_paths {
2378 if self.lang_for(&file_path) != Some(LangId::Rust)
2379 || rust_src_prefix(&file_path) != src_prefix
2380 {
2381 continue;
2382 }
2383 let file_module_segments = rust_module_segments_for_rel(&file_path);
2384 if !module_segments.starts_with(&file_module_segments) {
2385 continue;
2386 }
2387 let scoped_segments = &module_segments[file_module_segments.len()..];
2388 if scoped_segments.is_empty() {
2389 continue;
2390 }
2391 let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2392 if self.node_for_symbol(&file_path, &scoped_symbol).is_some() {
2393 return Some((file_path, scoped_symbol));
2394 }
2395 }
2396 None
2397 }
2398}
2399
2400struct DiskProjectIndex<'a> {
2404 project_root: &'a Path,
2405 conn: &'a Connection,
2406 caller_file: &'a str,
2407 caller_data: &'a FileCallData,
2408 workspace_crate_prefixes: WorkspaceCratePrefixCache,
2409 module_resolution_memo: &'a callgraph::ModuleResolutionMemo,
2410}
2411
2412impl DiskProjectIndex<'_> {
2413 fn file_index(&self, rel_path: &str) -> Option<DbFileIndex> {
2414 let lang: String = self
2415 .conn
2416 .query_row(
2417 "SELECT lang FROM files WHERE path = ?1",
2418 params![rel_path],
2419 |row| row.get(0),
2420 )
2421 .optional()
2422 .ok()??;
2423 let mut index = DbFileIndex {
2424 lang: lang_from_label(&lang),
2425 exports: HashSet::new(),
2426 default_export: None,
2427 export_aliases: HashMap::new(),
2428 node_by_scoped: HashMap::new(),
2429 node_by_bare: HashMap::new(),
2430 node_kind_by_id: HashMap::new(),
2431 module_targets: HashMap::new(),
2432 declared_module_targets: HashMap::new(),
2433 reexports: Vec::new(),
2434 };
2435 let mut nodes = self
2436 .conn
2437 .prepare(
2438 "SELECT id, name, scoped_name, kind, exported, is_default_export
2439 FROM nodes WHERE file_path = ?1",
2440 )
2441 .ok()?;
2442 let rows = nodes
2443 .query_map(params![rel_path], |row| {
2444 Ok((
2445 row.get::<_, String>(0)?,
2446 row.get::<_, String>(1)?,
2447 row.get::<_, String>(2)?,
2448 row.get::<_, String>(3)?,
2449 row.get::<_, i64>(4)? != 0,
2450 row.get::<_, i64>(5)? != 0,
2451 ))
2452 })
2453 .ok()?
2454 .collect::<std::result::Result<Vec<_>, _>>()
2455 .ok()?;
2456 drop(nodes);
2457 for (id, name, scoped_name, kind, exported, is_default_export) in rows {
2458 if exported {
2459 index.exports.insert(name.clone());
2460 index.exports.insert(scoped_name.clone());
2461 }
2462 if is_default_export {
2463 index.default_export = Some(scoped_name.clone());
2464 }
2465 index.node_by_scoped.insert(scoped_name, id.clone());
2466 index.node_by_bare.entry(name).or_insert(id.clone());
2467 index.node_kind_by_id.insert(id, kind);
2468 }
2469
2470 let mut refs = self
2471 .conn
2472 .prepare(
2473 "SELECT ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name
2474 FROM refs
2475 WHERE caller_file = ?1 AND kind IN ('import', 'module', 'reexport', 'export_alias')",
2476 )
2477 .ok()?;
2478 let rows = refs
2479 .query_map(params![rel_path], |row| {
2480 Ok((
2481 row.get::<_, String>(0)?,
2482 row.get::<_, String>(1)?,
2483 row.get::<_, Option<String>>(2)?,
2484 row.get::<_, Option<String>>(3)?,
2485 row.get::<_, i64>(4)? != 0,
2486 row.get::<_, Option<String>>(5)?,
2487 row.get::<_, Option<String>>(6)?,
2488 ))
2489 })
2490 .ok()?
2491 .collect::<std::result::Result<Vec<_>, _>>()
2492 .ok()?;
2493 drop(refs);
2494 for (ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name) in rows {
2495 if kind == "export_alias" {
2496 if let (Some(exported), Some(source)) = (local_name, requested_name) {
2497 index.export_aliases.insert(exported, source);
2498 }
2499 continue;
2500 }
2501 let Some(module_path) = module_path else {
2502 continue;
2503 };
2504 let target_file = if kind == "module" {
2505 rust_declared_module_target(
2506 self.project_root,
2507 rel_path,
2508 &module_path,
2509 self.module_resolution_memo,
2510 )
2511 } else {
2512 self.disk_module_target(rel_path, &module_path)
2513 }
2514 .or_else(|| {
2515 self.conn
2516 .query_row(
2517 "SELECT d.dep_file
2518 FROM file_dependencies d
2519 JOIN files f ON f.path = d.dep_file
2520 WHERE d.file_path = ?1
2521 ORDER BY d.dep_file
2522 LIMIT 1",
2523 params![rel_path],
2524 |row| row.get::<_, String>(0),
2525 )
2526 .optional()
2527 .ok()
2528 .flatten()
2529 });
2530 index
2531 .module_targets
2532 .entry(module_path.clone())
2533 .or_insert_with(|| target_file.clone());
2534 if kind == "module" {
2535 index
2536 .declared_module_targets
2537 .entry(module_path.clone())
2538 .or_insert_with(|| target_file.clone());
2539 }
2540 if kind == "reexport" {
2541 let raw = RawRef {
2542 ref_id,
2543 caller_node: None,
2544 caller_symbol: None,
2545 caller_file: rel_path.to_string(),
2546 kind,
2547 short_name: None,
2548 full_ref,
2549 module_path: Some(module_path),
2550 import_kind: Some("reexport".to_string()),
2551 local_name: None,
2552 requested_name: None,
2553 namespace_alias: None,
2554 wildcard,
2555 line: 0,
2556 byte_start: 0,
2557 byte_end: 0,
2558 dependencies: BTreeSet::new(),
2559 };
2560 index
2561 .reexports
2562 .push(reexport_index_from_raw(&raw, target_file));
2563 }
2564 }
2565 Some(index)
2566 }
2567
2568 fn disk_module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2569 let caller_dir = self.project_root.join(caller_file).parent()?.to_path_buf();
2570 let candidate = callgraph::resolve_module_path_with_memo(
2571 &caller_dir,
2572 module_path,
2573 self.module_resolution_memo,
2574 )?;
2575 let rel_path = relative_path(self.project_root, &candidate);
2576 self.contains_file(&rel_path).then_some(rel_path)
2577 }
2578}
2579
2580impl ResolverIndex for DiskProjectIndex<'_> {
2581 fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2582 (file == self.caller_file).then_some(self.caller_data)
2583 }
2584
2585 fn lang_for(&self, file: &str) -> Option<LangId> {
2586 self.file_index(file).and_then(|index| index.lang)
2587 }
2588
2589 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2590 self.file_index(caller_file)
2591 .and_then(|index| index.module_targets.get(module_path).cloned().flatten())
2592 }
2593
2594 fn module_parent(&self, target_file: &str) -> Option<(String, String)> {
2595 let mut stmt = self
2596 .conn
2597 .prepare(
2598 "SELECT caller_file, module_path FROM refs
2599 WHERE kind = 'module' AND module_path IS NOT NULL
2600 ORDER BY caller_file, module_path",
2601 )
2602 .ok()?;
2603 let rows = stmt
2604 .query_map([], |row| {
2605 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2606 })
2607 .ok()?;
2608 for row in rows.flatten() {
2609 if self.module_target(&row.0, &row.1).as_deref() == Some(target_file) {
2610 return Some(row);
2611 }
2612 }
2613 None
2614 }
2615
2616 fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2617 self.file_index(file)
2618 .map(|index| index.reexports)
2619 .unwrap_or_default()
2620 }
2621
2622 fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2623 self.file_index(file).and_then(|index| {
2624 index
2625 .node_by_scoped
2626 .get(symbol)
2627 .cloned()
2628 .or_else(|| index.node_by_bare.get(symbol).cloned())
2629 })
2630 }
2631
2632 fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2633 self.file_index(file)
2634 .and_then(|index| index.node_kind_by_id.get(node_id).cloned())
2635 .is_some_and(|kind| matches!(kind.as_str(), "function" | "method"))
2636 }
2637
2638 fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2639 self.file_index(file)
2640 .and_then(|index| index.export_aliases.get(symbol).cloned())
2641 }
2642
2643 fn has_export(&self, file: &str, symbol: &str) -> bool {
2644 self.file_index(file)
2645 .is_some_and(|index| index.exports.contains(symbol))
2646 }
2647
2648 fn default_export(&self, file: &str) -> Option<String> {
2649 self.file_index(file).and_then(|index| index.default_export)
2650 }
2651
2652 fn contains_file(&self, file: &str) -> bool {
2653 self.conn
2654 .query_row(
2655 "SELECT 1 FROM files WHERE path = ?1 LIMIT 1",
2656 params![file],
2657 |_| Ok(()),
2658 )
2659 .is_ok()
2660 }
2661
2662 fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2663 self.workspace_crate_prefixes
2664 .0
2665 .get_or_init(|| build_workspace_crate_prefixes(self.project_root))
2666 .get(crate_name)
2667 .cloned()
2668 }
2669
2670 fn inline_scoped_target(
2671 &self,
2672 caller_file: &str,
2673 module_segments: &[String],
2674 short_name: &str,
2675 ) -> Option<(String, String)> {
2676 let src_prefix = rust_src_prefix(caller_file);
2677 let check = |file_path: String| {
2678 let file_module_segments = rust_module_segments_for_rel(&file_path);
2679 if rust_src_prefix(&file_path) != src_prefix
2680 || !module_segments.starts_with(&file_module_segments)
2681 {
2682 return None;
2683 }
2684 let scoped_segments = &module_segments[file_module_segments.len()..];
2685 if scoped_segments.is_empty() {
2686 return None;
2687 }
2688 let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2689 self.node_for_symbol(&file_path, &scoped_symbol)
2690 .map(|_| (file_path, scoped_symbol))
2691 };
2692 if let Some(target) = check(caller_file.to_string()) {
2693 return Some(target);
2694 }
2695 let mut statement = self
2696 .conn
2697 .prepare("SELECT path FROM files WHERE lang = 'rust' AND path <> ?1 ORDER BY path")
2698 .ok()?;
2699 let rows = statement
2700 .query_map(params![caller_file], |row| row.get::<_, String>(0))
2701 .ok()?;
2702 for path in rows.flatten() {
2703 if let Some(target) = check(path) {
2704 return Some(target);
2705 }
2706 }
2707 None
2708 }
2709}
2710
2711impl CallGraphStore {
2712 pub fn open_if_enabled(
2713 options: CallGraphStoreOptions,
2714 callgraph_dir: PathBuf,
2715 project_root: PathBuf,
2716 ) -> Result<Option<Self>> {
2717 if !options.enabled {
2718 return Ok(None);
2719 }
2720 Self::open(callgraph_dir, project_root).map(Some)
2721 }
2722
2723 pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
2724 let project_key = crate::search_index::artifact_cache_key(&project_root);
2725 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2726 else {
2727 return Err(CallGraphStoreError::Unavailable(
2728 "writer capability denied; use the read-only callgraph opener".to_string(),
2729 ));
2730 };
2731 std::fs::create_dir_all(&callgraph_dir)?;
2732 let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
2736 .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
2737 let OpenedStore { store, root_repair } = Self::open_at_path(
2738 project_root.clone(),
2739 project_key,
2740 sqlite_path,
2741 generation,
2742 true,
2743 Some(Arc::clone(&writer_lease)),
2744 None,
2745 )?;
2746 match root_repair {
2747 OpenRootRepair::NeedsRebuild { .. } => {
2748 log_root_repair_rebuild(&root_repair);
2749 drop(store);
2750 drop(writer_lease);
2751 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2752 let (store, _stats) =
2753 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2754 Ok(store)
2755 }
2756 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
2757 }
2758 }
2759
2760 pub fn open_readonly(
2761 callgraph_dir: PathBuf,
2762 project_root: PathBuf,
2763 ) -> Result<Option<ReadonlyCallGraphStore>> {
2764 let project_key = crate::search_index::artifact_cache_key(&project_root);
2765 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2766 {
2767 let conn = open_readonly_connection(&sqlite_path)?;
2768 if !database_ready(&conn).unwrap_or(false) {
2769 return Ok(None);
2770 }
2771 let marker_label = generation.as_deref().unwrap_or("legacy");
2772 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
2773 return Ok(Some(ReadonlyCallGraphStore::from_inner(
2774 Self::from_connection(
2775 project_root,
2776 project_key,
2777 sqlite_path,
2778 callgraph_dir,
2779 false,
2780 generation,
2781 None,
2782 Some(read_marker),
2783 conn,
2784 ),
2785 )));
2786 }
2787
2788 let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
2789 return Ok(None);
2790 };
2791 crate::slog_warn!(
2792 "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
2793 target.partition.harness,
2794 target.sqlite_path.display()
2795 );
2796 let conn = open_readonly_connection(&target.sqlite_path)?;
2797 if !database_ready(&conn).unwrap_or(false) {
2798 return Ok(None);
2799 }
2800 let marker_label =
2801 legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
2802 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
2803 Ok(Some(ReadonlyCallGraphStore::from_inner(
2804 Self::from_connection(
2805 project_root,
2806 project_key,
2807 target.sqlite_path,
2808 callgraph_dir,
2809 true,
2810 target.generation,
2811 None,
2812 Some(read_marker),
2813 conn,
2814 ),
2815 )))
2816 }
2817
2818 pub fn open_ready_repairing(
2824 callgraph_dir: PathBuf,
2825 project_root: PathBuf,
2826 ) -> Result<Option<Self>> {
2827 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true)
2828 }
2829
2830 pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
2834 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false)
2835 }
2836
2837 pub fn open_ready_no_rebuild(
2838 callgraph_dir: PathBuf,
2839 project_root: PathBuf,
2840 ) -> Result<Option<Self>> {
2841 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true)
2842 }
2843
2844 fn open_ready_with_rebuild_policy(
2845 callgraph_dir: PathBuf,
2846 project_root: PathBuf,
2847 allow_cold_build: bool,
2848 allow_root_repair: bool,
2849 ) -> Result<Option<Self>> {
2850 let project_key = crate::search_index::artifact_cache_key(&project_root);
2851 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2852 else {
2853 return Ok(None);
2854 };
2855 let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2856 else {
2857 return Ok(None);
2858 };
2859 let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
2860 project_root.clone(),
2861 project_key.clone(),
2862 sqlite_path,
2863 generation,
2864 true,
2865 Some(Arc::clone(&writer_lease)),
2866 None,
2867 allow_root_repair,
2868 )?;
2869 match root_repair {
2870 OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
2871 log_root_repair_rebuild(&root_repair);
2872 drop(store);
2873 drop(writer_lease);
2874 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2875 let (store, _stats) =
2876 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2877 Ok(Some(store))
2878 }
2879 OpenRootRepair::NeedsRebuild { .. } => {
2880 if let Some(message) = note_repair_entry(&project_key) {
2881 crate::slog_warn!("{message}");
2882 }
2883 Ok(None)
2884 }
2885 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2886 }
2887 }
2888
2889 pub fn cold_build_with_lease(
2890 callgraph_dir: PathBuf,
2891 project_root: PathBuf,
2892 files: &[PathBuf],
2893 ) -> Result<(Self, ColdBuildStats)> {
2894 Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
2895 }
2896
2897 pub fn cold_build_with_lease_chunked(
2898 callgraph_dir: PathBuf,
2899 project_root: PathBuf,
2900 files: &[PathBuf],
2901 chunk_size: usize,
2902 ) -> Result<(Self, ColdBuildStats)> {
2903 Self::cold_build_with_lease_chunked_inner(
2904 callgraph_dir,
2905 project_root,
2906 files,
2907 chunk_size,
2908 false,
2909 )
2910 }
2911
2912 pub(crate) fn force_cold_build_with_lease_chunked(
2913 callgraph_dir: PathBuf,
2914 project_root: PathBuf,
2915 files: &[PathBuf],
2916 chunk_size: usize,
2917 ) -> Result<(Self, ColdBuildStats)> {
2918 Self::cold_build_with_lease_chunked_inner(
2919 callgraph_dir,
2920 project_root,
2921 files,
2922 chunk_size,
2923 true,
2924 )
2925 }
2926
2927 fn cold_build_with_lease_chunked_inner(
2928 callgraph_dir: PathBuf,
2929 project_root: PathBuf,
2930 files: &[PathBuf],
2931 chunk_size: usize,
2932 require_new_publication: bool,
2933 ) -> Result<(Self, ColdBuildStats)> {
2934 let project_key = crate::search_index::artifact_cache_key(&project_root);
2935 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2936 else {
2937 let operation = if require_new_publication {
2938 "forced rebuild"
2939 } else {
2940 "cold build"
2941 };
2942 return Err(CallGraphStoreError::Unavailable(format!(
2943 "{operation} could not acquire writer capability"
2944 )));
2945 };
2946 std::fs::create_dir_all(&callgraph_dir)?;
2947 let (stats, generation) = Self::cold_build_publish_locked(
2948 &callgraph_dir,
2949 &project_root,
2950 &project_key,
2951 files,
2952 chunk_size,
2953 Arc::clone(&writer_lease),
2954 )?;
2955 let store = Self::open_generation(
2956 &callgraph_dir,
2957 project_root,
2958 project_key,
2959 generation,
2960 writer_lease,
2961 )?;
2962 Ok((store, stats))
2963 }
2964
2965 pub fn ensure_built_with_lease(
2966 callgraph_dir: PathBuf,
2967 project_root: PathBuf,
2968 files: &[PathBuf],
2969 ) -> Result<(Self, Option<ColdBuildStats>)> {
2970 Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
2971 }
2972
2973 pub fn ensure_built_with_lease_chunked(
2974 callgraph_dir: PathBuf,
2975 project_root: PathBuf,
2976 files: &[PathBuf],
2977 chunk_size: usize,
2978 ) -> Result<(Self, Option<ColdBuildStats>)> {
2979 let project_key = crate::search_index::artifact_cache_key(&project_root);
2980 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2981 else {
2982 return Err(CallGraphStoreError::Unavailable(
2983 "callgraph ensure could not acquire writer capability".to_string(),
2984 ));
2985 };
2986 std::fs::create_dir_all(&callgraph_dir)?;
2987 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2988 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2995 {
2996 let OpenedStore { store, root_repair } = Self::open_at_path(
2997 project_root.clone(),
2998 project_key.clone(),
2999 sqlite_path,
3000 generation,
3001 true,
3002 Some(Arc::clone(&writer_lease)),
3003 None,
3004 )?;
3005 match root_repair {
3006 OpenRootRepair::NeedsRebuild { .. } => {
3007 log_root_repair_rebuild(&root_repair);
3008 drop(store);
3009 let (stats, generation) = Self::cold_build_publish_locked(
3010 &callgraph_dir,
3011 &project_root,
3012 &project_key,
3013 files,
3014 chunk_size,
3015 Arc::clone(&writer_lease),
3016 )?;
3017 let store = Self::open_generation(
3018 &callgraph_dir,
3019 project_root,
3020 project_key,
3021 generation,
3022 writer_lease,
3023 )?;
3024 return Ok((store, Some(stats)));
3025 }
3026 OpenRootRepair::None | OpenRootRepair::ReRooted => {
3027 return Ok((store, None));
3028 }
3029 }
3030 }
3031 if let Some(store) = try_legacy_migration_or_fallback(
3032 &callgraph_dir,
3033 &project_root,
3034 &project_key,
3035 Arc::clone(&writer_lease),
3036 )? {
3037 return Ok((store, None));
3038 }
3039 let (stats, generation) = Self::cold_build_publish_locked(
3040 &callgraph_dir,
3041 &project_root,
3042 &project_key,
3043 files,
3044 chunk_size,
3045 Arc::clone(&writer_lease),
3046 )?;
3047 let store = Self::open_generation(
3048 &callgraph_dir,
3049 project_root,
3050 project_key,
3051 generation,
3052 writer_lease,
3053 )?;
3054 Ok((store, Some(stats)))
3055 }
3056
3057 pub fn migrate_legacy_with_lease(
3064 callgraph_dir: PathBuf,
3065 project_root: PathBuf,
3066 ) -> Result<Option<Self>> {
3067 let project_key = crate::search_index::artifact_cache_key(&project_root);
3068 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
3069 else {
3070 return Ok(None);
3071 };
3072 std::fs::create_dir_all(&callgraph_dir)?;
3073 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
3074
3075 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
3079 {
3080 let OpenedStore { store, root_repair } = Self::open_at_path(
3081 project_root,
3082 project_key,
3083 sqlite_path,
3084 generation,
3085 true,
3086 Some(writer_lease),
3087 None,
3088 )?;
3089 return match root_repair {
3090 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
3091 OpenRootRepair::NeedsRebuild { reason, .. } => {
3092 Err(CallGraphStoreError::Unavailable(format!(
3093 "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
3094 )))
3095 }
3096 };
3097 }
3098
3099 let store = try_legacy_migration_or_fallback(
3100 &callgraph_dir,
3101 &project_root,
3102 &project_key,
3103 writer_lease,
3104 )?;
3105 Ok(store.filter(|store| !store.is_legacy_fallback()))
3109 }
3110
3111 fn cold_build_publish_locked(
3122 callgraph_dir: &Path,
3123 project_root: &Path,
3124 project_key: &str,
3125 files: &[PathBuf],
3126 chunk_size: usize,
3127 writer_lease: Arc<crate::root_cache::WriterLease>,
3128 ) -> Result<(ColdBuildStats, String)> {
3129 if let Some((previous_root, remaining)) =
3130 rebuild_cooldown_denial(callgraph_dir, project_key, project_root, Instant::now())
3131 {
3132 return Err(CallGraphStoreError::Unavailable(format!(
3133 "cache key {project_key} was rebuilt for {} too recently; retry {} ms after the per-key cooldown",
3134 previous_root.display(),
3135 remaining.as_millis()
3136 )));
3137 }
3138 let breaker = crate::build_breaker::BuildDeathBreaker::open(
3139 callgraph_dir.join("build-breaker.sqlite"),
3140 )
3141 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
3142
3143 let generation = generation_file_name(project_key);
3144 let gen_path = callgraph_dir.join(&generation);
3145 let temp_path = callgraph_dir.join(format!("{project_key}.staging.sqlite.tmp.resume"));
3149 let adopting_staging = temp_path.exists();
3150 if !adopting_staging {
3151 remove_sqlite_file_set(&temp_path);
3152 }
3153
3154 let scope = crate::logging::IndexBuildScope::new(
3155 crate::logging::IndexPlane::Callgraph,
3156 project_root,
3157 project_key,
3158 );
3159 let _index_build = crate::logging::install_index_build(scope.clone());
3160 let mut failure_guard = crate::logging::IndexBuildFailureGuard::new();
3161 let mut started = crate::logging::IndexEvent::from_scope(
3162 crate::logging::IndexEventKind::BuildStarted,
3163 &scope,
3164 );
3165 if adopting_staging {
3166 started = started.field("resumed_from_staging", "true");
3167 }
3168 crate::logging::log_index_event(started);
3169
3170 let (stats, breaker_key) = {
3171 if adopting_staging {
3172 crate::slog_info!(
3173 "resuming callgraph cold build from staged generation {}",
3174 temp_path.display()
3175 );
3176 }
3177 let temp_store = Self::open_at_path(
3178 project_root.to_path_buf(),
3179 project_key.to_string(),
3180 temp_path.clone(),
3181 None,
3182 false,
3183 Some(Arc::clone(&writer_lease)),
3184 None,
3185 )?
3186 .store;
3187 let admission_fingerprint = corpus_fingerprint_for(project_root, files)?;
3198 let breaker_key = crate::build_breaker::BreakerKey::new(
3199 project_root.display().to_string(),
3200 crate::build_breaker::BuildDomain::CallgraphCold,
3201 admission_fingerprint,
3202 );
3203 match breaker
3204 .admit(&breaker_key, 0)
3205 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?
3206 {
3207 crate::build_breaker::BreakerAdmission::Admitted(_) => {
3208 crate::logging::log_index_event(crate::logging::IndexEvent::from_scope(
3209 crate::logging::IndexEventKind::BreakerAdmitted,
3210 &scope,
3211 ));
3212 }
3213 crate::build_breaker::BreakerAdmission::Suspended(suspension) => {
3214 crate::logging::log_index_event(
3215 crate::logging::IndexEvent::from_scope(
3216 crate::logging::IndexEventKind::BuildSuspended,
3217 &scope,
3218 )
3219 .field("reason", &suspension.reason),
3220 );
3221 failure_guard.disarm();
3222 return Err(CallGraphStoreError::Suspended(suspension));
3223 }
3224 }
3225 ensure_cold_build_current("inventory", 0, 1)?;
3226 let corpus_fingerprint = temp_store.stage_cold_build_file_inventory(files)?;
3227 ensure_cold_build_current("inventory", 1, 1)?;
3228 let stats = temp_store
3229 .cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)?;
3230 let _ = temp_store.checkpoint_wal_truncate();
3231 temp_store.prepare_for_atomic_swap()?;
3232 (stats, breaker_key)
3233 };
3234
3235 notify_cold_build_before_publish_observer();
3236 let publication = publish_if_current(|| {
3237 verify_writer_lease(&writer_lease)?;
3238 remove_sqlite_file_set(&gen_path);
3241 crate::fs_lock::rename_over(&temp_path, &gen_path)?;
3242 crate::fs_lock::sync_parent(&gen_path);
3243 remove_sqlite_sidecars(&gen_path);
3244
3245 notify_cold_build_swap_observer(&temp_path, &gen_path);
3246
3247 verify_writer_lease(&writer_lease)?;
3249 publish_pointer(callgraph_dir, project_key, &generation)?;
3250 gc_old_generations(callgraph_dir, project_key, &generation);
3251 sweep_orphaned_build_temps_store_wide(callgraph_dir);
3255 sweep_orphaned_callgraph_root_dirs(callgraph_dir);
3256 crate::search_index::sweep_transient_search_cache_dirs();
3257 if let Some(storage_root) = root_storage_dir(callgraph_dir) {
3258 let inspect_root =
3259 storage_root.join(crate::root_cache::RootCacheDomain::Inspect.as_str());
3260 let live_scope_keys = crate::root_cache::live_scope_keys_for_storage(&storage_root);
3261 crate::inspect::cache::sweep_inspect_scope_dirs(&inspect_root, &live_scope_keys);
3262 }
3263 Ok(())
3264 });
3265 if let Err(CallGraphStoreError::Superseded) = &publication {
3269 crate::logging::log_index_event(
3270 crate::logging::IndexEvent::from_scope(
3271 crate::logging::IndexEventKind::BuildSuperseded,
3272 &scope,
3273 )
3274 .field("stage", "publish"),
3275 );
3276 failure_guard.disarm();
3277 }
3278 publication?;
3279 breaker
3282 .record_ready_publication(&breaker_key)
3283 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
3284 crate::logging::log_index_event(crate::logging::IndexEvent::from_scope(
3285 crate::logging::IndexEventKind::BreakerReset,
3286 &scope,
3287 ));
3288 record_successful_rebuild(callgraph_dir, project_key, project_root, Instant::now());
3289 crate::logging::log_index_event(
3290 crate::logging::IndexEvent::from_scope(
3291 crate::logging::IndexEventKind::BuildReady,
3292 &scope,
3293 )
3294 .field("elapsed_ms", scope.elapsed_ms())
3295 .field("files", stats.files)
3296 .field("edges", stats.edges),
3297 );
3298 failure_guard.disarm();
3299 Ok((stats, generation))
3300 }
3301
3302 fn open_generation(
3305 callgraph_dir: &Path,
3306 project_root: PathBuf,
3307 project_key: String,
3308 generation: String,
3309 writer_lease: Arc<crate::root_cache::WriterLease>,
3310 ) -> Result<Self> {
3311 let gen_path = callgraph_dir.join(&generation);
3312 Ok(Self::open_at_path(
3313 project_root,
3314 project_key,
3315 gen_path,
3316 Some(generation),
3317 true,
3318 Some(writer_lease),
3319 None,
3320 )?
3321 .store)
3322 }
3323
3324 pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
3325 let project_key = crate::search_index::artifact_cache_key(project_root);
3326 Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
3329 }
3330
3331 pub fn cold_build_suspension(
3336 callgraph_dir: &Path,
3337 project_root: &Path,
3338 ) -> Result<Option<crate::build_breaker::BuildSuspension>> {
3339 let breaker_path = callgraph_dir.join("build-breaker.sqlite");
3340 if !breaker_path.exists() {
3341 return Ok(None);
3342 }
3343 let key = crate::build_breaker::BreakerKey::new(
3344 project_root.display().to_string(),
3345 crate::build_breaker::BuildDomain::CallgraphCold,
3346 callgraph_corpus_fingerprint(project_root)?,
3347 );
3348 crate::build_breaker::BuildDeathBreaker::open(breaker_path)
3349 .and_then(|breaker| breaker.suspension(&key))
3350 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))
3351 }
3352
3353 fn open_at_path(
3354 project_root: PathBuf,
3355 project_key: String,
3356 sqlite_path: PathBuf,
3357 generation: Option<String>,
3358 use_wal: bool,
3359 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3360 read_marker: Option<crate::root_cache::ReadMarker>,
3361 ) -> Result<OpenedStore> {
3362 Self::open_at_path_with_root_repair(
3363 project_root,
3364 project_key,
3365 sqlite_path,
3366 generation,
3367 use_wal,
3368 writer_lease,
3369 read_marker,
3370 true,
3371 )
3372 }
3373
3374 fn open_at_path_with_root_repair(
3375 project_root: PathBuf,
3376 project_key: String,
3377 sqlite_path: PathBuf,
3378 generation: Option<String>,
3379 use_wal: bool,
3380 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3381 read_marker: Option<crate::root_cache::ReadMarker>,
3382 allow_root_repair: bool,
3383 ) -> Result<OpenedStore> {
3384 if let Some(lease) = writer_lease.as_ref() {
3385 verify_writer_lease(lease)?;
3386 }
3387 if let Some(parent) = sqlite_path.parent() {
3388 std::fs::create_dir_all(parent)?;
3389 }
3390 let mut conn = Connection::open(&sqlite_path)?;
3391 if use_wal {
3392 configure_connection(&conn)?;
3393 } else {
3394 configure_build_connection(&conn)?;
3395 }
3396 if let Some(lease) = writer_lease.as_ref() {
3397 verify_writer_lease(lease)?;
3398 }
3399 initialize_schema(&conn)?;
3400 if let Some(lease) = writer_lease.as_ref() {
3401 verify_writer_lease(lease)?;
3402 }
3403 let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
3404 let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
3405 (Some(marker), _, _) => Some(marker),
3406 (None, Some(label), Some(cache_dir)) => {
3407 Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
3408 }
3409 (None, _, _) => None,
3410 };
3411 let publication_dir = sqlite_path
3412 .parent()
3413 .map(Path::to_path_buf)
3414 .unwrap_or_default();
3415 let store = Self::from_connection(
3416 project_root,
3417 project_key,
3418 sqlite_path,
3419 publication_dir,
3420 false,
3421 generation,
3422 writer_lease,
3423 read_marker,
3424 conn,
3425 );
3426 Ok(OpenedStore { store, root_repair })
3427 }
3428
3429 fn prepare_for_atomic_swap(&self) -> Result<()> {
3430 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3431 conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
3432 Ok(())
3433 }
3434
3435 fn atomic_swap_checkpoint_sql(&self) -> &'static str {
3436 let protected_reader = self.generation.as_deref().is_some_and(|generation| {
3437 self.sqlite_path
3438 .parent()
3439 .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
3440 });
3441 if protected_reader {
3442 "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
3443 } else {
3444 "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
3445 }
3446 }
3447
3448 fn from_connection(
3449 project_root: PathBuf,
3450 project_key: String,
3451 sqlite_path: PathBuf,
3452 publication_dir: PathBuf,
3453 legacy_fallback: bool,
3454 generation: Option<String>,
3455 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3456 read_marker: Option<crate::root_cache::ReadMarker>,
3457 conn: Connection,
3458 ) -> Self {
3459 let write_metrics = callgraph_write_metrics_for_key(&project_key);
3460 Self {
3461 project_root,
3462 project_key,
3463 sqlite_path,
3464 publication_dir,
3465 legacy_fallback,
3466 generation,
3467 writer_lease,
3468 read_marker,
3469 database_ready: AtomicBool::new(false),
3470 write_metrics,
3471 conn: Mutex::new(conn),
3472 }
3473 }
3474
3475 fn ensure_ready(&self, conn: &Connection) -> Result<()> {
3476 if self.database_ready.load(AtomicOrdering::Acquire) {
3477 return Ok(());
3478 }
3479 ensure_database_ready(conn)?;
3480 self.database_ready.store(true, AtomicOrdering::Release);
3481 Ok(())
3482 }
3483
3484 pub fn project_root(&self) -> &Path {
3485 &self.project_root
3486 }
3487
3488 pub fn project_key(&self) -> &str {
3489 &self.project_key
3490 }
3491
3492 pub fn sqlite_path(&self) -> &Path {
3493 &self.sqlite_path
3494 }
3495
3496 pub(crate) fn projection_generation(&self) -> Option<&str> {
3498 self.generation.as_deref()
3499 }
3500
3501 pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
3503 self.refresh_read_marker()?;
3504 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3505 self.ensure_ready(&conn)?;
3506 projection_write_revision(&conn)
3507 }
3508
3509 pub fn is_legacy_fallback(&self) -> bool {
3512 self.legacy_fallback
3513 }
3514
3515 pub(crate) fn is_legacy_migration(&self) -> bool {
3516 self.generation.as_deref().is_some_and(|generation| {
3517 migration_generation_requires_manifest(generation)
3518 && migration_manifest_valid(&self.publication_dir, generation)
3519 })
3520 }
3521
3522 pub fn writer_epoch_for_test(&self) -> Option<&str> {
3523 self.writer_lease.as_ref().map(|lease| lease.epoch())
3524 }
3525
3526 fn verify_writer_lease(&self) -> Result<()> {
3527 let Some(lease) = self.writer_lease.as_ref() else {
3528 return Err(CallGraphStoreError::Unavailable(
3529 "callgraph store opened read-only; write API is unavailable".to_string(),
3530 ));
3531 };
3532 verify_writer_lease(lease)
3533 }
3534
3535 fn refresh_read_marker(&self) -> Result<()> {
3536 if let Some(marker) = self.read_marker.as_ref() {
3537 marker.touch_if_due()?;
3538 }
3539 Ok(())
3540 }
3541
3542 fn record_commit(&self, total_changes_before: u64, conn: &Connection) {
3543 self.write_metrics
3544 .record_commit(conn.total_changes().saturating_sub(total_changes_before));
3545 }
3546
3547 fn checkpoint_wal_truncate(&self) -> bool {
3548 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3549 checkpoint_wal_truncate(&conn)
3550 }
3551
3552 pub fn is_current(&self) -> bool {
3558 let _ = self.refresh_read_marker();
3559 match (
3560 read_pointer(&self.publication_dir, &self.project_key),
3561 &self.generation,
3562 ) {
3563 (Some(_), _) if self.legacy_fallback => false,
3566 (Some(published), Some(opened)) => &published == opened,
3567 (Some(_), None) => false,
3569 (None, _) => true,
3572 }
3573 }
3574
3575 pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
3576 self.cold_build_chunked(files, COLD_BUILD_EXTRACT_BATCH_FILES)
3577 }
3578
3579 pub fn cold_build_chunked(
3583 &self,
3584 files: &[PathBuf],
3585 chunk_size: usize,
3586 ) -> Result<ColdBuildStats> {
3587 let corpus_fingerprint = self.stage_cold_build_file_inventory(files)?;
3588 self.cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)
3589 }
3590
3591 fn stage_cold_build_file_inventory(&self, files: &[PathBuf]) -> Result<String> {
3592 note_cold_build_phase("enumeration");
3593 if files.is_empty() {
3594 self.stage_cold_build_file_inventory_from(callgraph::walk_project_files(
3595 &self.project_root,
3596 ))
3597 } else {
3598 self.stage_cold_build_file_inventory_from(files.iter().cloned())
3599 }
3600 }
3601
3602 fn stage_cold_build_file_inventory_from<I>(&self, paths: I) -> Result<String>
3603 where
3604 I: IntoIterator<Item = PathBuf>,
3605 {
3606 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3607 self.verify_writer_lease()?;
3608 let total_changes_before = conn.total_changes();
3609 let tx = conn.transaction()?;
3610 tx.execute("DELETE FROM staging_file_inventory", [])?;
3611 tx.commit()?;
3612 self.record_commit(total_changes_before, &conn);
3613
3614 let mut batch = Vec::with_capacity(COLD_BUILD_EXTRACT_BATCH_FILES);
3615 for path in paths {
3616 let path = normalize_file_path(&self.project_root, &path)?;
3617 let rel_path = relative_path(&self.project_root, &path);
3618 let size = std::fs::metadata(&path)
3619 .map(|metadata| metadata.len())
3620 .unwrap_or(0);
3621 batch.push((rel_path, size));
3622 if batch.len() == COLD_BUILD_EXTRACT_BATCH_FILES {
3623 self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3624 batch.clear();
3625 }
3626 }
3627 if !batch.is_empty() {
3628 self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3629 }
3630
3631 staged_corpus_fingerprint(&conn, &self.project_root)
3632 }
3633
3634 fn insert_staged_file_inventory_batch(
3635 &self,
3636 conn: &mut Connection,
3637 batch: &[(String, u64)],
3638 ) -> Result<()> {
3639 self.verify_writer_lease()?;
3640 let total_changes_before = conn.total_changes();
3641 let tx = conn.transaction()?;
3642 {
3643 let mut insert = tx.prepare(
3644 "INSERT OR REPLACE INTO staging_file_inventory(path, size) VALUES(?1, ?2)",
3645 )?;
3646 for (path, size) in batch {
3647 insert.execute(params![path, *size as i64])?;
3648 }
3649 }
3650 tx.commit()?;
3651 self.record_commit(total_changes_before, conn);
3652 Ok(())
3653 }
3654
3655 fn cold_build_chunked_from_staged_inventory(
3656 &self,
3657 chunk_size: usize,
3658 corpus_fingerprint: &str,
3659 ) -> Result<ColdBuildStats> {
3660 let module_resolution_memo = callgraph::ModuleResolutionMemo::default();
3661 self.cold_build_chunked_from_staged_inventory_with_resolution_memo(
3662 chunk_size,
3663 corpus_fingerprint,
3664 COLD_BUILD_RESOLVE_WINDOW,
3665 &module_resolution_memo,
3666 )
3667 }
3668
3669 #[cfg(test)]
3670 fn cold_build_chunked_with_resolution_memo_for_test(
3671 &self,
3672 files: &[PathBuf],
3673 chunk_size: usize,
3674 resolve_window: usize,
3675 module_resolution_memo: &callgraph::ModuleResolutionMemo,
3676 ) -> Result<ColdBuildStats> {
3677 let corpus_fingerprint = self.stage_cold_build_file_inventory(files)?;
3678 self.cold_build_chunked_from_staged_inventory_with_resolution_memo(
3679 chunk_size,
3680 &corpus_fingerprint,
3681 resolve_window.max(1),
3682 module_resolution_memo,
3683 )
3684 }
3685
3686 fn cold_build_chunked_from_staged_inventory_with_resolution_memo(
3687 &self,
3688 chunk_size: usize,
3689 corpus_fingerprint: &str,
3690 resolve_window: usize,
3691 module_resolution_memo: &callgraph::ModuleResolutionMemo,
3692 ) -> Result<ColdBuildStats> {
3693 let started = Instant::now();
3694 let batch_files = chunk_size.max(1).min(COLD_BUILD_EXTRACT_BATCH_FILES);
3695 let workspace_root = self.project_root.display().to_string();
3696 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3697
3698 self.verify_writer_lease()?;
3699 ensure_cold_build_current("staging-admission", 0, 1)?;
3700 let mut phase = staged_build_phase(&conn)?;
3701 let staged_fingerprint = staged_string(&conn, STAGED_CORPUS_FINGERPRINT)?;
3702 let fingerprint_matches = staged_fingerprint.as_deref() == Some(corpus_fingerprint);
3703 if phase.as_deref() == Some("ready") && fingerprint_matches {
3704 ensure_cold_build_current("completed-staging", 1, 1)?;
3705 crate::slog_info!(
3706 "callgraph cold-build decision: reason=matching completed staging; action=publish"
3707 );
3708 conn.execute("DELETE FROM staging_file_inventory", [])?;
3709 return cold_build_stats_from_connection(&conn, started);
3710 }
3711 if phase.is_none() || !fingerprint_matches {
3712 if staged_fingerprint.is_some() && !fingerprint_matches {
3713 crate::slog_info!(
3714 "callgraph cold-build decision: reason=fingerprint mismatch; action=restart staging"
3715 );
3716 }
3717 let total_changes_before = conn.total_changes();
3718 let tx = conn.transaction()?;
3719 clear_tables(&tx)?;
3720 tx.execute("DELETE FROM staging_ref_context", [])?;
3721 insert_meta(&tx)?;
3722 drop_cold_build_secondary_indexes(&tx)?;
3723 set_meta_ready(&tx, false)?;
3724 set_staged_build_phase(&tx, "extracting")?;
3725 set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, corpus_fingerprint)?;
3726 set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0)?;
3727 set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, 0)?;
3728 tx.commit()?;
3729 self.record_commit(total_changes_before, &conn);
3730 phase = Some("extracting".to_string());
3731 }
3732
3733 note_cold_build_phase("extraction");
3737 if phase.as_deref() == Some("extracting") {
3738 prune_staged_files_not_in_inventory(&mut conn)?;
3739
3740 let total_files =
3741 query_count(&conn, "SELECT COUNT(*) FROM staging_file_inventory")? as usize;
3742 let mut completed_files = 0usize;
3743 ensure_cold_build_current("extraction", completed_files, total_files)?;
3744 let mut after_path = String::new();
3745 loop {
3746 let Some(batch) = load_staged_file_batch(
3747 &conn,
3748 &self.project_root,
3749 &after_path,
3750 batch_files,
3751 COLD_BUILD_EXTRACT_BATCH_BYTES,
3752 )?
3753 else {
3754 break;
3755 };
3756 after_path = batch.last_path;
3757 let batch_files = batch.paths.len();
3758
3759 let mut needs_extract = Vec::with_capacity(batch_files);
3760 for path in batch.paths {
3761 if !staged_content_matches(&conn, &self.project_root, &path)? {
3762 needs_extract.push(path);
3763 }
3764 }
3765 if needs_extract.is_empty() {
3766 completed_files = completed_files.saturating_add(batch_files);
3767 ensure_cold_build_current("extraction", completed_files, total_files)?;
3768 continue;
3769 }
3770
3771 notify_cold_build_extract_observer(&needs_extract);
3772 let build = build_extracts_parallel(&self.project_root, &needs_extract);
3773 self.verify_writer_lease()?;
3774 let total_changes_before = conn.total_changes();
3775 let tx = conn.transaction()?;
3776 let mut extracted_bytes = 0u64;
3777 {
3778 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3779 for extract in &build.extracts {
3780 delete_staged_file_rows(&tx, &extract.rel_path)?;
3781 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
3782 for raw in &extract.raw_refs {
3783 insert_staged_ref_prepared(&mut inserts, raw)?;
3784 }
3785 extracted_bytes = extracted_bytes.saturating_add(extract.freshness.size);
3786 }
3787 for failure in &build.failures {
3788 insert_backend_state_prepared(
3789 &mut inserts.backend_state,
3790 &workspace_root,
3791 &failure.rel_path,
3792 failure
3793 .freshness
3794 .as_ref()
3795 .map(|freshness| &freshness.content_hash),
3796 "stale",
3797 )?;
3798 }
3799 }
3800 increment_staged_extracted_bytes(&tx, extracted_bytes)?;
3801 note_cold_build_commit_barrier("extraction_batch_before_commit");
3802 tx.commit()?;
3803 note_cold_build_commit_barrier("extraction_batch_committed");
3804 self.record_commit(total_changes_before, &conn);
3805 completed_files = completed_files.saturating_add(batch_files);
3806 ensure_cold_build_current("extraction", completed_files, total_files)?;
3807 }
3808
3809 ensure_cold_build_current("extraction", completed_files, total_files)?;
3810 let total_changes_before = conn.total_changes();
3811 let tx = conn.transaction()?;
3812 set_staged_build_phase(&tx, "indexing")?;
3813 tx.commit()?;
3814 self.record_commit(total_changes_before, &conn);
3815 phase = Some("indexing".to_string());
3816 ensure_cold_build_current("extraction", total_files, total_files)?;
3817 }
3818
3819 note_cold_build_phase("symbol_export_index");
3823 if phase.as_deref() == Some("indexing") {
3824 ensure_cold_build_current("symbol-export-index", 0, 1)?;
3825 self.verify_writer_lease()?;
3826 let total_changes_before = conn.total_changes();
3827 let tx = conn.transaction()?;
3828 create_cold_build_secondary_indexes(&tx)?;
3829 set_staged_build_phase(&tx, "resolving")?;
3830 tx.commit()?;
3831 self.record_commit(total_changes_before, &conn);
3832 ensure_cold_build_current("symbol-export-index", 1, 1)?;
3833 }
3834
3835 note_cold_build_phase("resolution");
3836 let workspace_crate_prefixes = WorkspaceCratePrefixCache::default();
3837 let total_refs = query_count(&conn, "SELECT COUNT(*) FROM refs")? as usize;
3838 let mut resolved_refs =
3839 query_count(&conn, "SELECT COUNT(*) FROM refs WHERE status <> 'staged'")? as usize;
3840 ensure_cold_build_current("resolution", resolved_refs, total_refs)?;
3841 let mut resolve_cursor = staged_u64(&conn, STAGED_RESOLVE_CURSOR)?;
3842 loop {
3843 let staged = load_staged_ref_window(&conn, resolve_cursor, resolve_window)?;
3844 let Some(last_rowid) = staged.last().map(|entry| entry.rowid) else {
3845 break;
3846 };
3847
3848 self.verify_writer_lease()?;
3849 let total_changes_before = conn.total_changes();
3850 let tx = conn.transaction()?;
3851 {
3852 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3853 let mut offset = 0;
3854 while offset < staged.len() {
3855 let caller_file = staged[offset].raw.caller_file.clone();
3856 let end = staged[offset..]
3857 .iter()
3858 .position(|entry| entry.raw.caller_file != caller_file)
3859 .map(|relative| offset + relative)
3860 .unwrap_or(staged.len());
3861 let caller_extract = build_file_extract(
3862 &self.project_root,
3863 &self.project_root.join(&caller_file),
3864 );
3865 if let Ok(caller_extract) = caller_extract {
3866 let index = DiskProjectIndex {
3867 project_root: &self.project_root,
3868 conn: &tx,
3869 caller_file: &caller_file,
3870 caller_data: &caller_extract.data,
3871 workspace_crate_prefixes: workspace_crate_prefixes.clone(),
3872 module_resolution_memo,
3873 };
3874 for staged_ref in &staged[offset..end] {
3875 let resolved = resolve_ref(staged_ref.raw.clone(), &index)?;
3876 insert_resolved_ref_prepared(&mut inserts, &resolved)?;
3877 }
3878 } else {
3879 for staged_ref in &staged[offset..end] {
3880 let unresolved = unresolved_staged_ref(staged_ref.raw.clone());
3881 insert_resolved_ref_prepared(&mut inserts, &unresolved)?;
3882 }
3883 }
3884 offset = end;
3885 }
3886 }
3887 set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, last_rowid)?;
3888 tx.commit()?;
3889 self.record_commit(total_changes_before, &conn);
3890 resolve_cursor = last_rowid;
3891 resolved_refs = resolved_refs.saturating_add(staged.len()).min(total_refs);
3892 ensure_cold_build_current("resolution", resolved_refs, total_refs)?;
3893 }
3894
3895 ensure_cold_build_current("resolution", resolved_refs, total_refs)?;
3896 note_cold_build_phase("publication");
3897 self.verify_writer_lease()?;
3898 let total_changes_before = conn.total_changes();
3899 let tx = conn.transaction()?;
3900 let _supplemental_edge_count =
3901 insert_method_dispatch_edges_chunked(&tx, &self.project_root, batch_files)?;
3902 set_meta_ready(&tx, true)?;
3903 set_staged_build_phase(&tx, "ready")?;
3904 tx.execute("DELETE FROM staging_file_inventory", [])?;
3905 tx.execute("DELETE FROM staging_ref_context", [])?;
3906 bump_projection_write_revision(&tx)?;
3907 tx.commit()?;
3908 self.record_commit(total_changes_before, &conn);
3909
3910 cold_build_stats_from_connection(&conn, started)
3911 }
3912
3913 pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
3914 self.refresh_files_with_workspace_crate_prefix_cache(
3915 changed_files,
3916 WorkspaceCratePrefixCache::default(),
3917 )
3918 }
3919
3920 fn refresh_files_with_workspace_crate_prefix_cache(
3921 &self,
3922 changed_files: &[PathBuf],
3923 workspace_crate_prefixes: WorkspaceCratePrefixCache,
3924 ) -> Result<IncrementalStats> {
3925 let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
3926 changed_files,
3927 workspace_crate_prefixes,
3928 )?;
3929 if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
3930 eprintln!("refresh_files phases: {}", profile.report());
3931 }
3932 Ok(stats)
3933 }
3934
3935 #[doc(hidden)]
3937 pub fn refresh_files_profiled(
3938 &self,
3939 changed_files: &[PathBuf],
3940 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
3941 self.refresh_files_profiled_with_workspace_crate_prefix_cache(
3942 changed_files,
3943 WorkspaceCratePrefixCache::default(),
3944 )
3945 }
3946
3947 fn refresh_files_profiled_with_workspace_crate_prefix_cache(
3948 &self,
3949 changed_files: &[PathBuf],
3950 workspace_crate_prefixes: WorkspaceCratePrefixCache,
3951 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
3952 let total_started = Instant::now();
3953 let mut profile = RefreshFilesProfile::default();
3954 self.verify_writer_lease()?;
3955 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3956 ensure_database_ready(&conn)?;
3957 let total_changes_before = conn.total_changes();
3958 let mut changed = Vec::new();
3959 let mut surface_changed = BTreeSet::new();
3960 let mut deleted = BTreeSet::new();
3961 let mut own_refresh = BTreeSet::new();
3962 let mut candidate_own_refresh = BTreeSet::new();
3963 let mut confirmed_fresh = BTreeSet::new();
3964 let mut unchanged_extracts = 0usize;
3965 let mut selected_ref_ids = BTreeSet::new();
3966 let mut selected_refs_by_caller = BTreeMap::new();
3967 let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
3968 let mut fresh_metadata = BTreeMap::new();
3969
3970 for input in changed_files {
3971 let (abs_path, rel_path) = match normalize_project_file_path(&self.project_root, input)
3972 {
3973 Ok(path) => path,
3974 Err(error) => {
3975 record_path_identity_mismatch(&conn, &error)?;
3976 return Err(error);
3977 }
3978 };
3979 changed.push(rel_path.clone());
3980 let old_row = load_file_row(&conn, &rel_path)?;
3981 if !abs_path.exists() {
3982 if old_row.is_some() && deleted.insert(rel_path.clone()) {
3983 surface_changed.insert(rel_path.clone());
3984 let started = Instant::now();
3985 let dependent_refs =
3986 ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3987 profile.dependency_selection += started.elapsed();
3988 record_dependent_refs(
3989 &mut selected_ref_ids,
3990 &mut selected_refs_by_caller,
3991 dependent_refs,
3992 );
3993 }
3994 continue;
3995 }
3996
3997 if let Some(row) = &old_row {
3998 match cache_freshness::verify_file(&abs_path, &row.freshness) {
3999 FreshnessVerdict::HotFresh => {
4000 confirmed_fresh.insert(rel_path.clone());
4005 continue;
4006 }
4007 FreshnessVerdict::ContentFresh {
4008 new_mtime,
4009 new_size,
4010 } => {
4011 fresh_metadata.insert(
4012 rel_path.clone(),
4013 FileFreshness {
4014 content_hash: row.freshness.content_hash,
4015 mtime: new_mtime,
4016 size: new_size,
4017 },
4018 );
4019 continue;
4020 }
4021 FreshnessVerdict::Deleted => {
4022 if deleted.insert(rel_path.clone()) {
4023 surface_changed.insert(rel_path.clone());
4024 let started = Instant::now();
4025 let dependent_refs =
4026 ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
4027 profile.dependency_selection += started.elapsed();
4028 record_dependent_refs(
4029 &mut selected_ref_ids,
4030 &mut selected_refs_by_caller,
4031 dependent_refs,
4032 );
4033 }
4034 continue;
4035 }
4036 FreshnessVerdict::Stale => {}
4037 }
4038 }
4039
4040 let started = Instant::now();
4041 let extract = build_file_extract(&self.project_root, &abs_path)?;
4042 profile.parse += started.elapsed();
4043 let surface_is_changed = old_row
4044 .as_ref()
4045 .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
4046 .unwrap_or(true);
4047 if surface_is_changed {
4048 surface_changed.insert(rel_path.clone());
4049 let started = Instant::now();
4050 let dependent_refs = ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
4051 profile.dependency_selection += started.elapsed();
4052 record_dependent_refs(
4053 &mut selected_ref_ids,
4054 &mut selected_refs_by_caller,
4055 dependent_refs,
4056 );
4057 }
4058 candidate_own_refresh.insert(rel_path.clone());
4059 changed_extracts.insert(rel_path, extract);
4060 }
4061
4062 let dependency_selected_refs = selected_ref_ids.len();
4063 let mut touched_callers: BTreeSet<String> =
4064 selected_refs_by_caller.keys().cloned().collect();
4065 touched_callers.extend(candidate_own_refresh.iter().cloned());
4066
4067 let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
4068 for rel_path in &touched_callers {
4069 if deleted.contains(rel_path) {
4070 continue;
4071 }
4072 if let Some(extract) = changed_extracts.get(rel_path) {
4073 caller_extracts.insert(rel_path.clone(), extract.clone());
4074 continue;
4075 }
4076 let abs_path = self.project_root.join(rel_path);
4077 if abs_path.exists() {
4078 let started = Instant::now();
4079 let extract = build_file_extract(&self.project_root, &abs_path)?;
4080 profile.dependent_parse += started.elapsed();
4081 caller_extracts.insert(rel_path.clone(), extract);
4082 }
4083 }
4084
4085 let tx = conn.transaction()?;
4086 for (rel_path, freshness) in fresh_metadata {
4087 update_file_fresh_metadata(
4088 &tx,
4089 &self.project_root,
4090 &rel_path,
4091 &freshness.content_hash,
4092 freshness.mtime,
4093 freshness.size,
4094 )?;
4095 }
4096 for rel_path in &confirmed_fresh {
4097 clear_stale_backend_status_for_file(&tx, &self.project_root, rel_path)?;
4098 }
4099 for rel_path in &deleted {
4100 let started = Instant::now();
4101 delete_file_rows(&tx, rel_path)?;
4102 clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
4103 profile.row_deletes += started.elapsed();
4104 }
4105
4106 let started = Instant::now();
4107 let index = ProjectIndex::from_db_and_callers(
4108 &tx,
4109 &self.project_root,
4110 &caller_extracts,
4111 workspace_crate_prefixes,
4112 )?;
4113 profile.index_load += started.elapsed();
4114
4115 let workspace_root = self.project_root.display().to_string();
4116 {
4117 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
4118 for rel_path in &candidate_own_refresh {
4119 let Some(extract) = changed_extracts.get(rel_path) else {
4120 continue;
4121 };
4122 if !write_amplification_baseline_enabled()
4123 && stored_extract_matches(&tx, rel_path, extract, &index)?
4124 {
4125 unchanged_extracts += 1;
4126 update_file_fresh_metadata(
4127 &tx,
4128 &self.project_root,
4129 rel_path,
4130 &extract.freshness.content_hash,
4131 extract.freshness.mtime,
4132 extract.freshness.size,
4133 )?;
4134 continue;
4135 }
4136
4137 own_refresh.insert(rel_path.clone());
4138 let started = Instant::now();
4139 delete_file_rows(&tx, rel_path)?;
4140 clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
4141 profile.row_deletes += started.elapsed();
4142 let started = Instant::now();
4143 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
4144 profile.row_inserts += started.elapsed();
4145 }
4146
4147 let dependency_callers = touched_callers
4148 .iter()
4149 .filter(|rel_path| {
4150 !deleted.contains(*rel_path) && !candidate_own_refresh.contains(*rel_path)
4151 })
4152 .cloned()
4153 .collect::<Vec<_>>();
4154 for rel_path in dependency_callers {
4155 let Some(extract) = caller_extracts.get(&rel_path) else {
4156 continue;
4157 };
4158 if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
4159 continue;
4160 }
4161
4162 own_refresh.insert(rel_path.clone());
4163 let started = Instant::now();
4164 delete_file_rows(&tx, &rel_path)?;
4165 clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
4166 profile.row_deletes += started.elapsed();
4167 let started = Instant::now();
4168 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
4169 profile.row_inserts += started.elapsed();
4170 }
4171 let started = Instant::now();
4172 for rel_path in &touched_callers {
4173 if deleted.contains(rel_path) {
4174 continue;
4175 }
4176 let Some(extract) = caller_extracts.get(rel_path) else {
4177 continue;
4178 };
4179 if own_refresh.contains(rel_path) {
4180 delete_refs_for_caller(&tx, rel_path)?;
4181 for raw_ref in &extract.raw_refs {
4182 let resolved = resolve_ref(raw_ref.clone(), &index)?;
4183 insert_resolved_ref_prepared(&mut inserts, &resolved)?;
4184 }
4185 continue;
4186 }
4187
4188 let selected_for_caller = selected_refs_by_caller
4189 .get(rel_path)
4190 .cloned()
4191 .unwrap_or_default();
4192 delete_ref_ids(&tx, &selected_for_caller)?;
4193 for raw_ref in &extract.raw_refs {
4194 if selected_for_caller.contains(&raw_ref.ref_id) {
4195 let resolved = resolve_ref(raw_ref.clone(), &index)?;
4196 insert_resolved_ref_prepared(&mut inserts, &resolved)?;
4197 }
4198 }
4199 }
4200 profile.ref_resolution += started.elapsed();
4201 }
4202
4203 let started = Instant::now();
4204 delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
4205 insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
4206 profile.method_dispatch += started.elapsed();
4207
4208 bump_projection_write_revision(&tx)?;
4209 let started = Instant::now();
4210 commit_incremental_if_current(tx)?;
4211 self.record_commit(total_changes_before, &conn);
4212 profile.commit += started.elapsed();
4213 profile.total = total_started.elapsed();
4214 Ok((
4215 IncrementalStats {
4216 changed_files: changed,
4217 surface_changed: surface_changed.into_iter().collect(),
4218 deleted_files: deleted.into_iter().collect(),
4219 dependency_selected_refs,
4220 refreshed_own_files: own_refresh.len(),
4221 unchanged_extract_files: unchanged_extracts,
4222 },
4223 profile,
4224 ))
4225 }
4226
4227 pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
4228 self.cold_build(current_files)
4229 }
4230
4231 pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
4232 self.verify_writer_lease()?;
4233 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
4234 let total_changes_before = conn.total_changes();
4235 let tx = conn.transaction()?;
4236 let mut marked = Vec::new();
4237 for path in files {
4238 let (abs_path, rel_path) = match normalize_project_file_path(&self.project_root, path) {
4239 Ok(path) => path,
4240 Err(error) => {
4241 drop(tx);
4242 record_path_identity_mismatch(&conn, &error)?;
4243 return Err(error);
4244 }
4245 };
4246 let freshness = cache_freshness::collect(&abs_path).ok();
4247 mark_backend_state(
4248 &tx,
4249 &self.project_root,
4250 &rel_path,
4251 freshness.as_ref().map(|freshness| &freshness.content_hash),
4252 "stale",
4253 )?;
4254 marked.push(rel_path);
4255 }
4256 bump_projection_write_revision(&tx)?;
4257 tx.commit()?;
4258 self.record_commit(total_changes_before, &conn);
4259 marked.sort();
4260 marked.dedup();
4261 Ok(marked)
4262 }
4263
4264 pub fn stale_files(&self) -> Result<Vec<String>> {
4265 self.refresh_read_marker()?;
4266 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4267 let mut stmt = conn.prepare(
4268 "SELECT DISTINCT file_path FROM backend_file_state
4269 WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
4270 ORDER BY file_path",
4271 )?;
4272 let rows = stmt.query_map(
4273 params![BACKEND_TREESITTER, self.project_root.display().to_string()],
4274 |row| row.get::<_, String>(0),
4275 )?;
4276 rows.collect::<std::result::Result<Vec<_>, _>>()
4277 .map_err(Into::into)
4278 }
4279
4280 pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
4281 self.refresh_read_marker()?;
4282 let rel_path = relative_path(
4283 &self.project_root,
4284 &normalize_file_path(&self.project_root, file)?,
4285 );
4286 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4287 conn.query_row(
4288 "SELECT status FROM backend_file_state
4289 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
4290 ORDER BY updated_at DESC LIMIT 1",
4291 params![
4292 BACKEND_TREESITTER,
4293 self.project_root.display().to_string(),
4294 rel_path
4295 ],
4296 |row| row.get(0),
4297 )
4298 .optional()
4299 .map_err(Into::into)
4300 }
4301
4302 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4303 self.refresh_read_marker()?;
4304 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4305 self.ensure_ready(&conn)?;
4306 edge_snapshot_with_conn(&conn)
4307 }
4308
4309 pub fn indexed_file_count(&self) -> Result<usize> {
4310 self.refresh_read_marker()?;
4311 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4312 self.ensure_ready(&conn)?;
4313 indexed_file_count(&conn)
4314 }
4315
4316 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4317 self.refresh_read_marker()?;
4318 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4319 let rel_path = relative_path(&self.project_root, &abs_path);
4320 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4321 self.ensure_ready(&conn)?;
4322 resolve_node_for_rel(&conn, &rel_path, symbol)
4323 }
4324
4325 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4330 self.refresh_read_marker()?;
4331 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4332 let rel_path = relative_path(&self.project_root, &abs_path);
4333 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4334 self.ensure_ready(&conn)?;
4335 nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
4336 }
4337
4338 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4340 self.refresh_read_marker()?;
4341 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4342 self.ensure_ready(&conn)?;
4343 nodes_matching_symbol(&conn, symbol)
4344 }
4345
4346 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4348 self.refresh_read_marker()?;
4349 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4350 let rel_path = relative_path(&self.project_root, &abs_path);
4351 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4352 self.ensure_ready(&conn)?;
4353 direct_callers_for_tuple(&conn, &rel_path, symbol)
4354 }
4355
4356 pub fn direct_callers_for_symbols(
4358 &self,
4359 targets: &[(String, String)],
4360 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4361 if targets.is_empty() {
4362 return Ok(HashMap::new());
4363 }
4364 self.refresh_read_marker()?;
4365 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4366 self.ensure_ready(&conn)?;
4367 direct_callers_for_tuples(&conn, targets)
4368 }
4369
4370 pub fn direct_caller_counts_of(
4372 &self,
4373 targets: &[(String, String)],
4374 ) -> Result<HashMap<(String, String), usize>> {
4375 if targets.is_empty() {
4376 return Ok(HashMap::new());
4377 }
4378 self.refresh_read_marker()?;
4379 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4380 self.ensure_ready(&conn)?;
4381 direct_caller_counts_for_tuples(&conn, targets)
4382 }
4383
4384 pub fn callers_of(
4385 &self,
4386 file_rel: &Path,
4387 symbol: &str,
4388 depth: usize,
4389 ) -> Result<StoreCallersResult> {
4390 let target = self.node_for(file_rel, symbol)?;
4391 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4392 self.ensure_ready(&conn)?;
4393 let effective_depth = depth.max(1);
4394 let mut visited = HashSet::new();
4395 let mut callers = Vec::new();
4396 let mut depth_limited = false;
4397 let mut truncated = 0usize;
4398 collect_callers_recursive(
4399 &conn,
4400 &target.file,
4401 &target.symbol,
4402 effective_depth,
4403 0,
4404 &mut visited,
4405 &mut callers,
4406 &mut depth_limited,
4407 &mut truncated,
4408 )?;
4409 Ok(StoreCallersResult {
4410 target,
4411 callers,
4412 scanned_files: indexed_file_count(&conn)?,
4413 depth_limited,
4414 truncated,
4415 })
4416 }
4417
4418 pub fn impact_of(
4419 &self,
4420 file_rel: &Path,
4421 symbol: &str,
4422 depth: usize,
4423 ) -> Result<StoreImpactResult> {
4424 let callers = self.callers_of(file_rel, symbol, depth)?;
4425 let target_parameters = callers
4426 .target
4427 .signature
4428 .as_deref()
4429 .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
4430 .unwrap_or_default();
4431 let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
4432 for site in &callers.callers {
4433 source_lines_by_file
4434 .entry(site.caller.file.clone())
4435 .or_insert_with(|| {
4436 read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
4437 });
4438 }
4439 let enriched = callers
4440 .callers
4441 .iter()
4442 .map(|site| StoreImpactCaller {
4443 site: site.clone(),
4444 signature: site.caller.signature.clone(),
4445 is_entry_point: site.caller.is_entry_point,
4446 call_expression: source_lines_by_file
4447 .get(&site.caller.file)
4448 .and_then(|lines| lines.as_ref())
4449 .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
4450 .cloned(),
4451 parameters: site
4452 .caller
4453 .signature
4454 .as_deref()
4455 .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
4456 .unwrap_or_default(),
4457 })
4458 .collect();
4459 Ok(StoreImpactResult {
4460 target: callers.target,
4461 parameters: target_parameters,
4462 callers: enriched,
4463 depth_limited: callers.depth_limited,
4464 truncated: callers.truncated,
4465 })
4466 }
4467
4468 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4469 self.refresh_read_marker()?;
4470 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4471 self.ensure_ready(&conn)?;
4472 outgoing_calls_for_node(&conn, node)
4473 }
4474
4475 pub fn outgoing_calls_for_symbols(
4477 &self,
4478 sources: &[(String, String)],
4479 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4480 if sources.is_empty() {
4481 return Ok(HashMap::new());
4482 }
4483 self.refresh_read_marker()?;
4484 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4485 self.ensure_ready(&conn)?;
4486 outgoing_calls_for_symbol_tuples(&conn, sources)
4487 }
4488
4489 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4491 self.refresh_read_marker()?;
4492 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4493 self.ensure_ready(&conn)?;
4494 resolved_self_calls_for_node(&conn, node)
4495 }
4496
4497 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4498 self.refresh_read_marker()?;
4499 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4500 self.ensure_ready(&conn)?;
4501 unresolved_calls_for_node(&conn, node)
4502 }
4503
4504 pub fn call_tree(
4505 &self,
4506 file_rel: &Path,
4507 symbol: &str,
4508 max_depth: usize,
4509 ) -> Result<callgraph::CallTreeNode> {
4510 let node = self.node_for(file_rel, symbol)?;
4511 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4512 self.ensure_ready(&conn)?;
4513 let mut visited = HashSet::new();
4514 call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
4515 }
4516
4517 pub fn trace_to(
4518 &self,
4519 file_rel: &Path,
4520 symbol: &str,
4521 max_depth: usize,
4522 ) -> Result<callgraph::TraceToResult> {
4523 let target = self.node_for(file_rel, symbol)?;
4524 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4525 self.ensure_ready(&conn)?;
4526 let effective_max = if max_depth == 0 { 10 } else { max_depth };
4527
4528 #[derive(Clone)]
4529 struct PathElem {
4530 node: StoreNode,
4531 }
4532
4533 let initial = vec![PathElem {
4534 node: target.clone(),
4535 }];
4536 let mut complete_paths = Vec::new();
4537 if target.is_entry_point {
4538 complete_paths.push(initial.clone());
4539 }
4540
4541 let mut queue = vec![(initial, 0usize)];
4542 let mut max_depth_reached = false;
4543 let mut truncated_paths = 0usize;
4544
4545 while let Some((path, depth)) = queue.pop() {
4546 if depth >= effective_max {
4547 max_depth_reached = true;
4548 continue;
4549 }
4550 let Some(current) = path.last() else {
4551 continue;
4552 };
4553 let callers =
4554 direct_callers_for_tuple(&conn, ¤t.node.file, ¤t.node.symbol)?;
4555 if callers.is_empty() {
4556 if path.len() > 1 {
4557 truncated_paths += 1;
4558 }
4559 continue;
4560 }
4561
4562 let mut has_new_path = false;
4563 for site in callers {
4564 if path.iter().any(|elem| {
4565 elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
4566 }) {
4567 continue;
4568 }
4569 has_new_path = true;
4570 let mut new_path = path.clone();
4571 new_path.push(PathElem {
4572 node: site.caller.clone(),
4573 });
4574 if site.caller.is_entry_point {
4575 complete_paths.push(new_path.clone());
4576 }
4577 queue.push((new_path, depth + 1));
4578 }
4579 if !has_new_path && path.len() > 1 {
4580 truncated_paths += 1;
4581 }
4582 }
4583
4584 let mut paths: Vec<callgraph::TracePath> = complete_paths
4585 .into_iter()
4586 .map(|mut elems| {
4587 elems.reverse();
4588 let hops = elems
4589 .iter()
4590 .enumerate()
4591 .map(|(index, elem)| callgraph::TraceHop {
4592 symbol: elem.node.symbol.clone(),
4593 file: elem.node.file.clone(),
4594 line: elem.node.line,
4595 signature: elem.node.signature.clone(),
4596 is_entry_point: index == 0 && elem.node.is_entry_point,
4597 })
4598 .collect();
4599 callgraph::TracePath { hops }
4600 })
4601 .collect();
4602 paths.sort_by(|left, right| {
4603 let left_entry = left
4604 .hops
4605 .first()
4606 .map(|hop| hop.symbol.as_str())
4607 .unwrap_or("");
4608 let right_entry = right
4609 .hops
4610 .first()
4611 .map(|hop| hop.symbol.as_str())
4612 .unwrap_or("");
4613 left_entry
4614 .cmp(right_entry)
4615 .then(left.hops.len().cmp(&right.hops.len()))
4616 });
4617 let entry_points_found = paths
4618 .iter()
4619 .filter_map(|path| path.hops.first())
4620 .filter(|hop| hop.is_entry_point)
4621 .map(|hop| (hop.file.clone(), hop.symbol.clone()))
4622 .collect::<HashSet<_>>()
4623 .len();
4624
4625 Ok(callgraph::TraceToResult {
4626 target_symbol: target.symbol,
4627 target_file: target.file,
4628 total_paths: paths.len(),
4629 paths,
4630 entry_points_found,
4631 max_depth_reached,
4632 truncated_paths,
4633 })
4634 }
4635
4636 pub fn trace_to_symbol_candidates(
4637 &self,
4638 to_symbol: &str,
4639 ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
4640 self.refresh_read_marker()?;
4641 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4642 self.ensure_ready(&conn)?;
4643 let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
4644 for node in nodes_matching_symbol(&conn, to_symbol)? {
4645 candidates_by_file
4646 .entry(node.file)
4647 .and_modify(|line| *line = (*line).min(node.line))
4648 .or_insert(node.line);
4649 }
4650 let mut candidates: Vec<_> = candidates_by_file
4651 .into_iter()
4652 .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
4653 .collect();
4654 candidates
4655 .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
4656 Ok(candidates)
4657 }
4658
4659 pub fn trace_to_symbol(
4660 &self,
4661 file_rel: &Path,
4662 symbol: &str,
4663 to_symbol: &str,
4664 to_file: Option<&Path>,
4665 max_depth: usize,
4666 ) -> Result<callgraph::TraceToSymbolResult> {
4667 let origin = self.node_for(file_rel, symbol)?;
4668 let target_file = to_file
4669 .map(|path| normalize_file_path(&self.project_root, path))
4670 .transpose()?
4671 .map(|path| relative_path(&self.project_root, &path));
4672 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4673 self.ensure_ready(&conn)?;
4674 let effective_max = if max_depth == 0 {
4675 10
4676 } else {
4677 max_depth.min(16)
4678 };
4679
4680 let start_hop = trace_to_symbol_hop(&origin);
4681 if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
4682 return Ok(callgraph::TraceToSymbolResult {
4683 path: Some(vec![start_hop]),
4684 complete: true,
4685 reason: None,
4686 });
4687 }
4688
4689 let mut queue = VecDeque::new();
4690 queue.push_back((origin.clone(), vec![start_hop], 0usize));
4691 let mut visited = HashSet::new();
4692 visited.insert((origin.file.clone(), origin.symbol.clone()));
4693 let mut max_depth_exhausted = false;
4694
4695 while let Some((current, path, depth)) = queue.pop_front() {
4696 let callees = outgoing_calls_for_node(&conn, ¤t)?
4697 .into_iter()
4698 .filter_map(|site| site.target)
4699 .collect::<Vec<_>>();
4700
4701 if depth >= effective_max {
4702 if callees
4703 .iter()
4704 .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
4705 {
4706 max_depth_exhausted = true;
4707 }
4708 continue;
4709 }
4710
4711 for callee in callees {
4712 if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
4713 continue;
4714 }
4715 let mut next_path = path.clone();
4716 next_path.push(trace_to_symbol_hop(&callee));
4717 if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
4718 return Ok(callgraph::TraceToSymbolResult {
4719 path: Some(next_path),
4720 complete: true,
4721 reason: None,
4722 });
4723 }
4724 queue.push_back((callee, next_path, depth + 1));
4725 }
4726 }
4727
4728 if max_depth_exhausted {
4729 Ok(callgraph::TraceToSymbolResult {
4730 path: None,
4731 complete: false,
4732 reason: Some("max_depth_exhausted".to_string()),
4733 })
4734 } else {
4735 Ok(callgraph::TraceToSymbolResult {
4736 path: None,
4737 complete: true,
4738 reason: Some("no_path_found".to_string()),
4739 })
4740 }
4741 }
4742}
4743
4744impl ReadonlyCallGraphStore {
4745 fn from_inner(inner: CallGraphStore) -> Self {
4746 Self { inner }
4747 }
4748
4749 pub fn project_root(&self) -> &Path {
4750 self.inner.project_root()
4751 }
4752
4753 pub fn project_key(&self) -> &str {
4754 self.inner.project_key()
4755 }
4756
4757 pub fn sqlite_path(&self) -> &Path {
4758 self.inner.sqlite_path()
4759 }
4760
4761 pub fn stale_files(&self) -> Result<Vec<String>> {
4762 self.inner.stale_files()
4763 }
4764
4765 pub(crate) fn projection_generation(&self) -> Option<&str> {
4766 self.inner.projection_generation()
4767 }
4768
4769 pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
4770 self.inner.projection_write_revision()
4771 }
4772
4773 pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
4776 crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
4777 }
4778
4779 pub fn is_legacy_fallback(&self) -> bool {
4781 self.inner.is_legacy_fallback()
4782 }
4783
4784 pub fn is_current(&self) -> bool {
4785 self.inner.is_current()
4786 }
4787
4788 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4789 self.inner.edge_snapshot()
4790 }
4791
4792 pub fn indexed_file_count(&self) -> Result<usize> {
4793 self.inner.indexed_file_count()
4794 }
4795
4796 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4797 self.inner.node_for(file_rel, symbol)
4798 }
4799
4800 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4801 self.inner.nodes_for(file_rel, symbol)
4802 }
4803
4804 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4805 self.inner.nodes_matching(symbol)
4806 }
4807
4808 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4809 self.inner.direct_callers_of(file_rel, symbol)
4810 }
4811
4812 pub fn direct_callers_for_symbols(
4813 &self,
4814 targets: &[(String, String)],
4815 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4816 self.inner.direct_callers_for_symbols(targets)
4817 }
4818
4819 pub fn direct_caller_counts_of(
4820 &self,
4821 targets: &[(String, String)],
4822 ) -> Result<HashMap<(String, String), usize>> {
4823 self.inner.direct_caller_counts_of(targets)
4824 }
4825
4826 pub fn callers_of(
4827 &self,
4828 file_rel: &Path,
4829 symbol: &str,
4830 depth: usize,
4831 ) -> Result<StoreCallersResult> {
4832 self.inner.callers_of(file_rel, symbol, depth)
4833 }
4834
4835 pub fn impact_of(
4836 &self,
4837 file_rel: &Path,
4838 symbol: &str,
4839 depth: usize,
4840 ) -> Result<StoreImpactResult> {
4841 self.inner.impact_of(file_rel, symbol, depth)
4842 }
4843
4844 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4845 self.inner.outgoing_calls_of(node)
4846 }
4847
4848 pub fn outgoing_calls_for_symbols(
4849 &self,
4850 sources: &[(String, String)],
4851 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4852 self.inner.outgoing_calls_for_symbols(sources)
4853 }
4854
4855 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4856 self.inner.resolved_self_calls_of(node)
4857 }
4858
4859 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4860 self.inner.unresolved_calls_of(node)
4861 }
4862
4863 pub fn call_tree(
4864 &self,
4865 file_rel: &Path,
4866 symbol: &str,
4867 depth: usize,
4868 ) -> Result<callgraph::CallTreeNode> {
4869 self.inner.call_tree(file_rel, symbol, depth)
4870 }
4871
4872 pub fn trace_to(
4873 &self,
4874 file_rel: &Path,
4875 symbol: &str,
4876 max_depth: usize,
4877 ) -> Result<callgraph::TraceToResult> {
4878 self.inner.trace_to(file_rel, symbol, max_depth)
4879 }
4880
4881 pub fn trace_to_symbol_candidates(
4882 &self,
4883 to_symbol: &str,
4884 ) -> Result<Vec<TraceToSymbolCandidate>> {
4885 self.inner.trace_to_symbol_candidates(to_symbol)
4886 }
4887
4888 pub fn trace_to_symbol(
4889 &self,
4890 file_rel: &Path,
4891 symbol: &str,
4892 to_symbol: &str,
4893 to_file: Option<&Path>,
4894 max_depth: usize,
4895 ) -> Result<callgraph::TraceToSymbolResult> {
4896 self.inner
4897 .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4898 }
4899}
4900
4901impl CallGraphRead for CallGraphStore {
4902 fn project_root(&self) -> &Path {
4903 CallGraphStore::project_root(self)
4904 }
4905 fn project_key(&self) -> &str {
4906 CallGraphStore::project_key(self)
4907 }
4908 fn sqlite_path(&self) -> &Path {
4909 CallGraphStore::sqlite_path(self)
4910 }
4911 fn is_current(&self) -> bool {
4912 CallGraphStore::is_current(self)
4913 }
4914 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4915 CallGraphStore::edge_snapshot(self)
4916 }
4917 fn indexed_file_count(&self) -> Result<usize> {
4918 CallGraphStore::indexed_file_count(self)
4919 }
4920 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4921 CallGraphStore::node_for(self, file_rel, symbol)
4922 }
4923 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4924 CallGraphStore::nodes_for(self, file_rel, symbol)
4925 }
4926 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4927 CallGraphStore::nodes_matching(self, symbol)
4928 }
4929 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4930 CallGraphStore::direct_callers_of(self, file_rel, symbol)
4931 }
4932 fn direct_callers_for_symbols(
4933 &self,
4934 targets: &[(String, String)],
4935 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4936 CallGraphStore::direct_callers_for_symbols(self, targets)
4937 }
4938 fn direct_caller_counts_of(
4939 &self,
4940 targets: &[(String, String)],
4941 ) -> Result<HashMap<(String, String), usize>> {
4942 CallGraphStore::direct_caller_counts_of(self, targets)
4943 }
4944 fn callers_of(
4945 &self,
4946 file_rel: &Path,
4947 symbol: &str,
4948 depth: usize,
4949 ) -> Result<StoreCallersResult> {
4950 CallGraphStore::callers_of(self, file_rel, symbol, depth)
4951 }
4952 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4953 CallGraphStore::impact_of(self, file_rel, symbol, depth)
4954 }
4955 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4956 CallGraphStore::outgoing_calls_of(self, node)
4957 }
4958 fn outgoing_calls_for_symbols(
4959 &self,
4960 sources: &[(String, String)],
4961 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4962 CallGraphStore::outgoing_calls_for_symbols(self, sources)
4963 }
4964 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4965 CallGraphStore::resolved_self_calls_of(self, node)
4966 }
4967 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4968 CallGraphStore::unresolved_calls_of(self, node)
4969 }
4970 fn call_tree(
4971 &self,
4972 file_rel: &Path,
4973 symbol: &str,
4974 depth: usize,
4975 ) -> Result<callgraph::CallTreeNode> {
4976 CallGraphStore::call_tree(self, file_rel, symbol, depth)
4977 }
4978 fn trace_to(
4979 &self,
4980 file_rel: &Path,
4981 symbol: &str,
4982 max_depth: usize,
4983 ) -> Result<callgraph::TraceToResult> {
4984 CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
4985 }
4986 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4987 CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
4988 }
4989 fn trace_to_symbol(
4990 &self,
4991 file_rel: &Path,
4992 symbol: &str,
4993 to_symbol: &str,
4994 to_file: Option<&Path>,
4995 max_depth: usize,
4996 ) -> Result<callgraph::TraceToSymbolResult> {
4997 CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
4998 }
4999}
5000
5001impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
5002 fn project_root(&self) -> &Path {
5003 (**self).project_root()
5004 }
5005 fn project_key(&self) -> &str {
5006 (**self).project_key()
5007 }
5008 fn sqlite_path(&self) -> &Path {
5009 (**self).sqlite_path()
5010 }
5011 fn is_current(&self) -> bool {
5012 (**self).is_current()
5013 }
5014 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
5015 (**self).edge_snapshot()
5016 }
5017 fn indexed_file_count(&self) -> Result<usize> {
5018 (**self).indexed_file_count()
5019 }
5020 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
5021 (**self).node_for(file_rel, symbol)
5022 }
5023 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
5024 (**self).nodes_for(file_rel, symbol)
5025 }
5026 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
5027 (**self).nodes_matching(symbol)
5028 }
5029 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
5030 (**self).direct_callers_of(file_rel, symbol)
5031 }
5032 fn direct_callers_for_symbols(
5033 &self,
5034 targets: &[(String, String)],
5035 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5036 (**self).direct_callers_for_symbols(targets)
5037 }
5038 fn direct_caller_counts_of(
5039 &self,
5040 targets: &[(String, String)],
5041 ) -> Result<HashMap<(String, String), usize>> {
5042 (**self).direct_caller_counts_of(targets)
5043 }
5044 fn callers_of(
5045 &self,
5046 file_rel: &Path,
5047 symbol: &str,
5048 depth: usize,
5049 ) -> Result<StoreCallersResult> {
5050 (**self).callers_of(file_rel, symbol, depth)
5051 }
5052 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
5053 (**self).impact_of(file_rel, symbol, depth)
5054 }
5055 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5056 (**self).outgoing_calls_of(node)
5057 }
5058 fn outgoing_calls_for_symbols(
5059 &self,
5060 sources: &[(String, String)],
5061 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5062 (**self).outgoing_calls_for_symbols(sources)
5063 }
5064 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5065 (**self).resolved_self_calls_of(node)
5066 }
5067 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
5068 (**self).unresolved_calls_of(node)
5069 }
5070 fn call_tree(
5071 &self,
5072 file_rel: &Path,
5073 symbol: &str,
5074 depth: usize,
5075 ) -> Result<callgraph::CallTreeNode> {
5076 (**self).call_tree(file_rel, symbol, depth)
5077 }
5078 fn trace_to(
5079 &self,
5080 file_rel: &Path,
5081 symbol: &str,
5082 max_depth: usize,
5083 ) -> Result<callgraph::TraceToResult> {
5084 (**self).trace_to(file_rel, symbol, max_depth)
5085 }
5086 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
5087 (**self).trace_to_symbol_candidates(to_symbol)
5088 }
5089 fn trace_to_symbol(
5090 &self,
5091 file_rel: &Path,
5092 symbol: &str,
5093 to_symbol: &str,
5094 to_file: Option<&Path>,
5095 max_depth: usize,
5096 ) -> Result<callgraph::TraceToSymbolResult> {
5097 (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
5098 }
5099}
5100
5101impl CallGraphRead for ReadonlyCallGraphStore {
5102 fn project_root(&self) -> &Path {
5103 self.project_root()
5104 }
5105 fn project_key(&self) -> &str {
5106 self.project_key()
5107 }
5108 fn sqlite_path(&self) -> &Path {
5109 self.sqlite_path()
5110 }
5111 fn is_current(&self) -> bool {
5112 self.is_current()
5113 }
5114 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
5115 self.edge_snapshot()
5116 }
5117 fn indexed_file_count(&self) -> Result<usize> {
5118 self.indexed_file_count()
5119 }
5120 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
5121 self.node_for(file_rel, symbol)
5122 }
5123 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
5124 self.nodes_for(file_rel, symbol)
5125 }
5126 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
5127 self.nodes_matching(symbol)
5128 }
5129 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
5130 self.direct_callers_of(file_rel, symbol)
5131 }
5132 fn direct_callers_for_symbols(
5133 &self,
5134 targets: &[(String, String)],
5135 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5136 self.direct_callers_for_symbols(targets)
5137 }
5138 fn direct_caller_counts_of(
5139 &self,
5140 targets: &[(String, String)],
5141 ) -> Result<HashMap<(String, String), usize>> {
5142 self.direct_caller_counts_of(targets)
5143 }
5144 fn callers_of(
5145 &self,
5146 file_rel: &Path,
5147 symbol: &str,
5148 depth: usize,
5149 ) -> Result<StoreCallersResult> {
5150 self.callers_of(file_rel, symbol, depth)
5151 }
5152 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
5153 self.impact_of(file_rel, symbol, depth)
5154 }
5155 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5156 self.outgoing_calls_of(node)
5157 }
5158 fn outgoing_calls_for_symbols(
5159 &self,
5160 sources: &[(String, String)],
5161 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5162 self.outgoing_calls_for_symbols(sources)
5163 }
5164 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5165 self.resolved_self_calls_of(node)
5166 }
5167 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
5168 self.unresolved_calls_of(node)
5169 }
5170 fn call_tree(
5171 &self,
5172 file_rel: &Path,
5173 symbol: &str,
5174 depth: usize,
5175 ) -> Result<callgraph::CallTreeNode> {
5176 self.call_tree(file_rel, symbol, depth)
5177 }
5178 fn trace_to(
5179 &self,
5180 file_rel: &Path,
5181 symbol: &str,
5182 max_depth: usize,
5183 ) -> Result<callgraph::TraceToResult> {
5184 self.trace_to(file_rel, symbol, max_depth)
5185 }
5186 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
5187 self.trace_to_symbol_candidates(to_symbol)
5188 }
5189 fn trace_to_symbol(
5190 &self,
5191 file_rel: &Path,
5192 symbol: &str,
5193 to_symbol: &str,
5194 to_file: Option<&Path>,
5195 max_depth: usize,
5196 ) -> Result<callgraph::TraceToSymbolResult> {
5197 self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
5198 }
5199}
5200
5201fn indexed_file_count(conn: &Connection) -> Result<usize> {
5202 let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
5203 Ok(count.max(0) as usize)
5204}
5205
5206fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
5207 let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
5208 match candidates.as_slice() {
5209 [candidate] => Ok(candidate.clone()),
5210 [] => Err(AftError::SymbolNotFound {
5211 name: symbol.to_string(),
5212 file: rel_path.to_string(),
5213 }
5214 .into()),
5215 _ => Err(AftError::AmbiguousSymbol {
5216 name: symbol.to_string(),
5217 candidates: candidates
5218 .iter()
5219 .map(|candidate| candidate.symbol.clone())
5220 .collect(),
5221 }
5222 .into()),
5223 }
5224}
5225
5226fn nodes_for_file_matching_symbol(
5227 conn: &Connection,
5228 rel_path: &str,
5229 symbol: &str,
5230) -> Result<Vec<StoreNode>> {
5231 let qualified_query = symbol.contains("::");
5232 let sql = if qualified_query {
5233 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5234 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5235 FROM nodes n JOIN files f ON f.path = n.file_path
5236 WHERE n.file_path = ?1 AND n.scoped_name = ?2
5237 ORDER BY n.scoped_name, n.start_line, n.start_col"
5238 } else {
5239 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5240 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5241 FROM nodes n JOIN files f ON f.path = n.file_path
5242 WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
5243 ORDER BY n.scoped_name, n.start_line, n.start_col"
5244 };
5245 let mut stmt = conn.prepare(sql)?;
5246 let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
5247 rows.collect::<std::result::Result<Vec<_>, _>>()
5248 .map_err(Into::into)
5249}
5250
5251fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
5252 let qualified_query = symbol.contains("::");
5253 let sql = if qualified_query {
5254 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5255 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5256 FROM nodes n JOIN files f ON f.path = n.file_path
5257 WHERE n.scoped_name = ?1
5258 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
5259 } else {
5260 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5261 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5262 FROM nodes n JOIN files f ON f.path = n.file_path
5263 WHERE n.scoped_name = ?1 OR n.name = ?1
5264 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
5265 };
5266 let mut stmt = conn.prepare(sql)?;
5267 let rows = stmt.query_map(params![symbol], store_node_from_row)?;
5268 rows.collect::<std::result::Result<Vec<_>, _>>()
5269 .map_err(Into::into)
5270}
5271
5272fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
5273 store_node_from_row_at(row, 0)
5274}
5275
5276fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
5277 let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
5278 let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
5279 let lang_label_value: String = row.get(offset + 10)?;
5280 Ok(StoreNode {
5281 node_id: row.get(offset)?,
5282 file: row.get(offset + 1)?,
5283 symbol: row.get(offset + 2)?,
5284 name: row.get(offset + 3)?,
5285 kind: row.get(offset + 4)?,
5286 line: start_line.saturating_add(1),
5287 end_line: end_line.saturating_add(1),
5288 signature: row.get(offset + 7)?,
5289 exported: row.get::<_, i64>(offset + 8)? != 0,
5290 is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
5291 lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
5292 })
5293}
5294
5295fn optional_store_node_from_row_at(
5296 row: &rusqlite::Row<'_>,
5297 offset: usize,
5298) -> rusqlite::Result<Option<StoreNode>> {
5299 if row.get::<_, Option<String>>(offset)?.is_some() {
5300 store_node_from_row_at(row, offset).map(Some)
5301 } else {
5302 Ok(None)
5303 }
5304}
5305
5306#[allow(clippy::too_many_arguments)]
5307fn collect_callers_recursive(
5308 conn: &Connection,
5309 file: &str,
5310 symbol: &str,
5311 max_depth: usize,
5312 current_depth: usize,
5313 visited: &mut HashSet<(String, String)>,
5314 result: &mut Vec<StoreCallSite>,
5315 depth_limited: &mut bool,
5316 truncated: &mut usize,
5317) -> Result<()> {
5318 if current_depth >= max_depth {
5319 let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
5320 if omitted > 0 {
5321 *depth_limited = true;
5322 *truncated += omitted;
5323 }
5324 return Ok(());
5325 }
5326
5327 if !visited.insert((file.to_string(), symbol.to_string())) {
5328 return Ok(());
5329 }
5330
5331 let sites = direct_callers_for_tuple(conn, file, symbol)?;
5332 for site in sites {
5333 result.push(site.clone());
5334 if current_depth + 1 < max_depth {
5335 collect_callers_recursive(
5336 conn,
5337 &site.caller.file,
5338 &site.caller.symbol,
5339 max_depth,
5340 current_depth + 1,
5341 visited,
5342 result,
5343 depth_limited,
5344 truncated,
5345 )?;
5346 } else {
5347 let omitted =
5348 direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
5349 if omitted > 0 {
5350 *depth_limited = true;
5351 *truncated += omitted;
5352 }
5353 }
5354 }
5355 Ok(())
5356}
5357
5358const DIRECT_CALLER_BATCH_SIZE: usize = 499;
5360
5361fn direct_caller_counts_for_tuples(
5362 conn: &Connection,
5363 targets: &[(String, String)],
5364) -> Result<HashMap<(String, String), usize>> {
5365 let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
5366 let mut counts = unique_targets
5367 .iter()
5368 .cloned()
5369 .map(|target| (target, 0usize))
5370 .collect::<HashMap<_, _>>();
5371
5372 let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
5373 for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
5374 let requested_values = (0..chunk.len())
5375 .map(|_| "(?, ?)")
5376 .collect::<Vec<_>>()
5377 .join(", ");
5378 let sql = format!(
5379 "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
5380 deduped AS (
5381 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
5382 FROM requested requested
5383 JOIN edges e
5384 ON e.target_file = requested.target_file
5385 AND e.target_symbol = requested.target_symbol
5386 AND e.kind = 'call'
5387 JOIN refs r ON r.ref_id = e.ref_id
5388 JOIN nodes src ON src.id = e.source_node
5389 JOIN files src_file ON src_file.path = src.file_path
5390 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
5391 )
5392 SELECT target_file, target_symbol, COUNT(*)
5393 FROM deduped
5394 GROUP BY target_file, target_symbol"
5395 );
5396 let bindings = chunk
5397 .iter()
5398 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5399 let mut stmt = conn.prepare(&sql)?;
5400 let rows = stmt.query_map(params_from_iter(bindings), |row| {
5401 Ok((
5402 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
5403 row.get::<_, i64>(2)?,
5404 ))
5405 })?;
5406 for row in rows {
5407 let (target, count) = row?;
5408 counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
5409 }
5410 }
5411
5412 Ok(counts)
5413}
5414
5415fn direct_caller_count_for_tuple(
5416 conn: &Connection,
5417 target_file: &str,
5418 target_symbol: &str,
5419) -> Result<usize> {
5420 let count: i64 = conn.query_row(
5421 "SELECT COUNT(*)
5422 FROM edges e
5423 JOIN refs r ON r.ref_id = e.ref_id
5424 JOIN nodes src ON src.id = e.source_node
5425 JOIN files src_file ON src_file.path = src.file_path
5426 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
5427 params![target_file, target_symbol],
5428 |row| row.get(0),
5429 )?;
5430 Ok(usize::try_from(count).unwrap_or(usize::MAX))
5431}
5432
5433fn direct_callers_for_tuple(
5434 conn: &Connection,
5435 target_file: &str,
5436 target_symbol: &str,
5437) -> Result<Vec<StoreCallSite>> {
5438 let mut stmt = conn.prepare(
5439 "SELECT e.target_file, e.target_symbol, e.line,
5440 r.byte_start, r.byte_end, r.status, e.provenance,
5441 src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5442 src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5443 src_file.lang,
5444 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5445 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5446 tgt_file.lang
5447 FROM edges e
5448 JOIN refs r ON r.ref_id = e.ref_id
5449 JOIN nodes src ON src.id = e.source_node
5450 JOIN files src_file ON src_file.path = src.file_path
5451 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5452 ON tgt.id = e.target_node
5453 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
5454 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
5455 )?;
5456 let rows = stmt.query_map(
5457 params![target_file, target_symbol],
5458 direct_call_site_from_row,
5459 )?;
5460 rows.collect::<std::result::Result<Vec<_>, _>>()
5461 .map_err(Into::into)
5462}
5463
5464fn direct_call_site_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreCallSite> {
5465 let caller = store_node_from_row_at(row, 7)?;
5466 let target = optional_store_node_from_row_at(row, 18)?;
5467 Ok(StoreCallSite {
5468 caller,
5469 target_file: row.get(0)?,
5470 target_symbol: row.get(1)?,
5471 target,
5472 line: row.get::<_, i64>(2)?.max(0) as u32,
5473 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5474 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5475 resolved: row.get::<_, String>(5)? == "resolved",
5476 provenance: row.get(6)?,
5477 })
5478}
5479
5480fn direct_callers_for_tuples(
5481 conn: &Connection,
5482 targets: &[(String, String)],
5483) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5484 let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
5485 let mut callers_by_target = unique_targets
5486 .iter()
5487 .cloned()
5488 .map(|target| (target, Vec::new()))
5489 .collect::<HashMap<_, _>>();
5490 let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
5491
5492 for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
5493 let requested_values = (0..chunk.len())
5494 .map(|_| "(?, ?)")
5495 .collect::<Vec<_>>()
5496 .join(", ");
5497 let sql = format!(
5498 "WITH requested(target_file, target_symbol) AS (VALUES {requested_values})
5499 SELECT e.target_file, e.target_symbol, e.line,
5500 r.byte_start, r.byte_end, r.status, e.provenance,
5501 src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5502 src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5503 src_file.lang,
5504 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5505 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5506 tgt_file.lang
5507 FROM requested requested
5508 JOIN edges e
5509 ON e.target_file = requested.target_file
5510 AND e.target_symbol = requested.target_symbol
5511 AND e.kind = 'call'
5512 JOIN refs r ON r.ref_id = e.ref_id
5513 JOIN nodes src ON src.id = e.source_node
5514 JOIN files src_file ON src_file.path = src.file_path
5515 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5516 ON tgt.id = e.target_node
5517 ORDER BY e.target_file, e.target_symbol, e.source_node,
5518 r.byte_start, r.line, r.ref_id"
5519 );
5520 let bindings = chunk
5521 .iter()
5522 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5523 let mut stmt = conn.prepare(&sql)?;
5524 let rows = stmt.query_map(params_from_iter(bindings), |row| {
5525 let call = direct_call_site_from_row(row)?;
5526 let target_key = (call.target_file.clone(), call.target_symbol.clone());
5527 Ok((target_key, call))
5528 })?;
5529 for row in rows {
5530 let (target, call) = row?;
5531 callers_by_target
5532 .get_mut(&target)
5533 .expect("batched caller row belongs to a requested target")
5534 .push(call);
5535 }
5536 }
5537
5538 Ok(callers_by_target)
5539}
5540
5541const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
5543const OUTGOING_NODE_BATCH_SIZE: usize = 999;
5545
5546fn outgoing_calls_for_symbol_tuples(
5547 conn: &Connection,
5548 sources: &[(String, String)],
5549) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5550 let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
5551 let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
5552 let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
5553 let source_nodes = unique_sources
5554 .iter()
5555 .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
5556 .cloned()
5557 .collect::<Vec<_>>();
5558 let source_nodes_by_id = source_nodes
5559 .iter()
5560 .cloned()
5561 .map(|node| (node.node_id.clone(), node))
5562 .collect::<HashMap<_, _>>();
5563 let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
5564
5565 for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
5566 let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
5567 let sql = format!(
5568 "SELECT e.source_node,
5569 e.target_file, e.target_symbol, e.line,
5570 r.byte_start, r.byte_end, r.status, e.provenance,
5571 CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
5572 tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5573 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5574 tgt_file.lang
5575 FROM edges e
5576 JOIN refs r ON r.ref_id = e.ref_id
5577 LEFT JOIN nodes tgt ON tgt.id = e.target_node
5578 LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
5579 WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
5580 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
5581 );
5582 let bindings = chunk.iter().map(|node| node.node_id.as_str());
5583 let mut stmt = conn.prepare(&sql)?;
5584 let rows = stmt.query_map(params_from_iter(bindings), |row| {
5585 let source_node_id = row.get::<_, String>(0)?;
5586 let caller = source_nodes_by_id
5587 .get(&source_node_id)
5588 .expect("batched outgoing row belongs to a requested source node")
5589 .clone();
5590 let target = optional_store_node_from_row_at(row, 8)?;
5591 Ok((
5592 source_node_id,
5593 StoreCallSite {
5594 caller,
5595 target_file: row.get(1)?,
5596 target_symbol: row.get(2)?,
5597 target,
5598 line: row.get::<_, i64>(3)?.max(0) as u32,
5599 byte_start: row.get::<_, i64>(4)?.max(0) as usize,
5600 byte_end: row.get::<_, i64>(5)?.max(0) as usize,
5601 resolved: row.get::<_, String>(6)? == "resolved",
5602 provenance: row.get(7)?,
5603 },
5604 ))
5605 })?;
5606 for row in rows {
5607 let (source_node_id, call) = row?;
5608 calls_by_node.entry(source_node_id).or_default().push(call);
5609 }
5610 }
5611
5612 let mut calls_by_source = HashMap::new();
5613 for source in &unique_sources {
5614 let mut calls = Vec::new();
5615 if let Some(nodes) = source_nodes_by_symbol.get(source) {
5616 for node in nodes {
5617 if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
5618 calls.extend(node_calls);
5619 }
5620 }
5621 }
5622 calls_by_source.insert(source.clone(), calls);
5623 }
5624
5625 let target_tuples = calls_by_source
5628 .values()
5629 .flatten()
5630 .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
5631 .collect::<Vec<_>>();
5632 let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
5633 for calls in calls_by_source.values_mut() {
5634 for call in calls {
5635 if let Some(target) = target_nodes
5636 .get(&(call.target_file.clone(), call.target_symbol.clone()))
5637 .and_then(|nodes| nodes.first())
5638 {
5639 call.target = Some(target.clone());
5640 }
5641 }
5642 }
5643
5644 Ok(calls_by_source)
5645}
5646
5647fn nodes_for_symbol_tuples(
5648 conn: &Connection,
5649 symbols: &[(String, String)],
5650) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
5651 let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
5652 let mut nodes_by_symbol = unique_symbols
5653 .iter()
5654 .cloned()
5655 .map(|symbol| (symbol, Vec::new()))
5656 .collect::<HashMap<_, _>>();
5657 let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
5658
5659 for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
5660 let requested_values = (0..chunk.len())
5661 .map(|_| "(?, ?)")
5662 .collect::<Vec<_>>()
5663 .join(", ");
5664 let sql = format!(
5665 "WITH requested(file, symbol) AS (VALUES {requested_values})
5666 SELECT requested.file, requested.symbol,
5667 node.id, node.file_path, node.scoped_name, node.name, node.kind,
5668 node.start_line, node.end_line, node.signature, node.exported,
5669 node.is_callgraph_entry_point, node_file.lang
5670 FROM requested
5671 JOIN nodes node INDEXED BY idx_nodes_file
5672 ON node.file_path = requested.file
5673 AND node.scoped_name = requested.symbol
5674 JOIN files node_file ON node_file.path = node.file_path
5675 ORDER BY requested.file, requested.symbol,
5676 node.scoped_name, node.start_line, node.end_line,
5677 node.start_col, node.range_ordinal"
5678 );
5679 let bindings = chunk
5680 .iter()
5681 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5682 let mut stmt = conn.prepare(&sql)?;
5683 let rows = stmt.query_map(params_from_iter(bindings), |row| {
5684 Ok((
5685 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
5686 store_node_from_row_at(row, 2)?,
5687 ))
5688 })?;
5689 for row in rows {
5690 let (symbol, node) = row?;
5691 nodes_by_symbol.entry(symbol).or_default().push(node);
5692 }
5693 }
5694
5695 Ok(nodes_by_symbol)
5696}
5697
5698fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5699 let mut stmt = conn.prepare(
5700 "SELECT e.target_file, e.target_symbol, e.line,
5701 r.byte_start, r.byte_end, r.status, e.provenance,
5702 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5703 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5704 tgt_file.lang
5705 FROM edges e
5706 JOIN refs r ON r.ref_id = e.ref_id
5707 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5708 ON tgt.id = e.target_node
5709 WHERE e.kind = 'call' AND e.source_node = ?1
5710 ORDER BY r.byte_start, r.line, r.ref_id",
5711 )?;
5712 let rows = stmt.query_map(params![node.node_id], |row| {
5713 let target = optional_store_node_from_row_at(row, 7)?;
5714 Ok(StoreCallSite {
5715 caller: node.clone(),
5716 target_file: row.get(0)?,
5717 target_symbol: row.get(1)?,
5718 target,
5719 line: row.get::<_, i64>(2)?.max(0) as u32,
5720 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5721 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5722 resolved: row.get::<_, String>(5)? == "resolved",
5723 provenance: row.get(6)?,
5724 })
5725 })?;
5726 rows.collect::<std::result::Result<Vec<_>, _>>()
5727 .map_err(Into::into)
5728}
5729
5730fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5731 let mut stmt = conn.prepare(
5732 "SELECT r.target_file, r.target_symbol, r.line,
5733 r.byte_start, r.byte_end, r.status, r.provenance,
5734 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5735 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5736 tgt_file.lang
5737 FROM refs r
5738 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5739 ON tgt.id = r.target_node
5740 WHERE r.caller_node = ?1
5741 AND r.kind = 'call'
5742 AND r.status <> 'unresolved'
5743 AND r.target_file = ?2
5744 AND r.target_symbol = ?3
5745 AND r.provenance = ?4
5746 AND NOT EXISTS (
5747 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
5748 )
5749 ORDER BY r.byte_start, r.line, r.ref_id",
5750 )?;
5751 let rows = stmt.query_map(
5752 params![
5753 &node.node_id,
5754 &node.file,
5755 &node.symbol,
5756 PROVENANCE_TREESITTER
5757 ],
5758 |row| {
5759 let target = optional_store_node_from_row_at(row, 7)?;
5760 Ok(StoreCallSite {
5761 caller: node.clone(),
5762 target_file: row.get(0)?,
5763 target_symbol: row.get(1)?,
5764 target,
5765 line: row.get::<_, i64>(2)?.max(0) as u32,
5766 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5767 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5768 resolved: row.get::<_, String>(5)? == "resolved",
5769 provenance: row.get(6)?,
5770 })
5771 },
5772 )?;
5773 rows.collect::<std::result::Result<Vec<_>, _>>()
5774 .map_err(Into::into)
5775}
5776
5777fn unresolved_calls_for_node(
5778 conn: &Connection,
5779 node: &StoreNode,
5780) -> Result<Vec<StoreUnresolvedCall>> {
5781 let mut stmt = conn.prepare(
5782 "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
5783 FROM refs
5784 WHERE caller_node = ?1
5785 AND kind = 'call'
5786 AND status = 'unresolved'
5787 AND NOT EXISTS (
5788 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
5789 )
5790 ORDER BY byte_start, line, ref_id",
5791 )?;
5792 let rows = stmt.query_map(params![node.node_id], |row| {
5793 Ok(StoreUnresolvedCall {
5794 caller: node.clone(),
5795 symbol: row.get(0)?,
5796 full_ref: row.get(1)?,
5797 line: row.get::<_, i64>(2)?.max(0) as u32,
5798 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5799 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5800 })
5801 })?;
5802 rows.collect::<std::result::Result<Vec<_>, _>>()
5803 .map_err(Into::into)
5804}
5805
5806fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
5807 let mut calls = Vec::new();
5808 calls.extend(
5809 outgoing_calls_for_node(conn, node)?
5810 .into_iter()
5811 .map(StoreForwardCall::Resolved),
5812 );
5813 calls.extend(
5814 unresolved_calls_for_node(conn, node)?
5815 .into_iter()
5816 .map(StoreForwardCall::Unresolved),
5817 );
5818 calls.sort_by(|left, right| {
5819 left.byte_start()
5820 .cmp(&right.byte_start())
5821 .then(left.line().cmp(&right.line()))
5822 });
5823 Ok(calls)
5824}
5825
5826fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
5827 let resolved_count: i64 = conn.query_row(
5828 "SELECT COUNT(*)
5829 FROM edges e
5830 JOIN refs r ON r.ref_id = e.ref_id
5831 WHERE e.kind = 'call' AND e.source_node = ?1",
5832 params![&node.node_id],
5833 |row| row.get(0),
5834 )?;
5835 let unresolved_count: i64 = conn.query_row(
5836 "SELECT COUNT(*)
5837 FROM refs
5838 WHERE caller_node = ?1
5839 AND kind = 'call'
5840 AND status = 'unresolved'
5841 AND NOT EXISTS (
5842 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
5843 )",
5844 params![&node.node_id],
5845 |row| row.get(0),
5846 )?;
5847 let total = resolved_count.saturating_add(unresolved_count);
5848 Ok(usize::try_from(total).unwrap_or(usize::MAX))
5849}
5850
5851fn call_tree_inner(
5852 conn: &Connection,
5853 node: &StoreNode,
5854 max_depth: usize,
5855 current_depth: usize,
5856 visited: &mut HashSet<(String, String)>,
5857) -> Result<callgraph::CallTreeNode> {
5858 let visit_key = (node.file.clone(), node.symbol.clone());
5859 if visited.contains(&visit_key) {
5860 return Ok(callgraph::CallTreeNode {
5861 name: node.symbol.clone(),
5862 file: node.file.clone(),
5863 line: node.line,
5864 signature: node.signature.clone(),
5865 resolved: true,
5866 children: Vec::new(),
5867 depth_limited: false,
5868 truncated: 0,
5869 });
5870 }
5871 visited.insert(visit_key.clone());
5872
5873 let mut children = Vec::new();
5874 let mut depth_limited = false;
5875 let mut truncated = 0usize;
5876
5877 if current_depth < max_depth {
5878 let calls = forward_calls_for_node(conn, node)?;
5879 for call in calls {
5880 match call {
5881 StoreForwardCall::Resolved(site) => {
5882 if let Some(target) = site.target {
5883 let child =
5884 call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
5885 depth_limited |= child.depth_limited;
5886 truncated += child.truncated;
5887 children.push(child);
5888 } else {
5889 children.push(callgraph::CallTreeNode {
5890 name: site.target_symbol,
5891 file: site.target_file,
5892 line: site.line,
5893 signature: None,
5894 resolved: false,
5895 children: Vec::new(),
5896 depth_limited: false,
5897 truncated: 0,
5898 });
5899 }
5900 }
5901 StoreForwardCall::Unresolved(call) => {
5902 children.push(callgraph::CallTreeNode {
5903 name: call.symbol,
5904 file: call.caller.file,
5905 line: call.line,
5906 signature: None,
5907 resolved: false,
5908 children: Vec::new(),
5909 depth_limited: false,
5910 truncated: 0,
5911 });
5912 }
5913 }
5914 }
5915 } else {
5916 truncated = forward_call_count_for_node(conn, node)?;
5917 depth_limited = truncated > 0;
5918 }
5919
5920 visited.remove(&visit_key);
5921 Ok(callgraph::CallTreeNode {
5922 name: node.symbol.clone(),
5923 file: node.file.clone(),
5924 line: node.line,
5925 signature: node.signature.clone(),
5926 resolved: true,
5927 children,
5928 depth_limited,
5929 truncated,
5930 })
5931}
5932
5933fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
5934 callgraph::TraceToSymbolHop {
5935 symbol: node.symbol.clone(),
5936 file: node.file.clone(),
5937 line: node.line,
5938 }
5939}
5940
5941fn trace_to_symbol_matches_target(
5942 node: &StoreNode,
5943 to_symbol: &str,
5944 to_file: Option<&str>,
5945) -> bool {
5946 if !symbol_query_matches(&node.symbol, to_symbol) {
5947 return false;
5948 }
5949 match to_file {
5950 Some(file) => node.file == file,
5951 None => true,
5952 }
5953}
5954
5955fn symbol_query_matches(symbol: &str, query: &str) -> bool {
5956 symbol == query || unqualified_name(symbol) == query
5957}
5958
5959fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
5960 let source = std::fs::read_to_string(path).ok()?;
5961 Some(source.lines().map(|line| line.trim().to_string()).collect())
5962}
5963
5964#[doc(hidden)]
5965pub fn live_callgraph_edge_snapshot(
5966 project_root: &Path,
5967 files: &[PathBuf],
5968) -> Result<BTreeSet<StoredEdge>> {
5969 let files = normalize_file_list(project_root, files)?;
5970 let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
5971 let mut file_data = Vec::new();
5972 for file in &files {
5973 let canon = canonicalize_path(file);
5974 let data = graph.build_file(&canon)?.clone();
5975 file_data.push((canon, data));
5976 }
5977
5978 let mut edges = BTreeSet::new();
5979 for (caller_file, data) in &file_data {
5980 for (caller_symbol, call_sites) in &data.calls_by_symbol {
5981 for call_site in call_sites {
5982 let resolution = graph.resolve_cross_file_edge(
5983 &call_site.full_callee,
5984 &call_site.callee_name,
5985 caller_file,
5986 &data.import_block,
5987 );
5988 let (target_file, target_symbol) = match resolution {
5989 EdgeResolution::Resolved { file, symbol } => (file, symbol),
5990 EdgeResolution::Unresolved { callee_name } => {
5991 if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
5992 continue;
5993 }
5994 let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
5995 data,
5996 caller_file,
5997 &callee_name,
5998 ) else {
5999 continue;
6000 };
6001 (caller_file.clone(), target_symbol)
6002 }
6003 };
6004 if target_file == *caller_file && target_symbol == *caller_symbol {
6005 continue;
6006 }
6007 edges.insert(StoredEdge {
6008 source_file: relative_path(project_root, caller_file),
6009 source_symbol: caller_symbol.clone(),
6010 target_file: relative_path(project_root, &target_file),
6011 target_symbol,
6012 kind: "call".to_string(),
6013 line: call_site.line,
6014 });
6015 }
6016 }
6017 }
6018 Ok(edges)
6019}
6020
6021fn rebuild_cooldown_records() -> &'static Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>
6022{
6023 SUCCESSFUL_REBUILDS.get_or_init(|| Mutex::new(HashMap::new()))
6024}
6025
6026fn rebuild_cooldown_key(callgraph_dir: &Path, project_key: &str) -> RebuildCooldownKey {
6027 RebuildCooldownKey {
6028 callgraph_dir: std::fs::canonicalize(callgraph_dir)
6029 .unwrap_or_else(|_| callgraph_dir.to_path_buf()),
6030 project_key: project_key.to_string(),
6031 }
6032}
6033
6034fn rebuild_cooldown_denial(
6035 callgraph_dir: &Path,
6036 project_key: &str,
6037 project_root: &Path,
6038 now: Instant,
6039) -> Option<(PathBuf, Duration)> {
6040 let key = rebuild_cooldown_key(callgraph_dir, project_key);
6041 let records = rebuild_cooldown_records()
6042 .lock()
6043 .unwrap_or_else(std::sync::PoisonError::into_inner);
6044 let record = records.get(&key)?;
6045 if record.project_root == project_root || !record.cross_root_cooldown_armed {
6046 return None;
6047 }
6048 let elapsed = now.saturating_duration_since(record.published_at);
6049 (elapsed < REBUILD_COOLDOWN).then(|| (record.project_root.clone(), REBUILD_COOLDOWN - elapsed))
6050}
6051
6052fn record_successful_rebuild(
6053 callgraph_dir: &Path,
6054 project_key: &str,
6055 project_root: &Path,
6056 published_at: Instant,
6057) {
6058 let key = rebuild_cooldown_key(callgraph_dir, project_key);
6059 let mut records = rebuild_cooldown_records()
6060 .lock()
6061 .unwrap_or_else(std::sync::PoisonError::into_inner);
6062 if records.len() >= 4_096 && !records.contains_key(&key) {
6063 if let Some(evict) = records.keys().next().cloned() {
6064 records.remove(&evict);
6065 }
6066 }
6067 let cross_root_cooldown_armed = records.get(&key).is_some_and(|previous| {
6068 previous.cross_root_cooldown_armed || previous.project_root != project_root
6069 });
6070 records.insert(
6071 key,
6072 RebuildCooldownRecord {
6073 project_root: project_root.to_path_buf(),
6074 published_at,
6075 cross_root_cooldown_armed,
6076 },
6077 );
6078}
6079
6080fn acquire_writer_lease(
6081 callgraph_dir: &Path,
6082 project_key: &str,
6083 project_root: &Path,
6084) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
6085 crate::root_cache::WriterLease::acquire_shared(
6086 crate::root_cache::RootCacheDomain::Callgraph,
6087 callgraph_dir,
6088 project_key,
6089 project_root,
6090 )
6091 .map_err(CallGraphStoreError::from)
6092}
6093
6094fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
6095 if lease.verify()? {
6096 Ok(())
6097 } else {
6098 Err(CallGraphStoreError::Unavailable(format!(
6099 "callgraph writer lease for key {} lost epoch {}; aborting write",
6100 lease.key(),
6101 lease.epoch()
6102 )))
6103 }
6104}
6105
6106fn legacy_migration_completion_line(
6107 project_key: &str,
6108 method: &str,
6109 legacy_bytes: u64,
6110 migrated_bytes: u64,
6111) -> String {
6112 format!(
6113 "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
6114 )
6115}
6116
6117fn log_legacy_migration_completion(
6118 project_key: &str,
6119 method: &str,
6120 legacy_bytes: u64,
6121 migrated_bytes: u64,
6122) {
6123 crate::slog_info!(
6124 "{}",
6125 legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
6126 );
6127}
6128
6129fn try_legacy_migration_or_fallback(
6130 callgraph_dir: &Path,
6131 project_root: &Path,
6132 project_key: &str,
6133 writer_lease: Arc<crate::root_cache::WriterLease>,
6134) -> Result<Option<CallGraphStore>> {
6135 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
6136 if partitions.is_empty() {
6137 return Ok(None);
6138 }
6139
6140 for partition in &partitions {
6141 if let Some(source) = newest_superseded_legacy_generation(partition)? {
6142 if !migration_disk_floor_allows(&source, callgraph_dir)? {
6143 return open_legacy_fallback_store(
6144 callgraph_dir,
6145 project_root,
6146 project_key,
6147 &partitions,
6148 );
6149 }
6150 match publish_generation_copy_migration(
6151 callgraph_dir,
6152 project_key,
6153 &source,
6154 Arc::clone(&writer_lease),
6155 ) {
6156 Ok(published) => {
6157 log_legacy_migration_completion(
6158 project_key,
6159 "generation_copy",
6160 source.source_bytes,
6161 published.migrated_bytes,
6162 );
6163 return CallGraphStore::open_generation(
6164 callgraph_dir,
6165 project_root.to_path_buf(),
6166 project_key.to_string(),
6167 published.generation,
6168 writer_lease,
6169 )
6170 .map(Some);
6171 }
6172 Err(error) => {
6173 crate::slog_warn!(
6174 "root-keyed callgraph generation-copy migration failed from {}: {}",
6175 source.sqlite_path.display(),
6176 error
6177 );
6178 return open_legacy_fallback_store(
6179 callgraph_dir,
6180 project_root,
6181 project_key,
6182 &partitions,
6183 );
6184 }
6185 }
6186 }
6187
6188 if let Some(source) = current_legacy_generation(partition)? {
6189 if !migration_disk_floor_allows(&source, callgraph_dir)? {
6190 return open_legacy_fallback_store(
6191 callgraph_dir,
6192 project_root,
6193 project_key,
6194 &partitions,
6195 );
6196 }
6197 match publish_backup_migration(
6198 callgraph_dir,
6199 project_key,
6200 &source,
6201 Arc::clone(&writer_lease),
6202 ) {
6203 Ok(published) => {
6204 log_legacy_migration_completion(
6205 project_key,
6206 "sqlite_backup",
6207 source.source_bytes,
6208 published.migrated_bytes,
6209 );
6210 return CallGraphStore::open_generation(
6211 callgraph_dir,
6212 project_root.to_path_buf(),
6213 project_key.to_string(),
6214 published.generation,
6215 writer_lease,
6216 )
6217 .map(Some);
6218 }
6219 Err(error) => {
6220 crate::slog_warn!(
6221 "root-keyed callgraph backup migration failed from {}: {}",
6222 source.sqlite_path.display(),
6223 error
6224 );
6225 return open_legacy_fallback_store(
6226 callgraph_dir,
6227 project_root,
6228 project_key,
6229 &partitions,
6230 );
6231 }
6232 }
6233 }
6234 }
6235
6236 open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
6237}
6238
6239fn open_legacy_fallback_store(
6240 callgraph_dir: &Path,
6241 project_root: &Path,
6242 project_key: &str,
6243 partitions: &[LegacyCallgraphPartition],
6244) -> Result<Option<CallGraphStore>> {
6245 let Some(target) = first_ready_legacy_target(partitions)? else {
6246 return Ok(None);
6247 };
6248 crate::slog_warn!(
6249 "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
6250 target.partition.harness,
6251 target.sqlite_path.display()
6252 );
6253 let conn = open_readonly_connection(&target.sqlite_path)?;
6254 if !database_ready(&conn).unwrap_or(false) {
6255 return Ok(None);
6256 }
6257 let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
6258 let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
6259 Ok(Some(CallGraphStore::from_connection(
6260 project_root.to_path_buf(),
6261 project_key.to_string(),
6262 target.sqlite_path,
6263 callgraph_dir.to_path_buf(),
6264 true,
6265 target.generation,
6266 None,
6267 Some(read_marker),
6268 conn,
6269 )))
6270}
6271
6272fn migration_disk_floor_allows(
6273 source: &LegacyCallgraphTarget,
6274 callgraph_dir: &Path,
6275) -> Result<bool> {
6276 let available = migration_available_disk(callgraph_dir)?;
6277 let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
6278 source.source_bytes,
6279 available,
6280 );
6281 if decision.should_skip_copy() {
6282 crate::slog_warn!(
6283 "{}",
6284 decision.warning_message(&source.sqlite_path, callgraph_dir)
6285 );
6286 return Ok(false);
6287 }
6288 Ok(true)
6289}
6290
6291fn migration_available_disk(path: &Path) -> Result<u64> {
6292 if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
6293 return Ok(bytes);
6294 }
6295 crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
6296}
6297
6298fn legacy_callgraph_partitions(
6299 callgraph_dir: &Path,
6300 project_key: &str,
6301) -> Result<Vec<LegacyCallgraphPartition>> {
6302 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6303 return Ok(Vec::new());
6304 };
6305 let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
6306 let mut partitions = inventory
6307 .into_iter()
6308 .filter(|entry| {
6309 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
6310 && entry.key == project_key
6311 })
6312 .map(|entry| {
6313 let dir = if entry.path.is_dir() {
6314 entry.path.clone()
6315 } else {
6316 entry
6317 .path
6318 .parent()
6319 .map(Path::to_path_buf)
6320 .unwrap_or_else(|| entry.path.clone())
6321 };
6322 LegacyCallgraphPartition {
6323 harness: entry.harness,
6324 dir,
6325 key: entry.key,
6326 bytes: entry.bytes,
6327 freshness: entry.callgraph_pointer_mtime,
6328 }
6329 })
6330 .collect::<Vec<_>>();
6331 partitions.sort_by(|left, right| {
6332 right
6333 .freshness
6334 .cmp(&left.freshness)
6335 .then_with(|| right.bytes.cmp(&left.bytes))
6336 .then_with(|| left.harness.cmp(&right.harness))
6337 });
6338 Ok(partitions)
6339}
6340
6341fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
6342 let domain_dir = callgraph_dir.parent()?;
6343 if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
6344 return None;
6345 }
6346 domain_dir.parent().map(Path::to_path_buf)
6347}
6348
6349pub(crate) fn all_legacy_partitions_migrated_for_keys(
6350 callgraph_dir: &Path,
6351 configured_keys: &BTreeSet<String>,
6352) -> Result<bool> {
6353 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6354 return Ok(false);
6355 };
6356 let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
6357 .into_iter()
6358 .filter(|entry| {
6359 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
6360 && configured_keys.contains(&entry.key)
6361 })
6362 .map(|entry| entry.key)
6363 .collect::<BTreeSet<_>>();
6364 if legacy_keys.is_empty() {
6365 return Ok(false);
6366 }
6367
6368 for key in legacy_keys {
6369 let migrated_dir = storage_root.join("callgraph").join(&key);
6370 let Some(generation) = read_pointer(&migrated_dir, &key) else {
6371 return Ok(false);
6372 };
6373 if !migration_generation_requires_manifest(&generation)
6374 || !migration_manifest_valid(&migrated_dir, &generation)
6375 {
6376 return Ok(false);
6377 }
6378 }
6379 Ok(true)
6380}
6381
6382fn newest_superseded_legacy_generation(
6383 partition: &LegacyCallgraphPartition,
6384) -> Result<Option<LegacyCallgraphTarget>> {
6385 let Some(current) = read_pointer(&partition.dir, &partition.key) else {
6386 return Ok(None);
6387 };
6388 let prefix = format!("{}.g", partition.key);
6389 let Ok(entries) = std::fs::read_dir(&partition.dir) else {
6390 return Ok(None);
6391 };
6392 let mut candidates = Vec::new();
6393 for entry in entries.flatten() {
6394 let name = entry.file_name().to_string_lossy().to_string();
6395 if name == current
6396 || name.contains(".tmp.")
6397 || !name.starts_with(&prefix)
6398 || !name.ends_with(".sqlite")
6399 {
6400 continue;
6401 }
6402 let path = entry.path();
6403 if !db_path_ready(&path) {
6404 continue;
6405 }
6406 let modified = entry
6407 .metadata()
6408 .and_then(|metadata| metadata.modified())
6409 .unwrap_or(SystemTime::UNIX_EPOCH);
6410 candidates.push((modified, path, name));
6411 }
6412 candidates.sort_by(|left, right| right.0.cmp(&left.0));
6413 let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
6414 return Ok(None);
6415 };
6416 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6417 Ok(Some(LegacyCallgraphTarget {
6418 partition: partition.clone(),
6419 sqlite_path,
6420 generation: Some(generation),
6421 source_bytes,
6422 source_blake3: String::new(),
6423 }))
6424}
6425
6426fn current_legacy_generation(
6427 partition: &LegacyCallgraphPartition,
6428) -> Result<Option<LegacyCallgraphTarget>> {
6429 let Some(target) = ready_legacy_target(partition)? else {
6430 return Ok(None);
6431 };
6432 let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
6433 if has_superseded {
6434 return Ok(None);
6435 }
6436 Ok(Some(target))
6437}
6438
6439fn freshest_legacy_fallback_target(
6440 callgraph_dir: &Path,
6441 project_key: &str,
6442) -> Result<Option<LegacyCallgraphTarget>> {
6443 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
6444 first_ready_legacy_target(&partitions)
6445}
6446
6447fn first_ready_legacy_target(
6448 partitions: &[LegacyCallgraphPartition],
6449) -> Result<Option<LegacyCallgraphTarget>> {
6450 for partition in partitions {
6451 if let Some(target) = ready_legacy_target(partition)? {
6452 return Ok(Some(target));
6453 }
6454 }
6455 Ok(None)
6456}
6457
6458fn ready_legacy_target(
6459 partition: &LegacyCallgraphPartition,
6460) -> Result<Option<LegacyCallgraphTarget>> {
6461 if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
6462 let sqlite_path = partition.dir.join(&generation);
6463 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6464 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6465 return Ok(Some(LegacyCallgraphTarget {
6466 partition: partition.clone(),
6467 sqlite_path,
6468 generation: Some(generation),
6469 source_bytes,
6470 source_blake3: String::new(),
6471 }));
6472 }
6473 }
6474
6475 let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
6476 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6477 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6478 return Ok(Some(LegacyCallgraphTarget {
6479 partition: partition.clone(),
6480 sqlite_path,
6481 generation: None,
6482 source_bytes,
6483 source_blake3: String::new(),
6484 }));
6485 }
6486 Ok(None)
6487}
6488
6489fn publish_generation_copy_migration(
6490 callgraph_dir: &Path,
6491 project_key: &str,
6492 source: &LegacyCallgraphTarget,
6493 writer_lease: Arc<crate::root_cache::WriterLease>,
6494) -> Result<PublishedLegacyMigration> {
6495 let generation = migration_generation_file_name(project_key, "copy");
6496 let temp_path = migration_temp_path(callgraph_dir, &generation);
6497 remove_sqlite_file_set(&temp_path);
6498 copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
6499 fail_after_temp_copy_for_test()?;
6500
6501 let mut source = source.clone();
6502 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
6503 source.source_blake3 = fingerprint.blake3;
6504 let generation = publish_migrated_generation(
6505 callgraph_dir,
6506 project_key,
6507 &generation,
6508 &temp_path,
6509 &source,
6510 fingerprint.bytes,
6511 writer_lease,
6512 "generation_copy",
6513 )?;
6514 Ok(PublishedLegacyMigration {
6515 generation,
6516 migrated_bytes: fingerprint.bytes,
6517 })
6518}
6519
6520fn publish_backup_migration(
6521 callgraph_dir: &Path,
6522 project_key: &str,
6523 source: &LegacyCallgraphTarget,
6524 writer_lease: Arc<crate::root_cache::WriterLease>,
6525) -> Result<PublishedLegacyMigration> {
6526 if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
6527 return Err(CallGraphStoreError::Unavailable(
6528 "legacy callgraph backup migration budget exhausted by test seam".to_string(),
6529 ));
6530 }
6531
6532 let generation = migration_generation_file_name(project_key, "backup");
6533 let temp_path = migration_temp_path(callgraph_dir, &generation);
6534 remove_sqlite_file_set(&temp_path);
6535
6536 let source_conn = open_readonly_connection(&source.sqlite_path)?;
6537 let mut destination = Connection::open(&temp_path)?;
6538 destination.busy_timeout(Duration::from_secs(5))?;
6539 let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
6540 let started = Instant::now();
6541 let mut retries = 0;
6542 loop {
6543 match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
6544 rusqlite::backup::StepResult::Done => break,
6545 rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
6546 rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
6547 retries += 1;
6548 if retries > MIGRATION_BACKUP_RETRY_BUDGET
6549 || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
6550 {
6551 return Err(CallGraphStoreError::Unavailable(format!(
6552 "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
6553 )));
6554 }
6555 std::thread::sleep(Duration::from_millis(20));
6556 }
6557 _ => {
6558 return Err(CallGraphStoreError::Unavailable(
6559 "legacy callgraph backup returned an unknown step result".to_string(),
6560 ));
6561 }
6562 }
6563 }
6564 drop(backup);
6565
6566 let integrity: String =
6567 destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
6568 if integrity != "ok" {
6569 return Err(CallGraphStoreError::Unavailable(format!(
6570 "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
6571 )));
6572 }
6573 if !database_ready(&destination)? {
6574 return Err(CallGraphStoreError::Unavailable(
6575 "legacy callgraph backup produced a database without ready metadata".to_string(),
6576 ));
6577 }
6578 destination.execute_batch("PRAGMA optimize;")?;
6579 drop(destination);
6580 sync_file(&temp_path)?;
6581 fail_after_temp_copy_for_test()?;
6582
6583 let mut source = source.clone();
6584 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
6585 source.source_blake3 = fingerprint.blake3;
6586 let generation = publish_migrated_generation(
6587 callgraph_dir,
6588 project_key,
6589 &generation,
6590 &temp_path,
6591 &source,
6592 fingerprint.bytes,
6593 writer_lease,
6594 "sqlite_backup",
6595 )?;
6596 Ok(PublishedLegacyMigration {
6597 generation,
6598 migrated_bytes: fingerprint.bytes,
6599 })
6600}
6601
6602fn publish_migrated_generation(
6603 callgraph_dir: &Path,
6604 project_key: &str,
6605 generation: &str,
6606 temp_path: &Path,
6607 source: &LegacyCallgraphTarget,
6608 migrated_bytes: u64,
6609 writer_lease: Arc<crate::root_cache::WriterLease>,
6610 method: &str,
6611) -> Result<String> {
6612 let gen_path = callgraph_dir.join(generation);
6613 checkpoint_sqlite_before_publication(temp_path);
6614 let publication = publish_if_current(|| {
6615 verify_writer_lease(&writer_lease)?;
6616 remove_sqlite_file_set(&gen_path);
6617 rename_sqlite_file_set(temp_path, &gen_path)?;
6618 crate::fs_lock::sync_parent(&gen_path);
6619
6620 verify_writer_lease(&writer_lease)?;
6621 publish_pointer(callgraph_dir, project_key, generation)?;
6622 write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
6623 Ok(generation.to_string())
6624 });
6625 if matches!(publication, Err(CallGraphStoreError::Superseded)) {
6626 remove_sqlite_file_set(temp_path);
6627 }
6628 publication
6629}
6630
6631fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
6632 if let Some(parent) = destination.parent() {
6633 std::fs::create_dir_all(parent)?;
6634 }
6635 for suffix in SQLITE_FILE_SET_SUFFIXES {
6636 let source_path = sqlite_file_set_path(source, suffix);
6637 if !source_path.is_file() {
6638 continue;
6639 }
6640 let destination_path = sqlite_file_set_path(destination, suffix);
6641 std::fs::copy(&source_path, &destination_path)?;
6642 sync_file(&destination_path)?;
6643 }
6644 Ok(())
6645}
6646
6647fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
6648 for suffix in SQLITE_FILE_SET_SUFFIXES {
6649 let source_path = sqlite_file_set_path(source, suffix);
6650 if !source_path.exists() {
6651 continue;
6652 }
6653 let destination_path = sqlite_file_set_path(destination, suffix);
6654 if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
6655 let _ = std::fs::remove_file(&source_path);
6656 return Err(error.into());
6657 }
6658 }
6659 Ok(())
6660}
6661
6662fn sqlite_file_set_size(path: &Path) -> Result<u64> {
6663 let mut bytes = 0_u64;
6664 for suffix in SQLITE_FILE_SET_SUFFIXES {
6665 let member = sqlite_file_set_path(path, suffix);
6666 if !member.is_file() {
6667 continue;
6668 }
6669 bytes = bytes.saturating_add(member.metadata()?.len());
6670 }
6671 Ok(bytes)
6672}
6673
6674fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
6675 let mut hasher = blake3::Hasher::new();
6676 let mut bytes = 0_u64;
6677 let mut buffer = [0_u8; 64 * 1024];
6678 for suffix in SQLITE_FILE_SET_SUFFIXES {
6679 let member = sqlite_file_set_path(path, suffix);
6680 if !member.is_file() {
6681 continue;
6682 }
6683 hasher.update(suffix.as_bytes());
6684 let mut file = std::fs::File::open(&member)?;
6685 loop {
6686 let read = file.read(&mut buffer)?;
6687 if read == 0 {
6688 break;
6689 }
6690 bytes = bytes.saturating_add(read as u64);
6691 hasher.update(&buffer[..read]);
6692 }
6693 }
6694 Ok(SourceFingerprint {
6695 bytes,
6696 blake3: hash_to_hex(hasher.finalize()),
6697 })
6698}
6699
6700fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
6701 if suffix.is_empty() {
6702 path.to_path_buf()
6703 } else {
6704 PathBuf::from(format!("{}{suffix}", path.display()))
6705 }
6706}
6707
6708fn sync_file(path: &Path) -> Result<()> {
6709 let file = std::fs::OpenOptions::new()
6710 .read(true)
6711 .write(true)
6712 .open(path)?;
6713 file.sync_all()?;
6714 Ok(())
6715}
6716
6717fn fail_after_temp_copy_for_test() -> Result<()> {
6718 if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
6719 return Err(CallGraphStoreError::Unavailable(
6720 "legacy callgraph migration stopped after temp copy by test seam".to_string(),
6721 ));
6722 }
6723 Ok(())
6724}
6725
6726fn migration_generation_file_name(project_key: &str, method: &str) -> String {
6727 format!(
6728 "{project_key}.g{}.{}{}{}.sqlite",
6729 now_nanos(),
6730 std::process::id(),
6731 MIGRATION_GENERATION_TAG,
6732 method
6733 )
6734}
6735
6736fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
6737 callgraph_dir.join(format!(
6738 "{generation}.tmp.{}.{}",
6739 std::process::id(),
6740 now_nanos()
6741 ))
6742}
6743
6744fn write_migration_manifest(
6745 callgraph_dir: &Path,
6746 generation: &str,
6747 source: &LegacyCallgraphTarget,
6748 migrated_bytes: u64,
6749 method: &str,
6750) -> Result<()> {
6751 let manifest_path = migration_manifest_path(callgraph_dir, generation);
6752 let temp_path = manifest_path.with_extension(format!(
6753 "migration.json.tmp.{}.{}",
6754 std::process::id(),
6755 now_nanos()
6756 ));
6757 let manifest = serde_json::json!({
6758 "version": MIGRATION_MANIFEST_VERSION,
6759 "method": method,
6760 "target_generation": generation,
6761 "source_harness": source.partition.harness,
6762 "source_path": source.sqlite_path.display().to_string(),
6763 "source_generation": source.generation,
6764 "source_bytes": source.source_bytes,
6765 "source_blake3": source.source_blake3,
6766 "migrated_bytes": migrated_bytes,
6767 });
6768 {
6769 use std::io::Write as _;
6770 let mut file = std::fs::File::create(&temp_path)?;
6771 file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
6772 file.write_all(b"\n")?;
6773 file.sync_all()?;
6774 }
6775 if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
6776 let _ = std::fs::remove_file(&temp_path);
6777 return Err(error.into());
6778 }
6779 crate::fs_lock::sync_parent(&manifest_path);
6780 Ok(())
6781}
6782
6783fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
6784 callgraph_dir.join(format!("{generation}.migration.json"))
6785}
6786
6787fn migration_generation_requires_manifest(generation: &str) -> bool {
6788 generation.contains(MIGRATION_GENERATION_TAG)
6789}
6790
6791fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
6792 if !migration_generation_requires_manifest(generation) {
6793 return true;
6794 }
6795 let path = migration_manifest_path(callgraph_dir, generation);
6796 let Ok(bytes) = std::fs::read(path) else {
6797 return false;
6798 };
6799 let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
6800 return false;
6801 };
6802 value.get("version").and_then(serde_json::Value::as_u64)
6803 == Some(MIGRATION_MANIFEST_VERSION as u64)
6804 && value
6805 .get("target_generation")
6806 .and_then(serde_json::Value::as_str)
6807 == Some(generation)
6808 && value
6809 .get("source_bytes")
6810 .and_then(serde_json::Value::as_u64)
6811 .is_some_and(|bytes| bytes > 0)
6812 && value
6813 .get("source_blake3")
6814 .and_then(serde_json::Value::as_str)
6815 .is_some_and(|hash| hash.len() == 64)
6816}
6817
6818fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
6819 let pointer_generation = read_pointer(callgraph_dir, project_key);
6820 if let Some(generation) = pointer_generation.as_deref() {
6821 if migration_generation_requires_manifest(generation)
6822 && !migration_manifest_valid(callgraph_dir, generation)
6823 {
6824 let path = callgraph_dir.join(generation);
6825 remove_sqlite_file_set(&path);
6826 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
6827 let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
6828 }
6829 }
6830
6831 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6832 return;
6833 };
6834 for entry in entries.flatten() {
6835 let name = entry.file_name().to_string_lossy().to_string();
6836 let path = entry.path();
6837 if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
6838 let _ = std::fs::remove_file(path);
6839 continue;
6840 }
6841 if name.starts_with(&format!("{project_key}.g"))
6842 && name.ends_with(".sqlite")
6843 && name.contains(MIGRATION_GENERATION_TAG)
6844 && pointer_generation.as_deref() != Some(&name)
6845 && !migration_manifest_valid(callgraph_dir, &name)
6846 {
6847 remove_sqlite_file_set(&path);
6848 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
6849 }
6850 }
6851 crate::fs_lock::sync_parent(callgraph_dir);
6852}
6853
6854fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
6855 let mut hasher = blake3::Hasher::new();
6856 hasher.update(path.to_string_lossy().as_bytes());
6857 if let Some(generation) = generation {
6858 hasher.update(generation.as_bytes());
6859 }
6860 let digest = hash_to_hex(hasher.finalize());
6861 format!("legacy-{}", &digest[..16])
6862}
6863
6864fn open_readonly_connection(path: &Path) -> Result<Connection> {
6865 let uri = sqlite_readonly_uri(path);
6866 let conn = Connection::open_with_flags(
6867 &uri,
6868 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
6869 )?;
6870 conn.pragma_update(
6871 None,
6872 "synchronous",
6873 if write_amplification_baseline_enabled() {
6874 "FULL"
6875 } else {
6876 "NORMAL"
6877 },
6878 )?;
6879 conn.busy_timeout(reader_busy_timeout())?;
6880 conn.execute_batch("PRAGMA query_only=ON;")?;
6881 Ok(conn)
6882}
6883
6884fn reader_busy_timeout() -> Duration {
6885 let jitter = (now_nanos() % 500) as u64;
6886 Duration::from_millis(250 + jitter)
6887}
6888
6889fn sqlite_readonly_uri(path: &Path) -> String {
6890 let raw = path.to_string_lossy().replace('\\', "/");
6891 let encoded = percent_encode_sqlite_uri_path(&raw);
6892 if raw.starts_with('/') {
6893 format!("file://{encoded}?mode=ro")
6894 } else if raw.as_bytes().get(1) == Some(&b':') {
6895 format!("file:///{encoded}?mode=ro")
6896 } else {
6897 format!("file:{encoded}?mode=ro")
6898 }
6899}
6900
6901fn percent_encode_sqlite_uri_path(path: &str) -> String {
6902 let mut encoded = String::with_capacity(path.len());
6903 for byte in path.bytes() {
6904 match byte {
6905 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
6906 encoded.push(byte as char)
6907 }
6908 _ => encoded.push_str(&format!("%{byte:02X}")),
6909 }
6910 }
6911 encoded
6912}
6913
6914fn configure_connection(conn: &Connection) -> Result<()> {
6915 conn.busy_timeout(Duration::from_secs(5))?;
6919 conn.pragma_update(None, "journal_mode", "WAL")?;
6920 let baseline = write_amplification_baseline_enabled();
6921 conn.pragma_update(
6922 None,
6923 "synchronous",
6924 if baseline { "FULL" } else { "NORMAL" },
6925 )?;
6926 conn.pragma_update(
6927 None,
6928 "wal_autocheckpoint",
6929 if baseline {
6930 1_000
6931 } else {
6932 CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES
6933 },
6934 )?;
6935 conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
6936 Ok(())
6937}
6938
6939fn configure_build_connection(conn: &Connection) -> Result<()> {
6940 conn.busy_timeout(Duration::from_secs(5))?;
6945 conn.pragma_update(None, "journal_mode", "WAL")?;
6946 conn.pragma_update(
6947 None,
6948 "synchronous",
6949 if write_amplification_baseline_enabled() {
6950 "FULL"
6951 } else {
6952 "NORMAL"
6953 },
6954 )?;
6955 conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
6956 Ok(())
6957}
6958
6959fn checkpoint_sqlite_before_publication(path: &Path) {
6963 let Ok(conn) = Connection::open(path) else {
6964 return;
6965 };
6966 let _ = conn.pragma_update(None, "synchronous", "NORMAL");
6967 let _ = conn.busy_timeout(Duration::from_secs(5));
6968 let _ = checkpoint_wal_truncate(&conn);
6969}
6970
6971fn checkpoint_wal_truncate(conn: &Connection) -> bool {
6972 match conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
6973 row.get::<_, i64>(0)
6974 }) {
6975 Ok(0) => true,
6976 Ok(_) => false,
6977 Err(rusqlite::Error::SqliteFailure(error, _))
6978 if matches!(
6979 error.code,
6980 rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
6981 ) =>
6982 {
6983 false
6984 }
6985 Err(error) => {
6986 log::debug!("callgraph WAL truncate checkpoint skipped: {error}");
6987 false
6988 }
6989 }
6990}
6991
6992fn initialize_schema(conn: &Connection) -> Result<()> {
6993 conn.execute_batch(
6994 "CREATE TABLE IF NOT EXISTS files (
6995 path TEXT PRIMARY KEY,
6996 content_hash TEXT NOT NULL,
6997 mtime_ns INTEGER NOT NULL,
6998 size INTEGER NOT NULL,
6999 lang TEXT NOT NULL,
7000 is_dead_code_root INTEGER NOT NULL DEFAULT 0,
7001 is_public_api INTEGER NOT NULL DEFAULT 0,
7002 surface_fingerprint TEXT NOT NULL,
7003 indexed_at INTEGER NOT NULL
7004 );
7005
7006 CREATE TABLE IF NOT EXISTS nodes (
7007 id TEXT PRIMARY KEY,
7008 file_path TEXT NOT NULL,
7009 name TEXT NOT NULL,
7010 scoped_name TEXT NOT NULL,
7011 kind TEXT NOT NULL,
7012 start_line INTEGER NOT NULL,
7013 start_col INTEGER NOT NULL,
7014 end_line INTEGER NOT NULL,
7015 end_col INTEGER NOT NULL,
7016 range_ordinal INTEGER NOT NULL,
7017 signature TEXT,
7018 exported INTEGER NOT NULL,
7019 is_default_export INTEGER NOT NULL,
7020 is_type_like INTEGER NOT NULL,
7021 is_callgraph_entry_point INTEGER NOT NULL,
7022 provenance TEXT NOT NULL,
7023 UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
7024 );
7025 CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
7026 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
7027 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
7028
7029 CREATE TABLE IF NOT EXISTS refs (
7030 ref_id TEXT PRIMARY KEY,
7031 caller_node TEXT,
7032 caller_file TEXT NOT NULL,
7033 kind TEXT NOT NULL,
7034 short_name TEXT,
7035 full_ref TEXT,
7036 module_path TEXT,
7037 import_kind TEXT,
7038 local_name TEXT,
7039 requested_name TEXT,
7040 namespace_alias TEXT,
7041 wildcard INTEGER NOT NULL DEFAULT 0,
7042 line INTEGER NOT NULL,
7043 byte_start INTEGER NOT NULL,
7044 byte_end INTEGER NOT NULL,
7045 status TEXT NOT NULL,
7046 target_node TEXT,
7047 target_file TEXT,
7048 target_symbol TEXT,
7049 provenance TEXT NOT NULL
7050 );
7051 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
7052 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
7053 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
7054 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
7055 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
7056
7057 CREATE TABLE IF NOT EXISTS file_dependencies (
7058 file_path TEXT NOT NULL,
7059 dep_file TEXT NOT NULL,
7060 PRIMARY KEY(file_path, dep_file)
7061 );
7062 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
7063
7064 CREATE TABLE IF NOT EXISTS edges (
7065 edge_id TEXT PRIMARY KEY,
7066 ref_id TEXT NOT NULL,
7067 source_node TEXT NOT NULL,
7068 target_node TEXT,
7069 target_file TEXT NOT NULL,
7070 target_symbol TEXT NOT NULL,
7071 kind TEXT NOT NULL,
7072 line INTEGER NOT NULL,
7073 provenance TEXT NOT NULL
7074 );
7075 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
7076 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
7077 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
7078 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
7079
7080 CREATE TABLE IF NOT EXISTS dispatch_hints (
7081 id TEXT PRIMARY KEY,
7082 method_name TEXT NOT NULL,
7083 caller_node TEXT NOT NULL,
7084 file TEXT NOT NULL,
7085 line INTEGER NOT NULL,
7086 byte_start INTEGER NOT NULL,
7087 byte_end INTEGER NOT NULL,
7088 provenance TEXT NOT NULL
7089 );
7090 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
7091 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
7092
7093 CREATE TABLE IF NOT EXISTS type_ref_names (
7094 name TEXT PRIMARY KEY
7095 );
7096
7097 CREATE TABLE IF NOT EXISTS backend_file_state (
7098 backend TEXT NOT NULL,
7099 workspace_root TEXT NOT NULL,
7100 file_path TEXT NOT NULL,
7101 content_hash TEXT NOT NULL,
7102 status TEXT NOT NULL,
7103 updated_at INTEGER NOT NULL,
7104 PRIMARY KEY(backend, workspace_root, file_path, content_hash)
7105 );
7106 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
7107
7108 CREATE TABLE IF NOT EXISTS meta (
7109 k TEXT PRIMARY KEY,
7110 v TEXT NOT NULL
7111 );
7112
7113 -- The file walk is staged on disk so extraction can page through a
7114 -- deterministic inventory without retaining every source path in heap.
7115 CREATE TABLE IF NOT EXISTS staging_file_inventory (
7116 path TEXT PRIMARY KEY,
7117 size INTEGER NOT NULL
7118 ) WITHOUT ROWID;
7119
7120 -- Context needed only while a generation is staged. Raw refs live in
7121 -- `refs` with status `staged`; this table preserves the caller symbol
7122 -- needed to avoid inventing self edges during the later resolve pass.
7123 CREATE TABLE IF NOT EXISTS staging_ref_context (
7124 ref_id TEXT PRIMARY KEY,
7125 caller_symbol TEXT
7126 );",
7127 )?;
7128 insert_meta(conn)?;
7129 Ok(())
7130}
7131
7132fn insert_meta(conn: &Connection) -> Result<()> {
7133 conn.execute(
7134 "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
7135 params![SCHEMA_VERSION.to_string()],
7136 )?;
7137 conn.execute(
7138 "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
7139 params![schema_fingerprint()],
7140 )?;
7141 conn.execute(
7142 "INSERT OR IGNORE INTO meta(k, v) VALUES('projection_write_revision', '0')",
7143 [],
7144 )?;
7145 Ok(())
7146}
7147
7148const PATH_IDENTITY_MISMATCH_META_KEY: &str = "path_identity_mismatch";
7152
7153fn record_path_identity_mismatch(conn: &Connection, error: &CallGraphStoreError) -> Result<()> {
7154 let CallGraphStoreError::PathIdentityMismatch { path, project_root } = error else {
7155 return Ok(());
7156 };
7157 conn.execute(
7158 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7159 params![
7160 PATH_IDENTITY_MISMATCH_META_KEY,
7161 format!(
7162 "callgraph_path_identity_mismatch path={} project_root={}",
7163 path.display(),
7164 project_root.display()
7165 )
7166 ],
7167 )?;
7168 Ok(())
7169}
7170
7171pub(super) fn path_identity_mismatch_reason(conn: &Connection) -> Result<Option<String>> {
7172 conn.query_row(
7173 "SELECT v FROM meta WHERE k = ?1",
7174 [PATH_IDENTITY_MISMATCH_META_KEY],
7175 |row| row.get(0),
7176 )
7177 .optional()
7178 .map_err(Into::into)
7179}
7180
7181fn projection_write_revision(conn: &Connection) -> Result<Option<u64>> {
7182 let revision: Option<String> = conn
7183 .query_row(
7184 "SELECT v FROM meta WHERE k = 'projection_write_revision'",
7185 [],
7186 |row| row.get(0),
7187 )
7188 .optional()?;
7189 revision
7190 .map(|revision| {
7191 revision.parse::<u64>().map_err(|error| {
7192 CallGraphStoreError::Unavailable(format!(
7193 "callgraph projection write revision is invalid: {error}"
7194 ))
7195 })
7196 })
7197 .transpose()
7198}
7199
7200fn bump_projection_write_revision(tx: &Transaction<'_>) -> Result<()> {
7203 tx.execute(
7204 "INSERT INTO meta(k, v) VALUES('projection_write_revision', '1')
7205 ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1",
7206 [],
7207 )?;
7208 Ok(())
7209}
7210
7211fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
7212 conn.execute(
7213 "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
7214 params![if ready { "1" } else { "0" }],
7215 )?;
7216 Ok(())
7217}
7218
7219fn database_ready(conn: &Connection) -> Result<bool> {
7220 let schema_version: Option<String> = conn
7221 .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
7222 row.get(0)
7223 })
7224 .optional()?;
7225 let fingerprint: Option<String> = conn
7226 .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
7227 row.get(0)
7228 })
7229 .optional()?;
7230 let ready: Option<String> = conn
7231 .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
7232 .optional()?;
7233
7234 let expected_schema = SCHEMA_VERSION.to_string();
7235 let expected_fingerprint = schema_fingerprint();
7236 Ok(schema_version.as_deref() == Some(expected_schema.as_str())
7237 && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
7238 && ready.as_deref() == Some("1"))
7239}
7240
7241fn ensure_database_ready(conn: &Connection) -> Result<()> {
7242 if database_ready(conn)? {
7243 Ok(())
7244 } else {
7245 Err(CallGraphStoreError::Unavailable(
7246 "database is missing, stale, or mid-build".to_string(),
7247 ))
7248 }
7249}
7250
7251fn schema_fingerprint() -> String {
7252 let input =
7257 format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
7258 hash_to_hex(blake3::hash(input.as_bytes()))
7259}
7260
7261fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
7262 tx.execute_batch(
7263 "DELETE FROM staging_ref_context;
7264 DELETE FROM edges;
7265 DELETE FROM file_dependencies;
7266 DELETE FROM refs;
7267 DELETE FROM dispatch_hints;
7268 DELETE FROM type_ref_names;
7269 DELETE FROM backend_file_state;
7270 DELETE FROM nodes;
7271 DELETE FROM files;",
7272 )?;
7273 Ok(())
7274}
7275
7276fn staged_build_phase(conn: &Connection) -> Result<Option<String>> {
7277 conn.query_row(
7278 "SELECT v FROM meta WHERE k = ?1",
7279 params![STAGED_BUILD_PHASE],
7280 |row| row.get(0),
7281 )
7282 .optional()
7283 .map_err(Into::into)
7284}
7285
7286fn staged_u64(conn: &Connection, key: &str) -> Result<u64> {
7287 let value = staged_string(conn, key)?;
7288 Ok(value.and_then(|value| value.parse().ok()).unwrap_or(0))
7289}
7290
7291fn staged_string(conn: &Connection, key: &str) -> Result<Option<String>> {
7292 conn.query_row("SELECT v FROM meta WHERE k = ?1", params![key], |row| {
7293 row.get::<_, String>(0)
7294 })
7295 .optional()
7296 .map_err(Into::into)
7297}
7298
7299fn set_staged_build_phase(tx: &Transaction<'_>, phase: &str) -> Result<()> {
7300 tx.execute(
7301 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7302 params![STAGED_BUILD_PHASE, phase],
7303 )?;
7304 Ok(())
7305}
7306
7307fn set_staged_u64(tx: &Transaction<'_>, key: &str, value: u64) -> Result<()> {
7308 set_staged_string(tx, key, &value.to_string())
7309}
7310
7311fn set_staged_string(tx: &Transaction<'_>, key: &str, value: &str) -> Result<()> {
7312 tx.execute(
7313 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7314 params![key, value],
7315 )?;
7316 Ok(())
7317}
7318
7319fn increment_staged_extracted_bytes(tx: &Transaction<'_>, bytes: u64) -> Result<()> {
7323 tx.execute(
7324 "INSERT INTO meta(k, v) VALUES(?1, ?2)
7325 ON CONFLICT(k) DO UPDATE SET v = CAST(meta.v AS INTEGER) + excluded.v",
7326 params![STAGED_COMMITTED_EXTRACTED_BYTES, bytes.to_string()],
7327 )?;
7328 Ok(())
7329}
7330
7331fn staged_content_matches(conn: &Connection, project_root: &Path, path: &Path) -> Result<bool> {
7332 let Ok(source) = std::fs::read_to_string(path) else {
7333 return Ok(false);
7334 };
7335 let Ok(freshness) = collect_source_freshness(path, &source) else {
7336 return Ok(false);
7337 };
7338 let rel_path = relative_path(project_root, path);
7339 let staged_hash = conn
7340 .query_row(
7341 "SELECT content_hash FROM files WHERE path = ?1",
7342 params![rel_path],
7343 |row| row.get::<_, String>(0),
7344 )
7345 .optional()?;
7346 Ok(staged_hash.as_deref() == Some(hash_to_hex(freshness.content_hash).as_str()))
7347}
7348
7349fn delete_staged_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
7350 tx.execute(
7351 "DELETE FROM staging_ref_context
7352 WHERE ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?1)",
7353 params![rel_path],
7354 )?;
7355 delete_file_rows(tx, rel_path)
7356}
7357
7358fn prune_staged_files_not_in_inventory(conn: &mut Connection) -> Result<()> {
7359 loop {
7360 let removed = {
7361 let mut statement = conn.prepare(
7362 "SELECT path
7363 FROM files
7364 WHERE NOT EXISTS (
7365 SELECT 1 FROM staging_file_inventory inventory
7366 WHERE inventory.path = files.path
7367 )
7368 ORDER BY path
7369 LIMIT ?1",
7370 )?;
7371 let paths = statement
7372 .query_map(params![COLD_BUILD_EXTRACT_BATCH_FILES as i64], |row| {
7373 row.get::<_, String>(0)
7374 })?
7375 .collect::<std::result::Result<Vec<_>, _>>()?;
7376 paths
7377 };
7378 if removed.is_empty() {
7379 return Ok(());
7380 }
7381 let tx = conn.transaction()?;
7382 for path in removed {
7383 delete_staged_file_rows(&tx, &path)?;
7384 }
7385 tx.commit()?;
7386 }
7387}
7388
7389struct StagedFileBatch {
7390 paths: Vec<PathBuf>,
7391 last_path: String,
7392}
7393
7394fn load_staged_file_batch(
7395 conn: &Connection,
7396 project_root: &Path,
7397 after_path: &str,
7398 max_files: usize,
7399 max_bytes: u64,
7400) -> Result<Option<StagedFileBatch>> {
7401 let mut statement = conn.prepare(
7402 "SELECT path, size
7403 FROM staging_file_inventory
7404 WHERE path > ?1
7405 ORDER BY path
7406 LIMIT ?2",
7407 )?;
7408 let mut rows = statement.query(params![after_path, max_files.max(1) as i64])?;
7409 let mut paths = Vec::with_capacity(max_files.max(1));
7410 let mut last_path = String::new();
7411 let mut batch_bytes = 0u64;
7412 while let Some(row) = rows.next()? {
7413 let rel_path = row.get::<_, String>(0)?;
7414 let size = row.get::<_, i64>(1)?.max(0) as u64;
7415 if !paths.is_empty() && batch_bytes.saturating_add(size) > max_bytes {
7416 break;
7417 }
7418 batch_bytes = batch_bytes.saturating_add(size);
7419 last_path.clone_from(&rel_path);
7420 paths.push(project_root.join(rel_path));
7421 }
7422 if paths.is_empty() {
7423 Ok(None)
7424 } else {
7425 Ok(Some(StagedFileBatch { paths, last_path }))
7426 }
7427}
7428
7429fn staged_corpus_fingerprint(conn: &Connection, project_root: &Path) -> Result<String> {
7430 let mut statement = conn.prepare("SELECT path FROM staging_file_inventory ORDER BY path")?;
7431 let mut rows = statement.query([])?;
7432 let mut fingerprint = CorpusFingerprint::default();
7433 while let Some(row) = rows.next()? {
7434 let rel_path = row.get::<_, String>(0)?;
7435 fingerprint.add_path(project_root, &project_root.join(rel_path));
7436 }
7437 Ok(fingerprint.finish(project_root))
7438}
7439
7440fn load_staged_ref_window(
7441 conn: &Connection,
7442 after_rowid: u64,
7443 limit: usize,
7444) -> Result<Vec<StagedRef>> {
7445 let mut statement = conn.prepare(
7446 "SELECT refs.rowid, refs.ref_id, refs.caller_node, refs.caller_file, refs.kind,
7447 refs.short_name, refs.full_ref, refs.module_path, refs.import_kind,
7448 refs.local_name, refs.requested_name, refs.namespace_alias, refs.wildcard,
7449 refs.line, refs.byte_start, refs.byte_end, staging_ref_context.caller_symbol
7450 FROM refs
7451 LEFT JOIN staging_ref_context ON staging_ref_context.ref_id = refs.ref_id
7452 WHERE refs.status = 'staged' AND refs.rowid > ?1
7453 ORDER BY refs.rowid
7454 LIMIT ?2",
7455 )?;
7456 let rows = statement.query_map(params![after_rowid as i64, limit as i64], |row| {
7457 Ok(StagedRef {
7458 rowid: row.get::<_, i64>(0)? as u64,
7459 raw: RawRef {
7460 ref_id: row.get(1)?,
7461 caller_node: row.get(2)?,
7462 caller_file: row.get(3)?,
7463 kind: row.get(4)?,
7464 short_name: row.get(5)?,
7465 full_ref: row.get(6)?,
7466 module_path: row.get(7)?,
7467 import_kind: row.get(8)?,
7468 local_name: row.get(9)?,
7469 requested_name: row.get(10)?,
7470 namespace_alias: row.get(11)?,
7471 wildcard: row.get::<_, i64>(12)? != 0,
7472 line: row.get::<_, i64>(13)? as u32,
7473 byte_start: row.get::<_, i64>(14)? as usize,
7474 byte_end: row.get::<_, i64>(15)? as usize,
7475 caller_symbol: row.get(16)?,
7476 dependencies: BTreeSet::new(),
7477 },
7478 })
7479 })?;
7480 let mut refs = rows.collect::<std::result::Result<Vec<_>, _>>()?;
7481 drop(statement);
7482
7483 let mut dependencies = HashMap::<String, BTreeSet<String>>::new();
7484 let mut dependency_statement = conn
7485 .prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1 ORDER BY dep_file")?;
7486 for raw in refs.iter_mut().map(|entry| &mut entry.raw) {
7487 if !dependencies.contains_key(&raw.caller_file) {
7488 let rows =
7489 dependency_statement.query_map(params![raw.caller_file], |row| row.get(0))?;
7490 let values = rows.collect::<std::result::Result<BTreeSet<_>, _>>()?;
7491 dependencies.insert(raw.caller_file.clone(), values);
7492 }
7493 raw.dependencies = dependencies
7494 .get(&raw.caller_file)
7495 .cloned()
7496 .unwrap_or_default();
7497 }
7498 Ok(refs)
7499}
7500
7501fn unresolved_staged_ref(raw: RawRef) -> ResolvedRef {
7502 ResolvedRef {
7503 dependencies: raw.dependencies.clone(),
7504 raw,
7505 status: "unresolved".to_string(),
7506 target_node: None,
7507 target_file: None,
7508 target_symbol: None,
7509 edge: None,
7510 }
7511}
7512
7513fn query_count(conn: &Connection, query: &str) -> Result<u64> {
7514 conn.query_row(query, [], |row| row.get::<_, i64>(0))
7515 .map(|count| count.max(0) as u64)
7516 .map_err(Into::into)
7517}
7518
7519fn cold_build_stats_from_connection(conn: &Connection, started: Instant) -> Result<ColdBuildStats> {
7520 let files = query_count(conn, "SELECT COUNT(*) FROM files")? as usize;
7521 let nodes = query_count(conn, "SELECT COUNT(*) FROM nodes")? as usize;
7522 let refs = query_count(conn, "SELECT COUNT(*) FROM refs")? as usize;
7523 let edges = query_count(conn, "SELECT COUNT(*) FROM edges")? as usize;
7524 let failed_files = staged_failed_files(conn)?;
7525 let elapsed_ms = started.elapsed().as_millis();
7526 crate::slog_info!(
7527 "perf callgraph_store bounded cold_build: files={} nodes={} refs={} edges={} committed_extracted_bytes={} ms={}",
7528 files,
7529 nodes,
7530 refs,
7531 edges,
7532 staged_u64(conn, STAGED_COMMITTED_EXTRACTED_BYTES)?,
7533 elapsed_ms
7534 );
7535 Ok(ColdBuildStats {
7536 files,
7537 nodes,
7538 refs,
7539 edges,
7540 failed_files,
7541 elapsed_ms,
7542 })
7543}
7544
7545fn staged_failed_files(conn: &Connection) -> Result<Vec<String>> {
7546 let mut statement = conn.prepare(
7547 "SELECT DISTINCT file_path FROM backend_file_state WHERE status = 'stale' ORDER BY file_path",
7548 )?;
7549 let rows = statement.query_map([], |row| row.get(0))?;
7550 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
7551}
7552
7553fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
7554 tx.execute_batch(
7555 "DROP INDEX IF EXISTS idx_nodes_file;
7556 DROP INDEX IF EXISTS idx_nodes_name;
7557 DROP INDEX IF EXISTS idx_nodes_scoped;
7558 DROP INDEX IF EXISTS idx_refs_short_name;
7559 DROP INDEX IF EXISTS idx_refs_kind_caller_file;
7560 DROP INDEX IF EXISTS idx_refs_caller_file;
7561 DROP INDEX IF EXISTS idx_refs_caller_node_kind;
7562 DROP INDEX IF EXISTS idx_refs_target_file;
7563 DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
7564 DROP INDEX IF EXISTS idx_edges_source_kind;
7565 DROP INDEX IF EXISTS idx_edges_target_kind;
7566 DROP INDEX IF EXISTS idx_edges_target_file_symbol;
7567 DROP INDEX IF EXISTS idx_edges_ref_id;
7568 DROP INDEX IF EXISTS idx_dispatch_hints_method;
7569 DROP INDEX IF EXISTS idx_dispatch_hints_file;
7570 DROP INDEX IF EXISTS idx_backend_file_state_file;",
7571 )?;
7572 Ok(())
7573}
7574
7575fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
7576 tx.execute_batch(
7577 "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
7578 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
7579 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
7580 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
7581 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
7582 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
7583 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
7584 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
7585 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
7586 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
7587 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
7588 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
7589 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
7590 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
7591 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
7592 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
7593 )?;
7594 Ok(())
7595}
7596
7597const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
7598 ("files", "path"),
7599 ("nodes", "file_path"),
7600 ("refs", "caller_file"),
7601 ("refs", "target_file"),
7602 ("file_dependencies", "file_path"),
7603 ("file_dependencies", "dep_file"),
7604 ("edges", "target_file"),
7605 ("dispatch_hints", "file"),
7606 ("backend_file_state", "file_path"),
7607];
7608
7609fn reconcile_workspace_roots(
7622 conn: &mut Connection,
7623 project_root: &Path,
7624 allow_repair: bool,
7625) -> Result<OpenRootRepair> {
7626 let roots = stored_workspace_roots(conn)?;
7627 let current_root = project_root.display().to_string();
7628 if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
7629 return Ok(OpenRootRepair::None);
7630 }
7631
7632 if let Some(sample) = sample_absolute_data_path(conn)? {
7633 return Ok(OpenRootRepair::NeedsRebuild {
7634 previous_roots: roots,
7635 current_root,
7636 reason: format!("absolute store data path row {sample}"),
7637 });
7638 }
7639
7640 for stored_root in roots.iter() {
7641 if stored_root == ¤t_root {
7642 continue;
7643 }
7644 if Path::new(stored_root).exists() {
7645 let reason = format!(
7646 "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
7647 );
7648 return Ok(OpenRootRepair::NeedsRebuild {
7649 previous_roots: roots,
7650 current_root,
7651 reason,
7652 });
7653 }
7654 }
7655
7656 if !allow_repair {
7657 return Ok(OpenRootRepair::NeedsRebuild {
7658 previous_roots: roots,
7659 current_root,
7660 reason: "workspace root metadata requires deferred repair".to_string(),
7661 });
7662 }
7663
7664 publish_if_current(|| {
7665 let tx = conn.transaction()?;
7666 tx.execute(
7667 "UPDATE OR IGNORE backend_file_state
7668 SET workspace_root = ?1
7669 WHERE workspace_root <> ?1",
7670 params![¤t_root],
7671 )?;
7672 tx.execute(
7673 "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
7674 params![¤t_root],
7675 )?;
7676 tx.commit()?;
7677 Ok(())
7678 })?;
7679
7680 crate::slog_info!(
7681 "callgraph store re-rooted from {} to {}",
7682 roots.join(", "),
7683 current_root
7684 );
7685 Ok(OpenRootRepair::ReRooted)
7686}
7687
7688fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
7689 let mut stmt = conn.prepare(
7690 "SELECT DISTINCT workspace_root
7691 FROM backend_file_state
7692 ORDER BY workspace_root",
7693 )?;
7694 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
7695 rows.collect::<std::result::Result<Vec<_>, _>>()
7696 .map_err(Into::into)
7697}
7698
7699fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
7700 for (table, column) in STORE_DATA_PATH_COLUMNS {
7701 let sql = format!(
7702 "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
7703 );
7704 let mut stmt = conn.prepare(&sql)?;
7705 let mut rows = stmt.query([])?;
7706 while let Some(row) = rows.next()? {
7707 let value: String = row.get(0)?;
7708 if stored_path_is_absolute(&value) {
7709 return Ok(Some(format!("{table}.{column}={value}")));
7710 }
7711 }
7712 }
7713 Ok(None)
7714}
7715
7716fn stored_path_is_absolute(value: &str) -> bool {
7717 if value.is_empty() {
7718 return false;
7719 }
7720 if Path::new(value).is_absolute() || value.starts_with('/') {
7721 return true;
7722 }
7723 let bytes = value.as_bytes();
7724 if bytes.len() >= 3
7725 && bytes[1] == b':'
7726 && (bytes[2] == b'/' || bytes[2] == b'\\')
7727 && bytes[0].is_ascii_alphabetic()
7728 {
7729 return true;
7730 }
7731 value.starts_with("\\\\") || value.starts_with("//")
7732}
7733
7734fn log_root_repair_rebuild(repair: &OpenRootRepair) {
7735 if let OpenRootRepair::NeedsRebuild {
7736 previous_roots,
7737 current_root,
7738 reason,
7739 } = repair
7740 {
7741 crate::slog_info!(
7742 "callgraph cold-build decision: reason=re-rooting refused; from={}; to={}; detail={}",
7743 previous_roots.join(", "),
7744 current_root,
7745 reason
7746 );
7747 }
7748}
7749
7750fn now_nanos() -> u128 {
7752 SystemTime::now()
7753 .duration_since(UNIX_EPOCH)
7754 .unwrap_or(Duration::ZERO)
7755 .as_nanos()
7756}
7757
7758fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
7763 callgraph_dir.join(format!("{project_key}.current"))
7764}
7765
7766fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
7770 callgraph_dir.join(format!("{project_key}.sqlite"))
7771}
7772
7773fn generation_file_name(project_key: &str) -> String {
7777 format!(
7778 "{project_key}.g{}.{}.sqlite",
7779 now_nanos(),
7780 std::process::id()
7781 )
7782}
7783
7784fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
7786 let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
7787 let name = text.trim();
7788 if name.is_empty() {
7789 None
7790 } else {
7791 Some(name.to_string())
7792 }
7793}
7794
7795fn db_path_ready(path: &Path) -> bool {
7798 (|| -> Result<bool> {
7799 let conn = open_readonly_connection(path)?;
7800 database_ready(&conn)
7801 })()
7802 .unwrap_or(false)
7803}
7804
7805fn resolve_ready_target(
7813 callgraph_dir: &Path,
7814 project_key: &str,
7815) -> Option<(PathBuf, Option<String>)> {
7816 for _ in 0..5 {
7817 if let Some(generation) = read_pointer(callgraph_dir, project_key) {
7818 let gen_path = callgraph_dir.join(&generation);
7819 if gen_path.is_file() {
7820 return (migration_manifest_valid(callgraph_dir, &generation)
7821 && db_path_ready(&gen_path))
7822 .then_some((gen_path, Some(generation)));
7823 }
7824 std::thread::sleep(Duration::from_millis(5));
7827 continue;
7828 }
7829 let legacy = legacy_sqlite_path(callgraph_dir, project_key);
7831 return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
7832 }
7833 None
7834}
7835
7836fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
7840 let pointer = pointer_path(callgraph_dir, project_key);
7841 let tmp = callgraph_dir.join(format!(
7842 "{project_key}.current.tmp.{}.{}",
7843 std::process::id(),
7844 now_nanos()
7845 ));
7846 {
7847 use std::io::Write as _;
7848 let mut file = std::fs::File::create(&tmp)?;
7849 file.write_all(generation.as_bytes())?;
7850 file.write_all(b"\n")?;
7851 file.sync_all()?;
7852 }
7853 if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
7854 let _ = std::fs::remove_file(&tmp);
7855 return Err(error.into());
7856 }
7857 crate::fs_lock::sync_parent(&pointer);
7858 Ok(())
7859}
7860
7861#[derive(Clone, Debug)]
7862struct GenerationGcCandidate {
7863 name: String,
7864 path: PathBuf,
7865 modified: SystemTime,
7866}
7867
7868fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
7874 let temp_grace = Duration::from_secs(60);
7875 let now = SystemTime::now();
7876 let pointer_current =
7877 read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
7878 let gen_prefix = format!("{project_key}.g");
7879 let tmp_prefixes = [
7880 format!("{project_key}.g"), format!("{project_key}.current."), format!("{project_key}.sqlite.tmp."), ];
7884 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
7885 return;
7886 };
7887 let mut gens: Vec<GenerationGcCandidate> = Vec::new();
7888 for entry in entries.flatten() {
7889 let name = entry.file_name();
7890 let name = name.to_string_lossy().to_string();
7891 let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
7892 let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
7893
7894 if name.contains(".tmp.") {
7896 if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
7897 let _ = std::fs::remove_file(entry.path());
7898 }
7899 continue;
7900 }
7901
7902 if name == format!("{project_key}.sqlite") {
7905 remove_sqlite_file_set(&entry.path());
7906 continue;
7907 }
7908
7909 if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
7910 gens.push(GenerationGcCandidate {
7911 name,
7912 path: entry.path(),
7913 modified: mtime,
7914 });
7915 }
7916 }
7917
7918 let mut superseded = gens
7919 .iter()
7920 .filter(|generation| generation.name != pointer_current)
7921 .collect::<Vec<_>>();
7922 superseded.sort_by(|left, right| {
7923 right
7924 .modified
7925 .cmp(&left.modified)
7926 .then_with(|| right.name.cmp(&left.name))
7927 });
7928 let previous = superseded.first().map(|generation| generation.name.clone());
7929
7930 for generation in gens {
7931 let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
7932 if generation.name == pointer_current
7933 || Some(generation.name.as_str()) == previous.as_deref()
7934 {
7935 continue;
7936 }
7937
7938 let age = now
7939 .duration_since(generation.modified)
7940 .unwrap_or(Duration::ZERO);
7941 if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
7942 continue;
7943 }
7944
7945 remove_sqlite_file_set(&generation.path);
7946 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
7947 let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
7948 callgraph_dir,
7949 &generation.name,
7950 ));
7951 }
7952}
7953
7954fn remove_sqlite_file_set(path: &Path) {
7955 let _ = std::fs::remove_file(path);
7956 remove_sqlite_sidecars(path);
7957}
7958
7959fn remove_sqlite_sidecars(path: &Path) {
7960 let path_text = path.to_string_lossy();
7961 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
7962 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
7963 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
7964}
7965
7966#[derive(Clone, Copy, Debug, Default)]
7967struct CallgraphRootSweepSummary {
7968 scanned: usize,
7969 removed: usize,
7970 bytes: u64,
7971 generation_gc: usize,
7972 skipped_memo: usize,
7973 skipped_derived: usize,
7974 skipped_fresh: usize,
7975 skipped_reader: usize,
7976 skipped_lease: usize,
7977 skipped_unreadable: usize,
7978 budget_exhausted: bool,
7979}
7980
7981#[derive(Clone, Copy, Debug, Default)]
7982struct CallgraphRootFileStats {
7983 newest: Option<SystemTime>,
7984 bytes: u64,
7985}
7986
7987enum CallgraphRootWalk {
7988 Complete(CallgraphRootFileStats),
7989 BudgetExceeded,
7990 Failed,
7991}
7992
7993enum CallgraphRootCandidate {
7994 Removed { bytes: u64 },
7995 GenerationGc,
7996 SkippedMemo,
7997 SkippedDerived,
7998 SkippedFresh,
7999 SkippedReader,
8000 SkippedLease,
8001 SkippedUnreadable,
8002 BudgetExceeded,
8003}
8004
8005fn sweep_orphaned_callgraph_root_dirs(callgraph_dir: &Path) {
8014 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
8015 return;
8016 };
8017 let root_dir = storage_root.join(crate::root_cache::RootCacheDomain::Callgraph.as_str());
8018 let memo_keys = match crate::search_index::referenced_artifact_cache_keys(&storage_root) {
8019 Ok(keys) => keys,
8020 Err(error) => {
8021 crate::slog_warn!(
8022 "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={}",
8023 root_dir.display(),
8024 error
8025 );
8026 return;
8027 }
8028 };
8029 let derived_keys = crate::search_index::derived_artifact_cache_keys();
8030 let summary = sweep_callgraph_root_dirs_with_limits(
8031 &root_dir,
8032 &memo_keys,
8033 &derived_keys,
8034 CALLGRAPH_ROOT_SWEEP_BUDGET,
8035 CALLGRAPH_ROOT_SWEEP_LIMIT,
8036 );
8037 crate::slog_info!(
8038 "callgraph root sweep root={} scanned={} removed={} bytes={} generation_gc={} skipped_memo={} skipped_derived={} skipped_fresh={} skipped_reader={} skipped_lease={} skipped_unreadable={} budget_exhausted={}",
8039 root_dir.display(),
8040 summary.scanned,
8041 summary.removed,
8042 summary.bytes,
8043 summary.generation_gc,
8044 summary.skipped_memo,
8045 summary.skipped_derived,
8046 summary.skipped_fresh,
8047 summary.skipped_reader,
8048 summary.skipped_lease,
8049 summary.skipped_unreadable,
8050 summary.budget_exhausted
8051 );
8052}
8053
8054fn sweep_callgraph_root_dirs_with_limits(
8055 root_dir: &Path,
8056 memo_keys: &HashSet<String>,
8057 derived_keys: &HashSet<String>,
8058 wall_clock_budget: Duration,
8059 entry_limit: usize,
8060) -> CallgraphRootSweepSummary {
8061 let started = Instant::now();
8062 let deadline = started + wall_clock_budget;
8063 let boundary = match crate::walk_boundary::DeviceBoundary::for_root(root_dir) {
8064 Ok(boundary) => boundary,
8065 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
8066 return CallgraphRootSweepSummary::default();
8067 }
8068 Err(error) => {
8069 crate::slog_warn!(
8070 "cannot establish filesystem boundary for callgraph root sweep {}: {}",
8071 root_dir.display(),
8072 error
8073 );
8074 return CallgraphRootSweepSummary {
8075 skipped_unreadable: 1,
8076 ..CallgraphRootSweepSummary::default()
8077 };
8078 }
8079 };
8080 let mut entries = match std::fs::read_dir(root_dir) {
8081 Ok(entries) => entries
8082 .filter_map(|entry| entry.ok())
8083 .filter_map(|entry| {
8084 let name = entry.file_name().to_string_lossy().into_owned();
8085 entry
8086 .file_type()
8087 .ok()
8088 .filter(|file_type| file_type.is_dir() && artifact_key_looks_valid(&name))
8089 .map(|_| (name, entry.path()))
8090 })
8091 .collect::<Vec<_>>(),
8092 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
8093 Err(error) => {
8094 crate::slog_warn!(
8095 "cannot read callgraph root sweep directory {}: {}",
8096 root_dir.display(),
8097 error
8098 );
8099 return CallgraphRootSweepSummary {
8100 skipped_unreadable: 1,
8101 ..CallgraphRootSweepSummary::default()
8102 };
8103 }
8104 };
8105 entries.sort_by(|left, right| left.0.cmp(&right.0));
8106
8107 let cursor_store = CALLGRAPH_ROOT_SWEEP_CURSORS.get_or_init(|| Mutex::new(HashMap::new()));
8108 let last_name = cursor_store
8109 .lock()
8110 .ok()
8111 .and_then(|cursors| cursors.get(root_dir).cloned());
8112 if let Some(start) = last_name
8113 .as_deref()
8114 .and_then(|last| entries.iter().position(|(name, _)| name.as_str() > last))
8115 {
8116 entries.rotate_left(start);
8117 }
8118
8119 let mut summary = CallgraphRootSweepSummary::default();
8120 let mut cursor_name = last_name;
8121 for (processed, (key, cache_dir)) in entries.into_iter().enumerate() {
8122 if processed >= entry_limit || Instant::now() >= deadline {
8123 summary.budget_exhausted = true;
8124 break;
8125 }
8126 summary.scanned += 1;
8127 cursor_name = Some(key.clone());
8128 match callgraph_root_candidate(
8129 &cache_dir,
8130 &key,
8131 memo_keys.contains(&key),
8132 derived_keys.contains(&key),
8133 &boundary,
8134 deadline,
8135 ) {
8136 CallgraphRootCandidate::Removed { bytes } => {
8137 summary.removed += 1;
8138 summary.bytes = summary.bytes.saturating_add(bytes);
8139 }
8140 CallgraphRootCandidate::GenerationGc => summary.generation_gc += 1,
8141 CallgraphRootCandidate::SkippedMemo => summary.skipped_memo += 1,
8142 CallgraphRootCandidate::SkippedDerived => summary.skipped_derived += 1,
8143 CallgraphRootCandidate::SkippedFresh => summary.skipped_fresh += 1,
8144 CallgraphRootCandidate::SkippedReader => summary.skipped_reader += 1,
8145 CallgraphRootCandidate::SkippedLease => summary.skipped_lease += 1,
8146 CallgraphRootCandidate::SkippedUnreadable => summary.skipped_unreadable += 1,
8147 CallgraphRootCandidate::BudgetExceeded => {
8148 summary.budget_exhausted = true;
8149 break;
8150 }
8151 }
8152 }
8153
8154 if let Ok(mut cursors) = cursor_store.lock() {
8155 if summary.budget_exhausted {
8156 if let Some(cursor_name) = cursor_name {
8157 cursors.insert(root_dir.to_path_buf(), cursor_name);
8158 }
8159 } else {
8160 cursors.remove(root_dir);
8161 }
8162 }
8163 if summary.removed > 0 {
8164 crate::fs_lock::sync_parent(root_dir);
8165 }
8166 summary
8167}
8168
8169fn callgraph_root_candidate(
8170 cache_dir: &Path,
8171 project_key: &str,
8172 memo_referenced: bool,
8173 derived_in_process: bool,
8174 boundary: &crate::walk_boundary::DeviceBoundary,
8175 deadline: Instant,
8176) -> CallgraphRootCandidate {
8177 if !boundary.should_descend(cache_dir).unwrap_or(false) {
8178 return CallgraphRootCandidate::SkippedUnreadable;
8179 }
8180 if memo_referenced || derived_in_process {
8181 return sweep_live_callgraph_root_generations(
8182 cache_dir,
8183 project_key,
8184 memo_referenced,
8185 boundary,
8186 deadline,
8187 );
8188 }
8189
8190 let stats = match callgraph_root_file_stats(cache_dir, boundary, deadline) {
8191 CallgraphRootWalk::Complete(stats) => stats,
8192 CallgraphRootWalk::BudgetExceeded => return CallgraphRootCandidate::BudgetExceeded,
8193 CallgraphRootWalk::Failed => return CallgraphRootCandidate::SkippedUnreadable,
8194 };
8195 let Some(newest) = stats.newest else {
8196 return CallgraphRootCandidate::SkippedUnreadable;
8197 };
8198 if SystemTime::now()
8199 .duration_since(newest)
8200 .unwrap_or(Duration::ZERO)
8201 < CALLGRAPH_ROOT_ORPHAN_MIN_AGE
8202 {
8203 return CallgraphRootCandidate::SkippedFresh;
8204 }
8205
8206 let _writer_lease = match crate::fs_lock::try_acquire(
8209 &crate::root_cache::writer_lease_path(cache_dir),
8210 Duration::ZERO,
8211 ) {
8212 Ok(lease) => lease,
8213 Err(_) => return CallgraphRootCandidate::SkippedLease,
8214 };
8215 if crate::root_cache::sweep_all_read_markers(cache_dir).protected {
8216 return CallgraphRootCandidate::SkippedReader;
8217 }
8218
8219 match std::fs::remove_dir_all(cache_dir) {
8220 Ok(()) => {
8221 crate::slog_info!(
8222 "callgraph root sweep reaped dir={} key={} bytes={}",
8223 cache_dir.display(),
8224 project_key,
8225 stats.bytes
8226 );
8227 CallgraphRootCandidate::Removed { bytes: stats.bytes }
8228 }
8229 Err(error) if error.kind() == std::io::ErrorKind::NotFound && !cache_dir.exists() => {
8230 crate::slog_info!(
8231 "callgraph root sweep reaped dir={} key={} bytes={}",
8232 cache_dir.display(),
8233 project_key,
8234 stats.bytes
8235 );
8236 CallgraphRootCandidate::Removed { bytes: stats.bytes }
8237 }
8238 Err(_) => CallgraphRootCandidate::SkippedUnreadable,
8239 }
8240}
8241
8242fn sweep_live_callgraph_root_generations(
8243 cache_dir: &Path,
8244 project_key: &str,
8245 memo_referenced: bool,
8246 boundary: &crate::walk_boundary::DeviceBoundary,
8247 deadline: Instant,
8248) -> CallgraphRootCandidate {
8249 if Instant::now() >= deadline {
8250 return CallgraphRootCandidate::BudgetExceeded;
8251 }
8252 let stats = match callgraph_root_file_stats(cache_dir, boundary, deadline) {
8253 CallgraphRootWalk::Complete(stats) => stats,
8254 CallgraphRootWalk::BudgetExceeded => return CallgraphRootCandidate::BudgetExceeded,
8255 CallgraphRootWalk::Failed => return CallgraphRootCandidate::SkippedUnreadable,
8256 };
8257 let Some(newest) = stats.newest else {
8258 return CallgraphRootCandidate::SkippedUnreadable;
8259 };
8260 if SystemTime::now()
8261 .duration_since(newest)
8262 .unwrap_or(Duration::ZERO)
8263 < CALLGRAPH_ROOT_ORPHAN_MIN_AGE
8264 {
8265 return CallgraphRootCandidate::SkippedFresh;
8266 }
8267 let _writer_lease = match crate::fs_lock::try_acquire(
8268 &crate::root_cache::writer_lease_path(cache_dir),
8269 Duration::ZERO,
8270 ) {
8271 Ok(lease) => lease,
8272 Err(_) => return CallgraphRootCandidate::SkippedLease,
8273 };
8274 if crate::root_cache::sweep_all_read_markers(cache_dir).protected {
8275 return CallgraphRootCandidate::SkippedReader;
8276 }
8277 if let Some(current) = read_pointer(cache_dir, project_key) {
8278 gc_old_generations(cache_dir, project_key, ¤t);
8279 return CallgraphRootCandidate::GenerationGc;
8280 }
8281 if memo_referenced {
8282 CallgraphRootCandidate::SkippedMemo
8283 } else {
8284 CallgraphRootCandidate::SkippedDerived
8285 }
8286}
8287
8288fn callgraph_root_file_stats(
8289 cache_dir: &Path,
8290 boundary: &crate::walk_boundary::DeviceBoundary,
8291 deadline: Instant,
8292) -> CallgraphRootWalk {
8293 let metadata = match std::fs::metadata(cache_dir) {
8294 Ok(metadata) => metadata,
8295 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
8296 return CallgraphRootWalk::Complete(CallgraphRootFileStats::default());
8297 }
8298 Err(_) => return CallgraphRootWalk::Failed,
8299 };
8300 let mut stats = CallgraphRootFileStats {
8301 newest: metadata.modified().ok(),
8302 bytes: 0,
8303 };
8304 match callgraph_root_file_stats_inner(cache_dir, boundary, deadline, &mut stats) {
8305 Ok(()) => CallgraphRootWalk::Complete(stats),
8306 Err(CallgraphRootWalkError::BudgetExceeded) => CallgraphRootWalk::BudgetExceeded,
8307 Err(CallgraphRootWalkError::Failed) => CallgraphRootWalk::Failed,
8308 }
8309}
8310
8311enum CallgraphRootWalkError {
8312 BudgetExceeded,
8313 Failed,
8314}
8315
8316fn callgraph_root_file_stats_inner(
8317 directory: &Path,
8318 boundary: &crate::walk_boundary::DeviceBoundary,
8319 deadline: Instant,
8320 stats: &mut CallgraphRootFileStats,
8321) -> std::result::Result<(), CallgraphRootWalkError> {
8322 if Instant::now() >= deadline {
8323 return Err(CallgraphRootWalkError::BudgetExceeded);
8324 }
8325 let entries = std::fs::read_dir(directory).map_err(|_| CallgraphRootWalkError::Failed)?;
8326 for entry in entries {
8327 if Instant::now() >= deadline {
8328 return Err(CallgraphRootWalkError::BudgetExceeded);
8329 }
8330 let entry = entry.map_err(|_| CallgraphRootWalkError::Failed)?;
8331 let file_type = entry
8332 .file_type()
8333 .map_err(|_| CallgraphRootWalkError::Failed)?;
8334 if file_type.is_symlink() {
8335 return Err(CallgraphRootWalkError::Failed);
8336 }
8337 let path = entry.path();
8338 if file_type.is_dir() {
8339 if !boundary
8340 .should_descend(&path)
8341 .map_err(|_| CallgraphRootWalkError::Failed)?
8342 {
8343 return Err(CallgraphRootWalkError::Failed);
8344 }
8345 let metadata = entry
8346 .metadata()
8347 .map_err(|_| CallgraphRootWalkError::Failed)?;
8348 merge_newest_callgraph_root_mtime(stats, metadata.modified().ok());
8349 callgraph_root_file_stats_inner(&path, boundary, deadline, stats)?;
8350 continue;
8351 }
8352 if !file_type.is_file() {
8353 return Err(CallgraphRootWalkError::Failed);
8354 }
8355 let metadata = entry
8356 .metadata()
8357 .map_err(|_| CallgraphRootWalkError::Failed)?;
8358 stats.bytes = stats.bytes.saturating_add(metadata.len());
8359 merge_newest_callgraph_root_mtime(stats, metadata.modified().ok());
8360 }
8361 Ok(())
8362}
8363
8364fn merge_newest_callgraph_root_mtime(
8365 stats: &mut CallgraphRootFileStats,
8366 modified: Option<SystemTime>,
8367) {
8368 if let Some(modified) = modified {
8369 if stats.newest.is_none_or(|newest| modified > newest) {
8370 stats.newest = Some(modified);
8371 }
8372 }
8373}
8374
8375fn artifact_key_looks_valid(key: &str) -> bool {
8376 key.len() == 16 && key.bytes().all(|byte| byte.is_ascii_hexdigit())
8377}
8378
8379#[cfg(test)]
8380fn reset_callgraph_root_sweep_cursor_for_test() {
8381 if let Some(cursors) = CALLGRAPH_ROOT_SWEEP_CURSORS.get() {
8382 cursors.lock().unwrap().clear();
8383 }
8384}
8385
8386const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
8400
8401fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
8413 sweep_orphaned_build_temps(callgraph_dir);
8414 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
8415 return;
8416 };
8417 let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
8418 let Ok(boundary) = crate::walk_boundary::DeviceBoundary::for_root(&storage_root) else {
8422 crate::slog_warn!(
8423 "cannot establish filesystem boundary for callgraph sweep {}",
8424 storage_root.display()
8425 );
8426 return;
8427 };
8428 let mut skipped_foreign_mounts = 0usize;
8429
8430 let root_keyed_dir = storage_root.join(domain);
8432 if root_keyed_dir.is_dir() {
8433 if boundary.should_descend(&root_keyed_dir).unwrap_or(false) {
8434 if let Ok(entries) = std::fs::read_dir(&root_keyed_dir) {
8435 for entry in entries.flatten() {
8436 let path = entry.path();
8437 if path.is_dir() {
8438 if boundary.should_descend(&path).unwrap_or(false) {
8439 sweep_orphaned_build_temps(&path);
8440 } else {
8441 skipped_foreign_mounts += 1;
8442 }
8443 }
8444 }
8445 }
8446 } else {
8447 skipped_foreign_mounts += 1;
8448 }
8449 }
8450
8451 if let Ok(entries) = std::fs::read_dir(&storage_root) {
8453 for entry in entries.flatten() {
8454 let harness_dir = entry.path();
8455 if !harness_dir.is_dir() {
8456 continue;
8457 }
8458 if !boundary.should_descend(&harness_dir).unwrap_or(false) {
8459 skipped_foreign_mounts += 1;
8460 continue;
8461 }
8462 let legacy_dir = harness_dir.join(domain);
8463 if legacy_dir.is_dir() {
8464 if boundary.should_descend(&legacy_dir).unwrap_or(false) {
8465 sweep_orphaned_build_temps(&legacy_dir);
8466 } else {
8467 skipped_foreign_mounts += 1;
8468 }
8469 }
8470 }
8471 }
8472 if skipped_foreign_mounts > 0 {
8473 crate::slog_warn!(
8474 "callgraph sweep skipped {} foreign filesystem mount(s) below {}",
8475 skipped_foreign_mounts,
8476 storage_root.display()
8477 );
8478 }
8479}
8480
8481fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
8484 sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
8485}
8486
8487fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
8490 let now = SystemTime::now();
8491 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
8492 return;
8493 };
8494 let mut removed_any = false;
8495 for entry in entries.flatten() {
8496 let name = entry.file_name().to_string_lossy().to_string();
8497 if !name.contains(".sqlite.tmp.") {
8503 continue;
8504 }
8505 let mtime = entry
8506 .metadata()
8507 .and_then(|meta| meta.modified())
8508 .unwrap_or(now);
8509 if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
8510 continue;
8511 }
8512 match std::fs::remove_file(entry.path()) {
8518 Ok(()) => removed_any = true,
8519 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
8520 Err(_) => {}
8521 }
8522 }
8523 if removed_any {
8524 crate::fs_lock::sync_parent(callgraph_dir);
8525 }
8526}
8527
8528fn build_pool_size() -> usize {
8536 std::thread::available_parallelism()
8537 .map(|parallelism| parallelism.get())
8538 .unwrap_or(1)
8539 .div_ceil(2)
8540 .clamp(1, 8)
8541}
8542
8543fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
8544 let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
8545 Ok(extract) => Ok(extract),
8546 Err(error) => {
8547 let abs_path =
8548 normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
8549 let rel_path = relative_path(project_root, &abs_path);
8550 let freshness = cache_freshness::collect(&abs_path).ok();
8551 log::debug!(
8552 "callgraph store: skipping {} during cold build: {}",
8553 abs_path.display(),
8554 error
8555 );
8556 Err(ExtractFailure {
8557 rel_path,
8558 freshness,
8559 })
8560 }
8561 };
8562
8563 let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
8564 files.par_iter().map(extract_one).collect()
8565 };
8566
8567 let results = match rayon::ThreadPoolBuilder::new()
8570 .num_threads(build_pool_size())
8571 .thread_name(|index| format!("aft-callgraph-build-{index}"))
8572 .stack_size(8 * 1024 * 1024)
8573 .build()
8574 {
8575 Ok(pool) => pool.install(run),
8576 Err(error) => {
8577 log::warn!(
8578 "callgraph store: bounded build pool unavailable ({error}); using global pool"
8579 );
8580 run()
8581 }
8582 };
8583
8584 let mut extracts = Vec::new();
8585 let mut failures = Vec::new();
8586 for result in results {
8587 match result {
8588 Ok(extract) => extracts.push(extract),
8589 Err(failure) => failures.push(failure),
8590 }
8591 }
8592 BuildExtractsResult { extracts, failures }
8593}
8594
8595fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
8596 let metadata = std::fs::metadata(path)?;
8597 let size = metadata.len();
8598 let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
8599 cache_freshness::zero_hash()
8600 } else if source.len() as u64 == size {
8601 cache_freshness::hash_bytes(source.as_bytes())
8602 } else {
8603 cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
8604 };
8605 Ok(FileFreshness {
8606 mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
8607 size,
8608 content_hash,
8609 })
8610}
8611
8612fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
8613 let abs_path = normalize_file_path(project_root, path)?;
8614 let rel_path = relative_path(project_root, &abs_path);
8615 let source = std::fs::read_to_string(&abs_path)?;
8616 let freshness = collect_source_freshness(&abs_path, &source)?;
8617 let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
8618 let lang = data.lang;
8619 if lang == LangId::Rust {
8620 extend_rust_imports_with_nested_uses(&source, &mut data);
8621 }
8622 let mut nodes = build_node_records(&rel_path, &source, &data)?;
8623 let node_by_scoped: HashMap<String, String> = nodes
8624 .iter()
8625 .map(|node| (node.scoped_name.clone(), node.id.clone()))
8626 .collect();
8627 let import_dependencies =
8628 import_dependencies(project_root, &abs_path, &data.import_block.imports);
8629 let line_index = LineIndex::new(&source);
8630 let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
8631 let rust_reexports = if lang == LangId::Rust {
8632 collect_rust_pub_use_reexport_refs(
8633 project_root,
8634 &abs_path,
8635 &rel_path,
8636 &data.import_block.imports,
8637 &line_index,
8638 )
8639 } else {
8640 ReexportRefs {
8641 raw_refs: Vec::new(),
8642 surface_parts: Vec::new(),
8643 }
8644 };
8645 let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
8646 let mut raw_refs = Vec::new();
8647 raw_refs.extend(build_call_refs(
8648 &rel_path,
8649 &data,
8650 &node_by_scoped,
8651 &import_dependencies,
8652 ));
8653 raw_refs.extend(build_value_ref_refs(
8654 &rel_path,
8655 &data,
8656 &node_by_scoped,
8657 &import_dependencies,
8658 ));
8659 raw_refs.extend(build_import_refs(
8660 project_root,
8661 &abs_path,
8662 &rel_path,
8663 &data.import_block.imports,
8664 &line_index,
8665 ));
8666 if lang == LangId::Rust {
8667 raw_refs.extend(build_rust_module_refs(
8668 project_root,
8669 &abs_path,
8670 &rel_path,
8671 &source,
8672 ));
8673 }
8674 let mut surface_parts = reexports.surface_parts;
8675 surface_parts.extend(rust_reexports.surface_parts);
8676 surface_parts.extend(source_less_exports.surface_parts);
8677 raw_refs.extend(reexports.raw_refs);
8678 raw_refs.extend(rust_reexports.raw_refs);
8679 raw_refs.extend(source_less_exports.raw_refs);
8680 let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
8681 let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
8682
8683 Ok(FileExtract {
8684 rel_path,
8685 freshness,
8686 lang,
8687 data,
8688 nodes,
8689 raw_refs,
8690 dispatch_hints,
8691 surface_fingerprint,
8692 })
8693}
8694
8695fn build_node_records(
8696 rel_path: &str,
8697 source: &str,
8698 data: &FileCallData,
8699) -> Result<Vec<NodeRecord>> {
8700 let mut records = Vec::new();
8701 let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
8702 let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
8703 metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
8704
8705 for (scoped_name, meta) in metadata {
8706 let name = unqualified_name(scoped_name).to_string();
8707 let range = selection_range(source, scoped_name, &name, &meta.range);
8708 let range_key = (
8709 range.start_line,
8710 range.start_col,
8711 range.end_line,
8712 range.end_col,
8713 );
8714 let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
8715 let range_ordinal = *ordinal;
8716 *ordinal += 1;
8717 let id = node_id(rel_path, &range, range_ordinal, scoped_name);
8718 let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
8719 let is_default_export = data
8720 .default_export_symbol
8721 .as_deref()
8722 .map(|default| default == scoped_name || default == name)
8723 .unwrap_or(false);
8724 records.push(NodeRecord {
8725 id,
8726 file_path: rel_path.to_string(),
8727 name: name.clone(),
8728 scoped_name: scoped_name.clone(),
8729 kind: symbol_kind_label(&meta.kind).to_string(),
8730 range,
8731 range_ordinal,
8732 signature: meta.signature.clone(),
8733 exported,
8734 is_default_export,
8735 is_type_like: is_type_like(&meta.kind),
8736 is_callgraph_entry_point: meta.entry_point_attribute.is_some()
8737 || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
8738 });
8739 }
8740
8741 Ok(records)
8742}
8743
8744fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
8745 if scoped_name == TOP_LEVEL_SYMBOL {
8746 return Range {
8747 start_line: 0,
8748 start_col: 0,
8749 end_line: 0,
8750 end_col: 0,
8751 };
8752 }
8753 let Some(line) = source.lines().nth(fallback.start_line as usize) else {
8754 return fallback.clone();
8755 };
8756 let start_col = fallback.start_col as usize;
8757 let search_start = start_col.min(line.len());
8758 if let Some(offset) = line[search_start..].find(name) {
8759 let col = search_start + offset;
8760 return Range {
8761 start_line: fallback.start_line,
8762 start_col: col as u32,
8763 end_line: fallback.start_line,
8764 end_col: (col + name.len()) as u32,
8765 };
8766 }
8767 if let Some(offset) = line.find(name) {
8768 return Range {
8769 start_line: fallback.start_line,
8770 start_col: offset as u32,
8771 end_line: fallback.start_line,
8772 end_col: (offset + name.len()) as u32,
8773 };
8774 }
8775 Range {
8776 start_line: fallback.start_line,
8777 start_col: fallback.start_col,
8778 end_line: fallback.start_line,
8779 end_col: fallback.start_col.saturating_add(name.len() as u32),
8780 }
8781}
8782
8783fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
8784 if scoped_name == TOP_LEVEL_SYMBOL {
8785 return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
8786 }
8787 let input = format!(
8788 "{rel_path}:{}:{}:{}:{}:{ordinal}",
8789 range.start_line, range.start_col, range.end_line, range.end_col
8790 );
8791 format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
8792}
8793
8794fn build_call_refs(
8795 rel_path: &str,
8796 data: &FileCallData,
8797 node_by_scoped: &HashMap<String, String>,
8798 import_dependencies: &BTreeSet<String>,
8799) -> Vec<RawRef> {
8800 build_callable_refs(
8801 rel_path,
8802 &data.calls_by_symbol,
8803 node_by_scoped,
8804 import_dependencies,
8805 "call",
8806 )
8807}
8808
8809fn build_value_ref_refs(
8810 rel_path: &str,
8811 data: &FileCallData,
8812 node_by_scoped: &HashMap<String, String>,
8813 import_dependencies: &BTreeSet<String>,
8814) -> Vec<RawRef> {
8815 build_callable_refs(
8816 rel_path,
8817 &data.value_refs_by_symbol,
8818 node_by_scoped,
8819 import_dependencies,
8820 "value_ref",
8821 )
8822}
8823
8824fn build_callable_refs(
8825 rel_path: &str,
8826 sites_by_symbol: &HashMap<String, Vec<callgraph::CallSite>>,
8827 node_by_scoped: &HashMap<String, String>,
8828 import_dependencies: &BTreeSet<String>,
8829 kind: &str,
8830) -> Vec<RawRef> {
8831 let mut refs = Vec::new();
8832 let mut ordinal = 0usize;
8833 let mut symbols: Vec<_> = sites_by_symbol.iter().collect();
8834 symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
8835 for (caller_symbol, call_sites) in symbols {
8836 let caller_node = node_by_scoped.get(caller_symbol).cloned();
8837 for call_site in call_sites {
8838 ordinal += 1;
8839 let ref_id = ref_id(&[
8840 rel_path,
8841 kind,
8842 caller_symbol,
8843 &call_site.line.to_string(),
8844 &call_site.byte_start.to_string(),
8845 &call_site.byte_end.to_string(),
8846 &call_site.full_callee,
8847 &ordinal.to_string(),
8848 ]);
8849 refs.push(RawRef {
8850 ref_id,
8851 caller_node: caller_node.clone(),
8852 caller_symbol: Some(caller_symbol.clone()),
8853 caller_file: rel_path.to_string(),
8854 kind: kind.to_string(),
8855 short_name: Some(call_site.callee_name.clone()),
8856 full_ref: Some(call_site.full_callee.clone()),
8857 module_path: None,
8858 import_kind: None,
8859 local_name: Some(call_site.callee_name.clone()),
8860 requested_name: Some(call_site.callee_name.clone()),
8861 namespace_alias: namespace_alias(&call_site.full_callee),
8862 wildcard: false,
8863 line: call_site.line,
8864 byte_start: call_site.byte_start,
8865 byte_end: call_site.byte_end,
8866 dependencies: import_dependencies.clone(),
8867 });
8868 }
8869 }
8870 refs
8871}
8872
8873fn build_import_refs(
8874 project_root: &Path,
8875 abs_path: &Path,
8876 rel_path: &str,
8877 imports: &[ImportStatement],
8878 line_index: &LineIndex,
8879) -> Vec<RawRef> {
8880 let mut refs = Vec::new();
8881 for (index, import) in imports.iter().enumerate() {
8882 let import_kind = import_kind_label(import.kind).to_string();
8883 let local_name = import_local_names(import).join(",");
8884 let requested_name = import_requested_names(import).join(",");
8885 let ref_id = ref_id(&[
8886 rel_path,
8887 "import",
8888 &import.byte_range.start.to_string(),
8889 &import.byte_range.end.to_string(),
8890 &import.module_path,
8891 &index.to_string(),
8892 ]);
8893 refs.push(RawRef {
8894 ref_id,
8895 caller_node: None,
8896 caller_symbol: None,
8897 caller_file: rel_path.to_string(),
8898 kind: "import".to_string(),
8899 short_name: None,
8900 full_ref: Some(import.raw_text.clone()),
8901 module_path: Some(import.module_path.clone()),
8902 import_kind: Some(import_kind),
8903 local_name: empty_to_none(local_name),
8904 requested_name: empty_to_none(requested_name),
8905 namespace_alias: import.namespace_import.clone(),
8906 wildcard: import_is_wildcard(import),
8907 line: line_index.byte_to_line(import.byte_range.start),
8908 byte_start: import.byte_range.start,
8909 byte_end: import.byte_range.end,
8910 dependencies: module_dependencies(project_root, abs_path, &import.module_path),
8911 });
8912 }
8913 refs
8914}
8915
8916fn build_rust_module_refs(
8917 project_root: &Path,
8918 abs_path: &Path,
8919 rel_path: &str,
8920 source: &str,
8921) -> Vec<RawRef> {
8922 let grammar = grammar_for(LangId::Rust);
8923 let mut parser = Parser::new();
8924 if parser.set_language(&grammar).is_err() {
8925 return Vec::new();
8926 }
8927 let Some(tree) = parser.parse(source, None) else {
8928 return Vec::new();
8929 };
8930
8931 let mut refs = Vec::new();
8932 let mut stack = vec![tree.root_node()];
8933 while let Some(node) = stack.pop() {
8934 if node.kind() == "mod_item"
8935 && node
8936 .named_children(&mut node.walk())
8937 .all(|child| child.kind() != "declaration_list")
8938 {
8939 if let Some(name_node) = node.child_by_field_name("name") {
8940 let module_name = node_text(name_node, source).to_string();
8941 let target = rust_external_module_target(abs_path, source, node, &module_name);
8942 let mut dependencies = BTreeSet::new();
8943 if let Some(target) = target {
8944 dependencies.insert(relative_path(project_root, &canonicalize_path(&target)));
8945 }
8946 refs.push(RawRef {
8947 ref_id: ref_id(&[
8948 rel_path,
8949 "module",
8950 &module_name,
8951 &node.start_byte().to_string(),
8952 ]),
8953 caller_node: None,
8954 caller_symbol: None,
8955 caller_file: rel_path.to_string(),
8956 kind: "module".to_string(),
8957 short_name: Some(module_name.clone()),
8958 full_ref: Some(module_name.clone()),
8959 module_path: Some(module_name.clone()),
8960 import_kind: Some("module".to_string()),
8961 local_name: Some(module_name.clone()),
8962 requested_name: Some(module_name),
8963 namespace_alias: None,
8964 wildcard: false,
8965 line: node.start_position().row as u32 + 1,
8966 byte_start: node.start_byte(),
8967 byte_end: node.end_byte(),
8968 dependencies,
8969 });
8970 }
8971 }
8972
8973 let mut cursor = node.walk();
8974 if cursor.goto_first_child() {
8975 loop {
8976 stack.push(cursor.node());
8977 if !cursor.goto_next_sibling() {
8978 break;
8979 }
8980 }
8981 }
8982 }
8983 refs.sort_by_key(|raw| (raw.byte_start, raw.byte_end));
8984 refs
8985}
8986
8987fn rust_declared_module_target(
8988 project_root: &Path,
8989 caller_file: &str,
8990 module_name: &str,
8991 memo: &callgraph::ModuleResolutionMemo,
8992) -> Option<String> {
8993 memo.rust_declared_module_target(caller_file, module_name, || {
8994 rust_declared_module_targets(project_root, caller_file)
8995 })
8996}
8997
8998fn rust_declared_module_targets(
8999 project_root: &Path,
9000 caller_file: &str,
9001) -> HashMap<String, Option<String>> {
9002 let declaring_file = project_root.join(caller_file);
9003 let Ok(source) = std::fs::read_to_string(&declaring_file) else {
9004 return HashMap::new();
9005 };
9006 let Ok(tree) = parse_source_with_cached_parser(&declaring_file, &source, LangId::Rust) else {
9007 return HashMap::new();
9008 };
9009 let mut targets = HashMap::new();
9010 let mut stack = vec![tree.root_node()];
9011 while let Some(node) = stack.pop() {
9012 if node.kind() == "mod_item"
9013 && node
9014 .named_children(&mut node.walk())
9015 .all(|child| child.kind() != "declaration_list")
9016 {
9017 if let Some(name) = node.child_by_field_name("name") {
9018 let module_name = node_text(name, &source);
9019 let target =
9020 rust_external_module_target(&declaring_file, &source, node, module_name)
9021 .map(|target| relative_path(project_root, &canonicalize_path(&target)));
9022 targets.entry(module_name.to_string()).or_insert(target);
9023 }
9024 }
9025 let mut cursor = node.walk();
9026 if cursor.goto_first_child() {
9027 loop {
9028 stack.push(cursor.node());
9029 if !cursor.goto_next_sibling() {
9030 break;
9031 }
9032 }
9033 }
9034 }
9035 targets
9036}
9037
9038fn rust_external_module_target(
9039 declaring_file: &Path,
9040 source: &str,
9041 module: Node<'_>,
9042 module_name: &str,
9043) -> Option<PathBuf> {
9044 let parent = declaring_file.parent()?;
9045 let mut previous = module.prev_sibling();
9046 while let Some(attribute) = previous {
9047 if attribute.kind() != "attribute_item" {
9048 break;
9049 }
9050 let text = source.get(attribute.byte_range())?;
9051 if let Some(path) = rust_path_attribute(text) {
9052 let candidate = parent.join(path);
9053 return candidate.is_file().then_some(candidate);
9054 }
9055 previous = attribute.prev_sibling();
9056 }
9057
9058 let stem = declaring_file.file_stem().and_then(|stem| stem.to_str())?;
9059 let module_dir = if matches!(stem, "lib" | "main" | "mod") {
9060 parent.to_path_buf()
9061 } else {
9062 parent.join(stem)
9063 };
9064 [
9065 module_dir.join(format!("{module_name}.rs")),
9066 module_dir.join(module_name).join("mod.rs"),
9067 ]
9068 .into_iter()
9069 .find(|candidate| candidate.is_file())
9070}
9071
9072fn rust_path_attribute(attribute: &str) -> Option<&str> {
9073 let body = attribute.trim().strip_prefix("#[")?.strip_suffix(']')?;
9074 let (name, value) = body.split_once('=')?;
9075 (name.trim() == "path")
9076 .then(|| value.trim().trim_matches('"'))
9077 .filter(|path| !path.is_empty())
9078}
9079
9080fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
9081 let grammar = grammar_for(LangId::Rust);
9082 let mut parser = Parser::new();
9083 if parser.set_language(&grammar).is_err() {
9084 return;
9085 }
9086 let Some(tree) = parser.parse(source, None) else {
9087 return;
9088 };
9089
9090 let mut seen = data
9091 .import_block
9092 .imports
9093 .iter()
9094 .map(|import| (import.byte_range.start, import.byte_range.end))
9095 .collect::<HashSet<_>>();
9096 let mut nested_imports = Vec::new();
9097 collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
9098 if nested_imports.is_empty() {
9099 return;
9100 }
9101
9102 data.import_block.imports.extend(nested_imports);
9103 data.import_block
9104 .imports
9105 .sort_by_key(|import| import.byte_range.start);
9106 data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
9107}
9108
9109fn collect_rust_use_imports(
9110 source: &str,
9111 node: Node<'_>,
9112 seen: &mut HashSet<(usize, usize)>,
9113 imports: &mut Vec<ImportStatement>,
9114) {
9115 if node.kind() == "use_declaration" {
9116 let range = node.byte_range();
9117 if seen.insert((range.start, range.end)) {
9118 if let Some(import) = rust_import_from_use_node(source, node) {
9119 imports.push(import);
9120 }
9121 }
9122 }
9123
9124 let mut cursor = node.walk();
9125 if !cursor.goto_first_child() {
9126 return;
9127 }
9128 loop {
9129 collect_rust_use_imports(source, cursor.node(), seen, imports);
9130 if !cursor.goto_next_sibling() {
9131 break;
9132 }
9133 }
9134}
9135
9136fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
9137 let raw_text = source[node.byte_range()].to_string();
9138 let body = rust_use_body(&raw_text)?.to_string();
9139 let visibility = rust_use_visibility(&raw_text);
9140 let names = rust_use_list_names(&body);
9141 let group = classify_rust_import_group(&body);
9142 let byte_range = node.byte_range();
9143
9144 Some(ImportStatement {
9145 module_path: body,
9146 names: names.clone(),
9147 default_import: visibility.clone(),
9148 namespace_import: None,
9149 kind: ImportKind::Value,
9150 group,
9151 byte_range,
9152 raw_text,
9153 form: ImportForm::RustUse {
9154 visibility,
9155 named: names,
9156 },
9157 })
9158}
9159
9160fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
9161 let start = imports.iter().map(|import| import.byte_range.start).min()?;
9162 let end = imports.iter().map(|import| import.byte_range.end).max()?;
9163 Some(start..end)
9164}
9165
9166fn rust_use_visibility(raw_text: &str) -> Option<String> {
9167 let use_pos = raw_text.find("use ")?;
9168 let prefix = raw_text[..use_pos].trim();
9169 if prefix.is_empty() {
9170 None
9171 } else {
9172 Some(prefix.to_string())
9173 }
9174}
9175
9176fn rust_use_body(raw_text: &str) -> Option<&str> {
9177 let use_pos = raw_text.find("use ")?;
9178 Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
9179}
9180
9181fn rust_use_list_names(body: &str) -> Vec<String> {
9182 let Some(open) = body.find("::{") else {
9183 return Vec::new();
9184 };
9185 let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
9186 return Vec::new();
9187 };
9188 body[open + 3..close]
9189 .split(',')
9190 .filter_map(|spec| {
9191 let spec = spec.trim();
9192 if spec.is_empty() {
9193 None
9194 } else {
9195 Some(spec.to_string())
9196 }
9197 })
9198 .collect()
9199}
9200
9201fn classify_rust_import_group(body: &str) -> ImportGroup {
9202 let first = body
9203 .split("::")
9204 .next()
9205 .unwrap_or(body)
9206 .split_whitespace()
9207 .next()
9208 .unwrap_or(body);
9209 match first.trim() {
9210 "std" | "core" | "alloc" => ImportGroup::Stdlib,
9211 "crate" | "self" | "super" => ImportGroup::Internal,
9212 _ => ImportGroup::External,
9213 }
9214}
9215
9216#[derive(Debug, Clone)]
9217struct ReexportRefs {
9218 raw_refs: Vec<RawRef>,
9219 surface_parts: Vec<String>,
9220}
9221
9222fn collect_reexport_refs(
9223 project_root: &Path,
9224 abs_path: &Path,
9225 rel_path: &str,
9226 source: &str,
9227) -> ReexportRefs {
9228 let mut raw_refs = Vec::new();
9229 let mut surface_parts = Vec::new();
9230 let mut search_start = 0usize;
9231 let mut ordinal = 0usize;
9232 while let Some(export_offset) = source[search_start..].find("export") {
9233 let start = search_start + export_offset;
9234 let Some(statement_end_offset) = source[start..].find(';') else {
9235 break;
9236 };
9237 let end = start + statement_end_offset + 1;
9238 let statement = &source[start..end];
9239 search_start = end;
9240 if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
9241 continue;
9242 }
9243 let Some(module_path) = quoted_module_path(statement) else {
9244 continue;
9245 };
9246 ordinal += 1;
9247 let wildcard = statement.contains('*');
9248 let line = source[..start]
9249 .bytes()
9250 .filter(|byte| *byte == b'\n')
9251 .count() as u32
9252 + 1;
9253 let ref_id = ref_id(&[
9254 rel_path,
9255 "reexport",
9256 &start.to_string(),
9257 &end.to_string(),
9258 &module_path,
9259 &ordinal.to_string(),
9260 ]);
9261 surface_parts.push(format!("reexport\t{statement}"));
9262 raw_refs.push(RawRef {
9263 ref_id,
9264 caller_node: None,
9265 caller_symbol: None,
9266 caller_file: rel_path.to_string(),
9267 kind: "reexport".to_string(),
9268 short_name: None,
9269 full_ref: Some(statement.to_string()),
9270 module_path: Some(module_path.clone()),
9271 import_kind: Some("reexport".to_string()),
9272 local_name: None,
9273 requested_name: None,
9274 namespace_alias: None,
9275 wildcard,
9276 line,
9277 byte_start: start,
9278 byte_end: end,
9279 dependencies: module_dependencies(project_root, abs_path, &module_path),
9280 });
9281 }
9282 ReexportRefs {
9283 raw_refs,
9284 surface_parts,
9285 }
9286}
9287
9288fn collect_rust_pub_use_reexport_refs(
9289 project_root: &Path,
9290 abs_path: &Path,
9291 rel_path: &str,
9292 imports: &[ImportStatement],
9293 line_index: &LineIndex,
9294) -> ReexportRefs {
9295 let mut raw_refs = Vec::new();
9296 let mut surface_parts = Vec::new();
9297 let mut ordinal = 0usize;
9298
9299 for import in imports {
9300 let Some(visibility) = &import.default_import else {
9301 continue;
9302 };
9303 if !visibility.starts_with("pub") {
9304 continue;
9305 }
9306 let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
9307 continue;
9308 };
9309 ordinal += 1;
9310 let ref_id = ref_id(&[
9311 rel_path,
9312 "rust_reexport",
9313 &import.byte_range.start.to_string(),
9314 &import.byte_range.end.to_string(),
9315 &module_path,
9316 &ordinal.to_string(),
9317 ]);
9318 surface_parts.push(format!("reexport\t{}", import.raw_text));
9319 raw_refs.push(RawRef {
9320 ref_id,
9321 caller_node: None,
9322 caller_symbol: None,
9323 caller_file: rel_path.to_string(),
9324 kind: "reexport".to_string(),
9325 short_name: None,
9326 full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
9327 module_path: Some(module_path.clone()),
9328 import_kind: Some("reexport".to_string()),
9329 local_name: None,
9330 requested_name: None,
9331 namespace_alias: None,
9332 wildcard,
9333 line: line_index.byte_to_line(import.byte_range.start),
9334 byte_start: import.byte_range.start,
9335 byte_end: import.byte_range.end,
9336 dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
9337 });
9338 }
9339
9340 ReexportRefs {
9341 raw_refs,
9342 surface_parts,
9343 }
9344}
9345
9346fn rust_pub_use_reexport_parts(
9347 import: &ImportStatement,
9348) -> Option<(String, HashMap<String, String>, bool)> {
9349 let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
9350 let body = body.trim();
9351 if let Some(module_path) = body.strip_suffix("::*") {
9352 return Some((module_path.trim().to_string(), HashMap::new(), true));
9353 }
9354
9355 if let Some(brace_start) = body.find("::{") {
9356 let module_path = body[..brace_start].trim().to_string();
9357 let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
9358 if names.is_empty() {
9359 return None;
9360 }
9361 return Some((module_path, names, false));
9362 }
9363
9364 let (module_path, spec) = body.rsplit_once("::")?;
9365 let names = rust_reexport_names_from_specs(spec);
9366 if names.is_empty() {
9367 return None;
9368 }
9369 Some((module_path.trim().to_string(), names, false))
9370}
9371
9372fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
9373 let mut names = HashMap::new();
9374 for spec in specs.split(',') {
9375 let spec = spec.trim();
9376 if spec.is_empty() || spec == "self" {
9377 continue;
9378 }
9379 if let Some((source, local)) = spec.split_once(" as ") {
9380 let source = source.trim();
9381 let local = local.trim();
9382 if !source.is_empty() && !local.is_empty() && source != "self" {
9383 names.insert(local.to_string(), source.to_string());
9384 }
9385 } else {
9386 names.insert(spec.to_string(), spec.to_string());
9387 }
9388 }
9389 names
9390}
9391
9392fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
9393 if named.is_empty() {
9394 return fallback.to_string();
9395 }
9396 let mut specs = named
9397 .iter()
9398 .map(|(local, source)| {
9399 if local == source {
9400 source.clone()
9401 } else {
9402 format!("{source} as {local}")
9403 }
9404 })
9405 .collect::<Vec<_>>();
9406 specs.sort();
9407 format!("pub use {{{}}};", specs.join(", "))
9408}
9409
9410fn quoted_module_path(statement: &str) -> Option<String> {
9411 let quote = match (statement.find('\''), statement.find('"')) {
9412 (Some(single), Some(double)) if single < double => '\'',
9413 (Some(_), Some(_)) => '"',
9414 (Some(_), None) => '\'',
9415 (None, Some(_)) => '"',
9416 (None, None) => return None,
9417 };
9418 let start = statement.find(quote)? + 1;
9419 let end = statement[start..].find(quote)? + start;
9420 Some(statement[start..end].to_string())
9421}
9422
9423#[derive(Debug, Clone)]
9424struct SourceLessExportRefs {
9425 raw_refs: Vec<RawRef>,
9426 surface_parts: Vec<String>,
9427}
9428
9429fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
9430 let mut raw_refs = Vec::new();
9431 let mut surface_parts = Vec::new();
9432 let mut search_start = 0usize;
9433 let mut ordinal = 0usize;
9434 while let Some(export_offset) = source[search_start..].find("export") {
9435 let start = search_start + export_offset;
9436 let Some(statement_end_offset) = source[start..].find(';') else {
9437 break;
9438 };
9439 let end = start + statement_end_offset + 1;
9440 let statement = &source[start..end];
9441 search_start = end;
9442 if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
9443 continue;
9444 }
9445 let aliases = parse_reexport_names(statement);
9446 if aliases.is_empty() {
9447 continue;
9448 }
9449 let line = source[..start]
9450 .bytes()
9451 .filter(|byte| *byte == b'\n')
9452 .count() as u32
9453 + 1;
9454 for (exported, source_symbol) in aliases {
9455 ordinal += 1;
9456 let ref_id = ref_id(&[
9457 rel_path,
9458 "export_alias",
9459 &start.to_string(),
9460 &end.to_string(),
9461 &exported,
9462 &source_symbol,
9463 &ordinal.to_string(),
9464 ]);
9465 surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
9466 raw_refs.push(RawRef {
9467 ref_id,
9468 caller_node: None,
9469 caller_symbol: None,
9470 caller_file: rel_path.to_string(),
9471 kind: "export_alias".to_string(),
9472 short_name: None,
9473 full_ref: Some(statement.to_string()),
9474 module_path: None,
9475 import_kind: Some("export_alias".to_string()),
9476 local_name: Some(exported),
9477 requested_name: Some(source_symbol),
9478 namespace_alias: None,
9479 wildcard: false,
9480 line,
9481 byte_start: start,
9482 byte_end: end,
9483 dependencies: BTreeSet::new(),
9484 });
9485 }
9486 }
9487 SourceLessExportRefs {
9488 raw_refs,
9489 surface_parts,
9490 }
9491}
9492
9493fn build_dispatch_hints(
9494 rel_path: &str,
9495 data: &FileCallData,
9496 node_by_scoped: &HashMap<String, String>,
9497) -> Vec<DispatchHint> {
9498 let mut hints = Vec::new();
9499 let mut ordinal = 0usize;
9500 for (caller_symbol, call_sites) in &data.calls_by_symbol {
9501 let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
9502 continue;
9503 };
9504 for call_site in call_sites {
9505 if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
9506 continue;
9507 }
9508 ordinal += 1;
9509 hints.push(DispatchHint {
9510 id: ref_id(&[
9511 rel_path,
9512 "dispatch",
9513 caller_symbol,
9514 &call_site.line.to_string(),
9515 &call_site.byte_start.to_string(),
9516 &call_site.byte_end.to_string(),
9517 &ordinal.to_string(),
9518 ]),
9519 method_name: call_site.callee_name.clone(),
9520 caller_node: caller_node.clone(),
9521 file: rel_path.to_string(),
9522 line: call_site.line,
9523 byte_start: call_site.byte_start,
9524 byte_end: call_site.byte_end,
9525 });
9526 }
9527 }
9528 hints
9529}
9530
9531fn surface_fingerprint(
9532 nodes: &mut [NodeRecord],
9533 data: &FileCallData,
9534 reexport_parts: &[String],
9535) -> String {
9536 nodes.sort_by(|left, right| {
9537 (left.file_path.as_str(), left.scoped_name.as_str())
9538 .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
9539 });
9540 let mut parts = Vec::new();
9541 for node in nodes.iter() {
9542 parts.push(format!(
9543 "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
9544 node.scoped_name,
9545 node.name,
9546 node.kind,
9547 node.exported,
9548 node.range.start_line,
9549 node.range.start_col,
9550 node.range.end_line,
9551 node.range.end_col,
9552 node.range_ordinal,
9553 node.signature.as_deref().unwrap_or("")
9554 ));
9555 }
9556 let mut exports = data.exported_symbols.clone();
9557 exports.sort();
9558 for export in exports {
9559 parts.push(format!("export\t{export}"));
9560 }
9561 if let Some(default_export) = &data.default_export_symbol {
9562 parts.push(format!("default\t{default_export}"));
9563 }
9564 let mut imports: Vec<String> = data
9565 .import_block
9566 .imports
9567 .iter()
9568 .map(|import| {
9569 format!(
9570 "import\t{}\t{:?}\t{}",
9571 import.module_path, import.form, import.raw_text
9572 )
9573 })
9574 .collect();
9575 imports.sort();
9576 parts.extend(imports);
9577 parts.extend(reexport_parts.iter().cloned());
9578 hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
9579}
9580
9581fn resolve_ref<I: ResolverIndex>(raw: RawRef, index: &I) -> Result<ResolvedRef> {
9582 if !matches!(raw.kind.as_str(), "call" | "value_ref") {
9583 return Ok(ResolvedRef {
9584 dependencies: raw.dependencies.clone(),
9585 raw,
9586 status: "unresolved".to_string(),
9587 target_node: None,
9588 target_file: None,
9589 target_symbol: None,
9590 edge: None,
9591 });
9592 }
9593
9594 let caller_file = raw.caller_file.clone();
9595 let caller_data =
9596 index
9597 .caller_data(&caller_file)
9598 .ok_or_else(|| CallGraphStoreError::MissingCallerData {
9599 file: caller_file.clone(),
9600 })?;
9601 let full_ref = raw.full_ref.as_deref().unwrap_or_default();
9602 let short_name = raw.short_name.as_deref().unwrap_or_default();
9603 let mut dependencies = raw.dependencies.clone();
9604
9605 let resolved = match index.lang_for(&caller_file) {
9606 Some(LangId::Rust) => {
9607 resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
9608 }
9609 Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
9610 resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
9611 }
9612 _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
9613 };
9614
9615 let Some((status, target_file, target_symbol)) = resolved else {
9616 return Ok(ResolvedRef {
9617 raw,
9618 status: "unresolved".to_string(),
9619 target_node: None,
9620 target_file: None,
9621 target_symbol: None,
9622 dependencies,
9623 edge: None,
9624 });
9625 };
9626
9627 dependencies.insert(target_file.clone());
9628 let target_node = index.node_for_symbol(&target_file, &target_symbol);
9629 if raw.kind == "value_ref"
9630 && !target_node
9631 .as_deref()
9632 .is_some_and(|node_id| index.node_is_callable(&target_file, node_id))
9633 {
9634 return Ok(ResolvedRef {
9635 raw,
9636 status: "unresolved".to_string(),
9637 target_node: None,
9638 target_file: None,
9639 target_symbol: None,
9640 dependencies,
9641 edge: None,
9642 });
9643 }
9644 let source_node = raw.caller_node.clone();
9645 let edge = if let Some(source_node) = source_node {
9646 if target_file == caller_file
9647 && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
9648 {
9649 None
9650 } else {
9651 Some(EdgeRecord {
9652 edge_id: ref_id(&[&raw.ref_id, "edge"]),
9653 source_node,
9654 target_node: target_node.clone(),
9655 target_file: target_file.clone(),
9656 target_symbol: target_symbol.clone(),
9657 kind: raw.kind.clone(),
9658 line: raw.line,
9659 })
9660 }
9661 } else {
9662 None
9663 };
9664
9665 Ok(ResolvedRef {
9666 raw,
9667 status,
9668 target_node,
9669 target_file: Some(target_file),
9670 target_symbol: Some(target_symbol),
9671 dependencies,
9672 edge,
9673 })
9674}
9675
9676fn resolve_js_ts_target<I: ResolverIndex>(
9677 index: &I,
9678 caller_file: &str,
9679 full_ref: &str,
9680 short_name: &str,
9681 caller_data: &FileCallData,
9682) -> Option<(String, String, String)> {
9683 if let Some((namespace, member)) = full_ref.split_once('.') {
9684 for import in &caller_data.import_block.imports {
9685 if import.namespace_import.as_deref() == Some(namespace) {
9686 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
9687 if let Some((file, symbol)) =
9688 resolve_exported_symbol(index, &target_file, member, 0)
9689 {
9690 return Some(("resolved".to_string(), file, symbol));
9691 }
9692 }
9693 }
9694 }
9695 }
9696
9697 for import in &caller_data.import_block.imports {
9698 for spec in &import.names {
9699 if crate::imports::specifier_local_name(spec) == short_name {
9700 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
9701 let requested = crate::imports::specifier_imported_name(spec);
9702 let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
9703 .unwrap_or_else(|| (target_file, requested.to_string()));
9704 return Some(("resolved".to_string(), file, symbol));
9705 }
9706 }
9707 }
9708
9709 if import.default_import.as_deref() == Some(short_name) {
9710 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
9711 let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
9712 .or_else(|| {
9713 index
9714 .default_export(&target_file)
9715 .map(|symbol| (target_file.clone(), symbol))
9716 })
9717 .unwrap_or_else(|| {
9718 let file_name = Path::new(&target_file)
9719 .file_name()
9720 .and_then(|name| name.to_str())
9721 .unwrap_or("unknown")
9722 .to_string();
9723 (target_file, format!("<default:{file_name}>"))
9724 });
9725 return Some(("resolved".to_string(), file, symbol));
9726 }
9727 }
9728 }
9729
9730 for import in &caller_data.import_block.imports {
9731 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
9732 if index.has_export(&target_file, short_name) {
9733 return Some(("resolved".to_string(), target_file, short_name.to_string()));
9734 }
9735 }
9736 }
9737
9738 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
9739}
9740
9741fn resolve_exported_symbol<I: ResolverIndex>(
9742 index: &I,
9743 file: &str,
9744 requested: &str,
9745 depth: usize,
9746) -> Option<(String, String)> {
9747 let mut visited = std::collections::HashMap::new();
9748 resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
9749}
9750
9751fn resolve_exported_symbol_inner<I: ResolverIndex>(
9760 index: &I,
9761 file: &str,
9762 requested: &str,
9763 depth: usize,
9764 visited: &mut std::collections::HashMap<(String, String), usize>,
9765) -> Option<(String, String)> {
9766 if depth > 16 {
9767 return None;
9768 }
9769 if requested != "default" {
9770 if let Some(source_symbol) = index.export_alias(file, requested) {
9771 return Some((file.to_string(), source_symbol));
9772 }
9773 if index.has_export(file, requested) {
9774 return Some((file.to_string(), requested.to_string()));
9775 }
9776 } else if let Some(default) = index.default_export(file) {
9777 return Some((file.to_string(), default));
9778 }
9779
9780 match visited.entry((file.to_string(), requested.to_string())) {
9784 std::collections::hash_map::Entry::Occupied(mut seen) => {
9785 if *seen.get() <= depth {
9786 return None;
9787 }
9788 seen.insert(depth);
9789 }
9790 std::collections::hash_map::Entry::Vacant(slot) => {
9791 slot.insert(depth);
9792 }
9793 }
9794
9795 for reexport in index.reexports_for(file) {
9796 let mut next_requested = requested.to_string();
9797 let matches = if reexport.wildcard {
9798 true
9799 } else if let Some(source_name) = reexport.named.get(requested) {
9800 next_requested = source_name.clone();
9801 true
9802 } else {
9803 false
9804 };
9805 if !matches {
9806 continue;
9807 }
9808 if let Some(target_file) = &reexport.target_file {
9809 if let Some(target) = resolve_exported_symbol_inner(
9810 index,
9811 target_file,
9812 &next_requested,
9813 depth + 1,
9814 visited,
9815 ) {
9816 return Some(target);
9817 }
9818 }
9819 }
9820 None
9821}
9822
9823fn resolve_rust_target<I: ResolverIndex>(
9824 index: &I,
9825 caller_file: &str,
9826 full_ref: &str,
9827 short_name: &str,
9828 caller_data: &FileCallData,
9829 raw: &RawRef,
9830) -> Option<(String, String, String)> {
9831 if full_ref.contains("::") {
9832 if let Some((target_file, target_symbol)) =
9833 rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
9834 {
9835 return Some(("resolved".to_string(), target_file, target_symbol));
9836 }
9837 }
9838
9839 for import in &caller_data.import_block.imports {
9840 if let Some((target_file, target_symbol)) =
9841 rust_target_for_use(index, caller_file, import, short_name)
9842 {
9843 return Some(("resolved".to_string(), target_file, target_symbol));
9844 }
9845 }
9846
9847 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
9848}
9849
9850fn rust_target_for_qualified<I: ResolverIndex>(
9851 index: &I,
9852 caller_file: &str,
9853 full_ref: &str,
9854 short_name: &str,
9855 caller_data: &FileCallData,
9856 raw: &RawRef,
9857) -> Option<(String, String)> {
9858 let mut segments: Vec<&str> = full_ref.split("::").collect();
9859 if segments.len() < 2 {
9860 return None;
9861 }
9862 segments.pop();
9863 let requested_symbol = rust_target_symbol(full_ref, short_name);
9864
9865 for path in rust_module_path_candidates(&segments, caller_data, raw) {
9866 let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
9867 if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
9868 if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
9869 return Some(rust_resolve_reexport_if_symbol_missing(
9870 index,
9871 target_file,
9872 requested_symbol.clone(),
9873 ));
9874 }
9875 }
9876
9877 let module_segments = rust_resolve_segments_with_index(index, caller_file, &path_refs)?;
9878 if let Some(target) =
9879 rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
9880 {
9881 return Some(target);
9882 }
9883 if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
9884 return Some(rust_resolve_reexport_if_symbol_missing(
9885 index,
9886 target_file,
9887 requested_symbol.clone(),
9888 ));
9889 }
9890 }
9891 None
9892}
9893
9894fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
9895 full_ref
9896 .rsplit("::")
9897 .next()
9898 .filter(|name| !name.is_empty())
9899 .unwrap_or(short_name)
9900 .to_string()
9901}
9902
9903fn rust_resolve_reexport_if_symbol_missing<I: ResolverIndex>(
9904 index: &I,
9905 target_file: String,
9906 target_symbol: String,
9907) -> (String, String) {
9908 if index
9909 .node_for_symbol(&target_file, &target_symbol)
9910 .is_some()
9911 {
9912 return (target_file, target_symbol);
9913 }
9914 if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
9915 resolved
9916 } else {
9917 (target_file, target_symbol)
9918 }
9919}
9920
9921fn rust_module_path_candidates(
9922 segments: &[&str],
9923 caller_data: &FileCallData,
9924 raw: &RawRef,
9925) -> Vec<Vec<String>> {
9926 let mut candidates = Vec::new();
9927 if let Some(first) = segments.first().copied() {
9928 for import in &caller_data.import_block.imports {
9929 if !rust_import_is_visible_to_call(import, raw) {
9930 continue;
9931 }
9932 let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
9933 continue;
9934 };
9935 if local_name == first {
9936 path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
9937 rust_push_unique_path_candidate(&mut candidates, path_segments);
9938 }
9939 }
9940 }
9941 rust_push_unique_path_candidate(
9942 &mut candidates,
9943 segments
9944 .iter()
9945 .map(|segment| (*segment).to_string())
9946 .collect(),
9947 );
9948 candidates
9949}
9950
9951fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
9952 if !candidates.iter().any(|existing| existing == &candidate) {
9953 candidates.push(candidate);
9954 }
9955}
9956
9957fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
9958 import.byte_range.start <= raw.byte_start
9959}
9960
9961fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
9962 let path = import.module_path.trim().trim_end_matches(';').trim();
9963 if path.contains("::{") || path.contains('{') || path.contains('*') {
9964 return None;
9965 }
9966 let (path_without_alias, alias) = path
9967 .split_once(" as ")
9968 .map(|(left, right)| (left.trim(), Some(right.trim())))
9969 .unwrap_or((path, None));
9970 let segments = path_without_alias
9971 .split("::")
9972 .map(str::trim)
9973 .filter(|segment| !segment.is_empty())
9974 .collect::<Vec<_>>();
9975 let local_name = alias.or_else(|| segments.last().copied())?.to_string();
9976 if local_name.chars().next().is_some_and(char::is_uppercase) {
9977 return None;
9978 }
9979 Some((
9980 local_name,
9981 segments
9982 .into_iter()
9983 .map(|segment| segment.to_string())
9984 .collect(),
9985 ))
9986}
9987
9988fn rust_inline_scoped_target<I: ResolverIndex>(
9989 index: &I,
9990 caller_file: &str,
9991 module_segments: &[String],
9992 short_name: &str,
9993) -> Option<(String, String)> {
9994 index.inline_scoped_target(caller_file, module_segments, short_name)
9995}
9996
9997fn rust_target_for_use<I: ResolverIndex>(
9998 index: &I,
9999 caller_file: &str,
10000 import: &ImportStatement,
10001 short_name: &str,
10002) -> Option<(String, String)> {
10003 let path = import.module_path.trim().trim_end_matches(';');
10004 if let Some(brace_start) = path.find("::{") {
10005 let prefix = &path[..brace_start];
10006 if import.names.iter().any(|name| name == short_name) {
10007 let prefix_segments: Vec<&str> = prefix.split("::").collect();
10008 let module_segments =
10009 rust_resolve_segments_with_index(index, caller_file, &prefix_segments)?;
10010 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
10011 return Some((file, short_name.to_string()));
10012 }
10013 return None;
10014 }
10015
10016 let (path_without_alias, alias) = path
10017 .split_once(" as ")
10018 .map(|(left, right)| (left.trim(), Some(right.trim())))
10019 .unwrap_or((path, None));
10020 let segments: Vec<&str> = path_without_alias.split("::").collect();
10021 let imported = alias.or_else(|| segments.last().copied())?;
10022 if imported != short_name {
10023 return None;
10024 }
10025 if segments.len() < 2 {
10026 return None;
10027 }
10028 let module_segments =
10029 rust_resolve_segments_with_index(index, caller_file, &segments[..segments.len() - 1])?;
10030 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
10031 Some((file, segments.last().unwrap_or(&short_name).to_string()))
10032}
10033
10034fn rust_workspace_file_for_segments<I: ResolverIndex>(
10035 index: &I,
10036 segments: &[&str],
10037) -> Option<String> {
10038 let crate_name = segments.first().copied()?;
10039 let src_prefix = index.crate_src_prefix(crate_name)?;
10040 let module_segments = segments[1..]
10041 .iter()
10042 .map(|segment| segment.to_string())
10043 .collect::<Vec<_>>();
10044 rust_file_for_src_prefix(index, &src_prefix, &module_segments)
10045}
10046
10047#[cfg(test)]
10048static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
10049 OnceLock::new();
10050
10051#[cfg(test)]
10052fn note_workspace_crate_prefix_build(project_root: &Path) {
10053 let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
10054 .get_or_init(|| Mutex::new(HashMap::new()))
10055 .lock()
10056 .expect("workspace crate prefix build counts mutex poisoned");
10057 *counts.entry(project_root.to_path_buf()).or_default() += 1;
10058}
10059
10060#[cfg(not(test))]
10061fn note_workspace_crate_prefix_build(_project_root: &Path) {}
10062
10063#[cfg(test)]
10064fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
10065 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
10066 .get_or_init(|| Mutex::new(HashMap::new()))
10067 .lock()
10068 .expect("workspace crate prefix build counts mutex poisoned")
10069 .remove(project_root);
10070}
10071
10072#[cfg(test)]
10073fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
10074 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
10075 .get_or_init(|| Mutex::new(HashMap::new()))
10076 .lock()
10077 .expect("workspace crate prefix build counts mutex poisoned")
10078 .get(project_root)
10079 .copied()
10080 .unwrap_or(0)
10081}
10082
10083fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
10088 note_workspace_crate_prefix_build(project_root);
10089 let mut prefixes = HashMap::new();
10090 let mut stack = vec![project_root.to_path_buf()];
10091 while let Some(dir) = stack.pop() {
10092 let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
10093 if matches!(name, "target" | "node_modules" | ".git") {
10094 continue;
10095 }
10096 let manifest = dir.join("Cargo.toml");
10097 if manifest.is_file() {
10098 let crate_names = rust_manifest_crate_names(&manifest);
10099 if !crate_names.is_empty() {
10100 let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
10101 for crate_name in crate_names {
10102 prefixes
10103 .entry(crate_name)
10104 .or_insert_with(|| src_prefix.clone());
10105 }
10106 }
10107 }
10108 let Ok(entries) = std::fs::read_dir(&dir) else {
10109 continue;
10110 };
10111 for entry in entries.flatten() {
10112 let path = entry.path();
10113 if path.is_dir() {
10114 stack.push(path);
10115 }
10116 }
10117 }
10118 prefixes
10119}
10120
10121fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
10125 let Ok(source) = std::fs::read_to_string(manifest) else {
10126 return Vec::new();
10127 };
10128 let mut in_lib = false;
10129 let mut package_name = None;
10130 let mut lib_name = None;
10131 for line in source.lines() {
10132 let trimmed = line.trim();
10133 if trimmed.starts_with('[') {
10134 in_lib = trimmed == "[lib]";
10135 continue;
10136 }
10137 let Some((key, value)) = trimmed.split_once('=') else {
10138 continue;
10139 };
10140 let key = key.trim();
10141 let value = value.trim().trim_matches('"');
10142 if in_lib && key == "name" {
10143 lib_name = Some(value.to_string());
10144 } else if !in_lib && key == "name" && package_name.is_none() {
10145 package_name = Some(value.to_string());
10146 }
10147 }
10148 let mut names = Vec::new();
10149 if let Some(lib) = lib_name {
10150 names.push(lib);
10151 }
10152 if let Some(package) = package_name {
10153 let normalized = package.replace('-', "_");
10154 if !names.contains(&normalized) {
10155 names.push(normalized);
10156 }
10157 }
10158 names
10159}
10160
10161fn rust_resolve_segments_with_index<I: ResolverIndex>(
10162 index: &I,
10163 caller_file: &str,
10164 segments: &[&str],
10165) -> Option<Vec<String>> {
10166 let caller_segments = rust_registered_module_segments(index, caller_file)
10167 .unwrap_or_else(|| rust_module_segments_for_rel(caller_file));
10168 rust_resolve_segments_from(caller_segments, segments)
10169}
10170
10171fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
10172 rust_resolve_segments_from(rust_module_segments_for_rel(caller_file), segments)
10173}
10174
10175fn rust_resolve_segments_from(
10176 caller_segments: Vec<String>,
10177 segments: &[&str],
10178) -> Option<Vec<String>> {
10179 if segments.is_empty() {
10180 return Some(Vec::new());
10181 }
10182 match segments[0] {
10183 "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
10184 "self" => {
10185 let mut resolved = caller_segments;
10186 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
10187 Some(resolved)
10188 }
10189 "super" => {
10190 let mut resolved = caller_segments;
10191 resolved.pop();
10192 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
10193 Some(resolved)
10194 }
10195 _ => {
10196 let mut resolved = caller_segments;
10197 resolved.pop();
10198 resolved.extend(segments.iter().map(|item| item.to_string()));
10199 Some(resolved)
10200 }
10201 }
10202}
10203
10204fn rust_registered_module_segments<I: ResolverIndex>(
10205 index: &I,
10206 caller_file: &str,
10207) -> Option<Vec<String>> {
10208 let mut current = caller_file.to_string();
10209 let mut segments = Vec::new();
10210 let mut seen = HashSet::new();
10211 while seen.insert(current.clone()) {
10212 let Some((parent, module)) = index.module_parent(¤t) else {
10213 break;
10214 };
10215 segments.push(module);
10216 current = parent;
10217 }
10218 if segments.is_empty() {
10219 None
10220 } else {
10221 segments.reverse();
10222 Some(segments)
10223 }
10224}
10225
10226fn rust_file_for_segments<I: ResolverIndex>(
10227 index: &I,
10228 caller_file: &str,
10229 segments: &[String],
10230) -> Option<String> {
10231 let src_prefix = rust_src_prefix(caller_file);
10232 if let Some(target) = rust_file_from_module_declarations(index, &src_prefix, segments) {
10233 return Some(target);
10234 }
10235 rust_file_for_src_prefix(index, &src_prefix, segments)
10236}
10237
10238fn rust_file_from_module_declarations<I: ResolverIndex>(
10239 index: &I,
10240 src_prefix: &str,
10241 segments: &[String],
10242) -> Option<String> {
10243 let mut current = [
10244 format!("{src_prefix}/lib.rs"),
10245 format!("{src_prefix}/main.rs"),
10246 ]
10247 .into_iter()
10248 .find(|candidate| index.contains_file(candidate))?;
10249 for segment in segments {
10250 current = index.module_target(¤t, segment)?;
10251 }
10252 Some(current)
10253}
10254
10255fn rust_file_for_src_prefix<I: ResolverIndex>(
10256 index: &I,
10257 src_prefix: &str,
10258 segments: &[String],
10259) -> Option<String> {
10260 let candidate = if segments.is_empty() {
10261 [src_prefix, "lib.rs"].join("/")
10262 } else {
10263 format!("{}/{}.rs", src_prefix, segments.join("/"))
10264 };
10265 if index.contains_file(&candidate) {
10266 return Some(candidate);
10267 }
10268 if !segments.is_empty() {
10269 let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
10270 if index.contains_file(&mod_candidate) {
10271 return Some(mod_candidate);
10272 }
10273 }
10274 None
10275}
10276
10277fn rust_src_prefix(rel_path: &str) -> String {
10278 rel_path
10279 .split_once("/src/")
10280 .map(|(prefix, _)| format!("{prefix}/src"))
10281 .unwrap_or_else(|| "src".to_string())
10282}
10283
10284fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
10285 let after_src = rel_path
10286 .split_once("/src/")
10287 .map(|(_, rest)| rest)
10288 .or_else(|| rel_path.strip_prefix("src/"))
10289 .unwrap_or(rel_path);
10290 if matches!(after_src, "lib.rs" | "main.rs") {
10291 return Vec::new();
10292 }
10293 if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
10294 return prefix.split('/').map(|item| item.to_string()).collect();
10295 }
10296 after_src
10297 .strip_suffix(".rs")
10298 .unwrap_or(after_src)
10299 .split('/')
10300 .map(|item| item.to_string())
10301 .collect()
10302}
10303
10304fn resolve_local_target<I: ResolverIndex>(
10305 _index: &I,
10306 caller_file: &str,
10307 full_ref: &str,
10308 short_name: &str,
10309 caller_data: &FileCallData,
10310) -> Option<(String, String, String)> {
10311 if !callgraph::is_bare_callee(full_ref, short_name) {
10312 return None;
10313 }
10314 callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
10315 .ok()
10316 .map(|symbol| {
10317 (
10318 "resolved_local".to_string(),
10319 caller_file.to_string(),
10320 symbol,
10321 )
10322 })
10323}
10324
10325impl<'a> ProjectIndex<'a> {
10326 fn from_parts(
10327 project_root: &Path,
10328 files: HashMap<String, DbFileIndex>,
10329 caller_data: HashMap<String, &'a FileCallData>,
10330 workspace_crate_prefixes: WorkspaceCratePrefixCache,
10331 ) -> Self {
10332 Self {
10333 project_root: project_root.to_path_buf(),
10334 files,
10335 caller_data,
10336 workspace_crate_prefixes,
10337 }
10338 }
10339
10340 fn from_db_and_callers(
10341 tx: &Transaction<'_>,
10342 project_root: &Path,
10343 caller_extracts: &'a HashMap<String, FileExtract>,
10344 workspace_crate_prefixes: WorkspaceCratePrefixCache,
10345 ) -> Result<Self> {
10346 let module_resolution_memo = callgraph::ModuleResolutionMemo::default();
10349 let mut files = load_db_file_indexes(tx, project_root, &module_resolution_memo)?;
10350 let mut caller_data = HashMap::new();
10351 for (rel_path, extract) in caller_extracts {
10352 files.insert(
10353 rel_path.clone(),
10354 DbFileIndex::from_extract(project_root, extract),
10355 );
10356 caller_data.insert(rel_path.clone(), &extract.data);
10357 }
10358 Ok(Self::from_parts(
10359 project_root,
10360 files,
10361 caller_data,
10362 workspace_crate_prefixes,
10363 ))
10364 }
10365
10366 fn lang_for(&self, rel_path: &str) -> Option<LangId> {
10367 self.files.get(rel_path).and_then(|file| file.lang)
10368 }
10369
10370 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
10371 self.files
10372 .get(caller_file)
10373 .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
10374 }
10375
10376 fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
10377 self.files
10378 .get(rel_path)
10379 .map(|file| file.reexports.as_slice())
10380 .unwrap_or(&[])
10381 }
10382
10383 fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
10384 self.files.get(rel_path).and_then(|file| {
10385 file.node_by_scoped
10386 .get(symbol)
10387 .cloned()
10388 .or_else(|| file.node_by_bare.get(symbol).cloned())
10389 })
10390 }
10391
10392 fn node_is_callable(&self, rel_path: &str, node_id: &str) -> bool {
10393 self.files
10394 .get(rel_path)
10395 .and_then(|file| file.node_kind_by_id.get(node_id))
10396 .is_some_and(|kind| matches!(kind.as_str(), "function" | "method"))
10397 }
10398}
10399
10400impl DbFileIndex {
10401 fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
10402 let mut node_by_scoped = HashMap::new();
10403 let mut node_by_bare = HashMap::new();
10404 for node in &extract.nodes {
10405 node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
10406 node_by_bare
10407 .entry(node.name.clone())
10408 .or_insert(node.id.clone());
10409 }
10410 let node_kind_by_id = extract
10411 .nodes
10412 .iter()
10413 .map(|node| (node.id.clone(), node.kind.clone()))
10414 .collect();
10415 let mut export_aliases = HashMap::new();
10416 for raw_ref in &extract.raw_refs {
10417 if raw_ref.kind == "export_alias" {
10418 if let (Some(exported), Some(source_symbol)) =
10419 (&raw_ref.local_name, &raw_ref.requested_name)
10420 {
10421 export_aliases.insert(exported.clone(), source_symbol.clone());
10422 }
10423 }
10424 }
10425 let mut module_targets = HashMap::new();
10426 let mut declared_module_targets = HashMap::new();
10427 let mut reexports = Vec::new();
10428 for raw_ref in &extract.raw_refs {
10429 if !matches!(raw_ref.kind.as_str(), "import" | "reexport" | "module") {
10430 continue;
10431 }
10432 let Some(module_path) = &raw_ref.module_path else {
10433 continue;
10434 };
10435 let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
10436 module_targets
10437 .entry(module_path.clone())
10438 .or_insert_with(|| target_file.clone());
10439 if raw_ref.kind == "module" {
10440 declared_module_targets
10441 .entry(module_path.clone())
10442 .or_insert_with(|| target_file.clone());
10443 }
10444 if raw_ref.kind == "reexport" {
10445 reexports.push(reexport_index_from_raw(raw_ref, target_file));
10446 }
10447 }
10448 Self {
10449 lang: Some(extract.lang),
10450 exports: extract.data.exported_symbols.iter().cloned().collect(),
10451 default_export: extract.data.default_export_symbol.clone(),
10452 export_aliases,
10453 node_by_scoped,
10454 node_by_bare,
10455 node_kind_by_id,
10456 module_targets,
10457 declared_module_targets,
10458 reexports,
10459 }
10460 }
10461}
10462
10463fn load_db_file_indexes(
10464 tx: &Transaction<'_>,
10465 project_root: &Path,
10466 module_resolution_memo: &callgraph::ModuleResolutionMemo,
10467) -> Result<HashMap<String, DbFileIndex>> {
10468 let mut files = HashMap::new();
10469 let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
10470 let rows = stmt.query_map([], |row| {
10471 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
10472 })?;
10473 for row in rows {
10474 let (rel_path, lang) = row?;
10475 files.insert(
10476 rel_path.clone(),
10477 DbFileIndex {
10478 lang: lang_from_label(&lang),
10479 exports: HashSet::new(),
10480 default_export: None,
10481 export_aliases: HashMap::new(),
10482 node_by_scoped: HashMap::new(),
10483 node_by_bare: HashMap::new(),
10484 node_kind_by_id: HashMap::new(),
10485 module_targets: HashMap::new(),
10486 declared_module_targets: HashMap::new(),
10487 reexports: Vec::new(),
10488 },
10489 );
10490 }
10491
10492 let mut node_stmt = tx.prepare(
10493 "SELECT file_path, id, name, scoped_name, kind, exported, is_default_export FROM nodes",
10494 )?;
10495 let nodes = node_stmt.query_map([], |row| {
10496 Ok((
10497 row.get::<_, String>(0)?,
10498 row.get::<_, String>(1)?,
10499 row.get::<_, String>(2)?,
10500 row.get::<_, String>(3)?,
10501 row.get::<_, String>(4)?,
10502 row.get::<_, i64>(5)? != 0,
10503 row.get::<_, i64>(6)? != 0,
10504 ))
10505 })?;
10506 for row in nodes {
10507 let (file_path, id, name, scoped_name, kind, exported, is_default_export) = row?;
10508 let file = files
10509 .entry(file_path.clone())
10510 .or_insert_with(|| DbFileIndex {
10511 lang: None,
10512 exports: HashSet::new(),
10513 default_export: None,
10514 export_aliases: HashMap::new(),
10515 node_by_scoped: HashMap::new(),
10516 node_by_bare: HashMap::new(),
10517 node_kind_by_id: HashMap::new(),
10518 module_targets: HashMap::new(),
10519 declared_module_targets: HashMap::new(),
10520 reexports: Vec::new(),
10521 });
10522 if exported {
10523 file.exports.insert(name.clone());
10524 file.exports.insert(scoped_name.clone());
10525 }
10526 if is_default_export {
10527 file.default_export = Some(scoped_name.clone());
10528 }
10529 file.node_by_scoped.insert(scoped_name, id.clone());
10530 file.node_by_bare.entry(name).or_insert(id.clone());
10531 file.node_kind_by_id.insert(id, kind);
10532 }
10533 let file_keys: HashSet<String> = files.keys().cloned().collect();
10534 let dependencies_by_file = load_file_dependencies_index(tx)?;
10538 let mut ref_stmt = tx.prepare(
10539 "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
10540 FROM refs WHERE kind IN ('module', 'reexport', 'export_alias')",
10541 )?;
10542 let ref_rows = ref_stmt.query_map([], |row| {
10543 Ok((
10544 row.get::<_, String>(0)?,
10545 row.get::<_, String>(1)?,
10546 row.get::<_, String>(2)?,
10547 row.get::<_, Option<String>>(3)?,
10548 row.get::<_, Option<String>>(4)?,
10549 row.get::<_, i64>(5)? != 0,
10550 row.get::<_, Option<String>>(6)?,
10551 row.get::<_, Option<String>>(7)?,
10552 ))
10553 })?;
10554 for row in ref_rows {
10555 let (
10556 ref_id,
10557 caller_file,
10558 kind,
10559 module_path,
10560 full_ref,
10561 wildcard,
10562 local_name,
10563 requested_name,
10564 ) = row?;
10565 if kind == "export_alias" {
10566 if let (Some(exported), Some(source_symbol), Some(file)) =
10567 (local_name, requested_name, files.get_mut(&caller_file))
10568 {
10569 file.export_aliases.insert(exported, source_symbol);
10570 }
10571 continue;
10572 }
10573 let Some(module_path) = module_path else {
10574 continue;
10575 };
10576 let file_deps = dependencies_by_file
10577 .get(&caller_file)
10578 .cloned()
10579 .unwrap_or_default();
10580 let deps = stored_dependencies_for_module(
10581 project_root,
10582 &caller_file,
10583 &module_path,
10584 &file_deps,
10585 &file_keys,
10586 );
10587 let target_file = if kind == "module" {
10588 rust_declared_module_target(
10589 project_root,
10590 &caller_file,
10591 &module_path,
10592 module_resolution_memo,
10593 )
10594 } else {
10595 deps.iter()
10596 .find(|dep| file_keys.contains(*dep))
10597 .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))))
10598 };
10599 if let Some(file) = files.get_mut(&caller_file) {
10600 file.module_targets
10601 .entry(module_path.clone())
10602 .or_insert_with(|| target_file.clone());
10603 if kind == "module" {
10604 file.declared_module_targets
10605 .entry(module_path.clone())
10606 .or_insert_with(|| target_file.clone());
10607 }
10608 if kind == "reexport" {
10609 let raw = RawRef {
10610 ref_id,
10611 caller_node: None,
10612 caller_symbol: None,
10613 caller_file,
10614 kind,
10615 short_name: None,
10616 full_ref,
10617 module_path: Some(module_path),
10618 import_kind: Some("reexport".to_string()),
10619 local_name: None,
10620 requested_name: None,
10621 namespace_alias: None,
10622 wildcard,
10623 line: 0,
10624 byte_start: 0,
10625 byte_end: 0,
10626 dependencies: deps,
10627 };
10628 file.reexports
10629 .push(reexport_index_from_raw(&raw, target_file));
10630 }
10631 }
10632 }
10633
10634 Ok(files)
10635}
10636
10637fn stored_dependencies_for_module(
10638 project_root: &Path,
10639 caller_file: &str,
10640 module_path: &str,
10641 caller_dependencies: &BTreeSet<String>,
10642 indexed_files: &HashSet<String>,
10643) -> BTreeSet<String> {
10644 let caller_path = project_root.join(caller_file);
10645 let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
10646 if module_path.starts_with('.') {
10647 let caller_dir = caller_path.parent().unwrap_or(project_root);
10648 for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
10649 let normalized = if candidate.is_file() {
10650 canonicalize_path(&candidate)
10651 } else {
10652 candidate
10653 };
10654 candidates.insert(relative_path(project_root, &normalized));
10655 }
10656 }
10657 let exact = candidates
10658 .intersection(caller_dependencies)
10659 .filter(|dependency| indexed_files.contains(*dependency))
10660 .cloned()
10661 .collect::<BTreeSet<_>>();
10662 if !exact.is_empty() || module_path.starts_with('.') {
10663 return exact;
10664 }
10665
10666 let module_path = rust_module_path_without_alias_or_use_list(module_path)
10667 .trim_matches(|character| matches!(character, '\'' | '"'));
10668 let package_name = module_path
10669 .split('/')
10670 .next_back()
10671 .unwrap_or(module_path)
10672 .replace('_', "-");
10673 let matched = caller_dependencies
10674 .iter()
10675 .filter(|dependency| indexed_files.contains(*dependency))
10676 .filter(|dependency| {
10677 dependency.as_str() == module_path
10678 || dependency.ends_with(&format!("/{module_path}"))
10679 || Path::new(dependency).components().any(|component| {
10680 component.as_os_str().to_string_lossy().replace('_', "-") == package_name
10681 })
10682 })
10683 .cloned()
10684 .collect::<BTreeSet<_>>();
10685 if matched.len() == 1 {
10686 matched
10687 } else {
10688 BTreeSet::new()
10689 }
10690}
10691
10692fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
10693 let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
10694 let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
10695 let rows = stmt.query_map([], |row| {
10696 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
10697 })?;
10698 for row in rows {
10699 let (file_path, dependency) = row?;
10700 by_file.entry(file_path).or_default().insert(dependency);
10701 }
10702 Ok(by_file)
10703}
10704
10705struct ColdBuildInsertStatements<'stmt> {
10706 file: Statement<'stmt>,
10707 node: Statement<'stmt>,
10708 file_dependency: Statement<'stmt>,
10709 dispatch_hint: Statement<'stmt>,
10710 backend_state: Statement<'stmt>,
10711 reference: Statement<'stmt>,
10712 staging_ref_context: Statement<'stmt>,
10713 edge: Statement<'stmt>,
10714}
10715
10716impl<'stmt> ColdBuildInsertStatements<'stmt> {
10717 fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
10718 Ok(Self {
10719 file: tx.prepare(
10720 "INSERT OR REPLACE INTO files(
10721 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
10722 is_public_api, surface_fingerprint, indexed_at
10723 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
10724 )?,
10725 node: tx.prepare(
10726 "INSERT OR REPLACE INTO nodes(
10727 id, file_path, name, scoped_name, kind, start_line, start_col,
10728 end_line, end_col, range_ordinal, signature, exported,
10729 is_default_export, is_type_like, is_callgraph_entry_point, provenance
10730 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
10731 )?,
10732 file_dependency: tx.prepare(
10733 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
10734 )?,
10735 dispatch_hint: tx.prepare(
10736 "INSERT OR REPLACE INTO dispatch_hints(
10737 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
10738 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
10739 )?,
10740 backend_state: tx.prepare(
10741 "INSERT OR REPLACE INTO backend_file_state(
10742 backend, workspace_root, file_path, content_hash, status, updated_at
10743 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
10744 )?,
10745 reference: tx.prepare(
10746 "INSERT OR REPLACE INTO refs(
10747 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
10748 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
10749 byte_start, byte_end, status, target_node, target_file, target_symbol,
10750 provenance
10751 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
10752 )?,
10753 staging_ref_context: tx.prepare(
10754 "INSERT OR REPLACE INTO staging_ref_context(ref_id, caller_symbol) VALUES(?1, ?2)",
10755 )?,
10756 edge: tx.prepare(
10757 "INSERT OR REPLACE INTO edges(
10758 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
10759 kind, line, provenance
10760 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
10761 )?,
10762 })
10763 }
10764}
10765
10766fn insert_file_extract_prepared(
10767 statements: &mut ColdBuildInsertStatements<'_>,
10768 workspace_root: &str,
10769 extract: &FileExtract,
10770) -> Result<()> {
10771 statements.file.execute(params![
10772 extract.rel_path,
10773 hash_to_hex(extract.freshness.content_hash),
10774 system_time_to_ns(extract.freshness.mtime),
10775 extract.freshness.size as i64,
10776 lang_label(extract.lang),
10777 extract.surface_fingerprint,
10778 unix_seconds_now(),
10779 ])?;
10780 for node in &extract.nodes {
10781 statements.node.execute(params![
10782 node.id,
10783 node.file_path,
10784 node.name,
10785 node.scoped_name,
10786 node.kind,
10787 node.range.start_line as i64,
10788 node.range.start_col as i64,
10789 node.range.end_line as i64,
10790 node.range.end_col as i64,
10791 node.range_ordinal as i64,
10792 node.signature,
10793 bool_int(node.exported),
10794 bool_int(node.is_default_export),
10795 bool_int(node.is_type_like),
10796 bool_int(node.is_callgraph_entry_point),
10797 PROVENANCE_TREESITTER,
10798 ])?;
10799 }
10800
10801 let mut dependencies = BTreeSet::new();
10802 for raw_ref in &extract.raw_refs {
10803 dependencies.extend(raw_ref.dependencies.iter().cloned());
10804 }
10805 for dep_file in &dependencies {
10806 statements
10807 .file_dependency
10808 .execute(params![extract.rel_path, dep_file])?;
10809 }
10810
10811 for hint in &extract.dispatch_hints {
10812 statements.dispatch_hint.execute(params![
10813 hint.id,
10814 hint.method_name,
10815 hint.caller_node,
10816 hint.file,
10817 hint.line as i64,
10818 hint.byte_start as i64,
10819 hint.byte_end as i64,
10820 PROVENANCE_TREESITTER,
10821 ])?;
10822 }
10823 insert_backend_state_prepared(
10824 &mut statements.backend_state,
10825 workspace_root,
10826 &extract.rel_path,
10827 Some(&extract.freshness.content_hash),
10828 "fresh",
10829 )?;
10830 Ok(())
10831}
10832
10833fn insert_backend_state_prepared(
10834 stmt: &mut Statement<'_>,
10835 workspace_root: &str,
10836 rel_path: &str,
10837 content_hash: Option<&blake3::Hash>,
10838 status: &str,
10839) -> Result<()> {
10840 let hash = content_hash
10841 .map(|hash| hash_to_hex(*hash))
10842 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
10843 stmt.execute(params![
10844 BACKEND_TREESITTER,
10845 workspace_root,
10846 rel_path,
10847 hash,
10848 status,
10849 unix_seconds_now(),
10850 ])?;
10851 Ok(())
10852}
10853
10854fn insert_staged_ref_prepared(
10855 statements: &mut ColdBuildInsertStatements<'_>,
10856 raw: &RawRef,
10857) -> Result<()> {
10858 statements.reference.execute(params![
10859 raw.ref_id,
10860 raw.caller_node,
10861 raw.caller_file,
10862 raw.kind,
10863 raw.short_name,
10864 raw.full_ref,
10865 raw.module_path,
10866 raw.import_kind,
10867 raw.local_name,
10868 raw.requested_name,
10869 raw.namespace_alias,
10870 bool_int(raw.wildcard),
10871 raw.line as i64,
10872 raw.byte_start as i64,
10873 raw.byte_end as i64,
10874 "staged",
10875 Option::<String>::None,
10876 Option::<String>::None,
10877 Option::<String>::None,
10878 ref_provenance(raw),
10879 ])?;
10880 statements
10881 .staging_ref_context
10882 .execute(params![raw.ref_id, raw.caller_symbol])?;
10883 Ok(())
10884}
10885
10886fn insert_resolved_ref_prepared(
10887 statements: &mut ColdBuildInsertStatements<'_>,
10888 resolved: &ResolvedRef,
10889) -> Result<()> {
10890 let raw = &resolved.raw;
10891 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
10892 statements.reference.execute(params![
10893 raw.ref_id,
10894 raw.caller_node,
10895 raw.caller_file,
10896 raw.kind,
10897 raw.short_name,
10898 raw.full_ref,
10899 raw.module_path,
10900 raw.import_kind,
10901 raw.local_name,
10902 raw.requested_name,
10903 raw.namespace_alias,
10904 bool_int(raw.wildcard),
10905 raw.line as i64,
10906 raw.byte_start as i64,
10907 raw.byte_end as i64,
10908 resolved.status,
10909 resolved.target_node,
10910 resolved.target_file,
10911 resolved.target_symbol,
10912 ref_provenance(raw),
10913 ])?;
10914 if let Some(edge) = &resolved.edge {
10915 statements.edge.execute(params![
10916 edge.edge_id,
10917 raw.ref_id,
10918 edge.source_node,
10919 edge.target_node,
10920 edge.target_file,
10921 edge.target_symbol,
10922 edge.kind,
10923 edge.line as i64,
10924 ref_provenance(raw),
10925 ])?;
10926 }
10927 Ok(())
10928}
10929
10930#[cfg(test)]
10931fn insert_file_extract(
10932 tx: &Transaction<'_>,
10933 project_root: &Path,
10934 extract: &FileExtract,
10935) -> Result<()> {
10936 tx.execute(
10937 "INSERT OR REPLACE INTO files(
10938 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
10939 is_public_api, surface_fingerprint, indexed_at
10940 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
10941 params![
10942 extract.rel_path,
10943 hash_to_hex(extract.freshness.content_hash),
10944 system_time_to_ns(extract.freshness.mtime),
10945 extract.freshness.size as i64,
10946 lang_label(extract.lang),
10947 extract.surface_fingerprint,
10948 unix_seconds_now(),
10949 ],
10950 )?;
10951 for node in &extract.nodes {
10952 tx.execute(
10953 "INSERT OR REPLACE INTO nodes(
10954 id, file_path, name, scoped_name, kind, start_line, start_col,
10955 end_line, end_col, range_ordinal, signature, exported,
10956 is_default_export, is_type_like, is_callgraph_entry_point, provenance
10957 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
10958 params![
10959 node.id,
10960 node.file_path,
10961 node.name,
10962 node.scoped_name,
10963 node.kind,
10964 node.range.start_line as i64,
10965 node.range.start_col as i64,
10966 node.range.end_line as i64,
10967 node.range.end_col as i64,
10968 node.range_ordinal as i64,
10969 node.signature,
10970 bool_int(node.exported),
10971 bool_int(node.is_default_export),
10972 bool_int(node.is_type_like),
10973 bool_int(node.is_callgraph_entry_point),
10974 PROVENANCE_TREESITTER,
10975 ],
10976 )?;
10977 }
10978 let mut dependencies = BTreeSet::new();
10979 for raw_ref in &extract.raw_refs {
10980 dependencies.extend(raw_ref.dependencies.iter().cloned());
10981 }
10982 insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
10983
10984 for hint in &extract.dispatch_hints {
10985 tx.execute(
10986 "INSERT OR REPLACE INTO dispatch_hints(
10987 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
10988 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
10989 params![
10990 hint.id,
10991 hint.method_name,
10992 hint.caller_node,
10993 hint.file,
10994 hint.line as i64,
10995 hint.byte_start as i64,
10996 hint.byte_end as i64,
10997 PROVENANCE_TREESITTER,
10998 ],
10999 )?;
11000 }
11001 mark_backend_state(
11002 tx,
11003 project_root,
11004 &extract.rel_path,
11005 Some(&extract.freshness.content_hash),
11006 "fresh",
11007 )?;
11008 Ok(())
11009}
11010
11011#[cfg(test)]
11012fn insert_file_dependencies(
11013 tx: &Transaction<'_>,
11014 file_path: &str,
11015 dependencies: &BTreeSet<String>,
11016) -> Result<()> {
11017 for dep_file in dependencies {
11018 tx.execute(
11019 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
11020 params![file_path, dep_file],
11021 )?;
11022 }
11023 Ok(())
11024}
11025
11026fn ref_provenance(raw: &RawRef) -> &'static str {
11027 if raw.kind == "value_ref" {
11028 PROVENANCE_VALUE_REF
11029 } else {
11030 PROVENANCE_TREESITTER
11031 }
11032}
11033
11034#[cfg(test)]
11035fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
11036 let raw = &resolved.raw;
11037 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
11038 tx.execute(
11039 "INSERT OR REPLACE INTO refs(
11040 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
11041 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
11042 byte_start, byte_end, status, target_node, target_file, target_symbol,
11043 provenance
11044 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
11045 params![
11046 raw.ref_id,
11047 raw.caller_node,
11048 raw.caller_file,
11049 raw.kind,
11050 raw.short_name,
11051 raw.full_ref,
11052 raw.module_path,
11053 raw.import_kind,
11054 raw.local_name,
11055 raw.requested_name,
11056 raw.namespace_alias,
11057 bool_int(raw.wildcard),
11058 raw.line as i64,
11059 raw.byte_start as i64,
11060 raw.byte_end as i64,
11061 resolved.status,
11062 resolved.target_node,
11063 resolved.target_file,
11064 resolved.target_symbol,
11065 ref_provenance(raw),
11066 ],
11067 )?;
11068 if let Some(edge) = &resolved.edge {
11069 tx.execute(
11070 "INSERT OR REPLACE INTO edges(
11071 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
11072 kind, line, provenance
11073 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
11074 params![
11075 edge.edge_id,
11076 raw.ref_id,
11077 edge.source_node,
11078 edge.target_node,
11079 edge.target_file,
11080 edge.target_symbol,
11081 edge.kind,
11082 edge.line as i64,
11083 ref_provenance(raw),
11084 ],
11085 )?;
11086 }
11087 Ok(())
11088}
11089
11090fn insert_method_dispatch_edges(
11091 tx: &Transaction<'_>,
11092 project_root: &Path,
11093 caller_files: Option<&BTreeSet<String>>,
11094) -> Result<usize> {
11095 let references = load_name_match_refs(tx, caller_files)?;
11096 if references.is_empty() {
11097 return Ok(0);
11098 }
11099
11100 let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
11101 let mut source_cache: DispatchSourceCache = HashMap::new();
11102 let mut inserted = 0usize;
11103 for reference in references {
11104 let key = (reference.method_name.clone(), reference.lang.clone());
11105 let candidates = match candidates_by_name.entry(key) {
11106 Entry::Occupied(entry) => entry.into_mut(),
11107 Entry::Vacant(entry) => {
11108 let candidates =
11109 load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
11110 entry.insert(candidates)
11111 }
11112 };
11113
11114 match infer_receiver_type_state(project_root, &reference, &mut source_cache) {
11115 ReceiverTypeInference::Known(receiver_type) => {
11116 let Some(candidate) =
11117 select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
11118 else {
11119 continue;
11120 };
11121 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
11122 inserted += 1;
11123 continue;
11124 }
11125 ReceiverTypeInference::RustDirectSelfField {
11126 receiver_type,
11127 declaration_file,
11128 module_scope,
11129 } => {
11130 let Some(candidate) = select_rust_direct_self_field_candidate(
11131 project_root,
11132 &reference,
11133 candidates.as_slice(),
11134 &receiver_type,
11135 &declaration_file,
11136 &module_scope,
11137 &mut source_cache,
11138 ) else {
11139 continue;
11140 };
11141 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
11142 inserted += 1;
11143 continue;
11144 }
11145 ReceiverTypeInference::KnownButUnresolved => continue,
11146 ReceiverTypeInference::Unknown => {}
11147 }
11148
11149 if method_name_match_denylisted(&reference.method_name) {
11150 continue;
11151 }
11152
11153 let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
11154 continue;
11155 };
11156 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
11157 inserted += 1;
11158 }
11159 Ok(inserted)
11160}
11161
11162fn insert_method_dispatch_edges_chunked(
11163 tx: &Transaction<'_>,
11164 project_root: &Path,
11165 chunk_size: usize,
11166) -> Result<usize> {
11167 let total_files = query_count(
11168 tx,
11169 "SELECT COUNT(*) FROM (SELECT DISTINCT caller_file FROM refs)",
11170 )? as usize;
11171 let mut completed_files = 0usize;
11172 ensure_cold_build_current("method-dispatch", completed_files, total_files)?;
11173 let mut inserted = 0usize;
11174 let mut after_file = String::new();
11175 loop {
11176 let caller_files = {
11177 let mut statement = tx.prepare(
11178 "SELECT DISTINCT caller_file
11179 FROM refs
11180 WHERE caller_file > ?1
11181 ORDER BY caller_file
11182 LIMIT ?2",
11183 )?;
11184 let rows = statement
11185 .query_map(params![after_file, chunk_size.max(1) as i64], |row| {
11186 row.get::<_, String>(0)
11187 })?;
11188 rows.collect::<std::result::Result<BTreeSet<_>, _>>()?
11189 };
11190 let Some(last_file) = caller_files.last().cloned() else {
11191 break;
11192 };
11193 inserted += insert_method_dispatch_edges(tx, project_root, Some(&caller_files))?;
11194 after_file = last_file;
11195 completed_files = completed_files
11196 .saturating_add(caller_files.len())
11197 .min(total_files);
11198 ensure_cold_build_current("method-dispatch", completed_files, total_files)?;
11199 }
11200 ensure_cold_build_current("method-dispatch", completed_files, total_files)?;
11201 Ok(inserted)
11202}
11203
11204fn insert_method_dispatch_edge(
11205 tx: &Transaction<'_>,
11206 reference: &NameMatchRef,
11207 candidate: &NameMatchCandidate,
11208 provenance: &str,
11209) -> Result<()> {
11210 tx.execute(
11211 "INSERT OR REPLACE INTO edges(
11212 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
11213 kind, line, provenance
11214 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
11215 params![
11216 ref_id(&[&reference.ref_id, provenance, "edge"]),
11217 &reference.ref_id,
11218 &reference.caller_node,
11219 &candidate.node_id,
11220 &candidate.file_path,
11221 &candidate.scoped_name,
11222 reference.line as i64,
11223 provenance,
11224 ],
11225 )?;
11226 Ok(())
11227}
11228
11229fn delete_method_dispatch_edges_for_callers(
11230 tx: &Transaction<'_>,
11231 caller_files: &BTreeSet<String>,
11232) -> Result<()> {
11233 if caller_files.is_empty() {
11234 return Ok(());
11235 }
11236
11237 let mut stmt = tx.prepare(
11238 "DELETE FROM edges
11239 WHERE provenance IN (?1, ?2)
11240 AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
11241 )?;
11242 for caller_file in caller_files {
11243 stmt.execute(params![
11244 PROVENANCE_NAME_MATCH,
11245 PROVENANCE_TYPE_MATCH,
11246 caller_file
11247 ])?;
11248 }
11249 Ok(())
11250}
11251
11252fn load_name_match_refs(
11253 tx: &Transaction<'_>,
11254 caller_files: Option<&BTreeSet<String>>,
11255) -> Result<Vec<NameMatchRef>> {
11256 let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
11257 n.signature, r.short_name, r.full_ref, r.line, f.lang
11258 FROM refs r
11259 JOIN files f ON f.path = r.caller_file
11260 JOIN nodes n ON n.id = r.caller_node
11261 WHERE r.kind = 'call'
11262 AND r.status = 'unresolved'
11263 AND r.caller_node IS NOT NULL
11264 AND r.full_ref IS NOT NULL
11265 AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
11266 AND NOT EXISTS (
11267 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
11268 )";
11269 let mut references = Vec::new();
11270
11271 if let Some(caller_files) = caller_files {
11272 if caller_files.is_empty() {
11273 return Ok(references);
11274 }
11275 let sql = format!(
11276 "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
11277 );
11278 let mut stmt = tx.prepare(&sql)?;
11279 for caller_file in caller_files {
11280 let rows = stmt.query_map(params![caller_file], |row| {
11281 Ok((
11282 row.get::<_, String>(0)?,
11283 row.get::<_, Option<String>>(1)?,
11284 row.get::<_, String>(2)?,
11285 row.get::<_, String>(3)?,
11286 row.get::<_, Option<String>>(4)?,
11287 row.get::<_, Option<String>>(5)?,
11288 row.get::<_, Option<String>>(6)?,
11289 row.get::<_, i64>(7)?,
11290 row.get::<_, String>(8)?,
11291 ))
11292 })?;
11293 for row in rows {
11294 let (
11295 ref_id,
11296 caller_node,
11297 caller_file,
11298 caller_symbol,
11299 caller_signature,
11300 short_name,
11301 full_ref,
11302 line,
11303 lang,
11304 ) = row?;
11305 if let Some(reference) = name_match_ref_from_parts(
11306 ref_id,
11307 caller_node,
11308 caller_file,
11309 caller_symbol,
11310 caller_signature,
11311 short_name,
11312 full_ref,
11313 line,
11314 lang,
11315 ) {
11316 references.push(reference);
11317 }
11318 }
11319 }
11320 return Ok(references);
11321 }
11322
11323 let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
11324 let mut stmt = tx.prepare(&sql)?;
11325 let rows = stmt.query_map([], |row| {
11326 Ok((
11327 row.get::<_, String>(0)?,
11328 row.get::<_, Option<String>>(1)?,
11329 row.get::<_, String>(2)?,
11330 row.get::<_, String>(3)?,
11331 row.get::<_, Option<String>>(4)?,
11332 row.get::<_, Option<String>>(5)?,
11333 row.get::<_, Option<String>>(6)?,
11334 row.get::<_, i64>(7)?,
11335 row.get::<_, String>(8)?,
11336 ))
11337 })?;
11338 for row in rows {
11339 let (
11340 ref_id,
11341 caller_node,
11342 caller_file,
11343 caller_symbol,
11344 caller_signature,
11345 short_name,
11346 full_ref,
11347 line,
11348 lang,
11349 ) = row?;
11350 if let Some(reference) = name_match_ref_from_parts(
11351 ref_id,
11352 caller_node,
11353 caller_file,
11354 caller_symbol,
11355 caller_signature,
11356 short_name,
11357 full_ref,
11358 line,
11359 lang,
11360 ) {
11361 references.push(reference);
11362 }
11363 }
11364 Ok(references)
11365}
11366
11367#[allow(clippy::too_many_arguments)]
11368fn name_match_ref_from_parts(
11369 ref_id: String,
11370 caller_node: Option<String>,
11371 caller_file: String,
11372 caller_symbol: String,
11373 caller_signature: Option<String>,
11374 short_name: Option<String>,
11375 full_ref: Option<String>,
11376 line: i64,
11377 lang: String,
11378) -> Option<NameMatchRef> {
11379 let caller_node = caller_node?;
11380 let full_ref = full_ref?;
11381 let (receiver_expression, receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
11382 let method_name = if member.is_empty() {
11383 short_name.as_deref()?.to_string()
11384 } else {
11385 member
11386 };
11387 Some(NameMatchRef {
11388 ref_id,
11389 caller_node,
11390 caller_file,
11391 caller_symbol,
11392 caller_signature,
11393 receiver_expression,
11394 receiver,
11395 method_name,
11396 colon_dispatch,
11397 line: line.max(0) as u32,
11398 lang,
11399 })
11400}
11401
11402fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, String, bool)> {
11403 let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
11404 let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
11405 let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
11406 let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
11407 .into_iter()
11408 .flatten()
11409 .max_by_key(|(index, _, _)| *index)?;
11410 if delimiter == 0 {
11411 return None;
11412 }
11413 let member_start = delimiter + delimiter_len;
11414 if member_start >= full_ref.len() {
11415 return None;
11416 }
11417 let receiver_expression = full_ref[..delimiter].trim();
11418 let receiver = last_name_segment(receiver_expression).trim();
11419 let member = &full_ref[member_start..];
11420 if receiver.is_empty() || member.is_empty() {
11421 return None;
11422 }
11423 Some((
11424 receiver_expression.to_string(),
11425 receiver.to_string(),
11426 member.to_string(),
11427 colon_dispatch,
11428 ))
11429}
11430
11431fn last_name_segment(value: &str) -> &str {
11432 value
11433 .rsplit(['.', ':', '/', '\\', '-', '>'])
11434 .find(|segment| !segment.is_empty())
11435 .unwrap_or(value)
11436}
11437
11438fn load_name_match_candidates(
11439 tx: &Transaction<'_>,
11440 method_name: &str,
11441 lang: &str,
11442) -> Result<Vec<NameMatchCandidate>> {
11443 let mut stmt = tx.prepare(
11444 "SELECT n.id, n.file_path, n.scoped_name, n.kind, n.start_line
11445 FROM nodes n JOIN files f ON f.path = n.file_path
11446 WHERE n.name = ?1
11447 AND f.lang = ?2
11448 AND n.kind IN ('method', 'function')
11449 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
11450 )?;
11451 let rows = stmt.query_map(params![method_name, lang], |row| {
11452 Ok(NameMatchCandidate {
11453 node_id: row.get(0)?,
11454 file_path: row.get(1)?,
11455 scoped_name: row.get(2)?,
11456 kind: row.get(3)?,
11457 start_line: (row.get::<_, i64>(4)?.max(0) as u32).saturating_add(1),
11458 })
11459 })?;
11460 rows.collect::<std::result::Result<Vec<_>, _>>()
11461 .map_err(Into::into)
11462}
11463
11464struct ParsedDispatchSource {
11465 source: String,
11466 tree: tree_sitter::Tree,
11467}
11468
11469type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
11470
11471#[derive(Debug, Clone, PartialEq, Eq)]
11472enum ReceiverTypeInference {
11473 Unknown,
11474 Known(String),
11475 RustDirectSelfField {
11476 receiver_type: String,
11477 declaration_file: String,
11478 module_scope: Vec<(usize, usize)>,
11479 },
11480 KnownButUnresolved,
11481}
11482
11483#[cfg(test)]
11484fn infer_receiver_type(
11485 project_root: &Path,
11486 reference: &NameMatchRef,
11487 source_cache: &mut DispatchSourceCache,
11488) -> Option<String> {
11489 match infer_receiver_type_state(project_root, reference, source_cache) {
11490 ReceiverTypeInference::Known(receiver_type)
11491 | ReceiverTypeInference::RustDirectSelfField { receiver_type, .. } => Some(receiver_type),
11492 ReceiverTypeInference::Unknown | ReceiverTypeInference::KnownButUnresolved => None,
11493 }
11494}
11495
11496fn infer_receiver_type_state(
11497 project_root: &Path,
11498 reference: &NameMatchRef,
11499 source_cache: &mut DispatchSourceCache,
11500) -> ReceiverTypeInference {
11501 let known = |receiver_type| ReceiverTypeInference::Known(receiver_type);
11502 match reference.lang.as_str() {
11503 "rust" => infer_rust_receiver_type(project_root, reference, source_cache),
11504 "java" => {
11505 infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
11506 .map(known)
11507 .unwrap_or(ReceiverTypeInference::Unknown)
11508 }
11509 "kotlin" => {
11510 infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
11511 .map(known)
11512 .unwrap_or(ReceiverTypeInference::Unknown)
11513 }
11514 "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache)
11515 .map(known)
11516 .unwrap_or(ReceiverTypeInference::Unknown),
11517 _ => ReceiverTypeInference::Unknown,
11518 }
11519}
11520
11521fn parse_dispatch_source(
11522 project_root: &Path,
11523 caller_file: &str,
11524 lang: LangId,
11525) -> Option<ParsedDispatchSource> {
11526 let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
11527 let grammar = crate::parser::grammar_for(lang);
11528 let mut parser = tree_sitter::Parser::new();
11529 parser.set_language(&grammar).ok()?;
11530 let tree = parser.parse(&source, None)?;
11531 Some(ParsedDispatchSource { source, tree })
11532}
11533
11534fn parsed_dispatch_source<'a>(
11535 project_root: &Path,
11536 reference: &NameMatchRef,
11537 lang: LangId,
11538 source_cache: &'a mut DispatchSourceCache,
11539) -> Option<&'a ParsedDispatchSource> {
11540 parsed_dispatch_source_for_file(
11541 project_root,
11542 &reference.caller_file,
11543 &reference.lang,
11544 lang,
11545 source_cache,
11546 )
11547}
11548
11549fn parsed_dispatch_source_for_file<'a>(
11550 project_root: &Path,
11551 file_path: &str,
11552 lang_label: &str,
11553 lang: LangId,
11554 source_cache: &'a mut DispatchSourceCache,
11555) -> Option<&'a ParsedDispatchSource> {
11556 let key = (file_path.to_string(), lang_label.to_string());
11557 source_cache
11558 .entry(key)
11559 .or_insert_with(|| parse_dispatch_source(project_root, file_path, lang))
11560 .as_ref()
11561}
11562
11563fn infer_java_like_receiver_type(
11564 project_root: &Path,
11565 reference: &NameMatchRef,
11566 lang: LangId,
11567 source_cache: &mut DispatchSourceCache,
11568) -> Option<String> {
11569 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
11570 return None;
11571 }
11572
11573 let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
11574 let root = parsed.tree.root_node();
11575 let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
11576
11577 let callable_scope = type_node
11578 .and_then(|node| {
11579 find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
11580 })
11581 .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
11582
11583 if let Some(callable_scope) = callable_scope {
11584 if let Some(receiver_type) = infer_java_like_local_receiver_type(
11585 callable_scope,
11586 &parsed.source,
11587 &reference.receiver,
11588 reference.line.max(1),
11589 lang,
11590 ) {
11591 return Some(receiver_type);
11592 }
11593 }
11594
11595 type_node.and_then(|node| {
11596 infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
11597 })
11598}
11599
11600fn infer_cpp_receiver_type(
11601 project_root: &Path,
11602 reference: &NameMatchRef,
11603 source_cache: &mut DispatchSourceCache,
11604) -> Option<String> {
11605 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
11606 return None;
11607 }
11608
11609 let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
11610 let root = parsed.tree.root_node();
11611 let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
11612 infer_cpp_receiver_type_from_scope(
11613 scope,
11614 &parsed.source,
11615 &reference.receiver,
11616 reference.line.max(1),
11617 )
11618}
11619
11620fn find_enclosing_java_like_type_node<'tree>(
11621 root: tree_sitter::Node<'tree>,
11622 source: &str,
11623 reference: &NameMatchRef,
11624 lang: LangId,
11625) -> Option<tree_sitter::Node<'tree>> {
11626 let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
11627 .and_then(|name| simple_type_name(&name));
11628 let line = reference.line.max(1);
11629 let mut best = None;
11630 let mut stack = vec![root];
11631 while let Some(node) = stack.pop() {
11632 if !node_contains_line(node, line) {
11633 continue;
11634 }
11635 if is_java_like_type_kind(node.kind(), lang) {
11636 let name = declaration_name(node, source);
11637 if expected_type
11638 .as_deref()
11639 .is_none_or(|expected| name == Some(expected))
11640 {
11641 best = tighter_node(best, node);
11642 }
11643 }
11644 push_named_children(node, &mut stack);
11645 }
11646 best
11647}
11648
11649fn find_enclosing_java_like_callable_node<'tree>(
11650 root: tree_sitter::Node<'tree>,
11651 source: &str,
11652 reference: &NameMatchRef,
11653 lang: LangId,
11654) -> Option<tree_sitter::Node<'tree>> {
11655 let expected_name = reference.caller_symbol.rsplit("::").next();
11656 let line = reference.line.max(1);
11657 let mut best = None;
11658 let mut stack = vec![root];
11659 while let Some(node) = stack.pop() {
11660 if !node_contains_line(node, line) {
11661 continue;
11662 }
11663 if is_java_like_callable_kind(node.kind(), lang) {
11664 let name = declaration_name(node, source);
11665 if expected_name.is_none_or(|expected| name == Some(expected)) {
11666 best = tighter_node(best, node);
11667 }
11668 }
11669 push_named_children(node, &mut stack);
11670 }
11671 best
11672}
11673
11674fn find_enclosing_cpp_callable_node<'tree>(
11675 root: tree_sitter::Node<'tree>,
11676 _source: &str,
11677 reference: &NameMatchRef,
11678) -> Option<tree_sitter::Node<'tree>> {
11679 let line = reference.line.max(1);
11680 let mut best = None;
11681 let mut stack = vec![root];
11682 while let Some(node) = stack.pop() {
11683 if !node_contains_line(node, line) {
11684 continue;
11685 }
11686 if node.kind() == "function_definition" {
11687 best = tighter_node(best, node);
11688 }
11689 push_named_children(node, &mut stack);
11690 }
11691 best
11692}
11693
11694fn tighter_node<'tree>(
11695 current: Option<tree_sitter::Node<'tree>>,
11696 candidate: tree_sitter::Node<'tree>,
11697) -> Option<tree_sitter::Node<'tree>> {
11698 match current {
11699 Some(current)
11700 if current.start_byte() > candidate.start_byte()
11701 || (current.start_byte() == candidate.start_byte()
11702 && current.end_byte() <= candidate.end_byte()) =>
11703 {
11704 Some(current)
11705 }
11706 _ => Some(candidate),
11707 }
11708}
11709
11710fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
11711 let start = node.start_position().row as u32 + 1;
11712 let end = node.end_position().row as u32 + 1;
11713 start <= line && line <= end
11714}
11715
11716fn push_named_children<'tree>(
11717 node: tree_sitter::Node<'tree>,
11718 stack: &mut Vec<tree_sitter::Node<'tree>>,
11719) {
11720 for index in 0..node.named_child_count() {
11721 if let Some(child) = node.named_child(index as u32) {
11722 stack.push(child);
11723 }
11724 }
11725}
11726
11727fn declaration_name<'source>(
11728 node: tree_sitter::Node<'_>,
11729 source: &'source str,
11730) -> Option<&'source str> {
11731 node.child_by_field_name("name")
11732 .map(|name| node_text(name, source))
11733 .or_else(|| {
11734 first_named_child_text(
11735 node,
11736 source,
11737 &["identifier", "type_identifier", "simple_identifier"],
11738 )
11739 })
11740}
11741
11742fn first_named_child_text<'source>(
11743 node: tree_sitter::Node<'_>,
11744 source: &'source str,
11745 kinds: &[&str],
11746) -> Option<&'source str> {
11747 for index in 0..node.named_child_count() {
11748 let child = node.named_child(index as u32)?;
11749 if kinds.contains(&child.kind()) {
11750 return Some(node_text(child, source));
11751 }
11752 }
11753 None
11754}
11755
11756fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
11757 &source[node.byte_range()]
11758}
11759
11760fn infer_java_like_field_receiver_type(
11761 type_node: tree_sitter::Node<'_>,
11762 source: &str,
11763 receiver: &str,
11764 lang: LangId,
11765) -> Option<String> {
11766 let mut stack = Vec::new();
11767 push_named_children(type_node, &mut stack);
11768 while let Some(node) = stack.pop() {
11769 if is_java_like_field_kind(node.kind(), lang) {
11770 if let Some(receiver_type) =
11771 extract_java_like_declared_type(node_text(node, source), receiver, lang)
11772 {
11773 return Some(receiver_type);
11774 }
11775 }
11776 if is_java_like_type_kind(node.kind(), lang)
11777 || is_java_like_callable_kind(node.kind(), lang)
11778 {
11779 continue;
11780 }
11781 push_named_children(node, &mut stack);
11782 }
11783 None
11784}
11785
11786fn infer_java_like_local_receiver_type(
11787 callable_node: tree_sitter::Node<'_>,
11788 source: &str,
11789 receiver: &str,
11790 call_line: u32,
11791 lang: LangId,
11792) -> Option<String> {
11793 let mut best: Option<(u32, String)> = None;
11794 let mut stack = Vec::new();
11795 push_named_children(callable_node, &mut stack);
11796 while let Some(node) = stack.pop() {
11797 let start_line = node.start_position().row as u32 + 1;
11798 if start_line > call_line {
11799 continue;
11800 }
11801 if is_java_like_local_kind(node.kind(), lang) {
11802 if let Some(receiver_type) =
11803 extract_java_like_declared_type(node_text(node, source), receiver, lang)
11804 {
11805 if best
11806 .as_ref()
11807 .is_none_or(|(best_line, _)| start_line >= *best_line)
11808 {
11809 best = Some((start_line, receiver_type));
11810 }
11811 }
11812 }
11813 if is_java_like_type_kind(node.kind(), lang)
11814 || is_java_like_callable_kind(node.kind(), lang)
11815 {
11816 continue;
11817 }
11818 push_named_children(node, &mut stack);
11819 }
11820 best.map(|(_, receiver_type)| receiver_type)
11821}
11822
11823fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
11824 match lang {
11825 LangId::Java => matches!(
11826 kind,
11827 "class_declaration"
11828 | "interface_declaration"
11829 | "enum_declaration"
11830 | "record_declaration"
11831 | "annotation_type_declaration"
11832 ),
11833 LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
11834 _ => false,
11835 }
11836}
11837
11838fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
11839 match lang {
11840 LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
11841 LangId::Kotlin => kind == "function_declaration",
11842 _ => false,
11843 }
11844}
11845
11846fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
11847 match lang {
11848 LangId::Java => kind == "field_declaration",
11849 LangId::Kotlin => kind == "property_declaration",
11850 _ => false,
11851 }
11852}
11853
11854fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
11855 match lang {
11856 LangId::Java => kind == "local_variable_declaration",
11857 LangId::Kotlin => kind == "property_declaration",
11858 _ => false,
11859 }
11860}
11861
11862fn extract_java_like_declared_type(
11863 declaration: &str,
11864 receiver: &str,
11865 lang: LangId,
11866) -> Option<String> {
11867 match lang {
11868 LangId::Java => extract_java_declared_type(declaration, receiver),
11869 LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
11870 _ => None,
11871 }
11872}
11873
11874fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
11875 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
11876 let after = declaration[receiver_start + receiver.len()..].trim_start();
11877 if after
11878 .chars()
11879 .next()
11880 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
11881 {
11882 return None;
11883 }
11884
11885 let before = declaration[..receiver_start].trim_end();
11886 if before.contains(',') {
11887 return None;
11888 }
11889 normalize_receiver_type_name(strip_java_declaration_prefixes(before))
11890}
11891
11892fn strip_java_declaration_prefixes(mut value: &str) -> &str {
11893 loop {
11894 value = value.trim_start();
11895 if let Some(stripped) = strip_leading_java_annotation(value) {
11896 value = stripped;
11897 continue;
11898 }
11899 if let Some(stripped) = strip_leading_java_modifier(value) {
11900 value = stripped;
11901 continue;
11902 }
11903 return value.trim();
11904 }
11905}
11906
11907fn strip_leading_java_annotation(value: &str) -> Option<&str> {
11908 let value = value.trim_start();
11909 let mut chars = value.char_indices();
11910 let (_, first) = chars.next()?;
11911 if first != '@' {
11912 return None;
11913 }
11914 let mut end = first.len_utf8();
11915 for (index, ch) in chars {
11916 if !(is_code_ident_char(ch) || ch == '.') {
11917 end = index;
11918 break;
11919 }
11920 end = index + ch.len_utf8();
11921 }
11922 let rest = value[end..].trim_start();
11923 if let Some(stripped) = rest.strip_prefix('(') {
11924 let mut depth = 1usize;
11925 for (index, ch) in stripped.char_indices() {
11926 match ch {
11927 '(' => depth += 1,
11928 ')' => {
11929 depth = depth.saturating_sub(1);
11930 if depth == 0 {
11931 return Some(stripped[index + ch.len_utf8()..].trim_start());
11932 }
11933 }
11934 _ => {}
11935 }
11936 }
11937 return Some("");
11938 }
11939 Some(rest)
11940}
11941
11942fn strip_leading_java_modifier(value: &str) -> Option<&str> {
11943 const MODIFIERS: &[&str] = &[
11944 "public",
11945 "protected",
11946 "private",
11947 "abstract",
11948 "static",
11949 "final",
11950 "transient",
11951 "volatile",
11952 "synchronized",
11953 "native",
11954 "strictfp",
11955 ];
11956 MODIFIERS
11957 .iter()
11958 .find_map(|modifier| strip_leading_word(value, modifier))
11959}
11960
11961fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
11962 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
11963 let before = &declaration[..receiver_start];
11964 if find_identifier_occurrence(before, "val").is_none()
11965 && find_identifier_occurrence(before, "var").is_none()
11966 {
11967 return None;
11968 }
11969
11970 let after = declaration[receiver_start + receiver.len()..].trim_start();
11971 if let Some(type_text) = after.strip_prefix(':') {
11972 return normalize_receiver_type_name(read_type_prefix(type_text));
11973 }
11974 after
11975 .strip_prefix('=')
11976 .and_then(infer_kotlin_constructor_type)
11977}
11978
11979fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
11980 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
11981 if rest.trim_start().starts_with('(') {
11982 normalize_receiver_type_name(head)
11983 } else {
11984 None
11985 }
11986}
11987
11988fn read_type_prefix(value: &str) -> &str {
11989 let mut angle_depth = 0usize;
11990 for (index, ch) in value.char_indices() {
11991 match ch {
11992 '<' => angle_depth += 1,
11993 '>' => angle_depth = angle_depth.saturating_sub(1),
11994 '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
11995 return value[..index].trim();
11996 }
11997 _ => {}
11998 }
11999 }
12000 value.trim()
12001}
12002
12003fn infer_cpp_receiver_type_from_scope(
12004 scope: tree_sitter::Node<'_>,
12005 source: &str,
12006 receiver: &str,
12007 call_line: u32,
12008) -> Option<String> {
12009 let lines = source.lines().collect::<Vec<_>>();
12010 if lines.is_empty() {
12011 return None;
12012 }
12013 let scope_start = scope.start_position().row as usize;
12014 let call_index = (call_line as usize)
12015 .saturating_sub(1)
12016 .min(lines.len().saturating_sub(1));
12017 for index in (scope_start..=call_index).rev() {
12018 if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
12019 return Some(receiver_type);
12020 }
12021 }
12022 None
12023}
12024
12025fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
12026 for receiver_start in identifier_occurrences(line, receiver) {
12027 let after = line[receiver_start + receiver.len()..].trim_start();
12028 if after
12029 .chars()
12030 .next()
12031 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
12032 {
12033 continue;
12034 }
12035 let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
12036 let normalized = normalize_cpp_type_name(type_text)?;
12037 if normalized == "auto" {
12038 if let Some(rhs) = after.strip_prefix('=') {
12039 return infer_cpp_auto_receiver_type(rhs);
12040 }
12041 continue;
12042 }
12043 return Some(normalized);
12044 }
12045 None
12046}
12047
12048fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
12049 let candidate = prefix
12050 .rsplit([';', '{', '}', '('])
12051 .next()
12052 .unwrap_or(prefix)
12053 .trim();
12054 if candidate.is_empty() || candidate.ends_with(',') {
12055 None
12056 } else {
12057 Some(candidate)
12058 }
12059}
12060
12061fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
12062 let without_templates = strip_angle_groups(type_text);
12063 let mut cleaned = String::with_capacity(without_templates.len());
12064 for token in without_templates.split_whitespace() {
12065 if matches!(
12066 token,
12067 "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
12068 ) {
12069 continue;
12070 }
12071 if !cleaned.is_empty() {
12072 cleaned.push(' ');
12073 }
12074 cleaned.push_str(token);
12075 }
12076 let token = cleaned
12077 .split_whitespace()
12078 .last()
12079 .unwrap_or(cleaned.trim())
12080 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
12081 .trim_matches(['*', '&']);
12082 let simple = token.rsplit("::").next().unwrap_or(token).trim();
12083 if simple.is_empty() || cpp_non_type_token(simple) {
12084 None
12085 } else {
12086 Some(simple.to_string())
12087 }
12088}
12089
12090fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
12091 let rhs = rhs.trim_start();
12092 if let Some(after_new) = rhs.strip_prefix("new ") {
12093 return infer_cpp_constructor_type(after_new);
12094 }
12095 infer_cpp_make_template_type(rhs)
12096 .or_else(|| infer_cpp_constructor_type(rhs))
12097 .or_else(|| infer_cpp_factory_type(rhs))
12098}
12099
12100fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
12101 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
12102 let normalized = normalize_cpp_type_name(head)?;
12103 if !normalized
12104 .chars()
12105 .next()
12106 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
12107 {
12108 return None;
12109 }
12110 if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
12111 Some(normalized)
12112 } else {
12113 None
12114 }
12115}
12116
12117fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
12118 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
12119 if !rest.trim_start().starts_with('(') {
12120 return None;
12121 }
12122 let base = head.split('<').next().unwrap_or(head);
12123 let base_simple = base.rsplit("::").next().unwrap_or(base);
12124 if !matches!(base_simple, "make_unique" | "make_shared") {
12125 return None;
12126 }
12127 first_angle_arg(head).and_then(normalize_cpp_type_name)
12128}
12129
12130fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
12131 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
12132 if !rest.trim_start().starts_with('(') {
12133 return None;
12134 }
12135 let simple = head
12136 .split('<')
12137 .next()
12138 .unwrap_or(head)
12139 .rsplit("::")
12140 .next()
12141 .unwrap_or(head);
12142 for prefix in ["make", "create", "build"] {
12143 if let Some(suffix) = simple.strip_prefix(prefix) {
12144 if suffix
12145 .chars()
12146 .next()
12147 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
12148 {
12149 return normalize_cpp_type_name(suffix);
12150 }
12151 }
12152 }
12153 None
12154}
12155
12156#[derive(Debug, Clone, Copy)]
12157enum JavaLikeInvocation {
12158 Kotlin,
12159 Cpp,
12160}
12161
12162fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
12163 let value = value.trim_start();
12164 let mut end = 0usize;
12165 for (index, ch) in value.char_indices() {
12166 let allowed_separator = match flavor {
12167 JavaLikeInvocation::Kotlin => ch == '.',
12168 JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
12169 };
12170 if is_code_ident_char(ch) || allowed_separator {
12171 end = index + ch.len_utf8();
12172 continue;
12173 }
12174 break;
12175 }
12176 if end == 0 {
12177 return None;
12178 }
12179 let mut rest = &value[end..];
12180 if let Some(stripped) = rest.trim_start().strip_prefix('<') {
12181 let skipped = skip_balanced_angle(stripped)?;
12182 let rest_start = rest.len() - rest.trim_start().len();
12183 let angle_len = 1 + skipped;
12184 end += rest_start + angle_len;
12185 rest = &value[end..];
12186 }
12187 Some((value[..end].trim(), rest))
12188}
12189
12190fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
12191 let mut depth = 1usize;
12192 for (index, ch) in value_after_open.char_indices() {
12193 match ch {
12194 '<' => depth += 1,
12195 '>' => {
12196 depth = depth.saturating_sub(1);
12197 if depth == 0 {
12198 return Some(index + ch.len_utf8());
12199 }
12200 }
12201 _ => {}
12202 }
12203 }
12204 None
12205}
12206
12207fn first_angle_arg(value: &str) -> Option<&str> {
12208 let open = value.find('<')?;
12209 let inner_len = skip_balanced_angle(&value[open + 1..])?;
12210 let inner = &value[open + 1..open + inner_len];
12211 split_top_level_commas(inner).into_iter().next()
12212}
12213
12214fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
12215 let without_generics = strip_angle_groups(type_text);
12216 let cleaned = without_generics
12217 .replace("[]", " ")
12218 .replace("...", " ")
12219 .replace(['?', '&', '*'], " ");
12220 let token = cleaned
12221 .split_whitespace()
12222 .last()
12223 .unwrap_or(cleaned.trim())
12224 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
12225 let token = token.rsplit("::").next().unwrap_or(token);
12226 let simple = token.rsplit('.').next().unwrap_or(token).trim();
12227 if simple.is_empty()
12228 || java_like_primitive_type(simple)
12229 || !simple
12230 .chars()
12231 .next()
12232 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
12233 {
12234 None
12235 } else {
12236 Some(simple.to_string())
12237 }
12238}
12239
12240fn simple_type_name(scoped_name: &str) -> Option<String> {
12241 scoped_name
12242 .rsplit("::")
12243 .find(|segment| !segment.is_empty())
12244 .and_then(normalize_receiver_type_name)
12245}
12246
12247fn strip_angle_groups(value: &str) -> String {
12248 let mut output = String::with_capacity(value.len());
12249 let mut depth = 0usize;
12250 for ch in value.chars() {
12251 match ch {
12252 '<' => {
12253 if depth == 0 {
12254 output.push(' ');
12255 }
12256 depth += 1;
12257 }
12258 '>' => depth = depth.saturating_sub(1),
12259 _ if depth == 0 => output.push(ch),
12260 _ => {}
12261 }
12262 }
12263 output
12264}
12265
12266fn java_like_primitive_type(value: &str) -> bool {
12267 matches!(
12268 value,
12269 "boolean"
12270 | "byte"
12271 | "char"
12272 | "double"
12273 | "float"
12274 | "int"
12275 | "long"
12276 | "short"
12277 | "void"
12278 | "Boolean"
12279 | "Byte"
12280 | "Char"
12281 | "Double"
12282 | "Float"
12283 | "Int"
12284 | "Long"
12285 | "Short"
12286 | "Unit"
12287 )
12288}
12289
12290fn cpp_non_type_token(value: &str) -> bool {
12291 matches!(
12292 value,
12293 "return"
12294 | "if"
12295 | "else"
12296 | "for"
12297 | "while"
12298 | "do"
12299 | "switch"
12300 | "case"
12301 | "default"
12302 | "break"
12303 | "continue"
12304 | "goto"
12305 | "throw"
12306 | "new"
12307 | "delete"
12308 | "co_await"
12309 | "co_yield"
12310 | "co_return"
12311 | "static_cast"
12312 | "const_cast"
12313 | "dynamic_cast"
12314 | "reinterpret_cast"
12315 | "sizeof"
12316 | "alignof"
12317 | "typeid"
12318 | "and"
12319 | "or"
12320 | "not"
12321 | "xor"
12322 )
12323}
12324
12325fn receiver_is_bare_identifier(value: &str) -> bool {
12326 let mut chars = value.chars();
12327 let Some(first) = chars.next() else {
12328 return false;
12329 };
12330 (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
12331}
12332
12333fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
12334 identifier_occurrences(value, needle).into_iter().next()
12335}
12336
12337fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
12338 value
12339 .match_indices(needle)
12340 .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
12341 .collect()
12342}
12343
12344fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
12345 let before = value[..start].chars().next_back();
12346 let after = value[start + len..].chars().next();
12347 !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
12348}
12349
12350fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
12351 let stripped = value.strip_prefix(word)?;
12352 if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
12353 Some(stripped.trim_start())
12354 } else {
12355 None
12356 }
12357}
12358
12359fn is_code_ident_char(ch: char) -> bool {
12360 ch == '_' || ch.is_ascii_alphanumeric()
12361}
12362
12363fn infer_rust_receiver_type(
12364 project_root: &Path,
12365 reference: &NameMatchRef,
12366 source_cache: &mut DispatchSourceCache,
12367) -> ReceiverTypeInference {
12368 if matches!(reference.receiver.as_str(), "self" | "Self") {
12369 return enclosing_type_from_scoped_name(&reference.caller_symbol)
12370 .map(ReceiverTypeInference::Known)
12371 .unwrap_or(ReceiverTypeInference::Unknown);
12372 }
12373
12374 if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
12375 return ReceiverTypeInference::Known(reference.receiver.clone());
12376 }
12377
12378 if let Some(receiver_type) = reference
12379 .caller_signature
12380 .as_deref()
12381 .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
12382 {
12383 return ReceiverTypeInference::Known(receiver_type);
12384 }
12385
12386 infer_rust_direct_self_field_receiver_type(project_root, reference, source_cache)
12387}
12388
12389fn infer_rust_direct_self_field_receiver_type(
12390 project_root: &Path,
12391 reference: &NameMatchRef,
12392 source_cache: &mut DispatchSourceCache,
12393) -> ReceiverTypeInference {
12394 if reference.colon_dispatch {
12395 return ReceiverTypeInference::Unknown;
12396 }
12397 let Some(field_name) = rust_direct_self_field_name(&reference.receiver_expression) else {
12398 return ReceiverTypeInference::Unknown;
12399 };
12400 if field_name != reference.receiver {
12401 return ReceiverTypeInference::Unknown;
12402 }
12403
12404 let Some(impl_type) = enclosing_type_from_scoped_name(&reference.caller_symbol) else {
12405 return ReceiverTypeInference::Unknown;
12406 };
12407 let Some(struct_name) = rust_direct_nominal_type_name(&impl_type) else {
12408 return ReceiverTypeInference::KnownButUnresolved;
12409 };
12410 let Some(parsed) = parsed_dispatch_source(project_root, reference, LangId::Rust, source_cache)
12411 else {
12412 return ReceiverTypeInference::Unknown;
12413 };
12414 let Some(impl_node) =
12415 find_enclosing_rust_impl_node(parsed.tree.root_node(), reference.line.max(1))
12416 else {
12417 return ReceiverTypeInference::Unknown;
12418 };
12419 if impl_node.child_by_field_name("trait").is_some()
12420 || impl_node.child_by_field_name("type_parameters").is_some()
12421 {
12422 return ReceiverTypeInference::KnownButUnresolved;
12423 }
12424 let Some(impl_target) = impl_node.child_by_field_name("type") else {
12425 return ReceiverTypeInference::KnownButUnresolved;
12426 };
12427 if impl_target.kind() != "type_identifier"
12428 || node_text(impl_target, &parsed.source) != impl_type
12429 {
12430 return ReceiverTypeInference::KnownButUnresolved;
12431 }
12432
12433 let module_scope = rust_module_scope(impl_node);
12434 let Some(struct_node) = find_unique_rust_struct(
12435 parsed.tree.root_node(),
12436 &parsed.source,
12437 struct_name,
12438 &module_scope,
12439 ) else {
12440 return ReceiverTypeInference::KnownButUnresolved;
12441 };
12442 let Some(field_type) = rust_struct_field_type_node(struct_node, &parsed.source, field_name)
12443 else {
12444 return ReceiverTypeInference::KnownButUnresolved;
12445 };
12446 if field_type.kind() != "type_identifier" {
12447 return ReceiverTypeInference::KnownButUnresolved;
12448 }
12449 let field_type_name = node_text(field_type, &parsed.source);
12450 if find_unique_rust_struct(
12451 parsed.tree.root_node(),
12452 &parsed.source,
12453 field_type_name,
12454 &module_scope,
12455 )
12456 .is_none()
12457 {
12458 return ReceiverTypeInference::KnownButUnresolved;
12459 }
12460
12461 ReceiverTypeInference::RustDirectSelfField {
12462 receiver_type: field_type_name.to_string(),
12463 declaration_file: reference.caller_file.clone(),
12464 module_scope,
12465 }
12466}
12467
12468fn rust_direct_self_field_name(receiver_expression: &str) -> Option<&str> {
12469 let (base, field) = receiver_expression.split_once('.')?;
12470 let base = base.trim();
12471 let field = field.trim();
12472 (base == "self" && rust_direct_nominal_type_name(field).is_some()).then_some(field)
12473}
12474
12475fn rust_direct_nominal_type_name(value: &str) -> Option<&str> {
12476 let name = value.rsplit("::").next()?.trim();
12477 (!name.is_empty()
12478 && !name.chars().next().is_some_and(|ch| ch.is_ascii_digit())
12479 && name.chars().all(is_rust_ident_char))
12480 .then_some(name)
12481}
12482
12483fn find_enclosing_rust_impl_node<'tree>(
12484 root: tree_sitter::Node<'tree>,
12485 line: u32,
12486) -> Option<tree_sitter::Node<'tree>> {
12487 let mut best = None;
12488 let mut stack = vec![root];
12489 while let Some(node) = stack.pop() {
12490 if !node_contains_line(node, line) {
12491 continue;
12492 }
12493 if node.kind() == "impl_item" {
12494 best = tighter_node(best, node);
12495 }
12496 push_named_children(node, &mut stack);
12497 }
12498 best
12499}
12500
12501fn rust_module_scope(node: tree_sitter::Node<'_>) -> Vec<(usize, usize)> {
12502 let mut scope = Vec::new();
12503 let mut current = node.parent();
12504 while let Some(parent) = current {
12505 if parent.kind() == "mod_item" {
12506 scope.push((parent.start_byte(), parent.end_byte()));
12507 }
12508 current = parent.parent();
12509 }
12510 scope.reverse();
12511 scope
12512}
12513
12514fn find_unique_rust_struct<'tree>(
12515 root: tree_sitter::Node<'tree>,
12516 source: &str,
12517 expected_name: &str,
12518 module_scope: &[(usize, usize)],
12519) -> Option<tree_sitter::Node<'tree>> {
12520 let mut found = None;
12521 let mut stack = vec![root];
12522 while let Some(node) = stack.pop() {
12523 if node.kind() == "struct_item"
12524 && rust_module_scope(node) == module_scope
12525 && node.child_by_field_name("type_parameters").is_none()
12526 && declaration_name(node, source) == Some(expected_name)
12527 {
12528 if found.is_some() {
12529 return None;
12530 }
12531 found = Some(node);
12532 }
12533 push_named_children(node, &mut stack);
12534 }
12535 found
12536}
12537
12538fn rust_struct_field_type_node<'tree>(
12539 struct_node: tree_sitter::Node<'tree>,
12540 source: &str,
12541 field_name: &str,
12542) -> Option<tree_sitter::Node<'tree>> {
12543 let fields = struct_node.child_by_field_name("body")?;
12544 if fields.kind() != "field_declaration_list" {
12545 return None;
12546 }
12547 for index in 0..fields.named_child_count() {
12548 let field = fields.named_child(index as u32)?;
12549 if field.kind() != "field_declaration"
12550 || declaration_name(field, source) != Some(field_name)
12551 {
12552 continue;
12553 }
12554 return field.child_by_field_name("type");
12555 }
12556 None
12557}
12558
12559fn rust_receiver_looks_type_like(receiver: &str) -> bool {
12560 receiver
12561 .chars()
12562 .next()
12563 .is_some_and(|ch| ch == '_' || ch.is_uppercase())
12564}
12565
12566fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
12567 scoped_name
12568 .rsplit_once("::")
12569 .map(|(enclosing, _)| enclosing)
12570 .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
12571 .map(ToString::to_string)
12572}
12573
12574fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
12575 let params = signature_parameter_text(signature)?;
12576 for param in split_top_level_commas(params) {
12577 let Some((pattern, type_text)) = param.split_once(':') else {
12578 continue;
12579 };
12580 let Some(name) = rust_parameter_name(pattern) else {
12581 continue;
12582 };
12583 if name == receiver {
12584 return normalize_rust_receiver_type(type_text);
12585 }
12586 }
12587 None
12588}
12589
12590fn signature_parameter_text(signature: &str) -> Option<&str> {
12591 let open = signature.find('(')?;
12592 let mut depth = 0usize;
12593 for (offset, ch) in signature[open..].char_indices() {
12594 match ch {
12595 '(' => depth += 1,
12596 ')' => {
12597 depth = depth.saturating_sub(1);
12598 if depth == 0 {
12599 return Some(&signature[open + 1..open + offset]);
12600 }
12601 }
12602 _ => {}
12603 }
12604 }
12605 None
12606}
12607
12608fn split_top_level_commas(value: &str) -> Vec<&str> {
12609 let mut parts = Vec::new();
12610 let mut start = 0usize;
12611 let mut angle_depth = 0usize;
12612 let mut paren_depth = 0usize;
12613 let mut bracket_depth = 0usize;
12614 for (index, ch) in value.char_indices() {
12615 match ch {
12616 '<' => angle_depth += 1,
12617 '>' => angle_depth = angle_depth.saturating_sub(1),
12618 '(' => paren_depth += 1,
12619 ')' => paren_depth = paren_depth.saturating_sub(1),
12620 '[' => bracket_depth += 1,
12621 ']' => bracket_depth = bracket_depth.saturating_sub(1),
12622 ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
12623 let part = value[start..index].trim();
12624 if !part.is_empty() {
12625 parts.push(part);
12626 }
12627 start = index + ch.len_utf8();
12628 }
12629 _ => {}
12630 }
12631 }
12632 let part = value[start..].trim();
12633 if !part.is_empty() {
12634 parts.push(part);
12635 }
12636 parts
12637}
12638
12639fn rust_parameter_name(pattern: &str) -> Option<&str> {
12640 let mut pattern = pattern.trim();
12641 if let Some(stripped) = pattern.strip_prefix("mut ") {
12642 pattern = stripped.trim_start();
12643 }
12644 pattern
12645 .rsplit(|ch: char| !is_rust_ident_char(ch))
12646 .find(|part| !part.is_empty())
12647}
12648
12649fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
12650 let mut ty = strip_leading_rust_type_modifiers(type_text);
12651 let owned_inner;
12652 if let Some(inner) = single_outer_generic_arg(ty) {
12653 owned_inner = inner.trim().to_string();
12654 ty = strip_leading_rust_type_modifiers(&owned_inner);
12655 }
12656 rust_base_type_ident(ty)
12657}
12658
12659fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
12660 loop {
12661 ty = ty.trim_start();
12662 if let Some(stripped) = ty.strip_prefix('&') {
12663 ty = stripped.trim_start();
12664 if let Some(stripped) = strip_leading_lifetime(ty) {
12665 ty = stripped.trim_start();
12666 }
12667 if let Some(stripped) = ty.strip_prefix("mut ") {
12668 ty = stripped.trim_start();
12669 }
12670 continue;
12671 }
12672 if let Some(stripped) = ty.strip_prefix("mut ") {
12673 ty = stripped.trim_start();
12674 continue;
12675 }
12676 if let Some(stripped) = ty.strip_prefix("dyn ") {
12677 ty = stripped.trim_start();
12678 continue;
12679 }
12680 if let Some(stripped) = ty.strip_prefix("impl ") {
12681 ty = stripped.trim_start();
12682 continue;
12683 }
12684 break ty.trim();
12685 }
12686}
12687
12688fn strip_leading_lifetime(value: &str) -> Option<&str> {
12689 let mut chars = value.char_indices();
12690 let (_, first) = chars.next()?;
12691 if first != '\'' {
12692 return None;
12693 }
12694 for (index, ch) in chars {
12695 if !(ch == '_' || ch.is_ascii_alphanumeric()) {
12696 return Some(&value[index..]);
12697 }
12698 }
12699 Some("")
12700}
12701
12702fn single_outer_generic_arg(ty: &str) -> Option<&str> {
12703 let ty = ty.trim();
12704 let open = ty.find('<')?;
12705 let mut depth = 0usize;
12706 let mut close = None;
12707 for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
12708 match ch {
12709 '<' => depth += 1,
12710 '>' => {
12711 depth = depth.saturating_sub(1);
12712 if depth == 0 {
12713 close = Some(index);
12714 break;
12715 }
12716 }
12717 _ => {}
12718 }
12719 }
12720 let close = close?;
12721 if !ty[close + 1..].trim().is_empty() {
12722 return None;
12723 }
12724 let inner = &ty[open + 1..close];
12725 let args = split_top_level_commas(inner);
12726 match args.as_slice() {
12727 [arg] => Some(*arg),
12728 _ => None,
12729 }
12730}
12731
12732fn rust_base_type_ident(ty: &str) -> Option<String> {
12733 let ty = ty.trim();
12734 let head = ty
12735 .split([' ', '+', '='])
12736 .find(|part| !part.is_empty())
12737 .unwrap_or(ty);
12738 let head = head.split('<').next().unwrap_or(head).trim();
12739 let ident = head
12740 .rsplit("::")
12741 .next()
12742 .unwrap_or(head)
12743 .trim_matches(|ch: char| !is_rust_ident_char(ch));
12744 if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
12745 None
12746 } else {
12747 Some(ident.to_string())
12748 }
12749}
12750
12751fn is_rust_ident_char(ch: char) -> bool {
12752 ch == '_' || ch.is_ascii_alphanumeric()
12753}
12754
12755fn select_rust_direct_self_field_candidate(
12756 project_root: &Path,
12757 reference: &NameMatchRef,
12758 candidates: &[NameMatchCandidate],
12759 receiver_type: &str,
12760 declaration_file: &str,
12761 declaration_scope: &[(usize, usize)],
12762 source_cache: &mut DispatchSourceCache,
12763) -> Option<NameMatchCandidate> {
12764 let eligible = candidates
12765 .iter()
12766 .filter(|candidate| candidate.node_id != reference.caller_node)
12767 .filter(|candidate| {
12768 type_candidate_matches(candidate, receiver_type, &reference.method_name)
12769 })
12770 .filter(|candidate| {
12771 rust_direct_self_field_candidate_matches_scope(
12772 project_root,
12773 candidate,
12774 receiver_type,
12775 declaration_file,
12776 declaration_scope,
12777 source_cache,
12778 )
12779 })
12780 .collect::<Vec<_>>();
12781 match eligible.as_slice() {
12782 [candidate] => Some((**candidate).clone()),
12783 _ => None,
12784 }
12785}
12786
12787fn rust_direct_self_field_candidate_matches_scope(
12788 project_root: &Path,
12789 candidate: &NameMatchCandidate,
12790 receiver_type: &str,
12791 declaration_file: &str,
12792 declaration_scope: &[(usize, usize)],
12793 source_cache: &mut DispatchSourceCache,
12794) -> bool {
12795 if candidate.file_path != declaration_file {
12796 return false;
12797 }
12798 let Some(parsed) = parsed_dispatch_source_for_file(
12799 project_root,
12800 &candidate.file_path,
12801 "rust",
12802 LangId::Rust,
12803 source_cache,
12804 ) else {
12805 return false;
12806 };
12807 let Some(impl_node) =
12808 find_enclosing_rust_impl_node(parsed.tree.root_node(), candidate.start_line)
12809 else {
12810 return false;
12811 };
12812 if impl_node.child_by_field_name("trait").is_some()
12813 || impl_node.child_by_field_name("type_parameters").is_some()
12814 {
12815 return false;
12816 }
12817 let Some(impl_target) = impl_node.child_by_field_name("type") else {
12818 return false;
12819 };
12820 impl_target.kind() == "type_identifier"
12821 && node_text(impl_target, &parsed.source) == receiver_type
12822 && rust_module_scope(impl_node) == declaration_scope
12823}
12824
12825fn select_type_match_candidate(
12826 reference: &NameMatchRef,
12827 candidates: &[NameMatchCandidate],
12828 receiver_type: &str,
12829) -> Option<NameMatchCandidate> {
12830 let candidates = candidates
12831 .iter()
12832 .filter(|candidate| candidate.node_id != reference.caller_node)
12833 .filter(|candidate| {
12834 type_candidate_matches(candidate, receiver_type, &reference.method_name)
12835 })
12836 .collect::<Vec<_>>();
12837 match candidates.as_slice() {
12838 [candidate] => Some((**candidate).clone()),
12839 _ => None,
12840 }
12841}
12842
12843fn type_candidate_matches(
12844 candidate: &NameMatchCandidate,
12845 receiver_type: &str,
12846 method_name: &str,
12847) -> bool {
12848 let normalized_type = receiver_type.replace('.', "::");
12849 let suffix = format!("{normalized_type}::{method_name}");
12850 candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
12851}
12852
12853fn select_name_match_candidate(
12854 reference: &NameMatchRef,
12855 candidates: &[NameMatchCandidate],
12856) -> Option<NameMatchCandidate> {
12857 let candidates = candidates
12858 .iter()
12859 .filter(|candidate| candidate.node_id != reference.caller_node)
12860 .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
12861 .collect::<Vec<_>>();
12862 match candidates.as_slice() {
12863 [] => None,
12864 [candidate] => Some((**candidate).clone()),
12865 _ => select_scored_name_match_candidate(reference, &candidates),
12866 }
12867}
12868
12869fn candidate_allowed_for_reference(
12870 reference: &NameMatchRef,
12871 candidate: &NameMatchCandidate,
12872) -> bool {
12873 if !reference.colon_dispatch {
12874 return true;
12875 }
12876
12877 candidate.kind == "method"
12878 && candidate
12879 .scoped_name
12880 .split("::")
12881 .any(|segment| segment == reference.receiver)
12882}
12883
12884fn select_scored_name_match_candidate(
12885 reference: &NameMatchRef,
12886 candidates: &[&NameMatchCandidate],
12887) -> Option<NameMatchCandidate> {
12888 let receiver_words = split_camel_case(&reference.receiver);
12889 if receiver_words.is_empty() {
12890 return None;
12891 }
12892
12893 let mut best: Option<(&NameMatchCandidate, f64)> = None;
12894 let mut tied_best = false;
12895 for candidate in candidates {
12896 let candidate_words = split_camel_case(&candidate.scoped_name);
12897 let overlap = receiver_words
12898 .iter()
12899 .filter(|receiver_word| {
12900 candidate_words
12901 .iter()
12902 .any(|candidate_word| candidate_word == *receiver_word)
12903 })
12904 .count() as f64;
12905 let score =
12906 overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
12907 match best {
12908 None => {
12909 best = Some((*candidate, score));
12910 tied_best = false;
12911 }
12912 Some((_, best_score)) if score > best_score => {
12913 best = Some((*candidate, score));
12914 tied_best = false;
12915 }
12916 Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
12917 tied_best = true;
12918 }
12919 _ => {}
12920 }
12921 }
12922
12923 let (candidate, score) = best?;
12924 if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
12925 Some(candidate.clone())
12926 } else {
12927 None
12928 }
12929}
12930
12931fn method_name_match_denylisted(method_name: &str) -> bool {
12932 matches!(
12933 method_name,
12934 "and_then"
12935 | "as_bytes"
12936 | "as_deref"
12937 | "as_mut"
12938 | "as_ref"
12939 | "as_str"
12940 | "borrow"
12941 | "borrow_mut"
12942 | "clear"
12943 | "clone"
12944 | "collect"
12945 | "contains"
12946 | "contains_key"
12947 | "count"
12948 | "dedup"
12949 | "default"
12950 | "drain"
12951 | "ends_with"
12952 | "entry"
12953 | "err"
12954 | "expect"
12955 | "extend"
12956 | "filter"
12957 | "filter_map"
12958 | "find"
12959 | "from"
12960 | "get"
12961 | "get_mut"
12962 | "insert"
12963 | "into"
12964 | "into_iter"
12965 | "is_empty"
12966 | "is_err"
12967 | "is_none"
12968 | "is_ok"
12969 | "is_some"
12970 | "iter"
12971 | "iter_mut"
12972 | "join"
12973 | "len"
12974 | "lock"
12975 | "map"
12976 | "map_err"
12977 | "max"
12978 | "min"
12979 | "new"
12980 | "next"
12981 | "ok"
12982 | "or_default"
12983 | "or_else"
12984 | "or_insert"
12985 | "or_insert_with"
12986 | "parse"
12987 | "pop"
12988 | "position"
12989 | "push"
12990 | "read"
12991 | "recv"
12992 | "remove"
12993 | "replace"
12994 | "retain"
12995 | "send"
12996 | "sort"
12997 | "sort_by"
12998 | "split"
12999 | "starts_with"
13000 | "sum"
13001 | "take"
13002 | "to_owned"
13003 | "to_string"
13004 | "trim"
13005 | "try_from"
13006 | "try_into"
13007 | "unwrap"
13008 | "unwrap_or"
13009 | "unwrap_or_default"
13010 | "unwrap_or_else"
13011 | "with_capacity"
13012 | "write"
13013 )
13014}
13015
13016fn split_camel_case(value: &str) -> Vec<String> {
13017 let chars = value.chars().collect::<Vec<_>>();
13018 let mut normalized = String::with_capacity(value.len() + 8);
13019 for (index, ch) in chars.iter().enumerate() {
13020 let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
13021 let next = chars.get(index + 1);
13022 let is_separator = ch.is_whitespace()
13023 || matches!(
13024 ch,
13025 '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
13026 );
13027 if is_separator {
13028 normalized.push(' ');
13029 continue;
13030 }
13031 let camel_boundary = previous.is_some_and(|prev| {
13032 (prev.is_lowercase() && ch.is_uppercase())
13033 || (prev.is_ascii_digit() && ch.is_alphabetic())
13034 || (prev.is_uppercase()
13035 && ch.is_uppercase()
13036 && next.is_some_and(|next| next.is_lowercase()))
13037 });
13038 if camel_boundary {
13039 normalized.push(' ');
13040 }
13041 normalized.push(*ch);
13042 }
13043
13044 normalized
13045 .split_whitespace()
13046 .filter(|word| word.len() > 1)
13047 .map(|word| word.to_ascii_lowercase())
13048 .collect()
13049}
13050
13051fn compute_path_proximity(left: &str, right: &str) -> f64 {
13052 let left_dirs = left
13053 .rsplit_once('/')
13054 .map(|(dir, _)| dir)
13055 .unwrap_or_default()
13056 .split('/')
13057 .filter(|part| !part.is_empty());
13058 let right_dirs = right
13059 .rsplit_once('/')
13060 .map(|(dir, _)| dir)
13061 .unwrap_or_default()
13062 .split('/')
13063 .filter(|part| !part.is_empty());
13064
13065 let shared = left_dirs
13066 .zip(right_dirs)
13067 .take_while(|(left, right)| left == right)
13068 .count();
13069 ((shared as f64) * 0.05).min(0.5)
13070}
13071
13072fn mark_backend_state(
13073 tx: &Transaction<'_>,
13074 project_root: &Path,
13075 rel_path: &str,
13076 content_hash: Option<&blake3::Hash>,
13077 status: &str,
13078) -> Result<()> {
13079 clear_backend_state_for_file(tx, project_root, rel_path)?;
13080 let hash = content_hash
13081 .map(|hash| hash_to_hex(*hash))
13082 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
13083 tx.execute(
13084 "INSERT OR REPLACE INTO backend_file_state(
13085 backend, workspace_root, file_path, content_hash, status, updated_at
13086 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
13087 params![
13088 BACKEND_TREESITTER,
13089 project_root.display().to_string(),
13090 rel_path,
13091 hash,
13092 status,
13093 unix_seconds_now(),
13094 ],
13095 )?;
13096 Ok(())
13097}
13098
13099fn clear_backend_state_for_file(
13100 tx: &Transaction<'_>,
13101 project_root: &Path,
13102 rel_path: &str,
13103) -> Result<()> {
13104 tx.execute(
13105 "DELETE FROM backend_file_state
13106 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
13107 params![
13108 BACKEND_TREESITTER,
13109 project_root.display().to_string(),
13110 rel_path
13111 ],
13112 )?;
13113 Ok(())
13114}
13115
13116fn clear_stale_backend_status_for_file(
13122 tx: &Transaction<'_>,
13123 project_root: &Path,
13124 rel_path: &str,
13125) -> Result<()> {
13126 tx.execute(
13127 "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
13128 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3 AND status = 'stale'",
13129 params![
13130 BACKEND_TREESITTER,
13131 project_root.display().to_string(),
13132 rel_path,
13133 unix_seconds_now(),
13134 ],
13135 )?;
13136 Ok(())
13137}
13138
13139fn load_file_row(conn: &Connection, rel_path: &str) -> Result<Option<FileRow>> {
13140 conn.query_row(
13141 "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
13142 params![rel_path],
13143 |row| {
13144 let hash_text: String = row.get(1)?;
13145 Ok(FileRow {
13146 surface_fingerprint: row.get(0)?,
13147 freshness: FileFreshness {
13148 content_hash: hash_from_hex(&hash_text)
13149 .unwrap_or_else(cache_freshness::zero_hash),
13150 mtime: ns_to_system_time(row.get::<_, i64>(2)?),
13151 size: row.get::<_, i64>(3)? as u64,
13152 },
13153 })
13154 },
13155 )
13156 .optional()
13157 .map_err(CallGraphStoreError::from)
13158}
13159
13160fn stored_node_ids_match_extract(
13161 tx: &Transaction<'_>,
13162 rel_path: &str,
13163 extract: &FileExtract,
13164) -> Result<bool> {
13165 let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
13166 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
13167 let mut stored = BTreeSet::new();
13168 for row in rows {
13169 stored.insert(row?);
13170 }
13171 let extracted = extract
13172 .nodes
13173 .iter()
13174 .map(|node| node.id.clone())
13175 .collect::<BTreeSet<_>>();
13176 Ok(stored == extracted)
13177}
13178
13179fn stored_extract_matches(
13183 tx: &Transaction<'_>,
13184 rel_path: &str,
13185 extract: &FileExtract,
13186 index: &ProjectIndex<'_>,
13187) -> Result<bool> {
13188 let stored_file = tx
13189 .query_row(
13190 "SELECT lang, surface_fingerprint FROM files WHERE path = ?1",
13191 params![rel_path],
13192 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
13193 )
13194 .optional()?;
13195 if stored_file
13196 != Some((
13197 lang_label(extract.lang).to_string(),
13198 extract.surface_fingerprint.clone(),
13199 ))
13200 {
13201 return Ok(false);
13202 }
13203
13204 let mut stored_nodes_stmt = tx.prepare(
13205 "SELECT id, file_path, name, scoped_name, kind, start_line, start_col,
13206 end_line, end_col, range_ordinal, signature, exported,
13207 is_default_export, is_type_like, is_callgraph_entry_point, provenance
13208 FROM nodes WHERE file_path = ?1",
13209 )?;
13210 let stored_nodes = stored_nodes_stmt
13211 .query_map(params![rel_path], |row| {
13212 Ok(serde_json::json!([
13213 row.get::<_, String>(0)?,
13214 row.get::<_, String>(1)?,
13215 row.get::<_, String>(2)?,
13216 row.get::<_, String>(3)?,
13217 row.get::<_, String>(4)?,
13218 row.get::<_, i64>(5)?,
13219 row.get::<_, i64>(6)?,
13220 row.get::<_, i64>(7)?,
13221 row.get::<_, i64>(8)?,
13222 row.get::<_, i64>(9)?,
13223 row.get::<_, Option<String>>(10)?,
13224 row.get::<_, i64>(11)?,
13225 row.get::<_, i64>(12)?,
13226 row.get::<_, i64>(13)?,
13227 row.get::<_, i64>(14)?,
13228 row.get::<_, String>(15)?,
13229 ])
13230 .to_string())
13231 })?
13232 .collect::<rusqlite::Result<Vec<_>>>()?;
13233 let expected_nodes = extract
13234 .nodes
13235 .iter()
13236 .map(|node| {
13237 serde_json::json!([
13238 node.id,
13239 node.file_path,
13240 node.name,
13241 node.scoped_name,
13242 node.kind,
13243 node.range.start_line,
13244 node.range.start_col,
13245 node.range.end_line,
13246 node.range.end_col,
13247 node.range_ordinal,
13248 node.signature,
13249 bool_int(node.exported),
13250 bool_int(node.is_default_export),
13251 bool_int(node.is_type_like),
13252 bool_int(node.is_callgraph_entry_point),
13253 PROVENANCE_TREESITTER,
13254 ])
13255 .to_string()
13256 })
13257 .collect::<Vec<_>>();
13258 let mut stored_nodes = stored_nodes;
13259 let mut expected_nodes = expected_nodes;
13260 stored_nodes.sort();
13261 expected_nodes.sort();
13262 if stored_nodes != expected_nodes {
13263 return Ok(false);
13264 }
13265
13266 let resolved_refs = extract
13267 .raw_refs
13268 .iter()
13269 .cloned()
13270 .map(|raw| resolve_ref(raw, index))
13271 .collect::<Result<Vec<_>>>()?;
13272 let mut stored_refs_stmt = tx.prepare(
13273 "SELECT ref_id, caller_node, caller_file, kind, short_name, full_ref,
13274 module_path, import_kind, local_name, requested_name, namespace_alias,
13275 wildcard, line, byte_start, byte_end, status, target_node,
13276 target_file, target_symbol, provenance
13277 FROM refs WHERE caller_file = ?1",
13278 )?;
13279 let stored_refs = stored_refs_stmt
13280 .query_map(params![rel_path], |row| {
13281 Ok(serde_json::json!([
13282 row.get::<_, String>(0)?,
13283 row.get::<_, Option<String>>(1)?,
13284 row.get::<_, String>(2)?,
13285 row.get::<_, String>(3)?,
13286 row.get::<_, Option<String>>(4)?,
13287 row.get::<_, Option<String>>(5)?,
13288 row.get::<_, Option<String>>(6)?,
13289 row.get::<_, Option<String>>(7)?,
13290 row.get::<_, Option<String>>(8)?,
13291 row.get::<_, Option<String>>(9)?,
13292 row.get::<_, Option<String>>(10)?,
13293 row.get::<_, i64>(11)?,
13294 row.get::<_, i64>(12)?,
13295 row.get::<_, i64>(13)?,
13296 row.get::<_, i64>(14)?,
13297 row.get::<_, String>(15)?,
13298 row.get::<_, Option<String>>(16)?,
13299 row.get::<_, Option<String>>(17)?,
13300 row.get::<_, Option<String>>(18)?,
13301 row.get::<_, String>(19)?,
13302 ])
13303 .to_string())
13304 })?
13305 .collect::<rusqlite::Result<Vec<_>>>()?;
13306 let expected_refs = resolved_refs
13307 .iter()
13308 .map(|resolved| {
13309 let raw = &resolved.raw;
13310 serde_json::json!([
13311 raw.ref_id,
13312 raw.caller_node,
13313 raw.caller_file,
13314 raw.kind,
13315 raw.short_name,
13316 raw.full_ref,
13317 raw.module_path,
13318 raw.import_kind,
13319 raw.local_name,
13320 raw.requested_name,
13321 raw.namespace_alias,
13322 bool_int(raw.wildcard),
13323 raw.line,
13324 raw.byte_start,
13325 raw.byte_end,
13326 resolved.status,
13327 resolved.target_node,
13328 resolved.target_file,
13329 resolved.target_symbol,
13330 PROVENANCE_TREESITTER,
13331 ])
13332 .to_string()
13333 })
13334 .collect::<Vec<_>>();
13335 let mut stored_refs = stored_refs;
13336 let mut expected_refs = expected_refs;
13337 stored_refs.sort();
13338 expected_refs.sort();
13339 if stored_refs != expected_refs {
13340 return Ok(false);
13341 }
13342
13343 let mut stored_edges_stmt = tx.prepare(
13344 "SELECT e.edge_id, e.ref_id, e.source_node, e.target_node,
13345 e.target_file, e.target_symbol, e.kind, e.line, e.provenance
13346 FROM edges e JOIN refs r ON r.ref_id = e.ref_id
13347 WHERE r.caller_file = ?1 AND e.provenance = ?2",
13348 )?;
13349 let stored_edges = stored_edges_stmt
13350 .query_map(params![rel_path, PROVENANCE_TREESITTER], |row| {
13351 Ok(serde_json::json!([
13352 row.get::<_, String>(0)?,
13353 row.get::<_, String>(1)?,
13354 row.get::<_, String>(2)?,
13355 row.get::<_, Option<String>>(3)?,
13356 row.get::<_, String>(4)?,
13357 row.get::<_, String>(5)?,
13358 row.get::<_, String>(6)?,
13359 row.get::<_, i64>(7)?,
13360 row.get::<_, String>(8)?,
13361 ])
13362 .to_string())
13363 })?
13364 .collect::<rusqlite::Result<Vec<_>>>()?;
13365 let expected_edges = resolved_refs
13366 .iter()
13367 .filter_map(|resolved| {
13368 resolved.edge.as_ref().map(|edge| {
13369 serde_json::json!([
13370 edge.edge_id,
13371 resolved.raw.ref_id,
13372 edge.source_node,
13373 edge.target_node,
13374 edge.target_file,
13375 edge.target_symbol,
13376 edge.kind,
13377 edge.line,
13378 PROVENANCE_TREESITTER,
13379 ])
13380 .to_string()
13381 })
13382 })
13383 .collect::<Vec<_>>();
13384 let mut stored_edges = stored_edges;
13385 let mut expected_edges = expected_edges;
13386 stored_edges.sort();
13387 expected_edges.sort();
13388 if stored_edges != expected_edges {
13389 return Ok(false);
13390 }
13391
13392 let mut stored_dependencies_stmt =
13393 tx.prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1")?;
13394 let stored_dependencies = stored_dependencies_stmt
13395 .query_map(params![rel_path], |row| row.get::<_, String>(0))?
13396 .collect::<rusqlite::Result<BTreeSet<_>>>()?;
13397 let expected_dependencies = extract
13398 .raw_refs
13399 .iter()
13400 .flat_map(|raw| raw.dependencies.iter().cloned())
13401 .collect::<BTreeSet<_>>();
13402 if stored_dependencies != expected_dependencies {
13403 return Ok(false);
13404 }
13405
13406 let mut stored_hints_stmt = tx.prepare(
13407 "SELECT id, method_name, caller_node, file, line, byte_start, byte_end, provenance
13408 FROM dispatch_hints WHERE file = ?1",
13409 )?;
13410 let stored_hints = stored_hints_stmt
13411 .query_map(params![rel_path], |row| {
13412 Ok(serde_json::json!([
13413 row.get::<_, String>(0)?,
13414 row.get::<_, String>(1)?,
13415 row.get::<_, String>(2)?,
13416 row.get::<_, String>(3)?,
13417 row.get::<_, i64>(4)?,
13418 row.get::<_, i64>(5)?,
13419 row.get::<_, i64>(6)?,
13420 row.get::<_, String>(7)?,
13421 ])
13422 .to_string())
13423 })?
13424 .collect::<rusqlite::Result<Vec<_>>>()?;
13425 let expected_hints = extract
13426 .dispatch_hints
13427 .iter()
13428 .map(|hint| {
13429 serde_json::json!([
13430 hint.id,
13431 hint.method_name,
13432 hint.caller_node,
13433 hint.file,
13434 hint.line,
13435 hint.byte_start,
13436 hint.byte_end,
13437 PROVENANCE_TREESITTER,
13438 ])
13439 .to_string()
13440 })
13441 .collect::<Vec<_>>();
13442 let mut stored_hints = stored_hints;
13443 let mut expected_hints = expected_hints;
13444 stored_hints.sort();
13445 expected_hints.sort();
13446 Ok(stored_hints == expected_hints)
13447}
13448
13449fn update_file_fresh_metadata(
13450 tx: &Transaction<'_>,
13451 project_root: &Path,
13452 rel_path: &str,
13453 hash: &blake3::Hash,
13454 mtime: SystemTime,
13455 size: u64,
13456) -> Result<()> {
13457 tx.execute(
13458 "UPDATE files SET content_hash = ?2, mtime_ns = ?3, size = ?4, indexed_at = ?5
13459 WHERE path = ?1",
13460 params![
13461 rel_path,
13462 hash_to_hex(*hash),
13463 system_time_to_ns(mtime),
13464 size as i64,
13465 unix_seconds_now()
13466 ],
13467 )?;
13468 tx.execute(
13469 "UPDATE backend_file_state SET content_hash = ?3, status = 'fresh', updated_at = ?5
13470 WHERE backend = ?1 AND file_path = ?2 AND workspace_root = ?4",
13471 params![
13472 BACKEND_TREESITTER,
13473 rel_path,
13474 hash_to_hex(*hash),
13475 project_root.display().to_string(),
13476 unix_seconds_now(),
13477 ],
13478 )?;
13479 Ok(())
13480}
13481
13482#[derive(Debug, Clone, PartialEq, Eq)]
13483struct DependentRefSelection {
13484 ref_id: String,
13485 caller_file: String,
13486}
13487
13488fn ref_ids_depending_on(
13489 conn: &Connection,
13490 project_root: &Path,
13491 rel_path: &str,
13492) -> Result<Vec<DependentRefSelection>> {
13493 let mut stmt = conn.prepare(
13494 "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
13495 FROM refs r
13496 WHERE r.caller_file IN (
13497 SELECT file_path FROM file_dependencies WHERE dep_file = ?1
13498 )
13499 OR r.target_file = ?1
13500 ORDER BY r.ref_id",
13501 )?;
13502 let rows = stmt.query_map(params![rel_path], |row| {
13503 Ok(RefDependencyRow {
13504 ref_id: row.get(0)?,
13505 kind: row.get(1)?,
13506 caller_file: row.get(2)?,
13507 module_path: row.get(3)?,
13508 target_file: row.get(4)?,
13509 })
13510 })?;
13511 let mut ids = Vec::new();
13512 for row in rows {
13513 let row = row?;
13514 if ref_dependency_row_depends_on(project_root, &row, rel_path) {
13515 ids.push(DependentRefSelection {
13516 ref_id: row.ref_id,
13517 caller_file: row.caller_file,
13518 });
13519 }
13520 }
13521 Ok(ids)
13522}
13523
13524fn record_dependent_refs(
13525 selected_ref_ids: &mut BTreeSet<String>,
13526 selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
13527 dependent_refs: Vec<DependentRefSelection>,
13528) {
13529 for dependent_ref in dependent_refs {
13530 let DependentRefSelection {
13531 ref_id,
13532 caller_file,
13533 } = dependent_ref;
13534 selected_ref_ids.insert(ref_id.clone());
13535 selected_refs_by_caller
13536 .entry(caller_file)
13537 .or_default()
13538 .insert(ref_id);
13539 }
13540}
13541
13542#[cfg(test)]
13543fn refs_by_caller_for_ref_ids(
13544 tx: &Transaction<'_>,
13545 ref_ids: &BTreeSet<String>,
13546) -> Result<BTreeMap<String, BTreeSet<String>>> {
13547 let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
13548 let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
13549 for ref_id in ref_ids {
13550 if let Some(caller) = stmt
13551 .query_row(params![ref_id], |row| row.get::<_, String>(0))
13552 .optional()?
13553 {
13554 by_caller.entry(caller).or_default().insert(ref_id.clone());
13555 }
13556 }
13557 Ok(by_caller)
13558}
13559
13560fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
13561 tx.execute(
13562 "DELETE FROM file_dependencies WHERE file_path = ?1",
13563 params![rel_path],
13564 )?;
13565 delete_refs_for_caller(tx, rel_path)?;
13566 tx.execute(
13567 "DELETE FROM dispatch_hints WHERE file = ?1",
13568 params![rel_path],
13569 )?;
13570 tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
13571 tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
13572 Ok(())
13573}
13574
13575fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
13576 let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
13577 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
13578 let mut ids = BTreeSet::new();
13579 for row in rows {
13580 ids.insert(row?);
13581 }
13582 delete_ref_ids(tx, &ids)
13583}
13584
13585fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
13586 let mut delete_edges = tx.prepare("DELETE FROM edges WHERE ref_id = ?1")?;
13587 let mut delete_refs = tx.prepare("DELETE FROM refs WHERE ref_id = ?1")?;
13588 for ref_id in ref_ids {
13589 delete_edges.execute(params![ref_id])?;
13590 delete_refs.execute(params![ref_id])?;
13591 }
13592 Ok(())
13593}
13594
13595fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
13596 let mut stmt = conn.prepare(
13597 "SELECT source.file_path, source.scoped_name, edges.target_file,
13598 edges.target_symbol, edges.kind, edges.line
13599 FROM edges
13600 JOIN nodes AS source ON source.id = edges.source_node
13601 ORDER BY source.file_path, source.scoped_name, edges.target_file,
13602 edges.target_symbol, edges.kind, edges.line",
13603 )?;
13604 let rows = stmt.query_map([], |row| {
13605 Ok(StoredEdge {
13606 source_file: row.get(0)?,
13607 source_symbol: row.get(1)?,
13608 target_file: row.get(2)?,
13609 target_symbol: row.get(3)?,
13610 kind: row.get(4)?,
13611 line: row.get::<_, i64>(5)? as u32,
13612 })
13613 })?;
13614 let mut edges = BTreeSet::new();
13615 for row in rows {
13616 edges.insert(row?);
13617 }
13618 Ok(edges)
13619}
13620
13621fn module_target_from_dependencies(
13622 project_root: &Path,
13623 dependencies: &BTreeSet<String>,
13624) -> Option<String> {
13625 dependencies.iter().find_map(|dep| {
13626 let path = project_root.join(dep);
13627 if path.is_file() {
13628 Some(relative_path(project_root, &canonicalize_path(&path)))
13629 } else {
13630 None
13631 }
13632 })
13633}
13634
13635fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
13636 let mut named = HashMap::new();
13637 if let Some(full_ref) = &raw_ref.full_ref {
13638 named = parse_reexport_names(full_ref);
13639 }
13640 ReexportIndex {
13641 target_file,
13642 named,
13643 wildcard: raw_ref.wildcard,
13644 }
13645}
13646
13647fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
13648 let mut names = HashMap::new();
13649 let Some(open) = statement.find('{') else {
13650 return names;
13651 };
13652 let Some(close) = statement[open + 1..]
13653 .find('}')
13654 .map(|offset| open + 1 + offset)
13655 else {
13656 return names;
13657 };
13658 for spec in statement[open + 1..close].split(',') {
13659 let spec = spec.trim();
13660 if spec.is_empty() {
13661 continue;
13662 }
13663 if let Some((source, local)) = spec.split_once(" as ") {
13664 names.insert(local.trim().to_string(), source.trim().to_string());
13665 } else {
13666 names.insert(spec.to_string(), spec.to_string());
13667 }
13668 }
13669 names
13670}
13671
13672#[derive(Debug)]
13673struct RefDependencyRow {
13674 ref_id: String,
13675 kind: String,
13676 caller_file: String,
13677 module_path: Option<String>,
13678 target_file: Option<String>,
13679}
13680
13681fn ref_dependency_row_depends_on(
13682 project_root: &Path,
13683 row: &RefDependencyRow,
13684 rel_path: &str,
13685) -> bool {
13686 if row.target_file.as_deref() == Some(rel_path) {
13687 return true;
13688 }
13689
13690 match row.kind.as_str() {
13691 "call" => true,
13692 "import" | "reexport" => row
13693 .module_path
13694 .as_deref()
13695 .map(|module_path| {
13696 module_dependencies_for_ref(project_root, &row.caller_file, module_path)
13697 .contains(rel_path)
13698 })
13699 .unwrap_or(false),
13700 "export_alias" => false,
13701 _ => false,
13702 }
13703}
13704
13705fn module_dependencies_for_ref(
13706 project_root: &Path,
13707 caller_file: &str,
13708 module_path: &str,
13709) -> BTreeSet<String> {
13710 module_dependencies(project_root, &project_root.join(caller_file), module_path)
13711}
13712
13713fn import_dependencies(
13714 project_root: &Path,
13715 abs_path: &Path,
13716 imports: &[ImportStatement],
13717) -> BTreeSet<String> {
13718 let mut deps = BTreeSet::new();
13719 for import in imports {
13720 deps.extend(module_dependencies(
13721 project_root,
13722 abs_path,
13723 &import.module_path,
13724 ));
13725 }
13726 deps
13727}
13728
13729fn module_dependencies(
13730 project_root: &Path,
13731 abs_path: &Path,
13732 module_path: &str,
13733) -> BTreeSet<String> {
13734 let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
13735 let caller_dir = abs_path.parent().unwrap_or(project_root);
13736 if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
13737 deps.insert(relative_path(project_root, &resolved));
13738 }
13739 if module_path.starts_with('.') {
13740 let base = caller_dir.join(module_path);
13741 for candidate in relative_module_candidates(&base) {
13742 deps.insert(relative_path(project_root, &candidate));
13743 }
13744 }
13745 deps
13746}
13747
13748fn rust_module_dependencies(
13749 project_root: &Path,
13750 abs_path: &Path,
13751 module_path: &str,
13752) -> BTreeSet<String> {
13753 let mut deps = BTreeSet::new();
13754 let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
13755 let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
13756 return deps;
13757 };
13758 let src_prefix = rust_src_prefix(&rel_path);
13759 rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
13760 if !path_segments.is_empty() {
13761 rust_push_module_dependency_candidate(
13762 project_root,
13763 &mut deps,
13764 &src_prefix,
13765 &path_segments[..path_segments.len() - 1],
13766 );
13767 }
13768 deps
13769}
13770
13771fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
13772 let path = rust_module_path_without_alias_or_use_list(module_path);
13773 let segments = path
13774 .split("::")
13775 .map(str::trim)
13776 .filter(|segment| !segment.is_empty())
13777 .collect::<Vec<_>>();
13778 if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
13779 return None;
13780 }
13781 rust_resolve_segments(rel_path, &segments)
13782}
13783
13784fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
13785 let path = module_path
13786 .trim()
13787 .trim_end_matches(';')
13788 .split_once(" as ")
13789 .map(|(left, _)| left.trim())
13790 .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
13791 path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
13792}
13793
13794fn rust_push_module_dependency_candidate(
13795 project_root: &Path,
13796 deps: &mut BTreeSet<String>,
13797 src_prefix: &str,
13798 segments: &[String],
13799) {
13800 let candidates = if segments.is_empty() {
13801 vec![
13802 format!("{src_prefix}/lib.rs"),
13803 format!("{src_prefix}/main.rs"),
13804 ]
13805 } else {
13806 vec![
13807 format!("{}/{}.rs", src_prefix, segments.join("/")),
13808 format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
13809 ]
13810 };
13811 for candidate in candidates {
13812 if project_root.join(&candidate).is_file() {
13813 deps.insert(candidate);
13814 }
13815 }
13816}
13817
13818fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
13819 let mut candidates = Vec::new();
13820 if base.extension().is_some() {
13821 candidates.push(base.to_path_buf());
13822 return candidates;
13823 }
13824 for ext in JS_TS_EXTENSIONS {
13825 candidates.push(base.with_extension(ext));
13826 }
13827 for ext in JS_TS_EXTENSIONS {
13828 candidates.push(base.join(format!("index.{ext}")));
13829 }
13830 candidates
13831}
13832
13833fn import_local_names(import: &ImportStatement) -> Vec<String> {
13834 let mut names = Vec::new();
13835 if let Some(default) = &import.default_import {
13836 names.push(default.clone());
13837 }
13838 if let Some(namespace) = &import.namespace_import {
13839 names.push(namespace.clone());
13840 }
13841 for name in &import.names {
13842 names.push(crate::imports::specifier_local_name(name).to_string());
13843 }
13844 names
13845}
13846
13847fn import_requested_names(import: &ImportStatement) -> Vec<String> {
13848 import
13849 .names
13850 .iter()
13851 .map(|name| crate::imports::specifier_imported_name(name).to_string())
13852 .collect()
13853}
13854
13855fn import_is_wildcard(import: &ImportStatement) -> bool {
13856 import.namespace_import.is_some() || import.raw_text.contains('*')
13857}
13858
13859fn namespace_alias(full_ref: &str) -> Option<String> {
13860 full_ref
13861 .split_once('.')
13862 .map(|(namespace, _)| namespace.to_string())
13863}
13864
13865fn import_kind_label(kind: ImportKind) -> &'static str {
13866 match kind {
13867 ImportKind::Value => "value",
13868 ImportKind::Type => "type",
13869 ImportKind::SideEffect => "side_effect",
13870 }
13871}
13872
13873fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
13874 match kind {
13875 SymbolKind::Function => "function",
13876 SymbolKind::Class => "class",
13877 SymbolKind::Method => "method",
13878 SymbolKind::Struct => "struct",
13879 SymbolKind::Interface => "interface",
13880 SymbolKind::Enum => "enum",
13881 SymbolKind::TypeAlias => "type_alias",
13882 SymbolKind::Variable => "variable",
13883 SymbolKind::Heading => "heading",
13884 SymbolKind::FileSummary => "file_summary",
13885 }
13886}
13887
13888fn is_type_like(kind: &SymbolKind) -> bool {
13889 matches!(
13890 kind,
13891 SymbolKind::Class
13892 | SymbolKind::Struct
13893 | SymbolKind::Interface
13894 | SymbolKind::Enum
13895 | SymbolKind::TypeAlias
13896 )
13897}
13898
13899fn lang_label(lang: LangId) -> &'static str {
13900 match lang {
13901 LangId::TypeScript => "typescript",
13902 LangId::Tsx => "tsx",
13903 LangId::JavaScript => "javascript",
13904 LangId::Python => "python",
13905 LangId::Rust => "rust",
13906 LangId::Go => "go",
13907 LangId::C => "c",
13908 LangId::Cpp => "cpp",
13909 LangId::Zig => "zig",
13910 LangId::CSharp => "csharp",
13911 LangId::Bash => "bash",
13912 LangId::Html => "html",
13913 LangId::Markdown => "markdown",
13914 LangId::Solidity => "solidity",
13915 LangId::Scss => "scss",
13916 LangId::Vue => "vue",
13917 LangId::Json => "json",
13918 LangId::Scala => "scala",
13919 LangId::Java => "java",
13920 LangId::Ruby => "ruby",
13921 LangId::Kotlin => "kotlin",
13922 LangId::Swift => "swift",
13923 LangId::Php => "php",
13924 LangId::Lua => "lua",
13925 LangId::Perl => "perl",
13926 LangId::Yaml => "yaml",
13927 LangId::Pascal => "pascal",
13928 LangId::R => "r",
13929 LangId::Groovy => "groovy",
13930 LangId::ObjC => "objc",
13931 }
13932}
13933
13934fn lang_from_label(label: &str) -> Option<LangId> {
13935 match label {
13936 "typescript" => Some(LangId::TypeScript),
13937 "tsx" => Some(LangId::Tsx),
13938 "javascript" => Some(LangId::JavaScript),
13939 "python" => Some(LangId::Python),
13940 "rust" => Some(LangId::Rust),
13941 "go" => Some(LangId::Go),
13942 "c" => Some(LangId::C),
13943 "cpp" => Some(LangId::Cpp),
13944 "zig" => Some(LangId::Zig),
13945 "csharp" => Some(LangId::CSharp),
13946 "bash" => Some(LangId::Bash),
13947 "html" => Some(LangId::Html),
13948 "markdown" => Some(LangId::Markdown),
13949 "solidity" => Some(LangId::Solidity),
13950 "scss" => Some(LangId::Scss),
13951 "vue" => Some(LangId::Vue),
13952 "json" => Some(LangId::Json),
13953 "scala" => Some(LangId::Scala),
13954 "java" => Some(LangId::Java),
13955 "ruby" => Some(LangId::Ruby),
13956 "kotlin" => Some(LangId::Kotlin),
13957 "swift" => Some(LangId::Swift),
13958 "php" => Some(LangId::Php),
13959 "lua" => Some(LangId::Lua),
13960 "perl" => Some(LangId::Perl),
13961 "yaml" => Some(LangId::Yaml),
13962 "pascal" => Some(LangId::Pascal),
13963 "r" => Some(LangId::R),
13964 "groovy" => Some(LangId::Groovy),
13965 "objc" => Some(LangId::ObjC),
13966 _ => None,
13967 }
13968}
13969
13970fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
13971 let mut normalized = if files.is_empty() {
13972 callgraph::walk_project_files(project_root).collect::<Vec<_>>()
13973 } else {
13974 files
13975 .iter()
13976 .map(|path| normalize_file_path(project_root, path))
13977 .collect::<Result<Vec<_>>>()?
13978 };
13979 normalized.sort();
13980 normalized.dedup();
13981 Ok(normalized)
13982}
13983
13984fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
13985 let full_path = if path.is_relative() {
13986 project_root.join(path)
13987 } else {
13988 path.to_path_buf()
13989 };
13990 Ok(canonicalize_path(&full_path))
13991}
13992
13993fn normalize_project_file_path(project_root: &Path, path: &Path) -> Result<(PathBuf, String)> {
13997 let abs_path = normalize_file_path(project_root, path)?;
13998 let rel_path = relative_path(project_root, &abs_path);
13999 if Path::new(&rel_path).is_absolute() {
14000 return Err(CallGraphStoreError::PathIdentityMismatch {
14001 path: path.to_path_buf(),
14002 project_root: project_root.to_path_buf(),
14003 });
14004 }
14005 Ok((abs_path, rel_path))
14006}
14007
14008fn canonicalize_path(path: &Path) -> PathBuf {
14012 if let Ok(canonical) = std::fs::canonicalize(path) {
14013 return canonical;
14014 }
14015
14016 let mut resolved = PathBuf::new();
14017 let mut missing = Vec::new();
14018 for component in path.components() {
14019 match component {
14020 std::path::Component::Prefix(_) | std::path::Component::RootDir => {
14021 resolved.push(component.as_os_str());
14022 if let Ok(canonical) = std::fs::canonicalize(&resolved) {
14023 resolved = canonical;
14024 }
14025 }
14026 std::path::Component::CurDir => {}
14027 std::path::Component::ParentDir => {
14028 if missing.pop().is_none() {
14029 if !resolved.as_os_str().is_empty() && !resolved.is_dir() {
14030 return path.to_path_buf();
14031 }
14032 resolved.pop();
14033 }
14034 }
14035 std::path::Component::Normal(name) => {
14036 if missing.is_empty() {
14037 let candidate = resolved.join(name);
14038 match std::fs::canonicalize(&candidate) {
14039 Ok(canonical) => resolved = canonical,
14040 Err(_) => match std::fs::symlink_metadata(&candidate) {
14041 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
14042 missing.push(name.to_owned());
14043 }
14044 _ => return path.to_path_buf(),
14045 },
14046 }
14047 } else {
14048 missing.push(name.to_owned());
14049 }
14050 }
14051 }
14052 }
14053 resolved.extend(missing);
14054 resolved
14055}
14056
14057fn relative_path(project_root: &Path, path: &Path) -> String {
14058 if let Ok(stripped) = path.strip_prefix(project_root) {
14059 return stripped.to_string_lossy().replace('\\', "/");
14060 }
14061 let canon_root = canonicalize_path(project_root);
14062 let canon_path = canonicalize_path(path);
14063 if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
14064 return stripped.to_string_lossy().replace('\\', "/");
14065 }
14066 canon_path.to_string_lossy().replace('\\', "/")
14067}
14068
14069fn unqualified_name(scoped: &str) -> &str {
14070 if scoped == TOP_LEVEL_SYMBOL {
14071 return scoped;
14072 }
14073 scoped
14074 .rsplit("::")
14075 .next()
14076 .unwrap_or(scoped)
14077 .rsplit('.')
14078 .next()
14079 .unwrap_or(scoped)
14080 .rsplit('#')
14081 .next()
14082 .unwrap_or(scoped)
14083}
14084
14085fn ref_id(parts: &[&str]) -> String {
14086 let joined = parts.join("\0");
14087 hash_to_hex(blake3::hash(joined.as_bytes()))
14088}
14089
14090fn callgraph_corpus_fingerprint(project_root: &Path) -> Result<String> {
14091 let mut fingerprint = CorpusFingerprint::default();
14092 for path in callgraph::walk_project_files(project_root) {
14093 fingerprint.add_path(project_root, &path);
14094 }
14095 Ok(fingerprint.finish(project_root))
14096}
14097
14098fn corpus_fingerprint_for(project_root: &Path, files: &[PathBuf]) -> Result<String> {
14102 if files.is_empty() {
14103 return callgraph_corpus_fingerprint(project_root);
14104 }
14105 let mut fingerprint = CorpusFingerprint::default();
14106 for path in files {
14107 fingerprint.add_path(project_root, path);
14108 }
14109 Ok(fingerprint.finish(project_root))
14110}
14111
14112#[derive(Default)]
14113struct CorpusFingerprint {
14114 xor: [u8; 32],
14115 sums: [u64; 4],
14116 files: u64,
14117}
14118
14119impl CorpusFingerprint {
14120 fn add_path(&mut self, project_root: &Path, path: &Path) {
14121 let mut record = blake3::Hasher::new();
14122 record.update(relative_path(project_root, path).as_bytes());
14123 record.update(&[0]);
14124 match hash_file_bounded(path) {
14125 Ok(content_hash) => record.update(content_hash.as_bytes()),
14126 Err(error) => record.update(format!("missing:{error}").as_bytes()),
14129 };
14130 record.update(&[0]);
14131 let record = record.finalize();
14132 for (index, byte) in record.as_bytes().iter().copied().enumerate() {
14133 self.xor[index] ^= byte;
14134 }
14135 for (index, chunk) in record.as_bytes().chunks_exact(8).enumerate() {
14136 let value = u64::from_le_bytes(chunk.try_into().expect("eight-byte digest chunk"));
14137 self.sums[index] = self.sums[index].wrapping_add(value);
14138 }
14139 self.files = self.files.saturating_add(1);
14140 }
14141
14142 fn finish(self, project_root: &Path) -> String {
14143 let mut hasher = blake3::Hasher::new();
14146 hasher.update(b"callgraph-corpus-fingerprint-v2\0");
14147 hasher.update(&self.files.to_le_bytes());
14148 hasher.update(&self.xor);
14149 for sum in self.sums {
14150 hasher.update(&sum.to_le_bytes());
14151 }
14152 let ignore_rules = project_root.join(".gitignore");
14153 if let Ok(contents) = std::fs::read(ignore_rules) {
14154 hasher.update(b".gitignore\0");
14155 hasher.update(blake3::hash(&contents).as_bytes());
14156 }
14157 hash_to_hex(hasher.finalize())
14158 }
14159}
14160
14161fn hash_file_bounded(path: &Path) -> std::io::Result<blake3::Hash> {
14162 let mut file = std::fs::File::open(path)?;
14163 let mut hasher = blake3::Hasher::new();
14164 let mut buffer = [0u8; 64 * 1024];
14165 loop {
14166 let read = file.read(&mut buffer)?;
14167 if read == 0 {
14168 break;
14169 }
14170 hasher.update(&buffer[..read]);
14171 }
14172 Ok(hasher.finalize())
14173}
14174
14175#[cfg(test)]
14176pub(crate) fn callgraph_corpus_fingerprint_for_test(
14177 project_root: &Path,
14178 _files: &[PathBuf],
14179) -> Result<String> {
14180 callgraph_corpus_fingerprint(project_root)
14184}
14185
14186fn hash_to_hex(hash: blake3::Hash) -> String {
14187 hash.to_hex().to_string()
14188}
14189
14190fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
14191 let bytes = hex_to_bytes(value)?;
14192 Some(blake3::Hash::from_bytes(bytes))
14193}
14194
14195fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
14196 if value.len() != 64 {
14197 return None;
14198 }
14199 let mut bytes = [0u8; 32];
14200 for (index, slot) in bytes.iter_mut().enumerate() {
14201 let start = index * 2;
14202 let end = start + 2;
14203 *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
14204 }
14205 Some(bytes)
14206}
14207
14208#[derive(Debug, Clone)]
14209struct LineIndex {
14210 newline_offsets: Vec<usize>,
14211 source_len: usize,
14212}
14213
14214impl LineIndex {
14215 fn new(source: &str) -> Self {
14216 Self {
14217 newline_offsets: source
14218 .bytes()
14219 .enumerate()
14220 .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
14221 .collect(),
14222 source_len: source.len(),
14223 }
14224 }
14225
14226 fn byte_to_line(&self, byte_offset: usize) -> u32 {
14227 let byte_offset = byte_offset.min(self.source_len);
14228 self.newline_offsets
14229 .partition_point(|offset| *offset < byte_offset) as u32
14230 + 1
14231 }
14232}
14233
14234fn empty_to_none(value: String) -> Option<String> {
14235 if value.is_empty() {
14236 None
14237 } else {
14238 Some(value)
14239 }
14240}
14241
14242fn bool_int(value: bool) -> i64 {
14243 if value {
14244 1
14245 } else {
14246 0
14247 }
14248}
14249
14250fn system_time_to_ns(time: SystemTime) -> i64 {
14251 time.duration_since(UNIX_EPOCH)
14252 .unwrap_or_default()
14253 .as_nanos()
14254 .min(i64::MAX as u128) as i64
14255}
14256
14257fn ns_to_system_time(value: i64) -> SystemTime {
14258 UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
14259}
14260
14261pub(crate) fn unix_millis_now() -> u64 {
14262 SystemTime::now()
14263 .duration_since(UNIX_EPOCH)
14264 .unwrap_or_default()
14265 .as_millis()
14266 .min(u128::from(u64::MAX)) as u64
14267}
14268
14269fn unix_seconds_now() -> i64 {
14270 SystemTime::now()
14271 .duration_since(UNIX_EPOCH)
14272 .unwrap_or_default()
14273 .as_secs() as i64
14274}
14275
14276#[cfg(test)]
14281pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
14282
14283#[cfg(test)]
14284mod refresh_worker_tests {
14285 use super::*;
14286 use std::fs;
14287 use tempfile::tempdir;
14288
14289 fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
14290 let temp = tempdir().unwrap();
14291 let root = temp.path().join("root");
14292 fs::create_dir_all(&root).unwrap();
14293 let artifact_key = crate::search_index::artifact_cache_key(&root);
14294 crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
14295 let callgraph_dir = temp
14296 .path()
14297 .join("storage")
14298 .join("callgraph")
14299 .join(artifact_key);
14300 let source = root.join("main.rs");
14301 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
14302 let (store, _) = CallGraphStore::cold_build_with_lease(
14303 callgraph_dir.clone(),
14304 root.clone(),
14305 std::slice::from_ref(&source),
14306 )
14307 .unwrap();
14308 drop(store);
14309 (temp, root, callgraph_dir, source)
14310 }
14311
14312 fn pending_paths() -> PendingCallGraphStorePaths {
14313 Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
14314 }
14315
14316 fn wait_for_refresh_calls(root: &Path, expected: usize) {
14317 let deadline = Instant::now() + Duration::from_secs(12);
14318 while callgraph_refresh_worker_test_counts(root).0 < expected {
14319 assert!(
14320 Instant::now() < deadline,
14321 "timed out waiting for {expected} callgraph refresh worker call(s)"
14322 );
14323 std::thread::sleep(Duration::from_millis(5));
14324 }
14325 }
14326
14327 fn wait_for_refresh_worker_idle() {
14328 let deadline = Instant::now() + Duration::from_secs(12);
14329 loop {
14330 let worker = CALLGRAPH_REFRESH_WORKER
14331 .get_or_init(|| Mutex::new(None))
14332 .lock()
14333 .expect("callgraph refresh worker mutex poisoned")
14334 .clone();
14335 let idle = worker.is_none_or(|worker| {
14336 let queue = worker
14337 .shared
14338 .queue
14339 .lock()
14340 .expect("callgraph refresh queue mutex poisoned");
14341 queue.active.is_none() && queue.order.is_empty()
14342 });
14343 if idle {
14344 return;
14345 }
14346 assert!(
14347 Instant::now() < deadline,
14348 "timed out waiting for callgraph refresh worker to become idle"
14349 );
14350 std::thread::sleep(Duration::from_millis(5));
14351 }
14352 }
14353
14354 fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
14355 let temp = tempdir().unwrap();
14356 let root = temp.path().join("workspace");
14357 fs::create_dir_all(root.join("app/src")).unwrap();
14358 let artifact_key = crate::search_index::artifact_cache_key(&root);
14359 crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
14360 let callgraph_dir = temp
14361 .path()
14362 .join("storage")
14363 .join("callgraph")
14364 .join(artifact_key);
14365 fs::write(
14366 root.join("Cargo.toml"),
14367 "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
14368 )
14369 .unwrap();
14370 fs::write(
14371 root.join("app/Cargo.toml"),
14372 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
14373 )
14374 .unwrap();
14375 let caller = root.join("app/src/lib.rs");
14376 fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
14377 let (store, _) = CallGraphStore::cold_build_with_lease(
14378 callgraph_dir.clone(),
14379 root.clone(),
14380 std::slice::from_ref(&caller),
14381 )
14382 .unwrap();
14383 drop(store);
14384 (temp, root, callgraph_dir, caller)
14385 }
14386
14387 #[test]
14388 fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
14389 let _guard = REFRESH_WORKER_TEST_LOCK
14390 .lock()
14391 .unwrap_or_else(std::sync::PoisonError::into_inner);
14392 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14393 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
14394 reset_workspace_crate_prefix_build_count(&root);
14395 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14396
14397 for revision in ["first", "second"] {
14398 fs::write(
14399 &caller,
14400 format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
14401 )
14402 .unwrap();
14403 enqueue_callgraph_store_refresh(
14404 callgraph_dir.clone(),
14405 root.clone(),
14406 vec![caller.clone()],
14407 pending_paths(),
14408 );
14409 wait_for_refresh_worker_idle();
14410 }
14411
14412 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
14413 assert!(flush_callgraph_store_refreshes_with_budget(
14414 Duration::from_secs(5)
14415 ));
14416 clear_callgraph_refresh_worker_test_seam(&root);
14417 }
14418
14419 #[test]
14420 fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
14421 let _guard = REFRESH_WORKER_TEST_LOCK
14422 .lock()
14423 .unwrap_or_else(std::sync::PoisonError::into_inner);
14424 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14425 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
14426 reset_workspace_crate_prefix_build_count(&root);
14427 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14428
14429 fs::write(
14430 &caller,
14431 "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
14432 )
14433 .unwrap();
14434 enqueue_callgraph_store_refresh(
14435 callgraph_dir.clone(),
14436 root.clone(),
14437 vec![caller.clone()],
14438 pending_paths(),
14439 );
14440 wait_for_refresh_worker_idle();
14441 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
14442
14443 let added_manifest = root.join("added/Cargo.toml");
14444 let added_source = root.join("added/src/lib.rs");
14445 fs::create_dir_all(added_source.parent().unwrap()).unwrap();
14446 fs::write(
14447 root.join("Cargo.toml"),
14448 "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
14449 )
14450 .unwrap();
14451 fs::write(
14452 &added_manifest,
14453 "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
14454 )
14455 .unwrap();
14456 fs::write(&added_source, "pub fn target() {}\n").unwrap();
14457 fs::write(
14458 &caller,
14459 "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
14460 )
14461 .unwrap();
14462
14463 enqueue_callgraph_store_refresh(
14464 callgraph_dir.clone(),
14465 root.clone(),
14466 vec![
14467 root.join("Cargo.toml"),
14468 added_manifest,
14469 added_source,
14470 caller,
14471 ],
14472 pending_paths(),
14473 );
14474 assert!(flush_callgraph_store_refreshes_with_budget(
14475 Duration::from_secs(12)
14476 ));
14477
14478 assert_eq!(workspace_crate_prefix_build_count(&root), 2);
14482 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
14483 .unwrap()
14484 .expect("refreshed workspace store");
14485 let tree = store
14486 .call_tree(Path::new("app/src/lib.rs"), "run", 1)
14487 .unwrap();
14488 assert_eq!(tree.children.len(), 1);
14489 assert_eq!(tree.children[0].file, "added/src/lib.rs");
14490 assert_eq!(tree.children[0].name, "target");
14491 assert!(tree.children[0].resolved);
14492 clear_callgraph_refresh_worker_test_seam(&root);
14493 }
14494
14495 fn linked_worktree_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, PathBuf) {
14496 let temp = tempdir().unwrap();
14497 let main = temp.path().join("main");
14498 let worktree = temp.path().join("worktree");
14499 fs::create_dir_all(&main).unwrap();
14500 let mut git = std::process::Command::new("git");
14501 assert!(
14502 crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
14503 .status()
14504 .unwrap()
14505 .success()
14506 );
14507 fs::write(main.join("lib.rs"), "pub fn marker() {}\n").unwrap();
14508 for args in [
14509 vec![
14510 "-C",
14511 main.to_str().unwrap(),
14512 "config",
14513 "user.email",
14514 "test@example.com",
14515 ],
14516 vec![
14517 "-C",
14518 main.to_str().unwrap(),
14519 "config",
14520 "user.name",
14521 "AFT Test",
14522 ],
14523 vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
14524 vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
14525 ] {
14526 let mut command = std::process::Command::new("git");
14527 assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
14528 .status()
14529 .unwrap()
14530 .success());
14531 }
14532 let mut add_worktree = std::process::Command::new("git");
14533 assert!(crate::test_env::apply_hermetic_git_env(
14534 add_worktree
14535 .arg("-C")
14536 .arg(&main)
14537 .args(["worktree", "add", "--detach"])
14538 .arg(&worktree),
14539 )
14540 .status()
14541 .unwrap()
14542 .success());
14543 let main = fs::canonicalize(main).unwrap();
14544 let worktree = fs::canonicalize(worktree).unwrap();
14545 let project_key = crate::search_index::artifact_cache_key(&main);
14546 assert_eq!(
14547 crate::search_index::artifact_cache_key(&worktree),
14548 project_key
14549 );
14550 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
14551 (temp, main, worktree, project_key, callgraph_dir)
14552 }
14553
14554 #[test]
14555 fn linked_worktree_never_acquires_writer_or_publishes_any_build_path() {
14556 let _git_env = crate::test_env::hermetic_git_env_guard();
14557 let (_temp, _main, root, project_key, callgraph_dir) = linked_worktree_fixture();
14558 crate::root_cache::configure_artifact_access(&root, &project_key, true);
14559 crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
14560 let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14561 let publications_for_observer = Arc::clone(&publications);
14562 set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
14563 publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
14564 })));
14565 let source = root.join("lib.rs");
14566
14567 let open_error = CallGraphStore::open(callgraph_dir.clone(), root.clone())
14568 .expect_err("borrow-only writable open must remain unavailable");
14569 assert!(matches!(open_error, CallGraphStoreError::Unavailable(_)));
14570 assert!(
14571 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), root.clone())
14572 .unwrap()
14573 .is_none()
14574 );
14575 assert!(
14576 CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), root.clone())
14577 .unwrap()
14578 .is_none()
14579 );
14580 assert!(matches!(
14581 CallGraphStore::cold_build_with_lease(
14582 callgraph_dir.clone(),
14583 root.clone(),
14584 std::slice::from_ref(&source),
14585 ),
14586 Err(CallGraphStoreError::Unavailable(_))
14587 ));
14588 assert!(matches!(
14589 CallGraphStore::ensure_built_with_lease(
14590 callgraph_dir.clone(),
14591 root.clone(),
14592 std::slice::from_ref(&source),
14593 ),
14594 Err(CallGraphStoreError::Unavailable(_))
14595 ));
14596 let force_error = CallGraphStore::force_cold_build_with_lease_chunked(
14597 callgraph_dir.clone(),
14598 root.clone(),
14599 &[source],
14600 1,
14601 )
14602 .expect_err("borrow-only forced rebuild must remain unsatisfied");
14603 set_cold_build_swap_observer(None);
14604
14605 assert!(matches!(force_error, CallGraphStoreError::Unavailable(_)));
14606 assert_eq!(
14607 crate::root_cache::writer_lease_acquisition_count_for_test(
14608 crate::root_cache::RootCacheDomain::Callgraph,
14609 &project_key,
14610 &root,
14611 ),
14612 0
14613 );
14614 assert_eq!(publications.load(AtomicOrdering::SeqCst), 0);
14615 assert!(!pointer_path(&callgraph_dir, &project_key).exists());
14616 }
14617
14618 #[test]
14619 fn owner_and_linked_worktree_alternation_rebuilds_storm_generation_once() {
14620 let _git_env = crate::test_env::hermetic_git_env_guard();
14621 let (_temp, owner, worktree, project_key, callgraph_dir) = linked_worktree_fixture();
14622 crate::root_cache::configure_artifact_access(&owner, &project_key, false);
14623 crate::root_cache::configure_artifact_access(&worktree, &project_key, true);
14624 let source = owner.join("lib.rs");
14625 let (store, _) = CallGraphStore::cold_build_with_lease(
14626 callgraph_dir.clone(),
14627 owner.clone(),
14628 std::slice::from_ref(&source),
14629 )
14630 .unwrap();
14631 let sqlite_path = store.sqlite_path().to_path_buf();
14632 drop(store);
14633
14634 let conn = Connection::open(&sqlite_path).unwrap();
14635 conn.execute(
14636 "UPDATE backend_file_state SET workspace_root = ?1",
14637 [worktree.display().to_string()],
14638 )
14639 .unwrap();
14640 drop(conn);
14641
14642 let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14643 let publications_for_observer = Arc::clone(&publications);
14644 set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
14645 publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
14646 })));
14647 crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
14648
14649 let repaired = CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
14650 .unwrap()
14651 .expect("owner should purge the storm-era worktree root");
14652 drop(repaired);
14653 for _ in 0..3 {
14654 let borrower = CallGraphStore::open_readonly(callgraph_dir.clone(), worktree.clone())
14655 .unwrap()
14656 .expect("linked worktree should borrow the owner generation");
14657 drop(borrower);
14658 assert!(
14659 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), worktree.clone())
14660 .unwrap()
14661 .is_none()
14662 );
14663 let owner_store =
14664 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
14665 .unwrap()
14666 .expect("owner generation should remain ready");
14667 drop(owner_store);
14668 }
14669 set_cold_build_swap_observer(None);
14670
14671 assert_eq!(
14672 publications.load(AtomicOrdering::SeqCst),
14673 1,
14674 "the owner performs one expected post-storm purge and alternation stays read-only"
14675 );
14676 assert_eq!(
14677 crate::root_cache::writer_lease_acquisition_count_for_test(
14678 crate::root_cache::RootCacheDomain::Callgraph,
14679 &project_key,
14680 &worktree,
14681 ),
14682 0
14683 );
14684 }
14685
14686 #[test]
14687 fn rebuild_cooldown_records_only_successful_publication_per_cache_key() {
14688 let temp = tempdir().unwrap();
14689 let root = temp.path().join("owner");
14690 let other_root = temp.path().join("other");
14691 fs::create_dir_all(&root).unwrap();
14692 fs::create_dir_all(&other_root).unwrap();
14693 let source = root.join("lib.rs");
14694 fs::write(&source, "pub fn marker() {}\n").unwrap();
14695 let project_key = crate::search_index::artifact_cache_key(&root);
14696 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
14697 crate::root_cache::configure_artifact_access(&root, &project_key, false);
14698 let cooldown_key = rebuild_cooldown_key(&callgraph_dir, &project_key);
14699 rebuild_cooldown_records()
14700 .lock()
14701 .unwrap_or_else(std::sync::PoisonError::into_inner)
14702 .remove(&cooldown_key);
14703 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
14704 let stale_epoch = epoch.current();
14705 epoch.next();
14706
14707 let failed = with_publish_epoch(epoch, stale_epoch, || {
14708 CallGraphStore::cold_build_with_lease(
14709 callgraph_dir.clone(),
14710 root.clone(),
14711 std::slice::from_ref(&source),
14712 )
14713 });
14714 assert!(matches!(failed, Err(CallGraphStoreError::Superseded)));
14715 assert!(
14716 rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
14717 .is_none()
14718 );
14719
14720 let (store, _) = CallGraphStore::cold_build_with_lease(
14721 callgraph_dir.clone(),
14722 root.clone(),
14723 std::slice::from_ref(&source),
14724 )
14725 .unwrap();
14726 drop(store);
14727 assert!(
14728 rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
14729 .is_none()
14730 );
14731
14732 record_successful_rebuild(&callgraph_dir, &project_key, &other_root, Instant::now());
14733 assert!(
14734 rebuild_cooldown_denial(&callgraph_dir, &project_key, &root, Instant::now(),).is_some()
14735 );
14736 }
14737
14738 #[test]
14739 fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
14740 let _guard = REFRESH_WORKER_TEST_LOCK
14741 .lock()
14742 .unwrap_or_else(std::sync::PoisonError::into_inner);
14743 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14744 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
14745 let pending = pending_paths();
14746 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14747
14748 let lifecycle = SubcLifecycleAdmission::default();
14749 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
14750 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
14751 let ticket = CallgraphRefreshTicket::new(
14752 lifecycle,
14753 Arc::clone(&generation),
14754 7,
14755 publish_epoch.clone(),
14756 publish_epoch.current(),
14757 );
14758 generation.store(8, std::sync::atomic::Ordering::SeqCst);
14760 let installed = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
14761 .unwrap()
14762 .expect("ready store snapshot");
14763 let refresh_state = CallgraphRefreshState::new(
14764 Arc::new(std::sync::RwLock::new(Some(Arc::new(installed)))),
14765 Arc::new(AtomicBool::new(true)),
14766 );
14767
14768 enqueue_callgraph_store_refresh_fenced_with_state(
14769 callgraph_dir,
14770 root.clone(),
14771 vec![source.clone()],
14772 Arc::clone(&pending),
14773 refresh_state,
14774 ticket,
14775 );
14776 assert!(flush_callgraph_store_refreshes_with_budget(
14777 Duration::from_secs(5)
14778 ));
14779 assert_eq!(
14780 callgraph_refresh_worker_test_counts(&root).0,
14781 0,
14782 "superseded batch must not reach refresh_files or self-replay"
14783 );
14784 assert!(
14785 pending.lock().contains(&source),
14786 "superseded batch must defer its paths to the pending sink"
14787 );
14788 clear_callgraph_refresh_worker_test_seam(&root);
14789 }
14790
14791 #[test]
14792 fn superseded_open_failure_defers_without_self_replay() {
14793 let _guard = REFRESH_WORKER_TEST_LOCK
14794 .lock()
14795 .unwrap_or_else(std::sync::PoisonError::into_inner);
14796 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14797 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
14798 let pending = pending_paths();
14799 let installed = Arc::new(
14800 CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
14801 .unwrap()
14802 .expect("ready store snapshot"),
14803 );
14804 let refresh_state = CallgraphRefreshState::new(
14805 Arc::new(std::sync::RwLock::new(Some(Arc::clone(&installed)))),
14806 Arc::new(AtomicBool::new(true)),
14807 );
14808 assert!(!installed.is_legacy_fallback());
14809 assert!(installed.is_current());
14810 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
14811 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14812 set_callgraph_refresh_worker_test_open_failure(root.clone(), true);
14813 let (held_rx, release_tx) = install_callgraph_refresh_worker_test_gate(root.clone());
14814
14815 let lifecycle = SubcLifecycleAdmission::default();
14816 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
14817 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
14818 let ticket = CallgraphRefreshTicket::new(
14819 lifecycle,
14820 Arc::clone(&generation),
14821 7,
14822 publish_epoch.clone(),
14823 publish_epoch.current(),
14824 );
14825 enqueue_callgraph_store_refresh_fenced_with_state(
14826 callgraph_dir,
14827 root.clone(),
14828 vec![source.clone()],
14829 Arc::clone(&pending),
14830 refresh_state,
14831 ticket,
14832 );
14833 held_rx
14834 .recv_timeout(Duration::from_secs(12))
14835 .expect("refresh worker must hold after injected open failure");
14836
14837 generation.store(8, std::sync::atomic::Ordering::SeqCst);
14840 set_callgraph_refresh_worker_test_open_failure(root.clone(), false);
14841 release_tx
14842 .send(())
14843 .expect("release superseded refresh worker");
14844 wait_for_refresh_worker_idle();
14845
14846 assert_eq!(
14847 callgraph_refresh_worker_test_counts(&root).0,
14848 1,
14849 "superseded open-failure batch must not self-replay"
14850 );
14851 assert_eq!(
14852 callgraph_refresh_worker_test_worker_calls(&root),
14853 1,
14854 "superseded open-failure batch must not create another worker call"
14855 );
14856 assert!(
14857 pending.lock().contains(&source),
14858 "superseded open-failure paths must remain in the pending sink"
14859 );
14860 let tree = installed
14861 .call_tree(Path::new("main.rs"), "entry", 1)
14862 .unwrap();
14863 assert_eq!(
14864 tree.children[0].name, "old_leaf",
14865 "superseded open-failure batch must not converge the store"
14866 );
14867 clear_callgraph_refresh_worker_test_seam(&root);
14868 }
14869
14870 #[test]
14871 fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
14872 let _guard = REFRESH_WORKER_TEST_LOCK
14873 .lock()
14874 .unwrap_or_else(std::sync::PoisonError::into_inner);
14875 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::ZERO, false);
14879
14880 let lifecycle = SubcLifecycleAdmission::default();
14881 let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
14882 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
14883 let expected_epoch = publish_epoch.current();
14884 let ticket = CallgraphRefreshTicket::new(
14885 lifecycle,
14886 generation,
14887 3,
14888 publish_epoch.clone(),
14889 expected_epoch,
14890 );
14891 publish_epoch.next();
14893
14894 enqueue_callgraph_store_refresh_fenced(
14895 callgraph_dir,
14896 root.clone(),
14897 vec![source.clone()],
14898 Arc::clone(&pending),
14899 ticket,
14900 );
14901 assert!(flush_callgraph_store_refreshes_with_budget(
14902 Duration::from_secs(5)
14903 ));
14904 assert_eq!(
14905 callgraph_refresh_worker_test_counts(&root).0,
14906 0,
14907 "epoch-superseded batch must not reach refresh_files"
14908 );
14909 assert!(
14910 pending.lock().contains(&source),
14911 "epoch-superseded batch must defer its paths to the pending sink"
14912 );
14913 clear_callgraph_refresh_worker_test_seam(&root);
14914 }
14915
14916 #[test]
14917 fn fenced_refresh_with_current_ticket_commits_normally() {
14918 let _guard = REFRESH_WORKER_TEST_LOCK
14919 .lock()
14920 .unwrap_or_else(std::sync::PoisonError::into_inner);
14921 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14922 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
14923 let pending = pending_paths();
14924 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14925
14926 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
14927
14928 let lifecycle = SubcLifecycleAdmission::default();
14929 let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
14930 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
14931 let ticket = CallgraphRefreshTicket::new(
14932 lifecycle,
14933 generation,
14934 5,
14935 publish_epoch.clone(),
14936 publish_epoch.current(),
14937 );
14938
14939 enqueue_callgraph_store_refresh_fenced(
14940 callgraph_dir.clone(),
14941 root.clone(),
14942 vec![source.clone()],
14943 Arc::clone(&pending),
14944 ticket,
14945 );
14946 assert!(flush_callgraph_store_refreshes_with_budget(
14947 Duration::from_secs(5)
14948 ));
14949 assert_eq!(
14950 callgraph_refresh_worker_test_counts(&root).0,
14951 1,
14952 "current ticket must run the refresh"
14953 );
14954 assert!(
14955 pending.lock().is_empty(),
14956 "committed batch must not defer paths"
14957 );
14958
14959 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
14960 .unwrap()
14961 .expect("published generation must remain readable");
14962 let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
14963 assert_eq!(
14964 tree.children[0].name, "new_leaf",
14965 "fenced commit must actually persist the refreshed content"
14966 );
14967 clear_callgraph_refresh_worker_test_seam(&root);
14968 }
14969
14970 #[test]
14971 fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
14972 let _guard = REFRESH_WORKER_TEST_LOCK
14973 .lock()
14974 .unwrap_or_else(std::sync::PoisonError::into_inner);
14975 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14980 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
14981 let pending = pending_paths();
14982 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
14983
14984 enqueue_callgraph_store_refresh(
14985 callgraph_dir.clone(),
14986 root.clone(),
14987 vec![source.clone()],
14988 Arc::clone(&pending),
14989 );
14990 wait_for_refresh_calls(&root, 1);
14991 for _ in 0..3 {
14992 enqueue_callgraph_store_refresh(
14993 callgraph_dir.clone(),
14994 root.clone(),
14995 vec![source.clone()],
14996 Arc::clone(&pending),
14997 );
14998 }
14999
15000 assert!(flush_callgraph_store_refreshes_with_budget(
15001 Duration::from_secs(2)
15002 ));
15003 assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
15004 assert!(pending.lock().is_empty());
15005 clear_callgraph_refresh_worker_test_seam(&root);
15006 }
15007
15008 #[test]
15009 fn queued_refresh_opens_generation_published_after_enqueue() {
15010 let _guard = REFRESH_WORKER_TEST_LOCK
15011 .lock()
15012 .unwrap_or_else(std::sync::PoisonError::into_inner);
15013 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15018 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
15019 let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
15020 set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
15021 let (active_held_rx, active_release_tx) =
15022 install_callgraph_refresh_worker_test_gate(active_root.clone());
15023 set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
15024 enqueue_callgraph_store_refresh(
15025 active_dir,
15026 active_root.clone(),
15027 vec![active_source],
15028 pending_paths(),
15029 );
15030 active_held_rx
15031 .recv_timeout(Duration::from_secs(12))
15032 .expect("active refresh worker holds the queue");
15033
15034 fs::write(
15035 &target_source,
15036 "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
15037 )
15038 .unwrap();
15039 enqueue_callgraph_store_refresh(
15040 target_dir.clone(),
15041 target_root.clone(),
15042 vec![target_source.clone()],
15043 pending_paths(),
15044 );
15045 let (new_generation, _) = CallGraphStore::cold_build_with_lease(
15046 target_dir.clone(),
15047 target_root.clone(),
15048 std::slice::from_ref(&target_source),
15049 )
15050 .unwrap();
15051 fs::write(
15052 &target_source,
15053 "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
15054 )
15055 .unwrap();
15056 drop(new_generation);
15057
15058 active_release_tx
15059 .send(())
15060 .expect("release active refresh worker");
15061 wait_for_refresh_calls(&target_root, 1);
15062 assert!(flush_callgraph_store_refreshes_with_budget(
15063 Duration::from_secs(12)
15064 ));
15065 let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
15066 .unwrap()
15067 .expect("current callgraph generation");
15068 let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
15069 assert_eq!(tree.children[0].name, "worker_leaf");
15070 assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
15071 clear_callgraph_refresh_worker_test_seam(&active_root);
15072 clear_callgraph_refresh_worker_test_seam(&target_root);
15073 }
15074
15075 #[test]
15076 fn refresh_failure_marks_files_stale() {
15077 let _guard = REFRESH_WORKER_TEST_LOCK
15078 .lock()
15079 .unwrap_or_else(std::sync::PoisonError::into_inner);
15080 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15085 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15086 let pending = pending_paths();
15087 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
15088
15089 enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
15090 assert!(flush_callgraph_store_refreshes_with_budget(
15091 Duration::from_secs(2)
15092 ));
15093
15094 assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
15095 let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
15096 .unwrap()
15097 .expect("ready callgraph store");
15098 assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
15099 clear_callgraph_refresh_worker_test_seam(&root);
15100 }
15101
15102 #[test]
15103 fn idle_refresh_truncates_wal() {
15104 let _guard = REFRESH_WORKER_TEST_LOCK
15105 .lock()
15106 .unwrap_or_else(std::sync::PoisonError::into_inner);
15107 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15108 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15109 let generation = read_pointer(
15110 &callgraph_dir,
15111 &crate::search_index::artifact_cache_key(&root),
15112 )
15113 .expect("fixture publishes a generation");
15114 let wal_path = callgraph_dir.join(format!("{generation}-wal"));
15115 let pending = pending_paths();
15116 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15117
15118 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n\n").unwrap();
15119 enqueue_callgraph_store_refresh(
15120 callgraph_dir.clone(),
15121 root.clone(),
15122 vec![source.clone()],
15123 Arc::clone(&pending),
15124 );
15125 wait_for_refresh_calls(&root, 1);
15126 wait_for_refresh_worker_idle();
15127 let checkpoint_deadline = Instant::now() + Duration::from_secs(2);
15128 while fs::metadata(&wal_path)
15129 .map(|metadata| metadata.len())
15130 .unwrap_or(0)
15131 != 0
15132 {
15133 assert!(
15134 Instant::now() < checkpoint_deadline,
15135 "idle checkpoint did not truncate WAL"
15136 );
15137 std::thread::sleep(Duration::from_millis(5));
15138 }
15139 assert_eq!(
15140 fs::metadata(&wal_path)
15141 .map(|metadata| metadata.len())
15142 .unwrap_or(0),
15143 0,
15144 "idle transition truncates the refresh WAL"
15145 );
15146
15147 clear_callgraph_refresh_worker_test_seam(&root);
15148 }
15149
15150 #[test]
15151 fn bounded_shutdown_defers_unprocessed_batches() {
15152 let _guard = REFRESH_WORKER_TEST_LOCK
15153 .lock()
15154 .unwrap_or_else(std::sync::PoisonError::into_inner);
15155 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15160 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
15161 let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
15162 let active_pending = pending_paths();
15163 let queued_pending = pending_paths();
15164 set_callgraph_refresh_worker_test_seam(
15165 active_root.clone(),
15166 Duration::from_millis(300),
15167 false,
15168 );
15169
15170 enqueue_callgraph_store_refresh(
15171 active_dir,
15172 active_root.clone(),
15173 vec![active_source.clone()],
15174 Arc::clone(&active_pending),
15175 );
15176 wait_for_refresh_calls(&active_root, 1);
15177 enqueue_callgraph_store_refresh(
15178 queued_dir,
15179 queued_root.clone(),
15180 vec![queued_source.clone()],
15181 Arc::clone(&queued_pending),
15182 );
15183
15184 assert!(!flush_callgraph_store_refreshes_with_budget(
15185 Duration::from_millis(20)
15186 ));
15187 assert!(active_pending.lock().contains(&active_source));
15188 assert!(queued_pending.lock().contains(&queued_source));
15189 assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
15190 clear_callgraph_refresh_worker_test_seam(&active_root);
15191 }
15192}
15193
15194#[cfg(test)]
15195mod cold_build_insert_tests {
15196 use super::*;
15197 use crate::imports::ImportBlock;
15198 use std::cell::Cell;
15199 use std::fs;
15200 use std::path::{Path, PathBuf};
15201 use tempfile::tempdir;
15202
15203 thread_local! {
15204 static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
15205 static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
15206 static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
15207 }
15208
15209 fn count_caller_traversal_selects(sql: &str) {
15210 let sql = sql.trim_start();
15211 if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
15212 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
15213 }
15214 if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
15215 && sql.contains("e.target_file =")
15216 {
15217 CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
15218 }
15219 if sql.starts_with("WITH requested") && sql.contains("COUNT(*)") {
15220 BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
15221 }
15222 }
15223
15224 #[test]
15225 fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
15226 let dir = tempdir().unwrap();
15227 let previous_root = dir.path().join("previous-root");
15228 let current_root = dir.path().join("current-root");
15229 fs::create_dir_all(&previous_root).unwrap();
15230 fs::create_dir_all(¤t_root).unwrap();
15231 fs::remove_dir(&previous_root).unwrap();
15232 let mut conn = Connection::open_in_memory().unwrap();
15233 initialize_schema(&conn).unwrap();
15234 conn.execute(
15235 "INSERT INTO backend_file_state(
15236 backend, workspace_root, file_path, content_hash, status, updated_at
15237 ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
15238 params![previous_root.display().to_string()],
15239 )
15240 .unwrap();
15241
15242 let repair = reconcile_workspace_roots(&mut conn, ¤t_root, false).unwrap();
15243
15244 assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
15245 assert_eq!(
15246 stored_workspace_roots(&conn).unwrap(),
15247 vec![previous_root.display().to_string()]
15248 );
15249 }
15250
15251 #[test]
15252 fn sqlite_readonly_uri_percent_encodes_windows_paths() {
15253 assert_eq!(
15254 sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
15255 "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
15256 );
15257 }
15258
15259 #[test]
15260 fn legacy_migration_completion_log_has_operator_fields() {
15261 assert_eq!(
15262 legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
15263 "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
15264 );
15265 }
15266
15267 fn write_generation_with_age(
15268 dir: &Path,
15269 project_key: &str,
15270 ordinal: u64,
15271 age: Duration,
15272 ) -> String {
15273 let generation = format!("{project_key}.g{ordinal}.1.sqlite");
15274 let path = dir.join(&generation);
15275 fs::write(&path, b"sqlite placeholder").unwrap();
15276 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
15277 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
15278 generation
15279 }
15280
15281 #[test]
15282 fn gc_old_generations_preserves_live_reader_until_marker_drops() {
15283 let dir = tempfile::tempdir().unwrap();
15284 let project_key = "project";
15285 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
15286 let previous =
15287 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
15288 let pinned =
15289 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
15290 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
15291
15292 gc_old_generations(dir.path(), project_key, ¤t);
15293
15294 assert!(dir.path().join(&previous).is_file());
15295 assert!(dir.path().join(&pinned).is_file());
15296
15297 drop(marker);
15298 gc_old_generations(dir.path(), project_key, ¤t);
15299
15300 assert!(dir.path().join(&previous).is_file());
15301 assert!(!dir.path().join(&pinned).exists());
15302 }
15303
15304 #[test]
15305 fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
15306 let dir = tempfile::tempdir().unwrap();
15307 let project_key = "project";
15308 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
15309 let _previous =
15310 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
15311 let pinned =
15312 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
15313 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
15314 filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
15315
15316 gc_old_generations(dir.path(), project_key, ¤t);
15317
15318 assert!(dir.path().join(&pinned).is_file());
15319 }
15320
15321 #[test]
15322 fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
15323 let dir = tempfile::tempdir().unwrap();
15324 let project_key = "project";
15325 let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
15326 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
15327 let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
15328 let old = write_generation_with_age(
15329 dir.path(),
15330 project_key,
15331 200,
15332 expired + Duration::from_secs(60),
15333 );
15334 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
15335
15336 gc_old_generations(dir.path(), project_key, ¤t);
15337
15338 assert!(dir.path().join(¤t).is_file());
15339 assert!(dir.path().join(&previous).is_file());
15340 assert!(!dir.path().join(&old).exists());
15341 }
15342
15343 fn write_aged_callgraph_root(callgraph_root: &Path, key: &str) -> PathBuf {
15344 let cache_dir = callgraph_root.join(key);
15345 fs::create_dir_all(cache_dir.join("nested")).unwrap();
15346 fs::write(
15347 cache_dir.join("nested").join("payload.sqlite"),
15348 b"old cache payload",
15349 )
15350 .unwrap();
15351 age_callgraph_root_tree(&cache_dir);
15352 cache_dir
15353 }
15354
15355 fn age_callgraph_root_tree(path: &Path) {
15356 let old = SystemTime::now()
15357 .checked_sub(CALLGRAPH_ROOT_ORPHAN_MIN_AGE + Duration::from_secs(60))
15358 .unwrap_or(UNIX_EPOCH);
15359 let entries = fs::read_dir(path)
15360 .unwrap()
15361 .collect::<std::io::Result<Vec<_>>>()
15362 .unwrap();
15363 for entry in entries {
15364 let child = entry.path();
15365 if entry.file_type().unwrap().is_dir() {
15366 age_callgraph_root_tree(&child);
15367 } else {
15368 filetime::set_file_mtime(&child, filetime::FileTime::from_system_time(old))
15369 .unwrap();
15370 }
15371 }
15372 filetime::set_file_mtime(path, filetime::FileTime::from_system_time(old)).unwrap();
15373 }
15374
15375 #[test]
15376 fn callgraph_root_sweep_reaps_only_aged_unprotected_dead_roots() {
15377 reset_callgraph_root_sweep_cursor_for_test();
15378 let storage = tempdir().unwrap();
15379 let callgraph_root = storage.path().join("callgraph");
15380 let dead = write_aged_callgraph_root(&callgraph_root, "f1e2d3c4b5a69788");
15381 let leased = write_aged_callgraph_root(&callgraph_root, "e1d2c3b4a5968778");
15382 let fresh = callgraph_root.join("d1c2b3a495867768");
15383 fs::create_dir_all(&fresh).unwrap();
15384 fs::write(fresh.join("payload.sqlite"), b"fresh cache payload").unwrap();
15385 let marked = write_aged_callgraph_root(&callgraph_root, "c1b2a39485766758");
15386
15387 let writer_lease = crate::fs_lock::try_acquire(
15388 &crate::root_cache::writer_lease_path(&leased),
15389 Duration::ZERO,
15390 )
15391 .unwrap();
15392 age_callgraph_root_tree(&leased);
15393 let marker = crate::root_cache::ReadMarker::create(&marked, "generation").unwrap();
15394 age_callgraph_root_tree(&marked);
15397
15398 let first = sweep_callgraph_root_dirs_with_limits(
15399 &callgraph_root,
15400 &HashSet::new(),
15401 &HashSet::new(),
15402 CALLGRAPH_ROOT_SWEEP_BUDGET,
15403 usize::MAX,
15404 );
15405
15406 assert_eq!(first.removed, 1);
15407 assert!(first.bytes > 0, "the reaped byte count must be reported");
15408 assert!(!dead.exists(), "an aged dead root must be reaped");
15409 assert_eq!(first.skipped_lease, 1, "a held writer lease must win");
15410 assert_eq!(first.skipped_reader, 1, "a live reader marker must win");
15411 assert_eq!(first.skipped_fresh, 1, "a recent root must win");
15412 assert!(leased.is_dir(), "the leased root must survive");
15413 assert!(marked.is_dir(), "the reader-marked root must survive");
15414 assert!(fresh.is_dir(), "the recent root must survive");
15415
15416 drop(writer_lease);
15417 drop(marker);
15418 for cache_dir in [&leased, &marked, &fresh] {
15421 age_callgraph_root_tree(cache_dir);
15422 }
15423 let second = sweep_callgraph_root_dirs_with_limits(
15424 &callgraph_root,
15425 &HashSet::new(),
15426 &HashSet::new(),
15427 CALLGRAPH_ROOT_SWEEP_BUDGET,
15428 usize::MAX,
15429 );
15430
15431 assert_eq!(second.removed, 3);
15432 for cache_dir in [&leased, &marked, &fresh] {
15433 assert!(
15434 !cache_dir.exists(),
15435 "the decoy must be reaped after its guard or freshness changes"
15436 );
15437 }
15438 reset_callgraph_root_sweep_cursor_for_test();
15439 }
15440
15441 #[test]
15442 fn callgraph_root_sweep_resumes_after_entry_budget() {
15443 reset_callgraph_root_sweep_cursor_for_test();
15444 let storage = tempdir().unwrap();
15445 let callgraph_root = storage.path().join("callgraph");
15446 let first = write_aged_callgraph_root(&callgraph_root, "1111111111111111");
15447 let second = write_aged_callgraph_root(&callgraph_root, "2222222222222222");
15448 let third = write_aged_callgraph_root(&callgraph_root, "3333333333333333");
15449
15450 let first_pass = sweep_callgraph_root_dirs_with_limits(
15451 &callgraph_root,
15452 &HashSet::new(),
15453 &HashSet::new(),
15454 CALLGRAPH_ROOT_SWEEP_BUDGET,
15455 1,
15456 );
15457 assert!(first_pass.budget_exhausted);
15458 assert_eq!(first_pass.scanned, 1);
15459 assert!(!first.exists());
15460 assert!(second.exists());
15461 assert!(third.exists());
15462
15463 let second_pass = sweep_callgraph_root_dirs_with_limits(
15464 &callgraph_root,
15465 &HashSet::new(),
15466 &HashSet::new(),
15467 CALLGRAPH_ROOT_SWEEP_BUDGET,
15468 1,
15469 );
15470 assert!(second_pass.budget_exhausted);
15471 assert!(!second.exists());
15472 assert!(third.exists());
15473
15474 let third_pass = sweep_callgraph_root_dirs_with_limits(
15475 &callgraph_root,
15476 &HashSet::new(),
15477 &HashSet::new(),
15478 CALLGRAPH_ROOT_SWEEP_BUDGET,
15479 1,
15480 );
15481 assert!(!third_pass.budget_exhausted);
15482 assert!(!third.exists());
15483 reset_callgraph_root_sweep_cursor_for_test();
15484 }
15485
15486 #[test]
15487 fn callgraph_root_sweep_runs_generation_gc_for_memoized_root() {
15488 reset_callgraph_root_sweep_cursor_for_test();
15489 let storage = tempdir().unwrap();
15490 let callgraph_root = storage.path().join("callgraph");
15491 let key = "a1b2c3d4e5f60718";
15492 let cache_dir = callgraph_root.join(key);
15493 fs::create_dir_all(&cache_dir).unwrap();
15494 let current = write_generation_with_age(&cache_dir, key, 400, Duration::ZERO);
15495 let previous = write_generation_with_age(&cache_dir, key, 300, Duration::from_secs(1));
15496 let obsolete = write_generation_with_age(&cache_dir, key, 200, Duration::from_secs(2));
15497 publish_pointer(&cache_dir, key, ¤t).unwrap();
15498 age_callgraph_root_tree(&cache_dir);
15499 let memo_keys = HashSet::from([key.to_string()]);
15500
15501 let summary = sweep_callgraph_root_dirs_with_limits(
15502 &callgraph_root,
15503 &memo_keys,
15504 &HashSet::new(),
15505 CALLGRAPH_ROOT_SWEEP_BUDGET,
15506 usize::MAX,
15507 );
15508
15509 assert_eq!(summary.generation_gc, 1);
15510 assert!(cache_dir.join(¤t).is_file());
15511 assert!(cache_dir.join(&previous).is_file());
15512 assert!(
15513 !cache_dir.join(&obsolete).exists(),
15514 "the store-wide sweep must collect an inactive live root's obsolete generation"
15515 );
15516 reset_callgraph_root_sweep_cursor_for_test();
15517 }
15518
15519 fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
15520 let path = dir.join(name);
15521 fs::write(&path, b"temp placeholder").unwrap();
15522 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
15523 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
15524 path
15525 }
15526
15527 #[test]
15528 fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
15529 let dir = tempdir().unwrap();
15530 let aged = "project.g100.1.sqlite.tmp.1.200";
15534 let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
15535 let fresh = "project.g300.1.sqlite.tmp.1.400";
15536 let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
15537 write_build_temp_with_age(dir.path(), aged, aged_age);
15538 write_build_temp_with_age(dir.path(), aged_journal, aged_age);
15539 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
15540
15541 sweep_orphaned_build_temps(dir.path());
15542
15543 assert!(
15544 !dir.path().join(aged).exists(),
15545 "aged orphan must be removed"
15546 );
15547 assert!(
15548 !dir.path().join(aged_journal).exists(),
15549 "aged journal sidecar must be removed"
15550 );
15551 assert!(
15552 dir.path().join(fresh).is_file(),
15553 "fresh temporary must survive"
15554 );
15555 }
15556
15557 #[test]
15558 fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
15559 let storage = tempdir().unwrap();
15560 let storage_root = storage.path();
15561 let legacy_dir = storage_root.join("opencode").join("callgraph");
15567 fs::create_dir_all(&legacy_dir).unwrap();
15568 let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
15569 write_build_temp_with_age(
15570 &legacy_dir,
15571 orphan,
15572 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
15573 );
15574 assert!(
15575 !legacy_dir.join("deadbeef.current").exists(),
15576 "the dead root has no current pointer"
15577 );
15578
15579 let root_keyed_dir = storage_root.join("callgraph").join("livekey");
15580 fs::create_dir_all(&root_keyed_dir).unwrap();
15581
15582 sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
15583
15584 assert!(
15585 !legacy_dir.join(orphan).exists(),
15586 "legacy orphan must be reclaimed by the store-wide sweep"
15587 );
15588 }
15589
15590 #[test]
15591 fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
15592 let dir = tempdir().unwrap();
15598 let fresh = "project.g300.1.sqlite.tmp.1.400";
15599 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
15600
15601 sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
15602
15603 assert!(
15604 !dir.path().join(fresh).exists(),
15605 "with the age predicate forced open, the fresh temporary is removed"
15606 );
15607 }
15608
15609 #[test]
15610 fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
15611 let dir = tempdir().unwrap();
15612 let generation = write_generation_with_age(
15616 dir.path(),
15617 "project",
15618 400,
15619 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
15620 );
15621 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
15622
15623 sweep_orphaned_build_temps(dir.path());
15624
15625 assert!(
15626 dir.path().join(&generation).is_file(),
15627 "completed generation must survive the orphan sweep"
15628 );
15629 assert!(
15630 crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
15631 "read marker must survive the orphan sweep"
15632 );
15633 }
15634
15635 #[test]
15636 fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
15637 let dir = tempfile::tempdir().unwrap();
15638 let project_key = "project".to_string();
15639 let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
15640 let sqlite_path = dir.path().join(&generation);
15641 fs::remove_file(&sqlite_path).unwrap();
15642 let conn = Connection::open(&sqlite_path).unwrap();
15643 let store = CallGraphStore::from_connection(
15644 dir.path().to_path_buf(),
15645 project_key,
15646 sqlite_path,
15647 dir.path().to_path_buf(),
15648 false,
15649 Some(generation.clone()),
15650 None,
15651 None,
15652 conn,
15653 );
15654
15655 let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
15656 assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
15657
15658 drop(marker);
15659 assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
15660 }
15661
15662 #[test]
15663 fn readiness_cache_only_skips_checks_after_a_successful_validation() {
15664 let dir = tempdir().expect("temp dir");
15665 let file = dir.path().join("main.ts");
15666 fs::write(&file, "export function main() {}\n").expect("write fixture");
15667 let store = CallGraphStore::open(
15668 dir.path().join(".store-readiness-cache"),
15669 dir.path().to_path_buf(),
15670 )
15671 .expect("open store");
15672 {
15673 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
15674 conn.trace(Some(count_caller_traversal_selects));
15675 }
15676
15677 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
15678 assert!(store.indexed_file_count().is_err());
15679 assert!(store.indexed_file_count().is_err());
15680 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
15681
15682 store
15683 .cold_build(std::slice::from_ref(&file))
15684 .expect("cold build");
15685 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
15686 assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
15687 assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
15688 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
15689
15690 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
15691 conn.trace(None);
15692 }
15693
15694 #[test]
15695 fn direct_caller_frontier_chunks_sqlite_selects() {
15696 let dir = tempdir().expect("temp dir");
15697 let file = dir.path().join("main.ts");
15698 fs::write(
15699 &file,
15700 "export function caller() { target(); }\nexport function target() {}\n",
15701 )
15702 .expect("write fixture");
15703 let store = CallGraphStore::open(
15704 dir.path().join(".store-caller-frontier-query"),
15705 dir.path().to_path_buf(),
15706 )
15707 .expect("open store");
15708 store
15709 .cold_build(std::slice::from_ref(&file))
15710 .expect("cold build");
15711 let mut targets = vec![("main.ts".to_string(), "target".to_string())];
15712 targets.extend((1..1_000).map(|index| ("main.ts".to_string(), format!("missing{index}"))));
15713
15714 CALLER_QUERY_SELECTS.with(|count| count.set(0));
15715 BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
15716 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
15717 {
15718 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
15719 conn.trace(Some(count_caller_traversal_selects));
15720 }
15721 let callers = store
15722 .direct_callers_for_symbols(&targets)
15723 .expect("batched callers");
15724 {
15725 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
15726 conn.trace(None);
15727 }
15728
15729 assert_eq!(callers.len(), 1_000);
15730 assert_eq!(callers.get(&targets[0]).unwrap().len(), 1);
15731 assert_eq!(CALLER_QUERY_SELECTS.with(Cell::get), 3);
15732 assert_eq!(BOUNDARY_COUNT_SELECTS.with(Cell::get), 0);
15733 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
15734 }
15735
15736 #[test]
15737 fn callers_depth_boundary_batches_sqlite_counts() {
15738 const CALLER_COUNT: usize = 1_000;
15739
15740 let dir = tempdir().expect("temp dir");
15741 let file = dir.path().join("main.ts");
15742 let mut source = String::from("export function sharedHotHelper() {}\n");
15743 for index in 0..CALLER_COUNT {
15744 source.push_str(&format!(
15745 "export function caller{index}() {{ sharedHotHelper(); }}\n"
15746 ));
15747 }
15748 fs::write(&file, source).expect("write fixture");
15749
15750 let store = CallGraphStore::open(
15751 dir.path().join(".store-callers-query-fanout"),
15752 dir.path().to_path_buf(),
15753 )
15754 .expect("open store");
15755 store
15756 .cold_build(std::slice::from_ref(&file))
15757 .expect("cold build");
15758
15759 CALLER_QUERY_SELECTS.with(|count| count.set(0));
15760 BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
15761 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
15762 {
15763 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
15764 conn.trace(Some(count_caller_traversal_selects));
15765 }
15766
15767 let started = Instant::now();
15768 let result = crate::commands::callgraph_store_adapter::callers_result(
15769 &store,
15770 Path::new("main.ts"),
15771 "sharedHotHelper",
15772 1,
15773 true,
15774 )
15775 .expect("callers result");
15776 let elapsed = started.elapsed();
15777
15778 {
15779 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
15780 conn.trace(None);
15781 }
15782 let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
15783 let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
15784 let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
15785 eprintln!(
15786 "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
15787 result.total_callers,
15788 caller_queries,
15789 boundary_queries,
15790 total_selects,
15791 elapsed.as_secs_f64() * 1_000.0
15792 );
15793
15794 assert_eq!(result.total_callers, CALLER_COUNT);
15795 assert_eq!(caller_queries, 1);
15796 assert_eq!(boundary_queries, 3);
15797 assert_eq!(total_selects, 9);
15798 }
15799
15800 #[test]
15801 fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
15802 let dir = tempdir().expect("temp dir");
15803 let file = dir.path().join("main.ts");
15804 fs::write(
15805 &file,
15806 r#"export function topA() {
15807 root();
15808}
15809
15810export function topB() {
15811 root();
15812}
15813
15814export function root() {
15815 leaf();
15816 missing();
15817}
15818
15819export function leaf() {}
15820"#,
15821 )
15822 .expect("write fixture");
15823
15824 let store = CallGraphStore::open(
15825 dir.path().join(".store-depth-boundary-counts"),
15826 dir.path().to_path_buf(),
15827 )
15828 .expect("open store");
15829 store
15830 .cold_build(std::slice::from_ref(&file))
15831 .expect("cold build");
15832
15833 let root = store
15834 .node_for(Path::new("main.ts"), "root")
15835 .expect("root node");
15836 let leaf = store
15837 .node_for(Path::new("main.ts"), "leaf")
15838 .expect("leaf node");
15839
15840 let (full_forward_len, full_direct_len) = {
15841 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
15842 conn.execute(
15843 "INSERT INTO edges (
15844 edge_id, ref_id, source_node, target_node, target_file,
15845 target_symbol, kind, line, provenance
15846 ) VALUES (
15847 'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
15848 ?2, ?3, 'call', 98, ?4
15849 )",
15850 rusqlite::params![
15851 &root.node_id,
15852 &leaf.file,
15853 &leaf.symbol,
15854 PROVENANCE_TREESITTER
15855 ],
15856 )
15857 .expect("insert dangling forward edge");
15858 conn.execute(
15859 "INSERT INTO edges (
15860 edge_id, ref_id, source_node, target_node, target_file,
15861 target_symbol, kind, line, provenance
15862 ) VALUES (
15863 'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
15864 ?1, ?2, ?3, 'call', 99, ?4
15865 )",
15866 rusqlite::params![
15867 &root.node_id,
15868 &root.file,
15869 &root.symbol,
15870 PROVENANCE_TREESITTER
15871 ],
15872 )
15873 .expect("insert dangling direct-caller edge");
15874
15875 let full_forward_len = forward_calls_for_node(&conn, &root)
15876 .expect("full forward calls")
15877 .len();
15878 let counted_forward_len =
15879 forward_call_count_for_node(&conn, &root).expect("counted forward calls");
15880 assert_eq!(
15881 counted_forward_len, full_forward_len,
15882 "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
15883 );
15884
15885 let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
15886 .expect("full direct callers");
15887 let full_direct_len = full_direct.len();
15888 let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
15889 .expect("counted direct callers");
15890 assert_eq!(
15891 counted_direct_len, full_direct_len,
15892 "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
15893 );
15894
15895 let distinct_direct_len = full_direct
15896 .iter()
15897 .map(|site| {
15898 (
15899 site.caller.file.clone(),
15900 site.line,
15901 site.target_file.clone(),
15902 site.target_symbol.clone(),
15903 )
15904 })
15905 .collect::<BTreeSet<_>>()
15906 .len();
15907 let batch_counts = direct_caller_counts_for_tuples(
15908 &conn,
15909 &[
15910 (root.file.clone(), root.symbol.clone()),
15911 (root.file.clone(), root.symbol.clone()),
15912 (leaf.file.clone(), leaf.symbol.clone()),
15913 ],
15914 )
15915 .expect("batched direct-caller counts");
15916 assert_eq!(batch_counts.len(), 2);
15917 assert_eq!(
15918 batch_counts.get(&(root.file.clone(), root.symbol.clone())),
15919 Some(&distinct_direct_len)
15920 );
15921
15922 (full_forward_len, full_direct_len)
15923 };
15924
15925 assert_eq!(
15926 full_forward_len, 2,
15927 "fixture root should have one resolved and one unresolved outgoing call"
15928 );
15929 assert_eq!(
15930 full_direct_len, 2,
15931 "fixture root should have two real direct callers"
15932 );
15933
15934 let tree = store
15935 .call_tree(Path::new("main.ts"), "root", 0)
15936 .expect("call tree");
15937 assert!(tree.depth_limited);
15938 assert_eq!(tree.children.len(), 0);
15939 assert_eq!(
15940 tree.truncated, full_forward_len,
15941 "call_tree depth boundary must report the full forward-call list length"
15942 );
15943
15944 let callers = store
15945 .callers_of(Path::new("main.ts"), "leaf", 0)
15946 .expect("callers");
15947 assert!(callers.depth_limited);
15948 assert_eq!(callers.callers.len(), 1);
15949 assert_eq!(callers.callers[0].caller.symbol, "root");
15950 assert_eq!(
15951 callers.truncated, full_direct_len,
15952 "callers depth boundary must report the full direct-caller list length"
15953 );
15954 }
15955
15956 #[test]
15957 fn source_freshness_matches_cache_collect_for_same_bytes() {
15958 let dir = tempdir().expect("temp dir");
15959 let path = dir.path().join("fixture.ts");
15960 let source = "export function main() { return helper(); }\n";
15961 fs::write(&path, source).expect("write fixture");
15962
15963 let expected = cache_freshness::collect(&path).expect("collect freshness from file");
15964 let actual =
15965 collect_source_freshness(&path, source).expect("collect freshness from source");
15966
15967 assert_eq!(actual, expected);
15968 }
15969
15970 #[test]
15971 fn superseded_cold_build_cannot_publish_after_newer_epoch() {
15972 let root = tempfile::tempdir().unwrap();
15973 let callgraph_dir = tempfile::tempdir().unwrap();
15974 let source_dir = root.path().join("src");
15975 std::fs::create_dir_all(&source_dir).unwrap();
15976 let source = source_dir.join("lib.rs");
15977 std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
15978 let files = vec![source.clone()];
15979 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
15980 let old_epoch = epoch.next();
15981 let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
15982 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
15983 let old_epoch_flag = epoch.clone();
15984 let old_dir = callgraph_dir.path().to_path_buf();
15985 let old_root = root.path().to_path_buf();
15986 let old_files = files.clone();
15987 let old = std::thread::spawn(move || {
15988 set_cold_build_before_publish_observer(Some(Arc::new(move || {
15989 reached_tx.send(()).unwrap();
15990 release_rx.recv().unwrap();
15991 })));
15992 let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
15993 CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
15994 });
15995 set_cold_build_before_publish_observer(None);
15996 result
15997 });
15998 reached_rx
16002 .recv_timeout(Duration::from_secs(30))
16003 .expect("older build did not reach its publication barrier");
16004
16005 std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
16006 let new_epoch = epoch.next();
16007 let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
16008 CallGraphStore::cold_build_with_lease(
16009 callgraph_dir.path().to_path_buf(),
16010 root.path().to_path_buf(),
16011 &files,
16012 )
16013 })
16014 .expect("newer build should publish");
16015 drop(new_store);
16016
16017 release_tx.send(()).unwrap();
16018 assert!(matches!(
16019 old.join().unwrap(),
16020 Err(CallGraphStoreError::Superseded)
16021 ));
16022
16023 let current = CallGraphStore::open_readonly(
16024 callgraph_dir.path().to_path_buf(),
16025 root.path().to_path_buf(),
16026 )
16027 .unwrap()
16028 .expect("current callgraph generation");
16029 assert_eq!(
16030 current
16031 .nodes_matching("new_generation_marker")
16032 .unwrap()
16033 .len(),
16034 1
16035 );
16036 assert!(current
16037 .nodes_matching("old_generation_marker")
16038 .unwrap()
16039 .is_empty());
16040 }
16041
16042 #[test]
16043 fn publish_fence_supersession_keeps_completed_staging_for_zero_work_adoption() {
16044 let root = tempfile::tempdir().unwrap();
16045 let callgraph_dir = tempfile::tempdir().unwrap();
16046 let source = root.path().join("lib.rs");
16047 std::fs::write(&source, "pub fn completed_marker() {}\n").unwrap();
16048 let files = vec![source];
16049 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
16050 let old_epoch = epoch.next();
16051 let epoch_for_observer = epoch.clone();
16052 set_cold_build_before_publish_observer(Some(Arc::new(move || {
16053 epoch_for_observer.next();
16054 })));
16055 let result = with_publish_epoch(epoch.clone(), old_epoch, || {
16056 CallGraphStore::cold_build_with_lease_chunked(
16057 callgraph_dir.path().to_path_buf(),
16058 root.path().to_path_buf(),
16059 &files,
16060 1,
16061 )
16062 });
16063 set_cold_build_before_publish_observer(None);
16064 assert!(matches!(result, Err(CallGraphStoreError::Superseded)));
16065
16066 let project_key = crate::search_index::artifact_cache_key(root.path());
16067 let staging = callgraph_dir
16068 .path()
16069 .join(format!("{project_key}.staging.sqlite.tmp.resume"));
16070 let staged = Connection::open(&staging).unwrap();
16071 assert_eq!(
16072 staged_build_phase(&staged).unwrap().as_deref(),
16073 Some("ready")
16074 );
16075 drop(staged);
16076
16077 let extracted = Arc::new(std::sync::atomic::AtomicUsize::new(0));
16078 let extracted_for_observer = Arc::clone(&extracted);
16079 set_cold_build_extract_observer(Some(Arc::new(move |paths| {
16080 extracted_for_observer.fetch_add(paths.len(), AtomicOrdering::SeqCst);
16081 })));
16082 let successor_epoch = epoch.next();
16083 let (store, stats) = with_publish_epoch(epoch, successor_epoch, || {
16084 CallGraphStore::cold_build_with_lease_chunked(
16085 callgraph_dir.path().to_path_buf(),
16086 root.path().to_path_buf(),
16087 &files,
16088 1,
16089 )
16090 })
16091 .expect("completed same-corpus staging publishes without rebuilding");
16092 set_cold_build_extract_observer(None);
16093
16094 assert_eq!(stats.files, 1);
16095 assert_eq!(
16096 extracted.load(AtomicOrdering::SeqCst),
16097 0,
16098 "completed staging must not repeat extraction"
16099 );
16100 drop(store);
16101 }
16102
16103 #[test]
16104 fn superseded_slice_preserves_staging_and_same_corpus_successor_resumes() {
16105 let root = tempfile::tempdir().unwrap();
16106 let callgraph_dir = tempfile::tempdir().unwrap();
16107 let files = ["a.rs", "b.rs", "c.rs"]
16108 .into_iter()
16109 .map(|name| {
16110 let path = root.path().join(name);
16111 std::fs::write(&path, format!("pub fn {}() {{}}\n", name.replace('.', "_")))
16112 .unwrap();
16113 path
16114 })
16115 .collect::<Vec<_>>();
16116 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
16117 let old_epoch = epoch.next();
16118 let superseded = Arc::new(AtomicBool::new(false));
16119 let epoch_for_observer = epoch.clone();
16120 let superseded_for_observer = Arc::clone(&superseded);
16121 set_cold_build_slice_observer(Some(Arc::new(move |stage, completed, _total| {
16122 if stage == "extraction"
16123 && completed == 1
16124 && !superseded_for_observer.swap(true, AtomicOrdering::SeqCst)
16125 {
16126 epoch_for_observer.next();
16127 }
16128 })));
16129
16130 let result = with_publish_epoch(epoch.clone(), old_epoch, || {
16131 CallGraphStore::cold_build_with_lease_chunked(
16132 callgraph_dir.path().to_path_buf(),
16133 root.path().to_path_buf(),
16134 &files,
16135 1,
16136 )
16137 });
16138 set_cold_build_slice_observer(None);
16139 assert!(matches!(result, Err(CallGraphStoreError::Superseded)));
16140 assert!(superseded.load(AtomicOrdering::SeqCst));
16141
16142 let project_key = crate::search_index::artifact_cache_key(root.path());
16143 let staging = callgraph_dir
16144 .path()
16145 .join(format!("{project_key}.staging.sqlite.tmp.resume"));
16146 assert!(staging.exists(), "supersession must retain durable staging");
16147 let staged = Connection::open(&staging).unwrap();
16148 assert_eq!(
16149 staged_build_phase(&staged).unwrap().as_deref(),
16150 Some("extracting")
16151 );
16152 assert_eq!(
16153 query_count(&staged, "SELECT COUNT(*) FROM files").unwrap(),
16154 1
16155 );
16156 drop(staged);
16157
16158 let extracted = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
16159 let extracted_for_observer = Arc::clone(&extracted);
16160 set_cold_build_extract_observer(Some(Arc::new(move |paths| {
16161 extracted_for_observer
16162 .lock()
16163 .unwrap()
16164 .extend(paths.iter().filter_map(|path| {
16165 path.file_name()
16166 .map(|name| name.to_string_lossy().into_owned())
16167 }));
16168 })));
16169 let successor_epoch = epoch.next();
16170 let (store, stats) = with_publish_epoch(epoch.clone(), successor_epoch, || {
16171 CallGraphStore::cold_build_with_lease_chunked(
16172 callgraph_dir.path().to_path_buf(),
16173 root.path().to_path_buf(),
16174 &files,
16175 1,
16176 )
16177 })
16178 .expect("same-corpus successor resumes and publishes");
16179 set_cold_build_extract_observer(None);
16180
16181 assert_eq!(stats.files, 3);
16182 assert_eq!(
16183 *extracted.lock().unwrap(),
16184 vec!["b.rs".to_string(), "c.rs".to_string()],
16185 "the successor must not repeat the committed first slice"
16186 );
16187 drop(store);
16188 assert!(
16189 !staging.exists(),
16190 "published staging moves to its generation"
16191 );
16192 }
16193
16194 #[test]
16195 fn changed_corpus_restarts_instead_of_adopting_staged_progress() {
16196 let root = tempfile::tempdir().unwrap();
16197 let callgraph_dir = tempfile::tempdir().unwrap();
16198 let first = root.path().join("a.rs");
16199 let second = root.path().join("b.rs");
16200 std::fs::write(&first, "pub fn a() {}\n").unwrap();
16201 std::fs::write(&second, "pub fn b() {}\n").unwrap();
16202 let mut files = vec![first.clone(), second.clone()];
16203 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
16204 let old_epoch = epoch.next();
16205 let advanced = Arc::new(AtomicBool::new(false));
16206 let epoch_for_observer = epoch.clone();
16207 let advanced_for_observer = Arc::clone(&advanced);
16208 set_cold_build_slice_observer(Some(Arc::new(move |stage, completed, _total| {
16209 if stage == "extraction"
16210 && completed == 1
16211 && !advanced_for_observer.swap(true, AtomicOrdering::SeqCst)
16212 {
16213 epoch_for_observer.next();
16214 }
16215 })));
16216 let result = with_publish_epoch(epoch.clone(), old_epoch, || {
16217 CallGraphStore::cold_build_with_lease_chunked(
16218 callgraph_dir.path().to_path_buf(),
16219 root.path().to_path_buf(),
16220 &files,
16221 1,
16222 )
16223 });
16224 set_cold_build_slice_observer(None);
16225 assert!(matches!(result, Err(CallGraphStoreError::Superseded)));
16226
16227 std::fs::write(&first, "pub fn a_changed() { b(); }\n").unwrap();
16228 let third = root.path().join("c.rs");
16229 std::fs::write(&third, "pub fn c() {}\n").unwrap();
16230 files.push(third);
16231 let extracted = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
16232 let extracted_for_observer = Arc::clone(&extracted);
16233 set_cold_build_extract_observer(Some(Arc::new(move |paths| {
16234 extracted_for_observer
16235 .lock()
16236 .unwrap()
16237 .extend(paths.iter().filter_map(|path| {
16238 path.file_name()
16239 .map(|name| name.to_string_lossy().into_owned())
16240 }));
16241 })));
16242 let successor_epoch = epoch.next();
16243 let (store, stats) = with_publish_epoch(epoch, successor_epoch, || {
16244 CallGraphStore::cold_build_with_lease_chunked(
16245 callgraph_dir.path().to_path_buf(),
16246 root.path().to_path_buf(),
16247 &files,
16248 1,
16249 )
16250 })
16251 .expect("changed-corpus successor restarts and publishes");
16252 set_cold_build_extract_observer(None);
16253
16254 assert_eq!(stats.files, 3);
16255 assert_eq!(
16256 *extracted.lock().unwrap(),
16257 vec!["a.rs".to_string(), "b.rs".to_string(), "c.rs".to_string()],
16258 "fingerprint mismatch must invalidate every old extraction slice"
16259 );
16260 drop(store);
16261 }
16262
16263 #[test]
16264 fn cold_build_prepared_bulk_insert_matches_reference_rows() {
16265 let dir = tempdir().expect("temp dir");
16266 let project_root = dir.path();
16267 let extract = fixture_extract(project_root);
16268 let resolved = fixture_resolved(&extract);
16269
16270 let reference = build_reference_connection(project_root, &extract, &resolved);
16271 let optimized = build_optimized_connection(project_root, &extract, &resolved);
16272
16273 for table in [
16274 "files",
16275 "nodes",
16276 "file_dependencies",
16277 "dispatch_hints",
16278 "refs",
16279 "edges",
16280 ] {
16281 let excluded: &[&str] = if table == "files" {
16288 &["indexed_at"]
16289 } else {
16290 &[]
16291 };
16292 assert_eq!(
16293 table_rows_without(&reference, table, excluded),
16294 table_rows_without(&optimized, table, excluded),
16295 "table `{table}` rows must match apart from wall-clock columns"
16296 );
16297 }
16298 assert_eq!(
16299 backend_state_rows(&reference),
16300 backend_state_rows(&optimized),
16301 "backend freshness rows must match apart from updated_at"
16302 );
16303 assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
16304 }
16305
16306 #[test]
16307 fn cold_build_chunked_matches_unchunked_logical_rows() {
16308 let dir = tempdir().expect("temp dir");
16309 let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
16310 write_chunked_equivalence_fixture(&project_root);
16311 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
16312 assert!(
16313 files.len() > 6,
16314 "fixture should be large enough to split into multiple chunks"
16315 );
16316
16317 let unchunked = CallGraphStore::open(
16318 project_root.join(".store-unchunked"),
16319 project_root.to_path_buf(),
16320 )
16321 .expect("open unchunked store");
16322 let unchunked_stats = unchunked
16323 .cold_build_chunked(&files, 0)
16324 .expect("unchunked cold build");
16325
16326 let chunked = CallGraphStore::open(
16327 project_root.join(".store-chunked"),
16328 project_root.to_path_buf(),
16329 )
16330 .expect("open chunked store");
16331 let chunked_stats = chunked
16332 .cold_build_chunked(&files, 3)
16333 .expect("chunked cold build");
16334
16335 assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
16336 assert_eq!(
16337 unchunked.edge_snapshot().expect("unchunked edge snapshot"),
16338 chunked.edge_snapshot().expect("chunked edge snapshot"),
16339 "public edge snapshots must match"
16340 );
16341
16342 let dispatch_edges = {
16343 let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
16344 conn.query_row(
16345 "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
16346 [],
16347 |row| row.get::<_, i64>(0),
16348 )
16349 .expect("count dispatch edges")
16350 };
16351 assert!(
16352 dispatch_edges > 0,
16353 "fixture must exercise method-dispatch edge insertion"
16354 );
16355
16356 for table in [
16357 "edges",
16358 "refs",
16359 "nodes",
16360 "file_dependencies",
16361 "dispatch_hints",
16362 ] {
16363 assert_eq!(
16364 graph_table_rows(&unchunked, table),
16365 graph_table_rows(&chunked, table),
16366 "chunked cold build must match unchunked rows for {table}"
16367 );
16368 }
16369 assert_eq!(
16370 graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
16371 graph_table_rows_without(&chunked, "files", &["indexed_at"]),
16372 "files rows must match apart from indexed_at"
16373 );
16374 assert_eq!(
16375 graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
16376 graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
16377 "backend freshness rows must match apart from updated_at"
16378 );
16379
16380 let published_dir = project_root.join(".store-published");
16381 let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
16382 published_dir.clone(),
16383 project_root.to_path_buf(),
16384 &files,
16385 0,
16386 )
16387 .expect("published unchunked cold build");
16388 assert!(
16389 !CallGraphStore::needs_cold_build(&published_dir, &project_root)
16390 .expect("needs_cold_build after publish"),
16391 "published store should be ready"
16392 );
16393 drop(_published);
16394 let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
16395 published_dir,
16396 project_root.to_path_buf(),
16397 &files,
16398 3,
16399 )
16400 .expect("ensure with a different chunk size");
16401 assert!(
16402 rebuild_stats.is_none(),
16403 "changing callgraph_chunk_size must not affect store identity or force a rebuild"
16404 );
16405 }
16406
16407 #[test]
16408 fn cold_build_resolution_memo_bounds_filesystem_probes_and_preserves_rows() {
16409 let dir = tempdir().expect("temp dir");
16410 let project_root = dir.path().join("project");
16411 fs::create_dir_all(&project_root).expect("create project root");
16412 let project_root = fs::canonicalize(project_root).expect("canonical project root");
16413 let files = write_ts_resolution_memo_fixture(&project_root, 8, 8, 4);
16414 let resolve_window = 19;
16415
16416 callgraph::clear_workspace_package_cache();
16417 let uncached_memo = callgraph::ModuleResolutionMemo::new_for_test(false, true);
16418 let uncached = CallGraphStore::open(
16419 dir.path().join("store-uncached"),
16420 project_root.to_path_buf(),
16421 )
16422 .expect("open uncached store");
16423 let uncached_stats = uncached
16424 .cold_build_chunked_with_resolution_memo_for_test(
16425 &files,
16426 7,
16427 resolve_window,
16428 &uncached_memo,
16429 )
16430 .expect("uncached comparison build");
16431 assert!(
16432 uncached_stats.refs > resolve_window * 2,
16433 "fixture must cross several staged reference windows"
16434 );
16435
16436 callgraph::clear_workspace_package_cache();
16437 let cached_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
16438 let cached =
16439 CallGraphStore::open(dir.path().join("store-cached"), project_root.to_path_buf())
16440 .expect("open cached store");
16441 let cached_stats = cached
16442 .cold_build_chunked_with_resolution_memo_for_test(
16443 &files,
16444 7,
16445 resolve_window,
16446 &cached_memo,
16447 )
16448 .expect("cached build");
16449
16450 assert_cold_build_stats_match_except_elapsed(&uncached_stats, &cached_stats);
16451 for table in [
16452 "nodes",
16453 "refs",
16454 "file_dependencies",
16455 "edges",
16456 "dispatch_hints",
16457 "type_ref_names",
16458 "meta",
16459 "staging_file_inventory",
16460 "staging_ref_context",
16461 ] {
16462 assert_eq!(
16463 graph_table_rows(&uncached, table),
16464 graph_table_rows(&cached, table),
16465 "memoized and uncached cold builds must produce identical {table} rows"
16466 );
16467 }
16468 assert_eq!(
16469 graph_table_rows_without(&uncached, "files", &["indexed_at"]),
16470 graph_table_rows_without(&cached, "files", &["indexed_at"]),
16471 "files rows must match apart from indexed_at"
16472 );
16473 assert_eq!(
16474 graph_table_rows_without(&uncached, "backend_file_state", &["updated_at"]),
16475 graph_table_rows_without(&cached, "backend_file_state", &["updated_at"]),
16476 "backend rows must match apart from updated_at"
16477 );
16478
16479 let cached_module_computations = cached_memo.module_computations_for_test();
16480 assert!(
16481 !cached_module_computations.is_empty(),
16482 "fixture must exercise module resolution"
16483 );
16484 assert!(
16485 cached_module_computations.values().all(|count| *count == 1),
16486 "each importing-directory/specifier pair must reach the filesystem once"
16487 );
16488 let uncached_module_computations = uncached_memo.module_computations_for_test();
16489 assert!(
16490 uncached_module_computations
16491 .values()
16492 .copied()
16493 .max()
16494 .unwrap_or_default()
16495 > 16,
16496 "mutation control: disabling the memo must recompute a hot module target"
16497 );
16498
16499 let cached_package_probes = cached_memo
16500 .json_probes_for_test()
16501 .into_iter()
16502 .filter(|(path, _)| {
16503 path.file_name().and_then(|name| name.to_str()) == Some("package.json")
16504 })
16505 .collect::<HashMap<_, _>>();
16506 assert!(
16507 !cached_package_probes.is_empty(),
16508 "fixture must exercise package.json lookup"
16509 );
16510 assert!(
16511 cached_package_probes.values().all(|count| *count == 1),
16512 "every package.json path must be probed at most once per cold build"
16513 );
16514 let uncached_package_probes = uncached_memo
16515 .json_probes_for_test()
16516 .into_iter()
16517 .filter(|(path, _)| {
16518 path.file_name().and_then(|name| name.to_str()) == Some("package.json")
16519 })
16520 .collect::<HashMap<_, _>>();
16521 let cached_probe_total: usize = cached_package_probes.values().sum();
16522 let uncached_probe_total: usize = uncached_package_probes.values().sum();
16523 assert!(
16524 uncached_probe_total > cached_probe_total * 20,
16525 "mutation control: disabled memo should repeat the package ladder ({uncached_probe_total} vs {cached_probe_total})"
16526 );
16527 }
16528
16529 #[test]
16530 fn rust_declared_module_memo_parses_each_declaring_file_once_and_preserves_edges() {
16531 let dir = tempdir().expect("temp dir");
16532 let project_root = dir.path().join("project");
16533 fs::create_dir_all(&project_root).expect("create project root");
16534 let project_root = fs::canonicalize(project_root).expect("canonical project root");
16535 let files = write_rust_declared_module_memo_fixture(&project_root, 10);
16536
16537 let negative_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
16538 for _ in 0..3 {
16539 assert_eq!(
16540 rust_declared_module_target(
16541 &project_root,
16542 "src/lib.rs",
16543 "undeclared",
16544 &negative_memo,
16545 ),
16546 None
16547 );
16548 }
16549 assert_eq!(
16550 negative_memo
16551 .rust_declaration_parses_for_test()
16552 .get("src/lib.rs"),
16553 Some(&1),
16554 "an undeclared module must be retained as a file-level negative result"
16555 );
16556
16557 let uncached_memo = callgraph::ModuleResolutionMemo::new_for_test(false, true);
16558 let uncached = CallGraphStore::open(
16559 dir.path().join("rust-store-uncached"),
16560 project_root.to_path_buf(),
16561 )
16562 .expect("open uncached Rust store");
16563 let uncached_stats = uncached
16564 .cold_build_chunked_with_resolution_memo_for_test(&files, 3, 11, &uncached_memo)
16565 .expect("uncached Rust build");
16566
16567 let cached_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
16568 let cached = CallGraphStore::open(
16569 dir.path().join("rust-store-cached"),
16570 project_root.to_path_buf(),
16571 )
16572 .expect("open cached Rust store");
16573 let cached_stats = cached
16574 .cold_build_chunked_with_resolution_memo_for_test(&files, 3, 11, &cached_memo)
16575 .expect("cached Rust build");
16576
16577 assert!(
16578 cached_stats.refs >= 60,
16579 "fixture must exercise repeated qualified and undeclared paths"
16580 );
16581 assert_cold_build_stats_match_except_elapsed(&uncached_stats, &cached_stats);
16582 assert_eq!(
16583 uncached.edge_snapshot().expect("uncached edge snapshot"),
16584 cached.edge_snapshot().expect("cached edge snapshot"),
16585 "memoized Rust declarations must preserve the public edge set"
16586 );
16587 assert_eq!(
16588 graph_table_rows(&uncached, "edges"),
16589 graph_table_rows(&cached, "edges"),
16590 "source, symbol, target, and provenance rows must be byte-identical"
16591 );
16592
16593 let expected_declaring_files = [
16594 "src/lib.rs",
16595 "src/module_0.rs",
16596 "src/module_1.rs",
16597 "src/module_2.rs",
16598 "src/module_3.rs",
16599 "src/module_4.rs",
16600 ]
16601 .into_iter()
16602 .map(str::to_string)
16603 .collect::<BTreeSet<_>>();
16604 let cached_parses = cached_memo.rust_declaration_parses_for_test();
16605 assert_eq!(
16606 cached_parses.keys().cloned().collect::<BTreeSet<_>>(),
16607 expected_declaring_files,
16608 "the fixture must traverse exactly its six distinct declaring files"
16609 );
16610 assert!(
16611 cached_parses.values().all(|count| *count == 1),
16612 "each declaring file must be parsed once per cold build: {cached_parses:?}"
16613 );
16614
16615 let uncached_parses = uncached_memo.rust_declaration_parses_for_test();
16616 let uncached_parse_total: usize = uncached_parses.values().sum();
16617 let cached_parse_total: usize = cached_parses.values().sum();
16618 println!(
16619 "RUST_DECLARATION_PARSE_COUNTS memo=off:{uncached_parse_total} memo=on:{cached_parse_total} distinct={}",
16620 cached_parses.len()
16621 );
16622 assert!(
16623 uncached_parse_total > 100,
16624 "mutation control: disabling memo insertion must repeat declaration parses; got {uncached_parse_total}"
16625 );
16626 let cached_missing_refs = cached
16627 .conn
16628 .lock()
16629 .expect("callgraph store mutex poisoned")
16630 .query_row(
16631 "SELECT COUNT(*) FROM refs
16632 WHERE full_ref = 'crate::undeclared::missing' AND target_file IS NULL",
16633 [],
16634 |row| row.get::<_, usize>(0),
16635 )
16636 .expect("count unresolved negative refs");
16637 assert_eq!(
16638 cached_missing_refs, 10,
16639 "all negative-result references must remain unresolved without reparsing"
16640 );
16641 }
16642
16643 #[test]
16644 fn refresh_after_adding_rust_module_declaration_uses_fresh_snapshot() {
16645 let dir = tempdir().expect("temp dir");
16646 let project_root = dir.path().join("project");
16647 fs::create_dir_all(project_root.join("src/custom")).expect("create Rust fixture");
16648 fs::write(
16649 project_root.join("Cargo.toml"),
16650 "[package]\nname = \"refresh-declaration-fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
16651 )
16652 .expect("write Rust manifest");
16653 let lib = project_root.join("src/lib.rs");
16654 let existing = project_root.join("src/existing.rs");
16655 let added = project_root.join("src/custom/added.rs");
16656 fs::write(
16657 &lib,
16658 "mod existing;\npub fn run() { crate::added::target(); }\n",
16659 )
16660 .expect("write initial lib");
16661 fs::write(&existing, "pub fn existing() {}\n").expect("write existing module");
16662 fs::write(&added, "pub fn target() {}\n").expect("write added module target");
16663 let project_root = fs::canonicalize(project_root).expect("canonical project root");
16664 let lib = project_root.join("src/lib.rs");
16665 let existing = project_root.join("src/existing.rs");
16666 let added = project_root.join("src/custom/added.rs");
16667
16668 let store = CallGraphStore::open(
16669 dir.path().join("refresh-declaration-store"),
16670 project_root.to_path_buf(),
16671 )
16672 .expect("open refresh store");
16673 store
16674 .cold_build(&[lib.clone(), existing.clone(), added])
16675 .expect("initial cold build");
16676 assert!(
16677 store
16678 .direct_callers_of(Path::new("src/custom/added.rs"), "target")
16679 .expect("initial callers")
16680 .is_empty(),
16681 "the custom-path module must be unresolved before its declaration exists"
16682 );
16683
16684 fs::write(&existing, "pub fn existing() { let _ = 1; }\n").expect("touch existing module");
16685 store
16686 .refresh_files(std::slice::from_ref(&existing))
16687 .expect("warm refresh declaration loading");
16688 fs::write(
16689 &lib,
16690 "mod existing;\n#[path = \"custom/added.rs\"]\nmod added;\npub fn run() { crate::added::target(); }\n",
16691 )
16692 .expect("add custom-path module declaration");
16693 store
16694 .refresh_files(std::slice::from_ref(&lib))
16695 .expect("refresh declaring file");
16696
16697 let callers = store
16698 .direct_callers_of(Path::new("src/custom/added.rs"), "target")
16699 .expect("refreshed callers");
16700 assert!(
16701 callers
16702 .iter()
16703 .any(|site| { site.caller.file == "src/lib.rs" && site.caller.symbol == "run" }),
16704 "a fresh refresh generation must resolve the newly declared module: {callers:#?}"
16705 );
16706 }
16707
16708 #[test]
16713 #[ignore]
16714 fn bench_cold_build_resolution_memo() {
16715 let dir = tempdir().expect("temp dir");
16716 let project_root = dir.path().join("project");
16717 fs::create_dir_all(&project_root).expect("create benchmark root");
16718 let project_root = fs::canonicalize(project_root).expect("canonical benchmark root");
16719 let files = write_ts_resolution_memo_fixture(&project_root, 24, 12, 20);
16720 assert!(
16721 files.len() > 250,
16722 "benchmark fixture must contain hundreds of files"
16723 );
16724
16725 for enabled in [false, true] {
16726 callgraph::clear_workspace_package_cache();
16727 let memo = callgraph::ModuleResolutionMemo::new_for_test(enabled, false);
16728 let store = CallGraphStore::open(
16729 dir.path().join(if enabled {
16730 "store-cached"
16731 } else {
16732 "store-uncached"
16733 }),
16734 project_root.to_path_buf(),
16735 )
16736 .expect("open benchmark store");
16737 let cpu_started = process_cpu_time();
16738 let wall_started = Instant::now();
16739 let stats = store
16740 .cold_build_chunked_with_resolution_memo_for_test(&files, 32, 257, &memo)
16741 .expect("benchmark cold build");
16742 let wall_ms = wall_started.elapsed().as_millis();
16743 let cpu_ms = process_cpu_time()
16744 .checked_sub(cpu_started)
16745 .unwrap_or_default()
16746 .as_millis();
16747 println!(
16748 "BENCH_COLD_BUILD_RESOLUTION_MEMO memo={} files={} refs={} edges={} wall_ms={} cpu_ms={}",
16749 if enabled { "on" } else { "off" },
16750 stats.files,
16751 stats.refs,
16752 stats.edges,
16753 wall_ms,
16754 cpu_ms
16755 );
16756 }
16757 }
16758
16759 #[test]
16760 #[ignore]
16761 fn bench_rust_declared_module_memo_real_corpus() {
16762 let project_root = fs::canonicalize(env!("CARGO_MANIFEST_DIR"))
16763 .expect("canonical agent-file-tools crate root");
16764 let files = [
16765 "src/main.rs",
16766 "src/cli/mod.rs",
16767 "src/cli/index.rs",
16768 "src/cli/sandbox_launch.rs",
16769 "src/cli/warmup.rs",
16770 ]
16771 .into_iter()
16772 .map(|path| project_root.join(path))
16773 .collect::<Vec<_>>();
16774 assert!(
16775 files.iter().all(|path| path.is_file()),
16776 "real-corpus benchmark sources must exist"
16777 );
16778 let dir = tempdir().expect("benchmark temp dir");
16779 let mut baseline_edges = None;
16780 let mut baseline_stats = None;
16781 let enabled_modes = match std::env::var("AFT_RUST_DECL_MEMO").as_deref() {
16782 Ok("off") => vec![false],
16783 Ok("on") => vec![true],
16784 _ => vec![false, true],
16785 };
16786
16787 for enabled in enabled_modes {
16788 let memo = callgraph::ModuleResolutionMemo::new_for_test(enabled, true);
16789 let store = CallGraphStore::open(
16790 dir.path().join(if enabled {
16791 "rust-real-cached"
16792 } else {
16793 "rust-real-uncached"
16794 }),
16795 project_root.to_path_buf(),
16796 )
16797 .expect("open real-corpus store");
16798 let phase_times = Arc::new(Mutex::new((None, None)));
16799 let observer_times = Arc::clone(&phase_times);
16800 set_cold_build_phase_observer(Some(Arc::new(move |phase| {
16801 let mut times = observer_times.lock().expect("phase timing mutex poisoned");
16802 match phase {
16803 "resolution" if times.0.is_none() => times.0 = Some(Instant::now()),
16804 "publication" if times.1.is_none() => times.1 = Some(Instant::now()),
16805 _ => {}
16806 }
16807 })));
16808 let build = store.cold_build_chunked_with_resolution_memo_for_test(
16809 &files,
16810 COLD_BUILD_EXTRACT_BATCH_FILES,
16811 COLD_BUILD_RESOLVE_WINDOW,
16812 &memo,
16813 );
16814 set_cold_build_phase_observer(None);
16815 let stats = build.expect("real-corpus cold build");
16816 let times = phase_times.lock().expect("phase timing mutex poisoned");
16817 let resolution_elapsed = times
16818 .1
16819 .expect("publication phase timestamp")
16820 .duration_since(times.0.expect("resolution phase timestamp"));
16821 drop(times);
16822 let edges = graph_table_rows(&store, "edges");
16823 if let Some(expected) = &baseline_edges {
16824 assert_eq!(
16825 &edges, expected,
16826 "real-corpus edge rows, including provenance, must be byte-identical"
16827 );
16828 } else {
16829 baseline_edges = Some(edges);
16830 }
16831 let stats_tuple = (stats.files, stats.nodes, stats.refs, stats.edges);
16832 if let Some(expected) = baseline_stats {
16833 assert_eq!(stats_tuple, expected, "real-corpus build counts must match");
16834 } else {
16835 baseline_stats = Some(stats_tuple);
16836 }
16837 let parses = memo.rust_declaration_parses_for_test();
16838 println!(
16839 "BENCH_RUST_DECLARED_MODULE_MEMO memo={} files={} refs={} edges={} resolution_wall_ms={} declaration_parses={} distinct_declaring_files={}",
16840 if enabled { "on" } else { "off" },
16841 stats.files,
16842 stats.refs,
16843 stats.edges,
16844 resolution_elapsed.as_millis(),
16845 parses.values().sum::<usize>(),
16846 parses.len()
16847 );
16848 }
16849 }
16850
16851 #[test]
16858 #[ignore]
16859 fn bench_cold_build_chunk() {
16860 let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
16861 let chunk: usize = std::env::var("AFT_PERF_CHUNK")
16862 .expect("AFT_PERF_CHUNK")
16863 .parse()
16864 .expect("AFT_PERF_CHUNK must be a non-negative integer");
16865 let project_root = fs::canonicalize(&repo).expect("canonical repo root");
16866 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
16867 let dir = tempdir().expect("temp dir");
16868 let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
16869 .expect("open store");
16870 let started = Instant::now();
16871 let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
16872 let ms = started.elapsed().as_millis();
16873 println!(
16874 "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
16875 stats.files, stats.nodes, stats.refs, stats.edges
16876 );
16877 }
16878
16879 #[test]
16880 fn persisted_workspace_reexport_selects_its_package_dependency() {
16881 let root = tempdir().expect("temp dir");
16882 let dependencies = BTreeSet::from([
16883 "packages/aft-bridge/src/index.ts".to_string(),
16884 "packages/opencode-plugin/src/types.ts".to_string(),
16885 ]);
16886 let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
16887
16888 assert_eq!(
16889 stored_dependencies_for_module(
16890 root.path(),
16891 "packages/opencode-plugin/src/shared/bash-hints.ts",
16892 "@cortexkit/aft-bridge",
16893 &dependencies,
16894 &indexed_files,
16895 ),
16896 BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
16897 );
16898 }
16899
16900 #[test]
16901 fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
16902 let dir = tempdir().expect("temp dir");
16903 let project_root = dir.path();
16904 let files =
16905 write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
16906 let index_path = project_root.join("src/index.ts");
16907
16908 let store = CallGraphStore::open(
16909 project_root.join(".store-incremental-barrel"),
16910 project_root.to_path_buf(),
16911 )
16912 .expect("open incremental store");
16913 store.cold_build(&files).expect("initial cold build");
16914
16915 {
16916 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16917 let tx = conn.transaction().expect("dependency transaction");
16918 let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
16919 .expect("dependent refs for barrel");
16920 let selected_ref_ids = dependent_refs
16921 .iter()
16922 .map(|dependent_ref| dependent_ref.ref_id.clone())
16923 .collect::<BTreeSet<_>>();
16924 let mut threaded_ref_ids = BTreeSet::new();
16925 let mut threaded_by_caller = BTreeMap::new();
16926 record_dependent_refs(
16927 &mut threaded_ref_ids,
16928 &mut threaded_by_caller,
16929 dependent_refs,
16930 );
16931 let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
16932 .expect("old per-ref caller lookup");
16933
16934 assert_eq!(threaded_ref_ids, selected_ref_ids);
16935 assert_eq!(threaded_by_caller, old_by_caller);
16936 for consumer in [
16937 "src/consumer_a.ts",
16938 "src/consumer_b.ts",
16939 "src/consumer_c.ts",
16940 ] {
16941 assert!(
16942 threaded_by_caller.contains_key(consumer),
16943 "barrel edit should select dependent refs from {consumer}"
16944 );
16945 }
16946 }
16947
16948 fs::write(
16949 &index_path,
16950 "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
16951 )
16952 .expect("edit barrel");
16953 let stats = store
16954 .refresh_files(std::slice::from_ref(&index_path))
16955 .expect("incremental refresh");
16956 assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
16957 assert!(
16958 stats.dependency_selected_refs > 0,
16959 "barrel surface edit should select dependent refs"
16960 );
16961
16962 let cold_store = CallGraphStore::open(
16963 project_root.join(".store-cold-barrel"),
16964 project_root.to_path_buf(),
16965 )
16966 .expect("open cold rebuild store");
16967 cold_store
16968 .cold_build(&files)
16969 .expect("comparison cold build");
16970
16971 for table in [
16972 "nodes",
16973 "refs",
16974 "file_dependencies",
16975 "edges",
16976 "dispatch_hints",
16977 ] {
16978 assert_eq!(
16979 graph_table_rows(&store, table),
16980 graph_table_rows(&cold_store, table),
16981 "incremental refresh {table} rows must match cold rebuild"
16982 );
16983 }
16984
16985 let consumer_path = project_root.join("src/consumer_a.ts");
16986 fs::write(
16987 &consumer_path,
16988 "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
16989 )
16990 .expect("edit barrel consumer");
16991 store
16992 .refresh_files(std::slice::from_ref(&consumer_path))
16993 .expect("refresh consumer through unchanged barrel");
16994 cold_store
16995 .cold_build(&files)
16996 .expect("comparison cold rebuild after consumer refresh");
16997 for table in [
16998 "nodes",
16999 "refs",
17000 "file_dependencies",
17001 "edges",
17002 "dispatch_hints",
17003 ] {
17004 assert_eq!(
17005 graph_table_rows(&store, table),
17006 graph_table_rows(&cold_store, table),
17007 "refresh through a persisted barrel must preserve cold-build {table} rows"
17008 );
17009 }
17010 }
17011
17012 fn build_reference_connection(
17013 project_root: &Path,
17014 extract: &FileExtract,
17015 resolved: &ResolvedRef,
17016 ) -> Connection {
17017 let mut conn = Connection::open_in_memory().expect("open reference db");
17018 configure_build_connection(&conn).expect("configure reference db");
17019 initialize_schema(&conn).expect("initialize reference schema");
17020 {
17021 let tx = conn.transaction().expect("reference transaction");
17022 clear_tables(&tx).expect("reference clear");
17023 insert_meta(&tx).expect("reference meta");
17024 insert_file_extract(&tx, project_root, extract).expect("reference file extract");
17025 insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
17026 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
17027 .expect("reference dispatch edges");
17028 assert_eq!(supplemental, 0);
17029 tx.commit().expect("reference commit");
17030 }
17031 conn
17032 }
17033
17034 fn build_optimized_connection(
17035 project_root: &Path,
17036 extract: &FileExtract,
17037 resolved: &ResolvedRef,
17038 ) -> Connection {
17039 let mut conn = Connection::open_in_memory().expect("open optimized db");
17040 configure_build_connection(&conn).expect("configure optimized db");
17041 initialize_schema(&conn).expect("initialize optimized schema");
17042 {
17043 let tx = conn.transaction().expect("optimized transaction");
17044 clear_tables(&tx).expect("optimized clear");
17045 insert_meta(&tx).expect("optimized meta");
17046 drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
17047 {
17048 let workspace_root = project_root.display().to_string();
17049 let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
17050 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
17051 .expect("optimized file extract");
17052 insert_resolved_ref_prepared(&mut inserts, resolved)
17053 .expect("optimized resolved ref");
17054 }
17055 create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
17056 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
17057 .expect("optimized dispatch edges");
17058 assert_eq!(supplemental, 0);
17059 tx.commit().expect("optimized commit");
17060 }
17061 conn
17062 }
17063
17064 fn fixture_extract(_project_root: &Path) -> FileExtract {
17065 let rel_path = "src/main.ts".to_string();
17066 let target_path = "src/helper.ts".to_string();
17067 let node = NodeRecord {
17068 id: "node-main".to_string(),
17069 file_path: rel_path.clone(),
17070 name: "main".to_string(),
17071 scoped_name: "main".to_string(),
17072 kind: "function".to_string(),
17073 range: Range {
17074 start_line: 0,
17075 start_col: 0,
17076 end_line: 0,
17077 end_col: 32,
17078 },
17079 range_ordinal: 0,
17080 signature: Some("export function main()".to_string()),
17081 exported: true,
17082 is_default_export: false,
17083 is_type_like: false,
17084 is_callgraph_entry_point: true,
17085 };
17086 let mut dependencies = BTreeSet::new();
17087 dependencies.insert(target_path.clone());
17088 let raw_ref = RawRef {
17089 ref_id: "ref-main-helper".to_string(),
17090 caller_node: Some(node.id.clone()),
17091 caller_symbol: Some(node.scoped_name.clone()),
17092 caller_file: rel_path.clone(),
17093 kind: "call".to_string(),
17094 short_name: Some("helper".to_string()),
17095 full_ref: Some("helper".to_string()),
17096 module_path: None,
17097 import_kind: None,
17098 local_name: Some("helper".to_string()),
17099 requested_name: Some("helper".to_string()),
17100 namespace_alias: None,
17101 wildcard: false,
17102 line: 1,
17103 byte_start: 24,
17104 byte_end: 32,
17105 dependencies,
17106 };
17107 FileExtract {
17108 rel_path,
17109 freshness: FileFreshness {
17110 mtime: UNIX_EPOCH + Duration::from_secs(123),
17111 size: 40,
17112 content_hash: cache_freshness::hash_bytes(b"fixture source"),
17113 },
17114 lang: LangId::TypeScript,
17115 data: FileCallData {
17116 calls_by_symbol: HashMap::new(),
17117 value_refs_by_symbol: HashMap::new(),
17118 exported_symbols: Vec::new(),
17119 symbol_metadata: HashMap::new(),
17120 default_export_symbol: None,
17121 import_block: ImportBlock::empty(),
17122 lang: LangId::TypeScript,
17123 },
17124 nodes: vec![node.clone()],
17125 raw_refs: vec![raw_ref],
17126 dispatch_hints: vec![DispatchHint {
17127 id: "dispatch-main-helper".to_string(),
17128 method_name: "helper".to_string(),
17129 caller_node: node.id,
17130 file: "src/main.ts".to_string(),
17131 line: 1,
17132 byte_start: 24,
17133 byte_end: 32,
17134 }],
17135 surface_fingerprint: "surface".to_string(),
17136 }
17137 }
17138
17139 fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
17140 let raw = extract.raw_refs[0].clone();
17141 let mut dependencies = raw.dependencies.clone();
17142 dependencies.insert("src/helper.ts".to_string());
17143 ResolvedRef {
17144 edge: Some(EdgeRecord {
17145 edge_id: "edge-main-helper".to_string(),
17146 source_node: raw.caller_node.clone().expect("caller node"),
17147 target_node: Some("node-helper".to_string()),
17148 target_file: "src/helper.ts".to_string(),
17149 target_symbol: "helper".to_string(),
17150 kind: "call".to_string(),
17151 line: raw.line,
17152 }),
17153 raw,
17154 status: "resolved".to_string(),
17155 target_node: Some("node-helper".to_string()),
17156 target_file: Some("src/helper.ts".to_string()),
17157 target_symbol: Some("helper".to_string()),
17158 dependencies,
17159 }
17160 }
17161
17162 fn write_rust_declared_module_memo_fixture(
17163 project_root: &Path,
17164 calls_per_module: usize,
17165 ) -> Vec<PathBuf> {
17166 fs::create_dir_all(project_root.join("src")).expect("create Rust fixture root");
17167 fs::write(
17168 project_root.join("Cargo.toml"),
17169 "[package]\nname = \"rust-declaration-memo-fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
17170 )
17171 .expect("write Rust fixture manifest");
17172
17173 let modules = (0..5)
17174 .map(|index| format!("module_{index}"))
17175 .collect::<Vec<_>>();
17176 let mut lib_source = modules
17177 .iter()
17178 .map(|module| format!("pub mod {module};\n"))
17179 .collect::<String>();
17180 lib_source.push_str("\npub fn dispatch() {\n");
17181 for call in 0..calls_per_module {
17182 for (index, module) in modules.iter().enumerate() {
17183 lib_source.push_str(&format!(
17184 " crate::{module}::leaf::target_{index}(); // call {call}\n"
17185 ));
17186 }
17187 lib_source.push_str(" crate::undeclared::missing();\n");
17188 }
17189 lib_source.push_str("}\n");
17190 let lib = project_root.join("src/lib.rs");
17191 fs::write(&lib, lib_source).expect("write Rust fixture lib");
17192 let mut files = vec![lib];
17193
17194 for (index, module) in modules.iter().enumerate() {
17195 let declaring_file = project_root.join(format!("src/{module}.rs"));
17196 fs::write(&declaring_file, "pub mod leaf;\n").expect("write nested module declaration");
17197 let target_file = project_root.join(format!("src/{module}/leaf.rs"));
17198 fs::create_dir_all(target_file.parent().expect("nested module parent"))
17199 .expect("create nested module directory");
17200 fs::write(&target_file, format!("pub fn target_{index}() {{}}\n"))
17201 .expect("write nested module target");
17202 files.push(declaring_file);
17203 files.push(target_file);
17204 }
17205 files
17206 }
17207
17208 fn write_ts_resolution_memo_fixture(
17209 project_root: &Path,
17210 package_count: usize,
17211 files_per_package: usize,
17212 calls_per_file: usize,
17213 ) -> Vec<PathBuf> {
17214 fs::create_dir_all(project_root).expect("create memo fixture root");
17215 fs::write(
17216 project_root.join("package.json"),
17217 r#"{"name":"fixture-root","private":true,"workspaces":["packages/*"]}"#,
17218 )
17219 .expect("write workspace package manifest");
17220 fs::write(
17221 project_root.join("tsconfig.json"),
17222 r#"{"compilerOptions":{"baseUrl":".","paths":{}}}"#,
17223 )
17224 .expect("write fixture tsconfig");
17225
17226 let shared_root = project_root.join("packages/shared");
17227 let shared_source = shared_root.join("src/index.ts");
17228 fs::create_dir_all(shared_source.parent().expect("shared source parent"))
17229 .expect("create shared package");
17230 fs::write(
17231 shared_root.join("package.json"),
17232 r#"{"name":"@fixture/shared","exports":{".":{"source":"./src/index.ts"}}}"#,
17233 )
17234 .expect("write shared package manifest");
17235 fs::write(
17236 &shared_source,
17237 "export function shared(value: number) { return value + 1; }\n",
17238 )
17239 .expect("write shared source");
17240 let mut files = vec![shared_source];
17241
17242 for package in 0..package_count {
17243 let package_root = project_root.join(format!("packages/app-{package:02}"));
17244 fs::create_dir_all(&package_root).expect("create app package");
17245 fs::write(
17246 package_root.join("package.json"),
17247 format!(r#"{{"name":"@fixture/app-{package:02}"}}"#),
17248 )
17249 .expect("write app package manifest");
17250 let source_dir = package_root.join("src/features/deep/nested/leaf");
17251 fs::create_dir_all(&source_dir).expect("create deep app source dir");
17252
17253 for file in 0..files_per_package {
17254 let source_path = source_dir.join(format!("caller_{file:03}.ts"));
17255 let mut source = "import { shared } from \"@fixture/shared\";\n".to_string();
17256 for call in 0..calls_per_file {
17257 source.push_str(&format!(
17258 "export function caller_{package}_{file}_{call}() {{ return shared({call}); }}\n"
17259 ));
17260 }
17261 fs::write(&source_path, source).expect("write app source");
17262 files.push(source_path);
17263 }
17264 }
17265
17266 files
17267 }
17268
17269 #[cfg(unix)]
17270 fn process_cpu_time() -> Duration {
17271 let mut value = std::mem::MaybeUninit::<libc::timespec>::uninit();
17272 let result =
17273 unsafe { libc::clock_gettime(libc::CLOCK_PROCESS_CPUTIME_ID, value.as_mut_ptr()) };
17274 if result != 0 {
17275 return Duration::ZERO;
17276 }
17277 let value = unsafe { value.assume_init() };
17278 Duration::new(value.tv_sec.max(0) as u64, value.tv_nsec.max(0) as u32)
17279 }
17280
17281 #[cfg(not(unix))]
17282 fn process_cpu_time() -> Duration {
17283 Duration::ZERO
17284 }
17285
17286 fn write_chunked_equivalence_fixture(project_root: &Path) {
17287 let ts_dir = project_root.join("ts");
17288 fs::create_dir_all(&ts_dir).expect("create ts dir");
17289 fs::write(
17290 ts_dir.join("leaf.ts"),
17291 "export function leaf(value: number) {\n return value + 1;\n}\n",
17292 )
17293 .expect("write ts leaf");
17294 fs::write(
17295 ts_dir.join("mid.ts"),
17296 "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n return leaf(value);\n}\n",
17297 )
17298 .expect("write ts mid");
17299 fs::write(
17300 ts_dir.join("entry.ts"),
17301 "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n return mid(worker.run());\n}\n",
17302 )
17303 .expect("write ts entry");
17304 fs::write(
17305 ts_dir.join("worker.ts"),
17306 "export class Worker {\n run() {\n return 41;\n }\n}\n",
17307 )
17308 .expect("write ts worker");
17309 for idx in 0..4 {
17310 fs::write(
17311 ts_dir.join(format!("extra_{idx}.ts")),
17312 format!(
17313 "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n return entry(new Worker());\n}}\n"
17314 ),
17315 )
17316 .expect("write ts extra");
17317 }
17318
17319 let rust_dir = project_root.join("src");
17320 let commands_dir = rust_dir.join("commands");
17321 fs::create_dir_all(&commands_dir).expect("create rust commands dir");
17322 fs::write(
17323 rust_dir.join("context.rs"),
17324 r#"pub struct AppContext;
17325
17326impl AppContext {
17327 pub fn callgraph_store_for_ops(&self) -> usize {
17328 1
17329 }
17330}
17331"#,
17332 )
17333 .expect("write rust context");
17334 fs::write(
17335 rust_dir.join("lib.rs"),
17336 "pub mod context;\npub mod commands;\n",
17337 )
17338 .expect("write rust lib");
17339 fs::write(
17340 commands_dir.join("mod.rs"),
17341 "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
17342 )
17343 .expect("write rust commands mod");
17344 for name in ["callers", "impact", "trace_to"] {
17345 fs::write(
17346 commands_dir.join(format!("{name}.rs")),
17347 format!(
17348 r#"use crate::context::AppContext;
17349
17350pub fn handle_{name}(ctx: &AppContext) -> usize {{
17351 ctx.callgraph_store_for_ops()
17352}}
17353"#
17354 ),
17355 )
17356 .expect("write rust command");
17357 }
17358 }
17359
17360 fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
17361 let src_dir = project_root.join("src");
17362 fs::create_dir_all(&src_dir).expect("create src dir");
17363
17364 let target_path = src_dir.join("target.ts");
17365 fs::write(&target_path, "export function target() {\n return 1;\n}\n")
17366 .expect("write target");
17367
17368 let index_path = src_dir.join("index.ts");
17369 fs::write(&index_path, barrel_source).expect("write barrel");
17370
17371 let mut files = vec![target_path, index_path];
17372 for (file_name, function_name) in [
17373 ("consumer_a.ts", "consumerA"),
17374 ("consumer_b.ts", "consumerB"),
17375 ("consumer_c.ts", "consumerC"),
17376 ] {
17377 let path = src_dir.join(file_name);
17378 fs::write(
17379 &path,
17380 format!(
17381 "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n return target();\n}}\n"
17382 ),
17383 )
17384 .expect("write consumer");
17385 files.push(path);
17386 }
17387 files
17388 }
17389
17390 fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
17391 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
17392 table_rows(&conn, table)
17393 }
17394
17395 fn graph_table_rows_without(
17396 store: &CallGraphStore,
17397 table: &str,
17398 excluded_columns: &[&str],
17399 ) -> Vec<String> {
17400 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
17401 table_rows_without(&conn, table, excluded_columns)
17402 }
17403
17404 fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
17405 table_rows_without(conn, table, &[])
17406 }
17407
17408 fn table_rows_without(
17409 conn: &Connection,
17410 table: &str,
17411 excluded_columns: &[&str],
17412 ) -> Vec<String> {
17413 let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
17414 let columns: Vec<String> = conn
17415 .prepare(&format!("PRAGMA table_info({table})"))
17416 .expect("prepare table_info")
17417 .query_map([], |row| row.get::<_, String>(1))
17418 .expect("query table_info")
17419 .collect::<std::result::Result<Vec<String>, _>>()
17420 .expect("collect columns")
17421 .into_iter()
17422 .filter(|column| !excluded_columns.contains(column.as_str()))
17423 .collect();
17424 let sql = format!(
17425 "SELECT {} FROM {table} ORDER BY {}",
17426 columns.join(", "),
17427 columns.join(", ")
17428 );
17429 conn.prepare(&sql)
17430 .expect("prepare table rows")
17431 .query_map([], |row| row_to_strings(row, columns.len()))
17432 .expect("query table rows")
17433 .collect::<std::result::Result<_, _>>()
17434 .expect("collect table rows")
17435 }
17436
17437 fn assert_cold_build_stats_match_except_elapsed(
17438 expected: &ColdBuildStats,
17439 actual: &ColdBuildStats,
17440 ) {
17441 assert_eq!(actual.files, expected.files, "file counts must match");
17442 assert_eq!(actual.nodes, expected.nodes, "node counts must match");
17443 assert_eq!(actual.refs, expected.refs, "ref counts must match");
17444 assert_eq!(actual.edges, expected.edges, "edge counts must match");
17445 assert_eq!(
17446 actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
17447 expected
17448 .failed_files
17449 .iter()
17450 .cloned()
17451 .collect::<BTreeSet<_>>(),
17452 "failed file sets must match"
17453 );
17454 }
17455
17456 fn backend_state_rows(conn: &Connection) -> Vec<String> {
17457 conn.prepare(
17458 "SELECT backend, workspace_root, file_path, content_hash, status
17459 FROM backend_file_state
17460 ORDER BY backend, workspace_root, file_path, content_hash, status",
17461 )
17462 .expect("prepare backend rows")
17463 .query_map([], |row| row_to_strings(row, 5))
17464 .expect("query backend rows")
17465 .collect::<std::result::Result<_, _>>()
17466 .expect("collect backend rows")
17467 }
17468
17469 fn secondary_indexes(conn: &Connection) -> Vec<String> {
17470 let mut indexes = Vec::new();
17471 for table in [
17472 "files",
17473 "nodes",
17474 "refs",
17475 "file_dependencies",
17476 "edges",
17477 "dispatch_hints",
17478 "type_ref_names",
17479 "backend_file_state",
17480 "meta",
17481 ] {
17482 let sql = format!("PRAGMA index_list({table})");
17483 let mut stmt = conn.prepare(&sql).expect("prepare index list");
17484 let rows = stmt
17485 .query_map([], |row| row.get::<_, String>(1))
17486 .expect("query index list");
17487 for name in rows {
17488 let name = name.expect("index name");
17489 if name.starts_with("idx_") {
17490 indexes.push(format!("{table}:{name}"));
17491 }
17492 }
17493 }
17494 indexes.sort();
17495 indexes
17496 }
17497
17498 fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
17499 let mut values = Vec::with_capacity(len);
17500 for index in 0..len {
17501 let value = row.get_ref(index)?;
17502 values.push(match value {
17503 rusqlite::types::ValueRef::Null => "NULL".to_string(),
17504 rusqlite::types::ValueRef::Integer(value) => value.to_string(),
17505 rusqlite::types::ValueRef::Real(value) => value.to_string(),
17506 rusqlite::types::ValueRef::Text(value) => {
17507 String::from_utf8_lossy(value).into_owned()
17508 }
17509 rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
17510 });
17511 }
17512 Ok(values.join("\u{1f}"))
17513 }
17514}
17515
17516#[cfg(test)]
17517mod rust_resolution_tests {
17518 use super::*;
17519 use crate::inspect::job::CallgraphSnapshot;
17520 use std::fs;
17521 use tempfile::tempdir;
17522
17523 #[test]
17524 fn rust_function_scoped_module_alias_resolves_and_projects_live() {
17525 let dir = tempdir().expect("tempdir");
17526 let root = dir.path();
17527 write_rust_manifest(root, "scoped-alias-fixture");
17528 write_file(
17529 root,
17530 "src/lib.rs",
17531 r#"pub mod finalization_contract;
17532
17533pub fn run_alias() {
17534 use crate::finalization_contract as fc;
17535 fc::check_mason_contract();
17536}
17537"#,
17538 );
17539 write_file(
17540 root,
17541 "src/finalization_contract.rs",
17542 r#"pub fn check_mason_contract() {}
17543fn planted_dead() {}
17544"#,
17545 );
17546
17547 let (store, snapshot) = cold_build_twice(root);
17548 assert_direct_caller(
17549 &store,
17550 "src/finalization_contract.rs",
17551 "check_mason_contract",
17552 "src/lib.rs",
17553 "run_alias",
17554 );
17555 assert_projected_call(
17556 root,
17557 &snapshot,
17558 "src/finalization_contract.rs",
17559 "check_mason_contract",
17560 );
17561 assert_no_projected_call(
17562 root,
17563 &snapshot,
17564 "src/finalization_contract.rs",
17565 "planted_dead",
17566 );
17567 assert!(
17568 store
17569 .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
17570 .expect("planted dead callers")
17571 .is_empty(),
17572 "planted-dead guard should stay without callers"
17573 );
17574 }
17575
17576 #[test]
17577 fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
17578 let dir = tempdir().expect("tempdir");
17579 let root = dir.path();
17580 write_rust_manifest(root, "inline-module-fixture");
17581 write_file(
17582 root,
17583 "src/lib.rs",
17584 r#"mod work_graph { fn operations() {} }
17585mod manifest { fn operations() {} }
17586mod audit { fn operations() {} }
17587mod dispatch { fn operations() {} }
17588mod finalization { fn operations() {} }
17589
17590pub fn run_inline_operations() {
17591 work_graph::operations();
17592 manifest::operations();
17593 audit::operations();
17594 dispatch::operations();
17595 finalization::operations();
17596}
17597
17598fn planted_dead() {}
17599"#,
17600 );
17601
17602 let (store, snapshot) = cold_build_twice(root);
17603 for module in [
17604 "work_graph",
17605 "manifest",
17606 "audit",
17607 "dispatch",
17608 "finalization",
17609 ] {
17610 assert_direct_caller(
17611 &store,
17612 "src/lib.rs",
17613 &format!("{module}::operations"),
17614 "src/lib.rs",
17615 "run_inline_operations",
17616 );
17617 }
17618 assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
17619 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
17620 }
17621
17622 #[test]
17623 fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
17624 let dir = tempdir().expect("tempdir");
17625 let root = dir.path();
17626 fs::write(
17627 root.join("Cargo.toml"),
17628 "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
17629 )
17630 .expect("write workspace manifest");
17631 write_file(
17632 root,
17633 "crates/but-action/Cargo.toml",
17634 r#"[package]
17635name = "but-action"
17636version = "0.1.0"
17637edition = "2021"
17638"#,
17639 );
17640 write_file(
17641 root,
17642 "crates/but-action/src/lib.rs",
17643 "mod action;\npub use action::{list_actions};\n",
17644 );
17645 write_file(
17646 root,
17647 "crates/but-action/src/action.rs",
17648 "pub fn list_actions() {}\nfn planted_dead() {}\n",
17649 );
17650 write_file(
17651 root,
17652 "crates/app/Cargo.toml",
17653 r#"[package]
17654name = "app"
17655version = "0.1.0"
17656edition = "2021"
17657"#,
17658 );
17659 write_file(
17660 root,
17661 "crates/app/src/lib.rs",
17662 "pub fn run_actions() {\n but_action::list_actions();\n}\n",
17663 );
17664
17665 let (store, snapshot) = cold_build_twice(root);
17666 assert_direct_caller(
17667 &store,
17668 "crates/but-action/src/action.rs",
17669 "list_actions",
17670 "crates/app/src/lib.rs",
17671 "run_actions",
17672 );
17673 assert!(
17674 store
17675 .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
17676 .expect("lib reexport callers")
17677 .is_empty(),
17678 "call should target the reexported source function, not lib.rs"
17679 );
17680 assert_projected_call(
17681 root,
17682 &snapshot,
17683 "crates/but-action/src/action.rs",
17684 "list_actions",
17685 );
17686 assert_no_projected_call(
17687 root,
17688 &snapshot,
17689 "crates/but-action/src/action.rs",
17690 "planted_dead",
17691 );
17692 }
17693
17694 #[test]
17695 fn rust_cfg_attributed_module_resolves_outgoing_calls() {
17696 let dir = tempdir().expect("tempdir");
17697 let root = dir.path();
17698 write_rust_manifest(root, "cfg-module-outgoing-fixture");
17699 write_file(
17700 root,
17701 "src/lib.rs",
17702 "pub fn project_range() {}\n\n#[cfg(any(test, feature = \"test-conformance\"))]\npub mod conformance;\npub mod ordinary;\n",
17703 );
17704 for module in ["conformance", "ordinary"] {
17705 write_file(
17706 root,
17707 &format!("src/{module}.rs"),
17708 "use crate::project_range;\n\npub fn local_target() {}\n\npub fn run() {\n local_target();\n project_range();\n}\n",
17709 );
17710 }
17711
17712 let (store, _) = cold_build_twice(root);
17713 for module in ["conformance", "ordinary"] {
17714 assert_direct_caller(
17715 &store,
17716 &format!("src/{module}.rs"),
17717 "local_target",
17718 &format!("src/{module}.rs"),
17719 "run",
17720 );
17721 assert_direct_caller(
17722 &store,
17723 "src/lib.rs",
17724 "project_range",
17725 &format!("src/{module}.rs"),
17726 "run",
17727 );
17728 }
17729 }
17730
17731 #[test]
17732 fn rust_registered_modules_preserve_import_alias_resolution() {
17733 let dir = tempdir().expect("tempdir");
17734 let root = dir.path();
17735 write_rust_manifest(root, "registered-module-import-control");
17736 write_file(
17737 root,
17738 "src/main.rs",
17739 "mod commands;\nmod db;\nfn main() {}\n",
17740 );
17741 write_file(
17742 root,
17743 "src/commands.rs",
17744 "use crate::db;\n\npub fn run() {\n db::helper();\n}\n",
17745 );
17746 write_file(root, "src/db.rs", "pub fn helper() {}\n");
17747
17748 let main_extract =
17749 build_file_extract(root, &root.join("src/main.rs")).expect("main extract");
17750 let commands_extract =
17751 build_file_extract(root, &root.join("src/commands.rs")).expect("commands extract");
17752 let db_extract = build_file_extract(root, &root.join("src/db.rs")).expect("db extract");
17753 let files = [&main_extract, &commands_extract, &db_extract]
17754 .into_iter()
17755 .map(|extract| {
17756 (
17757 extract.rel_path.clone(),
17758 DbFileIndex::from_extract(root, extract),
17759 )
17760 })
17761 .collect::<HashMap<_, _>>();
17762 let caller_data = [&main_extract, &commands_extract, &db_extract]
17763 .into_iter()
17764 .map(|extract| (extract.rel_path.clone(), &extract.data))
17765 .collect::<HashMap<_, _>>();
17766 let index = ProjectIndex::from_parts(
17767 root,
17768 files,
17769 caller_data,
17770 WorkspaceCratePrefixCache::default(),
17771 );
17772 assert_eq!(
17773 index.module_parent("src/commands.rs"),
17774 Some(("src/main.rs".to_string(), "commands".to_string()))
17775 );
17776 assert_eq!(
17777 index.module_target("src/main.rs", "db").as_deref(),
17778 Some("src/db.rs")
17779 );
17780 let call = commands_extract
17781 .raw_refs
17782 .iter()
17783 .find(|raw| raw.kind == "call" && raw.full_ref.as_deref() == Some("db::helper"))
17784 .expect("db helper call")
17785 .clone();
17786 let resolved = resolve_ref(call, &index).expect("resolve db helper");
17787 assert_eq!(resolved.target_file.as_deref(), Some("src/db.rs"));
17788 assert_eq!(resolved.target_symbol.as_deref(), Some("helper"));
17789
17790 let (store, _) = cold_build_twice(root);
17791 assert_direct_caller(&store, "src/db.rs", "helper", "src/commands.rs", "run");
17792 }
17793
17794 #[test]
17795 fn rust_path_attributed_module_uses_declared_logical_parent() {
17796 let dir = tempdir().expect("tempdir");
17797 let root = dir.path();
17798 write_rust_manifest(root, "path-module-outgoing-fixture");
17799 write_file(
17800 root,
17801 "src/lib.rs",
17802 "pub fn project_range() {}\n\n#[cfg(test)]\n#[path = \"alternate/custom.rs\"]\npub mod conformance;\n",
17803 );
17804 write_file(
17805 root,
17806 "src/alternate/custom.rs",
17807 "pub fn run() {\n super::project_range();\n}\n",
17808 );
17809
17810 let (store, _) = cold_build_twice(root);
17811 assert_direct_caller(
17812 &store,
17813 "src/lib.rs",
17814 "project_range",
17815 "src/alternate/custom.rs",
17816 "run",
17817 );
17818 }
17819
17820 #[test]
17821 fn rust_same_file_test_module_receiver_method_dispatch_resolves() {
17822 let dir = tempdir().expect("tempdir");
17823 let root = dir.path();
17824 write_rust_manifest(root, "same-file-test-module-fixture");
17825 write_file(
17826 root,
17827 "src/lib.rs",
17828 r#"pub struct Index(u32);
17829
17830impl Index {
17831 pub fn shares_index_with(&self, other: &Self) -> bool {
17832 self.0 == other.0
17833 }
17834}
17835
17836#[cfg(test)]
17837mod tests {
17838 use super::Index;
17839
17840 #[test]
17841 fn compares_indexes() {
17842 let before = Index(1);
17843 let after = Index(1);
17844 assert!(before.shares_index_with(&after));
17845 }
17846}
17847"#,
17848 );
17849
17850 let (store, snapshot) = cold_build_twice(root);
17851 assert_direct_caller(
17852 &store,
17853 "src/lib.rs",
17854 "Index::shares_index_with",
17855 "src/lib.rs",
17856 "tests::compares_indexes",
17857 );
17858 assert!(
17859 snapshot.outbound_calls.iter().any(|call| {
17860 call.caller_symbol == "compares_indexes"
17861 && call.line == 17
17862 && call.target.starts_with(&format!(
17863 "shares_index_with{}before.shares_index_with",
17864 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
17865 ))
17866 }),
17867 "expected projected macro receiver call; calls: {:#?}",
17868 snapshot.outbound_calls
17869 );
17870 }
17871
17872 #[test]
17873 fn rust_generic_self_turbofish_method_dispatch_resolves() {
17874 let dir = tempdir().expect("tempdir");
17875 let root = dir.path();
17876 write_rust_manifest(root, "generic-self-fixture");
17877 write_file(
17878 root,
17879 "src/lib.rs",
17880 r#"pub struct Matcher;
17881
17882impl Matcher {
17883 pub fn run(&self) -> bool {
17884 self.fuzzy_match_optimal::<usize>("needle")
17885 }
17886
17887 fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
17888 let _ = std::marker::PhantomData::<T>;
17889 true
17890 }
17891
17892 fn planted_dead(&self) {}
17893}
17894
17895pub fn entry() -> bool {
17896 let matcher = Matcher;
17897 matcher.run()
17898}
17899"#,
17900 );
17901
17902 let (store, snapshot) = cold_build_twice(root);
17903 assert_direct_caller(
17904 &store,
17905 "src/lib.rs",
17906 "Matcher::fuzzy_match_optimal",
17907 "src/lib.rs",
17908 "Matcher::run",
17909 );
17910 assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
17911 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
17912 }
17913
17914 #[test]
17915 fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
17916 let dir = tempdir().expect("tempdir");
17917 let root = dir.path();
17918 write_rust_manifest(root, "manifest-operations-fixture");
17919 write_file(
17920 root,
17921 "src/main.rs",
17922 r#"mod dispatch;
17923use dispatch::{manifest_operations};
17924
17925fn main() {
17926 manifest_operations();
17927}
17928"#,
17929 );
17930 write_file(
17931 root,
17932 "src/dispatch.rs",
17933 r#"mod work_graph { fn operations() {} }
17934mod manifest { fn operations() {} }
17935mod audit { fn operations() {} }
17936mod descriptor { fn operations() {} }
17937mod writer { fn operations() {} }
17938
17939pub fn manifest_operations() {
17940 manifest::operations();
17941}
17942
17943pub fn work_graph_operations() {
17944 work_graph::operations();
17945}
17946
17947pub fn audit_operations() {
17948 audit::operations();
17949}
17950
17951pub fn descriptor_operations() {
17952 descriptor::operations();
17953}
17954
17955pub fn writer_operations() {
17956 writer::operations();
17957}
17958
17959fn planted_dead() {}
17960"#,
17961 );
17962
17963 let (store, snapshot) = cold_build_twice(root);
17964 assert_direct_caller(
17965 &store,
17966 "src/dispatch.rs",
17967 "manifest_operations",
17968 "src/main.rs",
17969 "main",
17970 );
17971 assert_direct_caller(
17972 &store,
17973 "src/dispatch.rs",
17974 "manifest::operations",
17975 "src/dispatch.rs",
17976 "manifest_operations",
17977 );
17978 assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
17979 assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
17980 assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
17981 }
17982
17983 fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
17984 let files = rust_files(root);
17985 let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
17986 .expect("open first store");
17987 first.cold_build(&files).expect("first cold build");
17988 let first_snapshot =
17989 project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
17990
17991 let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
17992 .expect("open second store");
17993 second.cold_build(&files).expect("second cold build");
17994 let second_snapshot =
17995 project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
17996
17997 assert_eq!(
17998 projection_rows(&first_snapshot),
17999 projection_rows(&second_snapshot),
18000 "cold-build projection should be deterministic"
18001 );
18002 (first, first_snapshot)
18003 }
18004
18005 fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
18006 let mut rows = Vec::new();
18007 for export in &snapshot.exported_symbols {
18008 rows.push(format!(
18009 "export\t{}\t{}\t{}\t{}",
18010 export.file.display(),
18011 export.symbol,
18012 export.kind,
18013 export.line
18014 ));
18015 }
18016 for call in &snapshot.outbound_calls {
18017 rows.push(format!(
18018 "call\t{}\t{}\t{}\t{}\t{}",
18019 call.caller_file.display(),
18020 call.caller_symbol,
18021 call.target,
18022 call.line,
18023 call.provenance
18024 ));
18025 }
18026 for file in &snapshot.entry_points {
18027 rows.push(format!("entry_file\t{}", file.display()));
18028 }
18029 for (file, symbols) in &snapshot.entry_point_symbols {
18030 for symbol in symbols {
18031 rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
18032 }
18033 }
18034 rows.sort();
18035 rows
18036 }
18037
18038 fn assert_direct_caller(
18039 store: &CallGraphStore,
18040 target_rel: &str,
18041 target_symbol: &str,
18042 caller_rel: &str,
18043 caller_symbol: &str,
18044 ) {
18045 let callers = store
18046 .direct_callers_of(Path::new(target_rel), target_symbol)
18047 .unwrap_or_else(|error| {
18048 panic!("direct callers for {target_rel}::{target_symbol}: {error}")
18049 });
18050 assert!(
18051 callers.iter().any(|site| {
18052 site.caller.file == caller_rel && site.caller.symbol == caller_symbol
18053 }),
18054 "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
18055 );
18056 }
18057
18058 fn assert_projected_call(
18059 root: &Path,
18060 snapshot: &CallgraphSnapshot,
18061 target_rel: &str,
18062 symbol: &str,
18063 ) {
18064 let target = projected_target(root, target_rel, symbol);
18065 assert!(
18066 snapshot.outbound_calls.iter().any(|call| {
18067 call.target == target
18068 || call.target.starts_with(&format!(
18069 "{target}{}",
18070 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
18071 ))
18072 }),
18073 "expected projected call to {target}; calls: {:#?}",
18074 snapshot.outbound_calls
18075 );
18076 }
18077
18078 fn assert_no_projected_call(
18079 root: &Path,
18080 snapshot: &CallgraphSnapshot,
18081 target_rel: &str,
18082 symbol: &str,
18083 ) {
18084 let target = projected_target(root, target_rel, symbol);
18085 assert!(
18086 snapshot.outbound_calls.iter().all(|call| {
18087 call.target != target
18088 && !call.target.starts_with(&format!(
18089 "{target}{}",
18090 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
18091 ))
18092 }),
18093 "did not expect projected call to {target}; calls: {:#?}",
18094 snapshot.outbound_calls
18095 );
18096 }
18097
18098 fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
18099 let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
18102 format!("{}::{symbol}", path.display())
18103 }
18104
18105 fn write_rust_manifest(root: &Path, name: &str) {
18106 write_file(
18107 root,
18108 "Cargo.toml",
18109 &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
18110 );
18111 }
18112
18113 fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
18114 let path = root.join(rel_path);
18115 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
18116 fs::write(&path, source).expect("write fixture file");
18117 path
18118 }
18119
18120 fn rust_files(root: &Path) -> Vec<PathBuf> {
18121 let mut files = Vec::new();
18122 collect_rust_files(root, &mut files);
18123 files.sort();
18124 files
18125 }
18126
18127 fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
18128 for entry in fs::read_dir(dir).expect("read fixture dir") {
18129 let entry = entry.expect("read fixture entry");
18130 let path = entry.path();
18131 if path.is_dir() {
18132 let name = path
18133 .file_name()
18134 .and_then(|name| name.to_str())
18135 .unwrap_or("");
18136 if !name.starts_with(".store") {
18137 collect_rust_files(&path, files);
18138 }
18139 } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
18140 files.push(path);
18141 }
18142 }
18143 }
18144}
18145
18146#[cfg(test)]
18147mod build_pool_tests {
18148 use super::build_pool_size;
18149
18150 #[test]
18151 fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
18152 let size = build_pool_size();
18153 assert!(size >= 1, "pool size must be at least 1");
18156 assert!(size <= 8, "pool size must be capped at 8, got {size}");
18157
18158 let cores = std::thread::available_parallelism()
18159 .map(|p| p.get())
18160 .unwrap_or(1);
18161 let expected = cores.div_ceil(2).clamp(1, 8);
18162 assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
18163 }
18164}
18165
18166#[cfg(test)]
18167mod reexport_resolution_tests {
18168 use super::*;
18169
18170 fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
18171 ProjectIndex {
18172 project_root: PathBuf::from("/fixture"),
18173 files: files.into_iter().collect(),
18174 caller_data: HashMap::new(),
18175 workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
18176 }
18177 }
18178
18179 fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
18180 DbFileIndex {
18181 lang: None,
18182 exports: HashSet::new(),
18183 default_export: None,
18184 export_aliases: HashMap::new(),
18185 node_by_scoped: HashMap::new(),
18186 node_by_bare: HashMap::new(),
18187 node_kind_by_id: HashMap::new(),
18188 module_targets: HashMap::new(),
18189 declared_module_targets: HashMap::new(),
18190 reexports: reexport_targets
18191 .iter()
18192 .map(|target| ReexportIndex {
18193 target_file: Some((*target).to_string()),
18194 named: HashMap::new(),
18195 wildcard: true,
18196 })
18197 .collect(),
18198 }
18199 }
18200
18201 #[test]
18208 fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
18209 let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
18210 let files = names
18211 .iter()
18212 .map(|name| {
18213 let targets: Vec<&str> = names
18214 .iter()
18215 .filter(|other| *other != name)
18216 .map(String::as_str)
18217 .collect();
18218 (name.clone(), barrel_file(&targets))
18219 })
18220 .collect();
18221 let index = barrel_index(files);
18222
18223 assert_eq!(
18224 resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
18225 None
18226 );
18227 }
18228
18229 #[test]
18235 fn shallow_revisit_after_deep_capped_visit_still_resolves() {
18236 let mut leaf = barrel_file(&[]);
18237 leaf.exports.insert("deep_symbol".to_string());
18238 let mut files: Vec<(String, DbFileIndex)> = Vec::new();
18239 files.push((
18242 "src/entry.ts".to_string(),
18243 barrel_file(&["src/chain0.ts", "src/shared.ts"]),
18244 ));
18245 for i in 0..15 {
18246 let next = if i == 14 {
18247 "src/shared.ts".to_string()
18248 } else {
18249 format!("src/chain{}.ts", i + 1)
18250 };
18251 files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
18252 }
18253 files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
18254 files.push(("src/leaf.ts".to_string(), leaf));
18255 let index = barrel_index(files);
18256
18257 assert_eq!(
18258 resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
18259 Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
18260 "a shallower re-visit must not be pruned by a deeper capped visit"
18261 );
18262 }
18263
18264 #[test]
18265 fn symbol_reachable_through_reexport_cycle_still_resolves() {
18266 let mut leaf = barrel_file(&[]);
18267 leaf.exports.insert("real_symbol".to_string());
18268 let index = barrel_index(vec![
18269 (
18270 "src/a.ts".to_string(),
18271 barrel_file(&["src/b.ts", "src/a.ts"]),
18272 ),
18273 (
18274 "src/b.ts".to_string(),
18275 barrel_file(&["src/a.ts", "src/leaf.ts"]),
18276 ),
18277 ("src/leaf.ts".to_string(), leaf),
18278 ]);
18279
18280 assert_eq!(
18281 resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
18282 Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
18283 );
18284 }
18285}
18286
18287#[cfg(test)]
18288mod method_dispatch_inference_tests {
18289 use super::*;
18290 use std::fs;
18291 use tempfile::tempdir;
18292
18293 #[test]
18294 fn java_field_receiver_type_selects_declared_class_method() {
18295 let source = r#"class EntryPoint {
18296 private UserService userService;
18297
18298 void handle() {
18299 userService.find();
18300 }
18301}
18302
18303class UserService {
18304 void find() {}
18305}
18306
18307class AuditService {
18308 void find() {}
18309}
18310"#;
18311 let dir = tempdir().expect("temp dir");
18312 let root = dir.path();
18313 write_fixture(root, "src/EntryPoint.java", source);
18314 let reference = reference(
18315 "java",
18316 "src/EntryPoint.java",
18317 "EntryPoint::handle",
18318 "userService",
18319 "find",
18320 line_of(source, "userService.find()"),
18321 );
18322 let mut cache = DispatchSourceCache::new();
18323
18324 let receiver_type =
18325 infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
18326 assert_eq!(receiver_type, "UserService");
18327
18328 let candidates = vec![
18329 method_candidate("audit", "AuditService::find"),
18330 method_candidate("user", "UserService::find"),
18331 ];
18332 let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
18333 .expect("type candidate");
18334 assert_eq!(selected.scoped_name, "UserService::find");
18335
18336 let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
18337 assert!(
18338 select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
18339 );
18340 }
18341
18342 #[test]
18343 fn kotlin_property_and_local_value_types_are_inferred() {
18344 let source = r#"class Handler {
18345 private val auditService: AuditService = AuditService()
18346
18347 fun handle() {
18348 auditService.find()
18349 val userService: UserService = UserService()
18350 userService.find()
18351 val billingService = BillingService()
18352 billingService.find()
18353 }
18354}
18355
18356class UserService { fun find() {} }
18357class AuditService { fun find() {} }
18358class BillingService { fun find() {} }
18359"#;
18360 let dir = tempdir().expect("temp dir");
18361 let root = dir.path();
18362 write_fixture(root, "src/Handler.kt", source);
18363 let mut cache = DispatchSourceCache::new();
18364
18365 let audit_ref = reference(
18366 "kotlin",
18367 "src/Handler.kt",
18368 "Handler::handle",
18369 "auditService",
18370 "find",
18371 line_of(source, "auditService.find()"),
18372 );
18373 assert_eq!(
18374 infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
18375 Some("AuditService")
18376 );
18377
18378 let user_ref = reference(
18379 "kotlin",
18380 "src/Handler.kt",
18381 "Handler::handle",
18382 "userService",
18383 "find",
18384 line_of(source, "userService.find()"),
18385 );
18386 assert_eq!(
18387 infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
18388 Some("UserService")
18389 );
18390
18391 let billing_ref = reference(
18392 "kotlin",
18393 "src/Handler.kt",
18394 "Handler::handle",
18395 "billingService",
18396 "find",
18397 line_of(source, "billingService.find()"),
18398 );
18399 assert_eq!(
18400 infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
18401 Some("BillingService")
18402 );
18403 }
18404
18405 #[test]
18406 fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
18407 let source = r#"struct Foo { void run(); };
18408struct PointerFoo { void run(); };
18409struct FactoryFoo { void run(); };
18410FactoryFoo makeFactoryFoo();
18411
18412void handle() {
18413 Foo foo;
18414 foo.run();
18415 PointerFoo* pointerFoo = nullptr;
18416 pointerFoo->run();
18417 auto factoryFoo = makeFactoryFoo();
18418 factoryFoo.run();
18419}
18420"#;
18421 let dir = tempdir().expect("temp dir");
18422 let root = dir.path();
18423 write_fixture(root, "src/fixture.cpp", source);
18424 let mut cache = DispatchSourceCache::new();
18425
18426 let foo_ref = reference(
18427 "cpp",
18428 "src/fixture.cpp",
18429 "handle",
18430 "foo",
18431 "run",
18432 line_of(source, "foo.run()"),
18433 );
18434 assert_eq!(
18435 infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
18436 Some("Foo")
18437 );
18438
18439 let pointer_ref = reference(
18440 "cpp",
18441 "src/fixture.cpp",
18442 "handle",
18443 "pointerFoo",
18444 "run",
18445 line_of(source, "pointerFoo->run()"),
18446 );
18447 assert_eq!(
18448 infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
18449 Some("PointerFoo")
18450 );
18451
18452 let factory_ref = reference(
18453 "cpp",
18454 "src/fixture.cpp",
18455 "handle",
18456 "factoryFoo",
18457 "run",
18458 line_of(source, "factoryFoo.run()"),
18459 );
18460 assert_eq!(
18461 infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
18462 Some("FactoryFoo")
18463 );
18464 }
18465
18466 #[test]
18467 fn rust_direct_self_field_name_trims_separator_whitespace() {
18468 for receiver_expression in ["self .engine", "self. engine", "self . engine"] {
18469 assert_eq!(
18470 rust_direct_self_field_name(receiver_expression),
18471 Some("engine")
18472 );
18473 }
18474 }
18475
18476 #[test]
18477 fn rust_direct_self_field_receiver_type_is_conservative() {
18478 let source = r#"struct Engine;
18479
18480struct Car {
18481 engine: Engine,
18482}
18483
18484impl Car {
18485 fn run(&self) {
18486 self.engine.start();
18487 }
18488}
18489
18490struct NestedCar {
18491 engine: Engine,
18492}
18493
18494impl NestedCar {
18495 fn run(&self) {
18496 self.inner.engine.start();
18497 }
18498}
18499
18500struct WrappedCar {
18501 engine: Option<Engine>,
18502}
18503
18504impl WrappedCar {
18505 fn run(&self) {
18506 self.engine.start(); // wrapped
18507 }
18508}
18509
18510struct GenericCar<T> {
18511 engine: T,
18512}
18513
18514impl<T> GenericCar<T> {
18515 fn run(&self) {
18516 self.engine.start(); // generic
18517 }
18518}
18519
18520type EngineAlias = Engine;
18521
18522struct AliasCar {
18523 engine: EngineAlias,
18524}
18525
18526impl AliasCar {
18527 fn run(&self) {
18528 self.engine.start(); // alias
18529 }
18530}
18531"#;
18532 let dir = tempdir().expect("temp dir");
18533 let root = dir.path();
18534 write_fixture(root, "src/lib.rs", source);
18535 let mut cache = DispatchSourceCache::new();
18536
18537 let mut direct = reference(
18538 "rust",
18539 "src/lib.rs",
18540 "Car::run",
18541 "engine",
18542 "start",
18543 line_of(source, "self.engine.start()"),
18544 );
18545 direct.receiver_expression = "self.engine".to_string();
18546 assert_eq!(
18547 infer_receiver_type(root, &direct, &mut cache).as_deref(),
18548 Some("Engine")
18549 );
18550
18551 let mut mismatched_impl_target = direct.clone();
18552 mismatched_impl_target.caller_symbol = "other::Car::run".to_string();
18553 assert!(infer_receiver_type(root, &mismatched_impl_target, &mut cache).is_none());
18554
18555 let mut nested = reference(
18556 "rust",
18557 "src/lib.rs",
18558 "NestedCar::run",
18559 "engine",
18560 "start",
18561 line_of(source, "self.inner.engine.start()"),
18562 );
18563 nested.receiver_expression = "self.inner.engine".to_string();
18564 assert!(infer_receiver_type(root, &nested, &mut cache).is_none());
18565
18566 let mut wrapped = reference(
18567 "rust",
18568 "src/lib.rs",
18569 "WrappedCar::run",
18570 "engine",
18571 "start",
18572 line_of(source, "self.engine.start(); // wrapped"),
18573 );
18574 wrapped.receiver_expression = "self.engine".to_string();
18575 assert!(infer_receiver_type(root, &wrapped, &mut cache).is_none());
18576
18577 let mut generic = reference(
18578 "rust",
18579 "src/lib.rs",
18580 "GenericCar::run",
18581 "engine",
18582 "start",
18583 line_of(source, "self.engine.start(); // generic"),
18584 );
18585 generic.receiver_expression = "self.engine".to_string();
18586 assert!(infer_receiver_type(root, &generic, &mut cache).is_none());
18587
18588 let mut alias = reference(
18589 "rust",
18590 "src/lib.rs",
18591 "AliasCar::run",
18592 "engine",
18593 "start",
18594 line_of(source, "self.engine.start(); // alias"),
18595 );
18596 alias.receiver_expression = "self.engine".to_string();
18597 assert!(infer_receiver_type(root, &alias, &mut cache).is_none());
18598 }
18599
18600 #[test]
18601 fn rust_direct_self_reference_field_receiver_is_not_inferred() {
18602 let source = r#"struct Engine;
18603
18604struct Car {
18605 engine: &'static Engine,
18606}
18607
18608impl Car {
18609 fn run(&self) {
18610 self.engine.start();
18611 }
18612}
18613"#;
18614 let dir = tempdir().expect("temp dir");
18615 let root = dir.path();
18616 write_fixture(root, "src/lib.rs", source);
18617 let mut cache = DispatchSourceCache::new();
18618 let mut reference = reference(
18619 "rust",
18620 "src/lib.rs",
18621 "Car::run",
18622 "engine",
18623 "start",
18624 line_of(source, "self.engine.start()"),
18625 );
18626 reference.receiver_expression = "self.engine".to_string();
18627
18628 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
18629 }
18630
18631 #[test]
18632 fn rust_trait_impl_self_field_receiver_is_not_inferred() {
18633 let source = r#"trait Drive {
18634 fn run(&self);
18635}
18636
18637struct Engine;
18638
18639struct Car {
18640 engine: Engine,
18641}
18642
18643impl Drive for Car {
18644 fn run(&self) {
18645 self.engine.start();
18646 }
18647}
18648"#;
18649 let dir = tempdir().expect("temp dir");
18650 let root = dir.path();
18651 write_fixture(root, "src/lib.rs", source);
18652 let mut cache = DispatchSourceCache::new();
18653 let mut reference = reference(
18654 "rust",
18655 "src/lib.rs",
18656 "Car::run",
18657 "engine",
18658 "start",
18659 line_of(source, "self.engine.start()"),
18660 );
18661 reference.receiver_expression = "self.engine".to_string();
18662
18663 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
18664 }
18665
18666 #[test]
18667 fn rust_self_field_does_not_bind_struct_from_another_module() {
18668 let source = r#"struct Engine;
18669
18670mod unrelated {
18671 struct Car {
18672 engine: Engine,
18673 }
18674}
18675
18676impl Car {
18677 fn run(&self) {
18678 self.engine.start();
18679 }
18680}
18681"#;
18682 let dir = tempdir().expect("temp dir");
18683 let root = dir.path();
18684 write_fixture(root, "src/lib.rs", source);
18685 let mut cache = DispatchSourceCache::new();
18686 let mut reference = reference(
18687 "rust",
18688 "src/lib.rs",
18689 "Car::run",
18690 "engine",
18691 "start",
18692 line_of(source, "self.engine.start()"),
18693 );
18694 reference.receiver_expression = "self.engine".to_string();
18695
18696 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
18697 }
18698
18699 #[test]
18700 fn unknown_java_receiver_still_uses_name_match_fallback() {
18701 let source = r#"class EntryPoint {
18702 void handle() {
18703 service.runSpecial();
18704 }
18705}
18706
18707class OnlyService {
18708 void runSpecial() {}
18709}
18710"#;
18711 let dir = tempdir().expect("temp dir");
18712 let root = dir.path();
18713 write_fixture(root, "src/EntryPoint.java", source);
18714 let reference = reference(
18715 "java",
18716 "src/EntryPoint.java",
18717 "EntryPoint::handle",
18718 "service",
18719 "runSpecial",
18720 line_of(source, "service.runSpecial()"),
18721 );
18722 let mut cache = DispatchSourceCache::new();
18723
18724 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
18725 let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
18726 let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
18727 assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
18728 }
18729
18730 fn reference(
18731 lang: &str,
18732 caller_file: &str,
18733 caller_symbol: &str,
18734 receiver: &str,
18735 method_name: &str,
18736 line: u32,
18737 ) -> NameMatchRef {
18738 NameMatchRef {
18739 ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
18740 caller_node: format!("{caller_symbol}:node"),
18741 caller_file: caller_file.to_string(),
18742 caller_symbol: caller_symbol.to_string(),
18743 caller_signature: None,
18744 receiver_expression: receiver.to_string(),
18745 receiver: receiver.to_string(),
18746 method_name: method_name.to_string(),
18747 colon_dispatch: false,
18748 line,
18749 lang: lang.to_string(),
18750 }
18751 }
18752
18753 fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
18754 NameMatchCandidate {
18755 node_id: node_id.to_string(),
18756 file_path: "src/targets.fixture".to_string(),
18757 scoped_name: scoped_name.to_string(),
18758 kind: "method".to_string(),
18759 start_line: 1,
18760 }
18761 }
18762
18763 fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
18764 let path = root.join(rel_path);
18765 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
18766 fs::write(path, source).expect("write fixture");
18767 }
18768
18769 fn line_of(source: &str, needle: &str) -> u32 {
18770 source
18771 .lines()
18772 .position(|line| line.contains(needle))
18773 .map(|index| index as u32 + 1)
18774 .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
18775 }
18776}
18777
18778#[cfg(test)]
18779mod bounded_build_breaker_tests {
18780 use super::*;
18781 use crate::build_breaker::{BreakerAdmission, BreakerKey, BuildDeathBreaker, BuildDomain};
18782 use tempfile::tempdir;
18783
18784 #[test]
18785 fn staged_inventory_drives_ordered_bounded_file_batches() {
18786 let temp = tempdir().unwrap();
18787 let root = temp.path().join("root");
18788 std::fs::create_dir_all(&root).unwrap();
18789 let first = root.join("a.ts");
18790 let second = root.join("b.ts");
18791 let third = root.join("c.ts");
18792 for path in [&first, &second, &third] {
18793 std::fs::write(path, "export function item() {}\n").unwrap();
18794 }
18795 let writer_lease = acquire_writer_lease(temp.path(), "inventory-key", &root)
18796 .unwrap()
18797 .expect("test root may write its private staging database");
18798 let store = CallGraphStore::open_at_path(
18799 root.clone(),
18800 "inventory-key".to_string(),
18801 temp.path().join("inventory.sqlite"),
18802 None,
18803 true,
18804 Some(writer_lease),
18805 None,
18806 )
18807 .unwrap()
18808 .store;
18809 let fingerprint = store
18810 .stage_cold_build_file_inventory(&[
18811 third.clone(),
18812 first.clone(),
18813 second.clone(),
18814 first.clone(),
18815 ])
18816 .unwrap();
18817
18818 let conn = store.conn.lock().unwrap();
18819 assert_eq!(
18820 query_count(&conn, "SELECT COUNT(*) FROM staging_file_inventory").unwrap(),
18821 3,
18822 "the primary key deduplicates caller-supplied paths on disk"
18823 );
18824 let first_batch = load_staged_file_batch(&conn, &root, "", 2, u64::MAX)
18825 .unwrap()
18826 .expect("first batch");
18827 assert_eq!(first_batch.paths, vec![first.clone(), second]);
18828 let second_batch =
18829 load_staged_file_batch(&conn, &root, &first_batch.last_path, 2, u64::MAX)
18830 .unwrap()
18831 .expect("second batch");
18832 assert_eq!(second_batch.paths, vec![third]);
18833 assert_eq!(
18834 fingerprint,
18835 callgraph_corpus_fingerprint(&root).unwrap(),
18836 "staged and direct streaming fingerprints agree without walk-order dependence"
18837 );
18838 }
18839
18840 #[test]
18841 fn resumed_stage_preserves_committed_batch_and_counter() {
18842 let temp = tempdir().unwrap();
18843 let root = temp.path().join("root");
18844 std::fs::create_dir_all(&root).unwrap();
18845 let first = root.join("first.ts");
18846 let second = root.join("second.ts");
18847 std::fs::write(&first, "export function first() {}\n").unwrap();
18848 std::fs::write(&second, "export function second() { first(); }\n").unwrap();
18849 let staging = temp.path().join("stage.sqlite");
18850 let writer_lease = acquire_writer_lease(temp.path(), "test-key", &root)
18851 .unwrap()
18852 .expect("test root may write its private staging database");
18853 let store = CallGraphStore::open_at_path(
18854 root.clone(),
18855 "test-key".to_string(),
18856 staging,
18857 None,
18858 true,
18859 Some(writer_lease),
18860 None,
18861 )
18862 .unwrap()
18863 .store;
18864 let corpus_fingerprint = store
18865 .stage_cold_build_file_inventory(&[first.clone(), second.clone()])
18866 .unwrap();
18867 let first_extract = build_file_extract(&root, &first).unwrap();
18868 let first_bytes = first_extract.freshness.size;
18869 {
18870 let mut conn = store.conn.lock().unwrap();
18871 let tx = conn.transaction().unwrap();
18872 clear_tables(&tx).unwrap();
18873 insert_meta(&tx).unwrap();
18874 drop_cold_build_secondary_indexes(&tx).unwrap();
18875 set_meta_ready(&tx, false).unwrap();
18876 set_staged_build_phase(&tx, "extracting").unwrap();
18877 set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, &corpus_fingerprint).unwrap();
18878 set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0).unwrap();
18879 {
18880 let mut inserts = ColdBuildInsertStatements::new(&tx).unwrap();
18881 insert_file_extract_prepared(
18882 &mut inserts,
18883 &root.display().to_string(),
18884 &first_extract,
18885 )
18886 .unwrap();
18887 for raw in &first_extract.raw_refs {
18888 insert_staged_ref_prepared(&mut inserts, raw).unwrap();
18889 }
18890 }
18891 increment_staged_extracted_bytes(&tx, first_bytes).unwrap();
18892 tx.commit().unwrap();
18893 }
18894
18895 store
18896 .cold_build_chunked(&[first.clone(), second.clone()], 1)
18897 .unwrap();
18898 let conn = store.conn.lock().unwrap();
18899 assert_eq!(query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(), 2);
18900 assert_eq!(
18901 staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
18902 first_bytes + std::fs::metadata(second).unwrap().len(),
18903 "the already committed batch and its credit survive adoption; only the new batch increments credit"
18904 );
18905 assert_eq!(staged_build_phase(&conn).unwrap().as_deref(), Some("ready"));
18906 }
18907
18908 const SPECIMEN_CHILD_TEST: &str =
18909 "callgraph_store::bounded_build_breaker_tests::respawn_loop_build_child";
18910 const SPECIMEN_CHILD_ROOT: &str = "AFT_SPECIMEN_CHILD_ROOT";
18911 const SPECIMEN_CHILD_STORE: &str = "AFT_SPECIMEN_CHILD_STORE";
18912 const SPECIMEN_CHILD_PHASE: &str = "AFT_SPECIMEN_CHILD_PHASE";
18913 const SPECIMEN_CHILD_SIGNAL: &str = "AFT_SPECIMEN_CHILD_SIGNAL";
18914
18915 fn wait_for_child_barrier(path: &Path) {
18916 let deadline = Instant::now() + Duration::from_secs(10);
18917 while !path.exists() {
18918 assert!(
18919 Instant::now() < deadline,
18920 "callgraph child did not reach barrier {}",
18921 path.display()
18922 );
18923 std::thread::sleep(Duration::from_millis(5));
18924 }
18925 }
18926
18927 fn spawn_build_child(root: &Path, store: &Path, phase: Option<&str>) -> std::process::Child {
18928 let signal = store.join("specimen-child.reached");
18929 let _ = std::fs::remove_file(&signal);
18930 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
18931 command
18932 .arg("--exact")
18933 .arg(SPECIMEN_CHILD_TEST)
18934 .arg("--nocapture")
18935 .arg("--test-threads=1")
18936 .env(SPECIMEN_CHILD_ROOT, root)
18937 .env(SPECIMEN_CHILD_STORE, store)
18938 .env(SPECIMEN_CHILD_SIGNAL, &signal)
18939 .stdout(std::process::Stdio::null())
18940 .stderr(std::process::Stdio::null());
18941 if let Some(phase) = phase {
18942 command.env(SPECIMEN_CHILD_PHASE, phase);
18943 }
18944 command.spawn().unwrap()
18945 }
18946
18947 fn staging_path(root: &Path, store: &Path) -> PathBuf {
18948 let project_key = crate::search_index::artifact_cache_key(root);
18949 store.join(format!("{project_key}.staging.sqlite.tmp.resume"))
18950 }
18951
18952 fn durable_staging_state(path: &Path) -> (u64, u64) {
18953 if !path.exists() {
18954 return (0, 0);
18955 }
18956 let conn = Connection::open(path).unwrap();
18957 (
18958 query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(),
18959 staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
18960 )
18961 }
18962
18963 fn kill_barrier_child(child: &mut std::process::Child, signal: &Path) {
18964 wait_for_child_barrier(signal);
18965 child.kill().unwrap();
18966 let _ = child.wait().unwrap();
18967 }
18968
18969 #[test]
18970 fn respawn_loop_build_child() {
18971 let Some(root) = std::env::var_os(SPECIMEN_CHILD_ROOT) else {
18972 return;
18973 };
18974 let root = PathBuf::from(root);
18975 let store = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_STORE).unwrap());
18976 if let Some(phase) = std::env::var_os(SPECIMEN_CHILD_PHASE) {
18977 let phase = phase.to_string_lossy().into_owned();
18978 let signal = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_SIGNAL).unwrap());
18979 set_cold_build_phase_observer(Some(Arc::new(move |observed| {
18980 if observed == phase {
18981 std::fs::write(&signal, observed.as_bytes()).unwrap();
18982 std::thread::sleep(Duration::from_secs(30));
18983 }
18984 })));
18985 }
18986 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
18987 CallGraphStore::cold_build_with_lease_chunked(store, root, &files, 1).unwrap();
18988 }
18989
18990 #[test]
18991 fn issue_250_respawn_loop_converges_or_trips_without_false_readiness() {
18992 let temp = tempdir().unwrap();
18993 let root = temp.path().join("resumable-root");
18994 let store = temp.path().join("resumable-store");
18995 std::fs::create_dir_all(&root).unwrap();
18996 std::fs::create_dir_all(&store).unwrap();
18997 for index in 0..3 {
18998 std::fs::write(
18999 root.join(format!("file-{index}.ts")),
19000 format!("export function specimen{index}() {{ return {index}; }}\n"),
19001 )
19002 .unwrap();
19003 }
19004 let stage = staging_path(&root, &store);
19005 let signal = store.join("specimen-child.reached");
19006
19007 let mut first = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
19008 kill_barrier_child(&mut first, &signal);
19009 let (first_rows, first_bytes) = durable_staging_state(&stage);
19010 assert_eq!(first_rows, 1);
19011 assert!(first_bytes > 0);
19012
19013 let mut second = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
19014 kill_barrier_child(&mut second, &signal);
19015 let (second_rows, second_bytes) = durable_staging_state(&stage);
19016 assert_eq!(second_rows, 2);
19017 assert!(
19018 second_bytes > first_bytes,
19019 "a replacement process must adopt committed bytes instead of restarting from zero"
19020 );
19021
19022 let status = spawn_build_child(&root, &store, None).wait().unwrap();
19023 assert!(status.success(), "uninterrupted replacement build failed");
19024 assert!(!stage.exists(), "published staging file must be renamed");
19025 let ready = CallGraphStore::open_readonly(store.clone(), root.clone())
19026 .unwrap()
19027 .expect("replacement attempts must converge to a published graph");
19028 assert_eq!(ready.indexed_file_count().unwrap(), 3);
19029
19030 let fast_root = temp.path().join("zero-credit-root");
19031 let fast_store = temp.path().join("zero-credit-store");
19032 std::fs::create_dir_all(&fast_root).unwrap();
19033 std::fs::create_dir_all(&fast_store).unwrap();
19034 std::fs::write(
19035 fast_root.join("main.ts"),
19036 "export function neverCommitted() {}\n",
19037 )
19038 .unwrap();
19039 let fast_stage = staging_path(&fast_root, &fast_store);
19040 let fast_signal = fast_store.join("specimen-child.reached");
19041 let breaker_path = fast_store.join("build-breaker.sqlite");
19042 let now = unix_millis_now();
19043
19044 for death in 0..3 {
19045 let mut child = spawn_build_child(&fast_root, &fast_store, Some("enumeration"));
19046 wait_for_child_barrier(&fast_signal);
19047 let attempt_id = Connection::open(&breaker_path)
19048 .unwrap()
19049 .query_row(
19050 "SELECT attempt_id FROM breaker_attempts
19051 WHERE death_charged = 0 ORDER BY rowid DESC LIMIT 1",
19052 [],
19053 |row| row.get::<_, String>(0),
19054 )
19055 .unwrap();
19056 let (_, committed_bytes) = durable_staging_state(&fast_stage);
19057 assert_eq!(
19058 committed_bytes, 0,
19059 "the fast-kill schedule must not cross an extraction commit"
19060 );
19061 child.kill().unwrap();
19062 let _ = child.wait().unwrap();
19063
19064 let key = BreakerKey::new(
19065 fast_root.display().to_string(),
19066 BuildDomain::CallgraphCold,
19067 callgraph_corpus_fingerprint(&fast_root).unwrap(),
19068 );
19069 BuildDeathBreaker::open(&breaker_path)
19070 .unwrap()
19071 .record_attributed_death_at(&key, &attempt_id, committed_bytes, 0, now + death)
19072 .unwrap();
19073 }
19074
19075 let files = crate::callgraph::walk_project_files(&fast_root).collect::<Vec<_>>();
19076 let suspension = CallGraphStore::cold_build_suspension(&fast_store, &fast_root)
19077 .unwrap()
19078 .expect("three zero-credit process deaths must suspend the root");
19079 assert_eq!(suspension.reason, "zero_credit_death_limit");
19080 assert_eq!(suspension.death_count, 3);
19081 let response = crate::commands::callgraph_store_adapter::suspended_response(
19082 "specimen",
19083 "callers",
19084 &suspension,
19085 );
19086 assert_eq!(response.data["code"], serde_json::json!("build_suspended"));
19087 let message = response.data["message"].as_str().unwrap();
19088 assert!(
19089 message.starts_with("callers: build_suspended domain=callgraph_cold deaths=3 age_ms=")
19090 );
19091 assert!(message.ends_with(
19092 " reason=zero_credit_death_limit; run doctor reset-build-breaker to resume"
19093 ));
19094 let refused =
19095 CallGraphStore::cold_build_with_lease_chunked(fast_store, fast_root, &files, 1)
19096 .expect_err("a suspended root must not report a perpetually building worker");
19097 assert!(matches!(refused, CallGraphStoreError::Suspended(_)));
19098 }
19099
19100 #[test]
19101 fn published_callgraph_build_respects_durable_domain_suspension() {
19102 let temp = tempdir().unwrap();
19103 let root = temp.path().join("root");
19104 let store_dir = temp.path().join("store");
19105 std::fs::create_dir_all(&root).unwrap();
19106 let source = root.join("main.ts");
19107 std::fs::write(&source, "export function marker() {}\n").unwrap();
19108 let files = vec![source];
19109 let key = BreakerKey::new(
19110 root.display().to_string(),
19111 BuildDomain::CallgraphCold,
19112 callgraph_corpus_fingerprint(&root).unwrap(),
19113 );
19114 let breaker = BuildDeathBreaker::open(store_dir.join("build-breaker.sqlite")).unwrap();
19115 for _ in 0..3 {
19116 let BreakerAdmission::Admitted(attempt) = breaker.admit(&key, 0).unwrap() else {
19117 panic!("unexpected early suspension");
19118 };
19119 breaker
19120 .record_attributed_death(&key, &attempt.attempt_id, 0, 0)
19121 .unwrap();
19122 }
19123
19124 let error = CallGraphStore::cold_build_with_lease_chunked(store_dir, root, &files, 1)
19125 .expect_err("durably tripped callgraph domain must refuse a new cold build");
19126 assert!(matches!(
19127 error,
19128 CallGraphStoreError::Suspended(ref suspension)
19129 if suspension.domain == BuildDomain::CallgraphCold
19130 && suspension.death_count == 3
19131 ));
19132 }
19133}