1pub(crate) mod disk_facts;
9pub(crate) mod facts;
10pub mod join;
11use disk_facts::DiskFacts;
12use facts::{byte_path, EntryKind, FactPaths, ProjectFacts};
13
14use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
15use crate::callgraph::{self, EdgeResolution, FileCallData, TraceToSymbolCandidate};
16use crate::context::SubcLifecycleAdmission;
17use crate::db::{SqliteStore, TrackedConnection};
18use crate::error::AftError;
19use crate::imports::{ImportForm, ImportGroup, ImportKind, ImportStatement};
20use crate::parser::{grammar_for, parse_source_with_cached_parser, LangId};
21use crate::symbols::{Range, SymbolKind};
22use rayon::prelude::*;
23use rusqlite::{
24 params, params_from_iter, Connection, OpenFlags, OptionalExtension, Statement, Transaction,
25};
26use std::cell::RefCell;
27use std::collections::{hash_map::Entry, BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
28use std::fmt;
29use std::io::Read;
30use std::path::{Path, PathBuf};
31use std::rc::Rc;
32use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
33use std::sync::{Arc, Condvar, Mutex, OnceLock};
34use std::thread::JoinHandle;
35use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
36use tree_sitter::{Node, Parser};
37
38const SCHEMA_VERSION: i64 = 1;
39const BACKEND_TREESITTER: &str = "treesitter";
40pub(crate) const PROVENANCE_TREESITTER: &str = "treesitter+resolver";
41const PROVENANCE_NAME_MATCH: &str = "name_match";
42const PROVENANCE_TYPE_MATCH: &str = "type_match";
43const PROVENANCE_VALUE_REF: &str = "value_ref";
44const NAME_MATCH_SCORE_THRESHOLD: f64 = 2.0;
45const TOP_LEVEL_SYMBOL: &str = "<top-level>";
46const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
47const MIGRATION_MANIFEST_VERSION: u32 = 1;
48const MIGRATION_GENERATION_TAG: &str = ".migrated.";
49const MIGRATION_BACKUP_PAGES_PER_STEP: i32 = 128;
50const MIGRATION_BACKUP_RETRY_BUDGET: usize = 25;
51const MIGRATION_BACKUP_WALL_CLOCK_BUDGET: Duration = Duration::from_secs(10);
52const SQLITE_FILE_SET_SUFFIXES: &[&str] = &["", "-wal", "-shm", "-journal"];
53const MARKED_GENERATION_RETENTION_TTL: Duration = Duration::from_secs(6 * 60 * 60);
58const REFRESH_WORKER_WARN_AFTER: Duration = Duration::from_secs(5);
59const REFRESH_WORKER_FINAL_AFTER: Duration = Duration::from_secs(30);
60pub const REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET: Duration = Duration::from_millis(100);
61const REBUILD_COOLDOWN: Duration = Duration::from_secs(30);
62const ROOT_REPAIR_WARN_INTERVAL: Duration = Duration::from_secs(60);
63const CALLGRAPH_WRITE_METRIC_WINDOW: Duration = Duration::from_secs(60);
64const CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES: i64 = 4_000;
65const CALLGRAPH_SQLITE_CACHE_KIB: i64 = -8 * 1024;
68const REFRESH_IDLE_CHECKPOINT_INTERVAL: Duration = Duration::from_secs(60);
69const CALLGRAPH_ROOT_ORPHAN_MIN_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
73const CALLGRAPH_ROOT_SWEEP_LIMIT: usize = 200;
76const CALLGRAPH_ROOT_SWEEP_BUDGET: Duration = Duration::from_secs(5);
77static CALLGRAPH_ROOT_SWEEP_CURSORS: OnceLock<Mutex<HashMap<PathBuf, String>>> = OnceLock::new();
78
79const COLD_BUILD_EXTRACT_BATCH_FILES: usize = 256;
82const COLD_BUILD_EXTRACT_BATCH_BYTES: u64 = 32 * 1024 * 1024;
83const COLD_BUILD_RESOLVE_WINDOW: usize = 20_000;
86const DISK_FILE_INDEX_MEMO_CAPACITY: usize = 4_096;
87const STAGED_COMMITTED_EXTRACTED_BYTES: &str = "committed_extracted_bytes";
88const STAGED_RESOLVE_CURSOR: &str = "resolve_cursor";
89const STAGED_BUILD_PHASE: &str = "staged_build_phase";
90const STAGED_CORPUS_FINGERPRINT: &str = "staged_corpus_fingerprint";
91
92type ColdBuildSwapObserver = dyn Fn(&Path, &Path) + Send + Sync + 'static;
93pub type ColdBuildPhaseObserver = dyn Fn(&'static str) + Send + Sync + 'static;
94#[cfg(test)]
95type ColdBuildSliceObserver = dyn Fn(&'static str, usize, usize) + Send + Sync + 'static;
96#[cfg(test)]
97type ColdBuildExtractObserver = dyn Fn(&[PathBuf]) + Send + Sync + 'static;
98
99#[cfg(test)]
100#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
101pub(crate) struct ProjectionMutationCounts {
102 pub revision_bumps: usize,
103 pub journal_appends: usize,
104}
105
106#[cfg(test)]
107thread_local! {
108 static PROJECTION_MUTATION_COUNTS: std::cell::Cell<ProjectionMutationCounts> =
109 const { std::cell::Cell::new(ProjectionMutationCounts { revision_bumps: 0, journal_appends: 0 }) };
110}
111
112#[cfg(test)]
113fn note_projection_revision_bump_for_test() {
114 PROJECTION_MUTATION_COUNTS.with(|counts| {
115 let mut current = counts.get();
116 current.revision_bumps += 1;
117 counts.set(current);
118 });
119}
120
121#[cfg(test)]
122pub(super) fn note_projection_journal_append_for_test() {
123 PROJECTION_MUTATION_COUNTS.with(|counts| {
124 let mut current = counts.get();
125 current.journal_appends += 1;
126 counts.set(current);
127 });
128}
129
130#[cfg(test)]
131pub(crate) fn take_projection_mutation_counts_for_test() -> ProjectionMutationCounts {
132 PROJECTION_MUTATION_COUNTS.with(|counts| counts.replace(ProjectionMutationCounts::default()))
133}
134
135static COLD_BUILD_PHASE_OBSERVER: OnceLock<Mutex<Option<Arc<ColdBuildPhaseObserver>>>> =
136 OnceLock::new();
137
138pub fn set_cold_build_phase_observer(observer: Option<Arc<ColdBuildPhaseObserver>>) {
142 *COLD_BUILD_PHASE_OBSERVER
143 .get_or_init(|| Mutex::new(None))
144 .lock()
145 .expect("cold build phase observer mutex poisoned") = observer;
146}
147
148fn note_cold_build_phase(phase: &'static str) {
149 if let Some(observer) = COLD_BUILD_PHASE_OBSERVER
150 .get_or_init(|| Mutex::new(None))
151 .lock()
152 .expect("cold build phase observer mutex poisoned")
153 .as_ref()
154 .cloned()
155 {
156 observer(phase);
157 }
158}
159
160#[cfg(test)]
161fn note_cold_build_commit_barrier(phase: &'static str) {
162 note_cold_build_phase(phase);
163}
164
165#[cfg(not(test))]
166fn note_cold_build_commit_barrier(_phase: &'static str) {}
167
168#[derive(Clone, Debug, Eq, Hash, PartialEq)]
169struct RebuildCooldownKey {
170 callgraph_dir: PathBuf,
171 project_key: String,
172}
173
174#[derive(Clone, Debug)]
175struct RebuildCooldownRecord {
176 project_root: PathBuf,
177 published_at: Instant,
178 cross_root_cooldown_armed: bool,
179}
180
181static SUCCESSFUL_REBUILDS: OnceLock<Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>> =
186 OnceLock::new();
187
188#[derive(Clone, Debug, Eq, Hash, PartialEq)]
189struct RootRepairWarningKey {
190 project_key: String,
191}
192
193#[derive(Clone, Debug)]
194struct RootRepairWarningRecord {
195 window_start: Instant,
196 last_emitted: Instant,
197 entry_count: u64,
198 suppressed: u64,
199}
200
201static ROOT_REPAIR_WARNINGS: OnceLock<
202 Mutex<HashMap<RootRepairWarningKey, RootRepairWarningRecord>>,
203> = OnceLock::new();
204
205#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
206pub(crate) struct CallgraphWriteMetricsSnapshot {
207 pub commits_60s: u64,
208 pub pages_or_bytes_written_60s: u64,
209}
210
211#[derive(Debug, Default)]
212struct CallgraphWriteMetrics {
213 window_start_ms: AtomicU64,
214 commits_60s: AtomicU64,
215 pages_or_bytes_written_60s: AtomicU64,
216}
217
218static CALLGRAPH_WRITE_METRICS: OnceLock<Mutex<HashMap<String, Arc<CallgraphWriteMetrics>>>> =
219 OnceLock::new();
220
221fn callgraph_write_metrics_for_key(project_key: &str) -> Arc<CallgraphWriteMetrics> {
222 let metrics = CALLGRAPH_WRITE_METRICS.get_or_init(|| Mutex::new(HashMap::new()));
223 let mut metrics = metrics
224 .lock()
225 .expect("callgraph write metrics mutex poisoned");
226 Arc::clone(
227 metrics
228 .entry(project_key.to_string())
229 .or_insert_with(|| Arc::new(CallgraphWriteMetrics::default())),
230 )
231}
232
233fn roll_callgraph_write_metric_window(metrics: &CallgraphWriteMetrics, now_ms: u64) {
234 let current_start = metrics.window_start_ms.load(AtomicOrdering::Acquire);
235 if current_start == 0 {
236 let _ = metrics.window_start_ms.compare_exchange(
237 0,
238 now_ms,
239 AtomicOrdering::AcqRel,
240 AtomicOrdering::Acquire,
241 );
242 return;
243 }
244 if now_ms.saturating_sub(current_start) < CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64 {
245 return;
246 }
247 if metrics
248 .window_start_ms
249 .compare_exchange(
250 current_start,
251 now_ms,
252 AtomicOrdering::AcqRel,
253 AtomicOrdering::Acquire,
254 )
255 .is_ok()
256 {
257 metrics.commits_60s.store(0, AtomicOrdering::Release);
258 metrics
259 .pages_or_bytes_written_60s
260 .store(0, AtomicOrdering::Release);
261 }
262}
263
264impl CallgraphWriteMetrics {
265 fn record_commit(&self, pages_or_bytes_written: u64) {
266 let now_ms = unix_millis_now();
267 roll_callgraph_write_metric_window(self, now_ms);
268 self.commits_60s.fetch_add(1, AtomicOrdering::Relaxed);
269 self.pages_or_bytes_written_60s
270 .fetch_add(pages_or_bytes_written, AtomicOrdering::Relaxed);
271 }
272
273 fn snapshot(&self) -> CallgraphWriteMetricsSnapshot {
274 roll_callgraph_write_metric_window(self, unix_millis_now());
275 CallgraphWriteMetricsSnapshot {
276 commits_60s: self.commits_60s.load(AtomicOrdering::Acquire),
277 pages_or_bytes_written_60s: self
278 .pages_or_bytes_written_60s
279 .load(AtomicOrdering::Acquire),
280 }
281 }
282}
283
284pub(crate) fn callgraph_write_metrics_for_project(
285 project_key: &str,
286) -> CallgraphWriteMetricsSnapshot {
287 callgraph_write_metrics_for_key(project_key).snapshot()
288}
289
290pub(crate) fn callgraph_write_metrics_total() -> CallgraphWriteMetricsSnapshot {
291 let Some(metrics) = CALLGRAPH_WRITE_METRICS.get() else {
292 return CallgraphWriteMetricsSnapshot::default();
293 };
294 let metrics = metrics
295 .lock()
296 .expect("callgraph write metrics mutex poisoned");
297 metrics.values().map(|metrics| metrics.snapshot()).fold(
298 CallgraphWriteMetricsSnapshot::default(),
299 |total, current| CallgraphWriteMetricsSnapshot {
300 commits_60s: total.commits_60s.saturating_add(current.commits_60s),
301 pages_or_bytes_written_60s: total
302 .pages_or_bytes_written_60s
303 .saturating_add(current.pages_or_bytes_written_60s),
304 },
305 )
306}
307
308const ROOT_REPAIR_WARNING_TEXT: &str =
309 "callgraph store root repair requires rebuild; open-only reader reports unavailable";
310
311fn next_root_repair_warning(key: RootRepairWarningKey, now: Instant) -> Option<String> {
312 let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
313 let mut warnings = warnings.lock().ok()?;
314 let entry = warnings.entry(key);
315 let record = match entry {
316 Entry::Vacant(entry) => {
317 entry.insert(RootRepairWarningRecord {
318 window_start: now,
319 last_emitted: now,
320 entry_count: 1,
321 suppressed: 0,
322 });
323 return Some(ROOT_REPAIR_WARNING_TEXT.to_string());
324 }
325 Entry::Occupied(entry) => entry.into_mut(),
326 };
327
328 if now.saturating_duration_since(record.window_start) >= ROOT_REPAIR_WARN_INTERVAL {
329 let suppressed = record.suppressed;
330 record.window_start = now;
331 record.last_emitted = now;
332 record.entry_count = 1;
333 record.suppressed = 0;
334 return Some(if suppressed == 0 {
335 ROOT_REPAIR_WARNING_TEXT.to_string()
336 } else {
337 format!("{ROOT_REPAIR_WARNING_TEXT} (repeated {suppressed}x in 60s)")
338 });
339 }
340
341 record.entry_count = record.entry_count.saturating_add(1);
342 if now.saturating_duration_since(record.last_emitted) < ROOT_REPAIR_WARN_INTERVAL {
343 record.suppressed = record.suppressed.saturating_add(1);
344 None
345 } else {
346 record.last_emitted = now;
347 Some(ROOT_REPAIR_WARNING_TEXT.to_string())
348 }
349}
350
351pub(crate) fn note_repair_entry(project_key: &str) -> Option<String> {
352 next_root_repair_warning(
353 RootRepairWarningKey {
354 project_key: project_key.to_string(),
355 },
356 Instant::now(),
357 )
358}
359
360pub(crate) fn repair_entry_rate(project_key: &str) -> Option<(u64, Instant)> {
365 let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
366 let warnings = warnings.lock().ok()?;
367 let record = warnings.get(&RootRepairWarningKey {
368 project_key: project_key.to_string(),
369 })?;
370 (Instant::now().saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL)
371 .then_some((record.entry_count, record.window_start))
372}
373
374pub(crate) fn repair_entry_rate_total() -> u64 {
375 let Ok(warnings) = ROOT_REPAIR_WARNINGS
376 .get_or_init(|| Mutex::new(HashMap::new()))
377 .lock()
378 else {
379 return 0;
380 };
381 let now = Instant::now();
382 warnings
383 .values()
384 .filter(|record| {
385 now.saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL
386 })
387 .map(|record| record.entry_count)
388 .sum()
389}
390
391#[cfg(test)]
392pub(crate) fn expire_repair_entry_window_for_test(project_key: &str) {
393 let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
394 let mut warnings = warnings.lock().unwrap();
395 if let Some(record) = warnings.get_mut(&RootRepairWarningKey {
396 project_key: project_key.to_string(),
397 }) {
398 record.window_start = Instant::now() - ROOT_REPAIR_WARN_INTERVAL;
399 }
400}
401
402#[cfg(test)]
403mod root_repair_warning_tests {
404 use super::*;
405
406 #[test]
407 fn repair_warning_emits_once_then_reemits_with_suppressed_count() {
408 let key = RootRepairWarningKey {
409 project_key: "test-project".to_string(),
410 };
411 let first_at = Instant::now();
412 let first = next_root_repair_warning(key.clone(), first_at).unwrap();
413 assert_eq!(first, ROOT_REPAIR_WARNING_TEXT);
414 assert!(next_root_repair_warning(key.clone(), first_at + Duration::from_secs(1)).is_none());
415 assert_eq!(
416 repair_entry_rate("test-project").map(|rate| rate.0),
417 Some(2)
418 );
419
420 let repeated = next_root_repair_warning(key, first_at + ROOT_REPAIR_WARN_INTERVAL).unwrap();
421 assert!(repeated.ends_with("(repeated 1x in 60s)"));
422 expire_repair_entry_window_for_test("test-project");
423 assert!(repair_entry_rate("test-project").is_none());
424 }
425}
426
427#[cfg(test)]
428mod write_amplification_tests {
429 use super::*;
430 use std::fs;
431 use tempfile::tempdir;
432
433 #[test]
434 fn callgraph_writer_waits_when_wal_setup_meets_a_write_lock() {
435 let temp = tempdir().unwrap();
436 let sqlite_path = temp.path().join("contended.sqlite");
437 let blocker = Connection::open(&sqlite_path).expect("open blocking connection");
438 blocker
439 .execute_batch(
440 "PRAGMA journal_mode=DELETE;
441 CREATE TABLE lock_probe (value INTEGER NOT NULL);
442 INSERT INTO lock_probe VALUES (1);
443 BEGIN EXCLUSIVE;
444 UPDATE lock_probe SET value = 2;",
445 )
446 .expect("hold exclusive write transaction");
447
448 let (started_tx, started_rx) = std::sync::mpsc::channel();
449 let configure = std::thread::spawn(move || {
450 let conn = Connection::open(sqlite_path).expect("open contending connection");
451 started_tx.send(()).expect("signal configure start");
452 configure_connection(&conn)
453 });
454 started_rx.recv().expect("configure thread started");
455 std::thread::sleep(Duration::from_millis(100));
456 blocker.execute_batch("COMMIT").expect("release write lock");
457
458 configure
459 .join()
460 .expect("configure thread joined")
461 .expect("WAL setup waits for the writer instead of failing locked");
462 }
463
464 #[test]
465 fn callgraph_writer_and_reader_use_bounded_normal_pragmas() {
466 let temp = tempdir().unwrap();
467 let root = temp.path().join("root");
468 fs::create_dir_all(&root).unwrap();
469 let source = root.join("main.ts");
470 fs::write(&source, "export function main() {}\n").unwrap();
471 let store_dir = temp.path().join("store");
472 let store = CallGraphStore::open(store_dir.clone(), root.clone()).unwrap();
473
474 let conn = store.conn.lock().unwrap();
475 let synchronous: i64 = conn
476 .pragma_query_value(None, "synchronous", |row| row.get(0))
477 .unwrap();
478 let autocheckpoint: i64 = conn
479 .pragma_query_value(None, "wal_autocheckpoint", |row| row.get(0))
480 .unwrap();
481 let cache_size: i64 = conn
482 .pragma_query_value(None, "cache_size", |row| row.get(0))
483 .unwrap();
484 assert_eq!(synchronous, 1, "NORMAL synchronous mode is value 1");
485 assert_eq!(autocheckpoint, CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES);
486 assert_eq!(cache_size, CALLGRAPH_SQLITE_CACHE_KIB);
487 drop(conn);
488 store.cold_build(std::slice::from_ref(&source)).unwrap();
489 drop(store);
490
491 let readonly = CallGraphStore::open_readonly(store_dir, root)
492 .unwrap()
493 .expect("writer-created empty schema should be readable");
494 let conn = readonly.inner.conn.lock().unwrap();
495 let synchronous: i64 = conn
496 .pragma_query_value(None, "synchronous", |row| row.get(0))
497 .unwrap();
498 assert_eq!(synchronous, 1);
499 }
500
501 #[test]
502 fn own_refresh_skips_identical_extract_but_not_position_shift() {
503 let temp = tempdir().unwrap();
504 let root = temp.path().join("root");
505 fs::create_dir_all(&root).unwrap();
506 let source = root.join("main.ts");
507 fs::write(&source, "export function main() { return 1; }\n").unwrap();
508 let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
509 store.cold_build(std::slice::from_ref(&source)).unwrap();
510 let write_metrics = callgraph_write_metrics_for_project(store.project_key());
511 assert!(write_metrics.commits_60s > 0);
512 assert!(write_metrics.pages_or_bytes_written_60s > 0);
513
514 let before = store.conn.lock().unwrap().total_changes();
515 fs::write(&source, "export function main() { return 1; }\n\n").unwrap();
516 let (stats, _) = store
517 .refresh_files_profiled(std::slice::from_ref(&source))
518 .unwrap();
519 let after = store.conn.lock().unwrap().total_changes();
520 assert_eq!(stats.unchanged_extract_files, 1);
521 assert_eq!(stats.refreshed_own_files, 0);
522 assert_eq!(
523 after - before,
524 2,
525 "only files and backend freshness update; graph-neutral edits retain the projection revision"
526 );
527
528 fs::write(&source, "\nexport function main() { return 1; }\n\n").unwrap();
529 let (shifted_stats, _) = store
530 .refresh_files_profiled(std::slice::from_ref(&source))
531 .unwrap();
532 assert_eq!(shifted_stats.unchanged_extract_files, 0);
533 assert_eq!(shifted_stats.refreshed_own_files, 1);
534 }
535
536 #[test]
537 fn revision_bumps_always_have_journal_entries_and_graph_neutral_saves_have_neither() {
538 let temp = tempdir().unwrap();
539 let root = temp.path().join("root");
540 fs::create_dir_all(&root).unwrap();
541 let source = root.join("main.ts");
542 fs::write(&source, "export function before() { return 1; }\n").unwrap();
543 let store = CallGraphStore::open(temp.path().join("store"), root).unwrap();
544 store.cold_build(std::slice::from_ref(&source)).unwrap();
545 let (baseline_revision, mut snapshot) =
546 dead_code_projection::project_dead_code_snapshot_with_revision(store.sqlite_path())
547 .unwrap();
548 let mut revision = baseline_revision.unwrap();
549 take_projection_mutation_counts_for_test();
550
551 for contents in [
552 "export function middle() { return 2; }\n",
553 "export function after() { return 3; }\n",
554 ] {
555 fs::write(&source, contents).unwrap();
556 store
557 .mark_files_stale(std::slice::from_ref(&source))
558 .unwrap();
559 store.refresh_files(std::slice::from_ref(&source)).unwrap();
560
561 let counts = take_projection_mutation_counts_for_test();
562 assert_eq!(
563 counts.revision_bumps, counts.journal_appends,
564 "every revision advance in an edit-save sequence must append its caller delta"
565 );
566 assert_eq!(counts.revision_bumps, 1);
567 let (next_revision, next_snapshot, verdict) =
568 dead_code_projection::project_dead_code_snapshot_incremental(
569 store.sqlite_path(),
570 Some((revision, &snapshot)),
571 )
572 .unwrap();
573 assert_eq!(verdict.kind, dead_code_projection::ProjectionKind::Spliced);
574 revision = next_revision.unwrap();
575 snapshot = next_snapshot;
576 }
577
578 fs::write(&source, "export function after() { return 3; }\n").unwrap();
579 store
580 .mark_files_stale(std::slice::from_ref(&source))
581 .unwrap();
582 store.refresh_files(std::slice::from_ref(&source)).unwrap();
583 assert_eq!(
584 take_projection_mutation_counts_for_test(),
585 ProjectionMutationCounts::default(),
586 "a graph-neutral save must neither advance the revision nor append a delta"
587 );
588 assert_eq!(store.projection_write_revision().unwrap(), Some(revision));
589 }
590
591 #[test]
592 fn one_file_no_graph_delta_refresh_appends_at_most_four_wal_pages_per_changed_row() {
593 const FUNCTION_COUNT: usize = 256;
594 const LOGICAL_ROWS_CHANGED: u64 = 2;
595 const MAX_WAL_PAGES_PER_CHANGED_ROW: u64 = 4;
596
597 let temp = tempdir().unwrap();
598 let root = temp.path().join("root");
599 fs::create_dir_all(&root).unwrap();
600 let source = root.join("large.ts");
601 let dependency = root.join("dependency.ts");
602 fs::write(&dependency, "export function dependency() { return 1; }\n").unwrap();
603 let mut contents = String::from("import { dependency } from './dependency';\n");
604 for index in 0..FUNCTION_COUNT {
605 let next = (index + 1) % FUNCTION_COUNT;
606 contents.push_str(&format!(
607 "export function symbol{index}() {{ console.log(symbol{next}()); return dependency(); }}\n"
608 ));
609 }
610 fs::write(&source, &contents).unwrap();
611
612 let store = CallGraphStore::open(temp.path().join("store"), root).unwrap();
613 store.cold_build(&[source.clone(), dependency]).unwrap();
614 assert!(store.checkpoint_wal_truncate());
615 let wal_path = sqlite_file_set_path(store.sqlite_path(), "-wal");
616 assert_eq!(
617 fs::metadata(&wal_path).map(|meta| meta.len()).unwrap_or(0),
618 0
619 );
620
621 contents.push_str("// Graph-neutral watcher edit.\n");
622 fs::write(&source, contents).unwrap();
623 let changes_before = store.conn.lock().unwrap().total_changes();
624 let stats = store.refresh_files(std::slice::from_ref(&source)).unwrap();
625 let conn = store.conn.lock().unwrap();
626 let changes_after = conn.total_changes();
627 let page_size: u64 = conn
628 .pragma_query_value(None, "page_size", |row| row.get(0))
629 .unwrap();
630 drop(conn);
631
632 let wal_bytes = fs::metadata(&wal_path).unwrap().len();
633 let max_wal_frames = LOGICAL_ROWS_CHANGED * MAX_WAL_PAGES_PER_CHANGED_ROW;
634 let max_wal_bytes = 32 + max_wal_frames * (page_size + 24);
635 assert!(
636 wal_bytes <= max_wal_bytes,
637 "one-file graph-neutral refresh appended {wal_bytes} WAL bytes; bound is {max_wal_bytes} bytes ({max_wal_frames} pages for {LOGICAL_ROWS_CHANGED} changed rows)"
638 );
639 assert_eq!(stats.unchanged_extract_files, 1);
640 assert_eq!(stats.refreshed_own_files, 0);
641 assert_eq!(changes_after - changes_before, LOGICAL_ROWS_CHANGED);
642 }
643
644 #[cfg(unix)]
645 #[test]
646 fn deleted_symlink_alias_refresh_removes_the_original_stale_row() {
647 let temp = tempdir().unwrap();
648 let root = temp.path().join("project");
649 let source = root.join("src/lib.ts");
650 fs::create_dir_all(source.parent().unwrap()).unwrap();
651 fs::write(&source, "export function live() {}\n").unwrap();
652 let alias = temp.path().join("project-alias");
653 std::os::unix::fs::symlink(&root, &alias).unwrap();
654 let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
655 store.cold_build(std::slice::from_ref(&source)).unwrap();
656 store
657 .mark_files_stale(std::slice::from_ref(&source))
658 .unwrap();
659
660 fs::remove_file(&source).unwrap();
661 let stats = store
662 .refresh_files(&[alias.join("src/lib.ts")])
663 .expect("deleted alias path must resolve through its existing parent");
664
665 assert_eq!(stats.deleted_files, vec!["src/lib.ts"]);
666 assert!(store.stale_files().unwrap().is_empty());
667 }
668
669 #[cfg(unix)]
670 #[test]
671 fn symlink_alias_refresh_preserves_real_mutation_detection() {
672 let temp = tempdir().unwrap();
673 let root = temp.path().join("project");
674 let source = root.join("src/lib.ts");
675 fs::create_dir_all(source.parent().unwrap()).unwrap();
676 fs::write(&source, "export function before() {}\n").unwrap();
677 let alias = temp.path().join("project-alias");
678 std::os::unix::fs::symlink(&root, &alias).unwrap();
679 let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
680 store.cold_build(std::slice::from_ref(&source)).unwrap();
681
682 fs::write(&source, "export function after() {}\n").unwrap();
683 let stats = store.refresh_files(&[alias.join("src/lib.ts")]).unwrap();
684
685 assert_eq!(stats.changed_files, vec!["src/lib.ts"]);
686 assert_eq!(stats.refreshed_own_files, 1);
687 assert!(store.node_for(Path::new("src/lib.ts"), "after").is_ok());
688 }
689
690 #[test]
691 fn unresolvable_refresh_path_records_a_path_identity_gap() {
692 let temp = tempdir().unwrap();
693 let root = temp.path().join("project");
694 let source = root.join("src/lib.ts");
695 fs::create_dir_all(source.parent().unwrap()).unwrap();
696 fs::write(&source, "export function live() {}\n").unwrap();
697 let foreign = temp.path().join("foreign.ts");
698 fs::write(&foreign, "export function foreign() {}\n").unwrap();
699 let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
700 store.cold_build(std::slice::from_ref(&source)).unwrap();
701
702 let error = store.refresh_files(&[foreign.clone()]).unwrap_err();
703 assert!(matches!(
704 error,
705 CallGraphStoreError::PathIdentityMismatch { .. }
706 ));
707 let conn = store.conn.lock().unwrap();
708 assert_eq!(
709 path_identity_mismatch_reason(&conn).unwrap(),
710 Some(format!(
711 "callgraph_path_identity_mismatch path={} project_root={}",
712 foreign.display(),
713 root.display()
714 ))
715 );
716 }
717
718 #[test]
719 fn idle_checkpoint_interval_prevents_checkpoint_storms() {
720 let now = Instant::now();
721 assert!(idle_checkpoint_due(None, now));
722 assert!(!idle_checkpoint_due(
723 Some(now),
724 now + Duration::from_secs(REFRESH_IDLE_CHECKPOINT_INTERVAL.as_secs() - 1),
725 ));
726 assert!(idle_checkpoint_due(
727 Some(now),
728 now + REFRESH_IDLE_CHECKPOINT_INTERVAL,
729 ));
730 }
731
732 #[test]
733 fn write_metrics_decay_after_the_sixty_second_window() {
734 let key = format!("metrics-test-{}", now_nanos());
735 let metrics = callgraph_write_metrics_for_key(&key);
736 metrics.record_commit(17);
737 assert_eq!(metrics.snapshot().commits_60s, 1);
738 assert_eq!(metrics.snapshot().pages_or_bytes_written_60s, 17);
739 metrics.window_start_ms.store(
740 unix_millis_now().saturating_sub(CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64),
741 AtomicOrdering::Release,
742 );
743 assert_eq!(metrics.snapshot(), CallgraphWriteMetricsSnapshot::default());
744 }
745}
746
747#[cfg(test)]
748type ColdBuildBeforePublishObserver = dyn Fn() + Send + Sync + 'static;
749thread_local! {
756 static COLD_BUILD_SWAP_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSwapObserver>>> =
757 const { std::cell::RefCell::new(None) };
758 #[cfg(test)]
759 static COLD_BUILD_BEFORE_PUBLISH_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildBeforePublishObserver>>> =
760 const { std::cell::RefCell::new(None) };
761 #[cfg(test)]
762 static COLD_BUILD_SLICE_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSliceObserver>>> =
763 const { std::cell::RefCell::new(None) };
764 #[cfg(test)]
765 static COLD_BUILD_EXTRACT_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildExtractObserver>>> =
766 const { std::cell::RefCell::new(None) };
767 static MIGRATION_AVAILABLE_DISK_OVERRIDE: std::cell::RefCell<Option<u64>> =
768 const { std::cell::RefCell::new(None) };
769 static MIGRATION_FAIL_AFTER_TEMP_COPY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
770 static MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED: std::cell::Cell<bool> =
771 const { std::cell::Cell::new(false) };
772 static PUBLISH_ADMISSION: std::cell::RefCell<Option<(crate::root_cache::ArtifactPublishEpoch, u64)>> =
773 const { std::cell::RefCell::new(None) };
774 static REFRESH_COMMIT_ADMISSION: std::cell::RefCell<Option<(SubcLifecycleAdmission, Arc<std::sync::atomic::AtomicU64>, u64)>> =
775 const { std::cell::RefCell::new(None) };
776}
777
778mod dead_code_projection;
779pub use dead_code_projection::project_dead_code_snapshot;
780#[cfg(test)]
781pub(crate) use dead_code_projection::{
782 project_dead_code_snapshot_incremental, set_projection_before_open_observer,
783 take_projection_work,
784};
785pub(crate) use dead_code_projection::{
786 project_dead_code_snapshot_incremental_with_costs, project_dead_code_snapshot_with_revision,
787 ProjectionCostEstimates, ProjectionKind, ProjectionVerdict, MAX_DELTA_BYTES,
788};
789
790#[doc(hidden)]
791pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
792 COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
793}
794
795#[cfg(test)]
796fn set_cold_build_before_publish_observer(observer: Option<Arc<ColdBuildBeforePublishObserver>>) {
797 COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
798}
799
800#[cfg(test)]
801fn notify_cold_build_before_publish_observer() {
802 let observer = COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| slot.borrow().clone());
803 if let Some(observer) = observer {
804 observer();
805 }
806}
807
808#[cfg(not(test))]
809fn notify_cold_build_before_publish_observer() {}
810
811#[cfg(test)]
812fn set_cold_build_slice_observer(observer: Option<Arc<ColdBuildSliceObserver>>) {
813 COLD_BUILD_SLICE_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
814}
815
816#[cfg(test)]
817fn notify_cold_build_slice_observer(stage: &'static str, completed: usize, total: usize) {
818 let observer = COLD_BUILD_SLICE_OBSERVER.with(|slot| slot.borrow().clone());
819 if let Some(observer) = observer {
820 observer(stage, completed, total);
821 }
822}
823
824#[cfg(not(test))]
825fn notify_cold_build_slice_observer(_stage: &'static str, _completed: usize, _total: usize) {}
826
827#[cfg(test)]
828fn set_cold_build_extract_observer(observer: Option<Arc<ColdBuildExtractObserver>>) {
829 COLD_BUILD_EXTRACT_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
830}
831
832#[cfg(test)]
833fn notify_cold_build_extract_observer(paths: &[PathBuf]) {
834 let observer = COLD_BUILD_EXTRACT_OBSERVER.with(|slot| slot.borrow().clone());
835 if let Some(observer) = observer {
836 observer(paths);
837 }
838}
839
840#[cfg(not(test))]
841fn notify_cold_build_extract_observer(_paths: &[PathBuf]) {}
842
843#[doc(hidden)]
844pub fn set_legacy_migration_available_disk_for_test(bytes: Option<u64>) {
845 MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow_mut() = bytes);
846}
847
848#[doc(hidden)]
849pub fn set_legacy_migration_fail_after_temp_copy_for_test(enabled: bool) {
850 MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.set(enabled));
851}
852
853#[doc(hidden)]
854pub fn set_legacy_migration_backup_budget_exhausted_for_test(enabled: bool) {
855 MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.set(enabled));
856}
857
858struct PublishAdmissionGuard {
859 previous: Option<(crate::root_cache::ArtifactPublishEpoch, u64)>,
860}
861
862impl Drop for PublishAdmissionGuard {
863 fn drop(&mut self) {
864 PUBLISH_ADMISSION.with(|slot| {
865 *slot.borrow_mut() = self.previous.take();
866 });
867 }
868}
869
870pub(crate) fn with_publish_epoch<R>(
871 epoch: crate::root_cache::ArtifactPublishEpoch,
872 expected: u64,
873 run: impl FnOnce() -> R,
874) -> R {
875 let previous = PUBLISH_ADMISSION.with(|slot| slot.replace(Some((epoch, expected))));
876 let _guard = PublishAdmissionGuard { previous };
877 run()
878}
879
880fn ensure_cold_build_current(stage: &'static str, completed: usize, total: usize) -> Result<()> {
881 notify_cold_build_slice_observer(stage, completed, total);
882 let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
883 if admission.is_none_or(|(epoch, expected)| epoch.is_current(expected)) {
884 if let Some(scope) = crate::logging::current_index_build() {
885 crate::logging::log_index_event(
886 crate::logging::IndexEvent::from_scope(
887 crate::logging::IndexEventKind::BuildProgress,
888 &scope,
889 )
890 .field("stage", stage)
891 .field("completed", completed)
892 .field("total", total)
893 .field("elapsed_ms", scope.elapsed_ms()),
894 );
895 }
896 return Ok(());
897 }
898 crate::slog_info!(
899 "callgraph cold build superseded, stopping after {}/{} ({})",
900 completed,
901 total,
902 stage
903 );
904 if let Some(scope) = crate::logging::current_index_build() {
905 crate::logging::log_index_event(
906 crate::logging::IndexEvent::from_scope(
907 crate::logging::IndexEventKind::BuildSuperseded,
908 &scope,
909 )
910 .field("stage", stage)
911 .field("completed", completed)
912 .field("total", total),
913 );
914 }
915 Err(CallGraphStoreError::Superseded)
916}
917
918fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
919 let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
920 match admission {
921 Some((epoch, expected)) => epoch
922 .run_if_current(expected, publish)
923 .unwrap_or(Err(CallGraphStoreError::Superseded)),
924 None => publish(),
925 }
926}
927
928struct RefreshCommitAdmissionGuard {
929 previous: Option<(
930 SubcLifecycleAdmission,
931 Arc<std::sync::atomic::AtomicU64>,
932 u64,
933 )>,
934}
935
936impl Drop for RefreshCommitAdmissionGuard {
937 fn drop(&mut self) {
938 REFRESH_COMMIT_ADMISSION.with(|slot| {
939 *slot.borrow_mut() = self.previous.take();
940 });
941 }
942}
943
944fn with_refresh_commit_admission<R>(
945 lifecycle: SubcLifecycleAdmission,
946 generation_flag: Arc<std::sync::atomic::AtomicU64>,
947 expected_generation: u64,
948 run: impl FnOnce() -> R,
949) -> R {
950 let previous = REFRESH_COMMIT_ADMISSION
951 .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
952 let _guard = RefreshCommitAdmissionGuard { previous };
953 run()
954}
955
956fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
957 let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
958 let commit = || {
959 publish_if_current(|| {
960 tx.commit()?;
961 Ok(())
962 })
963 };
964 match admission {
965 Some((lifecycle, generation_flag, expected_generation)) => lifecycle
966 .run_if_current(generation_flag.as_ref(), expected_generation, commit)
967 .unwrap_or(Err(CallGraphStoreError::Superseded)),
968 None => commit(),
969 }
970}
971
972fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
973 let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
974 if let Some(observer) = observer {
975 observer(temp_path, target_path);
976 }
977}
978
979#[derive(Debug)]
980pub enum CallGraphStoreError {
981 Io(std::io::Error),
982 Sqlite(rusqlite::Error),
983 Json(serde_json::Error),
984 Aft(AftError),
985 Lock(crate::fs_lock::AcquireError),
986 MissingCallerData {
987 file: String,
988 },
989 Unavailable(String),
990 PathIdentityMismatch {
991 path: PathBuf,
992 project_root: PathBuf,
993 },
994 Suspended(crate::build_breaker::BuildSuspension),
995 Superseded,
996 StaleFiles(Vec<String>),
997}
998
999impl CallGraphStoreError {
1000 pub(crate) fn is_transient_lock_contention(&self) -> bool {
1001 matches!(
1002 self,
1003 Self::Sqlite(rusqlite::Error::SqliteFailure(error, _))
1004 if matches!(
1005 error.code,
1006 rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
1007 )
1008 )
1009 }
1010}
1011
1012impl fmt::Display for CallGraphStoreError {
1013 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1014 match self {
1015 Self::Io(error) => write!(formatter, "I/O error: {error}"),
1016 Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
1017 Self::Json(error) => write!(formatter, "json error: {error}"),
1018 Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
1019 Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
1020 Self::MissingCallerData { file } => {
1021 write!(formatter, "missing extracted caller data for {file}")
1022 }
1023 Self::Unavailable(message) => {
1024 write!(formatter, "callgraph store unavailable: {message}")
1025 }
1026 Self::PathIdentityMismatch { path, project_root } => write!(
1027 formatter,
1028 "callgraph path identity mismatch: {} is not under project root {}",
1029 path.display(),
1030 project_root.display()
1031 ),
1032 Self::Suspended(suspension) => write!(
1033 formatter,
1034 "callgraph build suspended for {} after {} deaths ({})",
1035 suspension.domain.as_str(),
1036 suspension.death_count,
1037 suspension.reason
1038 ),
1039 Self::Superseded => {
1040 write!(formatter, "callgraph store build superseded before publish")
1041 }
1042 Self::StaleFiles(files) => {
1043 write!(
1044 formatter,
1045 "callgraph store has stale files: {}",
1046 files.join(", ")
1047 )
1048 }
1049 }
1050 }
1051}
1052
1053impl std::error::Error for CallGraphStoreError {}
1054
1055impl From<std::io::Error> for CallGraphStoreError {
1056 fn from(error: std::io::Error) -> Self {
1057 Self::Io(error)
1058 }
1059}
1060
1061impl From<rusqlite::Error> for CallGraphStoreError {
1062 fn from(error: rusqlite::Error) -> Self {
1063 Self::Sqlite(error)
1064 }
1065}
1066
1067impl From<serde_json::Error> for CallGraphStoreError {
1068 fn from(error: serde_json::Error) -> Self {
1069 Self::Json(error)
1070 }
1071}
1072
1073impl From<AftError> for CallGraphStoreError {
1074 fn from(error: AftError) -> Self {
1075 Self::Aft(error)
1076 }
1077}
1078
1079impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
1080 fn from(error: crate::fs_lock::AcquireError) -> Self {
1081 Self::Lock(error)
1082 }
1083}
1084
1085pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
1086
1087pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
1091
1092#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1093pub struct CallGraphStoreOptions {
1094 pub enabled: bool,
1095}
1096
1097pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
1098
1099#[derive(Clone)]
1103pub(crate) struct CallgraphRefreshState {
1104 store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
1105 heavy_root_work_allowed: Arc<AtomicBool>,
1106}
1107
1108impl CallgraphRefreshState {
1109 pub(crate) fn new(
1110 store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
1111 heavy_root_work_allowed: Arc<AtomicBool>,
1112 ) -> Self {
1113 Self {
1114 store,
1115 heavy_root_work_allowed,
1116 }
1117 }
1118
1119 fn installed_store_snapshot(&self) -> Option<Arc<ReadonlyCallGraphStore>> {
1120 self.store
1121 .read()
1122 .unwrap_or_else(std::sync::PoisonError::into_inner)
1123 .as_ref()
1124 .map(Arc::clone)
1125 }
1126}
1127
1128type WorkspaceCratePrefixes = HashMap<String, String>;
1129
1130#[derive(Clone, Debug, Default)]
1131struct WorkspaceCratePrefixCache(Arc<OnceLock<WorkspaceCratePrefixes>>);
1132
1133const REFRESH_WORKSPACE_CACHE_ROOT_CAP: usize = 128;
1134
1135pub(crate) fn invalidates_workspace_crate_prefix_cache(path: &Path) -> bool {
1136 path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")
1137}
1138
1139pub(crate) fn invalidates_workspace_package_cache(path: &Path) -> bool {
1144 matches!(
1145 path.file_name().and_then(|name| name.to_str()),
1146 Some("package.json") | Some("pnpm-workspace.yaml")
1147 )
1148}
1149
1150#[derive(Clone, Debug, Hash, PartialEq, Eq)]
1151struct RefreshRoot {
1152 callgraph_dir: PathBuf,
1153 project_root: PathBuf,
1154}
1155
1156#[derive(Clone)]
1157pub(crate) struct CallgraphRefreshTicket {
1158 lifecycle: SubcLifecycleAdmission,
1159 generation_flag: Arc<std::sync::atomic::AtomicU64>,
1160 expected_generation: u64,
1161 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
1162 expected_publish_epoch: u64,
1163}
1164
1165impl CallgraphRefreshTicket {
1166 pub(crate) fn new(
1167 lifecycle: SubcLifecycleAdmission,
1168 generation_flag: Arc<std::sync::atomic::AtomicU64>,
1169 expected_generation: u64,
1170 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
1171 expected_publish_epoch: u64,
1172 ) -> Self {
1173 Self {
1174 lifecycle,
1175 generation_flag,
1176 expected_generation,
1177 publish_epoch,
1178 expected_publish_epoch,
1179 }
1180 }
1181
1182 fn is_current(&self) -> bool {
1183 self.lifecycle
1184 .is_current(self.generation_flag.as_ref(), self.expected_generation)
1185 && self.publish_epoch.current() == self.expected_publish_epoch
1186 }
1187}
1188
1189#[derive(Clone)]
1190struct RefreshBatch {
1191 root: RefreshRoot,
1192 paths: BTreeSet<PathBuf>,
1193 pending_sinks: Vec<PendingCallGraphStorePaths>,
1194 refresh_states: Vec<CallgraphRefreshState>,
1195 ticket: Option<CallgraphRefreshTicket>,
1196}
1197
1198impl RefreshBatch {
1199 fn defer(&self) {
1200 for sink in &self.pending_sinks {
1201 sink.lock().extend(self.paths.iter().cloned());
1202 }
1203 }
1204
1205 fn defer_after_open_failure(&self) {
1206 self.defer();
1207 if self
1208 .ticket
1209 .as_ref()
1210 .is_some_and(|ticket| !ticket.is_current())
1211 || !self
1212 .refresh_states
1213 .iter()
1214 .any(|state| state.heavy_root_work_allowed.load(AtomicOrdering::SeqCst))
1215 {
1216 return;
1217 }
1218
1219 let ready_store_installed = self.refresh_states.iter().any(|state| {
1220 let store = state.installed_store_snapshot();
1221 store.is_some_and(|store| {
1222 store.project_root() == self.root.project_root
1223 && !store.is_legacy_fallback()
1224 && store.is_current()
1225 })
1226 });
1227 if !ready_store_installed {
1228 return;
1229 }
1230
1231 for sink in &self.pending_sinks {
1235 let paths = {
1236 let mut pending = sink.lock();
1237 self.paths
1238 .iter()
1239 .filter(|path| pending.remove(*path))
1240 .cloned()
1241 .collect::<Vec<_>>()
1242 };
1243 if paths.is_empty() {
1244 continue;
1245 }
1246 let _ = enqueue_callgraph_store_refresh_inner(
1247 self.root.callgraph_dir.clone(),
1248 self.root.project_root.clone(),
1249 paths,
1250 Arc::clone(sink),
1251 self.refresh_states.clone(),
1252 self.ticket.clone(),
1253 );
1254 }
1255 }
1256
1257 fn merge(
1258 &mut self,
1259 paths: impl IntoIterator<Item = PathBuf>,
1260 sink: PendingCallGraphStorePaths,
1261 refresh_states: Vec<CallgraphRefreshState>,
1262 ticket: Option<CallgraphRefreshTicket>,
1263 ) {
1264 self.paths.extend(paths);
1265 if ticket.is_some() {
1266 self.ticket = ticket;
1267 }
1268 if !self
1269 .pending_sinks
1270 .iter()
1271 .any(|existing| Arc::ptr_eq(existing, &sink))
1272 {
1273 self.pending_sinks.push(sink);
1274 }
1275 for refresh_state in refresh_states {
1276 if !self.refresh_states.iter().any(|existing| {
1277 Arc::ptr_eq(&existing.store, &refresh_state.store)
1278 && Arc::ptr_eq(
1279 &existing.heavy_root_work_allowed,
1280 &refresh_state.heavy_root_work_allowed,
1281 )
1282 }) {
1283 self.refresh_states.push(refresh_state);
1284 }
1285 }
1286 }
1287}
1288
1289#[derive(Default)]
1290struct RefreshQueue {
1291 order: VecDeque<RefreshRoot>,
1292 queued: HashMap<RefreshRoot, RefreshBatch>,
1293 active: Option<RefreshBatch>,
1294 shutdown_requested: bool,
1295}
1296
1297struct RefreshWorkerShared {
1298 queue: Mutex<RefreshQueue>,
1299 wake: Condvar,
1300}
1301
1302struct RefreshWorker {
1303 shared: Arc<RefreshWorkerShared>,
1304 thread: Mutex<Option<JoinHandle<()>>>,
1305}
1306
1307struct RefreshWorkerWatchdog {
1308 first_path: PathBuf,
1309 batch_len: usize,
1310 started: Instant,
1311}
1312
1313impl RefreshWorkerWatchdog {
1314 fn start(paths: &[PathBuf]) -> Self {
1315 Self {
1316 first_path: paths
1317 .first()
1318 .expect("non-empty callgraph refresh batch has a first path")
1319 .clone(),
1320 batch_len: paths.len(),
1321 started: Instant::now(),
1322 }
1323 }
1324}
1325
1326impl Drop for RefreshWorkerWatchdog {
1327 fn drop(&mut self) {
1328 let elapsed = self.started.elapsed();
1329 if elapsed < REFRESH_WORKER_WARN_AFTER {
1330 return;
1331 }
1332 let path = if self.batch_len == 1 {
1333 self.first_path.display().to_string()
1334 } else {
1335 format!(
1336 "{} (+{} paths)",
1337 self.first_path.display(),
1338 self.batch_len - 1
1339 )
1340 };
1341 log::warn!(
1342 "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
1343 path,
1344 elapsed.as_millis()
1345 );
1346 if elapsed >= REFRESH_WORKER_FINAL_AFTER {
1347 log::warn!(
1348 "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
1349 path,
1350 elapsed.as_millis()
1351 );
1352 }
1353 }
1354}
1355
1356impl RefreshWorker {
1357 fn spawn() -> Arc<Self> {
1358 let shared = Arc::new(RefreshWorkerShared {
1359 queue: Mutex::new(RefreshQueue::default()),
1360 wake: Condvar::new(),
1361 });
1362 let thread_shared = Arc::clone(&shared);
1363 let thread = std::thread::Builder::new()
1364 .name("aft-callgraph-refresh".to_string())
1365 .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
1366 .expect("failed to spawn callgraph refresh worker");
1367 Arc::new(Self {
1368 shared,
1369 thread: Mutex::new(Some(thread)),
1370 })
1371 }
1372
1373 fn enqueue(
1374 &self,
1375 root: RefreshRoot,
1376 paths: Vec<PathBuf>,
1377 pending_sink: PendingCallGraphStorePaths,
1378 refresh_states: Vec<CallgraphRefreshState>,
1379 ticket: Option<CallgraphRefreshTicket>,
1380 ) -> bool {
1381 let mut queue = self
1382 .shared
1383 .queue
1384 .lock()
1385 .expect("callgraph refresh queue mutex poisoned");
1386 if queue.shutdown_requested {
1387 pending_sink.lock().extend(paths);
1388 return false;
1389 }
1390 if let Some(batch) = queue.queued.get_mut(&root) {
1391 batch.merge(paths, pending_sink, refresh_states, ticket);
1392 } else {
1393 queue.order.push_back(root.clone());
1394 queue.queued.insert(
1395 root.clone(),
1396 RefreshBatch {
1397 root,
1398 paths: paths.into_iter().collect(),
1399 pending_sinks: vec![pending_sink],
1400 refresh_states,
1401 ticket,
1402 },
1403 );
1404 }
1405 self.shared.wake.notify_one();
1406 true
1407 }
1408
1409 fn shutdown_with_budget(&self, budget: Duration) -> bool {
1410 let deadline = Instant::now() + budget;
1411 let mut queue = self
1412 .shared
1413 .queue
1414 .lock()
1415 .expect("callgraph refresh queue mutex poisoned");
1416 queue.shutdown_requested = true;
1417 self.shared.wake.notify_one();
1418 while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
1419 let remaining = deadline.saturating_duration_since(Instant::now());
1420 let (next, _) = self
1421 .shared
1422 .wake
1423 .wait_timeout(queue, remaining)
1424 .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
1425 queue = next;
1426 }
1427 let drained = queue.active.is_none() && queue.order.is_empty();
1428 if !drained {
1429 if let Some(active) = queue.active.as_ref() {
1430 active.defer();
1431 }
1432 for batch in queue.queued.values() {
1433 batch.defer();
1434 }
1435 queue.order.clear();
1436 queue.queued.clear();
1437 }
1438 drop(queue);
1439
1440 if drained {
1441 if let Some(thread) = self
1442 .thread
1443 .lock()
1444 .expect("callgraph refresh worker thread mutex poisoned")
1445 .take()
1446 {
1447 let _ = thread.join();
1448 }
1449 }
1450 drained
1451 }
1452}
1453
1454static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
1455
1456pub fn enqueue_callgraph_store_refresh(
1457 callgraph_dir: PathBuf,
1458 project_root: PathBuf,
1459 paths: Vec<PathBuf>,
1460 pending_sink: PendingCallGraphStorePaths,
1461) -> bool {
1462 enqueue_callgraph_store_refresh_inner(
1463 callgraph_dir,
1464 project_root,
1465 paths,
1466 pending_sink,
1467 Vec::new(),
1468 None,
1469 )
1470}
1471
1472#[cfg(test)]
1473pub(crate) fn enqueue_callgraph_store_refresh_fenced(
1474 callgraph_dir: PathBuf,
1475 project_root: PathBuf,
1476 paths: Vec<PathBuf>,
1477 pending_sink: PendingCallGraphStorePaths,
1478 ticket: CallgraphRefreshTicket,
1479) -> bool {
1480 enqueue_callgraph_store_refresh_inner(
1481 callgraph_dir,
1482 project_root,
1483 paths,
1484 pending_sink,
1485 Vec::new(),
1486 Some(ticket),
1487 )
1488}
1489
1490pub(crate) fn enqueue_callgraph_store_refresh_fenced_with_state(
1491 callgraph_dir: PathBuf,
1492 project_root: PathBuf,
1493 paths: Vec<PathBuf>,
1494 pending_sink: PendingCallGraphStorePaths,
1495 refresh_state: CallgraphRefreshState,
1496 ticket: CallgraphRefreshTicket,
1497) -> bool {
1498 enqueue_callgraph_store_refresh_inner(
1499 callgraph_dir,
1500 project_root,
1501 paths,
1502 pending_sink,
1503 vec![refresh_state],
1504 Some(ticket),
1505 )
1506}
1507
1508fn enqueue_callgraph_store_refresh_inner(
1509 callgraph_dir: PathBuf,
1510 project_root: PathBuf,
1511 paths: Vec<PathBuf>,
1512 pending_sink: PendingCallGraphStorePaths,
1513 refresh_states: Vec<CallgraphRefreshState>,
1514 ticket: Option<CallgraphRefreshTicket>,
1515) -> bool {
1516 if paths.is_empty() {
1517 return true;
1518 }
1519 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1520 let worker = {
1521 let mut worker = slot
1522 .lock()
1523 .expect("callgraph refresh worker mutex poisoned");
1524 Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
1525 };
1526 worker.enqueue(
1527 RefreshRoot {
1528 callgraph_dir,
1529 project_root,
1530 },
1531 paths,
1532 pending_sink,
1533 refresh_states,
1534 ticket,
1535 )
1536}
1537
1538pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
1539 flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
1540}
1541
1542#[doc(hidden)]
1543pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
1544 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1545 let worker = slot
1546 .lock()
1547 .expect("callgraph refresh worker mutex poisoned")
1548 .clone();
1549 let Some(worker) = worker else {
1550 return true;
1551 };
1552 let drained = worker.shutdown_with_budget(budget);
1553 if drained {
1554 let mut current = slot
1555 .lock()
1556 .expect("callgraph refresh worker mutex poisoned");
1557 if current
1558 .as_ref()
1559 .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
1560 {
1561 *current = None;
1562 }
1563 }
1564 drained
1565}
1566
1567fn idle_checkpoint_due(last: Option<Instant>, now: Instant) -> bool {
1568 last.is_none_or(|last| now.saturating_duration_since(last) >= REFRESH_IDLE_CHECKPOINT_INTERVAL)
1569}
1570
1571fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
1572 let mut workspace_crate_prefixes = HashMap::new();
1575 let mut last_idle_checkpoints: HashMap<RefreshRoot, Instant> = HashMap::new();
1576 loop {
1577 let batch = {
1578 let mut queue = shared
1579 .queue
1580 .lock()
1581 .expect("callgraph refresh queue mutex poisoned");
1582 loop {
1583 if let Some(root) = queue.order.pop_front() {
1584 let batch = queue
1585 .queued
1586 .remove(&root)
1587 .expect("queued callgraph refresh root has a batch");
1588 queue.active = Some(batch.clone());
1589 break batch;
1590 }
1591 if queue.shutdown_requested {
1592 return;
1593 }
1594 queue = shared
1595 .wake
1596 .wait(queue)
1597 .expect("callgraph refresh queue mutex poisoned while waiting");
1598 }
1599 };
1600
1601 let store = process_callgraph_refresh_batch(&batch, &mut workspace_crate_prefixes);
1602
1603 let mut queue = shared
1604 .queue
1605 .lock()
1606 .expect("callgraph refresh queue mutex poisoned");
1607 queue.active = None;
1608 let became_idle = queue.order.is_empty();
1609 shared.wake.notify_all();
1610 drop(queue);
1611
1612 if became_idle {
1613 let checkpoint_due = idle_checkpoint_due(
1614 last_idle_checkpoints.get(&batch.root).copied(),
1615 Instant::now(),
1616 );
1617 if checkpoint_due {
1618 if let Some(store) = store {
1619 if store.checkpoint_wal_truncate() {
1620 last_idle_checkpoints.insert(batch.root.clone(), Instant::now());
1621 }
1622 }
1623 }
1624 }
1625 }
1626}
1627
1628fn process_callgraph_refresh_batch(
1629 batch: &RefreshBatch,
1630 workspace_crate_prefixes: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1631) -> Option<CallGraphStore> {
1632 if batch
1636 .paths
1637 .iter()
1638 .any(|path| invalidates_workspace_crate_prefix_cache(path))
1639 {
1640 workspace_crate_prefixes.remove(&batch.root);
1641 }
1642 if batch
1643 .paths
1644 .iter()
1645 .any(|path| invalidates_workspace_package_cache(path))
1646 {
1647 callgraph::clear_workspace_package_cache();
1648 }
1649
1650 let paths = batch
1651 .paths
1652 .iter()
1653 .filter(|path| crate::parser::detect_language(path).is_some())
1654 .cloned()
1655 .collect::<Vec<_>>();
1656 if paths.is_empty() {
1657 return None;
1658 }
1659 note_refresh_worker_batch_for_test(&batch.root.project_root, &paths);
1660 if batch
1661 .ticket
1662 .as_ref()
1663 .is_some_and(|ticket| !ticket.is_current())
1664 {
1665 batch.defer();
1668 return None;
1669 }
1670 let workspace_crate_prefix_cache =
1671 workspace_crate_prefix_cache_for_root(workspace_crate_prefixes, &batch.root);
1672 let _watchdog = RefreshWorkerWatchdog::start(&paths);
1673 let test_seam = refresh_worker_test_seam(&batch.root.project_root);
1674 note_refresh_worker_call_for_test(&batch.root.project_root);
1675 let opened = if test_seam.fail_open {
1676 Ok(None)
1677 } else {
1678 CallGraphStore::open_ready(
1679 batch.root.callgraph_dir.clone(),
1680 batch.root.project_root.clone(),
1681 )
1682 };
1683 if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
1684 let _ = gate.held_tx.send(());
1687 let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
1688 }
1689 let store = match opened {
1690 Ok(Some(store)) => store,
1691 Ok(None) => {
1692 batch.defer_after_open_failure();
1693 return None;
1694 }
1695 Err(error) => {
1696 batch.defer_after_open_failure();
1697 crate::slog_warn!(
1698 "callgraph store writer open failed during refresh; deferred paths: {}",
1699 error
1700 );
1701 return None;
1702 }
1703 };
1704 if !test_seam.delay.is_zero() {
1705 std::thread::sleep(test_seam.delay);
1706 }
1707 if batch
1708 .ticket
1709 .as_ref()
1710 .is_some_and(|ticket| !ticket.is_current())
1711 {
1712 batch.defer();
1715 return Some(store);
1716 }
1717 let refresh_result = if test_seam.fail_refresh {
1718 Err(CallGraphStoreError::Unavailable(
1719 "injected refresh worker failure".to_string(),
1720 ))
1721 } else if let Some(ticket) = &batch.ticket {
1722 with_publish_epoch(
1723 ticket.publish_epoch.clone(),
1724 ticket.expected_publish_epoch,
1725 || {
1726 with_refresh_commit_admission(
1727 ticket.lifecycle.clone(),
1728 Arc::clone(&ticket.generation_flag),
1729 ticket.expected_generation,
1730 || {
1731 store
1732 .refresh_files_with_workspace_crate_prefix_cache(
1733 &paths,
1734 workspace_crate_prefix_cache.clone(),
1735 )
1736 .map(|_| ())
1737 },
1738 )
1739 },
1740 )
1741 } else {
1742 store
1743 .refresh_files_with_workspace_crate_prefix_cache(
1744 &paths,
1745 workspace_crate_prefix_cache.clone(),
1746 )
1747 .map(|_| ())
1748 };
1749 if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
1750 batch.defer();
1754 return Some(store);
1755 }
1756 if let Err(error) = refresh_result {
1757 crate::slog_warn!("callgraph store refresh failed: {}", error);
1758 match store.mark_files_stale(&paths) {
1759 Ok(marked) => {
1760 note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
1761 crate::slog_warn!(
1762 "marked {} callgraph store file(s) stale after refresh failure",
1763 marked.len()
1764 );
1765 }
1766 Err(mark_error) => crate::slog_warn!(
1767 "failed to mark callgraph store files stale after refresh failure: {}",
1768 mark_error
1769 ),
1770 }
1771 } else {
1772 crate::logging::note_callgraph_invalidations(paths.len());
1773 }
1774 Some(store)
1775}
1776
1777fn workspace_crate_prefix_cache_for_root(
1778 caches: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1779 root: &RefreshRoot,
1780) -> WorkspaceCratePrefixCache {
1781 if !caches.contains_key(root) && caches.len() >= REFRESH_WORKSPACE_CACHE_ROOT_CAP {
1782 if let Some(evicted) = caches.keys().next().cloned() {
1784 caches.remove(&evicted);
1785 }
1786 }
1787 caches.entry(root.clone()).or_default().clone()
1788}
1789
1790#[derive(Clone, Default)]
1791struct RefreshWorkerTestSeam {
1792 delay: Duration,
1793 fail_refresh: bool,
1794 fail_open: bool,
1795 refresh_calls: usize,
1796 worker_calls: usize,
1797 stale_marks: usize,
1798 received_paths: BTreeSet<PathBuf>,
1799}
1800
1801static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
1802 OnceLock::new();
1803
1804struct RefreshWorkerTestGate {
1805 held_tx: crossbeam_channel::Sender<()>,
1806 release_rx: crossbeam_channel::Receiver<()>,
1807}
1808
1809static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
1810 OnceLock::new();
1811
1812#[doc(hidden)]
1813pub fn install_callgraph_refresh_worker_test_gate(
1814 project_root: PathBuf,
1815) -> (
1816 crossbeam_channel::Receiver<()>,
1817 crossbeam_channel::Sender<()>,
1818) {
1819 let (held_tx, held_rx) = crossbeam_channel::bounded(1);
1820 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1821 REFRESH_WORKER_TEST_GATES
1822 .get_or_init(|| Mutex::new(HashMap::new()))
1823 .lock()
1824 .expect("callgraph refresh test gate mutex poisoned")
1825 .insert(
1826 project_root,
1827 RefreshWorkerTestGate {
1828 held_tx,
1829 release_rx,
1830 },
1831 );
1832 (held_rx, release_tx)
1833}
1834
1835fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
1836 REFRESH_WORKER_TEST_GATES
1837 .get_or_init(|| Mutex::new(HashMap::new()))
1838 .lock()
1839 .expect("callgraph refresh test gate mutex poisoned")
1840 .remove(project_root)
1841}
1842
1843fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
1844 let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
1845 return RefreshWorkerTestSeam::default();
1846 };
1847 seams
1848 .lock()
1849 .expect("callgraph refresh test seam mutex poisoned")
1850 .get(project_root)
1851 .cloned()
1852 .unwrap_or_default()
1853}
1854
1855fn note_refresh_worker_batch_for_test(project_root: &Path, paths: &[PathBuf]) {
1856 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1857 if let Some(seam) = seams
1858 .lock()
1859 .expect("callgraph refresh test seam mutex poisoned")
1860 .get_mut(project_root)
1861 {
1862 seam.worker_calls += 1;
1863 seam.received_paths.extend(paths.iter().cloned());
1864 }
1865 }
1866}
1867
1868fn note_refresh_worker_call_for_test(project_root: &Path) {
1869 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1870 if let Some(seam) = seams
1871 .lock()
1872 .expect("callgraph refresh test seam mutex poisoned")
1873 .get_mut(project_root)
1874 {
1875 seam.refresh_calls += 1;
1876 }
1877 }
1878}
1879
1880fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
1881 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1882 if let Some(seam) = seams
1883 .lock()
1884 .expect("callgraph refresh test seam mutex poisoned")
1885 .get_mut(project_root)
1886 {
1887 seam.stale_marks += 1;
1888 }
1889 }
1890}
1891
1892#[doc(hidden)]
1893pub fn set_callgraph_refresh_worker_test_seam(
1894 project_root: PathBuf,
1895 delay: Duration,
1896 fail_refresh: bool,
1897) {
1898 REFRESH_WORKER_TEST_SEAMS
1899 .get_or_init(|| Mutex::new(HashMap::new()))
1900 .lock()
1901 .expect("callgraph refresh test seam mutex poisoned")
1902 .insert(
1903 project_root,
1904 RefreshWorkerTestSeam {
1905 delay,
1906 fail_refresh,
1907 ..RefreshWorkerTestSeam::default()
1908 },
1909 );
1910}
1911
1912#[doc(hidden)]
1913pub fn set_callgraph_refresh_worker_test_open_failure(project_root: PathBuf, enabled: bool) {
1914 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1915 if let Some(seam) = seams
1916 .lock()
1917 .expect("callgraph refresh test seam mutex poisoned")
1918 .get_mut(&project_root)
1919 {
1920 seam.fail_open = enabled;
1921 }
1922 }
1923}
1924
1925#[doc(hidden)]
1926pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
1927 let seam = refresh_worker_test_seam(project_root);
1928 (seam.refresh_calls, seam.stale_marks)
1929}
1930
1931#[doc(hidden)]
1932pub fn callgraph_refresh_worker_test_worker_calls(project_root: &Path) -> usize {
1933 refresh_worker_test_seam(project_root).worker_calls
1934}
1935
1936#[doc(hidden)]
1937pub fn callgraph_refresh_worker_test_paths(project_root: &Path) -> BTreeSet<PathBuf> {
1938 refresh_worker_test_seam(project_root).received_paths
1939}
1940
1941#[doc(hidden)]
1942pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
1943 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1944 seams
1945 .lock()
1946 .expect("callgraph refresh test seam mutex poisoned")
1947 .remove(project_root);
1948 }
1949}
1950
1951#[derive(Debug)]
1952pub struct CallGraphStore {
1953 project_root: PathBuf,
1954 project_key: String,
1955 sqlite_path: PathBuf,
1959 publication_dir: PathBuf,
1963 legacy_fallback: bool,
1967 manifest_view: bool,
1968 generation: Option<String>,
1973 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1974 read_marker: Option<crate::root_cache::ReadMarker>,
1975 database_ready: AtomicBool,
1978 write_metrics: Arc<CallgraphWriteMetrics>,
1979 conn: Mutex<TrackedConnection>,
1980}
1981
1982#[derive(Debug)]
1983pub struct ReadonlyCallGraphStore {
1984 inner: CallGraphStore,
1985 _view_pin: Option<Arc<crate::pins::QueryPin>>,
1986}
1987
1988pub trait CallGraphRead {
1989 fn project_root(&self) -> &Path;
1990 fn project_key(&self) -> &str;
1991 fn sqlite_path(&self) -> &Path;
1992 fn is_current(&self) -> bool;
1993 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
1994 fn indexed_file_count(&self) -> Result<usize>;
1995 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
1996 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
1997 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
1998 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
1999 fn direct_callers_for_symbols(
2000 &self,
2001 targets: &[(String, String)],
2002 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
2003 targets
2004 .iter()
2005 .cloned()
2006 .map(|target| {
2007 let callers = self.direct_callers_of(Path::new(&target.0), &target.1)?;
2008 Ok((target, callers))
2009 })
2010 .collect()
2011 }
2012 fn direct_caller_counts_of(
2013 &self,
2014 targets: &[(String, String)],
2015 ) -> Result<HashMap<(String, String), usize>>;
2016 fn outgoing_calls_for_symbols(
2017 &self,
2018 sources: &[(String, String)],
2019 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
2020 fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
2021 -> Result<StoreCallersResult>;
2022 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
2023 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
2024 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
2025 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
2026 fn call_tree(
2027 &self,
2028 file_rel: &Path,
2029 symbol: &str,
2030 depth: usize,
2031 ) -> Result<callgraph::CallTreeNode>;
2032 fn trace_to(
2033 &self,
2034 file_rel: &Path,
2035 symbol: &str,
2036 max_depth: usize,
2037 ) -> Result<callgraph::TraceToResult>;
2038 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
2039 fn trace_to_symbol(
2040 &self,
2041 file_rel: &Path,
2042 symbol: &str,
2043 to_symbol: &str,
2044 to_file: Option<&Path>,
2045 max_depth: usize,
2046 ) -> Result<callgraph::TraceToSymbolResult>;
2047}
2048
2049#[derive(Debug, Clone, PartialEq, Eq)]
2050enum OpenRootRepair {
2051 None,
2052 ReRooted,
2053 NeedsRebuild {
2054 previous_roots: Vec<String>,
2055 current_root: String,
2056 reason: String,
2057 },
2058}
2059
2060struct OpenedStore {
2061 store: CallGraphStore,
2062 root_repair: OpenRootRepair,
2063}
2064
2065#[derive(Clone, Debug)]
2066struct LegacyCallgraphPartition {
2067 harness: String,
2068 dir: PathBuf,
2069 key: String,
2070 bytes: u64,
2071 freshness: Option<SystemTime>,
2072}
2073
2074#[derive(Clone, Debug)]
2075struct LegacyCallgraphTarget {
2076 partition: LegacyCallgraphPartition,
2077 sqlite_path: PathBuf,
2078 generation: Option<String>,
2079 source_bytes: u64,
2080 source_blake3: String,
2081}
2082
2083#[derive(Clone, Debug)]
2084struct SourceFingerprint {
2085 bytes: u64,
2086 blake3: String,
2087}
2088
2089#[derive(Clone, Debug)]
2090struct PublishedLegacyMigration {
2091 generation: String,
2092 migrated_bytes: u64,
2093}
2094
2095#[derive(Debug, Clone)]
2096pub struct ColdBuildStats {
2097 pub files: usize,
2098 pub nodes: usize,
2099 pub refs: usize,
2100 pub edges: usize,
2101 pub failed_files: Vec<String>,
2102 pub elapsed_ms: u128,
2103}
2104
2105#[derive(Debug, Clone)]
2106pub struct IncrementalStats {
2107 pub changed_files: Vec<String>,
2108 pub surface_changed: Vec<String>,
2109 pub deleted_files: Vec<String>,
2110 pub dependency_selected_refs: usize,
2111 pub refreshed_own_files: usize,
2112 pub unchanged_extract_files: usize,
2113}
2114
2115#[doc(hidden)]
2117#[derive(Debug, Clone, Default, PartialEq, Eq)]
2118pub struct RefreshFilesProfile {
2119 pub parse: Duration,
2120 pub dependency_selection: Duration,
2121 pub row_deletes: Duration,
2122 pub row_inserts: Duration,
2123 pub dependent_parse: Duration,
2124 pub index_load: Duration,
2125 pub index_loads: usize,
2126 pub ref_resolution: Duration,
2127 pub method_dispatch: Duration,
2128 pub commit: Duration,
2129 pub total: Duration,
2130}
2131
2132impl RefreshFilesProfile {
2133 pub fn report(&self) -> String {
2134 format!(
2135 "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",
2136 self.parse.as_millis(),
2137 self.dependency_selection.as_millis(),
2138 self.row_deletes.as_millis(),
2139 self.row_inserts.as_millis(),
2140 self.dependent_parse.as_millis(),
2141 self.index_load.as_millis(),
2142 self.ref_resolution.as_millis(),
2143 self.method_dispatch.as_millis(),
2144 self.commit.as_millis(),
2145 self.total.as_millis(),
2146 )
2147 }
2148}
2149
2150#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2151pub struct StoredEdge {
2152 pub source_file: String,
2153 pub source_symbol: String,
2154 pub target_file: String,
2155 pub target_symbol: String,
2156 pub kind: String,
2157 pub line: u32,
2158}
2159
2160#[derive(Debug, Clone, PartialEq, Eq)]
2161pub struct StoreNode {
2162 node_id: String,
2163 pub file: String,
2164 pub symbol: String,
2165 pub name: String,
2166 pub kind: String,
2167 pub line: u32,
2168 pub end_line: u32,
2169 pub signature: Option<String>,
2170 pub exported: bool,
2171 pub is_entry_point: bool,
2172 pub lang: LangId,
2173}
2174
2175#[cfg(test)]
2176impl StoreNode {
2177 pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
2178 Self {
2179 node_id: format!("{file}:{symbol}"),
2180 file: file.to_string(),
2181 symbol: symbol.to_string(),
2182 name: symbol.to_string(),
2183 kind: "function".to_string(),
2184 line: 1,
2185 end_line: 1,
2186 signature: None,
2187 exported: is_entry_point,
2188 is_entry_point,
2189 lang: LangId::TypeScript,
2190 }
2191 }
2192}
2193
2194#[derive(Debug, Clone, PartialEq, Eq)]
2195pub struct StoreCallSite {
2196 pub caller: StoreNode,
2197 pub target_file: String,
2198 pub target_symbol: String,
2199 pub target: Option<StoreNode>,
2200 pub line: u32,
2201 pub byte_start: usize,
2202 pub byte_end: usize,
2203 pub resolved: bool,
2204 pub provenance: String,
2205}
2206
2207impl StoreCallSite {
2208 pub fn approximate(&self) -> bool {
2209 self.provenance == PROVENANCE_NAME_MATCH
2210 }
2211
2212 pub fn resolved_by(&self) -> &str {
2213 &self.provenance
2214 }
2215
2216 pub fn supplemental_resolution(&self) -> Option<&str> {
2217 match self.provenance.as_str() {
2218 PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
2219 _ => None,
2220 }
2221 }
2222}
2223
2224#[derive(Debug, Clone, PartialEq, Eq)]
2225pub struct StoreUnresolvedCall {
2226 pub caller: StoreNode,
2227 pub symbol: String,
2228 pub full_ref: Option<String>,
2229 pub line: u32,
2230 pub byte_start: usize,
2231 pub byte_end: usize,
2232}
2233
2234#[derive(Debug, Clone, PartialEq, Eq)]
2235pub struct StoreCallersResult {
2236 pub target: StoreNode,
2237 pub callers: Vec<StoreCallSite>,
2238 pub scanned_files: usize,
2239 pub depth_limited: bool,
2240 pub truncated: usize,
2241}
2242
2243#[derive(Debug, Clone, PartialEq, Eq)]
2244pub struct StoreImpactCaller {
2245 pub site: StoreCallSite,
2246 pub signature: Option<String>,
2247 pub is_entry_point: bool,
2248 pub call_expression: Option<String>,
2249 pub parameters: Vec<String>,
2250}
2251
2252#[derive(Debug, Clone, PartialEq, Eq)]
2253pub struct StoreImpactResult {
2254 pub target: StoreNode,
2255 pub parameters: Vec<String>,
2256 pub callers: Vec<StoreImpactCaller>,
2257 pub depth_limited: bool,
2258 pub truncated: usize,
2259}
2260
2261#[derive(Debug, Clone)]
2262struct ExtractFailure {
2263 rel_path: String,
2264 freshness: Option<FileFreshness>,
2265}
2266
2267#[derive(Debug, Clone)]
2268struct BuildExtractsResult {
2269 extracts: Vec<FileExtract>,
2270 failures: Vec<ExtractFailure>,
2271}
2272
2273#[derive(Debug, Clone)]
2274enum StoreForwardCall {
2275 Resolved(StoreCallSite),
2276 Unresolved(StoreUnresolvedCall),
2277}
2278
2279impl StoreForwardCall {
2280 fn byte_start(&self) -> usize {
2281 match self {
2282 Self::Resolved(site) => site.byte_start,
2283 Self::Unresolved(call) => call.byte_start,
2284 }
2285 }
2286
2287 fn line(&self) -> u32 {
2288 match self {
2289 Self::Resolved(site) => site.line,
2290 Self::Unresolved(call) => call.line,
2291 }
2292 }
2293}
2294
2295#[derive(Debug, Clone)]
2296struct FileExtract {
2297 rel_path: String,
2298 freshness: FileFreshness,
2299 lang: LangId,
2300 data: FileCallData,
2301 nodes: Vec<NodeRecord>,
2302 raw_refs: Vec<RawRef>,
2303 dispatch_hints: Vec<DispatchHint>,
2304 surface_fingerprint: String,
2305}
2306
2307#[derive(Debug, Clone)]
2308struct NodeRecord {
2309 id: String,
2310 file_path: String,
2311 name: String,
2312 scoped_name: String,
2313 kind: String,
2314 range: Range,
2315 range_ordinal: u32,
2316 signature: Option<String>,
2317 exported: bool,
2318 is_default_export: bool,
2319 is_type_like: bool,
2320 is_callgraph_entry_point: bool,
2321}
2322
2323#[derive(Debug, Clone)]
2324struct RawRef {
2325 ref_id: String,
2326 caller_node: Option<String>,
2327 caller_symbol: Option<String>,
2328 caller_file: String,
2329 kind: String,
2330 short_name: Option<String>,
2331 full_ref: Option<String>,
2332 module_path: Option<String>,
2333 import_kind: Option<String>,
2334 local_name: Option<String>,
2335 requested_name: Option<String>,
2336 namespace_alias: Option<String>,
2337 wildcard: bool,
2338 line: u32,
2339 byte_start: usize,
2340 byte_end: usize,
2341 dependencies: BTreeSet<String>,
2342}
2343
2344#[derive(Debug)]
2348struct StagedRef {
2349 rowid: u64,
2350 raw: RawRef,
2351}
2352
2353#[derive(Debug, Clone)]
2354struct ResolvedRef {
2355 raw: RawRef,
2356 status: String,
2357 target_node: Option<String>,
2358 target_file: Option<String>,
2359 target_symbol: Option<String>,
2360 dependencies: BTreeSet<String>,
2361 edge: Option<EdgeRecord>,
2362}
2363
2364#[derive(Debug, Clone)]
2365struct EdgeRecord {
2366 edge_id: String,
2367 source_node: String,
2368 target_node: Option<String>,
2369 target_file: String,
2370 target_symbol: String,
2371 kind: String,
2372 line: u32,
2373}
2374
2375#[derive(Debug, Clone)]
2376struct DispatchHint {
2377 id: String,
2378 method_name: String,
2379 caller_node: String,
2380 file: String,
2381 line: u32,
2382 byte_start: usize,
2383 byte_end: usize,
2384}
2385
2386#[derive(Debug, Clone)]
2387struct NameMatchRef {
2388 ref_id: String,
2389 caller_node: String,
2390 caller_file: String,
2391 caller_symbol: String,
2392 caller_signature: Option<String>,
2393 receiver_expression: String,
2394 receiver: String,
2395 method_name: String,
2396 colon_dispatch: bool,
2397 line: u32,
2398 lang: String,
2399}
2400
2401#[derive(Debug, Clone)]
2402struct NameMatchCandidate {
2403 node_id: String,
2404 file_path: String,
2405 scoped_name: String,
2406 kind: String,
2407 start_line: u32,
2409}
2410
2411#[derive(Debug, Clone)]
2412struct FileRow {
2413 surface_fingerprint: String,
2414 freshness: FileFreshness,
2415}
2416
2417#[derive(Debug, Clone)]
2418struct DbFileIndex {
2419 lang: Option<LangId>,
2420 exports: HashSet<String>,
2421 default_export: Option<String>,
2422 export_aliases: HashMap<String, String>,
2423 node_by_scoped: HashMap<String, String>,
2424 node_by_bare: HashMap<String, String>,
2425 node_kind_by_id: HashMap<String, String>,
2426 module_targets: HashMap<String, Option<String>>,
2427 declared_module_targets: HashMap<String, Option<String>>,
2428 reexports: Vec<ReexportIndex>,
2429}
2430
2431#[derive(Debug, Clone)]
2432struct ReexportIndex {
2433 target_file: Option<String>,
2434 named: HashMap<String, String>,
2435 wildcard: bool,
2436}
2437
2438#[derive(Clone)]
2439struct ProjectIndex<'a> {
2440 facts: Rc<dyn ProjectFacts + 'a>,
2441 unbound_non_utf8_paths: Vec<Vec<u8>>,
2442 project_root: PathBuf,
2443 files: HashMap<String, DbFileIndex>,
2444 caller_data: HashMap<String, &'a FileCallData>,
2445 workspace_crate_prefixes: WorkspaceCratePrefixCache,
2450 rust_crate_roots: callgraph::RustCrateRootMemo,
2451}
2452
2453trait ResolverIndex {
2457 fn caller_data(&self, file: &str) -> Option<&FileCallData>;
2458 fn lang_for(&self, file: &str) -> Option<LangId>;
2459 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String>;
2460 fn module_parent(&self, target_file: &str) -> Option<(String, String)>;
2461 fn reexports_for(&self, file: &str) -> Vec<ReexportIndex>;
2462 fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String>;
2463 fn node_is_callable(&self, file: &str, node_id: &str) -> bool;
2464 fn export_alias(&self, file: &str, symbol: &str) -> Option<String>;
2465 fn has_export(&self, file: &str, symbol: &str) -> bool;
2466 fn default_export(&self, file: &str) -> Option<String>;
2467 fn contains_file(&self, file: &str) -> bool;
2468 fn crate_src_prefix(&self, crate_name: &str) -> Option<String>;
2469 fn rust_crate_root_file(&self, caller_file: &str) -> Option<String>;
2470 fn inline_scoped_target(
2471 &self,
2472 caller_file: &str,
2473 module_segments: &[String],
2474 short_name: &str,
2475 ) -> Option<(String, String)>;
2476}
2477
2478impl ResolverIndex for ProjectIndex<'_> {
2479 fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2480 self.caller_data.get(file).copied()
2481 }
2482
2483 fn lang_for(&self, file: &str) -> Option<LangId> {
2484 self.lang_for(file)
2485 }
2486
2487 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2488 self.module_target(caller_file, module_path)
2489 }
2490
2491 fn module_parent(&self, target_file: &str) -> Option<(String, String)> {
2492 let mut parents = self
2493 .files
2494 .iter()
2495 .flat_map(|(file, index)| {
2496 index
2497 .declared_module_targets
2498 .iter()
2499 .filter_map(move |(module, target)| {
2500 (target.as_deref() == Some(target_file))
2501 .then(|| (file.clone(), module.clone()))
2502 })
2503 })
2504 .collect::<Vec<_>>();
2505 parents.sort();
2506 parents.into_iter().next()
2507 }
2508
2509 fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2510 self.reexports_for(file).to_vec()
2511 }
2512
2513 fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2514 self.node_for_symbol(file, symbol)
2515 }
2516
2517 fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2518 self.node_is_callable(file, node_id)
2519 }
2520
2521 fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2522 self.files
2523 .get(file)
2524 .and_then(|item| item.export_aliases.get(symbol))
2525 .cloned()
2526 }
2527
2528 fn has_export(&self, file: &str, symbol: &str) -> bool {
2529 self.files
2530 .get(file)
2531 .is_some_and(|item| item.exports.contains(symbol))
2532 }
2533
2534 fn default_export(&self, file: &str) -> Option<String> {
2535 self.files
2536 .get(file)
2537 .and_then(|item| item.default_export.clone())
2538 }
2539
2540 fn contains_file(&self, file: &str) -> bool {
2541 self.files.contains_key(file)
2542 }
2543
2544 fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2545 if self.workspace_crate_prefixes.0.get().is_some() {
2546 self.facts.memo_replay(&self.project_root, "crates", "");
2547 }
2548 self.workspace_crate_prefixes
2549 .0
2550 .get_or_init(|| {
2551 self.facts.memo_start(&self.project_root, "crates", "");
2552 let prefixes = build_workspace_crate_prefixes(
2553 &self.project_root,
2554 &FactPaths {
2555 root: &self.project_root,
2556 facts: self.facts.as_ref(),
2557 },
2558 );
2559 self.facts.memo_finish(&self.project_root, "crates", "");
2560 prefixes
2561 })
2562 .get(crate_name)
2563 .cloned()
2564 }
2565
2566 fn rust_crate_root_file(&self, caller_file: &str) -> Option<String> {
2567 let paths = FactPaths {
2568 root: &self.project_root,
2569 facts: self.facts.as_ref(),
2570 };
2571 callgraph::rust_crate_root_file_for_caller(
2572 &self.project_root,
2573 &self.project_root.join(caller_file),
2574 &paths,
2575 &self.rust_crate_roots,
2576 )
2577 .map(|path| relative_path(&self.project_root, &path))
2578 }
2579
2580 fn inline_scoped_target(
2581 &self,
2582 caller_file: &str,
2583 module_segments: &[String],
2584 short_name: &str,
2585 ) -> Option<(String, String)> {
2586 let src_prefix = rust_src_prefix(caller_file);
2587 let mut file_paths = self.files.keys().cloned().collect::<Vec<_>>();
2588 file_paths.sort();
2589 if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
2590 let caller = file_paths.remove(position);
2591 file_paths.insert(0, caller);
2592 }
2593 for file_path in file_paths {
2594 if self.lang_for(&file_path) != Some(LangId::Rust)
2595 || rust_src_prefix(&file_path) != src_prefix
2596 {
2597 continue;
2598 }
2599 let file_module_segments = rust_module_segments_for_rel(&file_path);
2600 if !module_segments.starts_with(&file_module_segments) {
2601 continue;
2602 }
2603 let scoped_segments = &module_segments[file_module_segments.len()..];
2604 if scoped_segments.is_empty() {
2605 continue;
2606 }
2607 let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2608 if self.node_for_symbol(&file_path, &scoped_symbol).is_some() {
2609 return Some((file_path, scoped_symbol));
2610 }
2611 }
2612 None
2613 }
2614}
2615
2616struct DiskProjectIndex<'a> {
2627 project_root: &'a Path,
2628 conn: &'a Connection,
2629 caller_file: &'a str,
2630 caller_data: &'a FileCallData,
2631 workspace_crate_prefixes: WorkspaceCratePrefixCache,
2632 module_resolution_memo: &'a callgraph::ModuleResolutionMemo,
2633 file_index_memo: RefCell<HashMap<String, Option<Rc<DbFileIndex>>>>,
2634 module_parent_memo: RefCell<HashMap<String, Option<(String, String)>>>,
2635 memoize_resolver_indexes: bool,
2636}
2637
2638impl DiskProjectIndex<'_> {
2639 fn file_index(&self, rel_path: &str) -> Option<Rc<DbFileIndex>> {
2640 if self.memoize_resolver_indexes {
2641 if let Some(cached) = self.file_index_memo.borrow().get(rel_path).cloned() {
2642 return cached;
2643 }
2644 }
2645
2646 let loaded = self.load_file_index(rel_path).map(Rc::new);
2647 if self.memoize_resolver_indexes {
2648 let mut memo = self.file_index_memo.borrow_mut();
2649 if memo.len() >= DISK_FILE_INDEX_MEMO_CAPACITY {
2650 let caller_index = memo.remove(self.caller_file);
2653 memo.clear();
2654 if let Some(caller_index) = caller_index {
2655 memo.insert(self.caller_file.to_string(), caller_index);
2656 }
2657 }
2658 memo.insert(rel_path.to_string(), loaded.clone());
2659 }
2660 loaded
2661 }
2662
2663 fn load_file_index(&self, rel_path: &str) -> Option<DbFileIndex> {
2664 let lang: String = self
2665 .conn
2666 .query_row(
2667 "SELECT lang FROM files WHERE path = ?1",
2668 params![rel_path],
2669 |row| row.get(0),
2670 )
2671 .optional()
2672 .ok()??;
2673 let mut index = DbFileIndex {
2674 lang: lang_from_label(&lang),
2675 exports: HashSet::new(),
2676 default_export: None,
2677 export_aliases: HashMap::new(),
2678 node_by_scoped: HashMap::new(),
2679 node_by_bare: HashMap::new(),
2680 node_kind_by_id: HashMap::new(),
2681 module_targets: HashMap::new(),
2682 declared_module_targets: HashMap::new(),
2683 reexports: Vec::new(),
2684 };
2685 let mut nodes = self
2686 .conn
2687 .prepare(
2688 "SELECT id, name, scoped_name, kind, exported, is_default_export
2689 FROM nodes WHERE file_path = ?1",
2690 )
2691 .ok()?;
2692 let rows = nodes
2693 .query_map(params![rel_path], |row| {
2694 Ok((
2695 row.get::<_, String>(0)?,
2696 row.get::<_, String>(1)?,
2697 row.get::<_, String>(2)?,
2698 row.get::<_, String>(3)?,
2699 row.get::<_, i64>(4)? != 0,
2700 row.get::<_, i64>(5)? != 0,
2701 ))
2702 })
2703 .ok()?
2704 .collect::<std::result::Result<Vec<_>, _>>()
2705 .ok()?;
2706 drop(nodes);
2707 for (id, name, scoped_name, kind, exported, is_default_export) in rows {
2708 if exported {
2709 index.exports.insert(name.clone());
2710 index.exports.insert(scoped_name.clone());
2711 }
2712 if is_default_export {
2713 index.default_export = Some(scoped_name.clone());
2714 }
2715 index.node_by_scoped.insert(scoped_name, id.clone());
2716 index.node_by_bare.entry(name).or_insert(id.clone());
2717 index.node_kind_by_id.insert(id, kind);
2718 }
2719
2720 let mut refs = self
2721 .conn
2722 .prepare(
2723 "SELECT ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name
2724 FROM refs
2725 WHERE caller_file = ?1 AND kind IN ('import', 'module', 'reexport', 'export_alias')",
2726 )
2727 .ok()?;
2728 let rows = refs
2729 .query_map(params![rel_path], |row| {
2730 Ok((
2731 row.get::<_, String>(0)?,
2732 row.get::<_, String>(1)?,
2733 row.get::<_, Option<String>>(2)?,
2734 row.get::<_, Option<String>>(3)?,
2735 row.get::<_, i64>(4)? != 0,
2736 row.get::<_, Option<String>>(5)?,
2737 row.get::<_, Option<String>>(6)?,
2738 ))
2739 })
2740 .ok()?
2741 .collect::<std::result::Result<Vec<_>, _>>()
2742 .ok()?;
2743 drop(refs);
2744 for (ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name) in rows {
2745 if kind == "export_alias" {
2746 if let (Some(exported), Some(source)) = (local_name, requested_name) {
2747 index.export_aliases.insert(exported, source);
2748 }
2749 continue;
2750 }
2751 let Some(module_path) = module_path else {
2752 continue;
2753 };
2754 let target_file = if kind == "module" {
2755 rust_declared_module_target(
2756 self.project_root,
2757 rel_path,
2758 &module_path,
2759 self.module_resolution_memo,
2760 &FactPaths {
2761 root: self.project_root,
2762 facts: &DiskFacts::new(self.project_root),
2763 },
2764 )
2765 } else {
2766 self.disk_module_target(rel_path, &module_path)
2767 }
2768 .or_else(|| {
2769 self.conn
2770 .query_row(
2771 "SELECT d.dep_file
2772 FROM file_dependencies d
2773 JOIN files f ON f.path = d.dep_file
2774 WHERE d.file_path = ?1
2775 ORDER BY d.dep_file
2776 LIMIT 1",
2777 params![rel_path],
2778 |row| row.get::<_, String>(0),
2779 )
2780 .optional()
2781 .ok()
2782 .flatten()
2783 });
2784 index
2785 .module_targets
2786 .entry(module_path.clone())
2787 .or_insert_with(|| target_file.clone());
2788 if kind == "module" {
2789 index
2790 .declared_module_targets
2791 .entry(module_path.clone())
2792 .or_insert_with(|| target_file.clone());
2793 }
2794 if kind == "reexport" {
2795 let raw = RawRef {
2796 ref_id,
2797 caller_node: None,
2798 caller_symbol: None,
2799 caller_file: rel_path.to_string(),
2800 kind,
2801 short_name: None,
2802 full_ref,
2803 module_path: Some(module_path),
2804 import_kind: Some("reexport".to_string()),
2805 local_name: None,
2806 requested_name: None,
2807 namespace_alias: None,
2808 wildcard,
2809 line: 0,
2810 byte_start: 0,
2811 byte_end: 0,
2812 dependencies: BTreeSet::new(),
2813 };
2814 index
2815 .reexports
2816 .push(reexport_index_from_raw(&raw, target_file));
2817 }
2818 }
2819 Some(index)
2820 }
2821
2822 fn disk_module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2823 let caller_dir = self.project_root.join(caller_file).parent()?.to_path_buf();
2824 let candidate = callgraph::resolve_module_path_with_memo(
2825 &caller_dir,
2826 module_path,
2827 self.module_resolution_memo,
2828 &FactPaths {
2829 root: self.project_root,
2830 facts: &DiskFacts::new(self.project_root),
2831 },
2832 )?;
2833 let rel_path = relative_path(self.project_root, &candidate);
2834 self.contains_file(&rel_path).then_some(rel_path)
2835 }
2836
2837 fn load_module_parent(&self, target_file: &str) -> Option<(String, String)> {
2838 let mut stmt = self
2839 .conn
2840 .prepare(
2841 "SELECT caller_file, module_path FROM refs
2842 WHERE kind = 'module' AND module_path IS NOT NULL
2843 ORDER BY caller_file, module_path",
2844 )
2845 .ok()?;
2846 let rows = stmt
2847 .query_map([], |row| {
2848 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2849 })
2850 .ok()?;
2851 for row in rows.flatten() {
2852 if self.module_target(&row.0, &row.1).as_deref() == Some(target_file) {
2853 return Some(row);
2854 }
2855 }
2856 None
2857 }
2858}
2859
2860impl ResolverIndex for DiskProjectIndex<'_> {
2861 fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2862 (file == self.caller_file).then_some(self.caller_data)
2863 }
2864
2865 fn lang_for(&self, file: &str) -> Option<LangId> {
2866 self.file_index(file).and_then(|index| index.lang)
2867 }
2868
2869 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2870 self.file_index(caller_file)
2871 .and_then(|index| index.module_targets.get(module_path).cloned().flatten())
2872 }
2873
2874 fn module_parent(&self, target_file: &str) -> Option<(String, String)> {
2875 if self.memoize_resolver_indexes {
2876 if let Some(cached) = self.module_parent_memo.borrow().get(target_file).cloned() {
2877 return cached;
2878 }
2879 }
2880
2881 let parent = self.load_module_parent(target_file);
2882 if self.memoize_resolver_indexes {
2883 let mut memo = self.module_parent_memo.borrow_mut();
2884 if memo.len() >= DISK_FILE_INDEX_MEMO_CAPACITY {
2885 memo.clear();
2886 }
2887 memo.insert(target_file.to_string(), parent.clone());
2888 }
2889 parent
2890 }
2891
2892 fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2893 self.file_index(file)
2894 .map(|index| index.reexports.clone())
2895 .unwrap_or_default()
2896 }
2897
2898 fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2899 self.file_index(file).and_then(|index| {
2900 index
2901 .node_by_scoped
2902 .get(symbol)
2903 .cloned()
2904 .or_else(|| index.node_by_bare.get(symbol).cloned())
2905 })
2906 }
2907
2908 fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2909 self.file_index(file)
2910 .and_then(|index| index.node_kind_by_id.get(node_id).cloned())
2911 .is_some_and(|kind| matches!(kind.as_str(), "function" | "kernel" | "method"))
2912 }
2913
2914 fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2915 self.file_index(file)
2916 .and_then(|index| index.export_aliases.get(symbol).cloned())
2917 }
2918
2919 fn has_export(&self, file: &str, symbol: &str) -> bool {
2920 self.file_index(file)
2921 .is_some_and(|index| index.exports.contains(symbol))
2922 }
2923
2924 fn default_export(&self, file: &str) -> Option<String> {
2925 self.file_index(file)
2926 .and_then(|index| index.default_export.clone())
2927 }
2928
2929 fn contains_file(&self, file: &str) -> bool {
2930 self.conn
2931 .query_row(
2932 "SELECT 1 FROM files WHERE path = ?1 LIMIT 1",
2933 params![file],
2934 |_| Ok(()),
2935 )
2936 .is_ok()
2937 }
2938
2939 fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2940 self.workspace_crate_prefixes
2941 .0
2942 .get_or_init(|| {
2943 build_workspace_crate_prefixes(
2944 self.project_root,
2945 &FactPaths {
2946 root: self.project_root,
2947 facts: &DiskFacts::new(self.project_root),
2948 },
2949 )
2950 })
2951 .get(crate_name)
2952 .cloned()
2953 }
2954
2955 fn rust_crate_root_file(&self, caller_file: &str) -> Option<String> {
2956 let disk = DiskFacts::new(self.project_root);
2957 let paths = FactPaths {
2958 root: self.project_root,
2959 facts: &disk,
2960 };
2961 self.module_resolution_memo
2962 .rust_crate_root_file(
2963 self.project_root,
2964 &self.project_root.join(caller_file),
2965 &paths,
2966 )
2967 .map(|path| relative_path(self.project_root, &path))
2968 }
2969
2970 fn inline_scoped_target(
2971 &self,
2972 caller_file: &str,
2973 module_segments: &[String],
2974 short_name: &str,
2975 ) -> Option<(String, String)> {
2976 let src_prefix = rust_src_prefix(caller_file);
2977 let check = |file_path: String| {
2978 let file_module_segments = rust_module_segments_for_rel(&file_path);
2979 if rust_src_prefix(&file_path) != src_prefix
2980 || !module_segments.starts_with(&file_module_segments)
2981 {
2982 return None;
2983 }
2984 let scoped_segments = &module_segments[file_module_segments.len()..];
2985 if scoped_segments.is_empty() {
2986 return None;
2987 }
2988 let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2989 self.node_for_symbol(&file_path, &scoped_symbol)
2990 .map(|_| (file_path, scoped_symbol))
2991 };
2992 if let Some(target) = check(caller_file.to_string()) {
2993 return Some(target);
2994 }
2995 let mut statement = self
2996 .conn
2997 .prepare("SELECT path FROM files WHERE lang = 'rust' AND path <> ?1 ORDER BY path")
2998 .ok()?;
2999 let rows = statement
3000 .query_map(params![caller_file], |row| row.get::<_, String>(0))
3001 .ok()?;
3002 for path in rows.flatten() {
3003 if let Some(target) = check(path) {
3004 return Some(target);
3005 }
3006 }
3007 None
3008 }
3009}
3010
3011impl CallGraphStore {
3012 pub fn open_if_enabled(
3013 options: CallGraphStoreOptions,
3014 callgraph_dir: PathBuf,
3015 project_root: PathBuf,
3016 ) -> Result<Option<Self>> {
3017 if !options.enabled {
3018 return Ok(None);
3019 }
3020 Self::open(callgraph_dir, project_root).map(Some)
3021 }
3022
3023 pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
3024 let project_key = crate::search_index::artifact_cache_key(&project_root);
3025 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
3026 else {
3027 return Err(CallGraphStoreError::Unavailable(
3028 "writer capability denied; use the read-only callgraph opener".to_string(),
3029 ));
3030 };
3031 std::fs::create_dir_all(&callgraph_dir)?;
3032 let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
3036 .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
3037 let OpenedStore { store, root_repair } = Self::open_at_path(
3038 project_root.clone(),
3039 project_key,
3040 sqlite_path,
3041 generation,
3042 true,
3043 Some(Arc::clone(&writer_lease)),
3044 None,
3045 )?;
3046 match root_repair {
3047 OpenRootRepair::NeedsRebuild { .. } => {
3048 log_root_repair_rebuild(&root_repair);
3049 drop(store);
3050 drop(writer_lease);
3051 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
3052 let (store, _stats) =
3053 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
3054 Ok(store)
3055 }
3056 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
3057 }
3058 }
3059
3060 pub fn open_readonly(
3061 callgraph_dir: PathBuf,
3062 project_root: PathBuf,
3063 ) -> Result<Option<ReadonlyCallGraphStore>> {
3064 let project_key = crate::search_index::artifact_cache_key(&project_root);
3065 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
3066 {
3067 let conn = open_readonly_connection(&sqlite_path)?;
3068 if !database_ready(&conn).unwrap_or(false) {
3069 return Ok(None);
3070 }
3071 let marker_label = generation.as_deref().unwrap_or("legacy");
3072 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
3073 return Ok(Some(ReadonlyCallGraphStore::from_inner(
3074 Self::from_connection(
3075 project_root,
3076 project_key,
3077 sqlite_path,
3078 callgraph_dir,
3079 false,
3080 generation,
3081 None,
3082 Some(read_marker),
3083 conn,
3084 ),
3085 )));
3086 }
3087
3088 let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
3089 return Ok(None);
3090 };
3091 crate::slog_warn!(
3092 "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
3093 target.partition.harness,
3094 target.sqlite_path.display()
3095 );
3096 let conn = open_readonly_connection(&target.sqlite_path)?;
3097 if !database_ready(&conn).unwrap_or(false) {
3098 return Ok(None);
3099 }
3100 let marker_label =
3101 legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
3102 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
3103 Ok(Some(ReadonlyCallGraphStore::from_inner(
3104 Self::from_connection(
3105 project_root,
3106 project_key,
3107 target.sqlite_path,
3108 callgraph_dir,
3109 true,
3110 target.generation,
3111 None,
3112 Some(read_marker),
3113 conn,
3114 ),
3115 )))
3116 }
3117
3118 pub fn open_ready_repairing(
3124 callgraph_dir: PathBuf,
3125 project_root: PathBuf,
3126 ) -> Result<Option<Self>> {
3127 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true)
3128 }
3129
3130 pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
3134 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false)
3135 }
3136
3137 pub fn open_ready_no_rebuild(
3138 callgraph_dir: PathBuf,
3139 project_root: PathBuf,
3140 ) -> Result<Option<Self>> {
3141 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true)
3142 }
3143
3144 fn open_ready_with_rebuild_policy(
3145 callgraph_dir: PathBuf,
3146 project_root: PathBuf,
3147 allow_cold_build: bool,
3148 allow_root_repair: bool,
3149 ) -> Result<Option<Self>> {
3150 let project_key = crate::search_index::artifact_cache_key(&project_root);
3151 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
3152 else {
3153 return Ok(None);
3154 };
3155 let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
3156 else {
3157 return Ok(None);
3158 };
3159 let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
3160 project_root.clone(),
3161 project_key.clone(),
3162 sqlite_path,
3163 generation,
3164 true,
3165 Some(Arc::clone(&writer_lease)),
3166 None,
3167 allow_root_repair,
3168 )?;
3169 match root_repair {
3170 OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
3171 log_root_repair_rebuild(&root_repair);
3172 drop(store);
3173 drop(writer_lease);
3174 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
3175 let (store, _stats) =
3176 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
3177 Ok(Some(store))
3178 }
3179 OpenRootRepair::NeedsRebuild { .. } => {
3180 if let Some(message) = note_repair_entry(&project_key) {
3181 crate::slog_warn!("{message}");
3182 }
3183 Ok(None)
3184 }
3185 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
3186 }
3187 }
3188
3189 pub fn cold_build_with_lease(
3190 callgraph_dir: PathBuf,
3191 project_root: PathBuf,
3192 files: &[PathBuf],
3193 ) -> Result<(Self, ColdBuildStats)> {
3194 Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
3195 }
3196
3197 pub fn cold_build_with_lease_chunked(
3198 callgraph_dir: PathBuf,
3199 project_root: PathBuf,
3200 files: &[PathBuf],
3201 chunk_size: usize,
3202 ) -> Result<(Self, ColdBuildStats)> {
3203 Self::cold_build_with_lease_chunked_inner(
3204 callgraph_dir,
3205 project_root,
3206 files,
3207 chunk_size,
3208 false,
3209 )
3210 }
3211
3212 pub(crate) fn force_cold_build_with_lease_chunked(
3213 callgraph_dir: PathBuf,
3214 project_root: PathBuf,
3215 files: &[PathBuf],
3216 chunk_size: usize,
3217 ) -> Result<(Self, ColdBuildStats)> {
3218 Self::cold_build_with_lease_chunked_inner(
3219 callgraph_dir,
3220 project_root,
3221 files,
3222 chunk_size,
3223 true,
3224 )
3225 }
3226
3227 fn cold_build_with_lease_chunked_inner(
3228 callgraph_dir: PathBuf,
3229 project_root: PathBuf,
3230 files: &[PathBuf],
3231 chunk_size: usize,
3232 require_new_publication: bool,
3233 ) -> Result<(Self, ColdBuildStats)> {
3234 let project_key = crate::search_index::artifact_cache_key(&project_root);
3235 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
3236 else {
3237 let operation = if require_new_publication {
3238 "forced rebuild"
3239 } else {
3240 "cold build"
3241 };
3242 return Err(CallGraphStoreError::Unavailable(format!(
3243 "{operation} could not acquire writer capability"
3244 )));
3245 };
3246 std::fs::create_dir_all(&callgraph_dir)?;
3247 let (stats, generation) = Self::cold_build_publish_locked(
3248 &callgraph_dir,
3249 &project_root,
3250 &project_key,
3251 files,
3252 chunk_size,
3253 Arc::clone(&writer_lease),
3254 )?;
3255 let store = Self::open_generation(
3256 &callgraph_dir,
3257 project_root,
3258 project_key,
3259 generation,
3260 writer_lease,
3261 )?;
3262 Ok((store, stats))
3263 }
3264
3265 pub fn ensure_built_with_lease(
3266 callgraph_dir: PathBuf,
3267 project_root: PathBuf,
3268 files: &[PathBuf],
3269 ) -> Result<(Self, Option<ColdBuildStats>)> {
3270 Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
3271 }
3272
3273 pub fn ensure_built_with_lease_chunked(
3274 callgraph_dir: PathBuf,
3275 project_root: PathBuf,
3276 files: &[PathBuf],
3277 chunk_size: usize,
3278 ) -> Result<(Self, Option<ColdBuildStats>)> {
3279 let project_key = crate::search_index::artifact_cache_key(&project_root);
3280 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
3281 else {
3282 return Err(CallGraphStoreError::Unavailable(
3283 "callgraph ensure could not acquire writer capability".to_string(),
3284 ));
3285 };
3286 std::fs::create_dir_all(&callgraph_dir)?;
3287 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
3288 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
3295 {
3296 let OpenedStore { store, root_repair } = Self::open_at_path(
3297 project_root.clone(),
3298 project_key.clone(),
3299 sqlite_path,
3300 generation,
3301 true,
3302 Some(Arc::clone(&writer_lease)),
3303 None,
3304 )?;
3305 match root_repair {
3306 OpenRootRepair::NeedsRebuild { .. } => {
3307 log_root_repair_rebuild(&root_repair);
3308 drop(store);
3309 let (stats, generation) = Self::cold_build_publish_locked(
3310 &callgraph_dir,
3311 &project_root,
3312 &project_key,
3313 files,
3314 chunk_size,
3315 Arc::clone(&writer_lease),
3316 )?;
3317 let store = Self::open_generation(
3318 &callgraph_dir,
3319 project_root,
3320 project_key,
3321 generation,
3322 writer_lease,
3323 )?;
3324 return Ok((store, Some(stats)));
3325 }
3326 OpenRootRepair::None | OpenRootRepair::ReRooted => {
3327 return Ok((store, None));
3328 }
3329 }
3330 }
3331 if let Some(store) = try_legacy_migration_or_fallback(
3332 &callgraph_dir,
3333 &project_root,
3334 &project_key,
3335 Arc::clone(&writer_lease),
3336 )? {
3337 return Ok((store, None));
3338 }
3339 let (stats, generation) = Self::cold_build_publish_locked(
3340 &callgraph_dir,
3341 &project_root,
3342 &project_key,
3343 files,
3344 chunk_size,
3345 Arc::clone(&writer_lease),
3346 )?;
3347 let store = Self::open_generation(
3348 &callgraph_dir,
3349 project_root,
3350 project_key,
3351 generation,
3352 writer_lease,
3353 )?;
3354 Ok((store, Some(stats)))
3355 }
3356
3357 pub fn migrate_legacy_with_lease(
3364 callgraph_dir: PathBuf,
3365 project_root: PathBuf,
3366 ) -> Result<Option<Self>> {
3367 let project_key = crate::search_index::artifact_cache_key(&project_root);
3368 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
3369 else {
3370 return Ok(None);
3371 };
3372 std::fs::create_dir_all(&callgraph_dir)?;
3373 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
3374
3375 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
3379 {
3380 let OpenedStore { store, root_repair } = Self::open_at_path(
3381 project_root,
3382 project_key,
3383 sqlite_path,
3384 generation,
3385 true,
3386 Some(writer_lease),
3387 None,
3388 )?;
3389 return match root_repair {
3390 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
3391 OpenRootRepair::NeedsRebuild { reason, .. } => {
3392 Err(CallGraphStoreError::Unavailable(format!(
3393 "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
3394 )))
3395 }
3396 };
3397 }
3398
3399 let store = try_legacy_migration_or_fallback(
3400 &callgraph_dir,
3401 &project_root,
3402 &project_key,
3403 writer_lease,
3404 )?;
3405 Ok(store.filter(|store| !store.is_legacy_fallback()))
3409 }
3410
3411 fn cold_build_publish_locked(
3422 callgraph_dir: &Path,
3423 project_root: &Path,
3424 project_key: &str,
3425 files: &[PathBuf],
3426 chunk_size: usize,
3427 writer_lease: Arc<crate::root_cache::WriterLease>,
3428 ) -> Result<(ColdBuildStats, String)> {
3429 if let Some((previous_root, remaining)) =
3430 rebuild_cooldown_denial(callgraph_dir, project_key, project_root, Instant::now())
3431 {
3432 return Err(CallGraphStoreError::Unavailable(format!(
3433 "cache key {project_key} was rebuilt for {} too recently; retry {} ms after the per-key cooldown",
3434 previous_root.display(),
3435 remaining.as_millis()
3436 )));
3437 }
3438 let breaker = crate::build_breaker::BuildDeathBreaker::open(
3439 callgraph_dir.join("build-breaker.sqlite"),
3440 )
3441 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
3442
3443 let generation = generation_file_name(project_key);
3444 let gen_path = callgraph_dir.join(&generation);
3445 let temp_path = callgraph_dir.join(format!("{project_key}.staging.sqlite.tmp.resume"));
3449 let adopting_staging = temp_path.exists();
3450 if !adopting_staging {
3451 remove_sqlite_file_set(&temp_path);
3452 }
3453
3454 let scope = crate::logging::IndexBuildScope::new(
3455 crate::logging::IndexPlane::Callgraph,
3456 project_root,
3457 project_key,
3458 );
3459 let _index_build = crate::logging::install_index_build(scope.clone());
3460 let mut failure_guard = crate::logging::IndexBuildFailureGuard::new();
3461 let mut started = crate::logging::IndexEvent::from_scope(
3462 crate::logging::IndexEventKind::BuildStarted,
3463 &scope,
3464 );
3465 if adopting_staging {
3466 started = started.field("resumed_from_staging", "true");
3467 }
3468 crate::logging::log_index_event(started);
3469
3470 let (stats, breaker_key) = {
3471 if adopting_staging {
3472 crate::slog_info!(
3473 "resuming callgraph cold build from staged generation {}",
3474 temp_path.display()
3475 );
3476 }
3477 let temp_store = Self::open_at_path(
3478 project_root.to_path_buf(),
3479 project_key.to_string(),
3480 temp_path.clone(),
3481 None,
3482 false,
3483 Some(Arc::clone(&writer_lease)),
3484 None,
3485 )?
3486 .store;
3487 let admission_fingerprint = corpus_fingerprint_for(project_root, files)?;
3498 let breaker_key = crate::build_breaker::BreakerKey::new(
3499 project_root.display().to_string(),
3500 crate::build_breaker::BuildDomain::CallgraphCold,
3501 admission_fingerprint,
3502 );
3503 match breaker
3504 .admit(&breaker_key, 0)
3505 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?
3506 {
3507 crate::build_breaker::BreakerAdmission::Admitted(_) => {
3508 crate::logging::log_index_event(crate::logging::IndexEvent::from_scope(
3509 crate::logging::IndexEventKind::BreakerAdmitted,
3510 &scope,
3511 ));
3512 }
3513 crate::build_breaker::BreakerAdmission::Suspended(suspension) => {
3514 crate::logging::log_index_event(
3515 crate::logging::IndexEvent::from_scope(
3516 crate::logging::IndexEventKind::BuildSuspended,
3517 &scope,
3518 )
3519 .field("reason", &suspension.reason),
3520 );
3521 failure_guard.disarm();
3522 return Err(CallGraphStoreError::Suspended(suspension));
3523 }
3524 }
3525 ensure_cold_build_current("inventory", 0, 1)?;
3526 let corpus_fingerprint = temp_store.stage_cold_build_file_inventory(files)?;
3527 ensure_cold_build_current("inventory", 1, 1)?;
3528 let stats = temp_store
3529 .cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)?;
3530 let _ = temp_store.checkpoint_wal_truncate();
3531 temp_store.prepare_for_atomic_swap()?;
3532 (stats, breaker_key)
3533 };
3534
3535 notify_cold_build_before_publish_observer();
3536 let publication = publish_if_current(|| {
3537 verify_writer_lease(&writer_lease)?;
3538 remove_sqlite_file_set(&gen_path);
3541 crate::fs_lock::rename_over(&temp_path, &gen_path)?;
3542 crate::fs_lock::sync_parent(&gen_path);
3543 remove_sqlite_sidecars(&gen_path);
3544
3545 notify_cold_build_swap_observer(&temp_path, &gen_path);
3546
3547 verify_writer_lease(&writer_lease)?;
3549 publish_pointer(callgraph_dir, project_key, &generation)?;
3550 gc_old_generations(callgraph_dir, project_key, &generation);
3551 sweep_orphaned_build_temps_store_wide(callgraph_dir);
3555 sweep_orphaned_callgraph_root_dirs(callgraph_dir);
3556 crate::search_index::sweep_transient_search_cache_dirs();
3557 if let Some(storage_root) = root_storage_dir(callgraph_dir) {
3558 let inspect_root =
3559 storage_root.join(crate::root_cache::RootCacheDomain::Inspect.as_str());
3560 let live_scope_keys = crate::root_cache::live_scope_keys_for_storage(&storage_root);
3561 crate::inspect::cache::sweep_inspect_scope_dirs(&inspect_root, &live_scope_keys);
3562 }
3563 Ok(())
3564 });
3565 if let Err(CallGraphStoreError::Superseded) = &publication {
3569 crate::logging::log_index_event(
3570 crate::logging::IndexEvent::from_scope(
3571 crate::logging::IndexEventKind::BuildSuperseded,
3572 &scope,
3573 )
3574 .field("stage", "publish"),
3575 );
3576 failure_guard.disarm();
3577 }
3578 publication?;
3579 breaker
3582 .record_ready_publication(&breaker_key)
3583 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
3584 crate::logging::log_index_event(crate::logging::IndexEvent::from_scope(
3585 crate::logging::IndexEventKind::BreakerReset,
3586 &scope,
3587 ));
3588 record_successful_rebuild(callgraph_dir, project_key, project_root, Instant::now());
3589 crate::logging::log_index_event(
3590 crate::logging::IndexEvent::from_scope(
3591 crate::logging::IndexEventKind::BuildReady,
3592 &scope,
3593 )
3594 .field("elapsed_ms", scope.elapsed_ms())
3595 .field("files", stats.files)
3596 .field("edges", stats.edges),
3597 );
3598 failure_guard.disarm();
3599 Ok((stats, generation))
3600 }
3601
3602 fn open_generation(
3605 callgraph_dir: &Path,
3606 project_root: PathBuf,
3607 project_key: String,
3608 generation: String,
3609 writer_lease: Arc<crate::root_cache::WriterLease>,
3610 ) -> Result<Self> {
3611 let gen_path = callgraph_dir.join(&generation);
3612 Ok(Self::open_at_path(
3613 project_root,
3614 project_key,
3615 gen_path,
3616 Some(generation),
3617 true,
3618 Some(writer_lease),
3619 None,
3620 )?
3621 .store)
3622 }
3623
3624 pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
3625 let project_key = crate::search_index::artifact_cache_key(project_root);
3626 Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
3629 }
3630
3631 pub fn cold_build_suspension(
3636 callgraph_dir: &Path,
3637 project_root: &Path,
3638 ) -> Result<Option<crate::build_breaker::BuildSuspension>> {
3639 let breaker_path = callgraph_dir.join("build-breaker.sqlite");
3640 if !breaker_path.exists() {
3641 return Ok(None);
3642 }
3643 let key = crate::build_breaker::BreakerKey::new(
3644 project_root.display().to_string(),
3645 crate::build_breaker::BuildDomain::CallgraphCold,
3646 callgraph_corpus_fingerprint(project_root)?,
3647 );
3648 crate::build_breaker::BuildDeathBreaker::open(breaker_path)
3649 .and_then(|breaker| breaker.suspension(&key))
3650 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))
3651 }
3652
3653 fn open_at_path(
3654 project_root: PathBuf,
3655 project_key: String,
3656 sqlite_path: PathBuf,
3657 generation: Option<String>,
3658 use_wal: bool,
3659 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3660 read_marker: Option<crate::root_cache::ReadMarker>,
3661 ) -> Result<OpenedStore> {
3662 Self::open_at_path_with_root_repair(
3663 project_root,
3664 project_key,
3665 sqlite_path,
3666 generation,
3667 use_wal,
3668 writer_lease,
3669 read_marker,
3670 true,
3671 )
3672 }
3673
3674 fn open_at_path_with_root_repair(
3675 project_root: PathBuf,
3676 project_key: String,
3677 sqlite_path: PathBuf,
3678 generation: Option<String>,
3679 use_wal: bool,
3680 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3681 read_marker: Option<crate::root_cache::ReadMarker>,
3682 allow_root_repair: bool,
3683 ) -> Result<OpenedStore> {
3684 if let Some(lease) = writer_lease.as_ref() {
3685 verify_writer_lease(lease)?;
3686 }
3687 if let Some(parent) = sqlite_path.parent() {
3688 std::fs::create_dir_all(parent)?;
3689 }
3690 let mut conn = TrackedConnection::open(&sqlite_path, SqliteStore::CallgraphGeneration)?;
3691 if use_wal {
3692 configure_connection(&conn)?;
3693 } else {
3694 configure_build_connection(&conn)?;
3695 }
3696 if let Some(lease) = writer_lease.as_ref() {
3697 verify_writer_lease(lease)?;
3698 }
3699 initialize_schema(&conn)?;
3700 if let Some(lease) = writer_lease.as_ref() {
3701 verify_writer_lease(lease)?;
3702 }
3703 let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
3704 let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
3705 (Some(marker), _, _) => Some(marker),
3706 (None, Some(label), Some(cache_dir)) => {
3707 Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
3708 }
3709 (None, _, _) => None,
3710 };
3711 let publication_dir = sqlite_path
3712 .parent()
3713 .map(Path::to_path_buf)
3714 .unwrap_or_default();
3715 let store = Self::from_connection(
3716 project_root,
3717 project_key,
3718 sqlite_path,
3719 publication_dir,
3720 false,
3721 generation,
3722 writer_lease,
3723 read_marker,
3724 conn,
3725 );
3726 Ok(OpenedStore { store, root_repair })
3727 }
3728
3729 fn prepare_for_atomic_swap(&self) -> Result<()> {
3730 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3731 conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
3732 Ok(())
3733 }
3734
3735 fn atomic_swap_checkpoint_sql(&self) -> &'static str {
3736 let protected_reader = self.generation.as_deref().is_some_and(|generation| {
3737 self.sqlite_path
3738 .parent()
3739 .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
3740 });
3741 if protected_reader {
3742 "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
3743 } else {
3744 "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
3745 }
3746 }
3747
3748 fn from_connection(
3749 project_root: PathBuf,
3750 project_key: String,
3751 sqlite_path: PathBuf,
3752 publication_dir: PathBuf,
3753 legacy_fallback: bool,
3754 generation: Option<String>,
3755 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3756 read_marker: Option<crate::root_cache::ReadMarker>,
3757 conn: TrackedConnection,
3758 ) -> Self {
3759 let write_metrics = callgraph_write_metrics_for_key(&project_key);
3760 Self {
3761 project_root,
3762 project_key,
3763 sqlite_path,
3764 publication_dir,
3765 legacy_fallback,
3766 manifest_view: false,
3767 generation,
3768 writer_lease,
3769 read_marker,
3770 database_ready: AtomicBool::new(false),
3771 write_metrics,
3772 conn: Mutex::new(conn),
3773 }
3774 }
3775
3776 fn ensure_ready(&self, conn: &Connection) -> Result<()> {
3777 if self.database_ready.load(AtomicOrdering::Acquire) {
3778 return Ok(());
3779 }
3780 ensure_database_ready(conn)?;
3781 self.database_ready.store(true, AtomicOrdering::Release);
3782 Ok(())
3783 }
3784
3785 pub fn project_root(&self) -> &Path {
3786 &self.project_root
3787 }
3788
3789 pub fn project_key(&self) -> &str {
3790 &self.project_key
3791 }
3792
3793 pub fn sqlite_path(&self) -> &Path {
3794 &self.sqlite_path
3795 }
3796
3797 pub(crate) fn projection_generation(&self) -> Option<&str> {
3799 self.generation.as_deref()
3800 }
3801
3802 pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
3804 self.refresh_read_marker()?;
3805 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3806 self.ensure_ready(&conn)?;
3807 projection_write_revision(&conn)
3808 }
3809
3810 pub fn is_legacy_fallback(&self) -> bool {
3813 self.legacy_fallback
3814 }
3815
3816 pub(crate) fn is_legacy_migration(&self) -> bool {
3817 self.generation.as_deref().is_some_and(|generation| {
3818 migration_generation_requires_manifest(generation)
3819 && migration_manifest_valid(&self.publication_dir, generation)
3820 })
3821 }
3822
3823 pub fn writer_epoch_for_test(&self) -> Option<&str> {
3824 self.writer_lease.as_ref().map(|lease| lease.epoch())
3825 }
3826
3827 fn verify_writer_lease(&self) -> Result<()> {
3828 let Some(lease) = self.writer_lease.as_ref() else {
3829 return Err(CallGraphStoreError::Unavailable(
3830 "callgraph store opened read-only; write API is unavailable".to_string(),
3831 ));
3832 };
3833 verify_writer_lease(lease)
3834 }
3835
3836 fn refresh_read_marker(&self) -> Result<()> {
3837 if let Some(marker) = self.read_marker.as_ref() {
3838 marker.touch_if_due()?;
3839 }
3840 Ok(())
3841 }
3842
3843 fn record_commit(&self, total_changes_before: u64, conn: &Connection) {
3844 self.write_metrics
3845 .record_commit(conn.total_changes().saturating_sub(total_changes_before));
3846 }
3847
3848 fn checkpoint_wal_truncate(&self) -> bool {
3849 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3850 checkpoint_wal_truncate(&conn)
3851 }
3852
3853 pub fn is_current(&self) -> bool {
3859 let _ = self.refresh_read_marker();
3860 match (
3861 read_pointer(&self.publication_dir, &self.project_key),
3862 &self.generation,
3863 ) {
3864 (Some(_), _) if self.legacy_fallback => false,
3867 (Some(published), Some(opened)) => &published == opened,
3868 (Some(_), None) => false,
3870 (None, _) => true,
3873 }
3874 }
3875
3876 pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
3877 self.cold_build_chunked(files, COLD_BUILD_EXTRACT_BATCH_FILES)
3878 }
3879
3880 pub fn cold_build_chunked(
3884 &self,
3885 files: &[PathBuf],
3886 chunk_size: usize,
3887 ) -> Result<ColdBuildStats> {
3888 let corpus_fingerprint = self.stage_cold_build_file_inventory(files)?;
3889 self.cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)
3890 }
3891
3892 fn stage_cold_build_file_inventory(&self, files: &[PathBuf]) -> Result<String> {
3893 note_cold_build_phase("enumeration");
3894 if files.is_empty() {
3895 self.stage_cold_build_file_inventory_from(callgraph::walk_project_files(
3896 &self.project_root,
3897 ))
3898 } else {
3899 self.stage_cold_build_file_inventory_from(files.iter().cloned())
3900 }
3901 }
3902
3903 fn stage_cold_build_file_inventory_from<I>(&self, paths: I) -> Result<String>
3904 where
3905 I: IntoIterator<Item = PathBuf>,
3906 {
3907 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3908 self.verify_writer_lease()?;
3909 let total_changes_before = conn.total_changes();
3910 let tx = conn.transaction()?;
3911 tx.execute("DELETE FROM staging_file_inventory", [])?;
3912 tx.commit()?;
3913 self.record_commit(total_changes_before, &conn);
3914
3915 let mut batch = Vec::with_capacity(COLD_BUILD_EXTRACT_BATCH_FILES);
3916 for path in paths {
3917 let path = normalize_file_path(&self.project_root, &path)?;
3918 let rel_path = relative_path(&self.project_root, &path);
3919 let size = std::fs::metadata(&path)
3920 .map(|metadata| metadata.len())
3921 .unwrap_or(0);
3922 batch.push((rel_path, size));
3923 if batch.len() == COLD_BUILD_EXTRACT_BATCH_FILES {
3924 self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3925 batch.clear();
3926 }
3927 }
3928 if !batch.is_empty() {
3929 self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3930 }
3931
3932 staged_corpus_fingerprint(&conn, &self.project_root)
3933 }
3934
3935 fn insert_staged_file_inventory_batch(
3936 &self,
3937 conn: &mut Connection,
3938 batch: &[(String, u64)],
3939 ) -> Result<()> {
3940 self.verify_writer_lease()?;
3941 let total_changes_before = conn.total_changes();
3942 let tx = conn.transaction()?;
3943 {
3944 let mut insert = tx.prepare(
3945 "INSERT OR REPLACE INTO staging_file_inventory(path, size) VALUES(?1, ?2)",
3946 )?;
3947 for (path, size) in batch {
3948 insert.execute(params![path, *size as i64])?;
3949 }
3950 }
3951 tx.commit()?;
3952 self.record_commit(total_changes_before, conn);
3953 Ok(())
3954 }
3955
3956 fn cold_build_chunked_from_staged_inventory(
3957 &self,
3958 chunk_size: usize,
3959 corpus_fingerprint: &str,
3960 ) -> Result<ColdBuildStats> {
3961 let module_resolution_memo = callgraph::ModuleResolutionMemo::default();
3962 self.cold_build_chunked_from_staged_inventory_with_resolution_memo(
3963 chunk_size,
3964 corpus_fingerprint,
3965 COLD_BUILD_RESOLVE_WINDOW,
3966 &module_resolution_memo,
3967 true,
3968 )
3969 }
3970
3971 #[cfg(test)]
3972 fn cold_build_chunked_with_resolution_memo_for_test(
3973 &self,
3974 files: &[PathBuf],
3975 chunk_size: usize,
3976 resolve_window: usize,
3977 module_resolution_memo: &callgraph::ModuleResolutionMemo,
3978 ) -> Result<ColdBuildStats> {
3979 self.cold_build_chunked_with_disk_index_memo_for_test(
3980 files,
3981 chunk_size,
3982 resolve_window,
3983 module_resolution_memo,
3984 true,
3985 )
3986 }
3987
3988 #[cfg(test)]
3989 fn cold_build_chunked_with_disk_index_memo_for_test(
3990 &self,
3991 files: &[PathBuf],
3992 chunk_size: usize,
3993 resolve_window: usize,
3994 module_resolution_memo: &callgraph::ModuleResolutionMemo,
3995 memoize_resolver_indexes: bool,
3996 ) -> Result<ColdBuildStats> {
3997 let corpus_fingerprint = self.stage_cold_build_file_inventory(files)?;
3998 self.cold_build_chunked_from_staged_inventory_with_resolution_memo(
3999 chunk_size,
4000 &corpus_fingerprint,
4001 resolve_window.max(1),
4002 module_resolution_memo,
4003 memoize_resolver_indexes,
4004 )
4005 }
4006
4007 fn cold_build_chunked_from_staged_inventory_with_resolution_memo(
4008 &self,
4009 chunk_size: usize,
4010 corpus_fingerprint: &str,
4011 resolve_window: usize,
4012 module_resolution_memo: &callgraph::ModuleResolutionMemo,
4013 memoize_resolver_indexes: bool,
4014 ) -> Result<ColdBuildStats> {
4015 let started = Instant::now();
4016 let batch_files = chunk_size.max(1).min(COLD_BUILD_EXTRACT_BATCH_FILES);
4017 let workspace_root = self.project_root.display().to_string();
4018 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
4019
4020 self.verify_writer_lease()?;
4021 ensure_cold_build_current("staging-admission", 0, 1)?;
4022 let mut phase = staged_build_phase(&conn)?;
4023 let staged_fingerprint = staged_string(&conn, STAGED_CORPUS_FINGERPRINT)?;
4024 let fingerprint_matches = staged_fingerprint.as_deref() == Some(corpus_fingerprint);
4025 if phase.as_deref() == Some("ready") && fingerprint_matches {
4026 ensure_cold_build_current("completed-staging", 1, 1)?;
4027 crate::slog_info!(
4028 "callgraph cold-build decision: reason=matching completed staging; action=publish"
4029 );
4030 conn.execute("DELETE FROM staging_file_inventory", [])?;
4031 return cold_build_stats_from_connection(&conn, started);
4032 }
4033 if phase.is_none() || !fingerprint_matches {
4034 if staged_fingerprint.is_some() && !fingerprint_matches {
4035 crate::slog_info!(
4036 "callgraph cold-build decision: reason=fingerprint mismatch; action=restart staging"
4037 );
4038 }
4039 let total_changes_before = conn.total_changes();
4040 let tx = conn.transaction()?;
4041 clear_tables(&tx)?;
4042 tx.execute("DELETE FROM staging_ref_context", [])?;
4043 insert_meta(&tx)?;
4044 drop_cold_build_secondary_indexes(&tx)?;
4045 set_meta_ready(&tx, false)?;
4046 set_staged_build_phase(&tx, "extracting")?;
4047 set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, corpus_fingerprint)?;
4048 set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0)?;
4049 set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, 0)?;
4050 tx.commit()?;
4051 self.record_commit(total_changes_before, &conn);
4052 phase = Some("extracting".to_string());
4053 }
4054
4055 note_cold_build_phase("extraction");
4059 if phase.as_deref() == Some("extracting") {
4060 prune_staged_files_not_in_inventory(&mut conn)?;
4061
4062 let total_files =
4063 query_count(&conn, "SELECT COUNT(*) FROM staging_file_inventory")? as usize;
4064 let mut completed_files = 0usize;
4065 ensure_cold_build_current("extraction", completed_files, total_files)?;
4066 let mut after_path = String::new();
4067 loop {
4068 let Some(batch) = load_staged_file_batch(
4069 &conn,
4070 &self.project_root,
4071 &after_path,
4072 batch_files,
4073 COLD_BUILD_EXTRACT_BATCH_BYTES,
4074 )?
4075 else {
4076 break;
4077 };
4078 after_path = batch.last_path;
4079 let batch_files = batch.paths.len();
4080
4081 let mut needs_extract = Vec::with_capacity(batch_files);
4082 for path in batch.paths {
4083 if !staged_content_matches(&conn, &self.project_root, &path)? {
4084 needs_extract.push(path);
4085 }
4086 }
4087 if needs_extract.is_empty() {
4088 completed_files = completed_files.saturating_add(batch_files);
4089 ensure_cold_build_current("extraction", completed_files, total_files)?;
4090 continue;
4091 }
4092
4093 notify_cold_build_extract_observer(&needs_extract);
4094 let build = build_extracts_parallel(&self.project_root, &needs_extract);
4095 self.verify_writer_lease()?;
4096 let total_changes_before = conn.total_changes();
4097 let tx = conn.transaction()?;
4098 let mut extracted_bytes = 0u64;
4099 {
4100 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
4101 for extract in &build.extracts {
4102 delete_staged_file_rows(&tx, &extract.rel_path)?;
4103 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
4104 for raw in &extract.raw_refs {
4105 insert_staged_ref_prepared(&mut inserts, raw)?;
4106 }
4107 extracted_bytes = extracted_bytes.saturating_add(extract.freshness.size);
4108 }
4109 for failure in &build.failures {
4110 insert_backend_state_prepared(
4111 &mut inserts.backend_state,
4112 &workspace_root,
4113 &failure.rel_path,
4114 failure
4115 .freshness
4116 .as_ref()
4117 .map(|freshness| &freshness.content_hash),
4118 "stale",
4119 )?;
4120 }
4121 }
4122 increment_staged_extracted_bytes(&tx, extracted_bytes)?;
4123 note_cold_build_commit_barrier("extraction_batch_before_commit");
4124 tx.commit()?;
4125 note_cold_build_commit_barrier("extraction_batch_committed");
4126 self.record_commit(total_changes_before, &conn);
4127 completed_files = completed_files.saturating_add(batch_files);
4128 ensure_cold_build_current("extraction", completed_files, total_files)?;
4129 }
4130
4131 ensure_cold_build_current("extraction", completed_files, total_files)?;
4132 let total_changes_before = conn.total_changes();
4133 let tx = conn.transaction()?;
4134 set_staged_build_phase(&tx, "indexing")?;
4135 tx.commit()?;
4136 self.record_commit(total_changes_before, &conn);
4137 phase = Some("indexing".to_string());
4138 ensure_cold_build_current("extraction", total_files, total_files)?;
4139 }
4140
4141 note_cold_build_phase("symbol_export_index");
4145 if phase.as_deref() == Some("indexing") {
4146 ensure_cold_build_current("symbol-export-index", 0, 1)?;
4147 self.verify_writer_lease()?;
4148 let total_changes_before = conn.total_changes();
4149 let tx = conn.transaction()?;
4150 create_cold_build_secondary_indexes(&tx)?;
4151 set_staged_build_phase(&tx, "resolving")?;
4152 tx.commit()?;
4153 self.record_commit(total_changes_before, &conn);
4154 ensure_cold_build_current("symbol-export-index", 1, 1)?;
4155 }
4156
4157 note_cold_build_phase("resolution");
4158 let workspace_crate_prefixes = WorkspaceCratePrefixCache::default();
4159 let total_refs = query_count(&conn, "SELECT COUNT(*) FROM refs")? as usize;
4160 let mut resolved_refs =
4161 query_count(&conn, "SELECT COUNT(*) FROM refs WHERE status <> 'staged'")? as usize;
4162 ensure_cold_build_current("resolution", resolved_refs, total_refs)?;
4163 let mut resolve_cursor = staged_u64(&conn, STAGED_RESOLVE_CURSOR)?;
4164 loop {
4165 let staged = load_staged_ref_window(&conn, resolve_cursor, resolve_window)?;
4166 let Some(last_rowid) = staged.last().map(|entry| entry.rowid) else {
4167 break;
4168 };
4169
4170 self.verify_writer_lease()?;
4171 let total_changes_before = conn.total_changes();
4172 let tx = conn.transaction()?;
4173 {
4174 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
4175 let mut offset = 0;
4176 while offset < staged.len() {
4177 let caller_file = staged[offset].raw.caller_file.clone();
4178 let end = staged[offset..]
4179 .iter()
4180 .position(|entry| entry.raw.caller_file != caller_file)
4181 .map(|relative| offset + relative)
4182 .unwrap_or(staged.len());
4183 let caller_extract = build_file_extract(
4184 &self.project_root,
4185 &self.project_root.join(&caller_file),
4186 );
4187 if let Ok(caller_extract) = caller_extract {
4188 let index = DiskProjectIndex {
4189 project_root: &self.project_root,
4190 conn: &tx,
4191 caller_file: &caller_file,
4192 caller_data: &caller_extract.data,
4193 workspace_crate_prefixes: workspace_crate_prefixes.clone(),
4194 module_resolution_memo,
4195 file_index_memo: RefCell::new(HashMap::new()),
4196 module_parent_memo: RefCell::new(HashMap::new()),
4197 memoize_resolver_indexes,
4198 };
4199 for staged_ref in &staged[offset..end] {
4200 let resolved = resolve_ref(staged_ref.raw.clone(), &index)?;
4201 insert_resolved_ref_prepared(&mut inserts, &resolved)?;
4202 }
4203 } else {
4204 for staged_ref in &staged[offset..end] {
4205 let unresolved = unresolved_staged_ref(staged_ref.raw.clone());
4206 insert_resolved_ref_prepared(&mut inserts, &unresolved)?;
4207 }
4208 }
4209 offset = end;
4210 }
4211 }
4212 set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, last_rowid)?;
4213 tx.commit()?;
4214 self.record_commit(total_changes_before, &conn);
4215 resolve_cursor = last_rowid;
4216 resolved_refs = resolved_refs.saturating_add(staged.len()).min(total_refs);
4217 ensure_cold_build_current("resolution", resolved_refs, total_refs)?;
4218 }
4219
4220 ensure_cold_build_current("resolution", resolved_refs, total_refs)?;
4221 note_cold_build_phase("publication");
4222 self.verify_writer_lease()?;
4223 let total_changes_before = conn.total_changes();
4224 let tx = conn.transaction()?;
4225 let _supplemental_edge_count =
4226 insert_method_dispatch_edges_chunked(&tx, &self.project_root, batch_files)?;
4227 set_meta_ready(&tx, true)?;
4228 set_staged_build_phase(&tx, "ready")?;
4229 tx.execute("DELETE FROM staging_file_inventory", [])?;
4230 tx.execute("DELETE FROM staging_ref_context", [])?;
4231 bump_projection_write_revision(&tx)?;
4232 tx.commit()?;
4233 self.record_commit(total_changes_before, &conn);
4234
4235 cold_build_stats_from_connection(&conn, started)
4236 }
4237
4238 pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
4239 self.refresh_files_with_workspace_crate_prefix_cache(
4240 changed_files,
4241 WorkspaceCratePrefixCache::default(),
4242 )
4243 }
4244
4245 fn refresh_files_with_workspace_crate_prefix_cache(
4246 &self,
4247 changed_files: &[PathBuf],
4248 workspace_crate_prefixes: WorkspaceCratePrefixCache,
4249 ) -> Result<IncrementalStats> {
4250 let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
4251 changed_files,
4252 workspace_crate_prefixes,
4253 )?;
4254 if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
4255 eprintln!("refresh_files phases: {}", profile.report());
4256 }
4257 Ok(stats)
4258 }
4259
4260 #[doc(hidden)]
4262 pub fn refresh_files_profiled(
4263 &self,
4264 changed_files: &[PathBuf],
4265 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
4266 self.refresh_files_profiled_with_workspace_crate_prefix_cache(
4267 changed_files,
4268 WorkspaceCratePrefixCache::default(),
4269 )
4270 }
4271
4272 fn refresh_files_profiled_with_workspace_crate_prefix_cache(
4273 &self,
4274 changed_files: &[PathBuf],
4275 workspace_crate_prefixes: WorkspaceCratePrefixCache,
4276 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
4277 let total_started = Instant::now();
4278 let mut profile = RefreshFilesProfile::default();
4279 self.verify_writer_lease()?;
4280 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
4281 ensure_database_ready(&conn)?;
4282 let total_changes_before = conn.total_changes();
4283 let mut changed = Vec::new();
4284 let mut surface_changed = BTreeSet::new();
4285 let mut deleted = BTreeSet::new();
4286 let mut own_refresh = BTreeSet::new();
4287 let mut candidate_own_refresh = BTreeSet::new();
4288 let mut confirmed_fresh = BTreeSet::new();
4289 let mut unchanged_extracts = 0usize;
4290 let mut selected_ref_ids = BTreeSet::new();
4291 let mut selected_refs_by_caller = BTreeMap::new();
4292 let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
4293 let mut fresh_metadata = BTreeMap::new();
4294
4295 for input in changed_files {
4296 let (abs_path, rel_path) = match normalize_project_file_path(&self.project_root, input)
4297 {
4298 Ok(path) => path,
4299 Err(error) => {
4300 record_path_identity_mismatch(&conn, &error)?;
4301 return Err(error);
4302 }
4303 };
4304 changed.push(rel_path.clone());
4305 let old_row = load_file_row(&conn, &rel_path)?;
4306 if !abs_path.exists() {
4307 if old_row.is_some() && deleted.insert(rel_path.clone()) {
4308 surface_changed.insert(rel_path.clone());
4309 let started = Instant::now();
4310 let dependent_refs =
4311 ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
4312 profile.dependency_selection += started.elapsed();
4313 record_dependent_refs(
4314 &mut selected_ref_ids,
4315 &mut selected_refs_by_caller,
4316 dependent_refs,
4317 );
4318 }
4319 continue;
4320 }
4321
4322 if let Some(row) = &old_row {
4323 match cache_freshness::verify_file(&abs_path, &row.freshness) {
4324 FreshnessVerdict::HotFresh => {
4325 confirmed_fresh.insert(rel_path.clone());
4330 continue;
4331 }
4332 FreshnessVerdict::ContentFresh {
4333 new_mtime,
4334 new_size,
4335 } => {
4336 fresh_metadata.insert(
4337 rel_path.clone(),
4338 FileFreshness {
4339 content_hash: row.freshness.content_hash,
4340 mtime: new_mtime,
4341 size: new_size,
4342 },
4343 );
4344 continue;
4345 }
4346 FreshnessVerdict::Deleted => {
4347 if deleted.insert(rel_path.clone()) {
4348 surface_changed.insert(rel_path.clone());
4349 let started = Instant::now();
4350 let dependent_refs =
4351 ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
4352 profile.dependency_selection += started.elapsed();
4353 record_dependent_refs(
4354 &mut selected_ref_ids,
4355 &mut selected_refs_by_caller,
4356 dependent_refs,
4357 );
4358 }
4359 continue;
4360 }
4361 FreshnessVerdict::Stale => {}
4362 }
4363 }
4364
4365 let started = Instant::now();
4366 let extract = build_file_extract(&self.project_root, &abs_path)?;
4367 profile.parse += started.elapsed();
4368 let surface_is_changed = old_row
4369 .as_ref()
4370 .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
4371 .unwrap_or(true);
4372 if surface_is_changed {
4373 surface_changed.insert(rel_path.clone());
4374 let started = Instant::now();
4375 let dependent_refs = ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
4376 profile.dependency_selection += started.elapsed();
4377 record_dependent_refs(
4378 &mut selected_ref_ids,
4379 &mut selected_refs_by_caller,
4380 dependent_refs,
4381 );
4382 }
4383 candidate_own_refresh.insert(rel_path.clone());
4384 changed_extracts.insert(rel_path, extract);
4385 }
4386
4387 let dependency_selected_refs = selected_ref_ids.len();
4388 let mut touched_callers: BTreeSet<String> =
4389 selected_refs_by_caller.keys().cloned().collect();
4390 touched_callers.extend(candidate_own_refresh.iter().cloned());
4391
4392 let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
4393 for rel_path in &touched_callers {
4394 if deleted.contains(rel_path) {
4395 continue;
4396 }
4397 if let Some(extract) = changed_extracts.get(rel_path) {
4398 caller_extracts.insert(rel_path.clone(), extract.clone());
4399 continue;
4400 }
4401 let abs_path = self.project_root.join(rel_path);
4402 if abs_path.exists() {
4403 let started = Instant::now();
4404 let extract = build_file_extract(&self.project_root, &abs_path)?;
4405 profile.dependent_parse += started.elapsed();
4406 caller_extracts.insert(rel_path.clone(), extract);
4407 }
4408 }
4409
4410 let mut projection_callers = touched_callers.clone();
4411 projection_callers.extend(deleted.iter().cloned());
4412 for file in touched_callers.iter().chain(deleted.iter()) {
4413 dead_code_projection::extend_projection_dependents(
4414 &conn,
4415 file,
4416 &mut projection_callers,
4417 )?;
4418 }
4419
4420 let tx = conn.transaction()?;
4421 for (rel_path, freshness) in fresh_metadata {
4422 update_file_fresh_metadata(
4423 &tx,
4424 &self.project_root,
4425 &rel_path,
4426 &freshness.content_hash,
4427 freshness.mtime,
4428 freshness.size,
4429 )?;
4430 }
4431 for rel_path in &confirmed_fresh {
4432 clear_stale_backend_status_for_file(&tx, &self.project_root, rel_path)?;
4433 }
4434 for rel_path in &deleted {
4435 let started = Instant::now();
4436 delete_file_rows(&tx, rel_path)?;
4437 clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
4438 profile.row_deletes += started.elapsed();
4439 }
4440
4441 if caller_extracts.is_empty() {
4445 let wrote_rows = tx.total_changes() != total_changes_before;
4446 if !deleted.is_empty() {
4447 dead_code_projection::record_projection_delta(&tx, &projection_callers)?;
4448 }
4449 let started = Instant::now();
4450 commit_incremental_if_current(tx)?;
4451 if wrote_rows {
4452 self.record_commit(total_changes_before, &conn);
4453 }
4454 profile.commit += started.elapsed();
4455 profile.total = total_started.elapsed();
4456 return Ok((
4457 IncrementalStats {
4458 changed_files: changed,
4459 surface_changed: surface_changed.into_iter().collect(),
4460 deleted_files: deleted.into_iter().collect(),
4461 dependency_selected_refs,
4462 refreshed_own_files: 0,
4463 unchanged_extract_files: 0,
4464 },
4465 profile,
4466 ));
4467 }
4468
4469 let started = Instant::now();
4470 profile.index_loads += 1;
4471 let index = ProjectIndex::from_db_and_callers(
4472 &tx,
4473 &self.project_root,
4474 &caller_extracts,
4475 workspace_crate_prefixes,
4476 )?;
4477 profile.index_load += started.elapsed();
4478
4479 let workspace_root = self.project_root.display().to_string();
4480 {
4481 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
4482 for rel_path in &candidate_own_refresh {
4483 let Some(extract) = changed_extracts.get(rel_path) else {
4484 continue;
4485 };
4486 if stored_extract_matches(&tx, rel_path, extract, &index)? {
4487 unchanged_extracts += 1;
4488 update_file_fresh_metadata(
4489 &tx,
4490 &self.project_root,
4491 rel_path,
4492 &extract.freshness.content_hash,
4493 extract.freshness.mtime,
4494 extract.freshness.size,
4495 )?;
4496 continue;
4497 }
4498
4499 own_refresh.insert(rel_path.clone());
4500 let started = Instant::now();
4501 delete_file_rows(&tx, rel_path)?;
4502 clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
4503 profile.row_deletes += started.elapsed();
4504 let started = Instant::now();
4505 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
4506 profile.row_inserts += started.elapsed();
4507 }
4508
4509 let dependency_callers = touched_callers
4510 .iter()
4511 .filter(|rel_path| {
4512 !deleted.contains(*rel_path) && !candidate_own_refresh.contains(*rel_path)
4513 })
4514 .cloned()
4515 .collect::<Vec<_>>();
4516 for rel_path in dependency_callers {
4517 let Some(extract) = caller_extracts.get(&rel_path) else {
4518 continue;
4519 };
4520 if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
4521 continue;
4522 }
4523
4524 own_refresh.insert(rel_path.clone());
4525 let started = Instant::now();
4526 delete_file_rows(&tx, &rel_path)?;
4527 clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
4528 profile.row_deletes += started.elapsed();
4529 let started = Instant::now();
4530 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
4531 profile.row_inserts += started.elapsed();
4532 }
4533 let started = Instant::now();
4534 for rel_path in &touched_callers {
4535 if deleted.contains(rel_path) {
4536 continue;
4537 }
4538 let Some(extract) = caller_extracts.get(rel_path) else {
4539 continue;
4540 };
4541 if own_refresh.contains(rel_path) {
4542 delete_refs_for_caller(&tx, rel_path)?;
4543 for raw_ref in &extract.raw_refs {
4544 let resolved = resolve_ref(raw_ref.clone(), &index)?;
4545 insert_resolved_ref_prepared(&mut inserts, &resolved)?;
4546 }
4547 continue;
4548 }
4549
4550 let selected_for_caller = selected_refs_by_caller
4551 .get(rel_path)
4552 .cloned()
4553 .unwrap_or_default();
4554 delete_ref_ids(&tx, &selected_for_caller)?;
4555 for raw_ref in &extract.raw_refs {
4556 if selected_for_caller.contains(&raw_ref.ref_id) {
4557 let resolved = resolve_ref(raw_ref.clone(), &index)?;
4558 insert_resolved_ref_prepared(&mut inserts, &resolved)?;
4559 }
4560 }
4561 }
4562 profile.ref_resolution += started.elapsed();
4563 }
4564
4565 let started = Instant::now();
4566 delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
4567 insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
4568 profile.method_dispatch += started.elapsed();
4569
4570 if !own_refresh.is_empty() || !selected_ref_ids.is_empty() || !deleted.is_empty() {
4573 dead_code_projection::record_projection_delta(&tx, &projection_callers)?;
4574 }
4575 let started = Instant::now();
4576 commit_incremental_if_current(tx)?;
4577 self.record_commit(total_changes_before, &conn);
4578 profile.commit += started.elapsed();
4579 profile.total = total_started.elapsed();
4580 Ok((
4581 IncrementalStats {
4582 changed_files: changed,
4583 surface_changed: surface_changed.into_iter().collect(),
4584 deleted_files: deleted.into_iter().collect(),
4585 dependency_selected_refs,
4586 refreshed_own_files: own_refresh.len(),
4587 unchanged_extract_files: unchanged_extracts,
4588 },
4589 profile,
4590 ))
4591 }
4592
4593 pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
4594 self.cold_build(current_files)
4595 }
4596
4597 pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
4598 self.verify_writer_lease()?;
4599 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
4600 let total_changes_before = conn.total_changes();
4601 let tx = conn.transaction()?;
4602 let mut marked = Vec::new();
4603 for path in files {
4604 let (abs_path, rel_path) = match normalize_project_file_path(&self.project_root, path) {
4605 Ok(path) => path,
4606 Err(error) => {
4607 drop(tx);
4608 record_path_identity_mismatch(&conn, &error)?;
4609 return Err(error);
4610 }
4611 };
4612 let freshness = cache_freshness::collect(&abs_path).ok();
4613 mark_backend_state(
4614 &tx,
4615 &self.project_root,
4616 &rel_path,
4617 freshness.as_ref().map(|freshness| &freshness.content_hash),
4618 "stale",
4619 )?;
4620 marked.push(rel_path);
4621 }
4622 tx.commit()?;
4626 self.record_commit(total_changes_before, &conn);
4627 marked.sort();
4628 marked.dedup();
4629 Ok(marked)
4630 }
4631
4632 pub fn stale_files(&self) -> Result<Vec<String>> {
4633 self.refresh_read_marker()?;
4634 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4635 let mut stmt = conn.prepare(
4636 "SELECT DISTINCT file_path FROM backend_file_state
4637 WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
4638 ORDER BY file_path",
4639 )?;
4640 let rows = stmt.query_map(
4641 params![BACKEND_TREESITTER, self.project_root.display().to_string()],
4642 |row| row.get::<_, String>(0),
4643 )?;
4644 rows.collect::<std::result::Result<Vec<_>, _>>()
4645 .map_err(Into::into)
4646 }
4647
4648 pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
4649 self.refresh_read_marker()?;
4650 let rel_path = relative_path(
4651 &self.project_root,
4652 &normalize_file_path(&self.project_root, file)?,
4653 );
4654 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4655 conn.query_row(
4656 "SELECT status FROM backend_file_state
4657 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
4658 ORDER BY updated_at DESC LIMIT 1",
4659 params![
4660 BACKEND_TREESITTER,
4661 self.project_root.display().to_string(),
4662 rel_path
4663 ],
4664 |row| row.get(0),
4665 )
4666 .optional()
4667 .map_err(Into::into)
4668 }
4669
4670 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4671 self.refresh_read_marker()?;
4672 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4673 self.ensure_ready(&conn)?;
4674 edge_snapshot_with_conn(&conn)
4675 }
4676
4677 pub fn indexed_file_count(&self) -> Result<usize> {
4678 self.refresh_read_marker()?;
4679 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4680 self.ensure_ready(&conn)?;
4681 indexed_file_count(&conn)
4682 }
4683
4684 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4685 self.refresh_read_marker()?;
4686 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4687 let rel_path = relative_path(&self.project_root, &abs_path);
4688 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4689 self.ensure_ready(&conn)?;
4690 resolve_node_for_rel(&conn, &rel_path, symbol)
4691 }
4692
4693 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4698 self.refresh_read_marker()?;
4699 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4700 let rel_path = relative_path(&self.project_root, &abs_path);
4701 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4702 self.ensure_ready(&conn)?;
4703 nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
4704 }
4705
4706 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4708 self.refresh_read_marker()?;
4709 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4710 self.ensure_ready(&conn)?;
4711 nodes_matching_symbol(&conn, symbol)
4712 }
4713
4714 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4716 self.refresh_read_marker()?;
4717 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4718 let rel_path = relative_path(&self.project_root, &abs_path);
4719 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4720 self.ensure_ready(&conn)?;
4721 direct_callers_for_tuple(&conn, &rel_path, symbol)
4722 }
4723
4724 pub fn direct_callers_for_symbols(
4726 &self,
4727 targets: &[(String, String)],
4728 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4729 if targets.is_empty() {
4730 return Ok(HashMap::new());
4731 }
4732 self.refresh_read_marker()?;
4733 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4734 self.ensure_ready(&conn)?;
4735 direct_callers_for_tuples(&conn, targets)
4736 }
4737
4738 pub fn direct_caller_counts_of(
4740 &self,
4741 targets: &[(String, String)],
4742 ) -> Result<HashMap<(String, String), usize>> {
4743 if targets.is_empty() {
4744 return Ok(HashMap::new());
4745 }
4746 self.refresh_read_marker()?;
4747 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4748 self.ensure_ready(&conn)?;
4749 direct_caller_counts_for_tuples(&conn, targets)
4750 }
4751
4752 pub fn callers_of(
4753 &self,
4754 file_rel: &Path,
4755 symbol: &str,
4756 depth: usize,
4757 ) -> Result<StoreCallersResult> {
4758 let target = self.node_for(file_rel, symbol)?;
4759 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4760 self.ensure_ready(&conn)?;
4761 let effective_depth = depth.max(1);
4762 let mut visited = HashSet::new();
4763 let mut callers = Vec::new();
4764 let mut depth_limited = false;
4765 let mut truncated = 0usize;
4766 collect_callers_recursive(
4767 &conn,
4768 &target.file,
4769 &target.symbol,
4770 effective_depth,
4771 0,
4772 &mut visited,
4773 &mut callers,
4774 &mut depth_limited,
4775 &mut truncated,
4776 )?;
4777 Ok(StoreCallersResult {
4778 target,
4779 callers,
4780 scanned_files: indexed_file_count(&conn)?,
4781 depth_limited,
4782 truncated,
4783 })
4784 }
4785
4786 pub fn impact_of(
4787 &self,
4788 file_rel: &Path,
4789 symbol: &str,
4790 depth: usize,
4791 ) -> Result<StoreImpactResult> {
4792 let callers = self.callers_of(file_rel, symbol, depth)?;
4793 let target_parameters = callers
4794 .target
4795 .signature
4796 .as_deref()
4797 .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
4798 .unwrap_or_default();
4799 let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
4800 for site in &callers.callers {
4801 source_lines_by_file
4802 .entry(site.caller.file.clone())
4803 .or_insert_with(|| {
4804 read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
4805 });
4806 }
4807 let enriched = callers
4808 .callers
4809 .iter()
4810 .map(|site| StoreImpactCaller {
4811 site: site.clone(),
4812 signature: site.caller.signature.clone(),
4813 is_entry_point: site.caller.is_entry_point,
4814 call_expression: source_lines_by_file
4815 .get(&site.caller.file)
4816 .and_then(|lines| lines.as_ref())
4817 .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
4818 .cloned(),
4819 parameters: site
4820 .caller
4821 .signature
4822 .as_deref()
4823 .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
4824 .unwrap_or_default(),
4825 })
4826 .collect();
4827 Ok(StoreImpactResult {
4828 target: callers.target,
4829 parameters: target_parameters,
4830 callers: enriched,
4831 depth_limited: callers.depth_limited,
4832 truncated: callers.truncated,
4833 })
4834 }
4835
4836 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4837 self.refresh_read_marker()?;
4838 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4839 self.ensure_ready(&conn)?;
4840 outgoing_calls_for_node(&conn, node)
4841 }
4842
4843 pub fn outgoing_calls_for_symbols(
4845 &self,
4846 sources: &[(String, String)],
4847 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4848 if sources.is_empty() {
4849 return Ok(HashMap::new());
4850 }
4851 self.refresh_read_marker()?;
4852 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4853 self.ensure_ready(&conn)?;
4854 outgoing_calls_for_symbol_tuples(&conn, sources)
4855 }
4856
4857 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4859 self.refresh_read_marker()?;
4860 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4861 self.ensure_ready(&conn)?;
4862 resolved_self_calls_for_node(&conn, node)
4863 }
4864
4865 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4866 self.refresh_read_marker()?;
4867 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4868 self.ensure_ready(&conn)?;
4869 unresolved_calls_for_node(&conn, node)
4870 }
4871
4872 pub fn call_tree(
4873 &self,
4874 file_rel: &Path,
4875 symbol: &str,
4876 max_depth: usize,
4877 ) -> Result<callgraph::CallTreeNode> {
4878 let node = self.node_for(file_rel, symbol)?;
4879 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4880 self.ensure_ready(&conn)?;
4881 let mut visited = HashSet::new();
4882 call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
4883 }
4884
4885 pub fn trace_to(
4886 &self,
4887 file_rel: &Path,
4888 symbol: &str,
4889 max_depth: usize,
4890 ) -> Result<callgraph::TraceToResult> {
4891 let target = self.node_for(file_rel, symbol)?;
4892 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4893 self.ensure_ready(&conn)?;
4894 let effective_max = if max_depth == 0 { 10 } else { max_depth };
4895
4896 #[derive(Clone)]
4897 struct PathElem {
4898 node: StoreNode,
4899 }
4900
4901 let initial = vec![PathElem {
4902 node: target.clone(),
4903 }];
4904 let mut complete_paths = Vec::new();
4905 if target.is_entry_point {
4906 complete_paths.push(initial.clone());
4907 }
4908
4909 let mut queue = vec![(initial, 0usize)];
4910 let mut max_depth_reached = false;
4911 let mut truncated_paths = 0usize;
4912
4913 while let Some((path, depth)) = queue.pop() {
4914 if depth >= effective_max {
4915 max_depth_reached = true;
4916 continue;
4917 }
4918 let Some(current) = path.last() else {
4919 continue;
4920 };
4921 let callers =
4922 direct_callers_for_tuple(&conn, ¤t.node.file, ¤t.node.symbol)?;
4923 if callers.is_empty() {
4924 if path.len() > 1 {
4925 truncated_paths += 1;
4926 }
4927 continue;
4928 }
4929
4930 let mut has_new_path = false;
4931 for site in callers {
4932 if path.iter().any(|elem| {
4933 elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
4934 }) {
4935 continue;
4936 }
4937 has_new_path = true;
4938 let mut new_path = path.clone();
4939 new_path.push(PathElem {
4940 node: site.caller.clone(),
4941 });
4942 if site.caller.is_entry_point {
4943 complete_paths.push(new_path.clone());
4944 }
4945 queue.push((new_path, depth + 1));
4946 }
4947 if !has_new_path && path.len() > 1 {
4948 truncated_paths += 1;
4949 }
4950 }
4951
4952 let mut paths: Vec<callgraph::TracePath> = complete_paths
4953 .into_iter()
4954 .map(|mut elems| {
4955 elems.reverse();
4956 let hops = elems
4957 .iter()
4958 .enumerate()
4959 .map(|(index, elem)| callgraph::TraceHop {
4960 symbol: elem.node.symbol.clone(),
4961 file: elem.node.file.clone(),
4962 line: elem.node.line,
4963 signature: elem.node.signature.clone(),
4964 is_entry_point: index == 0 && elem.node.is_entry_point,
4965 })
4966 .collect();
4967 callgraph::TracePath { hops }
4968 })
4969 .collect();
4970 paths.sort_by(|left, right| {
4971 let left_entry = left
4972 .hops
4973 .first()
4974 .map(|hop| hop.symbol.as_str())
4975 .unwrap_or("");
4976 let right_entry = right
4977 .hops
4978 .first()
4979 .map(|hop| hop.symbol.as_str())
4980 .unwrap_or("");
4981 left_entry
4982 .cmp(right_entry)
4983 .then(left.hops.len().cmp(&right.hops.len()))
4984 });
4985 let entry_points_found = paths
4986 .iter()
4987 .filter_map(|path| path.hops.first())
4988 .filter(|hop| hop.is_entry_point)
4989 .map(|hop| (hop.file.clone(), hop.symbol.clone()))
4990 .collect::<HashSet<_>>()
4991 .len();
4992
4993 Ok(callgraph::TraceToResult {
4994 target_symbol: target.symbol,
4995 target_file: target.file,
4996 total_paths: paths.len(),
4997 paths,
4998 entry_points_found,
4999 max_depth_reached,
5000 truncated_paths,
5001 })
5002 }
5003
5004 pub fn trace_to_symbol_candidates(
5005 &self,
5006 to_symbol: &str,
5007 ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
5008 self.refresh_read_marker()?;
5009 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
5010 self.ensure_ready(&conn)?;
5011 let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
5012 for node in nodes_matching_symbol(&conn, to_symbol)? {
5013 candidates_by_file
5014 .entry(node.file)
5015 .and_modify(|line| *line = (*line).min(node.line))
5016 .or_insert(node.line);
5017 }
5018 let mut candidates: Vec<_> = candidates_by_file
5019 .into_iter()
5020 .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
5021 .collect();
5022 candidates
5023 .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
5024 Ok(candidates)
5025 }
5026
5027 pub fn trace_to_symbol(
5028 &self,
5029 file_rel: &Path,
5030 symbol: &str,
5031 to_symbol: &str,
5032 to_file: Option<&Path>,
5033 max_depth: usize,
5034 ) -> Result<callgraph::TraceToSymbolResult> {
5035 let origin = self.node_for(file_rel, symbol)?;
5036 let target_file = to_file
5037 .map(|path| normalize_file_path(&self.project_root, path))
5038 .transpose()?
5039 .map(|path| relative_path(&self.project_root, &path));
5040 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
5041 self.ensure_ready(&conn)?;
5042 let effective_max = if max_depth == 0 {
5043 10
5044 } else {
5045 max_depth.min(16)
5046 };
5047
5048 let start_hop = trace_to_symbol_hop(&origin);
5049 if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
5050 return Ok(callgraph::TraceToSymbolResult {
5051 path: Some(vec![start_hop]),
5052 complete: true,
5053 reason: None,
5054 });
5055 }
5056
5057 let mut queue = VecDeque::new();
5058 queue.push_back((origin.clone(), vec![start_hop], 0usize));
5059 let mut visited = HashSet::new();
5060 visited.insert((origin.file.clone(), origin.symbol.clone()));
5061 let mut max_depth_exhausted = false;
5062
5063 while let Some((current, path, depth)) = queue.pop_front() {
5064 let callees = outgoing_calls_for_node(&conn, ¤t)?
5065 .into_iter()
5066 .filter_map(|site| site.target)
5067 .collect::<Vec<_>>();
5068
5069 if depth >= effective_max {
5070 if callees
5071 .iter()
5072 .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
5073 {
5074 max_depth_exhausted = true;
5075 }
5076 continue;
5077 }
5078
5079 for callee in callees {
5080 if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
5081 continue;
5082 }
5083 let mut next_path = path.clone();
5084 next_path.push(trace_to_symbol_hop(&callee));
5085 if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
5086 return Ok(callgraph::TraceToSymbolResult {
5087 path: Some(next_path),
5088 complete: true,
5089 reason: None,
5090 });
5091 }
5092 queue.push_back((callee, next_path, depth + 1));
5093 }
5094 }
5095
5096 if max_depth_exhausted {
5097 Ok(callgraph::TraceToSymbolResult {
5098 path: None,
5099 complete: false,
5100 reason: Some("max_depth_exhausted".to_string()),
5101 })
5102 } else {
5103 Ok(callgraph::TraceToSymbolResult {
5104 path: None,
5105 complete: true,
5106 reason: Some("no_path_found".to_string()),
5107 })
5108 }
5109 }
5110}
5111
5112impl ReadonlyCallGraphStore {
5113 pub(crate) fn open_manifest_view(
5114 project_root: PathBuf,
5115 family: String,
5116 view_dir: PathBuf,
5117 generation: &str,
5118 pin: Option<Arc<crate::pins::QueryPin>>,
5119 ) -> Result<Self> {
5120 let generation_path = view_dir.join(format!("derived-{generation}.sqlite"));
5121 let sqlite_path = if generation_path.is_file() {
5124 generation_path
5125 } else {
5126 view_dir.join("derived.sqlite")
5127 };
5128 let conn = open_readonly_connection(&sqlite_path)?;
5129 ensure_database_ready(&conn)?;
5130 let mut inner = CallGraphStore::from_connection(
5131 project_root,
5132 family,
5133 sqlite_path,
5134 view_dir,
5135 false,
5136 None,
5137 None,
5138 None,
5139 conn,
5140 );
5141 inner.manifest_view = true;
5142 inner.database_ready.store(true, AtomicOrdering::Release);
5143 Ok(Self {
5144 inner,
5145 _view_pin: pin,
5146 })
5147 }
5148
5149 pub fn reader_kind(&self) -> &'static str {
5150 if self.inner.manifest_view {
5151 "view"
5152 } else {
5153 "legacy"
5154 }
5155 }
5156
5157 fn from_inner(inner: CallGraphStore) -> Self {
5158 Self {
5159 inner,
5160 _view_pin: None,
5161 }
5162 }
5163
5164 pub fn project_root(&self) -> &Path {
5165 self.inner.project_root()
5166 }
5167
5168 pub fn project_key(&self) -> &str {
5169 self.inner.project_key()
5170 }
5171
5172 pub fn sqlite_path(&self) -> &Path {
5173 self.inner.sqlite_path()
5174 }
5175
5176 pub fn stale_files(&self) -> Result<Vec<String>> {
5177 self.inner.stale_files()
5178 }
5179
5180 pub(crate) fn projection_generation(&self) -> Option<&str> {
5181 self.inner.projection_generation()
5182 }
5183
5184 pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
5185 self.inner.projection_write_revision()
5186 }
5187
5188 pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
5191 crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
5192 }
5193
5194 pub fn is_legacy_fallback(&self) -> bool {
5196 self.inner.is_legacy_fallback()
5197 }
5198
5199 pub fn is_current(&self) -> bool {
5200 self.inner.is_current()
5201 }
5202
5203 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
5204 self.inner.edge_snapshot()
5205 }
5206
5207 pub fn indexed_file_count(&self) -> Result<usize> {
5208 self.inner.indexed_file_count()
5209 }
5210
5211 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
5212 self.inner.node_for(file_rel, symbol)
5213 }
5214
5215 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
5216 self.inner.nodes_for(file_rel, symbol)
5217 }
5218
5219 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
5220 self.inner.nodes_matching(symbol)
5221 }
5222
5223 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
5224 self.inner.direct_callers_of(file_rel, symbol)
5225 }
5226
5227 pub fn direct_callers_for_symbols(
5228 &self,
5229 targets: &[(String, String)],
5230 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5231 self.inner.direct_callers_for_symbols(targets)
5232 }
5233
5234 pub fn direct_caller_counts_of(
5235 &self,
5236 targets: &[(String, String)],
5237 ) -> Result<HashMap<(String, String), usize>> {
5238 self.inner.direct_caller_counts_of(targets)
5239 }
5240
5241 pub fn callers_of(
5242 &self,
5243 file_rel: &Path,
5244 symbol: &str,
5245 depth: usize,
5246 ) -> Result<StoreCallersResult> {
5247 self.inner.callers_of(file_rel, symbol, depth)
5248 }
5249
5250 pub fn impact_of(
5251 &self,
5252 file_rel: &Path,
5253 symbol: &str,
5254 depth: usize,
5255 ) -> Result<StoreImpactResult> {
5256 self.inner.impact_of(file_rel, symbol, depth)
5257 }
5258
5259 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5260 self.inner.outgoing_calls_of(node)
5261 }
5262
5263 pub fn outgoing_calls_for_symbols(
5264 &self,
5265 sources: &[(String, String)],
5266 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5267 self.inner.outgoing_calls_for_symbols(sources)
5268 }
5269
5270 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5271 self.inner.resolved_self_calls_of(node)
5272 }
5273
5274 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
5275 self.inner.unresolved_calls_of(node)
5276 }
5277
5278 pub fn call_tree(
5279 &self,
5280 file_rel: &Path,
5281 symbol: &str,
5282 depth: usize,
5283 ) -> Result<callgraph::CallTreeNode> {
5284 self.inner.call_tree(file_rel, symbol, depth)
5285 }
5286
5287 pub fn trace_to(
5288 &self,
5289 file_rel: &Path,
5290 symbol: &str,
5291 max_depth: usize,
5292 ) -> Result<callgraph::TraceToResult> {
5293 self.inner.trace_to(file_rel, symbol, max_depth)
5294 }
5295
5296 pub fn trace_to_symbol_candidates(
5297 &self,
5298 to_symbol: &str,
5299 ) -> Result<Vec<TraceToSymbolCandidate>> {
5300 self.inner.trace_to_symbol_candidates(to_symbol)
5301 }
5302
5303 pub fn trace_to_symbol(
5304 &self,
5305 file_rel: &Path,
5306 symbol: &str,
5307 to_symbol: &str,
5308 to_file: Option<&Path>,
5309 max_depth: usize,
5310 ) -> Result<callgraph::TraceToSymbolResult> {
5311 self.inner
5312 .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
5313 }
5314}
5315
5316impl CallGraphRead for CallGraphStore {
5317 fn project_root(&self) -> &Path {
5318 CallGraphStore::project_root(self)
5319 }
5320 fn project_key(&self) -> &str {
5321 CallGraphStore::project_key(self)
5322 }
5323 fn sqlite_path(&self) -> &Path {
5324 CallGraphStore::sqlite_path(self)
5325 }
5326 fn is_current(&self) -> bool {
5327 CallGraphStore::is_current(self)
5328 }
5329 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
5330 CallGraphStore::edge_snapshot(self)
5331 }
5332 fn indexed_file_count(&self) -> Result<usize> {
5333 CallGraphStore::indexed_file_count(self)
5334 }
5335 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
5336 CallGraphStore::node_for(self, file_rel, symbol)
5337 }
5338 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
5339 CallGraphStore::nodes_for(self, file_rel, symbol)
5340 }
5341 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
5342 CallGraphStore::nodes_matching(self, symbol)
5343 }
5344 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
5345 CallGraphStore::direct_callers_of(self, file_rel, symbol)
5346 }
5347 fn direct_callers_for_symbols(
5348 &self,
5349 targets: &[(String, String)],
5350 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5351 CallGraphStore::direct_callers_for_symbols(self, targets)
5352 }
5353 fn direct_caller_counts_of(
5354 &self,
5355 targets: &[(String, String)],
5356 ) -> Result<HashMap<(String, String), usize>> {
5357 CallGraphStore::direct_caller_counts_of(self, targets)
5358 }
5359 fn callers_of(
5360 &self,
5361 file_rel: &Path,
5362 symbol: &str,
5363 depth: usize,
5364 ) -> Result<StoreCallersResult> {
5365 CallGraphStore::callers_of(self, file_rel, symbol, depth)
5366 }
5367 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
5368 CallGraphStore::impact_of(self, file_rel, symbol, depth)
5369 }
5370 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5371 CallGraphStore::outgoing_calls_of(self, node)
5372 }
5373 fn outgoing_calls_for_symbols(
5374 &self,
5375 sources: &[(String, String)],
5376 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5377 CallGraphStore::outgoing_calls_for_symbols(self, sources)
5378 }
5379 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5380 CallGraphStore::resolved_self_calls_of(self, node)
5381 }
5382 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
5383 CallGraphStore::unresolved_calls_of(self, node)
5384 }
5385 fn call_tree(
5386 &self,
5387 file_rel: &Path,
5388 symbol: &str,
5389 depth: usize,
5390 ) -> Result<callgraph::CallTreeNode> {
5391 CallGraphStore::call_tree(self, file_rel, symbol, depth)
5392 }
5393 fn trace_to(
5394 &self,
5395 file_rel: &Path,
5396 symbol: &str,
5397 max_depth: usize,
5398 ) -> Result<callgraph::TraceToResult> {
5399 CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
5400 }
5401 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
5402 CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
5403 }
5404 fn trace_to_symbol(
5405 &self,
5406 file_rel: &Path,
5407 symbol: &str,
5408 to_symbol: &str,
5409 to_file: Option<&Path>,
5410 max_depth: usize,
5411 ) -> Result<callgraph::TraceToSymbolResult> {
5412 CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
5413 }
5414}
5415
5416impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
5417 fn project_root(&self) -> &Path {
5418 (**self).project_root()
5419 }
5420 fn project_key(&self) -> &str {
5421 (**self).project_key()
5422 }
5423 fn sqlite_path(&self) -> &Path {
5424 (**self).sqlite_path()
5425 }
5426 fn is_current(&self) -> bool {
5427 (**self).is_current()
5428 }
5429 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
5430 (**self).edge_snapshot()
5431 }
5432 fn indexed_file_count(&self) -> Result<usize> {
5433 (**self).indexed_file_count()
5434 }
5435 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
5436 (**self).node_for(file_rel, symbol)
5437 }
5438 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
5439 (**self).nodes_for(file_rel, symbol)
5440 }
5441 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
5442 (**self).nodes_matching(symbol)
5443 }
5444 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
5445 (**self).direct_callers_of(file_rel, symbol)
5446 }
5447 fn direct_callers_for_symbols(
5448 &self,
5449 targets: &[(String, String)],
5450 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5451 (**self).direct_callers_for_symbols(targets)
5452 }
5453 fn direct_caller_counts_of(
5454 &self,
5455 targets: &[(String, String)],
5456 ) -> Result<HashMap<(String, String), usize>> {
5457 (**self).direct_caller_counts_of(targets)
5458 }
5459 fn callers_of(
5460 &self,
5461 file_rel: &Path,
5462 symbol: &str,
5463 depth: usize,
5464 ) -> Result<StoreCallersResult> {
5465 (**self).callers_of(file_rel, symbol, depth)
5466 }
5467 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
5468 (**self).impact_of(file_rel, symbol, depth)
5469 }
5470 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5471 (**self).outgoing_calls_of(node)
5472 }
5473 fn outgoing_calls_for_symbols(
5474 &self,
5475 sources: &[(String, String)],
5476 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5477 (**self).outgoing_calls_for_symbols(sources)
5478 }
5479 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5480 (**self).resolved_self_calls_of(node)
5481 }
5482 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
5483 (**self).unresolved_calls_of(node)
5484 }
5485 fn call_tree(
5486 &self,
5487 file_rel: &Path,
5488 symbol: &str,
5489 depth: usize,
5490 ) -> Result<callgraph::CallTreeNode> {
5491 (**self).call_tree(file_rel, symbol, depth)
5492 }
5493 fn trace_to(
5494 &self,
5495 file_rel: &Path,
5496 symbol: &str,
5497 max_depth: usize,
5498 ) -> Result<callgraph::TraceToResult> {
5499 (**self).trace_to(file_rel, symbol, max_depth)
5500 }
5501 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
5502 (**self).trace_to_symbol_candidates(to_symbol)
5503 }
5504 fn trace_to_symbol(
5505 &self,
5506 file_rel: &Path,
5507 symbol: &str,
5508 to_symbol: &str,
5509 to_file: Option<&Path>,
5510 max_depth: usize,
5511 ) -> Result<callgraph::TraceToSymbolResult> {
5512 (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
5513 }
5514}
5515
5516impl CallGraphRead for ReadonlyCallGraphStore {
5517 fn project_root(&self) -> &Path {
5518 self.project_root()
5519 }
5520 fn project_key(&self) -> &str {
5521 self.project_key()
5522 }
5523 fn sqlite_path(&self) -> &Path {
5524 self.sqlite_path()
5525 }
5526 fn is_current(&self) -> bool {
5527 self.is_current()
5528 }
5529 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
5530 self.edge_snapshot()
5531 }
5532 fn indexed_file_count(&self) -> Result<usize> {
5533 self.indexed_file_count()
5534 }
5535 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
5536 self.node_for(file_rel, symbol)
5537 }
5538 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
5539 self.nodes_for(file_rel, symbol)
5540 }
5541 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
5542 self.nodes_matching(symbol)
5543 }
5544 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
5545 self.direct_callers_of(file_rel, symbol)
5546 }
5547 fn direct_callers_for_symbols(
5548 &self,
5549 targets: &[(String, String)],
5550 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5551 self.direct_callers_for_symbols(targets)
5552 }
5553 fn direct_caller_counts_of(
5554 &self,
5555 targets: &[(String, String)],
5556 ) -> Result<HashMap<(String, String), usize>> {
5557 self.direct_caller_counts_of(targets)
5558 }
5559 fn callers_of(
5560 &self,
5561 file_rel: &Path,
5562 symbol: &str,
5563 depth: usize,
5564 ) -> Result<StoreCallersResult> {
5565 self.callers_of(file_rel, symbol, depth)
5566 }
5567 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
5568 self.impact_of(file_rel, symbol, depth)
5569 }
5570 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5571 self.outgoing_calls_of(node)
5572 }
5573 fn outgoing_calls_for_symbols(
5574 &self,
5575 sources: &[(String, String)],
5576 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5577 self.outgoing_calls_for_symbols(sources)
5578 }
5579 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5580 self.resolved_self_calls_of(node)
5581 }
5582 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
5583 self.unresolved_calls_of(node)
5584 }
5585 fn call_tree(
5586 &self,
5587 file_rel: &Path,
5588 symbol: &str,
5589 depth: usize,
5590 ) -> Result<callgraph::CallTreeNode> {
5591 self.call_tree(file_rel, symbol, depth)
5592 }
5593 fn trace_to(
5594 &self,
5595 file_rel: &Path,
5596 symbol: &str,
5597 max_depth: usize,
5598 ) -> Result<callgraph::TraceToResult> {
5599 self.trace_to(file_rel, symbol, max_depth)
5600 }
5601 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
5602 self.trace_to_symbol_candidates(to_symbol)
5603 }
5604 fn trace_to_symbol(
5605 &self,
5606 file_rel: &Path,
5607 symbol: &str,
5608 to_symbol: &str,
5609 to_file: Option<&Path>,
5610 max_depth: usize,
5611 ) -> Result<callgraph::TraceToSymbolResult> {
5612 self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
5613 }
5614}
5615
5616fn indexed_file_count(conn: &Connection) -> Result<usize> {
5617 let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
5618 Ok(count.max(0) as usize)
5619}
5620
5621fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
5622 let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
5623 match candidates.as_slice() {
5624 [candidate] => Ok(candidate.clone()),
5625 [] => Err(AftError::SymbolNotFound {
5626 name: symbol.to_string(),
5627 file: rel_path.to_string(),
5628 }
5629 .into()),
5630 _ => Err(AftError::AmbiguousSymbol {
5631 name: symbol.to_string(),
5632 candidates: candidates
5633 .iter()
5634 .map(|candidate| candidate.symbol.clone())
5635 .collect(),
5636 }
5637 .into()),
5638 }
5639}
5640
5641fn nodes_for_file_matching_symbol(
5642 conn: &Connection,
5643 rel_path: &str,
5644 symbol: &str,
5645) -> Result<Vec<StoreNode>> {
5646 let qualified_query = symbol.contains("::");
5647 let sql = if qualified_query {
5648 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5649 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5650 FROM nodes n JOIN files f ON f.path = n.file_path
5651 WHERE n.file_path = ?1 AND n.scoped_name = ?2
5652 ORDER BY n.scoped_name, n.start_line, n.start_col"
5653 } else {
5654 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5655 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5656 FROM nodes n JOIN files f ON f.path = n.file_path
5657 WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
5658 ORDER BY n.scoped_name, n.start_line, n.start_col"
5659 };
5660 let mut stmt = conn.prepare(sql)?;
5661 let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
5662 rows.collect::<std::result::Result<Vec<_>, _>>()
5663 .map_err(Into::into)
5664}
5665
5666fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
5667 let qualified_query = symbol.contains("::");
5668 let sql = if qualified_query {
5669 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5670 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5671 FROM nodes n JOIN files f ON f.path = n.file_path
5672 WHERE n.scoped_name = ?1
5673 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
5674 } else {
5675 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5676 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5677 FROM nodes n JOIN files f ON f.path = n.file_path
5678 WHERE n.scoped_name = ?1 OR n.name = ?1
5679 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
5680 };
5681 let mut stmt = conn.prepare(sql)?;
5682 let rows = stmt.query_map(params![symbol], store_node_from_row)?;
5683 rows.collect::<std::result::Result<Vec<_>, _>>()
5684 .map_err(Into::into)
5685}
5686
5687fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
5688 store_node_from_row_at(row, 0)
5689}
5690
5691fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
5692 let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
5693 let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
5694 let lang_label_value: String = row.get(offset + 10)?;
5695 Ok(StoreNode {
5696 node_id: row.get(offset)?,
5697 file: row.get(offset + 1)?,
5698 symbol: row.get(offset + 2)?,
5699 name: row.get(offset + 3)?,
5700 kind: row.get(offset + 4)?,
5701 line: start_line.saturating_add(1),
5702 end_line: end_line.saturating_add(1),
5703 signature: row.get(offset + 7)?,
5704 exported: row.get::<_, i64>(offset + 8)? != 0,
5705 is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
5706 lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
5707 })
5708}
5709
5710fn optional_store_node_from_row_at(
5711 row: &rusqlite::Row<'_>,
5712 offset: usize,
5713) -> rusqlite::Result<Option<StoreNode>> {
5714 if row.get::<_, Option<String>>(offset)?.is_some() {
5715 store_node_from_row_at(row, offset).map(Some)
5716 } else {
5717 Ok(None)
5718 }
5719}
5720
5721#[allow(clippy::too_many_arguments)]
5722fn collect_callers_recursive(
5723 conn: &Connection,
5724 file: &str,
5725 symbol: &str,
5726 max_depth: usize,
5727 current_depth: usize,
5728 visited: &mut HashSet<(String, String)>,
5729 result: &mut Vec<StoreCallSite>,
5730 depth_limited: &mut bool,
5731 truncated: &mut usize,
5732) -> Result<()> {
5733 if current_depth >= max_depth {
5734 let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
5735 if omitted > 0 {
5736 *depth_limited = true;
5737 *truncated += omitted;
5738 }
5739 return Ok(());
5740 }
5741
5742 if !visited.insert((file.to_string(), symbol.to_string())) {
5743 return Ok(());
5744 }
5745
5746 let sites = direct_callers_for_tuple(conn, file, symbol)?;
5747 for site in sites {
5748 result.push(site.clone());
5749 if current_depth + 1 < max_depth {
5750 collect_callers_recursive(
5751 conn,
5752 &site.caller.file,
5753 &site.caller.symbol,
5754 max_depth,
5755 current_depth + 1,
5756 visited,
5757 result,
5758 depth_limited,
5759 truncated,
5760 )?;
5761 } else {
5762 let omitted =
5763 direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
5764 if omitted > 0 {
5765 *depth_limited = true;
5766 *truncated += omitted;
5767 }
5768 }
5769 }
5770 Ok(())
5771}
5772
5773const DIRECT_CALLER_BATCH_SIZE: usize = 499;
5775
5776fn direct_caller_counts_for_tuples(
5777 conn: &Connection,
5778 targets: &[(String, String)],
5779) -> Result<HashMap<(String, String), usize>> {
5780 let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
5781 let mut counts = unique_targets
5782 .iter()
5783 .cloned()
5784 .map(|target| (target, 0usize))
5785 .collect::<HashMap<_, _>>();
5786
5787 let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
5788 for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
5789 let requested_values = (0..chunk.len())
5790 .map(|_| "(?, ?)")
5791 .collect::<Vec<_>>()
5792 .join(", ");
5793 let sql = format!(
5794 "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
5795 deduped AS (
5796 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
5797 FROM requested requested
5798 JOIN edges e
5799 ON e.target_file = requested.target_file
5800 AND e.target_symbol = requested.target_symbol
5801 AND e.kind = 'call'
5802 JOIN refs r ON r.ref_id = e.ref_id
5803 JOIN nodes src ON src.id = e.source_node
5804 JOIN files src_file ON src_file.path = src.file_path
5805 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
5806 )
5807 SELECT target_file, target_symbol, COUNT(*)
5808 FROM deduped
5809 GROUP BY target_file, target_symbol"
5810 );
5811 let bindings = chunk
5812 .iter()
5813 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5814 let mut stmt = conn.prepare(&sql)?;
5815 let rows = stmt.query_map(params_from_iter(bindings), |row| {
5816 Ok((
5817 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
5818 row.get::<_, i64>(2)?,
5819 ))
5820 })?;
5821 for row in rows {
5822 let (target, count) = row?;
5823 counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
5824 }
5825 }
5826
5827 Ok(counts)
5828}
5829
5830fn direct_caller_count_for_tuple(
5831 conn: &Connection,
5832 target_file: &str,
5833 target_symbol: &str,
5834) -> Result<usize> {
5835 let count: i64 = conn.query_row(
5836 "SELECT COUNT(*)
5837 FROM edges e
5838 JOIN refs r ON r.ref_id = e.ref_id
5839 JOIN nodes src ON src.id = e.source_node
5840 JOIN files src_file ON src_file.path = src.file_path
5841 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
5842 params![target_file, target_symbol],
5843 |row| row.get(0),
5844 )?;
5845 Ok(usize::try_from(count).unwrap_or(usize::MAX))
5846}
5847
5848fn direct_callers_for_tuple(
5849 conn: &Connection,
5850 target_file: &str,
5851 target_symbol: &str,
5852) -> Result<Vec<StoreCallSite>> {
5853 let mut stmt = conn.prepare(
5854 "SELECT e.target_file, e.target_symbol, e.line,
5855 r.byte_start, r.byte_end, r.status, e.provenance,
5856 src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5857 src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5858 src_file.lang,
5859 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5860 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5861 tgt_file.lang
5862 FROM edges e
5863 JOIN refs r ON r.ref_id = e.ref_id
5864 JOIN nodes src ON src.id = e.source_node
5865 JOIN files src_file ON src_file.path = src.file_path
5866 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5867 ON tgt.id = e.target_node
5868 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
5869 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
5870 )?;
5871 let rows = stmt.query_map(
5872 params![target_file, target_symbol],
5873 direct_call_site_from_row,
5874 )?;
5875 rows.collect::<std::result::Result<Vec<_>, _>>()
5876 .map_err(Into::into)
5877}
5878
5879fn direct_call_site_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreCallSite> {
5880 let caller = store_node_from_row_at(row, 7)?;
5881 let target = optional_store_node_from_row_at(row, 18)?;
5882 Ok(StoreCallSite {
5883 caller,
5884 target_file: row.get(0)?,
5885 target_symbol: row.get(1)?,
5886 target,
5887 line: row.get::<_, i64>(2)?.max(0) as u32,
5888 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5889 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5890 resolved: row.get::<_, String>(5)? == "resolved",
5891 provenance: row.get(6)?,
5892 })
5893}
5894
5895fn direct_callers_for_tuples(
5896 conn: &Connection,
5897 targets: &[(String, String)],
5898) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5899 let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
5900 let mut callers_by_target = unique_targets
5901 .iter()
5902 .cloned()
5903 .map(|target| (target, Vec::new()))
5904 .collect::<HashMap<_, _>>();
5905 let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
5906
5907 for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
5908 let requested_values = (0..chunk.len())
5909 .map(|_| "(?, ?)")
5910 .collect::<Vec<_>>()
5911 .join(", ");
5912 let sql = format!(
5913 "WITH requested(target_file, target_symbol) AS (VALUES {requested_values})
5914 SELECT e.target_file, e.target_symbol, e.line,
5915 r.byte_start, r.byte_end, r.status, e.provenance,
5916 src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5917 src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5918 src_file.lang,
5919 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5920 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5921 tgt_file.lang
5922 FROM requested requested
5923 JOIN edges e
5924 ON e.target_file = requested.target_file
5925 AND e.target_symbol = requested.target_symbol
5926 AND e.kind = 'call'
5927 JOIN refs r ON r.ref_id = e.ref_id
5928 JOIN nodes src ON src.id = e.source_node
5929 JOIN files src_file ON src_file.path = src.file_path
5930 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5931 ON tgt.id = e.target_node
5932 ORDER BY e.target_file, e.target_symbol, e.source_node,
5933 r.byte_start, r.line, r.ref_id"
5934 );
5935 let bindings = chunk
5936 .iter()
5937 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5938 let mut stmt = conn.prepare(&sql)?;
5939 let rows = stmt.query_map(params_from_iter(bindings), |row| {
5940 let call = direct_call_site_from_row(row)?;
5941 let target_key = (call.target_file.clone(), call.target_symbol.clone());
5942 Ok((target_key, call))
5943 })?;
5944 for row in rows {
5945 let (target, call) = row?;
5946 callers_by_target
5947 .get_mut(&target)
5948 .expect("batched caller row belongs to a requested target")
5949 .push(call);
5950 }
5951 }
5952
5953 Ok(callers_by_target)
5954}
5955
5956const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
5958const OUTGOING_NODE_BATCH_SIZE: usize = 999;
5960
5961fn outgoing_calls_for_symbol_tuples(
5962 conn: &Connection,
5963 sources: &[(String, String)],
5964) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5965 let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
5966 let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
5967 let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
5968 let source_nodes = unique_sources
5969 .iter()
5970 .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
5971 .cloned()
5972 .collect::<Vec<_>>();
5973 let source_nodes_by_id = source_nodes
5974 .iter()
5975 .cloned()
5976 .map(|node| (node.node_id.clone(), node))
5977 .collect::<HashMap<_, _>>();
5978 let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
5979
5980 for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
5981 let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
5982 let sql = format!(
5983 "SELECT e.source_node,
5984 e.target_file, e.target_symbol, e.line,
5985 r.byte_start, r.byte_end, r.status, e.provenance,
5986 CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
5987 tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5988 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5989 tgt_file.lang
5990 FROM edges e
5991 JOIN refs r ON r.ref_id = e.ref_id
5992 LEFT JOIN nodes tgt ON tgt.id = e.target_node
5993 LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
5994 WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
5995 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
5996 );
5997 let bindings = chunk.iter().map(|node| node.node_id.as_str());
5998 let mut stmt = conn.prepare(&sql)?;
5999 let rows = stmt.query_map(params_from_iter(bindings), |row| {
6000 let source_node_id = row.get::<_, String>(0)?;
6001 let caller = source_nodes_by_id
6002 .get(&source_node_id)
6003 .expect("batched outgoing row belongs to a requested source node")
6004 .clone();
6005 let target = optional_store_node_from_row_at(row, 8)?;
6006 Ok((
6007 source_node_id,
6008 StoreCallSite {
6009 caller,
6010 target_file: row.get(1)?,
6011 target_symbol: row.get(2)?,
6012 target,
6013 line: row.get::<_, i64>(3)?.max(0) as u32,
6014 byte_start: row.get::<_, i64>(4)?.max(0) as usize,
6015 byte_end: row.get::<_, i64>(5)?.max(0) as usize,
6016 resolved: row.get::<_, String>(6)? == "resolved",
6017 provenance: row.get(7)?,
6018 },
6019 ))
6020 })?;
6021 for row in rows {
6022 let (source_node_id, call) = row?;
6023 calls_by_node.entry(source_node_id).or_default().push(call);
6024 }
6025 }
6026
6027 let mut calls_by_source = HashMap::new();
6028 for source in &unique_sources {
6029 let mut calls = Vec::new();
6030 if let Some(nodes) = source_nodes_by_symbol.get(source) {
6031 for node in nodes {
6032 if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
6033 calls.extend(node_calls);
6034 }
6035 }
6036 }
6037 calls_by_source.insert(source.clone(), calls);
6038 }
6039
6040 let target_tuples = calls_by_source
6043 .values()
6044 .flatten()
6045 .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
6046 .collect::<Vec<_>>();
6047 let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
6048 for calls in calls_by_source.values_mut() {
6049 for call in calls {
6050 if let Some(target) = target_nodes
6051 .get(&(call.target_file.clone(), call.target_symbol.clone()))
6052 .and_then(|nodes| nodes.first())
6053 {
6054 call.target = Some(target.clone());
6055 }
6056 }
6057 }
6058
6059 Ok(calls_by_source)
6060}
6061
6062fn nodes_for_symbol_tuples(
6063 conn: &Connection,
6064 symbols: &[(String, String)],
6065) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
6066 let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
6067 let mut nodes_by_symbol = unique_symbols
6068 .iter()
6069 .cloned()
6070 .map(|symbol| (symbol, Vec::new()))
6071 .collect::<HashMap<_, _>>();
6072 let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
6073
6074 for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
6075 let requested_values = (0..chunk.len())
6076 .map(|_| "(?, ?)")
6077 .collect::<Vec<_>>()
6078 .join(", ");
6079 let sql = format!(
6080 "WITH requested(file, symbol) AS (VALUES {requested_values})
6081 SELECT requested.file, requested.symbol,
6082 node.id, node.file_path, node.scoped_name, node.name, node.kind,
6083 node.start_line, node.end_line, node.signature, node.exported,
6084 node.is_callgraph_entry_point, node_file.lang
6085 FROM requested
6086 JOIN nodes node INDEXED BY idx_nodes_file
6087 ON node.file_path = requested.file
6088 AND node.scoped_name = requested.symbol
6089 JOIN files node_file ON node_file.path = node.file_path
6090 ORDER BY requested.file, requested.symbol,
6091 node.scoped_name, node.start_line, node.end_line,
6092 node.start_col, node.range_ordinal"
6093 );
6094 let bindings = chunk
6095 .iter()
6096 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
6097 let mut stmt = conn.prepare(&sql)?;
6098 let rows = stmt.query_map(params_from_iter(bindings), |row| {
6099 Ok((
6100 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
6101 store_node_from_row_at(row, 2)?,
6102 ))
6103 })?;
6104 for row in rows {
6105 let (symbol, node) = row?;
6106 nodes_by_symbol.entry(symbol).or_default().push(node);
6107 }
6108 }
6109
6110 Ok(nodes_by_symbol)
6111}
6112
6113fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
6114 let mut stmt = conn.prepare(
6115 "SELECT e.target_file, e.target_symbol, e.line,
6116 r.byte_start, r.byte_end, r.status, e.provenance,
6117 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
6118 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
6119 tgt_file.lang
6120 FROM edges e
6121 JOIN refs r ON r.ref_id = e.ref_id
6122 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
6123 ON tgt.id = e.target_node
6124 WHERE e.kind = 'call' AND e.source_node = ?1
6125 ORDER BY r.byte_start, r.line, r.ref_id",
6126 )?;
6127 let rows = stmt.query_map(params![node.node_id], |row| {
6128 let target = optional_store_node_from_row_at(row, 7)?;
6129 Ok(StoreCallSite {
6130 caller: node.clone(),
6131 target_file: row.get(0)?,
6132 target_symbol: row.get(1)?,
6133 target,
6134 line: row.get::<_, i64>(2)?.max(0) as u32,
6135 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
6136 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
6137 resolved: row.get::<_, String>(5)? == "resolved",
6138 provenance: row.get(6)?,
6139 })
6140 })?;
6141 rows.collect::<std::result::Result<Vec<_>, _>>()
6142 .map_err(Into::into)
6143}
6144
6145fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
6146 let mut stmt = conn.prepare(
6147 "SELECT r.target_file, r.target_symbol, r.line,
6148 r.byte_start, r.byte_end, r.status, r.provenance,
6149 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
6150 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
6151 tgt_file.lang
6152 FROM refs r
6153 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
6154 ON tgt.id = r.target_node
6155 WHERE r.caller_node = ?1
6156 AND r.kind = 'call'
6157 AND r.status <> 'unresolved'
6158 AND r.target_file = ?2
6159 AND r.target_symbol = ?3
6160 AND r.provenance = ?4
6161 AND NOT EXISTS (
6162 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
6163 )
6164 ORDER BY r.byte_start, r.line, r.ref_id",
6165 )?;
6166 let rows = stmt.query_map(
6167 params![
6168 &node.node_id,
6169 &node.file,
6170 &node.symbol,
6171 PROVENANCE_TREESITTER
6172 ],
6173 |row| {
6174 let target = optional_store_node_from_row_at(row, 7)?;
6175 Ok(StoreCallSite {
6176 caller: node.clone(),
6177 target_file: row.get(0)?,
6178 target_symbol: row.get(1)?,
6179 target,
6180 line: row.get::<_, i64>(2)?.max(0) as u32,
6181 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
6182 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
6183 resolved: row.get::<_, String>(5)? == "resolved",
6184 provenance: row.get(6)?,
6185 })
6186 },
6187 )?;
6188 rows.collect::<std::result::Result<Vec<_>, _>>()
6189 .map_err(Into::into)
6190}
6191
6192fn unresolved_calls_for_node(
6193 conn: &Connection,
6194 node: &StoreNode,
6195) -> Result<Vec<StoreUnresolvedCall>> {
6196 let mut stmt = conn.prepare(
6197 "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
6198 FROM refs
6199 WHERE caller_node = ?1
6200 AND kind = 'call'
6201 AND status = 'unresolved'
6202 AND NOT EXISTS (
6203 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
6204 )
6205 ORDER BY byte_start, line, ref_id",
6206 )?;
6207 let rows = stmt.query_map(params![node.node_id], |row| {
6208 Ok(StoreUnresolvedCall {
6209 caller: node.clone(),
6210 symbol: row.get(0)?,
6211 full_ref: row.get(1)?,
6212 line: row.get::<_, i64>(2)?.max(0) as u32,
6213 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
6214 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
6215 })
6216 })?;
6217 rows.collect::<std::result::Result<Vec<_>, _>>()
6218 .map_err(Into::into)
6219}
6220
6221fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
6222 let mut calls = Vec::new();
6223 calls.extend(
6224 outgoing_calls_for_node(conn, node)?
6225 .into_iter()
6226 .map(StoreForwardCall::Resolved),
6227 );
6228 calls.extend(
6229 unresolved_calls_for_node(conn, node)?
6230 .into_iter()
6231 .map(StoreForwardCall::Unresolved),
6232 );
6233 calls.sort_by(|left, right| {
6234 left.byte_start()
6235 .cmp(&right.byte_start())
6236 .then(left.line().cmp(&right.line()))
6237 });
6238 Ok(calls)
6239}
6240
6241fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
6242 let resolved_count: i64 = conn.query_row(
6243 "SELECT COUNT(*)
6244 FROM edges e
6245 JOIN refs r ON r.ref_id = e.ref_id
6246 WHERE e.kind = 'call' AND e.source_node = ?1",
6247 params![&node.node_id],
6248 |row| row.get(0),
6249 )?;
6250 let unresolved_count: i64 = conn.query_row(
6251 "SELECT COUNT(*)
6252 FROM refs
6253 WHERE caller_node = ?1
6254 AND kind = 'call'
6255 AND status = 'unresolved'
6256 AND NOT EXISTS (
6257 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
6258 )",
6259 params![&node.node_id],
6260 |row| row.get(0),
6261 )?;
6262 let total = resolved_count.saturating_add(unresolved_count);
6263 Ok(usize::try_from(total).unwrap_or(usize::MAX))
6264}
6265
6266fn call_tree_inner(
6267 conn: &Connection,
6268 node: &StoreNode,
6269 max_depth: usize,
6270 current_depth: usize,
6271 visited: &mut HashSet<(String, String)>,
6272) -> Result<callgraph::CallTreeNode> {
6273 let visit_key = (node.file.clone(), node.symbol.clone());
6274 if visited.contains(&visit_key) {
6275 return Ok(callgraph::CallTreeNode {
6276 name: node.symbol.clone(),
6277 file: node.file.clone(),
6278 line: node.line,
6279 signature: node.signature.clone(),
6280 resolved: true,
6281 children: Vec::new(),
6282 depth_limited: false,
6283 truncated: 0,
6284 });
6285 }
6286 visited.insert(visit_key.clone());
6287
6288 let mut children = Vec::new();
6289 let mut depth_limited = false;
6290 let mut truncated = 0usize;
6291
6292 if current_depth < max_depth {
6293 let calls = forward_calls_for_node(conn, node)?;
6294 for call in calls {
6295 match call {
6296 StoreForwardCall::Resolved(site) => {
6297 if let Some(target) = site.target {
6298 let child =
6299 call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
6300 depth_limited |= child.depth_limited;
6301 truncated += child.truncated;
6302 children.push(child);
6303 } else {
6304 children.push(callgraph::CallTreeNode {
6305 name: site.target_symbol,
6306 file: site.target_file,
6307 line: site.line,
6308 signature: None,
6309 resolved: false,
6310 children: Vec::new(),
6311 depth_limited: false,
6312 truncated: 0,
6313 });
6314 }
6315 }
6316 StoreForwardCall::Unresolved(call) => {
6317 children.push(callgraph::CallTreeNode {
6318 name: call.symbol,
6319 file: call.caller.file,
6320 line: call.line,
6321 signature: None,
6322 resolved: false,
6323 children: Vec::new(),
6324 depth_limited: false,
6325 truncated: 0,
6326 });
6327 }
6328 }
6329 }
6330 } else {
6331 truncated = forward_call_count_for_node(conn, node)?;
6332 depth_limited = truncated > 0;
6333 }
6334
6335 visited.remove(&visit_key);
6336 Ok(callgraph::CallTreeNode {
6337 name: node.symbol.clone(),
6338 file: node.file.clone(),
6339 line: node.line,
6340 signature: node.signature.clone(),
6341 resolved: true,
6342 children,
6343 depth_limited,
6344 truncated,
6345 })
6346}
6347
6348fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
6349 callgraph::TraceToSymbolHop {
6350 symbol: node.symbol.clone(),
6351 file: node.file.clone(),
6352 line: node.line,
6353 }
6354}
6355
6356fn trace_to_symbol_matches_target(
6357 node: &StoreNode,
6358 to_symbol: &str,
6359 to_file: Option<&str>,
6360) -> bool {
6361 if !symbol_query_matches(&node.symbol, to_symbol) {
6362 return false;
6363 }
6364 match to_file {
6365 Some(file) => node.file == file,
6366 None => true,
6367 }
6368}
6369
6370fn symbol_query_matches(symbol: &str, query: &str) -> bool {
6371 symbol == query || unqualified_name(symbol) == query
6372}
6373
6374fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
6375 let source = std::fs::read_to_string(path).ok()?;
6376 Some(source.lines().map(|line| line.trim().to_string()).collect())
6377}
6378
6379#[doc(hidden)]
6380pub fn live_callgraph_edge_snapshot(
6381 project_root: &Path,
6382 files: &[PathBuf],
6383) -> Result<BTreeSet<StoredEdge>> {
6384 let files = normalize_file_list(project_root, files)?;
6385 let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
6386 let mut file_data = Vec::new();
6387 for file in &files {
6388 let canon = canonicalize_path(file);
6389 let data = graph.build_file(&canon)?.clone();
6390 file_data.push((canon, data));
6391 }
6392
6393 let mut edges = BTreeSet::new();
6394 for (caller_file, data) in &file_data {
6395 for (caller_symbol, call_sites) in &data.calls_by_symbol {
6396 for call_site in call_sites {
6397 let resolution = graph.resolve_cross_file_edge(
6398 &call_site.full_callee,
6399 &call_site.callee_name,
6400 caller_file,
6401 &data.import_block,
6402 );
6403 let (target_file, target_symbol) = match resolution {
6404 EdgeResolution::Resolved { file, symbol } => (file, symbol),
6405 EdgeResolution::Unresolved { callee_name } => {
6406 if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
6407 continue;
6408 }
6409 let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
6410 data,
6411 caller_file,
6412 &callee_name,
6413 ) else {
6414 continue;
6415 };
6416 (caller_file.clone(), target_symbol)
6417 }
6418 };
6419 if target_file == *caller_file && target_symbol == *caller_symbol {
6420 continue;
6421 }
6422 edges.insert(StoredEdge {
6423 source_file: relative_path(project_root, caller_file),
6424 source_symbol: caller_symbol.clone(),
6425 target_file: relative_path(project_root, &target_file),
6426 target_symbol,
6427 kind: "call".to_string(),
6428 line: call_site.line,
6429 });
6430 }
6431 }
6432 }
6433 Ok(edges)
6434}
6435
6436fn rebuild_cooldown_records() -> &'static Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>
6437{
6438 SUCCESSFUL_REBUILDS.get_or_init(|| Mutex::new(HashMap::new()))
6439}
6440
6441fn rebuild_cooldown_key(callgraph_dir: &Path, project_key: &str) -> RebuildCooldownKey {
6442 RebuildCooldownKey {
6443 callgraph_dir: std::fs::canonicalize(callgraph_dir)
6444 .unwrap_or_else(|_| callgraph_dir.to_path_buf()),
6445 project_key: project_key.to_string(),
6446 }
6447}
6448
6449fn rebuild_cooldown_denial(
6450 callgraph_dir: &Path,
6451 project_key: &str,
6452 project_root: &Path,
6453 now: Instant,
6454) -> Option<(PathBuf, Duration)> {
6455 let key = rebuild_cooldown_key(callgraph_dir, project_key);
6456 let records = rebuild_cooldown_records()
6457 .lock()
6458 .unwrap_or_else(std::sync::PoisonError::into_inner);
6459 let record = records.get(&key)?;
6460 if record.project_root == project_root || !record.cross_root_cooldown_armed {
6461 return None;
6462 }
6463 let elapsed = now.saturating_duration_since(record.published_at);
6464 (elapsed < REBUILD_COOLDOWN).then(|| (record.project_root.clone(), REBUILD_COOLDOWN - elapsed))
6465}
6466
6467fn record_successful_rebuild(
6468 callgraph_dir: &Path,
6469 project_key: &str,
6470 project_root: &Path,
6471 published_at: Instant,
6472) {
6473 let key = rebuild_cooldown_key(callgraph_dir, project_key);
6474 let mut records = rebuild_cooldown_records()
6475 .lock()
6476 .unwrap_or_else(std::sync::PoisonError::into_inner);
6477 if records.len() >= 4_096 && !records.contains_key(&key) {
6478 if let Some(evict) = records.keys().next().cloned() {
6479 records.remove(&evict);
6480 }
6481 }
6482 let cross_root_cooldown_armed = records.get(&key).is_some_and(|previous| {
6483 previous.cross_root_cooldown_armed || previous.project_root != project_root
6484 });
6485 records.insert(
6486 key,
6487 RebuildCooldownRecord {
6488 project_root: project_root.to_path_buf(),
6489 published_at,
6490 cross_root_cooldown_armed,
6491 },
6492 );
6493}
6494
6495fn acquire_writer_lease(
6496 callgraph_dir: &Path,
6497 project_key: &str,
6498 project_root: &Path,
6499) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
6500 crate::root_cache::WriterLease::acquire_shared(
6501 crate::root_cache::RootCacheDomain::Callgraph,
6502 callgraph_dir,
6503 project_key,
6504 project_root,
6505 )
6506 .map_err(CallGraphStoreError::from)
6507}
6508
6509fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
6510 if lease.verify()? {
6511 Ok(())
6512 } else {
6513 Err(CallGraphStoreError::Unavailable(format!(
6514 "callgraph writer lease for key {} lost epoch {}; aborting write",
6515 lease.key(),
6516 lease.epoch()
6517 )))
6518 }
6519}
6520
6521fn legacy_migration_completion_line(
6522 project_key: &str,
6523 method: &str,
6524 legacy_bytes: u64,
6525 migrated_bytes: u64,
6526) -> String {
6527 format!(
6528 "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
6529 )
6530}
6531
6532fn log_legacy_migration_completion(
6533 project_key: &str,
6534 method: &str,
6535 legacy_bytes: u64,
6536 migrated_bytes: u64,
6537) {
6538 crate::slog_info!(
6539 "{}",
6540 legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
6541 );
6542}
6543
6544fn try_legacy_migration_or_fallback(
6545 callgraph_dir: &Path,
6546 project_root: &Path,
6547 project_key: &str,
6548 writer_lease: Arc<crate::root_cache::WriterLease>,
6549) -> Result<Option<CallGraphStore>> {
6550 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
6551 if partitions.is_empty() {
6552 return Ok(None);
6553 }
6554
6555 for partition in &partitions {
6556 if let Some(source) = newest_superseded_legacy_generation(partition)? {
6557 if !migration_disk_floor_allows(&source, callgraph_dir)? {
6558 return open_legacy_fallback_store(
6559 callgraph_dir,
6560 project_root,
6561 project_key,
6562 &partitions,
6563 );
6564 }
6565 match publish_generation_copy_migration(
6566 callgraph_dir,
6567 project_key,
6568 &source,
6569 Arc::clone(&writer_lease),
6570 ) {
6571 Ok(published) => {
6572 log_legacy_migration_completion(
6573 project_key,
6574 "generation_copy",
6575 source.source_bytes,
6576 published.migrated_bytes,
6577 );
6578 return CallGraphStore::open_generation(
6579 callgraph_dir,
6580 project_root.to_path_buf(),
6581 project_key.to_string(),
6582 published.generation,
6583 writer_lease,
6584 )
6585 .map(Some);
6586 }
6587 Err(error) => {
6588 crate::slog_warn!(
6589 "root-keyed callgraph generation-copy migration failed from {}: {}",
6590 source.sqlite_path.display(),
6591 error
6592 );
6593 return open_legacy_fallback_store(
6594 callgraph_dir,
6595 project_root,
6596 project_key,
6597 &partitions,
6598 );
6599 }
6600 }
6601 }
6602
6603 if let Some(source) = current_legacy_generation(partition)? {
6604 if !migration_disk_floor_allows(&source, callgraph_dir)? {
6605 return open_legacy_fallback_store(
6606 callgraph_dir,
6607 project_root,
6608 project_key,
6609 &partitions,
6610 );
6611 }
6612 match publish_backup_migration(
6613 callgraph_dir,
6614 project_key,
6615 &source,
6616 Arc::clone(&writer_lease),
6617 ) {
6618 Ok(published) => {
6619 log_legacy_migration_completion(
6620 project_key,
6621 "sqlite_backup",
6622 source.source_bytes,
6623 published.migrated_bytes,
6624 );
6625 return CallGraphStore::open_generation(
6626 callgraph_dir,
6627 project_root.to_path_buf(),
6628 project_key.to_string(),
6629 published.generation,
6630 writer_lease,
6631 )
6632 .map(Some);
6633 }
6634 Err(error) => {
6635 crate::slog_warn!(
6636 "root-keyed callgraph backup migration failed from {}: {}",
6637 source.sqlite_path.display(),
6638 error
6639 );
6640 return open_legacy_fallback_store(
6641 callgraph_dir,
6642 project_root,
6643 project_key,
6644 &partitions,
6645 );
6646 }
6647 }
6648 }
6649 }
6650
6651 open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
6652}
6653
6654fn open_legacy_fallback_store(
6655 callgraph_dir: &Path,
6656 project_root: &Path,
6657 project_key: &str,
6658 partitions: &[LegacyCallgraphPartition],
6659) -> Result<Option<CallGraphStore>> {
6660 let Some(target) = first_ready_legacy_target(partitions)? else {
6661 return Ok(None);
6662 };
6663 crate::slog_warn!(
6664 "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
6665 target.partition.harness,
6666 target.sqlite_path.display()
6667 );
6668 let conn = open_readonly_connection(&target.sqlite_path)?;
6669 if !database_ready(&conn).unwrap_or(false) {
6670 return Ok(None);
6671 }
6672 let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
6673 let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
6674 Ok(Some(CallGraphStore::from_connection(
6675 project_root.to_path_buf(),
6676 project_key.to_string(),
6677 target.sqlite_path,
6678 callgraph_dir.to_path_buf(),
6679 true,
6680 target.generation,
6681 None,
6682 Some(read_marker),
6683 conn,
6684 )))
6685}
6686
6687fn migration_disk_floor_allows(
6688 source: &LegacyCallgraphTarget,
6689 callgraph_dir: &Path,
6690) -> Result<bool> {
6691 let available = migration_available_disk(callgraph_dir)?;
6692 let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
6693 source.source_bytes,
6694 available,
6695 );
6696 if decision.should_skip_copy() {
6697 crate::slog_warn!(
6698 "{}",
6699 decision.warning_message(&source.sqlite_path, callgraph_dir)
6700 );
6701 return Ok(false);
6702 }
6703 Ok(true)
6704}
6705
6706fn migration_available_disk(path: &Path) -> Result<u64> {
6707 if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
6708 return Ok(bytes);
6709 }
6710 crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
6711}
6712
6713fn legacy_callgraph_partitions(
6714 callgraph_dir: &Path,
6715 project_key: &str,
6716) -> Result<Vec<LegacyCallgraphPartition>> {
6717 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6718 return Ok(Vec::new());
6719 };
6720 let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
6721 let mut partitions = inventory
6722 .into_iter()
6723 .filter(|entry| {
6724 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
6725 && entry.key == project_key
6726 })
6727 .map(|entry| {
6728 let dir = if entry.path.is_dir() {
6729 entry.path.clone()
6730 } else {
6731 entry
6732 .path
6733 .parent()
6734 .map(Path::to_path_buf)
6735 .unwrap_or_else(|| entry.path.clone())
6736 };
6737 LegacyCallgraphPartition {
6738 harness: entry.harness,
6739 dir,
6740 key: entry.key,
6741 bytes: entry.bytes,
6742 freshness: entry.callgraph_pointer_mtime,
6743 }
6744 })
6745 .collect::<Vec<_>>();
6746 partitions.sort_by(|left, right| {
6747 right
6748 .freshness
6749 .cmp(&left.freshness)
6750 .then_with(|| right.bytes.cmp(&left.bytes))
6751 .then_with(|| left.harness.cmp(&right.harness))
6752 });
6753 Ok(partitions)
6754}
6755
6756fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
6757 let domain_dir = callgraph_dir.parent()?;
6758 if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
6759 return None;
6760 }
6761 domain_dir.parent().map(Path::to_path_buf)
6762}
6763
6764pub(crate) fn all_legacy_partitions_migrated_for_keys(
6765 callgraph_dir: &Path,
6766 configured_keys: &BTreeSet<String>,
6767) -> Result<bool> {
6768 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6769 return Ok(false);
6770 };
6771 let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
6772 .into_iter()
6773 .filter(|entry| {
6774 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
6775 && configured_keys.contains(&entry.key)
6776 })
6777 .map(|entry| entry.key)
6778 .collect::<BTreeSet<_>>();
6779 if legacy_keys.is_empty() {
6780 return Ok(false);
6781 }
6782
6783 for key in legacy_keys {
6784 let migrated_dir = storage_root.join("callgraph").join(&key);
6785 let Some(generation) = read_pointer(&migrated_dir, &key) else {
6786 return Ok(false);
6787 };
6788 if !migration_generation_requires_manifest(&generation)
6789 || !migration_manifest_valid(&migrated_dir, &generation)
6790 {
6791 return Ok(false);
6792 }
6793 }
6794 Ok(true)
6795}
6796
6797fn newest_superseded_legacy_generation(
6798 partition: &LegacyCallgraphPartition,
6799) -> Result<Option<LegacyCallgraphTarget>> {
6800 let Some(current) = read_pointer(&partition.dir, &partition.key) else {
6801 return Ok(None);
6802 };
6803 let prefix = format!("{}.g", partition.key);
6804 let Ok(entries) = std::fs::read_dir(&partition.dir) else {
6805 return Ok(None);
6806 };
6807 let mut candidates = Vec::new();
6808 for entry in entries.flatten() {
6809 let name = entry.file_name().to_string_lossy().to_string();
6810 if name == current
6811 || name.contains(".tmp.")
6812 || !name.starts_with(&prefix)
6813 || !name.ends_with(".sqlite")
6814 {
6815 continue;
6816 }
6817 let path = entry.path();
6818 if !db_path_ready(&path) {
6819 continue;
6820 }
6821 let modified = entry
6822 .metadata()
6823 .and_then(|metadata| metadata.modified())
6824 .unwrap_or(SystemTime::UNIX_EPOCH);
6825 candidates.push((modified, path, name));
6826 }
6827 candidates.sort_by(|left, right| right.0.cmp(&left.0));
6828 let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
6829 return Ok(None);
6830 };
6831 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6832 Ok(Some(LegacyCallgraphTarget {
6833 partition: partition.clone(),
6834 sqlite_path,
6835 generation: Some(generation),
6836 source_bytes,
6837 source_blake3: String::new(),
6838 }))
6839}
6840
6841fn current_legacy_generation(
6842 partition: &LegacyCallgraphPartition,
6843) -> Result<Option<LegacyCallgraphTarget>> {
6844 let Some(target) = ready_legacy_target(partition)? else {
6845 return Ok(None);
6846 };
6847 let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
6848 if has_superseded {
6849 return Ok(None);
6850 }
6851 Ok(Some(target))
6852}
6853
6854fn freshest_legacy_fallback_target(
6855 callgraph_dir: &Path,
6856 project_key: &str,
6857) -> Result<Option<LegacyCallgraphTarget>> {
6858 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
6859 first_ready_legacy_target(&partitions)
6860}
6861
6862fn first_ready_legacy_target(
6863 partitions: &[LegacyCallgraphPartition],
6864) -> Result<Option<LegacyCallgraphTarget>> {
6865 for partition in partitions {
6866 if let Some(target) = ready_legacy_target(partition)? {
6867 return Ok(Some(target));
6868 }
6869 }
6870 Ok(None)
6871}
6872
6873fn ready_legacy_target(
6874 partition: &LegacyCallgraphPartition,
6875) -> Result<Option<LegacyCallgraphTarget>> {
6876 if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
6877 let sqlite_path = partition.dir.join(&generation);
6878 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6879 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6880 return Ok(Some(LegacyCallgraphTarget {
6881 partition: partition.clone(),
6882 sqlite_path,
6883 generation: Some(generation),
6884 source_bytes,
6885 source_blake3: String::new(),
6886 }));
6887 }
6888 }
6889
6890 let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
6891 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6892 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6893 return Ok(Some(LegacyCallgraphTarget {
6894 partition: partition.clone(),
6895 sqlite_path,
6896 generation: None,
6897 source_bytes,
6898 source_blake3: String::new(),
6899 }));
6900 }
6901 Ok(None)
6902}
6903
6904fn publish_generation_copy_migration(
6905 callgraph_dir: &Path,
6906 project_key: &str,
6907 source: &LegacyCallgraphTarget,
6908 writer_lease: Arc<crate::root_cache::WriterLease>,
6909) -> Result<PublishedLegacyMigration> {
6910 let generation = migration_generation_file_name(project_key, "copy");
6911 let temp_path = migration_temp_path(callgraph_dir, &generation);
6912 remove_sqlite_file_set(&temp_path);
6913 copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
6914 fail_after_temp_copy_for_test()?;
6915
6916 let mut source = source.clone();
6917 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
6918 source.source_blake3 = fingerprint.blake3;
6919 let generation = publish_migrated_generation(
6920 callgraph_dir,
6921 project_key,
6922 &generation,
6923 &temp_path,
6924 &source,
6925 fingerprint.bytes,
6926 writer_lease,
6927 "generation_copy",
6928 )?;
6929 Ok(PublishedLegacyMigration {
6930 generation,
6931 migrated_bytes: fingerprint.bytes,
6932 })
6933}
6934
6935fn publish_backup_migration(
6936 callgraph_dir: &Path,
6937 project_key: &str,
6938 source: &LegacyCallgraphTarget,
6939 writer_lease: Arc<crate::root_cache::WriterLease>,
6940) -> Result<PublishedLegacyMigration> {
6941 if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
6942 return Err(CallGraphStoreError::Unavailable(
6943 "legacy callgraph backup migration budget exhausted by test seam".to_string(),
6944 ));
6945 }
6946
6947 let generation = migration_generation_file_name(project_key, "backup");
6948 let temp_path = migration_temp_path(callgraph_dir, &generation);
6949 remove_sqlite_file_set(&temp_path);
6950
6951 let source_conn = open_readonly_connection(&source.sqlite_path)?;
6952 let mut destination = TrackedConnection::open(&temp_path, SqliteStore::CallgraphGeneration)?;
6953 destination.busy_timeout(Duration::from_secs(5))?;
6954 let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
6955 let started = Instant::now();
6956 let mut retries = 0;
6957 loop {
6958 match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
6959 rusqlite::backup::StepResult::Done => break,
6960 rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
6961 rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
6962 retries += 1;
6963 if retries > MIGRATION_BACKUP_RETRY_BUDGET
6964 || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
6965 {
6966 return Err(CallGraphStoreError::Unavailable(format!(
6967 "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
6968 )));
6969 }
6970 std::thread::sleep(Duration::from_millis(20));
6971 }
6972 _ => {
6973 return Err(CallGraphStoreError::Unavailable(
6974 "legacy callgraph backup returned an unknown step result".to_string(),
6975 ));
6976 }
6977 }
6978 }
6979 drop(backup);
6980
6981 let integrity: String =
6982 destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
6983 if integrity != "ok" {
6984 return Err(CallGraphStoreError::Unavailable(format!(
6985 "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
6986 )));
6987 }
6988 if !database_ready(&destination)? {
6989 return Err(CallGraphStoreError::Unavailable(
6990 "legacy callgraph backup produced a database without ready metadata".to_string(),
6991 ));
6992 }
6993 destination.execute_batch("PRAGMA optimize;")?;
6994 drop(destination);
6995 sync_file(&temp_path)?;
6996 fail_after_temp_copy_for_test()?;
6997
6998 let mut source = source.clone();
6999 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
7000 source.source_blake3 = fingerprint.blake3;
7001 let generation = publish_migrated_generation(
7002 callgraph_dir,
7003 project_key,
7004 &generation,
7005 &temp_path,
7006 &source,
7007 fingerprint.bytes,
7008 writer_lease,
7009 "sqlite_backup",
7010 )?;
7011 Ok(PublishedLegacyMigration {
7012 generation,
7013 migrated_bytes: fingerprint.bytes,
7014 })
7015}
7016
7017fn publish_migrated_generation(
7018 callgraph_dir: &Path,
7019 project_key: &str,
7020 generation: &str,
7021 temp_path: &Path,
7022 source: &LegacyCallgraphTarget,
7023 migrated_bytes: u64,
7024 writer_lease: Arc<crate::root_cache::WriterLease>,
7025 method: &str,
7026) -> Result<String> {
7027 let gen_path = callgraph_dir.join(generation);
7028 checkpoint_sqlite_before_publication(temp_path);
7029 let publication = publish_if_current(|| {
7030 verify_writer_lease(&writer_lease)?;
7031 remove_sqlite_file_set(&gen_path);
7032 rename_sqlite_file_set(temp_path, &gen_path)?;
7033 crate::fs_lock::sync_parent(&gen_path);
7034
7035 verify_writer_lease(&writer_lease)?;
7036 publish_pointer(callgraph_dir, project_key, generation)?;
7037 write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
7038 Ok(generation.to_string())
7039 });
7040 if matches!(publication, Err(CallGraphStoreError::Superseded)) {
7041 remove_sqlite_file_set(temp_path);
7042 }
7043 publication
7044}
7045
7046fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
7047 if let Some(parent) = destination.parent() {
7048 std::fs::create_dir_all(parent)?;
7049 }
7050 for suffix in SQLITE_FILE_SET_SUFFIXES {
7051 let source_path = sqlite_file_set_path(source, suffix);
7052 if !source_path.is_file() {
7053 continue;
7054 }
7055 let destination_path = sqlite_file_set_path(destination, suffix);
7056 std::fs::copy(&source_path, &destination_path)?;
7057 sync_file(&destination_path)?;
7058 }
7059 Ok(())
7060}
7061
7062fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
7063 for suffix in SQLITE_FILE_SET_SUFFIXES {
7064 let source_path = sqlite_file_set_path(source, suffix);
7065 if !source_path.exists() {
7066 continue;
7067 }
7068 let destination_path = sqlite_file_set_path(destination, suffix);
7069 if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
7070 let _ = std::fs::remove_file(&source_path);
7071 return Err(error.into());
7072 }
7073 }
7074 Ok(())
7075}
7076
7077fn sqlite_file_set_size(path: &Path) -> Result<u64> {
7078 let mut bytes = 0_u64;
7079 for suffix in SQLITE_FILE_SET_SUFFIXES {
7080 let member = sqlite_file_set_path(path, suffix);
7081 if !member.is_file() {
7082 continue;
7083 }
7084 bytes = bytes.saturating_add(member.metadata()?.len());
7085 }
7086 Ok(bytes)
7087}
7088
7089fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
7090 let mut hasher = blake3::Hasher::new();
7091 let mut bytes = 0_u64;
7092 let mut buffer = [0_u8; 64 * 1024];
7093 for suffix in SQLITE_FILE_SET_SUFFIXES {
7094 let member = sqlite_file_set_path(path, suffix);
7095 if !member.is_file() {
7096 continue;
7097 }
7098 hasher.update(suffix.as_bytes());
7099 let mut file = std::fs::File::open(&member)?;
7100 loop {
7101 let read = file.read(&mut buffer)?;
7102 if read == 0 {
7103 break;
7104 }
7105 bytes = bytes.saturating_add(read as u64);
7106 hasher.update(&buffer[..read]);
7107 }
7108 }
7109 Ok(SourceFingerprint {
7110 bytes,
7111 blake3: hash_to_hex(hasher.finalize()),
7112 })
7113}
7114
7115fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
7116 if suffix.is_empty() {
7117 path.to_path_buf()
7118 } else {
7119 PathBuf::from(format!("{}{suffix}", path.display()))
7120 }
7121}
7122
7123fn sync_file(path: &Path) -> Result<()> {
7124 let file = std::fs::OpenOptions::new()
7125 .read(true)
7126 .write(true)
7127 .open(path)?;
7128 file.sync_all()?;
7129 Ok(())
7130}
7131
7132fn fail_after_temp_copy_for_test() -> Result<()> {
7133 if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
7134 return Err(CallGraphStoreError::Unavailable(
7135 "legacy callgraph migration stopped after temp copy by test seam".to_string(),
7136 ));
7137 }
7138 Ok(())
7139}
7140
7141fn migration_generation_file_name(project_key: &str, method: &str) -> String {
7142 format!(
7143 "{project_key}.g{}.{}{}{}.sqlite",
7144 now_nanos(),
7145 std::process::id(),
7146 MIGRATION_GENERATION_TAG,
7147 method
7148 )
7149}
7150
7151fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
7152 callgraph_dir.join(format!(
7153 "{generation}.tmp.{}.{}",
7154 std::process::id(),
7155 now_nanos()
7156 ))
7157}
7158
7159fn write_migration_manifest(
7160 callgraph_dir: &Path,
7161 generation: &str,
7162 source: &LegacyCallgraphTarget,
7163 migrated_bytes: u64,
7164 method: &str,
7165) -> Result<()> {
7166 let manifest_path = migration_manifest_path(callgraph_dir, generation);
7167 let temp_path = manifest_path.with_extension(format!(
7168 "migration.json.tmp.{}.{}",
7169 std::process::id(),
7170 now_nanos()
7171 ));
7172 let manifest = serde_json::json!({
7173 "version": MIGRATION_MANIFEST_VERSION,
7174 "method": method,
7175 "target_generation": generation,
7176 "source_harness": source.partition.harness,
7177 "source_path": source.sqlite_path.display().to_string(),
7178 "source_generation": source.generation,
7179 "source_bytes": source.source_bytes,
7180 "source_blake3": source.source_blake3,
7181 "migrated_bytes": migrated_bytes,
7182 });
7183 {
7184 use std::io::Write as _;
7185 let mut file = std::fs::File::create(&temp_path)?;
7186 file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
7187 file.write_all(b"\n")?;
7188 file.sync_all()?;
7189 }
7190 if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
7191 let _ = std::fs::remove_file(&temp_path);
7192 return Err(error.into());
7193 }
7194 crate::fs_lock::sync_parent(&manifest_path);
7195 Ok(())
7196}
7197
7198fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
7199 callgraph_dir.join(format!("{generation}.migration.json"))
7200}
7201
7202fn migration_generation_requires_manifest(generation: &str) -> bool {
7203 generation.contains(MIGRATION_GENERATION_TAG)
7204}
7205
7206fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
7207 if !migration_generation_requires_manifest(generation) {
7208 return true;
7209 }
7210 let path = migration_manifest_path(callgraph_dir, generation);
7211 let Ok(bytes) = std::fs::read(path) else {
7212 return false;
7213 };
7214 let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
7215 return false;
7216 };
7217 value.get("version").and_then(serde_json::Value::as_u64)
7218 == Some(MIGRATION_MANIFEST_VERSION as u64)
7219 && value
7220 .get("target_generation")
7221 .and_then(serde_json::Value::as_str)
7222 == Some(generation)
7223 && value
7224 .get("source_bytes")
7225 .and_then(serde_json::Value::as_u64)
7226 .is_some_and(|bytes| bytes > 0)
7227 && value
7228 .get("source_blake3")
7229 .and_then(serde_json::Value::as_str)
7230 .is_some_and(|hash| hash.len() == 64)
7231}
7232
7233fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
7234 let pointer_generation = read_pointer(callgraph_dir, project_key);
7235 if let Some(generation) = pointer_generation.as_deref() {
7236 if migration_generation_requires_manifest(generation)
7237 && !migration_manifest_valid(callgraph_dir, generation)
7238 {
7239 let path = callgraph_dir.join(generation);
7240 remove_sqlite_file_set(&path);
7241 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
7242 let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
7243 }
7244 }
7245
7246 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
7247 return;
7248 };
7249 for entry in entries.flatten() {
7250 let name = entry.file_name().to_string_lossy().to_string();
7251 let path = entry.path();
7252 if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
7253 let _ = std::fs::remove_file(path);
7254 continue;
7255 }
7256 if name.starts_with(&format!("{project_key}.g"))
7257 && name.ends_with(".sqlite")
7258 && name.contains(MIGRATION_GENERATION_TAG)
7259 && pointer_generation.as_deref() != Some(&name)
7260 && !migration_manifest_valid(callgraph_dir, &name)
7261 {
7262 remove_sqlite_file_set(&path);
7263 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
7264 }
7265 }
7266 crate::fs_lock::sync_parent(callgraph_dir);
7267}
7268
7269fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
7270 let mut hasher = blake3::Hasher::new();
7271 hasher.update(path.to_string_lossy().as_bytes());
7272 if let Some(generation) = generation {
7273 hasher.update(generation.as_bytes());
7274 }
7275 let digest = hash_to_hex(hasher.finalize());
7276 format!("legacy-{}", &digest[..16])
7277}
7278
7279fn open_readonly_connection(path: &Path) -> Result<TrackedConnection> {
7280 let uri = sqlite_readonly_uri(path);
7281 let conn = TrackedConnection::open_with_flags(
7282 &uri,
7283 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
7284 SqliteStore::CallgraphGeneration,
7285 )?;
7286 conn.pragma_update(None, "synchronous", "NORMAL")?;
7287 conn.busy_timeout(reader_busy_timeout())?;
7288 conn.execute_batch("PRAGMA query_only=ON;")?;
7289 Ok(conn)
7290}
7291
7292fn reader_busy_timeout() -> Duration {
7293 let jitter = (now_nanos() % 500) as u64;
7294 Duration::from_millis(250 + jitter)
7295}
7296
7297fn sqlite_readonly_uri(path: &Path) -> String {
7298 let raw = path.to_string_lossy().replace('\\', "/");
7299 let encoded = percent_encode_sqlite_uri_path(&raw);
7300 if raw.starts_with('/') {
7301 format!("file://{encoded}?mode=ro")
7302 } else if raw.as_bytes().get(1) == Some(&b':') {
7303 format!("file:///{encoded}?mode=ro")
7304 } else {
7305 format!("file:{encoded}?mode=ro")
7306 }
7307}
7308
7309fn percent_encode_sqlite_uri_path(path: &str) -> String {
7310 let mut encoded = String::with_capacity(path.len());
7311 for byte in path.bytes() {
7312 match byte {
7313 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
7314 encoded.push(byte as char)
7315 }
7316 _ => encoded.push_str(&format!("%{byte:02X}")),
7317 }
7318 }
7319 encoded
7320}
7321
7322fn configure_connection(conn: &Connection) -> Result<()> {
7323 conn.busy_timeout(Duration::from_secs(5))?;
7327 conn.pragma_update(None, "journal_mode", "WAL")?;
7328 conn.pragma_update(None, "synchronous", "NORMAL")?;
7329 conn.pragma_update(
7330 None,
7331 "wal_autocheckpoint",
7332 CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES,
7333 )?;
7334 conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
7335 Ok(())
7336}
7337
7338fn configure_build_connection(conn: &Connection) -> Result<()> {
7339 conn.busy_timeout(Duration::from_secs(5))?;
7344 conn.pragma_update(None, "journal_mode", "WAL")?;
7345 conn.pragma_update(None, "synchronous", "NORMAL")?;
7346 conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
7347 Ok(())
7348}
7349
7350fn checkpoint_sqlite_before_publication(path: &Path) {
7354 let Ok(conn) = crate::db::lifecycle::TrackedConnection::open(
7355 path,
7356 crate::db::lifecycle::SqliteStore::CallgraphGeneration,
7357 ) else {
7358 return;
7359 };
7360 let _ = conn.pragma_update(None, "synchronous", "NORMAL");
7361 let _ = conn.busy_timeout(Duration::from_secs(5));
7362 let _ = checkpoint_wal_truncate(&conn);
7363}
7364
7365fn checkpoint_wal_truncate(conn: &Connection) -> bool {
7366 match conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
7367 row.get::<_, i64>(0)
7368 }) {
7369 Ok(0) => true,
7370 Ok(_) => false,
7371 Err(rusqlite::Error::SqliteFailure(error, _))
7372 if matches!(
7373 error.code,
7374 rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
7375 ) =>
7376 {
7377 false
7378 }
7379 Err(error) => {
7380 log::debug!("callgraph WAL truncate checkpoint skipped: {error}");
7381 false
7382 }
7383 }
7384}
7385
7386pub(crate) use crate::views::materialization::materialize_manifest_view_database;
7387
7388pub(crate) fn initialize_schema(conn: &Connection) -> Result<()> {
7389 conn.execute_batch(
7390 "CREATE TABLE IF NOT EXISTS files (
7391 path TEXT PRIMARY KEY,
7392 content_hash TEXT NOT NULL,
7393 mtime_ns INTEGER NOT NULL,
7394 size INTEGER NOT NULL,
7395 lang TEXT NOT NULL,
7396 is_dead_code_root INTEGER NOT NULL DEFAULT 0,
7397 is_public_api INTEGER NOT NULL DEFAULT 0,
7398 surface_fingerprint TEXT NOT NULL,
7399 indexed_at INTEGER NOT NULL
7400 );
7401
7402 CREATE TABLE IF NOT EXISTS nodes (
7403 id TEXT PRIMARY KEY,
7404 file_path TEXT NOT NULL,
7405 name TEXT NOT NULL,
7406 scoped_name TEXT NOT NULL,
7407 kind TEXT NOT NULL,
7408 start_line INTEGER NOT NULL,
7409 start_col INTEGER NOT NULL,
7410 end_line INTEGER NOT NULL,
7411 end_col INTEGER NOT NULL,
7412 range_ordinal INTEGER NOT NULL,
7413 signature TEXT,
7414 exported INTEGER NOT NULL,
7415 is_default_export INTEGER NOT NULL,
7416 is_type_like INTEGER NOT NULL,
7417 is_callgraph_entry_point INTEGER NOT NULL,
7418 provenance TEXT NOT NULL,
7419 UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
7420 );
7421 CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
7422 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
7423 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
7424
7425 CREATE TABLE IF NOT EXISTS refs (
7426 ref_id TEXT PRIMARY KEY,
7427 caller_node TEXT,
7428 caller_file TEXT NOT NULL,
7429 kind TEXT NOT NULL,
7430 short_name TEXT,
7431 full_ref TEXT,
7432 module_path TEXT,
7433 import_kind TEXT,
7434 local_name TEXT,
7435 requested_name TEXT,
7436 namespace_alias TEXT,
7437 wildcard INTEGER NOT NULL DEFAULT 0,
7438 line INTEGER NOT NULL,
7439 byte_start INTEGER NOT NULL,
7440 byte_end INTEGER NOT NULL,
7441 status TEXT NOT NULL,
7442 target_node TEXT,
7443 target_file TEXT,
7444 target_symbol TEXT,
7445 provenance TEXT NOT NULL
7446 );
7447 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
7448 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
7449 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
7450 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
7451 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
7452
7453 CREATE TABLE IF NOT EXISTS file_dependencies (
7454 file_path TEXT NOT NULL,
7455 dep_file TEXT NOT NULL,
7456 PRIMARY KEY(file_path, dep_file)
7457 );
7458 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
7459
7460 CREATE TABLE IF NOT EXISTS edges (
7461 edge_id TEXT PRIMARY KEY,
7462 ref_id TEXT NOT NULL,
7463 source_node TEXT NOT NULL,
7464 target_node TEXT,
7465 target_file TEXT NOT NULL,
7466 target_symbol TEXT NOT NULL,
7467 kind TEXT NOT NULL,
7468 line INTEGER NOT NULL,
7469 provenance TEXT NOT NULL
7470 );
7471 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
7472 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
7473 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
7474 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
7475
7476 CREATE TABLE IF NOT EXISTS dispatch_hints (
7477 id TEXT PRIMARY KEY,
7478 method_name TEXT NOT NULL,
7479 caller_node TEXT NOT NULL,
7480 file TEXT NOT NULL,
7481 line INTEGER NOT NULL,
7482 byte_start INTEGER NOT NULL,
7483 byte_end INTEGER NOT NULL,
7484 provenance TEXT NOT NULL
7485 );
7486 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
7487 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
7488
7489 CREATE TABLE IF NOT EXISTS type_ref_names (
7490 name TEXT PRIMARY KEY
7491 );
7492
7493 CREATE TABLE IF NOT EXISTS backend_file_state (
7494 backend TEXT NOT NULL,
7495 workspace_root TEXT NOT NULL,
7496 file_path TEXT NOT NULL,
7497 content_hash TEXT NOT NULL,
7498 status TEXT NOT NULL,
7499 updated_at INTEGER NOT NULL,
7500 PRIMARY KEY(backend, workspace_root, file_path, content_hash)
7501 );
7502 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
7503
7504 CREATE TABLE IF NOT EXISTS meta (
7505 k TEXT PRIMARY KEY,
7506 v TEXT NOT NULL
7507 );
7508
7509 -- The file walk is staged on disk so extraction can page through a
7510 -- deterministic inventory without retaining every source path in heap.
7511 CREATE TABLE IF NOT EXISTS staging_file_inventory (
7512 path TEXT PRIMARY KEY,
7513 size INTEGER NOT NULL
7514 ) WITHOUT ROWID;
7515
7516 -- Context needed only while a generation is staged. Raw refs live in
7517 -- `refs` with status `staged`; this table preserves the caller symbol
7518 -- needed to avoid inventing self edges during the later resolve pass.
7519 CREATE TABLE IF NOT EXISTS staging_ref_context (
7520 ref_id TEXT PRIMARY KEY,
7521 caller_symbol TEXT
7522 );",
7523 )?;
7524 insert_meta(conn)?;
7525 Ok(())
7526}
7527
7528fn insert_meta(conn: &Connection) -> Result<()> {
7529 conn.execute(
7530 "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
7531 params![SCHEMA_VERSION.to_string()],
7532 )?;
7533 conn.execute(
7534 "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
7535 params![schema_fingerprint()],
7536 )?;
7537 conn.execute(
7538 "INSERT OR IGNORE INTO meta(k, v) VALUES('projection_write_revision', '0')",
7539 [],
7540 )?;
7541 Ok(())
7542}
7543
7544const PATH_IDENTITY_MISMATCH_META_KEY: &str = "path_identity_mismatch";
7548
7549fn record_path_identity_mismatch(conn: &Connection, error: &CallGraphStoreError) -> Result<()> {
7550 let CallGraphStoreError::PathIdentityMismatch { path, project_root } = error else {
7551 return Ok(());
7552 };
7553 conn.execute(
7554 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7555 params![
7556 PATH_IDENTITY_MISMATCH_META_KEY,
7557 format!(
7558 "callgraph_path_identity_mismatch path={} project_root={}",
7559 path.display(),
7560 project_root.display()
7561 )
7562 ],
7563 )?;
7564 Ok(())
7565}
7566
7567pub(super) fn path_identity_mismatch_reason(conn: &Connection) -> Result<Option<String>> {
7568 conn.query_row(
7569 "SELECT v FROM meta WHERE k = ?1",
7570 [PATH_IDENTITY_MISMATCH_META_KEY],
7571 |row| row.get(0),
7572 )
7573 .optional()
7574 .map_err(Into::into)
7575}
7576
7577fn projection_write_revision(conn: &Connection) -> Result<Option<u64>> {
7578 let revision: Option<String> = conn
7579 .query_row(
7580 "SELECT v FROM meta WHERE k = 'projection_write_revision'",
7581 [],
7582 |row| row.get(0),
7583 )
7584 .optional()?;
7585 revision
7586 .map(|revision| {
7587 revision.parse::<u64>().map_err(|error| {
7588 CallGraphStoreError::Unavailable(format!(
7589 "callgraph projection write revision is invalid: {error}"
7590 ))
7591 })
7592 })
7593 .transpose()
7594}
7595
7596fn bump_projection_write_revision(tx: &Transaction<'_>) -> Result<()> {
7599 tx.execute(
7600 "INSERT INTO meta(k, v) VALUES('projection_write_revision', '1')
7601 ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1",
7602 [],
7603 )?;
7604 #[cfg(test)]
7605 note_projection_revision_bump_for_test();
7606 Ok(())
7607}
7608
7609pub(crate) fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
7610 conn.execute(
7611 "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
7612 params![if ready { "1" } else { "0" }],
7613 )?;
7614 Ok(())
7615}
7616
7617fn database_ready(conn: &Connection) -> Result<bool> {
7618 let schema_version: Option<String> = conn
7619 .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
7620 row.get(0)
7621 })
7622 .optional()?;
7623 let fingerprint: Option<String> = conn
7624 .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
7625 row.get(0)
7626 })
7627 .optional()?;
7628 let ready: Option<String> = conn
7629 .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
7630 .optional()?;
7631
7632 let expected_schema = SCHEMA_VERSION.to_string();
7633 let expected_fingerprint = schema_fingerprint();
7634 Ok(schema_version.as_deref() == Some(expected_schema.as_str())
7635 && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
7636 && ready.as_deref() == Some("1"))
7637}
7638
7639fn ensure_database_ready(conn: &Connection) -> Result<()> {
7640 if database_ready(conn)? {
7641 Ok(())
7642 } else {
7643 Err(CallGraphStoreError::Unavailable(
7644 "database is missing, stale, or mid-build".to_string(),
7645 ))
7646 }
7647}
7648
7649fn schema_fingerprint() -> String {
7650 let input =
7655 format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
7656 hash_to_hex(blake3::hash(input.as_bytes()))
7657}
7658
7659fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
7660 tx.execute_batch(
7661 "DELETE FROM staging_ref_context;
7662 DELETE FROM edges;
7663 DELETE FROM file_dependencies;
7664 DELETE FROM refs;
7665 DELETE FROM dispatch_hints;
7666 DELETE FROM type_ref_names;
7667 DELETE FROM backend_file_state;
7668 DELETE FROM nodes;
7669 DELETE FROM files;",
7670 )?;
7671 Ok(())
7672}
7673
7674fn staged_build_phase(conn: &Connection) -> Result<Option<String>> {
7675 conn.query_row(
7676 "SELECT v FROM meta WHERE k = ?1",
7677 params![STAGED_BUILD_PHASE],
7678 |row| row.get(0),
7679 )
7680 .optional()
7681 .map_err(Into::into)
7682}
7683
7684fn staged_u64(conn: &Connection, key: &str) -> Result<u64> {
7685 let value = staged_string(conn, key)?;
7686 Ok(value.and_then(|value| value.parse().ok()).unwrap_or(0))
7687}
7688
7689fn staged_string(conn: &Connection, key: &str) -> Result<Option<String>> {
7690 conn.query_row("SELECT v FROM meta WHERE k = ?1", params![key], |row| {
7691 row.get::<_, String>(0)
7692 })
7693 .optional()
7694 .map_err(Into::into)
7695}
7696
7697fn set_staged_build_phase(tx: &Transaction<'_>, phase: &str) -> Result<()> {
7698 tx.execute(
7699 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7700 params![STAGED_BUILD_PHASE, phase],
7701 )?;
7702 Ok(())
7703}
7704
7705fn set_staged_u64(tx: &Transaction<'_>, key: &str, value: u64) -> Result<()> {
7706 set_staged_string(tx, key, &value.to_string())
7707}
7708
7709fn set_staged_string(tx: &Transaction<'_>, key: &str, value: &str) -> Result<()> {
7710 tx.execute(
7711 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7712 params![key, value],
7713 )?;
7714 Ok(())
7715}
7716
7717fn increment_staged_extracted_bytes(tx: &Transaction<'_>, bytes: u64) -> Result<()> {
7721 tx.execute(
7722 "INSERT INTO meta(k, v) VALUES(?1, ?2)
7723 ON CONFLICT(k) DO UPDATE SET v = CAST(meta.v AS INTEGER) + excluded.v",
7724 params![STAGED_COMMITTED_EXTRACTED_BYTES, bytes.to_string()],
7725 )?;
7726 Ok(())
7727}
7728
7729fn staged_content_matches(conn: &Connection, project_root: &Path, path: &Path) -> Result<bool> {
7730 let Ok(source) = std::fs::read_to_string(path) else {
7731 return Ok(false);
7732 };
7733 let Ok(freshness) = collect_source_freshness(path, &source) else {
7734 return Ok(false);
7735 };
7736 let rel_path = relative_path(project_root, path);
7737 let staged_hash = conn
7738 .query_row(
7739 "SELECT content_hash FROM files WHERE path = ?1",
7740 params![rel_path],
7741 |row| row.get::<_, String>(0),
7742 )
7743 .optional()?;
7744 Ok(staged_hash.as_deref() == Some(hash_to_hex(freshness.content_hash).as_str()))
7745}
7746
7747fn delete_staged_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
7748 tx.execute(
7749 "DELETE FROM staging_ref_context
7750 WHERE ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?1)",
7751 params![rel_path],
7752 )?;
7753 delete_file_rows(tx, rel_path)
7754}
7755
7756fn prune_staged_files_not_in_inventory(conn: &mut Connection) -> Result<()> {
7757 loop {
7758 let removed = {
7759 let mut statement = conn.prepare(
7760 "SELECT path
7761 FROM files
7762 WHERE NOT EXISTS (
7763 SELECT 1 FROM staging_file_inventory inventory
7764 WHERE inventory.path = files.path
7765 )
7766 ORDER BY path
7767 LIMIT ?1",
7768 )?;
7769 let paths = statement
7770 .query_map(params![COLD_BUILD_EXTRACT_BATCH_FILES as i64], |row| {
7771 row.get::<_, String>(0)
7772 })?
7773 .collect::<std::result::Result<Vec<_>, _>>()?;
7774 paths
7775 };
7776 if removed.is_empty() {
7777 return Ok(());
7778 }
7779 let tx = conn.transaction()?;
7780 for path in removed {
7781 delete_staged_file_rows(&tx, &path)?;
7782 }
7783 tx.commit()?;
7784 }
7785}
7786
7787struct StagedFileBatch {
7788 paths: Vec<PathBuf>,
7789 last_path: String,
7790}
7791
7792fn load_staged_file_batch(
7793 conn: &Connection,
7794 project_root: &Path,
7795 after_path: &str,
7796 max_files: usize,
7797 max_bytes: u64,
7798) -> Result<Option<StagedFileBatch>> {
7799 let mut statement = conn.prepare(
7800 "SELECT path, size
7801 FROM staging_file_inventory
7802 WHERE path > ?1
7803 ORDER BY path
7804 LIMIT ?2",
7805 )?;
7806 let mut rows = statement.query(params![after_path, max_files.max(1) as i64])?;
7807 let mut paths = Vec::with_capacity(max_files.max(1));
7808 let mut last_path = String::new();
7809 let mut batch_bytes = 0u64;
7810 while let Some(row) = rows.next()? {
7811 let rel_path = row.get::<_, String>(0)?;
7812 let size = row.get::<_, i64>(1)?.max(0) as u64;
7813 if !paths.is_empty() && batch_bytes.saturating_add(size) > max_bytes {
7814 break;
7815 }
7816 batch_bytes = batch_bytes.saturating_add(size);
7817 last_path.clone_from(&rel_path);
7818 paths.push(project_root.join(rel_path));
7819 }
7820 if paths.is_empty() {
7821 Ok(None)
7822 } else {
7823 Ok(Some(StagedFileBatch { paths, last_path }))
7824 }
7825}
7826
7827fn staged_corpus_fingerprint(conn: &Connection, project_root: &Path) -> Result<String> {
7828 let mut statement = conn.prepare("SELECT path FROM staging_file_inventory ORDER BY path")?;
7829 let mut rows = statement.query([])?;
7830 let mut fingerprint = CorpusFingerprint::default();
7831 while let Some(row) = rows.next()? {
7832 let rel_path = row.get::<_, String>(0)?;
7833 fingerprint.add_path(project_root, &project_root.join(rel_path));
7834 }
7835 Ok(fingerprint.finish(project_root))
7836}
7837
7838fn load_staged_ref_window(
7839 conn: &Connection,
7840 after_rowid: u64,
7841 limit: usize,
7842) -> Result<Vec<StagedRef>> {
7843 let mut statement = conn.prepare(
7844 "SELECT refs.rowid, refs.ref_id, refs.caller_node, refs.caller_file, refs.kind,
7845 refs.short_name, refs.full_ref, refs.module_path, refs.import_kind,
7846 refs.local_name, refs.requested_name, refs.namespace_alias, refs.wildcard,
7847 refs.line, refs.byte_start, refs.byte_end, staging_ref_context.caller_symbol
7848 FROM refs
7849 LEFT JOIN staging_ref_context ON staging_ref_context.ref_id = refs.ref_id
7850 WHERE refs.status = 'staged' AND refs.rowid > ?1
7851 ORDER BY refs.rowid
7852 LIMIT ?2",
7853 )?;
7854 let rows = statement.query_map(params![after_rowid as i64, limit as i64], |row| {
7855 Ok(StagedRef {
7856 rowid: row.get::<_, i64>(0)? as u64,
7857 raw: RawRef {
7858 ref_id: row.get(1)?,
7859 caller_node: row.get(2)?,
7860 caller_file: row.get(3)?,
7861 kind: row.get(4)?,
7862 short_name: row.get(5)?,
7863 full_ref: row.get(6)?,
7864 module_path: row.get(7)?,
7865 import_kind: row.get(8)?,
7866 local_name: row.get(9)?,
7867 requested_name: row.get(10)?,
7868 namespace_alias: row.get(11)?,
7869 wildcard: row.get::<_, i64>(12)? != 0,
7870 line: row.get::<_, i64>(13)? as u32,
7871 byte_start: row.get::<_, i64>(14)? as usize,
7872 byte_end: row.get::<_, i64>(15)? as usize,
7873 caller_symbol: row.get(16)?,
7874 dependencies: BTreeSet::new(),
7875 },
7876 })
7877 })?;
7878 let mut refs = rows.collect::<std::result::Result<Vec<_>, _>>()?;
7879 drop(statement);
7880
7881 let mut dependencies = HashMap::<String, BTreeSet<String>>::new();
7882 let mut dependency_statement = conn
7883 .prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1 ORDER BY dep_file")?;
7884 for raw in refs.iter_mut().map(|entry| &mut entry.raw) {
7885 if !dependencies.contains_key(&raw.caller_file) {
7886 let rows =
7887 dependency_statement.query_map(params![raw.caller_file], |row| row.get(0))?;
7888 let values = rows.collect::<std::result::Result<BTreeSet<_>, _>>()?;
7889 dependencies.insert(raw.caller_file.clone(), values);
7890 }
7891 raw.dependencies = dependencies
7892 .get(&raw.caller_file)
7893 .cloned()
7894 .unwrap_or_default();
7895 }
7896 Ok(refs)
7897}
7898
7899fn unresolved_staged_ref(raw: RawRef) -> ResolvedRef {
7900 ResolvedRef {
7901 dependencies: raw.dependencies.clone(),
7902 raw,
7903 status: "unresolved".to_string(),
7904 target_node: None,
7905 target_file: None,
7906 target_symbol: None,
7907 edge: None,
7908 }
7909}
7910
7911fn query_count(conn: &Connection, query: &str) -> Result<u64> {
7912 conn.query_row(query, [], |row| row.get::<_, i64>(0))
7913 .map(|count| count.max(0) as u64)
7914 .map_err(Into::into)
7915}
7916
7917fn cold_build_stats_from_connection(conn: &Connection, started: Instant) -> Result<ColdBuildStats> {
7918 let files = query_count(conn, "SELECT COUNT(*) FROM files")? as usize;
7919 let nodes = query_count(conn, "SELECT COUNT(*) FROM nodes")? as usize;
7920 let refs = query_count(conn, "SELECT COUNT(*) FROM refs")? as usize;
7921 let edges = query_count(conn, "SELECT COUNT(*) FROM edges")? as usize;
7922 let failed_files = staged_failed_files(conn)?;
7923 let elapsed_ms = started.elapsed().as_millis();
7924 crate::slog_info!(
7925 "perf callgraph_store bounded cold_build: files={} nodes={} refs={} edges={} committed_extracted_bytes={} ms={}",
7926 files,
7927 nodes,
7928 refs,
7929 edges,
7930 staged_u64(conn, STAGED_COMMITTED_EXTRACTED_BYTES)?,
7931 elapsed_ms
7932 );
7933 Ok(ColdBuildStats {
7934 files,
7935 nodes,
7936 refs,
7937 edges,
7938 failed_files,
7939 elapsed_ms,
7940 })
7941}
7942
7943fn staged_failed_files(conn: &Connection) -> Result<Vec<String>> {
7944 let mut statement = conn.prepare(
7945 "SELECT DISTINCT file_path FROM backend_file_state WHERE status = 'stale' ORDER BY file_path",
7946 )?;
7947 let rows = statement.query_map([], |row| row.get(0))?;
7948 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
7949}
7950
7951fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
7952 tx.execute_batch(
7953 "DROP INDEX IF EXISTS idx_nodes_file;
7954 DROP INDEX IF EXISTS idx_nodes_name;
7955 DROP INDEX IF EXISTS idx_nodes_scoped;
7956 DROP INDEX IF EXISTS idx_refs_short_name;
7957 DROP INDEX IF EXISTS idx_refs_kind_caller_file;
7958 DROP INDEX IF EXISTS idx_refs_caller_file;
7959 DROP INDEX IF EXISTS idx_refs_caller_node_kind;
7960 DROP INDEX IF EXISTS idx_refs_target_file;
7961 DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
7962 DROP INDEX IF EXISTS idx_edges_source_kind;
7963 DROP INDEX IF EXISTS idx_edges_target_kind;
7964 DROP INDEX IF EXISTS idx_edges_target_file_symbol;
7965 DROP INDEX IF EXISTS idx_edges_ref_id;
7966 DROP INDEX IF EXISTS idx_dispatch_hints_method;
7967 DROP INDEX IF EXISTS idx_dispatch_hints_file;
7968 DROP INDEX IF EXISTS idx_backend_file_state_file;",
7969 )?;
7970 Ok(())
7971}
7972
7973fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
7974 tx.execute_batch(
7975 "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
7976 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
7977 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
7978 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
7979 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
7980 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
7981 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
7982 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
7983 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
7984 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
7985 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
7986 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
7987 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
7988 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
7989 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
7990 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
7991 )?;
7992 Ok(())
7993}
7994
7995const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
7996 ("files", "path"),
7997 ("nodes", "file_path"),
7998 ("refs", "caller_file"),
7999 ("refs", "target_file"),
8000 ("file_dependencies", "file_path"),
8001 ("file_dependencies", "dep_file"),
8002 ("edges", "target_file"),
8003 ("dispatch_hints", "file"),
8004 ("backend_file_state", "file_path"),
8005];
8006
8007fn reconcile_workspace_roots(
8020 conn: &mut Connection,
8021 project_root: &Path,
8022 allow_repair: bool,
8023) -> Result<OpenRootRepair> {
8024 let roots = stored_workspace_roots(conn)?;
8025 let current_root = project_root.display().to_string();
8026 if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
8027 return Ok(OpenRootRepair::None);
8028 }
8029
8030 if let Some(sample) = sample_absolute_data_path(conn)? {
8031 return Ok(OpenRootRepair::NeedsRebuild {
8032 previous_roots: roots,
8033 current_root,
8034 reason: format!("absolute store data path row {sample}"),
8035 });
8036 }
8037
8038 for stored_root in roots.iter() {
8039 if stored_root == ¤t_root {
8040 continue;
8041 }
8042 if Path::new(stored_root).exists() {
8043 let reason = format!(
8044 "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
8045 );
8046 return Ok(OpenRootRepair::NeedsRebuild {
8047 previous_roots: roots,
8048 current_root,
8049 reason,
8050 });
8051 }
8052 }
8053
8054 if !allow_repair {
8055 return Ok(OpenRootRepair::NeedsRebuild {
8056 previous_roots: roots,
8057 current_root,
8058 reason: "workspace root metadata requires deferred repair".to_string(),
8059 });
8060 }
8061
8062 publish_if_current(|| {
8063 let tx = conn.transaction()?;
8064 tx.execute(
8065 "UPDATE OR IGNORE backend_file_state
8066 SET workspace_root = ?1
8067 WHERE workspace_root <> ?1",
8068 params![¤t_root],
8069 )?;
8070 tx.execute(
8071 "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
8072 params![¤t_root],
8073 )?;
8074 tx.commit()?;
8075 Ok(())
8076 })?;
8077
8078 crate::slog_info!(
8079 "callgraph store re-rooted from {} to {}",
8080 roots.join(", "),
8081 current_root
8082 );
8083 Ok(OpenRootRepair::ReRooted)
8084}
8085
8086fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
8087 let mut stmt = conn.prepare(
8088 "SELECT DISTINCT workspace_root
8089 FROM backend_file_state
8090 ORDER BY workspace_root",
8091 )?;
8092 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
8093 rows.collect::<std::result::Result<Vec<_>, _>>()
8094 .map_err(Into::into)
8095}
8096
8097fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
8098 for (table, column) in STORE_DATA_PATH_COLUMNS {
8099 let sql = format!(
8100 "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
8101 );
8102 let mut stmt = conn.prepare(&sql)?;
8103 let mut rows = stmt.query([])?;
8104 while let Some(row) = rows.next()? {
8105 let value: String = row.get(0)?;
8106 if stored_path_is_absolute(&value) {
8107 return Ok(Some(format!("{table}.{column}={value}")));
8108 }
8109 }
8110 }
8111 Ok(None)
8112}
8113
8114fn stored_path_is_absolute(value: &str) -> bool {
8115 if value.is_empty() {
8116 return false;
8117 }
8118 if Path::new(value).is_absolute() || value.starts_with('/') {
8119 return true;
8120 }
8121 let bytes = value.as_bytes();
8122 if bytes.len() >= 3
8123 && bytes[1] == b':'
8124 && (bytes[2] == b'/' || bytes[2] == b'\\')
8125 && bytes[0].is_ascii_alphabetic()
8126 {
8127 return true;
8128 }
8129 value.starts_with("\\\\") || value.starts_with("//")
8130}
8131
8132fn log_root_repair_rebuild(repair: &OpenRootRepair) {
8133 if let OpenRootRepair::NeedsRebuild {
8134 previous_roots,
8135 current_root,
8136 reason,
8137 } = repair
8138 {
8139 crate::slog_info!(
8140 "callgraph cold-build decision: reason=re-rooting refused; from={}; to={}; detail={}",
8141 previous_roots.join(", "),
8142 current_root,
8143 reason
8144 );
8145 }
8146}
8147
8148fn now_nanos() -> u128 {
8150 SystemTime::now()
8151 .duration_since(UNIX_EPOCH)
8152 .unwrap_or(Duration::ZERO)
8153 .as_nanos()
8154}
8155
8156fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
8161 callgraph_dir.join(format!("{project_key}.current"))
8162}
8163
8164fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
8168 callgraph_dir.join(format!("{project_key}.sqlite"))
8169}
8170
8171fn generation_file_name(project_key: &str) -> String {
8175 format!(
8176 "{project_key}.g{}.{}.sqlite",
8177 now_nanos(),
8178 std::process::id()
8179 )
8180}
8181
8182fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
8184 let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
8185 let name = text.trim();
8186 if name.is_empty() {
8187 None
8188 } else {
8189 Some(name.to_string())
8190 }
8191}
8192
8193fn db_path_ready(path: &Path) -> bool {
8196 (|| -> Result<bool> {
8197 let conn = open_readonly_connection(path)?;
8198 database_ready(&conn)
8199 })()
8200 .unwrap_or(false)
8201}
8202
8203fn resolve_ready_target(
8211 callgraph_dir: &Path,
8212 project_key: &str,
8213) -> Option<(PathBuf, Option<String>)> {
8214 for _ in 0..5 {
8215 if let Some(generation) = read_pointer(callgraph_dir, project_key) {
8216 let gen_path = callgraph_dir.join(&generation);
8217 if gen_path.is_file() {
8218 return (migration_manifest_valid(callgraph_dir, &generation)
8219 && db_path_ready(&gen_path))
8220 .then_some((gen_path, Some(generation)));
8221 }
8222 std::thread::sleep(Duration::from_millis(5));
8225 continue;
8226 }
8227 let legacy = legacy_sqlite_path(callgraph_dir, project_key);
8229 return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
8230 }
8231 None
8232}
8233
8234fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
8238 let pointer = pointer_path(callgraph_dir, project_key);
8239 let tmp = callgraph_dir.join(format!(
8240 "{project_key}.current.tmp.{}.{}",
8241 std::process::id(),
8242 now_nanos()
8243 ));
8244 {
8245 use std::io::Write as _;
8246 let mut file = std::fs::File::create(&tmp)?;
8247 file.write_all(generation.as_bytes())?;
8248 file.write_all(b"\n")?;
8249 file.sync_all()?;
8250 }
8251 if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
8252 let _ = std::fs::remove_file(&tmp);
8253 return Err(error.into());
8254 }
8255 crate::fs_lock::sync_parent(&pointer);
8256 Ok(())
8257}
8258
8259#[derive(Clone, Debug)]
8260struct GenerationGcCandidate {
8261 name: String,
8262 path: PathBuf,
8263 modified: SystemTime,
8264}
8265
8266fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
8272 let temp_grace = Duration::from_secs(60);
8273 let now = SystemTime::now();
8274 let pointer_current =
8275 read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
8276 let gen_prefix = format!("{project_key}.g");
8277 let tmp_prefixes = [
8278 format!("{project_key}.g"), format!("{project_key}.current."), format!("{project_key}.sqlite.tmp."), ];
8282 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
8283 return;
8284 };
8285 let mut gens: Vec<GenerationGcCandidate> = Vec::new();
8286 for entry in entries.flatten() {
8287 let name = entry.file_name();
8288 let name = name.to_string_lossy().to_string();
8289 let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
8290 let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
8291
8292 if name.contains(".tmp.") {
8294 if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
8295 let _ = std::fs::remove_file(entry.path());
8296 }
8297 continue;
8298 }
8299
8300 if name == format!("{project_key}.sqlite") {
8303 remove_sqlite_file_set(&entry.path());
8304 continue;
8305 }
8306
8307 if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
8308 gens.push(GenerationGcCandidate {
8309 name,
8310 path: entry.path(),
8311 modified: mtime,
8312 });
8313 }
8314 }
8315
8316 let mut superseded = gens
8317 .iter()
8318 .filter(|generation| generation.name != pointer_current)
8319 .collect::<Vec<_>>();
8320 superseded.sort_by(|left, right| {
8321 right
8322 .modified
8323 .cmp(&left.modified)
8324 .then_with(|| right.name.cmp(&left.name))
8325 });
8326 let previous = superseded.first().map(|generation| generation.name.clone());
8327
8328 for generation in gens {
8329 let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
8330 if generation.name == pointer_current
8331 || Some(generation.name.as_str()) == previous.as_deref()
8332 {
8333 continue;
8334 }
8335
8336 let age = now
8337 .duration_since(generation.modified)
8338 .unwrap_or(Duration::ZERO);
8339 if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
8340 continue;
8341 }
8342
8343 remove_sqlite_file_set(&generation.path);
8344 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
8345 let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
8346 callgraph_dir,
8347 &generation.name,
8348 ));
8349 }
8350}
8351
8352fn remove_sqlite_file_set(path: &Path) {
8353 let _ = std::fs::remove_file(path);
8354 remove_sqlite_sidecars(path);
8355}
8356
8357fn remove_sqlite_sidecars(path: &Path) {
8358 let path_text = path.to_string_lossy();
8359 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
8360 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
8361 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
8362}
8363
8364#[derive(Clone, Copy, Debug, Default)]
8365struct CallgraphRootSweepSummary {
8366 scanned: usize,
8367 removed: usize,
8368 bytes: u64,
8369 generation_gc: usize,
8370 skipped_memo: usize,
8371 skipped_derived: usize,
8372 skipped_fresh: usize,
8373 skipped_reader: usize,
8374 skipped_lease: usize,
8375 skipped_unreadable: usize,
8376 budget_exhausted: bool,
8377}
8378
8379#[derive(Clone, Copy, Debug, Default)]
8380struct CallgraphRootFileStats {
8381 newest: Option<SystemTime>,
8382 bytes: u64,
8383}
8384
8385enum CallgraphRootWalk {
8386 Complete(CallgraphRootFileStats),
8387 BudgetExceeded,
8388 Failed,
8389}
8390
8391enum CallgraphRootCandidate {
8392 Removed { bytes: u64 },
8393 GenerationGc,
8394 SkippedMemo,
8395 SkippedDerived,
8396 SkippedFresh,
8397 SkippedReader,
8398 SkippedLease,
8399 SkippedUnreadable,
8400 BudgetExceeded,
8401}
8402
8403fn sweep_orphaned_callgraph_root_dirs(callgraph_dir: &Path) {
8412 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
8413 return;
8414 };
8415 let root_dir = storage_root.join(crate::root_cache::RootCacheDomain::Callgraph.as_str());
8416 let memo_keys = match crate::search_index::referenced_artifact_cache_keys(&storage_root) {
8417 Ok(keys) => keys,
8418 Err(error) => {
8419 crate::slog_warn!(
8420 "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={}",
8421 root_dir.display(),
8422 error
8423 );
8424 return;
8425 }
8426 };
8427 let derived_keys = crate::search_index::derived_artifact_cache_keys();
8428 let summary = sweep_callgraph_root_dirs_with_limits(
8429 &root_dir,
8430 &memo_keys,
8431 &derived_keys,
8432 CALLGRAPH_ROOT_SWEEP_BUDGET,
8433 CALLGRAPH_ROOT_SWEEP_LIMIT,
8434 );
8435 crate::slog_info!(
8436 "callgraph root sweep root={} scanned={} removed={} bytes={} generation_gc={} skipped_memo={} skipped_derived={} skipped_fresh={} skipped_reader={} skipped_lease={} skipped_unreadable={} budget_exhausted={}",
8437 root_dir.display(),
8438 summary.scanned,
8439 summary.removed,
8440 summary.bytes,
8441 summary.generation_gc,
8442 summary.skipped_memo,
8443 summary.skipped_derived,
8444 summary.skipped_fresh,
8445 summary.skipped_reader,
8446 summary.skipped_lease,
8447 summary.skipped_unreadable,
8448 summary.budget_exhausted
8449 );
8450}
8451
8452fn sweep_callgraph_root_dirs_with_limits(
8453 root_dir: &Path,
8454 memo_keys: &HashSet<String>,
8455 derived_keys: &HashSet<String>,
8456 wall_clock_budget: Duration,
8457 entry_limit: usize,
8458) -> CallgraphRootSweepSummary {
8459 let started = Instant::now();
8460 let deadline = started + wall_clock_budget;
8461 let boundary = match crate::walk_boundary::DeviceBoundary::for_root(root_dir) {
8462 Ok(boundary) => boundary,
8463 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
8464 return CallgraphRootSweepSummary::default();
8465 }
8466 Err(error) => {
8467 crate::slog_warn!(
8468 "cannot establish filesystem boundary for callgraph root sweep {}: {}",
8469 root_dir.display(),
8470 error
8471 );
8472 return CallgraphRootSweepSummary {
8473 skipped_unreadable: 1,
8474 ..CallgraphRootSweepSummary::default()
8475 };
8476 }
8477 };
8478 let mut entries = match std::fs::read_dir(root_dir) {
8479 Ok(entries) => entries
8480 .filter_map(|entry| entry.ok())
8481 .filter_map(|entry| {
8482 let name = entry.file_name().to_string_lossy().into_owned();
8483 entry
8484 .file_type()
8485 .ok()
8486 .filter(|file_type| file_type.is_dir() && artifact_key_looks_valid(&name))
8487 .map(|_| (name, entry.path()))
8488 })
8489 .collect::<Vec<_>>(),
8490 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
8491 Err(error) => {
8492 crate::slog_warn!(
8493 "cannot read callgraph root sweep directory {}: {}",
8494 root_dir.display(),
8495 error
8496 );
8497 return CallgraphRootSweepSummary {
8498 skipped_unreadable: 1,
8499 ..CallgraphRootSweepSummary::default()
8500 };
8501 }
8502 };
8503 entries.sort_by(|left, right| left.0.cmp(&right.0));
8504
8505 let cursor_store = CALLGRAPH_ROOT_SWEEP_CURSORS.get_or_init(|| Mutex::new(HashMap::new()));
8506 let last_name = cursor_store
8507 .lock()
8508 .ok()
8509 .and_then(|cursors| cursors.get(root_dir).cloned());
8510 if let Some(start) = last_name
8511 .as_deref()
8512 .and_then(|last| entries.iter().position(|(name, _)| name.as_str() > last))
8513 {
8514 entries.rotate_left(start);
8515 }
8516
8517 let mut summary = CallgraphRootSweepSummary::default();
8518 let mut cursor_name = last_name;
8519 for (processed, (key, cache_dir)) in entries.into_iter().enumerate() {
8520 if processed >= entry_limit || Instant::now() >= deadline {
8521 summary.budget_exhausted = true;
8522 break;
8523 }
8524 summary.scanned += 1;
8525 cursor_name = Some(key.clone());
8526 match callgraph_root_candidate(
8527 &cache_dir,
8528 &key,
8529 memo_keys.contains(&key),
8530 derived_keys.contains(&key),
8531 &boundary,
8532 deadline,
8533 ) {
8534 CallgraphRootCandidate::Removed { bytes } => {
8535 summary.removed += 1;
8536 summary.bytes = summary.bytes.saturating_add(bytes);
8537 }
8538 CallgraphRootCandidate::GenerationGc => summary.generation_gc += 1,
8539 CallgraphRootCandidate::SkippedMemo => summary.skipped_memo += 1,
8540 CallgraphRootCandidate::SkippedDerived => summary.skipped_derived += 1,
8541 CallgraphRootCandidate::SkippedFresh => summary.skipped_fresh += 1,
8542 CallgraphRootCandidate::SkippedReader => summary.skipped_reader += 1,
8543 CallgraphRootCandidate::SkippedLease => summary.skipped_lease += 1,
8544 CallgraphRootCandidate::SkippedUnreadable => summary.skipped_unreadable += 1,
8545 CallgraphRootCandidate::BudgetExceeded => {
8546 summary.budget_exhausted = true;
8547 break;
8548 }
8549 }
8550 }
8551
8552 if let Ok(mut cursors) = cursor_store.lock() {
8553 if summary.budget_exhausted {
8554 if let Some(cursor_name) = cursor_name {
8555 cursors.insert(root_dir.to_path_buf(), cursor_name);
8556 }
8557 } else {
8558 cursors.remove(root_dir);
8559 }
8560 }
8561 if summary.removed > 0 {
8562 crate::fs_lock::sync_parent(root_dir);
8563 }
8564 summary
8565}
8566
8567fn callgraph_root_candidate(
8568 cache_dir: &Path,
8569 project_key: &str,
8570 memo_referenced: bool,
8571 derived_in_process: bool,
8572 boundary: &crate::walk_boundary::DeviceBoundary,
8573 deadline: Instant,
8574) -> CallgraphRootCandidate {
8575 if !boundary.should_descend(cache_dir).unwrap_or(false) {
8576 return CallgraphRootCandidate::SkippedUnreadable;
8577 }
8578 if memo_referenced || derived_in_process {
8579 return sweep_live_callgraph_root_generations(
8580 cache_dir,
8581 project_key,
8582 memo_referenced,
8583 boundary,
8584 deadline,
8585 );
8586 }
8587
8588 let stats = match callgraph_root_file_stats(cache_dir, boundary, deadline) {
8589 CallgraphRootWalk::Complete(stats) => stats,
8590 CallgraphRootWalk::BudgetExceeded => return CallgraphRootCandidate::BudgetExceeded,
8591 CallgraphRootWalk::Failed => return CallgraphRootCandidate::SkippedUnreadable,
8592 };
8593 let Some(newest) = stats.newest else {
8594 return CallgraphRootCandidate::SkippedUnreadable;
8595 };
8596 if SystemTime::now()
8597 .duration_since(newest)
8598 .unwrap_or(Duration::ZERO)
8599 < CALLGRAPH_ROOT_ORPHAN_MIN_AGE
8600 {
8601 return CallgraphRootCandidate::SkippedFresh;
8602 }
8603
8604 let _writer_lease = match crate::fs_lock::try_acquire(
8607 &crate::root_cache::writer_lease_path(cache_dir),
8608 Duration::ZERO,
8609 ) {
8610 Ok(lease) => lease,
8611 Err(_) => return CallgraphRootCandidate::SkippedLease,
8612 };
8613 if crate::root_cache::sweep_all_read_markers(cache_dir).protected {
8614 return CallgraphRootCandidate::SkippedReader;
8615 }
8616
8617 match std::fs::remove_dir_all(cache_dir) {
8618 Ok(()) => {
8619 crate::slog_info!(
8620 "callgraph root sweep reaped dir={} key={} bytes={}",
8621 cache_dir.display(),
8622 project_key,
8623 stats.bytes
8624 );
8625 CallgraphRootCandidate::Removed { bytes: stats.bytes }
8626 }
8627 Err(error) if error.kind() == std::io::ErrorKind::NotFound && !cache_dir.exists() => {
8628 crate::slog_info!(
8629 "callgraph root sweep reaped dir={} key={} bytes={}",
8630 cache_dir.display(),
8631 project_key,
8632 stats.bytes
8633 );
8634 CallgraphRootCandidate::Removed { bytes: stats.bytes }
8635 }
8636 Err(_) => CallgraphRootCandidate::SkippedUnreadable,
8637 }
8638}
8639
8640fn sweep_live_callgraph_root_generations(
8641 cache_dir: &Path,
8642 project_key: &str,
8643 memo_referenced: bool,
8644 boundary: &crate::walk_boundary::DeviceBoundary,
8645 deadline: Instant,
8646) -> CallgraphRootCandidate {
8647 if Instant::now() >= deadline {
8648 return CallgraphRootCandidate::BudgetExceeded;
8649 }
8650 let stats = match callgraph_root_file_stats(cache_dir, boundary, deadline) {
8651 CallgraphRootWalk::Complete(stats) => stats,
8652 CallgraphRootWalk::BudgetExceeded => return CallgraphRootCandidate::BudgetExceeded,
8653 CallgraphRootWalk::Failed => return CallgraphRootCandidate::SkippedUnreadable,
8654 };
8655 let Some(newest) = stats.newest else {
8656 return CallgraphRootCandidate::SkippedUnreadable;
8657 };
8658 if SystemTime::now()
8659 .duration_since(newest)
8660 .unwrap_or(Duration::ZERO)
8661 < CALLGRAPH_ROOT_ORPHAN_MIN_AGE
8662 {
8663 return CallgraphRootCandidate::SkippedFresh;
8664 }
8665 let _writer_lease = match crate::fs_lock::try_acquire(
8666 &crate::root_cache::writer_lease_path(cache_dir),
8667 Duration::ZERO,
8668 ) {
8669 Ok(lease) => lease,
8670 Err(_) => return CallgraphRootCandidate::SkippedLease,
8671 };
8672 if crate::root_cache::sweep_all_read_markers(cache_dir).protected {
8673 return CallgraphRootCandidate::SkippedReader;
8674 }
8675 if let Some(current) = read_pointer(cache_dir, project_key) {
8676 gc_old_generations(cache_dir, project_key, ¤t);
8677 return CallgraphRootCandidate::GenerationGc;
8678 }
8679 if memo_referenced {
8680 CallgraphRootCandidate::SkippedMemo
8681 } else {
8682 CallgraphRootCandidate::SkippedDerived
8683 }
8684}
8685
8686fn callgraph_root_file_stats(
8687 cache_dir: &Path,
8688 boundary: &crate::walk_boundary::DeviceBoundary,
8689 deadline: Instant,
8690) -> CallgraphRootWalk {
8691 let metadata = match std::fs::metadata(cache_dir) {
8692 Ok(metadata) => metadata,
8693 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
8694 return CallgraphRootWalk::Complete(CallgraphRootFileStats::default());
8695 }
8696 Err(_) => return CallgraphRootWalk::Failed,
8697 };
8698 let mut stats = CallgraphRootFileStats {
8699 newest: metadata.modified().ok(),
8700 bytes: 0,
8701 };
8702 match callgraph_root_file_stats_inner(cache_dir, boundary, deadline, &mut stats) {
8703 Ok(()) => CallgraphRootWalk::Complete(stats),
8704 Err(CallgraphRootWalkError::BudgetExceeded) => CallgraphRootWalk::BudgetExceeded,
8705 Err(CallgraphRootWalkError::Failed) => CallgraphRootWalk::Failed,
8706 }
8707}
8708
8709enum CallgraphRootWalkError {
8710 BudgetExceeded,
8711 Failed,
8712}
8713
8714fn callgraph_root_file_stats_inner(
8715 directory: &Path,
8716 boundary: &crate::walk_boundary::DeviceBoundary,
8717 deadline: Instant,
8718 stats: &mut CallgraphRootFileStats,
8719) -> std::result::Result<(), CallgraphRootWalkError> {
8720 if Instant::now() >= deadline {
8721 return Err(CallgraphRootWalkError::BudgetExceeded);
8722 }
8723 let entries = std::fs::read_dir(directory).map_err(|_| CallgraphRootWalkError::Failed)?;
8724 for entry in entries {
8725 if Instant::now() >= deadline {
8726 return Err(CallgraphRootWalkError::BudgetExceeded);
8727 }
8728 let entry = entry.map_err(|_| CallgraphRootWalkError::Failed)?;
8729 let file_type = entry
8730 .file_type()
8731 .map_err(|_| CallgraphRootWalkError::Failed)?;
8732 if file_type.is_symlink() {
8733 return Err(CallgraphRootWalkError::Failed);
8734 }
8735 let path = entry.path();
8736 if file_type.is_dir() {
8737 if !boundary
8738 .should_descend(&path)
8739 .map_err(|_| CallgraphRootWalkError::Failed)?
8740 {
8741 return Err(CallgraphRootWalkError::Failed);
8742 }
8743 let metadata = entry
8744 .metadata()
8745 .map_err(|_| CallgraphRootWalkError::Failed)?;
8746 merge_newest_callgraph_root_mtime(stats, metadata.modified().ok());
8747 callgraph_root_file_stats_inner(&path, boundary, deadline, stats)?;
8748 continue;
8749 }
8750 if !file_type.is_file() {
8751 return Err(CallgraphRootWalkError::Failed);
8752 }
8753 let metadata = entry
8754 .metadata()
8755 .map_err(|_| CallgraphRootWalkError::Failed)?;
8756 stats.bytes = stats.bytes.saturating_add(metadata.len());
8757 merge_newest_callgraph_root_mtime(stats, metadata.modified().ok());
8758 }
8759 Ok(())
8760}
8761
8762fn merge_newest_callgraph_root_mtime(
8763 stats: &mut CallgraphRootFileStats,
8764 modified: Option<SystemTime>,
8765) {
8766 if let Some(modified) = modified {
8767 if stats.newest.is_none_or(|newest| modified > newest) {
8768 stats.newest = Some(modified);
8769 }
8770 }
8771}
8772
8773fn artifact_key_looks_valid(key: &str) -> bool {
8774 key.len() == 16 && key.bytes().all(|byte| byte.is_ascii_hexdigit())
8775}
8776
8777#[cfg(test)]
8778fn reset_callgraph_root_sweep_cursor_for_test() {
8779 if let Some(cursors) = CALLGRAPH_ROOT_SWEEP_CURSORS.get() {
8780 cursors.lock().unwrap().clear();
8781 }
8782}
8783
8784const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
8798
8799fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
8811 sweep_orphaned_build_temps(callgraph_dir);
8812 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
8813 return;
8814 };
8815 let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
8816 let Ok(boundary) = crate::walk_boundary::DeviceBoundary::for_root(&storage_root) else {
8820 crate::slog_warn!(
8821 "cannot establish filesystem boundary for callgraph sweep {}",
8822 storage_root.display()
8823 );
8824 return;
8825 };
8826 let mut skipped_foreign_mounts = 0usize;
8827
8828 let root_keyed_dir = storage_root.join(domain);
8830 if root_keyed_dir.is_dir() {
8831 if boundary.should_descend(&root_keyed_dir).unwrap_or(false) {
8832 if let Ok(entries) = std::fs::read_dir(&root_keyed_dir) {
8833 for entry in entries.flatten() {
8834 let path = entry.path();
8835 if path.is_dir() {
8836 if boundary.should_descend(&path).unwrap_or(false) {
8837 sweep_orphaned_build_temps(&path);
8838 } else {
8839 skipped_foreign_mounts += 1;
8840 }
8841 }
8842 }
8843 }
8844 } else {
8845 skipped_foreign_mounts += 1;
8846 }
8847 }
8848
8849 if let Ok(entries) = std::fs::read_dir(&storage_root) {
8851 for entry in entries.flatten() {
8852 let harness_dir = entry.path();
8853 if !harness_dir.is_dir() {
8854 continue;
8855 }
8856 if !boundary.should_descend(&harness_dir).unwrap_or(false) {
8857 skipped_foreign_mounts += 1;
8858 continue;
8859 }
8860 let legacy_dir = harness_dir.join(domain);
8861 if legacy_dir.is_dir() {
8862 if boundary.should_descend(&legacy_dir).unwrap_or(false) {
8863 sweep_orphaned_build_temps(&legacy_dir);
8864 } else {
8865 skipped_foreign_mounts += 1;
8866 }
8867 }
8868 }
8869 }
8870 if skipped_foreign_mounts > 0 {
8871 crate::slog_warn!(
8872 "callgraph sweep skipped {} foreign filesystem mount(s) below {}",
8873 skipped_foreign_mounts,
8874 storage_root.display()
8875 );
8876 }
8877}
8878
8879fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
8882 sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
8883}
8884
8885fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
8888 let now = SystemTime::now();
8889 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
8890 return;
8891 };
8892 let mut removed_any = false;
8893 for entry in entries.flatten() {
8894 let name = entry.file_name().to_string_lossy().to_string();
8895 if !name.contains(".sqlite.tmp.") {
8901 continue;
8902 }
8903 let mtime = entry
8904 .metadata()
8905 .and_then(|meta| meta.modified())
8906 .unwrap_or(now);
8907 if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
8908 continue;
8909 }
8910 match std::fs::remove_file(entry.path()) {
8916 Ok(()) => removed_any = true,
8917 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
8918 Err(_) => {}
8919 }
8920 }
8921 if removed_any {
8922 crate::fs_lock::sync_parent(callgraph_dir);
8923 }
8924}
8925
8926fn build_pool_size() -> usize {
8934 std::thread::available_parallelism()
8935 .map(|parallelism| parallelism.get())
8936 .unwrap_or(1)
8937 .div_ceil(2)
8938 .clamp(1, 8)
8939}
8940
8941fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
8942 let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
8943 Ok(extract) => Ok(extract),
8944 Err(error) => {
8945 let abs_path =
8946 normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
8947 let rel_path = relative_path(project_root, &abs_path);
8948 let freshness = cache_freshness::collect(&abs_path).ok();
8949 log::debug!(
8950 "callgraph store: skipping {} during cold build: {}",
8951 abs_path.display(),
8952 error
8953 );
8954 Err(ExtractFailure {
8955 rel_path,
8956 freshness,
8957 })
8958 }
8959 };
8960
8961 let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
8962 files.par_iter().map(extract_one).collect()
8963 };
8964
8965 let results = match rayon::ThreadPoolBuilder::new()
8968 .num_threads(build_pool_size())
8969 .thread_name(|index| format!("aft-callgraph-build-{index}"))
8970 .stack_size(8 * 1024 * 1024)
8971 .build()
8972 {
8973 Ok(pool) => pool.install(run),
8974 Err(error) => {
8975 log::warn!(
8976 "callgraph store: bounded build pool unavailable ({error}); using global pool"
8977 );
8978 run()
8979 }
8980 };
8981
8982 let mut extracts = Vec::new();
8983 let mut failures = Vec::new();
8984 for result in results {
8985 match result {
8986 Ok(extract) => extracts.push(extract),
8987 Err(failure) => failures.push(failure),
8988 }
8989 }
8990 BuildExtractsResult { extracts, failures }
8991}
8992
8993fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
8994 let metadata = std::fs::metadata(path)?;
8995 let size = metadata.len();
8996 let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
8997 cache_freshness::zero_hash()
8998 } else if source.len() as u64 == size {
8999 cache_freshness::hash_bytes(source.as_bytes())
9000 } else {
9001 cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
9002 };
9003 Ok(FileFreshness {
9004 mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
9005 size,
9006 content_hash,
9007 })
9008}
9009
9010fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
9011 let abs_path = normalize_file_path(project_root, path)?;
9012 let rel_path = relative_path(project_root, &abs_path);
9013 let source = std::fs::read_to_string(&abs_path)?;
9014 let freshness = collect_source_freshness(&abs_path, &source)?;
9015 let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
9016 let lang = data.lang;
9017 if lang == LangId::Rust {
9018 extend_rust_imports_with_nested_uses(&source, &mut data);
9019 }
9020 let mut nodes = build_node_records(&rel_path, &source, &data)?;
9021 let node_by_scoped: HashMap<String, String> = nodes
9022 .iter()
9023 .map(|node| (node.scoped_name.clone(), node.id.clone()))
9024 .collect();
9025 let import_dependencies = import_dependencies(
9026 project_root,
9027 &abs_path,
9028 &data.import_block.imports,
9029 &FactPaths {
9030 root: project_root,
9031 facts: &DiskFacts::new(project_root),
9032 },
9033 );
9034 let line_index = LineIndex::new(&source);
9035 let reexports = collect_reexport_refs(
9036 project_root,
9037 &abs_path,
9038 &rel_path,
9039 &source,
9040 &FactPaths {
9041 root: project_root,
9042 facts: &DiskFacts::new(project_root),
9043 },
9044 );
9045 let rust_reexports = if lang == LangId::Rust {
9046 collect_rust_pub_use_reexport_refs(
9047 project_root,
9048 &abs_path,
9049 &rel_path,
9050 &data.import_block.imports,
9051 &line_index,
9052 &FactPaths {
9053 root: project_root,
9054 facts: &DiskFacts::new(project_root),
9055 },
9056 )
9057 } else {
9058 ReexportRefs {
9059 raw_refs: Vec::new(),
9060 surface_parts: Vec::new(),
9061 }
9062 };
9063 let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
9064 let mut raw_refs = Vec::new();
9065 raw_refs.extend(build_call_refs(
9066 &rel_path,
9067 &data,
9068 &node_by_scoped,
9069 &import_dependencies,
9070 ));
9071 raw_refs.extend(build_value_ref_refs(
9072 &rel_path,
9073 &data,
9074 &node_by_scoped,
9075 &import_dependencies,
9076 ));
9077 raw_refs.extend(build_import_refs(
9078 project_root,
9079 &abs_path,
9080 &rel_path,
9081 &data.import_block.imports,
9082 &line_index,
9083 &FactPaths {
9084 root: project_root,
9085 facts: &DiskFacts::new(project_root),
9086 },
9087 ));
9088 if lang == LangId::Rust {
9089 raw_refs.extend(build_rust_module_refs(
9090 project_root,
9091 &abs_path,
9092 &rel_path,
9093 &source,
9094 &FactPaths {
9095 root: project_root,
9096 facts: &DiskFacts::new(project_root),
9097 },
9098 ));
9099 }
9100 let mut surface_parts = reexports.surface_parts;
9101 surface_parts.extend(rust_reexports.surface_parts);
9102 surface_parts.extend(source_less_exports.surface_parts);
9103 raw_refs.extend(reexports.raw_refs);
9104 raw_refs.extend(rust_reexports.raw_refs);
9105 raw_refs.extend(source_less_exports.raw_refs);
9106 let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
9107 let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
9108
9109 Ok(FileExtract {
9110 rel_path,
9111 freshness,
9112 lang,
9113 data,
9114 nodes,
9115 raw_refs,
9116 dispatch_hints,
9117 surface_fingerprint,
9118 })
9119}
9120
9121fn build_node_records(
9122 rel_path: &str,
9123 source: &str,
9124 data: &FileCallData,
9125) -> Result<Vec<NodeRecord>> {
9126 let mut records = Vec::new();
9127 let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
9128 let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
9129 metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
9130
9131 for (scoped_name, meta) in metadata {
9132 let name = unqualified_name(scoped_name).to_string();
9133 let range = selection_range(source, scoped_name, &name, &meta.range);
9134 let range_key = (
9135 range.start_line,
9136 range.start_col,
9137 range.end_line,
9138 range.end_col,
9139 );
9140 let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
9141 let range_ordinal = *ordinal;
9142 *ordinal += 1;
9143 let id = node_id(rel_path, &range, range_ordinal, scoped_name);
9144 let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
9145 let is_default_export = data
9146 .default_export_symbol
9147 .as_deref()
9148 .map(|default| default == scoped_name || default == name)
9149 .unwrap_or(false);
9150 records.push(NodeRecord {
9151 id,
9152 file_path: rel_path.to_string(),
9153 name: name.clone(),
9154 scoped_name: scoped_name.clone(),
9155 kind: symbol_kind_label(&meta.kind).to_string(),
9156 range,
9157 range_ordinal,
9158 signature: meta.signature.clone(),
9159 exported,
9160 is_default_export,
9161 is_type_like: is_type_like(&meta.kind),
9162 is_callgraph_entry_point: meta.entry_point_attribute.is_some()
9163 || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
9164 });
9165 }
9166
9167 Ok(records)
9168}
9169
9170fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
9171 if scoped_name == TOP_LEVEL_SYMBOL {
9172 return Range {
9173 start_line: 0,
9174 start_col: 0,
9175 end_line: 0,
9176 end_col: 0,
9177 };
9178 }
9179 let Some(line) = source.lines().nth(fallback.start_line as usize) else {
9180 return fallback.clone();
9181 };
9182 let start_col = fallback.start_col as usize;
9183 let search_start = start_col.min(line.len());
9184 if let Some(offset) = line[search_start..].find(name) {
9185 let col = search_start + offset;
9186 return Range {
9187 start_line: fallback.start_line,
9188 start_col: col as u32,
9189 end_line: fallback.start_line,
9190 end_col: (col + name.len()) as u32,
9191 };
9192 }
9193 if let Some(offset) = line.find(name) {
9194 return Range {
9195 start_line: fallback.start_line,
9196 start_col: offset as u32,
9197 end_line: fallback.start_line,
9198 end_col: (offset + name.len()) as u32,
9199 };
9200 }
9201 Range {
9202 start_line: fallback.start_line,
9203 start_col: fallback.start_col,
9204 end_line: fallback.start_line,
9205 end_col: fallback.start_col.saturating_add(name.len() as u32),
9206 }
9207}
9208
9209fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
9210 if scoped_name == TOP_LEVEL_SYMBOL {
9211 return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
9212 }
9213 let input = format!(
9214 "{rel_path}:{}:{}:{}:{}:{ordinal}",
9215 range.start_line, range.start_col, range.end_line, range.end_col
9216 );
9217 format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
9218}
9219
9220fn build_call_refs(
9221 rel_path: &str,
9222 data: &FileCallData,
9223 node_by_scoped: &HashMap<String, String>,
9224 import_dependencies: &BTreeSet<String>,
9225) -> Vec<RawRef> {
9226 build_callable_refs(
9227 rel_path,
9228 &data.calls_by_symbol,
9229 node_by_scoped,
9230 import_dependencies,
9231 "call",
9232 )
9233}
9234
9235fn build_value_ref_refs(
9236 rel_path: &str,
9237 data: &FileCallData,
9238 node_by_scoped: &HashMap<String, String>,
9239 import_dependencies: &BTreeSet<String>,
9240) -> Vec<RawRef> {
9241 build_callable_refs(
9242 rel_path,
9243 &data.value_refs_by_symbol,
9244 node_by_scoped,
9245 import_dependencies,
9246 "value_ref",
9247 )
9248}
9249
9250fn build_callable_refs(
9251 rel_path: &str,
9252 sites_by_symbol: &HashMap<String, Vec<callgraph::CallSite>>,
9253 node_by_scoped: &HashMap<String, String>,
9254 import_dependencies: &BTreeSet<String>,
9255 kind: &str,
9256) -> Vec<RawRef> {
9257 let mut refs = Vec::new();
9258 let mut ordinal = 0usize;
9259 let mut symbols: Vec<_> = sites_by_symbol.iter().collect();
9260 symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
9261 for (caller_symbol, call_sites) in symbols {
9262 let caller_node = node_by_scoped.get(caller_symbol).cloned();
9263 for call_site in call_sites {
9264 ordinal += 1;
9265 let ref_id = ref_id(&[
9266 rel_path,
9267 kind,
9268 caller_symbol,
9269 &call_site.line.to_string(),
9270 &call_site.byte_start.to_string(),
9271 &call_site.byte_end.to_string(),
9272 &call_site.full_callee,
9273 &ordinal.to_string(),
9274 ]);
9275 refs.push(RawRef {
9276 ref_id,
9277 caller_node: caller_node.clone(),
9278 caller_symbol: Some(caller_symbol.clone()),
9279 caller_file: rel_path.to_string(),
9280 kind: kind.to_string(),
9281 short_name: Some(call_site.callee_name.clone()),
9282 full_ref: Some(call_site.full_callee.clone()),
9283 module_path: None,
9284 import_kind: None,
9285 local_name: Some(call_site.callee_name.clone()),
9286 requested_name: Some(call_site.callee_name.clone()),
9287 namespace_alias: namespace_alias(&call_site.full_callee),
9288 wildcard: false,
9289 line: call_site.line,
9290 byte_start: call_site.byte_start,
9291 byte_end: call_site.byte_end,
9292 dependencies: import_dependencies.clone(),
9293 });
9294 }
9295 }
9296 refs
9297}
9298
9299fn build_import_refs(
9300 project_root: &Path,
9301 abs_path: &Path,
9302 rel_path: &str,
9303 imports: &[ImportStatement],
9304 line_index: &LineIndex,
9305 facts: &FactPaths<'_>,
9306) -> Vec<RawRef> {
9307 let mut refs = Vec::new();
9308 for (index, import) in imports.iter().enumerate() {
9309 let import_kind = import_kind_label(import.kind).to_string();
9310 let local_name = import_local_names(import).join(",");
9311 let requested_name = import_requested_names(import).join(",");
9312 let ref_id = ref_id(&[
9313 rel_path,
9314 "import",
9315 &import.byte_range.start.to_string(),
9316 &import.byte_range.end.to_string(),
9317 &import.module_path,
9318 &index.to_string(),
9319 ]);
9320 refs.push(RawRef {
9321 ref_id,
9322 caller_node: None,
9323 caller_symbol: None,
9324 caller_file: rel_path.to_string(),
9325 kind: "import".to_string(),
9326 short_name: None,
9327 full_ref: Some(import.raw_text.clone()),
9328 module_path: Some(import.module_path.clone()),
9329 import_kind: Some(import_kind),
9330 local_name: empty_to_none(local_name),
9331 requested_name: empty_to_none(requested_name),
9332 namespace_alias: import.namespace_import.clone(),
9333 wildcard: import_is_wildcard(import),
9334 line: line_index.byte_to_line(import.byte_range.start),
9335 byte_start: import.byte_range.start,
9336 byte_end: import.byte_range.end,
9337 dependencies: module_dependencies(project_root, abs_path, &import.module_path, facts),
9338 });
9339 }
9340 refs
9341}
9342
9343fn build_rust_module_refs(
9344 project_root: &Path,
9345 abs_path: &Path,
9346 rel_path: &str,
9347 source: &str,
9348 facts: &FactPaths<'_>,
9349) -> Vec<RawRef> {
9350 let grammar = grammar_for(LangId::Rust);
9351 let mut parser = Parser::new();
9352 if parser.set_language(&grammar).is_err() {
9353 return Vec::new();
9354 }
9355 let Some(tree) = parser.parse(source, None) else {
9356 return Vec::new();
9357 };
9358
9359 let mut refs = Vec::new();
9360 let mut stack = vec![tree.root_node()];
9361 while let Some(node) = stack.pop() {
9362 if node.kind() == "mod_item"
9363 && node
9364 .named_children(&mut node.walk())
9365 .all(|child| child.kind() != "declaration_list")
9366 {
9367 if let Some(name_node) = node.child_by_field_name("name") {
9368 let module_name = node_text(name_node, source).to_string();
9369 let target = rust_external_module_target(
9370 abs_path,
9371 rust_module_path_override(source, node),
9372 &module_name,
9373 facts,
9374 );
9375 let mut dependencies = BTreeSet::new();
9376 if let Some(target) = target {
9377 dependencies.insert(relative_path(project_root, &canonicalize_path(&target)));
9378 }
9379 refs.push(RawRef {
9380 ref_id: ref_id(&[
9381 rel_path,
9382 "module",
9383 &module_name,
9384 &node.start_byte().to_string(),
9385 ]),
9386 caller_node: None,
9387 caller_symbol: None,
9388 caller_file: rel_path.to_string(),
9389 kind: "module".to_string(),
9390 short_name: Some(module_name.clone()),
9391 full_ref: Some(module_name.clone()),
9392 module_path: Some(module_name.clone()),
9393 import_kind: Some("module".to_string()),
9394 local_name: Some(module_name.clone()),
9395 requested_name: Some(module_name),
9396 namespace_alias: None,
9397 wildcard: false,
9398 line: node.start_position().row as u32 + 1,
9399 byte_start: node.start_byte(),
9400 byte_end: node.end_byte(),
9401 dependencies,
9402 });
9403 }
9404 }
9405
9406 let mut cursor = node.walk();
9407 if cursor.goto_first_child() {
9408 loop {
9409 stack.push(cursor.node());
9410 if !cursor.goto_next_sibling() {
9411 break;
9412 }
9413 }
9414 }
9415 }
9416 refs.sort_by_key(|raw| (raw.byte_start, raw.byte_end));
9417 refs
9418}
9419
9420fn rust_declared_module_target(
9421 project_root: &Path,
9422 caller_file: &str,
9423 module_name: &str,
9424 memo: &callgraph::ModuleResolutionMemo,
9425 facts: &FactPaths<'_>,
9426) -> Option<String> {
9427 memo.rust_declared_module_target(caller_file, module_name, || {
9428 rust_declared_module_targets(project_root, caller_file, facts)
9429 })
9430}
9431
9432fn rust_declared_module_targets(
9433 project_root: &Path,
9434 caller_file: &str,
9435 facts: &FactPaths<'_>,
9436) -> HashMap<String, Option<String>> {
9437 let declaring_file = project_root.join(caller_file);
9438 let Ok(source) = std::fs::read_to_string(&declaring_file) else {
9439 return HashMap::new();
9440 };
9441 let Ok(tree) = parse_source_with_cached_parser(&declaring_file, &source, LangId::Rust) else {
9442 return HashMap::new();
9443 };
9444 let mut targets = HashMap::new();
9445 let mut stack = vec![tree.root_node()];
9446 while let Some(node) = stack.pop() {
9447 if node.kind() == "mod_item"
9448 && node
9449 .named_children(&mut node.walk())
9450 .all(|child| child.kind() != "declaration_list")
9451 {
9452 if let Some(name) = node.child_by_field_name("name") {
9453 let module_name = node_text(name, &source);
9454 let target = rust_external_module_target(
9455 &declaring_file,
9456 rust_module_path_override(&source, node),
9457 module_name,
9458 facts,
9459 )
9460 .map(|target| {
9461 relative_path(
9462 project_root,
9463 &facts.canonical(&target).unwrap_or(target.clone()),
9464 )
9465 });
9466 targets.entry(module_name.to_string()).or_insert(target);
9467 }
9468 }
9469 let mut cursor = node.walk();
9470 if cursor.goto_first_child() {
9471 loop {
9472 stack.push(cursor.node());
9473 if !cursor.goto_next_sibling() {
9474 break;
9475 }
9476 }
9477 }
9478 }
9479 targets
9480}
9481
9482fn rust_external_module_target(
9483 declaring_file: &Path,
9484 path_override: Option<&str>,
9485 module_name: &str,
9486 facts: &FactPaths<'_>,
9487) -> Option<PathBuf> {
9488 let parent = declaring_file.parent()?;
9489 if let Some(path) = path_override {
9490 let candidate = parent.join(path);
9491 return facts.is_file(&candidate).then_some(candidate);
9492 }
9493
9494 let stem = declaring_file.file_stem().and_then(|stem| stem.to_str())?;
9495 let declaring_file = facts
9496 .canonical(declaring_file)
9497 .unwrap_or_else(|| declaring_file.to_path_buf());
9498 let is_crate_root = callgraph::rust_crate_root_file_for_caller(
9499 facts.root,
9500 &declaring_file,
9501 facts,
9502 &callgraph::RustCrateRootMemo::default(),
9503 )
9504 .as_ref()
9505 == Some(&declaring_file);
9506 let module_dir = if matches!(stem, "lib" | "main" | "mod") || is_crate_root {
9507 parent.to_path_buf()
9508 } else {
9509 parent.join(stem)
9510 };
9511 [
9512 module_dir.join(format!("{module_name}.rs")),
9513 module_dir.join(module_name).join("mod.rs"),
9514 ]
9515 .into_iter()
9516 .find(|candidate| facts.is_file(candidate))
9517}
9518
9519fn rust_module_path_override<'a>(source: &'a str, module: Node<'_>) -> Option<&'a str> {
9520 let mut previous = module.prev_sibling();
9521 while let Some(attribute) = previous {
9522 if attribute.kind() != "attribute_item" {
9523 break;
9524 }
9525 if let Some(path) = rust_path_attribute(source.get(attribute.byte_range())?) {
9526 return Some(path);
9527 }
9528 previous = attribute.prev_sibling();
9529 }
9530 None
9531}
9532
9533fn rust_path_attribute(attribute: &str) -> Option<&str> {
9534 let body = attribute.trim().strip_prefix("#[")?.strip_suffix(']')?;
9535 let (name, value) = body.split_once('=')?;
9536 (name.trim() == "path")
9537 .then(|| value.trim().trim_matches('"'))
9538 .filter(|path| !path.is_empty())
9539}
9540
9541fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
9542 let grammar = grammar_for(LangId::Rust);
9543 let mut parser = Parser::new();
9544 if parser.set_language(&grammar).is_err() {
9545 return;
9546 }
9547 let Some(tree) = parser.parse(source, None) else {
9548 return;
9549 };
9550
9551 let mut seen = data
9552 .import_block
9553 .imports
9554 .iter()
9555 .map(|import| (import.byte_range.start, import.byte_range.end))
9556 .collect::<HashSet<_>>();
9557 let mut nested_imports = Vec::new();
9558 collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
9559 if nested_imports.is_empty() {
9560 return;
9561 }
9562
9563 data.import_block.imports.extend(nested_imports);
9564 data.import_block
9565 .imports
9566 .sort_by_key(|import| import.byte_range.start);
9567 data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
9568}
9569
9570fn collect_rust_use_imports(
9571 source: &str,
9572 node: Node<'_>,
9573 seen: &mut HashSet<(usize, usize)>,
9574 imports: &mut Vec<ImportStatement>,
9575) {
9576 if node.kind() == "use_declaration" {
9577 let range = node.byte_range();
9578 if seen.insert((range.start, range.end)) {
9579 if let Some(import) = rust_import_from_use_node(source, node) {
9580 imports.push(import);
9581 }
9582 }
9583 }
9584
9585 let mut cursor = node.walk();
9586 if !cursor.goto_first_child() {
9587 return;
9588 }
9589 loop {
9590 collect_rust_use_imports(source, cursor.node(), seen, imports);
9591 if !cursor.goto_next_sibling() {
9592 break;
9593 }
9594 }
9595}
9596
9597fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
9598 let raw_text = source[node.byte_range()].to_string();
9599 let body = rust_use_body(&raw_text)?.to_string();
9600 let visibility = rust_use_visibility(&raw_text);
9601 let names = rust_use_list_names(&body);
9602 let group = classify_rust_import_group(&body);
9603 let byte_range = node.byte_range();
9604
9605 Some(ImportStatement {
9606 module_path: body,
9607 names: names.clone(),
9608 default_import: visibility.clone(),
9609 namespace_import: None,
9610 kind: ImportKind::Value,
9611 group,
9612 byte_range,
9613 raw_text,
9614 form: ImportForm::RustUse {
9615 visibility,
9616 named: names,
9617 },
9618 })
9619}
9620
9621fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
9622 let start = imports.iter().map(|import| import.byte_range.start).min()?;
9623 let end = imports.iter().map(|import| import.byte_range.end).max()?;
9624 Some(start..end)
9625}
9626
9627fn rust_use_visibility(raw_text: &str) -> Option<String> {
9628 let use_pos = raw_text.find("use ")?;
9629 let prefix = raw_text[..use_pos].trim();
9630 if prefix.is_empty() {
9631 None
9632 } else {
9633 Some(prefix.to_string())
9634 }
9635}
9636
9637fn rust_use_body(raw_text: &str) -> Option<&str> {
9638 let use_pos = raw_text.find("use ")?;
9639 Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
9640}
9641
9642fn rust_use_list_names(body: &str) -> Vec<String> {
9643 let Some(open) = body.find("::{") else {
9644 return Vec::new();
9645 };
9646 let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
9647 return Vec::new();
9648 };
9649 body[open + 3..close]
9650 .split(',')
9651 .filter_map(|spec| {
9652 let spec = spec.trim();
9653 if spec.is_empty() {
9654 None
9655 } else {
9656 Some(spec.to_string())
9657 }
9658 })
9659 .collect()
9660}
9661
9662fn classify_rust_import_group(body: &str) -> ImportGroup {
9663 let first = body
9664 .split("::")
9665 .next()
9666 .unwrap_or(body)
9667 .split_whitespace()
9668 .next()
9669 .unwrap_or(body);
9670 match first.trim() {
9671 "std" | "core" | "alloc" => ImportGroup::Stdlib,
9672 "crate" | "self" | "super" => ImportGroup::Internal,
9673 _ => ImportGroup::External,
9674 }
9675}
9676
9677#[derive(Debug, Clone)]
9678struct ReexportRefs {
9679 raw_refs: Vec<RawRef>,
9680 surface_parts: Vec<String>,
9681}
9682
9683fn collect_reexport_refs(
9684 project_root: &Path,
9685 abs_path: &Path,
9686 rel_path: &str,
9687 source: &str,
9688 facts: &FactPaths<'_>,
9689) -> ReexportRefs {
9690 let mut raw_refs = Vec::new();
9691 let mut surface_parts = Vec::new();
9692 let mut search_start = 0usize;
9693 let mut ordinal = 0usize;
9694 while let Some(export_offset) = source[search_start..].find("export") {
9695 let start = search_start + export_offset;
9696 let Some(statement_end_offset) = source[start..].find(';') else {
9697 break;
9698 };
9699 let end = start + statement_end_offset + 1;
9700 let statement = &source[start..end];
9701 search_start = end;
9702 if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
9703 continue;
9704 }
9705 let Some(module_path) = quoted_module_path(statement) else {
9706 continue;
9707 };
9708 ordinal += 1;
9709 let wildcard = statement.contains('*');
9710 let line = source[..start]
9711 .bytes()
9712 .filter(|byte| *byte == b'\n')
9713 .count() as u32
9714 + 1;
9715 let ref_id = ref_id(&[
9716 rel_path,
9717 "reexport",
9718 &start.to_string(),
9719 &end.to_string(),
9720 &module_path,
9721 &ordinal.to_string(),
9722 ]);
9723 surface_parts.push(format!("reexport\t{statement}"));
9724 raw_refs.push(RawRef {
9725 ref_id,
9726 caller_node: None,
9727 caller_symbol: None,
9728 caller_file: rel_path.to_string(),
9729 kind: "reexport".to_string(),
9730 short_name: None,
9731 full_ref: Some(statement.to_string()),
9732 module_path: Some(module_path.clone()),
9733 import_kind: Some("reexport".to_string()),
9734 local_name: None,
9735 requested_name: None,
9736 namespace_alias: None,
9737 wildcard,
9738 line,
9739 byte_start: start,
9740 byte_end: end,
9741 dependencies: module_dependencies(project_root, abs_path, &module_path, facts),
9742 });
9743 }
9744 ReexportRefs {
9745 raw_refs,
9746 surface_parts,
9747 }
9748}
9749
9750fn collect_rust_pub_use_reexport_refs(
9751 project_root: &Path,
9752 abs_path: &Path,
9753 rel_path: &str,
9754 imports: &[ImportStatement],
9755 line_index: &LineIndex,
9756 facts: &FactPaths<'_>,
9757) -> ReexportRefs {
9758 let mut raw_refs = Vec::new();
9759 let mut surface_parts = Vec::new();
9760 let mut ordinal = 0usize;
9761
9762 for import in imports {
9763 let Some(visibility) = &import.default_import else {
9764 continue;
9765 };
9766 if !visibility.starts_with("pub") {
9767 continue;
9768 }
9769 let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
9770 continue;
9771 };
9772 ordinal += 1;
9773 let ref_id = ref_id(&[
9774 rel_path,
9775 "rust_reexport",
9776 &import.byte_range.start.to_string(),
9777 &import.byte_range.end.to_string(),
9778 &module_path,
9779 &ordinal.to_string(),
9780 ]);
9781 surface_parts.push(format!("reexport\t{}", import.raw_text));
9782 raw_refs.push(RawRef {
9783 ref_id,
9784 caller_node: None,
9785 caller_symbol: None,
9786 caller_file: rel_path.to_string(),
9787 kind: "reexport".to_string(),
9788 short_name: None,
9789 full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
9790 module_path: Some(module_path.clone()),
9791 import_kind: Some("reexport".to_string()),
9792 local_name: None,
9793 requested_name: None,
9794 namespace_alias: None,
9795 wildcard,
9796 line: line_index.byte_to_line(import.byte_range.start),
9797 byte_start: import.byte_range.start,
9798 byte_end: import.byte_range.end,
9799 dependencies: rust_module_dependencies(project_root, abs_path, &module_path, facts),
9800 });
9801 }
9802
9803 ReexportRefs {
9804 raw_refs,
9805 surface_parts,
9806 }
9807}
9808
9809fn rust_pub_use_reexport_parts(
9810 import: &ImportStatement,
9811) -> Option<(String, HashMap<String, String>, bool)> {
9812 let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
9813 let body = body.trim();
9814 if let Some(module_path) = body.strip_suffix("::*") {
9815 return Some((module_path.trim().to_string(), HashMap::new(), true));
9816 }
9817
9818 if let Some(brace_start) = body.find("::{") {
9819 let module_path = body[..brace_start].trim().to_string();
9820 let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
9821 if names.is_empty() {
9822 return None;
9823 }
9824 return Some((module_path, names, false));
9825 }
9826
9827 let (module_path, spec) = body.rsplit_once("::")?;
9828 let names = rust_reexport_names_from_specs(spec);
9829 if names.is_empty() {
9830 return None;
9831 }
9832 Some((module_path.trim().to_string(), names, false))
9833}
9834
9835fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
9836 let mut names = HashMap::new();
9837 for spec in specs.split(',') {
9838 let spec = spec.trim();
9839 if spec.is_empty() || spec == "self" {
9840 continue;
9841 }
9842 if let Some((source, local)) = spec.split_once(" as ") {
9843 let source = source.trim();
9844 let local = local.trim();
9845 if !source.is_empty() && !local.is_empty() && source != "self" {
9846 names.insert(local.to_string(), source.to_string());
9847 }
9848 } else {
9849 names.insert(spec.to_string(), spec.to_string());
9850 }
9851 }
9852 names
9853}
9854
9855fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
9856 if named.is_empty() {
9857 return fallback.to_string();
9858 }
9859 let mut specs = named
9860 .iter()
9861 .map(|(local, source)| {
9862 if local == source {
9863 source.clone()
9864 } else {
9865 format!("{source} as {local}")
9866 }
9867 })
9868 .collect::<Vec<_>>();
9869 specs.sort();
9870 format!("pub use {{{}}};", specs.join(", "))
9871}
9872
9873fn quoted_module_path(statement: &str) -> Option<String> {
9874 let quote = match (statement.find('\''), statement.find('"')) {
9875 (Some(single), Some(double)) if single < double => '\'',
9876 (Some(_), Some(_)) => '"',
9877 (Some(_), None) => '\'',
9878 (None, Some(_)) => '"',
9879 (None, None) => return None,
9880 };
9881 let start = statement.find(quote)? + 1;
9882 let end = statement[start..].find(quote)? + start;
9883 Some(statement[start..end].to_string())
9884}
9885
9886#[derive(Debug, Clone)]
9887struct SourceLessExportRefs {
9888 raw_refs: Vec<RawRef>,
9889 surface_parts: Vec<String>,
9890}
9891
9892fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
9893 let mut raw_refs = Vec::new();
9894 let mut surface_parts = Vec::new();
9895 let mut search_start = 0usize;
9896 let mut ordinal = 0usize;
9897 while let Some(export_offset) = source[search_start..].find("export") {
9898 let start = search_start + export_offset;
9899 let Some(statement_end_offset) = source[start..].find(';') else {
9900 break;
9901 };
9902 let end = start + statement_end_offset + 1;
9903 let statement = &source[start..end];
9904 search_start = end;
9905 if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
9906 continue;
9907 }
9908 let aliases = parse_reexport_names(statement);
9909 if aliases.is_empty() {
9910 continue;
9911 }
9912 let line = source[..start]
9913 .bytes()
9914 .filter(|byte| *byte == b'\n')
9915 .count() as u32
9916 + 1;
9917 for (exported, source_symbol) in aliases {
9918 ordinal += 1;
9919 let ref_id = ref_id(&[
9920 rel_path,
9921 "export_alias",
9922 &start.to_string(),
9923 &end.to_string(),
9924 &exported,
9925 &source_symbol,
9926 &ordinal.to_string(),
9927 ]);
9928 surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
9929 raw_refs.push(RawRef {
9930 ref_id,
9931 caller_node: None,
9932 caller_symbol: None,
9933 caller_file: rel_path.to_string(),
9934 kind: "export_alias".to_string(),
9935 short_name: None,
9936 full_ref: Some(statement.to_string()),
9937 module_path: None,
9938 import_kind: Some("export_alias".to_string()),
9939 local_name: Some(exported),
9940 requested_name: Some(source_symbol),
9941 namespace_alias: None,
9942 wildcard: false,
9943 line,
9944 byte_start: start,
9945 byte_end: end,
9946 dependencies: BTreeSet::new(),
9947 });
9948 }
9949 }
9950 SourceLessExportRefs {
9951 raw_refs,
9952 surface_parts,
9953 }
9954}
9955
9956fn build_dispatch_hints(
9957 rel_path: &str,
9958 data: &FileCallData,
9959 node_by_scoped: &HashMap<String, String>,
9960) -> Vec<DispatchHint> {
9961 let mut hints = Vec::new();
9962 let mut ordinal = 0usize;
9963 let mut calls_by_symbol = data.calls_by_symbol.iter().collect::<Vec<_>>();
9964 calls_by_symbol.sort_unstable_by(|left, right| left.0.cmp(right.0));
9965 for (caller_symbol, call_sites) in calls_by_symbol {
9966 let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
9967 continue;
9968 };
9969 for call_site in call_sites {
9970 if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
9971 continue;
9972 }
9973 ordinal += 1;
9974 hints.push(DispatchHint {
9975 id: ref_id(&[
9976 rel_path,
9977 "dispatch",
9978 caller_symbol,
9979 &call_site.line.to_string(),
9980 &call_site.byte_start.to_string(),
9981 &call_site.byte_end.to_string(),
9982 &ordinal.to_string(),
9983 ]),
9984 method_name: call_site.callee_name.clone(),
9985 caller_node: caller_node.clone(),
9986 file: rel_path.to_string(),
9987 line: call_site.line,
9988 byte_start: call_site.byte_start,
9989 byte_end: call_site.byte_end,
9990 });
9991 }
9992 }
9993 hints
9994}
9995
9996fn surface_fingerprint(
9997 nodes: &mut [NodeRecord],
9998 data: &FileCallData,
9999 reexport_parts: &[String],
10000) -> String {
10001 nodes.sort_by(|left, right| {
10002 (left.file_path.as_str(), left.scoped_name.as_str())
10003 .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
10004 });
10005 let mut parts = Vec::new();
10006 for node in nodes.iter() {
10007 parts.push(format!(
10008 "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
10009 node.scoped_name,
10010 node.name,
10011 node.kind,
10012 node.exported,
10013 node.range.start_line,
10014 node.range.start_col,
10015 node.range.end_line,
10016 node.range.end_col,
10017 node.range_ordinal,
10018 node.signature.as_deref().unwrap_or("")
10019 ));
10020 }
10021 let mut exports = data.exported_symbols.clone();
10022 exports.sort();
10023 for export in exports {
10024 parts.push(format!("export\t{export}"));
10025 }
10026 if let Some(default_export) = &data.default_export_symbol {
10027 parts.push(format!("default\t{default_export}"));
10028 }
10029 let mut imports: Vec<String> = data
10030 .import_block
10031 .imports
10032 .iter()
10033 .map(|import| {
10034 format!(
10035 "import\t{}\t{:?}\t{}",
10036 import.module_path, import.form, import.raw_text
10037 )
10038 })
10039 .collect();
10040 imports.sort();
10041 parts.extend(imports);
10042 parts.extend(reexport_parts.iter().cloned());
10043 hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
10044}
10045
10046fn resolve_ref<I: ResolverIndex>(raw: RawRef, index: &I) -> Result<ResolvedRef> {
10047 if !matches!(raw.kind.as_str(), "call" | "value_ref") {
10048 return Ok(ResolvedRef {
10049 dependencies: raw.dependencies.clone(),
10050 raw,
10051 status: "unresolved".to_string(),
10052 target_node: None,
10053 target_file: None,
10054 target_symbol: None,
10055 edge: None,
10056 });
10057 }
10058
10059 let caller_file = raw.caller_file.clone();
10060 let caller_data =
10061 index
10062 .caller_data(&caller_file)
10063 .ok_or_else(|| CallGraphStoreError::MissingCallerData {
10064 file: caller_file.clone(),
10065 })?;
10066 let full_ref = raw.full_ref.as_deref().unwrap_or_default();
10067 let short_name = raw.short_name.as_deref().unwrap_or_default();
10068 let mut dependencies = raw.dependencies.clone();
10069
10070 let resolved = match index.lang_for(&caller_file) {
10071 Some(LangId::Rust) => {
10072 resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
10073 }
10074 Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
10075 resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
10076 }
10077 _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
10078 };
10079
10080 let Some((status, target_file, target_symbol)) = resolved else {
10081 return Ok(ResolvedRef {
10082 raw,
10083 status: "unresolved".to_string(),
10084 target_node: None,
10085 target_file: None,
10086 target_symbol: None,
10087 dependencies,
10088 edge: None,
10089 });
10090 };
10091
10092 dependencies.insert(target_file.clone());
10093 let target_node = index.node_for_symbol(&target_file, &target_symbol);
10094 if raw.kind == "value_ref"
10095 && !target_node
10096 .as_deref()
10097 .is_some_and(|node_id| index.node_is_callable(&target_file, node_id))
10098 {
10099 return Ok(ResolvedRef {
10100 raw,
10101 status: "unresolved".to_string(),
10102 target_node: None,
10103 target_file: None,
10104 target_symbol: None,
10105 dependencies,
10106 edge: None,
10107 });
10108 }
10109 let source_node = raw.caller_node.clone();
10110 let edge = if let Some(source_node) = source_node {
10111 if target_file == caller_file
10112 && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
10113 {
10114 None
10115 } else {
10116 Some(EdgeRecord {
10117 edge_id: ref_id(&[&raw.ref_id, "edge"]),
10118 source_node,
10119 target_node: target_node.clone(),
10120 target_file: target_file.clone(),
10121 target_symbol: target_symbol.clone(),
10122 kind: raw.kind.clone(),
10123 line: raw.line,
10124 })
10125 }
10126 } else {
10127 None
10128 };
10129
10130 Ok(ResolvedRef {
10131 raw,
10132 status,
10133 target_node,
10134 target_file: Some(target_file),
10135 target_symbol: Some(target_symbol),
10136 dependencies,
10137 edge,
10138 })
10139}
10140
10141fn resolve_js_ts_target<I: ResolverIndex>(
10142 index: &I,
10143 caller_file: &str,
10144 full_ref: &str,
10145 short_name: &str,
10146 caller_data: &FileCallData,
10147) -> Option<(String, String, String)> {
10148 if let Some((namespace, member)) = full_ref.split_once('.') {
10149 for import in &caller_data.import_block.imports {
10150 if import.namespace_import.as_deref() == Some(namespace) {
10151 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
10152 if let Some((file, symbol)) =
10153 resolve_exported_symbol(index, &target_file, member, 0)
10154 {
10155 return Some(("resolved".to_string(), file, symbol));
10156 }
10157 }
10158 }
10159 }
10160 }
10161
10162 for import in &caller_data.import_block.imports {
10163 for spec in &import.names {
10164 if crate::imports::specifier_local_name(spec) == short_name {
10165 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
10166 let requested = crate::imports::specifier_imported_name(spec);
10167 let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
10168 .unwrap_or_else(|| (target_file, requested.to_string()));
10169 return Some(("resolved".to_string(), file, symbol));
10170 }
10171 }
10172 }
10173
10174 if import.default_import.as_deref() == Some(short_name) {
10175 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
10176 let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
10177 .or_else(|| {
10178 index
10179 .default_export(&target_file)
10180 .map(|symbol| (target_file.clone(), symbol))
10181 })
10182 .unwrap_or_else(|| {
10183 let file_name = Path::new(&target_file)
10184 .file_name()
10185 .and_then(|name| name.to_str())
10186 .unwrap_or("unknown")
10187 .to_string();
10188 (target_file, format!("<default:{file_name}>"))
10189 });
10190 return Some(("resolved".to_string(), file, symbol));
10191 }
10192 }
10193 }
10194
10195 for import in &caller_data.import_block.imports {
10196 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
10197 if index.has_export(&target_file, short_name) {
10198 return Some(("resolved".to_string(), target_file, short_name.to_string()));
10199 }
10200 }
10201 }
10202
10203 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
10204}
10205
10206fn resolve_exported_symbol<I: ResolverIndex>(
10207 index: &I,
10208 file: &str,
10209 requested: &str,
10210 depth: usize,
10211) -> Option<(String, String)> {
10212 let mut visited = std::collections::HashMap::new();
10213 resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
10214}
10215
10216fn resolve_exported_symbol_inner<I: ResolverIndex>(
10225 index: &I,
10226 file: &str,
10227 requested: &str,
10228 depth: usize,
10229 visited: &mut std::collections::HashMap<(String, String), usize>,
10230) -> Option<(String, String)> {
10231 if depth > 16 {
10232 return None;
10233 }
10234 if requested != "default" {
10235 if let Some(source_symbol) = index.export_alias(file, requested) {
10236 return Some((file.to_string(), source_symbol));
10237 }
10238 if index.has_export(file, requested) {
10239 return Some((file.to_string(), requested.to_string()));
10240 }
10241 } else if let Some(default) = index.default_export(file) {
10242 return Some((file.to_string(), default));
10243 }
10244
10245 match visited.entry((file.to_string(), requested.to_string())) {
10249 std::collections::hash_map::Entry::Occupied(mut seen) => {
10250 if *seen.get() <= depth {
10251 return None;
10252 }
10253 seen.insert(depth);
10254 }
10255 std::collections::hash_map::Entry::Vacant(slot) => {
10256 slot.insert(depth);
10257 }
10258 }
10259
10260 for reexport in index.reexports_for(file) {
10261 let mut next_requested = requested.to_string();
10262 let matches = if reexport.wildcard {
10263 true
10264 } else if let Some(source_name) = reexport.named.get(requested) {
10265 next_requested = source_name.clone();
10266 true
10267 } else {
10268 false
10269 };
10270 if !matches {
10271 continue;
10272 }
10273 if let Some(target_file) = &reexport.target_file {
10274 if let Some(target) = resolve_exported_symbol_inner(
10275 index,
10276 target_file,
10277 &next_requested,
10278 depth + 1,
10279 visited,
10280 ) {
10281 return Some(target);
10282 }
10283 }
10284 }
10285 None
10286}
10287
10288fn resolve_rust_target<I: ResolverIndex>(
10289 index: &I,
10290 caller_file: &str,
10291 full_ref: &str,
10292 short_name: &str,
10293 caller_data: &FileCallData,
10294 raw: &RawRef,
10295) -> Option<(String, String, String)> {
10296 if full_ref.contains("::") {
10297 if let Some((target_file, target_symbol)) =
10298 rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
10299 {
10300 return Some(("resolved".to_string(), target_file, target_symbol));
10301 }
10302 }
10303
10304 for import in &caller_data.import_block.imports {
10305 if let Some((target_file, target_symbol)) =
10306 rust_target_for_use(index, caller_file, import, short_name)
10307 {
10308 return Some(("resolved".to_string(), target_file, target_symbol));
10309 }
10310 }
10311
10312 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
10313}
10314
10315fn rust_target_for_qualified<I: ResolverIndex>(
10316 index: &I,
10317 caller_file: &str,
10318 full_ref: &str,
10319 short_name: &str,
10320 caller_data: &FileCallData,
10321 raw: &RawRef,
10322) -> Option<(String, String)> {
10323 let mut segments: Vec<&str> = full_ref.split("::").collect();
10324 if segments.len() < 2 {
10325 return None;
10326 }
10327 segments.pop();
10328 let requested_symbol = rust_target_symbol(full_ref, short_name);
10329
10330 for path in rust_module_path_candidates(&segments, caller_data, raw) {
10331 let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
10332 if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
10333 if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
10334 return Some(rust_resolve_reexport_if_symbol_missing(
10335 index,
10336 target_file,
10337 requested_symbol.clone(),
10338 ));
10339 }
10340 }
10341
10342 let module_segments = rust_resolve_segments_with_index(index, caller_file, &path_refs)?;
10343 if let Some(target) =
10344 rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
10345 {
10346 return Some(target);
10347 }
10348 if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
10349 return Some(rust_resolve_reexport_if_symbol_missing(
10350 index,
10351 target_file,
10352 requested_symbol.clone(),
10353 ));
10354 }
10355 }
10356 None
10357}
10358
10359fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
10360 full_ref
10361 .rsplit("::")
10362 .next()
10363 .filter(|name| !name.is_empty())
10364 .unwrap_or(short_name)
10365 .to_string()
10366}
10367
10368fn rust_resolve_reexport_if_symbol_missing<I: ResolverIndex>(
10369 index: &I,
10370 target_file: String,
10371 target_symbol: String,
10372) -> (String, String) {
10373 if index
10374 .node_for_symbol(&target_file, &target_symbol)
10375 .is_some()
10376 {
10377 return (target_file, target_symbol);
10378 }
10379 if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
10380 resolved
10381 } else {
10382 (target_file, target_symbol)
10383 }
10384}
10385
10386fn rust_module_path_candidates(
10387 segments: &[&str],
10388 caller_data: &FileCallData,
10389 raw: &RawRef,
10390) -> Vec<Vec<String>> {
10391 let mut candidates = Vec::new();
10392 if let Some(first) = segments.first().copied() {
10393 for import in &caller_data.import_block.imports {
10394 if !rust_import_is_visible_to_call(import, raw) {
10395 continue;
10396 }
10397 let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
10398 continue;
10399 };
10400 if local_name == first {
10401 path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
10402 rust_push_unique_path_candidate(&mut candidates, path_segments);
10403 }
10404 }
10405 }
10406 rust_push_unique_path_candidate(
10407 &mut candidates,
10408 segments
10409 .iter()
10410 .map(|segment| (*segment).to_string())
10411 .collect(),
10412 );
10413 candidates
10414}
10415
10416fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
10417 if !candidates.iter().any(|existing| existing == &candidate) {
10418 candidates.push(candidate);
10419 }
10420}
10421
10422fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
10423 import.byte_range.start <= raw.byte_start
10424}
10425
10426fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
10427 let path = import.module_path.trim().trim_end_matches(';').trim();
10428 if path.contains("::{") || path.contains('{') || path.contains('*') {
10429 return None;
10430 }
10431 let (path_without_alias, alias) = path
10432 .split_once(" as ")
10433 .map(|(left, right)| (left.trim(), Some(right.trim())))
10434 .unwrap_or((path, None));
10435 let segments = path_without_alias
10436 .split("::")
10437 .map(str::trim)
10438 .filter(|segment| !segment.is_empty())
10439 .collect::<Vec<_>>();
10440 let local_name = alias.or_else(|| segments.last().copied())?.to_string();
10441 if local_name.chars().next().is_some_and(char::is_uppercase) {
10442 return None;
10443 }
10444 Some((
10445 local_name,
10446 segments
10447 .into_iter()
10448 .map(|segment| segment.to_string())
10449 .collect(),
10450 ))
10451}
10452
10453fn rust_inline_scoped_target<I: ResolverIndex>(
10454 index: &I,
10455 caller_file: &str,
10456 module_segments: &[String],
10457 short_name: &str,
10458) -> Option<(String, String)> {
10459 index.inline_scoped_target(caller_file, module_segments, short_name)
10460}
10461
10462fn rust_target_for_use<I: ResolverIndex>(
10463 index: &I,
10464 caller_file: &str,
10465 import: &ImportStatement,
10466 short_name: &str,
10467) -> Option<(String, String)> {
10468 let path = import.module_path.trim().trim_end_matches(';');
10469 if let Some(brace_start) = path.find("::{") {
10470 let prefix = &path[..brace_start];
10471 if import.names.iter().any(|name| name == short_name) {
10472 let prefix_segments: Vec<&str> = prefix.split("::").collect();
10473 let module_segments =
10474 rust_resolve_segments_with_index(index, caller_file, &prefix_segments)?;
10475 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
10476 return Some((file, short_name.to_string()));
10477 }
10478 return None;
10479 }
10480
10481 let (path_without_alias, alias) = path
10482 .split_once(" as ")
10483 .map(|(left, right)| (left.trim(), Some(right.trim())))
10484 .unwrap_or((path, None));
10485 let segments: Vec<&str> = path_without_alias.split("::").collect();
10486 let imported = alias.or_else(|| segments.last().copied())?;
10487 if imported != short_name {
10488 return None;
10489 }
10490 if segments.len() < 2 {
10491 return None;
10492 }
10493 let module_segments =
10494 rust_resolve_segments_with_index(index, caller_file, &segments[..segments.len() - 1])?;
10495 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
10496 Some((file, segments.last().unwrap_or(&short_name).to_string()))
10497}
10498
10499fn rust_workspace_file_for_segments<I: ResolverIndex>(
10500 index: &I,
10501 segments: &[&str],
10502) -> Option<String> {
10503 let crate_name = segments.first().copied()?;
10504 let src_prefix = index.crate_src_prefix(crate_name)?;
10505 let module_segments = segments[1..]
10506 .iter()
10507 .map(|segment| segment.to_string())
10508 .collect::<Vec<_>>();
10509 rust_file_for_src_prefix(index, &src_prefix, &module_segments)
10510}
10511
10512#[cfg(test)]
10513static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
10514 OnceLock::new();
10515
10516#[cfg(test)]
10517fn note_workspace_crate_prefix_build(project_root: &Path) {
10518 let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
10519 .get_or_init(|| Mutex::new(HashMap::new()))
10520 .lock()
10521 .expect("workspace crate prefix build counts mutex poisoned");
10522 *counts.entry(project_root.to_path_buf()).or_default() += 1;
10523}
10524
10525#[cfg(not(test))]
10526fn note_workspace_crate_prefix_build(_project_root: &Path) {}
10527
10528#[cfg(test)]
10529fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
10530 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
10531 .get_or_init(|| Mutex::new(HashMap::new()))
10532 .lock()
10533 .expect("workspace crate prefix build counts mutex poisoned")
10534 .remove(project_root);
10535}
10536
10537#[cfg(test)]
10538fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
10539 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
10540 .get_or_init(|| Mutex::new(HashMap::new()))
10541 .lock()
10542 .expect("workspace crate prefix build counts mutex poisoned")
10543 .get(project_root)
10544 .copied()
10545 .unwrap_or(0)
10546}
10547
10548fn build_workspace_crate_prefixes(
10553 project_root: &Path,
10554 facts: &FactPaths<'_>,
10555) -> HashMap<String, String> {
10556 note_workspace_crate_prefix_build(project_root);
10557 let mut prefixes = HashMap::new();
10558 let mut stack = vec![project_root.to_path_buf()];
10559 while let Some(dir) = stack.pop() {
10560 let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
10561 if matches!(name, "target" | "node_modules" | ".git") {
10562 continue;
10563 }
10564 let manifest = dir.join("Cargo.toml");
10565 if facts.is_file(&manifest) {
10566 let crate_names = rust_manifest_crate_names(&manifest, facts);
10567 if !crate_names.is_empty() {
10568 let src_prefix = relative_path(
10569 project_root,
10570 &facts
10571 .canonical(&dir.join("src"))
10572 .unwrap_or_else(|| dir.join("src")),
10573 );
10574 for crate_name in crate_names {
10575 prefixes
10576 .entry(crate_name)
10577 .or_insert_with(|| src_prefix.clone());
10578 }
10579 }
10580 }
10581 for entry in facts.list_dir(&dir) {
10582 if entry.kind == EntryKind::Directory {
10583 stack.push(dir.join(byte_path(&entry.name)));
10584 }
10585 }
10586 }
10587 prefixes
10588}
10589
10590fn rust_manifest_crate_names(manifest: &Path, facts: &FactPaths<'_>) -> Vec<String> {
10594 facts.config_fact(manifest, "manifest.name");
10595 facts.config_fact(manifest, "manifest.lib.name");
10596 let Some(bytes) = facts.attributed_bytes(manifest) else {
10597 return Vec::new();
10598 };
10599 let (package_name, lib_name) = rust_manifest_name_fields(&bytes);
10600 let mut names = Vec::new();
10601 if let Some(lib) = lib_name {
10602 names.push(lib);
10603 }
10604 if let Some(package) = package_name {
10605 let normalized = package.replace('-', "_");
10606 if !names.contains(&normalized) {
10607 names.push(normalized);
10608 }
10609 }
10610 names
10611}
10612
10613pub(crate) fn rust_manifest_name_fields(bytes: &[u8]) -> (Option<String>, Option<String>) {
10616 let Ok(source) = std::str::from_utf8(bytes) else {
10617 return (None, None);
10618 };
10619 let mut in_lib = false;
10620 let mut package_name = None;
10621 let mut lib_name = None;
10622 for line in source.lines() {
10623 let trimmed = line.trim();
10624 if trimmed.starts_with('[') {
10625 in_lib = trimmed == "[lib]";
10626 continue;
10627 }
10628 let Some((key, value)) = trimmed.split_once('=') else {
10629 continue;
10630 };
10631 let key = key.trim();
10632 let value = value.trim().trim_matches('"');
10633 if in_lib && key == "name" {
10634 lib_name = Some(value.to_string());
10635 } else if !in_lib && key == "name" && package_name.is_none() {
10636 package_name = Some(value.to_string());
10637 }
10638 }
10639 (package_name, lib_name)
10640}
10641
10642fn rust_resolve_segments_with_index<I: ResolverIndex>(
10643 index: &I,
10644 caller_file: &str,
10645 segments: &[&str],
10646) -> Option<Vec<String>> {
10647 let caller_segments = if index.rust_crate_root_file(caller_file).as_deref() == Some(caller_file)
10648 {
10649 Vec::new()
10650 } else {
10651 rust_registered_module_segments(index, caller_file)
10652 .unwrap_or_else(|| rust_module_segments_for_rel(caller_file))
10653 };
10654 rust_resolve_segments_from(caller_segments, segments)
10655}
10656
10657fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
10658 rust_resolve_segments_from(rust_module_segments_for_rel(caller_file), segments)
10659}
10660
10661fn rust_resolve_segments_from(
10662 caller_segments: Vec<String>,
10663 segments: &[&str],
10664) -> Option<Vec<String>> {
10665 if segments.is_empty() {
10666 return Some(Vec::new());
10667 }
10668 match segments[0] {
10669 "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
10670 "self" => {
10671 let mut resolved = caller_segments;
10672 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
10673 Some(resolved)
10674 }
10675 "super" => {
10676 let mut resolved = caller_segments;
10677 resolved.pop();
10678 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
10679 Some(resolved)
10680 }
10681 _ => {
10682 let mut resolved = caller_segments;
10683 resolved.pop();
10684 resolved.extend(segments.iter().map(|item| item.to_string()));
10685 Some(resolved)
10686 }
10687 }
10688}
10689
10690fn rust_registered_module_segments<I: ResolverIndex>(
10691 index: &I,
10692 caller_file: &str,
10693) -> Option<Vec<String>> {
10694 let mut current = caller_file.to_string();
10695 let mut segments = Vec::new();
10696 let mut seen = HashSet::new();
10697 while seen.insert(current.clone()) {
10698 let Some((parent, module)) = index.module_parent(¤t) else {
10699 break;
10700 };
10701 segments.push(module);
10702 current = parent;
10703 }
10704 if segments.is_empty() {
10705 None
10706 } else {
10707 segments.reverse();
10708 Some(segments)
10709 }
10710}
10711
10712fn rust_file_for_segments<I: ResolverIndex>(
10713 index: &I,
10714 caller_file: &str,
10715 segments: &[String],
10716) -> Option<String> {
10717 let src_prefix = rust_src_prefix(caller_file);
10718 if let Some(target) =
10719 rust_file_from_module_declarations(index, caller_file, &src_prefix, segments)
10720 {
10721 return Some(target);
10722 }
10723 rust_file_for_src_prefix(index, &src_prefix, segments)
10724}
10725
10726fn rust_file_from_module_declarations<I: ResolverIndex>(
10727 index: &I,
10728 caller_file: &str,
10729 src_prefix: &str,
10730 segments: &[String],
10731) -> Option<String> {
10732 let mut current = index.rust_crate_root_file(caller_file).or_else(|| {
10733 [
10734 format!("{src_prefix}/lib.rs"),
10735 format!("{src_prefix}/main.rs"),
10736 ]
10737 .into_iter()
10738 .find(|candidate| index.contains_file(candidate))
10739 })?;
10740 for segment in segments {
10741 current = index.module_target(¤t, segment)?;
10742 }
10743 Some(current)
10744}
10745
10746fn rust_file_for_src_prefix<I: ResolverIndex>(
10747 index: &I,
10748 src_prefix: &str,
10749 segments: &[String],
10750) -> Option<String> {
10751 let candidate = if segments.is_empty() {
10752 [src_prefix, "lib.rs"].join("/")
10753 } else {
10754 format!("{}/{}.rs", src_prefix, segments.join("/"))
10755 };
10756 if index.contains_file(&candidate) {
10757 return Some(candidate);
10758 }
10759 if !segments.is_empty() {
10760 let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
10761 if index.contains_file(&mod_candidate) {
10762 return Some(mod_candidate);
10763 }
10764 }
10765 None
10766}
10767
10768fn rust_src_prefix(rel_path: &str) -> String {
10769 rel_path
10770 .split_once("/src/")
10771 .map(|(prefix, _)| format!("{prefix}/src"))
10772 .unwrap_or_else(|| "src".to_string())
10773}
10774
10775fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
10776 let after_src = rel_path
10777 .split_once("/src/")
10778 .map(|(_, rest)| rest)
10779 .or_else(|| rel_path.strip_prefix("src/"))
10780 .unwrap_or(rel_path);
10781 if matches!(after_src, "lib.rs" | "main.rs") {
10782 return Vec::new();
10783 }
10784 if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
10785 return prefix.split('/').map(|item| item.to_string()).collect();
10786 }
10787 after_src
10788 .strip_suffix(".rs")
10789 .unwrap_or(after_src)
10790 .split('/')
10791 .map(|item| item.to_string())
10792 .collect()
10793}
10794
10795fn resolve_local_target<I: ResolverIndex>(
10796 _index: &I,
10797 caller_file: &str,
10798 full_ref: &str,
10799 short_name: &str,
10800 caller_data: &FileCallData,
10801) -> Option<(String, String, String)> {
10802 if !callgraph::is_bare_callee(full_ref, short_name) {
10803 return None;
10804 }
10805 callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
10806 .ok()
10807 .map(|symbol| {
10808 (
10809 "resolved_local".to_string(),
10810 caller_file.to_string(),
10811 symbol,
10812 )
10813 })
10814}
10815
10816impl<'a> ProjectIndex<'a> {
10817 fn from_parts(
10818 project_root: &Path,
10819 files: HashMap<String, DbFileIndex>,
10820 caller_data: HashMap<String, &'a FileCallData>,
10821 workspace_crate_prefixes: WorkspaceCratePrefixCache,
10822 facts: Rc<dyn ProjectFacts + 'a>,
10823 ) -> Self {
10824 Self {
10825 facts,
10826 unbound_non_utf8_paths: Vec::new(),
10827 project_root: project_root.to_path_buf(),
10828 files,
10829 caller_data,
10830 workspace_crate_prefixes,
10831 rust_crate_roots: callgraph::RustCrateRootMemo::default(),
10832 }
10833 }
10834
10835 fn from_db_and_callers(
10836 tx: &Transaction<'_>,
10837 project_root: &Path,
10838 caller_extracts: &'a HashMap<String, FileExtract>,
10839 workspace_crate_prefixes: WorkspaceCratePrefixCache,
10840 ) -> Result<Self> {
10841 let module_resolution_memo = callgraph::ModuleResolutionMemo::default();
10844 let disk = DiskFacts::new(project_root);
10845 let facts = FactPaths {
10846 root: project_root,
10847 facts: &disk,
10848 };
10849 let mut files = load_db_file_indexes(tx, project_root, &module_resolution_memo, &facts)?;
10850 let mut caller_data = HashMap::new();
10851 for (rel_path, extract) in caller_extracts {
10852 files.insert(
10853 rel_path.clone(),
10854 DbFileIndex::from_extract(
10855 project_root,
10856 extract,
10857 &FactPaths {
10858 root: project_root,
10859 facts: &DiskFacts::new(project_root),
10860 },
10861 ),
10862 );
10863 caller_data.insert(rel_path.clone(), &extract.data);
10864 }
10865 Ok(Self::from_parts(
10866 project_root,
10867 files,
10868 caller_data,
10869 workspace_crate_prefixes,
10870 Rc::new(DiskFacts::new(project_root)),
10871 ))
10872 }
10873
10874 fn lang_for(&self, rel_path: &str) -> Option<LangId> {
10875 self.files.get(rel_path).and_then(|file| file.lang)
10876 }
10877
10878 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
10879 self.files
10880 .get(caller_file)
10881 .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
10882 }
10883
10884 fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
10885 self.files
10886 .get(rel_path)
10887 .map(|file| file.reexports.as_slice())
10888 .unwrap_or(&[])
10889 }
10890
10891 fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
10892 self.files.get(rel_path).and_then(|file| {
10893 file.node_by_scoped
10894 .get(symbol)
10895 .cloned()
10896 .or_else(|| file.node_by_bare.get(symbol).cloned())
10897 })
10898 }
10899
10900 fn node_is_callable(&self, rel_path: &str, node_id: &str) -> bool {
10901 self.files
10902 .get(rel_path)
10903 .and_then(|file| file.node_kind_by_id.get(node_id))
10904 .is_some_and(|kind| matches!(kind.as_str(), "function" | "kernel" | "method"))
10905 }
10906}
10907
10908impl DbFileIndex {
10909 fn from_extract(project_root: &Path, extract: &FileExtract, facts: &FactPaths<'_>) -> Self {
10910 let mut node_by_scoped = HashMap::new();
10911 let mut node_by_bare = HashMap::new();
10912 for node in &extract.nodes {
10913 node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
10914 node_by_bare
10915 .entry(node.name.clone())
10916 .or_insert(node.id.clone());
10917 }
10918 let node_kind_by_id = extract
10919 .nodes
10920 .iter()
10921 .map(|node| (node.id.clone(), node.kind.clone()))
10922 .collect();
10923 let mut export_aliases = HashMap::new();
10924 for raw_ref in &extract.raw_refs {
10925 if raw_ref.kind == "export_alias" {
10926 if let (Some(exported), Some(source_symbol)) =
10927 (&raw_ref.local_name, &raw_ref.requested_name)
10928 {
10929 export_aliases.insert(exported.clone(), source_symbol.clone());
10930 }
10931 }
10932 }
10933 let mut module_targets = HashMap::new();
10934 let mut declared_module_targets = HashMap::new();
10935 let mut reexports = Vec::new();
10936 for raw_ref in &extract.raw_refs {
10937 if !matches!(raw_ref.kind.as_str(), "import" | "reexport" | "module") {
10938 continue;
10939 }
10940 let Some(module_path) = &raw_ref.module_path else {
10941 continue;
10942 };
10943 let target_file =
10944 module_target_from_dependencies(project_root, &raw_ref.dependencies, facts);
10945 module_targets
10946 .entry(module_path.clone())
10947 .or_insert_with(|| target_file.clone());
10948 if raw_ref.kind == "module" {
10949 declared_module_targets
10950 .entry(module_path.clone())
10951 .or_insert_with(|| target_file.clone());
10952 }
10953 if raw_ref.kind == "reexport" {
10954 reexports.push(reexport_index_from_raw(raw_ref, target_file));
10955 }
10956 }
10957 Self {
10958 lang: Some(extract.lang),
10959 exports: extract.data.exported_symbols.iter().cloned().collect(),
10960 default_export: extract.data.default_export_symbol.clone(),
10961 export_aliases,
10962 node_by_scoped,
10963 node_by_bare,
10964 node_kind_by_id,
10965 module_targets,
10966 declared_module_targets,
10967 reexports,
10968 }
10969 }
10970}
10971
10972fn load_db_file_indexes(
10973 tx: &Transaction<'_>,
10974 project_root: &Path,
10975 module_resolution_memo: &callgraph::ModuleResolutionMemo,
10976 facts: &FactPaths<'_>,
10977) -> Result<HashMap<String, DbFileIndex>> {
10978 let mut files = HashMap::new();
10979 let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
10980 let rows = stmt.query_map([], |row| {
10981 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
10982 })?;
10983 for row in rows {
10984 let (rel_path, lang) = row?;
10985 files.insert(
10986 rel_path.clone(),
10987 DbFileIndex {
10988 lang: lang_from_label(&lang),
10989 exports: HashSet::new(),
10990 default_export: None,
10991 export_aliases: HashMap::new(),
10992 node_by_scoped: HashMap::new(),
10993 node_by_bare: HashMap::new(),
10994 node_kind_by_id: HashMap::new(),
10995 module_targets: HashMap::new(),
10996 declared_module_targets: HashMap::new(),
10997 reexports: Vec::new(),
10998 },
10999 );
11000 }
11001
11002 let mut node_stmt = tx.prepare(
11003 "SELECT file_path, id, name, scoped_name, kind, exported, is_default_export FROM nodes",
11004 )?;
11005 let nodes = node_stmt.query_map([], |row| {
11006 Ok((
11007 row.get::<_, String>(0)?,
11008 row.get::<_, String>(1)?,
11009 row.get::<_, String>(2)?,
11010 row.get::<_, String>(3)?,
11011 row.get::<_, String>(4)?,
11012 row.get::<_, i64>(5)? != 0,
11013 row.get::<_, i64>(6)? != 0,
11014 ))
11015 })?;
11016 for row in nodes {
11017 let (file_path, id, name, scoped_name, kind, exported, is_default_export) = row?;
11018 let file = files
11019 .entry(file_path.clone())
11020 .or_insert_with(|| DbFileIndex {
11021 lang: None,
11022 exports: HashSet::new(),
11023 default_export: None,
11024 export_aliases: HashMap::new(),
11025 node_by_scoped: HashMap::new(),
11026 node_by_bare: HashMap::new(),
11027 node_kind_by_id: HashMap::new(),
11028 module_targets: HashMap::new(),
11029 declared_module_targets: HashMap::new(),
11030 reexports: Vec::new(),
11031 });
11032 if exported {
11033 file.exports.insert(name.clone());
11034 file.exports.insert(scoped_name.clone());
11035 }
11036 if is_default_export {
11037 file.default_export = Some(scoped_name.clone());
11038 }
11039 file.node_by_scoped.insert(scoped_name, id.clone());
11040 file.node_by_bare.entry(name).or_insert(id.clone());
11041 file.node_kind_by_id.insert(id, kind);
11042 }
11043 let file_keys: HashSet<String> = files.keys().cloned().collect();
11044 let dependencies_by_file = load_file_dependencies_index(tx)?;
11048 let mut ref_stmt = tx.prepare(
11049 "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
11050 FROM refs WHERE kind IN ('module', 'reexport', 'export_alias')",
11051 )?;
11052 let ref_rows = ref_stmt.query_map([], |row| {
11053 Ok((
11054 row.get::<_, String>(0)?,
11055 row.get::<_, String>(1)?,
11056 row.get::<_, String>(2)?,
11057 row.get::<_, Option<String>>(3)?,
11058 row.get::<_, Option<String>>(4)?,
11059 row.get::<_, i64>(5)? != 0,
11060 row.get::<_, Option<String>>(6)?,
11061 row.get::<_, Option<String>>(7)?,
11062 ))
11063 })?;
11064 for row in ref_rows {
11065 let (
11066 ref_id,
11067 caller_file,
11068 kind,
11069 module_path,
11070 full_ref,
11071 wildcard,
11072 local_name,
11073 requested_name,
11074 ) = row?;
11075 if kind == "export_alias" {
11076 if let (Some(exported), Some(source_symbol), Some(file)) =
11077 (local_name, requested_name, files.get_mut(&caller_file))
11078 {
11079 file.export_aliases.insert(exported, source_symbol);
11080 }
11081 continue;
11082 }
11083 let Some(module_path) = module_path else {
11084 continue;
11085 };
11086 let file_deps = dependencies_by_file
11087 .get(&caller_file)
11088 .cloned()
11089 .unwrap_or_default();
11090 let deps = stored_dependencies_for_module(
11091 project_root,
11092 &caller_file,
11093 &module_path,
11094 &file_deps,
11095 &file_keys,
11096 facts,
11097 );
11098 let target_file = if kind == "module" {
11099 rust_declared_module_target(
11100 project_root,
11101 &caller_file,
11102 &module_path,
11103 module_resolution_memo,
11104 facts,
11105 )
11106 } else {
11107 deps.iter().find(|dep| file_keys.contains(*dep)).map(|dep| {
11108 relative_path(
11109 project_root,
11110 &facts
11111 .canonical(&project_root.join(dep))
11112 .unwrap_or_else(|| project_root.join(dep)),
11113 )
11114 })
11115 };
11116 if let Some(file) = files.get_mut(&caller_file) {
11117 file.module_targets
11118 .entry(module_path.clone())
11119 .or_insert_with(|| target_file.clone());
11120 if kind == "module" {
11121 file.declared_module_targets
11122 .entry(module_path.clone())
11123 .or_insert_with(|| target_file.clone());
11124 }
11125 if kind == "reexport" {
11126 let raw = RawRef {
11127 ref_id,
11128 caller_node: None,
11129 caller_symbol: None,
11130 caller_file,
11131 kind,
11132 short_name: None,
11133 full_ref,
11134 module_path: Some(module_path),
11135 import_kind: Some("reexport".to_string()),
11136 local_name: None,
11137 requested_name: None,
11138 namespace_alias: None,
11139 wildcard,
11140 line: 0,
11141 byte_start: 0,
11142 byte_end: 0,
11143 dependencies: deps,
11144 };
11145 file.reexports
11146 .push(reexport_index_from_raw(&raw, target_file));
11147 }
11148 }
11149 }
11150
11151 Ok(files)
11152}
11153
11154fn stored_dependencies_for_module(
11155 project_root: &Path,
11156 caller_file: &str,
11157 module_path: &str,
11158 caller_dependencies: &BTreeSet<String>,
11159 indexed_files: &HashSet<String>,
11160 facts: &FactPaths<'_>,
11161) -> BTreeSet<String> {
11162 let caller_path = project_root.join(caller_file);
11163 let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path, facts);
11164 if module_path.starts_with('.') {
11165 let caller_dir = caller_path.parent().unwrap_or(project_root);
11166 for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
11167 let normalized = if facts.is_file(&candidate) {
11168 facts.canonical(&candidate).unwrap_or(candidate.clone())
11169 } else {
11170 candidate
11171 };
11172 candidates.insert(relative_path(project_root, &normalized));
11173 }
11174 }
11175 let exact = candidates
11176 .intersection(caller_dependencies)
11177 .filter(|dependency| indexed_files.contains(*dependency))
11178 .cloned()
11179 .collect::<BTreeSet<_>>();
11180 if !exact.is_empty() || module_path.starts_with('.') {
11181 return exact;
11182 }
11183
11184 let module_path = rust_module_path_without_alias_or_use_list(module_path)
11185 .trim_matches(|character| matches!(character, '\'' | '"'));
11186 let package_name = module_path
11187 .split('/')
11188 .next_back()
11189 .unwrap_or(module_path)
11190 .replace('_', "-");
11191 let matched = caller_dependencies
11192 .iter()
11193 .filter(|dependency| indexed_files.contains(*dependency))
11194 .filter(|dependency| {
11195 dependency.as_str() == module_path
11196 || dependency.ends_with(&format!("/{module_path}"))
11197 || Path::new(dependency).components().any(|component| {
11198 component.as_os_str().to_string_lossy().replace('_', "-") == package_name
11199 })
11200 })
11201 .cloned()
11202 .collect::<BTreeSet<_>>();
11203 if matched.len() == 1 {
11204 matched
11205 } else {
11206 BTreeSet::new()
11207 }
11208}
11209
11210fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
11211 let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
11212 let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
11213 let rows = stmt.query_map([], |row| {
11214 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11215 })?;
11216 for row in rows {
11217 let (file_path, dependency) = row?;
11218 by_file.entry(file_path).or_default().insert(dependency);
11219 }
11220 Ok(by_file)
11221}
11222
11223struct ColdBuildInsertStatements<'stmt> {
11224 file: Statement<'stmt>,
11225 node: Statement<'stmt>,
11226 file_dependency: Statement<'stmt>,
11227 dispatch_hint: Statement<'stmt>,
11228 backend_state: Statement<'stmt>,
11229 reference: Statement<'stmt>,
11230 staging_ref_context: Statement<'stmt>,
11231 edge: Statement<'stmt>,
11232}
11233
11234impl<'stmt> ColdBuildInsertStatements<'stmt> {
11235 fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
11236 Ok(Self {
11237 file: tx.prepare(
11238 "INSERT OR REPLACE INTO files(
11239 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
11240 is_public_api, surface_fingerprint, indexed_at
11241 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
11242 )?,
11243 node: tx.prepare(
11244 "INSERT OR REPLACE INTO nodes(
11245 id, file_path, name, scoped_name, kind, start_line, start_col,
11246 end_line, end_col, range_ordinal, signature, exported,
11247 is_default_export, is_type_like, is_callgraph_entry_point, provenance
11248 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
11249 )?,
11250 file_dependency: tx.prepare(
11251 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
11252 )?,
11253 dispatch_hint: tx.prepare(
11254 "INSERT OR REPLACE INTO dispatch_hints(
11255 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
11256 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
11257 )?,
11258 backend_state: tx.prepare(
11259 "INSERT OR REPLACE INTO backend_file_state(
11260 backend, workspace_root, file_path, content_hash, status, updated_at
11261 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
11262 )?,
11263 reference: tx.prepare(
11264 "INSERT OR REPLACE INTO refs(
11265 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
11266 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
11267 byte_start, byte_end, status, target_node, target_file, target_symbol,
11268 provenance
11269 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
11270 )?,
11271 staging_ref_context: tx.prepare(
11272 "INSERT OR REPLACE INTO staging_ref_context(ref_id, caller_symbol) VALUES(?1, ?2)",
11273 )?,
11274 edge: tx.prepare(
11275 "INSERT OR REPLACE INTO edges(
11276 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
11277 kind, line, provenance
11278 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
11279 )?,
11280 })
11281 }
11282}
11283
11284fn insert_file_extract_prepared(
11285 statements: &mut ColdBuildInsertStatements<'_>,
11286 workspace_root: &str,
11287 extract: &FileExtract,
11288) -> Result<()> {
11289 statements.file.execute(params![
11290 extract.rel_path,
11291 hash_to_hex(extract.freshness.content_hash),
11292 system_time_to_ns(extract.freshness.mtime),
11293 extract.freshness.size as i64,
11294 lang_label(extract.lang),
11295 extract.surface_fingerprint,
11296 unix_seconds_now(),
11297 ])?;
11298 for node in &extract.nodes {
11299 statements.node.execute(params![
11300 node.id,
11301 node.file_path,
11302 node.name,
11303 node.scoped_name,
11304 node.kind,
11305 node.range.start_line as i64,
11306 node.range.start_col as i64,
11307 node.range.end_line as i64,
11308 node.range.end_col as i64,
11309 node.range_ordinal as i64,
11310 node.signature,
11311 bool_int(node.exported),
11312 bool_int(node.is_default_export),
11313 bool_int(node.is_type_like),
11314 bool_int(node.is_callgraph_entry_point),
11315 PROVENANCE_TREESITTER,
11316 ])?;
11317 }
11318
11319 let mut dependencies = BTreeSet::new();
11320 for raw_ref in &extract.raw_refs {
11321 dependencies.extend(raw_ref.dependencies.iter().cloned());
11322 }
11323 for dep_file in &dependencies {
11324 statements
11325 .file_dependency
11326 .execute(params![extract.rel_path, dep_file])?;
11327 }
11328
11329 for hint in &extract.dispatch_hints {
11330 statements.dispatch_hint.execute(params![
11331 hint.id,
11332 hint.method_name,
11333 hint.caller_node,
11334 hint.file,
11335 hint.line as i64,
11336 hint.byte_start as i64,
11337 hint.byte_end as i64,
11338 PROVENANCE_TREESITTER,
11339 ])?;
11340 }
11341 insert_backend_state_prepared(
11342 &mut statements.backend_state,
11343 workspace_root,
11344 &extract.rel_path,
11345 Some(&extract.freshness.content_hash),
11346 "fresh",
11347 )?;
11348 Ok(())
11349}
11350
11351fn insert_backend_state_prepared(
11352 stmt: &mut Statement<'_>,
11353 workspace_root: &str,
11354 rel_path: &str,
11355 content_hash: Option<&blake3::Hash>,
11356 status: &str,
11357) -> Result<()> {
11358 let hash = content_hash
11359 .map(|hash| hash_to_hex(*hash))
11360 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
11361 stmt.execute(params![
11362 BACKEND_TREESITTER,
11363 workspace_root,
11364 rel_path,
11365 hash,
11366 status,
11367 unix_seconds_now(),
11368 ])?;
11369 Ok(())
11370}
11371
11372fn insert_staged_ref_prepared(
11373 statements: &mut ColdBuildInsertStatements<'_>,
11374 raw: &RawRef,
11375) -> Result<()> {
11376 statements.reference.execute(params![
11377 raw.ref_id,
11378 raw.caller_node,
11379 raw.caller_file,
11380 raw.kind,
11381 raw.short_name,
11382 raw.full_ref,
11383 raw.module_path,
11384 raw.import_kind,
11385 raw.local_name,
11386 raw.requested_name,
11387 raw.namespace_alias,
11388 bool_int(raw.wildcard),
11389 raw.line as i64,
11390 raw.byte_start as i64,
11391 raw.byte_end as i64,
11392 "staged",
11393 Option::<String>::None,
11394 Option::<String>::None,
11395 Option::<String>::None,
11396 ref_provenance(raw),
11397 ])?;
11398 statements
11399 .staging_ref_context
11400 .execute(params![raw.ref_id, raw.caller_symbol])?;
11401 Ok(())
11402}
11403
11404fn insert_resolved_ref_prepared(
11405 statements: &mut ColdBuildInsertStatements<'_>,
11406 resolved: &ResolvedRef,
11407) -> Result<()> {
11408 let raw = &resolved.raw;
11409 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
11410 statements.reference.execute(params![
11411 raw.ref_id,
11412 raw.caller_node,
11413 raw.caller_file,
11414 raw.kind,
11415 raw.short_name,
11416 raw.full_ref,
11417 raw.module_path,
11418 raw.import_kind,
11419 raw.local_name,
11420 raw.requested_name,
11421 raw.namespace_alias,
11422 bool_int(raw.wildcard),
11423 raw.line as i64,
11424 raw.byte_start as i64,
11425 raw.byte_end as i64,
11426 resolved.status,
11427 resolved.target_node,
11428 resolved.target_file,
11429 resolved.target_symbol,
11430 ref_provenance(raw),
11431 ])?;
11432 if let Some(edge) = &resolved.edge {
11433 statements.edge.execute(params![
11434 edge.edge_id,
11435 raw.ref_id,
11436 edge.source_node,
11437 edge.target_node,
11438 edge.target_file,
11439 edge.target_symbol,
11440 edge.kind,
11441 edge.line as i64,
11442 ref_provenance(raw),
11443 ])?;
11444 }
11445 Ok(())
11446}
11447
11448#[cfg(test)]
11449fn insert_file_extract(
11450 tx: &Transaction<'_>,
11451 project_root: &Path,
11452 extract: &FileExtract,
11453) -> Result<()> {
11454 tx.execute(
11455 "INSERT OR REPLACE INTO files(
11456 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
11457 is_public_api, surface_fingerprint, indexed_at
11458 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
11459 params![
11460 extract.rel_path,
11461 hash_to_hex(extract.freshness.content_hash),
11462 system_time_to_ns(extract.freshness.mtime),
11463 extract.freshness.size as i64,
11464 lang_label(extract.lang),
11465 extract.surface_fingerprint,
11466 unix_seconds_now(),
11467 ],
11468 )?;
11469 for node in &extract.nodes {
11470 tx.execute(
11471 "INSERT OR REPLACE INTO nodes(
11472 id, file_path, name, scoped_name, kind, start_line, start_col,
11473 end_line, end_col, range_ordinal, signature, exported,
11474 is_default_export, is_type_like, is_callgraph_entry_point, provenance
11475 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
11476 params![
11477 node.id,
11478 node.file_path,
11479 node.name,
11480 node.scoped_name,
11481 node.kind,
11482 node.range.start_line as i64,
11483 node.range.start_col as i64,
11484 node.range.end_line as i64,
11485 node.range.end_col as i64,
11486 node.range_ordinal as i64,
11487 node.signature,
11488 bool_int(node.exported),
11489 bool_int(node.is_default_export),
11490 bool_int(node.is_type_like),
11491 bool_int(node.is_callgraph_entry_point),
11492 PROVENANCE_TREESITTER,
11493 ],
11494 )?;
11495 }
11496 let mut dependencies = BTreeSet::new();
11497 for raw_ref in &extract.raw_refs {
11498 dependencies.extend(raw_ref.dependencies.iter().cloned());
11499 }
11500 insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
11501
11502 for hint in &extract.dispatch_hints {
11503 tx.execute(
11504 "INSERT OR REPLACE INTO dispatch_hints(
11505 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
11506 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
11507 params![
11508 hint.id,
11509 hint.method_name,
11510 hint.caller_node,
11511 hint.file,
11512 hint.line as i64,
11513 hint.byte_start as i64,
11514 hint.byte_end as i64,
11515 PROVENANCE_TREESITTER,
11516 ],
11517 )?;
11518 }
11519 mark_backend_state(
11520 tx,
11521 project_root,
11522 &extract.rel_path,
11523 Some(&extract.freshness.content_hash),
11524 "fresh",
11525 )?;
11526 Ok(())
11527}
11528
11529#[cfg(test)]
11530fn insert_file_dependencies(
11531 tx: &Transaction<'_>,
11532 file_path: &str,
11533 dependencies: &BTreeSet<String>,
11534) -> Result<()> {
11535 for dep_file in dependencies {
11536 tx.execute(
11537 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
11538 params![file_path, dep_file],
11539 )?;
11540 }
11541 Ok(())
11542}
11543
11544fn ref_provenance(raw: &RawRef) -> &'static str {
11545 if raw.kind == "value_ref" {
11546 PROVENANCE_VALUE_REF
11547 } else {
11548 PROVENANCE_TREESITTER
11549 }
11550}
11551
11552#[cfg(test)]
11553fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
11554 let raw = &resolved.raw;
11555 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
11556 tx.execute(
11557 "INSERT OR REPLACE INTO refs(
11558 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
11559 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
11560 byte_start, byte_end, status, target_node, target_file, target_symbol,
11561 provenance
11562 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
11563 params![
11564 raw.ref_id,
11565 raw.caller_node,
11566 raw.caller_file,
11567 raw.kind,
11568 raw.short_name,
11569 raw.full_ref,
11570 raw.module_path,
11571 raw.import_kind,
11572 raw.local_name,
11573 raw.requested_name,
11574 raw.namespace_alias,
11575 bool_int(raw.wildcard),
11576 raw.line as i64,
11577 raw.byte_start as i64,
11578 raw.byte_end as i64,
11579 resolved.status,
11580 resolved.target_node,
11581 resolved.target_file,
11582 resolved.target_symbol,
11583 ref_provenance(raw),
11584 ],
11585 )?;
11586 if let Some(edge) = &resolved.edge {
11587 tx.execute(
11588 "INSERT OR REPLACE INTO edges(
11589 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
11590 kind, line, provenance
11591 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
11592 params![
11593 edge.edge_id,
11594 raw.ref_id,
11595 edge.source_node,
11596 edge.target_node,
11597 edge.target_file,
11598 edge.target_symbol,
11599 edge.kind,
11600 edge.line as i64,
11601 ref_provenance(raw),
11602 ],
11603 )?;
11604 }
11605 Ok(())
11606}
11607
11608fn insert_method_dispatch_edges(
11609 tx: &Transaction<'_>,
11610 project_root: &Path,
11611 caller_files: Option<&BTreeSet<String>>,
11612) -> Result<usize> {
11613 let references = load_name_match_refs(tx, caller_files)?;
11614 if references.is_empty() {
11615 return Ok(0);
11616 }
11617
11618 let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
11619 let mut source_cache: DispatchSourceCache = HashMap::new();
11620 let mut inserted = 0usize;
11621 for reference in references {
11622 let key = (reference.method_name.clone(), reference.lang.clone());
11623 let candidates = match candidates_by_name.entry(key) {
11624 Entry::Occupied(entry) => entry.into_mut(),
11625 Entry::Vacant(entry) => {
11626 let candidates =
11627 load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
11628 entry.insert(candidates)
11629 }
11630 };
11631
11632 match infer_receiver_type_state(project_root, &reference, &mut source_cache) {
11633 ReceiverTypeInference::Known(receiver_type) => {
11634 let Some(candidate) =
11635 select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
11636 else {
11637 continue;
11638 };
11639 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
11640 inserted += 1;
11641 continue;
11642 }
11643 ReceiverTypeInference::RustDirectSelfField {
11644 receiver_type,
11645 declaration_file,
11646 module_scope,
11647 } => {
11648 let Some(candidate) = select_rust_direct_self_field_candidate(
11649 project_root,
11650 &reference,
11651 candidates.as_slice(),
11652 &receiver_type,
11653 &declaration_file,
11654 &module_scope,
11655 &mut source_cache,
11656 ) else {
11657 continue;
11658 };
11659 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
11660 inserted += 1;
11661 continue;
11662 }
11663 ReceiverTypeInference::KnownButUnresolved => continue,
11664 ReceiverTypeInference::Unknown => {}
11665 }
11666
11667 if method_name_match_denylisted(&reference.method_name) {
11668 continue;
11669 }
11670
11671 let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
11672 continue;
11673 };
11674 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
11675 inserted += 1;
11676 }
11677 Ok(inserted)
11678}
11679
11680fn insert_method_dispatch_edges_chunked(
11681 tx: &Transaction<'_>,
11682 project_root: &Path,
11683 chunk_size: usize,
11684) -> Result<usize> {
11685 let total_files = query_count(
11686 tx,
11687 "SELECT COUNT(*) FROM (SELECT DISTINCT caller_file FROM refs)",
11688 )? as usize;
11689 let mut completed_files = 0usize;
11690 ensure_cold_build_current("method-dispatch", completed_files, total_files)?;
11691 let mut inserted = 0usize;
11692 let mut after_file = String::new();
11693 loop {
11694 let caller_files = {
11695 let mut statement = tx.prepare(
11696 "SELECT DISTINCT caller_file
11697 FROM refs
11698 WHERE caller_file > ?1
11699 ORDER BY caller_file
11700 LIMIT ?2",
11701 )?;
11702 let rows = statement
11703 .query_map(params![after_file, chunk_size.max(1) as i64], |row| {
11704 row.get::<_, String>(0)
11705 })?;
11706 rows.collect::<std::result::Result<BTreeSet<_>, _>>()?
11707 };
11708 let Some(last_file) = caller_files.last().cloned() else {
11709 break;
11710 };
11711 inserted += insert_method_dispatch_edges(tx, project_root, Some(&caller_files))?;
11712 after_file = last_file;
11713 completed_files = completed_files
11714 .saturating_add(caller_files.len())
11715 .min(total_files);
11716 ensure_cold_build_current("method-dispatch", completed_files, total_files)?;
11717 }
11718 ensure_cold_build_current("method-dispatch", completed_files, total_files)?;
11719 Ok(inserted)
11720}
11721
11722fn insert_method_dispatch_edge(
11723 tx: &Transaction<'_>,
11724 reference: &NameMatchRef,
11725 candidate: &NameMatchCandidate,
11726 provenance: &str,
11727) -> Result<()> {
11728 tx.execute(
11729 "INSERT OR REPLACE INTO edges(
11730 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
11731 kind, line, provenance
11732 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
11733 params![
11734 ref_id(&[&reference.ref_id, provenance, "edge"]),
11735 &reference.ref_id,
11736 &reference.caller_node,
11737 &candidate.node_id,
11738 &candidate.file_path,
11739 &candidate.scoped_name,
11740 reference.line as i64,
11741 provenance,
11742 ],
11743 )?;
11744 Ok(())
11745}
11746
11747fn delete_method_dispatch_edges_for_callers(
11748 tx: &Transaction<'_>,
11749 caller_files: &BTreeSet<String>,
11750) -> Result<()> {
11751 if caller_files.is_empty() {
11752 return Ok(());
11753 }
11754
11755 let mut stmt = tx.prepare(
11756 "DELETE FROM edges
11757 WHERE provenance IN (?1, ?2)
11758 AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
11759 )?;
11760 for caller_file in caller_files {
11761 stmt.execute(params![
11762 PROVENANCE_NAME_MATCH,
11763 PROVENANCE_TYPE_MATCH,
11764 caller_file
11765 ])?;
11766 }
11767 Ok(())
11768}
11769
11770fn load_name_match_refs(
11771 tx: &Transaction<'_>,
11772 caller_files: Option<&BTreeSet<String>>,
11773) -> Result<Vec<NameMatchRef>> {
11774 let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
11775 n.signature, r.short_name, r.full_ref, r.line, f.lang
11776 FROM refs r
11777 JOIN files f ON f.path = r.caller_file
11778 JOIN nodes n ON n.id = r.caller_node
11779 WHERE r.kind = 'call'
11780 AND r.status = 'unresolved'
11781 AND r.caller_node IS NOT NULL
11782 AND r.full_ref IS NOT NULL
11783 AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
11784 AND NOT EXISTS (
11785 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
11786 )";
11787 let mut references = Vec::new();
11788
11789 if let Some(caller_files) = caller_files {
11790 if caller_files.is_empty() {
11791 return Ok(references);
11792 }
11793 let sql = format!(
11794 "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
11795 );
11796 let mut stmt = tx.prepare(&sql)?;
11797 for caller_file in caller_files {
11798 let rows = stmt.query_map(params![caller_file], |row| {
11799 Ok((
11800 row.get::<_, String>(0)?,
11801 row.get::<_, Option<String>>(1)?,
11802 row.get::<_, String>(2)?,
11803 row.get::<_, String>(3)?,
11804 row.get::<_, Option<String>>(4)?,
11805 row.get::<_, Option<String>>(5)?,
11806 row.get::<_, Option<String>>(6)?,
11807 row.get::<_, i64>(7)?,
11808 row.get::<_, String>(8)?,
11809 ))
11810 })?;
11811 for row in rows {
11812 let (
11813 ref_id,
11814 caller_node,
11815 caller_file,
11816 caller_symbol,
11817 caller_signature,
11818 short_name,
11819 full_ref,
11820 line,
11821 lang,
11822 ) = row?;
11823 if let Some(reference) = name_match_ref_from_parts(
11824 ref_id,
11825 caller_node,
11826 caller_file,
11827 caller_symbol,
11828 caller_signature,
11829 short_name,
11830 full_ref,
11831 line,
11832 lang,
11833 ) {
11834 references.push(reference);
11835 }
11836 }
11837 }
11838 return Ok(references);
11839 }
11840
11841 let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
11842 let mut stmt = tx.prepare(&sql)?;
11843 let rows = stmt.query_map([], |row| {
11844 Ok((
11845 row.get::<_, String>(0)?,
11846 row.get::<_, Option<String>>(1)?,
11847 row.get::<_, String>(2)?,
11848 row.get::<_, String>(3)?,
11849 row.get::<_, Option<String>>(4)?,
11850 row.get::<_, Option<String>>(5)?,
11851 row.get::<_, Option<String>>(6)?,
11852 row.get::<_, i64>(7)?,
11853 row.get::<_, String>(8)?,
11854 ))
11855 })?;
11856 for row in rows {
11857 let (
11858 ref_id,
11859 caller_node,
11860 caller_file,
11861 caller_symbol,
11862 caller_signature,
11863 short_name,
11864 full_ref,
11865 line,
11866 lang,
11867 ) = row?;
11868 if let Some(reference) = name_match_ref_from_parts(
11869 ref_id,
11870 caller_node,
11871 caller_file,
11872 caller_symbol,
11873 caller_signature,
11874 short_name,
11875 full_ref,
11876 line,
11877 lang,
11878 ) {
11879 references.push(reference);
11880 }
11881 }
11882 Ok(references)
11883}
11884
11885#[allow(clippy::too_many_arguments)]
11886fn name_match_ref_from_parts(
11887 ref_id: String,
11888 caller_node: Option<String>,
11889 caller_file: String,
11890 caller_symbol: String,
11891 caller_signature: Option<String>,
11892 short_name: Option<String>,
11893 full_ref: Option<String>,
11894 line: i64,
11895 lang: String,
11896) -> Option<NameMatchRef> {
11897 let caller_node = caller_node?;
11898 let full_ref = full_ref?;
11899 let (receiver_expression, receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
11900 let method_name = if member.is_empty() {
11901 short_name.as_deref()?.to_string()
11902 } else {
11903 member
11904 };
11905 Some(NameMatchRef {
11906 ref_id,
11907 caller_node,
11908 caller_file,
11909 caller_symbol,
11910 caller_signature,
11911 receiver_expression,
11912 receiver,
11913 method_name,
11914 colon_dispatch,
11915 line: line.max(0) as u32,
11916 lang,
11917 })
11918}
11919
11920fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, String, bool)> {
11921 let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
11922 let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
11923 let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
11924 let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
11925 .into_iter()
11926 .flatten()
11927 .max_by_key(|(index, _, _)| *index)?;
11928 if delimiter == 0 {
11929 return None;
11930 }
11931 let member_start = delimiter + delimiter_len;
11932 if member_start >= full_ref.len() {
11933 return None;
11934 }
11935 let receiver_expression = full_ref[..delimiter].trim();
11936 let receiver = last_name_segment(receiver_expression).trim();
11937 let member = &full_ref[member_start..];
11938 if receiver.is_empty() || member.is_empty() {
11939 return None;
11940 }
11941 Some((
11942 receiver_expression.to_string(),
11943 receiver.to_string(),
11944 member.to_string(),
11945 colon_dispatch,
11946 ))
11947}
11948
11949fn last_name_segment(value: &str) -> &str {
11950 value
11951 .rsplit(['.', ':', '/', '\\', '-', '>'])
11952 .find(|segment| !segment.is_empty())
11953 .unwrap_or(value)
11954}
11955
11956fn load_name_match_candidates(
11957 tx: &Transaction<'_>,
11958 method_name: &str,
11959 lang: &str,
11960) -> Result<Vec<NameMatchCandidate>> {
11961 let mut stmt = tx.prepare(
11962 "SELECT n.id, n.file_path, n.scoped_name, n.kind, n.start_line
11963 FROM nodes n JOIN files f ON f.path = n.file_path
11964 WHERE n.name = ?1
11965 AND f.lang = ?2
11966 AND n.kind IN ('method', 'function', 'kernel')
11967 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
11968 )?;
11969 let rows = stmt.query_map(params![method_name, lang], |row| {
11970 Ok(NameMatchCandidate {
11971 node_id: row.get(0)?,
11972 file_path: row.get(1)?,
11973 scoped_name: row.get(2)?,
11974 kind: row.get(3)?,
11975 start_line: (row.get::<_, i64>(4)?.max(0) as u32).saturating_add(1),
11976 })
11977 })?;
11978 rows.collect::<std::result::Result<Vec<_>, _>>()
11979 .map_err(Into::into)
11980}
11981
11982struct ParsedDispatchSource {
11983 source: String,
11984 tree: tree_sitter::Tree,
11985}
11986
11987type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
11988
11989#[derive(Debug, Clone, PartialEq, Eq)]
11990enum ReceiverTypeInference {
11991 Unknown,
11992 Known(String),
11993 RustDirectSelfField {
11994 receiver_type: String,
11995 declaration_file: String,
11996 module_scope: Vec<(usize, usize)>,
11997 },
11998 KnownButUnresolved,
11999}
12000
12001#[cfg(test)]
12002fn infer_receiver_type(
12003 project_root: &Path,
12004 reference: &NameMatchRef,
12005 source_cache: &mut DispatchSourceCache,
12006) -> Option<String> {
12007 match infer_receiver_type_state(project_root, reference, source_cache) {
12008 ReceiverTypeInference::Known(receiver_type)
12009 | ReceiverTypeInference::RustDirectSelfField { receiver_type, .. } => Some(receiver_type),
12010 ReceiverTypeInference::Unknown | ReceiverTypeInference::KnownButUnresolved => None,
12011 }
12012}
12013
12014fn infer_receiver_type_state(
12015 project_root: &Path,
12016 reference: &NameMatchRef,
12017 source_cache: &mut DispatchSourceCache,
12018) -> ReceiverTypeInference {
12019 let known = |receiver_type| ReceiverTypeInference::Known(receiver_type);
12020 match reference.lang.as_str() {
12021 "rust" => infer_rust_receiver_type(project_root, reference, source_cache),
12022 "java" => {
12023 infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
12024 .map(known)
12025 .unwrap_or(ReceiverTypeInference::Unknown)
12026 }
12027 "kotlin" => {
12028 infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
12029 .map(known)
12030 .unwrap_or(ReceiverTypeInference::Unknown)
12031 }
12032 "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache)
12033 .map(known)
12034 .unwrap_or(ReceiverTypeInference::Unknown),
12035 _ => ReceiverTypeInference::Unknown,
12036 }
12037}
12038
12039fn parse_dispatch_source(
12040 project_root: &Path,
12041 caller_file: &str,
12042 lang: LangId,
12043) -> Option<ParsedDispatchSource> {
12044 let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
12045 let grammar = crate::parser::grammar_for(lang);
12046 let mut parser = tree_sitter::Parser::new();
12047 parser.set_language(&grammar).ok()?;
12048 let tree = parser.parse(&source, None)?;
12049 Some(ParsedDispatchSource { source, tree })
12050}
12051
12052fn parsed_dispatch_source<'a>(
12053 project_root: &Path,
12054 reference: &NameMatchRef,
12055 lang: LangId,
12056 source_cache: &'a mut DispatchSourceCache,
12057) -> Option<&'a ParsedDispatchSource> {
12058 parsed_dispatch_source_for_file(
12059 project_root,
12060 &reference.caller_file,
12061 &reference.lang,
12062 lang,
12063 source_cache,
12064 )
12065}
12066
12067fn parsed_dispatch_source_for_file<'a>(
12068 project_root: &Path,
12069 file_path: &str,
12070 lang_label: &str,
12071 lang: LangId,
12072 source_cache: &'a mut DispatchSourceCache,
12073) -> Option<&'a ParsedDispatchSource> {
12074 let key = (file_path.to_string(), lang_label.to_string());
12075 source_cache
12076 .entry(key)
12077 .or_insert_with(|| parse_dispatch_source(project_root, file_path, lang))
12078 .as_ref()
12079}
12080
12081fn infer_java_like_receiver_type(
12082 project_root: &Path,
12083 reference: &NameMatchRef,
12084 lang: LangId,
12085 source_cache: &mut DispatchSourceCache,
12086) -> Option<String> {
12087 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
12088 return None;
12089 }
12090
12091 let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
12092 let root = parsed.tree.root_node();
12093 let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
12094
12095 let callable_scope = type_node
12096 .and_then(|node| {
12097 find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
12098 })
12099 .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
12100
12101 if let Some(callable_scope) = callable_scope {
12102 if let Some(receiver_type) = infer_java_like_local_receiver_type(
12103 callable_scope,
12104 &parsed.source,
12105 &reference.receiver,
12106 reference.line.max(1),
12107 lang,
12108 ) {
12109 return Some(receiver_type);
12110 }
12111 }
12112
12113 type_node.and_then(|node| {
12114 infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
12115 })
12116}
12117
12118fn infer_cpp_receiver_type(
12119 project_root: &Path,
12120 reference: &NameMatchRef,
12121 source_cache: &mut DispatchSourceCache,
12122) -> Option<String> {
12123 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
12124 return None;
12125 }
12126
12127 let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
12128 let root = parsed.tree.root_node();
12129 let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
12130 infer_cpp_receiver_type_from_scope(
12131 scope,
12132 &parsed.source,
12133 &reference.receiver,
12134 reference.line.max(1),
12135 )
12136}
12137
12138fn find_enclosing_java_like_type_node<'tree>(
12139 root: tree_sitter::Node<'tree>,
12140 source: &str,
12141 reference: &NameMatchRef,
12142 lang: LangId,
12143) -> Option<tree_sitter::Node<'tree>> {
12144 let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
12145 .and_then(|name| simple_type_name(&name));
12146 let line = reference.line.max(1);
12147 let mut best = None;
12148 let mut stack = vec![root];
12149 while let Some(node) = stack.pop() {
12150 if !node_contains_line(node, line) {
12151 continue;
12152 }
12153 if is_java_like_type_kind(node.kind(), lang) {
12154 let name = declaration_name(node, source);
12155 if expected_type
12156 .as_deref()
12157 .is_none_or(|expected| name == Some(expected))
12158 {
12159 best = tighter_node(best, node);
12160 }
12161 }
12162 push_named_children(node, &mut stack);
12163 }
12164 best
12165}
12166
12167fn find_enclosing_java_like_callable_node<'tree>(
12168 root: tree_sitter::Node<'tree>,
12169 source: &str,
12170 reference: &NameMatchRef,
12171 lang: LangId,
12172) -> Option<tree_sitter::Node<'tree>> {
12173 let expected_name = reference.caller_symbol.rsplit("::").next();
12174 let line = reference.line.max(1);
12175 let mut best = None;
12176 let mut stack = vec![root];
12177 while let Some(node) = stack.pop() {
12178 if !node_contains_line(node, line) {
12179 continue;
12180 }
12181 if is_java_like_callable_kind(node.kind(), lang) {
12182 let name = declaration_name(node, source);
12183 if expected_name.is_none_or(|expected| name == Some(expected)) {
12184 best = tighter_node(best, node);
12185 }
12186 }
12187 push_named_children(node, &mut stack);
12188 }
12189 best
12190}
12191
12192fn find_enclosing_cpp_callable_node<'tree>(
12193 root: tree_sitter::Node<'tree>,
12194 _source: &str,
12195 reference: &NameMatchRef,
12196) -> Option<tree_sitter::Node<'tree>> {
12197 let line = reference.line.max(1);
12198 let mut best = None;
12199 let mut stack = vec![root];
12200 while let Some(node) = stack.pop() {
12201 if !node_contains_line(node, line) {
12202 continue;
12203 }
12204 if node.kind() == "function_definition" {
12205 best = tighter_node(best, node);
12206 }
12207 push_named_children(node, &mut stack);
12208 }
12209 best
12210}
12211
12212fn tighter_node<'tree>(
12213 current: Option<tree_sitter::Node<'tree>>,
12214 candidate: tree_sitter::Node<'tree>,
12215) -> Option<tree_sitter::Node<'tree>> {
12216 match current {
12217 Some(current)
12218 if current.start_byte() > candidate.start_byte()
12219 || (current.start_byte() == candidate.start_byte()
12220 && current.end_byte() <= candidate.end_byte()) =>
12221 {
12222 Some(current)
12223 }
12224 _ => Some(candidate),
12225 }
12226}
12227
12228fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
12229 let start = node.start_position().row as u32 + 1;
12230 let end = node.end_position().row as u32 + 1;
12231 start <= line && line <= end
12232}
12233
12234fn push_named_children<'tree>(
12235 node: tree_sitter::Node<'tree>,
12236 stack: &mut Vec<tree_sitter::Node<'tree>>,
12237) {
12238 for index in 0..node.named_child_count() {
12239 if let Some(child) = node.named_child(index as u32) {
12240 stack.push(child);
12241 }
12242 }
12243}
12244
12245fn declaration_name<'source>(
12246 node: tree_sitter::Node<'_>,
12247 source: &'source str,
12248) -> Option<&'source str> {
12249 node.child_by_field_name("name")
12250 .map(|name| node_text(name, source))
12251 .or_else(|| {
12252 first_named_child_text(
12253 node,
12254 source,
12255 &["identifier", "type_identifier", "simple_identifier"],
12256 )
12257 })
12258}
12259
12260fn first_named_child_text<'source>(
12261 node: tree_sitter::Node<'_>,
12262 source: &'source str,
12263 kinds: &[&str],
12264) -> Option<&'source str> {
12265 for index in 0..node.named_child_count() {
12266 let child = node.named_child(index as u32)?;
12267 if kinds.contains(&child.kind()) {
12268 return Some(node_text(child, source));
12269 }
12270 }
12271 None
12272}
12273
12274fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
12275 &source[node.byte_range()]
12276}
12277
12278fn infer_java_like_field_receiver_type(
12279 type_node: tree_sitter::Node<'_>,
12280 source: &str,
12281 receiver: &str,
12282 lang: LangId,
12283) -> Option<String> {
12284 let mut stack = Vec::new();
12285 push_named_children(type_node, &mut stack);
12286 while let Some(node) = stack.pop() {
12287 if is_java_like_field_kind(node.kind(), lang) {
12288 if let Some(receiver_type) =
12289 extract_java_like_declared_type(node_text(node, source), receiver, lang)
12290 {
12291 return Some(receiver_type);
12292 }
12293 }
12294 if is_java_like_type_kind(node.kind(), lang)
12295 || is_java_like_callable_kind(node.kind(), lang)
12296 {
12297 continue;
12298 }
12299 push_named_children(node, &mut stack);
12300 }
12301 None
12302}
12303
12304fn infer_java_like_local_receiver_type(
12305 callable_node: tree_sitter::Node<'_>,
12306 source: &str,
12307 receiver: &str,
12308 call_line: u32,
12309 lang: LangId,
12310) -> Option<String> {
12311 let mut best: Option<(u32, String)> = None;
12312 let mut stack = Vec::new();
12313 push_named_children(callable_node, &mut stack);
12314 while let Some(node) = stack.pop() {
12315 let start_line = node.start_position().row as u32 + 1;
12316 if start_line > call_line {
12317 continue;
12318 }
12319 if is_java_like_local_kind(node.kind(), lang) {
12320 if let Some(receiver_type) =
12321 extract_java_like_declared_type(node_text(node, source), receiver, lang)
12322 {
12323 if best
12324 .as_ref()
12325 .is_none_or(|(best_line, _)| start_line >= *best_line)
12326 {
12327 best = Some((start_line, receiver_type));
12328 }
12329 }
12330 }
12331 if is_java_like_type_kind(node.kind(), lang)
12332 || is_java_like_callable_kind(node.kind(), lang)
12333 {
12334 continue;
12335 }
12336 push_named_children(node, &mut stack);
12337 }
12338 best.map(|(_, receiver_type)| receiver_type)
12339}
12340
12341fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
12342 match lang {
12343 LangId::Java => matches!(
12344 kind,
12345 "class_declaration"
12346 | "interface_declaration"
12347 | "enum_declaration"
12348 | "record_declaration"
12349 | "annotation_type_declaration"
12350 ),
12351 LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
12352 _ => false,
12353 }
12354}
12355
12356fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
12357 match lang {
12358 LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
12359 LangId::Kotlin => kind == "function_declaration",
12360 _ => false,
12361 }
12362}
12363
12364fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
12365 match lang {
12366 LangId::Java => kind == "field_declaration",
12367 LangId::Kotlin => kind == "property_declaration",
12368 _ => false,
12369 }
12370}
12371
12372fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
12373 match lang {
12374 LangId::Java => kind == "local_variable_declaration",
12375 LangId::Kotlin => kind == "property_declaration",
12376 _ => false,
12377 }
12378}
12379
12380fn extract_java_like_declared_type(
12381 declaration: &str,
12382 receiver: &str,
12383 lang: LangId,
12384) -> Option<String> {
12385 match lang {
12386 LangId::Java => extract_java_declared_type(declaration, receiver),
12387 LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
12388 _ => None,
12389 }
12390}
12391
12392fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
12393 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
12394 let after = declaration[receiver_start + receiver.len()..].trim_start();
12395 if after
12396 .chars()
12397 .next()
12398 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
12399 {
12400 return None;
12401 }
12402
12403 let before = declaration[..receiver_start].trim_end();
12404 if before.contains(',') {
12405 return None;
12406 }
12407 normalize_receiver_type_name(strip_java_declaration_prefixes(before))
12408}
12409
12410fn strip_java_declaration_prefixes(mut value: &str) -> &str {
12411 loop {
12412 value = value.trim_start();
12413 if let Some(stripped) = strip_leading_java_annotation(value) {
12414 value = stripped;
12415 continue;
12416 }
12417 if let Some(stripped) = strip_leading_java_modifier(value) {
12418 value = stripped;
12419 continue;
12420 }
12421 return value.trim();
12422 }
12423}
12424
12425fn strip_leading_java_annotation(value: &str) -> Option<&str> {
12426 let value = value.trim_start();
12427 let mut chars = value.char_indices();
12428 let (_, first) = chars.next()?;
12429 if first != '@' {
12430 return None;
12431 }
12432 let mut end = first.len_utf8();
12433 for (index, ch) in chars {
12434 if !(is_code_ident_char(ch) || ch == '.') {
12435 end = index;
12436 break;
12437 }
12438 end = index + ch.len_utf8();
12439 }
12440 let rest = value[end..].trim_start();
12441 if let Some(stripped) = rest.strip_prefix('(') {
12442 let mut depth = 1usize;
12443 for (index, ch) in stripped.char_indices() {
12444 match ch {
12445 '(' => depth += 1,
12446 ')' => {
12447 depth = depth.saturating_sub(1);
12448 if depth == 0 {
12449 return Some(stripped[index + ch.len_utf8()..].trim_start());
12450 }
12451 }
12452 _ => {}
12453 }
12454 }
12455 return Some("");
12456 }
12457 Some(rest)
12458}
12459
12460fn strip_leading_java_modifier(value: &str) -> Option<&str> {
12461 const MODIFIERS: &[&str] = &[
12462 "public",
12463 "protected",
12464 "private",
12465 "abstract",
12466 "static",
12467 "final",
12468 "transient",
12469 "volatile",
12470 "synchronized",
12471 "native",
12472 "strictfp",
12473 ];
12474 MODIFIERS
12475 .iter()
12476 .find_map(|modifier| strip_leading_word(value, modifier))
12477}
12478
12479fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
12480 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
12481 let before = &declaration[..receiver_start];
12482 if find_identifier_occurrence(before, "val").is_none()
12483 && find_identifier_occurrence(before, "var").is_none()
12484 {
12485 return None;
12486 }
12487
12488 let after = declaration[receiver_start + receiver.len()..].trim_start();
12489 if let Some(type_text) = after.strip_prefix(':') {
12490 return normalize_receiver_type_name(read_type_prefix(type_text));
12491 }
12492 after
12493 .strip_prefix('=')
12494 .and_then(infer_kotlin_constructor_type)
12495}
12496
12497fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
12498 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
12499 if rest.trim_start().starts_with('(') {
12500 normalize_receiver_type_name(head)
12501 } else {
12502 None
12503 }
12504}
12505
12506fn read_type_prefix(value: &str) -> &str {
12507 let mut angle_depth = 0usize;
12508 for (index, ch) in value.char_indices() {
12509 match ch {
12510 '<' => angle_depth += 1,
12511 '>' => angle_depth = angle_depth.saturating_sub(1),
12512 '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
12513 return value[..index].trim();
12514 }
12515 _ => {}
12516 }
12517 }
12518 value.trim()
12519}
12520
12521fn infer_cpp_receiver_type_from_scope(
12522 scope: tree_sitter::Node<'_>,
12523 source: &str,
12524 receiver: &str,
12525 call_line: u32,
12526) -> Option<String> {
12527 let lines = source.lines().collect::<Vec<_>>();
12528 if lines.is_empty() {
12529 return None;
12530 }
12531 let scope_start = scope.start_position().row as usize;
12532 let call_index = (call_line as usize)
12533 .saturating_sub(1)
12534 .min(lines.len().saturating_sub(1));
12535 for index in (scope_start..=call_index).rev() {
12536 if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
12537 return Some(receiver_type);
12538 }
12539 }
12540 None
12541}
12542
12543fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
12544 for receiver_start in identifier_occurrences(line, receiver) {
12545 let after = line[receiver_start + receiver.len()..].trim_start();
12546 if after
12547 .chars()
12548 .next()
12549 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
12550 {
12551 continue;
12552 }
12553 let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
12554 let normalized = normalize_cpp_type_name(type_text)?;
12555 if normalized == "auto" {
12556 if let Some(rhs) = after.strip_prefix('=') {
12557 return infer_cpp_auto_receiver_type(rhs);
12558 }
12559 continue;
12560 }
12561 return Some(normalized);
12562 }
12563 None
12564}
12565
12566fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
12567 let candidate = prefix
12568 .rsplit([';', '{', '}', '('])
12569 .next()
12570 .unwrap_or(prefix)
12571 .trim();
12572 if candidate.is_empty() || candidate.ends_with(',') {
12573 None
12574 } else {
12575 Some(candidate)
12576 }
12577}
12578
12579fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
12580 let without_templates = strip_angle_groups(type_text);
12581 let mut cleaned = String::with_capacity(without_templates.len());
12582 for token in without_templates.split_whitespace() {
12583 if matches!(
12584 token,
12585 "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
12586 ) {
12587 continue;
12588 }
12589 if !cleaned.is_empty() {
12590 cleaned.push(' ');
12591 }
12592 cleaned.push_str(token);
12593 }
12594 let token = cleaned
12595 .split_whitespace()
12596 .last()
12597 .unwrap_or(cleaned.trim())
12598 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
12599 .trim_matches(['*', '&']);
12600 let simple = token.rsplit("::").next().unwrap_or(token).trim();
12601 if simple.is_empty() || cpp_non_type_token(simple) {
12602 None
12603 } else {
12604 Some(simple.to_string())
12605 }
12606}
12607
12608fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
12609 let rhs = rhs.trim_start();
12610 if let Some(after_new) = rhs.strip_prefix("new ") {
12611 return infer_cpp_constructor_type(after_new);
12612 }
12613 infer_cpp_make_template_type(rhs)
12614 .or_else(|| infer_cpp_constructor_type(rhs))
12615 .or_else(|| infer_cpp_factory_type(rhs))
12616}
12617
12618fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
12619 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
12620 let normalized = normalize_cpp_type_name(head)?;
12621 if !normalized
12622 .chars()
12623 .next()
12624 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
12625 {
12626 return None;
12627 }
12628 if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
12629 Some(normalized)
12630 } else {
12631 None
12632 }
12633}
12634
12635fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
12636 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
12637 if !rest.trim_start().starts_with('(') {
12638 return None;
12639 }
12640 let base = head.split('<').next().unwrap_or(head);
12641 let base_simple = base.rsplit("::").next().unwrap_or(base);
12642 if !matches!(base_simple, "make_unique" | "make_shared") {
12643 return None;
12644 }
12645 first_angle_arg(head).and_then(normalize_cpp_type_name)
12646}
12647
12648fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
12649 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
12650 if !rest.trim_start().starts_with('(') {
12651 return None;
12652 }
12653 let simple = head
12654 .split('<')
12655 .next()
12656 .unwrap_or(head)
12657 .rsplit("::")
12658 .next()
12659 .unwrap_or(head);
12660 for prefix in ["make", "create", "build"] {
12661 if let Some(suffix) = simple.strip_prefix(prefix) {
12662 if suffix
12663 .chars()
12664 .next()
12665 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
12666 {
12667 return normalize_cpp_type_name(suffix);
12668 }
12669 }
12670 }
12671 None
12672}
12673
12674#[derive(Debug, Clone, Copy)]
12675enum JavaLikeInvocation {
12676 Kotlin,
12677 Cpp,
12678}
12679
12680fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
12681 let value = value.trim_start();
12682 let mut end = 0usize;
12683 for (index, ch) in value.char_indices() {
12684 let allowed_separator = match flavor {
12685 JavaLikeInvocation::Kotlin => ch == '.',
12686 JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
12687 };
12688 if is_code_ident_char(ch) || allowed_separator {
12689 end = index + ch.len_utf8();
12690 continue;
12691 }
12692 break;
12693 }
12694 if end == 0 {
12695 return None;
12696 }
12697 let mut rest = &value[end..];
12698 if let Some(stripped) = rest.trim_start().strip_prefix('<') {
12699 let skipped = skip_balanced_angle(stripped)?;
12700 let rest_start = rest.len() - rest.trim_start().len();
12701 let angle_len = 1 + skipped;
12702 end += rest_start + angle_len;
12703 rest = &value[end..];
12704 }
12705 Some((value[..end].trim(), rest))
12706}
12707
12708fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
12709 let mut depth = 1usize;
12710 for (index, ch) in value_after_open.char_indices() {
12711 match ch {
12712 '<' => depth += 1,
12713 '>' => {
12714 depth = depth.saturating_sub(1);
12715 if depth == 0 {
12716 return Some(index + ch.len_utf8());
12717 }
12718 }
12719 _ => {}
12720 }
12721 }
12722 None
12723}
12724
12725fn first_angle_arg(value: &str) -> Option<&str> {
12726 let open = value.find('<')?;
12727 let inner_len = skip_balanced_angle(&value[open + 1..])?;
12728 let inner = &value[open + 1..open + inner_len];
12729 split_top_level_commas(inner).into_iter().next()
12730}
12731
12732fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
12733 let without_generics = strip_angle_groups(type_text);
12734 let cleaned = without_generics
12735 .replace("[]", " ")
12736 .replace("...", " ")
12737 .replace(['?', '&', '*'], " ");
12738 let token = cleaned
12739 .split_whitespace()
12740 .last()
12741 .unwrap_or(cleaned.trim())
12742 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
12743 let token = token.rsplit("::").next().unwrap_or(token);
12744 let simple = token.rsplit('.').next().unwrap_or(token).trim();
12745 if simple.is_empty()
12746 || java_like_primitive_type(simple)
12747 || !simple
12748 .chars()
12749 .next()
12750 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
12751 {
12752 None
12753 } else {
12754 Some(simple.to_string())
12755 }
12756}
12757
12758fn simple_type_name(scoped_name: &str) -> Option<String> {
12759 scoped_name
12760 .rsplit("::")
12761 .find(|segment| !segment.is_empty())
12762 .and_then(normalize_receiver_type_name)
12763}
12764
12765fn strip_angle_groups(value: &str) -> String {
12766 let mut output = String::with_capacity(value.len());
12767 let mut depth = 0usize;
12768 for ch in value.chars() {
12769 match ch {
12770 '<' => {
12771 if depth == 0 {
12772 output.push(' ');
12773 }
12774 depth += 1;
12775 }
12776 '>' => depth = depth.saturating_sub(1),
12777 _ if depth == 0 => output.push(ch),
12778 _ => {}
12779 }
12780 }
12781 output
12782}
12783
12784fn java_like_primitive_type(value: &str) -> bool {
12785 matches!(
12786 value,
12787 "boolean"
12788 | "byte"
12789 | "char"
12790 | "double"
12791 | "float"
12792 | "int"
12793 | "long"
12794 | "short"
12795 | "void"
12796 | "Boolean"
12797 | "Byte"
12798 | "Char"
12799 | "Double"
12800 | "Float"
12801 | "Int"
12802 | "Long"
12803 | "Short"
12804 | "Unit"
12805 )
12806}
12807
12808fn cpp_non_type_token(value: &str) -> bool {
12809 matches!(
12810 value,
12811 "return"
12812 | "if"
12813 | "else"
12814 | "for"
12815 | "while"
12816 | "do"
12817 | "switch"
12818 | "case"
12819 | "default"
12820 | "break"
12821 | "continue"
12822 | "goto"
12823 | "throw"
12824 | "new"
12825 | "delete"
12826 | "co_await"
12827 | "co_yield"
12828 | "co_return"
12829 | "static_cast"
12830 | "const_cast"
12831 | "dynamic_cast"
12832 | "reinterpret_cast"
12833 | "sizeof"
12834 | "alignof"
12835 | "typeid"
12836 | "and"
12837 | "or"
12838 | "not"
12839 | "xor"
12840 )
12841}
12842
12843fn receiver_is_bare_identifier(value: &str) -> bool {
12844 let mut chars = value.chars();
12845 let Some(first) = chars.next() else {
12846 return false;
12847 };
12848 (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
12849}
12850
12851fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
12852 identifier_occurrences(value, needle).into_iter().next()
12853}
12854
12855fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
12856 value
12857 .match_indices(needle)
12858 .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
12859 .collect()
12860}
12861
12862fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
12863 let before = value[..start].chars().next_back();
12864 let after = value[start + len..].chars().next();
12865 !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
12866}
12867
12868fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
12869 let stripped = value.strip_prefix(word)?;
12870 if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
12871 Some(stripped.trim_start())
12872 } else {
12873 None
12874 }
12875}
12876
12877fn is_code_ident_char(ch: char) -> bool {
12878 ch == '_' || ch.is_ascii_alphanumeric()
12879}
12880
12881fn infer_rust_receiver_type(
12882 project_root: &Path,
12883 reference: &NameMatchRef,
12884 source_cache: &mut DispatchSourceCache,
12885) -> ReceiverTypeInference {
12886 if matches!(reference.receiver.as_str(), "self" | "Self") {
12887 return enclosing_type_from_scoped_name(&reference.caller_symbol)
12888 .map(ReceiverTypeInference::Known)
12889 .unwrap_or(ReceiverTypeInference::Unknown);
12890 }
12891
12892 if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
12893 return ReceiverTypeInference::Known(reference.receiver.clone());
12894 }
12895
12896 if let Some(receiver_type) = reference
12897 .caller_signature
12898 .as_deref()
12899 .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
12900 {
12901 return ReceiverTypeInference::Known(receiver_type);
12902 }
12903
12904 infer_rust_direct_self_field_receiver_type(project_root, reference, source_cache)
12905}
12906
12907fn infer_rust_direct_self_field_receiver_type(
12908 project_root: &Path,
12909 reference: &NameMatchRef,
12910 source_cache: &mut DispatchSourceCache,
12911) -> ReceiverTypeInference {
12912 if reference.colon_dispatch {
12913 return ReceiverTypeInference::Unknown;
12914 }
12915 let Some(field_name) = rust_direct_self_field_name(&reference.receiver_expression) else {
12916 return ReceiverTypeInference::Unknown;
12917 };
12918 if field_name != reference.receiver {
12919 return ReceiverTypeInference::Unknown;
12920 }
12921
12922 let Some(impl_type) = enclosing_type_from_scoped_name(&reference.caller_symbol) else {
12923 return ReceiverTypeInference::Unknown;
12924 };
12925 let Some(struct_name) = rust_direct_nominal_type_name(&impl_type) else {
12926 return ReceiverTypeInference::KnownButUnresolved;
12927 };
12928 let Some(parsed) = parsed_dispatch_source(project_root, reference, LangId::Rust, source_cache)
12929 else {
12930 return ReceiverTypeInference::Unknown;
12931 };
12932 let Some(impl_node) =
12933 find_enclosing_rust_impl_node(parsed.tree.root_node(), reference.line.max(1))
12934 else {
12935 return ReceiverTypeInference::Unknown;
12936 };
12937 if impl_node.child_by_field_name("trait").is_some()
12938 || impl_node.child_by_field_name("type_parameters").is_some()
12939 {
12940 return ReceiverTypeInference::KnownButUnresolved;
12941 }
12942 let Some(impl_target) = impl_node.child_by_field_name("type") else {
12943 return ReceiverTypeInference::KnownButUnresolved;
12944 };
12945 if impl_target.kind() != "type_identifier"
12946 || node_text(impl_target, &parsed.source) != impl_type
12947 {
12948 return ReceiverTypeInference::KnownButUnresolved;
12949 }
12950
12951 let module_scope = rust_module_scope(impl_node);
12952 let Some(struct_node) = find_unique_rust_struct(
12953 parsed.tree.root_node(),
12954 &parsed.source,
12955 struct_name,
12956 &module_scope,
12957 ) else {
12958 return ReceiverTypeInference::KnownButUnresolved;
12959 };
12960 let Some(field_type) = rust_struct_field_type_node(struct_node, &parsed.source, field_name)
12961 else {
12962 return ReceiverTypeInference::KnownButUnresolved;
12963 };
12964 if field_type.kind() != "type_identifier" {
12965 return ReceiverTypeInference::KnownButUnresolved;
12966 }
12967 let field_type_name = node_text(field_type, &parsed.source);
12968 if find_unique_rust_struct(
12969 parsed.tree.root_node(),
12970 &parsed.source,
12971 field_type_name,
12972 &module_scope,
12973 )
12974 .is_none()
12975 {
12976 return ReceiverTypeInference::KnownButUnresolved;
12977 }
12978
12979 ReceiverTypeInference::RustDirectSelfField {
12980 receiver_type: field_type_name.to_string(),
12981 declaration_file: reference.caller_file.clone(),
12982 module_scope,
12983 }
12984}
12985
12986fn rust_direct_self_field_name(receiver_expression: &str) -> Option<&str> {
12987 let (base, field) = receiver_expression.split_once('.')?;
12988 let base = base.trim();
12989 let field = field.trim();
12990 (base == "self" && rust_direct_nominal_type_name(field).is_some()).then_some(field)
12991}
12992
12993fn rust_direct_nominal_type_name(value: &str) -> Option<&str> {
12994 let name = value.rsplit("::").next()?.trim();
12995 (!name.is_empty()
12996 && !name.chars().next().is_some_and(|ch| ch.is_ascii_digit())
12997 && name.chars().all(is_rust_ident_char))
12998 .then_some(name)
12999}
13000
13001fn find_enclosing_rust_impl_node<'tree>(
13002 root: tree_sitter::Node<'tree>,
13003 line: u32,
13004) -> Option<tree_sitter::Node<'tree>> {
13005 let mut best = None;
13006 let mut stack = vec![root];
13007 while let Some(node) = stack.pop() {
13008 if !node_contains_line(node, line) {
13009 continue;
13010 }
13011 if node.kind() == "impl_item" {
13012 best = tighter_node(best, node);
13013 }
13014 push_named_children(node, &mut stack);
13015 }
13016 best
13017}
13018
13019fn rust_module_scope(node: tree_sitter::Node<'_>) -> Vec<(usize, usize)> {
13020 let mut scope = Vec::new();
13021 let mut current = node.parent();
13022 while let Some(parent) = current {
13023 if parent.kind() == "mod_item" {
13024 scope.push((parent.start_byte(), parent.end_byte()));
13025 }
13026 current = parent.parent();
13027 }
13028 scope.reverse();
13029 scope
13030}
13031
13032fn find_unique_rust_struct<'tree>(
13033 root: tree_sitter::Node<'tree>,
13034 source: &str,
13035 expected_name: &str,
13036 module_scope: &[(usize, usize)],
13037) -> Option<tree_sitter::Node<'tree>> {
13038 let mut found = None;
13039 let mut stack = vec![root];
13040 while let Some(node) = stack.pop() {
13041 if node.kind() == "struct_item"
13042 && rust_module_scope(node) == module_scope
13043 && node.child_by_field_name("type_parameters").is_none()
13044 && declaration_name(node, source) == Some(expected_name)
13045 {
13046 if found.is_some() {
13047 return None;
13048 }
13049 found = Some(node);
13050 }
13051 push_named_children(node, &mut stack);
13052 }
13053 found
13054}
13055
13056fn rust_struct_field_type_node<'tree>(
13057 struct_node: tree_sitter::Node<'tree>,
13058 source: &str,
13059 field_name: &str,
13060) -> Option<tree_sitter::Node<'tree>> {
13061 let fields = struct_node.child_by_field_name("body")?;
13062 if fields.kind() != "field_declaration_list" {
13063 return None;
13064 }
13065 for index in 0..fields.named_child_count() {
13066 let field = fields.named_child(index as u32)?;
13067 if field.kind() != "field_declaration"
13068 || declaration_name(field, source) != Some(field_name)
13069 {
13070 continue;
13071 }
13072 return field.child_by_field_name("type");
13073 }
13074 None
13075}
13076
13077fn rust_receiver_looks_type_like(receiver: &str) -> bool {
13078 receiver
13079 .chars()
13080 .next()
13081 .is_some_and(|ch| ch == '_' || ch.is_uppercase())
13082}
13083
13084fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
13085 scoped_name
13086 .rsplit_once("::")
13087 .map(|(enclosing, _)| enclosing)
13088 .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
13089 .map(ToString::to_string)
13090}
13091
13092fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
13093 let params = signature_parameter_text(signature)?;
13094 for param in split_top_level_commas(params) {
13095 let Some((pattern, type_text)) = param.split_once(':') else {
13096 continue;
13097 };
13098 let Some(name) = rust_parameter_name(pattern) else {
13099 continue;
13100 };
13101 if name == receiver {
13102 return normalize_rust_receiver_type(type_text);
13103 }
13104 }
13105 None
13106}
13107
13108fn signature_parameter_text(signature: &str) -> Option<&str> {
13109 let open = signature.find('(')?;
13110 let mut depth = 0usize;
13111 for (offset, ch) in signature[open..].char_indices() {
13112 match ch {
13113 '(' => depth += 1,
13114 ')' => {
13115 depth = depth.saturating_sub(1);
13116 if depth == 0 {
13117 return Some(&signature[open + 1..open + offset]);
13118 }
13119 }
13120 _ => {}
13121 }
13122 }
13123 None
13124}
13125
13126fn split_top_level_commas(value: &str) -> Vec<&str> {
13127 let mut parts = Vec::new();
13128 let mut start = 0usize;
13129 let mut angle_depth = 0usize;
13130 let mut paren_depth = 0usize;
13131 let mut bracket_depth = 0usize;
13132 for (index, ch) in value.char_indices() {
13133 match ch {
13134 '<' => angle_depth += 1,
13135 '>' => angle_depth = angle_depth.saturating_sub(1),
13136 '(' => paren_depth += 1,
13137 ')' => paren_depth = paren_depth.saturating_sub(1),
13138 '[' => bracket_depth += 1,
13139 ']' => bracket_depth = bracket_depth.saturating_sub(1),
13140 ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
13141 let part = value[start..index].trim();
13142 if !part.is_empty() {
13143 parts.push(part);
13144 }
13145 start = index + ch.len_utf8();
13146 }
13147 _ => {}
13148 }
13149 }
13150 let part = value[start..].trim();
13151 if !part.is_empty() {
13152 parts.push(part);
13153 }
13154 parts
13155}
13156
13157fn rust_parameter_name(pattern: &str) -> Option<&str> {
13158 let mut pattern = pattern.trim();
13159 if let Some(stripped) = pattern.strip_prefix("mut ") {
13160 pattern = stripped.trim_start();
13161 }
13162 pattern
13163 .rsplit(|ch: char| !is_rust_ident_char(ch))
13164 .find(|part| !part.is_empty())
13165}
13166
13167fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
13168 let mut ty = strip_leading_rust_type_modifiers(type_text);
13169 let owned_inner;
13170 if let Some(inner) = single_outer_generic_arg(ty) {
13171 owned_inner = inner.trim().to_string();
13172 ty = strip_leading_rust_type_modifiers(&owned_inner);
13173 }
13174 rust_base_type_ident(ty)
13175}
13176
13177fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
13178 loop {
13179 ty = ty.trim_start();
13180 if let Some(stripped) = ty.strip_prefix('&') {
13181 ty = stripped.trim_start();
13182 if let Some(stripped) = strip_leading_lifetime(ty) {
13183 ty = stripped.trim_start();
13184 }
13185 if let Some(stripped) = ty.strip_prefix("mut ") {
13186 ty = stripped.trim_start();
13187 }
13188 continue;
13189 }
13190 if let Some(stripped) = ty.strip_prefix("mut ") {
13191 ty = stripped.trim_start();
13192 continue;
13193 }
13194 if let Some(stripped) = ty.strip_prefix("dyn ") {
13195 ty = stripped.trim_start();
13196 continue;
13197 }
13198 if let Some(stripped) = ty.strip_prefix("impl ") {
13199 ty = stripped.trim_start();
13200 continue;
13201 }
13202 break ty.trim();
13203 }
13204}
13205
13206fn strip_leading_lifetime(value: &str) -> Option<&str> {
13207 let mut chars = value.char_indices();
13208 let (_, first) = chars.next()?;
13209 if first != '\'' {
13210 return None;
13211 }
13212 for (index, ch) in chars {
13213 if !(ch == '_' || ch.is_ascii_alphanumeric()) {
13214 return Some(&value[index..]);
13215 }
13216 }
13217 Some("")
13218}
13219
13220fn single_outer_generic_arg(ty: &str) -> Option<&str> {
13221 let ty = ty.trim();
13222 let open = ty.find('<')?;
13223 let mut depth = 0usize;
13224 let mut close = None;
13225 for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
13226 match ch {
13227 '<' => depth += 1,
13228 '>' => {
13229 depth = depth.saturating_sub(1);
13230 if depth == 0 {
13231 close = Some(index);
13232 break;
13233 }
13234 }
13235 _ => {}
13236 }
13237 }
13238 let close = close?;
13239 if !ty[close + 1..].trim().is_empty() {
13240 return None;
13241 }
13242 let inner = &ty[open + 1..close];
13243 let args = split_top_level_commas(inner);
13244 match args.as_slice() {
13245 [arg] => Some(*arg),
13246 _ => None,
13247 }
13248}
13249
13250fn rust_base_type_ident(ty: &str) -> Option<String> {
13251 let ty = ty.trim();
13252 let head = ty
13253 .split([' ', '+', '='])
13254 .find(|part| !part.is_empty())
13255 .unwrap_or(ty);
13256 let head = head.split('<').next().unwrap_or(head).trim();
13257 let ident = head
13258 .rsplit("::")
13259 .next()
13260 .unwrap_or(head)
13261 .trim_matches(|ch: char| !is_rust_ident_char(ch));
13262 if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
13263 None
13264 } else {
13265 Some(ident.to_string())
13266 }
13267}
13268
13269fn is_rust_ident_char(ch: char) -> bool {
13270 ch == '_' || ch.is_ascii_alphanumeric()
13271}
13272
13273fn select_rust_direct_self_field_candidate(
13274 project_root: &Path,
13275 reference: &NameMatchRef,
13276 candidates: &[NameMatchCandidate],
13277 receiver_type: &str,
13278 declaration_file: &str,
13279 declaration_scope: &[(usize, usize)],
13280 source_cache: &mut DispatchSourceCache,
13281) -> Option<NameMatchCandidate> {
13282 let eligible = candidates
13283 .iter()
13284 .filter(|candidate| candidate.node_id != reference.caller_node)
13285 .filter(|candidate| {
13286 type_candidate_matches(candidate, receiver_type, &reference.method_name)
13287 })
13288 .filter(|candidate| {
13289 rust_direct_self_field_candidate_matches_scope(
13290 project_root,
13291 candidate,
13292 receiver_type,
13293 declaration_file,
13294 declaration_scope,
13295 source_cache,
13296 )
13297 })
13298 .collect::<Vec<_>>();
13299 match eligible.as_slice() {
13300 [candidate] => Some((**candidate).clone()),
13301 _ => None,
13302 }
13303}
13304
13305fn rust_direct_self_field_candidate_matches_scope(
13306 project_root: &Path,
13307 candidate: &NameMatchCandidate,
13308 receiver_type: &str,
13309 declaration_file: &str,
13310 declaration_scope: &[(usize, usize)],
13311 source_cache: &mut DispatchSourceCache,
13312) -> bool {
13313 if candidate.file_path != declaration_file {
13314 return false;
13315 }
13316 let Some(parsed) = parsed_dispatch_source_for_file(
13317 project_root,
13318 &candidate.file_path,
13319 "rust",
13320 LangId::Rust,
13321 source_cache,
13322 ) else {
13323 return false;
13324 };
13325 let Some(impl_node) =
13326 find_enclosing_rust_impl_node(parsed.tree.root_node(), candidate.start_line)
13327 else {
13328 return false;
13329 };
13330 if impl_node.child_by_field_name("trait").is_some()
13331 || impl_node.child_by_field_name("type_parameters").is_some()
13332 {
13333 return false;
13334 }
13335 let Some(impl_target) = impl_node.child_by_field_name("type") else {
13336 return false;
13337 };
13338 impl_target.kind() == "type_identifier"
13339 && node_text(impl_target, &parsed.source) == receiver_type
13340 && rust_module_scope(impl_node) == declaration_scope
13341}
13342
13343fn select_type_match_candidate(
13344 reference: &NameMatchRef,
13345 candidates: &[NameMatchCandidate],
13346 receiver_type: &str,
13347) -> Option<NameMatchCandidate> {
13348 let candidates = candidates
13349 .iter()
13350 .filter(|candidate| candidate.node_id != reference.caller_node)
13351 .filter(|candidate| {
13352 type_candidate_matches(candidate, receiver_type, &reference.method_name)
13353 })
13354 .collect::<Vec<_>>();
13355 match candidates.as_slice() {
13356 [candidate] => Some((**candidate).clone()),
13357 _ => None,
13358 }
13359}
13360
13361fn type_candidate_matches(
13362 candidate: &NameMatchCandidate,
13363 receiver_type: &str,
13364 method_name: &str,
13365) -> bool {
13366 let normalized_type = receiver_type.replace('.', "::");
13367 let suffix = format!("{normalized_type}::{method_name}");
13368 candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
13369}
13370
13371fn select_name_match_candidate(
13372 reference: &NameMatchRef,
13373 candidates: &[NameMatchCandidate],
13374) -> Option<NameMatchCandidate> {
13375 let candidates = candidates
13376 .iter()
13377 .filter(|candidate| candidate.node_id != reference.caller_node)
13378 .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
13379 .collect::<Vec<_>>();
13380 match candidates.as_slice() {
13381 [] => None,
13382 [candidate] => Some((**candidate).clone()),
13383 _ => select_scored_name_match_candidate(reference, &candidates),
13384 }
13385}
13386
13387fn candidate_allowed_for_reference(
13388 reference: &NameMatchRef,
13389 candidate: &NameMatchCandidate,
13390) -> bool {
13391 if !reference.colon_dispatch {
13392 return true;
13393 }
13394
13395 candidate.kind == "method"
13396 && candidate
13397 .scoped_name
13398 .split("::")
13399 .any(|segment| segment == reference.receiver)
13400}
13401
13402fn select_scored_name_match_candidate(
13403 reference: &NameMatchRef,
13404 candidates: &[&NameMatchCandidate],
13405) -> Option<NameMatchCandidate> {
13406 let receiver_words = split_camel_case(&reference.receiver);
13407 if receiver_words.is_empty() {
13408 return None;
13409 }
13410
13411 let mut best: Option<(&NameMatchCandidate, f64)> = None;
13412 let mut tied_best = false;
13413 for candidate in candidates {
13414 let candidate_words = split_camel_case(&candidate.scoped_name);
13415 let overlap = receiver_words
13416 .iter()
13417 .filter(|receiver_word| {
13418 candidate_words
13419 .iter()
13420 .any(|candidate_word| candidate_word == *receiver_word)
13421 })
13422 .count() as f64;
13423 let score =
13424 overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
13425 match best {
13426 None => {
13427 best = Some((*candidate, score));
13428 tied_best = false;
13429 }
13430 Some((_, best_score)) if score > best_score => {
13431 best = Some((*candidate, score));
13432 tied_best = false;
13433 }
13434 Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
13435 tied_best = true;
13436 }
13437 _ => {}
13438 }
13439 }
13440
13441 let (candidate, score) = best?;
13442 if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
13443 Some(candidate.clone())
13444 } else {
13445 None
13446 }
13447}
13448
13449fn method_name_match_denylisted(method_name: &str) -> bool {
13450 matches!(
13451 method_name,
13452 "and_then"
13453 | "as_bytes"
13454 | "as_deref"
13455 | "as_mut"
13456 | "as_ref"
13457 | "as_str"
13458 | "borrow"
13459 | "borrow_mut"
13460 | "clear"
13461 | "clone"
13462 | "collect"
13463 | "contains"
13464 | "contains_key"
13465 | "count"
13466 | "dedup"
13467 | "default"
13468 | "drain"
13469 | "ends_with"
13470 | "entry"
13471 | "err"
13472 | "expect"
13473 | "extend"
13474 | "filter"
13475 | "filter_map"
13476 | "find"
13477 | "from"
13478 | "get"
13479 | "get_mut"
13480 | "insert"
13481 | "into"
13482 | "into_iter"
13483 | "is_empty"
13484 | "is_err"
13485 | "is_none"
13486 | "is_ok"
13487 | "is_some"
13488 | "iter"
13489 | "iter_mut"
13490 | "join"
13491 | "len"
13492 | "lock"
13493 | "map"
13494 | "map_err"
13495 | "max"
13496 | "min"
13497 | "new"
13498 | "next"
13499 | "ok"
13500 | "or_default"
13501 | "or_else"
13502 | "or_insert"
13503 | "or_insert_with"
13504 | "parse"
13505 | "pop"
13506 | "position"
13507 | "push"
13508 | "read"
13509 | "recv"
13510 | "remove"
13511 | "replace"
13512 | "retain"
13513 | "send"
13514 | "sort"
13515 | "sort_by"
13516 | "split"
13517 | "starts_with"
13518 | "sum"
13519 | "take"
13520 | "to_owned"
13521 | "to_string"
13522 | "trim"
13523 | "try_from"
13524 | "try_into"
13525 | "unwrap"
13526 | "unwrap_or"
13527 | "unwrap_or_default"
13528 | "unwrap_or_else"
13529 | "with_capacity"
13530 | "write"
13531 )
13532}
13533
13534fn split_camel_case(value: &str) -> Vec<String> {
13535 let chars = value.chars().collect::<Vec<_>>();
13536 let mut normalized = String::with_capacity(value.len() + 8);
13537 for (index, ch) in chars.iter().enumerate() {
13538 let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
13539 let next = chars.get(index + 1);
13540 let is_separator = ch.is_whitespace()
13541 || matches!(
13542 ch,
13543 '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
13544 );
13545 if is_separator {
13546 normalized.push(' ');
13547 continue;
13548 }
13549 let camel_boundary = previous.is_some_and(|prev| {
13550 (prev.is_lowercase() && ch.is_uppercase())
13551 || (prev.is_ascii_digit() && ch.is_alphabetic())
13552 || (prev.is_uppercase()
13553 && ch.is_uppercase()
13554 && next.is_some_and(|next| next.is_lowercase()))
13555 });
13556 if camel_boundary {
13557 normalized.push(' ');
13558 }
13559 normalized.push(*ch);
13560 }
13561
13562 normalized
13563 .split_whitespace()
13564 .filter(|word| word.len() > 1)
13565 .map(|word| word.to_ascii_lowercase())
13566 .collect()
13567}
13568
13569fn compute_path_proximity(left: &str, right: &str) -> f64 {
13570 let left_dirs = left
13571 .rsplit_once('/')
13572 .map(|(dir, _)| dir)
13573 .unwrap_or_default()
13574 .split('/')
13575 .filter(|part| !part.is_empty());
13576 let right_dirs = right
13577 .rsplit_once('/')
13578 .map(|(dir, _)| dir)
13579 .unwrap_or_default()
13580 .split('/')
13581 .filter(|part| !part.is_empty());
13582
13583 let shared = left_dirs
13584 .zip(right_dirs)
13585 .take_while(|(left, right)| left == right)
13586 .count();
13587 ((shared as f64) * 0.05).min(0.5)
13588}
13589
13590fn mark_backend_state(
13591 tx: &Transaction<'_>,
13592 project_root: &Path,
13593 rel_path: &str,
13594 content_hash: Option<&blake3::Hash>,
13595 status: &str,
13596) -> Result<()> {
13597 clear_backend_state_for_file(tx, project_root, rel_path)?;
13598 let hash = content_hash
13599 .map(|hash| hash_to_hex(*hash))
13600 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
13601 tx.execute(
13602 "INSERT OR REPLACE INTO backend_file_state(
13603 backend, workspace_root, file_path, content_hash, status, updated_at
13604 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
13605 params![
13606 BACKEND_TREESITTER,
13607 project_root.display().to_string(),
13608 rel_path,
13609 hash,
13610 status,
13611 unix_seconds_now(),
13612 ],
13613 )?;
13614 Ok(())
13615}
13616
13617fn clear_backend_state_for_file(
13618 tx: &Transaction<'_>,
13619 project_root: &Path,
13620 rel_path: &str,
13621) -> Result<()> {
13622 tx.execute(
13623 "DELETE FROM backend_file_state
13624 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
13625 params![
13626 BACKEND_TREESITTER,
13627 project_root.display().to_string(),
13628 rel_path
13629 ],
13630 )?;
13631 Ok(())
13632}
13633
13634fn clear_stale_backend_status_for_file(
13640 tx: &Transaction<'_>,
13641 project_root: &Path,
13642 rel_path: &str,
13643) -> Result<()> {
13644 tx.execute(
13645 "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
13646 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3 AND status = 'stale'",
13647 params![
13648 BACKEND_TREESITTER,
13649 project_root.display().to_string(),
13650 rel_path,
13651 unix_seconds_now(),
13652 ],
13653 )?;
13654 Ok(())
13655}
13656
13657fn load_file_row(conn: &Connection, rel_path: &str) -> Result<Option<FileRow>> {
13658 conn.query_row(
13659 "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
13660 params![rel_path],
13661 |row| {
13662 let hash_text: String = row.get(1)?;
13663 Ok(FileRow {
13664 surface_fingerprint: row.get(0)?,
13665 freshness: FileFreshness {
13666 content_hash: hash_from_hex(&hash_text)
13667 .unwrap_or_else(cache_freshness::zero_hash),
13668 mtime: ns_to_system_time(row.get::<_, i64>(2)?),
13669 size: row.get::<_, i64>(3)? as u64,
13670 },
13671 })
13672 },
13673 )
13674 .optional()
13675 .map_err(CallGraphStoreError::from)
13676}
13677
13678fn stored_node_ids_match_extract(
13679 tx: &Transaction<'_>,
13680 rel_path: &str,
13681 extract: &FileExtract,
13682) -> Result<bool> {
13683 let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
13684 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
13685 let mut stored = BTreeSet::new();
13686 for row in rows {
13687 stored.insert(row?);
13688 }
13689 let extracted = extract
13690 .nodes
13691 .iter()
13692 .map(|node| node.id.clone())
13693 .collect::<BTreeSet<_>>();
13694 Ok(stored == extracted)
13695}
13696
13697fn stored_extract_matches(
13701 tx: &Transaction<'_>,
13702 rel_path: &str,
13703 extract: &FileExtract,
13704 index: &ProjectIndex<'_>,
13705) -> Result<bool> {
13706 let stored_file = tx
13707 .query_row(
13708 "SELECT lang, surface_fingerprint FROM files WHERE path = ?1",
13709 params![rel_path],
13710 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
13711 )
13712 .optional()?;
13713 if stored_file
13714 != Some((
13715 lang_label(extract.lang).to_string(),
13716 extract.surface_fingerprint.clone(),
13717 ))
13718 {
13719 return Ok(false);
13720 }
13721
13722 let mut stored_nodes_stmt = tx.prepare(
13723 "SELECT id, file_path, name, scoped_name, kind, start_line, start_col,
13724 end_line, end_col, range_ordinal, signature, exported,
13725 is_default_export, is_type_like, is_callgraph_entry_point, provenance
13726 FROM nodes WHERE file_path = ?1",
13727 )?;
13728 let stored_nodes = stored_nodes_stmt
13729 .query_map(params![rel_path], |row| {
13730 Ok(serde_json::json!([
13731 row.get::<_, String>(0)?,
13732 row.get::<_, String>(1)?,
13733 row.get::<_, String>(2)?,
13734 row.get::<_, String>(3)?,
13735 row.get::<_, String>(4)?,
13736 row.get::<_, i64>(5)?,
13737 row.get::<_, i64>(6)?,
13738 row.get::<_, i64>(7)?,
13739 row.get::<_, i64>(8)?,
13740 row.get::<_, i64>(9)?,
13741 row.get::<_, Option<String>>(10)?,
13742 row.get::<_, i64>(11)?,
13743 row.get::<_, i64>(12)?,
13744 row.get::<_, i64>(13)?,
13745 row.get::<_, i64>(14)?,
13746 row.get::<_, String>(15)?,
13747 ])
13748 .to_string())
13749 })?
13750 .collect::<rusqlite::Result<Vec<_>>>()?;
13751 let expected_nodes = extract
13752 .nodes
13753 .iter()
13754 .map(|node| {
13755 serde_json::json!([
13756 node.id,
13757 node.file_path,
13758 node.name,
13759 node.scoped_name,
13760 node.kind,
13761 node.range.start_line,
13762 node.range.start_col,
13763 node.range.end_line,
13764 node.range.end_col,
13765 node.range_ordinal,
13766 node.signature,
13767 bool_int(node.exported),
13768 bool_int(node.is_default_export),
13769 bool_int(node.is_type_like),
13770 bool_int(node.is_callgraph_entry_point),
13771 PROVENANCE_TREESITTER,
13772 ])
13773 .to_string()
13774 })
13775 .collect::<Vec<_>>();
13776 let mut stored_nodes = stored_nodes;
13777 let mut expected_nodes = expected_nodes;
13778 stored_nodes.sort();
13779 expected_nodes.sort();
13780 if stored_nodes != expected_nodes {
13781 return Ok(false);
13782 }
13783
13784 let resolved_refs = extract
13785 .raw_refs
13786 .iter()
13787 .cloned()
13788 .map(|raw| resolve_ref(raw, index))
13789 .collect::<Result<Vec<_>>>()?;
13790 let mut stored_refs_stmt = tx.prepare(
13791 "SELECT ref_id, caller_node, caller_file, kind, short_name, full_ref,
13792 module_path, import_kind, local_name, requested_name, namespace_alias,
13793 wildcard, line, byte_start, byte_end, status, target_node,
13794 target_file, target_symbol, provenance
13795 FROM refs WHERE caller_file = ?1",
13796 )?;
13797 let stored_refs = stored_refs_stmt
13798 .query_map(params![rel_path], |row| {
13799 Ok(serde_json::json!([
13800 row.get::<_, String>(0)?,
13801 row.get::<_, Option<String>>(1)?,
13802 row.get::<_, String>(2)?,
13803 row.get::<_, String>(3)?,
13804 row.get::<_, Option<String>>(4)?,
13805 row.get::<_, Option<String>>(5)?,
13806 row.get::<_, Option<String>>(6)?,
13807 row.get::<_, Option<String>>(7)?,
13808 row.get::<_, Option<String>>(8)?,
13809 row.get::<_, Option<String>>(9)?,
13810 row.get::<_, Option<String>>(10)?,
13811 row.get::<_, i64>(11)?,
13812 row.get::<_, i64>(12)?,
13813 row.get::<_, i64>(13)?,
13814 row.get::<_, i64>(14)?,
13815 row.get::<_, String>(15)?,
13816 row.get::<_, Option<String>>(16)?,
13817 row.get::<_, Option<String>>(17)?,
13818 row.get::<_, Option<String>>(18)?,
13819 row.get::<_, String>(19)?,
13820 ])
13821 .to_string())
13822 })?
13823 .collect::<rusqlite::Result<Vec<_>>>()?;
13824 let expected_refs = resolved_refs
13825 .iter()
13826 .map(|resolved| {
13827 let raw = &resolved.raw;
13828 serde_json::json!([
13829 raw.ref_id,
13830 raw.caller_node,
13831 raw.caller_file,
13832 raw.kind,
13833 raw.short_name,
13834 raw.full_ref,
13835 raw.module_path,
13836 raw.import_kind,
13837 raw.local_name,
13838 raw.requested_name,
13839 raw.namespace_alias,
13840 bool_int(raw.wildcard),
13841 raw.line,
13842 raw.byte_start,
13843 raw.byte_end,
13844 resolved.status,
13845 resolved.target_node,
13846 resolved.target_file,
13847 resolved.target_symbol,
13848 ref_provenance(raw),
13849 ])
13850 .to_string()
13851 })
13852 .collect::<Vec<_>>();
13853 let mut stored_refs = stored_refs;
13854 let mut expected_refs = expected_refs;
13855 stored_refs.sort();
13856 expected_refs.sort();
13857 if stored_refs != expected_refs {
13858 return Ok(false);
13859 }
13860
13861 let mut stored_edges_stmt = tx.prepare(
13862 "SELECT e.edge_id, e.ref_id, e.source_node, e.target_node,
13863 e.target_file, e.target_symbol, e.kind, e.line, e.provenance
13864 FROM edges e JOIN refs r ON r.ref_id = e.ref_id
13865 WHERE r.caller_file = ?1 AND e.provenance = ?2",
13866 )?;
13867 let stored_edges = stored_edges_stmt
13868 .query_map(params![rel_path, PROVENANCE_TREESITTER], |row| {
13869 Ok(serde_json::json!([
13870 row.get::<_, String>(0)?,
13871 row.get::<_, String>(1)?,
13872 row.get::<_, String>(2)?,
13873 row.get::<_, Option<String>>(3)?,
13874 row.get::<_, String>(4)?,
13875 row.get::<_, String>(5)?,
13876 row.get::<_, String>(6)?,
13877 row.get::<_, i64>(7)?,
13878 row.get::<_, String>(8)?,
13879 ])
13880 .to_string())
13881 })?
13882 .collect::<rusqlite::Result<Vec<_>>>()?;
13883 let expected_edges = resolved_refs
13884 .iter()
13885 .filter_map(|resolved| {
13886 resolved.edge.as_ref().map(|edge| {
13887 serde_json::json!([
13888 edge.edge_id,
13889 resolved.raw.ref_id,
13890 edge.source_node,
13891 edge.target_node,
13892 edge.target_file,
13893 edge.target_symbol,
13894 edge.kind,
13895 edge.line,
13896 ref_provenance(&resolved.raw),
13897 ])
13898 .to_string()
13899 })
13900 })
13901 .collect::<Vec<_>>();
13902 let mut stored_edges = stored_edges;
13903 let mut expected_edges = expected_edges;
13904 stored_edges.sort();
13905 expected_edges.sort();
13906 if stored_edges != expected_edges {
13907 return Ok(false);
13908 }
13909
13910 let mut stored_dependencies_stmt =
13911 tx.prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1")?;
13912 let stored_dependencies = stored_dependencies_stmt
13913 .query_map(params![rel_path], |row| row.get::<_, String>(0))?
13914 .collect::<rusqlite::Result<BTreeSet<_>>>()?;
13915 let expected_dependencies = extract
13916 .raw_refs
13917 .iter()
13918 .flat_map(|raw| raw.dependencies.iter().cloned())
13919 .collect::<BTreeSet<_>>();
13920 if stored_dependencies != expected_dependencies {
13921 return Ok(false);
13922 }
13923
13924 let mut stored_hints_stmt = tx.prepare(
13925 "SELECT id, method_name, caller_node, file, line, byte_start, byte_end, provenance
13926 FROM dispatch_hints WHERE file = ?1",
13927 )?;
13928 let stored_hints = stored_hints_stmt
13929 .query_map(params![rel_path], |row| {
13930 Ok(serde_json::json!([
13931 row.get::<_, String>(0)?,
13932 row.get::<_, String>(1)?,
13933 row.get::<_, String>(2)?,
13934 row.get::<_, String>(3)?,
13935 row.get::<_, i64>(4)?,
13936 row.get::<_, i64>(5)?,
13937 row.get::<_, i64>(6)?,
13938 row.get::<_, String>(7)?,
13939 ])
13940 .to_string())
13941 })?
13942 .collect::<rusqlite::Result<Vec<_>>>()?;
13943 let expected_hints = extract
13944 .dispatch_hints
13945 .iter()
13946 .map(|hint| {
13947 serde_json::json!([
13948 hint.id,
13949 hint.method_name,
13950 hint.caller_node,
13951 hint.file,
13952 hint.line,
13953 hint.byte_start,
13954 hint.byte_end,
13955 PROVENANCE_TREESITTER,
13956 ])
13957 .to_string()
13958 })
13959 .collect::<Vec<_>>();
13960 let mut stored_hints = stored_hints;
13961 let mut expected_hints = expected_hints;
13962 stored_hints.sort();
13963 expected_hints.sort();
13964 Ok(stored_hints == expected_hints)
13965}
13966
13967fn update_file_fresh_metadata(
13968 tx: &Transaction<'_>,
13969 project_root: &Path,
13970 rel_path: &str,
13971 hash: &blake3::Hash,
13972 mtime: SystemTime,
13973 size: u64,
13974) -> Result<()> {
13975 tx.execute(
13976 "UPDATE files SET content_hash = ?2, mtime_ns = ?3, size = ?4, indexed_at = ?5
13977 WHERE path = ?1",
13978 params![
13979 rel_path,
13980 hash_to_hex(*hash),
13981 system_time_to_ns(mtime),
13982 size as i64,
13983 unix_seconds_now()
13984 ],
13985 )?;
13986 tx.execute(
13987 "UPDATE backend_file_state SET content_hash = ?3, status = 'fresh', updated_at = ?5
13988 WHERE backend = ?1 AND file_path = ?2 AND workspace_root = ?4",
13989 params![
13990 BACKEND_TREESITTER,
13991 rel_path,
13992 hash_to_hex(*hash),
13993 project_root.display().to_string(),
13994 unix_seconds_now(),
13995 ],
13996 )?;
13997 Ok(())
13998}
13999
14000#[derive(Debug, Clone, PartialEq, Eq)]
14001struct DependentRefSelection {
14002 ref_id: String,
14003 caller_file: String,
14004}
14005
14006fn ref_ids_depending_on(
14007 conn: &Connection,
14008 project_root: &Path,
14009 rel_path: &str,
14010) -> Result<Vec<DependentRefSelection>> {
14011 let mut stmt = conn.prepare(
14012 "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
14013 FROM refs r
14014 WHERE r.caller_file IN (
14015 SELECT file_path FROM file_dependencies WHERE dep_file = ?1
14016 )
14017 OR r.target_file = ?1
14018 ORDER BY r.ref_id",
14019 )?;
14020 let rows = stmt.query_map(params![rel_path], |row| {
14021 Ok(RefDependencyRow {
14022 ref_id: row.get(0)?,
14023 kind: row.get(1)?,
14024 caller_file: row.get(2)?,
14025 module_path: row.get(3)?,
14026 target_file: row.get(4)?,
14027 })
14028 })?;
14029 let mut ids = Vec::new();
14030 for row in rows {
14031 let row = row?;
14032 if ref_dependency_row_depends_on(project_root, &row, rel_path) {
14033 ids.push(DependentRefSelection {
14034 ref_id: row.ref_id,
14035 caller_file: row.caller_file,
14036 });
14037 }
14038 }
14039 Ok(ids)
14040}
14041
14042fn record_dependent_refs(
14043 selected_ref_ids: &mut BTreeSet<String>,
14044 selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
14045 dependent_refs: Vec<DependentRefSelection>,
14046) {
14047 for dependent_ref in dependent_refs {
14048 let DependentRefSelection {
14049 ref_id,
14050 caller_file,
14051 } = dependent_ref;
14052 selected_ref_ids.insert(ref_id.clone());
14053 selected_refs_by_caller
14054 .entry(caller_file)
14055 .or_default()
14056 .insert(ref_id);
14057 }
14058}
14059
14060#[cfg(test)]
14061fn refs_by_caller_for_ref_ids(
14062 tx: &Transaction<'_>,
14063 ref_ids: &BTreeSet<String>,
14064) -> Result<BTreeMap<String, BTreeSet<String>>> {
14065 let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
14066 let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
14067 for ref_id in ref_ids {
14068 if let Some(caller) = stmt
14069 .query_row(params![ref_id], |row| row.get::<_, String>(0))
14070 .optional()?
14071 {
14072 by_caller.entry(caller).or_default().insert(ref_id.clone());
14073 }
14074 }
14075 Ok(by_caller)
14076}
14077
14078fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
14079 tx.execute(
14080 "DELETE FROM file_dependencies WHERE file_path = ?1",
14081 params![rel_path],
14082 )?;
14083 delete_refs_for_caller(tx, rel_path)?;
14084 tx.execute(
14085 "DELETE FROM dispatch_hints WHERE file = ?1",
14086 params![rel_path],
14087 )?;
14088 tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
14089 tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
14090 Ok(())
14091}
14092
14093fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
14094 let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
14095 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
14096 let mut ids = BTreeSet::new();
14097 for row in rows {
14098 ids.insert(row?);
14099 }
14100 delete_ref_ids(tx, &ids)
14101}
14102
14103fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
14104 let mut delete_edges = tx.prepare("DELETE FROM edges WHERE ref_id = ?1")?;
14105 let mut delete_refs = tx.prepare("DELETE FROM refs WHERE ref_id = ?1")?;
14106 for ref_id in ref_ids {
14107 delete_edges.execute(params![ref_id])?;
14108 delete_refs.execute(params![ref_id])?;
14109 }
14110 Ok(())
14111}
14112
14113fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
14114 let mut stmt = conn.prepare(
14115 "SELECT source.file_path, source.scoped_name, edges.target_file,
14116 edges.target_symbol, edges.kind, edges.line
14117 FROM edges
14118 JOIN nodes AS source ON source.id = edges.source_node
14119 ORDER BY source.file_path, source.scoped_name, edges.target_file,
14120 edges.target_symbol, edges.kind, edges.line",
14121 )?;
14122 let rows = stmt.query_map([], |row| {
14123 Ok(StoredEdge {
14124 source_file: row.get(0)?,
14125 source_symbol: row.get(1)?,
14126 target_file: row.get(2)?,
14127 target_symbol: row.get(3)?,
14128 kind: row.get(4)?,
14129 line: row.get::<_, i64>(5)? as u32,
14130 })
14131 })?;
14132 let mut edges = BTreeSet::new();
14133 for row in rows {
14134 edges.insert(row?);
14135 }
14136 Ok(edges)
14137}
14138
14139fn module_target_from_dependencies(
14140 project_root: &Path,
14141 dependencies: &BTreeSet<String>,
14142 facts: &FactPaths<'_>,
14143) -> Option<String> {
14144 dependencies.iter().find_map(|dep| {
14145 let path = project_root.join(dep);
14146 if facts.is_file(&path) {
14147 Some(relative_path(
14148 project_root,
14149 &facts.canonical(&path).unwrap_or(path.clone()),
14150 ))
14151 } else {
14152 None
14153 }
14154 })
14155}
14156
14157fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
14158 let mut named = HashMap::new();
14159 if let Some(full_ref) = &raw_ref.full_ref {
14160 named = parse_reexport_names(full_ref);
14161 }
14162 ReexportIndex {
14163 target_file,
14164 named,
14165 wildcard: raw_ref.wildcard,
14166 }
14167}
14168
14169fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
14170 let mut names = HashMap::new();
14171 let Some(open) = statement.find('{') else {
14172 return names;
14173 };
14174 let Some(close) = statement[open + 1..]
14175 .find('}')
14176 .map(|offset| open + 1 + offset)
14177 else {
14178 return names;
14179 };
14180 for spec in statement[open + 1..close].split(',') {
14181 let spec = spec.trim();
14182 if spec.is_empty() {
14183 continue;
14184 }
14185 if let Some((source, local)) = spec.split_once(" as ") {
14186 names.insert(local.trim().to_string(), source.trim().to_string());
14187 } else {
14188 names.insert(spec.to_string(), spec.to_string());
14189 }
14190 }
14191 names
14192}
14193
14194#[derive(Debug)]
14195struct RefDependencyRow {
14196 ref_id: String,
14197 kind: String,
14198 caller_file: String,
14199 module_path: Option<String>,
14200 target_file: Option<String>,
14201}
14202
14203fn ref_dependency_row_depends_on(
14204 project_root: &Path,
14205 row: &RefDependencyRow,
14206 rel_path: &str,
14207) -> bool {
14208 if row.target_file.as_deref() == Some(rel_path) {
14209 return true;
14210 }
14211
14212 match row.kind.as_str() {
14213 "call" => true,
14214 "import" | "reexport" => row
14215 .module_path
14216 .as_deref()
14217 .map(|module_path| {
14218 module_dependencies_for_ref(project_root, &row.caller_file, module_path)
14219 .contains(rel_path)
14220 })
14221 .unwrap_or(false),
14222 "export_alias" => false,
14223 _ => false,
14224 }
14225}
14226
14227fn module_dependencies_for_ref(
14228 project_root: &Path,
14229 caller_file: &str,
14230 module_path: &str,
14231) -> BTreeSet<String> {
14232 module_dependencies(
14233 project_root,
14234 &project_root.join(caller_file),
14235 module_path,
14236 &FactPaths {
14237 root: project_root,
14238 facts: &DiskFacts::new(project_root),
14239 },
14240 )
14241}
14242
14243fn import_dependencies(
14244 project_root: &Path,
14245 abs_path: &Path,
14246 imports: &[ImportStatement],
14247 facts: &FactPaths<'_>,
14248) -> BTreeSet<String> {
14249 let mut deps = BTreeSet::new();
14250 for import in imports {
14251 deps.extend(module_dependencies(
14252 project_root,
14253 abs_path,
14254 &import.module_path,
14255 facts,
14256 ));
14257 }
14258 deps
14259}
14260
14261fn module_dependencies(
14262 project_root: &Path,
14263 abs_path: &Path,
14264 module_path: &str,
14265 facts: &FactPaths<'_>,
14266) -> BTreeSet<String> {
14267 let mut deps = rust_module_dependencies(project_root, abs_path, module_path, facts);
14268 let caller_dir = abs_path.parent().unwrap_or(project_root);
14269 if let Some(resolved) = callgraph::resolve_module_path_with_memo(
14270 caller_dir,
14271 module_path,
14272 &callgraph::ModuleResolutionMemo::default(),
14273 facts,
14274 ) {
14275 deps.insert(relative_path(project_root, &resolved));
14276 }
14277 if module_path.starts_with('.') {
14278 let base = caller_dir.join(module_path);
14279 for candidate in relative_module_candidates(&base) {
14280 deps.insert(relative_path(project_root, &candidate));
14281 }
14282 }
14283 deps
14284}
14285
14286fn rust_module_dependencies(
14287 project_root: &Path,
14288 abs_path: &Path,
14289 module_path: &str,
14290 facts: &FactPaths<'_>,
14291) -> BTreeSet<String> {
14292 let mut deps = BTreeSet::new();
14293 let rel_path = relative_path(
14294 project_root,
14295 &facts
14296 .canonical(abs_path)
14297 .unwrap_or_else(|| abs_path.to_path_buf()),
14298 );
14299 let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
14300 return deps;
14301 };
14302 let src_prefix = rust_src_prefix(&rel_path);
14303 rust_push_module_dependency_candidate(
14304 project_root,
14305 &mut deps,
14306 &src_prefix,
14307 &path_segments,
14308 facts,
14309 );
14310 if !path_segments.is_empty() {
14311 rust_push_module_dependency_candidate(
14312 project_root,
14313 &mut deps,
14314 &src_prefix,
14315 &path_segments[..path_segments.len() - 1],
14316 facts,
14317 );
14318 }
14319 deps
14320}
14321
14322fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
14323 let path = rust_module_path_without_alias_or_use_list(module_path);
14324 let segments = path
14325 .split("::")
14326 .map(str::trim)
14327 .filter(|segment| !segment.is_empty())
14328 .collect::<Vec<_>>();
14329 if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
14330 return None;
14331 }
14332 rust_resolve_segments(rel_path, &segments)
14333}
14334
14335fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
14336 let path = module_path
14337 .trim()
14338 .trim_end_matches(';')
14339 .split_once(" as ")
14340 .map(|(left, _)| left.trim())
14341 .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
14342 path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
14343}
14344
14345fn rust_push_module_dependency_candidate(
14346 project_root: &Path,
14347 deps: &mut BTreeSet<String>,
14348 src_prefix: &str,
14349 segments: &[String],
14350 facts: &FactPaths<'_>,
14351) {
14352 let candidates = if segments.is_empty() {
14353 vec![
14354 format!("{src_prefix}/lib.rs"),
14355 format!("{src_prefix}/main.rs"),
14356 ]
14357 } else {
14358 vec![
14359 format!("{}/{}.rs", src_prefix, segments.join("/")),
14360 format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
14361 ]
14362 };
14363 for candidate in candidates {
14364 if facts.is_file(&project_root.join(&candidate)) {
14365 deps.insert(candidate);
14366 }
14367 }
14368}
14369
14370fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
14371 let mut candidates = Vec::new();
14372 if base.extension().is_some() {
14373 candidates.push(base.to_path_buf());
14374 return candidates;
14375 }
14376 for ext in JS_TS_EXTENSIONS {
14377 candidates.push(base.with_extension(ext));
14378 }
14379 for ext in JS_TS_EXTENSIONS {
14380 candidates.push(base.join(format!("index.{ext}")));
14381 }
14382 candidates
14383}
14384
14385fn import_local_names(import: &ImportStatement) -> Vec<String> {
14386 let mut names = Vec::new();
14387 if let Some(default) = &import.default_import {
14388 names.push(default.clone());
14389 }
14390 if let Some(namespace) = &import.namespace_import {
14391 names.push(namespace.clone());
14392 }
14393 for name in &import.names {
14394 names.push(crate::imports::specifier_local_name(name).to_string());
14395 }
14396 names
14397}
14398
14399fn import_requested_names(import: &ImportStatement) -> Vec<String> {
14400 import
14401 .names
14402 .iter()
14403 .map(|name| crate::imports::specifier_imported_name(name).to_string())
14404 .collect()
14405}
14406
14407fn import_is_wildcard(import: &ImportStatement) -> bool {
14408 import.namespace_import.is_some() || import.raw_text.contains('*')
14409}
14410
14411fn namespace_alias(full_ref: &str) -> Option<String> {
14412 full_ref
14413 .split_once('.')
14414 .map(|(namespace, _)| namespace.to_string())
14415}
14416
14417fn import_kind_label(kind: ImportKind) -> &'static str {
14418 match kind {
14419 ImportKind::Value => "value",
14420 ImportKind::Type => "type",
14421 ImportKind::SideEffect => "side_effect",
14422 }
14423}
14424
14425fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
14426 match kind {
14427 SymbolKind::Function => "function",
14428 SymbolKind::Kernel => "kernel",
14429 SymbolKind::Class => "class",
14430 SymbolKind::Method => "method",
14431 SymbolKind::Struct => "struct",
14432 SymbolKind::Interface => "interface",
14433 SymbolKind::Enum => "enum",
14434 SymbolKind::TypeAlias => "type_alias",
14435 SymbolKind::Variable => "variable",
14436 SymbolKind::Heading => "heading",
14437 SymbolKind::FileSummary => "file_summary",
14438 }
14439}
14440
14441fn is_type_like(kind: &SymbolKind) -> bool {
14442 matches!(
14443 kind,
14444 SymbolKind::Class
14445 | SymbolKind::Struct
14446 | SymbolKind::Interface
14447 | SymbolKind::Enum
14448 | SymbolKind::TypeAlias
14449 )
14450}
14451
14452fn lang_label(lang: LangId) -> &'static str {
14453 match lang {
14454 LangId::TypeScript => "typescript",
14455 LangId::Tsx => "tsx",
14456 LangId::JavaScript => "javascript",
14457 LangId::Python => "python",
14458 LangId::Rust => "rust",
14459 LangId::Go => "go",
14460 LangId::C => "c",
14461 LangId::Cpp => "cpp",
14462 LangId::Cuda => "cuda",
14463 LangId::Metal => "metal",
14464 LangId::Zig => "zig",
14465 LangId::CSharp => "csharp",
14466 LangId::Bash => "bash",
14467 LangId::Html => "html",
14468 LangId::Markdown => "markdown",
14469 LangId::Solidity => "solidity",
14470 LangId::Scss => "scss",
14471 LangId::Vue => "vue",
14472 LangId::Json => "json",
14473 LangId::Scala => "scala",
14474 LangId::Java => "java",
14475 LangId::Ruby => "ruby",
14476 LangId::Kotlin => "kotlin",
14477 LangId::Swift => "swift",
14478 LangId::Php => "php",
14479 LangId::Lua => "lua",
14480 LangId::Perl => "perl",
14481 LangId::Yaml => "yaml",
14482 LangId::Pascal => "pascal",
14483 LangId::R => "r",
14484 LangId::Groovy => "groovy",
14485 LangId::ObjC => "objc",
14486 LangId::Toml => "toml",
14487 }
14488}
14489
14490fn lang_from_label(label: &str) -> Option<LangId> {
14491 match label {
14492 "typescript" => Some(LangId::TypeScript),
14493 "tsx" => Some(LangId::Tsx),
14494 "javascript" => Some(LangId::JavaScript),
14495 "python" => Some(LangId::Python),
14496 "rust" => Some(LangId::Rust),
14497 "go" => Some(LangId::Go),
14498 "c" => Some(LangId::C),
14499 "cpp" => Some(LangId::Cpp),
14500 "cuda" => Some(LangId::Cuda),
14501 "metal" => Some(LangId::Metal),
14502 "zig" => Some(LangId::Zig),
14503 "csharp" => Some(LangId::CSharp),
14504 "bash" => Some(LangId::Bash),
14505 "html" => Some(LangId::Html),
14506 "markdown" => Some(LangId::Markdown),
14507 "solidity" => Some(LangId::Solidity),
14508 "scss" => Some(LangId::Scss),
14509 "vue" => Some(LangId::Vue),
14510 "json" => Some(LangId::Json),
14511 "scala" => Some(LangId::Scala),
14512 "java" => Some(LangId::Java),
14513 "ruby" => Some(LangId::Ruby),
14514 "kotlin" => Some(LangId::Kotlin),
14515 "swift" => Some(LangId::Swift),
14516 "php" => Some(LangId::Php),
14517 "lua" => Some(LangId::Lua),
14518 "perl" => Some(LangId::Perl),
14519 "yaml" => Some(LangId::Yaml),
14520 "pascal" => Some(LangId::Pascal),
14521 "r" => Some(LangId::R),
14522 "groovy" => Some(LangId::Groovy),
14523 "objc" => Some(LangId::ObjC),
14524 "toml" => Some(LangId::Toml),
14525 _ => None,
14526 }
14527}
14528
14529fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
14530 let mut normalized = if files.is_empty() {
14531 callgraph::walk_project_files(project_root).collect::<Vec<_>>()
14532 } else {
14533 files
14534 .iter()
14535 .map(|path| normalize_file_path(project_root, path))
14536 .collect::<Result<Vec<_>>>()?
14537 };
14538 normalized.sort();
14539 normalized.dedup();
14540 Ok(normalized)
14541}
14542
14543fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
14544 let full_path = if path.is_relative() {
14545 project_root.join(path)
14546 } else {
14547 path.to_path_buf()
14548 };
14549 Ok(canonicalize_path(&full_path))
14550}
14551
14552fn normalize_project_file_path(project_root: &Path, path: &Path) -> Result<(PathBuf, String)> {
14556 let abs_path = normalize_file_path(project_root, path)?;
14557 let rel_path = relative_path(project_root, &abs_path);
14558 if Path::new(&rel_path).is_absolute() {
14559 return Err(CallGraphStoreError::PathIdentityMismatch {
14560 path: path.to_path_buf(),
14561 project_root: project_root.to_path_buf(),
14562 });
14563 }
14564 Ok((abs_path, rel_path))
14565}
14566
14567fn canonicalize_path(path: &Path) -> PathBuf {
14571 if let Ok(canonical) = std::fs::canonicalize(path) {
14572 return canonical;
14573 }
14574
14575 let mut resolved = PathBuf::new();
14576 let mut missing = Vec::new();
14577 for component in path.components() {
14578 match component {
14579 std::path::Component::Prefix(_) | std::path::Component::RootDir => {
14580 resolved.push(component.as_os_str());
14581 if let Ok(canonical) = std::fs::canonicalize(&resolved) {
14582 resolved = canonical;
14583 }
14584 }
14585 std::path::Component::CurDir => {}
14586 std::path::Component::ParentDir => {
14587 if missing.pop().is_none() {
14588 if !resolved.as_os_str().is_empty() && !resolved.is_dir() {
14589 return path.to_path_buf();
14590 }
14591 resolved.pop();
14592 }
14593 }
14594 std::path::Component::Normal(name) => {
14595 if missing.is_empty() {
14596 let candidate = resolved.join(name);
14597 match std::fs::canonicalize(&candidate) {
14598 Ok(canonical) => resolved = canonical,
14599 Err(_) => match std::fs::symlink_metadata(&candidate) {
14600 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
14601 missing.push(name.to_owned());
14602 }
14603 _ => return path.to_path_buf(),
14604 },
14605 }
14606 } else {
14607 missing.push(name.to_owned());
14608 }
14609 }
14610 }
14611 }
14612 resolved.extend(missing);
14613 resolved
14614}
14615
14616fn relative_path(project_root: &Path, path: &Path) -> String {
14617 if let Ok(stripped) = path.strip_prefix(project_root) {
14618 return stripped.to_string_lossy().replace('\\', "/");
14619 }
14620 let canon_root = canonicalize_path(project_root);
14621 let canon_path = canonicalize_path(path);
14622 if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
14623 return stripped.to_string_lossy().replace('\\', "/");
14624 }
14625 canon_path.to_string_lossy().replace('\\', "/")
14626}
14627
14628fn unqualified_name(scoped: &str) -> &str {
14629 if scoped == TOP_LEVEL_SYMBOL {
14630 return scoped;
14631 }
14632 scoped
14633 .rsplit("::")
14634 .next()
14635 .unwrap_or(scoped)
14636 .rsplit('.')
14637 .next()
14638 .unwrap_or(scoped)
14639 .rsplit('#')
14640 .next()
14641 .unwrap_or(scoped)
14642}
14643
14644fn ref_id(parts: &[&str]) -> String {
14645 let joined = parts.join("\0");
14646 hash_to_hex(blake3::hash(joined.as_bytes()))
14647}
14648
14649fn callgraph_corpus_fingerprint(project_root: &Path) -> Result<String> {
14650 let mut fingerprint = CorpusFingerprint::default();
14651 for path in callgraph::walk_project_files(project_root) {
14652 fingerprint.add_path(project_root, &path);
14653 }
14654 Ok(fingerprint.finish(project_root))
14655}
14656
14657fn corpus_fingerprint_for(project_root: &Path, files: &[PathBuf]) -> Result<String> {
14661 if files.is_empty() {
14662 return callgraph_corpus_fingerprint(project_root);
14663 }
14664 let mut fingerprint = CorpusFingerprint::default();
14665 for path in files {
14666 fingerprint.add_path(project_root, path);
14667 }
14668 Ok(fingerprint.finish(project_root))
14669}
14670
14671#[derive(Default)]
14672struct CorpusFingerprint {
14673 xor: [u8; 32],
14674 sums: [u64; 4],
14675 files: u64,
14676}
14677
14678impl CorpusFingerprint {
14679 fn add_path(&mut self, project_root: &Path, path: &Path) {
14680 let mut record = blake3::Hasher::new();
14681 record.update(relative_path(project_root, path).as_bytes());
14682 record.update(&[0]);
14683 match hash_file_bounded(path) {
14684 Ok(content_hash) => record.update(content_hash.as_bytes()),
14685 Err(error) => record.update(format!("missing:{error}").as_bytes()),
14688 };
14689 record.update(&[0]);
14690 let record = record.finalize();
14691 for (index, byte) in record.as_bytes().iter().copied().enumerate() {
14692 self.xor[index] ^= byte;
14693 }
14694 for (index, chunk) in record.as_bytes().chunks_exact(8).enumerate() {
14695 let value = u64::from_le_bytes(chunk.try_into().expect("eight-byte digest chunk"));
14696 self.sums[index] = self.sums[index].wrapping_add(value);
14697 }
14698 self.files = self.files.saturating_add(1);
14699 }
14700
14701 fn finish(self, project_root: &Path) -> String {
14702 let mut hasher = blake3::Hasher::new();
14705 hasher.update(b"callgraph-corpus-fingerprint-v2\0");
14706 hasher.update(&self.files.to_le_bytes());
14707 hasher.update(&self.xor);
14708 for sum in self.sums {
14709 hasher.update(&sum.to_le_bytes());
14710 }
14711 let ignore_rules = project_root.join(".gitignore");
14712 if let Ok(contents) = std::fs::read(ignore_rules) {
14713 hasher.update(b".gitignore\0");
14714 hasher.update(blake3::hash(&contents).as_bytes());
14715 }
14716 hash_to_hex(hasher.finalize())
14717 }
14718}
14719
14720fn hash_file_bounded(path: &Path) -> std::io::Result<blake3::Hash> {
14721 let mut file = std::fs::File::open(path)?;
14722 let mut hasher = blake3::Hasher::new();
14723 let mut buffer = [0u8; 64 * 1024];
14724 loop {
14725 let read = file.read(&mut buffer)?;
14726 if read == 0 {
14727 break;
14728 }
14729 hasher.update(&buffer[..read]);
14730 }
14731 Ok(hasher.finalize())
14732}
14733
14734#[cfg(test)]
14735pub(crate) fn callgraph_corpus_fingerprint_for_test(
14736 project_root: &Path,
14737 _files: &[PathBuf],
14738) -> Result<String> {
14739 callgraph_corpus_fingerprint(project_root)
14743}
14744
14745fn hash_to_hex(hash: blake3::Hash) -> String {
14746 hash.to_hex().to_string()
14747}
14748
14749fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
14750 let bytes = hex_to_bytes(value)?;
14751 Some(blake3::Hash::from_bytes(bytes))
14752}
14753
14754fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
14755 if value.len() != 64 {
14756 return None;
14757 }
14758 let mut bytes = [0u8; 32];
14759 for (index, slot) in bytes.iter_mut().enumerate() {
14760 let start = index * 2;
14761 let end = start + 2;
14762 *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
14763 }
14764 Some(bytes)
14765}
14766
14767#[derive(Debug, Clone)]
14768struct LineIndex {
14769 newline_offsets: Vec<usize>,
14770 source_len: usize,
14771}
14772
14773impl LineIndex {
14774 fn new(source: &str) -> Self {
14775 Self {
14776 newline_offsets: source
14777 .bytes()
14778 .enumerate()
14779 .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
14780 .collect(),
14781 source_len: source.len(),
14782 }
14783 }
14784
14785 fn byte_to_line(&self, byte_offset: usize) -> u32 {
14786 let byte_offset = byte_offset.min(self.source_len);
14787 self.newline_offsets
14788 .partition_point(|offset| *offset < byte_offset) as u32
14789 + 1
14790 }
14791}
14792
14793fn empty_to_none(value: String) -> Option<String> {
14794 if value.is_empty() {
14795 None
14796 } else {
14797 Some(value)
14798 }
14799}
14800
14801fn bool_int(value: bool) -> i64 {
14802 if value {
14803 1
14804 } else {
14805 0
14806 }
14807}
14808
14809fn system_time_to_ns(time: SystemTime) -> i64 {
14810 time.duration_since(UNIX_EPOCH)
14811 .unwrap_or_default()
14812 .as_nanos()
14813 .min(i64::MAX as u128) as i64
14814}
14815
14816fn ns_to_system_time(value: i64) -> SystemTime {
14817 UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
14818}
14819
14820pub(crate) fn unix_millis_now() -> u64 {
14821 SystemTime::now()
14822 .duration_since(UNIX_EPOCH)
14823 .unwrap_or_default()
14824 .as_millis()
14825 .min(u128::from(u64::MAX)) as u64
14826}
14827
14828fn unix_seconds_now() -> i64 {
14829 SystemTime::now()
14830 .duration_since(UNIX_EPOCH)
14831 .unwrap_or_default()
14832 .as_secs() as i64
14833}
14834
14835#[cfg(test)]
14840pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
14841
14842#[cfg(test)]
14843mod refresh_worker_tests {
14844 use super::*;
14845 use std::fs;
14846 use tempfile::tempdir;
14847
14848 fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
14849 let temp = tempdir().unwrap();
14850 let root = temp.path().join("root");
14851 fs::create_dir_all(&root).unwrap();
14852 let artifact_key = crate::search_index::artifact_cache_key(&root);
14853 crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
14854 let callgraph_dir = temp
14855 .path()
14856 .join("storage")
14857 .join("callgraph")
14858 .join(artifact_key);
14859 let source = root.join("main.rs");
14860 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
14861 let (store, _) = CallGraphStore::cold_build_with_lease(
14862 callgraph_dir.clone(),
14863 root.clone(),
14864 std::slice::from_ref(&source),
14865 )
14866 .unwrap();
14867 drop(store);
14868 (temp, root, callgraph_dir, source)
14869 }
14870
14871 fn pending_paths() -> PendingCallGraphStorePaths {
14872 Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
14873 }
14874
14875 fn wait_for_refresh_calls(root: &Path, expected: usize) {
14876 let deadline = Instant::now() + Duration::from_secs(12);
14877 while callgraph_refresh_worker_test_counts(root).0 < expected {
14878 assert!(
14879 Instant::now() < deadline,
14880 "timed out waiting for {expected} callgraph refresh worker call(s)"
14881 );
14882 std::thread::sleep(Duration::from_millis(5));
14883 }
14884 }
14885
14886 fn wait_for_refresh_worker_idle() {
14887 let deadline = Instant::now() + Duration::from_secs(12);
14888 loop {
14889 let worker = CALLGRAPH_REFRESH_WORKER
14890 .get_or_init(|| Mutex::new(None))
14891 .lock()
14892 .expect("callgraph refresh worker mutex poisoned")
14893 .clone();
14894 let idle = worker.is_none_or(|worker| {
14895 let queue = worker
14896 .shared
14897 .queue
14898 .lock()
14899 .expect("callgraph refresh queue mutex poisoned");
14900 queue.active.is_none() && queue.order.is_empty()
14901 });
14902 if idle {
14903 return;
14904 }
14905 assert!(
14906 Instant::now() < deadline,
14907 "timed out waiting for callgraph refresh worker to become idle"
14908 );
14909 std::thread::sleep(Duration::from_millis(5));
14910 }
14911 }
14912
14913 fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
14914 let temp = tempdir().unwrap();
14915 let root = temp.path().join("workspace");
14916 fs::create_dir_all(root.join("app/src")).unwrap();
14917 let artifact_key = crate::search_index::artifact_cache_key(&root);
14918 crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
14919 let callgraph_dir = temp
14920 .path()
14921 .join("storage")
14922 .join("callgraph")
14923 .join(artifact_key);
14924 fs::write(
14925 root.join("Cargo.toml"),
14926 "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
14927 )
14928 .unwrap();
14929 fs::write(
14930 root.join("app/Cargo.toml"),
14931 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
14932 )
14933 .unwrap();
14934 let caller = root.join("app/src/lib.rs");
14935 fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
14936 let (store, _) = CallGraphStore::cold_build_with_lease(
14937 callgraph_dir.clone(),
14938 root.clone(),
14939 std::slice::from_ref(&caller),
14940 )
14941 .unwrap();
14942 drop(store);
14943 (temp, root, callgraph_dir, caller)
14944 }
14945
14946 #[test]
14947 fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
14948 let _guard = REFRESH_WORKER_TEST_LOCK
14949 .lock()
14950 .unwrap_or_else(std::sync::PoisonError::into_inner);
14951 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14952 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
14953 reset_workspace_crate_prefix_build_count(&root);
14954 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14955
14956 for revision in ["first", "second"] {
14957 fs::write(
14958 &caller,
14959 format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
14960 )
14961 .unwrap();
14962 enqueue_callgraph_store_refresh(
14963 callgraph_dir.clone(),
14964 root.clone(),
14965 vec![caller.clone()],
14966 pending_paths(),
14967 );
14968 wait_for_refresh_worker_idle();
14969 }
14970
14971 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
14972 assert!(flush_callgraph_store_refreshes_with_budget(
14973 Duration::from_secs(5)
14974 ));
14975 clear_callgraph_refresh_worker_test_seam(&root);
14976 }
14977
14978 #[test]
14979 fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
14980 let _guard = REFRESH_WORKER_TEST_LOCK
14981 .lock()
14982 .unwrap_or_else(std::sync::PoisonError::into_inner);
14983 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14984 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
14985 reset_workspace_crate_prefix_build_count(&root);
14986 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14987
14988 fs::write(
14989 &caller,
14990 "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
14991 )
14992 .unwrap();
14993 enqueue_callgraph_store_refresh(
14994 callgraph_dir.clone(),
14995 root.clone(),
14996 vec![caller.clone()],
14997 pending_paths(),
14998 );
14999 wait_for_refresh_worker_idle();
15000 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
15001
15002 let added_manifest = root.join("added/Cargo.toml");
15003 let added_source = root.join("added/src/lib.rs");
15004 fs::create_dir_all(added_source.parent().unwrap()).unwrap();
15005 fs::write(
15006 root.join("Cargo.toml"),
15007 "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
15008 )
15009 .unwrap();
15010 fs::write(
15011 &added_manifest,
15012 "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
15013 )
15014 .unwrap();
15015 fs::write(&added_source, "pub fn target() {}\n").unwrap();
15016 fs::write(
15017 &caller,
15018 "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
15019 )
15020 .unwrap();
15021
15022 enqueue_callgraph_store_refresh(
15023 callgraph_dir.clone(),
15024 root.clone(),
15025 vec![
15026 root.join("Cargo.toml"),
15027 added_manifest,
15028 added_source,
15029 caller,
15030 ],
15031 pending_paths(),
15032 );
15033 assert!(flush_callgraph_store_refreshes_with_budget(
15034 Duration::from_secs(12)
15035 ));
15036
15037 assert_eq!(workspace_crate_prefix_build_count(&root), 2);
15041 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
15042 .unwrap()
15043 .expect("refreshed workspace store");
15044 let tree = store
15045 .call_tree(Path::new("app/src/lib.rs"), "run", 1)
15046 .unwrap();
15047 assert_eq!(tree.children.len(), 1);
15048 assert_eq!(tree.children[0].file, "added/src/lib.rs");
15049 assert_eq!(tree.children[0].name, "target");
15050 assert!(tree.children[0].resolved);
15051 clear_callgraph_refresh_worker_test_seam(&root);
15052 }
15053
15054 fn linked_worktree_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, PathBuf) {
15055 let temp = tempdir().unwrap();
15056 let main = temp.path().join("main");
15057 let worktree = temp.path().join("worktree");
15058 fs::create_dir_all(&main).unwrap();
15059 let mut git = std::process::Command::new("git");
15060 assert!(
15061 crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
15062 .status()
15063 .unwrap()
15064 .success()
15065 );
15066 fs::write(main.join("lib.rs"), "pub fn marker() {}\n").unwrap();
15067 for args in [
15068 vec![
15069 "-C",
15070 main.to_str().unwrap(),
15071 "config",
15072 "user.email",
15073 "test@example.com",
15074 ],
15075 vec![
15076 "-C",
15077 main.to_str().unwrap(),
15078 "config",
15079 "user.name",
15080 "AFT Test",
15081 ],
15082 vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
15083 vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
15084 ] {
15085 let mut command = std::process::Command::new("git");
15086 assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
15087 .status()
15088 .unwrap()
15089 .success());
15090 }
15091 let mut add_worktree = std::process::Command::new("git");
15092 assert!(crate::test_env::apply_hermetic_git_env(
15093 add_worktree
15094 .arg("-C")
15095 .arg(&main)
15096 .args(["worktree", "add", "--detach"])
15097 .arg(&worktree),
15098 )
15099 .status()
15100 .unwrap()
15101 .success());
15102 let main = fs::canonicalize(main).unwrap();
15103 let worktree = fs::canonicalize(worktree).unwrap();
15104 let project_key = crate::search_index::artifact_cache_key(&main);
15105 assert_eq!(
15106 crate::search_index::artifact_cache_key(&worktree),
15107 project_key
15108 );
15109 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
15110 (temp, main, worktree, project_key, callgraph_dir)
15111 }
15112
15113 #[test]
15114 fn linked_worktree_never_acquires_writer_or_publishes_any_build_path() {
15115 let _git_env = crate::test_env::hermetic_git_env_guard();
15116 let (_temp, _main, root, project_key, callgraph_dir) = linked_worktree_fixture();
15117 crate::root_cache::configure_artifact_access(&root, &project_key, true);
15118 crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
15119 let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15120 let publications_for_observer = Arc::clone(&publications);
15121 set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
15122 publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
15123 })));
15124 let source = root.join("lib.rs");
15125
15126 let open_error = CallGraphStore::open(callgraph_dir.clone(), root.clone())
15127 .expect_err("borrow-only writable open must remain unavailable");
15128 assert!(matches!(open_error, CallGraphStoreError::Unavailable(_)));
15129 assert!(
15130 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), root.clone())
15131 .unwrap()
15132 .is_none()
15133 );
15134 assert!(
15135 CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), root.clone())
15136 .unwrap()
15137 .is_none()
15138 );
15139 assert!(matches!(
15140 CallGraphStore::cold_build_with_lease(
15141 callgraph_dir.clone(),
15142 root.clone(),
15143 std::slice::from_ref(&source),
15144 ),
15145 Err(CallGraphStoreError::Unavailable(_))
15146 ));
15147 assert!(matches!(
15148 CallGraphStore::ensure_built_with_lease(
15149 callgraph_dir.clone(),
15150 root.clone(),
15151 std::slice::from_ref(&source),
15152 ),
15153 Err(CallGraphStoreError::Unavailable(_))
15154 ));
15155 let force_error = CallGraphStore::force_cold_build_with_lease_chunked(
15156 callgraph_dir.clone(),
15157 root.clone(),
15158 &[source],
15159 1,
15160 )
15161 .expect_err("borrow-only forced rebuild must remain unsatisfied");
15162 set_cold_build_swap_observer(None);
15163
15164 assert!(matches!(force_error, CallGraphStoreError::Unavailable(_)));
15165 assert_eq!(
15166 crate::root_cache::writer_lease_acquisition_count_for_test(
15167 crate::root_cache::RootCacheDomain::Callgraph,
15168 &project_key,
15169 &root,
15170 ),
15171 0
15172 );
15173 assert_eq!(publications.load(AtomicOrdering::SeqCst), 0);
15174 assert!(!pointer_path(&callgraph_dir, &project_key).exists());
15175 }
15176
15177 #[test]
15178 fn owner_and_linked_worktree_alternation_rebuilds_storm_generation_once() {
15179 let _git_env = crate::test_env::hermetic_git_env_guard();
15180 let (_temp, owner, worktree, project_key, callgraph_dir) = linked_worktree_fixture();
15181 crate::root_cache::configure_artifact_access(&owner, &project_key, false);
15182 crate::root_cache::configure_artifact_access(&worktree, &project_key, true);
15183 let source = owner.join("lib.rs");
15184 let (store, _) = CallGraphStore::cold_build_with_lease(
15185 callgraph_dir.clone(),
15186 owner.clone(),
15187 std::slice::from_ref(&source),
15188 )
15189 .unwrap();
15190 let sqlite_path = store.sqlite_path().to_path_buf();
15191 drop(store);
15192
15193 let conn = Connection::open(&sqlite_path).unwrap();
15194 conn.execute(
15195 "UPDATE backend_file_state SET workspace_root = ?1",
15196 [worktree.display().to_string()],
15197 )
15198 .unwrap();
15199 drop(conn);
15200
15201 let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15202 let publications_for_observer = Arc::clone(&publications);
15203 set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
15204 publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
15205 })));
15206 crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
15207
15208 let repaired = CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
15209 .unwrap()
15210 .expect("owner should purge the storm-era worktree root");
15211 drop(repaired);
15212 for _ in 0..3 {
15213 let borrower = CallGraphStore::open_readonly(callgraph_dir.clone(), worktree.clone())
15214 .unwrap()
15215 .expect("linked worktree should borrow the owner generation");
15216 drop(borrower);
15217 assert!(
15218 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), worktree.clone())
15219 .unwrap()
15220 .is_none()
15221 );
15222 let owner_store =
15223 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
15224 .unwrap()
15225 .expect("owner generation should remain ready");
15226 drop(owner_store);
15227 }
15228 set_cold_build_swap_observer(None);
15229
15230 assert_eq!(
15231 publications.load(AtomicOrdering::SeqCst),
15232 1,
15233 "the owner performs one expected post-storm purge and alternation stays read-only"
15234 );
15235 assert_eq!(
15236 crate::root_cache::writer_lease_acquisition_count_for_test(
15237 crate::root_cache::RootCacheDomain::Callgraph,
15238 &project_key,
15239 &worktree,
15240 ),
15241 0
15242 );
15243 }
15244
15245 #[test]
15246 fn rebuild_cooldown_records_only_successful_publication_per_cache_key() {
15247 let temp = tempdir().unwrap();
15248 let root = temp.path().join("owner");
15249 let other_root = temp.path().join("other");
15250 fs::create_dir_all(&root).unwrap();
15251 fs::create_dir_all(&other_root).unwrap();
15252 let source = root.join("lib.rs");
15253 fs::write(&source, "pub fn marker() {}\n").unwrap();
15254 let project_key = crate::search_index::artifact_cache_key(&root);
15255 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
15256 crate::root_cache::configure_artifact_access(&root, &project_key, false);
15257 let cooldown_key = rebuild_cooldown_key(&callgraph_dir, &project_key);
15258 rebuild_cooldown_records()
15259 .lock()
15260 .unwrap_or_else(std::sync::PoisonError::into_inner)
15261 .remove(&cooldown_key);
15262 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
15263 let stale_epoch = epoch.current();
15264 epoch.next();
15265
15266 let failed = with_publish_epoch(epoch, stale_epoch, || {
15267 CallGraphStore::cold_build_with_lease(
15268 callgraph_dir.clone(),
15269 root.clone(),
15270 std::slice::from_ref(&source),
15271 )
15272 });
15273 assert!(matches!(failed, Err(CallGraphStoreError::Superseded)));
15274 assert!(
15275 rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
15276 .is_none()
15277 );
15278
15279 let (store, _) = CallGraphStore::cold_build_with_lease(
15280 callgraph_dir.clone(),
15281 root.clone(),
15282 std::slice::from_ref(&source),
15283 )
15284 .unwrap();
15285 drop(store);
15286 assert!(
15287 rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
15288 .is_none()
15289 );
15290
15291 record_successful_rebuild(&callgraph_dir, &project_key, &other_root, Instant::now());
15292 assert!(
15293 rebuild_cooldown_denial(&callgraph_dir, &project_key, &root, Instant::now(),).is_some()
15294 );
15295 }
15296
15297 #[test]
15298 fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
15299 let _guard = REFRESH_WORKER_TEST_LOCK
15300 .lock()
15301 .unwrap_or_else(std::sync::PoisonError::into_inner);
15302 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15303 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15304 let pending = pending_paths();
15305 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15306
15307 let lifecycle = SubcLifecycleAdmission::default();
15308 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
15309 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
15310 let ticket = CallgraphRefreshTicket::new(
15311 lifecycle,
15312 Arc::clone(&generation),
15313 7,
15314 publish_epoch.clone(),
15315 publish_epoch.current(),
15316 );
15317 generation.store(8, std::sync::atomic::Ordering::SeqCst);
15319 let installed = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
15320 .unwrap()
15321 .expect("ready store snapshot");
15322 let refresh_state = CallgraphRefreshState::new(
15323 Arc::new(std::sync::RwLock::new(Some(Arc::new(installed)))),
15324 Arc::new(AtomicBool::new(true)),
15325 );
15326
15327 enqueue_callgraph_store_refresh_fenced_with_state(
15328 callgraph_dir,
15329 root.clone(),
15330 vec![source.clone()],
15331 Arc::clone(&pending),
15332 refresh_state,
15333 ticket,
15334 );
15335 assert!(flush_callgraph_store_refreshes_with_budget(
15336 Duration::from_secs(5)
15337 ));
15338 assert_eq!(
15339 callgraph_refresh_worker_test_counts(&root).0,
15340 0,
15341 "superseded batch must not reach refresh_files or self-replay"
15342 );
15343 assert!(
15344 pending.lock().contains(&source),
15345 "superseded batch must defer its paths to the pending sink"
15346 );
15347 clear_callgraph_refresh_worker_test_seam(&root);
15348 }
15349
15350 #[test]
15351 fn superseded_open_failure_defers_without_self_replay() {
15352 let _guard = REFRESH_WORKER_TEST_LOCK
15353 .lock()
15354 .unwrap_or_else(std::sync::PoisonError::into_inner);
15355 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15356 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15357 let pending = pending_paths();
15358 let installed = Arc::new(
15359 CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
15360 .unwrap()
15361 .expect("ready store snapshot"),
15362 );
15363 let refresh_state = CallgraphRefreshState::new(
15364 Arc::new(std::sync::RwLock::new(Some(Arc::clone(&installed)))),
15365 Arc::new(AtomicBool::new(true)),
15366 );
15367 assert!(!installed.is_legacy_fallback());
15368 assert!(installed.is_current());
15369 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
15370 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15371 set_callgraph_refresh_worker_test_open_failure(root.clone(), true);
15372 let (held_rx, release_tx) = install_callgraph_refresh_worker_test_gate(root.clone());
15373
15374 let lifecycle = SubcLifecycleAdmission::default();
15375 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
15376 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
15377 let ticket = CallgraphRefreshTicket::new(
15378 lifecycle,
15379 Arc::clone(&generation),
15380 7,
15381 publish_epoch.clone(),
15382 publish_epoch.current(),
15383 );
15384 enqueue_callgraph_store_refresh_fenced_with_state(
15385 callgraph_dir,
15386 root.clone(),
15387 vec![source.clone()],
15388 Arc::clone(&pending),
15389 refresh_state,
15390 ticket,
15391 );
15392 held_rx
15393 .recv_timeout(Duration::from_secs(12))
15394 .expect("refresh worker must hold after injected open failure");
15395
15396 generation.store(8, std::sync::atomic::Ordering::SeqCst);
15399 set_callgraph_refresh_worker_test_open_failure(root.clone(), false);
15400 release_tx
15401 .send(())
15402 .expect("release superseded refresh worker");
15403 wait_for_refresh_worker_idle();
15404
15405 assert_eq!(
15406 callgraph_refresh_worker_test_counts(&root).0,
15407 1,
15408 "superseded open-failure batch must not self-replay"
15409 );
15410 assert_eq!(
15411 callgraph_refresh_worker_test_worker_calls(&root),
15412 1,
15413 "superseded open-failure batch must not create another worker call"
15414 );
15415 assert!(
15416 pending.lock().contains(&source),
15417 "superseded open-failure paths must remain in the pending sink"
15418 );
15419 let tree = installed
15420 .call_tree(Path::new("main.rs"), "entry", 1)
15421 .unwrap();
15422 assert_eq!(
15423 tree.children[0].name, "old_leaf",
15424 "superseded open-failure batch must not converge the store"
15425 );
15426 clear_callgraph_refresh_worker_test_seam(&root);
15427 }
15428
15429 #[test]
15430 fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
15431 let _guard = REFRESH_WORKER_TEST_LOCK
15432 .lock()
15433 .unwrap_or_else(std::sync::PoisonError::into_inner);
15434 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15435 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15436 let pending = pending_paths();
15437 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15438
15439 let lifecycle = SubcLifecycleAdmission::default();
15440 let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
15441 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
15442 let expected_epoch = publish_epoch.current();
15443 let ticket = CallgraphRefreshTicket::new(
15444 lifecycle,
15445 generation,
15446 3,
15447 publish_epoch.clone(),
15448 expected_epoch,
15449 );
15450 publish_epoch.next();
15452
15453 enqueue_callgraph_store_refresh_fenced(
15454 callgraph_dir,
15455 root.clone(),
15456 vec![source.clone()],
15457 Arc::clone(&pending),
15458 ticket,
15459 );
15460 assert!(flush_callgraph_store_refreshes_with_budget(
15461 Duration::from_secs(5)
15462 ));
15463 assert_eq!(
15464 callgraph_refresh_worker_test_counts(&root).0,
15465 0,
15466 "epoch-superseded batch must not reach refresh_files"
15467 );
15468 assert!(
15469 pending.lock().contains(&source),
15470 "epoch-superseded batch must defer its paths to the pending sink"
15471 );
15472 clear_callgraph_refresh_worker_test_seam(&root);
15473 }
15474
15475 #[test]
15476 fn fenced_refresh_with_current_ticket_commits_normally() {
15477 let _guard = REFRESH_WORKER_TEST_LOCK
15478 .lock()
15479 .unwrap_or_else(std::sync::PoisonError::into_inner);
15480 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15481 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15482 let pending = pending_paths();
15483 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15484
15485 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
15486
15487 let lifecycle = SubcLifecycleAdmission::default();
15488 let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
15489 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
15490 let ticket = CallgraphRefreshTicket::new(
15491 lifecycle,
15492 generation,
15493 5,
15494 publish_epoch.clone(),
15495 publish_epoch.current(),
15496 );
15497
15498 enqueue_callgraph_store_refresh_fenced(
15499 callgraph_dir.clone(),
15500 root.clone(),
15501 vec![source.clone()],
15502 Arc::clone(&pending),
15503 ticket,
15504 );
15505 assert!(flush_callgraph_store_refreshes_with_budget(
15506 Duration::from_secs(5)
15507 ));
15508 assert_eq!(
15509 callgraph_refresh_worker_test_counts(&root).0,
15510 1,
15511 "current ticket must run the refresh"
15512 );
15513 assert!(
15514 pending.lock().is_empty(),
15515 "committed batch must not defer paths"
15516 );
15517
15518 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
15519 .unwrap()
15520 .expect("published generation must remain readable");
15521 let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
15522 assert_eq!(
15523 tree.children[0].name, "new_leaf",
15524 "fenced commit must actually persist the refreshed content"
15525 );
15526 clear_callgraph_refresh_worker_test_seam(&root);
15527 }
15528
15529 #[test]
15530 fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
15531 let _guard = REFRESH_WORKER_TEST_LOCK
15532 .lock()
15533 .unwrap_or_else(std::sync::PoisonError::into_inner);
15534 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15539 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15540 let pending = pending_paths();
15541 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
15542
15543 enqueue_callgraph_store_refresh(
15544 callgraph_dir.clone(),
15545 root.clone(),
15546 vec![source.clone()],
15547 Arc::clone(&pending),
15548 );
15549 wait_for_refresh_calls(&root, 1);
15550 for _ in 0..3 {
15551 enqueue_callgraph_store_refresh(
15552 callgraph_dir.clone(),
15553 root.clone(),
15554 vec![source.clone()],
15555 Arc::clone(&pending),
15556 );
15557 }
15558
15559 assert!(flush_callgraph_store_refreshes_with_budget(
15560 Duration::from_secs(2)
15561 ));
15562 assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
15563 assert!(pending.lock().is_empty());
15564 clear_callgraph_refresh_worker_test_seam(&root);
15565 }
15566
15567 #[test]
15568 fn queued_refresh_opens_generation_published_after_enqueue() {
15569 let _guard = REFRESH_WORKER_TEST_LOCK
15570 .lock()
15571 .unwrap_or_else(std::sync::PoisonError::into_inner);
15572 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15577 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
15578 let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
15579 set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
15580 let (active_held_rx, active_release_tx) =
15581 install_callgraph_refresh_worker_test_gate(active_root.clone());
15582 set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
15583 enqueue_callgraph_store_refresh(
15584 active_dir,
15585 active_root.clone(),
15586 vec![active_source],
15587 pending_paths(),
15588 );
15589 active_held_rx
15590 .recv_timeout(Duration::from_secs(12))
15591 .expect("active refresh worker holds the queue");
15592
15593 fs::write(
15594 &target_source,
15595 "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
15596 )
15597 .unwrap();
15598 enqueue_callgraph_store_refresh(
15599 target_dir.clone(),
15600 target_root.clone(),
15601 vec![target_source.clone()],
15602 pending_paths(),
15603 );
15604 let (new_generation, _) = CallGraphStore::cold_build_with_lease(
15605 target_dir.clone(),
15606 target_root.clone(),
15607 std::slice::from_ref(&target_source),
15608 )
15609 .unwrap();
15610 fs::write(
15611 &target_source,
15612 "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
15613 )
15614 .unwrap();
15615 drop(new_generation);
15616
15617 active_release_tx
15618 .send(())
15619 .expect("release active refresh worker");
15620 wait_for_refresh_calls(&target_root, 1);
15621 assert!(flush_callgraph_store_refreshes_with_budget(
15622 Duration::from_secs(12)
15623 ));
15624 let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
15625 .unwrap()
15626 .expect("current callgraph generation");
15627 let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
15628 assert_eq!(tree.children[0].name, "worker_leaf");
15629 assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
15630 clear_callgraph_refresh_worker_test_seam(&active_root);
15631 clear_callgraph_refresh_worker_test_seam(&target_root);
15632 }
15633
15634 #[test]
15635 fn refresh_failure_marks_files_stale() {
15636 let _guard = REFRESH_WORKER_TEST_LOCK
15637 .lock()
15638 .unwrap_or_else(std::sync::PoisonError::into_inner);
15639 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15644 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15645 let pending = pending_paths();
15646 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
15647
15648 enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
15649 assert!(flush_callgraph_store_refreshes_with_budget(
15650 Duration::from_secs(2)
15651 ));
15652
15653 assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
15654 let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
15655 .unwrap()
15656 .expect("ready callgraph store");
15657 assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
15658 clear_callgraph_refresh_worker_test_seam(&root);
15659 }
15660
15661 #[test]
15662 fn idle_refresh_truncates_wal() {
15663 let _guard = REFRESH_WORKER_TEST_LOCK
15664 .lock()
15665 .unwrap_or_else(std::sync::PoisonError::into_inner);
15666 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15667 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15668 let generation = read_pointer(
15669 &callgraph_dir,
15670 &crate::search_index::artifact_cache_key(&root),
15671 )
15672 .expect("fixture publishes a generation");
15673 let wal_path = callgraph_dir.join(format!("{generation}-wal"));
15674 let pending = pending_paths();
15675 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15676
15677 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n\n").unwrap();
15678 enqueue_callgraph_store_refresh(
15679 callgraph_dir.clone(),
15680 root.clone(),
15681 vec![source.clone()],
15682 Arc::clone(&pending),
15683 );
15684 wait_for_refresh_calls(&root, 1);
15685 wait_for_refresh_worker_idle();
15686 let checkpoint_deadline = Instant::now() + Duration::from_secs(2);
15687 while fs::metadata(&wal_path)
15688 .map(|metadata| metadata.len())
15689 .unwrap_or(0)
15690 != 0
15691 {
15692 assert!(
15693 Instant::now() < checkpoint_deadline,
15694 "idle checkpoint did not truncate WAL"
15695 );
15696 std::thread::sleep(Duration::from_millis(5));
15697 }
15698 assert_eq!(
15699 fs::metadata(&wal_path)
15700 .map(|metadata| metadata.len())
15701 .unwrap_or(0),
15702 0,
15703 "idle transition truncates the refresh WAL"
15704 );
15705
15706 clear_callgraph_refresh_worker_test_seam(&root);
15707 }
15708
15709 #[test]
15710 fn bounded_shutdown_defers_unprocessed_batches() {
15711 let _guard = REFRESH_WORKER_TEST_LOCK
15712 .lock()
15713 .unwrap_or_else(std::sync::PoisonError::into_inner);
15714 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15719 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
15720 let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
15721 let active_pending = pending_paths();
15722 let queued_pending = pending_paths();
15723 set_callgraph_refresh_worker_test_seam(
15724 active_root.clone(),
15725 Duration::from_millis(300),
15726 false,
15727 );
15728
15729 enqueue_callgraph_store_refresh(
15730 active_dir,
15731 active_root.clone(),
15732 vec![active_source.clone()],
15733 Arc::clone(&active_pending),
15734 );
15735 wait_for_refresh_calls(&active_root, 1);
15736 enqueue_callgraph_store_refresh(
15737 queued_dir,
15738 queued_root.clone(),
15739 vec![queued_source.clone()],
15740 Arc::clone(&queued_pending),
15741 );
15742
15743 assert!(!flush_callgraph_store_refreshes_with_budget(
15744 Duration::from_millis(20)
15745 ));
15746 assert!(active_pending.lock().contains(&active_source));
15747 assert!(queued_pending.lock().contains(&queued_source));
15748 assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
15749 clear_callgraph_refresh_worker_test_seam(&active_root);
15750 }
15751}
15752
15753#[cfg(test)]
15754mod cold_build_insert_tests {
15755 use super::*;
15756 use crate::imports::ImportBlock;
15757 use std::cell::Cell;
15758 use std::fs;
15759 use std::path::{Path, PathBuf};
15760 use tempfile::tempdir;
15761
15762 thread_local! {
15763 static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
15764 static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
15765 static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
15766 }
15767
15768 fn count_caller_traversal_selects(sql: &str) {
15769 let sql = sql.trim_start();
15770 if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
15771 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
15772 }
15773 if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
15774 && sql.contains("e.target_file =")
15775 {
15776 CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
15777 }
15778 if sql.starts_with("WITH requested") && sql.contains("COUNT(*)") {
15779 BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
15780 }
15781 }
15782
15783 #[test]
15784 fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
15785 let dir = tempdir().unwrap();
15786 let previous_root = dir.path().join("previous-root");
15787 let current_root = dir.path().join("current-root");
15788 fs::create_dir_all(&previous_root).unwrap();
15789 fs::create_dir_all(¤t_root).unwrap();
15790 fs::remove_dir(&previous_root).unwrap();
15791 let mut conn = Connection::open_in_memory().unwrap();
15792 initialize_schema(&conn).unwrap();
15793 conn.execute(
15794 "INSERT INTO backend_file_state(
15795 backend, workspace_root, file_path, content_hash, status, updated_at
15796 ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
15797 params![previous_root.display().to_string()],
15798 )
15799 .unwrap();
15800
15801 let repair = reconcile_workspace_roots(&mut conn, ¤t_root, false).unwrap();
15802
15803 assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
15804 assert_eq!(
15805 stored_workspace_roots(&conn).unwrap(),
15806 vec![previous_root.display().to_string()]
15807 );
15808 }
15809
15810 #[test]
15811 fn sqlite_readonly_uri_percent_encodes_windows_paths() {
15812 assert_eq!(
15813 sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
15814 "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
15815 );
15816 }
15817
15818 #[test]
15819 fn legacy_migration_completion_log_has_operator_fields() {
15820 assert_eq!(
15821 legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
15822 "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
15823 );
15824 }
15825
15826 fn write_generation_with_age(
15827 dir: &Path,
15828 project_key: &str,
15829 ordinal: u64,
15830 age: Duration,
15831 ) -> String {
15832 let generation = format!("{project_key}.g{ordinal}.1.sqlite");
15833 let path = dir.join(&generation);
15834 fs::write(&path, b"sqlite placeholder").unwrap();
15835 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
15836 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
15837 generation
15838 }
15839
15840 #[test]
15841 fn gc_old_generations_preserves_live_reader_until_marker_drops() {
15842 let dir = tempfile::tempdir().unwrap();
15843 let project_key = "project";
15844 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
15845 let previous =
15846 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
15847 let pinned =
15848 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
15849 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
15850
15851 gc_old_generations(dir.path(), project_key, ¤t);
15852
15853 assert!(dir.path().join(&previous).is_file());
15854 assert!(dir.path().join(&pinned).is_file());
15855
15856 drop(marker);
15857 gc_old_generations(dir.path(), project_key, ¤t);
15858
15859 assert!(dir.path().join(&previous).is_file());
15860 assert!(!dir.path().join(&pinned).exists());
15861 }
15862
15863 #[test]
15864 fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
15865 let dir = tempfile::tempdir().unwrap();
15866 let project_key = "project";
15867 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
15868 let _previous =
15869 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
15870 let pinned =
15871 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
15872 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
15873 filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
15874
15875 gc_old_generations(dir.path(), project_key, ¤t);
15876
15877 assert!(dir.path().join(&pinned).is_file());
15878 }
15879
15880 #[test]
15881 fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
15882 let dir = tempfile::tempdir().unwrap();
15883 let project_key = "project";
15884 let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
15885 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
15886 let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
15887 let old = write_generation_with_age(
15888 dir.path(),
15889 project_key,
15890 200,
15891 expired + Duration::from_secs(60),
15892 );
15893 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
15894
15895 gc_old_generations(dir.path(), project_key, ¤t);
15896
15897 assert!(dir.path().join(¤t).is_file());
15898 assert!(dir.path().join(&previous).is_file());
15899 assert!(!dir.path().join(&old).exists());
15900 }
15901
15902 fn write_aged_callgraph_root(callgraph_root: &Path, key: &str) -> PathBuf {
15903 let cache_dir = callgraph_root.join(key);
15904 fs::create_dir_all(cache_dir.join("nested")).unwrap();
15905 fs::write(
15906 cache_dir.join("nested").join("payload.sqlite"),
15907 b"old cache payload",
15908 )
15909 .unwrap();
15910 age_callgraph_root_tree(&cache_dir);
15911 cache_dir
15912 }
15913
15914 fn age_callgraph_root_tree(path: &Path) {
15915 let old = SystemTime::now()
15916 .checked_sub(CALLGRAPH_ROOT_ORPHAN_MIN_AGE + Duration::from_secs(60))
15917 .unwrap_or(UNIX_EPOCH);
15918 let entries = fs::read_dir(path)
15919 .unwrap()
15920 .collect::<std::io::Result<Vec<_>>>()
15921 .unwrap();
15922 for entry in entries {
15923 let child = entry.path();
15924 if entry.file_type().unwrap().is_dir() {
15925 age_callgraph_root_tree(&child);
15926 } else {
15927 filetime::set_file_mtime(&child, filetime::FileTime::from_system_time(old))
15928 .unwrap();
15929 }
15930 }
15931 filetime::set_file_mtime(path, filetime::FileTime::from_system_time(old)).unwrap();
15932 }
15933
15934 #[test]
15935 fn callgraph_root_sweep_reaps_only_aged_unprotected_dead_roots() {
15936 reset_callgraph_root_sweep_cursor_for_test();
15937 let storage = tempdir().unwrap();
15938 let callgraph_root = storage.path().join("callgraph");
15939 let dead = write_aged_callgraph_root(&callgraph_root, "f1e2d3c4b5a69788");
15940 let leased = write_aged_callgraph_root(&callgraph_root, "e1d2c3b4a5968778");
15941 let fresh = callgraph_root.join("d1c2b3a495867768");
15942 fs::create_dir_all(&fresh).unwrap();
15943 fs::write(fresh.join("payload.sqlite"), b"fresh cache payload").unwrap();
15944 let marked = write_aged_callgraph_root(&callgraph_root, "c1b2a39485766758");
15945
15946 let writer_lease = crate::fs_lock::try_acquire(
15947 &crate::root_cache::writer_lease_path(&leased),
15948 Duration::ZERO,
15949 )
15950 .unwrap();
15951 age_callgraph_root_tree(&leased);
15952 let marker = crate::root_cache::ReadMarker::create(&marked, "generation").unwrap();
15953 age_callgraph_root_tree(&marked);
15956
15957 let first = sweep_callgraph_root_dirs_with_limits(
15958 &callgraph_root,
15959 &HashSet::new(),
15960 &HashSet::new(),
15961 CALLGRAPH_ROOT_SWEEP_BUDGET,
15962 usize::MAX,
15963 );
15964
15965 assert_eq!(first.removed, 1);
15966 assert!(first.bytes > 0, "the reaped byte count must be reported");
15967 assert!(!dead.exists(), "an aged dead root must be reaped");
15968 assert_eq!(first.skipped_lease, 1, "a held writer lease must win");
15969 assert_eq!(first.skipped_reader, 1, "a live reader marker must win");
15970 assert_eq!(first.skipped_fresh, 1, "a recent root must win");
15971 assert!(leased.is_dir(), "the leased root must survive");
15972 assert!(marked.is_dir(), "the reader-marked root must survive");
15973 assert!(fresh.is_dir(), "the recent root must survive");
15974
15975 drop(writer_lease);
15976 drop(marker);
15977 for cache_dir in [&leased, &marked, &fresh] {
15980 age_callgraph_root_tree(cache_dir);
15981 }
15982 let second = sweep_callgraph_root_dirs_with_limits(
15983 &callgraph_root,
15984 &HashSet::new(),
15985 &HashSet::new(),
15986 CALLGRAPH_ROOT_SWEEP_BUDGET,
15987 usize::MAX,
15988 );
15989
15990 assert_eq!(second.removed, 3);
15991 for cache_dir in [&leased, &marked, &fresh] {
15992 assert!(
15993 !cache_dir.exists(),
15994 "the decoy must be reaped after its guard or freshness changes"
15995 );
15996 }
15997 reset_callgraph_root_sweep_cursor_for_test();
15998 }
15999
16000 #[test]
16001 fn callgraph_root_sweep_resumes_after_entry_budget() {
16002 reset_callgraph_root_sweep_cursor_for_test();
16003 let storage = tempdir().unwrap();
16004 let callgraph_root = storage.path().join("callgraph");
16005 let first = write_aged_callgraph_root(&callgraph_root, "1111111111111111");
16006 let second = write_aged_callgraph_root(&callgraph_root, "2222222222222222");
16007 let third = write_aged_callgraph_root(&callgraph_root, "3333333333333333");
16008
16009 let first_pass = sweep_callgraph_root_dirs_with_limits(
16010 &callgraph_root,
16011 &HashSet::new(),
16012 &HashSet::new(),
16013 CALLGRAPH_ROOT_SWEEP_BUDGET,
16014 1,
16015 );
16016 assert!(first_pass.budget_exhausted);
16017 assert_eq!(first_pass.scanned, 1);
16018 assert!(!first.exists());
16019 assert!(second.exists());
16020 assert!(third.exists());
16021
16022 let second_pass = sweep_callgraph_root_dirs_with_limits(
16023 &callgraph_root,
16024 &HashSet::new(),
16025 &HashSet::new(),
16026 CALLGRAPH_ROOT_SWEEP_BUDGET,
16027 1,
16028 );
16029 assert!(second_pass.budget_exhausted);
16030 assert!(!second.exists());
16031 assert!(third.exists());
16032
16033 let third_pass = sweep_callgraph_root_dirs_with_limits(
16034 &callgraph_root,
16035 &HashSet::new(),
16036 &HashSet::new(),
16037 CALLGRAPH_ROOT_SWEEP_BUDGET,
16038 1,
16039 );
16040 assert!(!third_pass.budget_exhausted);
16041 assert!(!third.exists());
16042 reset_callgraph_root_sweep_cursor_for_test();
16043 }
16044
16045 #[test]
16046 fn callgraph_root_sweep_runs_generation_gc_for_memoized_root() {
16047 reset_callgraph_root_sweep_cursor_for_test();
16048 let storage = tempdir().unwrap();
16049 let callgraph_root = storage.path().join("callgraph");
16050 let key = "a1b2c3d4e5f60718";
16051 let cache_dir = callgraph_root.join(key);
16052 fs::create_dir_all(&cache_dir).unwrap();
16053 let current = write_generation_with_age(&cache_dir, key, 400, Duration::ZERO);
16054 let previous = write_generation_with_age(&cache_dir, key, 300, Duration::from_secs(1));
16055 let obsolete = write_generation_with_age(&cache_dir, key, 200, Duration::from_secs(2));
16056 publish_pointer(&cache_dir, key, ¤t).unwrap();
16057 age_callgraph_root_tree(&cache_dir);
16058 let memo_keys = HashSet::from([key.to_string()]);
16059
16060 let summary = sweep_callgraph_root_dirs_with_limits(
16061 &callgraph_root,
16062 &memo_keys,
16063 &HashSet::new(),
16064 CALLGRAPH_ROOT_SWEEP_BUDGET,
16065 usize::MAX,
16066 );
16067
16068 assert_eq!(summary.generation_gc, 1);
16069 assert!(cache_dir.join(¤t).is_file());
16070 assert!(cache_dir.join(&previous).is_file());
16071 assert!(
16072 !cache_dir.join(&obsolete).exists(),
16073 "the store-wide sweep must collect an inactive live root's obsolete generation"
16074 );
16075 reset_callgraph_root_sweep_cursor_for_test();
16076 }
16077
16078 fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
16079 let path = dir.join(name);
16080 fs::write(&path, b"temp placeholder").unwrap();
16081 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
16082 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
16083 path
16084 }
16085
16086 #[test]
16087 fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
16088 let dir = tempdir().unwrap();
16089 let aged = "project.g100.1.sqlite.tmp.1.200";
16093 let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
16094 let fresh = "project.g300.1.sqlite.tmp.1.400";
16095 let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
16096 write_build_temp_with_age(dir.path(), aged, aged_age);
16097 write_build_temp_with_age(dir.path(), aged_journal, aged_age);
16098 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
16099
16100 sweep_orphaned_build_temps(dir.path());
16101
16102 assert!(
16103 !dir.path().join(aged).exists(),
16104 "aged orphan must be removed"
16105 );
16106 assert!(
16107 !dir.path().join(aged_journal).exists(),
16108 "aged journal sidecar must be removed"
16109 );
16110 assert!(
16111 dir.path().join(fresh).is_file(),
16112 "fresh temporary must survive"
16113 );
16114 }
16115
16116 #[test]
16117 fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
16118 let storage = tempdir().unwrap();
16119 let storage_root = storage.path();
16120 let legacy_dir = storage_root.join("opencode").join("callgraph");
16126 fs::create_dir_all(&legacy_dir).unwrap();
16127 let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
16128 write_build_temp_with_age(
16129 &legacy_dir,
16130 orphan,
16131 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
16132 );
16133 assert!(
16134 !legacy_dir.join("deadbeef.current").exists(),
16135 "the dead root has no current pointer"
16136 );
16137
16138 let root_keyed_dir = storage_root.join("callgraph").join("livekey");
16139 fs::create_dir_all(&root_keyed_dir).unwrap();
16140
16141 sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
16142
16143 assert!(
16144 !legacy_dir.join(orphan).exists(),
16145 "legacy orphan must be reclaimed by the store-wide sweep"
16146 );
16147 }
16148
16149 #[test]
16150 fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
16151 let dir = tempdir().unwrap();
16157 let fresh = "project.g300.1.sqlite.tmp.1.400";
16158 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
16159
16160 sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
16161
16162 assert!(
16163 !dir.path().join(fresh).exists(),
16164 "with the age predicate forced open, the fresh temporary is removed"
16165 );
16166 }
16167
16168 #[test]
16169 fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
16170 let dir = tempdir().unwrap();
16171 let generation = write_generation_with_age(
16175 dir.path(),
16176 "project",
16177 400,
16178 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
16179 );
16180 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
16181
16182 sweep_orphaned_build_temps(dir.path());
16183
16184 assert!(
16185 dir.path().join(&generation).is_file(),
16186 "completed generation must survive the orphan sweep"
16187 );
16188 assert!(
16189 crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
16190 "read marker must survive the orphan sweep"
16191 );
16192 }
16193
16194 #[test]
16195 fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
16196 let dir = tempfile::tempdir().unwrap();
16197 let project_key = "project".to_string();
16198 let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
16199 let sqlite_path = dir.path().join(&generation);
16200 fs::remove_file(&sqlite_path).unwrap();
16201 let conn = TrackedConnection::open(&sqlite_path, SqliteStore::CallgraphGeneration).unwrap();
16202 let store = CallGraphStore::from_connection(
16203 dir.path().to_path_buf(),
16204 project_key,
16205 sqlite_path,
16206 dir.path().to_path_buf(),
16207 false,
16208 Some(generation.clone()),
16209 None,
16210 None,
16211 conn,
16212 );
16213
16214 let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
16215 assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
16216
16217 drop(marker);
16218 assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
16219 }
16220
16221 #[test]
16222 fn readiness_cache_only_skips_checks_after_a_successful_validation() {
16223 let dir = tempdir().expect("temp dir");
16224 let file = dir.path().join("main.ts");
16225 fs::write(&file, "export function main() {}\n").expect("write fixture");
16226 let store = CallGraphStore::open(
16227 dir.path().join(".store-readiness-cache"),
16228 dir.path().to_path_buf(),
16229 )
16230 .expect("open store");
16231 {
16232 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16233 conn.trace(Some(count_caller_traversal_selects));
16234 }
16235
16236 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
16237 assert!(store.indexed_file_count().is_err());
16238 assert!(store.indexed_file_count().is_err());
16239 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
16240
16241 store
16242 .cold_build(std::slice::from_ref(&file))
16243 .expect("cold build");
16244 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
16245 assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
16246 assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
16247 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
16248
16249 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16250 conn.trace(None);
16251 }
16252
16253 #[test]
16254 fn direct_caller_frontier_chunks_sqlite_selects() {
16255 let dir = tempdir().expect("temp dir");
16256 let file = dir.path().join("main.ts");
16257 fs::write(
16258 &file,
16259 "export function caller() { target(); }\nexport function target() {}\n",
16260 )
16261 .expect("write fixture");
16262 let store = CallGraphStore::open(
16263 dir.path().join(".store-caller-frontier-query"),
16264 dir.path().to_path_buf(),
16265 )
16266 .expect("open store");
16267 store
16268 .cold_build(std::slice::from_ref(&file))
16269 .expect("cold build");
16270 let mut targets = vec![("main.ts".to_string(), "target".to_string())];
16271 targets.extend((1..1_000).map(|index| ("main.ts".to_string(), format!("missing{index}"))));
16272
16273 CALLER_QUERY_SELECTS.with(|count| count.set(0));
16274 BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
16275 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
16276 {
16277 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16278 conn.trace(Some(count_caller_traversal_selects));
16279 }
16280 let callers = store
16281 .direct_callers_for_symbols(&targets)
16282 .expect("batched callers");
16283 {
16284 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16285 conn.trace(None);
16286 }
16287
16288 assert_eq!(callers.len(), 1_000);
16289 assert_eq!(callers.get(&targets[0]).unwrap().len(), 1);
16290 assert_eq!(CALLER_QUERY_SELECTS.with(Cell::get), 3);
16291 assert_eq!(BOUNDARY_COUNT_SELECTS.with(Cell::get), 0);
16292 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
16293 }
16294
16295 #[test]
16296 fn callers_depth_boundary_batches_sqlite_counts() {
16297 const CALLER_COUNT: usize = 1_000;
16298
16299 let dir = tempdir().expect("temp dir");
16300 let file = dir.path().join("main.ts");
16301 let mut source = String::from("export function sharedHotHelper() {}\n");
16302 for index in 0..CALLER_COUNT {
16303 source.push_str(&format!(
16304 "export function caller{index}() {{ sharedHotHelper(); }}\n"
16305 ));
16306 }
16307 fs::write(&file, source).expect("write fixture");
16308
16309 let store = CallGraphStore::open(
16310 dir.path().join(".store-callers-query-fanout"),
16311 dir.path().to_path_buf(),
16312 )
16313 .expect("open store");
16314 store
16315 .cold_build(std::slice::from_ref(&file))
16316 .expect("cold build");
16317
16318 CALLER_QUERY_SELECTS.with(|count| count.set(0));
16319 BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
16320 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
16321 {
16322 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16323 conn.trace(Some(count_caller_traversal_selects));
16324 }
16325
16326 let started = Instant::now();
16327 let result = crate::commands::callgraph_store_adapter::callers_result(
16328 &store,
16329 Path::new("main.ts"),
16330 "sharedHotHelper",
16331 1,
16332 true,
16333 )
16334 .expect("callers result");
16335 let elapsed = started.elapsed();
16336
16337 {
16338 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16339 conn.trace(None);
16340 }
16341 let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
16342 let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
16343 let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
16344 eprintln!(
16345 "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
16346 result.total_callers,
16347 caller_queries,
16348 boundary_queries,
16349 total_selects,
16350 elapsed.as_secs_f64() * 1_000.0
16351 );
16352
16353 assert_eq!(result.total_callers, CALLER_COUNT);
16354 assert_eq!(caller_queries, 1);
16355 assert_eq!(boundary_queries, 3);
16356 assert_eq!(total_selects, 9);
16357 }
16358
16359 #[test]
16360 fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
16361 let dir = tempdir().expect("temp dir");
16362 let file = dir.path().join("main.ts");
16363 fs::write(
16364 &file,
16365 r#"export function topA() {
16366 root();
16367}
16368
16369export function topB() {
16370 root();
16371}
16372
16373export function root() {
16374 leaf();
16375 missing();
16376}
16377
16378export function leaf() {}
16379"#,
16380 )
16381 .expect("write fixture");
16382
16383 let store = CallGraphStore::open(
16384 dir.path().join(".store-depth-boundary-counts"),
16385 dir.path().to_path_buf(),
16386 )
16387 .expect("open store");
16388 store
16389 .cold_build(std::slice::from_ref(&file))
16390 .expect("cold build");
16391
16392 let root = store
16393 .node_for(Path::new("main.ts"), "root")
16394 .expect("root node");
16395 let leaf = store
16396 .node_for(Path::new("main.ts"), "leaf")
16397 .expect("leaf node");
16398
16399 let (full_forward_len, full_direct_len) = {
16400 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
16401 conn.execute(
16402 "INSERT INTO edges (
16403 edge_id, ref_id, source_node, target_node, target_file,
16404 target_symbol, kind, line, provenance
16405 ) VALUES (
16406 'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
16407 ?2, ?3, 'call', 98, ?4
16408 )",
16409 rusqlite::params![
16410 &root.node_id,
16411 &leaf.file,
16412 &leaf.symbol,
16413 PROVENANCE_TREESITTER
16414 ],
16415 )
16416 .expect("insert dangling forward edge");
16417 conn.execute(
16418 "INSERT INTO edges (
16419 edge_id, ref_id, source_node, target_node, target_file,
16420 target_symbol, kind, line, provenance
16421 ) VALUES (
16422 'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
16423 ?1, ?2, ?3, 'call', 99, ?4
16424 )",
16425 rusqlite::params![
16426 &root.node_id,
16427 &root.file,
16428 &root.symbol,
16429 PROVENANCE_TREESITTER
16430 ],
16431 )
16432 .expect("insert dangling direct-caller edge");
16433
16434 let full_forward_len = forward_calls_for_node(&conn, &root)
16435 .expect("full forward calls")
16436 .len();
16437 let counted_forward_len =
16438 forward_call_count_for_node(&conn, &root).expect("counted forward calls");
16439 assert_eq!(
16440 counted_forward_len, full_forward_len,
16441 "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
16442 );
16443
16444 let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
16445 .expect("full direct callers");
16446 let full_direct_len = full_direct.len();
16447 let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
16448 .expect("counted direct callers");
16449 assert_eq!(
16450 counted_direct_len, full_direct_len,
16451 "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
16452 );
16453
16454 let distinct_direct_len = full_direct
16455 .iter()
16456 .map(|site| {
16457 (
16458 site.caller.file.clone(),
16459 site.line,
16460 site.target_file.clone(),
16461 site.target_symbol.clone(),
16462 )
16463 })
16464 .collect::<BTreeSet<_>>()
16465 .len();
16466 let batch_counts = direct_caller_counts_for_tuples(
16467 &conn,
16468 &[
16469 (root.file.clone(), root.symbol.clone()),
16470 (root.file.clone(), root.symbol.clone()),
16471 (leaf.file.clone(), leaf.symbol.clone()),
16472 ],
16473 )
16474 .expect("batched direct-caller counts");
16475 assert_eq!(batch_counts.len(), 2);
16476 assert_eq!(
16477 batch_counts.get(&(root.file.clone(), root.symbol.clone())),
16478 Some(&distinct_direct_len)
16479 );
16480
16481 (full_forward_len, full_direct_len)
16482 };
16483
16484 assert_eq!(
16485 full_forward_len, 2,
16486 "fixture root should have one resolved and one unresolved outgoing call"
16487 );
16488 assert_eq!(
16489 full_direct_len, 2,
16490 "fixture root should have two real direct callers"
16491 );
16492
16493 let tree = store
16494 .call_tree(Path::new("main.ts"), "root", 0)
16495 .expect("call tree");
16496 assert!(tree.depth_limited);
16497 assert_eq!(tree.children.len(), 0);
16498 assert_eq!(
16499 tree.truncated, full_forward_len,
16500 "call_tree depth boundary must report the full forward-call list length"
16501 );
16502
16503 let callers = store
16504 .callers_of(Path::new("main.ts"), "leaf", 0)
16505 .expect("callers");
16506 assert!(callers.depth_limited);
16507 assert_eq!(callers.callers.len(), 1);
16508 assert_eq!(callers.callers[0].caller.symbol, "root");
16509 assert_eq!(
16510 callers.truncated, full_direct_len,
16511 "callers depth boundary must report the full direct-caller list length"
16512 );
16513 }
16514
16515 #[test]
16516 fn source_freshness_matches_cache_collect_for_same_bytes() {
16517 let dir = tempdir().expect("temp dir");
16518 let path = dir.path().join("fixture.ts");
16519 let source = "export function main() { return helper(); }\n";
16520 fs::write(&path, source).expect("write fixture");
16521
16522 let expected = cache_freshness::collect(&path).expect("collect freshness from file");
16523 let actual =
16524 collect_source_freshness(&path, source).expect("collect freshness from source");
16525
16526 assert_eq!(actual, expected);
16527 }
16528
16529 #[test]
16530 fn superseded_cold_build_cannot_publish_after_newer_epoch() {
16531 let root = tempfile::tempdir().unwrap();
16532 let callgraph_dir = tempfile::tempdir().unwrap();
16533 let source_dir = root.path().join("src");
16534 std::fs::create_dir_all(&source_dir).unwrap();
16535 let source = source_dir.join("lib.rs");
16536 std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
16537 let files = vec![source.clone()];
16538 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
16539 let old_epoch = epoch.next();
16540 let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
16541 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
16542 let old_epoch_flag = epoch.clone();
16543 let old_dir = callgraph_dir.path().to_path_buf();
16544 let old_root = root.path().to_path_buf();
16545 let old_files = files.clone();
16546 let old = std::thread::spawn(move || {
16547 set_cold_build_before_publish_observer(Some(Arc::new(move || {
16548 reached_tx.send(()).unwrap();
16549 release_rx.recv().unwrap();
16550 })));
16551 let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
16552 CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
16553 });
16554 set_cold_build_before_publish_observer(None);
16555 result
16556 });
16557 reached_rx
16561 .recv_timeout(Duration::from_secs(30))
16562 .expect("older build did not reach its publication barrier");
16563
16564 std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
16565 let new_epoch = epoch.next();
16566 let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
16567 CallGraphStore::cold_build_with_lease(
16568 callgraph_dir.path().to_path_buf(),
16569 root.path().to_path_buf(),
16570 &files,
16571 )
16572 })
16573 .expect("newer build should publish");
16574 drop(new_store);
16575
16576 release_tx.send(()).unwrap();
16577 assert!(matches!(
16578 old.join().unwrap(),
16579 Err(CallGraphStoreError::Superseded)
16580 ));
16581
16582 let current = CallGraphStore::open_readonly(
16583 callgraph_dir.path().to_path_buf(),
16584 root.path().to_path_buf(),
16585 )
16586 .unwrap()
16587 .expect("current callgraph generation");
16588 assert_eq!(
16589 current
16590 .nodes_matching("new_generation_marker")
16591 .unwrap()
16592 .len(),
16593 1
16594 );
16595 assert!(current
16596 .nodes_matching("old_generation_marker")
16597 .unwrap()
16598 .is_empty());
16599 }
16600
16601 #[test]
16602 fn publish_fence_supersession_keeps_completed_staging_for_zero_work_adoption() {
16603 let root = tempfile::tempdir().unwrap();
16604 let callgraph_dir = tempfile::tempdir().unwrap();
16605 let source = root.path().join("lib.rs");
16606 std::fs::write(&source, "pub fn completed_marker() {}\n").unwrap();
16607 let files = vec![source];
16608 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
16609 let old_epoch = epoch.next();
16610 let epoch_for_observer = epoch.clone();
16611 set_cold_build_before_publish_observer(Some(Arc::new(move || {
16612 epoch_for_observer.next();
16613 })));
16614 let result = with_publish_epoch(epoch.clone(), old_epoch, || {
16615 CallGraphStore::cold_build_with_lease_chunked(
16616 callgraph_dir.path().to_path_buf(),
16617 root.path().to_path_buf(),
16618 &files,
16619 1,
16620 )
16621 });
16622 set_cold_build_before_publish_observer(None);
16623 assert!(matches!(result, Err(CallGraphStoreError::Superseded)));
16624
16625 let project_key = crate::search_index::artifact_cache_key(root.path());
16626 let staging = callgraph_dir
16627 .path()
16628 .join(format!("{project_key}.staging.sqlite.tmp.resume"));
16629 let staged = Connection::open(&staging).unwrap();
16630 assert_eq!(
16631 staged_build_phase(&staged).unwrap().as_deref(),
16632 Some("ready")
16633 );
16634 drop(staged);
16635
16636 let extracted = Arc::new(std::sync::atomic::AtomicUsize::new(0));
16637 let extracted_for_observer = Arc::clone(&extracted);
16638 set_cold_build_extract_observer(Some(Arc::new(move |paths| {
16639 extracted_for_observer.fetch_add(paths.len(), AtomicOrdering::SeqCst);
16640 })));
16641 let successor_epoch = epoch.next();
16642 let (store, stats) = with_publish_epoch(epoch, successor_epoch, || {
16643 CallGraphStore::cold_build_with_lease_chunked(
16644 callgraph_dir.path().to_path_buf(),
16645 root.path().to_path_buf(),
16646 &files,
16647 1,
16648 )
16649 })
16650 .expect("completed same-corpus staging publishes without rebuilding");
16651 set_cold_build_extract_observer(None);
16652
16653 assert_eq!(stats.files, 1);
16654 assert_eq!(
16655 extracted.load(AtomicOrdering::SeqCst),
16656 0,
16657 "completed staging must not repeat extraction"
16658 );
16659 drop(store);
16660 }
16661
16662 #[test]
16663 fn superseded_slice_preserves_staging_and_same_corpus_successor_resumes() {
16664 let root = tempfile::tempdir().unwrap();
16665 let callgraph_dir = tempfile::tempdir().unwrap();
16666 let files = ["a.rs", "b.rs", "c.rs"]
16667 .into_iter()
16668 .map(|name| {
16669 let path = root.path().join(name);
16670 std::fs::write(&path, format!("pub fn {}() {{}}\n", name.replace('.', "_")))
16671 .unwrap();
16672 path
16673 })
16674 .collect::<Vec<_>>();
16675 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
16676 let old_epoch = epoch.next();
16677 let superseded = Arc::new(AtomicBool::new(false));
16678 let epoch_for_observer = epoch.clone();
16679 let superseded_for_observer = Arc::clone(&superseded);
16680 set_cold_build_slice_observer(Some(Arc::new(move |stage, completed, _total| {
16681 if stage == "extraction"
16682 && completed == 1
16683 && !superseded_for_observer.swap(true, AtomicOrdering::SeqCst)
16684 {
16685 epoch_for_observer.next();
16686 }
16687 })));
16688
16689 let result = with_publish_epoch(epoch.clone(), old_epoch, || {
16690 CallGraphStore::cold_build_with_lease_chunked(
16691 callgraph_dir.path().to_path_buf(),
16692 root.path().to_path_buf(),
16693 &files,
16694 1,
16695 )
16696 });
16697 set_cold_build_slice_observer(None);
16698 assert!(matches!(result, Err(CallGraphStoreError::Superseded)));
16699 assert!(superseded.load(AtomicOrdering::SeqCst));
16700
16701 let project_key = crate::search_index::artifact_cache_key(root.path());
16702 let staging = callgraph_dir
16703 .path()
16704 .join(format!("{project_key}.staging.sqlite.tmp.resume"));
16705 assert!(staging.exists(), "supersession must retain durable staging");
16706 let staged = Connection::open(&staging).unwrap();
16707 assert_eq!(
16708 staged_build_phase(&staged).unwrap().as_deref(),
16709 Some("extracting")
16710 );
16711 assert_eq!(
16712 query_count(&staged, "SELECT COUNT(*) FROM files").unwrap(),
16713 1
16714 );
16715 drop(staged);
16716
16717 let extracted = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
16718 let extracted_for_observer = Arc::clone(&extracted);
16719 set_cold_build_extract_observer(Some(Arc::new(move |paths| {
16720 extracted_for_observer
16721 .lock()
16722 .unwrap()
16723 .extend(paths.iter().filter_map(|path| {
16724 path.file_name()
16725 .map(|name| name.to_string_lossy().into_owned())
16726 }));
16727 })));
16728 let successor_epoch = epoch.next();
16729 let (store, stats) = with_publish_epoch(epoch.clone(), successor_epoch, || {
16730 CallGraphStore::cold_build_with_lease_chunked(
16731 callgraph_dir.path().to_path_buf(),
16732 root.path().to_path_buf(),
16733 &files,
16734 1,
16735 )
16736 })
16737 .expect("same-corpus successor resumes and publishes");
16738 set_cold_build_extract_observer(None);
16739
16740 assert_eq!(stats.files, 3);
16741 assert_eq!(
16742 *extracted.lock().unwrap(),
16743 vec!["b.rs".to_string(), "c.rs".to_string()],
16744 "the successor must not repeat the committed first slice"
16745 );
16746 drop(store);
16747 assert!(
16748 !staging.exists(),
16749 "published staging moves to its generation"
16750 );
16751 }
16752
16753 #[test]
16754 fn changed_corpus_restarts_instead_of_adopting_staged_progress() {
16755 let root = tempfile::tempdir().unwrap();
16756 let callgraph_dir = tempfile::tempdir().unwrap();
16757 let first = root.path().join("a.rs");
16758 let second = root.path().join("b.rs");
16759 std::fs::write(&first, "pub fn a() {}\n").unwrap();
16760 std::fs::write(&second, "pub fn b() {}\n").unwrap();
16761 let mut files = vec![first.clone(), second.clone()];
16762 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
16763 let old_epoch = epoch.next();
16764 let advanced = Arc::new(AtomicBool::new(false));
16765 let epoch_for_observer = epoch.clone();
16766 let advanced_for_observer = Arc::clone(&advanced);
16767 set_cold_build_slice_observer(Some(Arc::new(move |stage, completed, _total| {
16768 if stage == "extraction"
16769 && completed == 1
16770 && !advanced_for_observer.swap(true, AtomicOrdering::SeqCst)
16771 {
16772 epoch_for_observer.next();
16773 }
16774 })));
16775 let result = with_publish_epoch(epoch.clone(), old_epoch, || {
16776 CallGraphStore::cold_build_with_lease_chunked(
16777 callgraph_dir.path().to_path_buf(),
16778 root.path().to_path_buf(),
16779 &files,
16780 1,
16781 )
16782 });
16783 set_cold_build_slice_observer(None);
16784 assert!(matches!(result, Err(CallGraphStoreError::Superseded)));
16785
16786 std::fs::write(&first, "pub fn a_changed() { b(); }\n").unwrap();
16787 let third = root.path().join("c.rs");
16788 std::fs::write(&third, "pub fn c() {}\n").unwrap();
16789 files.push(third);
16790 let extracted = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
16791 let extracted_for_observer = Arc::clone(&extracted);
16792 set_cold_build_extract_observer(Some(Arc::new(move |paths| {
16793 extracted_for_observer
16794 .lock()
16795 .unwrap()
16796 .extend(paths.iter().filter_map(|path| {
16797 path.file_name()
16798 .map(|name| name.to_string_lossy().into_owned())
16799 }));
16800 })));
16801 let successor_epoch = epoch.next();
16802 let (store, stats) = with_publish_epoch(epoch, successor_epoch, || {
16803 CallGraphStore::cold_build_with_lease_chunked(
16804 callgraph_dir.path().to_path_buf(),
16805 root.path().to_path_buf(),
16806 &files,
16807 1,
16808 )
16809 })
16810 .expect("changed-corpus successor restarts and publishes");
16811 set_cold_build_extract_observer(None);
16812
16813 assert_eq!(stats.files, 3);
16814 assert_eq!(
16815 *extracted.lock().unwrap(),
16816 vec!["a.rs".to_string(), "b.rs".to_string(), "c.rs".to_string()],
16817 "fingerprint mismatch must invalidate every old extraction slice"
16818 );
16819 drop(store);
16820 }
16821
16822 #[test]
16823 fn cold_build_prepared_bulk_insert_matches_reference_rows() {
16824 let dir = tempdir().expect("temp dir");
16825 let project_root = dir.path();
16826 let extract = fixture_extract(project_root);
16827 let resolved = fixture_resolved(&extract);
16828
16829 let reference = build_reference_connection(project_root, &extract, &resolved);
16830 let optimized = build_optimized_connection(project_root, &extract, &resolved);
16831
16832 for table in [
16833 "files",
16834 "nodes",
16835 "file_dependencies",
16836 "dispatch_hints",
16837 "refs",
16838 "edges",
16839 ] {
16840 let excluded: &[&str] = if table == "files" {
16847 &["indexed_at"]
16848 } else {
16849 &[]
16850 };
16851 assert_eq!(
16852 table_rows_without(&reference, table, excluded),
16853 table_rows_without(&optimized, table, excluded),
16854 "table `{table}` rows must match apart from wall-clock columns"
16855 );
16856 }
16857 assert_eq!(
16858 backend_state_rows(&reference),
16859 backend_state_rows(&optimized),
16860 "backend freshness rows must match apart from updated_at"
16861 );
16862 assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
16863 }
16864
16865 #[test]
16866 fn cold_build_chunked_matches_unchunked_logical_rows() {
16867 let dir = tempdir().expect("temp dir");
16868 let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
16869 write_chunked_equivalence_fixture(&project_root);
16870 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
16871 assert!(
16872 files.len() > 6,
16873 "fixture should be large enough to split into multiple chunks"
16874 );
16875
16876 let unchunked = CallGraphStore::open(
16877 project_root.join(".store-unchunked"),
16878 project_root.to_path_buf(),
16879 )
16880 .expect("open unchunked store");
16881 let unchunked_stats = unchunked
16882 .cold_build_chunked(&files, 0)
16883 .expect("unchunked cold build");
16884
16885 let chunked = CallGraphStore::open(
16886 project_root.join(".store-chunked"),
16887 project_root.to_path_buf(),
16888 )
16889 .expect("open chunked store");
16890 let chunked_stats = chunked
16891 .cold_build_chunked(&files, 3)
16892 .expect("chunked cold build");
16893
16894 assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
16895 assert_eq!(
16896 unchunked.edge_snapshot().expect("unchunked edge snapshot"),
16897 chunked.edge_snapshot().expect("chunked edge snapshot"),
16898 "public edge snapshots must match"
16899 );
16900
16901 let dispatch_edges = {
16902 let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
16903 conn.query_row(
16904 "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
16905 [],
16906 |row| row.get::<_, i64>(0),
16907 )
16908 .expect("count dispatch edges")
16909 };
16910 assert!(
16911 dispatch_edges > 0,
16912 "fixture must exercise method-dispatch edge insertion"
16913 );
16914
16915 for table in [
16916 "edges",
16917 "refs",
16918 "nodes",
16919 "file_dependencies",
16920 "dispatch_hints",
16921 ] {
16922 assert_eq!(
16923 graph_table_rows(&unchunked, table),
16924 graph_table_rows(&chunked, table),
16925 "chunked cold build must match unchunked rows for {table}"
16926 );
16927 }
16928 assert_eq!(
16929 graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
16930 graph_table_rows_without(&chunked, "files", &["indexed_at"]),
16931 "files rows must match apart from indexed_at"
16932 );
16933 assert_eq!(
16934 graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
16935 graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
16936 "backend freshness rows must match apart from updated_at"
16937 );
16938
16939 let published_dir = project_root.join(".store-published");
16940 let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
16941 published_dir.clone(),
16942 project_root.to_path_buf(),
16943 &files,
16944 0,
16945 )
16946 .expect("published unchunked cold build");
16947 assert!(
16948 !CallGraphStore::needs_cold_build(&published_dir, &project_root)
16949 .expect("needs_cold_build after publish"),
16950 "published store should be ready"
16951 );
16952 drop(_published);
16953 let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
16954 published_dir,
16955 project_root.to_path_buf(),
16956 &files,
16957 3,
16958 )
16959 .expect("ensure with a different chunk size");
16960 assert!(
16961 rebuild_stats.is_none(),
16962 "changing callgraph_chunk_size must not affect store identity or force a rebuild"
16963 );
16964 }
16965
16966 #[test]
16967 fn cold_build_resolution_memo_bounds_filesystem_probes_and_preserves_rows() {
16968 let dir = tempdir().expect("temp dir");
16969 let project_root = dir.path().join("project");
16970 fs::create_dir_all(&project_root).expect("create project root");
16971 let project_root = fs::canonicalize(project_root).expect("canonical project root");
16972 let files = write_ts_resolution_memo_fixture(&project_root, 8, 8, 4);
16973 let resolve_window = 19;
16974
16975 callgraph::clear_workspace_package_cache();
16976 let uncached_memo = callgraph::ModuleResolutionMemo::new_for_test(false, true);
16977 let uncached = CallGraphStore::open(
16978 dir.path().join("store-uncached"),
16979 project_root.to_path_buf(),
16980 )
16981 .expect("open uncached store");
16982 let uncached_stats = uncached
16985 .cold_build_chunked_with_disk_index_memo_for_test(
16986 &files,
16987 7,
16988 resolve_window,
16989 &uncached_memo,
16990 false,
16991 )
16992 .expect("uncached comparison build");
16993 assert!(
16994 uncached_stats.refs > resolve_window * 2,
16995 "fixture must cross several staged reference windows"
16996 );
16997
16998 callgraph::clear_workspace_package_cache();
16999 let cached_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
17000 let cached =
17001 CallGraphStore::open(dir.path().join("store-cached"), project_root.to_path_buf())
17002 .expect("open cached store");
17003 let cached_stats = cached
17004 .cold_build_chunked_with_resolution_memo_for_test(
17005 &files,
17006 7,
17007 resolve_window,
17008 &cached_memo,
17009 )
17010 .expect("cached build");
17011
17012 assert_cold_build_stats_match_except_elapsed(&uncached_stats, &cached_stats);
17013 for table in [
17014 "nodes",
17015 "refs",
17016 "file_dependencies",
17017 "edges",
17018 "dispatch_hints",
17019 "type_ref_names",
17020 "meta",
17021 "staging_file_inventory",
17022 "staging_ref_context",
17023 ] {
17024 assert_eq!(
17025 graph_table_rows(&uncached, table),
17026 graph_table_rows(&cached, table),
17027 "memoized and uncached cold builds must produce identical {table} rows"
17028 );
17029 }
17030 assert_eq!(
17031 graph_table_rows_without(&uncached, "files", &["indexed_at"]),
17032 graph_table_rows_without(&cached, "files", &["indexed_at"]),
17033 "files rows must match apart from indexed_at"
17034 );
17035 assert_eq!(
17036 graph_table_rows_without(&uncached, "backend_file_state", &["updated_at"]),
17037 graph_table_rows_without(&cached, "backend_file_state", &["updated_at"]),
17038 "backend rows must match apart from updated_at"
17039 );
17040
17041 let cached_module_computations = cached_memo.module_computations_for_test();
17042 assert!(
17043 !cached_module_computations.is_empty(),
17044 "fixture must exercise module resolution"
17045 );
17046 assert!(
17047 cached_module_computations.values().all(|count| *count == 1),
17048 "each importing-directory/specifier pair must reach the filesystem once"
17049 );
17050 let uncached_module_computations = uncached_memo.module_computations_for_test();
17051 assert!(
17052 uncached_module_computations
17053 .values()
17054 .copied()
17055 .max()
17056 .unwrap_or_default()
17057 > 16,
17058 "mutation control: disabling the memo must recompute a hot module target"
17059 );
17060
17061 let cached_package_probes = cached_memo
17062 .json_probes_for_test()
17063 .into_iter()
17064 .filter(|(path, _)| {
17065 path.file_name().and_then(|name| name.to_str()) == Some("package.json")
17066 })
17067 .collect::<HashMap<_, _>>();
17068 assert!(
17069 !cached_package_probes.is_empty(),
17070 "fixture must exercise package.json lookup"
17071 );
17072 assert!(
17073 cached_package_probes.values().all(|count| *count == 1),
17074 "every package.json path must be probed at most once per cold build"
17075 );
17076 let uncached_package_probes = uncached_memo
17077 .json_probes_for_test()
17078 .into_iter()
17079 .filter(|(path, _)| {
17080 path.file_name().and_then(|name| name.to_str()) == Some("package.json")
17081 })
17082 .collect::<HashMap<_, _>>();
17083 let cached_probe_total: usize = cached_package_probes.values().sum();
17084 let uncached_probe_total: usize = uncached_package_probes.values().sum();
17085 assert!(
17086 uncached_probe_total > cached_probe_total * 20,
17087 "mutation control: disabled memo should repeat the package ladder ({uncached_probe_total} vs {cached_probe_total})"
17088 );
17089 }
17090
17091 #[test]
17092 fn cold_build_disk_file_index_memo_preserves_resolved_rows() {
17093 let dir = tempdir().expect("temp dir");
17094 let project_root = dir.path().join("project");
17095 fs::create_dir_all(&project_root).expect("create project root");
17096 let project_root = fs::canonicalize(project_root).expect("canonical project root");
17097 let files = write_rust_declared_module_memo_fixture(&project_root, 6);
17098
17099 let bypassed = CallGraphStore::open(
17100 dir.path().join("store-disk-memo-bypassed"),
17101 project_root.to_path_buf(),
17102 )
17103 .expect("open bypassed store");
17104 let bypassed_module_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
17105 let bypassed_stats = bypassed
17106 .cold_build_chunked_with_disk_index_memo_for_test(
17107 &files,
17108 3,
17109 7,
17110 &bypassed_module_memo,
17111 false,
17112 )
17113 .expect("build with disk index memo bypassed");
17114
17115 let memoized = CallGraphStore::open(
17116 dir.path().join("store-disk-memoized"),
17117 project_root.to_path_buf(),
17118 )
17119 .expect("open memoized store");
17120 let memoized_module_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
17121 let memoized_stats = memoized
17122 .cold_build_chunked_with_disk_index_memo_for_test(
17123 &files,
17124 3,
17125 7,
17126 &memoized_module_memo,
17127 true,
17128 )
17129 .expect("build with disk index memo enabled");
17130
17131 assert_cold_build_stats_match_except_elapsed(&bypassed_stats, &memoized_stats);
17132 for table in ["refs", "edges"] {
17133 assert_eq!(
17134 graph_table_rows(&bypassed, table),
17135 graph_table_rows(&memoized, table),
17136 "memoized and bypassed disk indexes must produce byte-identical {table} rows"
17137 );
17138 }
17139 }
17140
17141 #[test]
17142 fn rust_declared_module_memo_parses_each_declaring_file_once_and_preserves_edges() {
17143 let dir = tempdir().expect("temp dir");
17144 let project_root = dir.path().join("project");
17145 fs::create_dir_all(&project_root).expect("create project root");
17146 let project_root = fs::canonicalize(project_root).expect("canonical project root");
17147 let files = write_rust_declared_module_memo_fixture(&project_root, 10);
17148
17149 let negative_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
17150 for _ in 0..3 {
17151 assert_eq!(
17152 rust_declared_module_target(
17153 &project_root,
17154 "src/lib.rs",
17155 "undeclared",
17156 &negative_memo,
17157 &FactPaths {
17158 root: &project_root,
17159 facts: &DiskFacts::new(&project_root)
17160 },
17161 ),
17162 None
17163 );
17164 }
17165 assert_eq!(
17166 negative_memo
17167 .rust_declaration_parses_for_test()
17168 .get("src/lib.rs"),
17169 Some(&1),
17170 "an undeclared module must be retained as a file-level negative result"
17171 );
17172
17173 let uncached_memo = callgraph::ModuleResolutionMemo::new_for_test(false, true);
17174 let uncached = CallGraphStore::open(
17175 dir.path().join("rust-store-uncached"),
17176 project_root.to_path_buf(),
17177 )
17178 .expect("open uncached Rust store");
17179 let uncached_stats = uncached
17182 .cold_build_chunked_with_disk_index_memo_for_test(&files, 3, 11, &uncached_memo, false)
17183 .expect("uncached Rust build");
17184
17185 let cached_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
17186 let cached = CallGraphStore::open(
17187 dir.path().join("rust-store-cached"),
17188 project_root.to_path_buf(),
17189 )
17190 .expect("open cached Rust store");
17191 let cached_stats = cached
17192 .cold_build_chunked_with_resolution_memo_for_test(&files, 3, 11, &cached_memo)
17193 .expect("cached Rust build");
17194
17195 assert!(
17196 cached_stats.refs >= 60,
17197 "fixture must exercise repeated qualified and undeclared paths"
17198 );
17199 assert_cold_build_stats_match_except_elapsed(&uncached_stats, &cached_stats);
17200 assert_eq!(
17201 uncached.edge_snapshot().expect("uncached edge snapshot"),
17202 cached.edge_snapshot().expect("cached edge snapshot"),
17203 "memoized Rust declarations must preserve the public edge set"
17204 );
17205 assert_eq!(
17206 graph_table_rows(&uncached, "edges"),
17207 graph_table_rows(&cached, "edges"),
17208 "source, symbol, target, and provenance rows must be byte-identical"
17209 );
17210
17211 let expected_declaring_files = [
17212 "src/lib.rs",
17213 "src/module_0.rs",
17214 "src/module_1.rs",
17215 "src/module_2.rs",
17216 "src/module_3.rs",
17217 "src/module_4.rs",
17218 ]
17219 .into_iter()
17220 .map(str::to_string)
17221 .collect::<BTreeSet<_>>();
17222 let cached_parses = cached_memo.rust_declaration_parses_for_test();
17223 assert_eq!(
17224 cached_parses.keys().cloned().collect::<BTreeSet<_>>(),
17225 expected_declaring_files,
17226 "the fixture must traverse exactly its six distinct declaring files"
17227 );
17228 assert!(
17229 cached_parses.values().all(|count| *count == 1),
17230 "each declaring file must be parsed once per cold build: {cached_parses:?}"
17231 );
17232
17233 let uncached_parses = uncached_memo.rust_declaration_parses_for_test();
17234 let uncached_parse_total: usize = uncached_parses.values().sum();
17235 let cached_parse_total: usize = cached_parses.values().sum();
17236 println!(
17237 "RUST_DECLARATION_PARSE_COUNTS memo=off:{uncached_parse_total} memo=on:{cached_parse_total} distinct={}",
17238 cached_parses.len()
17239 );
17240 assert!(
17241 uncached_parse_total > 100,
17242 "mutation control: disabling memo insertion must repeat declaration parses; got {uncached_parse_total}"
17243 );
17244 let cached_missing_refs = cached
17245 .conn
17246 .lock()
17247 .expect("callgraph store mutex poisoned")
17248 .query_row(
17249 "SELECT COUNT(*) FROM refs
17250 WHERE full_ref = 'crate::undeclared::missing' AND target_file IS NULL",
17251 [],
17252 |row| row.get::<_, usize>(0),
17253 )
17254 .expect("count unresolved negative refs");
17255 assert_eq!(
17256 cached_missing_refs, 10,
17257 "all negative-result references must remain unresolved without reparsing"
17258 );
17259 }
17260
17261 #[test]
17262 fn refresh_after_adding_rust_module_declaration_uses_fresh_snapshot() {
17263 let dir = tempdir().expect("temp dir");
17264 let project_root = dir.path().join("project");
17265 fs::create_dir_all(project_root.join("src/custom")).expect("create Rust fixture");
17266 fs::write(
17267 project_root.join("Cargo.toml"),
17268 "[package]\nname = \"refresh-declaration-fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
17269 )
17270 .expect("write Rust manifest");
17271 let lib = project_root.join("src/lib.rs");
17272 let existing = project_root.join("src/existing.rs");
17273 let added = project_root.join("src/custom/added.rs");
17274 fs::write(
17275 &lib,
17276 "mod existing;\npub fn run() { crate::added::target(); }\n",
17277 )
17278 .expect("write initial lib");
17279 fs::write(&existing, "pub fn existing() {}\n").expect("write existing module");
17280 fs::write(&added, "pub fn target() {}\n").expect("write added module target");
17281 let project_root = fs::canonicalize(project_root).expect("canonical project root");
17282 let lib = project_root.join("src/lib.rs");
17283 let existing = project_root.join("src/existing.rs");
17284 let added = project_root.join("src/custom/added.rs");
17285
17286 let store = CallGraphStore::open(
17287 dir.path().join("refresh-declaration-store"),
17288 project_root.to_path_buf(),
17289 )
17290 .expect("open refresh store");
17291 store
17292 .cold_build(&[lib.clone(), existing.clone(), added])
17293 .expect("initial cold build");
17294 assert!(
17295 store
17296 .direct_callers_of(Path::new("src/custom/added.rs"), "target")
17297 .expect("initial callers")
17298 .is_empty(),
17299 "the custom-path module must be unresolved before its declaration exists"
17300 );
17301
17302 fs::write(&existing, "pub fn existing() { let _ = 1; }\n").expect("touch existing module");
17303 store
17304 .refresh_files(std::slice::from_ref(&existing))
17305 .expect("warm refresh declaration loading");
17306 fs::write(
17307 &lib,
17308 "mod existing;\n#[path = \"custom/added.rs\"]\nmod added;\npub fn run() { crate::added::target(); }\n",
17309 )
17310 .expect("add custom-path module declaration");
17311 store
17312 .refresh_files(std::slice::from_ref(&lib))
17313 .expect("refresh declaring file");
17314
17315 let callers = store
17316 .direct_callers_of(Path::new("src/custom/added.rs"), "target")
17317 .expect("refreshed callers");
17318 assert!(
17319 callers
17320 .iter()
17321 .any(|site| { site.caller.file == "src/lib.rs" && site.caller.symbol == "run" }),
17322 "a fresh refresh generation must resolve the newly declared module: {callers:#?}"
17323 );
17324 }
17325
17326 #[test]
17331 #[ignore]
17332 fn bench_cold_build_resolution_memo() {
17333 let dir = tempdir().expect("temp dir");
17334 let project_root = dir.path().join("project");
17335 fs::create_dir_all(&project_root).expect("create benchmark root");
17336 let project_root = fs::canonicalize(project_root).expect("canonical benchmark root");
17337 let files = write_ts_resolution_memo_fixture(&project_root, 24, 12, 20);
17338 assert!(
17339 files.len() > 250,
17340 "benchmark fixture must contain hundreds of files"
17341 );
17342
17343 for enabled in [false, true] {
17344 callgraph::clear_workspace_package_cache();
17345 let memo = callgraph::ModuleResolutionMemo::new_for_test(enabled, false);
17346 let store = CallGraphStore::open(
17347 dir.path().join(if enabled {
17348 "store-cached"
17349 } else {
17350 "store-uncached"
17351 }),
17352 project_root.to_path_buf(),
17353 )
17354 .expect("open benchmark store");
17355 let cpu_started = process_cpu_time();
17356 let wall_started = Instant::now();
17357 let stats = store
17358 .cold_build_chunked_with_resolution_memo_for_test(&files, 32, 257, &memo)
17359 .expect("benchmark cold build");
17360 let wall_ms = wall_started.elapsed().as_millis();
17361 let cpu_ms = process_cpu_time()
17362 .checked_sub(cpu_started)
17363 .unwrap_or_default()
17364 .as_millis();
17365 println!(
17366 "BENCH_COLD_BUILD_RESOLUTION_MEMO memo={} files={} refs={} edges={} wall_ms={} cpu_ms={}",
17367 if enabled { "on" } else { "off" },
17368 stats.files,
17369 stats.refs,
17370 stats.edges,
17371 wall_ms,
17372 cpu_ms
17373 );
17374 }
17375 }
17376
17377 #[test]
17378 #[ignore]
17379 fn bench_rust_declared_module_memo_real_corpus() {
17380 let project_root = fs::canonicalize(env!("CARGO_MANIFEST_DIR"))
17381 .expect("canonical agent-file-tools crate root");
17382 let files = [
17383 "src/main.rs",
17384 "src/cli/mod.rs",
17385 "src/cli/index.rs",
17386 "src/cli/sandbox_launch.rs",
17387 "src/cli/warmup.rs",
17388 ]
17389 .into_iter()
17390 .map(|path| project_root.join(path))
17391 .collect::<Vec<_>>();
17392 assert!(
17393 files.iter().all(|path| path.is_file()),
17394 "real-corpus benchmark sources must exist"
17395 );
17396 let dir = tempdir().expect("benchmark temp dir");
17397 let mut baseline_edges = None;
17398 let mut baseline_stats = None;
17399 let enabled_modes = match std::env::var("AFT_RUST_DECL_MEMO").as_deref() {
17400 Ok("off") => vec![false],
17401 Ok("on") => vec![true],
17402 _ => vec![false, true],
17403 };
17404
17405 for enabled in enabled_modes {
17406 let memo = callgraph::ModuleResolutionMemo::new_for_test(enabled, true);
17407 let store = CallGraphStore::open(
17408 dir.path().join(if enabled {
17409 "rust-real-cached"
17410 } else {
17411 "rust-real-uncached"
17412 }),
17413 project_root.to_path_buf(),
17414 )
17415 .expect("open real-corpus store");
17416 let phase_times = Arc::new(Mutex::new((None, None)));
17417 let observer_times = Arc::clone(&phase_times);
17418 set_cold_build_phase_observer(Some(Arc::new(move |phase| {
17419 let mut times = observer_times.lock().expect("phase timing mutex poisoned");
17420 match phase {
17421 "resolution" if times.0.is_none() => times.0 = Some(Instant::now()),
17422 "publication" if times.1.is_none() => times.1 = Some(Instant::now()),
17423 _ => {}
17424 }
17425 })));
17426 let build = store.cold_build_chunked_with_resolution_memo_for_test(
17427 &files,
17428 COLD_BUILD_EXTRACT_BATCH_FILES,
17429 COLD_BUILD_RESOLVE_WINDOW,
17430 &memo,
17431 );
17432 set_cold_build_phase_observer(None);
17433 let stats = build.expect("real-corpus cold build");
17434 let times = phase_times.lock().expect("phase timing mutex poisoned");
17435 let resolution_elapsed = times
17436 .1
17437 .expect("publication phase timestamp")
17438 .duration_since(times.0.expect("resolution phase timestamp"));
17439 drop(times);
17440 let edges = graph_table_rows(&store, "edges");
17441 if let Some(expected) = &baseline_edges {
17442 assert_eq!(
17443 &edges, expected,
17444 "real-corpus edge rows, including provenance, must be byte-identical"
17445 );
17446 } else {
17447 baseline_edges = Some(edges);
17448 }
17449 let stats_tuple = (stats.files, stats.nodes, stats.refs, stats.edges);
17450 if let Some(expected) = baseline_stats {
17451 assert_eq!(stats_tuple, expected, "real-corpus build counts must match");
17452 } else {
17453 baseline_stats = Some(stats_tuple);
17454 }
17455 let parses = memo.rust_declaration_parses_for_test();
17456 println!(
17457 "BENCH_RUST_DECLARED_MODULE_MEMO memo={} files={} refs={} edges={} resolution_wall_ms={} declaration_parses={} distinct_declaring_files={}",
17458 if enabled { "on" } else { "off" },
17459 stats.files,
17460 stats.refs,
17461 stats.edges,
17462 resolution_elapsed.as_millis(),
17463 parses.values().sum::<usize>(),
17464 parses.len()
17465 );
17466 }
17467 }
17468
17469 #[test]
17476 #[ignore]
17477 fn bench_cold_build_chunk() {
17478 let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
17479 let chunk: usize = std::env::var("AFT_PERF_CHUNK")
17480 .expect("AFT_PERF_CHUNK")
17481 .parse()
17482 .expect("AFT_PERF_CHUNK must be a non-negative integer");
17483 let project_root = fs::canonicalize(&repo).expect("canonical repo root");
17484 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
17485 let dir = tempdir().expect("temp dir");
17486 let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
17487 .expect("open store");
17488 let started = Instant::now();
17489 let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
17490 let ms = started.elapsed().as_millis();
17491 println!(
17492 "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
17493 stats.files, stats.nodes, stats.refs, stats.edges
17494 );
17495 }
17496
17497 #[test]
17498 fn persisted_workspace_reexport_selects_its_package_dependency() {
17499 let root = tempdir().expect("temp dir");
17500 let dependencies = BTreeSet::from([
17501 "packages/aft-bridge/src/index.ts".to_string(),
17502 "packages/opencode-plugin/src/types.ts".to_string(),
17503 ]);
17504 let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
17505
17506 assert_eq!(
17507 stored_dependencies_for_module(
17508 root.path(),
17509 "packages/opencode-plugin/src/shared/bash-hints.ts",
17510 "@cortexkit/aft-bridge",
17511 &dependencies,
17512 &indexed_files,
17513 &FactPaths {
17514 root: root.path(),
17515 facts: &DiskFacts::new(root.path())
17516 }
17517 ),
17518 BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
17519 );
17520 }
17521
17522 #[test]
17523 fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
17524 let dir = tempdir().expect("temp dir");
17525 let project_root = dir.path();
17526 let files =
17527 write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
17528 let index_path = project_root.join("src/index.ts");
17529
17530 let store = CallGraphStore::open(
17531 project_root.join(".store-incremental-barrel"),
17532 project_root.to_path_buf(),
17533 )
17534 .expect("open incremental store");
17535 store.cold_build(&files).expect("initial cold build");
17536
17537 {
17538 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
17539 let tx = conn.transaction().expect("dependency transaction");
17540 let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
17541 .expect("dependent refs for barrel");
17542 let selected_ref_ids = dependent_refs
17543 .iter()
17544 .map(|dependent_ref| dependent_ref.ref_id.clone())
17545 .collect::<BTreeSet<_>>();
17546 let mut threaded_ref_ids = BTreeSet::new();
17547 let mut threaded_by_caller = BTreeMap::new();
17548 record_dependent_refs(
17549 &mut threaded_ref_ids,
17550 &mut threaded_by_caller,
17551 dependent_refs,
17552 );
17553 let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
17554 .expect("old per-ref caller lookup");
17555
17556 assert_eq!(threaded_ref_ids, selected_ref_ids);
17557 assert_eq!(threaded_by_caller, old_by_caller);
17558 for consumer in [
17559 "src/consumer_a.ts",
17560 "src/consumer_b.ts",
17561 "src/consumer_c.ts",
17562 ] {
17563 assert!(
17564 threaded_by_caller.contains_key(consumer),
17565 "barrel edit should select dependent refs from {consumer}"
17566 );
17567 }
17568 }
17569
17570 fs::write(
17571 &index_path,
17572 "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
17573 )
17574 .expect("edit barrel");
17575 let stats = store
17576 .refresh_files(std::slice::from_ref(&index_path))
17577 .expect("incremental refresh");
17578 assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
17579 assert!(
17580 stats.dependency_selected_refs > 0,
17581 "barrel surface edit should select dependent refs"
17582 );
17583
17584 let cold_store = CallGraphStore::open(
17585 project_root.join(".store-cold-barrel"),
17586 project_root.to_path_buf(),
17587 )
17588 .expect("open cold rebuild store");
17589 cold_store
17590 .cold_build(&files)
17591 .expect("comparison cold build");
17592
17593 for table in [
17594 "nodes",
17595 "refs",
17596 "file_dependencies",
17597 "edges",
17598 "dispatch_hints",
17599 ] {
17600 assert_eq!(
17601 graph_table_rows(&store, table),
17602 graph_table_rows(&cold_store, table),
17603 "incremental refresh {table} rows must match cold rebuild"
17604 );
17605 }
17606
17607 let consumer_path = project_root.join("src/consumer_a.ts");
17608 fs::write(
17609 &consumer_path,
17610 "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
17611 )
17612 .expect("edit barrel consumer");
17613 store
17614 .refresh_files(std::slice::from_ref(&consumer_path))
17615 .expect("refresh consumer through unchanged barrel");
17616 cold_store
17617 .cold_build(&files)
17618 .expect("comparison cold rebuild after consumer refresh");
17619 for table in [
17620 "nodes",
17621 "refs",
17622 "file_dependencies",
17623 "edges",
17624 "dispatch_hints",
17625 ] {
17626 assert_eq!(
17627 graph_table_rows(&store, table),
17628 graph_table_rows(&cold_store, table),
17629 "refresh through a persisted barrel must preserve cold-build {table} rows"
17630 );
17631 }
17632 }
17633
17634 fn build_reference_connection(
17635 project_root: &Path,
17636 extract: &FileExtract,
17637 resolved: &ResolvedRef,
17638 ) -> Connection {
17639 let mut conn = Connection::open_in_memory().expect("open reference db");
17640 configure_build_connection(&conn).expect("configure reference db");
17641 initialize_schema(&conn).expect("initialize reference schema");
17642 {
17643 let tx = conn.transaction().expect("reference transaction");
17644 clear_tables(&tx).expect("reference clear");
17645 insert_meta(&tx).expect("reference meta");
17646 insert_file_extract(&tx, project_root, extract).expect("reference file extract");
17647 insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
17648 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
17649 .expect("reference dispatch edges");
17650 assert_eq!(supplemental, 0);
17651 tx.commit().expect("reference commit");
17652 }
17653 conn
17654 }
17655
17656 fn build_optimized_connection(
17657 project_root: &Path,
17658 extract: &FileExtract,
17659 resolved: &ResolvedRef,
17660 ) -> Connection {
17661 let mut conn = Connection::open_in_memory().expect("open optimized db");
17662 configure_build_connection(&conn).expect("configure optimized db");
17663 initialize_schema(&conn).expect("initialize optimized schema");
17664 {
17665 let tx = conn.transaction().expect("optimized transaction");
17666 clear_tables(&tx).expect("optimized clear");
17667 insert_meta(&tx).expect("optimized meta");
17668 drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
17669 {
17670 let workspace_root = project_root.display().to_string();
17671 let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
17672 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
17673 .expect("optimized file extract");
17674 insert_resolved_ref_prepared(&mut inserts, resolved)
17675 .expect("optimized resolved ref");
17676 }
17677 create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
17678 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
17679 .expect("optimized dispatch edges");
17680 assert_eq!(supplemental, 0);
17681 tx.commit().expect("optimized commit");
17682 }
17683 conn
17684 }
17685
17686 fn fixture_extract(_project_root: &Path) -> FileExtract {
17687 let rel_path = "src/main.ts".to_string();
17688 let target_path = "src/helper.ts".to_string();
17689 let node = NodeRecord {
17690 id: "node-main".to_string(),
17691 file_path: rel_path.clone(),
17692 name: "main".to_string(),
17693 scoped_name: "main".to_string(),
17694 kind: "function".to_string(),
17695 range: Range {
17696 start_line: 0,
17697 start_col: 0,
17698 end_line: 0,
17699 end_col: 32,
17700 },
17701 range_ordinal: 0,
17702 signature: Some("export function main()".to_string()),
17703 exported: true,
17704 is_default_export: false,
17705 is_type_like: false,
17706 is_callgraph_entry_point: true,
17707 };
17708 let mut dependencies = BTreeSet::new();
17709 dependencies.insert(target_path.clone());
17710 let raw_ref = RawRef {
17711 ref_id: "ref-main-helper".to_string(),
17712 caller_node: Some(node.id.clone()),
17713 caller_symbol: Some(node.scoped_name.clone()),
17714 caller_file: rel_path.clone(),
17715 kind: "call".to_string(),
17716 short_name: Some("helper".to_string()),
17717 full_ref: Some("helper".to_string()),
17718 module_path: None,
17719 import_kind: None,
17720 local_name: Some("helper".to_string()),
17721 requested_name: Some("helper".to_string()),
17722 namespace_alias: None,
17723 wildcard: false,
17724 line: 1,
17725 byte_start: 24,
17726 byte_end: 32,
17727 dependencies,
17728 };
17729 FileExtract {
17730 rel_path,
17731 freshness: FileFreshness {
17732 mtime: UNIX_EPOCH + Duration::from_secs(123),
17733 size: 40,
17734 content_hash: cache_freshness::hash_bytes(b"fixture source"),
17735 },
17736 lang: LangId::TypeScript,
17737 data: FileCallData {
17738 calls_by_symbol: HashMap::new(),
17739 value_refs_by_symbol: HashMap::new(),
17740 exported_symbols: Vec::new(),
17741 symbol_metadata: HashMap::new(),
17742 default_export_symbol: None,
17743 import_block: ImportBlock::empty(),
17744 lang: LangId::TypeScript,
17745 },
17746 nodes: vec![node.clone()],
17747 raw_refs: vec![raw_ref],
17748 dispatch_hints: vec![DispatchHint {
17749 id: "dispatch-main-helper".to_string(),
17750 method_name: "helper".to_string(),
17751 caller_node: node.id,
17752 file: "src/main.ts".to_string(),
17753 line: 1,
17754 byte_start: 24,
17755 byte_end: 32,
17756 }],
17757 surface_fingerprint: "surface".to_string(),
17758 }
17759 }
17760
17761 fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
17762 let raw = extract.raw_refs[0].clone();
17763 let mut dependencies = raw.dependencies.clone();
17764 dependencies.insert("src/helper.ts".to_string());
17765 ResolvedRef {
17766 edge: Some(EdgeRecord {
17767 edge_id: "edge-main-helper".to_string(),
17768 source_node: raw.caller_node.clone().expect("caller node"),
17769 target_node: Some("node-helper".to_string()),
17770 target_file: "src/helper.ts".to_string(),
17771 target_symbol: "helper".to_string(),
17772 kind: "call".to_string(),
17773 line: raw.line,
17774 }),
17775 raw,
17776 status: "resolved".to_string(),
17777 target_node: Some("node-helper".to_string()),
17778 target_file: Some("src/helper.ts".to_string()),
17779 target_symbol: Some("helper".to_string()),
17780 dependencies,
17781 }
17782 }
17783
17784 fn write_rust_declared_module_memo_fixture(
17785 project_root: &Path,
17786 calls_per_module: usize,
17787 ) -> Vec<PathBuf> {
17788 fs::create_dir_all(project_root.join("src")).expect("create Rust fixture root");
17789 fs::write(
17790 project_root.join("Cargo.toml"),
17791 "[package]\nname = \"rust-declaration-memo-fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
17792 )
17793 .expect("write Rust fixture manifest");
17794
17795 let modules = (0..5)
17796 .map(|index| format!("module_{index}"))
17797 .collect::<Vec<_>>();
17798 let mut lib_source = modules
17799 .iter()
17800 .map(|module| format!("pub mod {module};\n"))
17801 .collect::<String>();
17802 lib_source.push_str("\npub fn dispatch() {\n");
17803 for call in 0..calls_per_module {
17804 for (index, module) in modules.iter().enumerate() {
17805 lib_source.push_str(&format!(
17806 " crate::{module}::leaf::target_{index}(); // call {call}\n"
17807 ));
17808 }
17809 lib_source.push_str(" crate::undeclared::missing();\n");
17810 }
17811 lib_source.push_str("}\n");
17812 let lib = project_root.join("src/lib.rs");
17813 fs::write(&lib, lib_source).expect("write Rust fixture lib");
17814 let mut files = vec![lib];
17815
17816 for (index, module) in modules.iter().enumerate() {
17817 let declaring_file = project_root.join(format!("src/{module}.rs"));
17818 fs::write(&declaring_file, "pub mod leaf;\n").expect("write nested module declaration");
17819 let target_file = project_root.join(format!("src/{module}/leaf.rs"));
17820 fs::create_dir_all(target_file.parent().expect("nested module parent"))
17821 .expect("create nested module directory");
17822 fs::write(&target_file, format!("pub fn target_{index}() {{}}\n"))
17823 .expect("write nested module target");
17824 files.push(declaring_file);
17825 files.push(target_file);
17826 }
17827 files
17828 }
17829
17830 fn write_ts_resolution_memo_fixture(
17831 project_root: &Path,
17832 package_count: usize,
17833 files_per_package: usize,
17834 calls_per_file: usize,
17835 ) -> Vec<PathBuf> {
17836 fs::create_dir_all(project_root).expect("create memo fixture root");
17837 fs::write(
17838 project_root.join("package.json"),
17839 r#"{"name":"fixture-root","private":true,"workspaces":["packages/*"]}"#,
17840 )
17841 .expect("write workspace package manifest");
17842 fs::write(
17843 project_root.join("tsconfig.json"),
17844 r#"{"compilerOptions":{"baseUrl":".","paths":{}}}"#,
17845 )
17846 .expect("write fixture tsconfig");
17847
17848 let shared_root = project_root.join("packages/shared");
17849 let shared_source = shared_root.join("src/index.ts");
17850 fs::create_dir_all(shared_source.parent().expect("shared source parent"))
17851 .expect("create shared package");
17852 fs::write(
17853 shared_root.join("package.json"),
17854 r#"{"name":"@fixture/shared","exports":{".":{"source":"./src/index.ts"}}}"#,
17855 )
17856 .expect("write shared package manifest");
17857 fs::write(
17858 &shared_source,
17859 "export function shared(value: number) { return value + 1; }\n",
17860 )
17861 .expect("write shared source");
17862 let mut files = vec![shared_source];
17863
17864 for package in 0..package_count {
17865 let package_root = project_root.join(format!("packages/app-{package:02}"));
17866 fs::create_dir_all(&package_root).expect("create app package");
17867 fs::write(
17868 package_root.join("package.json"),
17869 format!(r#"{{"name":"@fixture/app-{package:02}"}}"#),
17870 )
17871 .expect("write app package manifest");
17872 let source_dir = package_root.join("src/features/deep/nested/leaf");
17873 fs::create_dir_all(&source_dir).expect("create deep app source dir");
17874
17875 for file in 0..files_per_package {
17876 let source_path = source_dir.join(format!("caller_{file:03}.ts"));
17877 let mut source = "import { shared } from \"@fixture/shared\";\n".to_string();
17878 for call in 0..calls_per_file {
17879 source.push_str(&format!(
17880 "export function caller_{package}_{file}_{call}() {{ return shared({call}); }}\n"
17881 ));
17882 }
17883 fs::write(&source_path, source).expect("write app source");
17884 files.push(source_path);
17885 }
17886 }
17887
17888 files
17889 }
17890
17891 #[cfg(unix)]
17892 fn process_cpu_time() -> Duration {
17893 let mut value = std::mem::MaybeUninit::<libc::timespec>::uninit();
17894 let result =
17895 unsafe { libc::clock_gettime(libc::CLOCK_PROCESS_CPUTIME_ID, value.as_mut_ptr()) };
17896 if result != 0 {
17897 return Duration::ZERO;
17898 }
17899 let value = unsafe { value.assume_init() };
17900 Duration::new(value.tv_sec.max(0) as u64, value.tv_nsec.max(0) as u32)
17901 }
17902
17903 #[cfg(not(unix))]
17904 fn process_cpu_time() -> Duration {
17905 Duration::ZERO
17906 }
17907
17908 fn write_chunked_equivalence_fixture(project_root: &Path) {
17909 let ts_dir = project_root.join("ts");
17910 fs::create_dir_all(&ts_dir).expect("create ts dir");
17911 fs::write(
17912 ts_dir.join("leaf.ts"),
17913 "export function leaf(value: number) {\n return value + 1;\n}\n",
17914 )
17915 .expect("write ts leaf");
17916 fs::write(
17917 ts_dir.join("mid.ts"),
17918 "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n return leaf(value);\n}\n",
17919 )
17920 .expect("write ts mid");
17921 fs::write(
17922 ts_dir.join("entry.ts"),
17923 "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n return mid(worker.run());\n}\n",
17924 )
17925 .expect("write ts entry");
17926 fs::write(
17927 ts_dir.join("worker.ts"),
17928 "export class Worker {\n run() {\n return 41;\n }\n}\n",
17929 )
17930 .expect("write ts worker");
17931 for idx in 0..4 {
17932 fs::write(
17933 ts_dir.join(format!("extra_{idx}.ts")),
17934 format!(
17935 "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n return entry(new Worker());\n}}\n"
17936 ),
17937 )
17938 .expect("write ts extra");
17939 }
17940
17941 let rust_dir = project_root.join("src");
17942 let commands_dir = rust_dir.join("commands");
17943 fs::create_dir_all(&commands_dir).expect("create rust commands dir");
17944 fs::write(
17945 rust_dir.join("context.rs"),
17946 r#"pub struct AppContext;
17947
17948impl AppContext {
17949 pub fn callgraph_store_for_ops(&self) -> usize {
17950 1
17951 }
17952}
17953"#,
17954 )
17955 .expect("write rust context");
17956 fs::write(
17957 rust_dir.join("lib.rs"),
17958 "pub mod context;\npub mod commands;\n",
17959 )
17960 .expect("write rust lib");
17961 fs::write(
17962 commands_dir.join("mod.rs"),
17963 "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
17964 )
17965 .expect("write rust commands mod");
17966 for name in ["callers", "impact", "trace_to"] {
17967 fs::write(
17968 commands_dir.join(format!("{name}.rs")),
17969 format!(
17970 r#"use crate::context::AppContext;
17971
17972pub fn handle_{name}(ctx: &AppContext) -> usize {{
17973 ctx.callgraph_store_for_ops()
17974}}
17975"#
17976 ),
17977 )
17978 .expect("write rust command");
17979 }
17980 }
17981
17982 fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
17983 let src_dir = project_root.join("src");
17984 fs::create_dir_all(&src_dir).expect("create src dir");
17985
17986 let target_path = src_dir.join("target.ts");
17987 fs::write(&target_path, "export function target() {\n return 1;\n}\n")
17988 .expect("write target");
17989
17990 let index_path = src_dir.join("index.ts");
17991 fs::write(&index_path, barrel_source).expect("write barrel");
17992
17993 let mut files = vec![target_path, index_path];
17994 for (file_name, function_name) in [
17995 ("consumer_a.ts", "consumerA"),
17996 ("consumer_b.ts", "consumerB"),
17997 ("consumer_c.ts", "consumerC"),
17998 ] {
17999 let path = src_dir.join(file_name);
18000 fs::write(
18001 &path,
18002 format!(
18003 "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n return target();\n}}\n"
18004 ),
18005 )
18006 .expect("write consumer");
18007 files.push(path);
18008 }
18009 files
18010 }
18011
18012 fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
18013 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
18014 table_rows(&conn, table)
18015 }
18016
18017 fn graph_table_rows_without(
18018 store: &CallGraphStore,
18019 table: &str,
18020 excluded_columns: &[&str],
18021 ) -> Vec<String> {
18022 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
18023 table_rows_without(&conn, table, excluded_columns)
18024 }
18025
18026 fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
18027 table_rows_without(conn, table, &[])
18028 }
18029
18030 fn table_rows_without(
18031 conn: &Connection,
18032 table: &str,
18033 excluded_columns: &[&str],
18034 ) -> Vec<String> {
18035 let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
18036 let columns: Vec<String> = conn
18037 .prepare(&format!("PRAGMA table_info({table})"))
18038 .expect("prepare table_info")
18039 .query_map([], |row| row.get::<_, String>(1))
18040 .expect("query table_info")
18041 .collect::<std::result::Result<Vec<String>, _>>()
18042 .expect("collect columns")
18043 .into_iter()
18044 .filter(|column| !excluded_columns.contains(column.as_str()))
18045 .collect();
18046 let sql = format!(
18047 "SELECT {} FROM {table} ORDER BY {}",
18048 columns.join(", "),
18049 columns.join(", ")
18050 );
18051 conn.prepare(&sql)
18052 .expect("prepare table rows")
18053 .query_map([], |row| row_to_strings(row, columns.len()))
18054 .expect("query table rows")
18055 .collect::<std::result::Result<_, _>>()
18056 .expect("collect table rows")
18057 }
18058
18059 fn assert_cold_build_stats_match_except_elapsed(
18060 expected: &ColdBuildStats,
18061 actual: &ColdBuildStats,
18062 ) {
18063 assert_eq!(actual.files, expected.files, "file counts must match");
18064 assert_eq!(actual.nodes, expected.nodes, "node counts must match");
18065 assert_eq!(actual.refs, expected.refs, "ref counts must match");
18066 assert_eq!(actual.edges, expected.edges, "edge counts must match");
18067 assert_eq!(
18068 actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
18069 expected
18070 .failed_files
18071 .iter()
18072 .cloned()
18073 .collect::<BTreeSet<_>>(),
18074 "failed file sets must match"
18075 );
18076 }
18077
18078 fn backend_state_rows(conn: &Connection) -> Vec<String> {
18079 conn.prepare(
18080 "SELECT backend, workspace_root, file_path, content_hash, status
18081 FROM backend_file_state
18082 ORDER BY backend, workspace_root, file_path, content_hash, status",
18083 )
18084 .expect("prepare backend rows")
18085 .query_map([], |row| row_to_strings(row, 5))
18086 .expect("query backend rows")
18087 .collect::<std::result::Result<_, _>>()
18088 .expect("collect backend rows")
18089 }
18090
18091 fn secondary_indexes(conn: &Connection) -> Vec<String> {
18092 let mut indexes = Vec::new();
18093 for table in [
18094 "files",
18095 "nodes",
18096 "refs",
18097 "file_dependencies",
18098 "edges",
18099 "dispatch_hints",
18100 "type_ref_names",
18101 "backend_file_state",
18102 "meta",
18103 ] {
18104 let sql = format!("PRAGMA index_list({table})");
18105 let mut stmt = conn.prepare(&sql).expect("prepare index list");
18106 let rows = stmt
18107 .query_map([], |row| row.get::<_, String>(1))
18108 .expect("query index list");
18109 for name in rows {
18110 let name = name.expect("index name");
18111 if name.starts_with("idx_") {
18112 indexes.push(format!("{table}:{name}"));
18113 }
18114 }
18115 }
18116 indexes.sort();
18117 indexes
18118 }
18119
18120 fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
18121 let mut values = Vec::with_capacity(len);
18122 for index in 0..len {
18123 let value = row.get_ref(index)?;
18124 values.push(match value {
18125 rusqlite::types::ValueRef::Null => "NULL".to_string(),
18126 rusqlite::types::ValueRef::Integer(value) => value.to_string(),
18127 rusqlite::types::ValueRef::Real(value) => value.to_string(),
18128 rusqlite::types::ValueRef::Text(value) => {
18129 String::from_utf8_lossy(value).into_owned()
18130 }
18131 rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
18132 });
18133 }
18134 Ok(values.join("\u{1f}"))
18135 }
18136}
18137
18138#[cfg(test)]
18139mod rust_resolution_tests {
18140 use super::*;
18141 use crate::inspect::job::CallgraphSnapshot;
18142 use std::fs;
18143 use tempfile::tempdir;
18144
18145 #[test]
18146 fn rust_function_scoped_module_alias_resolves_and_projects_live() {
18147 let dir = tempdir().expect("tempdir");
18148 let root = dir.path();
18149 write_rust_manifest(root, "scoped-alias-fixture");
18150 write_file(
18151 root,
18152 "src/lib.rs",
18153 r#"pub mod finalization_contract;
18154
18155pub fn run_alias() {
18156 use crate::finalization_contract as fc;
18157 fc::check_mason_contract();
18158}
18159"#,
18160 );
18161 write_file(
18162 root,
18163 "src/finalization_contract.rs",
18164 r#"pub fn check_mason_contract() {}
18165fn planted_dead() {}
18166"#,
18167 );
18168
18169 let (store, snapshot) = cold_build_twice(root);
18170 assert_direct_caller(
18171 &store,
18172 "src/finalization_contract.rs",
18173 "check_mason_contract",
18174 "src/lib.rs",
18175 "run_alias",
18176 );
18177 assert_projected_call(
18178 root,
18179 &snapshot,
18180 "src/finalization_contract.rs",
18181 "check_mason_contract",
18182 );
18183 assert_no_projected_call(
18184 root,
18185 &snapshot,
18186 "src/finalization_contract.rs",
18187 "planted_dead",
18188 );
18189 assert!(
18190 store
18191 .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
18192 .expect("planted dead callers")
18193 .is_empty(),
18194 "planted-dead guard should stay without callers"
18195 );
18196 }
18197
18198 #[test]
18199 fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
18200 let dir = tempdir().expect("tempdir");
18201 let root = dir.path();
18202 write_rust_manifest(root, "inline-module-fixture");
18203 write_file(
18204 root,
18205 "src/lib.rs",
18206 r#"mod work_graph { fn operations() {} }
18207mod manifest { fn operations() {} }
18208mod audit { fn operations() {} }
18209mod dispatch { fn operations() {} }
18210mod finalization { fn operations() {} }
18211
18212pub fn run_inline_operations() {
18213 work_graph::operations();
18214 manifest::operations();
18215 audit::operations();
18216 dispatch::operations();
18217 finalization::operations();
18218}
18219
18220fn planted_dead() {}
18221"#,
18222 );
18223
18224 let (store, snapshot) = cold_build_twice(root);
18225 for module in [
18226 "work_graph",
18227 "manifest",
18228 "audit",
18229 "dispatch",
18230 "finalization",
18231 ] {
18232 assert_direct_caller(
18233 &store,
18234 "src/lib.rs",
18235 &format!("{module}::operations"),
18236 "src/lib.rs",
18237 "run_inline_operations",
18238 );
18239 }
18240 assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
18241 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
18242 }
18243
18244 #[test]
18245 fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
18246 let dir = tempdir().expect("tempdir");
18247 let root = dir.path();
18248 fs::write(
18249 root.join("Cargo.toml"),
18250 "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
18251 )
18252 .expect("write workspace manifest");
18253 write_file(
18254 root,
18255 "crates/but-action/Cargo.toml",
18256 r#"[package]
18257name = "but-action"
18258version = "0.1.0"
18259edition = "2021"
18260"#,
18261 );
18262 write_file(
18263 root,
18264 "crates/but-action/src/lib.rs",
18265 "mod action;\npub use action::{list_actions};\n",
18266 );
18267 write_file(
18268 root,
18269 "crates/but-action/src/action.rs",
18270 "pub fn list_actions() {}\nfn planted_dead() {}\n",
18271 );
18272 write_file(
18273 root,
18274 "crates/app/Cargo.toml",
18275 r#"[package]
18276name = "app"
18277version = "0.1.0"
18278edition = "2021"
18279"#,
18280 );
18281 write_file(
18282 root,
18283 "crates/app/src/lib.rs",
18284 "pub fn run_actions() {\n but_action::list_actions();\n}\n",
18285 );
18286
18287 let (store, snapshot) = cold_build_twice(root);
18288 assert_direct_caller(
18289 &store,
18290 "crates/but-action/src/action.rs",
18291 "list_actions",
18292 "crates/app/src/lib.rs",
18293 "run_actions",
18294 );
18295 assert!(
18296 store
18297 .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
18298 .expect("lib reexport callers")
18299 .is_empty(),
18300 "call should target the reexported source function, not lib.rs"
18301 );
18302 assert_projected_call(
18303 root,
18304 &snapshot,
18305 "crates/but-action/src/action.rs",
18306 "list_actions",
18307 );
18308 assert_no_projected_call(
18309 root,
18310 &snapshot,
18311 "crates/but-action/src/action.rs",
18312 "planted_dead",
18313 );
18314 }
18315
18316 #[test]
18317 fn rust_cfg_attributed_module_resolves_outgoing_calls() {
18318 let dir = tempdir().expect("tempdir");
18319 let root = dir.path();
18320 write_rust_manifest(root, "cfg-module-outgoing-fixture");
18321 write_file(
18322 root,
18323 "src/lib.rs",
18324 "pub fn project_range() {}\n\n#[cfg(any(test, feature = \"test-conformance\"))]\npub mod conformance;\npub mod ordinary;\n",
18325 );
18326 for module in ["conformance", "ordinary"] {
18327 write_file(
18328 root,
18329 &format!("src/{module}.rs"),
18330 "use crate::project_range;\n\npub fn local_target() {}\n\npub fn run() {\n local_target();\n project_range();\n}\n",
18331 );
18332 }
18333
18334 let (store, _) = cold_build_twice(root);
18335 for module in ["conformance", "ordinary"] {
18336 assert_direct_caller(
18337 &store,
18338 &format!("src/{module}.rs"),
18339 "local_target",
18340 &format!("src/{module}.rs"),
18341 "run",
18342 );
18343 assert_direct_caller(
18344 &store,
18345 "src/lib.rs",
18346 "project_range",
18347 &format!("src/{module}.rs"),
18348 "run",
18349 );
18350 }
18351 }
18352
18353 #[test]
18354 fn rust_registered_modules_preserve_import_alias_resolution() {
18355 let dir = tempdir().expect("tempdir");
18356 let root = dir.path();
18357 write_rust_manifest(root, "registered-module-import-control");
18358 write_file(
18359 root,
18360 "src/main.rs",
18361 "mod commands;\nmod db;\nfn main() {}\n",
18362 );
18363 write_file(
18364 root,
18365 "src/commands.rs",
18366 "use crate::db;\n\npub fn run() {\n db::helper();\n}\n",
18367 );
18368 write_file(root, "src/db.rs", "pub fn helper() {}\n");
18369
18370 let main_extract =
18371 build_file_extract(root, &root.join("src/main.rs")).expect("main extract");
18372 let commands_extract =
18373 build_file_extract(root, &root.join("src/commands.rs")).expect("commands extract");
18374 let db_extract = build_file_extract(root, &root.join("src/db.rs")).expect("db extract");
18375 let files = [&main_extract, &commands_extract, &db_extract]
18376 .into_iter()
18377 .map(|extract| {
18378 (
18379 extract.rel_path.clone(),
18380 DbFileIndex::from_extract(
18381 root,
18382 extract,
18383 &FactPaths {
18384 root,
18385 facts: &DiskFacts::new(root),
18386 },
18387 ),
18388 )
18389 })
18390 .collect::<HashMap<_, _>>();
18391 let caller_data = [&main_extract, &commands_extract, &db_extract]
18392 .into_iter()
18393 .map(|extract| (extract.rel_path.clone(), &extract.data))
18394 .collect::<HashMap<_, _>>();
18395 let index = ProjectIndex::from_parts(
18396 root,
18397 files,
18398 caller_data,
18399 WorkspaceCratePrefixCache::default(),
18400 Rc::new(DiskFacts::new(root)),
18401 );
18402 assert_eq!(
18403 index.module_parent("src/commands.rs"),
18404 Some(("src/main.rs".to_string(), "commands".to_string()))
18405 );
18406 assert_eq!(
18407 index.module_target("src/main.rs", "db").as_deref(),
18408 Some("src/db.rs")
18409 );
18410 let call = commands_extract
18411 .raw_refs
18412 .iter()
18413 .find(|raw| raw.kind == "call" && raw.full_ref.as_deref() == Some("db::helper"))
18414 .expect("db helper call")
18415 .clone();
18416 let resolved = resolve_ref(call, &index).expect("resolve db helper");
18417 assert_eq!(resolved.target_file.as_deref(), Some("src/db.rs"));
18418 assert_eq!(resolved.target_symbol.as_deref(), Some("helper"));
18419
18420 let (store, _) = cold_build_twice(root);
18421 assert_direct_caller(&store, "src/db.rs", "helper", "src/commands.rs", "run");
18422 }
18423
18424 #[test]
18425 fn rust_path_attributed_module_uses_declared_logical_parent() {
18426 let dir = tempdir().expect("tempdir");
18427 let root = dir.path();
18428 write_rust_manifest(root, "path-module-outgoing-fixture");
18429 write_file(
18430 root,
18431 "src/lib.rs",
18432 "pub fn project_range() {}\n\n#[cfg(test)]\n#[path = \"alternate/custom.rs\"]\npub mod conformance;\n",
18433 );
18434 write_file(
18435 root,
18436 "src/alternate/custom.rs",
18437 "pub fn run() {\n super::project_range();\n}\n",
18438 );
18439
18440 let (store, _) = cold_build_twice(root);
18441 assert_direct_caller(
18442 &store,
18443 "src/lib.rs",
18444 "project_range",
18445 "src/alternate/custom.rs",
18446 "run",
18447 );
18448 }
18449
18450 #[test]
18451 fn rust_same_file_test_module_receiver_method_dispatch_resolves() {
18452 let dir = tempdir().expect("tempdir");
18453 let root = dir.path();
18454 write_rust_manifest(root, "same-file-test-module-fixture");
18455 write_file(
18456 root,
18457 "src/lib.rs",
18458 r#"pub struct Index(u32);
18459
18460impl Index {
18461 pub fn shares_index_with(&self, other: &Self) -> bool {
18462 self.0 == other.0
18463 }
18464}
18465
18466#[cfg(test)]
18467mod tests {
18468 use super::Index;
18469
18470 #[test]
18471 fn compares_indexes() {
18472 let before = Index(1);
18473 let after = Index(1);
18474 assert!(before.shares_index_with(&after));
18475 }
18476}
18477"#,
18478 );
18479
18480 let (store, snapshot) = cold_build_twice(root);
18481 assert_direct_caller(
18482 &store,
18483 "src/lib.rs",
18484 "Index::shares_index_with",
18485 "src/lib.rs",
18486 "tests::compares_indexes",
18487 );
18488 assert!(
18489 snapshot.outbound_calls.iter().any(|call| {
18490 call.caller_symbol == "compares_indexes"
18491 && call.line == 17
18492 && call.target.starts_with(&format!(
18493 "shares_index_with{}before.shares_index_with",
18494 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
18495 ))
18496 }),
18497 "expected projected macro receiver call; calls: {:#?}",
18498 snapshot.outbound_calls
18499 );
18500 }
18501
18502 #[test]
18503 fn rust_generic_self_turbofish_method_dispatch_resolves() {
18504 let dir = tempdir().expect("tempdir");
18505 let root = dir.path();
18506 write_rust_manifest(root, "generic-self-fixture");
18507 write_file(
18508 root,
18509 "src/lib.rs",
18510 r#"pub struct Matcher;
18511
18512impl Matcher {
18513 pub fn run(&self) -> bool {
18514 self.fuzzy_match_optimal::<usize>("needle")
18515 }
18516
18517 fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
18518 let _ = std::marker::PhantomData::<T>;
18519 true
18520 }
18521
18522 fn planted_dead(&self) {}
18523}
18524
18525pub fn entry() -> bool {
18526 let matcher = Matcher;
18527 matcher.run()
18528}
18529"#,
18530 );
18531
18532 let (store, snapshot) = cold_build_twice(root);
18533 assert_direct_caller(
18534 &store,
18535 "src/lib.rs",
18536 "Matcher::fuzzy_match_optimal",
18537 "src/lib.rs",
18538 "Matcher::run",
18539 );
18540 assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
18541 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
18542 }
18543
18544 #[test]
18545 fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
18546 let dir = tempdir().expect("tempdir");
18547 let root = dir.path();
18548 write_rust_manifest(root, "manifest-operations-fixture");
18549 write_file(
18550 root,
18551 "src/main.rs",
18552 r#"mod dispatch;
18553use dispatch::{manifest_operations};
18554
18555fn main() {
18556 manifest_operations();
18557}
18558"#,
18559 );
18560 write_file(
18561 root,
18562 "src/dispatch.rs",
18563 r#"mod work_graph { fn operations() {} }
18564mod manifest { fn operations() {} }
18565mod audit { fn operations() {} }
18566mod descriptor { fn operations() {} }
18567mod writer { fn operations() {} }
18568
18569pub fn manifest_operations() {
18570 manifest::operations();
18571}
18572
18573pub fn work_graph_operations() {
18574 work_graph::operations();
18575}
18576
18577pub fn audit_operations() {
18578 audit::operations();
18579}
18580
18581pub fn descriptor_operations() {
18582 descriptor::operations();
18583}
18584
18585pub fn writer_operations() {
18586 writer::operations();
18587}
18588
18589fn planted_dead() {}
18590"#,
18591 );
18592
18593 let (store, snapshot) = cold_build_twice(root);
18594 assert_direct_caller(
18595 &store,
18596 "src/dispatch.rs",
18597 "manifest_operations",
18598 "src/main.rs",
18599 "main",
18600 );
18601 assert_direct_caller(
18602 &store,
18603 "src/dispatch.rs",
18604 "manifest::operations",
18605 "src/dispatch.rs",
18606 "manifest_operations",
18607 );
18608 assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
18609 assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
18610 assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
18611 }
18612
18613 fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
18614 let files = rust_files(root);
18615 let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
18616 .expect("open first store");
18617 first.cold_build(&files).expect("first cold build");
18618 let first_snapshot =
18619 project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
18620
18621 let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
18622 .expect("open second store");
18623 second.cold_build(&files).expect("second cold build");
18624 let second_snapshot =
18625 project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
18626
18627 assert_eq!(
18628 projection_rows(&first_snapshot),
18629 projection_rows(&second_snapshot),
18630 "cold-build projection should be deterministic"
18631 );
18632 (first, first_snapshot)
18633 }
18634
18635 fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
18636 let mut rows = Vec::new();
18637 for export in &snapshot.exported_symbols {
18638 rows.push(format!(
18639 "export\t{}\t{}\t{}\t{}",
18640 export.file.display(),
18641 export.symbol,
18642 export.kind,
18643 export.line
18644 ));
18645 }
18646 for call in &snapshot.outbound_calls {
18647 rows.push(format!(
18648 "call\t{}\t{}\t{}\t{}\t{}",
18649 call.caller_file.display(),
18650 call.caller_symbol,
18651 call.target,
18652 call.line,
18653 call.provenance
18654 ));
18655 }
18656 for file in &snapshot.entry_points {
18657 rows.push(format!("entry_file\t{}", file.display()));
18658 }
18659 for (file, symbols) in &snapshot.entry_point_symbols {
18660 for symbol in symbols {
18661 rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
18662 }
18663 }
18664 rows.sort();
18665 rows
18666 }
18667
18668 fn assert_direct_caller(
18669 store: &CallGraphStore,
18670 target_rel: &str,
18671 target_symbol: &str,
18672 caller_rel: &str,
18673 caller_symbol: &str,
18674 ) {
18675 let callers = store
18676 .direct_callers_of(Path::new(target_rel), target_symbol)
18677 .unwrap_or_else(|error| {
18678 panic!("direct callers for {target_rel}::{target_symbol}: {error}")
18679 });
18680 assert!(
18681 callers.iter().any(|site| {
18682 site.caller.file == caller_rel && site.caller.symbol == caller_symbol
18683 }),
18684 "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
18685 );
18686 }
18687
18688 fn assert_projected_call(
18689 root: &Path,
18690 snapshot: &CallgraphSnapshot,
18691 target_rel: &str,
18692 symbol: &str,
18693 ) {
18694 let target = projected_target(root, target_rel, symbol);
18695 assert!(
18696 snapshot.outbound_calls.iter().any(|call| {
18697 call.target == target
18698 || call.target.starts_with(&format!(
18699 "{target}{}",
18700 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
18701 ))
18702 }),
18703 "expected projected call to {target}; calls: {:#?}",
18704 snapshot.outbound_calls
18705 );
18706 }
18707
18708 fn assert_no_projected_call(
18709 root: &Path,
18710 snapshot: &CallgraphSnapshot,
18711 target_rel: &str,
18712 symbol: &str,
18713 ) {
18714 let target = projected_target(root, target_rel, symbol);
18715 assert!(
18716 snapshot.outbound_calls.iter().all(|call| {
18717 call.target != target
18718 && !call.target.starts_with(&format!(
18719 "{target}{}",
18720 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
18721 ))
18722 }),
18723 "did not expect projected call to {target}; calls: {:#?}",
18724 snapshot.outbound_calls
18725 );
18726 }
18727
18728 fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
18729 let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
18732 format!("{}::{symbol}", path.display())
18733 }
18734
18735 fn write_rust_manifest(root: &Path, name: &str) {
18736 write_file(
18737 root,
18738 "Cargo.toml",
18739 &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
18740 );
18741 }
18742
18743 fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
18744 let path = root.join(rel_path);
18745 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
18746 fs::write(&path, source).expect("write fixture file");
18747 path
18748 }
18749
18750 fn rust_files(root: &Path) -> Vec<PathBuf> {
18751 let mut files = Vec::new();
18752 collect_rust_files(root, &mut files);
18753 files.sort();
18754 files
18755 }
18756
18757 fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
18758 for entry in fs::read_dir(dir).expect("read fixture dir") {
18759 let entry = entry.expect("read fixture entry");
18760 let path = entry.path();
18761 if path.is_dir() {
18762 let name = path
18763 .file_name()
18764 .and_then(|name| name.to_str())
18765 .unwrap_or("");
18766 if !name.starts_with(".store") {
18767 collect_rust_files(&path, files);
18768 }
18769 } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
18770 files.push(path);
18771 }
18772 }
18773 }
18774}
18775
18776#[cfg(test)]
18777mod build_pool_tests {
18778 use super::build_pool_size;
18779
18780 #[test]
18781 fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
18782 let size = build_pool_size();
18783 assert!(size >= 1, "pool size must be at least 1");
18786 assert!(size <= 8, "pool size must be capped at 8, got {size}");
18787
18788 let cores = std::thread::available_parallelism()
18789 .map(|p| p.get())
18790 .unwrap_or(1);
18791 let expected = cores.div_ceil(2).clamp(1, 8);
18792 assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
18793 }
18794}
18795
18796#[cfg(test)]
18797mod reexport_resolution_tests {
18798 use super::*;
18799
18800 fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
18801 ProjectIndex {
18802 facts: Rc::new(DiskFacts::new(Path::new("/fixture"))),
18803 unbound_non_utf8_paths: Vec::new(),
18804 project_root: PathBuf::from("/fixture"),
18805 files: files.into_iter().collect(),
18806 caller_data: HashMap::new(),
18807 workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
18808 rust_crate_roots: callgraph::RustCrateRootMemo::default(),
18809 }
18810 }
18811
18812 fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
18813 DbFileIndex {
18814 lang: None,
18815 exports: HashSet::new(),
18816 default_export: None,
18817 export_aliases: HashMap::new(),
18818 node_by_scoped: HashMap::new(),
18819 node_by_bare: HashMap::new(),
18820 node_kind_by_id: HashMap::new(),
18821 module_targets: HashMap::new(),
18822 declared_module_targets: HashMap::new(),
18823 reexports: reexport_targets
18824 .iter()
18825 .map(|target| ReexportIndex {
18826 target_file: Some((*target).to_string()),
18827 named: HashMap::new(),
18828 wildcard: true,
18829 })
18830 .collect(),
18831 }
18832 }
18833
18834 #[test]
18841 fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
18842 let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
18843 let files = names
18844 .iter()
18845 .map(|name| {
18846 let targets: Vec<&str> = names
18847 .iter()
18848 .filter(|other| *other != name)
18849 .map(String::as_str)
18850 .collect();
18851 (name.clone(), barrel_file(&targets))
18852 })
18853 .collect();
18854 let index = barrel_index(files);
18855
18856 assert_eq!(
18857 resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
18858 None
18859 );
18860 }
18861
18862 #[test]
18868 fn shallow_revisit_after_deep_capped_visit_still_resolves() {
18869 let mut leaf = barrel_file(&[]);
18870 leaf.exports.insert("deep_symbol".to_string());
18871 let mut files: Vec<(String, DbFileIndex)> = Vec::new();
18872 files.push((
18875 "src/entry.ts".to_string(),
18876 barrel_file(&["src/chain0.ts", "src/shared.ts"]),
18877 ));
18878 for i in 0..15 {
18879 let next = if i == 14 {
18880 "src/shared.ts".to_string()
18881 } else {
18882 format!("src/chain{}.ts", i + 1)
18883 };
18884 files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
18885 }
18886 files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
18887 files.push(("src/leaf.ts".to_string(), leaf));
18888 let index = barrel_index(files);
18889
18890 assert_eq!(
18891 resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
18892 Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
18893 "a shallower re-visit must not be pruned by a deeper capped visit"
18894 );
18895 }
18896
18897 #[test]
18898 fn symbol_reachable_through_reexport_cycle_still_resolves() {
18899 let mut leaf = barrel_file(&[]);
18900 leaf.exports.insert("real_symbol".to_string());
18901 let index = barrel_index(vec![
18902 (
18903 "src/a.ts".to_string(),
18904 barrel_file(&["src/b.ts", "src/a.ts"]),
18905 ),
18906 (
18907 "src/b.ts".to_string(),
18908 barrel_file(&["src/a.ts", "src/leaf.ts"]),
18909 ),
18910 ("src/leaf.ts".to_string(), leaf),
18911 ]);
18912
18913 assert_eq!(
18914 resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
18915 Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
18916 );
18917 }
18918}
18919
18920#[cfg(test)]
18921mod method_dispatch_inference_tests {
18922 use super::*;
18923 use std::fs;
18924 use tempfile::tempdir;
18925
18926 #[test]
18927 fn java_field_receiver_type_selects_declared_class_method() {
18928 let source = r#"class EntryPoint {
18929 private UserService userService;
18930
18931 void handle() {
18932 userService.find();
18933 }
18934}
18935
18936class UserService {
18937 void find() {}
18938}
18939
18940class AuditService {
18941 void find() {}
18942}
18943"#;
18944 let dir = tempdir().expect("temp dir");
18945 let root = dir.path();
18946 write_fixture(root, "src/EntryPoint.java", source);
18947 let reference = reference(
18948 "java",
18949 "src/EntryPoint.java",
18950 "EntryPoint::handle",
18951 "userService",
18952 "find",
18953 line_of(source, "userService.find()"),
18954 );
18955 let mut cache = DispatchSourceCache::new();
18956
18957 let receiver_type =
18958 infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
18959 assert_eq!(receiver_type, "UserService");
18960
18961 let candidates = vec![
18962 method_candidate("audit", "AuditService::find"),
18963 method_candidate("user", "UserService::find"),
18964 ];
18965 let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
18966 .expect("type candidate");
18967 assert_eq!(selected.scoped_name, "UserService::find");
18968
18969 let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
18970 assert!(
18971 select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
18972 );
18973 }
18974
18975 #[test]
18976 fn kotlin_property_and_local_value_types_are_inferred() {
18977 let source = r#"class Handler {
18978 private val auditService: AuditService = AuditService()
18979
18980 fun handle() {
18981 auditService.find()
18982 val userService: UserService = UserService()
18983 userService.find()
18984 val billingService = BillingService()
18985 billingService.find()
18986 }
18987}
18988
18989class UserService { fun find() {} }
18990class AuditService { fun find() {} }
18991class BillingService { fun find() {} }
18992"#;
18993 let dir = tempdir().expect("temp dir");
18994 let root = dir.path();
18995 write_fixture(root, "src/Handler.kt", source);
18996 let mut cache = DispatchSourceCache::new();
18997
18998 let audit_ref = reference(
18999 "kotlin",
19000 "src/Handler.kt",
19001 "Handler::handle",
19002 "auditService",
19003 "find",
19004 line_of(source, "auditService.find()"),
19005 );
19006 assert_eq!(
19007 infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
19008 Some("AuditService")
19009 );
19010
19011 let user_ref = reference(
19012 "kotlin",
19013 "src/Handler.kt",
19014 "Handler::handle",
19015 "userService",
19016 "find",
19017 line_of(source, "userService.find()"),
19018 );
19019 assert_eq!(
19020 infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
19021 Some("UserService")
19022 );
19023
19024 let billing_ref = reference(
19025 "kotlin",
19026 "src/Handler.kt",
19027 "Handler::handle",
19028 "billingService",
19029 "find",
19030 line_of(source, "billingService.find()"),
19031 );
19032 assert_eq!(
19033 infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
19034 Some("BillingService")
19035 );
19036 }
19037
19038 #[test]
19039 fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
19040 let source = r#"struct Foo { void run(); };
19041struct PointerFoo { void run(); };
19042struct FactoryFoo { void run(); };
19043FactoryFoo makeFactoryFoo();
19044
19045void handle() {
19046 Foo foo;
19047 foo.run();
19048 PointerFoo* pointerFoo = nullptr;
19049 pointerFoo->run();
19050 auto factoryFoo = makeFactoryFoo();
19051 factoryFoo.run();
19052}
19053"#;
19054 let dir = tempdir().expect("temp dir");
19055 let root = dir.path();
19056 write_fixture(root, "src/fixture.cpp", source);
19057 let mut cache = DispatchSourceCache::new();
19058
19059 let foo_ref = reference(
19060 "cpp",
19061 "src/fixture.cpp",
19062 "handle",
19063 "foo",
19064 "run",
19065 line_of(source, "foo.run()"),
19066 );
19067 assert_eq!(
19068 infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
19069 Some("Foo")
19070 );
19071
19072 let pointer_ref = reference(
19073 "cpp",
19074 "src/fixture.cpp",
19075 "handle",
19076 "pointerFoo",
19077 "run",
19078 line_of(source, "pointerFoo->run()"),
19079 );
19080 assert_eq!(
19081 infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
19082 Some("PointerFoo")
19083 );
19084
19085 let factory_ref = reference(
19086 "cpp",
19087 "src/fixture.cpp",
19088 "handle",
19089 "factoryFoo",
19090 "run",
19091 line_of(source, "factoryFoo.run()"),
19092 );
19093 assert_eq!(
19094 infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
19095 Some("FactoryFoo")
19096 );
19097 }
19098
19099 #[test]
19100 fn rust_direct_self_field_name_trims_separator_whitespace() {
19101 for receiver_expression in ["self .engine", "self. engine", "self . engine"] {
19102 assert_eq!(
19103 rust_direct_self_field_name(receiver_expression),
19104 Some("engine")
19105 );
19106 }
19107 }
19108
19109 #[test]
19110 fn rust_direct_self_field_receiver_type_is_conservative() {
19111 let source = r#"struct Engine;
19112
19113struct Car {
19114 engine: Engine,
19115}
19116
19117impl Car {
19118 fn run(&self) {
19119 self.engine.start();
19120 }
19121}
19122
19123struct NestedCar {
19124 engine: Engine,
19125}
19126
19127impl NestedCar {
19128 fn run(&self) {
19129 self.inner.engine.start();
19130 }
19131}
19132
19133struct WrappedCar {
19134 engine: Option<Engine>,
19135}
19136
19137impl WrappedCar {
19138 fn run(&self) {
19139 self.engine.start(); // wrapped
19140 }
19141}
19142
19143struct GenericCar<T> {
19144 engine: T,
19145}
19146
19147impl<T> GenericCar<T> {
19148 fn run(&self) {
19149 self.engine.start(); // generic
19150 }
19151}
19152
19153type EngineAlias = Engine;
19154
19155struct AliasCar {
19156 engine: EngineAlias,
19157}
19158
19159impl AliasCar {
19160 fn run(&self) {
19161 self.engine.start(); // alias
19162 }
19163}
19164"#;
19165 let dir = tempdir().expect("temp dir");
19166 let root = dir.path();
19167 write_fixture(root, "src/lib.rs", source);
19168 let mut cache = DispatchSourceCache::new();
19169
19170 let mut direct = reference(
19171 "rust",
19172 "src/lib.rs",
19173 "Car::run",
19174 "engine",
19175 "start",
19176 line_of(source, "self.engine.start()"),
19177 );
19178 direct.receiver_expression = "self.engine".to_string();
19179 assert_eq!(
19180 infer_receiver_type(root, &direct, &mut cache).as_deref(),
19181 Some("Engine")
19182 );
19183
19184 let mut mismatched_impl_target = direct.clone();
19185 mismatched_impl_target.caller_symbol = "other::Car::run".to_string();
19186 assert!(infer_receiver_type(root, &mismatched_impl_target, &mut cache).is_none());
19187
19188 let mut nested = reference(
19189 "rust",
19190 "src/lib.rs",
19191 "NestedCar::run",
19192 "engine",
19193 "start",
19194 line_of(source, "self.inner.engine.start()"),
19195 );
19196 nested.receiver_expression = "self.inner.engine".to_string();
19197 assert!(infer_receiver_type(root, &nested, &mut cache).is_none());
19198
19199 let mut wrapped = reference(
19200 "rust",
19201 "src/lib.rs",
19202 "WrappedCar::run",
19203 "engine",
19204 "start",
19205 line_of(source, "self.engine.start(); // wrapped"),
19206 );
19207 wrapped.receiver_expression = "self.engine".to_string();
19208 assert!(infer_receiver_type(root, &wrapped, &mut cache).is_none());
19209
19210 let mut generic = reference(
19211 "rust",
19212 "src/lib.rs",
19213 "GenericCar::run",
19214 "engine",
19215 "start",
19216 line_of(source, "self.engine.start(); // generic"),
19217 );
19218 generic.receiver_expression = "self.engine".to_string();
19219 assert!(infer_receiver_type(root, &generic, &mut cache).is_none());
19220
19221 let mut alias = reference(
19222 "rust",
19223 "src/lib.rs",
19224 "AliasCar::run",
19225 "engine",
19226 "start",
19227 line_of(source, "self.engine.start(); // alias"),
19228 );
19229 alias.receiver_expression = "self.engine".to_string();
19230 assert!(infer_receiver_type(root, &alias, &mut cache).is_none());
19231 }
19232
19233 #[test]
19234 fn rust_direct_self_reference_field_receiver_is_not_inferred() {
19235 let source = r#"struct Engine;
19236
19237struct Car {
19238 engine: &'static Engine,
19239}
19240
19241impl Car {
19242 fn run(&self) {
19243 self.engine.start();
19244 }
19245}
19246"#;
19247 let dir = tempdir().expect("temp dir");
19248 let root = dir.path();
19249 write_fixture(root, "src/lib.rs", source);
19250 let mut cache = DispatchSourceCache::new();
19251 let mut reference = reference(
19252 "rust",
19253 "src/lib.rs",
19254 "Car::run",
19255 "engine",
19256 "start",
19257 line_of(source, "self.engine.start()"),
19258 );
19259 reference.receiver_expression = "self.engine".to_string();
19260
19261 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
19262 }
19263
19264 #[test]
19265 fn rust_trait_impl_self_field_receiver_is_not_inferred() {
19266 let source = r#"trait Drive {
19267 fn run(&self);
19268}
19269
19270struct Engine;
19271
19272struct Car {
19273 engine: Engine,
19274}
19275
19276impl Drive for Car {
19277 fn run(&self) {
19278 self.engine.start();
19279 }
19280}
19281"#;
19282 let dir = tempdir().expect("temp dir");
19283 let root = dir.path();
19284 write_fixture(root, "src/lib.rs", source);
19285 let mut cache = DispatchSourceCache::new();
19286 let mut reference = reference(
19287 "rust",
19288 "src/lib.rs",
19289 "Car::run",
19290 "engine",
19291 "start",
19292 line_of(source, "self.engine.start()"),
19293 );
19294 reference.receiver_expression = "self.engine".to_string();
19295
19296 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
19297 }
19298
19299 #[test]
19300 fn rust_self_field_does_not_bind_struct_from_another_module() {
19301 let source = r#"struct Engine;
19302
19303mod unrelated {
19304 struct Car {
19305 engine: Engine,
19306 }
19307}
19308
19309impl Car {
19310 fn run(&self) {
19311 self.engine.start();
19312 }
19313}
19314"#;
19315 let dir = tempdir().expect("temp dir");
19316 let root = dir.path();
19317 write_fixture(root, "src/lib.rs", source);
19318 let mut cache = DispatchSourceCache::new();
19319 let mut reference = reference(
19320 "rust",
19321 "src/lib.rs",
19322 "Car::run",
19323 "engine",
19324 "start",
19325 line_of(source, "self.engine.start()"),
19326 );
19327 reference.receiver_expression = "self.engine".to_string();
19328
19329 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
19330 }
19331
19332 #[test]
19333 fn unknown_java_receiver_still_uses_name_match_fallback() {
19334 let source = r#"class EntryPoint {
19335 void handle() {
19336 service.runSpecial();
19337 }
19338}
19339
19340class OnlyService {
19341 void runSpecial() {}
19342}
19343"#;
19344 let dir = tempdir().expect("temp dir");
19345 let root = dir.path();
19346 write_fixture(root, "src/EntryPoint.java", source);
19347 let reference = reference(
19348 "java",
19349 "src/EntryPoint.java",
19350 "EntryPoint::handle",
19351 "service",
19352 "runSpecial",
19353 line_of(source, "service.runSpecial()"),
19354 );
19355 let mut cache = DispatchSourceCache::new();
19356
19357 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
19358 let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
19359 let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
19360 assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
19361 }
19362
19363 fn reference(
19364 lang: &str,
19365 caller_file: &str,
19366 caller_symbol: &str,
19367 receiver: &str,
19368 method_name: &str,
19369 line: u32,
19370 ) -> NameMatchRef {
19371 NameMatchRef {
19372 ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
19373 caller_node: format!("{caller_symbol}:node"),
19374 caller_file: caller_file.to_string(),
19375 caller_symbol: caller_symbol.to_string(),
19376 caller_signature: None,
19377 receiver_expression: receiver.to_string(),
19378 receiver: receiver.to_string(),
19379 method_name: method_name.to_string(),
19380 colon_dispatch: false,
19381 line,
19382 lang: lang.to_string(),
19383 }
19384 }
19385
19386 fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
19387 NameMatchCandidate {
19388 node_id: node_id.to_string(),
19389 file_path: "src/targets.fixture".to_string(),
19390 scoped_name: scoped_name.to_string(),
19391 kind: "method".to_string(),
19392 start_line: 1,
19393 }
19394 }
19395
19396 fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
19397 let path = root.join(rel_path);
19398 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
19399 fs::write(path, source).expect("write fixture");
19400 }
19401
19402 fn line_of(source: &str, needle: &str) -> u32 {
19403 source
19404 .lines()
19405 .position(|line| line.contains(needle))
19406 .map(|index| index as u32 + 1)
19407 .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
19408 }
19409}
19410
19411#[cfg(test)]
19412mod bounded_build_breaker_tests {
19413 use super::*;
19414 use crate::build_breaker::{BreakerAdmission, BreakerKey, BuildDeathBreaker, BuildDomain};
19415 use tempfile::tempdir;
19416
19417 #[test]
19418 fn staged_inventory_drives_ordered_bounded_file_batches() {
19419 let temp = tempdir().unwrap();
19420 let root = temp.path().join("root");
19421 std::fs::create_dir_all(&root).unwrap();
19422 let first = root.join("a.ts");
19423 let second = root.join("b.ts");
19424 let third = root.join("c.ts");
19425 for path in [&first, &second, &third] {
19426 std::fs::write(path, "export function item() {}\n").unwrap();
19427 }
19428 let writer_lease = acquire_writer_lease(temp.path(), "inventory-key", &root)
19429 .unwrap()
19430 .expect("test root may write its private staging database");
19431 let store = CallGraphStore::open_at_path(
19432 root.clone(),
19433 "inventory-key".to_string(),
19434 temp.path().join("inventory.sqlite"),
19435 None,
19436 true,
19437 Some(writer_lease),
19438 None,
19439 )
19440 .unwrap()
19441 .store;
19442 let fingerprint = store
19443 .stage_cold_build_file_inventory(&[
19444 third.clone(),
19445 first.clone(),
19446 second.clone(),
19447 first.clone(),
19448 ])
19449 .unwrap();
19450
19451 let conn = store.conn.lock().unwrap();
19452 assert_eq!(
19453 query_count(&conn, "SELECT COUNT(*) FROM staging_file_inventory").unwrap(),
19454 3,
19455 "the primary key deduplicates caller-supplied paths on disk"
19456 );
19457 let first_batch = load_staged_file_batch(&conn, &root, "", 2, u64::MAX)
19458 .unwrap()
19459 .expect("first batch");
19460 assert_eq!(first_batch.paths, vec![first.clone(), second]);
19461 let second_batch =
19462 load_staged_file_batch(&conn, &root, &first_batch.last_path, 2, u64::MAX)
19463 .unwrap()
19464 .expect("second batch");
19465 assert_eq!(second_batch.paths, vec![third]);
19466 assert_eq!(
19467 fingerprint,
19468 callgraph_corpus_fingerprint(&root).unwrap(),
19469 "staged and direct streaming fingerprints agree without walk-order dependence"
19470 );
19471 }
19472
19473 #[test]
19474 fn resumed_stage_preserves_committed_batch_and_counter() {
19475 let temp = tempdir().unwrap();
19476 let root = temp.path().join("root");
19477 std::fs::create_dir_all(&root).unwrap();
19478 let first = root.join("first.ts");
19479 let second = root.join("second.ts");
19480 std::fs::write(&first, "export function first() {}\n").unwrap();
19481 std::fs::write(&second, "export function second() { first(); }\n").unwrap();
19482 let staging = temp.path().join("stage.sqlite");
19483 let writer_lease = acquire_writer_lease(temp.path(), "test-key", &root)
19484 .unwrap()
19485 .expect("test root may write its private staging database");
19486 let store = CallGraphStore::open_at_path(
19487 root.clone(),
19488 "test-key".to_string(),
19489 staging,
19490 None,
19491 true,
19492 Some(writer_lease),
19493 None,
19494 )
19495 .unwrap()
19496 .store;
19497 let corpus_fingerprint = store
19498 .stage_cold_build_file_inventory(&[first.clone(), second.clone()])
19499 .unwrap();
19500 let first_extract = build_file_extract(&root, &first).unwrap();
19501 let first_bytes = first_extract.freshness.size;
19502 {
19503 let mut conn = store.conn.lock().unwrap();
19504 let tx = conn.transaction().unwrap();
19505 clear_tables(&tx).unwrap();
19506 insert_meta(&tx).unwrap();
19507 drop_cold_build_secondary_indexes(&tx).unwrap();
19508 set_meta_ready(&tx, false).unwrap();
19509 set_staged_build_phase(&tx, "extracting").unwrap();
19510 set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, &corpus_fingerprint).unwrap();
19511 set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0).unwrap();
19512 {
19513 let mut inserts = ColdBuildInsertStatements::new(&tx).unwrap();
19514 insert_file_extract_prepared(
19515 &mut inserts,
19516 &root.display().to_string(),
19517 &first_extract,
19518 )
19519 .unwrap();
19520 for raw in &first_extract.raw_refs {
19521 insert_staged_ref_prepared(&mut inserts, raw).unwrap();
19522 }
19523 }
19524 increment_staged_extracted_bytes(&tx, first_bytes).unwrap();
19525 tx.commit().unwrap();
19526 }
19527
19528 store
19529 .cold_build_chunked(&[first.clone(), second.clone()], 1)
19530 .unwrap();
19531 let conn = store.conn.lock().unwrap();
19532 assert_eq!(query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(), 2);
19533 assert_eq!(
19534 staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
19535 first_bytes + std::fs::metadata(second).unwrap().len(),
19536 "the already committed batch and its credit survive adoption; only the new batch increments credit"
19537 );
19538 assert_eq!(staged_build_phase(&conn).unwrap().as_deref(), Some("ready"));
19539 }
19540
19541 const SPECIMEN_CHILD_TEST: &str =
19542 "callgraph_store::bounded_build_breaker_tests::respawn_loop_build_child";
19543 const SPECIMEN_CHILD_ROOT: &str = "AFT_SPECIMEN_CHILD_ROOT";
19544 const SPECIMEN_CHILD_STORE: &str = "AFT_SPECIMEN_CHILD_STORE";
19545 const SPECIMEN_CHILD_PHASE: &str = "AFT_SPECIMEN_CHILD_PHASE";
19546 const SPECIMEN_CHILD_SIGNAL: &str = "AFT_SPECIMEN_CHILD_SIGNAL";
19547
19548 fn wait_for_child_barrier(path: &Path) {
19549 let deadline = Instant::now() + Duration::from_secs(10);
19550 while !path.exists() {
19551 assert!(
19552 Instant::now() < deadline,
19553 "callgraph child did not reach barrier {}",
19554 path.display()
19555 );
19556 std::thread::sleep(Duration::from_millis(5));
19557 }
19558 }
19559
19560 fn spawn_build_child(root: &Path, store: &Path, phase: Option<&str>) -> std::process::Child {
19561 let signal = store.join("specimen-child.reached");
19562 let _ = std::fs::remove_file(&signal);
19563 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
19564 command
19565 .arg("--exact")
19566 .arg(SPECIMEN_CHILD_TEST)
19567 .arg("--nocapture")
19568 .arg("--test-threads=1")
19569 .env(SPECIMEN_CHILD_ROOT, root)
19570 .env(SPECIMEN_CHILD_STORE, store)
19571 .env(SPECIMEN_CHILD_SIGNAL, &signal)
19572 .stdout(std::process::Stdio::null())
19573 .stderr(std::process::Stdio::null());
19574 if let Some(phase) = phase {
19575 command.env(SPECIMEN_CHILD_PHASE, phase);
19576 }
19577 command.spawn().unwrap()
19578 }
19579
19580 fn staging_path(root: &Path, store: &Path) -> PathBuf {
19581 let project_key = crate::search_index::artifact_cache_key(root);
19582 store.join(format!("{project_key}.staging.sqlite.tmp.resume"))
19583 }
19584
19585 fn durable_staging_state(path: &Path) -> (u64, u64) {
19586 if !path.exists() {
19587 return (0, 0);
19588 }
19589 let conn = Connection::open(path).unwrap();
19590 (
19591 query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(),
19592 staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
19593 )
19594 }
19595
19596 fn kill_barrier_child(child: &mut std::process::Child, signal: &Path) {
19597 wait_for_child_barrier(signal);
19598 child.kill().unwrap();
19599 let _ = child.wait().unwrap();
19600 }
19601
19602 #[test]
19603 fn respawn_loop_build_child() {
19604 let Some(root) = std::env::var_os(SPECIMEN_CHILD_ROOT) else {
19605 return;
19606 };
19607 let root = PathBuf::from(root);
19608 let store = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_STORE).unwrap());
19609 if let Some(phase) = std::env::var_os(SPECIMEN_CHILD_PHASE) {
19610 let phase = phase.to_string_lossy().into_owned();
19611 let signal = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_SIGNAL).unwrap());
19612 set_cold_build_phase_observer(Some(Arc::new(move |observed| {
19613 if observed == phase {
19614 std::fs::write(&signal, observed.as_bytes()).unwrap();
19615 std::thread::sleep(Duration::from_secs(30));
19616 }
19617 })));
19618 }
19619 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
19620 CallGraphStore::cold_build_with_lease_chunked(store, root, &files, 1).unwrap();
19621 }
19622
19623 #[test]
19624 fn issue_250_respawn_loop_converges_or_trips_without_false_readiness() {
19625 let temp = tempdir().unwrap();
19626 let root = temp.path().join("resumable-root");
19627 let store = temp.path().join("resumable-store");
19628 std::fs::create_dir_all(&root).unwrap();
19629 std::fs::create_dir_all(&store).unwrap();
19630 for index in 0..3 {
19631 std::fs::write(
19632 root.join(format!("file-{index}.ts")),
19633 format!("export function specimen{index}() {{ return {index}; }}\n"),
19634 )
19635 .unwrap();
19636 }
19637 let stage = staging_path(&root, &store);
19638 let signal = store.join("specimen-child.reached");
19639
19640 let mut first = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
19641 kill_barrier_child(&mut first, &signal);
19642 let (first_rows, first_bytes) = durable_staging_state(&stage);
19643 assert_eq!(first_rows, 1);
19644 assert!(first_bytes > 0);
19645
19646 let mut second = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
19647 kill_barrier_child(&mut second, &signal);
19648 let (second_rows, second_bytes) = durable_staging_state(&stage);
19649 assert_eq!(second_rows, 2);
19650 assert!(
19651 second_bytes > first_bytes,
19652 "a replacement process must adopt committed bytes instead of restarting from zero"
19653 );
19654
19655 let status = spawn_build_child(&root, &store, None).wait().unwrap();
19656 assert!(status.success(), "uninterrupted replacement build failed");
19657 assert!(!stage.exists(), "published staging file must be renamed");
19658 let ready = CallGraphStore::open_readonly(store.clone(), root.clone())
19659 .unwrap()
19660 .expect("replacement attempts must converge to a published graph");
19661 assert_eq!(ready.indexed_file_count().unwrap(), 3);
19662
19663 let fast_root = temp.path().join("zero-credit-root");
19664 let fast_store = temp.path().join("zero-credit-store");
19665 std::fs::create_dir_all(&fast_root).unwrap();
19666 std::fs::create_dir_all(&fast_store).unwrap();
19667 std::fs::write(
19668 fast_root.join("main.ts"),
19669 "export function neverCommitted() {}\n",
19670 )
19671 .unwrap();
19672 let fast_stage = staging_path(&fast_root, &fast_store);
19673 let fast_signal = fast_store.join("specimen-child.reached");
19674 let breaker_path = fast_store.join("build-breaker.sqlite");
19675 let now = unix_millis_now();
19676
19677 for death in 0..3 {
19678 let mut child = spawn_build_child(&fast_root, &fast_store, Some("enumeration"));
19679 wait_for_child_barrier(&fast_signal);
19680 let attempt_id = Connection::open(&breaker_path)
19681 .unwrap()
19682 .query_row(
19683 "SELECT attempt_id FROM breaker_attempts
19684 WHERE death_charged = 0 ORDER BY rowid DESC LIMIT 1",
19685 [],
19686 |row| row.get::<_, String>(0),
19687 )
19688 .unwrap();
19689 let (_, committed_bytes) = durable_staging_state(&fast_stage);
19690 assert_eq!(
19691 committed_bytes, 0,
19692 "the fast-kill schedule must not cross an extraction commit"
19693 );
19694 child.kill().unwrap();
19695 let _ = child.wait().unwrap();
19696
19697 let key = BreakerKey::new(
19698 fast_root.display().to_string(),
19699 BuildDomain::CallgraphCold,
19700 callgraph_corpus_fingerprint(&fast_root).unwrap(),
19701 );
19702 BuildDeathBreaker::open(&breaker_path)
19703 .unwrap()
19704 .record_attributed_death_at(&key, &attempt_id, committed_bytes, 0, now + death)
19705 .unwrap();
19706 }
19707
19708 let files = crate::callgraph::walk_project_files(&fast_root).collect::<Vec<_>>();
19709 let suspension = CallGraphStore::cold_build_suspension(&fast_store, &fast_root)
19710 .unwrap()
19711 .expect("three zero-credit process deaths must suspend the root");
19712 assert_eq!(suspension.reason, "zero_credit_death_limit");
19713 assert_eq!(suspension.death_count, 3);
19714 let response = crate::commands::callgraph_store_adapter::suspended_response(
19715 "specimen",
19716 "callers",
19717 &suspension,
19718 );
19719 assert_eq!(response.data["code"], serde_json::json!("build_suspended"));
19720 let message = response.data["message"].as_str().unwrap();
19721 assert!(
19722 message.starts_with("callers: build_suspended domain=callgraph_cold deaths=3 age_ms=")
19723 );
19724 assert!(message.ends_with(
19725 " reason=zero_credit_death_limit; run doctor reset-build-breaker to resume"
19726 ));
19727 let refused =
19728 CallGraphStore::cold_build_with_lease_chunked(fast_store, fast_root, &files, 1)
19729 .expect_err("a suspended root must not report a perpetually building worker");
19730 assert!(matches!(refused, CallGraphStoreError::Suspended(_)));
19731 }
19732
19733 #[test]
19734 fn published_callgraph_build_respects_durable_domain_suspension() {
19735 let temp = tempdir().unwrap();
19736 let root = temp.path().join("root");
19737 let store_dir = temp.path().join("store");
19738 std::fs::create_dir_all(&root).unwrap();
19739 let source = root.join("main.ts");
19740 std::fs::write(&source, "export function marker() {}\n").unwrap();
19741 let files = vec![source];
19742 let key = BreakerKey::new(
19743 root.display().to_string(),
19744 BuildDomain::CallgraphCold,
19745 callgraph_corpus_fingerprint(&root).unwrap(),
19746 );
19747 let breaker = BuildDeathBreaker::open(store_dir.join("build-breaker.sqlite")).unwrap();
19748 for _ in 0..3 {
19749 let BreakerAdmission::Admitted(attempt) = breaker.admit(&key, 0).unwrap() else {
19750 panic!("unexpected early suspension");
19751 };
19752 breaker
19753 .record_attributed_death(&key, &attempt.attempt_id, 0, 0)
19754 .unwrap();
19755 }
19756
19757 let error = CallGraphStore::cold_build_with_lease_chunked(store_dir, root, &files, 1)
19758 .expect_err("durably tripped callgraph domain must refuse a new cold build");
19759 assert!(matches!(
19760 error,
19761 CallGraphStoreError::Suspended(ref suspension)
19762 if suspension.domain == BuildDomain::CallgraphCold
19763 && suspension.death_count == 3
19764 ));
19765 }
19766}