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;
780pub(crate) use dead_code_projection::{
781 project_dead_code_snapshot_from_view, project_dead_code_snapshot_incremental_with_costs,
782 project_dead_code_snapshot_with_revision, ProjectionCostEstimates, ProjectionKind,
783 ProjectionVerdict, MAX_DELTA_BYTES,
784};
785#[cfg(test)]
786pub(crate) use dead_code_projection::{
787 project_dead_code_snapshot_incremental, set_projection_before_open_observer,
788 take_projection_work,
789};
790
791#[doc(hidden)]
792pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
793 COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
794}
795
796#[cfg(test)]
797fn set_cold_build_before_publish_observer(observer: Option<Arc<ColdBuildBeforePublishObserver>>) {
798 COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
799}
800
801#[cfg(test)]
802fn notify_cold_build_before_publish_observer() {
803 let observer = COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| slot.borrow().clone());
804 if let Some(observer) = observer {
805 observer();
806 }
807}
808
809#[cfg(not(test))]
810fn notify_cold_build_before_publish_observer() {}
811
812#[cfg(test)]
813fn set_cold_build_slice_observer(observer: Option<Arc<ColdBuildSliceObserver>>) {
814 COLD_BUILD_SLICE_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
815}
816
817#[cfg(test)]
818fn notify_cold_build_slice_observer(stage: &'static str, completed: usize, total: usize) {
819 let observer = COLD_BUILD_SLICE_OBSERVER.with(|slot| slot.borrow().clone());
820 if let Some(observer) = observer {
821 observer(stage, completed, total);
822 }
823}
824
825#[cfg(not(test))]
826fn notify_cold_build_slice_observer(_stage: &'static str, _completed: usize, _total: usize) {}
827
828#[cfg(test)]
829fn set_cold_build_extract_observer(observer: Option<Arc<ColdBuildExtractObserver>>) {
830 COLD_BUILD_EXTRACT_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
831}
832
833#[cfg(test)]
834fn notify_cold_build_extract_observer(paths: &[PathBuf]) {
835 let observer = COLD_BUILD_EXTRACT_OBSERVER.with(|slot| slot.borrow().clone());
836 if let Some(observer) = observer {
837 observer(paths);
838 }
839}
840
841#[cfg(not(test))]
842fn notify_cold_build_extract_observer(_paths: &[PathBuf]) {}
843
844#[doc(hidden)]
845pub fn set_legacy_migration_available_disk_for_test(bytes: Option<u64>) {
846 MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow_mut() = bytes);
847}
848
849#[doc(hidden)]
850pub fn set_legacy_migration_fail_after_temp_copy_for_test(enabled: bool) {
851 MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.set(enabled));
852}
853
854#[doc(hidden)]
855pub fn set_legacy_migration_backup_budget_exhausted_for_test(enabled: bool) {
856 MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.set(enabled));
857}
858
859struct PublishAdmissionGuard {
860 previous: Option<(crate::root_cache::ArtifactPublishEpoch, u64)>,
861}
862
863impl Drop for PublishAdmissionGuard {
864 fn drop(&mut self) {
865 PUBLISH_ADMISSION.with(|slot| {
866 *slot.borrow_mut() = self.previous.take();
867 });
868 }
869}
870
871pub(crate) fn with_publish_epoch<R>(
872 epoch: crate::root_cache::ArtifactPublishEpoch,
873 expected: u64,
874 run: impl FnOnce() -> R,
875) -> R {
876 let previous = PUBLISH_ADMISSION.with(|slot| slot.replace(Some((epoch, expected))));
877 let _guard = PublishAdmissionGuard { previous };
878 run()
879}
880
881fn ensure_cold_build_current(stage: &'static str, completed: usize, total: usize) -> Result<()> {
882 notify_cold_build_slice_observer(stage, completed, total);
883 let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
884 if admission.is_none_or(|(epoch, expected)| epoch.is_current(expected)) {
885 if let Some(scope) = crate::logging::current_index_build() {
886 crate::logging::log_index_event(
887 crate::logging::IndexEvent::from_scope(
888 crate::logging::IndexEventKind::BuildProgress,
889 &scope,
890 )
891 .field("stage", stage)
892 .field("completed", completed)
893 .field("total", total)
894 .field("elapsed_ms", scope.elapsed_ms()),
895 );
896 }
897 return Ok(());
898 }
899 crate::slog_info!(
900 "callgraph cold build superseded, stopping after {}/{} ({})",
901 completed,
902 total,
903 stage
904 );
905 if let Some(scope) = crate::logging::current_index_build() {
906 crate::logging::log_index_event(
907 crate::logging::IndexEvent::from_scope(
908 crate::logging::IndexEventKind::BuildSuperseded,
909 &scope,
910 )
911 .field("stage", stage)
912 .field("completed", completed)
913 .field("total", total),
914 );
915 }
916 Err(CallGraphStoreError::Superseded)
917}
918
919fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
920 let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
921 match admission {
922 Some((epoch, expected)) => epoch
923 .run_if_current(expected, publish)
924 .unwrap_or(Err(CallGraphStoreError::Superseded)),
925 None => publish(),
926 }
927}
928
929struct RefreshCommitAdmissionGuard {
930 previous: Option<(
931 SubcLifecycleAdmission,
932 Arc<std::sync::atomic::AtomicU64>,
933 u64,
934 )>,
935}
936
937impl Drop for RefreshCommitAdmissionGuard {
938 fn drop(&mut self) {
939 REFRESH_COMMIT_ADMISSION.with(|slot| {
940 *slot.borrow_mut() = self.previous.take();
941 });
942 }
943}
944
945fn with_refresh_commit_admission<R>(
946 lifecycle: SubcLifecycleAdmission,
947 generation_flag: Arc<std::sync::atomic::AtomicU64>,
948 expected_generation: u64,
949 run: impl FnOnce() -> R,
950) -> R {
951 let previous = REFRESH_COMMIT_ADMISSION
952 .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
953 let _guard = RefreshCommitAdmissionGuard { previous };
954 run()
955}
956
957fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
958 let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
959 let commit = || {
960 publish_if_current(|| {
961 tx.commit()?;
962 Ok(())
963 })
964 };
965 match admission {
966 Some((lifecycle, generation_flag, expected_generation)) => lifecycle
967 .run_if_current(generation_flag.as_ref(), expected_generation, commit)
968 .unwrap_or(Err(CallGraphStoreError::Superseded)),
969 None => commit(),
970 }
971}
972
973fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
974 let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
975 if let Some(observer) = observer {
976 observer(temp_path, target_path);
977 }
978}
979
980#[derive(Debug)]
981pub enum CallGraphStoreError {
982 Io(std::io::Error),
983 Sqlite(rusqlite::Error),
984 Json(serde_json::Error),
985 Aft(AftError),
986 Lock(crate::fs_lock::AcquireError),
987 MissingCallerData {
988 file: String,
989 },
990 Unavailable(String),
991 PathIdentityMismatch {
992 path: PathBuf,
993 project_root: PathBuf,
994 },
995 Suspended(crate::build_breaker::BuildSuspension),
996 Superseded,
997 StaleFiles(Vec<String>),
998}
999
1000impl CallGraphStoreError {
1001 pub(crate) fn is_transient_lock_contention(&self) -> bool {
1002 matches!(
1003 self,
1004 Self::Sqlite(rusqlite::Error::SqliteFailure(error, _))
1005 if matches!(
1006 error.code,
1007 rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
1008 )
1009 )
1010 }
1011}
1012
1013impl fmt::Display for CallGraphStoreError {
1014 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1015 match self {
1016 Self::Io(error) => write!(formatter, "I/O error: {error}"),
1017 Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
1018 Self::Json(error) => write!(formatter, "json error: {error}"),
1019 Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
1020 Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
1021 Self::MissingCallerData { file } => {
1022 write!(formatter, "missing extracted caller data for {file}")
1023 }
1024 Self::Unavailable(message) => {
1025 write!(formatter, "callgraph store unavailable: {message}")
1026 }
1027 Self::PathIdentityMismatch { path, project_root } => write!(
1028 formatter,
1029 "callgraph path identity mismatch: {} is not under project root {}",
1030 path.display(),
1031 project_root.display()
1032 ),
1033 Self::Suspended(suspension) => write!(
1034 formatter,
1035 "callgraph build suspended for {} after {} deaths ({})",
1036 suspension.domain.as_str(),
1037 suspension.death_count,
1038 suspension.reason
1039 ),
1040 Self::Superseded => {
1041 write!(formatter, "callgraph store build superseded before publish")
1042 }
1043 Self::StaleFiles(files) => {
1044 write!(
1045 formatter,
1046 "callgraph store has stale files: {}",
1047 files.join(", ")
1048 )
1049 }
1050 }
1051 }
1052}
1053
1054impl std::error::Error for CallGraphStoreError {}
1055
1056impl From<std::io::Error> for CallGraphStoreError {
1057 fn from(error: std::io::Error) -> Self {
1058 Self::Io(error)
1059 }
1060}
1061
1062impl From<rusqlite::Error> for CallGraphStoreError {
1063 fn from(error: rusqlite::Error) -> Self {
1064 Self::Sqlite(error)
1065 }
1066}
1067
1068impl From<serde_json::Error> for CallGraphStoreError {
1069 fn from(error: serde_json::Error) -> Self {
1070 Self::Json(error)
1071 }
1072}
1073
1074impl From<AftError> for CallGraphStoreError {
1075 fn from(error: AftError) -> Self {
1076 Self::Aft(error)
1077 }
1078}
1079
1080impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
1081 fn from(error: crate::fs_lock::AcquireError) -> Self {
1082 Self::Lock(error)
1083 }
1084}
1085
1086pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
1087
1088pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
1092
1093#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1094pub struct CallGraphStoreOptions {
1095 pub enabled: bool,
1096}
1097
1098pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
1099
1100#[derive(Clone)]
1104pub(crate) struct CallgraphRefreshState {
1105 store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
1106 heavy_root_work_allowed: Arc<AtomicBool>,
1107}
1108
1109impl CallgraphRefreshState {
1110 pub(crate) fn new(
1111 store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
1112 heavy_root_work_allowed: Arc<AtomicBool>,
1113 ) -> Self {
1114 Self {
1115 store,
1116 heavy_root_work_allowed,
1117 }
1118 }
1119
1120 fn installed_store_snapshot(&self) -> Option<Arc<ReadonlyCallGraphStore>> {
1121 self.store
1122 .read()
1123 .unwrap_or_else(std::sync::PoisonError::into_inner)
1124 .as_ref()
1125 .map(Arc::clone)
1126 }
1127}
1128
1129type WorkspaceCratePrefixes = HashMap<String, String>;
1130
1131#[derive(Clone, Debug, Default)]
1132struct WorkspaceCratePrefixCache(Arc<OnceLock<WorkspaceCratePrefixes>>);
1133
1134const REFRESH_WORKSPACE_CACHE_ROOT_CAP: usize = 128;
1135
1136pub(crate) fn invalidates_workspace_crate_prefix_cache(path: &Path) -> bool {
1137 path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")
1138}
1139
1140pub(crate) fn invalidates_workspace_package_cache(path: &Path) -> bool {
1145 matches!(
1146 path.file_name().and_then(|name| name.to_str()),
1147 Some("package.json") | Some("pnpm-workspace.yaml")
1148 )
1149}
1150
1151#[derive(Clone, Debug, Hash, PartialEq, Eq)]
1152struct RefreshRoot {
1153 callgraph_dir: PathBuf,
1154 project_root: PathBuf,
1155}
1156
1157#[derive(Clone)]
1158pub(crate) struct CallgraphRefreshTicket {
1159 lifecycle: SubcLifecycleAdmission,
1160 generation_flag: Arc<std::sync::atomic::AtomicU64>,
1161 expected_generation: u64,
1162 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
1163 expected_publish_epoch: u64,
1164}
1165
1166impl CallgraphRefreshTicket {
1167 pub(crate) fn new(
1168 lifecycle: SubcLifecycleAdmission,
1169 generation_flag: Arc<std::sync::atomic::AtomicU64>,
1170 expected_generation: u64,
1171 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
1172 expected_publish_epoch: u64,
1173 ) -> Self {
1174 Self {
1175 lifecycle,
1176 generation_flag,
1177 expected_generation,
1178 publish_epoch,
1179 expected_publish_epoch,
1180 }
1181 }
1182
1183 fn is_current(&self) -> bool {
1184 self.lifecycle
1185 .is_current(self.generation_flag.as_ref(), self.expected_generation)
1186 && self.publish_epoch.current() == self.expected_publish_epoch
1187 }
1188}
1189
1190#[derive(Clone)]
1191struct RefreshBatch {
1192 root: RefreshRoot,
1193 paths: BTreeSet<PathBuf>,
1194 pending_sinks: Vec<PendingCallGraphStorePaths>,
1195 refresh_states: Vec<CallgraphRefreshState>,
1196 ticket: Option<CallgraphRefreshTicket>,
1197}
1198
1199impl RefreshBatch {
1200 fn defer(&self) {
1201 for sink in &self.pending_sinks {
1202 sink.lock().extend(self.paths.iter().cloned());
1203 }
1204 }
1205
1206 fn defer_after_open_failure(&self) {
1207 self.defer();
1208 if self
1209 .ticket
1210 .as_ref()
1211 .is_some_and(|ticket| !ticket.is_current())
1212 || !self
1213 .refresh_states
1214 .iter()
1215 .any(|state| state.heavy_root_work_allowed.load(AtomicOrdering::SeqCst))
1216 {
1217 return;
1218 }
1219
1220 let ready_store_installed = self.refresh_states.iter().any(|state| {
1221 let store = state.installed_store_snapshot();
1222 store.is_some_and(|store| {
1223 store.project_root() == self.root.project_root
1224 && !store.is_legacy_fallback()
1225 && store.is_current()
1226 })
1227 });
1228 if !ready_store_installed {
1229 return;
1230 }
1231
1232 for sink in &self.pending_sinks {
1236 let paths = {
1237 let mut pending = sink.lock();
1238 self.paths
1239 .iter()
1240 .filter(|path| pending.remove(*path))
1241 .cloned()
1242 .collect::<Vec<_>>()
1243 };
1244 if paths.is_empty() {
1245 continue;
1246 }
1247 let _ = enqueue_callgraph_store_refresh_inner(
1248 self.root.callgraph_dir.clone(),
1249 self.root.project_root.clone(),
1250 paths,
1251 Arc::clone(sink),
1252 self.refresh_states.clone(),
1253 self.ticket.clone(),
1254 );
1255 }
1256 }
1257
1258 fn merge(
1259 &mut self,
1260 paths: impl IntoIterator<Item = PathBuf>,
1261 sink: PendingCallGraphStorePaths,
1262 refresh_states: Vec<CallgraphRefreshState>,
1263 ticket: Option<CallgraphRefreshTicket>,
1264 ) {
1265 self.paths.extend(paths);
1266 if ticket.is_some() {
1267 self.ticket = ticket;
1268 }
1269 if !self
1270 .pending_sinks
1271 .iter()
1272 .any(|existing| Arc::ptr_eq(existing, &sink))
1273 {
1274 self.pending_sinks.push(sink);
1275 }
1276 for refresh_state in refresh_states {
1277 if !self.refresh_states.iter().any(|existing| {
1278 Arc::ptr_eq(&existing.store, &refresh_state.store)
1279 && Arc::ptr_eq(
1280 &existing.heavy_root_work_allowed,
1281 &refresh_state.heavy_root_work_allowed,
1282 )
1283 }) {
1284 self.refresh_states.push(refresh_state);
1285 }
1286 }
1287 }
1288}
1289
1290#[derive(Default)]
1291struct RefreshQueue {
1292 order: VecDeque<RefreshRoot>,
1293 queued: HashMap<RefreshRoot, RefreshBatch>,
1294 active: Option<RefreshBatch>,
1295 shutdown_requested: bool,
1296}
1297
1298struct RefreshWorkerShared {
1299 queue: Mutex<RefreshQueue>,
1300 wake: Condvar,
1301}
1302
1303struct RefreshWorker {
1304 shared: Arc<RefreshWorkerShared>,
1305 thread: Mutex<Option<JoinHandle<()>>>,
1306}
1307
1308struct RefreshWorkerWatchdog {
1309 first_path: PathBuf,
1310 batch_len: usize,
1311 started: Instant,
1312}
1313
1314impl RefreshWorkerWatchdog {
1315 fn start(paths: &[PathBuf]) -> Self {
1316 Self {
1317 first_path: paths
1318 .first()
1319 .expect("non-empty callgraph refresh batch has a first path")
1320 .clone(),
1321 batch_len: paths.len(),
1322 started: Instant::now(),
1323 }
1324 }
1325}
1326
1327impl Drop for RefreshWorkerWatchdog {
1328 fn drop(&mut self) {
1329 let elapsed = self.started.elapsed();
1330 if elapsed < REFRESH_WORKER_WARN_AFTER {
1331 return;
1332 }
1333 let path = if self.batch_len == 1 {
1334 self.first_path.display().to_string()
1335 } else {
1336 format!(
1337 "{} (+{} paths)",
1338 self.first_path.display(),
1339 self.batch_len - 1
1340 )
1341 };
1342 log::warn!(
1343 "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
1344 path,
1345 elapsed.as_millis()
1346 );
1347 if elapsed >= REFRESH_WORKER_FINAL_AFTER {
1348 log::warn!(
1349 "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
1350 path,
1351 elapsed.as_millis()
1352 );
1353 }
1354 }
1355}
1356
1357impl RefreshWorker {
1358 fn spawn() -> Arc<Self> {
1359 let shared = Arc::new(RefreshWorkerShared {
1360 queue: Mutex::new(RefreshQueue::default()),
1361 wake: Condvar::new(),
1362 });
1363 let thread_shared = Arc::clone(&shared);
1364 let thread = std::thread::Builder::new()
1365 .name("aft-callgraph-refresh".to_string())
1366 .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
1367 .expect("failed to spawn callgraph refresh worker");
1368 Arc::new(Self {
1369 shared,
1370 thread: Mutex::new(Some(thread)),
1371 })
1372 }
1373
1374 fn enqueue(
1375 &self,
1376 root: RefreshRoot,
1377 paths: Vec<PathBuf>,
1378 pending_sink: PendingCallGraphStorePaths,
1379 refresh_states: Vec<CallgraphRefreshState>,
1380 ticket: Option<CallgraphRefreshTicket>,
1381 ) -> bool {
1382 let mut queue = self
1383 .shared
1384 .queue
1385 .lock()
1386 .expect("callgraph refresh queue mutex poisoned");
1387 if queue.shutdown_requested {
1388 pending_sink.lock().extend(paths);
1389 return false;
1390 }
1391 if let Some(batch) = queue.queued.get_mut(&root) {
1392 batch.merge(paths, pending_sink, refresh_states, ticket);
1393 } else {
1394 queue.order.push_back(root.clone());
1395 queue.queued.insert(
1396 root.clone(),
1397 RefreshBatch {
1398 root,
1399 paths: paths.into_iter().collect(),
1400 pending_sinks: vec![pending_sink],
1401 refresh_states,
1402 ticket,
1403 },
1404 );
1405 }
1406 self.shared.wake.notify_one();
1407 true
1408 }
1409
1410 fn shutdown_with_budget(&self, budget: Duration) -> bool {
1411 let deadline = Instant::now() + budget;
1412 let mut queue = self
1413 .shared
1414 .queue
1415 .lock()
1416 .expect("callgraph refresh queue mutex poisoned");
1417 queue.shutdown_requested = true;
1418 self.shared.wake.notify_one();
1419 while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
1420 let remaining = deadline.saturating_duration_since(Instant::now());
1421 let (next, _) = self
1422 .shared
1423 .wake
1424 .wait_timeout(queue, remaining)
1425 .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
1426 queue = next;
1427 }
1428 let drained = queue.active.is_none() && queue.order.is_empty();
1429 if !drained {
1430 if let Some(active) = queue.active.as_ref() {
1431 active.defer();
1432 }
1433 for batch in queue.queued.values() {
1434 batch.defer();
1435 }
1436 queue.order.clear();
1437 queue.queued.clear();
1438 }
1439 drop(queue);
1440
1441 if drained {
1442 if let Some(thread) = self
1443 .thread
1444 .lock()
1445 .expect("callgraph refresh worker thread mutex poisoned")
1446 .take()
1447 {
1448 let _ = thread.join();
1449 }
1450 }
1451 drained
1452 }
1453}
1454
1455static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
1456
1457pub fn enqueue_callgraph_store_refresh(
1458 callgraph_dir: PathBuf,
1459 project_root: PathBuf,
1460 paths: Vec<PathBuf>,
1461 pending_sink: PendingCallGraphStorePaths,
1462) -> bool {
1463 enqueue_callgraph_store_refresh_inner(
1464 callgraph_dir,
1465 project_root,
1466 paths,
1467 pending_sink,
1468 Vec::new(),
1469 None,
1470 )
1471}
1472
1473#[cfg(test)]
1474pub(crate) fn enqueue_callgraph_store_refresh_fenced(
1475 callgraph_dir: PathBuf,
1476 project_root: PathBuf,
1477 paths: Vec<PathBuf>,
1478 pending_sink: PendingCallGraphStorePaths,
1479 ticket: CallgraphRefreshTicket,
1480) -> bool {
1481 enqueue_callgraph_store_refresh_inner(
1482 callgraph_dir,
1483 project_root,
1484 paths,
1485 pending_sink,
1486 Vec::new(),
1487 Some(ticket),
1488 )
1489}
1490
1491pub(crate) fn enqueue_callgraph_store_refresh_fenced_with_state(
1492 callgraph_dir: PathBuf,
1493 project_root: PathBuf,
1494 paths: Vec<PathBuf>,
1495 pending_sink: PendingCallGraphStorePaths,
1496 refresh_state: CallgraphRefreshState,
1497 ticket: CallgraphRefreshTicket,
1498) -> bool {
1499 enqueue_callgraph_store_refresh_inner(
1500 callgraph_dir,
1501 project_root,
1502 paths,
1503 pending_sink,
1504 vec![refresh_state],
1505 Some(ticket),
1506 )
1507}
1508
1509fn enqueue_callgraph_store_refresh_inner(
1510 callgraph_dir: PathBuf,
1511 project_root: PathBuf,
1512 paths: Vec<PathBuf>,
1513 pending_sink: PendingCallGraphStorePaths,
1514 refresh_states: Vec<CallgraphRefreshState>,
1515 ticket: Option<CallgraphRefreshTicket>,
1516) -> bool {
1517 if paths.is_empty() {
1518 return true;
1519 }
1520 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1521 let worker = {
1522 let mut worker = slot
1523 .lock()
1524 .expect("callgraph refresh worker mutex poisoned");
1525 Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
1526 };
1527 worker.enqueue(
1528 RefreshRoot {
1529 callgraph_dir,
1530 project_root,
1531 },
1532 paths,
1533 pending_sink,
1534 refresh_states,
1535 ticket,
1536 )
1537}
1538
1539pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
1540 flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
1541}
1542
1543#[doc(hidden)]
1544pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
1545 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1546 let worker = slot
1547 .lock()
1548 .expect("callgraph refresh worker mutex poisoned")
1549 .clone();
1550 let Some(worker) = worker else {
1551 return true;
1552 };
1553 let drained = worker.shutdown_with_budget(budget);
1554 if drained {
1555 let mut current = slot
1556 .lock()
1557 .expect("callgraph refresh worker mutex poisoned");
1558 if current
1559 .as_ref()
1560 .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
1561 {
1562 *current = None;
1563 }
1564 }
1565 drained
1566}
1567
1568fn idle_checkpoint_due(last: Option<Instant>, now: Instant) -> bool {
1569 last.is_none_or(|last| now.saturating_duration_since(last) >= REFRESH_IDLE_CHECKPOINT_INTERVAL)
1570}
1571
1572fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
1573 let mut workspace_crate_prefixes = HashMap::new();
1576 let mut last_idle_checkpoints: HashMap<RefreshRoot, Instant> = HashMap::new();
1577 loop {
1578 let batch = {
1579 let mut queue = shared
1580 .queue
1581 .lock()
1582 .expect("callgraph refresh queue mutex poisoned");
1583 loop {
1584 if let Some(root) = queue.order.pop_front() {
1585 let batch = queue
1586 .queued
1587 .remove(&root)
1588 .expect("queued callgraph refresh root has a batch");
1589 queue.active = Some(batch.clone());
1590 break batch;
1591 }
1592 if queue.shutdown_requested {
1593 return;
1594 }
1595 queue = shared
1596 .wake
1597 .wait(queue)
1598 .expect("callgraph refresh queue mutex poisoned while waiting");
1599 }
1600 };
1601
1602 let store = process_callgraph_refresh_batch(&batch, &mut workspace_crate_prefixes);
1603
1604 let mut queue = shared
1605 .queue
1606 .lock()
1607 .expect("callgraph refresh queue mutex poisoned");
1608 queue.active = None;
1609 let became_idle = queue.order.is_empty();
1610 shared.wake.notify_all();
1611 drop(queue);
1612
1613 if became_idle {
1614 let checkpoint_due = idle_checkpoint_due(
1615 last_idle_checkpoints.get(&batch.root).copied(),
1616 Instant::now(),
1617 );
1618 if checkpoint_due {
1619 if let Some(store) = store {
1620 if store.checkpoint_wal_truncate() {
1621 last_idle_checkpoints.insert(batch.root.clone(), Instant::now());
1622 }
1623 }
1624 }
1625 }
1626 }
1627}
1628
1629fn process_callgraph_refresh_batch(
1630 batch: &RefreshBatch,
1631 workspace_crate_prefixes: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1632) -> Option<CallGraphStore> {
1633 if batch
1637 .paths
1638 .iter()
1639 .any(|path| invalidates_workspace_crate_prefix_cache(path))
1640 {
1641 workspace_crate_prefixes.remove(&batch.root);
1642 }
1643 if batch
1644 .paths
1645 .iter()
1646 .any(|path| invalidates_workspace_package_cache(path))
1647 {
1648 callgraph::clear_workspace_package_cache();
1649 }
1650
1651 let paths = batch
1652 .paths
1653 .iter()
1654 .filter(|path| crate::parser::detect_language(path).is_some())
1655 .cloned()
1656 .collect::<Vec<_>>();
1657 if paths.is_empty() {
1658 return None;
1659 }
1660 note_refresh_worker_batch_for_test(&batch.root.project_root, &paths);
1661 if batch
1662 .ticket
1663 .as_ref()
1664 .is_some_and(|ticket| !ticket.is_current())
1665 {
1666 batch.defer();
1669 return None;
1670 }
1671 let workspace_crate_prefix_cache =
1672 workspace_crate_prefix_cache_for_root(workspace_crate_prefixes, &batch.root);
1673 let _watchdog = RefreshWorkerWatchdog::start(&paths);
1674 let test_seam = refresh_worker_test_seam(&batch.root.project_root);
1675 note_refresh_worker_call_for_test(&batch.root.project_root);
1676 let opened = if test_seam.fail_open {
1677 Ok(None)
1678 } else {
1679 CallGraphStore::open_ready(
1680 batch.root.callgraph_dir.clone(),
1681 batch.root.project_root.clone(),
1682 )
1683 };
1684 if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
1685 let _ = gate.held_tx.send(());
1688 let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
1689 }
1690 let store = match opened {
1691 Ok(Some(store)) => store,
1692 Ok(None) => {
1693 batch.defer_after_open_failure();
1694 return None;
1695 }
1696 Err(error) => {
1697 batch.defer_after_open_failure();
1698 crate::slog_warn!(
1699 "callgraph store writer open failed during refresh; deferred paths: {}",
1700 error
1701 );
1702 return None;
1703 }
1704 };
1705 if !test_seam.delay.is_zero() {
1706 std::thread::sleep(test_seam.delay);
1707 }
1708 if batch
1709 .ticket
1710 .as_ref()
1711 .is_some_and(|ticket| !ticket.is_current())
1712 {
1713 batch.defer();
1716 return Some(store);
1717 }
1718 let refresh_result = if test_seam.fail_refresh {
1719 Err(CallGraphStoreError::Unavailable(
1720 "injected refresh worker failure".to_string(),
1721 ))
1722 } else if let Some(ticket) = &batch.ticket {
1723 with_publish_epoch(
1724 ticket.publish_epoch.clone(),
1725 ticket.expected_publish_epoch,
1726 || {
1727 with_refresh_commit_admission(
1728 ticket.lifecycle.clone(),
1729 Arc::clone(&ticket.generation_flag),
1730 ticket.expected_generation,
1731 || {
1732 store
1733 .refresh_files_with_workspace_crate_prefix_cache(
1734 &paths,
1735 workspace_crate_prefix_cache.clone(),
1736 )
1737 .map(|_| ())
1738 },
1739 )
1740 },
1741 )
1742 } else {
1743 store
1744 .refresh_files_with_workspace_crate_prefix_cache(
1745 &paths,
1746 workspace_crate_prefix_cache.clone(),
1747 )
1748 .map(|_| ())
1749 };
1750 if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
1751 batch.defer();
1755 return Some(store);
1756 }
1757 if let Err(error) = refresh_result {
1758 crate::slog_warn!("callgraph store refresh failed: {}", error);
1759 match store.mark_files_stale(&paths) {
1760 Ok(marked) => {
1761 note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
1762 crate::slog_warn!(
1763 "marked {} callgraph store file(s) stale after refresh failure",
1764 marked.len()
1765 );
1766 }
1767 Err(mark_error) => crate::slog_warn!(
1768 "failed to mark callgraph store files stale after refresh failure: {}",
1769 mark_error
1770 ),
1771 }
1772 } else {
1773 crate::logging::note_callgraph_invalidations(paths.len());
1774 }
1775 Some(store)
1776}
1777
1778fn workspace_crate_prefix_cache_for_root(
1779 caches: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1780 root: &RefreshRoot,
1781) -> WorkspaceCratePrefixCache {
1782 if !caches.contains_key(root) && caches.len() >= REFRESH_WORKSPACE_CACHE_ROOT_CAP {
1783 if let Some(evicted) = caches.keys().next().cloned() {
1785 caches.remove(&evicted);
1786 }
1787 }
1788 caches.entry(root.clone()).or_default().clone()
1789}
1790
1791#[derive(Clone, Default)]
1792struct RefreshWorkerTestSeam {
1793 delay: Duration,
1794 fail_refresh: bool,
1795 fail_open: bool,
1796 refresh_calls: usize,
1797 worker_calls: usize,
1798 stale_marks: usize,
1799 received_paths: BTreeSet<PathBuf>,
1800}
1801
1802static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
1803 OnceLock::new();
1804
1805struct RefreshWorkerTestGate {
1806 held_tx: crossbeam_channel::Sender<()>,
1807 release_rx: crossbeam_channel::Receiver<()>,
1808}
1809
1810static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
1811 OnceLock::new();
1812
1813#[doc(hidden)]
1814pub fn install_callgraph_refresh_worker_test_gate(
1815 project_root: PathBuf,
1816) -> (
1817 crossbeam_channel::Receiver<()>,
1818 crossbeam_channel::Sender<()>,
1819) {
1820 let (held_tx, held_rx) = crossbeam_channel::bounded(1);
1821 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1822 REFRESH_WORKER_TEST_GATES
1823 .get_or_init(|| Mutex::new(HashMap::new()))
1824 .lock()
1825 .expect("callgraph refresh test gate mutex poisoned")
1826 .insert(
1827 project_root,
1828 RefreshWorkerTestGate {
1829 held_tx,
1830 release_rx,
1831 },
1832 );
1833 (held_rx, release_tx)
1834}
1835
1836fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
1837 REFRESH_WORKER_TEST_GATES
1838 .get_or_init(|| Mutex::new(HashMap::new()))
1839 .lock()
1840 .expect("callgraph refresh test gate mutex poisoned")
1841 .remove(project_root)
1842}
1843
1844fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
1845 let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
1846 return RefreshWorkerTestSeam::default();
1847 };
1848 seams
1849 .lock()
1850 .expect("callgraph refresh test seam mutex poisoned")
1851 .get(project_root)
1852 .cloned()
1853 .unwrap_or_default()
1854}
1855
1856fn note_refresh_worker_batch_for_test(project_root: &Path, paths: &[PathBuf]) {
1857 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1858 if let Some(seam) = seams
1859 .lock()
1860 .expect("callgraph refresh test seam mutex poisoned")
1861 .get_mut(project_root)
1862 {
1863 seam.worker_calls += 1;
1864 seam.received_paths.extend(paths.iter().cloned());
1865 }
1866 }
1867}
1868
1869fn note_refresh_worker_call_for_test(project_root: &Path) {
1870 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1871 if let Some(seam) = seams
1872 .lock()
1873 .expect("callgraph refresh test seam mutex poisoned")
1874 .get_mut(project_root)
1875 {
1876 seam.refresh_calls += 1;
1877 }
1878 }
1879}
1880
1881fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
1882 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1883 if let Some(seam) = seams
1884 .lock()
1885 .expect("callgraph refresh test seam mutex poisoned")
1886 .get_mut(project_root)
1887 {
1888 seam.stale_marks += 1;
1889 }
1890 }
1891}
1892
1893#[doc(hidden)]
1894pub fn set_callgraph_refresh_worker_test_seam(
1895 project_root: PathBuf,
1896 delay: Duration,
1897 fail_refresh: bool,
1898) {
1899 REFRESH_WORKER_TEST_SEAMS
1900 .get_or_init(|| Mutex::new(HashMap::new()))
1901 .lock()
1902 .expect("callgraph refresh test seam mutex poisoned")
1903 .insert(
1904 project_root,
1905 RefreshWorkerTestSeam {
1906 delay,
1907 fail_refresh,
1908 ..RefreshWorkerTestSeam::default()
1909 },
1910 );
1911}
1912
1913#[doc(hidden)]
1914pub fn set_callgraph_refresh_worker_test_open_failure(project_root: PathBuf, enabled: bool) {
1915 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1916 if let Some(seam) = seams
1917 .lock()
1918 .expect("callgraph refresh test seam mutex poisoned")
1919 .get_mut(&project_root)
1920 {
1921 seam.fail_open = enabled;
1922 }
1923 }
1924}
1925
1926#[doc(hidden)]
1927pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
1928 let seam = refresh_worker_test_seam(project_root);
1929 (seam.refresh_calls, seam.stale_marks)
1930}
1931
1932#[doc(hidden)]
1933pub fn callgraph_refresh_worker_test_worker_calls(project_root: &Path) -> usize {
1934 refresh_worker_test_seam(project_root).worker_calls
1935}
1936
1937#[doc(hidden)]
1938pub fn callgraph_refresh_worker_test_paths(project_root: &Path) -> BTreeSet<PathBuf> {
1939 refresh_worker_test_seam(project_root).received_paths
1940}
1941
1942#[doc(hidden)]
1943pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
1944 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1945 seams
1946 .lock()
1947 .expect("callgraph refresh test seam mutex poisoned")
1948 .remove(project_root);
1949 }
1950}
1951
1952#[derive(Debug)]
1953pub struct CallGraphStore {
1954 project_root: PathBuf,
1955 project_key: String,
1956 sqlite_path: PathBuf,
1960 publication_dir: PathBuf,
1964 legacy_fallback: bool,
1968 manifest_view: bool,
1969 generation: Option<String>,
1974 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1975 read_marker: Option<crate::root_cache::ReadMarker>,
1976 database_ready: AtomicBool,
1979 write_metrics: Arc<CallgraphWriteMetrics>,
1980 conn: Mutex<TrackedConnection>,
1981}
1982
1983#[derive(Debug)]
1984pub struct ReadonlyCallGraphStore {
1985 inner: CallGraphStore,
1986 _view_pin: Option<Arc<crate::pins::QueryPin>>,
1987}
1988
1989pub trait CallGraphRead {
1990 fn project_root(&self) -> &Path;
1991 fn project_key(&self) -> &str;
1992 fn sqlite_path(&self) -> &Path;
1993 fn is_current(&self) -> bool;
1994 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
1995 fn indexed_file_count(&self) -> Result<usize>;
1996 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
1997 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
1998 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
1999 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
2000 fn direct_callers_for_symbols(
2001 &self,
2002 targets: &[(String, String)],
2003 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
2004 targets
2005 .iter()
2006 .cloned()
2007 .map(|target| {
2008 let callers = self.direct_callers_of(Path::new(&target.0), &target.1)?;
2009 Ok((target, callers))
2010 })
2011 .collect()
2012 }
2013 fn direct_caller_counts_of(
2014 &self,
2015 targets: &[(String, String)],
2016 ) -> Result<HashMap<(String, String), usize>>;
2017 fn outgoing_calls_for_symbols(
2018 &self,
2019 sources: &[(String, String)],
2020 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
2021 fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
2022 -> Result<StoreCallersResult>;
2023 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
2024 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
2025 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
2026 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
2027 fn call_tree(
2028 &self,
2029 file_rel: &Path,
2030 symbol: &str,
2031 depth: usize,
2032 ) -> Result<callgraph::CallTreeNode>;
2033 fn trace_to(
2034 &self,
2035 file_rel: &Path,
2036 symbol: &str,
2037 max_depth: usize,
2038 ) -> Result<callgraph::TraceToResult>;
2039 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
2040 fn trace_to_symbol(
2041 &self,
2042 file_rel: &Path,
2043 symbol: &str,
2044 to_symbol: &str,
2045 to_file: Option<&Path>,
2046 max_depth: usize,
2047 ) -> Result<callgraph::TraceToSymbolResult>;
2048}
2049
2050#[derive(Debug, Clone, PartialEq, Eq)]
2051enum OpenRootRepair {
2052 None,
2053 ReRooted,
2054 NeedsRebuild {
2055 previous_roots: Vec<String>,
2056 current_root: String,
2057 reason: String,
2058 },
2059}
2060
2061struct OpenedStore {
2062 store: CallGraphStore,
2063 root_repair: OpenRootRepair,
2064}
2065
2066#[derive(Clone, Debug)]
2067struct LegacyCallgraphPartition {
2068 harness: String,
2069 dir: PathBuf,
2070 key: String,
2071 bytes: u64,
2072 freshness: Option<SystemTime>,
2073}
2074
2075#[derive(Clone, Debug)]
2076struct LegacyCallgraphTarget {
2077 partition: LegacyCallgraphPartition,
2078 sqlite_path: PathBuf,
2079 generation: Option<String>,
2080 source_bytes: u64,
2081 source_blake3: String,
2082}
2083
2084#[derive(Clone, Debug)]
2085struct SourceFingerprint {
2086 bytes: u64,
2087 blake3: String,
2088}
2089
2090#[derive(Clone, Debug)]
2091struct PublishedLegacyMigration {
2092 generation: String,
2093 migrated_bytes: u64,
2094}
2095
2096#[derive(Debug, Clone)]
2097pub struct ColdBuildStats {
2098 pub files: usize,
2099 pub nodes: usize,
2100 pub refs: usize,
2101 pub edges: usize,
2102 pub failed_files: Vec<String>,
2103 pub elapsed_ms: u128,
2104}
2105
2106#[derive(Debug, Clone)]
2107pub struct IncrementalStats {
2108 pub changed_files: Vec<String>,
2109 pub surface_changed: Vec<String>,
2110 pub deleted_files: Vec<String>,
2111 pub dependency_selected_refs: usize,
2112 pub refreshed_own_files: usize,
2113 pub unchanged_extract_files: usize,
2114}
2115
2116#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2117pub struct StalePathCensus {
2118 pub stale: usize,
2119 pub absent: usize,
2120 pub unreadable: usize,
2121}
2122
2123#[doc(hidden)]
2125#[derive(Debug, Clone, Default, PartialEq, Eq)]
2126pub struct RefreshFilesProfile {
2127 pub parse: Duration,
2128 pub dependency_selection: Duration,
2129 pub row_deletes: Duration,
2130 pub row_inserts: Duration,
2131 pub dependent_parse: Duration,
2132 pub index_load: Duration,
2133 pub index_loads: usize,
2134 pub ref_resolution: Duration,
2135 pub method_dispatch: Duration,
2136 pub commit: Duration,
2137 pub total: Duration,
2138}
2139
2140impl RefreshFilesProfile {
2141 pub fn report(&self) -> String {
2142 format!(
2143 "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",
2144 self.parse.as_millis(),
2145 self.dependency_selection.as_millis(),
2146 self.row_deletes.as_millis(),
2147 self.row_inserts.as_millis(),
2148 self.dependent_parse.as_millis(),
2149 self.index_load.as_millis(),
2150 self.ref_resolution.as_millis(),
2151 self.method_dispatch.as_millis(),
2152 self.commit.as_millis(),
2153 self.total.as_millis(),
2154 )
2155 }
2156}
2157
2158#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2159pub struct StoredEdge {
2160 pub source_file: String,
2161 pub source_symbol: String,
2162 pub target_file: String,
2163 pub target_symbol: String,
2164 pub kind: String,
2165 pub line: u32,
2166}
2167
2168#[derive(Debug, Clone, PartialEq, Eq)]
2169pub struct StoreNode {
2170 node_id: String,
2171 pub file: String,
2172 pub symbol: String,
2173 pub name: String,
2174 pub kind: String,
2175 pub line: u32,
2176 pub end_line: u32,
2177 pub signature: Option<String>,
2178 pub exported: bool,
2179 pub is_entry_point: bool,
2180 pub lang: LangId,
2181}
2182
2183#[cfg(test)]
2184impl StoreNode {
2185 pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
2186 Self {
2187 node_id: format!("{file}:{symbol}"),
2188 file: file.to_string(),
2189 symbol: symbol.to_string(),
2190 name: symbol.to_string(),
2191 kind: "function".to_string(),
2192 line: 1,
2193 end_line: 1,
2194 signature: None,
2195 exported: is_entry_point,
2196 is_entry_point,
2197 lang: LangId::TypeScript,
2198 }
2199 }
2200}
2201
2202#[derive(Debug, Clone, PartialEq, Eq)]
2203pub struct StoreCallSite {
2204 pub caller: StoreNode,
2205 pub target_file: String,
2206 pub target_symbol: String,
2207 pub target: Option<StoreNode>,
2208 pub line: u32,
2209 pub byte_start: usize,
2210 pub byte_end: usize,
2211 pub resolved: bool,
2212 pub provenance: String,
2213}
2214
2215impl StoreCallSite {
2216 pub fn approximate(&self) -> bool {
2217 self.provenance == PROVENANCE_NAME_MATCH
2218 }
2219
2220 pub fn resolved_by(&self) -> &str {
2221 &self.provenance
2222 }
2223
2224 pub fn supplemental_resolution(&self) -> Option<&str> {
2225 match self.provenance.as_str() {
2226 PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
2227 _ => None,
2228 }
2229 }
2230}
2231
2232#[derive(Debug, Clone, PartialEq, Eq)]
2233pub struct StoreUnresolvedCall {
2234 pub caller: StoreNode,
2235 pub symbol: String,
2236 pub full_ref: Option<String>,
2237 pub line: u32,
2238 pub byte_start: usize,
2239 pub byte_end: usize,
2240}
2241
2242#[derive(Debug, Clone, PartialEq, Eq)]
2243pub struct StoreCallersResult {
2244 pub target: StoreNode,
2245 pub callers: Vec<StoreCallSite>,
2246 pub scanned_files: usize,
2247 pub depth_limited: bool,
2248 pub truncated: usize,
2249}
2250
2251#[derive(Debug, Clone, PartialEq, Eq)]
2252pub struct StoreImpactCaller {
2253 pub site: StoreCallSite,
2254 pub signature: Option<String>,
2255 pub is_entry_point: bool,
2256 pub call_expression: Option<String>,
2257 pub parameters: Vec<String>,
2258}
2259
2260#[derive(Debug, Clone, PartialEq, Eq)]
2261pub struct StoreImpactResult {
2262 pub target: StoreNode,
2263 pub parameters: Vec<String>,
2264 pub callers: Vec<StoreImpactCaller>,
2265 pub depth_limited: bool,
2266 pub truncated: usize,
2267}
2268
2269#[derive(Debug, Clone)]
2270struct ExtractFailure {
2271 rel_path: String,
2272 freshness: Option<FileFreshness>,
2273}
2274
2275#[derive(Debug, Clone)]
2276struct BuildExtractsResult {
2277 extracts: Vec<FileExtract>,
2278 failures: Vec<ExtractFailure>,
2279}
2280
2281#[derive(Debug, Clone)]
2282enum StoreForwardCall {
2283 Resolved(StoreCallSite),
2284 Unresolved(StoreUnresolvedCall),
2285}
2286
2287impl StoreForwardCall {
2288 fn byte_start(&self) -> usize {
2289 match self {
2290 Self::Resolved(site) => site.byte_start,
2291 Self::Unresolved(call) => call.byte_start,
2292 }
2293 }
2294
2295 fn line(&self) -> u32 {
2296 match self {
2297 Self::Resolved(site) => site.line,
2298 Self::Unresolved(call) => call.line,
2299 }
2300 }
2301}
2302
2303#[derive(Debug, Clone)]
2304struct FileExtract {
2305 rel_path: String,
2306 freshness: FileFreshness,
2307 lang: LangId,
2308 data: FileCallData,
2309 nodes: Vec<NodeRecord>,
2310 raw_refs: Vec<RawRef>,
2311 dispatch_hints: Vec<DispatchHint>,
2312 surface_fingerprint: String,
2313}
2314
2315#[derive(Debug, Clone)]
2316struct NodeRecord {
2317 id: String,
2318 file_path: String,
2319 name: String,
2320 scoped_name: String,
2321 kind: String,
2322 range: Range,
2323 range_ordinal: u32,
2324 signature: Option<String>,
2325 exported: bool,
2326 is_default_export: bool,
2327 is_type_like: bool,
2328 is_callgraph_entry_point: bool,
2329}
2330
2331#[derive(Debug, Clone)]
2332struct RawRef {
2333 ref_id: String,
2334 caller_node: Option<String>,
2335 caller_symbol: Option<String>,
2336 caller_file: String,
2337 kind: String,
2338 short_name: Option<String>,
2339 full_ref: Option<String>,
2340 module_path: Option<String>,
2341 import_kind: Option<String>,
2342 local_name: Option<String>,
2343 requested_name: Option<String>,
2344 namespace_alias: Option<String>,
2345 wildcard: bool,
2346 line: u32,
2347 byte_start: usize,
2348 byte_end: usize,
2349 dependencies: BTreeSet<String>,
2350}
2351
2352#[derive(Debug)]
2356struct StagedRef {
2357 rowid: u64,
2358 raw: RawRef,
2359}
2360
2361#[derive(Debug, Clone)]
2362struct ResolvedRef {
2363 raw: RawRef,
2364 status: String,
2365 target_node: Option<String>,
2366 target_file: Option<String>,
2367 target_symbol: Option<String>,
2368 dependencies: BTreeSet<String>,
2369 edge: Option<EdgeRecord>,
2370}
2371
2372#[derive(Debug, Clone)]
2373struct EdgeRecord {
2374 edge_id: String,
2375 source_node: String,
2376 target_node: Option<String>,
2377 target_file: String,
2378 target_symbol: String,
2379 kind: String,
2380 line: u32,
2381}
2382
2383#[derive(Debug, Clone)]
2384struct DispatchHint {
2385 id: String,
2386 method_name: String,
2387 caller_node: String,
2388 file: String,
2389 line: u32,
2390 byte_start: usize,
2391 byte_end: usize,
2392}
2393
2394#[derive(Debug, Clone)]
2395struct NameMatchRef {
2396 ref_id: String,
2397 caller_node: String,
2398 caller_file: String,
2399 caller_symbol: String,
2400 caller_signature: Option<String>,
2401 receiver_expression: String,
2402 receiver: String,
2403 method_name: String,
2404 colon_dispatch: bool,
2405 line: u32,
2406 lang: String,
2407}
2408
2409#[derive(Debug, Clone)]
2410struct NameMatchCandidate {
2411 node_id: String,
2412 file_path: String,
2413 scoped_name: String,
2414 kind: String,
2415 start_line: u32,
2417}
2418
2419#[derive(Debug, Clone)]
2420struct FileRow {
2421 surface_fingerprint: String,
2422 freshness: FileFreshness,
2423}
2424
2425#[derive(Debug, Clone)]
2426struct DbFileIndex {
2427 lang: Option<LangId>,
2428 exports: HashSet<String>,
2429 default_export: Option<String>,
2430 export_aliases: HashMap<String, String>,
2431 node_by_scoped: HashMap<String, String>,
2432 node_by_bare: HashMap<String, String>,
2433 node_kind_by_id: HashMap<String, String>,
2434 module_targets: HashMap<String, Option<String>>,
2435 declared_module_targets: HashMap<String, Option<String>>,
2436 reexports: Vec<ReexportIndex>,
2437}
2438
2439#[derive(Debug, Clone)]
2440struct ReexportIndex {
2441 target_file: Option<String>,
2442 named: HashMap<String, String>,
2443 wildcard: bool,
2444}
2445
2446#[derive(Clone)]
2447struct ProjectIndex<'a> {
2448 facts: Rc<dyn ProjectFacts + 'a>,
2449 unbound_non_utf8_paths: Vec<Vec<u8>>,
2450 project_root: PathBuf,
2451 files: HashMap<String, DbFileIndex>,
2452 caller_data: HashMap<String, &'a FileCallData>,
2453 workspace_crate_prefixes: WorkspaceCratePrefixCache,
2458 rust_crate_roots: callgraph::RustCrateRootMemo,
2459}
2460
2461trait ResolverIndex {
2465 fn caller_data(&self, file: &str) -> Option<&FileCallData>;
2466 fn lang_for(&self, file: &str) -> Option<LangId>;
2467 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String>;
2468 fn module_parent(&self, target_file: &str) -> Option<(String, String)>;
2469 fn reexports_for(&self, file: &str) -> Vec<ReexportIndex>;
2470 fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String>;
2471 fn node_is_callable(&self, file: &str, node_id: &str) -> bool;
2472 fn export_alias(&self, file: &str, symbol: &str) -> Option<String>;
2473 fn has_export(&self, file: &str, symbol: &str) -> bool;
2474 fn default_export(&self, file: &str) -> Option<String>;
2475 fn contains_file(&self, file: &str) -> bool;
2476 fn crate_src_prefix(&self, crate_name: &str) -> Option<String>;
2477 fn rust_crate_root_file(&self, caller_file: &str) -> Option<String>;
2478 fn inline_scoped_target(
2479 &self,
2480 caller_file: &str,
2481 module_segments: &[String],
2482 short_name: &str,
2483 ) -> Option<(String, String)>;
2484}
2485
2486impl ResolverIndex for ProjectIndex<'_> {
2487 fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2488 self.caller_data.get(file).copied()
2489 }
2490
2491 fn lang_for(&self, file: &str) -> Option<LangId> {
2492 self.lang_for(file)
2493 }
2494
2495 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2496 self.module_target(caller_file, module_path)
2497 }
2498
2499 fn module_parent(&self, target_file: &str) -> Option<(String, String)> {
2500 let mut parents = self
2501 .files
2502 .iter()
2503 .flat_map(|(file, index)| {
2504 index
2505 .declared_module_targets
2506 .iter()
2507 .filter_map(move |(module, target)| {
2508 (target.as_deref() == Some(target_file))
2509 .then(|| (file.clone(), module.clone()))
2510 })
2511 })
2512 .collect::<Vec<_>>();
2513 parents.sort();
2514 parents.into_iter().next()
2515 }
2516
2517 fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2518 self.reexports_for(file).to_vec()
2519 }
2520
2521 fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2522 self.node_for_symbol(file, symbol)
2523 }
2524
2525 fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2526 self.node_is_callable(file, node_id)
2527 }
2528
2529 fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2530 self.files
2531 .get(file)
2532 .and_then(|item| item.export_aliases.get(symbol))
2533 .cloned()
2534 }
2535
2536 fn has_export(&self, file: &str, symbol: &str) -> bool {
2537 self.files
2538 .get(file)
2539 .is_some_and(|item| item.exports.contains(symbol))
2540 }
2541
2542 fn default_export(&self, file: &str) -> Option<String> {
2543 self.files
2544 .get(file)
2545 .and_then(|item| item.default_export.clone())
2546 }
2547
2548 fn contains_file(&self, file: &str) -> bool {
2549 self.files.contains_key(file)
2550 }
2551
2552 fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2553 if self.workspace_crate_prefixes.0.get().is_some() {
2554 self.facts.memo_replay(&self.project_root, "crates", "");
2555 }
2556 self.workspace_crate_prefixes
2557 .0
2558 .get_or_init(|| {
2559 self.facts.memo_start(&self.project_root, "crates", "");
2560 let prefixes = build_workspace_crate_prefixes(
2561 &self.project_root,
2562 &FactPaths {
2563 root: &self.project_root,
2564 facts: self.facts.as_ref(),
2565 },
2566 );
2567 self.facts.memo_finish(&self.project_root, "crates", "");
2568 prefixes
2569 })
2570 .get(crate_name)
2571 .cloned()
2572 }
2573
2574 fn rust_crate_root_file(&self, caller_file: &str) -> Option<String> {
2575 let paths = FactPaths {
2576 root: &self.project_root,
2577 facts: self.facts.as_ref(),
2578 };
2579 callgraph::rust_crate_root_file_for_caller(
2580 &self.project_root,
2581 &self.project_root.join(caller_file),
2582 &paths,
2583 &self.rust_crate_roots,
2584 )
2585 .map(|path| relative_path(&self.project_root, &path))
2586 }
2587
2588 fn inline_scoped_target(
2589 &self,
2590 caller_file: &str,
2591 module_segments: &[String],
2592 short_name: &str,
2593 ) -> Option<(String, String)> {
2594 let src_prefix = rust_src_prefix(caller_file);
2595 let mut file_paths = self.files.keys().cloned().collect::<Vec<_>>();
2596 file_paths.sort();
2597 if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
2598 let caller = file_paths.remove(position);
2599 file_paths.insert(0, caller);
2600 }
2601 for file_path in file_paths {
2602 if self.lang_for(&file_path) != Some(LangId::Rust)
2603 || rust_src_prefix(&file_path) != src_prefix
2604 {
2605 continue;
2606 }
2607 let file_module_segments = rust_module_segments_for_rel(&file_path);
2608 if !module_segments.starts_with(&file_module_segments) {
2609 continue;
2610 }
2611 let scoped_segments = &module_segments[file_module_segments.len()..];
2612 if scoped_segments.is_empty() {
2613 continue;
2614 }
2615 let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2616 if self.node_for_symbol(&file_path, &scoped_symbol).is_some() {
2617 return Some((file_path, scoped_symbol));
2618 }
2619 }
2620 None
2621 }
2622}
2623
2624struct DiskProjectIndex<'a> {
2635 project_root: &'a Path,
2636 conn: &'a Connection,
2637 caller_file: &'a str,
2638 caller_data: &'a FileCallData,
2639 workspace_crate_prefixes: WorkspaceCratePrefixCache,
2640 module_resolution_memo: &'a callgraph::ModuleResolutionMemo,
2641 file_index_memo: RefCell<HashMap<String, Option<Rc<DbFileIndex>>>>,
2642 module_parent_memo: RefCell<HashMap<String, Option<(String, String)>>>,
2643 memoize_resolver_indexes: bool,
2644}
2645
2646impl DiskProjectIndex<'_> {
2647 fn file_index(&self, rel_path: &str) -> Option<Rc<DbFileIndex>> {
2648 if self.memoize_resolver_indexes {
2649 if let Some(cached) = self.file_index_memo.borrow().get(rel_path).cloned() {
2650 return cached;
2651 }
2652 }
2653
2654 let loaded = self.load_file_index(rel_path).map(Rc::new);
2655 if self.memoize_resolver_indexes {
2656 let mut memo = self.file_index_memo.borrow_mut();
2657 if memo.len() >= DISK_FILE_INDEX_MEMO_CAPACITY {
2658 let caller_index = memo.remove(self.caller_file);
2661 memo.clear();
2662 if let Some(caller_index) = caller_index {
2663 memo.insert(self.caller_file.to_string(), caller_index);
2664 }
2665 }
2666 memo.insert(rel_path.to_string(), loaded.clone());
2667 }
2668 loaded
2669 }
2670
2671 fn load_file_index(&self, rel_path: &str) -> Option<DbFileIndex> {
2672 let lang: String = self
2673 .conn
2674 .query_row(
2675 "SELECT lang FROM files WHERE path = ?1",
2676 params![rel_path],
2677 |row| row.get(0),
2678 )
2679 .optional()
2680 .ok()??;
2681 let mut index = DbFileIndex {
2682 lang: lang_from_label(&lang),
2683 exports: HashSet::new(),
2684 default_export: None,
2685 export_aliases: HashMap::new(),
2686 node_by_scoped: HashMap::new(),
2687 node_by_bare: HashMap::new(),
2688 node_kind_by_id: HashMap::new(),
2689 module_targets: HashMap::new(),
2690 declared_module_targets: HashMap::new(),
2691 reexports: Vec::new(),
2692 };
2693 let mut nodes = self
2694 .conn
2695 .prepare(
2696 "SELECT id, name, scoped_name, kind, exported, is_default_export
2697 FROM nodes WHERE file_path = ?1",
2698 )
2699 .ok()?;
2700 let rows = nodes
2701 .query_map(params![rel_path], |row| {
2702 Ok((
2703 row.get::<_, String>(0)?,
2704 row.get::<_, String>(1)?,
2705 row.get::<_, String>(2)?,
2706 row.get::<_, String>(3)?,
2707 row.get::<_, i64>(4)? != 0,
2708 row.get::<_, i64>(5)? != 0,
2709 ))
2710 })
2711 .ok()?
2712 .collect::<std::result::Result<Vec<_>, _>>()
2713 .ok()?;
2714 drop(nodes);
2715 for (id, name, scoped_name, kind, exported, is_default_export) in rows {
2716 if exported {
2717 index.exports.insert(name.clone());
2718 index.exports.insert(scoped_name.clone());
2719 }
2720 if is_default_export {
2721 index.default_export = Some(scoped_name.clone());
2722 }
2723 index.node_by_scoped.insert(scoped_name, id.clone());
2724 index.node_by_bare.entry(name).or_insert(id.clone());
2725 index.node_kind_by_id.insert(id, kind);
2726 }
2727
2728 let mut refs = self
2729 .conn
2730 .prepare(
2731 "SELECT ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name
2732 FROM refs
2733 WHERE caller_file = ?1 AND kind IN ('import', 'module', 'reexport', 'export_alias')",
2734 )
2735 .ok()?;
2736 let rows = refs
2737 .query_map(params![rel_path], |row| {
2738 Ok((
2739 row.get::<_, String>(0)?,
2740 row.get::<_, String>(1)?,
2741 row.get::<_, Option<String>>(2)?,
2742 row.get::<_, Option<String>>(3)?,
2743 row.get::<_, i64>(4)? != 0,
2744 row.get::<_, Option<String>>(5)?,
2745 row.get::<_, Option<String>>(6)?,
2746 ))
2747 })
2748 .ok()?
2749 .collect::<std::result::Result<Vec<_>, _>>()
2750 .ok()?;
2751 drop(refs);
2752 for (ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name) in rows {
2753 if kind == "export_alias" {
2754 if let (Some(exported), Some(source)) = (local_name, requested_name) {
2755 index.export_aliases.insert(exported, source);
2756 }
2757 continue;
2758 }
2759 let Some(module_path) = module_path else {
2760 continue;
2761 };
2762 let target_file = if kind == "module" {
2763 rust_declared_module_target(
2764 self.project_root,
2765 rel_path,
2766 &module_path,
2767 self.module_resolution_memo,
2768 &FactPaths {
2769 root: self.project_root,
2770 facts: &DiskFacts::new(self.project_root),
2771 },
2772 )
2773 } else {
2774 self.disk_module_target(rel_path, &module_path)
2775 }
2776 .or_else(|| {
2777 self.conn
2778 .query_row(
2779 "SELECT d.dep_file
2780 FROM file_dependencies d
2781 JOIN files f ON f.path = d.dep_file
2782 WHERE d.file_path = ?1
2783 ORDER BY d.dep_file
2784 LIMIT 1",
2785 params![rel_path],
2786 |row| row.get::<_, String>(0),
2787 )
2788 .optional()
2789 .ok()
2790 .flatten()
2791 });
2792 index
2793 .module_targets
2794 .entry(module_path.clone())
2795 .or_insert_with(|| target_file.clone());
2796 if kind == "module" {
2797 index
2798 .declared_module_targets
2799 .entry(module_path.clone())
2800 .or_insert_with(|| target_file.clone());
2801 }
2802 if kind == "reexport" {
2803 let raw = RawRef {
2804 ref_id,
2805 caller_node: None,
2806 caller_symbol: None,
2807 caller_file: rel_path.to_string(),
2808 kind,
2809 short_name: None,
2810 full_ref,
2811 module_path: Some(module_path),
2812 import_kind: Some("reexport".to_string()),
2813 local_name: None,
2814 requested_name: None,
2815 namespace_alias: None,
2816 wildcard,
2817 line: 0,
2818 byte_start: 0,
2819 byte_end: 0,
2820 dependencies: BTreeSet::new(),
2821 };
2822 index
2823 .reexports
2824 .push(reexport_index_from_raw(&raw, target_file));
2825 }
2826 }
2827 Some(index)
2828 }
2829
2830 fn disk_module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2831 let caller_dir = self.project_root.join(caller_file).parent()?.to_path_buf();
2832 let candidate = callgraph::resolve_module_path_with_memo(
2833 &caller_dir,
2834 module_path,
2835 self.module_resolution_memo,
2836 &FactPaths {
2837 root: self.project_root,
2838 facts: &DiskFacts::new(self.project_root),
2839 },
2840 )?;
2841 let rel_path = relative_path(self.project_root, &candidate);
2842 self.contains_file(&rel_path).then_some(rel_path)
2843 }
2844
2845 fn load_module_parent(&self, target_file: &str) -> Option<(String, String)> {
2846 let mut stmt = self
2847 .conn
2848 .prepare(
2849 "SELECT caller_file, module_path FROM refs
2850 WHERE kind = 'module' AND module_path IS NOT NULL
2851 ORDER BY caller_file, module_path",
2852 )
2853 .ok()?;
2854 let rows = stmt
2855 .query_map([], |row| {
2856 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2857 })
2858 .ok()?;
2859 for row in rows.flatten() {
2860 if self.module_target(&row.0, &row.1).as_deref() == Some(target_file) {
2861 return Some(row);
2862 }
2863 }
2864 None
2865 }
2866}
2867
2868impl ResolverIndex for DiskProjectIndex<'_> {
2869 fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2870 (file == self.caller_file).then_some(self.caller_data)
2871 }
2872
2873 fn lang_for(&self, file: &str) -> Option<LangId> {
2874 self.file_index(file).and_then(|index| index.lang)
2875 }
2876
2877 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2878 self.file_index(caller_file)
2879 .and_then(|index| index.module_targets.get(module_path).cloned().flatten())
2880 }
2881
2882 fn module_parent(&self, target_file: &str) -> Option<(String, String)> {
2883 if self.memoize_resolver_indexes {
2884 if let Some(cached) = self.module_parent_memo.borrow().get(target_file).cloned() {
2885 return cached;
2886 }
2887 }
2888
2889 let parent = self.load_module_parent(target_file);
2890 if self.memoize_resolver_indexes {
2891 let mut memo = self.module_parent_memo.borrow_mut();
2892 if memo.len() >= DISK_FILE_INDEX_MEMO_CAPACITY {
2893 memo.clear();
2894 }
2895 memo.insert(target_file.to_string(), parent.clone());
2896 }
2897 parent
2898 }
2899
2900 fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2901 self.file_index(file)
2902 .map(|index| index.reexports.clone())
2903 .unwrap_or_default()
2904 }
2905
2906 fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2907 self.file_index(file).and_then(|index| {
2908 index
2909 .node_by_scoped
2910 .get(symbol)
2911 .cloned()
2912 .or_else(|| index.node_by_bare.get(symbol).cloned())
2913 })
2914 }
2915
2916 fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2917 self.file_index(file)
2918 .and_then(|index| index.node_kind_by_id.get(node_id).cloned())
2919 .is_some_and(|kind| matches!(kind.as_str(), "function" | "kernel" | "method"))
2920 }
2921
2922 fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2923 self.file_index(file)
2924 .and_then(|index| index.export_aliases.get(symbol).cloned())
2925 }
2926
2927 fn has_export(&self, file: &str, symbol: &str) -> bool {
2928 self.file_index(file)
2929 .is_some_and(|index| index.exports.contains(symbol))
2930 }
2931
2932 fn default_export(&self, file: &str) -> Option<String> {
2933 self.file_index(file)
2934 .and_then(|index| index.default_export.clone())
2935 }
2936
2937 fn contains_file(&self, file: &str) -> bool {
2938 self.conn
2939 .query_row(
2940 "SELECT 1 FROM files WHERE path = ?1 LIMIT 1",
2941 params![file],
2942 |_| Ok(()),
2943 )
2944 .is_ok()
2945 }
2946
2947 fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2948 self.workspace_crate_prefixes
2949 .0
2950 .get_or_init(|| {
2951 build_workspace_crate_prefixes(
2952 self.project_root,
2953 &FactPaths {
2954 root: self.project_root,
2955 facts: &DiskFacts::new(self.project_root),
2956 },
2957 )
2958 })
2959 .get(crate_name)
2960 .cloned()
2961 }
2962
2963 fn rust_crate_root_file(&self, caller_file: &str) -> Option<String> {
2964 let disk = DiskFacts::new(self.project_root);
2965 let paths = FactPaths {
2966 root: self.project_root,
2967 facts: &disk,
2968 };
2969 self.module_resolution_memo
2970 .rust_crate_root_file(
2971 self.project_root,
2972 &self.project_root.join(caller_file),
2973 &paths,
2974 )
2975 .map(|path| relative_path(self.project_root, &path))
2976 }
2977
2978 fn inline_scoped_target(
2979 &self,
2980 caller_file: &str,
2981 module_segments: &[String],
2982 short_name: &str,
2983 ) -> Option<(String, String)> {
2984 let src_prefix = rust_src_prefix(caller_file);
2985 let check = |file_path: String| {
2986 let file_module_segments = rust_module_segments_for_rel(&file_path);
2987 if rust_src_prefix(&file_path) != src_prefix
2988 || !module_segments.starts_with(&file_module_segments)
2989 {
2990 return None;
2991 }
2992 let scoped_segments = &module_segments[file_module_segments.len()..];
2993 if scoped_segments.is_empty() {
2994 return None;
2995 }
2996 let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2997 self.node_for_symbol(&file_path, &scoped_symbol)
2998 .map(|_| (file_path, scoped_symbol))
2999 };
3000 if let Some(target) = check(caller_file.to_string()) {
3001 return Some(target);
3002 }
3003 let mut statement = self
3004 .conn
3005 .prepare("SELECT path FROM files WHERE lang = 'rust' AND path <> ?1 ORDER BY path")
3006 .ok()?;
3007 let rows = statement
3008 .query_map(params![caller_file], |row| row.get::<_, String>(0))
3009 .ok()?;
3010 for path in rows.flatten() {
3011 if let Some(target) = check(path) {
3012 return Some(target);
3013 }
3014 }
3015 None
3016 }
3017}
3018
3019impl CallGraphStore {
3020 pub fn open_if_enabled(
3021 options: CallGraphStoreOptions,
3022 callgraph_dir: PathBuf,
3023 project_root: PathBuf,
3024 ) -> Result<Option<Self>> {
3025 if !options.enabled {
3026 return Ok(None);
3027 }
3028 Self::open(callgraph_dir, project_root).map(Some)
3029 }
3030
3031 pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
3032 let project_key = crate::search_index::artifact_cache_key(&project_root);
3033 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
3034 else {
3035 return Err(CallGraphStoreError::Unavailable(
3036 "writer capability denied; use the read-only callgraph opener".to_string(),
3037 ));
3038 };
3039 std::fs::create_dir_all(&callgraph_dir)?;
3040 let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
3044 .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
3045 let OpenedStore { store, root_repair } = Self::open_at_path(
3046 project_root.clone(),
3047 project_key,
3048 sqlite_path,
3049 generation,
3050 true,
3051 Some(Arc::clone(&writer_lease)),
3052 None,
3053 )?;
3054 match root_repair {
3055 OpenRootRepair::NeedsRebuild { .. } => {
3056 log_root_repair_rebuild(&root_repair);
3057 drop(store);
3058 drop(writer_lease);
3059 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
3060 let (store, _stats) =
3061 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
3062 Ok(store)
3063 }
3064 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
3065 }
3066 }
3067
3068 pub fn open_readonly(
3069 callgraph_dir: PathBuf,
3070 project_root: PathBuf,
3071 ) -> Result<Option<ReadonlyCallGraphStore>> {
3072 let project_key = crate::search_index::artifact_cache_key(&project_root);
3073 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
3074 {
3075 let conn = open_readonly_connection(&sqlite_path)?;
3076 if !database_ready(&conn).unwrap_or(false) {
3077 return Ok(None);
3078 }
3079 let marker_label = generation.as_deref().unwrap_or("legacy");
3080 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
3081 return Ok(Some(ReadonlyCallGraphStore::from_inner(
3082 Self::from_connection(
3083 project_root,
3084 project_key,
3085 sqlite_path,
3086 callgraph_dir,
3087 false,
3088 generation,
3089 None,
3090 Some(read_marker),
3091 conn,
3092 ),
3093 )));
3094 }
3095
3096 let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
3097 return Ok(None);
3098 };
3099 crate::slog_warn!(
3100 "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
3101 target.partition.harness,
3102 target.sqlite_path.display()
3103 );
3104 let conn = open_readonly_connection(&target.sqlite_path)?;
3105 if !database_ready(&conn).unwrap_or(false) {
3106 return Ok(None);
3107 }
3108 let marker_label =
3109 legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
3110 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
3111 Ok(Some(ReadonlyCallGraphStore::from_inner(
3112 Self::from_connection(
3113 project_root,
3114 project_key,
3115 target.sqlite_path,
3116 callgraph_dir,
3117 true,
3118 target.generation,
3119 None,
3120 Some(read_marker),
3121 conn,
3122 ),
3123 )))
3124 }
3125
3126 pub fn open_ready_repairing(
3132 callgraph_dir: PathBuf,
3133 project_root: PathBuf,
3134 ) -> Result<Option<Self>> {
3135 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true)
3136 }
3137
3138 pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
3142 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false)
3143 }
3144
3145 pub fn open_ready_no_rebuild(
3146 callgraph_dir: PathBuf,
3147 project_root: PathBuf,
3148 ) -> Result<Option<Self>> {
3149 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true)
3150 }
3151
3152 fn open_ready_with_rebuild_policy(
3153 callgraph_dir: PathBuf,
3154 project_root: PathBuf,
3155 allow_cold_build: bool,
3156 allow_root_repair: bool,
3157 ) -> Result<Option<Self>> {
3158 let project_key = crate::search_index::artifact_cache_key(&project_root);
3159 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
3160 else {
3161 return Ok(None);
3162 };
3163 let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
3164 else {
3165 return Ok(None);
3166 };
3167 let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
3168 project_root.clone(),
3169 project_key.clone(),
3170 sqlite_path,
3171 generation,
3172 true,
3173 Some(Arc::clone(&writer_lease)),
3174 None,
3175 allow_root_repair,
3176 )?;
3177 match root_repair {
3178 OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
3179 log_root_repair_rebuild(&root_repair);
3180 drop(store);
3181 drop(writer_lease);
3182 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
3183 let (store, _stats) =
3184 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
3185 Ok(Some(store))
3186 }
3187 OpenRootRepair::NeedsRebuild { .. } => {
3188 if let Some(message) = note_repair_entry(&project_key) {
3189 crate::slog_warn!("{message}");
3190 }
3191 Ok(None)
3192 }
3193 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
3194 }
3195 }
3196
3197 pub fn cold_build_with_lease(
3198 callgraph_dir: PathBuf,
3199 project_root: PathBuf,
3200 files: &[PathBuf],
3201 ) -> Result<(Self, ColdBuildStats)> {
3202 Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
3203 }
3204
3205 pub fn cold_build_with_lease_chunked(
3206 callgraph_dir: PathBuf,
3207 project_root: PathBuf,
3208 files: &[PathBuf],
3209 chunk_size: usize,
3210 ) -> Result<(Self, ColdBuildStats)> {
3211 Self::cold_build_with_lease_chunked_inner(
3212 callgraph_dir,
3213 project_root,
3214 files,
3215 chunk_size,
3216 false,
3217 )
3218 }
3219
3220 pub(crate) fn force_cold_build_with_lease_chunked(
3221 callgraph_dir: PathBuf,
3222 project_root: PathBuf,
3223 files: &[PathBuf],
3224 chunk_size: usize,
3225 ) -> Result<(Self, ColdBuildStats)> {
3226 Self::cold_build_with_lease_chunked_inner(
3227 callgraph_dir,
3228 project_root,
3229 files,
3230 chunk_size,
3231 true,
3232 )
3233 }
3234
3235 fn cold_build_with_lease_chunked_inner(
3236 callgraph_dir: PathBuf,
3237 project_root: PathBuf,
3238 files: &[PathBuf],
3239 chunk_size: usize,
3240 require_new_publication: bool,
3241 ) -> Result<(Self, ColdBuildStats)> {
3242 let project_key = crate::search_index::artifact_cache_key(&project_root);
3243 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
3244 else {
3245 let operation = if require_new_publication {
3246 "forced rebuild"
3247 } else {
3248 "cold build"
3249 };
3250 return Err(CallGraphStoreError::Unavailable(format!(
3251 "{operation} could not acquire writer capability"
3252 )));
3253 };
3254 std::fs::create_dir_all(&callgraph_dir)?;
3255 let (stats, generation) = Self::cold_build_publish_locked(
3256 &callgraph_dir,
3257 &project_root,
3258 &project_key,
3259 files,
3260 chunk_size,
3261 Arc::clone(&writer_lease),
3262 )?;
3263 let store = Self::open_generation(
3264 &callgraph_dir,
3265 project_root,
3266 project_key,
3267 generation,
3268 writer_lease,
3269 )?;
3270 Ok((store, stats))
3271 }
3272
3273 pub fn ensure_built_with_lease(
3274 callgraph_dir: PathBuf,
3275 project_root: PathBuf,
3276 files: &[PathBuf],
3277 ) -> Result<(Self, Option<ColdBuildStats>)> {
3278 Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
3279 }
3280
3281 pub fn ensure_built_with_lease_chunked(
3282 callgraph_dir: PathBuf,
3283 project_root: PathBuf,
3284 files: &[PathBuf],
3285 chunk_size: usize,
3286 ) -> Result<(Self, Option<ColdBuildStats>)> {
3287 let project_key = crate::search_index::artifact_cache_key(&project_root);
3288 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
3289 else {
3290 return Err(CallGraphStoreError::Unavailable(
3291 "callgraph ensure could not acquire writer capability".to_string(),
3292 ));
3293 };
3294 std::fs::create_dir_all(&callgraph_dir)?;
3295 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
3296 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
3303 {
3304 let OpenedStore { store, root_repair } = Self::open_at_path(
3305 project_root.clone(),
3306 project_key.clone(),
3307 sqlite_path,
3308 generation,
3309 true,
3310 Some(Arc::clone(&writer_lease)),
3311 None,
3312 )?;
3313 match root_repair {
3314 OpenRootRepair::NeedsRebuild { .. } => {
3315 log_root_repair_rebuild(&root_repair);
3316 drop(store);
3317 let (stats, generation) = Self::cold_build_publish_locked(
3318 &callgraph_dir,
3319 &project_root,
3320 &project_key,
3321 files,
3322 chunk_size,
3323 Arc::clone(&writer_lease),
3324 )?;
3325 let store = Self::open_generation(
3326 &callgraph_dir,
3327 project_root,
3328 project_key,
3329 generation,
3330 writer_lease,
3331 )?;
3332 return Ok((store, Some(stats)));
3333 }
3334 OpenRootRepair::None | OpenRootRepair::ReRooted => {
3335 return Ok((store, None));
3336 }
3337 }
3338 }
3339 if let Some(store) = try_legacy_migration_or_fallback(
3340 &callgraph_dir,
3341 &project_root,
3342 &project_key,
3343 Arc::clone(&writer_lease),
3344 )? {
3345 return Ok((store, None));
3346 }
3347 let (stats, generation) = Self::cold_build_publish_locked(
3348 &callgraph_dir,
3349 &project_root,
3350 &project_key,
3351 files,
3352 chunk_size,
3353 Arc::clone(&writer_lease),
3354 )?;
3355 let store = Self::open_generation(
3356 &callgraph_dir,
3357 project_root,
3358 project_key,
3359 generation,
3360 writer_lease,
3361 )?;
3362 Ok((store, Some(stats)))
3363 }
3364
3365 pub fn migrate_legacy_with_lease(
3372 callgraph_dir: PathBuf,
3373 project_root: PathBuf,
3374 ) -> Result<Option<Self>> {
3375 let project_key = crate::search_index::artifact_cache_key(&project_root);
3376 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
3377 else {
3378 return Ok(None);
3379 };
3380 std::fs::create_dir_all(&callgraph_dir)?;
3381 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
3382
3383 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
3387 {
3388 let OpenedStore { store, root_repair } = Self::open_at_path(
3389 project_root,
3390 project_key,
3391 sqlite_path,
3392 generation,
3393 true,
3394 Some(writer_lease),
3395 None,
3396 )?;
3397 return match root_repair {
3398 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
3399 OpenRootRepair::NeedsRebuild { reason, .. } => {
3400 Err(CallGraphStoreError::Unavailable(format!(
3401 "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
3402 )))
3403 }
3404 };
3405 }
3406
3407 let store = try_legacy_migration_or_fallback(
3408 &callgraph_dir,
3409 &project_root,
3410 &project_key,
3411 writer_lease,
3412 )?;
3413 Ok(store.filter(|store| !store.is_legacy_fallback()))
3417 }
3418
3419 fn cold_build_publish_locked(
3430 callgraph_dir: &Path,
3431 project_root: &Path,
3432 project_key: &str,
3433 files: &[PathBuf],
3434 chunk_size: usize,
3435 writer_lease: Arc<crate::root_cache::WriterLease>,
3436 ) -> Result<(ColdBuildStats, String)> {
3437 if let Some((previous_root, remaining)) =
3438 rebuild_cooldown_denial(callgraph_dir, project_key, project_root, Instant::now())
3439 {
3440 return Err(CallGraphStoreError::Unavailable(format!(
3441 "cache key {project_key} was rebuilt for {} too recently; retry {} ms after the per-key cooldown",
3442 previous_root.display(),
3443 remaining.as_millis()
3444 )));
3445 }
3446 let breaker = crate::build_breaker::BuildDeathBreaker::open(
3447 callgraph_dir.join("build-breaker.sqlite"),
3448 )
3449 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
3450
3451 let generation = generation_file_name(project_key);
3452 let gen_path = callgraph_dir.join(&generation);
3453 let temp_path = callgraph_dir.join(format!("{project_key}.staging.sqlite.tmp.resume"));
3457 let adopting_staging = temp_path.exists();
3458 if !adopting_staging {
3459 remove_sqlite_file_set(&temp_path);
3460 }
3461
3462 let scope = crate::logging::IndexBuildScope::new(
3463 crate::logging::IndexPlane::Callgraph,
3464 project_root,
3465 project_key,
3466 );
3467 let _index_build = crate::logging::install_index_build(scope.clone());
3468 let mut failure_guard = crate::logging::IndexBuildFailureGuard::new();
3469 let mut started = crate::logging::IndexEvent::from_scope(
3470 crate::logging::IndexEventKind::BuildStarted,
3471 &scope,
3472 );
3473 if adopting_staging {
3474 started = started.field("resumed_from_staging", "true");
3475 }
3476 crate::logging::log_index_event(started);
3477
3478 let (stats, breaker_key) = {
3479 if adopting_staging {
3480 crate::slog_info!(
3481 "resuming callgraph cold build from staged generation {}",
3482 temp_path.display()
3483 );
3484 }
3485 let temp_store = Self::open_at_path(
3486 project_root.to_path_buf(),
3487 project_key.to_string(),
3488 temp_path.clone(),
3489 None,
3490 false,
3491 Some(Arc::clone(&writer_lease)),
3492 None,
3493 )?
3494 .store;
3495 let admission_fingerprint = corpus_fingerprint_for(project_root, files)?;
3506 let breaker_key = crate::build_breaker::BreakerKey::new(
3507 project_root.display().to_string(),
3508 crate::build_breaker::BuildDomain::CallgraphCold,
3509 admission_fingerprint,
3510 );
3511 match breaker
3512 .admit(&breaker_key, 0)
3513 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?
3514 {
3515 crate::build_breaker::BreakerAdmission::Admitted(_) => {
3516 crate::logging::log_index_event(crate::logging::IndexEvent::from_scope(
3517 crate::logging::IndexEventKind::BreakerAdmitted,
3518 &scope,
3519 ));
3520 }
3521 crate::build_breaker::BreakerAdmission::Suspended(suspension) => {
3522 crate::logging::log_index_event(
3523 crate::logging::IndexEvent::from_scope(
3524 crate::logging::IndexEventKind::BuildSuspended,
3525 &scope,
3526 )
3527 .field("reason", &suspension.reason),
3528 );
3529 failure_guard.disarm();
3530 return Err(CallGraphStoreError::Suspended(suspension));
3531 }
3532 }
3533 ensure_cold_build_current("inventory", 0, 1)?;
3534 let corpus_fingerprint = temp_store.stage_cold_build_file_inventory(files)?;
3535 ensure_cold_build_current("inventory", 1, 1)?;
3536 let stats = temp_store
3537 .cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)?;
3538 let _ = temp_store.checkpoint_wal_truncate();
3539 temp_store.prepare_for_atomic_swap()?;
3540 (stats, breaker_key)
3541 };
3542
3543 notify_cold_build_before_publish_observer();
3544 let publication = publish_if_current(|| {
3545 verify_writer_lease(&writer_lease)?;
3546 remove_sqlite_file_set(&gen_path);
3549 crate::fs_lock::rename_over(&temp_path, &gen_path)?;
3550 crate::fs_lock::sync_parent(&gen_path);
3551 remove_sqlite_sidecars(&gen_path);
3552
3553 notify_cold_build_swap_observer(&temp_path, &gen_path);
3554
3555 verify_writer_lease(&writer_lease)?;
3557 publish_pointer(callgraph_dir, project_key, &generation)?;
3558 gc_old_generations(callgraph_dir, project_key, &generation);
3559 sweep_orphaned_build_temps_store_wide(callgraph_dir);
3563 sweep_orphaned_callgraph_root_dirs(callgraph_dir);
3564 crate::search_index::sweep_transient_search_cache_dirs();
3565 if let Some(storage_root) = root_storage_dir(callgraph_dir) {
3566 let inspect_root =
3567 storage_root.join(crate::root_cache::RootCacheDomain::Inspect.as_str());
3568 let live_scope_keys = crate::root_cache::live_scope_keys_for_storage(&storage_root);
3569 crate::inspect::cache::sweep_inspect_scope_dirs(&inspect_root, &live_scope_keys);
3570 }
3571 Ok(())
3572 });
3573 if let Err(CallGraphStoreError::Superseded) = &publication {
3577 crate::logging::log_index_event(
3578 crate::logging::IndexEvent::from_scope(
3579 crate::logging::IndexEventKind::BuildSuperseded,
3580 &scope,
3581 )
3582 .field("stage", "publish"),
3583 );
3584 failure_guard.disarm();
3585 }
3586 publication?;
3587 breaker
3590 .record_ready_publication(&breaker_key)
3591 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
3592 crate::logging::log_index_event(crate::logging::IndexEvent::from_scope(
3593 crate::logging::IndexEventKind::BreakerReset,
3594 &scope,
3595 ));
3596 record_successful_rebuild(callgraph_dir, project_key, project_root, Instant::now());
3597 crate::logging::log_index_event(
3598 crate::logging::IndexEvent::from_scope(
3599 crate::logging::IndexEventKind::BuildReady,
3600 &scope,
3601 )
3602 .field("elapsed_ms", scope.elapsed_ms())
3603 .field("files", stats.files)
3604 .field("edges", stats.edges),
3605 );
3606 failure_guard.disarm();
3607 Ok((stats, generation))
3608 }
3609
3610 fn open_generation(
3613 callgraph_dir: &Path,
3614 project_root: PathBuf,
3615 project_key: String,
3616 generation: String,
3617 writer_lease: Arc<crate::root_cache::WriterLease>,
3618 ) -> Result<Self> {
3619 let gen_path = callgraph_dir.join(&generation);
3620 Ok(Self::open_at_path(
3621 project_root,
3622 project_key,
3623 gen_path,
3624 Some(generation),
3625 true,
3626 Some(writer_lease),
3627 None,
3628 )?
3629 .store)
3630 }
3631
3632 pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
3633 let project_key = crate::search_index::artifact_cache_key(project_root);
3634 Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
3637 }
3638
3639 pub fn cold_build_suspension(
3644 callgraph_dir: &Path,
3645 project_root: &Path,
3646 ) -> Result<Option<crate::build_breaker::BuildSuspension>> {
3647 let breaker_path = callgraph_dir.join("build-breaker.sqlite");
3648 if !breaker_path.exists() {
3649 return Ok(None);
3650 }
3651 let key = crate::build_breaker::BreakerKey::new(
3652 project_root.display().to_string(),
3653 crate::build_breaker::BuildDomain::CallgraphCold,
3654 callgraph_corpus_fingerprint(project_root)?,
3655 );
3656 crate::build_breaker::BuildDeathBreaker::open(breaker_path)
3657 .and_then(|breaker| breaker.suspension(&key))
3658 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))
3659 }
3660
3661 fn open_at_path(
3662 project_root: PathBuf,
3663 project_key: String,
3664 sqlite_path: PathBuf,
3665 generation: Option<String>,
3666 use_wal: bool,
3667 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3668 read_marker: Option<crate::root_cache::ReadMarker>,
3669 ) -> Result<OpenedStore> {
3670 Self::open_at_path_with_root_repair(
3671 project_root,
3672 project_key,
3673 sqlite_path,
3674 generation,
3675 use_wal,
3676 writer_lease,
3677 read_marker,
3678 true,
3679 )
3680 }
3681
3682 fn open_at_path_with_root_repair(
3683 project_root: PathBuf,
3684 project_key: String,
3685 sqlite_path: PathBuf,
3686 generation: Option<String>,
3687 use_wal: bool,
3688 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3689 read_marker: Option<crate::root_cache::ReadMarker>,
3690 allow_root_repair: bool,
3691 ) -> Result<OpenedStore> {
3692 if let Some(lease) = writer_lease.as_ref() {
3693 verify_writer_lease(lease)?;
3694 }
3695 if let Some(parent) = sqlite_path.parent() {
3696 std::fs::create_dir_all(parent)?;
3697 }
3698 let mut conn = TrackedConnection::open(&sqlite_path, SqliteStore::CallgraphGeneration)?;
3699 if use_wal {
3700 configure_connection(&conn)?;
3701 } else {
3702 configure_build_connection(&conn)?;
3703 }
3704 if let Some(lease) = writer_lease.as_ref() {
3705 verify_writer_lease(lease)?;
3706 }
3707 initialize_schema(&conn)?;
3708 if let Some(lease) = writer_lease.as_ref() {
3709 verify_writer_lease(lease)?;
3710 }
3711 let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
3712 let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
3713 (Some(marker), _, _) => Some(marker),
3714 (None, Some(label), Some(cache_dir)) => {
3715 Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
3716 }
3717 (None, _, _) => None,
3718 };
3719 let publication_dir = sqlite_path
3720 .parent()
3721 .map(Path::to_path_buf)
3722 .unwrap_or_default();
3723 let store = Self::from_connection(
3724 project_root,
3725 project_key,
3726 sqlite_path,
3727 publication_dir,
3728 false,
3729 generation,
3730 writer_lease,
3731 read_marker,
3732 conn,
3733 );
3734 Ok(OpenedStore { store, root_repair })
3735 }
3736
3737 fn prepare_for_atomic_swap(&self) -> Result<()> {
3738 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3739 conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
3740 Ok(())
3741 }
3742
3743 fn atomic_swap_checkpoint_sql(&self) -> &'static str {
3744 let protected_reader = self.generation.as_deref().is_some_and(|generation| {
3745 self.sqlite_path
3746 .parent()
3747 .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
3748 });
3749 if protected_reader {
3750 "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
3751 } else {
3752 "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
3753 }
3754 }
3755
3756 fn from_connection(
3757 project_root: PathBuf,
3758 project_key: String,
3759 sqlite_path: PathBuf,
3760 publication_dir: PathBuf,
3761 legacy_fallback: bool,
3762 generation: Option<String>,
3763 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3764 read_marker: Option<crate::root_cache::ReadMarker>,
3765 conn: TrackedConnection,
3766 ) -> Self {
3767 let write_metrics = callgraph_write_metrics_for_key(&project_key);
3768 Self {
3769 project_root,
3770 project_key,
3771 sqlite_path,
3772 publication_dir,
3773 legacy_fallback,
3774 manifest_view: false,
3775 generation,
3776 writer_lease,
3777 read_marker,
3778 database_ready: AtomicBool::new(false),
3779 write_metrics,
3780 conn: Mutex::new(conn),
3781 }
3782 }
3783
3784 fn ensure_ready(&self, conn: &Connection) -> Result<()> {
3785 if self.database_ready.load(AtomicOrdering::Acquire) {
3786 return Ok(());
3787 }
3788 ensure_database_ready(conn)?;
3789 self.database_ready.store(true, AtomicOrdering::Release);
3790 Ok(())
3791 }
3792
3793 pub fn project_root(&self) -> &Path {
3794 &self.project_root
3795 }
3796
3797 pub fn project_key(&self) -> &str {
3798 &self.project_key
3799 }
3800
3801 pub fn sqlite_path(&self) -> &Path {
3802 &self.sqlite_path
3803 }
3804
3805 pub(crate) fn projection_generation(&self) -> Option<&str> {
3807 self.generation.as_deref()
3808 }
3809
3810 pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
3812 self.refresh_read_marker()?;
3813 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3814 self.ensure_ready(&conn)?;
3815 projection_write_revision(&conn)
3816 }
3817
3818 pub fn is_legacy_fallback(&self) -> bool {
3821 self.legacy_fallback
3822 }
3823
3824 pub(crate) fn is_legacy_migration(&self) -> bool {
3825 self.generation.as_deref().is_some_and(|generation| {
3826 migration_generation_requires_manifest(generation)
3827 && migration_manifest_valid(&self.publication_dir, generation)
3828 })
3829 }
3830
3831 pub fn writer_epoch_for_test(&self) -> Option<&str> {
3832 self.writer_lease.as_ref().map(|lease| lease.epoch())
3833 }
3834
3835 fn verify_writer_lease(&self) -> Result<()> {
3836 let Some(lease) = self.writer_lease.as_ref() else {
3837 return Err(CallGraphStoreError::Unavailable(
3838 "callgraph store opened read-only; write API is unavailable".to_string(),
3839 ));
3840 };
3841 verify_writer_lease(lease)
3842 }
3843
3844 fn refresh_read_marker(&self) -> Result<()> {
3845 if let Some(marker) = self.read_marker.as_ref() {
3846 marker.touch_if_due()?;
3847 }
3848 Ok(())
3849 }
3850
3851 fn record_commit(&self, total_changes_before: u64, conn: &Connection) {
3852 self.write_metrics
3853 .record_commit(conn.total_changes().saturating_sub(total_changes_before));
3854 }
3855
3856 fn checkpoint_wal_truncate(&self) -> bool {
3857 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3858 checkpoint_wal_truncate(&conn)
3859 }
3860
3861 pub fn is_current(&self) -> bool {
3867 let _ = self.refresh_read_marker();
3868 match (
3869 read_pointer(&self.publication_dir, &self.project_key),
3870 &self.generation,
3871 ) {
3872 (Some(_), _) if self.legacy_fallback => false,
3875 (Some(published), Some(opened)) => &published == opened,
3876 (Some(_), None) => false,
3878 (None, _) => true,
3881 }
3882 }
3883
3884 pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
3885 self.cold_build_chunked(files, COLD_BUILD_EXTRACT_BATCH_FILES)
3886 }
3887
3888 pub fn cold_build_chunked(
3892 &self,
3893 files: &[PathBuf],
3894 chunk_size: usize,
3895 ) -> Result<ColdBuildStats> {
3896 let corpus_fingerprint = self.stage_cold_build_file_inventory(files)?;
3897 self.cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)
3898 }
3899
3900 fn stage_cold_build_file_inventory(&self, files: &[PathBuf]) -> Result<String> {
3901 note_cold_build_phase("enumeration");
3902 if files.is_empty() {
3903 self.stage_cold_build_file_inventory_from(callgraph::walk_project_files(
3904 &self.project_root,
3905 ))
3906 } else {
3907 self.stage_cold_build_file_inventory_from(files.iter().cloned())
3908 }
3909 }
3910
3911 fn stage_cold_build_file_inventory_from<I>(&self, paths: I) -> Result<String>
3912 where
3913 I: IntoIterator<Item = PathBuf>,
3914 {
3915 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3916 self.verify_writer_lease()?;
3917 let total_changes_before = conn.total_changes();
3918 let tx = conn.transaction()?;
3919 tx.execute("DELETE FROM staging_file_inventory", [])?;
3920 tx.commit()?;
3921 self.record_commit(total_changes_before, &conn);
3922
3923 let mut batch = Vec::with_capacity(COLD_BUILD_EXTRACT_BATCH_FILES);
3924 for path in paths {
3925 let path = normalize_file_path(&self.project_root, &path)?;
3926 let rel_path = relative_path(&self.project_root, &path);
3927 let size = std::fs::metadata(&path)
3928 .map(|metadata| metadata.len())
3929 .unwrap_or(0);
3930 batch.push((rel_path, size));
3931 if batch.len() == COLD_BUILD_EXTRACT_BATCH_FILES {
3932 self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3933 batch.clear();
3934 }
3935 }
3936 if !batch.is_empty() {
3937 self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3938 }
3939
3940 staged_corpus_fingerprint(&conn, &self.project_root)
3941 }
3942
3943 fn insert_staged_file_inventory_batch(
3944 &self,
3945 conn: &mut Connection,
3946 batch: &[(String, u64)],
3947 ) -> Result<()> {
3948 self.verify_writer_lease()?;
3949 let total_changes_before = conn.total_changes();
3950 let tx = conn.transaction()?;
3951 {
3952 let mut insert = tx.prepare(
3953 "INSERT OR REPLACE INTO staging_file_inventory(path, size) VALUES(?1, ?2)",
3954 )?;
3955 for (path, size) in batch {
3956 insert.execute(params![path, *size as i64])?;
3957 }
3958 }
3959 tx.commit()?;
3960 self.record_commit(total_changes_before, conn);
3961 Ok(())
3962 }
3963
3964 fn cold_build_chunked_from_staged_inventory(
3965 &self,
3966 chunk_size: usize,
3967 corpus_fingerprint: &str,
3968 ) -> Result<ColdBuildStats> {
3969 let module_resolution_memo = callgraph::ModuleResolutionMemo::default();
3970 self.cold_build_chunked_from_staged_inventory_with_resolution_memo(
3971 chunk_size,
3972 corpus_fingerprint,
3973 COLD_BUILD_RESOLVE_WINDOW,
3974 &module_resolution_memo,
3975 true,
3976 )
3977 }
3978
3979 #[cfg(test)]
3980 fn cold_build_chunked_with_resolution_memo_for_test(
3981 &self,
3982 files: &[PathBuf],
3983 chunk_size: usize,
3984 resolve_window: usize,
3985 module_resolution_memo: &callgraph::ModuleResolutionMemo,
3986 ) -> Result<ColdBuildStats> {
3987 self.cold_build_chunked_with_disk_index_memo_for_test(
3988 files,
3989 chunk_size,
3990 resolve_window,
3991 module_resolution_memo,
3992 true,
3993 )
3994 }
3995
3996 #[cfg(test)]
3997 fn cold_build_chunked_with_disk_index_memo_for_test(
3998 &self,
3999 files: &[PathBuf],
4000 chunk_size: usize,
4001 resolve_window: usize,
4002 module_resolution_memo: &callgraph::ModuleResolutionMemo,
4003 memoize_resolver_indexes: bool,
4004 ) -> Result<ColdBuildStats> {
4005 let corpus_fingerprint = self.stage_cold_build_file_inventory(files)?;
4006 self.cold_build_chunked_from_staged_inventory_with_resolution_memo(
4007 chunk_size,
4008 &corpus_fingerprint,
4009 resolve_window.max(1),
4010 module_resolution_memo,
4011 memoize_resolver_indexes,
4012 )
4013 }
4014
4015 fn cold_build_chunked_from_staged_inventory_with_resolution_memo(
4016 &self,
4017 chunk_size: usize,
4018 corpus_fingerprint: &str,
4019 resolve_window: usize,
4020 module_resolution_memo: &callgraph::ModuleResolutionMemo,
4021 memoize_resolver_indexes: bool,
4022 ) -> Result<ColdBuildStats> {
4023 let started = Instant::now();
4024 let batch_files = chunk_size.max(1).min(COLD_BUILD_EXTRACT_BATCH_FILES);
4025 let workspace_root = self.project_root.display().to_string();
4026 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
4027
4028 self.verify_writer_lease()?;
4029 ensure_cold_build_current("staging-admission", 0, 1)?;
4030 let mut phase = staged_build_phase(&conn)?;
4031 let staged_fingerprint = staged_string(&conn, STAGED_CORPUS_FINGERPRINT)?;
4032 let fingerprint_matches = staged_fingerprint.as_deref() == Some(corpus_fingerprint);
4033 if phase.as_deref() == Some("ready") && fingerprint_matches {
4034 ensure_cold_build_current("completed-staging", 1, 1)?;
4035 crate::slog_info!(
4036 "callgraph cold-build decision: reason=matching completed staging; action=publish"
4037 );
4038 conn.execute("DELETE FROM staging_file_inventory", [])?;
4039 return cold_build_stats_from_connection(&conn, started);
4040 }
4041 if phase.is_none() || !fingerprint_matches {
4042 if staged_fingerprint.is_some() && !fingerprint_matches {
4043 crate::slog_info!(
4044 "callgraph cold-build decision: reason=fingerprint mismatch; action=restart staging"
4045 );
4046 }
4047 let total_changes_before = conn.total_changes();
4048 let tx = conn.transaction()?;
4049 clear_tables(&tx)?;
4050 tx.execute("DELETE FROM staging_ref_context", [])?;
4051 insert_meta(&tx)?;
4052 drop_cold_build_secondary_indexes(&tx)?;
4053 set_meta_ready(&tx, false)?;
4054 set_staged_build_phase(&tx, "extracting")?;
4055 set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, corpus_fingerprint)?;
4056 set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0)?;
4057 set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, 0)?;
4058 tx.commit()?;
4059 self.record_commit(total_changes_before, &conn);
4060 phase = Some("extracting".to_string());
4061 }
4062
4063 note_cold_build_phase("extraction");
4067 if phase.as_deref() == Some("extracting") {
4068 prune_staged_files_not_in_inventory(&mut conn)?;
4069
4070 let total_files =
4071 query_count(&conn, "SELECT COUNT(*) FROM staging_file_inventory")? as usize;
4072 let mut completed_files = 0usize;
4073 ensure_cold_build_current("extraction", completed_files, total_files)?;
4074 let mut after_path = String::new();
4075 loop {
4076 let Some(batch) = load_staged_file_batch(
4077 &conn,
4078 &self.project_root,
4079 &after_path,
4080 batch_files,
4081 COLD_BUILD_EXTRACT_BATCH_BYTES,
4082 )?
4083 else {
4084 break;
4085 };
4086 after_path = batch.last_path;
4087 let batch_files = batch.paths.len();
4088
4089 let mut needs_extract = Vec::with_capacity(batch_files);
4090 for path in batch.paths {
4091 if !staged_content_matches(&conn, &self.project_root, &path)? {
4092 needs_extract.push(path);
4093 }
4094 }
4095 if needs_extract.is_empty() {
4096 completed_files = completed_files.saturating_add(batch_files);
4097 ensure_cold_build_current("extraction", completed_files, total_files)?;
4098 continue;
4099 }
4100
4101 notify_cold_build_extract_observer(&needs_extract);
4102 let build = build_extracts_parallel(&self.project_root, &needs_extract);
4103 self.verify_writer_lease()?;
4104 let total_changes_before = conn.total_changes();
4105 let tx = conn.transaction()?;
4106 let mut extracted_bytes = 0u64;
4107 {
4108 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
4109 for extract in &build.extracts {
4110 delete_staged_file_rows(&tx, &extract.rel_path)?;
4111 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
4112 for raw in &extract.raw_refs {
4113 insert_staged_ref_prepared(&mut inserts, raw)?;
4114 }
4115 extracted_bytes = extracted_bytes.saturating_add(extract.freshness.size);
4116 }
4117 for failure in &build.failures {
4118 insert_backend_state_prepared(
4119 &mut inserts.backend_state,
4120 &workspace_root,
4121 &failure.rel_path,
4122 failure
4123 .freshness
4124 .as_ref()
4125 .map(|freshness| &freshness.content_hash),
4126 "stale",
4127 )?;
4128 }
4129 }
4130 increment_staged_extracted_bytes(&tx, extracted_bytes)?;
4131 note_cold_build_commit_barrier("extraction_batch_before_commit");
4132 tx.commit()?;
4133 note_cold_build_commit_barrier("extraction_batch_committed");
4134 self.record_commit(total_changes_before, &conn);
4135 completed_files = completed_files.saturating_add(batch_files);
4136 ensure_cold_build_current("extraction", completed_files, total_files)?;
4137 }
4138
4139 ensure_cold_build_current("extraction", completed_files, total_files)?;
4140 let total_changes_before = conn.total_changes();
4141 let tx = conn.transaction()?;
4142 set_staged_build_phase(&tx, "indexing")?;
4143 tx.commit()?;
4144 self.record_commit(total_changes_before, &conn);
4145 phase = Some("indexing".to_string());
4146 ensure_cold_build_current("extraction", total_files, total_files)?;
4147 }
4148
4149 note_cold_build_phase("symbol_export_index");
4153 if phase.as_deref() == Some("indexing") {
4154 ensure_cold_build_current("symbol-export-index", 0, 1)?;
4155 self.verify_writer_lease()?;
4156 let total_changes_before = conn.total_changes();
4157 let tx = conn.transaction()?;
4158 create_cold_build_secondary_indexes(&tx)?;
4159 set_staged_build_phase(&tx, "resolving")?;
4160 tx.commit()?;
4161 self.record_commit(total_changes_before, &conn);
4162 ensure_cold_build_current("symbol-export-index", 1, 1)?;
4163 }
4164
4165 note_cold_build_phase("resolution");
4166 let workspace_crate_prefixes = WorkspaceCratePrefixCache::default();
4167 let total_refs = query_count(&conn, "SELECT COUNT(*) FROM refs")? as usize;
4168 let mut resolved_refs =
4169 query_count(&conn, "SELECT COUNT(*) FROM refs WHERE status <> 'staged'")? as usize;
4170 ensure_cold_build_current("resolution", resolved_refs, total_refs)?;
4171 let mut resolve_cursor = staged_u64(&conn, STAGED_RESOLVE_CURSOR)?;
4172 loop {
4173 let staged = load_staged_ref_window(&conn, resolve_cursor, resolve_window)?;
4174 let Some(last_rowid) = staged.last().map(|entry| entry.rowid) else {
4175 break;
4176 };
4177
4178 self.verify_writer_lease()?;
4179 let total_changes_before = conn.total_changes();
4180 let tx = conn.transaction()?;
4181 {
4182 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
4183 let mut offset = 0;
4184 while offset < staged.len() {
4185 let caller_file = staged[offset].raw.caller_file.clone();
4186 let end = staged[offset..]
4187 .iter()
4188 .position(|entry| entry.raw.caller_file != caller_file)
4189 .map(|relative| offset + relative)
4190 .unwrap_or(staged.len());
4191 let caller_extract = build_file_extract(
4192 &self.project_root,
4193 &self.project_root.join(&caller_file),
4194 );
4195 if let Ok(caller_extract) = caller_extract {
4196 let index = DiskProjectIndex {
4197 project_root: &self.project_root,
4198 conn: &tx,
4199 caller_file: &caller_file,
4200 caller_data: &caller_extract.data,
4201 workspace_crate_prefixes: workspace_crate_prefixes.clone(),
4202 module_resolution_memo,
4203 file_index_memo: RefCell::new(HashMap::new()),
4204 module_parent_memo: RefCell::new(HashMap::new()),
4205 memoize_resolver_indexes,
4206 };
4207 for staged_ref in &staged[offset..end] {
4208 let resolved = resolve_ref(staged_ref.raw.clone(), &index)?;
4209 insert_resolved_ref_prepared(&mut inserts, &resolved)?;
4210 }
4211 } else {
4212 for staged_ref in &staged[offset..end] {
4213 let unresolved = unresolved_staged_ref(staged_ref.raw.clone());
4214 insert_resolved_ref_prepared(&mut inserts, &unresolved)?;
4215 }
4216 }
4217 offset = end;
4218 }
4219 }
4220 set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, last_rowid)?;
4221 tx.commit()?;
4222 self.record_commit(total_changes_before, &conn);
4223 resolve_cursor = last_rowid;
4224 resolved_refs = resolved_refs.saturating_add(staged.len()).min(total_refs);
4225 ensure_cold_build_current("resolution", resolved_refs, total_refs)?;
4226 }
4227
4228 ensure_cold_build_current("resolution", resolved_refs, total_refs)?;
4229 note_cold_build_phase("publication");
4230 self.verify_writer_lease()?;
4231 let total_changes_before = conn.total_changes();
4232 let tx = conn.transaction()?;
4233 let _supplemental_edge_count =
4234 insert_method_dispatch_edges_chunked(&tx, &self.project_root, batch_files)?;
4235 set_meta_ready(&tx, true)?;
4236 set_staged_build_phase(&tx, "ready")?;
4237 tx.execute("DELETE FROM staging_file_inventory", [])?;
4238 tx.execute("DELETE FROM staging_ref_context", [])?;
4239 bump_projection_write_revision(&tx)?;
4240 tx.commit()?;
4241 self.record_commit(total_changes_before, &conn);
4242
4243 cold_build_stats_from_connection(&conn, started)
4244 }
4245
4246 pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
4247 self.refresh_files_with_workspace_crate_prefix_cache(
4248 changed_files,
4249 WorkspaceCratePrefixCache::default(),
4250 )
4251 }
4252
4253 fn refresh_files_with_workspace_crate_prefix_cache(
4254 &self,
4255 changed_files: &[PathBuf],
4256 workspace_crate_prefixes: WorkspaceCratePrefixCache,
4257 ) -> Result<IncrementalStats> {
4258 let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
4259 changed_files,
4260 workspace_crate_prefixes,
4261 )?;
4262 if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
4263 eprintln!("refresh_files phases: {}", profile.report());
4264 }
4265 Ok(stats)
4266 }
4267
4268 #[doc(hidden)]
4270 pub fn refresh_files_profiled(
4271 &self,
4272 changed_files: &[PathBuf],
4273 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
4274 self.refresh_files_profiled_with_workspace_crate_prefix_cache(
4275 changed_files,
4276 WorkspaceCratePrefixCache::default(),
4277 )
4278 }
4279
4280 fn refresh_files_profiled_with_workspace_crate_prefix_cache(
4281 &self,
4282 changed_files: &[PathBuf],
4283 workspace_crate_prefixes: WorkspaceCratePrefixCache,
4284 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
4285 let _io = crate::views::io::Window::event("legacy_callgraph_refresh", &self.project_root);
4286 let total_started = Instant::now();
4287 let mut profile = RefreshFilesProfile::default();
4288 self.verify_writer_lease()?;
4289 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
4290 ensure_database_ready(&conn)?;
4291 let total_changes_before = conn.total_changes();
4292 let mut changed = Vec::new();
4293 let mut surface_changed = BTreeSet::new();
4294 let mut deleted = BTreeSet::new();
4295 let mut own_refresh = BTreeSet::new();
4296 let mut candidate_own_refresh = BTreeSet::new();
4297 let mut confirmed_fresh = BTreeSet::new();
4298 let mut unchanged_extracts = 0usize;
4299 let mut selected_ref_ids = BTreeSet::new();
4300 let mut selected_refs_by_caller = BTreeMap::new();
4301 let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
4302 let mut fresh_metadata = BTreeMap::new();
4303
4304 for rel_path in stale_backend_file_paths(&conn, &self.project_root, true)? {
4310 if stale_path_status(&self.project_root, &rel_path) != StalePathStatus::Absent {
4311 continue;
4312 }
4313 if deleted.insert(rel_path.clone()) && load_file_row(&conn, &rel_path)?.is_some() {
4314 surface_changed.insert(rel_path.clone());
4315 let started = Instant::now();
4316 let dependent_refs = ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
4317 profile.dependency_selection += started.elapsed();
4318 record_dependent_refs(
4319 &mut selected_ref_ids,
4320 &mut selected_refs_by_caller,
4321 dependent_refs,
4322 );
4323 }
4324 }
4325
4326 for input in changed_files {
4327 let (abs_path, rel_path) = match normalize_project_file_path(&self.project_root, input)
4328 {
4329 Ok(path) => path,
4330 Err(error) => {
4331 record_path_identity_mismatch(&conn, &error)?;
4332 return Err(error);
4333 }
4334 };
4335 changed.push(rel_path.clone());
4336 let old_row = load_file_row(&conn, &rel_path)?;
4337 if !abs_path.exists() {
4338 if old_row.is_some() && deleted.insert(rel_path.clone()) {
4339 surface_changed.insert(rel_path.clone());
4340 let started = Instant::now();
4341 let dependent_refs =
4342 ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
4343 profile.dependency_selection += started.elapsed();
4344 record_dependent_refs(
4345 &mut selected_ref_ids,
4346 &mut selected_refs_by_caller,
4347 dependent_refs,
4348 );
4349 }
4350 continue;
4351 }
4352
4353 if let Some(row) = &old_row {
4354 match cache_freshness::verify_file(&abs_path, &row.freshness) {
4355 FreshnessVerdict::HotFresh => {
4356 confirmed_fresh.insert(rel_path.clone());
4361 continue;
4362 }
4363 FreshnessVerdict::ContentFresh {
4364 new_mtime,
4365 new_size,
4366 } => {
4367 fresh_metadata.insert(
4368 rel_path.clone(),
4369 FileFreshness {
4370 content_hash: row.freshness.content_hash,
4371 mtime: new_mtime,
4372 size: new_size,
4373 },
4374 );
4375 continue;
4376 }
4377 FreshnessVerdict::Deleted => {
4378 if deleted.insert(rel_path.clone()) {
4379 surface_changed.insert(rel_path.clone());
4380 let started = Instant::now();
4381 let dependent_refs =
4382 ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
4383 profile.dependency_selection += started.elapsed();
4384 record_dependent_refs(
4385 &mut selected_ref_ids,
4386 &mut selected_refs_by_caller,
4387 dependent_refs,
4388 );
4389 }
4390 continue;
4391 }
4392 FreshnessVerdict::Stale => {}
4393 }
4394 }
4395
4396 let started = Instant::now();
4397 let extract = build_file_extract(&self.project_root, &abs_path)?;
4398 profile.parse += started.elapsed();
4399 let surface_is_changed = old_row
4400 .as_ref()
4401 .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
4402 .unwrap_or(true);
4403 if surface_is_changed {
4404 surface_changed.insert(rel_path.clone());
4405 let started = Instant::now();
4406 let dependent_refs = ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
4407 profile.dependency_selection += started.elapsed();
4408 record_dependent_refs(
4409 &mut selected_ref_ids,
4410 &mut selected_refs_by_caller,
4411 dependent_refs,
4412 );
4413 }
4414 candidate_own_refresh.insert(rel_path.clone());
4415 changed_extracts.insert(rel_path, extract);
4416 }
4417
4418 let dependency_selected_refs = selected_ref_ids.len();
4419 let mut touched_callers: BTreeSet<String> =
4420 selected_refs_by_caller.keys().cloned().collect();
4421 touched_callers.extend(candidate_own_refresh.iter().cloned());
4422
4423 let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
4424 for rel_path in &touched_callers {
4425 if deleted.contains(rel_path) {
4426 continue;
4427 }
4428 if let Some(extract) = changed_extracts.get(rel_path) {
4429 caller_extracts.insert(rel_path.clone(), extract.clone());
4430 continue;
4431 }
4432 let abs_path = self.project_root.join(rel_path);
4433 if abs_path.exists() {
4434 let started = Instant::now();
4435 let extract = build_file_extract(&self.project_root, &abs_path)?;
4436 profile.dependent_parse += started.elapsed();
4437 caller_extracts.insert(rel_path.clone(), extract);
4438 }
4439 }
4440
4441 let mut projection_callers = touched_callers.clone();
4442 projection_callers.extend(deleted.iter().cloned());
4443 for file in touched_callers.iter().chain(deleted.iter()) {
4444 dead_code_projection::extend_projection_dependents(
4445 &conn,
4446 file,
4447 &mut projection_callers,
4448 )?;
4449 }
4450
4451 let tx = conn.transaction()?;
4452 for (rel_path, freshness) in fresh_metadata {
4453 update_file_fresh_metadata(
4454 &tx,
4455 &self.project_root,
4456 &rel_path,
4457 &freshness.content_hash,
4458 freshness.mtime,
4459 freshness.size,
4460 )?;
4461 }
4462 for rel_path in &confirmed_fresh {
4463 clear_stale_backend_status_for_file(&tx, &self.project_root, rel_path)?;
4464 }
4465 for rel_path in &deleted {
4466 let started = Instant::now();
4467 delete_file_rows(&tx, rel_path)?;
4468 clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
4469 profile.row_deletes += started.elapsed();
4470 }
4471
4472 if caller_extracts.is_empty() {
4476 let wrote_rows = tx.total_changes() != total_changes_before;
4477 if !deleted.is_empty() {
4478 dead_code_projection::record_projection_delta(&tx, &projection_callers)?;
4479 }
4480 let started = Instant::now();
4481 commit_incremental_if_current(tx)?;
4482 if wrote_rows {
4483 self.record_commit(total_changes_before, &conn);
4484 }
4485 profile.commit += started.elapsed();
4486 profile.total = total_started.elapsed();
4487 return Ok((
4488 IncrementalStats {
4489 changed_files: changed,
4490 surface_changed: surface_changed.into_iter().collect(),
4491 deleted_files: deleted.into_iter().collect(),
4492 dependency_selected_refs,
4493 refreshed_own_files: 0,
4494 unchanged_extract_files: 0,
4495 },
4496 profile,
4497 ));
4498 }
4499
4500 let started = Instant::now();
4501 profile.index_loads += 1;
4502 let index = ProjectIndex::from_db_and_callers(
4503 &tx,
4504 &self.project_root,
4505 &caller_extracts,
4506 workspace_crate_prefixes,
4507 )?;
4508 profile.index_load += started.elapsed();
4509
4510 let workspace_root = self.project_root.display().to_string();
4511 {
4512 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
4513 for rel_path in &candidate_own_refresh {
4514 let Some(extract) = changed_extracts.get(rel_path) else {
4515 continue;
4516 };
4517 if stored_extract_matches(&tx, rel_path, extract, &index)? {
4518 unchanged_extracts += 1;
4519 update_file_fresh_metadata(
4520 &tx,
4521 &self.project_root,
4522 rel_path,
4523 &extract.freshness.content_hash,
4524 extract.freshness.mtime,
4525 extract.freshness.size,
4526 )?;
4527 continue;
4528 }
4529
4530 own_refresh.insert(rel_path.clone());
4531 let started = Instant::now();
4532 delete_file_rows(&tx, rel_path)?;
4533 clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
4534 profile.row_deletes += started.elapsed();
4535 let started = Instant::now();
4536 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
4537 profile.row_inserts += started.elapsed();
4538 }
4539
4540 let dependency_callers = touched_callers
4541 .iter()
4542 .filter(|rel_path| {
4543 !deleted.contains(*rel_path) && !candidate_own_refresh.contains(*rel_path)
4544 })
4545 .cloned()
4546 .collect::<Vec<_>>();
4547 for rel_path in dependency_callers {
4548 let Some(extract) = caller_extracts.get(&rel_path) else {
4549 continue;
4550 };
4551 if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
4552 continue;
4553 }
4554
4555 own_refresh.insert(rel_path.clone());
4556 let started = Instant::now();
4557 delete_file_rows(&tx, &rel_path)?;
4558 clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
4559 profile.row_deletes += started.elapsed();
4560 let started = Instant::now();
4561 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
4562 profile.row_inserts += started.elapsed();
4563 }
4564 let started = Instant::now();
4565 for rel_path in &touched_callers {
4566 if deleted.contains(rel_path) {
4567 continue;
4568 }
4569 let Some(extract) = caller_extracts.get(rel_path) else {
4570 continue;
4571 };
4572 if own_refresh.contains(rel_path) {
4573 delete_refs_for_caller(&tx, rel_path)?;
4574 for raw_ref in &extract.raw_refs {
4575 let resolved = resolve_ref(raw_ref.clone(), &index)?;
4576 insert_resolved_ref_prepared(&mut inserts, &resolved)?;
4577 }
4578 continue;
4579 }
4580
4581 let selected_for_caller = selected_refs_by_caller
4582 .get(rel_path)
4583 .cloned()
4584 .unwrap_or_default();
4585 delete_ref_ids(&tx, &selected_for_caller)?;
4586 for raw_ref in &extract.raw_refs {
4587 if selected_for_caller.contains(&raw_ref.ref_id) {
4588 let resolved = resolve_ref(raw_ref.clone(), &index)?;
4589 insert_resolved_ref_prepared(&mut inserts, &resolved)?;
4590 }
4591 }
4592 }
4593 profile.ref_resolution += started.elapsed();
4594 }
4595
4596 let started = Instant::now();
4597 delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
4598 insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
4599 profile.method_dispatch += started.elapsed();
4600
4601 if !own_refresh.is_empty() || !selected_ref_ids.is_empty() || !deleted.is_empty() {
4604 dead_code_projection::record_projection_delta(&tx, &projection_callers)?;
4605 }
4606 let started = Instant::now();
4607 commit_incremental_if_current(tx)?;
4608 self.record_commit(total_changes_before, &conn);
4609 profile.commit += started.elapsed();
4610 profile.total = total_started.elapsed();
4611 Ok((
4612 IncrementalStats {
4613 changed_files: changed,
4614 surface_changed: surface_changed.into_iter().collect(),
4615 deleted_files: deleted.into_iter().collect(),
4616 dependency_selected_refs,
4617 refreshed_own_files: own_refresh.len(),
4618 unchanged_extract_files: unchanged_extracts,
4619 },
4620 profile,
4621 ))
4622 }
4623
4624 pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
4625 self.cold_build(current_files)
4626 }
4627
4628 pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
4629 self.verify_writer_lease()?;
4630 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
4631 let total_changes_before = conn.total_changes();
4632 let tx = conn.transaction()?;
4633 let mut marked = Vec::new();
4634 for path in files {
4635 let (abs_path, rel_path) = match normalize_project_file_path(&self.project_root, path) {
4636 Ok(path) => path,
4637 Err(error) => {
4638 drop(tx);
4639 record_path_identity_mismatch(&conn, &error)?;
4640 return Err(error);
4641 }
4642 };
4643 let freshness = cache_freshness::collect(&abs_path).ok();
4644 mark_backend_state(
4645 &tx,
4646 &self.project_root,
4647 &rel_path,
4648 freshness.as_ref().map(|freshness| &freshness.content_hash),
4649 "stale",
4650 )?;
4651 marked.push(rel_path);
4652 }
4653 tx.commit()?;
4657 self.record_commit(total_changes_before, &conn);
4658 marked.sort();
4659 marked.dedup();
4660 Ok(marked)
4661 }
4662
4663 pub fn stale_files(&self) -> Result<Vec<String>> {
4664 self.refresh_read_marker()?;
4665 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4666 stale_backend_file_paths(&conn, &self.project_root, true)
4667 }
4668
4669 pub fn stale_path_census(&self) -> Result<StalePathCensus> {
4670 self.refresh_read_marker()?;
4671 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4672 stale_path_census(&conn, &self.project_root)
4673 }
4674
4675 pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
4676 self.refresh_read_marker()?;
4677 let rel_path = relative_path(
4678 &self.project_root,
4679 &normalize_file_path(&self.project_root, file)?,
4680 );
4681 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4682 conn.query_row(
4683 "SELECT status FROM backend_file_state
4684 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
4685 ORDER BY updated_at DESC LIMIT 1",
4686 params![
4687 BACKEND_TREESITTER,
4688 self.project_root.display().to_string(),
4689 rel_path
4690 ],
4691 |row| row.get(0),
4692 )
4693 .optional()
4694 .map_err(Into::into)
4695 }
4696
4697 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4698 self.refresh_read_marker()?;
4699 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4700 self.ensure_ready(&conn)?;
4701 edge_snapshot_with_conn(&conn)
4702 }
4703
4704 pub fn indexed_file_count(&self) -> Result<usize> {
4705 self.refresh_read_marker()?;
4706 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4707 self.ensure_ready(&conn)?;
4708 indexed_file_count(&conn)
4709 }
4710
4711 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4712 self.refresh_read_marker()?;
4713 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4714 let rel_path = relative_path(&self.project_root, &abs_path);
4715 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4716 self.ensure_ready(&conn)?;
4717 resolve_node_for_rel(&conn, &rel_path, symbol)
4718 }
4719
4720 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4725 self.refresh_read_marker()?;
4726 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4727 let rel_path = relative_path(&self.project_root, &abs_path);
4728 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4729 self.ensure_ready(&conn)?;
4730 nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
4731 }
4732
4733 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4735 self.refresh_read_marker()?;
4736 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4737 self.ensure_ready(&conn)?;
4738 nodes_matching_symbol(&conn, symbol)
4739 }
4740
4741 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4743 self.refresh_read_marker()?;
4744 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4745 let rel_path = relative_path(&self.project_root, &abs_path);
4746 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4747 self.ensure_ready(&conn)?;
4748 direct_callers_for_tuple(&conn, &rel_path, symbol)
4749 }
4750
4751 pub fn direct_callers_for_symbols(
4753 &self,
4754 targets: &[(String, String)],
4755 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4756 if targets.is_empty() {
4757 return Ok(HashMap::new());
4758 }
4759 self.refresh_read_marker()?;
4760 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4761 self.ensure_ready(&conn)?;
4762 direct_callers_for_tuples(&conn, targets)
4763 }
4764
4765 pub fn direct_caller_counts_of(
4767 &self,
4768 targets: &[(String, String)],
4769 ) -> Result<HashMap<(String, String), usize>> {
4770 if targets.is_empty() {
4771 return Ok(HashMap::new());
4772 }
4773 self.refresh_read_marker()?;
4774 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4775 self.ensure_ready(&conn)?;
4776 direct_caller_counts_for_tuples(&conn, targets)
4777 }
4778
4779 pub fn callers_of(
4780 &self,
4781 file_rel: &Path,
4782 symbol: &str,
4783 depth: usize,
4784 ) -> Result<StoreCallersResult> {
4785 let target = self.node_for(file_rel, symbol)?;
4786 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4787 self.ensure_ready(&conn)?;
4788 let effective_depth = depth.max(1);
4789 let mut visited = HashSet::new();
4790 let mut callers = Vec::new();
4791 let mut depth_limited = false;
4792 let mut truncated = 0usize;
4793 collect_callers_recursive(
4794 &conn,
4795 &target.file,
4796 &target.symbol,
4797 effective_depth,
4798 0,
4799 &mut visited,
4800 &mut callers,
4801 &mut depth_limited,
4802 &mut truncated,
4803 )?;
4804 Ok(StoreCallersResult {
4805 target,
4806 callers,
4807 scanned_files: indexed_file_count(&conn)?,
4808 depth_limited,
4809 truncated,
4810 })
4811 }
4812
4813 pub fn impact_of(
4814 &self,
4815 file_rel: &Path,
4816 symbol: &str,
4817 depth: usize,
4818 ) -> Result<StoreImpactResult> {
4819 let callers = self.callers_of(file_rel, symbol, depth)?;
4820 let target_parameters = callers
4821 .target
4822 .signature
4823 .as_deref()
4824 .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
4825 .unwrap_or_default();
4826 let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
4827 for site in &callers.callers {
4828 source_lines_by_file
4829 .entry(site.caller.file.clone())
4830 .or_insert_with(|| {
4831 read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
4832 });
4833 }
4834 let enriched = callers
4835 .callers
4836 .iter()
4837 .map(|site| StoreImpactCaller {
4838 site: site.clone(),
4839 signature: site.caller.signature.clone(),
4840 is_entry_point: site.caller.is_entry_point,
4841 call_expression: source_lines_by_file
4842 .get(&site.caller.file)
4843 .and_then(|lines| lines.as_ref())
4844 .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
4845 .cloned(),
4846 parameters: site
4847 .caller
4848 .signature
4849 .as_deref()
4850 .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
4851 .unwrap_or_default(),
4852 })
4853 .collect();
4854 Ok(StoreImpactResult {
4855 target: callers.target,
4856 parameters: target_parameters,
4857 callers: enriched,
4858 depth_limited: callers.depth_limited,
4859 truncated: callers.truncated,
4860 })
4861 }
4862
4863 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4864 self.refresh_read_marker()?;
4865 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4866 self.ensure_ready(&conn)?;
4867 outgoing_calls_for_node(&conn, node)
4868 }
4869
4870 pub fn outgoing_calls_for_symbols(
4872 &self,
4873 sources: &[(String, String)],
4874 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4875 if sources.is_empty() {
4876 return Ok(HashMap::new());
4877 }
4878 self.refresh_read_marker()?;
4879 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4880 self.ensure_ready(&conn)?;
4881 outgoing_calls_for_symbol_tuples(&conn, sources)
4882 }
4883
4884 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4886 self.refresh_read_marker()?;
4887 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4888 self.ensure_ready(&conn)?;
4889 resolved_self_calls_for_node(&conn, node)
4890 }
4891
4892 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4893 self.refresh_read_marker()?;
4894 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4895 self.ensure_ready(&conn)?;
4896 unresolved_calls_for_node(&conn, node)
4897 }
4898
4899 pub fn call_tree(
4900 &self,
4901 file_rel: &Path,
4902 symbol: &str,
4903 max_depth: usize,
4904 ) -> Result<callgraph::CallTreeNode> {
4905 let node = self.node_for(file_rel, symbol)?;
4906 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4907 self.ensure_ready(&conn)?;
4908 let mut visited = HashSet::new();
4909 call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
4910 }
4911
4912 pub fn trace_to(
4913 &self,
4914 file_rel: &Path,
4915 symbol: &str,
4916 max_depth: usize,
4917 ) -> Result<callgraph::TraceToResult> {
4918 let target = self.node_for(file_rel, symbol)?;
4919 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4920 self.ensure_ready(&conn)?;
4921 let effective_max = if max_depth == 0 { 10 } else { max_depth };
4922
4923 #[derive(Clone)]
4924 struct PathElem {
4925 node: StoreNode,
4926 }
4927
4928 let initial = vec![PathElem {
4929 node: target.clone(),
4930 }];
4931 let mut complete_paths = Vec::new();
4932 if target.is_entry_point {
4933 complete_paths.push(initial.clone());
4934 }
4935
4936 let mut queue = vec![(initial, 0usize)];
4937 let mut max_depth_reached = false;
4938 let mut truncated_paths = 0usize;
4939
4940 while let Some((path, depth)) = queue.pop() {
4941 if depth >= effective_max {
4942 max_depth_reached = true;
4943 continue;
4944 }
4945 let Some(current) = path.last() else {
4946 continue;
4947 };
4948 let callers =
4949 direct_callers_for_tuple(&conn, ¤t.node.file, ¤t.node.symbol)?;
4950 if callers.is_empty() {
4951 if path.len() > 1 {
4952 truncated_paths += 1;
4953 }
4954 continue;
4955 }
4956
4957 let mut has_new_path = false;
4958 for site in callers {
4959 if path.iter().any(|elem| {
4960 elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
4961 }) {
4962 continue;
4963 }
4964 has_new_path = true;
4965 let mut new_path = path.clone();
4966 new_path.push(PathElem {
4967 node: site.caller.clone(),
4968 });
4969 if site.caller.is_entry_point {
4970 complete_paths.push(new_path.clone());
4971 }
4972 queue.push((new_path, depth + 1));
4973 }
4974 if !has_new_path && path.len() > 1 {
4975 truncated_paths += 1;
4976 }
4977 }
4978
4979 let mut paths: Vec<callgraph::TracePath> = complete_paths
4980 .into_iter()
4981 .map(|mut elems| {
4982 elems.reverse();
4983 let hops = elems
4984 .iter()
4985 .enumerate()
4986 .map(|(index, elem)| callgraph::TraceHop {
4987 symbol: elem.node.symbol.clone(),
4988 file: elem.node.file.clone(),
4989 line: elem.node.line,
4990 signature: elem.node.signature.clone(),
4991 is_entry_point: index == 0 && elem.node.is_entry_point,
4992 })
4993 .collect();
4994 callgraph::TracePath { hops }
4995 })
4996 .collect();
4997 paths.sort_by(|left, right| {
4998 let left_entry = left
4999 .hops
5000 .first()
5001 .map(|hop| hop.symbol.as_str())
5002 .unwrap_or("");
5003 let right_entry = right
5004 .hops
5005 .first()
5006 .map(|hop| hop.symbol.as_str())
5007 .unwrap_or("");
5008 left_entry
5009 .cmp(right_entry)
5010 .then(left.hops.len().cmp(&right.hops.len()))
5011 });
5012 let entry_points_found = paths
5013 .iter()
5014 .filter_map(|path| path.hops.first())
5015 .filter(|hop| hop.is_entry_point)
5016 .map(|hop| (hop.file.clone(), hop.symbol.clone()))
5017 .collect::<HashSet<_>>()
5018 .len();
5019
5020 Ok(callgraph::TraceToResult {
5021 target_symbol: target.symbol,
5022 target_file: target.file,
5023 total_paths: paths.len(),
5024 paths,
5025 entry_points_found,
5026 max_depth_reached,
5027 truncated_paths,
5028 })
5029 }
5030
5031 pub fn trace_to_symbol_candidates(
5032 &self,
5033 to_symbol: &str,
5034 ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
5035 self.refresh_read_marker()?;
5036 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
5037 self.ensure_ready(&conn)?;
5038 let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
5039 for node in nodes_matching_symbol(&conn, to_symbol)? {
5040 candidates_by_file
5041 .entry(node.file)
5042 .and_modify(|line| *line = (*line).min(node.line))
5043 .or_insert(node.line);
5044 }
5045 let mut candidates: Vec<_> = candidates_by_file
5046 .into_iter()
5047 .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
5048 .collect();
5049 candidates
5050 .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
5051 Ok(candidates)
5052 }
5053
5054 pub fn trace_to_symbol(
5055 &self,
5056 file_rel: &Path,
5057 symbol: &str,
5058 to_symbol: &str,
5059 to_file: Option<&Path>,
5060 max_depth: usize,
5061 ) -> Result<callgraph::TraceToSymbolResult> {
5062 let origin = self.node_for(file_rel, symbol)?;
5063 let target_file = to_file
5064 .map(|path| normalize_file_path(&self.project_root, path))
5065 .transpose()?
5066 .map(|path| relative_path(&self.project_root, &path));
5067 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
5068 self.ensure_ready(&conn)?;
5069 let effective_max = if max_depth == 0 {
5070 10
5071 } else {
5072 max_depth.min(16)
5073 };
5074
5075 let start_hop = trace_to_symbol_hop(&origin);
5076 if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
5077 return Ok(callgraph::TraceToSymbolResult {
5078 path: Some(vec![start_hop]),
5079 complete: true,
5080 reason: None,
5081 });
5082 }
5083
5084 let mut queue = VecDeque::new();
5085 queue.push_back((origin.clone(), vec![start_hop], 0usize));
5086 let mut visited = HashSet::new();
5087 visited.insert((origin.file.clone(), origin.symbol.clone()));
5088 let mut max_depth_exhausted = false;
5089
5090 while let Some((current, path, depth)) = queue.pop_front() {
5091 let callees = outgoing_calls_for_node(&conn, ¤t)?
5092 .into_iter()
5093 .filter_map(|site| site.target)
5094 .collect::<Vec<_>>();
5095
5096 if depth >= effective_max {
5097 if callees
5098 .iter()
5099 .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
5100 {
5101 max_depth_exhausted = true;
5102 }
5103 continue;
5104 }
5105
5106 for callee in callees {
5107 if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
5108 continue;
5109 }
5110 let mut next_path = path.clone();
5111 next_path.push(trace_to_symbol_hop(&callee));
5112 if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
5113 return Ok(callgraph::TraceToSymbolResult {
5114 path: Some(next_path),
5115 complete: true,
5116 reason: None,
5117 });
5118 }
5119 queue.push_back((callee, next_path, depth + 1));
5120 }
5121 }
5122
5123 if max_depth_exhausted {
5124 Ok(callgraph::TraceToSymbolResult {
5125 path: None,
5126 complete: false,
5127 reason: Some("max_depth_exhausted".to_string()),
5128 })
5129 } else {
5130 Ok(callgraph::TraceToSymbolResult {
5131 path: None,
5132 complete: true,
5133 reason: Some("no_path_found".to_string()),
5134 })
5135 }
5136 }
5137}
5138
5139impl ReadonlyCallGraphStore {
5140 pub(crate) fn open_manifest_view(
5141 project_root: PathBuf,
5142 family: String,
5143 view_dir: PathBuf,
5144 generation: &str,
5145 pin: Option<Arc<crate::pins::QueryPin>>,
5146 ) -> Result<Self> {
5147 let generation_path = crate::views::resolve_derived_path(&view_dir, generation)
5148 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
5149 let sqlite_path = if generation_path.is_file() {
5152 generation_path
5153 } else {
5154 view_dir.join("derived.sqlite")
5155 };
5156 let conn = open_readonly_connection(&sqlite_path)?;
5157 ensure_database_ready(&conn)?;
5158 let mut inner = CallGraphStore::from_connection(
5159 project_root,
5160 family,
5161 sqlite_path,
5162 view_dir,
5163 false,
5164 None,
5165 None,
5166 None,
5167 conn,
5168 );
5169 inner.manifest_view = true;
5170 inner.database_ready.store(true, AtomicOrdering::Release);
5171 Ok(Self {
5172 inner,
5173 _view_pin: pin,
5174 })
5175 }
5176
5177 pub fn reader_kind(&self) -> &'static str {
5178 if self.inner.manifest_view {
5179 "view"
5180 } else {
5181 "legacy"
5182 }
5183 }
5184
5185 fn from_inner(inner: CallGraphStore) -> Self {
5186 Self {
5187 inner,
5188 _view_pin: None,
5189 }
5190 }
5191
5192 pub fn project_root(&self) -> &Path {
5193 self.inner.project_root()
5194 }
5195
5196 pub fn project_key(&self) -> &str {
5197 self.inner.project_key()
5198 }
5199
5200 pub fn sqlite_path(&self) -> &Path {
5201 self.inner.sqlite_path()
5202 }
5203
5204 pub fn stale_files(&self) -> Result<Vec<String>> {
5205 self.inner.stale_files()
5206 }
5207
5208 pub fn stale_path_census(&self) -> Result<StalePathCensus> {
5209 self.inner.stale_path_census()
5210 }
5211
5212 pub fn try_stale_path_census(&self) -> Result<Option<StalePathCensus>> {
5215 let conn = match self.inner.conn.try_lock() {
5216 Ok(conn) => conn,
5217 Err(std::sync::TryLockError::WouldBlock) => return Ok(None),
5218 Err(std::sync::TryLockError::Poisoned(error)) => error.into_inner(),
5219 };
5220 stale_path_census(&conn, &self.inner.project_root).map(Some)
5221 }
5222
5223 pub(crate) fn projection_generation(&self) -> Option<&str> {
5224 self.inner.projection_generation()
5225 }
5226
5227 pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
5228 self.inner.projection_write_revision()
5229 }
5230
5231 pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
5234 crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
5235 }
5236
5237 pub fn is_legacy_fallback(&self) -> bool {
5239 self.inner.is_legacy_fallback()
5240 }
5241
5242 pub fn is_current(&self) -> bool {
5243 self.inner.is_current()
5244 }
5245
5246 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
5247 self.inner.edge_snapshot()
5248 }
5249
5250 pub fn indexed_file_count(&self) -> Result<usize> {
5251 self.inner.indexed_file_count()
5252 }
5253
5254 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
5255 self.inner.node_for(file_rel, symbol)
5256 }
5257
5258 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
5259 self.inner.nodes_for(file_rel, symbol)
5260 }
5261
5262 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
5263 self.inner.nodes_matching(symbol)
5264 }
5265
5266 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
5267 self.inner.direct_callers_of(file_rel, symbol)
5268 }
5269
5270 pub fn direct_callers_for_symbols(
5271 &self,
5272 targets: &[(String, String)],
5273 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5274 self.inner.direct_callers_for_symbols(targets)
5275 }
5276
5277 pub fn direct_caller_counts_of(
5278 &self,
5279 targets: &[(String, String)],
5280 ) -> Result<HashMap<(String, String), usize>> {
5281 self.inner.direct_caller_counts_of(targets)
5282 }
5283
5284 pub fn callers_of(
5285 &self,
5286 file_rel: &Path,
5287 symbol: &str,
5288 depth: usize,
5289 ) -> Result<StoreCallersResult> {
5290 self.inner.callers_of(file_rel, symbol, depth)
5291 }
5292
5293 pub fn impact_of(
5294 &self,
5295 file_rel: &Path,
5296 symbol: &str,
5297 depth: usize,
5298 ) -> Result<StoreImpactResult> {
5299 self.inner.impact_of(file_rel, symbol, depth)
5300 }
5301
5302 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5303 self.inner.outgoing_calls_of(node)
5304 }
5305
5306 pub fn outgoing_calls_for_symbols(
5307 &self,
5308 sources: &[(String, String)],
5309 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5310 self.inner.outgoing_calls_for_symbols(sources)
5311 }
5312
5313 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5314 self.inner.resolved_self_calls_of(node)
5315 }
5316
5317 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
5318 self.inner.unresolved_calls_of(node)
5319 }
5320
5321 pub fn call_tree(
5322 &self,
5323 file_rel: &Path,
5324 symbol: &str,
5325 depth: usize,
5326 ) -> Result<callgraph::CallTreeNode> {
5327 self.inner.call_tree(file_rel, symbol, depth)
5328 }
5329
5330 pub fn trace_to(
5331 &self,
5332 file_rel: &Path,
5333 symbol: &str,
5334 max_depth: usize,
5335 ) -> Result<callgraph::TraceToResult> {
5336 self.inner.trace_to(file_rel, symbol, max_depth)
5337 }
5338
5339 pub fn trace_to_symbol_candidates(
5340 &self,
5341 to_symbol: &str,
5342 ) -> Result<Vec<TraceToSymbolCandidate>> {
5343 self.inner.trace_to_symbol_candidates(to_symbol)
5344 }
5345
5346 pub fn trace_to_symbol(
5347 &self,
5348 file_rel: &Path,
5349 symbol: &str,
5350 to_symbol: &str,
5351 to_file: Option<&Path>,
5352 max_depth: usize,
5353 ) -> Result<callgraph::TraceToSymbolResult> {
5354 self.inner
5355 .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
5356 }
5357}
5358
5359impl CallGraphRead for CallGraphStore {
5360 fn project_root(&self) -> &Path {
5361 CallGraphStore::project_root(self)
5362 }
5363 fn project_key(&self) -> &str {
5364 CallGraphStore::project_key(self)
5365 }
5366 fn sqlite_path(&self) -> &Path {
5367 CallGraphStore::sqlite_path(self)
5368 }
5369 fn is_current(&self) -> bool {
5370 CallGraphStore::is_current(self)
5371 }
5372 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
5373 CallGraphStore::edge_snapshot(self)
5374 }
5375 fn indexed_file_count(&self) -> Result<usize> {
5376 CallGraphStore::indexed_file_count(self)
5377 }
5378 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
5379 CallGraphStore::node_for(self, file_rel, symbol)
5380 }
5381 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
5382 CallGraphStore::nodes_for(self, file_rel, symbol)
5383 }
5384 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
5385 CallGraphStore::nodes_matching(self, symbol)
5386 }
5387 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
5388 CallGraphStore::direct_callers_of(self, file_rel, symbol)
5389 }
5390 fn direct_callers_for_symbols(
5391 &self,
5392 targets: &[(String, String)],
5393 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5394 CallGraphStore::direct_callers_for_symbols(self, targets)
5395 }
5396 fn direct_caller_counts_of(
5397 &self,
5398 targets: &[(String, String)],
5399 ) -> Result<HashMap<(String, String), usize>> {
5400 CallGraphStore::direct_caller_counts_of(self, targets)
5401 }
5402 fn callers_of(
5403 &self,
5404 file_rel: &Path,
5405 symbol: &str,
5406 depth: usize,
5407 ) -> Result<StoreCallersResult> {
5408 CallGraphStore::callers_of(self, file_rel, symbol, depth)
5409 }
5410 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
5411 CallGraphStore::impact_of(self, file_rel, symbol, depth)
5412 }
5413 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5414 CallGraphStore::outgoing_calls_of(self, node)
5415 }
5416 fn outgoing_calls_for_symbols(
5417 &self,
5418 sources: &[(String, String)],
5419 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5420 CallGraphStore::outgoing_calls_for_symbols(self, sources)
5421 }
5422 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5423 CallGraphStore::resolved_self_calls_of(self, node)
5424 }
5425 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
5426 CallGraphStore::unresolved_calls_of(self, node)
5427 }
5428 fn call_tree(
5429 &self,
5430 file_rel: &Path,
5431 symbol: &str,
5432 depth: usize,
5433 ) -> Result<callgraph::CallTreeNode> {
5434 CallGraphStore::call_tree(self, file_rel, symbol, depth)
5435 }
5436 fn trace_to(
5437 &self,
5438 file_rel: &Path,
5439 symbol: &str,
5440 max_depth: usize,
5441 ) -> Result<callgraph::TraceToResult> {
5442 CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
5443 }
5444 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
5445 CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
5446 }
5447 fn trace_to_symbol(
5448 &self,
5449 file_rel: &Path,
5450 symbol: &str,
5451 to_symbol: &str,
5452 to_file: Option<&Path>,
5453 max_depth: usize,
5454 ) -> Result<callgraph::TraceToSymbolResult> {
5455 CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
5456 }
5457}
5458
5459impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
5460 fn project_root(&self) -> &Path {
5461 (**self).project_root()
5462 }
5463 fn project_key(&self) -> &str {
5464 (**self).project_key()
5465 }
5466 fn sqlite_path(&self) -> &Path {
5467 (**self).sqlite_path()
5468 }
5469 fn is_current(&self) -> bool {
5470 (**self).is_current()
5471 }
5472 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
5473 (**self).edge_snapshot()
5474 }
5475 fn indexed_file_count(&self) -> Result<usize> {
5476 (**self).indexed_file_count()
5477 }
5478 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
5479 (**self).node_for(file_rel, symbol)
5480 }
5481 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
5482 (**self).nodes_for(file_rel, symbol)
5483 }
5484 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
5485 (**self).nodes_matching(symbol)
5486 }
5487 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
5488 (**self).direct_callers_of(file_rel, symbol)
5489 }
5490 fn direct_callers_for_symbols(
5491 &self,
5492 targets: &[(String, String)],
5493 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5494 (**self).direct_callers_for_symbols(targets)
5495 }
5496 fn direct_caller_counts_of(
5497 &self,
5498 targets: &[(String, String)],
5499 ) -> Result<HashMap<(String, String), usize>> {
5500 (**self).direct_caller_counts_of(targets)
5501 }
5502 fn callers_of(
5503 &self,
5504 file_rel: &Path,
5505 symbol: &str,
5506 depth: usize,
5507 ) -> Result<StoreCallersResult> {
5508 (**self).callers_of(file_rel, symbol, depth)
5509 }
5510 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
5511 (**self).impact_of(file_rel, symbol, depth)
5512 }
5513 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5514 (**self).outgoing_calls_of(node)
5515 }
5516 fn outgoing_calls_for_symbols(
5517 &self,
5518 sources: &[(String, String)],
5519 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5520 (**self).outgoing_calls_for_symbols(sources)
5521 }
5522 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5523 (**self).resolved_self_calls_of(node)
5524 }
5525 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
5526 (**self).unresolved_calls_of(node)
5527 }
5528 fn call_tree(
5529 &self,
5530 file_rel: &Path,
5531 symbol: &str,
5532 depth: usize,
5533 ) -> Result<callgraph::CallTreeNode> {
5534 (**self).call_tree(file_rel, symbol, depth)
5535 }
5536 fn trace_to(
5537 &self,
5538 file_rel: &Path,
5539 symbol: &str,
5540 max_depth: usize,
5541 ) -> Result<callgraph::TraceToResult> {
5542 (**self).trace_to(file_rel, symbol, max_depth)
5543 }
5544 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
5545 (**self).trace_to_symbol_candidates(to_symbol)
5546 }
5547 fn trace_to_symbol(
5548 &self,
5549 file_rel: &Path,
5550 symbol: &str,
5551 to_symbol: &str,
5552 to_file: Option<&Path>,
5553 max_depth: usize,
5554 ) -> Result<callgraph::TraceToSymbolResult> {
5555 (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
5556 }
5557}
5558
5559impl CallGraphRead for ReadonlyCallGraphStore {
5560 fn project_root(&self) -> &Path {
5561 self.project_root()
5562 }
5563 fn project_key(&self) -> &str {
5564 self.project_key()
5565 }
5566 fn sqlite_path(&self) -> &Path {
5567 self.sqlite_path()
5568 }
5569 fn is_current(&self) -> bool {
5570 self.is_current()
5571 }
5572 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
5573 self.edge_snapshot()
5574 }
5575 fn indexed_file_count(&self) -> Result<usize> {
5576 self.indexed_file_count()
5577 }
5578 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
5579 self.node_for(file_rel, symbol)
5580 }
5581 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
5582 self.nodes_for(file_rel, symbol)
5583 }
5584 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
5585 self.nodes_matching(symbol)
5586 }
5587 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
5588 self.direct_callers_of(file_rel, symbol)
5589 }
5590 fn direct_callers_for_symbols(
5591 &self,
5592 targets: &[(String, String)],
5593 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5594 self.direct_callers_for_symbols(targets)
5595 }
5596 fn direct_caller_counts_of(
5597 &self,
5598 targets: &[(String, String)],
5599 ) -> Result<HashMap<(String, String), usize>> {
5600 self.direct_caller_counts_of(targets)
5601 }
5602 fn callers_of(
5603 &self,
5604 file_rel: &Path,
5605 symbol: &str,
5606 depth: usize,
5607 ) -> Result<StoreCallersResult> {
5608 self.callers_of(file_rel, symbol, depth)
5609 }
5610 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
5611 self.impact_of(file_rel, symbol, depth)
5612 }
5613 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5614 self.outgoing_calls_of(node)
5615 }
5616 fn outgoing_calls_for_symbols(
5617 &self,
5618 sources: &[(String, String)],
5619 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5620 self.outgoing_calls_for_symbols(sources)
5621 }
5622 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5623 self.resolved_self_calls_of(node)
5624 }
5625 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
5626 self.unresolved_calls_of(node)
5627 }
5628 fn call_tree(
5629 &self,
5630 file_rel: &Path,
5631 symbol: &str,
5632 depth: usize,
5633 ) -> Result<callgraph::CallTreeNode> {
5634 self.call_tree(file_rel, symbol, depth)
5635 }
5636 fn trace_to(
5637 &self,
5638 file_rel: &Path,
5639 symbol: &str,
5640 max_depth: usize,
5641 ) -> Result<callgraph::TraceToResult> {
5642 self.trace_to(file_rel, symbol, max_depth)
5643 }
5644 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
5645 self.trace_to_symbol_candidates(to_symbol)
5646 }
5647 fn trace_to_symbol(
5648 &self,
5649 file_rel: &Path,
5650 symbol: &str,
5651 to_symbol: &str,
5652 to_file: Option<&Path>,
5653 max_depth: usize,
5654 ) -> Result<callgraph::TraceToSymbolResult> {
5655 self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
5656 }
5657}
5658
5659fn indexed_file_count(conn: &Connection) -> Result<usize> {
5660 let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
5661 Ok(count.max(0) as usize)
5662}
5663
5664fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
5665 let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
5666 match candidates.as_slice() {
5667 [candidate] => Ok(candidate.clone()),
5668 [] => Err(AftError::SymbolNotFound {
5669 name: symbol.to_string(),
5670 file: rel_path.to_string(),
5671 }
5672 .into()),
5673 _ => Err(AftError::AmbiguousSymbol {
5674 name: symbol.to_string(),
5675 candidates: candidates
5676 .iter()
5677 .map(|candidate| candidate.symbol.clone())
5678 .collect(),
5679 }
5680 .into()),
5681 }
5682}
5683
5684fn nodes_for_file_matching_symbol(
5685 conn: &Connection,
5686 rel_path: &str,
5687 symbol: &str,
5688) -> Result<Vec<StoreNode>> {
5689 let qualified_query = symbol.contains("::");
5690 let sql = if qualified_query {
5691 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5692 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5693 FROM nodes n JOIN files f ON f.path = n.file_path
5694 WHERE n.file_path = ?1 AND n.scoped_name = ?2
5695 ORDER BY n.scoped_name, n.start_line, n.start_col"
5696 } else {
5697 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5698 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5699 FROM nodes n JOIN files f ON f.path = n.file_path
5700 WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
5701 ORDER BY n.scoped_name, n.start_line, n.start_col"
5702 };
5703 let mut stmt = conn.prepare(sql)?;
5704 let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
5705 rows.collect::<std::result::Result<Vec<_>, _>>()
5706 .map_err(Into::into)
5707}
5708
5709fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
5710 let qualified_query = symbol.contains("::");
5711 let sql = if qualified_query {
5712 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5713 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5714 FROM nodes n JOIN files f ON f.path = n.file_path
5715 WHERE n.scoped_name = ?1
5716 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
5717 } else {
5718 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5719 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5720 FROM nodes n JOIN files f ON f.path = n.file_path
5721 WHERE n.scoped_name = ?1 OR n.name = ?1
5722 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
5723 };
5724 let mut stmt = conn.prepare(sql)?;
5725 let rows = stmt.query_map(params![symbol], store_node_from_row)?;
5726 rows.collect::<std::result::Result<Vec<_>, _>>()
5727 .map_err(Into::into)
5728}
5729
5730fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
5731 store_node_from_row_at(row, 0)
5732}
5733
5734fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
5735 let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
5736 let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
5737 let lang_label_value: String = row.get(offset + 10)?;
5738 Ok(StoreNode {
5739 node_id: row.get(offset)?,
5740 file: row.get(offset + 1)?,
5741 symbol: row.get(offset + 2)?,
5742 name: row.get(offset + 3)?,
5743 kind: row.get(offset + 4)?,
5744 line: start_line.saturating_add(1),
5745 end_line: end_line.saturating_add(1),
5746 signature: row.get(offset + 7)?,
5747 exported: row.get::<_, i64>(offset + 8)? != 0,
5748 is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
5749 lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
5750 })
5751}
5752
5753fn optional_store_node_from_row_at(
5754 row: &rusqlite::Row<'_>,
5755 offset: usize,
5756) -> rusqlite::Result<Option<StoreNode>> {
5757 if row.get::<_, Option<String>>(offset)?.is_some() {
5758 store_node_from_row_at(row, offset).map(Some)
5759 } else {
5760 Ok(None)
5761 }
5762}
5763
5764#[allow(clippy::too_many_arguments)]
5765fn collect_callers_recursive(
5766 conn: &Connection,
5767 file: &str,
5768 symbol: &str,
5769 max_depth: usize,
5770 current_depth: usize,
5771 visited: &mut HashSet<(String, String)>,
5772 result: &mut Vec<StoreCallSite>,
5773 depth_limited: &mut bool,
5774 truncated: &mut usize,
5775) -> Result<()> {
5776 if current_depth >= max_depth {
5777 let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
5778 if omitted > 0 {
5779 *depth_limited = true;
5780 *truncated += omitted;
5781 }
5782 return Ok(());
5783 }
5784
5785 if !visited.insert((file.to_string(), symbol.to_string())) {
5786 return Ok(());
5787 }
5788
5789 let sites = direct_callers_for_tuple(conn, file, symbol)?;
5790 for site in sites {
5791 result.push(site.clone());
5792 if current_depth + 1 < max_depth {
5793 collect_callers_recursive(
5794 conn,
5795 &site.caller.file,
5796 &site.caller.symbol,
5797 max_depth,
5798 current_depth + 1,
5799 visited,
5800 result,
5801 depth_limited,
5802 truncated,
5803 )?;
5804 } else {
5805 let omitted =
5806 direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
5807 if omitted > 0 {
5808 *depth_limited = true;
5809 *truncated += omitted;
5810 }
5811 }
5812 }
5813 Ok(())
5814}
5815
5816const DIRECT_CALLER_BATCH_SIZE: usize = 499;
5818
5819fn direct_caller_counts_for_tuples(
5820 conn: &Connection,
5821 targets: &[(String, String)],
5822) -> Result<HashMap<(String, String), usize>> {
5823 let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
5824 let mut counts = unique_targets
5825 .iter()
5826 .cloned()
5827 .map(|target| (target, 0usize))
5828 .collect::<HashMap<_, _>>();
5829
5830 let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
5831 for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
5832 let requested_values = (0..chunk.len())
5833 .map(|_| "(?, ?)")
5834 .collect::<Vec<_>>()
5835 .join(", ");
5836 let sql = format!(
5837 "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
5838 deduped AS (
5839 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
5840 FROM requested requested
5841 JOIN edges e
5842 ON e.target_file = requested.target_file
5843 AND e.target_symbol = requested.target_symbol
5844 AND e.kind = 'call'
5845 JOIN refs r ON r.ref_id = e.ref_id
5846 JOIN nodes src ON src.id = e.source_node
5847 JOIN files src_file ON src_file.path = src.file_path
5848 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
5849 )
5850 SELECT target_file, target_symbol, COUNT(*)
5851 FROM deduped
5852 GROUP BY target_file, target_symbol"
5853 );
5854 let bindings = chunk
5855 .iter()
5856 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5857 let mut stmt = conn.prepare(&sql)?;
5858 let rows = stmt.query_map(params_from_iter(bindings), |row| {
5859 Ok((
5860 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
5861 row.get::<_, i64>(2)?,
5862 ))
5863 })?;
5864 for row in rows {
5865 let (target, count) = row?;
5866 counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
5867 }
5868 }
5869
5870 Ok(counts)
5871}
5872
5873fn direct_caller_count_for_tuple(
5874 conn: &Connection,
5875 target_file: &str,
5876 target_symbol: &str,
5877) -> Result<usize> {
5878 let count: i64 = conn.query_row(
5879 "SELECT COUNT(*)
5880 FROM edges e
5881 JOIN refs r ON r.ref_id = e.ref_id
5882 JOIN nodes src ON src.id = e.source_node
5883 JOIN files src_file ON src_file.path = src.file_path
5884 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
5885 params![target_file, target_symbol],
5886 |row| row.get(0),
5887 )?;
5888 Ok(usize::try_from(count).unwrap_or(usize::MAX))
5889}
5890
5891fn direct_callers_for_tuple(
5892 conn: &Connection,
5893 target_file: &str,
5894 target_symbol: &str,
5895) -> Result<Vec<StoreCallSite>> {
5896 let mut stmt = conn.prepare(
5897 "SELECT e.target_file, e.target_symbol, e.line,
5898 r.byte_start, r.byte_end, r.status, e.provenance,
5899 src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5900 src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5901 src_file.lang,
5902 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5903 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5904 tgt_file.lang
5905 FROM edges e
5906 JOIN refs r ON r.ref_id = e.ref_id
5907 JOIN nodes src ON src.id = e.source_node
5908 JOIN files src_file ON src_file.path = src.file_path
5909 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5910 ON tgt.id = e.target_node
5911 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
5912 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
5913 )?;
5914 let rows = stmt.query_map(
5915 params![target_file, target_symbol],
5916 direct_call_site_from_row,
5917 )?;
5918 rows.collect::<std::result::Result<Vec<_>, _>>()
5919 .map_err(Into::into)
5920}
5921
5922fn direct_call_site_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreCallSite> {
5923 let caller = store_node_from_row_at(row, 7)?;
5924 let target = optional_store_node_from_row_at(row, 18)?;
5925 Ok(StoreCallSite {
5926 caller,
5927 target_file: row.get(0)?,
5928 target_symbol: row.get(1)?,
5929 target,
5930 line: row.get::<_, i64>(2)?.max(0) as u32,
5931 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5932 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5933 resolved: row.get::<_, String>(5)? == "resolved",
5934 provenance: row.get(6)?,
5935 })
5936}
5937
5938fn direct_callers_for_tuples(
5939 conn: &Connection,
5940 targets: &[(String, String)],
5941) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5942 let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
5943 let mut callers_by_target = unique_targets
5944 .iter()
5945 .cloned()
5946 .map(|target| (target, Vec::new()))
5947 .collect::<HashMap<_, _>>();
5948 let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
5949
5950 for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
5951 let requested_values = (0..chunk.len())
5952 .map(|_| "(?, ?)")
5953 .collect::<Vec<_>>()
5954 .join(", ");
5955 let sql = format!(
5956 "WITH requested(target_file, target_symbol) AS (VALUES {requested_values})
5957 SELECT e.target_file, e.target_symbol, e.line,
5958 r.byte_start, r.byte_end, r.status, e.provenance,
5959 src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5960 src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5961 src_file.lang,
5962 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5963 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5964 tgt_file.lang
5965 FROM requested requested
5966 JOIN edges e
5967 ON e.target_file = requested.target_file
5968 AND e.target_symbol = requested.target_symbol
5969 AND e.kind = 'call'
5970 JOIN refs r ON r.ref_id = e.ref_id
5971 JOIN nodes src ON src.id = e.source_node
5972 JOIN files src_file ON src_file.path = src.file_path
5973 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5974 ON tgt.id = e.target_node
5975 ORDER BY e.target_file, e.target_symbol, e.source_node,
5976 r.byte_start, r.line, r.ref_id"
5977 );
5978 let bindings = chunk
5979 .iter()
5980 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5981 let mut stmt = conn.prepare(&sql)?;
5982 let rows = stmt.query_map(params_from_iter(bindings), |row| {
5983 let call = direct_call_site_from_row(row)?;
5984 let target_key = (call.target_file.clone(), call.target_symbol.clone());
5985 Ok((target_key, call))
5986 })?;
5987 for row in rows {
5988 let (target, call) = row?;
5989 callers_by_target
5990 .get_mut(&target)
5991 .expect("batched caller row belongs to a requested target")
5992 .push(call);
5993 }
5994 }
5995
5996 Ok(callers_by_target)
5997}
5998
5999const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
6001const OUTGOING_NODE_BATCH_SIZE: usize = 999;
6003
6004fn outgoing_calls_for_symbol_tuples(
6005 conn: &Connection,
6006 sources: &[(String, String)],
6007) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
6008 let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
6009 let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
6010 let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
6011 let source_nodes = unique_sources
6012 .iter()
6013 .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
6014 .cloned()
6015 .collect::<Vec<_>>();
6016 let source_nodes_by_id = source_nodes
6017 .iter()
6018 .cloned()
6019 .map(|node| (node.node_id.clone(), node))
6020 .collect::<HashMap<_, _>>();
6021 let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
6022
6023 for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
6024 let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
6025 let sql = format!(
6026 "SELECT e.source_node,
6027 e.target_file, e.target_symbol, e.line,
6028 r.byte_start, r.byte_end, r.status, e.provenance,
6029 CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
6030 tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
6031 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
6032 tgt_file.lang
6033 FROM edges e
6034 JOIN refs r ON r.ref_id = e.ref_id
6035 LEFT JOIN nodes tgt ON tgt.id = e.target_node
6036 LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
6037 WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
6038 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
6039 );
6040 let bindings = chunk.iter().map(|node| node.node_id.as_str());
6041 let mut stmt = conn.prepare(&sql)?;
6042 let rows = stmt.query_map(params_from_iter(bindings), |row| {
6043 let source_node_id = row.get::<_, String>(0)?;
6044 let caller = source_nodes_by_id
6045 .get(&source_node_id)
6046 .expect("batched outgoing row belongs to a requested source node")
6047 .clone();
6048 let target = optional_store_node_from_row_at(row, 8)?;
6049 Ok((
6050 source_node_id,
6051 StoreCallSite {
6052 caller,
6053 target_file: row.get(1)?,
6054 target_symbol: row.get(2)?,
6055 target,
6056 line: row.get::<_, i64>(3)?.max(0) as u32,
6057 byte_start: row.get::<_, i64>(4)?.max(0) as usize,
6058 byte_end: row.get::<_, i64>(5)?.max(0) as usize,
6059 resolved: row.get::<_, String>(6)? == "resolved",
6060 provenance: row.get(7)?,
6061 },
6062 ))
6063 })?;
6064 for row in rows {
6065 let (source_node_id, call) = row?;
6066 calls_by_node.entry(source_node_id).or_default().push(call);
6067 }
6068 }
6069
6070 let mut calls_by_source = HashMap::new();
6071 for source in &unique_sources {
6072 let mut calls = Vec::new();
6073 if let Some(nodes) = source_nodes_by_symbol.get(source) {
6074 for node in nodes {
6075 if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
6076 calls.extend(node_calls);
6077 }
6078 }
6079 }
6080 calls_by_source.insert(source.clone(), calls);
6081 }
6082
6083 let target_tuples = calls_by_source
6086 .values()
6087 .flatten()
6088 .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
6089 .collect::<Vec<_>>();
6090 let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
6091 for calls in calls_by_source.values_mut() {
6092 for call in calls {
6093 if let Some(target) = target_nodes
6094 .get(&(call.target_file.clone(), call.target_symbol.clone()))
6095 .and_then(|nodes| nodes.first())
6096 {
6097 call.target = Some(target.clone());
6098 }
6099 }
6100 }
6101
6102 Ok(calls_by_source)
6103}
6104
6105fn nodes_for_symbol_tuples(
6106 conn: &Connection,
6107 symbols: &[(String, String)],
6108) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
6109 let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
6110 let mut nodes_by_symbol = unique_symbols
6111 .iter()
6112 .cloned()
6113 .map(|symbol| (symbol, Vec::new()))
6114 .collect::<HashMap<_, _>>();
6115 let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
6116
6117 for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
6118 let requested_values = (0..chunk.len())
6119 .map(|_| "(?, ?)")
6120 .collect::<Vec<_>>()
6121 .join(", ");
6122 let sql = format!(
6123 "WITH requested(file, symbol) AS (VALUES {requested_values})
6124 SELECT requested.file, requested.symbol,
6125 node.id, node.file_path, node.scoped_name, node.name, node.kind,
6126 node.start_line, node.end_line, node.signature, node.exported,
6127 node.is_callgraph_entry_point, node_file.lang
6128 FROM requested
6129 JOIN nodes node INDEXED BY idx_nodes_file
6130 ON node.file_path = requested.file
6131 AND node.scoped_name = requested.symbol
6132 JOIN files node_file ON node_file.path = node.file_path
6133 ORDER BY requested.file, requested.symbol,
6134 node.scoped_name, node.start_line, node.end_line,
6135 node.start_col, node.range_ordinal"
6136 );
6137 let bindings = chunk
6138 .iter()
6139 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
6140 let mut stmt = conn.prepare(&sql)?;
6141 let rows = stmt.query_map(params_from_iter(bindings), |row| {
6142 Ok((
6143 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
6144 store_node_from_row_at(row, 2)?,
6145 ))
6146 })?;
6147 for row in rows {
6148 let (symbol, node) = row?;
6149 nodes_by_symbol.entry(symbol).or_default().push(node);
6150 }
6151 }
6152
6153 Ok(nodes_by_symbol)
6154}
6155
6156fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
6157 let mut stmt = conn.prepare(
6158 "SELECT e.target_file, e.target_symbol, e.line,
6159 r.byte_start, r.byte_end, r.status, e.provenance,
6160 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
6161 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
6162 tgt_file.lang
6163 FROM edges e
6164 JOIN refs r ON r.ref_id = e.ref_id
6165 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
6166 ON tgt.id = e.target_node
6167 WHERE e.kind = 'call' AND e.source_node = ?1
6168 ORDER BY r.byte_start, r.line, r.ref_id",
6169 )?;
6170 let rows = stmt.query_map(params![node.node_id], |row| {
6171 let target = optional_store_node_from_row_at(row, 7)?;
6172 Ok(StoreCallSite {
6173 caller: node.clone(),
6174 target_file: row.get(0)?,
6175 target_symbol: row.get(1)?,
6176 target,
6177 line: row.get::<_, i64>(2)?.max(0) as u32,
6178 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
6179 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
6180 resolved: row.get::<_, String>(5)? == "resolved",
6181 provenance: row.get(6)?,
6182 })
6183 })?;
6184 rows.collect::<std::result::Result<Vec<_>, _>>()
6185 .map_err(Into::into)
6186}
6187
6188fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
6189 let mut stmt = conn.prepare(
6190 "SELECT r.target_file, r.target_symbol, r.line,
6191 r.byte_start, r.byte_end, r.status, r.provenance,
6192 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
6193 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
6194 tgt_file.lang
6195 FROM refs r
6196 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
6197 ON tgt.id = r.target_node
6198 WHERE r.caller_node = ?1
6199 AND r.kind = 'call'
6200 AND r.status <> 'unresolved'
6201 AND r.target_file = ?2
6202 AND r.target_symbol = ?3
6203 AND r.provenance = ?4
6204 AND NOT EXISTS (
6205 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
6206 )
6207 ORDER BY r.byte_start, r.line, r.ref_id",
6208 )?;
6209 let rows = stmt.query_map(
6210 params![
6211 &node.node_id,
6212 &node.file,
6213 &node.symbol,
6214 PROVENANCE_TREESITTER
6215 ],
6216 |row| {
6217 let target = optional_store_node_from_row_at(row, 7)?;
6218 Ok(StoreCallSite {
6219 caller: node.clone(),
6220 target_file: row.get(0)?,
6221 target_symbol: row.get(1)?,
6222 target,
6223 line: row.get::<_, i64>(2)?.max(0) as u32,
6224 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
6225 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
6226 resolved: row.get::<_, String>(5)? == "resolved",
6227 provenance: row.get(6)?,
6228 })
6229 },
6230 )?;
6231 rows.collect::<std::result::Result<Vec<_>, _>>()
6232 .map_err(Into::into)
6233}
6234
6235fn unresolved_calls_for_node(
6236 conn: &Connection,
6237 node: &StoreNode,
6238) -> Result<Vec<StoreUnresolvedCall>> {
6239 let mut stmt = conn.prepare(
6240 "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
6241 FROM refs
6242 WHERE caller_node = ?1
6243 AND kind = 'call'
6244 AND status = 'unresolved'
6245 AND NOT EXISTS (
6246 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
6247 )
6248 ORDER BY byte_start, line, ref_id",
6249 )?;
6250 let rows = stmt.query_map(params![node.node_id], |row| {
6251 Ok(StoreUnresolvedCall {
6252 caller: node.clone(),
6253 symbol: row.get(0)?,
6254 full_ref: row.get(1)?,
6255 line: row.get::<_, i64>(2)?.max(0) as u32,
6256 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
6257 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
6258 })
6259 })?;
6260 rows.collect::<std::result::Result<Vec<_>, _>>()
6261 .map_err(Into::into)
6262}
6263
6264fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
6265 let mut calls = Vec::new();
6266 calls.extend(
6267 outgoing_calls_for_node(conn, node)?
6268 .into_iter()
6269 .map(StoreForwardCall::Resolved),
6270 );
6271 calls.extend(
6272 unresolved_calls_for_node(conn, node)?
6273 .into_iter()
6274 .map(StoreForwardCall::Unresolved),
6275 );
6276 calls.sort_by(|left, right| {
6277 left.byte_start()
6278 .cmp(&right.byte_start())
6279 .then(left.line().cmp(&right.line()))
6280 });
6281 Ok(calls)
6282}
6283
6284fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
6285 let resolved_count: i64 = conn.query_row(
6286 "SELECT COUNT(*)
6287 FROM edges e
6288 JOIN refs r ON r.ref_id = e.ref_id
6289 WHERE e.kind = 'call' AND e.source_node = ?1",
6290 params![&node.node_id],
6291 |row| row.get(0),
6292 )?;
6293 let unresolved_count: i64 = conn.query_row(
6294 "SELECT COUNT(*)
6295 FROM refs
6296 WHERE caller_node = ?1
6297 AND kind = 'call'
6298 AND status = 'unresolved'
6299 AND NOT EXISTS (
6300 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
6301 )",
6302 params![&node.node_id],
6303 |row| row.get(0),
6304 )?;
6305 let total = resolved_count.saturating_add(unresolved_count);
6306 Ok(usize::try_from(total).unwrap_or(usize::MAX))
6307}
6308
6309fn call_tree_inner(
6310 conn: &Connection,
6311 node: &StoreNode,
6312 max_depth: usize,
6313 current_depth: usize,
6314 visited: &mut HashSet<(String, String)>,
6315) -> Result<callgraph::CallTreeNode> {
6316 let visit_key = (node.file.clone(), node.symbol.clone());
6317 if visited.contains(&visit_key) {
6318 return Ok(callgraph::CallTreeNode {
6319 name: node.symbol.clone(),
6320 file: node.file.clone(),
6321 line: node.line,
6322 signature: node.signature.clone(),
6323 resolved: true,
6324 children: Vec::new(),
6325 depth_limited: false,
6326 truncated: 0,
6327 });
6328 }
6329 visited.insert(visit_key.clone());
6330
6331 let mut children = Vec::new();
6332 let mut depth_limited = false;
6333 let mut truncated = 0usize;
6334
6335 if current_depth < max_depth {
6336 let calls = forward_calls_for_node(conn, node)?;
6337 for call in calls {
6338 match call {
6339 StoreForwardCall::Resolved(site) => {
6340 if let Some(target) = site.target {
6341 let child =
6342 call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
6343 depth_limited |= child.depth_limited;
6344 truncated += child.truncated;
6345 children.push(child);
6346 } else {
6347 children.push(callgraph::CallTreeNode {
6348 name: site.target_symbol,
6349 file: site.target_file,
6350 line: site.line,
6351 signature: None,
6352 resolved: false,
6353 children: Vec::new(),
6354 depth_limited: false,
6355 truncated: 0,
6356 });
6357 }
6358 }
6359 StoreForwardCall::Unresolved(call) => {
6360 children.push(callgraph::CallTreeNode {
6361 name: call.symbol,
6362 file: call.caller.file,
6363 line: call.line,
6364 signature: None,
6365 resolved: false,
6366 children: Vec::new(),
6367 depth_limited: false,
6368 truncated: 0,
6369 });
6370 }
6371 }
6372 }
6373 } else {
6374 truncated = forward_call_count_for_node(conn, node)?;
6375 depth_limited = truncated > 0;
6376 }
6377
6378 visited.remove(&visit_key);
6379 Ok(callgraph::CallTreeNode {
6380 name: node.symbol.clone(),
6381 file: node.file.clone(),
6382 line: node.line,
6383 signature: node.signature.clone(),
6384 resolved: true,
6385 children,
6386 depth_limited,
6387 truncated,
6388 })
6389}
6390
6391fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
6392 callgraph::TraceToSymbolHop {
6393 symbol: node.symbol.clone(),
6394 file: node.file.clone(),
6395 line: node.line,
6396 }
6397}
6398
6399fn trace_to_symbol_matches_target(
6400 node: &StoreNode,
6401 to_symbol: &str,
6402 to_file: Option<&str>,
6403) -> bool {
6404 if !symbol_query_matches(&node.symbol, to_symbol) {
6405 return false;
6406 }
6407 match to_file {
6408 Some(file) => node.file == file,
6409 None => true,
6410 }
6411}
6412
6413fn symbol_query_matches(symbol: &str, query: &str) -> bool {
6414 symbol == query || unqualified_name(symbol) == query
6415}
6416
6417fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
6418 let source = std::fs::read_to_string(path).ok()?;
6419 Some(source.lines().map(|line| line.trim().to_string()).collect())
6420}
6421
6422#[doc(hidden)]
6423pub fn live_callgraph_edge_snapshot(
6424 project_root: &Path,
6425 files: &[PathBuf],
6426) -> Result<BTreeSet<StoredEdge>> {
6427 let files = normalize_file_list(project_root, files)?;
6428 let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
6429 let mut file_data = Vec::new();
6430 for file in &files {
6431 let canon = canonicalize_path(file);
6432 let data = graph.build_file(&canon)?.clone();
6433 file_data.push((canon, data));
6434 }
6435
6436 let mut edges = BTreeSet::new();
6437 for (caller_file, data) in &file_data {
6438 for (caller_symbol, call_sites) in &data.calls_by_symbol {
6439 for call_site in call_sites {
6440 let resolution = graph.resolve_cross_file_edge(
6441 &call_site.full_callee,
6442 &call_site.callee_name,
6443 caller_file,
6444 &data.import_block,
6445 );
6446 let (target_file, target_symbol) = match resolution {
6447 EdgeResolution::Resolved { file, symbol } => (file, symbol),
6448 EdgeResolution::Unresolved { callee_name } => {
6449 if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
6450 continue;
6451 }
6452 let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
6453 data,
6454 caller_file,
6455 &callee_name,
6456 ) else {
6457 continue;
6458 };
6459 (caller_file.clone(), target_symbol)
6460 }
6461 };
6462 if target_file == *caller_file && target_symbol == *caller_symbol {
6463 continue;
6464 }
6465 edges.insert(StoredEdge {
6466 source_file: relative_path(project_root, caller_file),
6467 source_symbol: caller_symbol.clone(),
6468 target_file: relative_path(project_root, &target_file),
6469 target_symbol,
6470 kind: "call".to_string(),
6471 line: call_site.line,
6472 });
6473 }
6474 }
6475 }
6476 Ok(edges)
6477}
6478
6479fn rebuild_cooldown_records() -> &'static Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>
6480{
6481 SUCCESSFUL_REBUILDS.get_or_init(|| Mutex::new(HashMap::new()))
6482}
6483
6484fn rebuild_cooldown_key(callgraph_dir: &Path, project_key: &str) -> RebuildCooldownKey {
6485 RebuildCooldownKey {
6486 callgraph_dir: std::fs::canonicalize(callgraph_dir)
6487 .unwrap_or_else(|_| callgraph_dir.to_path_buf()),
6488 project_key: project_key.to_string(),
6489 }
6490}
6491
6492fn rebuild_cooldown_denial(
6493 callgraph_dir: &Path,
6494 project_key: &str,
6495 project_root: &Path,
6496 now: Instant,
6497) -> Option<(PathBuf, Duration)> {
6498 let key = rebuild_cooldown_key(callgraph_dir, project_key);
6499 let records = rebuild_cooldown_records()
6500 .lock()
6501 .unwrap_or_else(std::sync::PoisonError::into_inner);
6502 let record = records.get(&key)?;
6503 if record.project_root == project_root || !record.cross_root_cooldown_armed {
6504 return None;
6505 }
6506 let elapsed = now.saturating_duration_since(record.published_at);
6507 (elapsed < REBUILD_COOLDOWN).then(|| (record.project_root.clone(), REBUILD_COOLDOWN - elapsed))
6508}
6509
6510fn record_successful_rebuild(
6511 callgraph_dir: &Path,
6512 project_key: &str,
6513 project_root: &Path,
6514 published_at: Instant,
6515) {
6516 let key = rebuild_cooldown_key(callgraph_dir, project_key);
6517 let mut records = rebuild_cooldown_records()
6518 .lock()
6519 .unwrap_or_else(std::sync::PoisonError::into_inner);
6520 if records.len() >= 4_096 && !records.contains_key(&key) {
6521 if let Some(evict) = records.keys().next().cloned() {
6522 records.remove(&evict);
6523 }
6524 }
6525 let cross_root_cooldown_armed = records.get(&key).is_some_and(|previous| {
6526 previous.cross_root_cooldown_armed || previous.project_root != project_root
6527 });
6528 records.insert(
6529 key,
6530 RebuildCooldownRecord {
6531 project_root: project_root.to_path_buf(),
6532 published_at,
6533 cross_root_cooldown_armed,
6534 },
6535 );
6536}
6537
6538fn acquire_writer_lease(
6539 callgraph_dir: &Path,
6540 project_key: &str,
6541 project_root: &Path,
6542) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
6543 crate::root_cache::WriterLease::acquire_shared(
6544 crate::root_cache::RootCacheDomain::Callgraph,
6545 callgraph_dir,
6546 project_key,
6547 project_root,
6548 )
6549 .map_err(CallGraphStoreError::from)
6550}
6551
6552fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
6553 if lease.verify()? {
6554 Ok(())
6555 } else {
6556 Err(CallGraphStoreError::Unavailable(format!(
6557 "callgraph writer lease for key {} lost epoch {}; aborting write",
6558 lease.key(),
6559 lease.epoch()
6560 )))
6561 }
6562}
6563
6564fn legacy_migration_completion_line(
6565 project_key: &str,
6566 method: &str,
6567 legacy_bytes: u64,
6568 migrated_bytes: u64,
6569) -> String {
6570 format!(
6571 "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
6572 )
6573}
6574
6575fn log_legacy_migration_completion(
6576 project_key: &str,
6577 method: &str,
6578 legacy_bytes: u64,
6579 migrated_bytes: u64,
6580) {
6581 crate::slog_info!(
6582 "{}",
6583 legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
6584 );
6585}
6586
6587fn try_legacy_migration_or_fallback(
6588 callgraph_dir: &Path,
6589 project_root: &Path,
6590 project_key: &str,
6591 writer_lease: Arc<crate::root_cache::WriterLease>,
6592) -> Result<Option<CallGraphStore>> {
6593 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
6594 if partitions.is_empty() {
6595 return Ok(None);
6596 }
6597
6598 for partition in &partitions {
6599 if let Some(source) = newest_superseded_legacy_generation(partition)? {
6600 if !migration_disk_floor_allows(&source, callgraph_dir)? {
6601 return open_legacy_fallback_store(
6602 callgraph_dir,
6603 project_root,
6604 project_key,
6605 &partitions,
6606 );
6607 }
6608 match publish_generation_copy_migration(
6609 callgraph_dir,
6610 project_key,
6611 &source,
6612 Arc::clone(&writer_lease),
6613 ) {
6614 Ok(published) => {
6615 log_legacy_migration_completion(
6616 project_key,
6617 "generation_copy",
6618 source.source_bytes,
6619 published.migrated_bytes,
6620 );
6621 return CallGraphStore::open_generation(
6622 callgraph_dir,
6623 project_root.to_path_buf(),
6624 project_key.to_string(),
6625 published.generation,
6626 writer_lease,
6627 )
6628 .map(Some);
6629 }
6630 Err(error) => {
6631 crate::slog_warn!(
6632 "root-keyed callgraph generation-copy migration failed from {}: {}",
6633 source.sqlite_path.display(),
6634 error
6635 );
6636 return open_legacy_fallback_store(
6637 callgraph_dir,
6638 project_root,
6639 project_key,
6640 &partitions,
6641 );
6642 }
6643 }
6644 }
6645
6646 if let Some(source) = current_legacy_generation(partition)? {
6647 if !migration_disk_floor_allows(&source, callgraph_dir)? {
6648 return open_legacy_fallback_store(
6649 callgraph_dir,
6650 project_root,
6651 project_key,
6652 &partitions,
6653 );
6654 }
6655 match publish_backup_migration(
6656 callgraph_dir,
6657 project_key,
6658 &source,
6659 Arc::clone(&writer_lease),
6660 ) {
6661 Ok(published) => {
6662 log_legacy_migration_completion(
6663 project_key,
6664 "sqlite_backup",
6665 source.source_bytes,
6666 published.migrated_bytes,
6667 );
6668 return CallGraphStore::open_generation(
6669 callgraph_dir,
6670 project_root.to_path_buf(),
6671 project_key.to_string(),
6672 published.generation,
6673 writer_lease,
6674 )
6675 .map(Some);
6676 }
6677 Err(error) => {
6678 crate::slog_warn!(
6679 "root-keyed callgraph backup migration failed from {}: {}",
6680 source.sqlite_path.display(),
6681 error
6682 );
6683 return open_legacy_fallback_store(
6684 callgraph_dir,
6685 project_root,
6686 project_key,
6687 &partitions,
6688 );
6689 }
6690 }
6691 }
6692 }
6693
6694 open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
6695}
6696
6697fn open_legacy_fallback_store(
6698 callgraph_dir: &Path,
6699 project_root: &Path,
6700 project_key: &str,
6701 partitions: &[LegacyCallgraphPartition],
6702) -> Result<Option<CallGraphStore>> {
6703 let Some(target) = first_ready_legacy_target(partitions)? else {
6704 return Ok(None);
6705 };
6706 crate::slog_warn!(
6707 "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
6708 target.partition.harness,
6709 target.sqlite_path.display()
6710 );
6711 let conn = open_readonly_connection(&target.sqlite_path)?;
6712 if !database_ready(&conn).unwrap_or(false) {
6713 return Ok(None);
6714 }
6715 let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
6716 let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
6717 Ok(Some(CallGraphStore::from_connection(
6718 project_root.to_path_buf(),
6719 project_key.to_string(),
6720 target.sqlite_path,
6721 callgraph_dir.to_path_buf(),
6722 true,
6723 target.generation,
6724 None,
6725 Some(read_marker),
6726 conn,
6727 )))
6728}
6729
6730fn migration_disk_floor_allows(
6731 source: &LegacyCallgraphTarget,
6732 callgraph_dir: &Path,
6733) -> Result<bool> {
6734 let available = migration_available_disk(callgraph_dir)?;
6735 let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
6736 source.source_bytes,
6737 available,
6738 );
6739 if decision.should_skip_copy() {
6740 crate::slog_warn!(
6741 "{}",
6742 decision.warning_message(&source.sqlite_path, callgraph_dir)
6743 );
6744 return Ok(false);
6745 }
6746 Ok(true)
6747}
6748
6749fn migration_available_disk(path: &Path) -> Result<u64> {
6750 if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
6751 return Ok(bytes);
6752 }
6753 crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
6754}
6755
6756fn legacy_callgraph_partitions(
6757 callgraph_dir: &Path,
6758 project_key: &str,
6759) -> Result<Vec<LegacyCallgraphPartition>> {
6760 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6761 return Ok(Vec::new());
6762 };
6763 let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
6764 let mut partitions = inventory
6765 .into_iter()
6766 .filter(|entry| {
6767 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
6768 && entry.key == project_key
6769 })
6770 .map(|entry| {
6771 let dir = if entry.path.is_dir() {
6772 entry.path.clone()
6773 } else {
6774 entry
6775 .path
6776 .parent()
6777 .map(Path::to_path_buf)
6778 .unwrap_or_else(|| entry.path.clone())
6779 };
6780 LegacyCallgraphPartition {
6781 harness: entry.harness,
6782 dir,
6783 key: entry.key,
6784 bytes: entry.bytes,
6785 freshness: entry.callgraph_pointer_mtime,
6786 }
6787 })
6788 .collect::<Vec<_>>();
6789 partitions.sort_by(|left, right| {
6790 right
6791 .freshness
6792 .cmp(&left.freshness)
6793 .then_with(|| right.bytes.cmp(&left.bytes))
6794 .then_with(|| left.harness.cmp(&right.harness))
6795 });
6796 Ok(partitions)
6797}
6798
6799fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
6800 let domain_dir = callgraph_dir.parent()?;
6801 if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
6802 return None;
6803 }
6804 domain_dir.parent().map(Path::to_path_buf)
6805}
6806
6807pub(crate) fn all_legacy_partitions_migrated_for_keys(
6808 callgraph_dir: &Path,
6809 configured_keys: &BTreeSet<String>,
6810) -> Result<bool> {
6811 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6812 return Ok(false);
6813 };
6814 let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
6815 .into_iter()
6816 .filter(|entry| {
6817 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
6818 && configured_keys.contains(&entry.key)
6819 })
6820 .map(|entry| entry.key)
6821 .collect::<BTreeSet<_>>();
6822 if legacy_keys.is_empty() {
6823 return Ok(false);
6824 }
6825
6826 for key in legacy_keys {
6827 let migrated_dir = storage_root.join("callgraph").join(&key);
6828 let Some(generation) = read_pointer(&migrated_dir, &key) else {
6829 return Ok(false);
6830 };
6831 if !migration_generation_requires_manifest(&generation)
6832 || !migration_manifest_valid(&migrated_dir, &generation)
6833 {
6834 return Ok(false);
6835 }
6836 }
6837 Ok(true)
6838}
6839
6840fn newest_superseded_legacy_generation(
6841 partition: &LegacyCallgraphPartition,
6842) -> Result<Option<LegacyCallgraphTarget>> {
6843 let Some(current) = read_pointer(&partition.dir, &partition.key) else {
6844 return Ok(None);
6845 };
6846 let prefix = format!("{}.g", partition.key);
6847 let Ok(entries) = std::fs::read_dir(&partition.dir) else {
6848 return Ok(None);
6849 };
6850 let mut candidates = Vec::new();
6851 for entry in entries.flatten() {
6852 let name = entry.file_name().to_string_lossy().to_string();
6853 if name == current
6854 || name.contains(".tmp.")
6855 || !name.starts_with(&prefix)
6856 || !name.ends_with(".sqlite")
6857 {
6858 continue;
6859 }
6860 let path = entry.path();
6861 if !db_path_ready(&path) {
6862 continue;
6863 }
6864 let modified = entry
6865 .metadata()
6866 .and_then(|metadata| metadata.modified())
6867 .unwrap_or(SystemTime::UNIX_EPOCH);
6868 candidates.push((modified, path, name));
6869 }
6870 candidates.sort_by(|left, right| right.0.cmp(&left.0));
6871 let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
6872 return Ok(None);
6873 };
6874 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6875 Ok(Some(LegacyCallgraphTarget {
6876 partition: partition.clone(),
6877 sqlite_path,
6878 generation: Some(generation),
6879 source_bytes,
6880 source_blake3: String::new(),
6881 }))
6882}
6883
6884fn current_legacy_generation(
6885 partition: &LegacyCallgraphPartition,
6886) -> Result<Option<LegacyCallgraphTarget>> {
6887 let Some(target) = ready_legacy_target(partition)? else {
6888 return Ok(None);
6889 };
6890 let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
6891 if has_superseded {
6892 return Ok(None);
6893 }
6894 Ok(Some(target))
6895}
6896
6897fn freshest_legacy_fallback_target(
6898 callgraph_dir: &Path,
6899 project_key: &str,
6900) -> Result<Option<LegacyCallgraphTarget>> {
6901 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
6902 first_ready_legacy_target(&partitions)
6903}
6904
6905fn first_ready_legacy_target(
6906 partitions: &[LegacyCallgraphPartition],
6907) -> Result<Option<LegacyCallgraphTarget>> {
6908 for partition in partitions {
6909 if let Some(target) = ready_legacy_target(partition)? {
6910 return Ok(Some(target));
6911 }
6912 }
6913 Ok(None)
6914}
6915
6916fn ready_legacy_target(
6917 partition: &LegacyCallgraphPartition,
6918) -> Result<Option<LegacyCallgraphTarget>> {
6919 if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
6920 let sqlite_path = partition.dir.join(&generation);
6921 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6922 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6923 return Ok(Some(LegacyCallgraphTarget {
6924 partition: partition.clone(),
6925 sqlite_path,
6926 generation: Some(generation),
6927 source_bytes,
6928 source_blake3: String::new(),
6929 }));
6930 }
6931 }
6932
6933 let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
6934 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6935 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6936 return Ok(Some(LegacyCallgraphTarget {
6937 partition: partition.clone(),
6938 sqlite_path,
6939 generation: None,
6940 source_bytes,
6941 source_blake3: String::new(),
6942 }));
6943 }
6944 Ok(None)
6945}
6946
6947fn publish_generation_copy_migration(
6948 callgraph_dir: &Path,
6949 project_key: &str,
6950 source: &LegacyCallgraphTarget,
6951 writer_lease: Arc<crate::root_cache::WriterLease>,
6952) -> Result<PublishedLegacyMigration> {
6953 let generation = migration_generation_file_name(project_key, "copy");
6954 let temp_path = migration_temp_path(callgraph_dir, &generation);
6955 remove_sqlite_file_set(&temp_path);
6956 copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
6957 fail_after_temp_copy_for_test()?;
6958
6959 let mut source = source.clone();
6960 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
6961 source.source_blake3 = fingerprint.blake3;
6962 let generation = publish_migrated_generation(
6963 callgraph_dir,
6964 project_key,
6965 &generation,
6966 &temp_path,
6967 &source,
6968 fingerprint.bytes,
6969 writer_lease,
6970 "generation_copy",
6971 )?;
6972 Ok(PublishedLegacyMigration {
6973 generation,
6974 migrated_bytes: fingerprint.bytes,
6975 })
6976}
6977
6978fn publish_backup_migration(
6979 callgraph_dir: &Path,
6980 project_key: &str,
6981 source: &LegacyCallgraphTarget,
6982 writer_lease: Arc<crate::root_cache::WriterLease>,
6983) -> Result<PublishedLegacyMigration> {
6984 if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
6985 return Err(CallGraphStoreError::Unavailable(
6986 "legacy callgraph backup migration budget exhausted by test seam".to_string(),
6987 ));
6988 }
6989
6990 let generation = migration_generation_file_name(project_key, "backup");
6991 let temp_path = migration_temp_path(callgraph_dir, &generation);
6992 remove_sqlite_file_set(&temp_path);
6993
6994 let source_conn = open_readonly_connection(&source.sqlite_path)?;
6995 let mut destination = TrackedConnection::open(&temp_path, SqliteStore::CallgraphGeneration)?;
6996 destination.busy_timeout(Duration::from_secs(5))?;
6997 let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
6998 let started = Instant::now();
6999 let mut retries = 0;
7000 loop {
7001 match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
7002 rusqlite::backup::StepResult::Done => break,
7003 rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
7004 rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
7005 retries += 1;
7006 if retries > MIGRATION_BACKUP_RETRY_BUDGET
7007 || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
7008 {
7009 return Err(CallGraphStoreError::Unavailable(format!(
7010 "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
7011 )));
7012 }
7013 std::thread::sleep(Duration::from_millis(20));
7014 }
7015 _ => {
7016 return Err(CallGraphStoreError::Unavailable(
7017 "legacy callgraph backup returned an unknown step result".to_string(),
7018 ));
7019 }
7020 }
7021 }
7022 drop(backup);
7023
7024 let integrity: String =
7025 destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
7026 if integrity != "ok" {
7027 return Err(CallGraphStoreError::Unavailable(format!(
7028 "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
7029 )));
7030 }
7031 if !database_ready(&destination)? {
7032 return Err(CallGraphStoreError::Unavailable(
7033 "legacy callgraph backup produced a database without ready metadata".to_string(),
7034 ));
7035 }
7036 destination.execute_batch("PRAGMA optimize;")?;
7037 drop(destination);
7038 sync_file(&temp_path)?;
7039 fail_after_temp_copy_for_test()?;
7040
7041 let mut source = source.clone();
7042 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
7043 source.source_blake3 = fingerprint.blake3;
7044 let generation = publish_migrated_generation(
7045 callgraph_dir,
7046 project_key,
7047 &generation,
7048 &temp_path,
7049 &source,
7050 fingerprint.bytes,
7051 writer_lease,
7052 "sqlite_backup",
7053 )?;
7054 Ok(PublishedLegacyMigration {
7055 generation,
7056 migrated_bytes: fingerprint.bytes,
7057 })
7058}
7059
7060fn publish_migrated_generation(
7061 callgraph_dir: &Path,
7062 project_key: &str,
7063 generation: &str,
7064 temp_path: &Path,
7065 source: &LegacyCallgraphTarget,
7066 migrated_bytes: u64,
7067 writer_lease: Arc<crate::root_cache::WriterLease>,
7068 method: &str,
7069) -> Result<String> {
7070 let gen_path = callgraph_dir.join(generation);
7071 checkpoint_sqlite_before_publication(temp_path);
7072 let publication = publish_if_current(|| {
7073 verify_writer_lease(&writer_lease)?;
7074 remove_sqlite_file_set(&gen_path);
7075 rename_sqlite_file_set(temp_path, &gen_path)?;
7076 crate::fs_lock::sync_parent(&gen_path);
7077
7078 verify_writer_lease(&writer_lease)?;
7079 publish_pointer(callgraph_dir, project_key, generation)?;
7080 write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
7081 Ok(generation.to_string())
7082 });
7083 if matches!(publication, Err(CallGraphStoreError::Superseded)) {
7084 remove_sqlite_file_set(temp_path);
7085 }
7086 publication
7087}
7088
7089fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
7090 if let Some(parent) = destination.parent() {
7091 std::fs::create_dir_all(parent)?;
7092 }
7093 for suffix in SQLITE_FILE_SET_SUFFIXES {
7094 let source_path = sqlite_file_set_path(source, suffix);
7095 if !source_path.is_file() {
7096 continue;
7097 }
7098 let destination_path = sqlite_file_set_path(destination, suffix);
7099 std::fs::copy(&source_path, &destination_path)?;
7100 sync_file(&destination_path)?;
7101 }
7102 Ok(())
7103}
7104
7105fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
7106 for suffix in SQLITE_FILE_SET_SUFFIXES {
7107 let source_path = sqlite_file_set_path(source, suffix);
7108 if !source_path.exists() {
7109 continue;
7110 }
7111 let destination_path = sqlite_file_set_path(destination, suffix);
7112 if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
7113 let _ = std::fs::remove_file(&source_path);
7114 return Err(error.into());
7115 }
7116 }
7117 Ok(())
7118}
7119
7120fn sqlite_file_set_size(path: &Path) -> Result<u64> {
7121 let mut bytes = 0_u64;
7122 for suffix in SQLITE_FILE_SET_SUFFIXES {
7123 let member = sqlite_file_set_path(path, suffix);
7124 if !member.is_file() {
7125 continue;
7126 }
7127 bytes = bytes.saturating_add(member.metadata()?.len());
7128 }
7129 Ok(bytes)
7130}
7131
7132fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
7133 let mut hasher = blake3::Hasher::new();
7134 let mut bytes = 0_u64;
7135 let mut buffer = [0_u8; 64 * 1024];
7136 for suffix in SQLITE_FILE_SET_SUFFIXES {
7137 let member = sqlite_file_set_path(path, suffix);
7138 if !member.is_file() {
7139 continue;
7140 }
7141 hasher.update(suffix.as_bytes());
7142 let mut file = std::fs::File::open(&member)?;
7143 loop {
7144 let read = file.read(&mut buffer)?;
7145 if read == 0 {
7146 break;
7147 }
7148 bytes = bytes.saturating_add(read as u64);
7149 hasher.update(&buffer[..read]);
7150 }
7151 }
7152 Ok(SourceFingerprint {
7153 bytes,
7154 blake3: hash_to_hex(hasher.finalize()),
7155 })
7156}
7157
7158fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
7159 if suffix.is_empty() {
7160 path.to_path_buf()
7161 } else {
7162 PathBuf::from(format!("{}{suffix}", path.display()))
7163 }
7164}
7165
7166fn sync_file(path: &Path) -> Result<()> {
7167 let file = std::fs::OpenOptions::new()
7168 .read(true)
7169 .write(true)
7170 .open(path)?;
7171 file.sync_all()?;
7172 Ok(())
7173}
7174
7175fn fail_after_temp_copy_for_test() -> Result<()> {
7176 if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
7177 return Err(CallGraphStoreError::Unavailable(
7178 "legacy callgraph migration stopped after temp copy by test seam".to_string(),
7179 ));
7180 }
7181 Ok(())
7182}
7183
7184fn migration_generation_file_name(project_key: &str, method: &str) -> String {
7185 format!(
7186 "{project_key}.g{}.{}{}{}.sqlite",
7187 now_nanos(),
7188 std::process::id(),
7189 MIGRATION_GENERATION_TAG,
7190 method
7191 )
7192}
7193
7194fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
7195 callgraph_dir.join(format!(
7196 "{generation}.tmp.{}.{}",
7197 std::process::id(),
7198 now_nanos()
7199 ))
7200}
7201
7202fn write_migration_manifest(
7203 callgraph_dir: &Path,
7204 generation: &str,
7205 source: &LegacyCallgraphTarget,
7206 migrated_bytes: u64,
7207 method: &str,
7208) -> Result<()> {
7209 let manifest_path = migration_manifest_path(callgraph_dir, generation);
7210 let temp_path = manifest_path.with_extension(format!(
7211 "migration.json.tmp.{}.{}",
7212 std::process::id(),
7213 now_nanos()
7214 ));
7215 let manifest = serde_json::json!({
7216 "version": MIGRATION_MANIFEST_VERSION,
7217 "method": method,
7218 "target_generation": generation,
7219 "source_harness": source.partition.harness,
7220 "source_path": source.sqlite_path.display().to_string(),
7221 "source_generation": source.generation,
7222 "source_bytes": source.source_bytes,
7223 "source_blake3": source.source_blake3,
7224 "migrated_bytes": migrated_bytes,
7225 });
7226 {
7227 use std::io::Write as _;
7228 let mut file = std::fs::File::create(&temp_path)?;
7229 file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
7230 file.write_all(b"\n")?;
7231 file.sync_all()?;
7232 }
7233 if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
7234 let _ = std::fs::remove_file(&temp_path);
7235 return Err(error.into());
7236 }
7237 crate::fs_lock::sync_parent(&manifest_path);
7238 Ok(())
7239}
7240
7241fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
7242 callgraph_dir.join(format!("{generation}.migration.json"))
7243}
7244
7245fn migration_generation_requires_manifest(generation: &str) -> bool {
7246 generation.contains(MIGRATION_GENERATION_TAG)
7247}
7248
7249fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
7250 if !migration_generation_requires_manifest(generation) {
7251 return true;
7252 }
7253 let path = migration_manifest_path(callgraph_dir, generation);
7254 let Ok(bytes) = std::fs::read(path) else {
7255 return false;
7256 };
7257 let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
7258 return false;
7259 };
7260 value.get("version").and_then(serde_json::Value::as_u64)
7261 == Some(MIGRATION_MANIFEST_VERSION as u64)
7262 && value
7263 .get("target_generation")
7264 .and_then(serde_json::Value::as_str)
7265 == Some(generation)
7266 && value
7267 .get("source_bytes")
7268 .and_then(serde_json::Value::as_u64)
7269 .is_some_and(|bytes| bytes > 0)
7270 && value
7271 .get("source_blake3")
7272 .and_then(serde_json::Value::as_str)
7273 .is_some_and(|hash| hash.len() == 64)
7274}
7275
7276fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
7277 let pointer_generation = read_pointer(callgraph_dir, project_key);
7278 if let Some(generation) = pointer_generation.as_deref() {
7279 if migration_generation_requires_manifest(generation)
7280 && !migration_manifest_valid(callgraph_dir, generation)
7281 {
7282 let path = callgraph_dir.join(generation);
7283 remove_sqlite_file_set(&path);
7284 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
7285 let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
7286 }
7287 }
7288
7289 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
7290 return;
7291 };
7292 for entry in entries.flatten() {
7293 let name = entry.file_name().to_string_lossy().to_string();
7294 let path = entry.path();
7295 if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
7296 let _ = std::fs::remove_file(path);
7297 continue;
7298 }
7299 if name.starts_with(&format!("{project_key}.g"))
7300 && name.ends_with(".sqlite")
7301 && name.contains(MIGRATION_GENERATION_TAG)
7302 && pointer_generation.as_deref() != Some(&name)
7303 && !migration_manifest_valid(callgraph_dir, &name)
7304 {
7305 remove_sqlite_file_set(&path);
7306 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
7307 }
7308 }
7309 crate::fs_lock::sync_parent(callgraph_dir);
7310}
7311
7312fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
7313 let mut hasher = blake3::Hasher::new();
7314 hasher.update(path.to_string_lossy().as_bytes());
7315 if let Some(generation) = generation {
7316 hasher.update(generation.as_bytes());
7317 }
7318 let digest = hash_to_hex(hasher.finalize());
7319 format!("legacy-{}", &digest[..16])
7320}
7321
7322fn open_readonly_connection(path: &Path) -> Result<TrackedConnection> {
7323 let uri = sqlite_readonly_uri(path);
7324 let conn = TrackedConnection::open_with_flags(
7325 &uri,
7326 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
7327 SqliteStore::CallgraphGeneration,
7328 )?;
7329 conn.pragma_update(None, "synchronous", "NORMAL")?;
7330 conn.busy_timeout(reader_busy_timeout())?;
7331 conn.execute_batch("PRAGMA query_only=ON;")?;
7332 Ok(conn)
7333}
7334
7335fn reader_busy_timeout() -> Duration {
7336 let jitter = (now_nanos() % 500) as u64;
7337 Duration::from_millis(250 + jitter)
7338}
7339
7340fn sqlite_readonly_uri(path: &Path) -> String {
7341 let raw = path.to_string_lossy().replace('\\', "/");
7342 let encoded = percent_encode_sqlite_uri_path(&raw);
7343 if raw.starts_with('/') {
7344 format!("file://{encoded}?mode=ro")
7345 } else if raw.as_bytes().get(1) == Some(&b':') {
7346 format!("file:///{encoded}?mode=ro")
7347 } else {
7348 format!("file:{encoded}?mode=ro")
7349 }
7350}
7351
7352fn percent_encode_sqlite_uri_path(path: &str) -> String {
7353 let mut encoded = String::with_capacity(path.len());
7354 for byte in path.bytes() {
7355 match byte {
7356 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
7357 encoded.push(byte as char)
7358 }
7359 _ => encoded.push_str(&format!("%{byte:02X}")),
7360 }
7361 }
7362 encoded
7363}
7364
7365fn configure_connection(conn: &Connection) -> Result<()> {
7366 conn.busy_timeout(Duration::from_secs(5))?;
7370 conn.pragma_update(None, "journal_mode", "WAL")?;
7371 conn.pragma_update(None, "synchronous", "NORMAL")?;
7372 conn.pragma_update(
7373 None,
7374 "wal_autocheckpoint",
7375 CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES,
7376 )?;
7377 conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
7378 Ok(())
7379}
7380
7381fn configure_build_connection(conn: &Connection) -> Result<()> {
7382 conn.busy_timeout(Duration::from_secs(5))?;
7387 conn.pragma_update(None, "journal_mode", "WAL")?;
7388 conn.pragma_update(None, "synchronous", "NORMAL")?;
7389 conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
7390 Ok(())
7391}
7392
7393fn checkpoint_sqlite_before_publication(path: &Path) {
7397 let Ok(conn) = crate::db::lifecycle::TrackedConnection::open(
7398 path,
7399 crate::db::lifecycle::SqliteStore::CallgraphGeneration,
7400 ) else {
7401 return;
7402 };
7403 let _ = conn.pragma_update(None, "synchronous", "NORMAL");
7404 let _ = conn.busy_timeout(Duration::from_secs(5));
7405 let _ = checkpoint_wal_truncate(&conn);
7406}
7407
7408fn checkpoint_wal_truncate(conn: &Connection) -> bool {
7409 match conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
7410 row.get::<_, i64>(0)
7411 }) {
7412 Ok(0) => true,
7413 Ok(_) => false,
7414 Err(rusqlite::Error::SqliteFailure(error, _))
7415 if matches!(
7416 error.code,
7417 rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
7418 ) =>
7419 {
7420 false
7421 }
7422 Err(error) => {
7423 log::debug!("callgraph WAL truncate checkpoint skipped: {error}");
7424 false
7425 }
7426 }
7427}
7428
7429pub(crate) use crate::views::materialization::materialize_manifest_view_database;
7430
7431pub(crate) fn initialize_schema(conn: &Connection) -> Result<()> {
7432 conn.execute_batch(
7433 "CREATE TABLE IF NOT EXISTS files (
7434 path TEXT PRIMARY KEY,
7435 content_hash TEXT NOT NULL,
7436 mtime_ns INTEGER NOT NULL,
7437 size INTEGER NOT NULL,
7438 lang TEXT NOT NULL,
7439 is_dead_code_root INTEGER NOT NULL DEFAULT 0,
7440 is_public_api INTEGER NOT NULL DEFAULT 0,
7441 surface_fingerprint TEXT NOT NULL,
7442 indexed_at INTEGER NOT NULL
7443 );
7444
7445 CREATE TABLE IF NOT EXISTS nodes (
7446 id TEXT PRIMARY KEY,
7447 file_path TEXT NOT NULL,
7448 name TEXT NOT NULL,
7449 scoped_name TEXT NOT NULL,
7450 kind TEXT NOT NULL,
7451 start_line INTEGER NOT NULL,
7452 start_col INTEGER NOT NULL,
7453 end_line INTEGER NOT NULL,
7454 end_col INTEGER NOT NULL,
7455 range_ordinal INTEGER NOT NULL,
7456 signature TEXT,
7457 exported INTEGER NOT NULL,
7458 is_default_export INTEGER NOT NULL,
7459 is_type_like INTEGER NOT NULL,
7460 is_callgraph_entry_point INTEGER NOT NULL,
7461 provenance TEXT NOT NULL,
7462 UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
7463 );
7464 CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
7465 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
7466 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
7467
7468 CREATE TABLE IF NOT EXISTS refs (
7469 ref_id TEXT PRIMARY KEY,
7470 caller_node TEXT,
7471 caller_file TEXT NOT NULL,
7472 kind TEXT NOT NULL,
7473 short_name TEXT,
7474 full_ref TEXT,
7475 module_path TEXT,
7476 import_kind TEXT,
7477 local_name TEXT,
7478 requested_name TEXT,
7479 namespace_alias TEXT,
7480 wildcard INTEGER NOT NULL DEFAULT 0,
7481 line INTEGER NOT NULL,
7482 byte_start INTEGER NOT NULL,
7483 byte_end INTEGER NOT NULL,
7484 status TEXT NOT NULL,
7485 target_node TEXT,
7486 target_file TEXT,
7487 target_symbol TEXT,
7488 provenance TEXT NOT NULL
7489 );
7490 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
7491 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
7492 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
7493 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
7494 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
7495
7496 CREATE TABLE IF NOT EXISTS file_dependencies (
7497 file_path TEXT NOT NULL,
7498 dep_file TEXT NOT NULL,
7499 PRIMARY KEY(file_path, dep_file)
7500 );
7501 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
7502
7503 CREATE TABLE IF NOT EXISTS edges (
7504 edge_id TEXT PRIMARY KEY,
7505 ref_id TEXT NOT NULL,
7506 source_node TEXT NOT NULL,
7507 target_node TEXT,
7508 target_file TEXT NOT NULL,
7509 target_symbol TEXT NOT NULL,
7510 kind TEXT NOT NULL,
7511 line INTEGER NOT NULL,
7512 provenance TEXT NOT NULL
7513 );
7514 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
7515 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
7516 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
7517 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
7518
7519 CREATE TABLE IF NOT EXISTS dispatch_hints (
7520 id TEXT PRIMARY KEY,
7521 method_name TEXT NOT NULL,
7522 caller_node TEXT NOT NULL,
7523 file TEXT NOT NULL,
7524 line INTEGER NOT NULL,
7525 byte_start INTEGER NOT NULL,
7526 byte_end INTEGER NOT NULL,
7527 provenance TEXT NOT NULL
7528 );
7529 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
7530 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
7531
7532 CREATE TABLE IF NOT EXISTS type_ref_names (
7533 name TEXT PRIMARY KEY
7534 );
7535
7536 CREATE TABLE IF NOT EXISTS backend_file_state (
7537 backend TEXT NOT NULL,
7538 workspace_root TEXT NOT NULL,
7539 file_path TEXT NOT NULL,
7540 content_hash TEXT NOT NULL,
7541 status TEXT NOT NULL,
7542 updated_at INTEGER NOT NULL,
7543 PRIMARY KEY(backend, workspace_root, file_path, content_hash)
7544 );
7545 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
7546
7547 CREATE TABLE IF NOT EXISTS meta (
7548 k TEXT PRIMARY KEY,
7549 v TEXT NOT NULL
7550 );
7551
7552 -- The file walk is staged on disk so extraction can page through a
7553 -- deterministic inventory without retaining every source path in heap.
7554 CREATE TABLE IF NOT EXISTS staging_file_inventory (
7555 path TEXT PRIMARY KEY,
7556 size INTEGER NOT NULL
7557 ) WITHOUT ROWID;
7558
7559 -- Context needed only while a generation is staged. Raw refs live in
7560 -- `refs` with status `staged`; this table preserves the caller symbol
7561 -- needed to avoid inventing self edges during the later resolve pass.
7562 CREATE TABLE IF NOT EXISTS staging_ref_context (
7563 ref_id TEXT PRIMARY KEY,
7564 caller_symbol TEXT
7565 );",
7566 )?;
7567 insert_meta(conn)?;
7568 Ok(())
7569}
7570
7571fn insert_meta(conn: &Connection) -> Result<()> {
7572 conn.execute(
7573 "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
7574 params![SCHEMA_VERSION.to_string()],
7575 )?;
7576 conn.execute(
7577 "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
7578 params![schema_fingerprint()],
7579 )?;
7580 conn.execute(
7581 "INSERT OR IGNORE INTO meta(k, v) VALUES('projection_write_revision', '0')",
7582 [],
7583 )?;
7584 Ok(())
7585}
7586
7587const PATH_IDENTITY_MISMATCH_META_KEY: &str = "path_identity_mismatch";
7591
7592fn record_path_identity_mismatch(conn: &Connection, error: &CallGraphStoreError) -> Result<()> {
7593 let CallGraphStoreError::PathIdentityMismatch { path, project_root } = error else {
7594 return Ok(());
7595 };
7596 conn.execute(
7597 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7598 params![
7599 PATH_IDENTITY_MISMATCH_META_KEY,
7600 format!(
7601 "callgraph_path_identity_mismatch path={} project_root={}",
7602 path.display(),
7603 project_root.display()
7604 )
7605 ],
7606 )?;
7607 Ok(())
7608}
7609
7610pub(super) fn path_identity_mismatch_reason(conn: &Connection) -> Result<Option<String>> {
7611 conn.query_row(
7612 "SELECT v FROM meta WHERE k = ?1",
7613 [PATH_IDENTITY_MISMATCH_META_KEY],
7614 |row| row.get(0),
7615 )
7616 .optional()
7617 .map_err(Into::into)
7618}
7619
7620fn projection_write_revision(conn: &Connection) -> Result<Option<u64>> {
7621 let revision: Option<String> = conn
7622 .query_row(
7623 "SELECT v FROM meta WHERE k = 'projection_write_revision'",
7624 [],
7625 |row| row.get(0),
7626 )
7627 .optional()?;
7628 revision
7629 .map(|revision| {
7630 revision.parse::<u64>().map_err(|error| {
7631 CallGraphStoreError::Unavailable(format!(
7632 "callgraph projection write revision is invalid: {error}"
7633 ))
7634 })
7635 })
7636 .transpose()
7637}
7638
7639fn bump_projection_write_revision(tx: &Transaction<'_>) -> Result<()> {
7642 tx.execute(
7643 "INSERT INTO meta(k, v) VALUES('projection_write_revision', '1')
7644 ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1",
7645 [],
7646 )?;
7647 #[cfg(test)]
7648 note_projection_revision_bump_for_test();
7649 Ok(())
7650}
7651
7652pub(crate) fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
7653 conn.execute(
7654 "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
7655 params![if ready { "1" } else { "0" }],
7656 )?;
7657 Ok(())
7658}
7659
7660fn database_ready(conn: &Connection) -> Result<bool> {
7661 let schema_version: Option<String> = conn
7662 .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
7663 row.get(0)
7664 })
7665 .optional()?;
7666 let fingerprint: Option<String> = conn
7667 .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
7668 row.get(0)
7669 })
7670 .optional()?;
7671 let ready: Option<String> = conn
7672 .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
7673 .optional()?;
7674
7675 let expected_schema = SCHEMA_VERSION.to_string();
7676 let expected_fingerprint = schema_fingerprint();
7677 Ok(schema_version.as_deref() == Some(expected_schema.as_str())
7678 && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
7679 && ready.as_deref() == Some("1"))
7680}
7681
7682fn ensure_database_ready(conn: &Connection) -> Result<()> {
7683 if database_ready(conn)? {
7684 Ok(())
7685 } else {
7686 Err(CallGraphStoreError::Unavailable(
7687 "database is missing, stale, or mid-build".to_string(),
7688 ))
7689 }
7690}
7691
7692fn schema_fingerprint() -> String {
7693 let input =
7698 format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
7699 hash_to_hex(blake3::hash(input.as_bytes()))
7700}
7701
7702fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
7703 tx.execute_batch(
7704 "DELETE FROM staging_ref_context;
7705 DELETE FROM edges;
7706 DELETE FROM file_dependencies;
7707 DELETE FROM refs;
7708 DELETE FROM dispatch_hints;
7709 DELETE FROM type_ref_names;
7710 DELETE FROM backend_file_state;
7711 DELETE FROM nodes;
7712 DELETE FROM files;",
7713 )?;
7714 Ok(())
7715}
7716
7717fn staged_build_phase(conn: &Connection) -> Result<Option<String>> {
7718 conn.query_row(
7719 "SELECT v FROM meta WHERE k = ?1",
7720 params![STAGED_BUILD_PHASE],
7721 |row| row.get(0),
7722 )
7723 .optional()
7724 .map_err(Into::into)
7725}
7726
7727fn staged_u64(conn: &Connection, key: &str) -> Result<u64> {
7728 let value = staged_string(conn, key)?;
7729 Ok(value.and_then(|value| value.parse().ok()).unwrap_or(0))
7730}
7731
7732fn staged_string(conn: &Connection, key: &str) -> Result<Option<String>> {
7733 conn.query_row("SELECT v FROM meta WHERE k = ?1", params![key], |row| {
7734 row.get::<_, String>(0)
7735 })
7736 .optional()
7737 .map_err(Into::into)
7738}
7739
7740fn set_staged_build_phase(tx: &Transaction<'_>, phase: &str) -> Result<()> {
7741 tx.execute(
7742 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7743 params![STAGED_BUILD_PHASE, phase],
7744 )?;
7745 Ok(())
7746}
7747
7748fn set_staged_u64(tx: &Transaction<'_>, key: &str, value: u64) -> Result<()> {
7749 set_staged_string(tx, key, &value.to_string())
7750}
7751
7752fn set_staged_string(tx: &Transaction<'_>, key: &str, value: &str) -> Result<()> {
7753 tx.execute(
7754 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7755 params![key, value],
7756 )?;
7757 Ok(())
7758}
7759
7760fn increment_staged_extracted_bytes(tx: &Transaction<'_>, bytes: u64) -> Result<()> {
7764 tx.execute(
7765 "INSERT INTO meta(k, v) VALUES(?1, ?2)
7766 ON CONFLICT(k) DO UPDATE SET v = CAST(meta.v AS INTEGER) + excluded.v",
7767 params![STAGED_COMMITTED_EXTRACTED_BYTES, bytes.to_string()],
7768 )?;
7769 Ok(())
7770}
7771
7772fn staged_content_matches(conn: &Connection, project_root: &Path, path: &Path) -> Result<bool> {
7773 let Ok(source) = std::fs::read_to_string(path) else {
7774 return Ok(false);
7775 };
7776 let Ok(freshness) = collect_source_freshness(path, &source) else {
7777 return Ok(false);
7778 };
7779 let rel_path = relative_path(project_root, path);
7780 let staged_hash = conn
7781 .query_row(
7782 "SELECT content_hash FROM files WHERE path = ?1",
7783 params![rel_path],
7784 |row| row.get::<_, String>(0),
7785 )
7786 .optional()?;
7787 Ok(staged_hash.as_deref() == Some(hash_to_hex(freshness.content_hash).as_str()))
7788}
7789
7790fn delete_staged_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
7791 tx.execute(
7792 "DELETE FROM staging_ref_context
7793 WHERE ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?1)",
7794 params![rel_path],
7795 )?;
7796 delete_file_rows(tx, rel_path)
7797}
7798
7799fn prune_staged_files_not_in_inventory(conn: &mut Connection) -> Result<()> {
7800 loop {
7801 let removed = {
7802 let mut statement = conn.prepare(
7803 "SELECT path
7804 FROM files
7805 WHERE NOT EXISTS (
7806 SELECT 1 FROM staging_file_inventory inventory
7807 WHERE inventory.path = files.path
7808 )
7809 ORDER BY path
7810 LIMIT ?1",
7811 )?;
7812 let paths = statement
7813 .query_map(params![COLD_BUILD_EXTRACT_BATCH_FILES as i64], |row| {
7814 row.get::<_, String>(0)
7815 })?
7816 .collect::<std::result::Result<Vec<_>, _>>()?;
7817 paths
7818 };
7819 if removed.is_empty() {
7820 return Ok(());
7821 }
7822 let tx = conn.transaction()?;
7823 for path in removed {
7824 delete_staged_file_rows(&tx, &path)?;
7825 }
7826 tx.commit()?;
7827 }
7828}
7829
7830struct StagedFileBatch {
7831 paths: Vec<PathBuf>,
7832 last_path: String,
7833}
7834
7835fn load_staged_file_batch(
7836 conn: &Connection,
7837 project_root: &Path,
7838 after_path: &str,
7839 max_files: usize,
7840 max_bytes: u64,
7841) -> Result<Option<StagedFileBatch>> {
7842 let mut statement = conn.prepare(
7843 "SELECT path, size
7844 FROM staging_file_inventory
7845 WHERE path > ?1
7846 ORDER BY path
7847 LIMIT ?2",
7848 )?;
7849 let mut rows = statement.query(params![after_path, max_files.max(1) as i64])?;
7850 let mut paths = Vec::with_capacity(max_files.max(1));
7851 let mut last_path = String::new();
7852 let mut batch_bytes = 0u64;
7853 while let Some(row) = rows.next()? {
7854 let rel_path = row.get::<_, String>(0)?;
7855 let size = row.get::<_, i64>(1)?.max(0) as u64;
7856 if !paths.is_empty() && batch_bytes.saturating_add(size) > max_bytes {
7857 break;
7858 }
7859 batch_bytes = batch_bytes.saturating_add(size);
7860 last_path.clone_from(&rel_path);
7861 paths.push(project_root.join(rel_path));
7862 }
7863 if paths.is_empty() {
7864 Ok(None)
7865 } else {
7866 Ok(Some(StagedFileBatch { paths, last_path }))
7867 }
7868}
7869
7870fn staged_corpus_fingerprint(conn: &Connection, project_root: &Path) -> Result<String> {
7871 let mut statement = conn.prepare("SELECT path FROM staging_file_inventory ORDER BY path")?;
7872 let mut rows = statement.query([])?;
7873 let mut fingerprint = CorpusFingerprint::default();
7874 while let Some(row) = rows.next()? {
7875 let rel_path = row.get::<_, String>(0)?;
7876 fingerprint.add_path(project_root, &project_root.join(rel_path));
7877 }
7878 Ok(fingerprint.finish(project_root))
7879}
7880
7881fn load_staged_ref_window(
7882 conn: &Connection,
7883 after_rowid: u64,
7884 limit: usize,
7885) -> Result<Vec<StagedRef>> {
7886 let mut statement = conn.prepare(
7887 "SELECT refs.rowid, refs.ref_id, refs.caller_node, refs.caller_file, refs.kind,
7888 refs.short_name, refs.full_ref, refs.module_path, refs.import_kind,
7889 refs.local_name, refs.requested_name, refs.namespace_alias, refs.wildcard,
7890 refs.line, refs.byte_start, refs.byte_end, staging_ref_context.caller_symbol
7891 FROM refs
7892 LEFT JOIN staging_ref_context ON staging_ref_context.ref_id = refs.ref_id
7893 WHERE refs.status = 'staged' AND refs.rowid > ?1
7894 ORDER BY refs.rowid
7895 LIMIT ?2",
7896 )?;
7897 let rows = statement.query_map(params![after_rowid as i64, limit as i64], |row| {
7898 Ok(StagedRef {
7899 rowid: row.get::<_, i64>(0)? as u64,
7900 raw: RawRef {
7901 ref_id: row.get(1)?,
7902 caller_node: row.get(2)?,
7903 caller_file: row.get(3)?,
7904 kind: row.get(4)?,
7905 short_name: row.get(5)?,
7906 full_ref: row.get(6)?,
7907 module_path: row.get(7)?,
7908 import_kind: row.get(8)?,
7909 local_name: row.get(9)?,
7910 requested_name: row.get(10)?,
7911 namespace_alias: row.get(11)?,
7912 wildcard: row.get::<_, i64>(12)? != 0,
7913 line: row.get::<_, i64>(13)? as u32,
7914 byte_start: row.get::<_, i64>(14)? as usize,
7915 byte_end: row.get::<_, i64>(15)? as usize,
7916 caller_symbol: row.get(16)?,
7917 dependencies: BTreeSet::new(),
7918 },
7919 })
7920 })?;
7921 let mut refs = rows.collect::<std::result::Result<Vec<_>, _>>()?;
7922 drop(statement);
7923
7924 let mut dependencies = HashMap::<String, BTreeSet<String>>::new();
7925 let mut dependency_statement = conn
7926 .prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1 ORDER BY dep_file")?;
7927 for raw in refs.iter_mut().map(|entry| &mut entry.raw) {
7928 if !dependencies.contains_key(&raw.caller_file) {
7929 let rows =
7930 dependency_statement.query_map(params![raw.caller_file], |row| row.get(0))?;
7931 let values = rows.collect::<std::result::Result<BTreeSet<_>, _>>()?;
7932 dependencies.insert(raw.caller_file.clone(), values);
7933 }
7934 raw.dependencies = dependencies
7935 .get(&raw.caller_file)
7936 .cloned()
7937 .unwrap_or_default();
7938 }
7939 Ok(refs)
7940}
7941
7942fn unresolved_staged_ref(raw: RawRef) -> ResolvedRef {
7943 ResolvedRef {
7944 dependencies: raw.dependencies.clone(),
7945 raw,
7946 status: "unresolved".to_string(),
7947 target_node: None,
7948 target_file: None,
7949 target_symbol: None,
7950 edge: None,
7951 }
7952}
7953
7954fn query_count(conn: &Connection, query: &str) -> Result<u64> {
7955 conn.query_row(query, [], |row| row.get::<_, i64>(0))
7956 .map(|count| count.max(0) as u64)
7957 .map_err(Into::into)
7958}
7959
7960fn cold_build_stats_from_connection(conn: &Connection, started: Instant) -> Result<ColdBuildStats> {
7961 let files = query_count(conn, "SELECT COUNT(*) FROM files")? as usize;
7962 let nodes = query_count(conn, "SELECT COUNT(*) FROM nodes")? as usize;
7963 let refs = query_count(conn, "SELECT COUNT(*) FROM refs")? as usize;
7964 let edges = query_count(conn, "SELECT COUNT(*) FROM edges")? as usize;
7965 let failed_files = staged_failed_files(conn)?;
7966 let elapsed_ms = started.elapsed().as_millis();
7967 crate::slog_info!(
7968 "perf callgraph_store bounded cold_build: files={} nodes={} refs={} edges={} committed_extracted_bytes={} ms={}",
7969 files,
7970 nodes,
7971 refs,
7972 edges,
7973 staged_u64(conn, STAGED_COMMITTED_EXTRACTED_BYTES)?,
7974 elapsed_ms
7975 );
7976 Ok(ColdBuildStats {
7977 files,
7978 nodes,
7979 refs,
7980 edges,
7981 failed_files,
7982 elapsed_ms,
7983 })
7984}
7985
7986fn staged_failed_files(conn: &Connection) -> Result<Vec<String>> {
7987 let mut statement = conn.prepare(
7988 "SELECT DISTINCT file_path FROM backend_file_state WHERE status = 'stale' ORDER BY file_path",
7989 )?;
7990 let rows = statement.query_map([], |row| row.get(0))?;
7991 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
7992}
7993
7994fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
7995 tx.execute_batch(
7996 "DROP INDEX IF EXISTS idx_nodes_file;
7997 DROP INDEX IF EXISTS idx_nodes_name;
7998 DROP INDEX IF EXISTS idx_nodes_scoped;
7999 DROP INDEX IF EXISTS idx_refs_short_name;
8000 DROP INDEX IF EXISTS idx_refs_kind_caller_file;
8001 DROP INDEX IF EXISTS idx_refs_caller_file;
8002 DROP INDEX IF EXISTS idx_refs_caller_node_kind;
8003 DROP INDEX IF EXISTS idx_refs_target_file;
8004 DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
8005 DROP INDEX IF EXISTS idx_edges_source_kind;
8006 DROP INDEX IF EXISTS idx_edges_target_kind;
8007 DROP INDEX IF EXISTS idx_edges_target_file_symbol;
8008 DROP INDEX IF EXISTS idx_edges_ref_id;
8009 DROP INDEX IF EXISTS idx_dispatch_hints_method;
8010 DROP INDEX IF EXISTS idx_dispatch_hints_file;
8011 DROP INDEX IF EXISTS idx_backend_file_state_file;",
8012 )?;
8013 Ok(())
8014}
8015
8016fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
8017 tx.execute_batch(
8018 "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
8019 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
8020 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
8021 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
8022 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
8023 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
8024 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
8025 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
8026 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
8027 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
8028 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
8029 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
8030 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
8031 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
8032 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
8033 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
8034 )?;
8035 Ok(())
8036}
8037
8038const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
8039 ("files", "path"),
8040 ("nodes", "file_path"),
8041 ("refs", "caller_file"),
8042 ("refs", "target_file"),
8043 ("file_dependencies", "file_path"),
8044 ("file_dependencies", "dep_file"),
8045 ("edges", "target_file"),
8046 ("dispatch_hints", "file"),
8047 ("backend_file_state", "file_path"),
8048];
8049
8050fn reconcile_workspace_roots(
8063 conn: &mut Connection,
8064 project_root: &Path,
8065 allow_repair: bool,
8066) -> Result<OpenRootRepair> {
8067 let roots = stored_workspace_roots(conn)?;
8068 let current_root = project_root.display().to_string();
8069 if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
8070 return Ok(OpenRootRepair::None);
8071 }
8072
8073 if let Some(sample) = sample_absolute_data_path(conn)? {
8074 return Ok(OpenRootRepair::NeedsRebuild {
8075 previous_roots: roots,
8076 current_root,
8077 reason: format!("absolute store data path row {sample}"),
8078 });
8079 }
8080
8081 for stored_root in roots.iter() {
8082 if stored_root == ¤t_root {
8083 continue;
8084 }
8085 if Path::new(stored_root).exists() {
8086 let reason = format!(
8087 "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
8088 );
8089 return Ok(OpenRootRepair::NeedsRebuild {
8090 previous_roots: roots,
8091 current_root,
8092 reason,
8093 });
8094 }
8095 }
8096
8097 if !allow_repair {
8098 return Ok(OpenRootRepair::NeedsRebuild {
8099 previous_roots: roots,
8100 current_root,
8101 reason: "workspace root metadata requires deferred repair".to_string(),
8102 });
8103 }
8104
8105 publish_if_current(|| {
8106 let tx = conn.transaction()?;
8107 tx.execute(
8108 "UPDATE OR IGNORE backend_file_state
8109 SET workspace_root = ?1
8110 WHERE workspace_root <> ?1",
8111 params![¤t_root],
8112 )?;
8113 tx.execute(
8114 "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
8115 params![¤t_root],
8116 )?;
8117 tx.commit()?;
8118 Ok(())
8119 })?;
8120
8121 crate::slog_info!(
8122 "callgraph store re-rooted from {} to {}",
8123 roots.join(", "),
8124 current_root
8125 );
8126 Ok(OpenRootRepair::ReRooted)
8127}
8128
8129fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
8130 let mut stmt = conn.prepare(
8131 "SELECT DISTINCT workspace_root
8132 FROM backend_file_state
8133 ORDER BY workspace_root",
8134 )?;
8135 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
8136 rows.collect::<std::result::Result<Vec<_>, _>>()
8137 .map_err(Into::into)
8138}
8139
8140fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
8141 for (table, column) in STORE_DATA_PATH_COLUMNS {
8142 let sql = format!(
8143 "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
8144 );
8145 let mut stmt = conn.prepare(&sql)?;
8146 let mut rows = stmt.query([])?;
8147 while let Some(row) = rows.next()? {
8148 let value: String = row.get(0)?;
8149 if stored_path_is_absolute(&value) {
8150 return Ok(Some(format!("{table}.{column}={value}")));
8151 }
8152 }
8153 }
8154 Ok(None)
8155}
8156
8157fn stored_path_is_absolute(value: &str) -> bool {
8158 if value.is_empty() {
8159 return false;
8160 }
8161 if Path::new(value).is_absolute() || value.starts_with('/') {
8162 return true;
8163 }
8164 let bytes = value.as_bytes();
8165 if bytes.len() >= 3
8166 && bytes[1] == b':'
8167 && (bytes[2] == b'/' || bytes[2] == b'\\')
8168 && bytes[0].is_ascii_alphabetic()
8169 {
8170 return true;
8171 }
8172 value.starts_with("\\\\") || value.starts_with("//")
8173}
8174
8175fn log_root_repair_rebuild(repair: &OpenRootRepair) {
8176 if let OpenRootRepair::NeedsRebuild {
8177 previous_roots,
8178 current_root,
8179 reason,
8180 } = repair
8181 {
8182 crate::slog_info!(
8183 "callgraph cold-build decision: reason=re-rooting refused; from={}; to={}; detail={}",
8184 previous_roots.join(", "),
8185 current_root,
8186 reason
8187 );
8188 }
8189}
8190
8191fn now_nanos() -> u128 {
8193 SystemTime::now()
8194 .duration_since(UNIX_EPOCH)
8195 .unwrap_or(Duration::ZERO)
8196 .as_nanos()
8197}
8198
8199fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
8204 callgraph_dir.join(format!("{project_key}.current"))
8205}
8206
8207fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
8211 callgraph_dir.join(format!("{project_key}.sqlite"))
8212}
8213
8214fn generation_file_name(project_key: &str) -> String {
8218 format!(
8219 "{project_key}.g{}.{}.sqlite",
8220 now_nanos(),
8221 std::process::id()
8222 )
8223}
8224
8225fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
8227 let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
8228 let name = text.trim();
8229 if name.is_empty() {
8230 None
8231 } else {
8232 Some(name.to_string())
8233 }
8234}
8235
8236fn db_path_ready(path: &Path) -> bool {
8239 (|| -> Result<bool> {
8240 let conn = open_readonly_connection(path)?;
8241 database_ready(&conn)
8242 })()
8243 .unwrap_or(false)
8244}
8245
8246fn resolve_ready_target(
8254 callgraph_dir: &Path,
8255 project_key: &str,
8256) -> Option<(PathBuf, Option<String>)> {
8257 for _ in 0..5 {
8258 if let Some(generation) = read_pointer(callgraph_dir, project_key) {
8259 let gen_path = callgraph_dir.join(&generation);
8260 if gen_path.is_file() {
8261 return (migration_manifest_valid(callgraph_dir, &generation)
8262 && db_path_ready(&gen_path))
8263 .then_some((gen_path, Some(generation)));
8264 }
8265 std::thread::sleep(Duration::from_millis(5));
8268 continue;
8269 }
8270 let legacy = legacy_sqlite_path(callgraph_dir, project_key);
8272 return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
8273 }
8274 None
8275}
8276
8277fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
8281 let pointer = pointer_path(callgraph_dir, project_key);
8282 let tmp = callgraph_dir.join(format!(
8283 "{project_key}.current.tmp.{}.{}",
8284 std::process::id(),
8285 now_nanos()
8286 ));
8287 {
8288 use std::io::Write as _;
8289 let mut file = std::fs::File::create(&tmp)?;
8290 file.write_all(generation.as_bytes())?;
8291 file.write_all(b"\n")?;
8292 file.sync_all()?;
8293 }
8294 if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
8295 let _ = std::fs::remove_file(&tmp);
8296 return Err(error.into());
8297 }
8298 crate::fs_lock::sync_parent(&pointer);
8299 Ok(())
8300}
8301
8302#[derive(Clone, Debug)]
8303struct GenerationGcCandidate {
8304 name: String,
8305 path: PathBuf,
8306 modified: SystemTime,
8307}
8308
8309fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
8315 let temp_grace = Duration::from_secs(60);
8316 let now = SystemTime::now();
8317 let pointer_current =
8318 read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
8319 let gen_prefix = format!("{project_key}.g");
8320 let tmp_prefixes = [
8321 format!("{project_key}.g"), format!("{project_key}.current."), format!("{project_key}.sqlite.tmp."), ];
8325 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
8326 return;
8327 };
8328 let mut gens: Vec<GenerationGcCandidate> = Vec::new();
8329 for entry in entries.flatten() {
8330 let name = entry.file_name();
8331 let name = name.to_string_lossy().to_string();
8332 let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
8333 let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
8334
8335 if name.contains(".tmp.") {
8337 if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
8338 let _ = std::fs::remove_file(entry.path());
8339 }
8340 continue;
8341 }
8342
8343 if name == format!("{project_key}.sqlite") {
8346 remove_sqlite_file_set(&entry.path());
8347 continue;
8348 }
8349
8350 if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
8351 gens.push(GenerationGcCandidate {
8352 name,
8353 path: entry.path(),
8354 modified: mtime,
8355 });
8356 }
8357 }
8358
8359 let mut superseded = gens
8360 .iter()
8361 .filter(|generation| generation.name != pointer_current)
8362 .collect::<Vec<_>>();
8363 superseded.sort_by(|left, right| {
8364 right
8365 .modified
8366 .cmp(&left.modified)
8367 .then_with(|| right.name.cmp(&left.name))
8368 });
8369 let previous = superseded.first().map(|generation| generation.name.clone());
8370
8371 for generation in gens {
8372 let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
8373 if generation.name == pointer_current
8374 || Some(generation.name.as_str()) == previous.as_deref()
8375 {
8376 continue;
8377 }
8378
8379 let age = now
8380 .duration_since(generation.modified)
8381 .unwrap_or(Duration::ZERO);
8382 if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
8383 continue;
8384 }
8385
8386 remove_sqlite_file_set(&generation.path);
8387 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
8388 let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
8389 callgraph_dir,
8390 &generation.name,
8391 ));
8392 }
8393}
8394
8395fn remove_sqlite_file_set(path: &Path) {
8396 let _ = std::fs::remove_file(path);
8397 remove_sqlite_sidecars(path);
8398}
8399
8400fn remove_sqlite_sidecars(path: &Path) {
8401 let path_text = path.to_string_lossy();
8402 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
8403 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
8404 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
8405}
8406
8407#[derive(Clone, Copy, Debug, Default)]
8408struct CallgraphRootSweepSummary {
8409 scanned: usize,
8410 removed: usize,
8411 bytes: u64,
8412 generation_gc: usize,
8413 skipped_memo: usize,
8414 skipped_derived: usize,
8415 skipped_fresh: usize,
8416 skipped_reader: usize,
8417 skipped_lease: usize,
8418 skipped_unreadable: usize,
8419 budget_exhausted: bool,
8420}
8421
8422#[derive(Clone, Copy, Debug, Default)]
8423struct CallgraphRootFileStats {
8424 newest: Option<SystemTime>,
8425 bytes: u64,
8426}
8427
8428enum CallgraphRootWalk {
8429 Complete(CallgraphRootFileStats),
8430 BudgetExceeded,
8431 Failed,
8432}
8433
8434enum CallgraphRootCandidate {
8435 Removed { bytes: u64 },
8436 GenerationGc,
8437 SkippedMemo,
8438 SkippedDerived,
8439 SkippedFresh,
8440 SkippedReader,
8441 SkippedLease,
8442 SkippedUnreadable,
8443 BudgetExceeded,
8444}
8445
8446fn sweep_orphaned_callgraph_root_dirs(callgraph_dir: &Path) {
8455 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
8456 return;
8457 };
8458 let root_dir = storage_root.join(crate::root_cache::RootCacheDomain::Callgraph.as_str());
8459 let memo_keys = match crate::search_index::referenced_artifact_cache_keys(&storage_root) {
8460 Ok(keys) => keys,
8461 Err(error) => {
8462 crate::slog_warn!(
8463 "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={}",
8464 root_dir.display(),
8465 error
8466 );
8467 return;
8468 }
8469 };
8470 let derived_keys = crate::search_index::derived_artifact_cache_keys();
8471 let summary = sweep_callgraph_root_dirs_with_limits(
8472 &root_dir,
8473 &memo_keys,
8474 &derived_keys,
8475 CALLGRAPH_ROOT_SWEEP_BUDGET,
8476 CALLGRAPH_ROOT_SWEEP_LIMIT,
8477 );
8478 crate::slog_info!(
8479 "callgraph root sweep root={} scanned={} removed={} bytes={} generation_gc={} skipped_memo={} skipped_derived={} skipped_fresh={} skipped_reader={} skipped_lease={} skipped_unreadable={} budget_exhausted={}",
8480 root_dir.display(),
8481 summary.scanned,
8482 summary.removed,
8483 summary.bytes,
8484 summary.generation_gc,
8485 summary.skipped_memo,
8486 summary.skipped_derived,
8487 summary.skipped_fresh,
8488 summary.skipped_reader,
8489 summary.skipped_lease,
8490 summary.skipped_unreadable,
8491 summary.budget_exhausted
8492 );
8493}
8494
8495fn sweep_callgraph_root_dirs_with_limits(
8496 root_dir: &Path,
8497 memo_keys: &HashSet<String>,
8498 derived_keys: &HashSet<String>,
8499 wall_clock_budget: Duration,
8500 entry_limit: usize,
8501) -> CallgraphRootSweepSummary {
8502 let started = Instant::now();
8503 let deadline = started + wall_clock_budget;
8504 let boundary = match crate::walk_boundary::DeviceBoundary::for_root(root_dir) {
8505 Ok(boundary) => boundary,
8506 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
8507 return CallgraphRootSweepSummary::default();
8508 }
8509 Err(error) => {
8510 crate::slog_warn!(
8511 "cannot establish filesystem boundary for callgraph root sweep {}: {}",
8512 root_dir.display(),
8513 error
8514 );
8515 return CallgraphRootSweepSummary {
8516 skipped_unreadable: 1,
8517 ..CallgraphRootSweepSummary::default()
8518 };
8519 }
8520 };
8521 let mut entries = match std::fs::read_dir(root_dir) {
8522 Ok(entries) => entries
8523 .filter_map(|entry| entry.ok())
8524 .filter_map(|entry| {
8525 let name = entry.file_name().to_string_lossy().into_owned();
8526 entry
8527 .file_type()
8528 .ok()
8529 .filter(|file_type| file_type.is_dir() && artifact_key_looks_valid(&name))
8530 .map(|_| (name, entry.path()))
8531 })
8532 .collect::<Vec<_>>(),
8533 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
8534 Err(error) => {
8535 crate::slog_warn!(
8536 "cannot read callgraph root sweep directory {}: {}",
8537 root_dir.display(),
8538 error
8539 );
8540 return CallgraphRootSweepSummary {
8541 skipped_unreadable: 1,
8542 ..CallgraphRootSweepSummary::default()
8543 };
8544 }
8545 };
8546 entries.sort_by(|left, right| left.0.cmp(&right.0));
8547
8548 let cursor_store = CALLGRAPH_ROOT_SWEEP_CURSORS.get_or_init(|| Mutex::new(HashMap::new()));
8549 let last_name = cursor_store
8550 .lock()
8551 .ok()
8552 .and_then(|cursors| cursors.get(root_dir).cloned());
8553 if let Some(start) = last_name
8554 .as_deref()
8555 .and_then(|last| entries.iter().position(|(name, _)| name.as_str() > last))
8556 {
8557 entries.rotate_left(start);
8558 }
8559
8560 let mut summary = CallgraphRootSweepSummary::default();
8561 let mut cursor_name = last_name;
8562 for (processed, (key, cache_dir)) in entries.into_iter().enumerate() {
8563 if processed >= entry_limit || Instant::now() >= deadline {
8564 summary.budget_exhausted = true;
8565 break;
8566 }
8567 summary.scanned += 1;
8568 cursor_name = Some(key.clone());
8569 match callgraph_root_candidate(
8570 &cache_dir,
8571 &key,
8572 memo_keys.contains(&key),
8573 derived_keys.contains(&key),
8574 &boundary,
8575 deadline,
8576 ) {
8577 CallgraphRootCandidate::Removed { bytes } => {
8578 summary.removed += 1;
8579 summary.bytes = summary.bytes.saturating_add(bytes);
8580 }
8581 CallgraphRootCandidate::GenerationGc => summary.generation_gc += 1,
8582 CallgraphRootCandidate::SkippedMemo => summary.skipped_memo += 1,
8583 CallgraphRootCandidate::SkippedDerived => summary.skipped_derived += 1,
8584 CallgraphRootCandidate::SkippedFresh => summary.skipped_fresh += 1,
8585 CallgraphRootCandidate::SkippedReader => summary.skipped_reader += 1,
8586 CallgraphRootCandidate::SkippedLease => summary.skipped_lease += 1,
8587 CallgraphRootCandidate::SkippedUnreadable => summary.skipped_unreadable += 1,
8588 CallgraphRootCandidate::BudgetExceeded => {
8589 summary.budget_exhausted = true;
8590 break;
8591 }
8592 }
8593 }
8594
8595 if let Ok(mut cursors) = cursor_store.lock() {
8596 if summary.budget_exhausted {
8597 if let Some(cursor_name) = cursor_name {
8598 cursors.insert(root_dir.to_path_buf(), cursor_name);
8599 }
8600 } else {
8601 cursors.remove(root_dir);
8602 }
8603 }
8604 if summary.removed > 0 {
8605 crate::fs_lock::sync_parent(root_dir);
8606 }
8607 summary
8608}
8609
8610fn callgraph_root_candidate(
8611 cache_dir: &Path,
8612 project_key: &str,
8613 memo_referenced: bool,
8614 derived_in_process: bool,
8615 boundary: &crate::walk_boundary::DeviceBoundary,
8616 deadline: Instant,
8617) -> CallgraphRootCandidate {
8618 if !boundary.should_descend(cache_dir).unwrap_or(false) {
8619 return CallgraphRootCandidate::SkippedUnreadable;
8620 }
8621 if memo_referenced || derived_in_process {
8622 return sweep_live_callgraph_root_generations(
8623 cache_dir,
8624 project_key,
8625 memo_referenced,
8626 boundary,
8627 deadline,
8628 );
8629 }
8630
8631 let stats = match callgraph_root_file_stats(cache_dir, boundary, deadline) {
8632 CallgraphRootWalk::Complete(stats) => stats,
8633 CallgraphRootWalk::BudgetExceeded => return CallgraphRootCandidate::BudgetExceeded,
8634 CallgraphRootWalk::Failed => return CallgraphRootCandidate::SkippedUnreadable,
8635 };
8636 let Some(newest) = stats.newest else {
8637 return CallgraphRootCandidate::SkippedUnreadable;
8638 };
8639 if SystemTime::now()
8640 .duration_since(newest)
8641 .unwrap_or(Duration::ZERO)
8642 < CALLGRAPH_ROOT_ORPHAN_MIN_AGE
8643 {
8644 return CallgraphRootCandidate::SkippedFresh;
8645 }
8646
8647 let _writer_lease = match crate::fs_lock::try_acquire(
8650 &crate::root_cache::writer_lease_path(cache_dir),
8651 Duration::ZERO,
8652 ) {
8653 Ok(lease) => lease,
8654 Err(_) => return CallgraphRootCandidate::SkippedLease,
8655 };
8656 if crate::root_cache::sweep_all_read_markers(cache_dir).protected {
8657 return CallgraphRootCandidate::SkippedReader;
8658 }
8659
8660 match std::fs::remove_dir_all(cache_dir) {
8661 Ok(()) => {
8662 crate::slog_info!(
8663 "callgraph root sweep reaped dir={} key={} bytes={}",
8664 cache_dir.display(),
8665 project_key,
8666 stats.bytes
8667 );
8668 CallgraphRootCandidate::Removed { bytes: stats.bytes }
8669 }
8670 Err(error) if error.kind() == std::io::ErrorKind::NotFound && !cache_dir.exists() => {
8671 crate::slog_info!(
8672 "callgraph root sweep reaped dir={} key={} bytes={}",
8673 cache_dir.display(),
8674 project_key,
8675 stats.bytes
8676 );
8677 CallgraphRootCandidate::Removed { bytes: stats.bytes }
8678 }
8679 Err(_) => CallgraphRootCandidate::SkippedUnreadable,
8680 }
8681}
8682
8683fn sweep_live_callgraph_root_generations(
8684 cache_dir: &Path,
8685 project_key: &str,
8686 memo_referenced: bool,
8687 boundary: &crate::walk_boundary::DeviceBoundary,
8688 deadline: Instant,
8689) -> CallgraphRootCandidate {
8690 if Instant::now() >= deadline {
8691 return CallgraphRootCandidate::BudgetExceeded;
8692 }
8693 let stats = match callgraph_root_file_stats(cache_dir, boundary, deadline) {
8694 CallgraphRootWalk::Complete(stats) => stats,
8695 CallgraphRootWalk::BudgetExceeded => return CallgraphRootCandidate::BudgetExceeded,
8696 CallgraphRootWalk::Failed => return CallgraphRootCandidate::SkippedUnreadable,
8697 };
8698 let Some(newest) = stats.newest else {
8699 return CallgraphRootCandidate::SkippedUnreadable;
8700 };
8701 if SystemTime::now()
8702 .duration_since(newest)
8703 .unwrap_or(Duration::ZERO)
8704 < CALLGRAPH_ROOT_ORPHAN_MIN_AGE
8705 {
8706 return CallgraphRootCandidate::SkippedFresh;
8707 }
8708 let _writer_lease = match crate::fs_lock::try_acquire(
8709 &crate::root_cache::writer_lease_path(cache_dir),
8710 Duration::ZERO,
8711 ) {
8712 Ok(lease) => lease,
8713 Err(_) => return CallgraphRootCandidate::SkippedLease,
8714 };
8715 if crate::root_cache::sweep_all_read_markers(cache_dir).protected {
8716 return CallgraphRootCandidate::SkippedReader;
8717 }
8718 if let Some(current) = read_pointer(cache_dir, project_key) {
8719 gc_old_generations(cache_dir, project_key, ¤t);
8720 return CallgraphRootCandidate::GenerationGc;
8721 }
8722 if memo_referenced {
8723 CallgraphRootCandidate::SkippedMemo
8724 } else {
8725 CallgraphRootCandidate::SkippedDerived
8726 }
8727}
8728
8729fn callgraph_root_file_stats(
8730 cache_dir: &Path,
8731 boundary: &crate::walk_boundary::DeviceBoundary,
8732 deadline: Instant,
8733) -> CallgraphRootWalk {
8734 let metadata = match std::fs::metadata(cache_dir) {
8735 Ok(metadata) => metadata,
8736 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
8737 return CallgraphRootWalk::Complete(CallgraphRootFileStats::default());
8738 }
8739 Err(_) => return CallgraphRootWalk::Failed,
8740 };
8741 let mut stats = CallgraphRootFileStats {
8742 newest: metadata.modified().ok(),
8743 bytes: 0,
8744 };
8745 match callgraph_root_file_stats_inner(cache_dir, boundary, deadline, &mut stats) {
8746 Ok(()) => CallgraphRootWalk::Complete(stats),
8747 Err(CallgraphRootWalkError::BudgetExceeded) => CallgraphRootWalk::BudgetExceeded,
8748 Err(CallgraphRootWalkError::Failed) => CallgraphRootWalk::Failed,
8749 }
8750}
8751
8752enum CallgraphRootWalkError {
8753 BudgetExceeded,
8754 Failed,
8755}
8756
8757fn callgraph_root_file_stats_inner(
8758 directory: &Path,
8759 boundary: &crate::walk_boundary::DeviceBoundary,
8760 deadline: Instant,
8761 stats: &mut CallgraphRootFileStats,
8762) -> std::result::Result<(), CallgraphRootWalkError> {
8763 if Instant::now() >= deadline {
8764 return Err(CallgraphRootWalkError::BudgetExceeded);
8765 }
8766 let entries = std::fs::read_dir(directory).map_err(|_| CallgraphRootWalkError::Failed)?;
8767 for entry in entries {
8768 if Instant::now() >= deadline {
8769 return Err(CallgraphRootWalkError::BudgetExceeded);
8770 }
8771 let entry = entry.map_err(|_| CallgraphRootWalkError::Failed)?;
8772 let file_type = entry
8773 .file_type()
8774 .map_err(|_| CallgraphRootWalkError::Failed)?;
8775 if file_type.is_symlink() {
8776 return Err(CallgraphRootWalkError::Failed);
8777 }
8778 let path = entry.path();
8779 if file_type.is_dir() {
8780 if !boundary
8781 .should_descend(&path)
8782 .map_err(|_| CallgraphRootWalkError::Failed)?
8783 {
8784 return Err(CallgraphRootWalkError::Failed);
8785 }
8786 let metadata = entry
8787 .metadata()
8788 .map_err(|_| CallgraphRootWalkError::Failed)?;
8789 merge_newest_callgraph_root_mtime(stats, metadata.modified().ok());
8790 callgraph_root_file_stats_inner(&path, boundary, deadline, stats)?;
8791 continue;
8792 }
8793 if !file_type.is_file() {
8794 return Err(CallgraphRootWalkError::Failed);
8795 }
8796 let metadata = entry
8797 .metadata()
8798 .map_err(|_| CallgraphRootWalkError::Failed)?;
8799 stats.bytes = stats.bytes.saturating_add(metadata.len());
8800 merge_newest_callgraph_root_mtime(stats, metadata.modified().ok());
8801 }
8802 Ok(())
8803}
8804
8805fn merge_newest_callgraph_root_mtime(
8806 stats: &mut CallgraphRootFileStats,
8807 modified: Option<SystemTime>,
8808) {
8809 if let Some(modified) = modified {
8810 if stats.newest.is_none_or(|newest| modified > newest) {
8811 stats.newest = Some(modified);
8812 }
8813 }
8814}
8815
8816fn artifact_key_looks_valid(key: &str) -> bool {
8817 key.len() == 16 && key.bytes().all(|byte| byte.is_ascii_hexdigit())
8818}
8819
8820#[cfg(test)]
8821fn reset_callgraph_root_sweep_cursor_for_test() {
8822 if let Some(cursors) = CALLGRAPH_ROOT_SWEEP_CURSORS.get() {
8823 cursors.lock().unwrap().clear();
8824 }
8825}
8826
8827const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
8841
8842fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
8854 sweep_orphaned_build_temps(callgraph_dir);
8855 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
8856 return;
8857 };
8858 let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
8859 let Ok(boundary) = crate::walk_boundary::DeviceBoundary::for_root(&storage_root) else {
8863 crate::slog_warn!(
8864 "cannot establish filesystem boundary for callgraph sweep {}",
8865 storage_root.display()
8866 );
8867 return;
8868 };
8869 let mut skipped_foreign_mounts = 0usize;
8870
8871 let root_keyed_dir = storage_root.join(domain);
8873 if root_keyed_dir.is_dir() {
8874 if boundary.should_descend(&root_keyed_dir).unwrap_or(false) {
8875 if let Ok(entries) = std::fs::read_dir(&root_keyed_dir) {
8876 for entry in entries.flatten() {
8877 let path = entry.path();
8878 if path.is_dir() {
8879 if boundary.should_descend(&path).unwrap_or(false) {
8880 sweep_orphaned_build_temps(&path);
8881 } else {
8882 skipped_foreign_mounts += 1;
8883 }
8884 }
8885 }
8886 }
8887 } else {
8888 skipped_foreign_mounts += 1;
8889 }
8890 }
8891
8892 if let Ok(entries) = std::fs::read_dir(&storage_root) {
8894 for entry in entries.flatten() {
8895 let harness_dir = entry.path();
8896 if !harness_dir.is_dir() {
8897 continue;
8898 }
8899 if !boundary.should_descend(&harness_dir).unwrap_or(false) {
8900 skipped_foreign_mounts += 1;
8901 continue;
8902 }
8903 let legacy_dir = harness_dir.join(domain);
8904 if legacy_dir.is_dir() {
8905 if boundary.should_descend(&legacy_dir).unwrap_or(false) {
8906 sweep_orphaned_build_temps(&legacy_dir);
8907 } else {
8908 skipped_foreign_mounts += 1;
8909 }
8910 }
8911 }
8912 }
8913 if skipped_foreign_mounts > 0 {
8914 crate::slog_warn!(
8915 "callgraph sweep skipped {} foreign filesystem mount(s) below {}",
8916 skipped_foreign_mounts,
8917 storage_root.display()
8918 );
8919 }
8920}
8921
8922fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
8925 sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
8926}
8927
8928fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
8931 let now = SystemTime::now();
8932 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
8933 return;
8934 };
8935 let mut removed_any = false;
8936 for entry in entries.flatten() {
8937 let name = entry.file_name().to_string_lossy().to_string();
8938 if !name.contains(".sqlite.tmp.") {
8944 continue;
8945 }
8946 let mtime = entry
8947 .metadata()
8948 .and_then(|meta| meta.modified())
8949 .unwrap_or(now);
8950 if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
8951 continue;
8952 }
8953 match std::fs::remove_file(entry.path()) {
8959 Ok(()) => removed_any = true,
8960 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
8961 Err(_) => {}
8962 }
8963 }
8964 if removed_any {
8965 crate::fs_lock::sync_parent(callgraph_dir);
8966 }
8967}
8968
8969fn build_pool_size() -> usize {
8977 std::thread::available_parallelism()
8978 .map(|parallelism| parallelism.get())
8979 .unwrap_or(1)
8980 .div_ceil(2)
8981 .clamp(1, 8)
8982}
8983
8984fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
8985 let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
8986 Ok(extract) => Ok(extract),
8987 Err(error) => {
8988 let abs_path =
8989 normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
8990 let rel_path = relative_path(project_root, &abs_path);
8991 let freshness = cache_freshness::collect(&abs_path).ok();
8992 log::debug!(
8993 "callgraph store: skipping {} during cold build: {}",
8994 abs_path.display(),
8995 error
8996 );
8997 Err(ExtractFailure {
8998 rel_path,
8999 freshness,
9000 })
9001 }
9002 };
9003
9004 let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
9005 files.par_iter().map(extract_one).collect()
9006 };
9007
9008 let results = match rayon::ThreadPoolBuilder::new()
9011 .num_threads(build_pool_size())
9012 .thread_name(|index| format!("aft-callgraph-build-{index}"))
9013 .stack_size(8 * 1024 * 1024)
9014 .build()
9015 {
9016 Ok(pool) => pool.install(run),
9017 Err(error) => {
9018 log::warn!(
9019 "callgraph store: bounded build pool unavailable ({error}); using global pool"
9020 );
9021 run()
9022 }
9023 };
9024
9025 let mut extracts = Vec::new();
9026 let mut failures = Vec::new();
9027 for result in results {
9028 match result {
9029 Ok(extract) => extracts.push(extract),
9030 Err(failure) => failures.push(failure),
9031 }
9032 }
9033 BuildExtractsResult { extracts, failures }
9034}
9035
9036fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
9037 let metadata = std::fs::metadata(path)?;
9038 let size = metadata.len();
9039 let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
9040 cache_freshness::zero_hash()
9041 } else if source.len() as u64 == size {
9042 cache_freshness::hash_bytes(source.as_bytes())
9043 } else {
9044 cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
9045 };
9046 Ok(FileFreshness {
9047 mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
9048 size,
9049 content_hash,
9050 })
9051}
9052
9053fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
9054 let abs_path = normalize_file_path(project_root, path)?;
9055 let rel_path = relative_path(project_root, &abs_path);
9056 let source = std::fs::read_to_string(&abs_path)?;
9057 let freshness = collect_source_freshness(&abs_path, &source)?;
9058 let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
9059 let lang = data.lang;
9060 if lang == LangId::Rust {
9061 extend_rust_imports_with_nested_uses(&source, &mut data);
9062 }
9063 let mut nodes = build_node_records(&rel_path, &source, &data)?;
9064 let node_by_scoped: HashMap<String, String> = nodes
9065 .iter()
9066 .map(|node| (node.scoped_name.clone(), node.id.clone()))
9067 .collect();
9068 let import_dependencies = import_dependencies(
9069 project_root,
9070 &abs_path,
9071 &data.import_block.imports,
9072 &FactPaths {
9073 root: project_root,
9074 facts: &DiskFacts::new(project_root),
9075 },
9076 );
9077 let line_index = LineIndex::new(&source);
9078 let reexports = collect_reexport_refs(
9079 project_root,
9080 &abs_path,
9081 &rel_path,
9082 &source,
9083 &FactPaths {
9084 root: project_root,
9085 facts: &DiskFacts::new(project_root),
9086 },
9087 );
9088 let rust_reexports = if lang == LangId::Rust {
9089 collect_rust_pub_use_reexport_refs(
9090 project_root,
9091 &abs_path,
9092 &rel_path,
9093 &data.import_block.imports,
9094 &line_index,
9095 &FactPaths {
9096 root: project_root,
9097 facts: &DiskFacts::new(project_root),
9098 },
9099 )
9100 } else {
9101 ReexportRefs {
9102 raw_refs: Vec::new(),
9103 surface_parts: Vec::new(),
9104 }
9105 };
9106 let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
9107 let mut raw_refs = Vec::new();
9108 raw_refs.extend(build_call_refs(
9109 &rel_path,
9110 &data,
9111 &node_by_scoped,
9112 &import_dependencies,
9113 ));
9114 raw_refs.extend(build_value_ref_refs(
9115 &rel_path,
9116 &data,
9117 &node_by_scoped,
9118 &import_dependencies,
9119 ));
9120 raw_refs.extend(build_import_refs(
9121 project_root,
9122 &abs_path,
9123 &rel_path,
9124 &data.import_block.imports,
9125 &line_index,
9126 &FactPaths {
9127 root: project_root,
9128 facts: &DiskFacts::new(project_root),
9129 },
9130 ));
9131 if lang == LangId::Rust {
9132 raw_refs.extend(build_rust_module_refs(
9133 project_root,
9134 &abs_path,
9135 &rel_path,
9136 &source,
9137 &FactPaths {
9138 root: project_root,
9139 facts: &DiskFacts::new(project_root),
9140 },
9141 ));
9142 }
9143 let mut surface_parts = reexports.surface_parts;
9144 surface_parts.extend(rust_reexports.surface_parts);
9145 surface_parts.extend(source_less_exports.surface_parts);
9146 raw_refs.extend(reexports.raw_refs);
9147 raw_refs.extend(rust_reexports.raw_refs);
9148 raw_refs.extend(source_less_exports.raw_refs);
9149 let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
9150 let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
9151
9152 Ok(FileExtract {
9153 rel_path,
9154 freshness,
9155 lang,
9156 data,
9157 nodes,
9158 raw_refs,
9159 dispatch_hints,
9160 surface_fingerprint,
9161 })
9162}
9163
9164fn build_node_records(
9165 rel_path: &str,
9166 source: &str,
9167 data: &FileCallData,
9168) -> Result<Vec<NodeRecord>> {
9169 let mut records = Vec::new();
9170 let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
9171 let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
9172 metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
9173
9174 for (scoped_name, meta) in metadata {
9175 let name = unqualified_name(scoped_name).to_string();
9176 let range = selection_range(source, scoped_name, &name, &meta.range);
9177 let range_key = (
9178 range.start_line,
9179 range.start_col,
9180 range.end_line,
9181 range.end_col,
9182 );
9183 let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
9184 let range_ordinal = *ordinal;
9185 *ordinal += 1;
9186 let id = node_id(rel_path, &range, range_ordinal, scoped_name);
9187 let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
9188 let is_default_export = data
9189 .default_export_symbol
9190 .as_deref()
9191 .map(|default| default == scoped_name || default == name)
9192 .unwrap_or(false);
9193 records.push(NodeRecord {
9194 id,
9195 file_path: rel_path.to_string(),
9196 name: name.clone(),
9197 scoped_name: scoped_name.clone(),
9198 kind: symbol_kind_label(&meta.kind).to_string(),
9199 range,
9200 range_ordinal,
9201 signature: meta.signature.clone(),
9202 exported,
9203 is_default_export,
9204 is_type_like: is_type_like(&meta.kind),
9205 is_callgraph_entry_point: meta.entry_point_attribute.is_some()
9206 || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
9207 });
9208 }
9209
9210 Ok(records)
9211}
9212
9213fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
9214 if scoped_name == TOP_LEVEL_SYMBOL {
9215 return Range {
9216 start_line: 0,
9217 start_col: 0,
9218 end_line: 0,
9219 end_col: 0,
9220 };
9221 }
9222 let Some(line) = source.lines().nth(fallback.start_line as usize) else {
9223 return fallback.clone();
9224 };
9225 let start_col = fallback.start_col as usize;
9226 let search_start = start_col.min(line.len());
9227 if let Some(offset) = line[search_start..].find(name) {
9228 let col = search_start + offset;
9229 return Range {
9230 start_line: fallback.start_line,
9231 start_col: col as u32,
9232 end_line: fallback.start_line,
9233 end_col: (col + name.len()) as u32,
9234 };
9235 }
9236 if let Some(offset) = line.find(name) {
9237 return Range {
9238 start_line: fallback.start_line,
9239 start_col: offset as u32,
9240 end_line: fallback.start_line,
9241 end_col: (offset + name.len()) as u32,
9242 };
9243 }
9244 Range {
9245 start_line: fallback.start_line,
9246 start_col: fallback.start_col,
9247 end_line: fallback.start_line,
9248 end_col: fallback.start_col.saturating_add(name.len() as u32),
9249 }
9250}
9251
9252fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
9253 if scoped_name == TOP_LEVEL_SYMBOL {
9254 return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
9255 }
9256 let input = format!(
9257 "{rel_path}:{}:{}:{}:{}:{ordinal}",
9258 range.start_line, range.start_col, range.end_line, range.end_col
9259 );
9260 format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
9261}
9262
9263fn build_call_refs(
9264 rel_path: &str,
9265 data: &FileCallData,
9266 node_by_scoped: &HashMap<String, String>,
9267 import_dependencies: &BTreeSet<String>,
9268) -> Vec<RawRef> {
9269 build_callable_refs(
9270 rel_path,
9271 &data.calls_by_symbol,
9272 node_by_scoped,
9273 import_dependencies,
9274 "call",
9275 )
9276}
9277
9278fn build_value_ref_refs(
9279 rel_path: &str,
9280 data: &FileCallData,
9281 node_by_scoped: &HashMap<String, String>,
9282 import_dependencies: &BTreeSet<String>,
9283) -> Vec<RawRef> {
9284 build_callable_refs(
9285 rel_path,
9286 &data.value_refs_by_symbol,
9287 node_by_scoped,
9288 import_dependencies,
9289 "value_ref",
9290 )
9291}
9292
9293fn build_callable_refs(
9294 rel_path: &str,
9295 sites_by_symbol: &HashMap<String, Vec<callgraph::CallSite>>,
9296 node_by_scoped: &HashMap<String, String>,
9297 import_dependencies: &BTreeSet<String>,
9298 kind: &str,
9299) -> Vec<RawRef> {
9300 let mut refs = Vec::new();
9301 let mut ordinal = 0usize;
9302 let mut symbols: Vec<_> = sites_by_symbol.iter().collect();
9303 symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
9304 for (caller_symbol, call_sites) in symbols {
9305 let caller_node = node_by_scoped.get(caller_symbol).cloned();
9306 for call_site in call_sites {
9307 ordinal += 1;
9308 let ref_id = ref_id(&[
9309 rel_path,
9310 kind,
9311 caller_symbol,
9312 &call_site.line.to_string(),
9313 &call_site.byte_start.to_string(),
9314 &call_site.byte_end.to_string(),
9315 &call_site.full_callee,
9316 &ordinal.to_string(),
9317 ]);
9318 refs.push(RawRef {
9319 ref_id,
9320 caller_node: caller_node.clone(),
9321 caller_symbol: Some(caller_symbol.clone()),
9322 caller_file: rel_path.to_string(),
9323 kind: kind.to_string(),
9324 short_name: Some(call_site.callee_name.clone()),
9325 full_ref: Some(call_site.full_callee.clone()),
9326 module_path: None,
9327 import_kind: None,
9328 local_name: Some(call_site.callee_name.clone()),
9329 requested_name: Some(call_site.callee_name.clone()),
9330 namespace_alias: namespace_alias(&call_site.full_callee),
9331 wildcard: false,
9332 line: call_site.line,
9333 byte_start: call_site.byte_start,
9334 byte_end: call_site.byte_end,
9335 dependencies: import_dependencies.clone(),
9336 });
9337 }
9338 }
9339 refs
9340}
9341
9342fn build_import_refs(
9343 project_root: &Path,
9344 abs_path: &Path,
9345 rel_path: &str,
9346 imports: &[ImportStatement],
9347 line_index: &LineIndex,
9348 facts: &FactPaths<'_>,
9349) -> Vec<RawRef> {
9350 let mut refs = Vec::new();
9351 for (index, import) in imports.iter().enumerate() {
9352 let import_kind = import_kind_label(import.kind).to_string();
9353 let local_name = import_local_names(import).join(",");
9354 let requested_name = import_requested_names(import).join(",");
9355 let ref_id = ref_id(&[
9356 rel_path,
9357 "import",
9358 &import.byte_range.start.to_string(),
9359 &import.byte_range.end.to_string(),
9360 &import.module_path,
9361 &index.to_string(),
9362 ]);
9363 refs.push(RawRef {
9364 ref_id,
9365 caller_node: None,
9366 caller_symbol: None,
9367 caller_file: rel_path.to_string(),
9368 kind: "import".to_string(),
9369 short_name: None,
9370 full_ref: Some(import.raw_text.clone()),
9371 module_path: Some(import.module_path.clone()),
9372 import_kind: Some(import_kind),
9373 local_name: empty_to_none(local_name),
9374 requested_name: empty_to_none(requested_name),
9375 namespace_alias: import.namespace_import.clone(),
9376 wildcard: import_is_wildcard(import),
9377 line: line_index.byte_to_line(import.byte_range.start),
9378 byte_start: import.byte_range.start,
9379 byte_end: import.byte_range.end,
9380 dependencies: module_dependencies(project_root, abs_path, &import.module_path, facts),
9381 });
9382 }
9383 refs
9384}
9385
9386fn build_rust_module_refs(
9387 project_root: &Path,
9388 abs_path: &Path,
9389 rel_path: &str,
9390 source: &str,
9391 facts: &FactPaths<'_>,
9392) -> Vec<RawRef> {
9393 let grammar = grammar_for(LangId::Rust);
9394 let mut parser = Parser::new();
9395 if parser.set_language(&grammar).is_err() {
9396 return Vec::new();
9397 }
9398 let Some(tree) = parser.parse(source, None) else {
9399 return Vec::new();
9400 };
9401
9402 let mut refs = Vec::new();
9403 let mut stack = vec![tree.root_node()];
9404 while let Some(node) = stack.pop() {
9405 if node.kind() == "mod_item"
9406 && node
9407 .named_children(&mut node.walk())
9408 .all(|child| child.kind() != "declaration_list")
9409 {
9410 if let Some(name_node) = node.child_by_field_name("name") {
9411 let module_name = node_text(name_node, source).to_string();
9412 let target = rust_external_module_target(
9413 abs_path,
9414 rust_module_path_override(source, node),
9415 &module_name,
9416 facts,
9417 );
9418 let mut dependencies = BTreeSet::new();
9419 if let Some(target) = target {
9420 dependencies.insert(relative_path(project_root, &canonicalize_path(&target)));
9421 }
9422 refs.push(RawRef {
9423 ref_id: ref_id(&[
9424 rel_path,
9425 "module",
9426 &module_name,
9427 &node.start_byte().to_string(),
9428 ]),
9429 caller_node: None,
9430 caller_symbol: None,
9431 caller_file: rel_path.to_string(),
9432 kind: "module".to_string(),
9433 short_name: Some(module_name.clone()),
9434 full_ref: Some(module_name.clone()),
9435 module_path: Some(module_name.clone()),
9436 import_kind: Some("module".to_string()),
9437 local_name: Some(module_name.clone()),
9438 requested_name: Some(module_name),
9439 namespace_alias: None,
9440 wildcard: false,
9441 line: node.start_position().row as u32 + 1,
9442 byte_start: node.start_byte(),
9443 byte_end: node.end_byte(),
9444 dependencies,
9445 });
9446 }
9447 }
9448
9449 let mut cursor = node.walk();
9450 if cursor.goto_first_child() {
9451 loop {
9452 stack.push(cursor.node());
9453 if !cursor.goto_next_sibling() {
9454 break;
9455 }
9456 }
9457 }
9458 }
9459 refs.sort_by_key(|raw| (raw.byte_start, raw.byte_end));
9460 refs
9461}
9462
9463fn rust_declared_module_target(
9464 project_root: &Path,
9465 caller_file: &str,
9466 module_name: &str,
9467 memo: &callgraph::ModuleResolutionMemo,
9468 facts: &FactPaths<'_>,
9469) -> Option<String> {
9470 memo.rust_declared_module_target(caller_file, module_name, || {
9471 rust_declared_module_targets(project_root, caller_file, facts)
9472 })
9473}
9474
9475fn rust_declared_module_targets(
9476 project_root: &Path,
9477 caller_file: &str,
9478 facts: &FactPaths<'_>,
9479) -> HashMap<String, Option<String>> {
9480 let declaring_file = project_root.join(caller_file);
9481 let Ok(source) = std::fs::read_to_string(&declaring_file) else {
9482 return HashMap::new();
9483 };
9484 let Ok(tree) = parse_source_with_cached_parser(&declaring_file, &source, LangId::Rust) else {
9485 return HashMap::new();
9486 };
9487 let mut targets = HashMap::new();
9488 let mut stack = vec![tree.root_node()];
9489 while let Some(node) = stack.pop() {
9490 if node.kind() == "mod_item"
9491 && node
9492 .named_children(&mut node.walk())
9493 .all(|child| child.kind() != "declaration_list")
9494 {
9495 if let Some(name) = node.child_by_field_name("name") {
9496 let module_name = node_text(name, &source);
9497 let target = rust_external_module_target(
9498 &declaring_file,
9499 rust_module_path_override(&source, node),
9500 module_name,
9501 facts,
9502 )
9503 .map(|target| {
9504 relative_path(
9505 project_root,
9506 &facts.canonical(&target).unwrap_or(target.clone()),
9507 )
9508 });
9509 targets.entry(module_name.to_string()).or_insert(target);
9510 }
9511 }
9512 let mut cursor = node.walk();
9513 if cursor.goto_first_child() {
9514 loop {
9515 stack.push(cursor.node());
9516 if !cursor.goto_next_sibling() {
9517 break;
9518 }
9519 }
9520 }
9521 }
9522 targets
9523}
9524
9525fn rust_external_module_target(
9526 declaring_file: &Path,
9527 path_override: Option<&str>,
9528 module_name: &str,
9529 facts: &FactPaths<'_>,
9530) -> Option<PathBuf> {
9531 let parent = declaring_file.parent()?;
9532 if let Some(path) = path_override {
9533 let candidate = parent.join(path);
9534 return facts.is_file(&candidate).then_some(candidate);
9535 }
9536
9537 let stem = declaring_file.file_stem().and_then(|stem| stem.to_str())?;
9538 let declaring_file = facts
9539 .canonical(declaring_file)
9540 .unwrap_or_else(|| declaring_file.to_path_buf());
9541 let is_crate_root = callgraph::rust_crate_root_file_for_caller(
9542 facts.root,
9543 &declaring_file,
9544 facts,
9545 &callgraph::RustCrateRootMemo::default(),
9546 )
9547 .as_ref()
9548 == Some(&declaring_file);
9549 let module_dir = if matches!(stem, "lib" | "main" | "mod") || is_crate_root {
9550 parent.to_path_buf()
9551 } else {
9552 parent.join(stem)
9553 };
9554 [
9555 module_dir.join(format!("{module_name}.rs")),
9556 module_dir.join(module_name).join("mod.rs"),
9557 ]
9558 .into_iter()
9559 .find(|candidate| facts.is_file(candidate))
9560}
9561
9562fn rust_module_path_override<'a>(source: &'a str, module: Node<'_>) -> Option<&'a str> {
9563 let mut previous = module.prev_sibling();
9564 while let Some(attribute) = previous {
9565 if attribute.kind() != "attribute_item" {
9566 break;
9567 }
9568 if let Some(path) = rust_path_attribute(source.get(attribute.byte_range())?) {
9569 return Some(path);
9570 }
9571 previous = attribute.prev_sibling();
9572 }
9573 None
9574}
9575
9576fn rust_path_attribute(attribute: &str) -> Option<&str> {
9577 let body = attribute.trim().strip_prefix("#[")?.strip_suffix(']')?;
9578 let (name, value) = body.split_once('=')?;
9579 (name.trim() == "path")
9580 .then(|| value.trim().trim_matches('"'))
9581 .filter(|path| !path.is_empty())
9582}
9583
9584fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
9585 let grammar = grammar_for(LangId::Rust);
9586 let mut parser = Parser::new();
9587 if parser.set_language(&grammar).is_err() {
9588 return;
9589 }
9590 let Some(tree) = parser.parse(source, None) else {
9591 return;
9592 };
9593
9594 let mut seen = data
9595 .import_block
9596 .imports
9597 .iter()
9598 .map(|import| (import.byte_range.start, import.byte_range.end))
9599 .collect::<HashSet<_>>();
9600 let mut nested_imports = Vec::new();
9601 collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
9602 if nested_imports.is_empty() {
9603 return;
9604 }
9605
9606 data.import_block.imports.extend(nested_imports);
9607 data.import_block
9608 .imports
9609 .sort_by_key(|import| import.byte_range.start);
9610 data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
9611}
9612
9613fn collect_rust_use_imports(
9614 source: &str,
9615 node: Node<'_>,
9616 seen: &mut HashSet<(usize, usize)>,
9617 imports: &mut Vec<ImportStatement>,
9618) {
9619 if node.kind() == "use_declaration" {
9620 let range = node.byte_range();
9621 if seen.insert((range.start, range.end)) {
9622 if let Some(import) = rust_import_from_use_node(source, node) {
9623 imports.push(import);
9624 }
9625 }
9626 }
9627
9628 let mut cursor = node.walk();
9629 if !cursor.goto_first_child() {
9630 return;
9631 }
9632 loop {
9633 collect_rust_use_imports(source, cursor.node(), seen, imports);
9634 if !cursor.goto_next_sibling() {
9635 break;
9636 }
9637 }
9638}
9639
9640fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
9641 let raw_text = source[node.byte_range()].to_string();
9642 let body = rust_use_body(&raw_text)?.to_string();
9643 let visibility = rust_use_visibility(&raw_text);
9644 let names = rust_use_list_names(&body);
9645 let group = classify_rust_import_group(&body);
9646 let byte_range = node.byte_range();
9647
9648 Some(ImportStatement {
9649 module_path: body,
9650 names: names.clone(),
9651 default_import: visibility.clone(),
9652 namespace_import: None,
9653 kind: ImportKind::Value,
9654 group,
9655 byte_range,
9656 raw_text,
9657 form: ImportForm::RustUse {
9658 visibility,
9659 named: names,
9660 },
9661 })
9662}
9663
9664fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
9665 let start = imports.iter().map(|import| import.byte_range.start).min()?;
9666 let end = imports.iter().map(|import| import.byte_range.end).max()?;
9667 Some(start..end)
9668}
9669
9670fn rust_use_visibility(raw_text: &str) -> Option<String> {
9671 let use_pos = raw_text.find("use ")?;
9672 let prefix = raw_text[..use_pos].trim();
9673 if prefix.is_empty() {
9674 None
9675 } else {
9676 Some(prefix.to_string())
9677 }
9678}
9679
9680fn rust_use_body(raw_text: &str) -> Option<&str> {
9681 let use_pos = raw_text.find("use ")?;
9682 Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
9683}
9684
9685fn rust_use_list_names(body: &str) -> Vec<String> {
9686 let Some(open) = body.find("::{") else {
9687 return Vec::new();
9688 };
9689 let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
9690 return Vec::new();
9691 };
9692 body[open + 3..close]
9693 .split(',')
9694 .filter_map(|spec| {
9695 let spec = spec.trim();
9696 if spec.is_empty() {
9697 None
9698 } else {
9699 Some(spec.to_string())
9700 }
9701 })
9702 .collect()
9703}
9704
9705fn classify_rust_import_group(body: &str) -> ImportGroup {
9706 let first = body
9707 .split("::")
9708 .next()
9709 .unwrap_or(body)
9710 .split_whitespace()
9711 .next()
9712 .unwrap_or(body);
9713 match first.trim() {
9714 "std" | "core" | "alloc" => ImportGroup::Stdlib,
9715 "crate" | "self" | "super" => ImportGroup::Internal,
9716 _ => ImportGroup::External,
9717 }
9718}
9719
9720#[derive(Debug, Clone)]
9721struct ReexportRefs {
9722 raw_refs: Vec<RawRef>,
9723 surface_parts: Vec<String>,
9724}
9725
9726fn collect_reexport_refs(
9727 project_root: &Path,
9728 abs_path: &Path,
9729 rel_path: &str,
9730 source: &str,
9731 facts: &FactPaths<'_>,
9732) -> ReexportRefs {
9733 let mut raw_refs = Vec::new();
9734 let mut surface_parts = Vec::new();
9735 let mut search_start = 0usize;
9736 let mut ordinal = 0usize;
9737 while let Some(export_offset) = source[search_start..].find("export") {
9738 let start = search_start + export_offset;
9739 let Some(statement_end_offset) = source[start..].find(';') else {
9740 break;
9741 };
9742 let end = start + statement_end_offset + 1;
9743 let statement = &source[start..end];
9744 search_start = end;
9745 if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
9746 continue;
9747 }
9748 let Some(module_path) = quoted_module_path(statement) else {
9749 continue;
9750 };
9751 ordinal += 1;
9752 let wildcard = statement.contains('*');
9753 let line = source[..start]
9754 .bytes()
9755 .filter(|byte| *byte == b'\n')
9756 .count() as u32
9757 + 1;
9758 let ref_id = ref_id(&[
9759 rel_path,
9760 "reexport",
9761 &start.to_string(),
9762 &end.to_string(),
9763 &module_path,
9764 &ordinal.to_string(),
9765 ]);
9766 surface_parts.push(format!("reexport\t{statement}"));
9767 raw_refs.push(RawRef {
9768 ref_id,
9769 caller_node: None,
9770 caller_symbol: None,
9771 caller_file: rel_path.to_string(),
9772 kind: "reexport".to_string(),
9773 short_name: None,
9774 full_ref: Some(statement.to_string()),
9775 module_path: Some(module_path.clone()),
9776 import_kind: Some("reexport".to_string()),
9777 local_name: None,
9778 requested_name: None,
9779 namespace_alias: None,
9780 wildcard,
9781 line,
9782 byte_start: start,
9783 byte_end: end,
9784 dependencies: module_dependencies(project_root, abs_path, &module_path, facts),
9785 });
9786 }
9787 ReexportRefs {
9788 raw_refs,
9789 surface_parts,
9790 }
9791}
9792
9793fn collect_rust_pub_use_reexport_refs(
9794 project_root: &Path,
9795 abs_path: &Path,
9796 rel_path: &str,
9797 imports: &[ImportStatement],
9798 line_index: &LineIndex,
9799 facts: &FactPaths<'_>,
9800) -> ReexportRefs {
9801 let mut raw_refs = Vec::new();
9802 let mut surface_parts = Vec::new();
9803 let mut ordinal = 0usize;
9804
9805 for import in imports {
9806 let Some(visibility) = &import.default_import else {
9807 continue;
9808 };
9809 if !visibility.starts_with("pub") {
9810 continue;
9811 }
9812 let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
9813 continue;
9814 };
9815 ordinal += 1;
9816 let ref_id = ref_id(&[
9817 rel_path,
9818 "rust_reexport",
9819 &import.byte_range.start.to_string(),
9820 &import.byte_range.end.to_string(),
9821 &module_path,
9822 &ordinal.to_string(),
9823 ]);
9824 surface_parts.push(format!("reexport\t{}", import.raw_text));
9825 raw_refs.push(RawRef {
9826 ref_id,
9827 caller_node: None,
9828 caller_symbol: None,
9829 caller_file: rel_path.to_string(),
9830 kind: "reexport".to_string(),
9831 short_name: None,
9832 full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
9833 module_path: Some(module_path.clone()),
9834 import_kind: Some("reexport".to_string()),
9835 local_name: None,
9836 requested_name: None,
9837 namespace_alias: None,
9838 wildcard,
9839 line: line_index.byte_to_line(import.byte_range.start),
9840 byte_start: import.byte_range.start,
9841 byte_end: import.byte_range.end,
9842 dependencies: rust_module_dependencies(project_root, abs_path, &module_path, facts),
9843 });
9844 }
9845
9846 ReexportRefs {
9847 raw_refs,
9848 surface_parts,
9849 }
9850}
9851
9852fn rust_pub_use_reexport_parts(
9853 import: &ImportStatement,
9854) -> Option<(String, HashMap<String, String>, bool)> {
9855 let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
9856 let body = body.trim();
9857 if let Some(module_path) = body.strip_suffix("::*") {
9858 return Some((module_path.trim().to_string(), HashMap::new(), true));
9859 }
9860
9861 if let Some(brace_start) = body.find("::{") {
9862 let module_path = body[..brace_start].trim().to_string();
9863 let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
9864 if names.is_empty() {
9865 return None;
9866 }
9867 return Some((module_path, names, false));
9868 }
9869
9870 let (module_path, spec) = body.rsplit_once("::")?;
9871 let names = rust_reexport_names_from_specs(spec);
9872 if names.is_empty() {
9873 return None;
9874 }
9875 Some((module_path.trim().to_string(), names, false))
9876}
9877
9878fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
9879 let mut names = HashMap::new();
9880 for spec in specs.split(',') {
9881 let spec = spec.trim();
9882 if spec.is_empty() || spec == "self" {
9883 continue;
9884 }
9885 if let Some((source, local)) = spec.split_once(" as ") {
9886 let source = source.trim();
9887 let local = local.trim();
9888 if !source.is_empty() && !local.is_empty() && source != "self" {
9889 names.insert(local.to_string(), source.to_string());
9890 }
9891 } else {
9892 names.insert(spec.to_string(), spec.to_string());
9893 }
9894 }
9895 names
9896}
9897
9898fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
9899 if named.is_empty() {
9900 return fallback.to_string();
9901 }
9902 let mut specs = named
9903 .iter()
9904 .map(|(local, source)| {
9905 if local == source {
9906 source.clone()
9907 } else {
9908 format!("{source} as {local}")
9909 }
9910 })
9911 .collect::<Vec<_>>();
9912 specs.sort();
9913 format!("pub use {{{}}};", specs.join(", "))
9914}
9915
9916fn quoted_module_path(statement: &str) -> Option<String> {
9917 let quote = match (statement.find('\''), statement.find('"')) {
9918 (Some(single), Some(double)) if single < double => '\'',
9919 (Some(_), Some(_)) => '"',
9920 (Some(_), None) => '\'',
9921 (None, Some(_)) => '"',
9922 (None, None) => return None,
9923 };
9924 let start = statement.find(quote)? + 1;
9925 let end = statement[start..].find(quote)? + start;
9926 Some(statement[start..end].to_string())
9927}
9928
9929#[derive(Debug, Clone)]
9930struct SourceLessExportRefs {
9931 raw_refs: Vec<RawRef>,
9932 surface_parts: Vec<String>,
9933}
9934
9935fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
9936 let mut raw_refs = Vec::new();
9937 let mut surface_parts = Vec::new();
9938 let mut search_start = 0usize;
9939 let mut ordinal = 0usize;
9940 while let Some(export_offset) = source[search_start..].find("export") {
9941 let start = search_start + export_offset;
9942 let Some(statement_end_offset) = source[start..].find(';') else {
9943 break;
9944 };
9945 let end = start + statement_end_offset + 1;
9946 let statement = &source[start..end];
9947 search_start = end;
9948 if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
9949 continue;
9950 }
9951 let aliases = parse_reexport_names(statement);
9952 if aliases.is_empty() {
9953 continue;
9954 }
9955 let line = source[..start]
9956 .bytes()
9957 .filter(|byte| *byte == b'\n')
9958 .count() as u32
9959 + 1;
9960 for (exported, source_symbol) in aliases {
9961 ordinal += 1;
9962 let ref_id = ref_id(&[
9963 rel_path,
9964 "export_alias",
9965 &start.to_string(),
9966 &end.to_string(),
9967 &exported,
9968 &source_symbol,
9969 &ordinal.to_string(),
9970 ]);
9971 surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
9972 raw_refs.push(RawRef {
9973 ref_id,
9974 caller_node: None,
9975 caller_symbol: None,
9976 caller_file: rel_path.to_string(),
9977 kind: "export_alias".to_string(),
9978 short_name: None,
9979 full_ref: Some(statement.to_string()),
9980 module_path: None,
9981 import_kind: Some("export_alias".to_string()),
9982 local_name: Some(exported),
9983 requested_name: Some(source_symbol),
9984 namespace_alias: None,
9985 wildcard: false,
9986 line,
9987 byte_start: start,
9988 byte_end: end,
9989 dependencies: BTreeSet::new(),
9990 });
9991 }
9992 }
9993 SourceLessExportRefs {
9994 raw_refs,
9995 surface_parts,
9996 }
9997}
9998
9999fn build_dispatch_hints(
10000 rel_path: &str,
10001 data: &FileCallData,
10002 node_by_scoped: &HashMap<String, String>,
10003) -> Vec<DispatchHint> {
10004 let mut hints = Vec::new();
10005 let mut ordinal = 0usize;
10006 let mut calls_by_symbol = data.calls_by_symbol.iter().collect::<Vec<_>>();
10007 calls_by_symbol.sort_unstable_by(|left, right| left.0.cmp(right.0));
10008 for (caller_symbol, call_sites) in calls_by_symbol {
10009 let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
10010 continue;
10011 };
10012 for call_site in call_sites {
10013 if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
10014 continue;
10015 }
10016 ordinal += 1;
10017 hints.push(DispatchHint {
10018 id: ref_id(&[
10019 rel_path,
10020 "dispatch",
10021 caller_symbol,
10022 &call_site.line.to_string(),
10023 &call_site.byte_start.to_string(),
10024 &call_site.byte_end.to_string(),
10025 &ordinal.to_string(),
10026 ]),
10027 method_name: call_site.callee_name.clone(),
10028 caller_node: caller_node.clone(),
10029 file: rel_path.to_string(),
10030 line: call_site.line,
10031 byte_start: call_site.byte_start,
10032 byte_end: call_site.byte_end,
10033 });
10034 }
10035 }
10036 hints
10037}
10038
10039fn surface_fingerprint(
10040 nodes: &mut [NodeRecord],
10041 data: &FileCallData,
10042 reexport_parts: &[String],
10043) -> String {
10044 nodes.sort_by(|left, right| {
10045 (left.file_path.as_str(), left.scoped_name.as_str())
10046 .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
10047 });
10048 let mut parts = Vec::new();
10049 for node in nodes.iter() {
10050 parts.push(format!(
10051 "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
10052 node.scoped_name,
10053 node.name,
10054 node.kind,
10055 node.exported,
10056 node.range.start_line,
10057 node.range.start_col,
10058 node.range.end_line,
10059 node.range.end_col,
10060 node.range_ordinal,
10061 node.signature.as_deref().unwrap_or("")
10062 ));
10063 }
10064 let mut exports = data.exported_symbols.clone();
10065 exports.sort();
10066 for export in exports {
10067 parts.push(format!("export\t{export}"));
10068 }
10069 if let Some(default_export) = &data.default_export_symbol {
10070 parts.push(format!("default\t{default_export}"));
10071 }
10072 let mut imports: Vec<String> = data
10073 .import_block
10074 .imports
10075 .iter()
10076 .map(|import| {
10077 format!(
10078 "import\t{}\t{:?}\t{}",
10079 import.module_path, import.form, import.raw_text
10080 )
10081 })
10082 .collect();
10083 imports.sort();
10084 parts.extend(imports);
10085 parts.extend(reexport_parts.iter().cloned());
10086 hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
10087}
10088
10089fn resolve_ref<I: ResolverIndex>(raw: RawRef, index: &I) -> Result<ResolvedRef> {
10090 if !matches!(raw.kind.as_str(), "call" | "value_ref") {
10091 return Ok(ResolvedRef {
10092 dependencies: raw.dependencies.clone(),
10093 raw,
10094 status: "unresolved".to_string(),
10095 target_node: None,
10096 target_file: None,
10097 target_symbol: None,
10098 edge: None,
10099 });
10100 }
10101
10102 let caller_file = raw.caller_file.clone();
10103 let caller_data =
10104 index
10105 .caller_data(&caller_file)
10106 .ok_or_else(|| CallGraphStoreError::MissingCallerData {
10107 file: caller_file.clone(),
10108 })?;
10109 let full_ref = raw.full_ref.as_deref().unwrap_or_default();
10110 let short_name = raw.short_name.as_deref().unwrap_or_default();
10111 let mut dependencies = raw.dependencies.clone();
10112
10113 let resolved = match index.lang_for(&caller_file) {
10114 Some(LangId::Rust) => {
10115 resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
10116 }
10117 Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
10118 resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
10119 }
10120 _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
10121 };
10122
10123 let Some((status, target_file, target_symbol)) = resolved else {
10124 return Ok(ResolvedRef {
10125 raw,
10126 status: "unresolved".to_string(),
10127 target_node: None,
10128 target_file: None,
10129 target_symbol: None,
10130 dependencies,
10131 edge: None,
10132 });
10133 };
10134
10135 dependencies.insert(target_file.clone());
10136 let target_node = index.node_for_symbol(&target_file, &target_symbol);
10137 if raw.kind == "value_ref"
10138 && !target_node
10139 .as_deref()
10140 .is_some_and(|node_id| index.node_is_callable(&target_file, node_id))
10141 {
10142 return Ok(ResolvedRef {
10143 raw,
10144 status: "unresolved".to_string(),
10145 target_node: None,
10146 target_file: None,
10147 target_symbol: None,
10148 dependencies,
10149 edge: None,
10150 });
10151 }
10152 let source_node = raw.caller_node.clone();
10153 let edge = if let Some(source_node) = source_node {
10154 if target_file == caller_file
10155 && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
10156 {
10157 None
10158 } else {
10159 Some(EdgeRecord {
10160 edge_id: ref_id(&[&raw.ref_id, "edge"]),
10161 source_node,
10162 target_node: target_node.clone(),
10163 target_file: target_file.clone(),
10164 target_symbol: target_symbol.clone(),
10165 kind: raw.kind.clone(),
10166 line: raw.line,
10167 })
10168 }
10169 } else {
10170 None
10171 };
10172
10173 Ok(ResolvedRef {
10174 raw,
10175 status,
10176 target_node,
10177 target_file: Some(target_file),
10178 target_symbol: Some(target_symbol),
10179 dependencies,
10180 edge,
10181 })
10182}
10183
10184fn resolve_js_ts_target<I: ResolverIndex>(
10185 index: &I,
10186 caller_file: &str,
10187 full_ref: &str,
10188 short_name: &str,
10189 caller_data: &FileCallData,
10190) -> Option<(String, String, String)> {
10191 if let Some((namespace, member)) = full_ref.split_once('.') {
10192 for import in &caller_data.import_block.imports {
10193 if import.namespace_import.as_deref() == Some(namespace) {
10194 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
10195 if let Some((file, symbol)) =
10196 resolve_exported_symbol(index, &target_file, member, 0)
10197 {
10198 return Some(("resolved".to_string(), file, symbol));
10199 }
10200 }
10201 }
10202 }
10203 }
10204
10205 for import in &caller_data.import_block.imports {
10206 for spec in &import.names {
10207 if crate::imports::specifier_local_name(spec) == short_name {
10208 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
10209 let requested = crate::imports::specifier_imported_name(spec);
10210 let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
10211 .unwrap_or_else(|| (target_file, requested.to_string()));
10212 return Some(("resolved".to_string(), file, symbol));
10213 }
10214 }
10215 }
10216
10217 if import.default_import.as_deref() == Some(short_name) {
10218 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
10219 let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
10220 .or_else(|| {
10221 index
10222 .default_export(&target_file)
10223 .map(|symbol| (target_file.clone(), symbol))
10224 })
10225 .unwrap_or_else(|| {
10226 let file_name = Path::new(&target_file)
10227 .file_name()
10228 .and_then(|name| name.to_str())
10229 .unwrap_or("unknown")
10230 .to_string();
10231 (target_file, format!("<default:{file_name}>"))
10232 });
10233 return Some(("resolved".to_string(), file, symbol));
10234 }
10235 }
10236 }
10237
10238 for import in &caller_data.import_block.imports {
10239 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
10240 if index.has_export(&target_file, short_name) {
10241 return Some(("resolved".to_string(), target_file, short_name.to_string()));
10242 }
10243 }
10244 }
10245
10246 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
10247}
10248
10249fn resolve_exported_symbol<I: ResolverIndex>(
10250 index: &I,
10251 file: &str,
10252 requested: &str,
10253 depth: usize,
10254) -> Option<(String, String)> {
10255 let mut visited = std::collections::HashMap::new();
10256 resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
10257}
10258
10259fn resolve_exported_symbol_inner<I: ResolverIndex>(
10268 index: &I,
10269 file: &str,
10270 requested: &str,
10271 depth: usize,
10272 visited: &mut std::collections::HashMap<(String, String), usize>,
10273) -> Option<(String, String)> {
10274 if depth > 16 {
10275 return None;
10276 }
10277 if requested != "default" {
10278 if let Some(source_symbol) = index.export_alias(file, requested) {
10279 return Some((file.to_string(), source_symbol));
10280 }
10281 if index.has_export(file, requested) {
10282 return Some((file.to_string(), requested.to_string()));
10283 }
10284 } else if let Some(default) = index.default_export(file) {
10285 return Some((file.to_string(), default));
10286 }
10287
10288 match visited.entry((file.to_string(), requested.to_string())) {
10292 std::collections::hash_map::Entry::Occupied(mut seen) => {
10293 if *seen.get() <= depth {
10294 return None;
10295 }
10296 seen.insert(depth);
10297 }
10298 std::collections::hash_map::Entry::Vacant(slot) => {
10299 slot.insert(depth);
10300 }
10301 }
10302
10303 for reexport in index.reexports_for(file) {
10304 let mut next_requested = requested.to_string();
10305 let matches = if reexport.wildcard {
10306 true
10307 } else if let Some(source_name) = reexport.named.get(requested) {
10308 next_requested = source_name.clone();
10309 true
10310 } else {
10311 false
10312 };
10313 if !matches {
10314 continue;
10315 }
10316 if let Some(target_file) = &reexport.target_file {
10317 if let Some(target) = resolve_exported_symbol_inner(
10318 index,
10319 target_file,
10320 &next_requested,
10321 depth + 1,
10322 visited,
10323 ) {
10324 return Some(target);
10325 }
10326 }
10327 }
10328 None
10329}
10330
10331fn resolve_rust_target<I: ResolverIndex>(
10332 index: &I,
10333 caller_file: &str,
10334 full_ref: &str,
10335 short_name: &str,
10336 caller_data: &FileCallData,
10337 raw: &RawRef,
10338) -> Option<(String, String, String)> {
10339 if full_ref.contains("::") {
10340 if let Some((target_file, target_symbol)) =
10341 rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
10342 {
10343 return Some(("resolved".to_string(), target_file, target_symbol));
10344 }
10345 }
10346
10347 for import in &caller_data.import_block.imports {
10348 if let Some((target_file, target_symbol)) =
10349 rust_target_for_use(index, caller_file, import, short_name)
10350 {
10351 return Some(("resolved".to_string(), target_file, target_symbol));
10352 }
10353 }
10354
10355 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
10356}
10357
10358fn rust_target_for_qualified<I: ResolverIndex>(
10359 index: &I,
10360 caller_file: &str,
10361 full_ref: &str,
10362 short_name: &str,
10363 caller_data: &FileCallData,
10364 raw: &RawRef,
10365) -> Option<(String, String)> {
10366 let mut segments: Vec<&str> = full_ref.split("::").collect();
10367 if segments.len() < 2 {
10368 return None;
10369 }
10370 segments.pop();
10371 let requested_symbol = rust_target_symbol(full_ref, short_name);
10372
10373 for path in rust_module_path_candidates(&segments, caller_data, raw) {
10374 let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
10375 if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
10376 if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
10377 return Some(rust_resolve_reexport_if_symbol_missing(
10378 index,
10379 target_file,
10380 requested_symbol.clone(),
10381 ));
10382 }
10383 }
10384
10385 let module_segments = rust_resolve_segments_with_index(index, caller_file, &path_refs)?;
10386 if let Some(target) =
10387 rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
10388 {
10389 return Some(target);
10390 }
10391 if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
10392 return Some(rust_resolve_reexport_if_symbol_missing(
10393 index,
10394 target_file,
10395 requested_symbol.clone(),
10396 ));
10397 }
10398 }
10399 None
10400}
10401
10402fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
10403 full_ref
10404 .rsplit("::")
10405 .next()
10406 .filter(|name| !name.is_empty())
10407 .unwrap_or(short_name)
10408 .to_string()
10409}
10410
10411fn rust_resolve_reexport_if_symbol_missing<I: ResolverIndex>(
10412 index: &I,
10413 target_file: String,
10414 target_symbol: String,
10415) -> (String, String) {
10416 if index
10417 .node_for_symbol(&target_file, &target_symbol)
10418 .is_some()
10419 {
10420 return (target_file, target_symbol);
10421 }
10422 if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
10423 resolved
10424 } else {
10425 (target_file, target_symbol)
10426 }
10427}
10428
10429fn rust_module_path_candidates(
10430 segments: &[&str],
10431 caller_data: &FileCallData,
10432 raw: &RawRef,
10433) -> Vec<Vec<String>> {
10434 let mut candidates = Vec::new();
10435 if let Some(first) = segments.first().copied() {
10436 for import in &caller_data.import_block.imports {
10437 if !rust_import_is_visible_to_call(import, raw) {
10438 continue;
10439 }
10440 let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
10441 continue;
10442 };
10443 if local_name == first {
10444 path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
10445 rust_push_unique_path_candidate(&mut candidates, path_segments);
10446 }
10447 }
10448 }
10449 rust_push_unique_path_candidate(
10450 &mut candidates,
10451 segments
10452 .iter()
10453 .map(|segment| (*segment).to_string())
10454 .collect(),
10455 );
10456 candidates
10457}
10458
10459fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
10460 if !candidates.iter().any(|existing| existing == &candidate) {
10461 candidates.push(candidate);
10462 }
10463}
10464
10465fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
10466 import.byte_range.start <= raw.byte_start
10467}
10468
10469fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
10470 let path = import.module_path.trim().trim_end_matches(';').trim();
10471 if path.contains("::{") || path.contains('{') || path.contains('*') {
10472 return None;
10473 }
10474 let (path_without_alias, alias) = path
10475 .split_once(" as ")
10476 .map(|(left, right)| (left.trim(), Some(right.trim())))
10477 .unwrap_or((path, None));
10478 let segments = path_without_alias
10479 .split("::")
10480 .map(str::trim)
10481 .filter(|segment| !segment.is_empty())
10482 .collect::<Vec<_>>();
10483 let local_name = alias.or_else(|| segments.last().copied())?.to_string();
10484 if local_name.chars().next().is_some_and(char::is_uppercase) {
10485 return None;
10486 }
10487 Some((
10488 local_name,
10489 segments
10490 .into_iter()
10491 .map(|segment| segment.to_string())
10492 .collect(),
10493 ))
10494}
10495
10496fn rust_inline_scoped_target<I: ResolverIndex>(
10497 index: &I,
10498 caller_file: &str,
10499 module_segments: &[String],
10500 short_name: &str,
10501) -> Option<(String, String)> {
10502 index.inline_scoped_target(caller_file, module_segments, short_name)
10503}
10504
10505fn rust_target_for_use<I: ResolverIndex>(
10506 index: &I,
10507 caller_file: &str,
10508 import: &ImportStatement,
10509 short_name: &str,
10510) -> Option<(String, String)> {
10511 let path = import.module_path.trim().trim_end_matches(';');
10512 if let Some(brace_start) = path.find("::{") {
10513 let prefix = &path[..brace_start];
10514 if import.names.iter().any(|name| name == short_name) {
10515 let prefix_segments: Vec<&str> = prefix.split("::").collect();
10516 let module_segments =
10517 rust_resolve_segments_with_index(index, caller_file, &prefix_segments)?;
10518 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
10519 return Some((file, short_name.to_string()));
10520 }
10521 return None;
10522 }
10523
10524 let (path_without_alias, alias) = path
10525 .split_once(" as ")
10526 .map(|(left, right)| (left.trim(), Some(right.trim())))
10527 .unwrap_or((path, None));
10528 let segments: Vec<&str> = path_without_alias.split("::").collect();
10529 let imported = alias.or_else(|| segments.last().copied())?;
10530 if imported != short_name {
10531 return None;
10532 }
10533 if segments.len() < 2 {
10534 return None;
10535 }
10536 let module_segments =
10537 rust_resolve_segments_with_index(index, caller_file, &segments[..segments.len() - 1])?;
10538 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
10539 Some((file, segments.last().unwrap_or(&short_name).to_string()))
10540}
10541
10542fn rust_workspace_file_for_segments<I: ResolverIndex>(
10543 index: &I,
10544 segments: &[&str],
10545) -> Option<String> {
10546 let crate_name = segments.first().copied()?;
10547 let src_prefix = index.crate_src_prefix(crate_name)?;
10548 let module_segments = segments[1..]
10549 .iter()
10550 .map(|segment| segment.to_string())
10551 .collect::<Vec<_>>();
10552 rust_file_for_src_prefix(index, &src_prefix, &module_segments)
10553}
10554
10555#[cfg(test)]
10556static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
10557 OnceLock::new();
10558
10559#[cfg(test)]
10560fn note_workspace_crate_prefix_build(project_root: &Path) {
10561 let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
10562 .get_or_init(|| Mutex::new(HashMap::new()))
10563 .lock()
10564 .expect("workspace crate prefix build counts mutex poisoned");
10565 *counts.entry(project_root.to_path_buf()).or_default() += 1;
10566}
10567
10568#[cfg(not(test))]
10569fn note_workspace_crate_prefix_build(_project_root: &Path) {}
10570
10571#[cfg(test)]
10572fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
10573 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
10574 .get_or_init(|| Mutex::new(HashMap::new()))
10575 .lock()
10576 .expect("workspace crate prefix build counts mutex poisoned")
10577 .remove(project_root);
10578}
10579
10580#[cfg(test)]
10581fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
10582 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
10583 .get_or_init(|| Mutex::new(HashMap::new()))
10584 .lock()
10585 .expect("workspace crate prefix build counts mutex poisoned")
10586 .get(project_root)
10587 .copied()
10588 .unwrap_or(0)
10589}
10590
10591fn build_workspace_crate_prefixes(
10596 project_root: &Path,
10597 facts: &FactPaths<'_>,
10598) -> HashMap<String, String> {
10599 note_workspace_crate_prefix_build(project_root);
10600 let mut prefixes = HashMap::new();
10601 let mut stack = vec![project_root.to_path_buf()];
10602 while let Some(dir) = stack.pop() {
10603 let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
10604 if matches!(name, "target" | "node_modules" | ".git") {
10605 continue;
10606 }
10607 let manifest = dir.join("Cargo.toml");
10608 if facts.is_file(&manifest) {
10609 let crate_names = rust_manifest_crate_names(&manifest, facts);
10610 if !crate_names.is_empty() {
10611 let src_prefix = relative_path(
10612 project_root,
10613 &facts
10614 .canonical(&dir.join("src"))
10615 .unwrap_or_else(|| dir.join("src")),
10616 );
10617 for crate_name in crate_names {
10618 prefixes
10619 .entry(crate_name)
10620 .or_insert_with(|| src_prefix.clone());
10621 }
10622 }
10623 }
10624 for entry in facts.list_dir(&dir) {
10625 if entry.kind == EntryKind::Directory {
10626 stack.push(dir.join(byte_path(&entry.name)));
10627 }
10628 }
10629 }
10630 prefixes
10631}
10632
10633fn rust_manifest_crate_names(manifest: &Path, facts: &FactPaths<'_>) -> Vec<String> {
10637 facts.config_fact(manifest, "manifest.name");
10638 facts.config_fact(manifest, "manifest.lib.name");
10639 let Some(bytes) = facts.attributed_bytes(manifest) else {
10640 return Vec::new();
10641 };
10642 let (package_name, lib_name) = rust_manifest_name_fields(&bytes);
10643 let mut names = Vec::new();
10644 if let Some(lib) = lib_name {
10645 names.push(lib);
10646 }
10647 if let Some(package) = package_name {
10648 let normalized = package.replace('-', "_");
10649 if !names.contains(&normalized) {
10650 names.push(normalized);
10651 }
10652 }
10653 names
10654}
10655
10656pub(crate) fn rust_manifest_name_fields(bytes: &[u8]) -> (Option<String>, Option<String>) {
10659 let Ok(source) = std::str::from_utf8(bytes) else {
10660 return (None, None);
10661 };
10662 let mut in_lib = false;
10663 let mut package_name = None;
10664 let mut lib_name = None;
10665 for line in source.lines() {
10666 let trimmed = line.trim();
10667 if trimmed.starts_with('[') {
10668 in_lib = trimmed == "[lib]";
10669 continue;
10670 }
10671 let Some((key, value)) = trimmed.split_once('=') else {
10672 continue;
10673 };
10674 let key = key.trim();
10675 let value = value.trim().trim_matches('"');
10676 if in_lib && key == "name" {
10677 lib_name = Some(value.to_string());
10678 } else if !in_lib && key == "name" && package_name.is_none() {
10679 package_name = Some(value.to_string());
10680 }
10681 }
10682 (package_name, lib_name)
10683}
10684
10685fn rust_resolve_segments_with_index<I: ResolverIndex>(
10686 index: &I,
10687 caller_file: &str,
10688 segments: &[&str],
10689) -> Option<Vec<String>> {
10690 let caller_segments = if index.rust_crate_root_file(caller_file).as_deref() == Some(caller_file)
10691 {
10692 Vec::new()
10693 } else {
10694 rust_registered_module_segments(index, caller_file)
10695 .unwrap_or_else(|| rust_module_segments_for_rel(caller_file))
10696 };
10697 rust_resolve_segments_from(caller_segments, segments)
10698}
10699
10700fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
10701 rust_resolve_segments_from(rust_module_segments_for_rel(caller_file), segments)
10702}
10703
10704fn rust_resolve_segments_from(
10705 caller_segments: Vec<String>,
10706 segments: &[&str],
10707) -> Option<Vec<String>> {
10708 if segments.is_empty() {
10709 return Some(Vec::new());
10710 }
10711 match segments[0] {
10712 "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
10713 "self" => {
10714 let mut resolved = caller_segments;
10715 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
10716 Some(resolved)
10717 }
10718 "super" => {
10719 let mut resolved = caller_segments;
10720 resolved.pop();
10721 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
10722 Some(resolved)
10723 }
10724 _ => {
10725 let mut resolved = caller_segments;
10726 resolved.pop();
10727 resolved.extend(segments.iter().map(|item| item.to_string()));
10728 Some(resolved)
10729 }
10730 }
10731}
10732
10733fn rust_registered_module_segments<I: ResolverIndex>(
10734 index: &I,
10735 caller_file: &str,
10736) -> Option<Vec<String>> {
10737 let mut current = caller_file.to_string();
10738 let mut segments = Vec::new();
10739 let mut seen = HashSet::new();
10740 while seen.insert(current.clone()) {
10741 let Some((parent, module)) = index.module_parent(¤t) else {
10742 break;
10743 };
10744 segments.push(module);
10745 current = parent;
10746 }
10747 if segments.is_empty() {
10748 None
10749 } else {
10750 segments.reverse();
10751 Some(segments)
10752 }
10753}
10754
10755fn rust_file_for_segments<I: ResolverIndex>(
10756 index: &I,
10757 caller_file: &str,
10758 segments: &[String],
10759) -> Option<String> {
10760 let src_prefix = rust_src_prefix(caller_file);
10761 if let Some(target) =
10762 rust_file_from_module_declarations(index, caller_file, &src_prefix, segments)
10763 {
10764 return Some(target);
10765 }
10766 rust_file_for_src_prefix(index, &src_prefix, segments)
10767}
10768
10769fn rust_file_from_module_declarations<I: ResolverIndex>(
10770 index: &I,
10771 caller_file: &str,
10772 src_prefix: &str,
10773 segments: &[String],
10774) -> Option<String> {
10775 let mut current = index.rust_crate_root_file(caller_file).or_else(|| {
10776 [
10777 format!("{src_prefix}/lib.rs"),
10778 format!("{src_prefix}/main.rs"),
10779 ]
10780 .into_iter()
10781 .find(|candidate| index.contains_file(candidate))
10782 })?;
10783 for segment in segments {
10784 current = index.module_target(¤t, segment)?;
10785 }
10786 Some(current)
10787}
10788
10789fn rust_file_for_src_prefix<I: ResolverIndex>(
10790 index: &I,
10791 src_prefix: &str,
10792 segments: &[String],
10793) -> Option<String> {
10794 let candidate = if segments.is_empty() {
10795 [src_prefix, "lib.rs"].join("/")
10796 } else {
10797 format!("{}/{}.rs", src_prefix, segments.join("/"))
10798 };
10799 if index.contains_file(&candidate) {
10800 return Some(candidate);
10801 }
10802 if !segments.is_empty() {
10803 let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
10804 if index.contains_file(&mod_candidate) {
10805 return Some(mod_candidate);
10806 }
10807 }
10808 None
10809}
10810
10811fn rust_src_prefix(rel_path: &str) -> String {
10812 rel_path
10813 .split_once("/src/")
10814 .map(|(prefix, _)| format!("{prefix}/src"))
10815 .unwrap_or_else(|| "src".to_string())
10816}
10817
10818fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
10819 let after_src = rel_path
10820 .split_once("/src/")
10821 .map(|(_, rest)| rest)
10822 .or_else(|| rel_path.strip_prefix("src/"))
10823 .unwrap_or(rel_path);
10824 if matches!(after_src, "lib.rs" | "main.rs") {
10825 return Vec::new();
10826 }
10827 if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
10828 return prefix.split('/').map(|item| item.to_string()).collect();
10829 }
10830 after_src
10831 .strip_suffix(".rs")
10832 .unwrap_or(after_src)
10833 .split('/')
10834 .map(|item| item.to_string())
10835 .collect()
10836}
10837
10838fn resolve_local_target<I: ResolverIndex>(
10839 _index: &I,
10840 caller_file: &str,
10841 full_ref: &str,
10842 short_name: &str,
10843 caller_data: &FileCallData,
10844) -> Option<(String, String, String)> {
10845 if !callgraph::is_bare_callee(full_ref, short_name) {
10846 return None;
10847 }
10848 callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
10849 .ok()
10850 .map(|symbol| {
10851 (
10852 "resolved_local".to_string(),
10853 caller_file.to_string(),
10854 symbol,
10855 )
10856 })
10857}
10858
10859impl<'a> ProjectIndex<'a> {
10860 fn from_parts(
10861 project_root: &Path,
10862 files: HashMap<String, DbFileIndex>,
10863 caller_data: HashMap<String, &'a FileCallData>,
10864 workspace_crate_prefixes: WorkspaceCratePrefixCache,
10865 facts: Rc<dyn ProjectFacts + 'a>,
10866 ) -> Self {
10867 Self {
10868 facts,
10869 unbound_non_utf8_paths: Vec::new(),
10870 project_root: project_root.to_path_buf(),
10871 files,
10872 caller_data,
10873 workspace_crate_prefixes,
10874 rust_crate_roots: callgraph::RustCrateRootMemo::default(),
10875 }
10876 }
10877
10878 fn from_db_and_callers(
10879 tx: &Transaction<'_>,
10880 project_root: &Path,
10881 caller_extracts: &'a HashMap<String, FileExtract>,
10882 workspace_crate_prefixes: WorkspaceCratePrefixCache,
10883 ) -> Result<Self> {
10884 let module_resolution_memo = callgraph::ModuleResolutionMemo::default();
10887 let disk = DiskFacts::new(project_root);
10888 let facts = FactPaths {
10889 root: project_root,
10890 facts: &disk,
10891 };
10892 let mut files = load_db_file_indexes(tx, project_root, &module_resolution_memo, &facts)?;
10893 let mut caller_data = HashMap::new();
10894 for (rel_path, extract) in caller_extracts {
10895 files.insert(
10896 rel_path.clone(),
10897 DbFileIndex::from_extract(
10898 project_root,
10899 extract,
10900 &FactPaths {
10901 root: project_root,
10902 facts: &DiskFacts::new(project_root),
10903 },
10904 ),
10905 );
10906 caller_data.insert(rel_path.clone(), &extract.data);
10907 }
10908 Ok(Self::from_parts(
10909 project_root,
10910 files,
10911 caller_data,
10912 workspace_crate_prefixes,
10913 Rc::new(DiskFacts::new(project_root)),
10914 ))
10915 }
10916
10917 fn lang_for(&self, rel_path: &str) -> Option<LangId> {
10918 self.files.get(rel_path).and_then(|file| file.lang)
10919 }
10920
10921 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
10922 self.files
10923 .get(caller_file)
10924 .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
10925 }
10926
10927 fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
10928 self.files
10929 .get(rel_path)
10930 .map(|file| file.reexports.as_slice())
10931 .unwrap_or(&[])
10932 }
10933
10934 fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
10935 self.files.get(rel_path).and_then(|file| {
10936 file.node_by_scoped
10937 .get(symbol)
10938 .cloned()
10939 .or_else(|| file.node_by_bare.get(symbol).cloned())
10940 })
10941 }
10942
10943 fn node_is_callable(&self, rel_path: &str, node_id: &str) -> bool {
10944 self.files
10945 .get(rel_path)
10946 .and_then(|file| file.node_kind_by_id.get(node_id))
10947 .is_some_and(|kind| matches!(kind.as_str(), "function" | "kernel" | "method"))
10948 }
10949}
10950
10951impl DbFileIndex {
10952 fn from_extract(project_root: &Path, extract: &FileExtract, facts: &FactPaths<'_>) -> Self {
10953 let mut node_by_scoped = HashMap::new();
10954 let mut node_by_bare = HashMap::new();
10955 for node in &extract.nodes {
10956 node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
10957 node_by_bare
10958 .entry(node.name.clone())
10959 .or_insert(node.id.clone());
10960 }
10961 let node_kind_by_id = extract
10962 .nodes
10963 .iter()
10964 .map(|node| (node.id.clone(), node.kind.clone()))
10965 .collect();
10966 let mut export_aliases = HashMap::new();
10967 for raw_ref in &extract.raw_refs {
10968 if raw_ref.kind == "export_alias" {
10969 if let (Some(exported), Some(source_symbol)) =
10970 (&raw_ref.local_name, &raw_ref.requested_name)
10971 {
10972 export_aliases.insert(exported.clone(), source_symbol.clone());
10973 }
10974 }
10975 }
10976 let mut module_targets = HashMap::new();
10977 let mut declared_module_targets = HashMap::new();
10978 let mut reexports = Vec::new();
10979 for raw_ref in &extract.raw_refs {
10980 if !matches!(raw_ref.kind.as_str(), "import" | "reexport" | "module") {
10981 continue;
10982 }
10983 let Some(module_path) = &raw_ref.module_path else {
10984 continue;
10985 };
10986 let target_file =
10987 module_target_from_dependencies(project_root, &raw_ref.dependencies, facts);
10988 module_targets
10989 .entry(module_path.clone())
10990 .or_insert_with(|| target_file.clone());
10991 if raw_ref.kind == "module" {
10992 declared_module_targets
10993 .entry(module_path.clone())
10994 .or_insert_with(|| target_file.clone());
10995 }
10996 if raw_ref.kind == "reexport" {
10997 reexports.push(reexport_index_from_raw(raw_ref, target_file));
10998 }
10999 }
11000 Self {
11001 lang: Some(extract.lang),
11002 exports: extract.data.exported_symbols.iter().cloned().collect(),
11003 default_export: extract.data.default_export_symbol.clone(),
11004 export_aliases,
11005 node_by_scoped,
11006 node_by_bare,
11007 node_kind_by_id,
11008 module_targets,
11009 declared_module_targets,
11010 reexports,
11011 }
11012 }
11013}
11014
11015fn load_db_file_indexes(
11016 tx: &Transaction<'_>,
11017 project_root: &Path,
11018 module_resolution_memo: &callgraph::ModuleResolutionMemo,
11019 facts: &FactPaths<'_>,
11020) -> Result<HashMap<String, DbFileIndex>> {
11021 let mut files = HashMap::new();
11022 let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
11023 let rows = stmt.query_map([], |row| {
11024 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11025 })?;
11026 for row in rows {
11027 let (rel_path, lang) = row?;
11028 files.insert(
11029 rel_path.clone(),
11030 DbFileIndex {
11031 lang: lang_from_label(&lang),
11032 exports: HashSet::new(),
11033 default_export: None,
11034 export_aliases: HashMap::new(),
11035 node_by_scoped: HashMap::new(),
11036 node_by_bare: HashMap::new(),
11037 node_kind_by_id: HashMap::new(),
11038 module_targets: HashMap::new(),
11039 declared_module_targets: HashMap::new(),
11040 reexports: Vec::new(),
11041 },
11042 );
11043 }
11044
11045 let mut node_stmt = tx.prepare(
11046 "SELECT file_path, id, name, scoped_name, kind, exported, is_default_export FROM nodes",
11047 )?;
11048 let nodes = node_stmt.query_map([], |row| {
11049 Ok((
11050 row.get::<_, String>(0)?,
11051 row.get::<_, String>(1)?,
11052 row.get::<_, String>(2)?,
11053 row.get::<_, String>(3)?,
11054 row.get::<_, String>(4)?,
11055 row.get::<_, i64>(5)? != 0,
11056 row.get::<_, i64>(6)? != 0,
11057 ))
11058 })?;
11059 for row in nodes {
11060 let (file_path, id, name, scoped_name, kind, exported, is_default_export) = row?;
11061 let file = files
11062 .entry(file_path.clone())
11063 .or_insert_with(|| DbFileIndex {
11064 lang: None,
11065 exports: HashSet::new(),
11066 default_export: None,
11067 export_aliases: HashMap::new(),
11068 node_by_scoped: HashMap::new(),
11069 node_by_bare: HashMap::new(),
11070 node_kind_by_id: HashMap::new(),
11071 module_targets: HashMap::new(),
11072 declared_module_targets: HashMap::new(),
11073 reexports: Vec::new(),
11074 });
11075 if exported {
11076 file.exports.insert(name.clone());
11077 file.exports.insert(scoped_name.clone());
11078 }
11079 if is_default_export {
11080 file.default_export = Some(scoped_name.clone());
11081 }
11082 file.node_by_scoped.insert(scoped_name, id.clone());
11083 file.node_by_bare.entry(name).or_insert(id.clone());
11084 file.node_kind_by_id.insert(id, kind);
11085 }
11086 let file_keys: HashSet<String> = files.keys().cloned().collect();
11087 let dependencies_by_file = load_file_dependencies_index(tx)?;
11091 let mut ref_stmt = tx.prepare(
11092 "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
11093 FROM refs WHERE kind IN ('module', 'reexport', 'export_alias')",
11094 )?;
11095 let ref_rows = ref_stmt.query_map([], |row| {
11096 Ok((
11097 row.get::<_, String>(0)?,
11098 row.get::<_, String>(1)?,
11099 row.get::<_, String>(2)?,
11100 row.get::<_, Option<String>>(3)?,
11101 row.get::<_, Option<String>>(4)?,
11102 row.get::<_, i64>(5)? != 0,
11103 row.get::<_, Option<String>>(6)?,
11104 row.get::<_, Option<String>>(7)?,
11105 ))
11106 })?;
11107 for row in ref_rows {
11108 let (
11109 ref_id,
11110 caller_file,
11111 kind,
11112 module_path,
11113 full_ref,
11114 wildcard,
11115 local_name,
11116 requested_name,
11117 ) = row?;
11118 if kind == "export_alias" {
11119 if let (Some(exported), Some(source_symbol), Some(file)) =
11120 (local_name, requested_name, files.get_mut(&caller_file))
11121 {
11122 file.export_aliases.insert(exported, source_symbol);
11123 }
11124 continue;
11125 }
11126 let Some(module_path) = module_path else {
11127 continue;
11128 };
11129 let file_deps = dependencies_by_file
11130 .get(&caller_file)
11131 .cloned()
11132 .unwrap_or_default();
11133 let deps = stored_dependencies_for_module(
11134 project_root,
11135 &caller_file,
11136 &module_path,
11137 &file_deps,
11138 &file_keys,
11139 facts,
11140 );
11141 let target_file = if kind == "module" {
11142 rust_declared_module_target(
11143 project_root,
11144 &caller_file,
11145 &module_path,
11146 module_resolution_memo,
11147 facts,
11148 )
11149 } else {
11150 deps.iter().find(|dep| file_keys.contains(*dep)).map(|dep| {
11151 relative_path(
11152 project_root,
11153 &facts
11154 .canonical(&project_root.join(dep))
11155 .unwrap_or_else(|| project_root.join(dep)),
11156 )
11157 })
11158 };
11159 if let Some(file) = files.get_mut(&caller_file) {
11160 file.module_targets
11161 .entry(module_path.clone())
11162 .or_insert_with(|| target_file.clone());
11163 if kind == "module" {
11164 file.declared_module_targets
11165 .entry(module_path.clone())
11166 .or_insert_with(|| target_file.clone());
11167 }
11168 if kind == "reexport" {
11169 let raw = RawRef {
11170 ref_id,
11171 caller_node: None,
11172 caller_symbol: None,
11173 caller_file,
11174 kind,
11175 short_name: None,
11176 full_ref,
11177 module_path: Some(module_path),
11178 import_kind: Some("reexport".to_string()),
11179 local_name: None,
11180 requested_name: None,
11181 namespace_alias: None,
11182 wildcard,
11183 line: 0,
11184 byte_start: 0,
11185 byte_end: 0,
11186 dependencies: deps,
11187 };
11188 file.reexports
11189 .push(reexport_index_from_raw(&raw, target_file));
11190 }
11191 }
11192 }
11193
11194 Ok(files)
11195}
11196
11197fn stored_dependencies_for_module(
11198 project_root: &Path,
11199 caller_file: &str,
11200 module_path: &str,
11201 caller_dependencies: &BTreeSet<String>,
11202 indexed_files: &HashSet<String>,
11203 facts: &FactPaths<'_>,
11204) -> BTreeSet<String> {
11205 let caller_path = project_root.join(caller_file);
11206 let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path, facts);
11207 if module_path.starts_with('.') {
11208 let caller_dir = caller_path.parent().unwrap_or(project_root);
11209 for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
11210 let normalized = if facts.is_file(&candidate) {
11211 facts.canonical(&candidate).unwrap_or(candidate.clone())
11212 } else {
11213 candidate
11214 };
11215 candidates.insert(relative_path(project_root, &normalized));
11216 }
11217 }
11218 let exact = candidates
11219 .intersection(caller_dependencies)
11220 .filter(|dependency| indexed_files.contains(*dependency))
11221 .cloned()
11222 .collect::<BTreeSet<_>>();
11223 if !exact.is_empty() || module_path.starts_with('.') {
11224 return exact;
11225 }
11226
11227 let module_path = rust_module_path_without_alias_or_use_list(module_path)
11228 .trim_matches(|character| matches!(character, '\'' | '"'));
11229 let package_name = module_path
11230 .split('/')
11231 .next_back()
11232 .unwrap_or(module_path)
11233 .replace('_', "-");
11234 let matched = caller_dependencies
11235 .iter()
11236 .filter(|dependency| indexed_files.contains(*dependency))
11237 .filter(|dependency| {
11238 dependency.as_str() == module_path
11239 || dependency.ends_with(&format!("/{module_path}"))
11240 || Path::new(dependency).components().any(|component| {
11241 component.as_os_str().to_string_lossy().replace('_', "-") == package_name
11242 })
11243 })
11244 .cloned()
11245 .collect::<BTreeSet<_>>();
11246 if matched.len() == 1 {
11247 matched
11248 } else {
11249 BTreeSet::new()
11250 }
11251}
11252
11253fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
11254 let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
11255 let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
11256 let rows = stmt.query_map([], |row| {
11257 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11258 })?;
11259 for row in rows {
11260 let (file_path, dependency) = row?;
11261 by_file.entry(file_path).or_default().insert(dependency);
11262 }
11263 Ok(by_file)
11264}
11265
11266struct ColdBuildInsertStatements<'stmt> {
11267 file: Statement<'stmt>,
11268 node: Statement<'stmt>,
11269 file_dependency: Statement<'stmt>,
11270 dispatch_hint: Statement<'stmt>,
11271 backend_state: Statement<'stmt>,
11272 reference: Statement<'stmt>,
11273 staging_ref_context: Statement<'stmt>,
11274 edge: Statement<'stmt>,
11275}
11276
11277impl<'stmt> ColdBuildInsertStatements<'stmt> {
11278 fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
11279 Ok(Self {
11280 file: tx.prepare(
11281 "INSERT OR REPLACE INTO files(
11282 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
11283 is_public_api, surface_fingerprint, indexed_at
11284 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
11285 )?,
11286 node: tx.prepare(
11287 "INSERT OR REPLACE INTO nodes(
11288 id, file_path, name, scoped_name, kind, start_line, start_col,
11289 end_line, end_col, range_ordinal, signature, exported,
11290 is_default_export, is_type_like, is_callgraph_entry_point, provenance
11291 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
11292 )?,
11293 file_dependency: tx.prepare(
11294 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
11295 )?,
11296 dispatch_hint: tx.prepare(
11297 "INSERT OR REPLACE INTO dispatch_hints(
11298 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
11299 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
11300 )?,
11301 backend_state: tx.prepare(
11302 "INSERT OR REPLACE INTO backend_file_state(
11303 backend, workspace_root, file_path, content_hash, status, updated_at
11304 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
11305 )?,
11306 reference: tx.prepare(
11307 "INSERT OR REPLACE INTO refs(
11308 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
11309 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
11310 byte_start, byte_end, status, target_node, target_file, target_symbol,
11311 provenance
11312 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
11313 )?,
11314 staging_ref_context: tx.prepare(
11315 "INSERT OR REPLACE INTO staging_ref_context(ref_id, caller_symbol) VALUES(?1, ?2)",
11316 )?,
11317 edge: tx.prepare(
11318 "INSERT OR REPLACE INTO edges(
11319 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
11320 kind, line, provenance
11321 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
11322 )?,
11323 })
11324 }
11325}
11326
11327fn insert_file_extract_prepared(
11328 statements: &mut ColdBuildInsertStatements<'_>,
11329 workspace_root: &str,
11330 extract: &FileExtract,
11331) -> Result<()> {
11332 statements.file.execute(params![
11333 extract.rel_path,
11334 hash_to_hex(extract.freshness.content_hash),
11335 system_time_to_ns(extract.freshness.mtime),
11336 extract.freshness.size as i64,
11337 lang_label(extract.lang),
11338 extract.surface_fingerprint,
11339 unix_seconds_now(),
11340 ])?;
11341 for node in &extract.nodes {
11342 statements.node.execute(params![
11343 node.id,
11344 node.file_path,
11345 node.name,
11346 node.scoped_name,
11347 node.kind,
11348 node.range.start_line as i64,
11349 node.range.start_col as i64,
11350 node.range.end_line as i64,
11351 node.range.end_col as i64,
11352 node.range_ordinal as i64,
11353 node.signature,
11354 bool_int(node.exported),
11355 bool_int(node.is_default_export),
11356 bool_int(node.is_type_like),
11357 bool_int(node.is_callgraph_entry_point),
11358 PROVENANCE_TREESITTER,
11359 ])?;
11360 }
11361
11362 let mut dependencies = BTreeSet::new();
11363 for raw_ref in &extract.raw_refs {
11364 dependencies.extend(raw_ref.dependencies.iter().cloned());
11365 }
11366 for dep_file in &dependencies {
11367 statements
11368 .file_dependency
11369 .execute(params![extract.rel_path, dep_file])?;
11370 }
11371
11372 for hint in &extract.dispatch_hints {
11373 statements.dispatch_hint.execute(params![
11374 hint.id,
11375 hint.method_name,
11376 hint.caller_node,
11377 hint.file,
11378 hint.line as i64,
11379 hint.byte_start as i64,
11380 hint.byte_end as i64,
11381 PROVENANCE_TREESITTER,
11382 ])?;
11383 }
11384 insert_backend_state_prepared(
11385 &mut statements.backend_state,
11386 workspace_root,
11387 &extract.rel_path,
11388 Some(&extract.freshness.content_hash),
11389 "fresh",
11390 )?;
11391 Ok(())
11392}
11393
11394fn insert_backend_state_prepared(
11395 stmt: &mut Statement<'_>,
11396 workspace_root: &str,
11397 rel_path: &str,
11398 content_hash: Option<&blake3::Hash>,
11399 status: &str,
11400) -> Result<()> {
11401 let hash = content_hash
11402 .map(|hash| hash_to_hex(*hash))
11403 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
11404 stmt.execute(params![
11405 BACKEND_TREESITTER,
11406 workspace_root,
11407 rel_path,
11408 hash,
11409 status,
11410 unix_seconds_now(),
11411 ])?;
11412 Ok(())
11413}
11414
11415fn insert_staged_ref_prepared(
11416 statements: &mut ColdBuildInsertStatements<'_>,
11417 raw: &RawRef,
11418) -> Result<()> {
11419 statements.reference.execute(params![
11420 raw.ref_id,
11421 raw.caller_node,
11422 raw.caller_file,
11423 raw.kind,
11424 raw.short_name,
11425 raw.full_ref,
11426 raw.module_path,
11427 raw.import_kind,
11428 raw.local_name,
11429 raw.requested_name,
11430 raw.namespace_alias,
11431 bool_int(raw.wildcard),
11432 raw.line as i64,
11433 raw.byte_start as i64,
11434 raw.byte_end as i64,
11435 "staged",
11436 Option::<String>::None,
11437 Option::<String>::None,
11438 Option::<String>::None,
11439 ref_provenance(raw),
11440 ])?;
11441 statements
11442 .staging_ref_context
11443 .execute(params![raw.ref_id, raw.caller_symbol])?;
11444 Ok(())
11445}
11446
11447fn insert_resolved_ref_prepared(
11448 statements: &mut ColdBuildInsertStatements<'_>,
11449 resolved: &ResolvedRef,
11450) -> Result<()> {
11451 let raw = &resolved.raw;
11452 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
11453 statements.reference.execute(params![
11454 raw.ref_id,
11455 raw.caller_node,
11456 raw.caller_file,
11457 raw.kind,
11458 raw.short_name,
11459 raw.full_ref,
11460 raw.module_path,
11461 raw.import_kind,
11462 raw.local_name,
11463 raw.requested_name,
11464 raw.namespace_alias,
11465 bool_int(raw.wildcard),
11466 raw.line as i64,
11467 raw.byte_start as i64,
11468 raw.byte_end as i64,
11469 resolved.status,
11470 resolved.target_node,
11471 resolved.target_file,
11472 resolved.target_symbol,
11473 ref_provenance(raw),
11474 ])?;
11475 if let Some(edge) = &resolved.edge {
11476 statements.edge.execute(params![
11477 edge.edge_id,
11478 raw.ref_id,
11479 edge.source_node,
11480 edge.target_node,
11481 edge.target_file,
11482 edge.target_symbol,
11483 edge.kind,
11484 edge.line as i64,
11485 ref_provenance(raw),
11486 ])?;
11487 }
11488 Ok(())
11489}
11490
11491#[cfg(test)]
11492fn insert_file_extract(
11493 tx: &Transaction<'_>,
11494 project_root: &Path,
11495 extract: &FileExtract,
11496) -> Result<()> {
11497 tx.execute(
11498 "INSERT OR REPLACE INTO files(
11499 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
11500 is_public_api, surface_fingerprint, indexed_at
11501 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
11502 params![
11503 extract.rel_path,
11504 hash_to_hex(extract.freshness.content_hash),
11505 system_time_to_ns(extract.freshness.mtime),
11506 extract.freshness.size as i64,
11507 lang_label(extract.lang),
11508 extract.surface_fingerprint,
11509 unix_seconds_now(),
11510 ],
11511 )?;
11512 for node in &extract.nodes {
11513 tx.execute(
11514 "INSERT OR REPLACE INTO nodes(
11515 id, file_path, name, scoped_name, kind, start_line, start_col,
11516 end_line, end_col, range_ordinal, signature, exported,
11517 is_default_export, is_type_like, is_callgraph_entry_point, provenance
11518 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
11519 params![
11520 node.id,
11521 node.file_path,
11522 node.name,
11523 node.scoped_name,
11524 node.kind,
11525 node.range.start_line as i64,
11526 node.range.start_col as i64,
11527 node.range.end_line as i64,
11528 node.range.end_col as i64,
11529 node.range_ordinal as i64,
11530 node.signature,
11531 bool_int(node.exported),
11532 bool_int(node.is_default_export),
11533 bool_int(node.is_type_like),
11534 bool_int(node.is_callgraph_entry_point),
11535 PROVENANCE_TREESITTER,
11536 ],
11537 )?;
11538 }
11539 let mut dependencies = BTreeSet::new();
11540 for raw_ref in &extract.raw_refs {
11541 dependencies.extend(raw_ref.dependencies.iter().cloned());
11542 }
11543 insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
11544
11545 for hint in &extract.dispatch_hints {
11546 tx.execute(
11547 "INSERT OR REPLACE INTO dispatch_hints(
11548 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
11549 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
11550 params![
11551 hint.id,
11552 hint.method_name,
11553 hint.caller_node,
11554 hint.file,
11555 hint.line as i64,
11556 hint.byte_start as i64,
11557 hint.byte_end as i64,
11558 PROVENANCE_TREESITTER,
11559 ],
11560 )?;
11561 }
11562 mark_backend_state(
11563 tx,
11564 project_root,
11565 &extract.rel_path,
11566 Some(&extract.freshness.content_hash),
11567 "fresh",
11568 )?;
11569 Ok(())
11570}
11571
11572#[cfg(test)]
11573fn insert_file_dependencies(
11574 tx: &Transaction<'_>,
11575 file_path: &str,
11576 dependencies: &BTreeSet<String>,
11577) -> Result<()> {
11578 for dep_file in dependencies {
11579 tx.execute(
11580 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
11581 params![file_path, dep_file],
11582 )?;
11583 }
11584 Ok(())
11585}
11586
11587fn ref_provenance(raw: &RawRef) -> &'static str {
11588 if raw.kind == "value_ref" {
11589 PROVENANCE_VALUE_REF
11590 } else {
11591 PROVENANCE_TREESITTER
11592 }
11593}
11594
11595#[cfg(test)]
11596fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
11597 let raw = &resolved.raw;
11598 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
11599 tx.execute(
11600 "INSERT OR REPLACE INTO refs(
11601 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
11602 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
11603 byte_start, byte_end, status, target_node, target_file, target_symbol,
11604 provenance
11605 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
11606 params![
11607 raw.ref_id,
11608 raw.caller_node,
11609 raw.caller_file,
11610 raw.kind,
11611 raw.short_name,
11612 raw.full_ref,
11613 raw.module_path,
11614 raw.import_kind,
11615 raw.local_name,
11616 raw.requested_name,
11617 raw.namespace_alias,
11618 bool_int(raw.wildcard),
11619 raw.line as i64,
11620 raw.byte_start as i64,
11621 raw.byte_end as i64,
11622 resolved.status,
11623 resolved.target_node,
11624 resolved.target_file,
11625 resolved.target_symbol,
11626 ref_provenance(raw),
11627 ],
11628 )?;
11629 if let Some(edge) = &resolved.edge {
11630 tx.execute(
11631 "INSERT OR REPLACE INTO edges(
11632 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
11633 kind, line, provenance
11634 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
11635 params![
11636 edge.edge_id,
11637 raw.ref_id,
11638 edge.source_node,
11639 edge.target_node,
11640 edge.target_file,
11641 edge.target_symbol,
11642 edge.kind,
11643 edge.line as i64,
11644 ref_provenance(raw),
11645 ],
11646 )?;
11647 }
11648 Ok(())
11649}
11650
11651fn insert_method_dispatch_edges(
11652 tx: &Transaction<'_>,
11653 project_root: &Path,
11654 caller_files: Option<&BTreeSet<String>>,
11655) -> Result<usize> {
11656 let references = load_name_match_refs(tx, caller_files)?;
11657 if references.is_empty() {
11658 return Ok(0);
11659 }
11660
11661 let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
11662 let mut source_cache: DispatchSourceCache = HashMap::new();
11663 let mut inserted = 0usize;
11664 for reference in references {
11665 let key = (reference.method_name.clone(), reference.lang.clone());
11666 let candidates = match candidates_by_name.entry(key) {
11667 Entry::Occupied(entry) => entry.into_mut(),
11668 Entry::Vacant(entry) => {
11669 let candidates =
11670 load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
11671 entry.insert(candidates)
11672 }
11673 };
11674
11675 match infer_receiver_type_state(project_root, &reference, &mut source_cache) {
11676 ReceiverTypeInference::Known(receiver_type) => {
11677 let Some(candidate) =
11678 select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
11679 else {
11680 continue;
11681 };
11682 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
11683 inserted += 1;
11684 continue;
11685 }
11686 ReceiverTypeInference::RustDirectSelfField {
11687 receiver_type,
11688 declaration_file,
11689 module_scope,
11690 } => {
11691 let Some(candidate) = select_rust_direct_self_field_candidate(
11692 project_root,
11693 &reference,
11694 candidates.as_slice(),
11695 &receiver_type,
11696 &declaration_file,
11697 &module_scope,
11698 &mut source_cache,
11699 ) else {
11700 continue;
11701 };
11702 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
11703 inserted += 1;
11704 continue;
11705 }
11706 ReceiverTypeInference::KnownButUnresolved => continue,
11707 ReceiverTypeInference::Unknown => {}
11708 }
11709
11710 if method_name_match_denylisted(&reference.method_name) {
11711 continue;
11712 }
11713
11714 let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
11715 continue;
11716 };
11717 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
11718 inserted += 1;
11719 }
11720 Ok(inserted)
11721}
11722
11723fn insert_method_dispatch_edges_chunked(
11724 tx: &Transaction<'_>,
11725 project_root: &Path,
11726 chunk_size: usize,
11727) -> Result<usize> {
11728 let total_files = query_count(
11729 tx,
11730 "SELECT COUNT(*) FROM (SELECT DISTINCT caller_file FROM refs)",
11731 )? as usize;
11732 let mut completed_files = 0usize;
11733 ensure_cold_build_current("method-dispatch", completed_files, total_files)?;
11734 let mut inserted = 0usize;
11735 let mut after_file = String::new();
11736 loop {
11737 let caller_files = {
11738 let mut statement = tx.prepare(
11739 "SELECT DISTINCT caller_file
11740 FROM refs
11741 WHERE caller_file > ?1
11742 ORDER BY caller_file
11743 LIMIT ?2",
11744 )?;
11745 let rows = statement
11746 .query_map(params![after_file, chunk_size.max(1) as i64], |row| {
11747 row.get::<_, String>(0)
11748 })?;
11749 rows.collect::<std::result::Result<BTreeSet<_>, _>>()?
11750 };
11751 let Some(last_file) = caller_files.last().cloned() else {
11752 break;
11753 };
11754 inserted += insert_method_dispatch_edges(tx, project_root, Some(&caller_files))?;
11755 after_file = last_file;
11756 completed_files = completed_files
11757 .saturating_add(caller_files.len())
11758 .min(total_files);
11759 ensure_cold_build_current("method-dispatch", completed_files, total_files)?;
11760 }
11761 ensure_cold_build_current("method-dispatch", completed_files, total_files)?;
11762 Ok(inserted)
11763}
11764
11765fn insert_method_dispatch_edge(
11766 tx: &Transaction<'_>,
11767 reference: &NameMatchRef,
11768 candidate: &NameMatchCandidate,
11769 provenance: &str,
11770) -> Result<()> {
11771 tx.execute(
11772 "INSERT OR REPLACE INTO edges(
11773 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
11774 kind, line, provenance
11775 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
11776 params![
11777 ref_id(&[&reference.ref_id, provenance, "edge"]),
11778 &reference.ref_id,
11779 &reference.caller_node,
11780 &candidate.node_id,
11781 &candidate.file_path,
11782 &candidate.scoped_name,
11783 reference.line as i64,
11784 provenance,
11785 ],
11786 )?;
11787 Ok(())
11788}
11789
11790fn delete_method_dispatch_edges_for_callers(
11791 tx: &Transaction<'_>,
11792 caller_files: &BTreeSet<String>,
11793) -> Result<()> {
11794 if caller_files.is_empty() {
11795 return Ok(());
11796 }
11797
11798 let mut stmt = tx.prepare(
11799 "DELETE FROM edges
11800 WHERE provenance IN (?1, ?2)
11801 AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
11802 )?;
11803 for caller_file in caller_files {
11804 stmt.execute(params![
11805 PROVENANCE_NAME_MATCH,
11806 PROVENANCE_TYPE_MATCH,
11807 caller_file
11808 ])?;
11809 }
11810 Ok(())
11811}
11812
11813fn load_name_match_refs(
11814 tx: &Transaction<'_>,
11815 caller_files: Option<&BTreeSet<String>>,
11816) -> Result<Vec<NameMatchRef>> {
11817 let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
11818 n.signature, r.short_name, r.full_ref, r.line, f.lang
11819 FROM refs r
11820 JOIN files f ON f.path = r.caller_file
11821 JOIN nodes n ON n.id = r.caller_node
11822 WHERE r.kind = 'call'
11823 AND r.status = 'unresolved'
11824 AND r.caller_node IS NOT NULL
11825 AND r.full_ref IS NOT NULL
11826 AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
11827 AND NOT EXISTS (
11828 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
11829 )";
11830 let mut references = Vec::new();
11831
11832 if let Some(caller_files) = caller_files {
11833 if caller_files.is_empty() {
11834 return Ok(references);
11835 }
11836 let sql = format!(
11837 "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
11838 );
11839 let mut stmt = tx.prepare(&sql)?;
11840 for caller_file in caller_files {
11841 let rows = stmt.query_map(params![caller_file], |row| {
11842 Ok((
11843 row.get::<_, String>(0)?,
11844 row.get::<_, Option<String>>(1)?,
11845 row.get::<_, String>(2)?,
11846 row.get::<_, String>(3)?,
11847 row.get::<_, Option<String>>(4)?,
11848 row.get::<_, Option<String>>(5)?,
11849 row.get::<_, Option<String>>(6)?,
11850 row.get::<_, i64>(7)?,
11851 row.get::<_, String>(8)?,
11852 ))
11853 })?;
11854 for row in rows {
11855 let (
11856 ref_id,
11857 caller_node,
11858 caller_file,
11859 caller_symbol,
11860 caller_signature,
11861 short_name,
11862 full_ref,
11863 line,
11864 lang,
11865 ) = row?;
11866 if let Some(reference) = name_match_ref_from_parts(
11867 ref_id,
11868 caller_node,
11869 caller_file,
11870 caller_symbol,
11871 caller_signature,
11872 short_name,
11873 full_ref,
11874 line,
11875 lang,
11876 ) {
11877 references.push(reference);
11878 }
11879 }
11880 }
11881 return Ok(references);
11882 }
11883
11884 let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
11885 let mut stmt = tx.prepare(&sql)?;
11886 let rows = stmt.query_map([], |row| {
11887 Ok((
11888 row.get::<_, String>(0)?,
11889 row.get::<_, Option<String>>(1)?,
11890 row.get::<_, String>(2)?,
11891 row.get::<_, String>(3)?,
11892 row.get::<_, Option<String>>(4)?,
11893 row.get::<_, Option<String>>(5)?,
11894 row.get::<_, Option<String>>(6)?,
11895 row.get::<_, i64>(7)?,
11896 row.get::<_, String>(8)?,
11897 ))
11898 })?;
11899 for row in rows {
11900 let (
11901 ref_id,
11902 caller_node,
11903 caller_file,
11904 caller_symbol,
11905 caller_signature,
11906 short_name,
11907 full_ref,
11908 line,
11909 lang,
11910 ) = row?;
11911 if let Some(reference) = name_match_ref_from_parts(
11912 ref_id,
11913 caller_node,
11914 caller_file,
11915 caller_symbol,
11916 caller_signature,
11917 short_name,
11918 full_ref,
11919 line,
11920 lang,
11921 ) {
11922 references.push(reference);
11923 }
11924 }
11925 Ok(references)
11926}
11927
11928#[allow(clippy::too_many_arguments)]
11929fn name_match_ref_from_parts(
11930 ref_id: String,
11931 caller_node: Option<String>,
11932 caller_file: String,
11933 caller_symbol: String,
11934 caller_signature: Option<String>,
11935 short_name: Option<String>,
11936 full_ref: Option<String>,
11937 line: i64,
11938 lang: String,
11939) -> Option<NameMatchRef> {
11940 let caller_node = caller_node?;
11941 let full_ref = full_ref?;
11942 let (receiver_expression, receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
11943 let method_name = if member.is_empty() {
11944 short_name.as_deref()?.to_string()
11945 } else {
11946 member
11947 };
11948 Some(NameMatchRef {
11949 ref_id,
11950 caller_node,
11951 caller_file,
11952 caller_symbol,
11953 caller_signature,
11954 receiver_expression,
11955 receiver,
11956 method_name,
11957 colon_dispatch,
11958 line: line.max(0) as u32,
11959 lang,
11960 })
11961}
11962
11963fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, String, bool)> {
11964 let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
11965 let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
11966 let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
11967 let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
11968 .into_iter()
11969 .flatten()
11970 .max_by_key(|(index, _, _)| *index)?;
11971 if delimiter == 0 {
11972 return None;
11973 }
11974 let member_start = delimiter + delimiter_len;
11975 if member_start >= full_ref.len() {
11976 return None;
11977 }
11978 let receiver_expression = full_ref[..delimiter].trim();
11979 let receiver = last_name_segment(receiver_expression).trim();
11980 let member = &full_ref[member_start..];
11981 if receiver.is_empty() || member.is_empty() {
11982 return None;
11983 }
11984 Some((
11985 receiver_expression.to_string(),
11986 receiver.to_string(),
11987 member.to_string(),
11988 colon_dispatch,
11989 ))
11990}
11991
11992fn last_name_segment(value: &str) -> &str {
11993 value
11994 .rsplit(['.', ':', '/', '\\', '-', '>'])
11995 .find(|segment| !segment.is_empty())
11996 .unwrap_or(value)
11997}
11998
11999fn load_name_match_candidates(
12000 tx: &Transaction<'_>,
12001 method_name: &str,
12002 lang: &str,
12003) -> Result<Vec<NameMatchCandidate>> {
12004 let mut stmt = tx.prepare(
12005 "SELECT n.id, n.file_path, n.scoped_name, n.kind, n.start_line
12006 FROM nodes n JOIN files f ON f.path = n.file_path
12007 WHERE n.name = ?1
12008 AND f.lang = ?2
12009 AND n.kind IN ('method', 'function', 'kernel')
12010 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
12011 )?;
12012 let rows = stmt.query_map(params![method_name, lang], |row| {
12013 Ok(NameMatchCandidate {
12014 node_id: row.get(0)?,
12015 file_path: row.get(1)?,
12016 scoped_name: row.get(2)?,
12017 kind: row.get(3)?,
12018 start_line: (row.get::<_, i64>(4)?.max(0) as u32).saturating_add(1),
12019 })
12020 })?;
12021 rows.collect::<std::result::Result<Vec<_>, _>>()
12022 .map_err(Into::into)
12023}
12024
12025struct ParsedDispatchSource {
12026 source: String,
12027 tree: tree_sitter::Tree,
12028}
12029
12030type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
12031
12032#[derive(Debug, Clone, PartialEq, Eq)]
12033enum ReceiverTypeInference {
12034 Unknown,
12035 Known(String),
12036 RustDirectSelfField {
12037 receiver_type: String,
12038 declaration_file: String,
12039 module_scope: Vec<(usize, usize)>,
12040 },
12041 KnownButUnresolved,
12042}
12043
12044#[cfg(test)]
12045fn infer_receiver_type(
12046 project_root: &Path,
12047 reference: &NameMatchRef,
12048 source_cache: &mut DispatchSourceCache,
12049) -> Option<String> {
12050 match infer_receiver_type_state(project_root, reference, source_cache) {
12051 ReceiverTypeInference::Known(receiver_type)
12052 | ReceiverTypeInference::RustDirectSelfField { receiver_type, .. } => Some(receiver_type),
12053 ReceiverTypeInference::Unknown | ReceiverTypeInference::KnownButUnresolved => None,
12054 }
12055}
12056
12057fn infer_receiver_type_state(
12058 project_root: &Path,
12059 reference: &NameMatchRef,
12060 source_cache: &mut DispatchSourceCache,
12061) -> ReceiverTypeInference {
12062 let known = |receiver_type| ReceiverTypeInference::Known(receiver_type);
12063 match reference.lang.as_str() {
12064 "rust" => infer_rust_receiver_type(project_root, reference, source_cache),
12065 "java" => {
12066 infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
12067 .map(known)
12068 .unwrap_or(ReceiverTypeInference::Unknown)
12069 }
12070 "kotlin" => {
12071 infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
12072 .map(known)
12073 .unwrap_or(ReceiverTypeInference::Unknown)
12074 }
12075 "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache)
12076 .map(known)
12077 .unwrap_or(ReceiverTypeInference::Unknown),
12078 _ => ReceiverTypeInference::Unknown,
12079 }
12080}
12081
12082fn parse_dispatch_source(
12083 project_root: &Path,
12084 caller_file: &str,
12085 lang: LangId,
12086) -> Option<ParsedDispatchSource> {
12087 let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
12088 let grammar = crate::parser::grammar_for(lang);
12089 let mut parser = tree_sitter::Parser::new();
12090 parser.set_language(&grammar).ok()?;
12091 let tree = parser.parse(&source, None)?;
12092 Some(ParsedDispatchSource { source, tree })
12093}
12094
12095fn parsed_dispatch_source<'a>(
12096 project_root: &Path,
12097 reference: &NameMatchRef,
12098 lang: LangId,
12099 source_cache: &'a mut DispatchSourceCache,
12100) -> Option<&'a ParsedDispatchSource> {
12101 parsed_dispatch_source_for_file(
12102 project_root,
12103 &reference.caller_file,
12104 &reference.lang,
12105 lang,
12106 source_cache,
12107 )
12108}
12109
12110fn parsed_dispatch_source_for_file<'a>(
12111 project_root: &Path,
12112 file_path: &str,
12113 lang_label: &str,
12114 lang: LangId,
12115 source_cache: &'a mut DispatchSourceCache,
12116) -> Option<&'a ParsedDispatchSource> {
12117 let key = (file_path.to_string(), lang_label.to_string());
12118 source_cache
12119 .entry(key)
12120 .or_insert_with(|| parse_dispatch_source(project_root, file_path, lang))
12121 .as_ref()
12122}
12123
12124fn infer_java_like_receiver_type(
12125 project_root: &Path,
12126 reference: &NameMatchRef,
12127 lang: LangId,
12128 source_cache: &mut DispatchSourceCache,
12129) -> Option<String> {
12130 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
12131 return None;
12132 }
12133
12134 let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
12135 let root = parsed.tree.root_node();
12136 let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
12137
12138 let callable_scope = type_node
12139 .and_then(|node| {
12140 find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
12141 })
12142 .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
12143
12144 if let Some(callable_scope) = callable_scope {
12145 if let Some(receiver_type) = infer_java_like_local_receiver_type(
12146 callable_scope,
12147 &parsed.source,
12148 &reference.receiver,
12149 reference.line.max(1),
12150 lang,
12151 ) {
12152 return Some(receiver_type);
12153 }
12154 }
12155
12156 type_node.and_then(|node| {
12157 infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
12158 })
12159}
12160
12161fn infer_cpp_receiver_type(
12162 project_root: &Path,
12163 reference: &NameMatchRef,
12164 source_cache: &mut DispatchSourceCache,
12165) -> Option<String> {
12166 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
12167 return None;
12168 }
12169
12170 let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
12171 let root = parsed.tree.root_node();
12172 let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
12173 infer_cpp_receiver_type_from_scope(
12174 scope,
12175 &parsed.source,
12176 &reference.receiver,
12177 reference.line.max(1),
12178 )
12179}
12180
12181fn find_enclosing_java_like_type_node<'tree>(
12182 root: tree_sitter::Node<'tree>,
12183 source: &str,
12184 reference: &NameMatchRef,
12185 lang: LangId,
12186) -> Option<tree_sitter::Node<'tree>> {
12187 let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
12188 .and_then(|name| simple_type_name(&name));
12189 let line = reference.line.max(1);
12190 let mut best = None;
12191 let mut stack = vec![root];
12192 while let Some(node) = stack.pop() {
12193 if !node_contains_line(node, line) {
12194 continue;
12195 }
12196 if is_java_like_type_kind(node.kind(), lang) {
12197 let name = declaration_name(node, source);
12198 if expected_type
12199 .as_deref()
12200 .is_none_or(|expected| name == Some(expected))
12201 {
12202 best = tighter_node(best, node);
12203 }
12204 }
12205 push_named_children(node, &mut stack);
12206 }
12207 best
12208}
12209
12210fn find_enclosing_java_like_callable_node<'tree>(
12211 root: tree_sitter::Node<'tree>,
12212 source: &str,
12213 reference: &NameMatchRef,
12214 lang: LangId,
12215) -> Option<tree_sitter::Node<'tree>> {
12216 let expected_name = reference.caller_symbol.rsplit("::").next();
12217 let line = reference.line.max(1);
12218 let mut best = None;
12219 let mut stack = vec![root];
12220 while let Some(node) = stack.pop() {
12221 if !node_contains_line(node, line) {
12222 continue;
12223 }
12224 if is_java_like_callable_kind(node.kind(), lang) {
12225 let name = declaration_name(node, source);
12226 if expected_name.is_none_or(|expected| name == Some(expected)) {
12227 best = tighter_node(best, node);
12228 }
12229 }
12230 push_named_children(node, &mut stack);
12231 }
12232 best
12233}
12234
12235fn find_enclosing_cpp_callable_node<'tree>(
12236 root: tree_sitter::Node<'tree>,
12237 _source: &str,
12238 reference: &NameMatchRef,
12239) -> Option<tree_sitter::Node<'tree>> {
12240 let line = reference.line.max(1);
12241 let mut best = None;
12242 let mut stack = vec![root];
12243 while let Some(node) = stack.pop() {
12244 if !node_contains_line(node, line) {
12245 continue;
12246 }
12247 if node.kind() == "function_definition" {
12248 best = tighter_node(best, node);
12249 }
12250 push_named_children(node, &mut stack);
12251 }
12252 best
12253}
12254
12255fn tighter_node<'tree>(
12256 current: Option<tree_sitter::Node<'tree>>,
12257 candidate: tree_sitter::Node<'tree>,
12258) -> Option<tree_sitter::Node<'tree>> {
12259 match current {
12260 Some(current)
12261 if current.start_byte() > candidate.start_byte()
12262 || (current.start_byte() == candidate.start_byte()
12263 && current.end_byte() <= candidate.end_byte()) =>
12264 {
12265 Some(current)
12266 }
12267 _ => Some(candidate),
12268 }
12269}
12270
12271fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
12272 let start = node.start_position().row as u32 + 1;
12273 let end = node.end_position().row as u32 + 1;
12274 start <= line && line <= end
12275}
12276
12277fn push_named_children<'tree>(
12278 node: tree_sitter::Node<'tree>,
12279 stack: &mut Vec<tree_sitter::Node<'tree>>,
12280) {
12281 for index in 0..node.named_child_count() {
12282 if let Some(child) = node.named_child(index as u32) {
12283 stack.push(child);
12284 }
12285 }
12286}
12287
12288fn declaration_name<'source>(
12289 node: tree_sitter::Node<'_>,
12290 source: &'source str,
12291) -> Option<&'source str> {
12292 node.child_by_field_name("name")
12293 .map(|name| node_text(name, source))
12294 .or_else(|| {
12295 first_named_child_text(
12296 node,
12297 source,
12298 &["identifier", "type_identifier", "simple_identifier"],
12299 )
12300 })
12301}
12302
12303fn first_named_child_text<'source>(
12304 node: tree_sitter::Node<'_>,
12305 source: &'source str,
12306 kinds: &[&str],
12307) -> Option<&'source str> {
12308 for index in 0..node.named_child_count() {
12309 let child = node.named_child(index as u32)?;
12310 if kinds.contains(&child.kind()) {
12311 return Some(node_text(child, source));
12312 }
12313 }
12314 None
12315}
12316
12317fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
12318 &source[node.byte_range()]
12319}
12320
12321fn infer_java_like_field_receiver_type(
12322 type_node: tree_sitter::Node<'_>,
12323 source: &str,
12324 receiver: &str,
12325 lang: LangId,
12326) -> Option<String> {
12327 let mut stack = Vec::new();
12328 push_named_children(type_node, &mut stack);
12329 while let Some(node) = stack.pop() {
12330 if is_java_like_field_kind(node.kind(), lang) {
12331 if let Some(receiver_type) =
12332 extract_java_like_declared_type(node_text(node, source), receiver, lang)
12333 {
12334 return Some(receiver_type);
12335 }
12336 }
12337 if is_java_like_type_kind(node.kind(), lang)
12338 || is_java_like_callable_kind(node.kind(), lang)
12339 {
12340 continue;
12341 }
12342 push_named_children(node, &mut stack);
12343 }
12344 None
12345}
12346
12347fn infer_java_like_local_receiver_type(
12348 callable_node: tree_sitter::Node<'_>,
12349 source: &str,
12350 receiver: &str,
12351 call_line: u32,
12352 lang: LangId,
12353) -> Option<String> {
12354 let mut best: Option<(u32, String)> = None;
12355 let mut stack = Vec::new();
12356 push_named_children(callable_node, &mut stack);
12357 while let Some(node) = stack.pop() {
12358 let start_line = node.start_position().row as u32 + 1;
12359 if start_line > call_line {
12360 continue;
12361 }
12362 if is_java_like_local_kind(node.kind(), lang) {
12363 if let Some(receiver_type) =
12364 extract_java_like_declared_type(node_text(node, source), receiver, lang)
12365 {
12366 if best
12367 .as_ref()
12368 .is_none_or(|(best_line, _)| start_line >= *best_line)
12369 {
12370 best = Some((start_line, receiver_type));
12371 }
12372 }
12373 }
12374 if is_java_like_type_kind(node.kind(), lang)
12375 || is_java_like_callable_kind(node.kind(), lang)
12376 {
12377 continue;
12378 }
12379 push_named_children(node, &mut stack);
12380 }
12381 best.map(|(_, receiver_type)| receiver_type)
12382}
12383
12384fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
12385 match lang {
12386 LangId::Java => matches!(
12387 kind,
12388 "class_declaration"
12389 | "interface_declaration"
12390 | "enum_declaration"
12391 | "record_declaration"
12392 | "annotation_type_declaration"
12393 ),
12394 LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
12395 _ => false,
12396 }
12397}
12398
12399fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
12400 match lang {
12401 LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
12402 LangId::Kotlin => kind == "function_declaration",
12403 _ => false,
12404 }
12405}
12406
12407fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
12408 match lang {
12409 LangId::Java => kind == "field_declaration",
12410 LangId::Kotlin => kind == "property_declaration",
12411 _ => false,
12412 }
12413}
12414
12415fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
12416 match lang {
12417 LangId::Java => kind == "local_variable_declaration",
12418 LangId::Kotlin => kind == "property_declaration",
12419 _ => false,
12420 }
12421}
12422
12423fn extract_java_like_declared_type(
12424 declaration: &str,
12425 receiver: &str,
12426 lang: LangId,
12427) -> Option<String> {
12428 match lang {
12429 LangId::Java => extract_java_declared_type(declaration, receiver),
12430 LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
12431 _ => None,
12432 }
12433}
12434
12435fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
12436 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
12437 let after = declaration[receiver_start + receiver.len()..].trim_start();
12438 if after
12439 .chars()
12440 .next()
12441 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
12442 {
12443 return None;
12444 }
12445
12446 let before = declaration[..receiver_start].trim_end();
12447 if before.contains(',') {
12448 return None;
12449 }
12450 normalize_receiver_type_name(strip_java_declaration_prefixes(before))
12451}
12452
12453fn strip_java_declaration_prefixes(mut value: &str) -> &str {
12454 loop {
12455 value = value.trim_start();
12456 if let Some(stripped) = strip_leading_java_annotation(value) {
12457 value = stripped;
12458 continue;
12459 }
12460 if let Some(stripped) = strip_leading_java_modifier(value) {
12461 value = stripped;
12462 continue;
12463 }
12464 return value.trim();
12465 }
12466}
12467
12468fn strip_leading_java_annotation(value: &str) -> Option<&str> {
12469 let value = value.trim_start();
12470 let mut chars = value.char_indices();
12471 let (_, first) = chars.next()?;
12472 if first != '@' {
12473 return None;
12474 }
12475 let mut end = first.len_utf8();
12476 for (index, ch) in chars {
12477 if !(is_code_ident_char(ch) || ch == '.') {
12478 end = index;
12479 break;
12480 }
12481 end = index + ch.len_utf8();
12482 }
12483 let rest = value[end..].trim_start();
12484 if let Some(stripped) = rest.strip_prefix('(') {
12485 let mut depth = 1usize;
12486 for (index, ch) in stripped.char_indices() {
12487 match ch {
12488 '(' => depth += 1,
12489 ')' => {
12490 depth = depth.saturating_sub(1);
12491 if depth == 0 {
12492 return Some(stripped[index + ch.len_utf8()..].trim_start());
12493 }
12494 }
12495 _ => {}
12496 }
12497 }
12498 return Some("");
12499 }
12500 Some(rest)
12501}
12502
12503fn strip_leading_java_modifier(value: &str) -> Option<&str> {
12504 const MODIFIERS: &[&str] = &[
12505 "public",
12506 "protected",
12507 "private",
12508 "abstract",
12509 "static",
12510 "final",
12511 "transient",
12512 "volatile",
12513 "synchronized",
12514 "native",
12515 "strictfp",
12516 ];
12517 MODIFIERS
12518 .iter()
12519 .find_map(|modifier| strip_leading_word(value, modifier))
12520}
12521
12522fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
12523 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
12524 let before = &declaration[..receiver_start];
12525 if find_identifier_occurrence(before, "val").is_none()
12526 && find_identifier_occurrence(before, "var").is_none()
12527 {
12528 return None;
12529 }
12530
12531 let after = declaration[receiver_start + receiver.len()..].trim_start();
12532 if let Some(type_text) = after.strip_prefix(':') {
12533 return normalize_receiver_type_name(read_type_prefix(type_text));
12534 }
12535 after
12536 .strip_prefix('=')
12537 .and_then(infer_kotlin_constructor_type)
12538}
12539
12540fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
12541 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
12542 if rest.trim_start().starts_with('(') {
12543 normalize_receiver_type_name(head)
12544 } else {
12545 None
12546 }
12547}
12548
12549fn read_type_prefix(value: &str) -> &str {
12550 let mut angle_depth = 0usize;
12551 for (index, ch) in value.char_indices() {
12552 match ch {
12553 '<' => angle_depth += 1,
12554 '>' => angle_depth = angle_depth.saturating_sub(1),
12555 '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
12556 return value[..index].trim();
12557 }
12558 _ => {}
12559 }
12560 }
12561 value.trim()
12562}
12563
12564fn infer_cpp_receiver_type_from_scope(
12565 scope: tree_sitter::Node<'_>,
12566 source: &str,
12567 receiver: &str,
12568 call_line: u32,
12569) -> Option<String> {
12570 let lines = source.lines().collect::<Vec<_>>();
12571 if lines.is_empty() {
12572 return None;
12573 }
12574 let scope_start = scope.start_position().row as usize;
12575 let call_index = (call_line as usize)
12576 .saturating_sub(1)
12577 .min(lines.len().saturating_sub(1));
12578 for index in (scope_start..=call_index).rev() {
12579 if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
12580 return Some(receiver_type);
12581 }
12582 }
12583 None
12584}
12585
12586fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
12587 for receiver_start in identifier_occurrences(line, receiver) {
12588 let after = line[receiver_start + receiver.len()..].trim_start();
12589 if after
12590 .chars()
12591 .next()
12592 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
12593 {
12594 continue;
12595 }
12596 let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
12597 let normalized = normalize_cpp_type_name(type_text)?;
12598 if normalized == "auto" {
12599 if let Some(rhs) = after.strip_prefix('=') {
12600 return infer_cpp_auto_receiver_type(rhs);
12601 }
12602 continue;
12603 }
12604 return Some(normalized);
12605 }
12606 None
12607}
12608
12609fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
12610 let candidate = prefix
12611 .rsplit([';', '{', '}', '('])
12612 .next()
12613 .unwrap_or(prefix)
12614 .trim();
12615 if candidate.is_empty() || candidate.ends_with(',') {
12616 None
12617 } else {
12618 Some(candidate)
12619 }
12620}
12621
12622fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
12623 let without_templates = strip_angle_groups(type_text);
12624 let mut cleaned = String::with_capacity(without_templates.len());
12625 for token in without_templates.split_whitespace() {
12626 if matches!(
12627 token,
12628 "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
12629 ) {
12630 continue;
12631 }
12632 if !cleaned.is_empty() {
12633 cleaned.push(' ');
12634 }
12635 cleaned.push_str(token);
12636 }
12637 let token = cleaned
12638 .split_whitespace()
12639 .last()
12640 .unwrap_or(cleaned.trim())
12641 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
12642 .trim_matches(['*', '&']);
12643 let simple = token.rsplit("::").next().unwrap_or(token).trim();
12644 if simple.is_empty() || cpp_non_type_token(simple) {
12645 None
12646 } else {
12647 Some(simple.to_string())
12648 }
12649}
12650
12651fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
12652 let rhs = rhs.trim_start();
12653 if let Some(after_new) = rhs.strip_prefix("new ") {
12654 return infer_cpp_constructor_type(after_new);
12655 }
12656 infer_cpp_make_template_type(rhs)
12657 .or_else(|| infer_cpp_constructor_type(rhs))
12658 .or_else(|| infer_cpp_factory_type(rhs))
12659}
12660
12661fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
12662 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
12663 let normalized = normalize_cpp_type_name(head)?;
12664 if !normalized
12665 .chars()
12666 .next()
12667 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
12668 {
12669 return None;
12670 }
12671 if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
12672 Some(normalized)
12673 } else {
12674 None
12675 }
12676}
12677
12678fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
12679 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
12680 if !rest.trim_start().starts_with('(') {
12681 return None;
12682 }
12683 let base = head.split('<').next().unwrap_or(head);
12684 let base_simple = base.rsplit("::").next().unwrap_or(base);
12685 if !matches!(base_simple, "make_unique" | "make_shared") {
12686 return None;
12687 }
12688 first_angle_arg(head).and_then(normalize_cpp_type_name)
12689}
12690
12691fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
12692 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
12693 if !rest.trim_start().starts_with('(') {
12694 return None;
12695 }
12696 let simple = head
12697 .split('<')
12698 .next()
12699 .unwrap_or(head)
12700 .rsplit("::")
12701 .next()
12702 .unwrap_or(head);
12703 for prefix in ["make", "create", "build"] {
12704 if let Some(suffix) = simple.strip_prefix(prefix) {
12705 if suffix
12706 .chars()
12707 .next()
12708 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
12709 {
12710 return normalize_cpp_type_name(suffix);
12711 }
12712 }
12713 }
12714 None
12715}
12716
12717#[derive(Debug, Clone, Copy)]
12718enum JavaLikeInvocation {
12719 Kotlin,
12720 Cpp,
12721}
12722
12723fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
12724 let value = value.trim_start();
12725 let mut end = 0usize;
12726 for (index, ch) in value.char_indices() {
12727 let allowed_separator = match flavor {
12728 JavaLikeInvocation::Kotlin => ch == '.',
12729 JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
12730 };
12731 if is_code_ident_char(ch) || allowed_separator {
12732 end = index + ch.len_utf8();
12733 continue;
12734 }
12735 break;
12736 }
12737 if end == 0 {
12738 return None;
12739 }
12740 let mut rest = &value[end..];
12741 if let Some(stripped) = rest.trim_start().strip_prefix('<') {
12742 let skipped = skip_balanced_angle(stripped)?;
12743 let rest_start = rest.len() - rest.trim_start().len();
12744 let angle_len = 1 + skipped;
12745 end += rest_start + angle_len;
12746 rest = &value[end..];
12747 }
12748 Some((value[..end].trim(), rest))
12749}
12750
12751fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
12752 let mut depth = 1usize;
12753 for (index, ch) in value_after_open.char_indices() {
12754 match ch {
12755 '<' => depth += 1,
12756 '>' => {
12757 depth = depth.saturating_sub(1);
12758 if depth == 0 {
12759 return Some(index + ch.len_utf8());
12760 }
12761 }
12762 _ => {}
12763 }
12764 }
12765 None
12766}
12767
12768fn first_angle_arg(value: &str) -> Option<&str> {
12769 let open = value.find('<')?;
12770 let inner_len = skip_balanced_angle(&value[open + 1..])?;
12771 let inner = &value[open + 1..open + inner_len];
12772 split_top_level_commas(inner).into_iter().next()
12773}
12774
12775fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
12776 let without_generics = strip_angle_groups(type_text);
12777 let cleaned = without_generics
12778 .replace("[]", " ")
12779 .replace("...", " ")
12780 .replace(['?', '&', '*'], " ");
12781 let token = cleaned
12782 .split_whitespace()
12783 .last()
12784 .unwrap_or(cleaned.trim())
12785 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
12786 let token = token.rsplit("::").next().unwrap_or(token);
12787 let simple = token.rsplit('.').next().unwrap_or(token).trim();
12788 if simple.is_empty()
12789 || java_like_primitive_type(simple)
12790 || !simple
12791 .chars()
12792 .next()
12793 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
12794 {
12795 None
12796 } else {
12797 Some(simple.to_string())
12798 }
12799}
12800
12801fn simple_type_name(scoped_name: &str) -> Option<String> {
12802 scoped_name
12803 .rsplit("::")
12804 .find(|segment| !segment.is_empty())
12805 .and_then(normalize_receiver_type_name)
12806}
12807
12808fn strip_angle_groups(value: &str) -> String {
12809 let mut output = String::with_capacity(value.len());
12810 let mut depth = 0usize;
12811 for ch in value.chars() {
12812 match ch {
12813 '<' => {
12814 if depth == 0 {
12815 output.push(' ');
12816 }
12817 depth += 1;
12818 }
12819 '>' => depth = depth.saturating_sub(1),
12820 _ if depth == 0 => output.push(ch),
12821 _ => {}
12822 }
12823 }
12824 output
12825}
12826
12827fn java_like_primitive_type(value: &str) -> bool {
12828 matches!(
12829 value,
12830 "boolean"
12831 | "byte"
12832 | "char"
12833 | "double"
12834 | "float"
12835 | "int"
12836 | "long"
12837 | "short"
12838 | "void"
12839 | "Boolean"
12840 | "Byte"
12841 | "Char"
12842 | "Double"
12843 | "Float"
12844 | "Int"
12845 | "Long"
12846 | "Short"
12847 | "Unit"
12848 )
12849}
12850
12851fn cpp_non_type_token(value: &str) -> bool {
12852 matches!(
12853 value,
12854 "return"
12855 | "if"
12856 | "else"
12857 | "for"
12858 | "while"
12859 | "do"
12860 | "switch"
12861 | "case"
12862 | "default"
12863 | "break"
12864 | "continue"
12865 | "goto"
12866 | "throw"
12867 | "new"
12868 | "delete"
12869 | "co_await"
12870 | "co_yield"
12871 | "co_return"
12872 | "static_cast"
12873 | "const_cast"
12874 | "dynamic_cast"
12875 | "reinterpret_cast"
12876 | "sizeof"
12877 | "alignof"
12878 | "typeid"
12879 | "and"
12880 | "or"
12881 | "not"
12882 | "xor"
12883 )
12884}
12885
12886fn receiver_is_bare_identifier(value: &str) -> bool {
12887 let mut chars = value.chars();
12888 let Some(first) = chars.next() else {
12889 return false;
12890 };
12891 (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
12892}
12893
12894fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
12895 identifier_occurrences(value, needle).into_iter().next()
12896}
12897
12898fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
12899 value
12900 .match_indices(needle)
12901 .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
12902 .collect()
12903}
12904
12905fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
12906 let before = value[..start].chars().next_back();
12907 let after = value[start + len..].chars().next();
12908 !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
12909}
12910
12911fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
12912 let stripped = value.strip_prefix(word)?;
12913 if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
12914 Some(stripped.trim_start())
12915 } else {
12916 None
12917 }
12918}
12919
12920fn is_code_ident_char(ch: char) -> bool {
12921 ch == '_' || ch.is_ascii_alphanumeric()
12922}
12923
12924fn infer_rust_receiver_type(
12925 project_root: &Path,
12926 reference: &NameMatchRef,
12927 source_cache: &mut DispatchSourceCache,
12928) -> ReceiverTypeInference {
12929 if matches!(reference.receiver.as_str(), "self" | "Self") {
12930 return enclosing_type_from_scoped_name(&reference.caller_symbol)
12931 .map(ReceiverTypeInference::Known)
12932 .unwrap_or(ReceiverTypeInference::Unknown);
12933 }
12934
12935 if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
12936 return ReceiverTypeInference::Known(reference.receiver.clone());
12937 }
12938
12939 if let Some(receiver_type) = reference
12940 .caller_signature
12941 .as_deref()
12942 .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
12943 {
12944 return ReceiverTypeInference::Known(receiver_type);
12945 }
12946
12947 infer_rust_direct_self_field_receiver_type(project_root, reference, source_cache)
12948}
12949
12950fn infer_rust_direct_self_field_receiver_type(
12951 project_root: &Path,
12952 reference: &NameMatchRef,
12953 source_cache: &mut DispatchSourceCache,
12954) -> ReceiverTypeInference {
12955 if reference.colon_dispatch {
12956 return ReceiverTypeInference::Unknown;
12957 }
12958 let Some(field_name) = rust_direct_self_field_name(&reference.receiver_expression) else {
12959 return ReceiverTypeInference::Unknown;
12960 };
12961 if field_name != reference.receiver {
12962 return ReceiverTypeInference::Unknown;
12963 }
12964
12965 let Some(impl_type) = enclosing_type_from_scoped_name(&reference.caller_symbol) else {
12966 return ReceiverTypeInference::Unknown;
12967 };
12968 let Some(struct_name) = rust_direct_nominal_type_name(&impl_type) else {
12969 return ReceiverTypeInference::KnownButUnresolved;
12970 };
12971 let Some(parsed) = parsed_dispatch_source(project_root, reference, LangId::Rust, source_cache)
12972 else {
12973 return ReceiverTypeInference::Unknown;
12974 };
12975 let Some(impl_node) =
12976 find_enclosing_rust_impl_node(parsed.tree.root_node(), reference.line.max(1))
12977 else {
12978 return ReceiverTypeInference::Unknown;
12979 };
12980 if impl_node.child_by_field_name("trait").is_some()
12981 || impl_node.child_by_field_name("type_parameters").is_some()
12982 {
12983 return ReceiverTypeInference::KnownButUnresolved;
12984 }
12985 let Some(impl_target) = impl_node.child_by_field_name("type") else {
12986 return ReceiverTypeInference::KnownButUnresolved;
12987 };
12988 if impl_target.kind() != "type_identifier"
12989 || node_text(impl_target, &parsed.source) != impl_type
12990 {
12991 return ReceiverTypeInference::KnownButUnresolved;
12992 }
12993
12994 let module_scope = rust_module_scope(impl_node);
12995 let Some(struct_node) = find_unique_rust_struct(
12996 parsed.tree.root_node(),
12997 &parsed.source,
12998 struct_name,
12999 &module_scope,
13000 ) else {
13001 return ReceiverTypeInference::KnownButUnresolved;
13002 };
13003 let Some(field_type) = rust_struct_field_type_node(struct_node, &parsed.source, field_name)
13004 else {
13005 return ReceiverTypeInference::KnownButUnresolved;
13006 };
13007 if field_type.kind() != "type_identifier" {
13008 return ReceiverTypeInference::KnownButUnresolved;
13009 }
13010 let field_type_name = node_text(field_type, &parsed.source);
13011 if find_unique_rust_struct(
13012 parsed.tree.root_node(),
13013 &parsed.source,
13014 field_type_name,
13015 &module_scope,
13016 )
13017 .is_none()
13018 {
13019 return ReceiverTypeInference::KnownButUnresolved;
13020 }
13021
13022 ReceiverTypeInference::RustDirectSelfField {
13023 receiver_type: field_type_name.to_string(),
13024 declaration_file: reference.caller_file.clone(),
13025 module_scope,
13026 }
13027}
13028
13029fn rust_direct_self_field_name(receiver_expression: &str) -> Option<&str> {
13030 let (base, field) = receiver_expression.split_once('.')?;
13031 let base = base.trim();
13032 let field = field.trim();
13033 (base == "self" && rust_direct_nominal_type_name(field).is_some()).then_some(field)
13034}
13035
13036fn rust_direct_nominal_type_name(value: &str) -> Option<&str> {
13037 let name = value.rsplit("::").next()?.trim();
13038 (!name.is_empty()
13039 && !name.chars().next().is_some_and(|ch| ch.is_ascii_digit())
13040 && name.chars().all(is_rust_ident_char))
13041 .then_some(name)
13042}
13043
13044fn find_enclosing_rust_impl_node<'tree>(
13045 root: tree_sitter::Node<'tree>,
13046 line: u32,
13047) -> Option<tree_sitter::Node<'tree>> {
13048 let mut best = None;
13049 let mut stack = vec![root];
13050 while let Some(node) = stack.pop() {
13051 if !node_contains_line(node, line) {
13052 continue;
13053 }
13054 if node.kind() == "impl_item" {
13055 best = tighter_node(best, node);
13056 }
13057 push_named_children(node, &mut stack);
13058 }
13059 best
13060}
13061
13062fn rust_module_scope(node: tree_sitter::Node<'_>) -> Vec<(usize, usize)> {
13063 let mut scope = Vec::new();
13064 let mut current = node.parent();
13065 while let Some(parent) = current {
13066 if parent.kind() == "mod_item" {
13067 scope.push((parent.start_byte(), parent.end_byte()));
13068 }
13069 current = parent.parent();
13070 }
13071 scope.reverse();
13072 scope
13073}
13074
13075fn find_unique_rust_struct<'tree>(
13076 root: tree_sitter::Node<'tree>,
13077 source: &str,
13078 expected_name: &str,
13079 module_scope: &[(usize, usize)],
13080) -> Option<tree_sitter::Node<'tree>> {
13081 let mut found = None;
13082 let mut stack = vec![root];
13083 while let Some(node) = stack.pop() {
13084 if node.kind() == "struct_item"
13085 && rust_module_scope(node) == module_scope
13086 && node.child_by_field_name("type_parameters").is_none()
13087 && declaration_name(node, source) == Some(expected_name)
13088 {
13089 if found.is_some() {
13090 return None;
13091 }
13092 found = Some(node);
13093 }
13094 push_named_children(node, &mut stack);
13095 }
13096 found
13097}
13098
13099fn rust_struct_field_type_node<'tree>(
13100 struct_node: tree_sitter::Node<'tree>,
13101 source: &str,
13102 field_name: &str,
13103) -> Option<tree_sitter::Node<'tree>> {
13104 let fields = struct_node.child_by_field_name("body")?;
13105 if fields.kind() != "field_declaration_list" {
13106 return None;
13107 }
13108 for index in 0..fields.named_child_count() {
13109 let field = fields.named_child(index as u32)?;
13110 if field.kind() != "field_declaration"
13111 || declaration_name(field, source) != Some(field_name)
13112 {
13113 continue;
13114 }
13115 return field.child_by_field_name("type");
13116 }
13117 None
13118}
13119
13120fn rust_receiver_looks_type_like(receiver: &str) -> bool {
13121 receiver
13122 .chars()
13123 .next()
13124 .is_some_and(|ch| ch == '_' || ch.is_uppercase())
13125}
13126
13127fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
13128 scoped_name
13129 .rsplit_once("::")
13130 .map(|(enclosing, _)| enclosing)
13131 .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
13132 .map(ToString::to_string)
13133}
13134
13135fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
13136 let params = signature_parameter_text(signature)?;
13137 for param in split_top_level_commas(params) {
13138 let Some((pattern, type_text)) = param.split_once(':') else {
13139 continue;
13140 };
13141 let Some(name) = rust_parameter_name(pattern) else {
13142 continue;
13143 };
13144 if name == receiver {
13145 return normalize_rust_receiver_type(type_text);
13146 }
13147 }
13148 None
13149}
13150
13151fn signature_parameter_text(signature: &str) -> Option<&str> {
13152 let open = signature.find('(')?;
13153 let mut depth = 0usize;
13154 for (offset, ch) in signature[open..].char_indices() {
13155 match ch {
13156 '(' => depth += 1,
13157 ')' => {
13158 depth = depth.saturating_sub(1);
13159 if depth == 0 {
13160 return Some(&signature[open + 1..open + offset]);
13161 }
13162 }
13163 _ => {}
13164 }
13165 }
13166 None
13167}
13168
13169fn split_top_level_commas(value: &str) -> Vec<&str> {
13170 let mut parts = Vec::new();
13171 let mut start = 0usize;
13172 let mut angle_depth = 0usize;
13173 let mut paren_depth = 0usize;
13174 let mut bracket_depth = 0usize;
13175 for (index, ch) in value.char_indices() {
13176 match ch {
13177 '<' => angle_depth += 1,
13178 '>' => angle_depth = angle_depth.saturating_sub(1),
13179 '(' => paren_depth += 1,
13180 ')' => paren_depth = paren_depth.saturating_sub(1),
13181 '[' => bracket_depth += 1,
13182 ']' => bracket_depth = bracket_depth.saturating_sub(1),
13183 ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
13184 let part = value[start..index].trim();
13185 if !part.is_empty() {
13186 parts.push(part);
13187 }
13188 start = index + ch.len_utf8();
13189 }
13190 _ => {}
13191 }
13192 }
13193 let part = value[start..].trim();
13194 if !part.is_empty() {
13195 parts.push(part);
13196 }
13197 parts
13198}
13199
13200fn rust_parameter_name(pattern: &str) -> Option<&str> {
13201 let mut pattern = pattern.trim();
13202 if let Some(stripped) = pattern.strip_prefix("mut ") {
13203 pattern = stripped.trim_start();
13204 }
13205 pattern
13206 .rsplit(|ch: char| !is_rust_ident_char(ch))
13207 .find(|part| !part.is_empty())
13208}
13209
13210fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
13211 let mut ty = strip_leading_rust_type_modifiers(type_text);
13212 let owned_inner;
13213 if let Some(inner) = single_outer_generic_arg(ty) {
13214 owned_inner = inner.trim().to_string();
13215 ty = strip_leading_rust_type_modifiers(&owned_inner);
13216 }
13217 rust_base_type_ident(ty)
13218}
13219
13220fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
13221 loop {
13222 ty = ty.trim_start();
13223 if let Some(stripped) = ty.strip_prefix('&') {
13224 ty = stripped.trim_start();
13225 if let Some(stripped) = strip_leading_lifetime(ty) {
13226 ty = stripped.trim_start();
13227 }
13228 if let Some(stripped) = ty.strip_prefix("mut ") {
13229 ty = stripped.trim_start();
13230 }
13231 continue;
13232 }
13233 if let Some(stripped) = ty.strip_prefix("mut ") {
13234 ty = stripped.trim_start();
13235 continue;
13236 }
13237 if let Some(stripped) = ty.strip_prefix("dyn ") {
13238 ty = stripped.trim_start();
13239 continue;
13240 }
13241 if let Some(stripped) = ty.strip_prefix("impl ") {
13242 ty = stripped.trim_start();
13243 continue;
13244 }
13245 break ty.trim();
13246 }
13247}
13248
13249fn strip_leading_lifetime(value: &str) -> Option<&str> {
13250 let mut chars = value.char_indices();
13251 let (_, first) = chars.next()?;
13252 if first != '\'' {
13253 return None;
13254 }
13255 for (index, ch) in chars {
13256 if !(ch == '_' || ch.is_ascii_alphanumeric()) {
13257 return Some(&value[index..]);
13258 }
13259 }
13260 Some("")
13261}
13262
13263fn single_outer_generic_arg(ty: &str) -> Option<&str> {
13264 let ty = ty.trim();
13265 let open = ty.find('<')?;
13266 let mut depth = 0usize;
13267 let mut close = None;
13268 for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
13269 match ch {
13270 '<' => depth += 1,
13271 '>' => {
13272 depth = depth.saturating_sub(1);
13273 if depth == 0 {
13274 close = Some(index);
13275 break;
13276 }
13277 }
13278 _ => {}
13279 }
13280 }
13281 let close = close?;
13282 if !ty[close + 1..].trim().is_empty() {
13283 return None;
13284 }
13285 let inner = &ty[open + 1..close];
13286 let args = split_top_level_commas(inner);
13287 match args.as_slice() {
13288 [arg] => Some(*arg),
13289 _ => None,
13290 }
13291}
13292
13293fn rust_base_type_ident(ty: &str) -> Option<String> {
13294 let ty = ty.trim();
13295 let head = ty
13296 .split([' ', '+', '='])
13297 .find(|part| !part.is_empty())
13298 .unwrap_or(ty);
13299 let head = head.split('<').next().unwrap_or(head).trim();
13300 let ident = head
13301 .rsplit("::")
13302 .next()
13303 .unwrap_or(head)
13304 .trim_matches(|ch: char| !is_rust_ident_char(ch));
13305 if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
13306 None
13307 } else {
13308 Some(ident.to_string())
13309 }
13310}
13311
13312fn is_rust_ident_char(ch: char) -> bool {
13313 ch == '_' || ch.is_ascii_alphanumeric()
13314}
13315
13316fn select_rust_direct_self_field_candidate(
13317 project_root: &Path,
13318 reference: &NameMatchRef,
13319 candidates: &[NameMatchCandidate],
13320 receiver_type: &str,
13321 declaration_file: &str,
13322 declaration_scope: &[(usize, usize)],
13323 source_cache: &mut DispatchSourceCache,
13324) -> Option<NameMatchCandidate> {
13325 let eligible = candidates
13326 .iter()
13327 .filter(|candidate| candidate.node_id != reference.caller_node)
13328 .filter(|candidate| {
13329 type_candidate_matches(candidate, receiver_type, &reference.method_name)
13330 })
13331 .filter(|candidate| {
13332 rust_direct_self_field_candidate_matches_scope(
13333 project_root,
13334 candidate,
13335 receiver_type,
13336 declaration_file,
13337 declaration_scope,
13338 source_cache,
13339 )
13340 })
13341 .collect::<Vec<_>>();
13342 match eligible.as_slice() {
13343 [candidate] => Some((**candidate).clone()),
13344 _ => None,
13345 }
13346}
13347
13348fn rust_direct_self_field_candidate_matches_scope(
13349 project_root: &Path,
13350 candidate: &NameMatchCandidate,
13351 receiver_type: &str,
13352 declaration_file: &str,
13353 declaration_scope: &[(usize, usize)],
13354 source_cache: &mut DispatchSourceCache,
13355) -> bool {
13356 if candidate.file_path != declaration_file {
13357 return false;
13358 }
13359 let Some(parsed) = parsed_dispatch_source_for_file(
13360 project_root,
13361 &candidate.file_path,
13362 "rust",
13363 LangId::Rust,
13364 source_cache,
13365 ) else {
13366 return false;
13367 };
13368 let Some(impl_node) =
13369 find_enclosing_rust_impl_node(parsed.tree.root_node(), candidate.start_line)
13370 else {
13371 return false;
13372 };
13373 if impl_node.child_by_field_name("trait").is_some()
13374 || impl_node.child_by_field_name("type_parameters").is_some()
13375 {
13376 return false;
13377 }
13378 let Some(impl_target) = impl_node.child_by_field_name("type") else {
13379 return false;
13380 };
13381 impl_target.kind() == "type_identifier"
13382 && node_text(impl_target, &parsed.source) == receiver_type
13383 && rust_module_scope(impl_node) == declaration_scope
13384}
13385
13386fn select_type_match_candidate(
13387 reference: &NameMatchRef,
13388 candidates: &[NameMatchCandidate],
13389 receiver_type: &str,
13390) -> Option<NameMatchCandidate> {
13391 let candidates = candidates
13392 .iter()
13393 .filter(|candidate| candidate.node_id != reference.caller_node)
13394 .filter(|candidate| {
13395 type_candidate_matches(candidate, receiver_type, &reference.method_name)
13396 })
13397 .collect::<Vec<_>>();
13398 match candidates.as_slice() {
13399 [candidate] => Some((**candidate).clone()),
13400 _ => None,
13401 }
13402}
13403
13404fn type_candidate_matches(
13405 candidate: &NameMatchCandidate,
13406 receiver_type: &str,
13407 method_name: &str,
13408) -> bool {
13409 let normalized_type = receiver_type.replace('.', "::");
13410 let suffix = format!("{normalized_type}::{method_name}");
13411 candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
13412}
13413
13414fn select_name_match_candidate(
13415 reference: &NameMatchRef,
13416 candidates: &[NameMatchCandidate],
13417) -> Option<NameMatchCandidate> {
13418 let candidates = candidates
13419 .iter()
13420 .filter(|candidate| candidate.node_id != reference.caller_node)
13421 .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
13422 .collect::<Vec<_>>();
13423 match candidates.as_slice() {
13424 [] => None,
13425 [candidate] => Some((**candidate).clone()),
13426 _ => select_scored_name_match_candidate(reference, &candidates),
13427 }
13428}
13429
13430fn candidate_allowed_for_reference(
13431 reference: &NameMatchRef,
13432 candidate: &NameMatchCandidate,
13433) -> bool {
13434 if !reference.colon_dispatch {
13435 return true;
13436 }
13437
13438 candidate.kind == "method"
13439 && candidate
13440 .scoped_name
13441 .split("::")
13442 .any(|segment| segment == reference.receiver)
13443}
13444
13445fn select_scored_name_match_candidate(
13446 reference: &NameMatchRef,
13447 candidates: &[&NameMatchCandidate],
13448) -> Option<NameMatchCandidate> {
13449 let receiver_words = split_camel_case(&reference.receiver);
13450 if receiver_words.is_empty() {
13451 return None;
13452 }
13453
13454 let mut best: Option<(&NameMatchCandidate, f64)> = None;
13455 let mut tied_best = false;
13456 for candidate in candidates {
13457 let candidate_words = split_camel_case(&candidate.scoped_name);
13458 let overlap = receiver_words
13459 .iter()
13460 .filter(|receiver_word| {
13461 candidate_words
13462 .iter()
13463 .any(|candidate_word| candidate_word == *receiver_word)
13464 })
13465 .count() as f64;
13466 let score =
13467 overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
13468 match best {
13469 None => {
13470 best = Some((*candidate, score));
13471 tied_best = false;
13472 }
13473 Some((_, best_score)) if score > best_score => {
13474 best = Some((*candidate, score));
13475 tied_best = false;
13476 }
13477 Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
13478 tied_best = true;
13479 }
13480 _ => {}
13481 }
13482 }
13483
13484 let (candidate, score) = best?;
13485 if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
13486 Some(candidate.clone())
13487 } else {
13488 None
13489 }
13490}
13491
13492fn method_name_match_denylisted(method_name: &str) -> bool {
13493 matches!(
13494 method_name,
13495 "and_then"
13496 | "as_bytes"
13497 | "as_deref"
13498 | "as_mut"
13499 | "as_ref"
13500 | "as_str"
13501 | "borrow"
13502 | "borrow_mut"
13503 | "clear"
13504 | "clone"
13505 | "collect"
13506 | "contains"
13507 | "contains_key"
13508 | "count"
13509 | "dedup"
13510 | "default"
13511 | "drain"
13512 | "ends_with"
13513 | "entry"
13514 | "err"
13515 | "expect"
13516 | "extend"
13517 | "filter"
13518 | "filter_map"
13519 | "find"
13520 | "from"
13521 | "get"
13522 | "get_mut"
13523 | "insert"
13524 | "into"
13525 | "into_iter"
13526 | "is_empty"
13527 | "is_err"
13528 | "is_none"
13529 | "is_ok"
13530 | "is_some"
13531 | "iter"
13532 | "iter_mut"
13533 | "join"
13534 | "len"
13535 | "lock"
13536 | "map"
13537 | "map_err"
13538 | "max"
13539 | "min"
13540 | "new"
13541 | "next"
13542 | "ok"
13543 | "or_default"
13544 | "or_else"
13545 | "or_insert"
13546 | "or_insert_with"
13547 | "parse"
13548 | "pop"
13549 | "position"
13550 | "push"
13551 | "read"
13552 | "recv"
13553 | "remove"
13554 | "replace"
13555 | "retain"
13556 | "send"
13557 | "sort"
13558 | "sort_by"
13559 | "split"
13560 | "starts_with"
13561 | "sum"
13562 | "take"
13563 | "to_owned"
13564 | "to_string"
13565 | "trim"
13566 | "try_from"
13567 | "try_into"
13568 | "unwrap"
13569 | "unwrap_or"
13570 | "unwrap_or_default"
13571 | "unwrap_or_else"
13572 | "with_capacity"
13573 | "write"
13574 )
13575}
13576
13577fn split_camel_case(value: &str) -> Vec<String> {
13578 let chars = value.chars().collect::<Vec<_>>();
13579 let mut normalized = String::with_capacity(value.len() + 8);
13580 for (index, ch) in chars.iter().enumerate() {
13581 let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
13582 let next = chars.get(index + 1);
13583 let is_separator = ch.is_whitespace()
13584 || matches!(
13585 ch,
13586 '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
13587 );
13588 if is_separator {
13589 normalized.push(' ');
13590 continue;
13591 }
13592 let camel_boundary = previous.is_some_and(|prev| {
13593 (prev.is_lowercase() && ch.is_uppercase())
13594 || (prev.is_ascii_digit() && ch.is_alphabetic())
13595 || (prev.is_uppercase()
13596 && ch.is_uppercase()
13597 && next.is_some_and(|next| next.is_lowercase()))
13598 });
13599 if camel_boundary {
13600 normalized.push(' ');
13601 }
13602 normalized.push(*ch);
13603 }
13604
13605 normalized
13606 .split_whitespace()
13607 .filter(|word| word.len() > 1)
13608 .map(|word| word.to_ascii_lowercase())
13609 .collect()
13610}
13611
13612fn compute_path_proximity(left: &str, right: &str) -> f64 {
13613 let left_dirs = left
13614 .rsplit_once('/')
13615 .map(|(dir, _)| dir)
13616 .unwrap_or_default()
13617 .split('/')
13618 .filter(|part| !part.is_empty());
13619 let right_dirs = right
13620 .rsplit_once('/')
13621 .map(|(dir, _)| dir)
13622 .unwrap_or_default()
13623 .split('/')
13624 .filter(|part| !part.is_empty());
13625
13626 let shared = left_dirs
13627 .zip(right_dirs)
13628 .take_while(|(left, right)| left == right)
13629 .count();
13630 ((shared as f64) * 0.05).min(0.5)
13631}
13632
13633fn mark_backend_state(
13634 tx: &Transaction<'_>,
13635 project_root: &Path,
13636 rel_path: &str,
13637 content_hash: Option<&blake3::Hash>,
13638 status: &str,
13639) -> Result<()> {
13640 clear_backend_state_for_file(tx, project_root, rel_path)?;
13641 let hash = content_hash
13642 .map(|hash| hash_to_hex(*hash))
13643 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
13644 tx.execute(
13645 "INSERT OR REPLACE INTO backend_file_state(
13646 backend, workspace_root, file_path, content_hash, status, updated_at
13647 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
13648 params![
13649 BACKEND_TREESITTER,
13650 project_root.display().to_string(),
13651 rel_path,
13652 hash,
13653 status,
13654 unix_seconds_now(),
13655 ],
13656 )?;
13657 Ok(())
13658}
13659
13660#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13661enum StalePathStatus {
13662 Present,
13663 Absent,
13664 Unreadable,
13665}
13666
13667fn stale_backend_file_paths(
13668 conn: &Connection,
13669 project_root: &Path,
13670 distinct: bool,
13671) -> Result<Vec<String>> {
13672 let distinct = if distinct { "DISTINCT " } else { "" };
13673 let sql = format!(
13674 "SELECT {distinct}file_path FROM backend_file_state
13675 WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
13676 ORDER BY file_path"
13677 );
13678 let mut stmt = conn.prepare(&sql)?;
13679 let rows = stmt.query_map(
13680 params![BACKEND_TREESITTER, project_root.display().to_string()],
13681 |row| row.get::<_, String>(0),
13682 )?;
13683 rows.collect::<std::result::Result<Vec<_>, _>>()
13684 .map_err(Into::into)
13685}
13686
13687fn stale_path_census(conn: &Connection, project_root: &Path) -> Result<StalePathCensus> {
13688 let mut census = StalePathCensus::default();
13689 for rel_path in stale_backend_file_paths(conn, project_root, false)? {
13690 census.stale += 1;
13691 match stale_path_status(project_root, &rel_path) {
13692 StalePathStatus::Present => {}
13693 StalePathStatus::Absent => census.absent += 1,
13694 StalePathStatus::Unreadable => census.unreadable += 1,
13695 }
13696 }
13697 Ok(census)
13698}
13699
13700fn stale_path_status(project_root: &Path, rel_path: &str) -> StalePathStatus {
13701 let requested = project_root.join(rel_path);
13702 let Ok((path, normalized_rel_path)) = normalize_project_file_path(project_root, &requested)
13703 else {
13704 return StalePathStatus::Unreadable;
13705 };
13706 if normalized_rel_path != rel_path.replace('\\', "/") {
13707 return StalePathStatus::Unreadable;
13708 }
13709 match std::fs::metadata(path) {
13710 Ok(_) => StalePathStatus::Present,
13711 Err(error) if error.kind() == std::io::ErrorKind::NotFound => StalePathStatus::Absent,
13712 Err(_) => StalePathStatus::Unreadable,
13713 }
13714}
13715
13716fn clear_backend_state_for_file(
13717 tx: &Transaction<'_>,
13718 project_root: &Path,
13719 rel_path: &str,
13720) -> Result<()> {
13721 tx.execute(
13722 "DELETE FROM backend_file_state
13723 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
13724 params![
13725 BACKEND_TREESITTER,
13726 project_root.display().to_string(),
13727 rel_path
13728 ],
13729 )?;
13730 Ok(())
13731}
13732
13733fn clear_stale_backend_status_for_file(
13739 tx: &Transaction<'_>,
13740 project_root: &Path,
13741 rel_path: &str,
13742) -> Result<()> {
13743 tx.execute(
13744 "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
13745 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3 AND status = 'stale'",
13746 params![
13747 BACKEND_TREESITTER,
13748 project_root.display().to_string(),
13749 rel_path,
13750 unix_seconds_now(),
13751 ],
13752 )?;
13753 Ok(())
13754}
13755
13756fn load_file_row(conn: &Connection, rel_path: &str) -> Result<Option<FileRow>> {
13757 conn.query_row(
13758 "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
13759 params![rel_path],
13760 |row| {
13761 let hash_text: String = row.get(1)?;
13762 Ok(FileRow {
13763 surface_fingerprint: row.get(0)?,
13764 freshness: FileFreshness {
13765 content_hash: hash_from_hex(&hash_text)
13766 .unwrap_or_else(cache_freshness::zero_hash),
13767 mtime: ns_to_system_time(row.get::<_, i64>(2)?),
13768 size: row.get::<_, i64>(3)? as u64,
13769 },
13770 })
13771 },
13772 )
13773 .optional()
13774 .map_err(CallGraphStoreError::from)
13775}
13776
13777fn stored_node_ids_match_extract(
13778 tx: &Transaction<'_>,
13779 rel_path: &str,
13780 extract: &FileExtract,
13781) -> Result<bool> {
13782 let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
13783 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
13784 let mut stored = BTreeSet::new();
13785 for row in rows {
13786 stored.insert(row?);
13787 }
13788 let extracted = extract
13789 .nodes
13790 .iter()
13791 .map(|node| node.id.clone())
13792 .collect::<BTreeSet<_>>();
13793 Ok(stored == extracted)
13794}
13795
13796fn stored_extract_matches(
13800 tx: &Transaction<'_>,
13801 rel_path: &str,
13802 extract: &FileExtract,
13803 index: &ProjectIndex<'_>,
13804) -> Result<bool> {
13805 let stored_file = tx
13806 .query_row(
13807 "SELECT lang, surface_fingerprint FROM files WHERE path = ?1",
13808 params![rel_path],
13809 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
13810 )
13811 .optional()?;
13812 if stored_file
13813 != Some((
13814 lang_label(extract.lang).to_string(),
13815 extract.surface_fingerprint.clone(),
13816 ))
13817 {
13818 return Ok(false);
13819 }
13820
13821 let mut stored_nodes_stmt = tx.prepare(
13822 "SELECT id, file_path, name, scoped_name, kind, start_line, start_col,
13823 end_line, end_col, range_ordinal, signature, exported,
13824 is_default_export, is_type_like, is_callgraph_entry_point, provenance
13825 FROM nodes WHERE file_path = ?1",
13826 )?;
13827 let stored_nodes = stored_nodes_stmt
13828 .query_map(params![rel_path], |row| {
13829 Ok(serde_json::json!([
13830 row.get::<_, String>(0)?,
13831 row.get::<_, String>(1)?,
13832 row.get::<_, String>(2)?,
13833 row.get::<_, String>(3)?,
13834 row.get::<_, String>(4)?,
13835 row.get::<_, i64>(5)?,
13836 row.get::<_, i64>(6)?,
13837 row.get::<_, i64>(7)?,
13838 row.get::<_, i64>(8)?,
13839 row.get::<_, i64>(9)?,
13840 row.get::<_, Option<String>>(10)?,
13841 row.get::<_, i64>(11)?,
13842 row.get::<_, i64>(12)?,
13843 row.get::<_, i64>(13)?,
13844 row.get::<_, i64>(14)?,
13845 row.get::<_, String>(15)?,
13846 ])
13847 .to_string())
13848 })?
13849 .collect::<rusqlite::Result<Vec<_>>>()?;
13850 let expected_nodes = extract
13851 .nodes
13852 .iter()
13853 .map(|node| {
13854 serde_json::json!([
13855 node.id,
13856 node.file_path,
13857 node.name,
13858 node.scoped_name,
13859 node.kind,
13860 node.range.start_line,
13861 node.range.start_col,
13862 node.range.end_line,
13863 node.range.end_col,
13864 node.range_ordinal,
13865 node.signature,
13866 bool_int(node.exported),
13867 bool_int(node.is_default_export),
13868 bool_int(node.is_type_like),
13869 bool_int(node.is_callgraph_entry_point),
13870 PROVENANCE_TREESITTER,
13871 ])
13872 .to_string()
13873 })
13874 .collect::<Vec<_>>();
13875 let mut stored_nodes = stored_nodes;
13876 let mut expected_nodes = expected_nodes;
13877 stored_nodes.sort();
13878 expected_nodes.sort();
13879 if stored_nodes != expected_nodes {
13880 return Ok(false);
13881 }
13882
13883 let resolved_refs = extract
13884 .raw_refs
13885 .iter()
13886 .cloned()
13887 .map(|raw| resolve_ref(raw, index))
13888 .collect::<Result<Vec<_>>>()?;
13889 let mut stored_refs_stmt = tx.prepare(
13890 "SELECT ref_id, caller_node, caller_file, kind, short_name, full_ref,
13891 module_path, import_kind, local_name, requested_name, namespace_alias,
13892 wildcard, line, byte_start, byte_end, status, target_node,
13893 target_file, target_symbol, provenance
13894 FROM refs WHERE caller_file = ?1",
13895 )?;
13896 let stored_refs = stored_refs_stmt
13897 .query_map(params![rel_path], |row| {
13898 Ok(serde_json::json!([
13899 row.get::<_, String>(0)?,
13900 row.get::<_, Option<String>>(1)?,
13901 row.get::<_, String>(2)?,
13902 row.get::<_, String>(3)?,
13903 row.get::<_, Option<String>>(4)?,
13904 row.get::<_, Option<String>>(5)?,
13905 row.get::<_, Option<String>>(6)?,
13906 row.get::<_, Option<String>>(7)?,
13907 row.get::<_, Option<String>>(8)?,
13908 row.get::<_, Option<String>>(9)?,
13909 row.get::<_, Option<String>>(10)?,
13910 row.get::<_, i64>(11)?,
13911 row.get::<_, i64>(12)?,
13912 row.get::<_, i64>(13)?,
13913 row.get::<_, i64>(14)?,
13914 row.get::<_, String>(15)?,
13915 row.get::<_, Option<String>>(16)?,
13916 row.get::<_, Option<String>>(17)?,
13917 row.get::<_, Option<String>>(18)?,
13918 row.get::<_, String>(19)?,
13919 ])
13920 .to_string())
13921 })?
13922 .collect::<rusqlite::Result<Vec<_>>>()?;
13923 let expected_refs = resolved_refs
13924 .iter()
13925 .map(|resolved| {
13926 let raw = &resolved.raw;
13927 serde_json::json!([
13928 raw.ref_id,
13929 raw.caller_node,
13930 raw.caller_file,
13931 raw.kind,
13932 raw.short_name,
13933 raw.full_ref,
13934 raw.module_path,
13935 raw.import_kind,
13936 raw.local_name,
13937 raw.requested_name,
13938 raw.namespace_alias,
13939 bool_int(raw.wildcard),
13940 raw.line,
13941 raw.byte_start,
13942 raw.byte_end,
13943 resolved.status,
13944 resolved.target_node,
13945 resolved.target_file,
13946 resolved.target_symbol,
13947 ref_provenance(raw),
13948 ])
13949 .to_string()
13950 })
13951 .collect::<Vec<_>>();
13952 let mut stored_refs = stored_refs;
13953 let mut expected_refs = expected_refs;
13954 stored_refs.sort();
13955 expected_refs.sort();
13956 if stored_refs != expected_refs {
13957 return Ok(false);
13958 }
13959
13960 let mut stored_edges_stmt = tx.prepare(
13961 "SELECT e.edge_id, e.ref_id, e.source_node, e.target_node,
13962 e.target_file, e.target_symbol, e.kind, e.line, e.provenance
13963 FROM edges e JOIN refs r ON r.ref_id = e.ref_id
13964 WHERE r.caller_file = ?1 AND e.provenance = ?2",
13965 )?;
13966 let stored_edges = stored_edges_stmt
13967 .query_map(params![rel_path, PROVENANCE_TREESITTER], |row| {
13968 Ok(serde_json::json!([
13969 row.get::<_, String>(0)?,
13970 row.get::<_, String>(1)?,
13971 row.get::<_, String>(2)?,
13972 row.get::<_, Option<String>>(3)?,
13973 row.get::<_, String>(4)?,
13974 row.get::<_, String>(5)?,
13975 row.get::<_, String>(6)?,
13976 row.get::<_, i64>(7)?,
13977 row.get::<_, String>(8)?,
13978 ])
13979 .to_string())
13980 })?
13981 .collect::<rusqlite::Result<Vec<_>>>()?;
13982 let expected_edges = resolved_refs
13983 .iter()
13984 .filter_map(|resolved| {
13985 resolved.edge.as_ref().map(|edge| {
13986 serde_json::json!([
13987 edge.edge_id,
13988 resolved.raw.ref_id,
13989 edge.source_node,
13990 edge.target_node,
13991 edge.target_file,
13992 edge.target_symbol,
13993 edge.kind,
13994 edge.line,
13995 ref_provenance(&resolved.raw),
13996 ])
13997 .to_string()
13998 })
13999 })
14000 .collect::<Vec<_>>();
14001 let mut stored_edges = stored_edges;
14002 let mut expected_edges = expected_edges;
14003 stored_edges.sort();
14004 expected_edges.sort();
14005 if stored_edges != expected_edges {
14006 return Ok(false);
14007 }
14008
14009 let mut stored_dependencies_stmt =
14010 tx.prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1")?;
14011 let stored_dependencies = stored_dependencies_stmt
14012 .query_map(params![rel_path], |row| row.get::<_, String>(0))?
14013 .collect::<rusqlite::Result<BTreeSet<_>>>()?;
14014 let expected_dependencies = extract
14015 .raw_refs
14016 .iter()
14017 .flat_map(|raw| raw.dependencies.iter().cloned())
14018 .collect::<BTreeSet<_>>();
14019 if stored_dependencies != expected_dependencies {
14020 return Ok(false);
14021 }
14022
14023 let mut stored_hints_stmt = tx.prepare(
14024 "SELECT id, method_name, caller_node, file, line, byte_start, byte_end, provenance
14025 FROM dispatch_hints WHERE file = ?1",
14026 )?;
14027 let stored_hints = stored_hints_stmt
14028 .query_map(params![rel_path], |row| {
14029 Ok(serde_json::json!([
14030 row.get::<_, String>(0)?,
14031 row.get::<_, String>(1)?,
14032 row.get::<_, String>(2)?,
14033 row.get::<_, String>(3)?,
14034 row.get::<_, i64>(4)?,
14035 row.get::<_, i64>(5)?,
14036 row.get::<_, i64>(6)?,
14037 row.get::<_, String>(7)?,
14038 ])
14039 .to_string())
14040 })?
14041 .collect::<rusqlite::Result<Vec<_>>>()?;
14042 let expected_hints = extract
14043 .dispatch_hints
14044 .iter()
14045 .map(|hint| {
14046 serde_json::json!([
14047 hint.id,
14048 hint.method_name,
14049 hint.caller_node,
14050 hint.file,
14051 hint.line,
14052 hint.byte_start,
14053 hint.byte_end,
14054 PROVENANCE_TREESITTER,
14055 ])
14056 .to_string()
14057 })
14058 .collect::<Vec<_>>();
14059 let mut stored_hints = stored_hints;
14060 let mut expected_hints = expected_hints;
14061 stored_hints.sort();
14062 expected_hints.sort();
14063 Ok(stored_hints == expected_hints)
14064}
14065
14066fn update_file_fresh_metadata(
14067 tx: &Transaction<'_>,
14068 project_root: &Path,
14069 rel_path: &str,
14070 hash: &blake3::Hash,
14071 mtime: SystemTime,
14072 size: u64,
14073) -> Result<()> {
14074 tx.execute(
14075 "UPDATE files SET content_hash = ?2, mtime_ns = ?3, size = ?4, indexed_at = ?5
14076 WHERE path = ?1",
14077 params![
14078 rel_path,
14079 hash_to_hex(*hash),
14080 system_time_to_ns(mtime),
14081 size as i64,
14082 unix_seconds_now()
14083 ],
14084 )?;
14085 tx.execute(
14086 "UPDATE backend_file_state SET content_hash = ?3, status = 'fresh', updated_at = ?5
14087 WHERE backend = ?1 AND file_path = ?2 AND workspace_root = ?4",
14088 params![
14089 BACKEND_TREESITTER,
14090 rel_path,
14091 hash_to_hex(*hash),
14092 project_root.display().to_string(),
14093 unix_seconds_now(),
14094 ],
14095 )?;
14096 Ok(())
14097}
14098
14099#[derive(Debug, Clone, PartialEq, Eq)]
14100struct DependentRefSelection {
14101 ref_id: String,
14102 caller_file: String,
14103}
14104
14105fn ref_ids_depending_on(
14106 conn: &Connection,
14107 project_root: &Path,
14108 rel_path: &str,
14109) -> Result<Vec<DependentRefSelection>> {
14110 let mut stmt = conn.prepare(
14111 "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
14112 FROM refs r
14113 WHERE r.caller_file IN (
14114 SELECT file_path FROM file_dependencies WHERE dep_file = ?1
14115 )
14116 OR r.target_file = ?1
14117 ORDER BY r.ref_id",
14118 )?;
14119 let rows = stmt.query_map(params![rel_path], |row| {
14120 Ok(RefDependencyRow {
14121 ref_id: row.get(0)?,
14122 kind: row.get(1)?,
14123 caller_file: row.get(2)?,
14124 module_path: row.get(3)?,
14125 target_file: row.get(4)?,
14126 })
14127 })?;
14128 let mut ids = Vec::new();
14129 for row in rows {
14130 let row = row?;
14131 if ref_dependency_row_depends_on(project_root, &row, rel_path) {
14132 ids.push(DependentRefSelection {
14133 ref_id: row.ref_id,
14134 caller_file: row.caller_file,
14135 });
14136 }
14137 }
14138 Ok(ids)
14139}
14140
14141fn record_dependent_refs(
14142 selected_ref_ids: &mut BTreeSet<String>,
14143 selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
14144 dependent_refs: Vec<DependentRefSelection>,
14145) {
14146 for dependent_ref in dependent_refs {
14147 let DependentRefSelection {
14148 ref_id,
14149 caller_file,
14150 } = dependent_ref;
14151 selected_ref_ids.insert(ref_id.clone());
14152 selected_refs_by_caller
14153 .entry(caller_file)
14154 .or_default()
14155 .insert(ref_id);
14156 }
14157}
14158
14159#[cfg(test)]
14160fn refs_by_caller_for_ref_ids(
14161 tx: &Transaction<'_>,
14162 ref_ids: &BTreeSet<String>,
14163) -> Result<BTreeMap<String, BTreeSet<String>>> {
14164 let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
14165 let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
14166 for ref_id in ref_ids {
14167 if let Some(caller) = stmt
14168 .query_row(params![ref_id], |row| row.get::<_, String>(0))
14169 .optional()?
14170 {
14171 by_caller.entry(caller).or_default().insert(ref_id.clone());
14172 }
14173 }
14174 Ok(by_caller)
14175}
14176
14177fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
14178 tx.execute(
14179 "DELETE FROM file_dependencies WHERE file_path = ?1",
14180 params![rel_path],
14181 )?;
14182 delete_refs_for_caller(tx, rel_path)?;
14183 tx.execute(
14184 "DELETE FROM dispatch_hints WHERE file = ?1",
14185 params![rel_path],
14186 )?;
14187 tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
14188 tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
14189 Ok(())
14190}
14191
14192fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
14193 let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
14194 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
14195 let mut ids = BTreeSet::new();
14196 for row in rows {
14197 ids.insert(row?);
14198 }
14199 delete_ref_ids(tx, &ids)
14200}
14201
14202fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
14203 let mut delete_edges = tx.prepare("DELETE FROM edges WHERE ref_id = ?1")?;
14204 let mut delete_refs = tx.prepare("DELETE FROM refs WHERE ref_id = ?1")?;
14205 for ref_id in ref_ids {
14206 delete_edges.execute(params![ref_id])?;
14207 delete_refs.execute(params![ref_id])?;
14208 }
14209 Ok(())
14210}
14211
14212fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
14213 let mut stmt = conn.prepare(
14214 "SELECT source.file_path, source.scoped_name, edges.target_file,
14215 edges.target_symbol, edges.kind, edges.line
14216 FROM edges
14217 JOIN nodes AS source ON source.id = edges.source_node
14218 ORDER BY source.file_path, source.scoped_name, edges.target_file,
14219 edges.target_symbol, edges.kind, edges.line",
14220 )?;
14221 let rows = stmt.query_map([], |row| {
14222 Ok(StoredEdge {
14223 source_file: row.get(0)?,
14224 source_symbol: row.get(1)?,
14225 target_file: row.get(2)?,
14226 target_symbol: row.get(3)?,
14227 kind: row.get(4)?,
14228 line: row.get::<_, i64>(5)? as u32,
14229 })
14230 })?;
14231 let mut edges = BTreeSet::new();
14232 for row in rows {
14233 edges.insert(row?);
14234 }
14235 Ok(edges)
14236}
14237
14238fn module_target_from_dependencies(
14239 project_root: &Path,
14240 dependencies: &BTreeSet<String>,
14241 facts: &FactPaths<'_>,
14242) -> Option<String> {
14243 dependencies.iter().find_map(|dep| {
14244 let path = project_root.join(dep);
14245 if facts.is_file(&path) {
14246 Some(relative_path(
14247 project_root,
14248 &facts.canonical(&path).unwrap_or(path.clone()),
14249 ))
14250 } else {
14251 None
14252 }
14253 })
14254}
14255
14256fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
14257 let mut named = HashMap::new();
14258 if let Some(full_ref) = &raw_ref.full_ref {
14259 named = parse_reexport_names(full_ref);
14260 }
14261 ReexportIndex {
14262 target_file,
14263 named,
14264 wildcard: raw_ref.wildcard,
14265 }
14266}
14267
14268fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
14269 let mut names = HashMap::new();
14270 let Some(open) = statement.find('{') else {
14271 return names;
14272 };
14273 let Some(close) = statement[open + 1..]
14274 .find('}')
14275 .map(|offset| open + 1 + offset)
14276 else {
14277 return names;
14278 };
14279 for spec in statement[open + 1..close].split(',') {
14280 let spec = spec.trim();
14281 if spec.is_empty() {
14282 continue;
14283 }
14284 if let Some((source, local)) = spec.split_once(" as ") {
14285 names.insert(local.trim().to_string(), source.trim().to_string());
14286 } else {
14287 names.insert(spec.to_string(), spec.to_string());
14288 }
14289 }
14290 names
14291}
14292
14293#[derive(Debug)]
14294struct RefDependencyRow {
14295 ref_id: String,
14296 kind: String,
14297 caller_file: String,
14298 module_path: Option<String>,
14299 target_file: Option<String>,
14300}
14301
14302fn ref_dependency_row_depends_on(
14303 project_root: &Path,
14304 row: &RefDependencyRow,
14305 rel_path: &str,
14306) -> bool {
14307 if row.target_file.as_deref() == Some(rel_path) {
14308 return true;
14309 }
14310
14311 match row.kind.as_str() {
14312 "call" => true,
14313 "import" | "reexport" => row
14314 .module_path
14315 .as_deref()
14316 .map(|module_path| {
14317 module_dependencies_for_ref(project_root, &row.caller_file, module_path)
14318 .contains(rel_path)
14319 })
14320 .unwrap_or(false),
14321 "export_alias" => false,
14322 _ => false,
14323 }
14324}
14325
14326fn module_dependencies_for_ref(
14327 project_root: &Path,
14328 caller_file: &str,
14329 module_path: &str,
14330) -> BTreeSet<String> {
14331 module_dependencies(
14332 project_root,
14333 &project_root.join(caller_file),
14334 module_path,
14335 &FactPaths {
14336 root: project_root,
14337 facts: &DiskFacts::new(project_root),
14338 },
14339 )
14340}
14341
14342fn import_dependencies(
14343 project_root: &Path,
14344 abs_path: &Path,
14345 imports: &[ImportStatement],
14346 facts: &FactPaths<'_>,
14347) -> BTreeSet<String> {
14348 let mut deps = BTreeSet::new();
14349 for import in imports {
14350 deps.extend(module_dependencies(
14351 project_root,
14352 abs_path,
14353 &import.module_path,
14354 facts,
14355 ));
14356 }
14357 deps
14358}
14359
14360fn module_dependencies(
14361 project_root: &Path,
14362 abs_path: &Path,
14363 module_path: &str,
14364 facts: &FactPaths<'_>,
14365) -> BTreeSet<String> {
14366 let mut deps = rust_module_dependencies(project_root, abs_path, module_path, facts);
14367 let caller_dir = abs_path.parent().unwrap_or(project_root);
14368 if let Some(resolved) = callgraph::resolve_module_path_with_memo(
14369 caller_dir,
14370 module_path,
14371 &callgraph::ModuleResolutionMemo::default(),
14372 facts,
14373 ) {
14374 deps.insert(relative_path(project_root, &resolved));
14375 }
14376 if module_path.starts_with('.') {
14377 let base = caller_dir.join(module_path);
14378 for candidate in relative_module_candidates(&base) {
14379 deps.insert(relative_path(project_root, &candidate));
14380 }
14381 }
14382 deps
14383}
14384
14385fn rust_module_dependencies(
14386 project_root: &Path,
14387 abs_path: &Path,
14388 module_path: &str,
14389 facts: &FactPaths<'_>,
14390) -> BTreeSet<String> {
14391 let mut deps = BTreeSet::new();
14392 let rel_path = relative_path(
14393 project_root,
14394 &facts
14395 .canonical(abs_path)
14396 .unwrap_or_else(|| abs_path.to_path_buf()),
14397 );
14398 let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
14399 return deps;
14400 };
14401 let src_prefix = rust_src_prefix(&rel_path);
14402 rust_push_module_dependency_candidate(
14403 project_root,
14404 &mut deps,
14405 &src_prefix,
14406 &path_segments,
14407 facts,
14408 );
14409 if !path_segments.is_empty() {
14410 rust_push_module_dependency_candidate(
14411 project_root,
14412 &mut deps,
14413 &src_prefix,
14414 &path_segments[..path_segments.len() - 1],
14415 facts,
14416 );
14417 }
14418 deps
14419}
14420
14421fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
14422 let path = rust_module_path_without_alias_or_use_list(module_path);
14423 let segments = path
14424 .split("::")
14425 .map(str::trim)
14426 .filter(|segment| !segment.is_empty())
14427 .collect::<Vec<_>>();
14428 if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
14429 return None;
14430 }
14431 rust_resolve_segments(rel_path, &segments)
14432}
14433
14434fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
14435 let path = module_path
14436 .trim()
14437 .trim_end_matches(';')
14438 .split_once(" as ")
14439 .map(|(left, _)| left.trim())
14440 .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
14441 path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
14442}
14443
14444fn rust_push_module_dependency_candidate(
14445 project_root: &Path,
14446 deps: &mut BTreeSet<String>,
14447 src_prefix: &str,
14448 segments: &[String],
14449 facts: &FactPaths<'_>,
14450) {
14451 let candidates = if segments.is_empty() {
14452 vec![
14453 format!("{src_prefix}/lib.rs"),
14454 format!("{src_prefix}/main.rs"),
14455 ]
14456 } else {
14457 vec![
14458 format!("{}/{}.rs", src_prefix, segments.join("/")),
14459 format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
14460 ]
14461 };
14462 for candidate in candidates {
14463 if facts.is_file(&project_root.join(&candidate)) {
14464 deps.insert(candidate);
14465 }
14466 }
14467}
14468
14469fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
14470 let mut candidates = Vec::new();
14471 if base.extension().is_some() {
14472 candidates.push(base.to_path_buf());
14473 return candidates;
14474 }
14475 for ext in JS_TS_EXTENSIONS {
14476 candidates.push(base.with_extension(ext));
14477 }
14478 for ext in JS_TS_EXTENSIONS {
14479 candidates.push(base.join(format!("index.{ext}")));
14480 }
14481 candidates
14482}
14483
14484fn import_local_names(import: &ImportStatement) -> Vec<String> {
14485 let mut names = Vec::new();
14486 if let Some(default) = &import.default_import {
14487 names.push(default.clone());
14488 }
14489 if let Some(namespace) = &import.namespace_import {
14490 names.push(namespace.clone());
14491 }
14492 for name in &import.names {
14493 names.push(crate::imports::specifier_local_name(name).to_string());
14494 }
14495 names
14496}
14497
14498fn import_requested_names(import: &ImportStatement) -> Vec<String> {
14499 import
14500 .names
14501 .iter()
14502 .map(|name| crate::imports::specifier_imported_name(name).to_string())
14503 .collect()
14504}
14505
14506fn import_is_wildcard(import: &ImportStatement) -> bool {
14507 import.namespace_import.is_some() || import.raw_text.contains('*')
14508}
14509
14510fn namespace_alias(full_ref: &str) -> Option<String> {
14511 full_ref
14512 .split_once('.')
14513 .map(|(namespace, _)| namespace.to_string())
14514}
14515
14516fn import_kind_label(kind: ImportKind) -> &'static str {
14517 match kind {
14518 ImportKind::Value => "value",
14519 ImportKind::Type => "type",
14520 ImportKind::SideEffect => "side_effect",
14521 }
14522}
14523
14524fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
14525 match kind {
14526 SymbolKind::Function => "function",
14527 SymbolKind::Kernel => "kernel",
14528 SymbolKind::Class => "class",
14529 SymbolKind::Method => "method",
14530 SymbolKind::Struct => "struct",
14531 SymbolKind::Interface => "interface",
14532 SymbolKind::Enum => "enum",
14533 SymbolKind::TypeAlias => "type_alias",
14534 SymbolKind::Variable => "variable",
14535 SymbolKind::Heading => "heading",
14536 SymbolKind::FileSummary => "file_summary",
14537 }
14538}
14539
14540fn is_type_like(kind: &SymbolKind) -> bool {
14541 matches!(
14542 kind,
14543 SymbolKind::Class
14544 | SymbolKind::Struct
14545 | SymbolKind::Interface
14546 | SymbolKind::Enum
14547 | SymbolKind::TypeAlias
14548 )
14549}
14550
14551fn lang_label(lang: LangId) -> &'static str {
14552 match lang {
14553 LangId::TypeScript => "typescript",
14554 LangId::Tsx => "tsx",
14555 LangId::JavaScript => "javascript",
14556 LangId::Python => "python",
14557 LangId::Rust => "rust",
14558 LangId::Go => "go",
14559 LangId::C => "c",
14560 LangId::Cpp => "cpp",
14561 LangId::Cuda => "cuda",
14562 LangId::Metal => "metal",
14563 LangId::Zig => "zig",
14564 LangId::CSharp => "csharp",
14565 LangId::Bash => "bash",
14566 LangId::Html => "html",
14567 LangId::Markdown => "markdown",
14568 LangId::Solidity => "solidity",
14569 LangId::Scss => "scss",
14570 LangId::Vue => "vue",
14571 LangId::Json => "json",
14572 LangId::Scala => "scala",
14573 LangId::Java => "java",
14574 LangId::Ruby => "ruby",
14575 LangId::Kotlin => "kotlin",
14576 LangId::Swift => "swift",
14577 LangId::Php => "php",
14578 LangId::Lua => "lua",
14579 LangId::Perl => "perl",
14580 LangId::Yaml => "yaml",
14581 LangId::Pascal => "pascal",
14582 LangId::R => "r",
14583 LangId::Groovy => "groovy",
14584 LangId::ObjC => "objc",
14585 LangId::Toml => "toml",
14586 }
14587}
14588
14589fn lang_from_label(label: &str) -> Option<LangId> {
14590 match label {
14591 "typescript" => Some(LangId::TypeScript),
14592 "tsx" => Some(LangId::Tsx),
14593 "javascript" => Some(LangId::JavaScript),
14594 "python" => Some(LangId::Python),
14595 "rust" => Some(LangId::Rust),
14596 "go" => Some(LangId::Go),
14597 "c" => Some(LangId::C),
14598 "cpp" => Some(LangId::Cpp),
14599 "cuda" => Some(LangId::Cuda),
14600 "metal" => Some(LangId::Metal),
14601 "zig" => Some(LangId::Zig),
14602 "csharp" => Some(LangId::CSharp),
14603 "bash" => Some(LangId::Bash),
14604 "html" => Some(LangId::Html),
14605 "markdown" => Some(LangId::Markdown),
14606 "solidity" => Some(LangId::Solidity),
14607 "scss" => Some(LangId::Scss),
14608 "vue" => Some(LangId::Vue),
14609 "json" => Some(LangId::Json),
14610 "scala" => Some(LangId::Scala),
14611 "java" => Some(LangId::Java),
14612 "ruby" => Some(LangId::Ruby),
14613 "kotlin" => Some(LangId::Kotlin),
14614 "swift" => Some(LangId::Swift),
14615 "php" => Some(LangId::Php),
14616 "lua" => Some(LangId::Lua),
14617 "perl" => Some(LangId::Perl),
14618 "yaml" => Some(LangId::Yaml),
14619 "pascal" => Some(LangId::Pascal),
14620 "r" => Some(LangId::R),
14621 "groovy" => Some(LangId::Groovy),
14622 "objc" => Some(LangId::ObjC),
14623 "toml" => Some(LangId::Toml),
14624 _ => None,
14625 }
14626}
14627
14628fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
14629 let mut normalized = if files.is_empty() {
14630 callgraph::walk_project_files(project_root).collect::<Vec<_>>()
14631 } else {
14632 files
14633 .iter()
14634 .map(|path| normalize_file_path(project_root, path))
14635 .collect::<Result<Vec<_>>>()?
14636 };
14637 normalized.sort();
14638 normalized.dedup();
14639 Ok(normalized)
14640}
14641
14642fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
14643 let full_path = if path.is_relative() {
14644 project_root.join(path)
14645 } else {
14646 path.to_path_buf()
14647 };
14648 Ok(canonicalize_path(&full_path))
14649}
14650
14651fn normalize_project_file_path(project_root: &Path, path: &Path) -> Result<(PathBuf, String)> {
14655 let abs_path = normalize_file_path(project_root, path)?;
14656 let rel_path = relative_path(project_root, &abs_path);
14657 if Path::new(&rel_path).is_absolute() {
14658 return Err(CallGraphStoreError::PathIdentityMismatch {
14659 path: path.to_path_buf(),
14660 project_root: project_root.to_path_buf(),
14661 });
14662 }
14663 Ok((abs_path, rel_path))
14664}
14665
14666fn canonicalize_path(path: &Path) -> PathBuf {
14670 if let Ok(canonical) = std::fs::canonicalize(path) {
14671 return canonical;
14672 }
14673
14674 let mut resolved = PathBuf::new();
14675 let mut missing = Vec::new();
14676 for component in path.components() {
14677 match component {
14678 std::path::Component::Prefix(_) | std::path::Component::RootDir => {
14679 resolved.push(component.as_os_str());
14680 if let Ok(canonical) = std::fs::canonicalize(&resolved) {
14681 resolved = canonical;
14682 }
14683 }
14684 std::path::Component::CurDir => {}
14685 std::path::Component::ParentDir => {
14686 if missing.pop().is_none() {
14687 if !resolved.as_os_str().is_empty() && !resolved.is_dir() {
14688 return path.to_path_buf();
14689 }
14690 resolved.pop();
14691 }
14692 }
14693 std::path::Component::Normal(name) => {
14694 if missing.is_empty() {
14695 let candidate = resolved.join(name);
14696 match std::fs::canonicalize(&candidate) {
14697 Ok(canonical) => resolved = canonical,
14698 Err(_) => match std::fs::symlink_metadata(&candidate) {
14699 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
14700 missing.push(name.to_owned());
14701 }
14702 _ => return path.to_path_buf(),
14703 },
14704 }
14705 } else {
14706 missing.push(name.to_owned());
14707 }
14708 }
14709 }
14710 }
14711 resolved.extend(missing);
14712 resolved
14713}
14714
14715fn relative_path(project_root: &Path, path: &Path) -> String {
14716 if let Ok(stripped) = path.strip_prefix(project_root) {
14717 return stripped.to_string_lossy().replace('\\', "/");
14718 }
14719 let canon_root = canonicalize_path(project_root);
14720 let canon_path = canonicalize_path(path);
14721 if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
14722 return stripped.to_string_lossy().replace('\\', "/");
14723 }
14724 canon_path.to_string_lossy().replace('\\', "/")
14725}
14726
14727fn unqualified_name(scoped: &str) -> &str {
14728 if scoped == TOP_LEVEL_SYMBOL {
14729 return scoped;
14730 }
14731 scoped
14732 .rsplit("::")
14733 .next()
14734 .unwrap_or(scoped)
14735 .rsplit('.')
14736 .next()
14737 .unwrap_or(scoped)
14738 .rsplit('#')
14739 .next()
14740 .unwrap_or(scoped)
14741}
14742
14743fn ref_id(parts: &[&str]) -> String {
14744 let joined = parts.join("\0");
14745 hash_to_hex(blake3::hash(joined.as_bytes()))
14746}
14747
14748fn callgraph_corpus_fingerprint(project_root: &Path) -> Result<String> {
14749 let mut fingerprint = CorpusFingerprint::default();
14750 for path in callgraph::walk_project_files(project_root) {
14751 fingerprint.add_path(project_root, &path);
14752 }
14753 Ok(fingerprint.finish(project_root))
14754}
14755
14756fn corpus_fingerprint_for(project_root: &Path, files: &[PathBuf]) -> Result<String> {
14760 if files.is_empty() {
14761 return callgraph_corpus_fingerprint(project_root);
14762 }
14763 let mut fingerprint = CorpusFingerprint::default();
14764 for path in files {
14765 fingerprint.add_path(project_root, path);
14766 }
14767 Ok(fingerprint.finish(project_root))
14768}
14769
14770#[derive(Default)]
14771struct CorpusFingerprint {
14772 xor: [u8; 32],
14773 sums: [u64; 4],
14774 files: u64,
14775}
14776
14777impl CorpusFingerprint {
14778 fn add_path(&mut self, project_root: &Path, path: &Path) {
14779 let mut record = blake3::Hasher::new();
14780 record.update(relative_path(project_root, path).as_bytes());
14781 record.update(&[0]);
14782 match hash_file_bounded(path) {
14783 Ok(content_hash) => record.update(content_hash.as_bytes()),
14784 Err(error) => record.update(format!("missing:{error}").as_bytes()),
14787 };
14788 record.update(&[0]);
14789 let record = record.finalize();
14790 for (index, byte) in record.as_bytes().iter().copied().enumerate() {
14791 self.xor[index] ^= byte;
14792 }
14793 for (index, chunk) in record.as_bytes().chunks_exact(8).enumerate() {
14794 let value = u64::from_le_bytes(chunk.try_into().expect("eight-byte digest chunk"));
14795 self.sums[index] = self.sums[index].wrapping_add(value);
14796 }
14797 self.files = self.files.saturating_add(1);
14798 }
14799
14800 fn finish(self, project_root: &Path) -> String {
14801 let mut hasher = blake3::Hasher::new();
14804 hasher.update(b"callgraph-corpus-fingerprint-v2\0");
14805 hasher.update(&self.files.to_le_bytes());
14806 hasher.update(&self.xor);
14807 for sum in self.sums {
14808 hasher.update(&sum.to_le_bytes());
14809 }
14810 let ignore_rules = project_root.join(".gitignore");
14811 if let Ok(contents) = std::fs::read(ignore_rules) {
14812 hasher.update(b".gitignore\0");
14813 hasher.update(blake3::hash(&contents).as_bytes());
14814 }
14815 hash_to_hex(hasher.finalize())
14816 }
14817}
14818
14819fn hash_file_bounded(path: &Path) -> std::io::Result<blake3::Hash> {
14820 let mut file = std::fs::File::open(path)?;
14821 let mut hasher = blake3::Hasher::new();
14822 let mut buffer = [0u8; 64 * 1024];
14823 loop {
14824 let read = file.read(&mut buffer)?;
14825 if read == 0 {
14826 break;
14827 }
14828 hasher.update(&buffer[..read]);
14829 }
14830 Ok(hasher.finalize())
14831}
14832
14833#[cfg(test)]
14834pub(crate) fn callgraph_corpus_fingerprint_for_test(
14835 project_root: &Path,
14836 _files: &[PathBuf],
14837) -> Result<String> {
14838 callgraph_corpus_fingerprint(project_root)
14842}
14843
14844fn hash_to_hex(hash: blake3::Hash) -> String {
14845 hash.to_hex().to_string()
14846}
14847
14848fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
14849 let bytes = hex_to_bytes(value)?;
14850 Some(blake3::Hash::from_bytes(bytes))
14851}
14852
14853fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
14854 if value.len() != 64 {
14855 return None;
14856 }
14857 let mut bytes = [0u8; 32];
14858 for (index, slot) in bytes.iter_mut().enumerate() {
14859 let start = index * 2;
14860 let end = start + 2;
14861 *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
14862 }
14863 Some(bytes)
14864}
14865
14866#[derive(Debug, Clone)]
14867struct LineIndex {
14868 newline_offsets: Vec<usize>,
14869 source_len: usize,
14870}
14871
14872impl LineIndex {
14873 fn new(source: &str) -> Self {
14874 Self {
14875 newline_offsets: source
14876 .bytes()
14877 .enumerate()
14878 .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
14879 .collect(),
14880 source_len: source.len(),
14881 }
14882 }
14883
14884 fn byte_to_line(&self, byte_offset: usize) -> u32 {
14885 let byte_offset = byte_offset.min(self.source_len);
14886 self.newline_offsets
14887 .partition_point(|offset| *offset < byte_offset) as u32
14888 + 1
14889 }
14890}
14891
14892fn empty_to_none(value: String) -> Option<String> {
14893 if value.is_empty() {
14894 None
14895 } else {
14896 Some(value)
14897 }
14898}
14899
14900fn bool_int(value: bool) -> i64 {
14901 if value {
14902 1
14903 } else {
14904 0
14905 }
14906}
14907
14908fn system_time_to_ns(time: SystemTime) -> i64 {
14909 time.duration_since(UNIX_EPOCH)
14910 .unwrap_or_default()
14911 .as_nanos()
14912 .min(i64::MAX as u128) as i64
14913}
14914
14915fn ns_to_system_time(value: i64) -> SystemTime {
14916 UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
14917}
14918
14919pub(crate) fn unix_millis_now() -> u64 {
14920 SystemTime::now()
14921 .duration_since(UNIX_EPOCH)
14922 .unwrap_or_default()
14923 .as_millis()
14924 .min(u128::from(u64::MAX)) as u64
14925}
14926
14927fn unix_seconds_now() -> i64 {
14928 SystemTime::now()
14929 .duration_since(UNIX_EPOCH)
14930 .unwrap_or_default()
14931 .as_secs() as i64
14932}
14933
14934#[cfg(test)]
14939pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
14940
14941#[cfg(test)]
14942mod refresh_worker_tests {
14943 use super::*;
14944 use std::fs;
14945 use tempfile::tempdir;
14946
14947 fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
14948 let temp = tempdir().unwrap();
14949 let root = temp.path().join("root");
14950 fs::create_dir_all(&root).unwrap();
14951 let artifact_key = crate::search_index::artifact_cache_key(&root);
14952 crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
14953 let callgraph_dir = temp
14954 .path()
14955 .join("storage")
14956 .join("callgraph")
14957 .join(artifact_key);
14958 let source = root.join("main.rs");
14959 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
14960 let (store, _) = CallGraphStore::cold_build_with_lease(
14961 callgraph_dir.clone(),
14962 root.clone(),
14963 std::slice::from_ref(&source),
14964 )
14965 .unwrap();
14966 drop(store);
14967 (temp, root, callgraph_dir, source)
14968 }
14969
14970 fn pending_paths() -> PendingCallGraphStorePaths {
14971 Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
14972 }
14973
14974 fn wait_for_refresh_calls(root: &Path, expected: usize) {
14975 let deadline = Instant::now() + Duration::from_secs(12);
14976 while callgraph_refresh_worker_test_counts(root).0 < expected {
14977 assert!(
14978 Instant::now() < deadline,
14979 "timed out waiting for {expected} callgraph refresh worker call(s)"
14980 );
14981 std::thread::sleep(Duration::from_millis(5));
14982 }
14983 }
14984
14985 fn wait_for_refresh_worker_idle() {
14986 let deadline = Instant::now() + Duration::from_secs(12);
14987 loop {
14988 let worker = CALLGRAPH_REFRESH_WORKER
14989 .get_or_init(|| Mutex::new(None))
14990 .lock()
14991 .expect("callgraph refresh worker mutex poisoned")
14992 .clone();
14993 let idle = worker.is_none_or(|worker| {
14994 let queue = worker
14995 .shared
14996 .queue
14997 .lock()
14998 .expect("callgraph refresh queue mutex poisoned");
14999 queue.active.is_none() && queue.order.is_empty()
15000 });
15001 if idle {
15002 return;
15003 }
15004 assert!(
15005 Instant::now() < deadline,
15006 "timed out waiting for callgraph refresh worker to become idle"
15007 );
15008 std::thread::sleep(Duration::from_millis(5));
15009 }
15010 }
15011
15012 fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
15013 let temp = tempdir().unwrap();
15014 let root = temp.path().join("workspace");
15015 fs::create_dir_all(root.join("app/src")).unwrap();
15016 let artifact_key = crate::search_index::artifact_cache_key(&root);
15017 crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
15018 let callgraph_dir = temp
15019 .path()
15020 .join("storage")
15021 .join("callgraph")
15022 .join(artifact_key);
15023 fs::write(
15024 root.join("Cargo.toml"),
15025 "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
15026 )
15027 .unwrap();
15028 fs::write(
15029 root.join("app/Cargo.toml"),
15030 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
15031 )
15032 .unwrap();
15033 let caller = root.join("app/src/lib.rs");
15034 fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
15035 let (store, _) = CallGraphStore::cold_build_with_lease(
15036 callgraph_dir.clone(),
15037 root.clone(),
15038 std::slice::from_ref(&caller),
15039 )
15040 .unwrap();
15041 drop(store);
15042 (temp, root, callgraph_dir, caller)
15043 }
15044
15045 #[test]
15046 fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
15047 let _guard = REFRESH_WORKER_TEST_LOCK
15048 .lock()
15049 .unwrap_or_else(std::sync::PoisonError::into_inner);
15050 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15051 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
15052 reset_workspace_crate_prefix_build_count(&root);
15053 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15054
15055 for revision in ["first", "second"] {
15056 fs::write(
15057 &caller,
15058 format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
15059 )
15060 .unwrap();
15061 enqueue_callgraph_store_refresh(
15062 callgraph_dir.clone(),
15063 root.clone(),
15064 vec![caller.clone()],
15065 pending_paths(),
15066 );
15067 wait_for_refresh_worker_idle();
15068 }
15069
15070 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
15071 assert!(flush_callgraph_store_refreshes_with_budget(
15072 Duration::from_secs(5)
15073 ));
15074 clear_callgraph_refresh_worker_test_seam(&root);
15075 }
15076
15077 #[test]
15078 fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
15079 let _guard = REFRESH_WORKER_TEST_LOCK
15080 .lock()
15081 .unwrap_or_else(std::sync::PoisonError::into_inner);
15082 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15083 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
15084 reset_workspace_crate_prefix_build_count(&root);
15085 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15086
15087 fs::write(
15088 &caller,
15089 "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
15090 )
15091 .unwrap();
15092 enqueue_callgraph_store_refresh(
15093 callgraph_dir.clone(),
15094 root.clone(),
15095 vec![caller.clone()],
15096 pending_paths(),
15097 );
15098 wait_for_refresh_worker_idle();
15099 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
15100
15101 let added_manifest = root.join("added/Cargo.toml");
15102 let added_source = root.join("added/src/lib.rs");
15103 fs::create_dir_all(added_source.parent().unwrap()).unwrap();
15104 fs::write(
15105 root.join("Cargo.toml"),
15106 "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
15107 )
15108 .unwrap();
15109 fs::write(
15110 &added_manifest,
15111 "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
15112 )
15113 .unwrap();
15114 fs::write(&added_source, "pub fn target() {}\n").unwrap();
15115 fs::write(
15116 &caller,
15117 "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
15118 )
15119 .unwrap();
15120
15121 enqueue_callgraph_store_refresh(
15122 callgraph_dir.clone(),
15123 root.clone(),
15124 vec![
15125 root.join("Cargo.toml"),
15126 added_manifest,
15127 added_source,
15128 caller,
15129 ],
15130 pending_paths(),
15131 );
15132 assert!(flush_callgraph_store_refreshes_with_budget(
15133 Duration::from_secs(12)
15134 ));
15135
15136 assert_eq!(workspace_crate_prefix_build_count(&root), 2);
15140 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
15141 .unwrap()
15142 .expect("refreshed workspace store");
15143 let tree = store
15144 .call_tree(Path::new("app/src/lib.rs"), "run", 1)
15145 .unwrap();
15146 assert_eq!(tree.children.len(), 1);
15147 assert_eq!(tree.children[0].file, "added/src/lib.rs");
15148 assert_eq!(tree.children[0].name, "target");
15149 assert!(tree.children[0].resolved);
15150 clear_callgraph_refresh_worker_test_seam(&root);
15151 }
15152
15153 fn linked_worktree_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, PathBuf) {
15154 let temp = tempdir().unwrap();
15155 let main = temp.path().join("main");
15156 let worktree = temp.path().join("worktree");
15157 fs::create_dir_all(&main).unwrap();
15158 let mut git = std::process::Command::new("git");
15159 assert!(
15160 crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
15161 .status()
15162 .unwrap()
15163 .success()
15164 );
15165 fs::write(main.join("lib.rs"), "pub fn marker() {}\n").unwrap();
15166 for args in [
15167 vec![
15168 "-C",
15169 main.to_str().unwrap(),
15170 "config",
15171 "user.email",
15172 "test@example.com",
15173 ],
15174 vec![
15175 "-C",
15176 main.to_str().unwrap(),
15177 "config",
15178 "user.name",
15179 "AFT Test",
15180 ],
15181 vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
15182 vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
15183 ] {
15184 let mut command = std::process::Command::new("git");
15185 assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
15186 .status()
15187 .unwrap()
15188 .success());
15189 }
15190 let mut add_worktree = std::process::Command::new("git");
15191 assert!(crate::test_env::apply_hermetic_git_env(
15192 add_worktree
15193 .arg("-C")
15194 .arg(&main)
15195 .args(["worktree", "add", "--detach"])
15196 .arg(&worktree),
15197 )
15198 .status()
15199 .unwrap()
15200 .success());
15201 let main = fs::canonicalize(main).unwrap();
15202 let worktree = fs::canonicalize(worktree).unwrap();
15203 let project_key = crate::search_index::artifact_cache_key(&main);
15204 assert_eq!(
15205 crate::search_index::artifact_cache_key(&worktree),
15206 project_key
15207 );
15208 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
15209 (temp, main, worktree, project_key, callgraph_dir)
15210 }
15211
15212 #[test]
15213 fn linked_worktree_never_acquires_writer_or_publishes_any_build_path() {
15214 let _git_env = crate::test_env::hermetic_git_env_guard();
15215 let (_temp, _main, root, project_key, callgraph_dir) = linked_worktree_fixture();
15216 crate::root_cache::configure_artifact_access(&root, &project_key, true);
15217 crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
15218 let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15219 let publications_for_observer = Arc::clone(&publications);
15220 set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
15221 publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
15222 })));
15223 let source = root.join("lib.rs");
15224
15225 let open_error = CallGraphStore::open(callgraph_dir.clone(), root.clone())
15226 .expect_err("borrow-only writable open must remain unavailable");
15227 assert!(matches!(open_error, CallGraphStoreError::Unavailable(_)));
15228 assert!(
15229 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), root.clone())
15230 .unwrap()
15231 .is_none()
15232 );
15233 assert!(
15234 CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), root.clone())
15235 .unwrap()
15236 .is_none()
15237 );
15238 assert!(matches!(
15239 CallGraphStore::cold_build_with_lease(
15240 callgraph_dir.clone(),
15241 root.clone(),
15242 std::slice::from_ref(&source),
15243 ),
15244 Err(CallGraphStoreError::Unavailable(_))
15245 ));
15246 assert!(matches!(
15247 CallGraphStore::ensure_built_with_lease(
15248 callgraph_dir.clone(),
15249 root.clone(),
15250 std::slice::from_ref(&source),
15251 ),
15252 Err(CallGraphStoreError::Unavailable(_))
15253 ));
15254 let force_error = CallGraphStore::force_cold_build_with_lease_chunked(
15255 callgraph_dir.clone(),
15256 root.clone(),
15257 &[source],
15258 1,
15259 )
15260 .expect_err("borrow-only forced rebuild must remain unsatisfied");
15261 set_cold_build_swap_observer(None);
15262
15263 assert!(matches!(force_error, CallGraphStoreError::Unavailable(_)));
15264 assert_eq!(
15265 crate::root_cache::writer_lease_acquisition_count_for_test(
15266 crate::root_cache::RootCacheDomain::Callgraph,
15267 &project_key,
15268 &root,
15269 ),
15270 0
15271 );
15272 assert_eq!(publications.load(AtomicOrdering::SeqCst), 0);
15273 assert!(!pointer_path(&callgraph_dir, &project_key).exists());
15274 }
15275
15276 #[test]
15277 fn owner_and_linked_worktree_alternation_rebuilds_storm_generation_once() {
15278 let _git_env = crate::test_env::hermetic_git_env_guard();
15279 let (_temp, owner, worktree, project_key, callgraph_dir) = linked_worktree_fixture();
15280 crate::root_cache::configure_artifact_access(&owner, &project_key, false);
15281 crate::root_cache::configure_artifact_access(&worktree, &project_key, true);
15282 let source = owner.join("lib.rs");
15283 let (store, _) = CallGraphStore::cold_build_with_lease(
15284 callgraph_dir.clone(),
15285 owner.clone(),
15286 std::slice::from_ref(&source),
15287 )
15288 .unwrap();
15289 let sqlite_path = store.sqlite_path().to_path_buf();
15290 drop(store);
15291
15292 let conn = Connection::open(&sqlite_path).unwrap();
15293 conn.execute(
15294 "UPDATE backend_file_state SET workspace_root = ?1",
15295 [worktree.display().to_string()],
15296 )
15297 .unwrap();
15298 drop(conn);
15299
15300 let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15301 let publications_for_observer = Arc::clone(&publications);
15302 set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
15303 publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
15304 })));
15305 crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
15306
15307 let repaired = CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
15308 .unwrap()
15309 .expect("owner should purge the storm-era worktree root");
15310 drop(repaired);
15311 for _ in 0..3 {
15312 let borrower = CallGraphStore::open_readonly(callgraph_dir.clone(), worktree.clone())
15313 .unwrap()
15314 .expect("linked worktree should borrow the owner generation");
15315 drop(borrower);
15316 assert!(
15317 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), worktree.clone())
15318 .unwrap()
15319 .is_none()
15320 );
15321 let owner_store =
15322 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
15323 .unwrap()
15324 .expect("owner generation should remain ready");
15325 drop(owner_store);
15326 }
15327 set_cold_build_swap_observer(None);
15328
15329 assert_eq!(
15330 publications.load(AtomicOrdering::SeqCst),
15331 1,
15332 "the owner performs one expected post-storm purge and alternation stays read-only"
15333 );
15334 assert_eq!(
15335 crate::root_cache::writer_lease_acquisition_count_for_test(
15336 crate::root_cache::RootCacheDomain::Callgraph,
15337 &project_key,
15338 &worktree,
15339 ),
15340 0
15341 );
15342 }
15343
15344 #[test]
15345 fn rebuild_cooldown_records_only_successful_publication_per_cache_key() {
15346 let temp = tempdir().unwrap();
15347 let root = temp.path().join("owner");
15348 let other_root = temp.path().join("other");
15349 fs::create_dir_all(&root).unwrap();
15350 fs::create_dir_all(&other_root).unwrap();
15351 let source = root.join("lib.rs");
15352 fs::write(&source, "pub fn marker() {}\n").unwrap();
15353 let project_key = crate::search_index::artifact_cache_key(&root);
15354 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
15355 crate::root_cache::configure_artifact_access(&root, &project_key, false);
15356 let cooldown_key = rebuild_cooldown_key(&callgraph_dir, &project_key);
15357 rebuild_cooldown_records()
15358 .lock()
15359 .unwrap_or_else(std::sync::PoisonError::into_inner)
15360 .remove(&cooldown_key);
15361 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
15362 let stale_epoch = epoch.current();
15363 epoch.next();
15364
15365 let failed = with_publish_epoch(epoch, stale_epoch, || {
15366 CallGraphStore::cold_build_with_lease(
15367 callgraph_dir.clone(),
15368 root.clone(),
15369 std::slice::from_ref(&source),
15370 )
15371 });
15372 assert!(matches!(failed, Err(CallGraphStoreError::Superseded)));
15373 assert!(
15374 rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
15375 .is_none()
15376 );
15377
15378 let (store, _) = CallGraphStore::cold_build_with_lease(
15379 callgraph_dir.clone(),
15380 root.clone(),
15381 std::slice::from_ref(&source),
15382 )
15383 .unwrap();
15384 drop(store);
15385 assert!(
15386 rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
15387 .is_none()
15388 );
15389
15390 record_successful_rebuild(&callgraph_dir, &project_key, &other_root, Instant::now());
15391 assert!(
15392 rebuild_cooldown_denial(&callgraph_dir, &project_key, &root, Instant::now(),).is_some()
15393 );
15394 }
15395
15396 #[test]
15397 fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
15398 let _guard = REFRESH_WORKER_TEST_LOCK
15399 .lock()
15400 .unwrap_or_else(std::sync::PoisonError::into_inner);
15401 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15402 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15403 let pending = pending_paths();
15404 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15405
15406 let lifecycle = SubcLifecycleAdmission::default();
15407 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
15408 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
15409 let ticket = CallgraphRefreshTicket::new(
15410 lifecycle,
15411 Arc::clone(&generation),
15412 7,
15413 publish_epoch.clone(),
15414 publish_epoch.current(),
15415 );
15416 generation.store(8, std::sync::atomic::Ordering::SeqCst);
15418 let installed = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
15419 .unwrap()
15420 .expect("ready store snapshot");
15421 let refresh_state = CallgraphRefreshState::new(
15422 Arc::new(std::sync::RwLock::new(Some(Arc::new(installed)))),
15423 Arc::new(AtomicBool::new(true)),
15424 );
15425
15426 enqueue_callgraph_store_refresh_fenced_with_state(
15427 callgraph_dir,
15428 root.clone(),
15429 vec![source.clone()],
15430 Arc::clone(&pending),
15431 refresh_state,
15432 ticket,
15433 );
15434 assert!(flush_callgraph_store_refreshes_with_budget(
15435 Duration::from_secs(5)
15436 ));
15437 assert_eq!(
15438 callgraph_refresh_worker_test_counts(&root).0,
15439 0,
15440 "superseded batch must not reach refresh_files or self-replay"
15441 );
15442 assert!(
15443 pending.lock().contains(&source),
15444 "superseded batch must defer its paths to the pending sink"
15445 );
15446 clear_callgraph_refresh_worker_test_seam(&root);
15447 }
15448
15449 #[test]
15450 fn superseded_open_failure_defers_without_self_replay() {
15451 let _guard = REFRESH_WORKER_TEST_LOCK
15452 .lock()
15453 .unwrap_or_else(std::sync::PoisonError::into_inner);
15454 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15455 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15456 let pending = pending_paths();
15457 let installed = Arc::new(
15458 CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
15459 .unwrap()
15460 .expect("ready store snapshot"),
15461 );
15462 let refresh_state = CallgraphRefreshState::new(
15463 Arc::new(std::sync::RwLock::new(Some(Arc::clone(&installed)))),
15464 Arc::new(AtomicBool::new(true)),
15465 );
15466 assert!(!installed.is_legacy_fallback());
15467 assert!(installed.is_current());
15468 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
15469 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15470 set_callgraph_refresh_worker_test_open_failure(root.clone(), true);
15471 let (held_rx, release_tx) = install_callgraph_refresh_worker_test_gate(root.clone());
15472
15473 let lifecycle = SubcLifecycleAdmission::default();
15474 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
15475 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
15476 let ticket = CallgraphRefreshTicket::new(
15477 lifecycle,
15478 Arc::clone(&generation),
15479 7,
15480 publish_epoch.clone(),
15481 publish_epoch.current(),
15482 );
15483 enqueue_callgraph_store_refresh_fenced_with_state(
15484 callgraph_dir,
15485 root.clone(),
15486 vec![source.clone()],
15487 Arc::clone(&pending),
15488 refresh_state,
15489 ticket,
15490 );
15491 held_rx
15492 .recv_timeout(Duration::from_secs(12))
15493 .expect("refresh worker must hold after injected open failure");
15494
15495 generation.store(8, std::sync::atomic::Ordering::SeqCst);
15498 set_callgraph_refresh_worker_test_open_failure(root.clone(), false);
15499 release_tx
15500 .send(())
15501 .expect("release superseded refresh worker");
15502 wait_for_refresh_worker_idle();
15503
15504 assert_eq!(
15505 callgraph_refresh_worker_test_counts(&root).0,
15506 1,
15507 "superseded open-failure batch must not self-replay"
15508 );
15509 assert_eq!(
15510 callgraph_refresh_worker_test_worker_calls(&root),
15511 1,
15512 "superseded open-failure batch must not create another worker call"
15513 );
15514 assert!(
15515 pending.lock().contains(&source),
15516 "superseded open-failure paths must remain in the pending sink"
15517 );
15518 let tree = installed
15519 .call_tree(Path::new("main.rs"), "entry", 1)
15520 .unwrap();
15521 assert_eq!(
15522 tree.children[0].name, "old_leaf",
15523 "superseded open-failure batch must not converge the store"
15524 );
15525 clear_callgraph_refresh_worker_test_seam(&root);
15526 }
15527
15528 #[test]
15529 fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
15530 let _guard = REFRESH_WORKER_TEST_LOCK
15531 .lock()
15532 .unwrap_or_else(std::sync::PoisonError::into_inner);
15533 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15534 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15535 let pending = pending_paths();
15536 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15537
15538 let lifecycle = SubcLifecycleAdmission::default();
15539 let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
15540 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
15541 let expected_epoch = publish_epoch.current();
15542 let ticket = CallgraphRefreshTicket::new(
15543 lifecycle,
15544 generation,
15545 3,
15546 publish_epoch.clone(),
15547 expected_epoch,
15548 );
15549 publish_epoch.next();
15551
15552 enqueue_callgraph_store_refresh_fenced(
15553 callgraph_dir,
15554 root.clone(),
15555 vec![source.clone()],
15556 Arc::clone(&pending),
15557 ticket,
15558 );
15559 assert!(flush_callgraph_store_refreshes_with_budget(
15560 Duration::from_secs(5)
15561 ));
15562 assert_eq!(
15563 callgraph_refresh_worker_test_counts(&root).0,
15564 0,
15565 "epoch-superseded batch must not reach refresh_files"
15566 );
15567 assert!(
15568 pending.lock().contains(&source),
15569 "epoch-superseded batch must defer its paths to the pending sink"
15570 );
15571 clear_callgraph_refresh_worker_test_seam(&root);
15572 }
15573
15574 #[test]
15575 fn fenced_refresh_with_current_ticket_commits_normally() {
15576 let _guard = REFRESH_WORKER_TEST_LOCK
15577 .lock()
15578 .unwrap_or_else(std::sync::PoisonError::into_inner);
15579 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15580 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15581 let pending = pending_paths();
15582 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15583
15584 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
15585
15586 let lifecycle = SubcLifecycleAdmission::default();
15587 let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
15588 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
15589 let ticket = CallgraphRefreshTicket::new(
15590 lifecycle,
15591 generation,
15592 5,
15593 publish_epoch.clone(),
15594 publish_epoch.current(),
15595 );
15596
15597 enqueue_callgraph_store_refresh_fenced(
15598 callgraph_dir.clone(),
15599 root.clone(),
15600 vec![source.clone()],
15601 Arc::clone(&pending),
15602 ticket,
15603 );
15604 assert!(flush_callgraph_store_refreshes_with_budget(
15605 Duration::from_secs(5)
15606 ));
15607 assert_eq!(
15608 callgraph_refresh_worker_test_counts(&root).0,
15609 1,
15610 "current ticket must run the refresh"
15611 );
15612 assert!(
15613 pending.lock().is_empty(),
15614 "committed batch must not defer paths"
15615 );
15616
15617 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
15618 .unwrap()
15619 .expect("published generation must remain readable");
15620 let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
15621 assert_eq!(
15622 tree.children[0].name, "new_leaf",
15623 "fenced commit must actually persist the refreshed content"
15624 );
15625 clear_callgraph_refresh_worker_test_seam(&root);
15626 }
15627
15628 #[test]
15629 fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
15630 let _guard = REFRESH_WORKER_TEST_LOCK
15631 .lock()
15632 .unwrap_or_else(std::sync::PoisonError::into_inner);
15633 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15638 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15639 let pending = pending_paths();
15640 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
15641
15642 enqueue_callgraph_store_refresh(
15643 callgraph_dir.clone(),
15644 root.clone(),
15645 vec![source.clone()],
15646 Arc::clone(&pending),
15647 );
15648 wait_for_refresh_calls(&root, 1);
15649 for _ in 0..3 {
15650 enqueue_callgraph_store_refresh(
15651 callgraph_dir.clone(),
15652 root.clone(),
15653 vec![source.clone()],
15654 Arc::clone(&pending),
15655 );
15656 }
15657
15658 assert!(flush_callgraph_store_refreshes_with_budget(
15659 Duration::from_secs(2)
15660 ));
15661 assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
15662 assert!(pending.lock().is_empty());
15663 clear_callgraph_refresh_worker_test_seam(&root);
15664 }
15665
15666 #[test]
15667 fn queued_refresh_opens_generation_published_after_enqueue() {
15668 let _guard = REFRESH_WORKER_TEST_LOCK
15669 .lock()
15670 .unwrap_or_else(std::sync::PoisonError::into_inner);
15671 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15676 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
15677 let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
15678 set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
15679 let (active_held_rx, active_release_tx) =
15680 install_callgraph_refresh_worker_test_gate(active_root.clone());
15681 set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
15682 enqueue_callgraph_store_refresh(
15683 active_dir,
15684 active_root.clone(),
15685 vec![active_source],
15686 pending_paths(),
15687 );
15688 active_held_rx
15689 .recv_timeout(Duration::from_secs(12))
15690 .expect("active refresh worker holds the queue");
15691
15692 fs::write(
15693 &target_source,
15694 "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
15695 )
15696 .unwrap();
15697 enqueue_callgraph_store_refresh(
15698 target_dir.clone(),
15699 target_root.clone(),
15700 vec![target_source.clone()],
15701 pending_paths(),
15702 );
15703 let (new_generation, _) = CallGraphStore::cold_build_with_lease(
15704 target_dir.clone(),
15705 target_root.clone(),
15706 std::slice::from_ref(&target_source),
15707 )
15708 .unwrap();
15709 fs::write(
15710 &target_source,
15711 "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
15712 )
15713 .unwrap();
15714 drop(new_generation);
15715
15716 active_release_tx
15717 .send(())
15718 .expect("release active refresh worker");
15719 wait_for_refresh_calls(&target_root, 1);
15720 assert!(flush_callgraph_store_refreshes_with_budget(
15721 Duration::from_secs(12)
15722 ));
15723 let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
15724 .unwrap()
15725 .expect("current callgraph generation");
15726 let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
15727 assert_eq!(tree.children[0].name, "worker_leaf");
15728 assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
15729 clear_callgraph_refresh_worker_test_seam(&active_root);
15730 clear_callgraph_refresh_worker_test_seam(&target_root);
15731 }
15732
15733 #[test]
15734 fn refresh_failure_marks_files_stale() {
15735 let _guard = REFRESH_WORKER_TEST_LOCK
15736 .lock()
15737 .unwrap_or_else(std::sync::PoisonError::into_inner);
15738 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15743 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15744 let pending = pending_paths();
15745 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
15746
15747 enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
15748 assert!(flush_callgraph_store_refreshes_with_budget(
15749 Duration::from_secs(2)
15750 ));
15751
15752 assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
15753 let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
15754 .unwrap()
15755 .expect("ready callgraph store");
15756 assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
15757 clear_callgraph_refresh_worker_test_seam(&root);
15758 }
15759
15760 #[test]
15761 fn idle_refresh_truncates_wal() {
15762 let _guard = REFRESH_WORKER_TEST_LOCK
15763 .lock()
15764 .unwrap_or_else(std::sync::PoisonError::into_inner);
15765 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15766 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15767 let generation = read_pointer(
15768 &callgraph_dir,
15769 &crate::search_index::artifact_cache_key(&root),
15770 )
15771 .expect("fixture publishes a generation");
15772 let wal_path = callgraph_dir.join(format!("{generation}-wal"));
15773 let pending = pending_paths();
15774 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15775
15776 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n\n").unwrap();
15777 enqueue_callgraph_store_refresh(
15778 callgraph_dir.clone(),
15779 root.clone(),
15780 vec![source.clone()],
15781 Arc::clone(&pending),
15782 );
15783 wait_for_refresh_calls(&root, 1);
15784 wait_for_refresh_worker_idle();
15785 let checkpoint_deadline = Instant::now() + Duration::from_secs(2);
15786 while fs::metadata(&wal_path)
15787 .map(|metadata| metadata.len())
15788 .unwrap_or(0)
15789 != 0
15790 {
15791 assert!(
15792 Instant::now() < checkpoint_deadline,
15793 "idle checkpoint did not truncate WAL"
15794 );
15795 std::thread::sleep(Duration::from_millis(5));
15796 }
15797 assert_eq!(
15798 fs::metadata(&wal_path)
15799 .map(|metadata| metadata.len())
15800 .unwrap_or(0),
15801 0,
15802 "idle transition truncates the refresh WAL"
15803 );
15804
15805 clear_callgraph_refresh_worker_test_seam(&root);
15806 }
15807
15808 #[test]
15809 fn bounded_shutdown_defers_unprocessed_batches() {
15810 let _guard = REFRESH_WORKER_TEST_LOCK
15811 .lock()
15812 .unwrap_or_else(std::sync::PoisonError::into_inner);
15813 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15818 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
15819 let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
15820 let active_pending = pending_paths();
15821 let queued_pending = pending_paths();
15822 set_callgraph_refresh_worker_test_seam(
15823 active_root.clone(),
15824 Duration::from_millis(300),
15825 false,
15826 );
15827
15828 enqueue_callgraph_store_refresh(
15829 active_dir,
15830 active_root.clone(),
15831 vec![active_source.clone()],
15832 Arc::clone(&active_pending),
15833 );
15834 wait_for_refresh_calls(&active_root, 1);
15835 enqueue_callgraph_store_refresh(
15836 queued_dir,
15837 queued_root.clone(),
15838 vec![queued_source.clone()],
15839 Arc::clone(&queued_pending),
15840 );
15841
15842 assert!(!flush_callgraph_store_refreshes_with_budget(
15843 Duration::from_millis(20)
15844 ));
15845 assert!(active_pending.lock().contains(&active_source));
15846 assert!(queued_pending.lock().contains(&queued_source));
15847 assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
15848 clear_callgraph_refresh_worker_test_seam(&active_root);
15849 }
15850}
15851
15852#[cfg(test)]
15853mod cold_build_insert_tests {
15854 use super::*;
15855 use crate::imports::ImportBlock;
15856 use std::cell::Cell;
15857 use std::fs;
15858 use std::path::{Path, PathBuf};
15859 use tempfile::tempdir;
15860
15861 thread_local! {
15862 static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
15863 static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
15864 static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
15865 }
15866
15867 fn count_caller_traversal_selects(sql: &str) {
15868 let sql = sql.trim_start();
15869 if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
15870 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
15871 }
15872 if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
15873 && sql.contains("e.target_file =")
15874 {
15875 CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
15876 }
15877 if sql.starts_with("WITH requested") && sql.contains("COUNT(*)") {
15878 BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
15879 }
15880 }
15881
15882 #[test]
15883 fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
15884 let dir = tempdir().unwrap();
15885 let previous_root = dir.path().join("previous-root");
15886 let current_root = dir.path().join("current-root");
15887 fs::create_dir_all(&previous_root).unwrap();
15888 fs::create_dir_all(¤t_root).unwrap();
15889 fs::remove_dir(&previous_root).unwrap();
15890 let mut conn = Connection::open_in_memory().unwrap();
15891 initialize_schema(&conn).unwrap();
15892 conn.execute(
15893 "INSERT INTO backend_file_state(
15894 backend, workspace_root, file_path, content_hash, status, updated_at
15895 ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
15896 params![previous_root.display().to_string()],
15897 )
15898 .unwrap();
15899
15900 let repair = reconcile_workspace_roots(&mut conn, ¤t_root, false).unwrap();
15901
15902 assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
15903 assert_eq!(
15904 stored_workspace_roots(&conn).unwrap(),
15905 vec![previous_root.display().to_string()]
15906 );
15907 }
15908
15909 #[test]
15910 fn sqlite_readonly_uri_percent_encodes_windows_paths() {
15911 assert_eq!(
15912 sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
15913 "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
15914 );
15915 }
15916
15917 #[test]
15918 fn legacy_migration_completion_log_has_operator_fields() {
15919 assert_eq!(
15920 legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
15921 "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
15922 );
15923 }
15924
15925 fn write_generation_with_age(
15926 dir: &Path,
15927 project_key: &str,
15928 ordinal: u64,
15929 age: Duration,
15930 ) -> String {
15931 let generation = format!("{project_key}.g{ordinal}.1.sqlite");
15932 let path = dir.join(&generation);
15933 fs::write(&path, b"sqlite placeholder").unwrap();
15934 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
15935 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
15936 generation
15937 }
15938
15939 #[test]
15940 fn gc_old_generations_preserves_live_reader_until_marker_drops() {
15941 let dir = tempfile::tempdir().unwrap();
15942 let project_key = "project";
15943 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
15944 let previous =
15945 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
15946 let pinned =
15947 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
15948 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
15949
15950 gc_old_generations(dir.path(), project_key, ¤t);
15951
15952 assert!(dir.path().join(&previous).is_file());
15953 assert!(dir.path().join(&pinned).is_file());
15954
15955 drop(marker);
15956 gc_old_generations(dir.path(), project_key, ¤t);
15957
15958 assert!(dir.path().join(&previous).is_file());
15959 assert!(!dir.path().join(&pinned).exists());
15960 }
15961
15962 #[test]
15963 fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
15964 let dir = tempfile::tempdir().unwrap();
15965 let project_key = "project";
15966 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
15967 let _previous =
15968 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
15969 let pinned =
15970 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
15971 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
15972 filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
15973
15974 gc_old_generations(dir.path(), project_key, ¤t);
15975
15976 assert!(dir.path().join(&pinned).is_file());
15977 }
15978
15979 #[test]
15980 fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
15981 let dir = tempfile::tempdir().unwrap();
15982 let project_key = "project";
15983 let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
15984 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
15985 let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
15986 let old = write_generation_with_age(
15987 dir.path(),
15988 project_key,
15989 200,
15990 expired + Duration::from_secs(60),
15991 );
15992 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
15993
15994 gc_old_generations(dir.path(), project_key, ¤t);
15995
15996 assert!(dir.path().join(¤t).is_file());
15997 assert!(dir.path().join(&previous).is_file());
15998 assert!(!dir.path().join(&old).exists());
15999 }
16000
16001 fn write_aged_callgraph_root(callgraph_root: &Path, key: &str) -> PathBuf {
16002 let cache_dir = callgraph_root.join(key);
16003 fs::create_dir_all(cache_dir.join("nested")).unwrap();
16004 fs::write(
16005 cache_dir.join("nested").join("payload.sqlite"),
16006 b"old cache payload",
16007 )
16008 .unwrap();
16009 age_callgraph_root_tree(&cache_dir);
16010 cache_dir
16011 }
16012
16013 fn age_callgraph_root_tree(path: &Path) {
16014 let old = SystemTime::now()
16015 .checked_sub(CALLGRAPH_ROOT_ORPHAN_MIN_AGE + Duration::from_secs(60))
16016 .unwrap_or(UNIX_EPOCH);
16017 let entries = fs::read_dir(path)
16018 .unwrap()
16019 .collect::<std::io::Result<Vec<_>>>()
16020 .unwrap();
16021 for entry in entries {
16022 let child = entry.path();
16023 if entry.file_type().unwrap().is_dir() {
16024 age_callgraph_root_tree(&child);
16025 } else {
16026 filetime::set_file_mtime(&child, filetime::FileTime::from_system_time(old))
16027 .unwrap();
16028 }
16029 }
16030 filetime::set_file_mtime(path, filetime::FileTime::from_system_time(old)).unwrap();
16031 }
16032
16033 #[test]
16034 fn callgraph_root_sweep_reaps_only_aged_unprotected_dead_roots() {
16035 reset_callgraph_root_sweep_cursor_for_test();
16036 let storage = tempdir().unwrap();
16037 let callgraph_root = storage.path().join("callgraph");
16038 let dead = write_aged_callgraph_root(&callgraph_root, "f1e2d3c4b5a69788");
16039 let leased = write_aged_callgraph_root(&callgraph_root, "e1d2c3b4a5968778");
16040 let fresh = callgraph_root.join("d1c2b3a495867768");
16041 fs::create_dir_all(&fresh).unwrap();
16042 fs::write(fresh.join("payload.sqlite"), b"fresh cache payload").unwrap();
16043 let marked = write_aged_callgraph_root(&callgraph_root, "c1b2a39485766758");
16044
16045 let writer_lease = crate::fs_lock::try_acquire(
16046 &crate::root_cache::writer_lease_path(&leased),
16047 Duration::ZERO,
16048 )
16049 .unwrap();
16050 age_callgraph_root_tree(&leased);
16051 let marker = crate::root_cache::ReadMarker::create(&marked, "generation").unwrap();
16052 age_callgraph_root_tree(&marked);
16055
16056 let first = sweep_callgraph_root_dirs_with_limits(
16057 &callgraph_root,
16058 &HashSet::new(),
16059 &HashSet::new(),
16060 CALLGRAPH_ROOT_SWEEP_BUDGET,
16061 usize::MAX,
16062 );
16063
16064 assert_eq!(first.removed, 1);
16065 assert!(first.bytes > 0, "the reaped byte count must be reported");
16066 assert!(!dead.exists(), "an aged dead root must be reaped");
16067 assert_eq!(first.skipped_lease, 1, "a held writer lease must win");
16068 assert_eq!(first.skipped_reader, 1, "a live reader marker must win");
16069 assert_eq!(first.skipped_fresh, 1, "a recent root must win");
16070 assert!(leased.is_dir(), "the leased root must survive");
16071 assert!(marked.is_dir(), "the reader-marked root must survive");
16072 assert!(fresh.is_dir(), "the recent root must survive");
16073
16074 drop(writer_lease);
16075 drop(marker);
16076 for cache_dir in [&leased, &marked, &fresh] {
16079 age_callgraph_root_tree(cache_dir);
16080 }
16081 let second = sweep_callgraph_root_dirs_with_limits(
16082 &callgraph_root,
16083 &HashSet::new(),
16084 &HashSet::new(),
16085 CALLGRAPH_ROOT_SWEEP_BUDGET,
16086 usize::MAX,
16087 );
16088
16089 assert_eq!(second.removed, 3);
16090 for cache_dir in [&leased, &marked, &fresh] {
16091 assert!(
16092 !cache_dir.exists(),
16093 "the decoy must be reaped after its guard or freshness changes"
16094 );
16095 }
16096 reset_callgraph_root_sweep_cursor_for_test();
16097 }
16098
16099 #[test]
16100 fn callgraph_root_sweep_resumes_after_entry_budget() {
16101 reset_callgraph_root_sweep_cursor_for_test();
16102 let storage = tempdir().unwrap();
16103 let callgraph_root = storage.path().join("callgraph");
16104 let first = write_aged_callgraph_root(&callgraph_root, "1111111111111111");
16105 let second = write_aged_callgraph_root(&callgraph_root, "2222222222222222");
16106 let third = write_aged_callgraph_root(&callgraph_root, "3333333333333333");
16107
16108 let first_pass = sweep_callgraph_root_dirs_with_limits(
16109 &callgraph_root,
16110 &HashSet::new(),
16111 &HashSet::new(),
16112 CALLGRAPH_ROOT_SWEEP_BUDGET,
16113 1,
16114 );
16115 assert!(first_pass.budget_exhausted);
16116 assert_eq!(first_pass.scanned, 1);
16117 assert!(!first.exists());
16118 assert!(second.exists());
16119 assert!(third.exists());
16120
16121 let second_pass = sweep_callgraph_root_dirs_with_limits(
16122 &callgraph_root,
16123 &HashSet::new(),
16124 &HashSet::new(),
16125 CALLGRAPH_ROOT_SWEEP_BUDGET,
16126 1,
16127 );
16128 assert!(second_pass.budget_exhausted);
16129 assert!(!second.exists());
16130 assert!(third.exists());
16131
16132 let third_pass = sweep_callgraph_root_dirs_with_limits(
16133 &callgraph_root,
16134 &HashSet::new(),
16135 &HashSet::new(),
16136 CALLGRAPH_ROOT_SWEEP_BUDGET,
16137 1,
16138 );
16139 assert!(!third_pass.budget_exhausted);
16140 assert!(!third.exists());
16141 reset_callgraph_root_sweep_cursor_for_test();
16142 }
16143
16144 #[test]
16145 fn callgraph_root_sweep_runs_generation_gc_for_memoized_root() {
16146 reset_callgraph_root_sweep_cursor_for_test();
16147 let storage = tempdir().unwrap();
16148 let callgraph_root = storage.path().join("callgraph");
16149 let key = "a1b2c3d4e5f60718";
16150 let cache_dir = callgraph_root.join(key);
16151 fs::create_dir_all(&cache_dir).unwrap();
16152 let current = write_generation_with_age(&cache_dir, key, 400, Duration::ZERO);
16153 let previous = write_generation_with_age(&cache_dir, key, 300, Duration::from_secs(1));
16154 let obsolete = write_generation_with_age(&cache_dir, key, 200, Duration::from_secs(2));
16155 publish_pointer(&cache_dir, key, ¤t).unwrap();
16156 age_callgraph_root_tree(&cache_dir);
16157 let memo_keys = HashSet::from([key.to_string()]);
16158
16159 let summary = sweep_callgraph_root_dirs_with_limits(
16160 &callgraph_root,
16161 &memo_keys,
16162 &HashSet::new(),
16163 CALLGRAPH_ROOT_SWEEP_BUDGET,
16164 usize::MAX,
16165 );
16166
16167 assert_eq!(summary.generation_gc, 1);
16168 assert!(cache_dir.join(¤t).is_file());
16169 assert!(cache_dir.join(&previous).is_file());
16170 assert!(
16171 !cache_dir.join(&obsolete).exists(),
16172 "the store-wide sweep must collect an inactive live root's obsolete generation"
16173 );
16174 reset_callgraph_root_sweep_cursor_for_test();
16175 }
16176
16177 fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
16178 let path = dir.join(name);
16179 fs::write(&path, b"temp placeholder").unwrap();
16180 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
16181 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
16182 path
16183 }
16184
16185 #[test]
16186 fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
16187 let dir = tempdir().unwrap();
16188 let aged = "project.g100.1.sqlite.tmp.1.200";
16192 let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
16193 let fresh = "project.g300.1.sqlite.tmp.1.400";
16194 let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
16195 write_build_temp_with_age(dir.path(), aged, aged_age);
16196 write_build_temp_with_age(dir.path(), aged_journal, aged_age);
16197 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
16198
16199 sweep_orphaned_build_temps(dir.path());
16200
16201 assert!(
16202 !dir.path().join(aged).exists(),
16203 "aged orphan must be removed"
16204 );
16205 assert!(
16206 !dir.path().join(aged_journal).exists(),
16207 "aged journal sidecar must be removed"
16208 );
16209 assert!(
16210 dir.path().join(fresh).is_file(),
16211 "fresh temporary must survive"
16212 );
16213 }
16214
16215 #[test]
16216 fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
16217 let storage = tempdir().unwrap();
16218 let storage_root = storage.path();
16219 let legacy_dir = storage_root.join("opencode").join("callgraph");
16225 fs::create_dir_all(&legacy_dir).unwrap();
16226 let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
16227 write_build_temp_with_age(
16228 &legacy_dir,
16229 orphan,
16230 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
16231 );
16232 assert!(
16233 !legacy_dir.join("deadbeef.current").exists(),
16234 "the dead root has no current pointer"
16235 );
16236
16237 let root_keyed_dir = storage_root.join("callgraph").join("livekey");
16238 fs::create_dir_all(&root_keyed_dir).unwrap();
16239
16240 sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
16241
16242 assert!(
16243 !legacy_dir.join(orphan).exists(),
16244 "legacy orphan must be reclaimed by the store-wide sweep"
16245 );
16246 }
16247
16248 #[test]
16249 fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
16250 let dir = tempdir().unwrap();
16256 let fresh = "project.g300.1.sqlite.tmp.1.400";
16257 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
16258
16259 sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
16260
16261 assert!(
16262 !dir.path().join(fresh).exists(),
16263 "with the age predicate forced open, the fresh temporary is removed"
16264 );
16265 }
16266
16267 #[test]
16268 fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
16269 let dir = tempdir().unwrap();
16270 let generation = write_generation_with_age(
16274 dir.path(),
16275 "project",
16276 400,
16277 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
16278 );
16279 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
16280
16281 sweep_orphaned_build_temps(dir.path());
16282
16283 assert!(
16284 dir.path().join(&generation).is_file(),
16285 "completed generation must survive the orphan sweep"
16286 );
16287 assert!(
16288 crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
16289 "read marker must survive the orphan sweep"
16290 );
16291 }
16292
16293 #[test]
16294 fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
16295 let dir = tempfile::tempdir().unwrap();
16296 let project_key = "project".to_string();
16297 let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
16298 let sqlite_path = dir.path().join(&generation);
16299 fs::remove_file(&sqlite_path).unwrap();
16300 let conn = TrackedConnection::open(&sqlite_path, SqliteStore::CallgraphGeneration).unwrap();
16301 let store = CallGraphStore::from_connection(
16302 dir.path().to_path_buf(),
16303 project_key,
16304 sqlite_path,
16305 dir.path().to_path_buf(),
16306 false,
16307 Some(generation.clone()),
16308 None,
16309 None,
16310 conn,
16311 );
16312
16313 let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
16314 assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
16315
16316 drop(marker);
16317 assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
16318 }
16319
16320 #[test]
16321 fn readiness_cache_only_skips_checks_after_a_successful_validation() {
16322 let dir = tempdir().expect("temp dir");
16323 let file = dir.path().join("main.ts");
16324 fs::write(&file, "export function main() {}\n").expect("write fixture");
16325 let store = CallGraphStore::open(
16326 dir.path().join(".store-readiness-cache"),
16327 dir.path().to_path_buf(),
16328 )
16329 .expect("open store");
16330 {
16331 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16332 conn.trace(Some(count_caller_traversal_selects));
16333 }
16334
16335 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
16336 assert!(store.indexed_file_count().is_err());
16337 assert!(store.indexed_file_count().is_err());
16338 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
16339
16340 store
16341 .cold_build(std::slice::from_ref(&file))
16342 .expect("cold build");
16343 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
16344 assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
16345 assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
16346 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
16347
16348 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16349 conn.trace(None);
16350 }
16351
16352 #[test]
16353 fn direct_caller_frontier_chunks_sqlite_selects() {
16354 let dir = tempdir().expect("temp dir");
16355 let file = dir.path().join("main.ts");
16356 fs::write(
16357 &file,
16358 "export function caller() { target(); }\nexport function target() {}\n",
16359 )
16360 .expect("write fixture");
16361 let store = CallGraphStore::open(
16362 dir.path().join(".store-caller-frontier-query"),
16363 dir.path().to_path_buf(),
16364 )
16365 .expect("open store");
16366 store
16367 .cold_build(std::slice::from_ref(&file))
16368 .expect("cold build");
16369 let mut targets = vec![("main.ts".to_string(), "target".to_string())];
16370 targets.extend((1..1_000).map(|index| ("main.ts".to_string(), format!("missing{index}"))));
16371
16372 CALLER_QUERY_SELECTS.with(|count| count.set(0));
16373 BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
16374 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
16375 {
16376 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16377 conn.trace(Some(count_caller_traversal_selects));
16378 }
16379 let callers = store
16380 .direct_callers_for_symbols(&targets)
16381 .expect("batched callers");
16382 {
16383 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16384 conn.trace(None);
16385 }
16386
16387 assert_eq!(callers.len(), 1_000);
16388 assert_eq!(callers.get(&targets[0]).unwrap().len(), 1);
16389 assert_eq!(CALLER_QUERY_SELECTS.with(Cell::get), 3);
16390 assert_eq!(BOUNDARY_COUNT_SELECTS.with(Cell::get), 0);
16391 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
16392 }
16393
16394 #[test]
16395 fn callers_depth_boundary_batches_sqlite_counts() {
16396 const CALLER_COUNT: usize = 1_000;
16397
16398 let dir = tempdir().expect("temp dir");
16399 let file = dir.path().join("main.ts");
16400 let mut source = String::from("export function sharedHotHelper() {}\n");
16401 for index in 0..CALLER_COUNT {
16402 source.push_str(&format!(
16403 "export function caller{index}() {{ sharedHotHelper(); }}\n"
16404 ));
16405 }
16406 fs::write(&file, source).expect("write fixture");
16407
16408 let store = CallGraphStore::open(
16409 dir.path().join(".store-callers-query-fanout"),
16410 dir.path().to_path_buf(),
16411 )
16412 .expect("open store");
16413 store
16414 .cold_build(std::slice::from_ref(&file))
16415 .expect("cold build");
16416
16417 CALLER_QUERY_SELECTS.with(|count| count.set(0));
16418 BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
16419 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
16420 {
16421 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16422 conn.trace(Some(count_caller_traversal_selects));
16423 }
16424
16425 let started = Instant::now();
16426 let result = crate::commands::callgraph_store_adapter::callers_result(
16427 &store,
16428 Path::new("main.ts"),
16429 "sharedHotHelper",
16430 1,
16431 true,
16432 )
16433 .expect("callers result");
16434 let elapsed = started.elapsed();
16435
16436 {
16437 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16438 conn.trace(None);
16439 }
16440 let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
16441 let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
16442 let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
16443 eprintln!(
16444 "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
16445 result.total_callers,
16446 caller_queries,
16447 boundary_queries,
16448 total_selects,
16449 elapsed.as_secs_f64() * 1_000.0
16450 );
16451
16452 assert_eq!(result.total_callers, CALLER_COUNT);
16453 assert_eq!(caller_queries, 1);
16454 assert_eq!(boundary_queries, 3);
16455 assert_eq!(total_selects, 9);
16456 }
16457
16458 #[test]
16459 fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
16460 let dir = tempdir().expect("temp dir");
16461 let file = dir.path().join("main.ts");
16462 fs::write(
16463 &file,
16464 r#"export function topA() {
16465 root();
16466}
16467
16468export function topB() {
16469 root();
16470}
16471
16472export function root() {
16473 leaf();
16474 missing();
16475}
16476
16477export function leaf() {}
16478"#,
16479 )
16480 .expect("write fixture");
16481
16482 let store = CallGraphStore::open(
16483 dir.path().join(".store-depth-boundary-counts"),
16484 dir.path().to_path_buf(),
16485 )
16486 .expect("open store");
16487 store
16488 .cold_build(std::slice::from_ref(&file))
16489 .expect("cold build");
16490
16491 let root = store
16492 .node_for(Path::new("main.ts"), "root")
16493 .expect("root node");
16494 let leaf = store
16495 .node_for(Path::new("main.ts"), "leaf")
16496 .expect("leaf node");
16497
16498 let (full_forward_len, full_direct_len) = {
16499 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
16500 conn.execute(
16501 "INSERT INTO edges (
16502 edge_id, ref_id, source_node, target_node, target_file,
16503 target_symbol, kind, line, provenance
16504 ) VALUES (
16505 'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
16506 ?2, ?3, 'call', 98, ?4
16507 )",
16508 rusqlite::params![
16509 &root.node_id,
16510 &leaf.file,
16511 &leaf.symbol,
16512 PROVENANCE_TREESITTER
16513 ],
16514 )
16515 .expect("insert dangling forward edge");
16516 conn.execute(
16517 "INSERT INTO edges (
16518 edge_id, ref_id, source_node, target_node, target_file,
16519 target_symbol, kind, line, provenance
16520 ) VALUES (
16521 'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
16522 ?1, ?2, ?3, 'call', 99, ?4
16523 )",
16524 rusqlite::params![
16525 &root.node_id,
16526 &root.file,
16527 &root.symbol,
16528 PROVENANCE_TREESITTER
16529 ],
16530 )
16531 .expect("insert dangling direct-caller edge");
16532
16533 let full_forward_len = forward_calls_for_node(&conn, &root)
16534 .expect("full forward calls")
16535 .len();
16536 let counted_forward_len =
16537 forward_call_count_for_node(&conn, &root).expect("counted forward calls");
16538 assert_eq!(
16539 counted_forward_len, full_forward_len,
16540 "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
16541 );
16542
16543 let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
16544 .expect("full direct callers");
16545 let full_direct_len = full_direct.len();
16546 let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
16547 .expect("counted direct callers");
16548 assert_eq!(
16549 counted_direct_len, full_direct_len,
16550 "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
16551 );
16552
16553 let distinct_direct_len = full_direct
16554 .iter()
16555 .map(|site| {
16556 (
16557 site.caller.file.clone(),
16558 site.line,
16559 site.target_file.clone(),
16560 site.target_symbol.clone(),
16561 )
16562 })
16563 .collect::<BTreeSet<_>>()
16564 .len();
16565 let batch_counts = direct_caller_counts_for_tuples(
16566 &conn,
16567 &[
16568 (root.file.clone(), root.symbol.clone()),
16569 (root.file.clone(), root.symbol.clone()),
16570 (leaf.file.clone(), leaf.symbol.clone()),
16571 ],
16572 )
16573 .expect("batched direct-caller counts");
16574 assert_eq!(batch_counts.len(), 2);
16575 assert_eq!(
16576 batch_counts.get(&(root.file.clone(), root.symbol.clone())),
16577 Some(&distinct_direct_len)
16578 );
16579
16580 (full_forward_len, full_direct_len)
16581 };
16582
16583 assert_eq!(
16584 full_forward_len, 2,
16585 "fixture root should have one resolved and one unresolved outgoing call"
16586 );
16587 assert_eq!(
16588 full_direct_len, 2,
16589 "fixture root should have two real direct callers"
16590 );
16591
16592 let tree = store
16593 .call_tree(Path::new("main.ts"), "root", 0)
16594 .expect("call tree");
16595 assert!(tree.depth_limited);
16596 assert_eq!(tree.children.len(), 0);
16597 assert_eq!(
16598 tree.truncated, full_forward_len,
16599 "call_tree depth boundary must report the full forward-call list length"
16600 );
16601
16602 let callers = store
16603 .callers_of(Path::new("main.ts"), "leaf", 0)
16604 .expect("callers");
16605 assert!(callers.depth_limited);
16606 assert_eq!(callers.callers.len(), 1);
16607 assert_eq!(callers.callers[0].caller.symbol, "root");
16608 assert_eq!(
16609 callers.truncated, full_direct_len,
16610 "callers depth boundary must report the full direct-caller list length"
16611 );
16612 }
16613
16614 #[test]
16615 fn source_freshness_matches_cache_collect_for_same_bytes() {
16616 let dir = tempdir().expect("temp dir");
16617 let path = dir.path().join("fixture.ts");
16618 let source = "export function main() { return helper(); }\n";
16619 fs::write(&path, source).expect("write fixture");
16620
16621 let expected = cache_freshness::collect(&path).expect("collect freshness from file");
16622 let actual =
16623 collect_source_freshness(&path, source).expect("collect freshness from source");
16624
16625 assert_eq!(actual, expected);
16626 }
16627
16628 #[test]
16629 fn superseded_cold_build_cannot_publish_after_newer_epoch() {
16630 let root = tempfile::tempdir().unwrap();
16631 let callgraph_dir = tempfile::tempdir().unwrap();
16632 let source_dir = root.path().join("src");
16633 std::fs::create_dir_all(&source_dir).unwrap();
16634 let source = source_dir.join("lib.rs");
16635 std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
16636 let files = vec![source.clone()];
16637 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
16638 let old_epoch = epoch.next();
16639 let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
16640 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
16641 let old_epoch_flag = epoch.clone();
16642 let old_dir = callgraph_dir.path().to_path_buf();
16643 let old_root = root.path().to_path_buf();
16644 let old_files = files.clone();
16645 let old = std::thread::spawn(move || {
16646 set_cold_build_before_publish_observer(Some(Arc::new(move || {
16647 reached_tx.send(()).unwrap();
16648 release_rx.recv().unwrap();
16649 })));
16650 let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
16651 CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
16652 });
16653 set_cold_build_before_publish_observer(None);
16654 result
16655 });
16656 reached_rx
16660 .recv_timeout(Duration::from_secs(30))
16661 .expect("older build did not reach its publication barrier");
16662
16663 std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
16664 let new_epoch = epoch.next();
16665 let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
16666 CallGraphStore::cold_build_with_lease(
16667 callgraph_dir.path().to_path_buf(),
16668 root.path().to_path_buf(),
16669 &files,
16670 )
16671 })
16672 .expect("newer build should publish");
16673 drop(new_store);
16674
16675 release_tx.send(()).unwrap();
16676 assert!(matches!(
16677 old.join().unwrap(),
16678 Err(CallGraphStoreError::Superseded)
16679 ));
16680
16681 let current = CallGraphStore::open_readonly(
16682 callgraph_dir.path().to_path_buf(),
16683 root.path().to_path_buf(),
16684 )
16685 .unwrap()
16686 .expect("current callgraph generation");
16687 assert_eq!(
16688 current
16689 .nodes_matching("new_generation_marker")
16690 .unwrap()
16691 .len(),
16692 1
16693 );
16694 assert!(current
16695 .nodes_matching("old_generation_marker")
16696 .unwrap()
16697 .is_empty());
16698 }
16699
16700 #[test]
16701 fn publish_fence_supersession_keeps_completed_staging_for_zero_work_adoption() {
16702 let root = tempfile::tempdir().unwrap();
16703 let callgraph_dir = tempfile::tempdir().unwrap();
16704 let source = root.path().join("lib.rs");
16705 std::fs::write(&source, "pub fn completed_marker() {}\n").unwrap();
16706 let files = vec![source];
16707 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
16708 let old_epoch = epoch.next();
16709 let epoch_for_observer = epoch.clone();
16710 set_cold_build_before_publish_observer(Some(Arc::new(move || {
16711 epoch_for_observer.next();
16712 })));
16713 let result = with_publish_epoch(epoch.clone(), old_epoch, || {
16714 CallGraphStore::cold_build_with_lease_chunked(
16715 callgraph_dir.path().to_path_buf(),
16716 root.path().to_path_buf(),
16717 &files,
16718 1,
16719 )
16720 });
16721 set_cold_build_before_publish_observer(None);
16722 assert!(matches!(result, Err(CallGraphStoreError::Superseded)));
16723
16724 let project_key = crate::search_index::artifact_cache_key(root.path());
16725 let staging = callgraph_dir
16726 .path()
16727 .join(format!("{project_key}.staging.sqlite.tmp.resume"));
16728 let staged = Connection::open(&staging).unwrap();
16729 assert_eq!(
16730 staged_build_phase(&staged).unwrap().as_deref(),
16731 Some("ready")
16732 );
16733 drop(staged);
16734
16735 let extracted = Arc::new(std::sync::atomic::AtomicUsize::new(0));
16736 let extracted_for_observer = Arc::clone(&extracted);
16737 set_cold_build_extract_observer(Some(Arc::new(move |paths| {
16738 extracted_for_observer.fetch_add(paths.len(), AtomicOrdering::SeqCst);
16739 })));
16740 let successor_epoch = epoch.next();
16741 let (store, stats) = with_publish_epoch(epoch, successor_epoch, || {
16742 CallGraphStore::cold_build_with_lease_chunked(
16743 callgraph_dir.path().to_path_buf(),
16744 root.path().to_path_buf(),
16745 &files,
16746 1,
16747 )
16748 })
16749 .expect("completed same-corpus staging publishes without rebuilding");
16750 set_cold_build_extract_observer(None);
16751
16752 assert_eq!(stats.files, 1);
16753 assert_eq!(
16754 extracted.load(AtomicOrdering::SeqCst),
16755 0,
16756 "completed staging must not repeat extraction"
16757 );
16758 drop(store);
16759 }
16760
16761 #[test]
16762 fn superseded_slice_preserves_staging_and_same_corpus_successor_resumes() {
16763 let root = tempfile::tempdir().unwrap();
16764 let callgraph_dir = tempfile::tempdir().unwrap();
16765 let files = ["a.rs", "b.rs", "c.rs"]
16766 .into_iter()
16767 .map(|name| {
16768 let path = root.path().join(name);
16769 std::fs::write(&path, format!("pub fn {}() {{}}\n", name.replace('.', "_")))
16770 .unwrap();
16771 path
16772 })
16773 .collect::<Vec<_>>();
16774 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
16775 let old_epoch = epoch.next();
16776 let superseded = Arc::new(AtomicBool::new(false));
16777 let epoch_for_observer = epoch.clone();
16778 let superseded_for_observer = Arc::clone(&superseded);
16779 set_cold_build_slice_observer(Some(Arc::new(move |stage, completed, _total| {
16780 if stage == "extraction"
16781 && completed == 1
16782 && !superseded_for_observer.swap(true, AtomicOrdering::SeqCst)
16783 {
16784 epoch_for_observer.next();
16785 }
16786 })));
16787
16788 let result = with_publish_epoch(epoch.clone(), old_epoch, || {
16789 CallGraphStore::cold_build_with_lease_chunked(
16790 callgraph_dir.path().to_path_buf(),
16791 root.path().to_path_buf(),
16792 &files,
16793 1,
16794 )
16795 });
16796 set_cold_build_slice_observer(None);
16797 assert!(matches!(result, Err(CallGraphStoreError::Superseded)));
16798 assert!(superseded.load(AtomicOrdering::SeqCst));
16799
16800 let project_key = crate::search_index::artifact_cache_key(root.path());
16801 let staging = callgraph_dir
16802 .path()
16803 .join(format!("{project_key}.staging.sqlite.tmp.resume"));
16804 assert!(staging.exists(), "supersession must retain durable staging");
16805 let staged = Connection::open(&staging).unwrap();
16806 assert_eq!(
16807 staged_build_phase(&staged).unwrap().as_deref(),
16808 Some("extracting")
16809 );
16810 assert_eq!(
16811 query_count(&staged, "SELECT COUNT(*) FROM files").unwrap(),
16812 1
16813 );
16814 drop(staged);
16815
16816 let extracted = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
16817 let extracted_for_observer = Arc::clone(&extracted);
16818 set_cold_build_extract_observer(Some(Arc::new(move |paths| {
16819 extracted_for_observer
16820 .lock()
16821 .unwrap()
16822 .extend(paths.iter().filter_map(|path| {
16823 path.file_name()
16824 .map(|name| name.to_string_lossy().into_owned())
16825 }));
16826 })));
16827 let successor_epoch = epoch.next();
16828 let (store, stats) = with_publish_epoch(epoch.clone(), successor_epoch, || {
16829 CallGraphStore::cold_build_with_lease_chunked(
16830 callgraph_dir.path().to_path_buf(),
16831 root.path().to_path_buf(),
16832 &files,
16833 1,
16834 )
16835 })
16836 .expect("same-corpus successor resumes and publishes");
16837 set_cold_build_extract_observer(None);
16838
16839 assert_eq!(stats.files, 3);
16840 assert_eq!(
16841 *extracted.lock().unwrap(),
16842 vec!["b.rs".to_string(), "c.rs".to_string()],
16843 "the successor must not repeat the committed first slice"
16844 );
16845 drop(store);
16846 assert!(
16847 !staging.exists(),
16848 "published staging moves to its generation"
16849 );
16850 }
16851
16852 #[test]
16853 fn changed_corpus_restarts_instead_of_adopting_staged_progress() {
16854 let root = tempfile::tempdir().unwrap();
16855 let callgraph_dir = tempfile::tempdir().unwrap();
16856 let first = root.path().join("a.rs");
16857 let second = root.path().join("b.rs");
16858 std::fs::write(&first, "pub fn a() {}\n").unwrap();
16859 std::fs::write(&second, "pub fn b() {}\n").unwrap();
16860 let mut files = vec![first.clone(), second.clone()];
16861 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
16862 let old_epoch = epoch.next();
16863 let advanced = Arc::new(AtomicBool::new(false));
16864 let epoch_for_observer = epoch.clone();
16865 let advanced_for_observer = Arc::clone(&advanced);
16866 set_cold_build_slice_observer(Some(Arc::new(move |stage, completed, _total| {
16867 if stage == "extraction"
16868 && completed == 1
16869 && !advanced_for_observer.swap(true, AtomicOrdering::SeqCst)
16870 {
16871 epoch_for_observer.next();
16872 }
16873 })));
16874 let result = with_publish_epoch(epoch.clone(), old_epoch, || {
16875 CallGraphStore::cold_build_with_lease_chunked(
16876 callgraph_dir.path().to_path_buf(),
16877 root.path().to_path_buf(),
16878 &files,
16879 1,
16880 )
16881 });
16882 set_cold_build_slice_observer(None);
16883 assert!(matches!(result, Err(CallGraphStoreError::Superseded)));
16884
16885 std::fs::write(&first, "pub fn a_changed() { b(); }\n").unwrap();
16886 let third = root.path().join("c.rs");
16887 std::fs::write(&third, "pub fn c() {}\n").unwrap();
16888 files.push(third);
16889 let extracted = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
16890 let extracted_for_observer = Arc::clone(&extracted);
16891 set_cold_build_extract_observer(Some(Arc::new(move |paths| {
16892 extracted_for_observer
16893 .lock()
16894 .unwrap()
16895 .extend(paths.iter().filter_map(|path| {
16896 path.file_name()
16897 .map(|name| name.to_string_lossy().into_owned())
16898 }));
16899 })));
16900 let successor_epoch = epoch.next();
16901 let (store, stats) = with_publish_epoch(epoch, successor_epoch, || {
16902 CallGraphStore::cold_build_with_lease_chunked(
16903 callgraph_dir.path().to_path_buf(),
16904 root.path().to_path_buf(),
16905 &files,
16906 1,
16907 )
16908 })
16909 .expect("changed-corpus successor restarts and publishes");
16910 set_cold_build_extract_observer(None);
16911
16912 assert_eq!(stats.files, 3);
16913 assert_eq!(
16914 *extracted.lock().unwrap(),
16915 vec!["a.rs".to_string(), "b.rs".to_string(), "c.rs".to_string()],
16916 "fingerprint mismatch must invalidate every old extraction slice"
16917 );
16918 drop(store);
16919 }
16920
16921 #[test]
16922 fn cold_build_prepared_bulk_insert_matches_reference_rows() {
16923 let dir = tempdir().expect("temp dir");
16924 let project_root = dir.path();
16925 let extract = fixture_extract(project_root);
16926 let resolved = fixture_resolved(&extract);
16927
16928 let reference = build_reference_connection(project_root, &extract, &resolved);
16929 let optimized = build_optimized_connection(project_root, &extract, &resolved);
16930
16931 for table in [
16932 "files",
16933 "nodes",
16934 "file_dependencies",
16935 "dispatch_hints",
16936 "refs",
16937 "edges",
16938 ] {
16939 let excluded: &[&str] = if table == "files" {
16946 &["indexed_at"]
16947 } else {
16948 &[]
16949 };
16950 assert_eq!(
16951 table_rows_without(&reference, table, excluded),
16952 table_rows_without(&optimized, table, excluded),
16953 "table `{table}` rows must match apart from wall-clock columns"
16954 );
16955 }
16956 assert_eq!(
16957 backend_state_rows(&reference),
16958 backend_state_rows(&optimized),
16959 "backend freshness rows must match apart from updated_at"
16960 );
16961 assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
16962 }
16963
16964 #[test]
16965 fn cold_build_chunked_matches_unchunked_logical_rows() {
16966 let dir = tempdir().expect("temp dir");
16967 let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
16968 write_chunked_equivalence_fixture(&project_root);
16969 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
16970 assert!(
16971 files.len() > 6,
16972 "fixture should be large enough to split into multiple chunks"
16973 );
16974
16975 let unchunked = CallGraphStore::open(
16976 project_root.join(".store-unchunked"),
16977 project_root.to_path_buf(),
16978 )
16979 .expect("open unchunked store");
16980 let unchunked_stats = unchunked
16981 .cold_build_chunked(&files, 0)
16982 .expect("unchunked cold build");
16983
16984 let chunked = CallGraphStore::open(
16985 project_root.join(".store-chunked"),
16986 project_root.to_path_buf(),
16987 )
16988 .expect("open chunked store");
16989 let chunked_stats = chunked
16990 .cold_build_chunked(&files, 3)
16991 .expect("chunked cold build");
16992
16993 assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
16994 assert_eq!(
16995 unchunked.edge_snapshot().expect("unchunked edge snapshot"),
16996 chunked.edge_snapshot().expect("chunked edge snapshot"),
16997 "public edge snapshots must match"
16998 );
16999
17000 let dispatch_edges = {
17001 let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
17002 conn.query_row(
17003 "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
17004 [],
17005 |row| row.get::<_, i64>(0),
17006 )
17007 .expect("count dispatch edges")
17008 };
17009 assert!(
17010 dispatch_edges > 0,
17011 "fixture must exercise method-dispatch edge insertion"
17012 );
17013
17014 for table in [
17015 "edges",
17016 "refs",
17017 "nodes",
17018 "file_dependencies",
17019 "dispatch_hints",
17020 ] {
17021 assert_eq!(
17022 graph_table_rows(&unchunked, table),
17023 graph_table_rows(&chunked, table),
17024 "chunked cold build must match unchunked rows for {table}"
17025 );
17026 }
17027 assert_eq!(
17028 graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
17029 graph_table_rows_without(&chunked, "files", &["indexed_at"]),
17030 "files rows must match apart from indexed_at"
17031 );
17032 assert_eq!(
17033 graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
17034 graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
17035 "backend freshness rows must match apart from updated_at"
17036 );
17037
17038 let published_dir = project_root.join(".store-published");
17039 let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
17040 published_dir.clone(),
17041 project_root.to_path_buf(),
17042 &files,
17043 0,
17044 )
17045 .expect("published unchunked cold build");
17046 assert!(
17047 !CallGraphStore::needs_cold_build(&published_dir, &project_root)
17048 .expect("needs_cold_build after publish"),
17049 "published store should be ready"
17050 );
17051 drop(_published);
17052 let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
17053 published_dir,
17054 project_root.to_path_buf(),
17055 &files,
17056 3,
17057 )
17058 .expect("ensure with a different chunk size");
17059 assert!(
17060 rebuild_stats.is_none(),
17061 "changing callgraph_chunk_size must not affect store identity or force a rebuild"
17062 );
17063 }
17064
17065 #[test]
17066 fn cold_build_resolution_memo_bounds_filesystem_probes_and_preserves_rows() {
17067 let dir = tempdir().expect("temp dir");
17068 let project_root = dir.path().join("project");
17069 fs::create_dir_all(&project_root).expect("create project root");
17070 let project_root = fs::canonicalize(project_root).expect("canonical project root");
17071 let files = write_ts_resolution_memo_fixture(&project_root, 8, 8, 4);
17072 let resolve_window = 19;
17073
17074 callgraph::clear_workspace_package_cache();
17075 let uncached_memo = callgraph::ModuleResolutionMemo::new_for_test(false, true);
17076 let uncached = CallGraphStore::open(
17077 dir.path().join("store-uncached"),
17078 project_root.to_path_buf(),
17079 )
17080 .expect("open uncached store");
17081 let uncached_stats = uncached
17084 .cold_build_chunked_with_disk_index_memo_for_test(
17085 &files,
17086 7,
17087 resolve_window,
17088 &uncached_memo,
17089 false,
17090 )
17091 .expect("uncached comparison build");
17092 assert!(
17093 uncached_stats.refs > resolve_window * 2,
17094 "fixture must cross several staged reference windows"
17095 );
17096
17097 callgraph::clear_workspace_package_cache();
17098 let cached_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
17099 let cached =
17100 CallGraphStore::open(dir.path().join("store-cached"), project_root.to_path_buf())
17101 .expect("open cached store");
17102 let cached_stats = cached
17103 .cold_build_chunked_with_resolution_memo_for_test(
17104 &files,
17105 7,
17106 resolve_window,
17107 &cached_memo,
17108 )
17109 .expect("cached build");
17110
17111 assert_cold_build_stats_match_except_elapsed(&uncached_stats, &cached_stats);
17112 for table in [
17113 "nodes",
17114 "refs",
17115 "file_dependencies",
17116 "edges",
17117 "dispatch_hints",
17118 "type_ref_names",
17119 "meta",
17120 "staging_file_inventory",
17121 "staging_ref_context",
17122 ] {
17123 assert_eq!(
17124 graph_table_rows(&uncached, table),
17125 graph_table_rows(&cached, table),
17126 "memoized and uncached cold builds must produce identical {table} rows"
17127 );
17128 }
17129 assert_eq!(
17130 graph_table_rows_without(&uncached, "files", &["indexed_at"]),
17131 graph_table_rows_without(&cached, "files", &["indexed_at"]),
17132 "files rows must match apart from indexed_at"
17133 );
17134 assert_eq!(
17135 graph_table_rows_without(&uncached, "backend_file_state", &["updated_at"]),
17136 graph_table_rows_without(&cached, "backend_file_state", &["updated_at"]),
17137 "backend rows must match apart from updated_at"
17138 );
17139
17140 let cached_module_computations = cached_memo.module_computations_for_test();
17141 assert!(
17142 !cached_module_computations.is_empty(),
17143 "fixture must exercise module resolution"
17144 );
17145 assert!(
17146 cached_module_computations.values().all(|count| *count == 1),
17147 "each importing-directory/specifier pair must reach the filesystem once"
17148 );
17149 let uncached_module_computations = uncached_memo.module_computations_for_test();
17150 assert!(
17151 uncached_module_computations
17152 .values()
17153 .copied()
17154 .max()
17155 .unwrap_or_default()
17156 > 16,
17157 "mutation control: disabling the memo must recompute a hot module target"
17158 );
17159
17160 let cached_package_probes = cached_memo
17161 .json_probes_for_test()
17162 .into_iter()
17163 .filter(|(path, _)| {
17164 path.file_name().and_then(|name| name.to_str()) == Some("package.json")
17165 })
17166 .collect::<HashMap<_, _>>();
17167 assert!(
17168 !cached_package_probes.is_empty(),
17169 "fixture must exercise package.json lookup"
17170 );
17171 assert!(
17172 cached_package_probes.values().all(|count| *count == 1),
17173 "every package.json path must be probed at most once per cold build"
17174 );
17175 let uncached_package_probes = uncached_memo
17176 .json_probes_for_test()
17177 .into_iter()
17178 .filter(|(path, _)| {
17179 path.file_name().and_then(|name| name.to_str()) == Some("package.json")
17180 })
17181 .collect::<HashMap<_, _>>();
17182 let cached_probe_total: usize = cached_package_probes.values().sum();
17183 let uncached_probe_total: usize = uncached_package_probes.values().sum();
17184 assert!(
17185 uncached_probe_total > cached_probe_total * 20,
17186 "mutation control: disabled memo should repeat the package ladder ({uncached_probe_total} vs {cached_probe_total})"
17187 );
17188 }
17189
17190 #[test]
17191 fn cold_build_disk_file_index_memo_preserves_resolved_rows() {
17192 let dir = tempdir().expect("temp dir");
17193 let project_root = dir.path().join("project");
17194 fs::create_dir_all(&project_root).expect("create project root");
17195 let project_root = fs::canonicalize(project_root).expect("canonical project root");
17196 let files = write_rust_declared_module_memo_fixture(&project_root, 6);
17197
17198 let bypassed = CallGraphStore::open(
17199 dir.path().join("store-disk-memo-bypassed"),
17200 project_root.to_path_buf(),
17201 )
17202 .expect("open bypassed store");
17203 let bypassed_module_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
17204 let bypassed_stats = bypassed
17205 .cold_build_chunked_with_disk_index_memo_for_test(
17206 &files,
17207 3,
17208 7,
17209 &bypassed_module_memo,
17210 false,
17211 )
17212 .expect("build with disk index memo bypassed");
17213
17214 let memoized = CallGraphStore::open(
17215 dir.path().join("store-disk-memoized"),
17216 project_root.to_path_buf(),
17217 )
17218 .expect("open memoized store");
17219 let memoized_module_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
17220 let memoized_stats = memoized
17221 .cold_build_chunked_with_disk_index_memo_for_test(
17222 &files,
17223 3,
17224 7,
17225 &memoized_module_memo,
17226 true,
17227 )
17228 .expect("build with disk index memo enabled");
17229
17230 assert_cold_build_stats_match_except_elapsed(&bypassed_stats, &memoized_stats);
17231 for table in ["refs", "edges"] {
17232 assert_eq!(
17233 graph_table_rows(&bypassed, table),
17234 graph_table_rows(&memoized, table),
17235 "memoized and bypassed disk indexes must produce byte-identical {table} rows"
17236 );
17237 }
17238 }
17239
17240 #[test]
17241 fn rust_declared_module_memo_parses_each_declaring_file_once_and_preserves_edges() {
17242 let dir = tempdir().expect("temp dir");
17243 let project_root = dir.path().join("project");
17244 fs::create_dir_all(&project_root).expect("create project root");
17245 let project_root = fs::canonicalize(project_root).expect("canonical project root");
17246 let files = write_rust_declared_module_memo_fixture(&project_root, 10);
17247
17248 let negative_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
17249 for _ in 0..3 {
17250 assert_eq!(
17251 rust_declared_module_target(
17252 &project_root,
17253 "src/lib.rs",
17254 "undeclared",
17255 &negative_memo,
17256 &FactPaths {
17257 root: &project_root,
17258 facts: &DiskFacts::new(&project_root)
17259 },
17260 ),
17261 None
17262 );
17263 }
17264 assert_eq!(
17265 negative_memo
17266 .rust_declaration_parses_for_test()
17267 .get("src/lib.rs"),
17268 Some(&1),
17269 "an undeclared module must be retained as a file-level negative result"
17270 );
17271
17272 let uncached_memo = callgraph::ModuleResolutionMemo::new_for_test(false, true);
17273 let uncached = CallGraphStore::open(
17274 dir.path().join("rust-store-uncached"),
17275 project_root.to_path_buf(),
17276 )
17277 .expect("open uncached Rust store");
17278 let uncached_stats = uncached
17281 .cold_build_chunked_with_disk_index_memo_for_test(&files, 3, 11, &uncached_memo, false)
17282 .expect("uncached Rust build");
17283
17284 let cached_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
17285 let cached = CallGraphStore::open(
17286 dir.path().join("rust-store-cached"),
17287 project_root.to_path_buf(),
17288 )
17289 .expect("open cached Rust store");
17290 let cached_stats = cached
17291 .cold_build_chunked_with_resolution_memo_for_test(&files, 3, 11, &cached_memo)
17292 .expect("cached Rust build");
17293
17294 assert!(
17295 cached_stats.refs >= 60,
17296 "fixture must exercise repeated qualified and undeclared paths"
17297 );
17298 assert_cold_build_stats_match_except_elapsed(&uncached_stats, &cached_stats);
17299 assert_eq!(
17300 uncached.edge_snapshot().expect("uncached edge snapshot"),
17301 cached.edge_snapshot().expect("cached edge snapshot"),
17302 "memoized Rust declarations must preserve the public edge set"
17303 );
17304 assert_eq!(
17305 graph_table_rows(&uncached, "edges"),
17306 graph_table_rows(&cached, "edges"),
17307 "source, symbol, target, and provenance rows must be byte-identical"
17308 );
17309
17310 let expected_declaring_files = [
17311 "src/lib.rs",
17312 "src/module_0.rs",
17313 "src/module_1.rs",
17314 "src/module_2.rs",
17315 "src/module_3.rs",
17316 "src/module_4.rs",
17317 ]
17318 .into_iter()
17319 .map(str::to_string)
17320 .collect::<BTreeSet<_>>();
17321 let cached_parses = cached_memo.rust_declaration_parses_for_test();
17322 assert_eq!(
17323 cached_parses.keys().cloned().collect::<BTreeSet<_>>(),
17324 expected_declaring_files,
17325 "the fixture must traverse exactly its six distinct declaring files"
17326 );
17327 assert!(
17328 cached_parses.values().all(|count| *count == 1),
17329 "each declaring file must be parsed once per cold build: {cached_parses:?}"
17330 );
17331
17332 let uncached_parses = uncached_memo.rust_declaration_parses_for_test();
17333 let uncached_parse_total: usize = uncached_parses.values().sum();
17334 let cached_parse_total: usize = cached_parses.values().sum();
17335 println!(
17336 "RUST_DECLARATION_PARSE_COUNTS memo=off:{uncached_parse_total} memo=on:{cached_parse_total} distinct={}",
17337 cached_parses.len()
17338 );
17339 assert!(
17340 uncached_parse_total > 100,
17341 "mutation control: disabling memo insertion must repeat declaration parses; got {uncached_parse_total}"
17342 );
17343 let cached_missing_refs = cached
17344 .conn
17345 .lock()
17346 .expect("callgraph store mutex poisoned")
17347 .query_row(
17348 "SELECT COUNT(*) FROM refs
17349 WHERE full_ref = 'crate::undeclared::missing' AND target_file IS NULL",
17350 [],
17351 |row| row.get::<_, usize>(0),
17352 )
17353 .expect("count unresolved negative refs");
17354 assert_eq!(
17355 cached_missing_refs, 10,
17356 "all negative-result references must remain unresolved without reparsing"
17357 );
17358 }
17359
17360 #[test]
17361 fn refresh_after_adding_rust_module_declaration_uses_fresh_snapshot() {
17362 let dir = tempdir().expect("temp dir");
17363 let project_root = dir.path().join("project");
17364 fs::create_dir_all(project_root.join("src/custom")).expect("create Rust fixture");
17365 fs::write(
17366 project_root.join("Cargo.toml"),
17367 "[package]\nname = \"refresh-declaration-fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
17368 )
17369 .expect("write Rust manifest");
17370 let lib = project_root.join("src/lib.rs");
17371 let existing = project_root.join("src/existing.rs");
17372 let added = project_root.join("src/custom/added.rs");
17373 fs::write(
17374 &lib,
17375 "mod existing;\npub fn run() { crate::added::target(); }\n",
17376 )
17377 .expect("write initial lib");
17378 fs::write(&existing, "pub fn existing() {}\n").expect("write existing module");
17379 fs::write(&added, "pub fn target() {}\n").expect("write added module target");
17380 let project_root = fs::canonicalize(project_root).expect("canonical project root");
17381 let lib = project_root.join("src/lib.rs");
17382 let existing = project_root.join("src/existing.rs");
17383 let added = project_root.join("src/custom/added.rs");
17384
17385 let store = CallGraphStore::open(
17386 dir.path().join("refresh-declaration-store"),
17387 project_root.to_path_buf(),
17388 )
17389 .expect("open refresh store");
17390 store
17391 .cold_build(&[lib.clone(), existing.clone(), added])
17392 .expect("initial cold build");
17393 assert!(
17394 store
17395 .direct_callers_of(Path::new("src/custom/added.rs"), "target")
17396 .expect("initial callers")
17397 .is_empty(),
17398 "the custom-path module must be unresolved before its declaration exists"
17399 );
17400
17401 fs::write(&existing, "pub fn existing() { let _ = 1; }\n").expect("touch existing module");
17402 store
17403 .refresh_files(std::slice::from_ref(&existing))
17404 .expect("warm refresh declaration loading");
17405 fs::write(
17406 &lib,
17407 "mod existing;\n#[path = \"custom/added.rs\"]\nmod added;\npub fn run() { crate::added::target(); }\n",
17408 )
17409 .expect("add custom-path module declaration");
17410 store
17411 .refresh_files(std::slice::from_ref(&lib))
17412 .expect("refresh declaring file");
17413
17414 let callers = store
17415 .direct_callers_of(Path::new("src/custom/added.rs"), "target")
17416 .expect("refreshed callers");
17417 assert!(
17418 callers
17419 .iter()
17420 .any(|site| { site.caller.file == "src/lib.rs" && site.caller.symbol == "run" }),
17421 "a fresh refresh generation must resolve the newly declared module: {callers:#?}"
17422 );
17423 }
17424
17425 #[test]
17430 #[ignore]
17431 fn bench_cold_build_resolution_memo() {
17432 let dir = tempdir().expect("temp dir");
17433 let project_root = dir.path().join("project");
17434 fs::create_dir_all(&project_root).expect("create benchmark root");
17435 let project_root = fs::canonicalize(project_root).expect("canonical benchmark root");
17436 let files = write_ts_resolution_memo_fixture(&project_root, 24, 12, 20);
17437 assert!(
17438 files.len() > 250,
17439 "benchmark fixture must contain hundreds of files"
17440 );
17441
17442 for enabled in [false, true] {
17443 callgraph::clear_workspace_package_cache();
17444 let memo = callgraph::ModuleResolutionMemo::new_for_test(enabled, false);
17445 let store = CallGraphStore::open(
17446 dir.path().join(if enabled {
17447 "store-cached"
17448 } else {
17449 "store-uncached"
17450 }),
17451 project_root.to_path_buf(),
17452 )
17453 .expect("open benchmark store");
17454 let cpu_started = process_cpu_time();
17455 let wall_started = Instant::now();
17456 let stats = store
17457 .cold_build_chunked_with_resolution_memo_for_test(&files, 32, 257, &memo)
17458 .expect("benchmark cold build");
17459 let wall_ms = wall_started.elapsed().as_millis();
17460 let cpu_ms = process_cpu_time()
17461 .checked_sub(cpu_started)
17462 .unwrap_or_default()
17463 .as_millis();
17464 println!(
17465 "BENCH_COLD_BUILD_RESOLUTION_MEMO memo={} files={} refs={} edges={} wall_ms={} cpu_ms={}",
17466 if enabled { "on" } else { "off" },
17467 stats.files,
17468 stats.refs,
17469 stats.edges,
17470 wall_ms,
17471 cpu_ms
17472 );
17473 }
17474 }
17475
17476 #[test]
17477 #[ignore]
17478 fn bench_rust_declared_module_memo_real_corpus() {
17479 let project_root = fs::canonicalize(env!("CARGO_MANIFEST_DIR"))
17480 .expect("canonical agent-file-tools crate root");
17481 let files = [
17482 "src/main.rs",
17483 "src/cli/mod.rs",
17484 "src/cli/index.rs",
17485 "src/cli/sandbox_launch.rs",
17486 "src/cli/warmup.rs",
17487 ]
17488 .into_iter()
17489 .map(|path| project_root.join(path))
17490 .collect::<Vec<_>>();
17491 assert!(
17492 files.iter().all(|path| path.is_file()),
17493 "real-corpus benchmark sources must exist"
17494 );
17495 let dir = tempdir().expect("benchmark temp dir");
17496 let mut baseline_edges = None;
17497 let mut baseline_stats = None;
17498 let enabled_modes = match std::env::var("AFT_RUST_DECL_MEMO").as_deref() {
17499 Ok("off") => vec![false],
17500 Ok("on") => vec![true],
17501 _ => vec![false, true],
17502 };
17503
17504 for enabled in enabled_modes {
17505 let memo = callgraph::ModuleResolutionMemo::new_for_test(enabled, true);
17506 let store = CallGraphStore::open(
17507 dir.path().join(if enabled {
17508 "rust-real-cached"
17509 } else {
17510 "rust-real-uncached"
17511 }),
17512 project_root.to_path_buf(),
17513 )
17514 .expect("open real-corpus store");
17515 let phase_times = Arc::new(Mutex::new((None, None)));
17516 let observer_times = Arc::clone(&phase_times);
17517 set_cold_build_phase_observer(Some(Arc::new(move |phase| {
17518 let mut times = observer_times.lock().expect("phase timing mutex poisoned");
17519 match phase {
17520 "resolution" if times.0.is_none() => times.0 = Some(Instant::now()),
17521 "publication" if times.1.is_none() => times.1 = Some(Instant::now()),
17522 _ => {}
17523 }
17524 })));
17525 let build = store.cold_build_chunked_with_resolution_memo_for_test(
17526 &files,
17527 COLD_BUILD_EXTRACT_BATCH_FILES,
17528 COLD_BUILD_RESOLVE_WINDOW,
17529 &memo,
17530 );
17531 set_cold_build_phase_observer(None);
17532 let stats = build.expect("real-corpus cold build");
17533 let times = phase_times.lock().expect("phase timing mutex poisoned");
17534 let resolution_elapsed = times
17535 .1
17536 .expect("publication phase timestamp")
17537 .duration_since(times.0.expect("resolution phase timestamp"));
17538 drop(times);
17539 let edges = graph_table_rows(&store, "edges");
17540 if let Some(expected) = &baseline_edges {
17541 assert_eq!(
17542 &edges, expected,
17543 "real-corpus edge rows, including provenance, must be byte-identical"
17544 );
17545 } else {
17546 baseline_edges = Some(edges);
17547 }
17548 let stats_tuple = (stats.files, stats.nodes, stats.refs, stats.edges);
17549 if let Some(expected) = baseline_stats {
17550 assert_eq!(stats_tuple, expected, "real-corpus build counts must match");
17551 } else {
17552 baseline_stats = Some(stats_tuple);
17553 }
17554 let parses = memo.rust_declaration_parses_for_test();
17555 println!(
17556 "BENCH_RUST_DECLARED_MODULE_MEMO memo={} files={} refs={} edges={} resolution_wall_ms={} declaration_parses={} distinct_declaring_files={}",
17557 if enabled { "on" } else { "off" },
17558 stats.files,
17559 stats.refs,
17560 stats.edges,
17561 resolution_elapsed.as_millis(),
17562 parses.values().sum::<usize>(),
17563 parses.len()
17564 );
17565 }
17566 }
17567
17568 #[test]
17575 #[ignore]
17576 fn bench_cold_build_chunk() {
17577 let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
17578 let chunk: usize = std::env::var("AFT_PERF_CHUNK")
17579 .expect("AFT_PERF_CHUNK")
17580 .parse()
17581 .expect("AFT_PERF_CHUNK must be a non-negative integer");
17582 let project_root = fs::canonicalize(&repo).expect("canonical repo root");
17583 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
17584 let dir = tempdir().expect("temp dir");
17585 let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
17586 .expect("open store");
17587 let started = Instant::now();
17588 let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
17589 let ms = started.elapsed().as_millis();
17590 println!(
17591 "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
17592 stats.files, stats.nodes, stats.refs, stats.edges
17593 );
17594 }
17595
17596 #[test]
17597 fn persisted_workspace_reexport_selects_its_package_dependency() {
17598 let root = tempdir().expect("temp dir");
17599 let dependencies = BTreeSet::from([
17600 "packages/aft-bridge/src/index.ts".to_string(),
17601 "packages/opencode-plugin/src/types.ts".to_string(),
17602 ]);
17603 let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
17604
17605 assert_eq!(
17606 stored_dependencies_for_module(
17607 root.path(),
17608 "packages/opencode-plugin/src/shared/bash-hints.ts",
17609 "@cortexkit/aft-bridge",
17610 &dependencies,
17611 &indexed_files,
17612 &FactPaths {
17613 root: root.path(),
17614 facts: &DiskFacts::new(root.path())
17615 }
17616 ),
17617 BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
17618 );
17619 }
17620
17621 #[test]
17622 fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
17623 let dir = tempdir().expect("temp dir");
17624 let project_root = dir.path();
17625 let files =
17626 write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
17627 let index_path = project_root.join("src/index.ts");
17628
17629 let store = CallGraphStore::open(
17630 project_root.join(".store-incremental-barrel"),
17631 project_root.to_path_buf(),
17632 )
17633 .expect("open incremental store");
17634 store.cold_build(&files).expect("initial cold build");
17635
17636 {
17637 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
17638 let tx = conn.transaction().expect("dependency transaction");
17639 let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
17640 .expect("dependent refs for barrel");
17641 let selected_ref_ids = dependent_refs
17642 .iter()
17643 .map(|dependent_ref| dependent_ref.ref_id.clone())
17644 .collect::<BTreeSet<_>>();
17645 let mut threaded_ref_ids = BTreeSet::new();
17646 let mut threaded_by_caller = BTreeMap::new();
17647 record_dependent_refs(
17648 &mut threaded_ref_ids,
17649 &mut threaded_by_caller,
17650 dependent_refs,
17651 );
17652 let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
17653 .expect("old per-ref caller lookup");
17654
17655 assert_eq!(threaded_ref_ids, selected_ref_ids);
17656 assert_eq!(threaded_by_caller, old_by_caller);
17657 for consumer in [
17658 "src/consumer_a.ts",
17659 "src/consumer_b.ts",
17660 "src/consumer_c.ts",
17661 ] {
17662 assert!(
17663 threaded_by_caller.contains_key(consumer),
17664 "barrel edit should select dependent refs from {consumer}"
17665 );
17666 }
17667 }
17668
17669 fs::write(
17670 &index_path,
17671 "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
17672 )
17673 .expect("edit barrel");
17674 let stats = store
17675 .refresh_files(std::slice::from_ref(&index_path))
17676 .expect("incremental refresh");
17677 assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
17678 assert!(
17679 stats.dependency_selected_refs > 0,
17680 "barrel surface edit should select dependent refs"
17681 );
17682
17683 let cold_store = CallGraphStore::open(
17684 project_root.join(".store-cold-barrel"),
17685 project_root.to_path_buf(),
17686 )
17687 .expect("open cold rebuild store");
17688 cold_store
17689 .cold_build(&files)
17690 .expect("comparison cold build");
17691
17692 for table in [
17693 "nodes",
17694 "refs",
17695 "file_dependencies",
17696 "edges",
17697 "dispatch_hints",
17698 ] {
17699 assert_eq!(
17700 graph_table_rows(&store, table),
17701 graph_table_rows(&cold_store, table),
17702 "incremental refresh {table} rows must match cold rebuild"
17703 );
17704 }
17705
17706 let consumer_path = project_root.join("src/consumer_a.ts");
17707 fs::write(
17708 &consumer_path,
17709 "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
17710 )
17711 .expect("edit barrel consumer");
17712 store
17713 .refresh_files(std::slice::from_ref(&consumer_path))
17714 .expect("refresh consumer through unchanged barrel");
17715 cold_store
17716 .cold_build(&files)
17717 .expect("comparison cold rebuild after consumer refresh");
17718 for table in [
17719 "nodes",
17720 "refs",
17721 "file_dependencies",
17722 "edges",
17723 "dispatch_hints",
17724 ] {
17725 assert_eq!(
17726 graph_table_rows(&store, table),
17727 graph_table_rows(&cold_store, table),
17728 "refresh through a persisted barrel must preserve cold-build {table} rows"
17729 );
17730 }
17731 }
17732
17733 fn build_reference_connection(
17734 project_root: &Path,
17735 extract: &FileExtract,
17736 resolved: &ResolvedRef,
17737 ) -> Connection {
17738 let mut conn = Connection::open_in_memory().expect("open reference db");
17739 configure_build_connection(&conn).expect("configure reference db");
17740 initialize_schema(&conn).expect("initialize reference schema");
17741 {
17742 let tx = conn.transaction().expect("reference transaction");
17743 clear_tables(&tx).expect("reference clear");
17744 insert_meta(&tx).expect("reference meta");
17745 insert_file_extract(&tx, project_root, extract).expect("reference file extract");
17746 insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
17747 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
17748 .expect("reference dispatch edges");
17749 assert_eq!(supplemental, 0);
17750 tx.commit().expect("reference commit");
17751 }
17752 conn
17753 }
17754
17755 fn build_optimized_connection(
17756 project_root: &Path,
17757 extract: &FileExtract,
17758 resolved: &ResolvedRef,
17759 ) -> Connection {
17760 let mut conn = Connection::open_in_memory().expect("open optimized db");
17761 configure_build_connection(&conn).expect("configure optimized db");
17762 initialize_schema(&conn).expect("initialize optimized schema");
17763 {
17764 let tx = conn.transaction().expect("optimized transaction");
17765 clear_tables(&tx).expect("optimized clear");
17766 insert_meta(&tx).expect("optimized meta");
17767 drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
17768 {
17769 let workspace_root = project_root.display().to_string();
17770 let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
17771 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
17772 .expect("optimized file extract");
17773 insert_resolved_ref_prepared(&mut inserts, resolved)
17774 .expect("optimized resolved ref");
17775 }
17776 create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
17777 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
17778 .expect("optimized dispatch edges");
17779 assert_eq!(supplemental, 0);
17780 tx.commit().expect("optimized commit");
17781 }
17782 conn
17783 }
17784
17785 fn fixture_extract(_project_root: &Path) -> FileExtract {
17786 let rel_path = "src/main.ts".to_string();
17787 let target_path = "src/helper.ts".to_string();
17788 let node = NodeRecord {
17789 id: "node-main".to_string(),
17790 file_path: rel_path.clone(),
17791 name: "main".to_string(),
17792 scoped_name: "main".to_string(),
17793 kind: "function".to_string(),
17794 range: Range {
17795 start_line: 0,
17796 start_col: 0,
17797 end_line: 0,
17798 end_col: 32,
17799 },
17800 range_ordinal: 0,
17801 signature: Some("export function main()".to_string()),
17802 exported: true,
17803 is_default_export: false,
17804 is_type_like: false,
17805 is_callgraph_entry_point: true,
17806 };
17807 let mut dependencies = BTreeSet::new();
17808 dependencies.insert(target_path.clone());
17809 let raw_ref = RawRef {
17810 ref_id: "ref-main-helper".to_string(),
17811 caller_node: Some(node.id.clone()),
17812 caller_symbol: Some(node.scoped_name.clone()),
17813 caller_file: rel_path.clone(),
17814 kind: "call".to_string(),
17815 short_name: Some("helper".to_string()),
17816 full_ref: Some("helper".to_string()),
17817 module_path: None,
17818 import_kind: None,
17819 local_name: Some("helper".to_string()),
17820 requested_name: Some("helper".to_string()),
17821 namespace_alias: None,
17822 wildcard: false,
17823 line: 1,
17824 byte_start: 24,
17825 byte_end: 32,
17826 dependencies,
17827 };
17828 FileExtract {
17829 rel_path,
17830 freshness: FileFreshness {
17831 mtime: UNIX_EPOCH + Duration::from_secs(123),
17832 size: 40,
17833 content_hash: cache_freshness::hash_bytes(b"fixture source"),
17834 },
17835 lang: LangId::TypeScript,
17836 data: FileCallData {
17837 calls_by_symbol: HashMap::new(),
17838 value_refs_by_symbol: HashMap::new(),
17839 exported_symbols: Vec::new(),
17840 symbol_metadata: HashMap::new(),
17841 default_export_symbol: None,
17842 import_block: ImportBlock::empty(),
17843 lang: LangId::TypeScript,
17844 },
17845 nodes: vec![node.clone()],
17846 raw_refs: vec![raw_ref],
17847 dispatch_hints: vec![DispatchHint {
17848 id: "dispatch-main-helper".to_string(),
17849 method_name: "helper".to_string(),
17850 caller_node: node.id,
17851 file: "src/main.ts".to_string(),
17852 line: 1,
17853 byte_start: 24,
17854 byte_end: 32,
17855 }],
17856 surface_fingerprint: "surface".to_string(),
17857 }
17858 }
17859
17860 fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
17861 let raw = extract.raw_refs[0].clone();
17862 let mut dependencies = raw.dependencies.clone();
17863 dependencies.insert("src/helper.ts".to_string());
17864 ResolvedRef {
17865 edge: Some(EdgeRecord {
17866 edge_id: "edge-main-helper".to_string(),
17867 source_node: raw.caller_node.clone().expect("caller node"),
17868 target_node: Some("node-helper".to_string()),
17869 target_file: "src/helper.ts".to_string(),
17870 target_symbol: "helper".to_string(),
17871 kind: "call".to_string(),
17872 line: raw.line,
17873 }),
17874 raw,
17875 status: "resolved".to_string(),
17876 target_node: Some("node-helper".to_string()),
17877 target_file: Some("src/helper.ts".to_string()),
17878 target_symbol: Some("helper".to_string()),
17879 dependencies,
17880 }
17881 }
17882
17883 fn write_rust_declared_module_memo_fixture(
17884 project_root: &Path,
17885 calls_per_module: usize,
17886 ) -> Vec<PathBuf> {
17887 fs::create_dir_all(project_root.join("src")).expect("create Rust fixture root");
17888 fs::write(
17889 project_root.join("Cargo.toml"),
17890 "[package]\nname = \"rust-declaration-memo-fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
17891 )
17892 .expect("write Rust fixture manifest");
17893
17894 let modules = (0..5)
17895 .map(|index| format!("module_{index}"))
17896 .collect::<Vec<_>>();
17897 let mut lib_source = modules
17898 .iter()
17899 .map(|module| format!("pub mod {module};\n"))
17900 .collect::<String>();
17901 lib_source.push_str("\npub fn dispatch() {\n");
17902 for call in 0..calls_per_module {
17903 for (index, module) in modules.iter().enumerate() {
17904 lib_source.push_str(&format!(
17905 " crate::{module}::leaf::target_{index}(); // call {call}\n"
17906 ));
17907 }
17908 lib_source.push_str(" crate::undeclared::missing();\n");
17909 }
17910 lib_source.push_str("}\n");
17911 let lib = project_root.join("src/lib.rs");
17912 fs::write(&lib, lib_source).expect("write Rust fixture lib");
17913 let mut files = vec![lib];
17914
17915 for (index, module) in modules.iter().enumerate() {
17916 let declaring_file = project_root.join(format!("src/{module}.rs"));
17917 fs::write(&declaring_file, "pub mod leaf;\n").expect("write nested module declaration");
17918 let target_file = project_root.join(format!("src/{module}/leaf.rs"));
17919 fs::create_dir_all(target_file.parent().expect("nested module parent"))
17920 .expect("create nested module directory");
17921 fs::write(&target_file, format!("pub fn target_{index}() {{}}\n"))
17922 .expect("write nested module target");
17923 files.push(declaring_file);
17924 files.push(target_file);
17925 }
17926 files
17927 }
17928
17929 fn write_ts_resolution_memo_fixture(
17930 project_root: &Path,
17931 package_count: usize,
17932 files_per_package: usize,
17933 calls_per_file: usize,
17934 ) -> Vec<PathBuf> {
17935 fs::create_dir_all(project_root).expect("create memo fixture root");
17936 fs::write(
17937 project_root.join("package.json"),
17938 r#"{"name":"fixture-root","private":true,"workspaces":["packages/*"]}"#,
17939 )
17940 .expect("write workspace package manifest");
17941 fs::write(
17942 project_root.join("tsconfig.json"),
17943 r#"{"compilerOptions":{"baseUrl":".","paths":{}}}"#,
17944 )
17945 .expect("write fixture tsconfig");
17946
17947 let shared_root = project_root.join("packages/shared");
17948 let shared_source = shared_root.join("src/index.ts");
17949 fs::create_dir_all(shared_source.parent().expect("shared source parent"))
17950 .expect("create shared package");
17951 fs::write(
17952 shared_root.join("package.json"),
17953 r#"{"name":"@fixture/shared","exports":{".":{"source":"./src/index.ts"}}}"#,
17954 )
17955 .expect("write shared package manifest");
17956 fs::write(
17957 &shared_source,
17958 "export function shared(value: number) { return value + 1; }\n",
17959 )
17960 .expect("write shared source");
17961 let mut files = vec![shared_source];
17962
17963 for package in 0..package_count {
17964 let package_root = project_root.join(format!("packages/app-{package:02}"));
17965 fs::create_dir_all(&package_root).expect("create app package");
17966 fs::write(
17967 package_root.join("package.json"),
17968 format!(r#"{{"name":"@fixture/app-{package:02}"}}"#),
17969 )
17970 .expect("write app package manifest");
17971 let source_dir = package_root.join("src/features/deep/nested/leaf");
17972 fs::create_dir_all(&source_dir).expect("create deep app source dir");
17973
17974 for file in 0..files_per_package {
17975 let source_path = source_dir.join(format!("caller_{file:03}.ts"));
17976 let mut source = "import { shared } from \"@fixture/shared\";\n".to_string();
17977 for call in 0..calls_per_file {
17978 source.push_str(&format!(
17979 "export function caller_{package}_{file}_{call}() {{ return shared({call}); }}\n"
17980 ));
17981 }
17982 fs::write(&source_path, source).expect("write app source");
17983 files.push(source_path);
17984 }
17985 }
17986
17987 files
17988 }
17989
17990 #[cfg(unix)]
17991 fn process_cpu_time() -> Duration {
17992 let mut value = std::mem::MaybeUninit::<libc::timespec>::uninit();
17993 let result =
17994 unsafe { libc::clock_gettime(libc::CLOCK_PROCESS_CPUTIME_ID, value.as_mut_ptr()) };
17995 if result != 0 {
17996 return Duration::ZERO;
17997 }
17998 let value = unsafe { value.assume_init() };
17999 Duration::new(value.tv_sec.max(0) as u64, value.tv_nsec.max(0) as u32)
18000 }
18001
18002 #[cfg(not(unix))]
18003 fn process_cpu_time() -> Duration {
18004 Duration::ZERO
18005 }
18006
18007 fn write_chunked_equivalence_fixture(project_root: &Path) {
18008 let ts_dir = project_root.join("ts");
18009 fs::create_dir_all(&ts_dir).expect("create ts dir");
18010 fs::write(
18011 ts_dir.join("leaf.ts"),
18012 "export function leaf(value: number) {\n return value + 1;\n}\n",
18013 )
18014 .expect("write ts leaf");
18015 fs::write(
18016 ts_dir.join("mid.ts"),
18017 "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n return leaf(value);\n}\n",
18018 )
18019 .expect("write ts mid");
18020 fs::write(
18021 ts_dir.join("entry.ts"),
18022 "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n return mid(worker.run());\n}\n",
18023 )
18024 .expect("write ts entry");
18025 fs::write(
18026 ts_dir.join("worker.ts"),
18027 "export class Worker {\n run() {\n return 41;\n }\n}\n",
18028 )
18029 .expect("write ts worker");
18030 for idx in 0..4 {
18031 fs::write(
18032 ts_dir.join(format!("extra_{idx}.ts")),
18033 format!(
18034 "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n return entry(new Worker());\n}}\n"
18035 ),
18036 )
18037 .expect("write ts extra");
18038 }
18039
18040 let rust_dir = project_root.join("src");
18041 let commands_dir = rust_dir.join("commands");
18042 fs::create_dir_all(&commands_dir).expect("create rust commands dir");
18043 fs::write(
18044 rust_dir.join("context.rs"),
18045 r#"pub struct AppContext;
18046
18047impl AppContext {
18048 pub fn callgraph_store_for_ops(&self) -> usize {
18049 1
18050 }
18051}
18052"#,
18053 )
18054 .expect("write rust context");
18055 fs::write(
18056 rust_dir.join("lib.rs"),
18057 "pub mod context;\npub mod commands;\n",
18058 )
18059 .expect("write rust lib");
18060 fs::write(
18061 commands_dir.join("mod.rs"),
18062 "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
18063 )
18064 .expect("write rust commands mod");
18065 for name in ["callers", "impact", "trace_to"] {
18066 fs::write(
18067 commands_dir.join(format!("{name}.rs")),
18068 format!(
18069 r#"use crate::context::AppContext;
18070
18071pub fn handle_{name}(ctx: &AppContext) -> usize {{
18072 ctx.callgraph_store_for_ops()
18073}}
18074"#
18075 ),
18076 )
18077 .expect("write rust command");
18078 }
18079 }
18080
18081 fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
18082 let src_dir = project_root.join("src");
18083 fs::create_dir_all(&src_dir).expect("create src dir");
18084
18085 let target_path = src_dir.join("target.ts");
18086 fs::write(&target_path, "export function target() {\n return 1;\n}\n")
18087 .expect("write target");
18088
18089 let index_path = src_dir.join("index.ts");
18090 fs::write(&index_path, barrel_source).expect("write barrel");
18091
18092 let mut files = vec![target_path, index_path];
18093 for (file_name, function_name) in [
18094 ("consumer_a.ts", "consumerA"),
18095 ("consumer_b.ts", "consumerB"),
18096 ("consumer_c.ts", "consumerC"),
18097 ] {
18098 let path = src_dir.join(file_name);
18099 fs::write(
18100 &path,
18101 format!(
18102 "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n return target();\n}}\n"
18103 ),
18104 )
18105 .expect("write consumer");
18106 files.push(path);
18107 }
18108 files
18109 }
18110
18111 fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
18112 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
18113 table_rows(&conn, table)
18114 }
18115
18116 fn graph_table_rows_without(
18117 store: &CallGraphStore,
18118 table: &str,
18119 excluded_columns: &[&str],
18120 ) -> Vec<String> {
18121 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
18122 table_rows_without(&conn, table, excluded_columns)
18123 }
18124
18125 fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
18126 table_rows_without(conn, table, &[])
18127 }
18128
18129 fn table_rows_without(
18130 conn: &Connection,
18131 table: &str,
18132 excluded_columns: &[&str],
18133 ) -> Vec<String> {
18134 let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
18135 let columns: Vec<String> = conn
18136 .prepare(&format!("PRAGMA table_info({table})"))
18137 .expect("prepare table_info")
18138 .query_map([], |row| row.get::<_, String>(1))
18139 .expect("query table_info")
18140 .collect::<std::result::Result<Vec<String>, _>>()
18141 .expect("collect columns")
18142 .into_iter()
18143 .filter(|column| !excluded_columns.contains(column.as_str()))
18144 .collect();
18145 let sql = format!(
18146 "SELECT {} FROM {table} ORDER BY {}",
18147 columns.join(", "),
18148 columns.join(", ")
18149 );
18150 conn.prepare(&sql)
18151 .expect("prepare table rows")
18152 .query_map([], |row| row_to_strings(row, columns.len()))
18153 .expect("query table rows")
18154 .collect::<std::result::Result<_, _>>()
18155 .expect("collect table rows")
18156 }
18157
18158 fn assert_cold_build_stats_match_except_elapsed(
18159 expected: &ColdBuildStats,
18160 actual: &ColdBuildStats,
18161 ) {
18162 assert_eq!(actual.files, expected.files, "file counts must match");
18163 assert_eq!(actual.nodes, expected.nodes, "node counts must match");
18164 assert_eq!(actual.refs, expected.refs, "ref counts must match");
18165 assert_eq!(actual.edges, expected.edges, "edge counts must match");
18166 assert_eq!(
18167 actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
18168 expected
18169 .failed_files
18170 .iter()
18171 .cloned()
18172 .collect::<BTreeSet<_>>(),
18173 "failed file sets must match"
18174 );
18175 }
18176
18177 fn backend_state_rows(conn: &Connection) -> Vec<String> {
18178 conn.prepare(
18179 "SELECT backend, workspace_root, file_path, content_hash, status
18180 FROM backend_file_state
18181 ORDER BY backend, workspace_root, file_path, content_hash, status",
18182 )
18183 .expect("prepare backend rows")
18184 .query_map([], |row| row_to_strings(row, 5))
18185 .expect("query backend rows")
18186 .collect::<std::result::Result<_, _>>()
18187 .expect("collect backend rows")
18188 }
18189
18190 fn secondary_indexes(conn: &Connection) -> Vec<String> {
18191 let mut indexes = Vec::new();
18192 for table in [
18193 "files",
18194 "nodes",
18195 "refs",
18196 "file_dependencies",
18197 "edges",
18198 "dispatch_hints",
18199 "type_ref_names",
18200 "backend_file_state",
18201 "meta",
18202 ] {
18203 let sql = format!("PRAGMA index_list({table})");
18204 let mut stmt = conn.prepare(&sql).expect("prepare index list");
18205 let rows = stmt
18206 .query_map([], |row| row.get::<_, String>(1))
18207 .expect("query index list");
18208 for name in rows {
18209 let name = name.expect("index name");
18210 if name.starts_with("idx_") {
18211 indexes.push(format!("{table}:{name}"));
18212 }
18213 }
18214 }
18215 indexes.sort();
18216 indexes
18217 }
18218
18219 fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
18220 let mut values = Vec::with_capacity(len);
18221 for index in 0..len {
18222 let value = row.get_ref(index)?;
18223 values.push(match value {
18224 rusqlite::types::ValueRef::Null => "NULL".to_string(),
18225 rusqlite::types::ValueRef::Integer(value) => value.to_string(),
18226 rusqlite::types::ValueRef::Real(value) => value.to_string(),
18227 rusqlite::types::ValueRef::Text(value) => {
18228 String::from_utf8_lossy(value).into_owned()
18229 }
18230 rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
18231 });
18232 }
18233 Ok(values.join("\u{1f}"))
18234 }
18235}
18236
18237#[cfg(test)]
18238mod rust_resolution_tests {
18239 use super::*;
18240 use crate::inspect::job::CallgraphSnapshot;
18241 use std::fs;
18242 use tempfile::tempdir;
18243
18244 #[test]
18245 fn rust_function_scoped_module_alias_resolves_and_projects_live() {
18246 let dir = tempdir().expect("tempdir");
18247 let root = dir.path();
18248 write_rust_manifest(root, "scoped-alias-fixture");
18249 write_file(
18250 root,
18251 "src/lib.rs",
18252 r#"pub mod finalization_contract;
18253
18254pub fn run_alias() {
18255 use crate::finalization_contract as fc;
18256 fc::check_mason_contract();
18257}
18258"#,
18259 );
18260 write_file(
18261 root,
18262 "src/finalization_contract.rs",
18263 r#"pub fn check_mason_contract() {}
18264fn planted_dead() {}
18265"#,
18266 );
18267
18268 let (store, snapshot) = cold_build_twice(root);
18269 assert_direct_caller(
18270 &store,
18271 "src/finalization_contract.rs",
18272 "check_mason_contract",
18273 "src/lib.rs",
18274 "run_alias",
18275 );
18276 assert_projected_call(
18277 root,
18278 &snapshot,
18279 "src/finalization_contract.rs",
18280 "check_mason_contract",
18281 );
18282 assert_no_projected_call(
18283 root,
18284 &snapshot,
18285 "src/finalization_contract.rs",
18286 "planted_dead",
18287 );
18288 assert!(
18289 store
18290 .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
18291 .expect("planted dead callers")
18292 .is_empty(),
18293 "planted-dead guard should stay without callers"
18294 );
18295 }
18296
18297 #[test]
18298 fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
18299 let dir = tempdir().expect("tempdir");
18300 let root = dir.path();
18301 write_rust_manifest(root, "inline-module-fixture");
18302 write_file(
18303 root,
18304 "src/lib.rs",
18305 r#"mod work_graph { fn operations() {} }
18306mod manifest { fn operations() {} }
18307mod audit { fn operations() {} }
18308mod dispatch { fn operations() {} }
18309mod finalization { fn operations() {} }
18310
18311pub fn run_inline_operations() {
18312 work_graph::operations();
18313 manifest::operations();
18314 audit::operations();
18315 dispatch::operations();
18316 finalization::operations();
18317}
18318
18319fn planted_dead() {}
18320"#,
18321 );
18322
18323 let (store, snapshot) = cold_build_twice(root);
18324 for module in [
18325 "work_graph",
18326 "manifest",
18327 "audit",
18328 "dispatch",
18329 "finalization",
18330 ] {
18331 assert_direct_caller(
18332 &store,
18333 "src/lib.rs",
18334 &format!("{module}::operations"),
18335 "src/lib.rs",
18336 "run_inline_operations",
18337 );
18338 }
18339 assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
18340 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
18341 }
18342
18343 #[test]
18344 fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
18345 let dir = tempdir().expect("tempdir");
18346 let root = dir.path();
18347 fs::write(
18348 root.join("Cargo.toml"),
18349 "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
18350 )
18351 .expect("write workspace manifest");
18352 write_file(
18353 root,
18354 "crates/but-action/Cargo.toml",
18355 r#"[package]
18356name = "but-action"
18357version = "0.1.0"
18358edition = "2021"
18359"#,
18360 );
18361 write_file(
18362 root,
18363 "crates/but-action/src/lib.rs",
18364 "mod action;\npub use action::{list_actions};\n",
18365 );
18366 write_file(
18367 root,
18368 "crates/but-action/src/action.rs",
18369 "pub fn list_actions() {}\nfn planted_dead() {}\n",
18370 );
18371 write_file(
18372 root,
18373 "crates/app/Cargo.toml",
18374 r#"[package]
18375name = "app"
18376version = "0.1.0"
18377edition = "2021"
18378"#,
18379 );
18380 write_file(
18381 root,
18382 "crates/app/src/lib.rs",
18383 "pub fn run_actions() {\n but_action::list_actions();\n}\n",
18384 );
18385
18386 let (store, snapshot) = cold_build_twice(root);
18387 assert_direct_caller(
18388 &store,
18389 "crates/but-action/src/action.rs",
18390 "list_actions",
18391 "crates/app/src/lib.rs",
18392 "run_actions",
18393 );
18394 assert!(
18395 store
18396 .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
18397 .expect("lib reexport callers")
18398 .is_empty(),
18399 "call should target the reexported source function, not lib.rs"
18400 );
18401 assert_projected_call(
18402 root,
18403 &snapshot,
18404 "crates/but-action/src/action.rs",
18405 "list_actions",
18406 );
18407 assert_no_projected_call(
18408 root,
18409 &snapshot,
18410 "crates/but-action/src/action.rs",
18411 "planted_dead",
18412 );
18413 }
18414
18415 #[test]
18416 fn rust_cfg_attributed_module_resolves_outgoing_calls() {
18417 let dir = tempdir().expect("tempdir");
18418 let root = dir.path();
18419 write_rust_manifest(root, "cfg-module-outgoing-fixture");
18420 write_file(
18421 root,
18422 "src/lib.rs",
18423 "pub fn project_range() {}\n\n#[cfg(any(test, feature = \"test-conformance\"))]\npub mod conformance;\npub mod ordinary;\n",
18424 );
18425 for module in ["conformance", "ordinary"] {
18426 write_file(
18427 root,
18428 &format!("src/{module}.rs"),
18429 "use crate::project_range;\n\npub fn local_target() {}\n\npub fn run() {\n local_target();\n project_range();\n}\n",
18430 );
18431 }
18432
18433 let (store, _) = cold_build_twice(root);
18434 for module in ["conformance", "ordinary"] {
18435 assert_direct_caller(
18436 &store,
18437 &format!("src/{module}.rs"),
18438 "local_target",
18439 &format!("src/{module}.rs"),
18440 "run",
18441 );
18442 assert_direct_caller(
18443 &store,
18444 "src/lib.rs",
18445 "project_range",
18446 &format!("src/{module}.rs"),
18447 "run",
18448 );
18449 }
18450 }
18451
18452 #[test]
18453 fn rust_registered_modules_preserve_import_alias_resolution() {
18454 let dir = tempdir().expect("tempdir");
18455 let root = dir.path();
18456 write_rust_manifest(root, "registered-module-import-control");
18457 write_file(
18458 root,
18459 "src/main.rs",
18460 "mod commands;\nmod db;\nfn main() {}\n",
18461 );
18462 write_file(
18463 root,
18464 "src/commands.rs",
18465 "use crate::db;\n\npub fn run() {\n db::helper();\n}\n",
18466 );
18467 write_file(root, "src/db.rs", "pub fn helper() {}\n");
18468
18469 let main_extract =
18470 build_file_extract(root, &root.join("src/main.rs")).expect("main extract");
18471 let commands_extract =
18472 build_file_extract(root, &root.join("src/commands.rs")).expect("commands extract");
18473 let db_extract = build_file_extract(root, &root.join("src/db.rs")).expect("db extract");
18474 let files = [&main_extract, &commands_extract, &db_extract]
18475 .into_iter()
18476 .map(|extract| {
18477 (
18478 extract.rel_path.clone(),
18479 DbFileIndex::from_extract(
18480 root,
18481 extract,
18482 &FactPaths {
18483 root,
18484 facts: &DiskFacts::new(root),
18485 },
18486 ),
18487 )
18488 })
18489 .collect::<HashMap<_, _>>();
18490 let caller_data = [&main_extract, &commands_extract, &db_extract]
18491 .into_iter()
18492 .map(|extract| (extract.rel_path.clone(), &extract.data))
18493 .collect::<HashMap<_, _>>();
18494 let index = ProjectIndex::from_parts(
18495 root,
18496 files,
18497 caller_data,
18498 WorkspaceCratePrefixCache::default(),
18499 Rc::new(DiskFacts::new(root)),
18500 );
18501 assert_eq!(
18502 index.module_parent("src/commands.rs"),
18503 Some(("src/main.rs".to_string(), "commands".to_string()))
18504 );
18505 assert_eq!(
18506 index.module_target("src/main.rs", "db").as_deref(),
18507 Some("src/db.rs")
18508 );
18509 let call = commands_extract
18510 .raw_refs
18511 .iter()
18512 .find(|raw| raw.kind == "call" && raw.full_ref.as_deref() == Some("db::helper"))
18513 .expect("db helper call")
18514 .clone();
18515 let resolved = resolve_ref(call, &index).expect("resolve db helper");
18516 assert_eq!(resolved.target_file.as_deref(), Some("src/db.rs"));
18517 assert_eq!(resolved.target_symbol.as_deref(), Some("helper"));
18518
18519 let (store, _) = cold_build_twice(root);
18520 assert_direct_caller(&store, "src/db.rs", "helper", "src/commands.rs", "run");
18521 }
18522
18523 #[test]
18524 fn rust_path_attributed_module_uses_declared_logical_parent() {
18525 let dir = tempdir().expect("tempdir");
18526 let root = dir.path();
18527 write_rust_manifest(root, "path-module-outgoing-fixture");
18528 write_file(
18529 root,
18530 "src/lib.rs",
18531 "pub fn project_range() {}\n\n#[cfg(test)]\n#[path = \"alternate/custom.rs\"]\npub mod conformance;\n",
18532 );
18533 write_file(
18534 root,
18535 "src/alternate/custom.rs",
18536 "pub fn run() {\n super::project_range();\n}\n",
18537 );
18538
18539 let (store, _) = cold_build_twice(root);
18540 assert_direct_caller(
18541 &store,
18542 "src/lib.rs",
18543 "project_range",
18544 "src/alternate/custom.rs",
18545 "run",
18546 );
18547 }
18548
18549 #[test]
18550 fn rust_same_file_test_module_receiver_method_dispatch_resolves() {
18551 let dir = tempdir().expect("tempdir");
18552 let root = dir.path();
18553 write_rust_manifest(root, "same-file-test-module-fixture");
18554 write_file(
18555 root,
18556 "src/lib.rs",
18557 r#"pub struct Index(u32);
18558
18559impl Index {
18560 pub fn shares_index_with(&self, other: &Self) -> bool {
18561 self.0 == other.0
18562 }
18563}
18564
18565#[cfg(test)]
18566mod tests {
18567 use super::Index;
18568
18569 #[test]
18570 fn compares_indexes() {
18571 let before = Index(1);
18572 let after = Index(1);
18573 assert!(before.shares_index_with(&after));
18574 }
18575}
18576"#,
18577 );
18578
18579 let (store, snapshot) = cold_build_twice(root);
18580 assert_direct_caller(
18581 &store,
18582 "src/lib.rs",
18583 "Index::shares_index_with",
18584 "src/lib.rs",
18585 "tests::compares_indexes",
18586 );
18587 assert!(
18588 snapshot.outbound_calls.iter().any(|call| {
18589 call.caller_symbol == "compares_indexes"
18590 && call.line == 17
18591 && call.target.starts_with(&format!(
18592 "shares_index_with{}before.shares_index_with",
18593 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
18594 ))
18595 }),
18596 "expected projected macro receiver call; calls: {:#?}",
18597 snapshot.outbound_calls
18598 );
18599 }
18600
18601 #[test]
18602 fn rust_generic_self_turbofish_method_dispatch_resolves() {
18603 let dir = tempdir().expect("tempdir");
18604 let root = dir.path();
18605 write_rust_manifest(root, "generic-self-fixture");
18606 write_file(
18607 root,
18608 "src/lib.rs",
18609 r#"pub struct Matcher;
18610
18611impl Matcher {
18612 pub fn run(&self) -> bool {
18613 self.fuzzy_match_optimal::<usize>("needle")
18614 }
18615
18616 fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
18617 let _ = std::marker::PhantomData::<T>;
18618 true
18619 }
18620
18621 fn planted_dead(&self) {}
18622}
18623
18624pub fn entry() -> bool {
18625 let matcher = Matcher;
18626 matcher.run()
18627}
18628"#,
18629 );
18630
18631 let (store, snapshot) = cold_build_twice(root);
18632 assert_direct_caller(
18633 &store,
18634 "src/lib.rs",
18635 "Matcher::fuzzy_match_optimal",
18636 "src/lib.rs",
18637 "Matcher::run",
18638 );
18639 assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
18640 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
18641 }
18642
18643 #[test]
18644 fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
18645 let dir = tempdir().expect("tempdir");
18646 let root = dir.path();
18647 write_rust_manifest(root, "manifest-operations-fixture");
18648 write_file(
18649 root,
18650 "src/main.rs",
18651 r#"mod dispatch;
18652use dispatch::{manifest_operations};
18653
18654fn main() {
18655 manifest_operations();
18656}
18657"#,
18658 );
18659 write_file(
18660 root,
18661 "src/dispatch.rs",
18662 r#"mod work_graph { fn operations() {} }
18663mod manifest { fn operations() {} }
18664mod audit { fn operations() {} }
18665mod descriptor { fn operations() {} }
18666mod writer { fn operations() {} }
18667
18668pub fn manifest_operations() {
18669 manifest::operations();
18670}
18671
18672pub fn work_graph_operations() {
18673 work_graph::operations();
18674}
18675
18676pub fn audit_operations() {
18677 audit::operations();
18678}
18679
18680pub fn descriptor_operations() {
18681 descriptor::operations();
18682}
18683
18684pub fn writer_operations() {
18685 writer::operations();
18686}
18687
18688fn planted_dead() {}
18689"#,
18690 );
18691
18692 let (store, snapshot) = cold_build_twice(root);
18693 assert_direct_caller(
18694 &store,
18695 "src/dispatch.rs",
18696 "manifest_operations",
18697 "src/main.rs",
18698 "main",
18699 );
18700 assert_direct_caller(
18701 &store,
18702 "src/dispatch.rs",
18703 "manifest::operations",
18704 "src/dispatch.rs",
18705 "manifest_operations",
18706 );
18707 assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
18708 assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
18709 assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
18710 }
18711
18712 fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
18713 let files = rust_files(root);
18714 let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
18715 .expect("open first store");
18716 first.cold_build(&files).expect("first cold build");
18717 let first_snapshot =
18718 project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
18719
18720 let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
18721 .expect("open second store");
18722 second.cold_build(&files).expect("second cold build");
18723 let second_snapshot =
18724 project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
18725
18726 assert_eq!(
18727 projection_rows(&first_snapshot),
18728 projection_rows(&second_snapshot),
18729 "cold-build projection should be deterministic"
18730 );
18731 (first, first_snapshot)
18732 }
18733
18734 fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
18735 let mut rows = Vec::new();
18736 for export in &snapshot.exported_symbols {
18737 rows.push(format!(
18738 "export\t{}\t{}\t{}\t{}",
18739 export.file.display(),
18740 export.symbol,
18741 export.kind,
18742 export.line
18743 ));
18744 }
18745 for call in &snapshot.outbound_calls {
18746 rows.push(format!(
18747 "call\t{}\t{}\t{}\t{}\t{}",
18748 call.caller_file.display(),
18749 call.caller_symbol,
18750 call.target,
18751 call.line,
18752 call.provenance
18753 ));
18754 }
18755 for file in &snapshot.entry_points {
18756 rows.push(format!("entry_file\t{}", file.display()));
18757 }
18758 for (file, symbols) in &snapshot.entry_point_symbols {
18759 for symbol in symbols {
18760 rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
18761 }
18762 }
18763 rows.sort();
18764 rows
18765 }
18766
18767 fn assert_direct_caller(
18768 store: &CallGraphStore,
18769 target_rel: &str,
18770 target_symbol: &str,
18771 caller_rel: &str,
18772 caller_symbol: &str,
18773 ) {
18774 let callers = store
18775 .direct_callers_of(Path::new(target_rel), target_symbol)
18776 .unwrap_or_else(|error| {
18777 panic!("direct callers for {target_rel}::{target_symbol}: {error}")
18778 });
18779 assert!(
18780 callers.iter().any(|site| {
18781 site.caller.file == caller_rel && site.caller.symbol == caller_symbol
18782 }),
18783 "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
18784 );
18785 }
18786
18787 fn assert_projected_call(
18788 root: &Path,
18789 snapshot: &CallgraphSnapshot,
18790 target_rel: &str,
18791 symbol: &str,
18792 ) {
18793 let target = projected_target(root, target_rel, symbol);
18794 assert!(
18795 snapshot.outbound_calls.iter().any(|call| {
18796 call.target == target
18797 || call.target.starts_with(&format!(
18798 "{target}{}",
18799 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
18800 ))
18801 }),
18802 "expected projected call to {target}; calls: {:#?}",
18803 snapshot.outbound_calls
18804 );
18805 }
18806
18807 fn assert_no_projected_call(
18808 root: &Path,
18809 snapshot: &CallgraphSnapshot,
18810 target_rel: &str,
18811 symbol: &str,
18812 ) {
18813 let target = projected_target(root, target_rel, symbol);
18814 assert!(
18815 snapshot.outbound_calls.iter().all(|call| {
18816 call.target != target
18817 && !call.target.starts_with(&format!(
18818 "{target}{}",
18819 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
18820 ))
18821 }),
18822 "did not expect projected call to {target}; calls: {:#?}",
18823 snapshot.outbound_calls
18824 );
18825 }
18826
18827 fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
18828 let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
18831 format!("{}::{symbol}", path.display())
18832 }
18833
18834 fn write_rust_manifest(root: &Path, name: &str) {
18835 write_file(
18836 root,
18837 "Cargo.toml",
18838 &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
18839 );
18840 }
18841
18842 fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
18843 let path = root.join(rel_path);
18844 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
18845 fs::write(&path, source).expect("write fixture file");
18846 path
18847 }
18848
18849 fn rust_files(root: &Path) -> Vec<PathBuf> {
18850 let mut files = Vec::new();
18851 collect_rust_files(root, &mut files);
18852 files.sort();
18853 files
18854 }
18855
18856 fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
18857 for entry in fs::read_dir(dir).expect("read fixture dir") {
18858 let entry = entry.expect("read fixture entry");
18859 let path = entry.path();
18860 if path.is_dir() {
18861 let name = path
18862 .file_name()
18863 .and_then(|name| name.to_str())
18864 .unwrap_or("");
18865 if !name.starts_with(".store") {
18866 collect_rust_files(&path, files);
18867 }
18868 } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
18869 files.push(path);
18870 }
18871 }
18872 }
18873}
18874
18875#[cfg(test)]
18876mod build_pool_tests {
18877 use super::build_pool_size;
18878
18879 #[test]
18880 fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
18881 let size = build_pool_size();
18882 assert!(size >= 1, "pool size must be at least 1");
18885 assert!(size <= 8, "pool size must be capped at 8, got {size}");
18886
18887 let cores = std::thread::available_parallelism()
18888 .map(|p| p.get())
18889 .unwrap_or(1);
18890 let expected = cores.div_ceil(2).clamp(1, 8);
18891 assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
18892 }
18893}
18894
18895#[cfg(test)]
18896mod reexport_resolution_tests {
18897 use super::*;
18898
18899 fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
18900 ProjectIndex {
18901 facts: Rc::new(DiskFacts::new(Path::new("/fixture"))),
18902 unbound_non_utf8_paths: Vec::new(),
18903 project_root: PathBuf::from("/fixture"),
18904 files: files.into_iter().collect(),
18905 caller_data: HashMap::new(),
18906 workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
18907 rust_crate_roots: callgraph::RustCrateRootMemo::default(),
18908 }
18909 }
18910
18911 fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
18912 DbFileIndex {
18913 lang: None,
18914 exports: HashSet::new(),
18915 default_export: None,
18916 export_aliases: HashMap::new(),
18917 node_by_scoped: HashMap::new(),
18918 node_by_bare: HashMap::new(),
18919 node_kind_by_id: HashMap::new(),
18920 module_targets: HashMap::new(),
18921 declared_module_targets: HashMap::new(),
18922 reexports: reexport_targets
18923 .iter()
18924 .map(|target| ReexportIndex {
18925 target_file: Some((*target).to_string()),
18926 named: HashMap::new(),
18927 wildcard: true,
18928 })
18929 .collect(),
18930 }
18931 }
18932
18933 #[test]
18940 fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
18941 let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
18942 let files = names
18943 .iter()
18944 .map(|name| {
18945 let targets: Vec<&str> = names
18946 .iter()
18947 .filter(|other| *other != name)
18948 .map(String::as_str)
18949 .collect();
18950 (name.clone(), barrel_file(&targets))
18951 })
18952 .collect();
18953 let index = barrel_index(files);
18954
18955 assert_eq!(
18956 resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
18957 None
18958 );
18959 }
18960
18961 #[test]
18967 fn shallow_revisit_after_deep_capped_visit_still_resolves() {
18968 let mut leaf = barrel_file(&[]);
18969 leaf.exports.insert("deep_symbol".to_string());
18970 let mut files: Vec<(String, DbFileIndex)> = Vec::new();
18971 files.push((
18974 "src/entry.ts".to_string(),
18975 barrel_file(&["src/chain0.ts", "src/shared.ts"]),
18976 ));
18977 for i in 0..15 {
18978 let next = if i == 14 {
18979 "src/shared.ts".to_string()
18980 } else {
18981 format!("src/chain{}.ts", i + 1)
18982 };
18983 files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
18984 }
18985 files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
18986 files.push(("src/leaf.ts".to_string(), leaf));
18987 let index = barrel_index(files);
18988
18989 assert_eq!(
18990 resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
18991 Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
18992 "a shallower re-visit must not be pruned by a deeper capped visit"
18993 );
18994 }
18995
18996 #[test]
18997 fn symbol_reachable_through_reexport_cycle_still_resolves() {
18998 let mut leaf = barrel_file(&[]);
18999 leaf.exports.insert("real_symbol".to_string());
19000 let index = barrel_index(vec![
19001 (
19002 "src/a.ts".to_string(),
19003 barrel_file(&["src/b.ts", "src/a.ts"]),
19004 ),
19005 (
19006 "src/b.ts".to_string(),
19007 barrel_file(&["src/a.ts", "src/leaf.ts"]),
19008 ),
19009 ("src/leaf.ts".to_string(), leaf),
19010 ]);
19011
19012 assert_eq!(
19013 resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
19014 Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
19015 );
19016 }
19017}
19018
19019#[cfg(test)]
19020mod method_dispatch_inference_tests {
19021 use super::*;
19022 use std::fs;
19023 use tempfile::tempdir;
19024
19025 #[test]
19026 fn java_field_receiver_type_selects_declared_class_method() {
19027 let source = r#"class EntryPoint {
19028 private UserService userService;
19029
19030 void handle() {
19031 userService.find();
19032 }
19033}
19034
19035class UserService {
19036 void find() {}
19037}
19038
19039class AuditService {
19040 void find() {}
19041}
19042"#;
19043 let dir = tempdir().expect("temp dir");
19044 let root = dir.path();
19045 write_fixture(root, "src/EntryPoint.java", source);
19046 let reference = reference(
19047 "java",
19048 "src/EntryPoint.java",
19049 "EntryPoint::handle",
19050 "userService",
19051 "find",
19052 line_of(source, "userService.find()"),
19053 );
19054 let mut cache = DispatchSourceCache::new();
19055
19056 let receiver_type =
19057 infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
19058 assert_eq!(receiver_type, "UserService");
19059
19060 let candidates = vec![
19061 method_candidate("audit", "AuditService::find"),
19062 method_candidate("user", "UserService::find"),
19063 ];
19064 let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
19065 .expect("type candidate");
19066 assert_eq!(selected.scoped_name, "UserService::find");
19067
19068 let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
19069 assert!(
19070 select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
19071 );
19072 }
19073
19074 #[test]
19075 fn kotlin_property_and_local_value_types_are_inferred() {
19076 let source = r#"class Handler {
19077 private val auditService: AuditService = AuditService()
19078
19079 fun handle() {
19080 auditService.find()
19081 val userService: UserService = UserService()
19082 userService.find()
19083 val billingService = BillingService()
19084 billingService.find()
19085 }
19086}
19087
19088class UserService { fun find() {} }
19089class AuditService { fun find() {} }
19090class BillingService { fun find() {} }
19091"#;
19092 let dir = tempdir().expect("temp dir");
19093 let root = dir.path();
19094 write_fixture(root, "src/Handler.kt", source);
19095 let mut cache = DispatchSourceCache::new();
19096
19097 let audit_ref = reference(
19098 "kotlin",
19099 "src/Handler.kt",
19100 "Handler::handle",
19101 "auditService",
19102 "find",
19103 line_of(source, "auditService.find()"),
19104 );
19105 assert_eq!(
19106 infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
19107 Some("AuditService")
19108 );
19109
19110 let user_ref = reference(
19111 "kotlin",
19112 "src/Handler.kt",
19113 "Handler::handle",
19114 "userService",
19115 "find",
19116 line_of(source, "userService.find()"),
19117 );
19118 assert_eq!(
19119 infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
19120 Some("UserService")
19121 );
19122
19123 let billing_ref = reference(
19124 "kotlin",
19125 "src/Handler.kt",
19126 "Handler::handle",
19127 "billingService",
19128 "find",
19129 line_of(source, "billingService.find()"),
19130 );
19131 assert_eq!(
19132 infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
19133 Some("BillingService")
19134 );
19135 }
19136
19137 #[test]
19138 fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
19139 let source = r#"struct Foo { void run(); };
19140struct PointerFoo { void run(); };
19141struct FactoryFoo { void run(); };
19142FactoryFoo makeFactoryFoo();
19143
19144void handle() {
19145 Foo foo;
19146 foo.run();
19147 PointerFoo* pointerFoo = nullptr;
19148 pointerFoo->run();
19149 auto factoryFoo = makeFactoryFoo();
19150 factoryFoo.run();
19151}
19152"#;
19153 let dir = tempdir().expect("temp dir");
19154 let root = dir.path();
19155 write_fixture(root, "src/fixture.cpp", source);
19156 let mut cache = DispatchSourceCache::new();
19157
19158 let foo_ref = reference(
19159 "cpp",
19160 "src/fixture.cpp",
19161 "handle",
19162 "foo",
19163 "run",
19164 line_of(source, "foo.run()"),
19165 );
19166 assert_eq!(
19167 infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
19168 Some("Foo")
19169 );
19170
19171 let pointer_ref = reference(
19172 "cpp",
19173 "src/fixture.cpp",
19174 "handle",
19175 "pointerFoo",
19176 "run",
19177 line_of(source, "pointerFoo->run()"),
19178 );
19179 assert_eq!(
19180 infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
19181 Some("PointerFoo")
19182 );
19183
19184 let factory_ref = reference(
19185 "cpp",
19186 "src/fixture.cpp",
19187 "handle",
19188 "factoryFoo",
19189 "run",
19190 line_of(source, "factoryFoo.run()"),
19191 );
19192 assert_eq!(
19193 infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
19194 Some("FactoryFoo")
19195 );
19196 }
19197
19198 #[test]
19199 fn rust_direct_self_field_name_trims_separator_whitespace() {
19200 for receiver_expression in ["self .engine", "self. engine", "self . engine"] {
19201 assert_eq!(
19202 rust_direct_self_field_name(receiver_expression),
19203 Some("engine")
19204 );
19205 }
19206 }
19207
19208 #[test]
19209 fn rust_direct_self_field_receiver_type_is_conservative() {
19210 let source = r#"struct Engine;
19211
19212struct Car {
19213 engine: Engine,
19214}
19215
19216impl Car {
19217 fn run(&self) {
19218 self.engine.start();
19219 }
19220}
19221
19222struct NestedCar {
19223 engine: Engine,
19224}
19225
19226impl NestedCar {
19227 fn run(&self) {
19228 self.inner.engine.start();
19229 }
19230}
19231
19232struct WrappedCar {
19233 engine: Option<Engine>,
19234}
19235
19236impl WrappedCar {
19237 fn run(&self) {
19238 self.engine.start(); // wrapped
19239 }
19240}
19241
19242struct GenericCar<T> {
19243 engine: T,
19244}
19245
19246impl<T> GenericCar<T> {
19247 fn run(&self) {
19248 self.engine.start(); // generic
19249 }
19250}
19251
19252type EngineAlias = Engine;
19253
19254struct AliasCar {
19255 engine: EngineAlias,
19256}
19257
19258impl AliasCar {
19259 fn run(&self) {
19260 self.engine.start(); // alias
19261 }
19262}
19263"#;
19264 let dir = tempdir().expect("temp dir");
19265 let root = dir.path();
19266 write_fixture(root, "src/lib.rs", source);
19267 let mut cache = DispatchSourceCache::new();
19268
19269 let mut direct = reference(
19270 "rust",
19271 "src/lib.rs",
19272 "Car::run",
19273 "engine",
19274 "start",
19275 line_of(source, "self.engine.start()"),
19276 );
19277 direct.receiver_expression = "self.engine".to_string();
19278 assert_eq!(
19279 infer_receiver_type(root, &direct, &mut cache).as_deref(),
19280 Some("Engine")
19281 );
19282
19283 let mut mismatched_impl_target = direct.clone();
19284 mismatched_impl_target.caller_symbol = "other::Car::run".to_string();
19285 assert!(infer_receiver_type(root, &mismatched_impl_target, &mut cache).is_none());
19286
19287 let mut nested = reference(
19288 "rust",
19289 "src/lib.rs",
19290 "NestedCar::run",
19291 "engine",
19292 "start",
19293 line_of(source, "self.inner.engine.start()"),
19294 );
19295 nested.receiver_expression = "self.inner.engine".to_string();
19296 assert!(infer_receiver_type(root, &nested, &mut cache).is_none());
19297
19298 let mut wrapped = reference(
19299 "rust",
19300 "src/lib.rs",
19301 "WrappedCar::run",
19302 "engine",
19303 "start",
19304 line_of(source, "self.engine.start(); // wrapped"),
19305 );
19306 wrapped.receiver_expression = "self.engine".to_string();
19307 assert!(infer_receiver_type(root, &wrapped, &mut cache).is_none());
19308
19309 let mut generic = reference(
19310 "rust",
19311 "src/lib.rs",
19312 "GenericCar::run",
19313 "engine",
19314 "start",
19315 line_of(source, "self.engine.start(); // generic"),
19316 );
19317 generic.receiver_expression = "self.engine".to_string();
19318 assert!(infer_receiver_type(root, &generic, &mut cache).is_none());
19319
19320 let mut alias = reference(
19321 "rust",
19322 "src/lib.rs",
19323 "AliasCar::run",
19324 "engine",
19325 "start",
19326 line_of(source, "self.engine.start(); // alias"),
19327 );
19328 alias.receiver_expression = "self.engine".to_string();
19329 assert!(infer_receiver_type(root, &alias, &mut cache).is_none());
19330 }
19331
19332 #[test]
19333 fn rust_direct_self_reference_field_receiver_is_not_inferred() {
19334 let source = r#"struct Engine;
19335
19336struct Car {
19337 engine: &'static Engine,
19338}
19339
19340impl Car {
19341 fn run(&self) {
19342 self.engine.start();
19343 }
19344}
19345"#;
19346 let dir = tempdir().expect("temp dir");
19347 let root = dir.path();
19348 write_fixture(root, "src/lib.rs", source);
19349 let mut cache = DispatchSourceCache::new();
19350 let mut reference = reference(
19351 "rust",
19352 "src/lib.rs",
19353 "Car::run",
19354 "engine",
19355 "start",
19356 line_of(source, "self.engine.start()"),
19357 );
19358 reference.receiver_expression = "self.engine".to_string();
19359
19360 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
19361 }
19362
19363 #[test]
19364 fn rust_trait_impl_self_field_receiver_is_not_inferred() {
19365 let source = r#"trait Drive {
19366 fn run(&self);
19367}
19368
19369struct Engine;
19370
19371struct Car {
19372 engine: Engine,
19373}
19374
19375impl Drive for Car {
19376 fn run(&self) {
19377 self.engine.start();
19378 }
19379}
19380"#;
19381 let dir = tempdir().expect("temp dir");
19382 let root = dir.path();
19383 write_fixture(root, "src/lib.rs", source);
19384 let mut cache = DispatchSourceCache::new();
19385 let mut reference = reference(
19386 "rust",
19387 "src/lib.rs",
19388 "Car::run",
19389 "engine",
19390 "start",
19391 line_of(source, "self.engine.start()"),
19392 );
19393 reference.receiver_expression = "self.engine".to_string();
19394
19395 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
19396 }
19397
19398 #[test]
19399 fn rust_self_field_does_not_bind_struct_from_another_module() {
19400 let source = r#"struct Engine;
19401
19402mod unrelated {
19403 struct Car {
19404 engine: Engine,
19405 }
19406}
19407
19408impl Car {
19409 fn run(&self) {
19410 self.engine.start();
19411 }
19412}
19413"#;
19414 let dir = tempdir().expect("temp dir");
19415 let root = dir.path();
19416 write_fixture(root, "src/lib.rs", source);
19417 let mut cache = DispatchSourceCache::new();
19418 let mut reference = reference(
19419 "rust",
19420 "src/lib.rs",
19421 "Car::run",
19422 "engine",
19423 "start",
19424 line_of(source, "self.engine.start()"),
19425 );
19426 reference.receiver_expression = "self.engine".to_string();
19427
19428 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
19429 }
19430
19431 #[test]
19432 fn unknown_java_receiver_still_uses_name_match_fallback() {
19433 let source = r#"class EntryPoint {
19434 void handle() {
19435 service.runSpecial();
19436 }
19437}
19438
19439class OnlyService {
19440 void runSpecial() {}
19441}
19442"#;
19443 let dir = tempdir().expect("temp dir");
19444 let root = dir.path();
19445 write_fixture(root, "src/EntryPoint.java", source);
19446 let reference = reference(
19447 "java",
19448 "src/EntryPoint.java",
19449 "EntryPoint::handle",
19450 "service",
19451 "runSpecial",
19452 line_of(source, "service.runSpecial()"),
19453 );
19454 let mut cache = DispatchSourceCache::new();
19455
19456 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
19457 let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
19458 let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
19459 assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
19460 }
19461
19462 fn reference(
19463 lang: &str,
19464 caller_file: &str,
19465 caller_symbol: &str,
19466 receiver: &str,
19467 method_name: &str,
19468 line: u32,
19469 ) -> NameMatchRef {
19470 NameMatchRef {
19471 ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
19472 caller_node: format!("{caller_symbol}:node"),
19473 caller_file: caller_file.to_string(),
19474 caller_symbol: caller_symbol.to_string(),
19475 caller_signature: None,
19476 receiver_expression: receiver.to_string(),
19477 receiver: receiver.to_string(),
19478 method_name: method_name.to_string(),
19479 colon_dispatch: false,
19480 line,
19481 lang: lang.to_string(),
19482 }
19483 }
19484
19485 fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
19486 NameMatchCandidate {
19487 node_id: node_id.to_string(),
19488 file_path: "src/targets.fixture".to_string(),
19489 scoped_name: scoped_name.to_string(),
19490 kind: "method".to_string(),
19491 start_line: 1,
19492 }
19493 }
19494
19495 fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
19496 let path = root.join(rel_path);
19497 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
19498 fs::write(path, source).expect("write fixture");
19499 }
19500
19501 fn line_of(source: &str, needle: &str) -> u32 {
19502 source
19503 .lines()
19504 .position(|line| line.contains(needle))
19505 .map(|index| index as u32 + 1)
19506 .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
19507 }
19508}
19509
19510#[cfg(test)]
19511mod bounded_build_breaker_tests {
19512 use super::*;
19513 use crate::build_breaker::{BreakerAdmission, BreakerKey, BuildDeathBreaker, BuildDomain};
19514 use tempfile::tempdir;
19515
19516 #[test]
19517 fn staged_inventory_drives_ordered_bounded_file_batches() {
19518 let temp = tempdir().unwrap();
19519 let root = temp.path().join("root");
19520 std::fs::create_dir_all(&root).unwrap();
19521 let first = root.join("a.ts");
19522 let second = root.join("b.ts");
19523 let third = root.join("c.ts");
19524 for path in [&first, &second, &third] {
19525 std::fs::write(path, "export function item() {}\n").unwrap();
19526 }
19527 let writer_lease = acquire_writer_lease(temp.path(), "inventory-key", &root)
19528 .unwrap()
19529 .expect("test root may write its private staging database");
19530 let store = CallGraphStore::open_at_path(
19531 root.clone(),
19532 "inventory-key".to_string(),
19533 temp.path().join("inventory.sqlite"),
19534 None,
19535 true,
19536 Some(writer_lease),
19537 None,
19538 )
19539 .unwrap()
19540 .store;
19541 let fingerprint = store
19542 .stage_cold_build_file_inventory(&[
19543 third.clone(),
19544 first.clone(),
19545 second.clone(),
19546 first.clone(),
19547 ])
19548 .unwrap();
19549
19550 let conn = store.conn.lock().unwrap();
19551 assert_eq!(
19552 query_count(&conn, "SELECT COUNT(*) FROM staging_file_inventory").unwrap(),
19553 3,
19554 "the primary key deduplicates caller-supplied paths on disk"
19555 );
19556 let first_batch = load_staged_file_batch(&conn, &root, "", 2, u64::MAX)
19557 .unwrap()
19558 .expect("first batch");
19559 assert_eq!(first_batch.paths, vec![first.clone(), second]);
19560 let second_batch =
19561 load_staged_file_batch(&conn, &root, &first_batch.last_path, 2, u64::MAX)
19562 .unwrap()
19563 .expect("second batch");
19564 assert_eq!(second_batch.paths, vec![third]);
19565 assert_eq!(
19566 fingerprint,
19567 callgraph_corpus_fingerprint(&root).unwrap(),
19568 "staged and direct streaming fingerprints agree without walk-order dependence"
19569 );
19570 }
19571
19572 #[test]
19573 fn resumed_stage_preserves_committed_batch_and_counter() {
19574 let temp = tempdir().unwrap();
19575 let root = temp.path().join("root");
19576 std::fs::create_dir_all(&root).unwrap();
19577 let first = root.join("first.ts");
19578 let second = root.join("second.ts");
19579 std::fs::write(&first, "export function first() {}\n").unwrap();
19580 std::fs::write(&second, "export function second() { first(); }\n").unwrap();
19581 let staging = temp.path().join("stage.sqlite");
19582 let writer_lease = acquire_writer_lease(temp.path(), "test-key", &root)
19583 .unwrap()
19584 .expect("test root may write its private staging database");
19585 let store = CallGraphStore::open_at_path(
19586 root.clone(),
19587 "test-key".to_string(),
19588 staging,
19589 None,
19590 true,
19591 Some(writer_lease),
19592 None,
19593 )
19594 .unwrap()
19595 .store;
19596 let corpus_fingerprint = store
19597 .stage_cold_build_file_inventory(&[first.clone(), second.clone()])
19598 .unwrap();
19599 let first_extract = build_file_extract(&root, &first).unwrap();
19600 let first_bytes = first_extract.freshness.size;
19601 {
19602 let mut conn = store.conn.lock().unwrap();
19603 let tx = conn.transaction().unwrap();
19604 clear_tables(&tx).unwrap();
19605 insert_meta(&tx).unwrap();
19606 drop_cold_build_secondary_indexes(&tx).unwrap();
19607 set_meta_ready(&tx, false).unwrap();
19608 set_staged_build_phase(&tx, "extracting").unwrap();
19609 set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, &corpus_fingerprint).unwrap();
19610 set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0).unwrap();
19611 {
19612 let mut inserts = ColdBuildInsertStatements::new(&tx).unwrap();
19613 insert_file_extract_prepared(
19614 &mut inserts,
19615 &root.display().to_string(),
19616 &first_extract,
19617 )
19618 .unwrap();
19619 for raw in &first_extract.raw_refs {
19620 insert_staged_ref_prepared(&mut inserts, raw).unwrap();
19621 }
19622 }
19623 increment_staged_extracted_bytes(&tx, first_bytes).unwrap();
19624 tx.commit().unwrap();
19625 }
19626
19627 store
19628 .cold_build_chunked(&[first.clone(), second.clone()], 1)
19629 .unwrap();
19630 let conn = store.conn.lock().unwrap();
19631 assert_eq!(query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(), 2);
19632 assert_eq!(
19633 staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
19634 first_bytes + std::fs::metadata(second).unwrap().len(),
19635 "the already committed batch and its credit survive adoption; only the new batch increments credit"
19636 );
19637 assert_eq!(staged_build_phase(&conn).unwrap().as_deref(), Some("ready"));
19638 }
19639
19640 const SPECIMEN_CHILD_TEST: &str =
19641 "callgraph_store::bounded_build_breaker_tests::respawn_loop_build_child";
19642 const SPECIMEN_CHILD_ROOT: &str = "AFT_SPECIMEN_CHILD_ROOT";
19643 const SPECIMEN_CHILD_STORE: &str = "AFT_SPECIMEN_CHILD_STORE";
19644 const SPECIMEN_CHILD_PHASE: &str = "AFT_SPECIMEN_CHILD_PHASE";
19645 const SPECIMEN_CHILD_SIGNAL: &str = "AFT_SPECIMEN_CHILD_SIGNAL";
19646
19647 fn wait_for_child_barrier(path: &Path) {
19648 let deadline = Instant::now() + Duration::from_secs(10);
19649 while !path.exists() {
19650 assert!(
19651 Instant::now() < deadline,
19652 "callgraph child did not reach barrier {}",
19653 path.display()
19654 );
19655 std::thread::sleep(Duration::from_millis(5));
19656 }
19657 }
19658
19659 fn spawn_build_child(root: &Path, store: &Path, phase: Option<&str>) -> std::process::Child {
19660 let signal = store.join("specimen-child.reached");
19661 let _ = std::fs::remove_file(&signal);
19662 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
19663 command
19664 .arg("--exact")
19665 .arg(SPECIMEN_CHILD_TEST)
19666 .arg("--nocapture")
19667 .arg("--test-threads=1")
19668 .env(SPECIMEN_CHILD_ROOT, root)
19669 .env(SPECIMEN_CHILD_STORE, store)
19670 .env(SPECIMEN_CHILD_SIGNAL, &signal)
19671 .stdout(std::process::Stdio::null())
19672 .stderr(std::process::Stdio::null());
19673 if let Some(phase) = phase {
19674 command.env(SPECIMEN_CHILD_PHASE, phase);
19675 }
19676 command.spawn().unwrap()
19677 }
19678
19679 fn staging_path(root: &Path, store: &Path) -> PathBuf {
19680 let project_key = crate::search_index::artifact_cache_key(root);
19681 store.join(format!("{project_key}.staging.sqlite.tmp.resume"))
19682 }
19683
19684 fn durable_staging_state(path: &Path) -> (u64, u64) {
19685 if !path.exists() {
19686 return (0, 0);
19687 }
19688 let conn = Connection::open(path).unwrap();
19689 (
19690 query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(),
19691 staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
19692 )
19693 }
19694
19695 fn kill_barrier_child(child: &mut std::process::Child, signal: &Path) {
19696 wait_for_child_barrier(signal);
19697 child.kill().unwrap();
19698 let _ = child.wait().unwrap();
19699 }
19700
19701 #[test]
19702 fn respawn_loop_build_child() {
19703 let Some(root) = std::env::var_os(SPECIMEN_CHILD_ROOT) else {
19704 return;
19705 };
19706 let root = PathBuf::from(root);
19707 let store = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_STORE).unwrap());
19708 if let Some(phase) = std::env::var_os(SPECIMEN_CHILD_PHASE) {
19709 let phase = phase.to_string_lossy().into_owned();
19710 let signal = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_SIGNAL).unwrap());
19711 set_cold_build_phase_observer(Some(Arc::new(move |observed| {
19712 if observed == phase {
19713 std::fs::write(&signal, observed.as_bytes()).unwrap();
19714 std::thread::sleep(Duration::from_secs(30));
19715 }
19716 })));
19717 }
19718 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
19719 CallGraphStore::cold_build_with_lease_chunked(store, root, &files, 1).unwrap();
19720 }
19721
19722 #[test]
19723 fn issue_250_respawn_loop_converges_or_trips_without_false_readiness() {
19724 let temp = tempdir().unwrap();
19725 let root = temp.path().join("resumable-root");
19726 let store = temp.path().join("resumable-store");
19727 std::fs::create_dir_all(&root).unwrap();
19728 std::fs::create_dir_all(&store).unwrap();
19729 for index in 0..3 {
19730 std::fs::write(
19731 root.join(format!("file-{index}.ts")),
19732 format!("export function specimen{index}() {{ return {index}; }}\n"),
19733 )
19734 .unwrap();
19735 }
19736 let stage = staging_path(&root, &store);
19737 let signal = store.join("specimen-child.reached");
19738
19739 let mut first = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
19740 kill_barrier_child(&mut first, &signal);
19741 let (first_rows, first_bytes) = durable_staging_state(&stage);
19742 assert_eq!(first_rows, 1);
19743 assert!(first_bytes > 0);
19744
19745 let mut second = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
19746 kill_barrier_child(&mut second, &signal);
19747 let (second_rows, second_bytes) = durable_staging_state(&stage);
19748 assert_eq!(second_rows, 2);
19749 assert!(
19750 second_bytes > first_bytes,
19751 "a replacement process must adopt committed bytes instead of restarting from zero"
19752 );
19753
19754 let status = spawn_build_child(&root, &store, None).wait().unwrap();
19755 assert!(status.success(), "uninterrupted replacement build failed");
19756 assert!(!stage.exists(), "published staging file must be renamed");
19757 let ready = CallGraphStore::open_readonly(store.clone(), root.clone())
19758 .unwrap()
19759 .expect("replacement attempts must converge to a published graph");
19760 assert_eq!(ready.indexed_file_count().unwrap(), 3);
19761
19762 let fast_root = temp.path().join("zero-credit-root");
19763 let fast_store = temp.path().join("zero-credit-store");
19764 std::fs::create_dir_all(&fast_root).unwrap();
19765 std::fs::create_dir_all(&fast_store).unwrap();
19766 std::fs::write(
19767 fast_root.join("main.ts"),
19768 "export function neverCommitted() {}\n",
19769 )
19770 .unwrap();
19771 let fast_stage = staging_path(&fast_root, &fast_store);
19772 let fast_signal = fast_store.join("specimen-child.reached");
19773 let breaker_path = fast_store.join("build-breaker.sqlite");
19774 let now = unix_millis_now();
19775
19776 for death in 0..3 {
19777 let mut child = spawn_build_child(&fast_root, &fast_store, Some("enumeration"));
19778 wait_for_child_barrier(&fast_signal);
19779 let attempt_id = Connection::open(&breaker_path)
19780 .unwrap()
19781 .query_row(
19782 "SELECT attempt_id FROM breaker_attempts
19783 WHERE death_charged = 0 ORDER BY rowid DESC LIMIT 1",
19784 [],
19785 |row| row.get::<_, String>(0),
19786 )
19787 .unwrap();
19788 let (_, committed_bytes) = durable_staging_state(&fast_stage);
19789 assert_eq!(
19790 committed_bytes, 0,
19791 "the fast-kill schedule must not cross an extraction commit"
19792 );
19793 child.kill().unwrap();
19794 let _ = child.wait().unwrap();
19795
19796 let key = BreakerKey::new(
19797 fast_root.display().to_string(),
19798 BuildDomain::CallgraphCold,
19799 callgraph_corpus_fingerprint(&fast_root).unwrap(),
19800 );
19801 BuildDeathBreaker::open(&breaker_path)
19802 .unwrap()
19803 .record_attributed_death_at(&key, &attempt_id, committed_bytes, 0, now + death)
19804 .unwrap();
19805 }
19806
19807 let files = crate::callgraph::walk_project_files(&fast_root).collect::<Vec<_>>();
19808 let suspension = CallGraphStore::cold_build_suspension(&fast_store, &fast_root)
19809 .unwrap()
19810 .expect("three zero-credit process deaths must suspend the root");
19811 assert_eq!(suspension.reason, "zero_credit_death_limit");
19812 assert_eq!(suspension.death_count, 3);
19813 let response = crate::commands::callgraph_store_adapter::suspended_response(
19814 "specimen",
19815 "callers",
19816 &suspension,
19817 );
19818 assert_eq!(response.data["code"], serde_json::json!("build_suspended"));
19819 let message = response.data["message"].as_str().unwrap();
19820 assert!(
19821 message.starts_with("callers: build_suspended domain=callgraph_cold deaths=3 age_ms=")
19822 );
19823 assert!(message.ends_with(
19824 " reason=zero_credit_death_limit; run doctor reset-build-breaker to resume"
19825 ));
19826 let refused =
19827 CallGraphStore::cold_build_with_lease_chunked(fast_store, fast_root, &files, 1)
19828 .expect_err("a suspended root must not report a perpetually building worker");
19829 assert!(matches!(refused, CallGraphStoreError::Suspended(_)));
19830 }
19831
19832 #[test]
19833 fn published_callgraph_build_respects_durable_domain_suspension() {
19834 let temp = tempdir().unwrap();
19835 let root = temp.path().join("root");
19836 let store_dir = temp.path().join("store");
19837 std::fs::create_dir_all(&root).unwrap();
19838 let source = root.join("main.ts");
19839 std::fs::write(&source, "export function marker() {}\n").unwrap();
19840 let files = vec![source];
19841 let key = BreakerKey::new(
19842 root.display().to_string(),
19843 BuildDomain::CallgraphCold,
19844 callgraph_corpus_fingerprint(&root).unwrap(),
19845 );
19846 let breaker = BuildDeathBreaker::open(store_dir.join("build-breaker.sqlite")).unwrap();
19847 for _ in 0..3 {
19848 let BreakerAdmission::Admitted(attempt) = breaker.admit(&key, 0).unwrap() else {
19849 panic!("unexpected early suspension");
19850 };
19851 breaker
19852 .record_attributed_death(&key, &attempt.attempt_id, 0, 0)
19853 .unwrap();
19854 }
19855
19856 let error = CallGraphStore::cold_build_with_lease_chunked(store_dir, root, &files, 1)
19857 .expect_err("durably tripped callgraph domain must refuse a new cold build");
19858 assert!(matches!(
19859 error,
19860 CallGraphStoreError::Suspended(ref suspension)
19861 if suspension.domain == BuildDomain::CallgraphCold
19862 && suspension.death_count == 3
19863 ));
19864 }
19865}