1use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
9use crate::callgraph::{self, EdgeResolution, FileCallData, TraceToSymbolCandidate};
10use crate::context::SubcLifecycleAdmission;
11use crate::error::AftError;
12use crate::imports::{ImportForm, ImportGroup, ImportKind, ImportStatement};
13use crate::parser::{grammar_for, LangId};
14use crate::symbols::{Range, SymbolKind};
15use rayon::prelude::*;
16use rusqlite::{
17 params, params_from_iter, Connection, OpenFlags, OptionalExtension, Statement, Transaction,
18};
19use std::collections::{hash_map::Entry, BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
20use std::fmt;
21use std::io::Read;
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
24use std::sync::{Arc, Condvar, Mutex, OnceLock};
25use std::thread::JoinHandle;
26use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
27use tree_sitter::{Node, Parser};
28
29const SCHEMA_VERSION: i64 = 1;
30const BACKEND_TREESITTER: &str = "treesitter";
31const PROVENANCE_TREESITTER: &str = "treesitter+resolver";
32const PROVENANCE_NAME_MATCH: &str = "name_match";
33const PROVENANCE_TYPE_MATCH: &str = "type_match";
34const PROVENANCE_VALUE_REF: &str = "value_ref";
35const NAME_MATCH_SCORE_THRESHOLD: f64 = 2.0;
36const TOP_LEVEL_SYMBOL: &str = "<top-level>";
37const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
38const MIGRATION_MANIFEST_VERSION: u32 = 1;
39const MIGRATION_GENERATION_TAG: &str = ".migrated.";
40const MIGRATION_BACKUP_PAGES_PER_STEP: i32 = 128;
41const MIGRATION_BACKUP_RETRY_BUDGET: usize = 25;
42const MIGRATION_BACKUP_WALL_CLOCK_BUDGET: Duration = Duration::from_secs(10);
43const SQLITE_FILE_SET_SUFFIXES: &[&str] = &["", "-wal", "-shm", "-journal"];
44const MARKED_GENERATION_RETENTION_TTL: Duration = Duration::from_secs(6 * 60 * 60);
49const REFRESH_WORKER_WARN_AFTER: Duration = Duration::from_secs(5);
50const REFRESH_WORKER_FINAL_AFTER: Duration = Duration::from_secs(30);
51pub const REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET: Duration = Duration::from_millis(100);
52const REBUILD_COOLDOWN: Duration = Duration::from_secs(30);
53const ROOT_REPAIR_WARN_INTERVAL: Duration = Duration::from_secs(60);
54const CALLGRAPH_WRITE_METRIC_WINDOW: Duration = Duration::from_secs(60);
55const CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES: i64 = 4_000;
56const CALLGRAPH_SQLITE_CACHE_KIB: i64 = -8 * 1024;
59const REFRESH_IDLE_CHECKPOINT_INTERVAL: Duration = Duration::from_secs(60);
60
61const COLD_BUILD_EXTRACT_BATCH_FILES: usize = 256;
64const COLD_BUILD_EXTRACT_BATCH_BYTES: u64 = 32 * 1024 * 1024;
65const COLD_BUILD_RESOLVE_WINDOW: usize = 20_000;
68const STAGED_COMMITTED_EXTRACTED_BYTES: &str = "committed_extracted_bytes";
69const STAGED_RESOLVE_CURSOR: &str = "resolve_cursor";
70const STAGED_BUILD_PHASE: &str = "staged_build_phase";
71const STAGED_CORPUS_FINGERPRINT: &str = "staged_corpus_fingerprint";
72
73fn write_amplification_baseline_enabled() -> bool {
74 std::env::var_os("AFT_CALLGRAPH_WRITE_AMP_BASELINE").is_some()
75}
76
77type ColdBuildSwapObserver = dyn Fn(&Path, &Path) + Send + Sync + 'static;
78pub type ColdBuildPhaseObserver = dyn Fn(&'static str) + Send + Sync + 'static;
79
80static COLD_BUILD_PHASE_OBSERVER: OnceLock<Mutex<Option<Arc<ColdBuildPhaseObserver>>>> =
81 OnceLock::new();
82
83pub fn set_cold_build_phase_observer(observer: Option<Arc<ColdBuildPhaseObserver>>) {
87 *COLD_BUILD_PHASE_OBSERVER
88 .get_or_init(|| Mutex::new(None))
89 .lock()
90 .expect("cold build phase observer mutex poisoned") = observer;
91}
92
93fn note_cold_build_phase(phase: &'static str) {
94 if let Some(observer) = COLD_BUILD_PHASE_OBSERVER
95 .get_or_init(|| Mutex::new(None))
96 .lock()
97 .expect("cold build phase observer mutex poisoned")
98 .as_ref()
99 .cloned()
100 {
101 observer(phase);
102 }
103}
104
105#[cfg(test)]
106fn note_cold_build_commit_barrier(phase: &'static str) {
107 note_cold_build_phase(phase);
108}
109
110#[cfg(not(test))]
111fn note_cold_build_commit_barrier(_phase: &'static str) {}
112
113#[derive(Clone, Debug, Eq, Hash, PartialEq)]
114struct RebuildCooldownKey {
115 callgraph_dir: PathBuf,
116 project_key: String,
117}
118
119#[derive(Clone, Debug)]
120struct RebuildCooldownRecord {
121 project_root: PathBuf,
122 published_at: Instant,
123 cross_root_cooldown_armed: bool,
124}
125
126static SUCCESSFUL_REBUILDS: OnceLock<Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>> =
131 OnceLock::new();
132
133#[derive(Clone, Debug, Eq, Hash, PartialEq)]
134struct RootRepairWarningKey {
135 project_key: String,
136}
137
138#[derive(Clone, Debug)]
139struct RootRepairWarningRecord {
140 window_start: Instant,
141 last_emitted: Instant,
142 entry_count: u64,
143 suppressed: u64,
144}
145
146static ROOT_REPAIR_WARNINGS: OnceLock<
147 Mutex<HashMap<RootRepairWarningKey, RootRepairWarningRecord>>,
148> = OnceLock::new();
149
150#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
151pub(crate) struct CallgraphWriteMetricsSnapshot {
152 pub commits_60s: u64,
153 pub pages_or_bytes_written_60s: u64,
154}
155
156#[derive(Debug, Default)]
157struct CallgraphWriteMetrics {
158 window_start_ms: AtomicU64,
159 commits_60s: AtomicU64,
160 pages_or_bytes_written_60s: AtomicU64,
161}
162
163static CALLGRAPH_WRITE_METRICS: OnceLock<Mutex<HashMap<String, Arc<CallgraphWriteMetrics>>>> =
164 OnceLock::new();
165
166fn callgraph_write_metrics_for_key(project_key: &str) -> Arc<CallgraphWriteMetrics> {
167 let metrics = CALLGRAPH_WRITE_METRICS.get_or_init(|| Mutex::new(HashMap::new()));
168 let mut metrics = metrics
169 .lock()
170 .expect("callgraph write metrics mutex poisoned");
171 Arc::clone(
172 metrics
173 .entry(project_key.to_string())
174 .or_insert_with(|| Arc::new(CallgraphWriteMetrics::default())),
175 )
176}
177
178fn roll_callgraph_write_metric_window(metrics: &CallgraphWriteMetrics, now_ms: u64) {
179 let current_start = metrics.window_start_ms.load(AtomicOrdering::Acquire);
180 if current_start == 0 {
181 let _ = metrics.window_start_ms.compare_exchange(
182 0,
183 now_ms,
184 AtomicOrdering::AcqRel,
185 AtomicOrdering::Acquire,
186 );
187 return;
188 }
189 if now_ms.saturating_sub(current_start) < CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64 {
190 return;
191 }
192 if metrics
193 .window_start_ms
194 .compare_exchange(
195 current_start,
196 now_ms,
197 AtomicOrdering::AcqRel,
198 AtomicOrdering::Acquire,
199 )
200 .is_ok()
201 {
202 metrics.commits_60s.store(0, AtomicOrdering::Release);
203 metrics
204 .pages_or_bytes_written_60s
205 .store(0, AtomicOrdering::Release);
206 }
207}
208
209impl CallgraphWriteMetrics {
210 fn record_commit(&self, pages_or_bytes_written: u64) {
211 let now_ms = unix_millis_now();
212 roll_callgraph_write_metric_window(self, now_ms);
213 self.commits_60s.fetch_add(1, AtomicOrdering::Relaxed);
214 self.pages_or_bytes_written_60s
215 .fetch_add(pages_or_bytes_written, AtomicOrdering::Relaxed);
216 }
217
218 fn snapshot(&self) -> CallgraphWriteMetricsSnapshot {
219 roll_callgraph_write_metric_window(self, unix_millis_now());
220 CallgraphWriteMetricsSnapshot {
221 commits_60s: self.commits_60s.load(AtomicOrdering::Acquire),
222 pages_or_bytes_written_60s: self
223 .pages_or_bytes_written_60s
224 .load(AtomicOrdering::Acquire),
225 }
226 }
227}
228
229pub(crate) fn callgraph_write_metrics_for_project(
230 project_key: &str,
231) -> CallgraphWriteMetricsSnapshot {
232 callgraph_write_metrics_for_key(project_key).snapshot()
233}
234
235pub(crate) fn callgraph_write_metrics_total() -> CallgraphWriteMetricsSnapshot {
236 let Some(metrics) = CALLGRAPH_WRITE_METRICS.get() else {
237 return CallgraphWriteMetricsSnapshot::default();
238 };
239 let metrics = metrics
240 .lock()
241 .expect("callgraph write metrics mutex poisoned");
242 metrics.values().map(|metrics| metrics.snapshot()).fold(
243 CallgraphWriteMetricsSnapshot::default(),
244 |total, current| CallgraphWriteMetricsSnapshot {
245 commits_60s: total.commits_60s.saturating_add(current.commits_60s),
246 pages_or_bytes_written_60s: total
247 .pages_or_bytes_written_60s
248 .saturating_add(current.pages_or_bytes_written_60s),
249 },
250 )
251}
252
253const ROOT_REPAIR_WARNING_TEXT: &str =
254 "callgraph store root repair requires rebuild; open-only reader reports unavailable";
255
256fn next_root_repair_warning(key: RootRepairWarningKey, now: Instant) -> Option<String> {
257 let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
258 let mut warnings = warnings.lock().ok()?;
259 let entry = warnings.entry(key);
260 let record = match entry {
261 Entry::Vacant(entry) => {
262 entry.insert(RootRepairWarningRecord {
263 window_start: now,
264 last_emitted: now,
265 entry_count: 1,
266 suppressed: 0,
267 });
268 return Some(ROOT_REPAIR_WARNING_TEXT.to_string());
269 }
270 Entry::Occupied(entry) => entry.into_mut(),
271 };
272
273 if now.saturating_duration_since(record.window_start) >= ROOT_REPAIR_WARN_INTERVAL {
274 let suppressed = record.suppressed;
275 record.window_start = now;
276 record.last_emitted = now;
277 record.entry_count = 1;
278 record.suppressed = 0;
279 return Some(if suppressed == 0 {
280 ROOT_REPAIR_WARNING_TEXT.to_string()
281 } else {
282 format!("{ROOT_REPAIR_WARNING_TEXT} (repeated {suppressed}x in 60s)")
283 });
284 }
285
286 record.entry_count = record.entry_count.saturating_add(1);
287 if now.saturating_duration_since(record.last_emitted) < ROOT_REPAIR_WARN_INTERVAL {
288 record.suppressed = record.suppressed.saturating_add(1);
289 None
290 } else {
291 record.last_emitted = now;
292 Some(ROOT_REPAIR_WARNING_TEXT.to_string())
293 }
294}
295
296pub(crate) fn note_repair_entry(project_key: &str) -> Option<String> {
297 next_root_repair_warning(
298 RootRepairWarningKey {
299 project_key: project_key.to_string(),
300 },
301 Instant::now(),
302 )
303}
304
305pub(crate) fn repair_entry_rate(project_key: &str) -> Option<(u64, Instant)> {
310 let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
311 let warnings = warnings.lock().ok()?;
312 let record = warnings.get(&RootRepairWarningKey {
313 project_key: project_key.to_string(),
314 })?;
315 (Instant::now().saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL)
316 .then_some((record.entry_count, record.window_start))
317}
318
319pub(crate) fn repair_entry_rate_total() -> u64 {
320 let Ok(warnings) = ROOT_REPAIR_WARNINGS
321 .get_or_init(|| Mutex::new(HashMap::new()))
322 .lock()
323 else {
324 return 0;
325 };
326 let now = Instant::now();
327 warnings
328 .values()
329 .filter(|record| {
330 now.saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL
331 })
332 .map(|record| record.entry_count)
333 .sum()
334}
335
336#[cfg(test)]
337pub(crate) fn expire_repair_entry_window_for_test(project_key: &str) {
338 let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
339 let mut warnings = warnings.lock().unwrap();
340 if let Some(record) = warnings.get_mut(&RootRepairWarningKey {
341 project_key: project_key.to_string(),
342 }) {
343 record.window_start = Instant::now() - ROOT_REPAIR_WARN_INTERVAL;
344 }
345}
346
347#[cfg(test)]
348mod root_repair_warning_tests {
349 use super::*;
350
351 #[test]
352 fn repair_warning_emits_once_then_reemits_with_suppressed_count() {
353 let key = RootRepairWarningKey {
354 project_key: "test-project".to_string(),
355 };
356 let first_at = Instant::now();
357 let first = next_root_repair_warning(key.clone(), first_at).unwrap();
358 assert_eq!(first, ROOT_REPAIR_WARNING_TEXT);
359 assert!(next_root_repair_warning(key.clone(), first_at + Duration::from_secs(1)).is_none());
360 assert_eq!(
361 repair_entry_rate("test-project").map(|rate| rate.0),
362 Some(2)
363 );
364
365 let repeated = next_root_repair_warning(key, first_at + ROOT_REPAIR_WARN_INTERVAL).unwrap();
366 assert!(repeated.ends_with("(repeated 1x in 60s)"));
367 expire_repair_entry_window_for_test("test-project");
368 assert!(repair_entry_rate("test-project").is_none());
369 }
370}
371
372#[cfg(test)]
373mod write_amplification_tests {
374 use super::*;
375 use std::fs;
376 use tempfile::tempdir;
377
378 #[test]
379 fn callgraph_writer_and_reader_use_bounded_normal_pragmas() {
380 let temp = tempdir().unwrap();
381 let root = temp.path().join("root");
382 fs::create_dir_all(&root).unwrap();
383 let source = root.join("main.ts");
384 fs::write(&source, "export function main() {}\n").unwrap();
385 let store_dir = temp.path().join("store");
386 let store = CallGraphStore::open(store_dir.clone(), root.clone()).unwrap();
387
388 let conn = store.conn.lock().unwrap();
389 let synchronous: i64 = conn
390 .pragma_query_value(None, "synchronous", |row| row.get(0))
391 .unwrap();
392 let autocheckpoint: i64 = conn
393 .pragma_query_value(None, "wal_autocheckpoint", |row| row.get(0))
394 .unwrap();
395 let cache_size: i64 = conn
396 .pragma_query_value(None, "cache_size", |row| row.get(0))
397 .unwrap();
398 assert_eq!(synchronous, 1, "NORMAL synchronous mode is value 1");
399 assert_eq!(autocheckpoint, CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES);
400 assert_eq!(cache_size, CALLGRAPH_SQLITE_CACHE_KIB);
401 drop(conn);
402 store.cold_build(std::slice::from_ref(&source)).unwrap();
403 drop(store);
404
405 let readonly = CallGraphStore::open_readonly(store_dir, root)
406 .unwrap()
407 .expect("writer-created empty schema should be readable");
408 let conn = readonly.inner.conn.lock().unwrap();
409 let synchronous: i64 = conn
410 .pragma_query_value(None, "synchronous", |row| row.get(0))
411 .unwrap();
412 assert_eq!(synchronous, 1);
413 }
414
415 #[test]
416 fn own_refresh_skips_identical_extract_but_not_position_shift() {
417 let temp = tempdir().unwrap();
418 let root = temp.path().join("root");
419 fs::create_dir_all(&root).unwrap();
420 let source = root.join("main.ts");
421 fs::write(&source, "export function main() { return 1; }\n").unwrap();
422 let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
423 store.cold_build(std::slice::from_ref(&source)).unwrap();
424 let write_metrics = callgraph_write_metrics_for_project(store.project_key());
425 assert!(write_metrics.commits_60s > 0);
426 assert!(write_metrics.pages_or_bytes_written_60s > 0);
427
428 let before = store.conn.lock().unwrap().total_changes();
429 fs::write(&source, "export function main() { return 1; }\n\n").unwrap();
430 let (stats, _) = store
431 .refresh_files_profiled(std::slice::from_ref(&source))
432 .unwrap();
433 let after = store.conn.lock().unwrap().total_changes();
434 assert_eq!(stats.unchanged_extract_files, 1);
435 assert_eq!(stats.refreshed_own_files, 0);
436 assert_eq!(
437 after - before,
438 3,
439 "files, backend freshness, and the durable projection revision update"
440 );
441
442 fs::write(&source, "\nexport function main() { return 1; }\n\n").unwrap();
443 let (shifted_stats, _) = store
444 .refresh_files_profiled(std::slice::from_ref(&source))
445 .unwrap();
446 assert_eq!(shifted_stats.unchanged_extract_files, 0);
447 assert_eq!(shifted_stats.refreshed_own_files, 1);
448 }
449
450 #[test]
451 fn idle_checkpoint_interval_prevents_checkpoint_storms() {
452 let now = Instant::now();
453 assert!(idle_checkpoint_due(None, now));
454 assert!(!idle_checkpoint_due(
455 Some(now),
456 now + Duration::from_secs(REFRESH_IDLE_CHECKPOINT_INTERVAL.as_secs() - 1),
457 ));
458 assert!(idle_checkpoint_due(
459 Some(now),
460 now + REFRESH_IDLE_CHECKPOINT_INTERVAL,
461 ));
462 }
463
464 #[test]
465 fn write_metrics_decay_after_the_sixty_second_window() {
466 let key = format!("metrics-test-{}", now_nanos());
467 let metrics = callgraph_write_metrics_for_key(&key);
468 metrics.record_commit(17);
469 assert_eq!(metrics.snapshot().commits_60s, 1);
470 assert_eq!(metrics.snapshot().pages_or_bytes_written_60s, 17);
471 metrics.window_start_ms.store(
472 unix_millis_now().saturating_sub(CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64),
473 AtomicOrdering::Release,
474 );
475 assert_eq!(metrics.snapshot(), CallgraphWriteMetricsSnapshot::default());
476 }
477}
478
479#[cfg(test)]
480type ColdBuildBeforePublishObserver = dyn Fn() + Send + Sync + 'static;
481thread_local! {
488 static COLD_BUILD_SWAP_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSwapObserver>>> =
489 const { std::cell::RefCell::new(None) };
490 #[cfg(test)]
491 static COLD_BUILD_BEFORE_PUBLISH_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildBeforePublishObserver>>> =
492 const { std::cell::RefCell::new(None) };
493 static MIGRATION_AVAILABLE_DISK_OVERRIDE: std::cell::RefCell<Option<u64>> =
494 const { std::cell::RefCell::new(None) };
495 static MIGRATION_FAIL_AFTER_TEMP_COPY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
496 static MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED: std::cell::Cell<bool> =
497 const { std::cell::Cell::new(false) };
498 static PUBLISH_ADMISSION: std::cell::RefCell<Option<(crate::root_cache::ArtifactPublishEpoch, u64)>> =
499 const { std::cell::RefCell::new(None) };
500 static REFRESH_COMMIT_ADMISSION: std::cell::RefCell<Option<(SubcLifecycleAdmission, Arc<std::sync::atomic::AtomicU64>, u64)>> =
501 const { std::cell::RefCell::new(None) };
502}
503
504mod dead_code_projection;
505pub use dead_code_projection::project_dead_code_snapshot;
506pub(crate) use dead_code_projection::project_dead_code_snapshot_with_revision;
507#[cfg(test)]
508pub(crate) use dead_code_projection::set_projection_before_open_observer;
509
510#[doc(hidden)]
511pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
512 COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
513}
514
515#[cfg(test)]
516fn set_cold_build_before_publish_observer(observer: Option<Arc<ColdBuildBeforePublishObserver>>) {
517 COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
518}
519
520#[cfg(test)]
521fn notify_cold_build_before_publish_observer() {
522 let observer = COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| slot.borrow().clone());
523 if let Some(observer) = observer {
524 observer();
525 }
526}
527
528#[cfg(not(test))]
529fn notify_cold_build_before_publish_observer() {}
530
531#[doc(hidden)]
532pub fn set_legacy_migration_available_disk_for_test(bytes: Option<u64>) {
533 MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow_mut() = bytes);
534}
535
536#[doc(hidden)]
537pub fn set_legacy_migration_fail_after_temp_copy_for_test(enabled: bool) {
538 MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.set(enabled));
539}
540
541#[doc(hidden)]
542pub fn set_legacy_migration_backup_budget_exhausted_for_test(enabled: bool) {
543 MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.set(enabled));
544}
545
546struct PublishAdmissionGuard {
547 previous: Option<(crate::root_cache::ArtifactPublishEpoch, u64)>,
548}
549
550impl Drop for PublishAdmissionGuard {
551 fn drop(&mut self) {
552 PUBLISH_ADMISSION.with(|slot| {
553 *slot.borrow_mut() = self.previous.take();
554 });
555 }
556}
557
558pub(crate) fn with_publish_epoch<R>(
559 epoch: crate::root_cache::ArtifactPublishEpoch,
560 expected: u64,
561 run: impl FnOnce() -> R,
562) -> R {
563 let previous = PUBLISH_ADMISSION.with(|slot| slot.replace(Some((epoch, expected))));
564 let _guard = PublishAdmissionGuard { previous };
565 run()
566}
567
568fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
569 let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
570 match admission {
571 Some((epoch, expected)) => epoch
572 .run_if_current(expected, publish)
573 .unwrap_or(Err(CallGraphStoreError::Superseded)),
574 None => publish(),
575 }
576}
577
578struct RefreshCommitAdmissionGuard {
579 previous: Option<(
580 SubcLifecycleAdmission,
581 Arc<std::sync::atomic::AtomicU64>,
582 u64,
583 )>,
584}
585
586impl Drop for RefreshCommitAdmissionGuard {
587 fn drop(&mut self) {
588 REFRESH_COMMIT_ADMISSION.with(|slot| {
589 *slot.borrow_mut() = self.previous.take();
590 });
591 }
592}
593
594fn with_refresh_commit_admission<R>(
595 lifecycle: SubcLifecycleAdmission,
596 generation_flag: Arc<std::sync::atomic::AtomicU64>,
597 expected_generation: u64,
598 run: impl FnOnce() -> R,
599) -> R {
600 let previous = REFRESH_COMMIT_ADMISSION
601 .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
602 let _guard = RefreshCommitAdmissionGuard { previous };
603 run()
604}
605
606fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
607 let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
608 let commit = || {
609 publish_if_current(|| {
610 tx.commit()?;
611 Ok(())
612 })
613 };
614 match admission {
615 Some((lifecycle, generation_flag, expected_generation)) => lifecycle
616 .run_if_current(generation_flag.as_ref(), expected_generation, commit)
617 .unwrap_or(Err(CallGraphStoreError::Superseded)),
618 None => commit(),
619 }
620}
621
622fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
623 let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
624 if let Some(observer) = observer {
625 observer(temp_path, target_path);
626 }
627}
628
629#[derive(Debug)]
630pub enum CallGraphStoreError {
631 Io(std::io::Error),
632 Sqlite(rusqlite::Error),
633 Json(serde_json::Error),
634 Aft(AftError),
635 Lock(crate::fs_lock::AcquireError),
636 MissingCallerData { file: String },
637 Unavailable(String),
638 Suspended(crate::build_breaker::BuildSuspension),
639 Superseded,
640 StaleFiles(Vec<String>),
641}
642
643impl fmt::Display for CallGraphStoreError {
644 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
645 match self {
646 Self::Io(error) => write!(formatter, "I/O error: {error}"),
647 Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
648 Self::Json(error) => write!(formatter, "json error: {error}"),
649 Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
650 Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
651 Self::MissingCallerData { file } => {
652 write!(formatter, "missing extracted caller data for {file}")
653 }
654 Self::Unavailable(message) => {
655 write!(formatter, "callgraph store unavailable: {message}")
656 }
657 Self::Suspended(suspension) => write!(
658 formatter,
659 "callgraph build suspended for {} after {} deaths ({})",
660 suspension.domain.as_str(),
661 suspension.death_count,
662 suspension.reason
663 ),
664 Self::Superseded => {
665 write!(formatter, "callgraph store build superseded before publish")
666 }
667 Self::StaleFiles(files) => {
668 write!(
669 formatter,
670 "callgraph store has stale files: {}",
671 files.join(", ")
672 )
673 }
674 }
675 }
676}
677
678impl std::error::Error for CallGraphStoreError {}
679
680impl From<std::io::Error> for CallGraphStoreError {
681 fn from(error: std::io::Error) -> Self {
682 Self::Io(error)
683 }
684}
685
686impl From<rusqlite::Error> for CallGraphStoreError {
687 fn from(error: rusqlite::Error) -> Self {
688 Self::Sqlite(error)
689 }
690}
691
692impl From<serde_json::Error> for CallGraphStoreError {
693 fn from(error: serde_json::Error) -> Self {
694 Self::Json(error)
695 }
696}
697
698impl From<AftError> for CallGraphStoreError {
699 fn from(error: AftError) -> Self {
700 Self::Aft(error)
701 }
702}
703
704impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
705 fn from(error: crate::fs_lock::AcquireError) -> Self {
706 Self::Lock(error)
707 }
708}
709
710pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
711
712pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
716
717#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
718pub struct CallGraphStoreOptions {
719 pub enabled: bool,
720}
721
722pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
723
724#[derive(Clone)]
728pub(crate) struct CallgraphRefreshState {
729 store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
730 heavy_root_work_allowed: Arc<AtomicBool>,
731}
732
733impl CallgraphRefreshState {
734 pub(crate) fn new(
735 store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
736 heavy_root_work_allowed: Arc<AtomicBool>,
737 ) -> Self {
738 Self {
739 store,
740 heavy_root_work_allowed,
741 }
742 }
743
744 fn installed_store_snapshot(&self) -> Option<Arc<ReadonlyCallGraphStore>> {
745 self.store
746 .read()
747 .unwrap_or_else(std::sync::PoisonError::into_inner)
748 .as_ref()
749 .map(Arc::clone)
750 }
751}
752
753type WorkspaceCratePrefixes = HashMap<String, String>;
754
755#[derive(Clone, Debug, Default)]
756struct WorkspaceCratePrefixCache(Arc<OnceLock<WorkspaceCratePrefixes>>);
757
758const REFRESH_WORKSPACE_CACHE_ROOT_CAP: usize = 128;
759
760pub(crate) fn invalidates_workspace_crate_prefix_cache(path: &Path) -> bool {
761 path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")
762}
763
764#[derive(Clone, Debug, Hash, PartialEq, Eq)]
765struct RefreshRoot {
766 callgraph_dir: PathBuf,
767 project_root: PathBuf,
768}
769
770#[derive(Clone)]
771pub(crate) struct CallgraphRefreshTicket {
772 lifecycle: SubcLifecycleAdmission,
773 generation_flag: Arc<std::sync::atomic::AtomicU64>,
774 expected_generation: u64,
775 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
776 expected_publish_epoch: u64,
777}
778
779impl CallgraphRefreshTicket {
780 pub(crate) fn new(
781 lifecycle: SubcLifecycleAdmission,
782 generation_flag: Arc<std::sync::atomic::AtomicU64>,
783 expected_generation: u64,
784 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
785 expected_publish_epoch: u64,
786 ) -> Self {
787 Self {
788 lifecycle,
789 generation_flag,
790 expected_generation,
791 publish_epoch,
792 expected_publish_epoch,
793 }
794 }
795
796 fn is_current(&self) -> bool {
797 self.lifecycle
798 .is_current(self.generation_flag.as_ref(), self.expected_generation)
799 && self.publish_epoch.current() == self.expected_publish_epoch
800 }
801}
802
803#[derive(Clone)]
804struct RefreshBatch {
805 root: RefreshRoot,
806 paths: BTreeSet<PathBuf>,
807 pending_sinks: Vec<PendingCallGraphStorePaths>,
808 refresh_states: Vec<CallgraphRefreshState>,
809 ticket: Option<CallgraphRefreshTicket>,
810}
811
812impl RefreshBatch {
813 fn defer(&self) {
814 for sink in &self.pending_sinks {
815 sink.lock().extend(self.paths.iter().cloned());
816 }
817 }
818
819 fn defer_after_open_failure(&self) {
820 self.defer();
821 if self
822 .ticket
823 .as_ref()
824 .is_some_and(|ticket| !ticket.is_current())
825 || !self
826 .refresh_states
827 .iter()
828 .any(|state| state.heavy_root_work_allowed.load(AtomicOrdering::SeqCst))
829 {
830 return;
831 }
832
833 let ready_store_installed = self.refresh_states.iter().any(|state| {
834 let store = state.installed_store_snapshot();
835 store.is_some_and(|store| {
836 store.project_root() == self.root.project_root
837 && !store.is_legacy_fallback()
838 && store.is_current()
839 })
840 });
841 if !ready_store_installed {
842 return;
843 }
844
845 for sink in &self.pending_sinks {
849 let paths = {
850 let mut pending = sink.lock();
851 self.paths
852 .iter()
853 .filter(|path| pending.remove(*path))
854 .cloned()
855 .collect::<Vec<_>>()
856 };
857 if paths.is_empty() {
858 continue;
859 }
860 let _ = enqueue_callgraph_store_refresh_inner(
861 self.root.callgraph_dir.clone(),
862 self.root.project_root.clone(),
863 paths,
864 Arc::clone(sink),
865 self.refresh_states.clone(),
866 self.ticket.clone(),
867 );
868 }
869 }
870
871 fn merge(
872 &mut self,
873 paths: impl IntoIterator<Item = PathBuf>,
874 sink: PendingCallGraphStorePaths,
875 refresh_states: Vec<CallgraphRefreshState>,
876 ticket: Option<CallgraphRefreshTicket>,
877 ) {
878 self.paths.extend(paths);
879 if ticket.is_some() {
880 self.ticket = ticket;
881 }
882 if !self
883 .pending_sinks
884 .iter()
885 .any(|existing| Arc::ptr_eq(existing, &sink))
886 {
887 self.pending_sinks.push(sink);
888 }
889 for refresh_state in refresh_states {
890 if !self.refresh_states.iter().any(|existing| {
891 Arc::ptr_eq(&existing.store, &refresh_state.store)
892 && Arc::ptr_eq(
893 &existing.heavy_root_work_allowed,
894 &refresh_state.heavy_root_work_allowed,
895 )
896 }) {
897 self.refresh_states.push(refresh_state);
898 }
899 }
900 }
901}
902
903#[derive(Default)]
904struct RefreshQueue {
905 order: VecDeque<RefreshRoot>,
906 queued: HashMap<RefreshRoot, RefreshBatch>,
907 active: Option<RefreshBatch>,
908 shutdown_requested: bool,
909}
910
911struct RefreshWorkerShared {
912 queue: Mutex<RefreshQueue>,
913 wake: Condvar,
914}
915
916struct RefreshWorker {
917 shared: Arc<RefreshWorkerShared>,
918 thread: Mutex<Option<JoinHandle<()>>>,
919}
920
921struct RefreshWorkerWatchdog {
922 first_path: PathBuf,
923 batch_len: usize,
924 started: Instant,
925}
926
927impl RefreshWorkerWatchdog {
928 fn start(paths: &[PathBuf]) -> Self {
929 Self {
930 first_path: paths
931 .first()
932 .expect("non-empty callgraph refresh batch has a first path")
933 .clone(),
934 batch_len: paths.len(),
935 started: Instant::now(),
936 }
937 }
938}
939
940impl Drop for RefreshWorkerWatchdog {
941 fn drop(&mut self) {
942 let elapsed = self.started.elapsed();
943 if elapsed < REFRESH_WORKER_WARN_AFTER {
944 return;
945 }
946 let path = if self.batch_len == 1 {
947 self.first_path.display().to_string()
948 } else {
949 format!(
950 "{} (+{} paths)",
951 self.first_path.display(),
952 self.batch_len - 1
953 )
954 };
955 log::warn!(
956 "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
957 path,
958 elapsed.as_millis()
959 );
960 if elapsed >= REFRESH_WORKER_FINAL_AFTER {
961 log::warn!(
962 "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
963 path,
964 elapsed.as_millis()
965 );
966 }
967 }
968}
969
970impl RefreshWorker {
971 fn spawn() -> Arc<Self> {
972 let shared = Arc::new(RefreshWorkerShared {
973 queue: Mutex::new(RefreshQueue::default()),
974 wake: Condvar::new(),
975 });
976 let thread_shared = Arc::clone(&shared);
977 let thread = std::thread::Builder::new()
978 .name("aft-callgraph-refresh".to_string())
979 .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
980 .expect("failed to spawn callgraph refresh worker");
981 Arc::new(Self {
982 shared,
983 thread: Mutex::new(Some(thread)),
984 })
985 }
986
987 fn enqueue(
988 &self,
989 root: RefreshRoot,
990 paths: Vec<PathBuf>,
991 pending_sink: PendingCallGraphStorePaths,
992 refresh_states: Vec<CallgraphRefreshState>,
993 ticket: Option<CallgraphRefreshTicket>,
994 ) -> bool {
995 let mut queue = self
996 .shared
997 .queue
998 .lock()
999 .expect("callgraph refresh queue mutex poisoned");
1000 if queue.shutdown_requested {
1001 pending_sink.lock().extend(paths);
1002 return false;
1003 }
1004 if let Some(batch) = queue.queued.get_mut(&root) {
1005 batch.merge(paths, pending_sink, refresh_states, ticket);
1006 } else {
1007 queue.order.push_back(root.clone());
1008 queue.queued.insert(
1009 root.clone(),
1010 RefreshBatch {
1011 root,
1012 paths: paths.into_iter().collect(),
1013 pending_sinks: vec![pending_sink],
1014 refresh_states,
1015 ticket,
1016 },
1017 );
1018 }
1019 self.shared.wake.notify_one();
1020 true
1021 }
1022
1023 fn shutdown_with_budget(&self, budget: Duration) -> bool {
1024 let deadline = Instant::now() + budget;
1025 let mut queue = self
1026 .shared
1027 .queue
1028 .lock()
1029 .expect("callgraph refresh queue mutex poisoned");
1030 queue.shutdown_requested = true;
1031 self.shared.wake.notify_one();
1032 while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
1033 let remaining = deadline.saturating_duration_since(Instant::now());
1034 let (next, _) = self
1035 .shared
1036 .wake
1037 .wait_timeout(queue, remaining)
1038 .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
1039 queue = next;
1040 }
1041 let drained = queue.active.is_none() && queue.order.is_empty();
1042 if !drained {
1043 if let Some(active) = queue.active.as_ref() {
1044 active.defer();
1045 }
1046 for batch in queue.queued.values() {
1047 batch.defer();
1048 }
1049 queue.order.clear();
1050 queue.queued.clear();
1051 }
1052 drop(queue);
1053
1054 if drained {
1055 if let Some(thread) = self
1056 .thread
1057 .lock()
1058 .expect("callgraph refresh worker thread mutex poisoned")
1059 .take()
1060 {
1061 let _ = thread.join();
1062 }
1063 }
1064 drained
1065 }
1066}
1067
1068static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
1069
1070pub fn enqueue_callgraph_store_refresh(
1071 callgraph_dir: PathBuf,
1072 project_root: PathBuf,
1073 paths: Vec<PathBuf>,
1074 pending_sink: PendingCallGraphStorePaths,
1075) -> bool {
1076 enqueue_callgraph_store_refresh_inner(
1077 callgraph_dir,
1078 project_root,
1079 paths,
1080 pending_sink,
1081 Vec::new(),
1082 None,
1083 )
1084}
1085
1086#[cfg(test)]
1087pub(crate) fn enqueue_callgraph_store_refresh_fenced(
1088 callgraph_dir: PathBuf,
1089 project_root: PathBuf,
1090 paths: Vec<PathBuf>,
1091 pending_sink: PendingCallGraphStorePaths,
1092 ticket: CallgraphRefreshTicket,
1093) -> bool {
1094 enqueue_callgraph_store_refresh_inner(
1095 callgraph_dir,
1096 project_root,
1097 paths,
1098 pending_sink,
1099 Vec::new(),
1100 Some(ticket),
1101 )
1102}
1103
1104pub(crate) fn enqueue_callgraph_store_refresh_fenced_with_state(
1105 callgraph_dir: PathBuf,
1106 project_root: PathBuf,
1107 paths: Vec<PathBuf>,
1108 pending_sink: PendingCallGraphStorePaths,
1109 refresh_state: CallgraphRefreshState,
1110 ticket: CallgraphRefreshTicket,
1111) -> bool {
1112 enqueue_callgraph_store_refresh_inner(
1113 callgraph_dir,
1114 project_root,
1115 paths,
1116 pending_sink,
1117 vec![refresh_state],
1118 Some(ticket),
1119 )
1120}
1121
1122fn enqueue_callgraph_store_refresh_inner(
1123 callgraph_dir: PathBuf,
1124 project_root: PathBuf,
1125 paths: Vec<PathBuf>,
1126 pending_sink: PendingCallGraphStorePaths,
1127 refresh_states: Vec<CallgraphRefreshState>,
1128 ticket: Option<CallgraphRefreshTicket>,
1129) -> bool {
1130 if paths.is_empty() {
1131 return true;
1132 }
1133 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1134 let worker = {
1135 let mut worker = slot
1136 .lock()
1137 .expect("callgraph refresh worker mutex poisoned");
1138 Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
1139 };
1140 worker.enqueue(
1141 RefreshRoot {
1142 callgraph_dir,
1143 project_root,
1144 },
1145 paths,
1146 pending_sink,
1147 refresh_states,
1148 ticket,
1149 )
1150}
1151
1152pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
1153 flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
1154}
1155
1156#[doc(hidden)]
1157pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
1158 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1159 let worker = slot
1160 .lock()
1161 .expect("callgraph refresh worker mutex poisoned")
1162 .clone();
1163 let Some(worker) = worker else {
1164 return true;
1165 };
1166 let drained = worker.shutdown_with_budget(budget);
1167 if drained {
1168 let mut current = slot
1169 .lock()
1170 .expect("callgraph refresh worker mutex poisoned");
1171 if current
1172 .as_ref()
1173 .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
1174 {
1175 *current = None;
1176 }
1177 }
1178 drained
1179}
1180
1181fn idle_checkpoint_due(last: Option<Instant>, now: Instant) -> bool {
1182 last.is_none_or(|last| now.saturating_duration_since(last) >= REFRESH_IDLE_CHECKPOINT_INTERVAL)
1183}
1184
1185fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
1186 let mut workspace_crate_prefixes = HashMap::new();
1189 let mut last_idle_checkpoints: HashMap<RefreshRoot, Instant> = HashMap::new();
1190 loop {
1191 let batch = {
1192 let mut queue = shared
1193 .queue
1194 .lock()
1195 .expect("callgraph refresh queue mutex poisoned");
1196 loop {
1197 if let Some(root) = queue.order.pop_front() {
1198 let batch = queue
1199 .queued
1200 .remove(&root)
1201 .expect("queued callgraph refresh root has a batch");
1202 queue.active = Some(batch.clone());
1203 break batch;
1204 }
1205 if queue.shutdown_requested {
1206 return;
1207 }
1208 queue = shared
1209 .wake
1210 .wait(queue)
1211 .expect("callgraph refresh queue mutex poisoned while waiting");
1212 }
1213 };
1214
1215 let store = process_callgraph_refresh_batch(&batch, &mut workspace_crate_prefixes);
1216
1217 let mut queue = shared
1218 .queue
1219 .lock()
1220 .expect("callgraph refresh queue mutex poisoned");
1221 queue.active = None;
1222 let became_idle = queue.order.is_empty();
1223 shared.wake.notify_all();
1224 drop(queue);
1225
1226 if became_idle {
1227 let checkpoint_due = idle_checkpoint_due(
1228 last_idle_checkpoints.get(&batch.root).copied(),
1229 Instant::now(),
1230 );
1231 if checkpoint_due {
1232 if let Some(store) = store {
1233 if store.checkpoint_wal_truncate() {
1234 last_idle_checkpoints.insert(batch.root.clone(), Instant::now());
1235 }
1236 }
1237 }
1238 }
1239 }
1240}
1241
1242fn process_callgraph_refresh_batch(
1243 batch: &RefreshBatch,
1244 workspace_crate_prefixes: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1245) -> Option<CallGraphStore> {
1246 if batch
1250 .paths
1251 .iter()
1252 .any(|path| invalidates_workspace_crate_prefix_cache(path))
1253 {
1254 workspace_crate_prefixes.remove(&batch.root);
1255 }
1256
1257 let paths = batch
1258 .paths
1259 .iter()
1260 .filter(|path| crate::parser::detect_language(path).is_some())
1261 .cloned()
1262 .collect::<Vec<_>>();
1263 if paths.is_empty() {
1264 return None;
1265 }
1266 note_refresh_worker_batch_for_test(&batch.root.project_root);
1267 if batch
1268 .ticket
1269 .as_ref()
1270 .is_some_and(|ticket| !ticket.is_current())
1271 {
1272 batch.defer();
1275 return None;
1276 }
1277 let workspace_crate_prefix_cache =
1278 workspace_crate_prefix_cache_for_root(workspace_crate_prefixes, &batch.root);
1279 let _watchdog = RefreshWorkerWatchdog::start(&paths);
1280 let test_seam = refresh_worker_test_seam(&batch.root.project_root);
1281 note_refresh_worker_call_for_test(&batch.root.project_root);
1282 let opened = if test_seam.fail_open {
1283 Ok(None)
1284 } else {
1285 CallGraphStore::open_ready(
1286 batch.root.callgraph_dir.clone(),
1287 batch.root.project_root.clone(),
1288 )
1289 };
1290 if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
1291 let _ = gate.held_tx.send(());
1294 let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
1295 }
1296 let store = match opened {
1297 Ok(Some(store)) => store,
1298 Ok(None) => {
1299 batch.defer_after_open_failure();
1300 return None;
1301 }
1302 Err(error) => {
1303 batch.defer_after_open_failure();
1304 crate::slog_warn!(
1305 "callgraph store writer open failed during refresh; deferred paths: {}",
1306 error
1307 );
1308 return None;
1309 }
1310 };
1311 if !test_seam.delay.is_zero() {
1312 std::thread::sleep(test_seam.delay);
1313 }
1314 if batch
1315 .ticket
1316 .as_ref()
1317 .is_some_and(|ticket| !ticket.is_current())
1318 {
1319 batch.defer();
1322 return Some(store);
1323 }
1324 let refresh_result = if test_seam.fail_refresh {
1325 Err(CallGraphStoreError::Unavailable(
1326 "injected refresh worker failure".to_string(),
1327 ))
1328 } else if let Some(ticket) = &batch.ticket {
1329 with_publish_epoch(
1330 ticket.publish_epoch.clone(),
1331 ticket.expected_publish_epoch,
1332 || {
1333 with_refresh_commit_admission(
1334 ticket.lifecycle.clone(),
1335 Arc::clone(&ticket.generation_flag),
1336 ticket.expected_generation,
1337 || {
1338 store
1339 .refresh_files_with_workspace_crate_prefix_cache(
1340 &paths,
1341 workspace_crate_prefix_cache.clone(),
1342 )
1343 .map(|_| ())
1344 },
1345 )
1346 },
1347 )
1348 } else {
1349 store
1350 .refresh_files_with_workspace_crate_prefix_cache(
1351 &paths,
1352 workspace_crate_prefix_cache.clone(),
1353 )
1354 .map(|_| ())
1355 };
1356 if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
1357 batch.defer();
1361 return Some(store);
1362 }
1363 if let Err(error) = refresh_result {
1364 crate::slog_warn!("callgraph store refresh failed: {}", error);
1365 match store.mark_files_stale(&paths) {
1366 Ok(marked) => {
1367 note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
1368 crate::slog_warn!(
1369 "marked {} callgraph store file(s) stale after refresh failure",
1370 marked.len()
1371 );
1372 }
1373 Err(mark_error) => crate::slog_warn!(
1374 "failed to mark callgraph store files stale after refresh failure: {}",
1375 mark_error
1376 ),
1377 }
1378 } else {
1379 crate::logging::note_callgraph_invalidations(paths.len());
1380 }
1381 Some(store)
1382}
1383
1384fn workspace_crate_prefix_cache_for_root(
1385 caches: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1386 root: &RefreshRoot,
1387) -> WorkspaceCratePrefixCache {
1388 if !caches.contains_key(root) && caches.len() >= REFRESH_WORKSPACE_CACHE_ROOT_CAP {
1389 if let Some(evicted) = caches.keys().next().cloned() {
1391 caches.remove(&evicted);
1392 }
1393 }
1394 caches.entry(root.clone()).or_default().clone()
1395}
1396
1397#[derive(Clone, Copy, Default)]
1398struct RefreshWorkerTestSeam {
1399 delay: Duration,
1400 fail_refresh: bool,
1401 fail_open: bool,
1402 refresh_calls: usize,
1403 worker_calls: usize,
1404 stale_marks: usize,
1405}
1406
1407static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
1408 OnceLock::new();
1409
1410struct RefreshWorkerTestGate {
1411 held_tx: crossbeam_channel::Sender<()>,
1412 release_rx: crossbeam_channel::Receiver<()>,
1413}
1414
1415static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
1416 OnceLock::new();
1417
1418#[doc(hidden)]
1419pub fn install_callgraph_refresh_worker_test_gate(
1420 project_root: PathBuf,
1421) -> (
1422 crossbeam_channel::Receiver<()>,
1423 crossbeam_channel::Sender<()>,
1424) {
1425 let (held_tx, held_rx) = crossbeam_channel::bounded(1);
1426 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1427 REFRESH_WORKER_TEST_GATES
1428 .get_or_init(|| Mutex::new(HashMap::new()))
1429 .lock()
1430 .expect("callgraph refresh test gate mutex poisoned")
1431 .insert(
1432 project_root,
1433 RefreshWorkerTestGate {
1434 held_tx,
1435 release_rx,
1436 },
1437 );
1438 (held_rx, release_tx)
1439}
1440
1441fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
1442 REFRESH_WORKER_TEST_GATES
1443 .get_or_init(|| Mutex::new(HashMap::new()))
1444 .lock()
1445 .expect("callgraph refresh test gate mutex poisoned")
1446 .remove(project_root)
1447}
1448
1449fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
1450 let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
1451 return RefreshWorkerTestSeam::default();
1452 };
1453 seams
1454 .lock()
1455 .expect("callgraph refresh test seam mutex poisoned")
1456 .get(project_root)
1457 .copied()
1458 .unwrap_or_default()
1459}
1460
1461fn note_refresh_worker_batch_for_test(project_root: &Path) {
1462 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1463 if let Some(seam) = seams
1464 .lock()
1465 .expect("callgraph refresh test seam mutex poisoned")
1466 .get_mut(project_root)
1467 {
1468 seam.worker_calls += 1;
1469 }
1470 }
1471}
1472
1473fn note_refresh_worker_call_for_test(project_root: &Path) {
1474 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1475 if let Some(seam) = seams
1476 .lock()
1477 .expect("callgraph refresh test seam mutex poisoned")
1478 .get_mut(project_root)
1479 {
1480 seam.refresh_calls += 1;
1481 }
1482 }
1483}
1484
1485fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
1486 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1487 if let Some(seam) = seams
1488 .lock()
1489 .expect("callgraph refresh test seam mutex poisoned")
1490 .get_mut(project_root)
1491 {
1492 seam.stale_marks += 1;
1493 }
1494 }
1495}
1496
1497#[doc(hidden)]
1498pub fn set_callgraph_refresh_worker_test_seam(
1499 project_root: PathBuf,
1500 delay: Duration,
1501 fail_refresh: bool,
1502) {
1503 REFRESH_WORKER_TEST_SEAMS
1504 .get_or_init(|| Mutex::new(HashMap::new()))
1505 .lock()
1506 .expect("callgraph refresh test seam mutex poisoned")
1507 .insert(
1508 project_root,
1509 RefreshWorkerTestSeam {
1510 delay,
1511 fail_refresh,
1512 ..RefreshWorkerTestSeam::default()
1513 },
1514 );
1515}
1516
1517#[doc(hidden)]
1518pub fn set_callgraph_refresh_worker_test_open_failure(project_root: PathBuf, enabled: bool) {
1519 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1520 if let Some(seam) = seams
1521 .lock()
1522 .expect("callgraph refresh test seam mutex poisoned")
1523 .get_mut(&project_root)
1524 {
1525 seam.fail_open = enabled;
1526 }
1527 }
1528}
1529
1530#[doc(hidden)]
1531pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
1532 let seam = refresh_worker_test_seam(project_root);
1533 (seam.refresh_calls, seam.stale_marks)
1534}
1535
1536#[doc(hidden)]
1537pub fn callgraph_refresh_worker_test_worker_calls(project_root: &Path) -> usize {
1538 refresh_worker_test_seam(project_root).worker_calls
1539}
1540
1541#[doc(hidden)]
1542pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
1543 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1544 seams
1545 .lock()
1546 .expect("callgraph refresh test seam mutex poisoned")
1547 .remove(project_root);
1548 }
1549}
1550
1551#[derive(Debug)]
1552pub struct CallGraphStore {
1553 project_root: PathBuf,
1554 project_key: String,
1555 sqlite_path: PathBuf,
1559 publication_dir: PathBuf,
1563 legacy_fallback: bool,
1567 generation: Option<String>,
1572 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1573 read_marker: Option<crate::root_cache::ReadMarker>,
1574 database_ready: AtomicBool,
1577 write_metrics: Arc<CallgraphWriteMetrics>,
1578 conn: Mutex<Connection>,
1579}
1580
1581#[derive(Debug)]
1582pub struct ReadonlyCallGraphStore {
1583 inner: CallGraphStore,
1584}
1585
1586pub trait CallGraphRead {
1587 fn project_root(&self) -> &Path;
1588 fn project_key(&self) -> &str;
1589 fn sqlite_path(&self) -> &Path;
1590 fn is_current(&self) -> bool;
1591 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
1592 fn indexed_file_count(&self) -> Result<usize>;
1593 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
1594 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
1595 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
1596 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
1597 fn direct_callers_for_symbols(
1598 &self,
1599 targets: &[(String, String)],
1600 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
1601 targets
1602 .iter()
1603 .cloned()
1604 .map(|target| {
1605 let callers = self.direct_callers_of(Path::new(&target.0), &target.1)?;
1606 Ok((target, callers))
1607 })
1608 .collect()
1609 }
1610 fn direct_caller_counts_of(
1611 &self,
1612 targets: &[(String, String)],
1613 ) -> Result<HashMap<(String, String), usize>>;
1614 fn outgoing_calls_for_symbols(
1615 &self,
1616 sources: &[(String, String)],
1617 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
1618 fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
1619 -> Result<StoreCallersResult>;
1620 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
1621 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1622 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1623 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
1624 fn call_tree(
1625 &self,
1626 file_rel: &Path,
1627 symbol: &str,
1628 depth: usize,
1629 ) -> Result<callgraph::CallTreeNode>;
1630 fn trace_to(
1631 &self,
1632 file_rel: &Path,
1633 symbol: &str,
1634 max_depth: usize,
1635 ) -> Result<callgraph::TraceToResult>;
1636 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
1637 fn trace_to_symbol(
1638 &self,
1639 file_rel: &Path,
1640 symbol: &str,
1641 to_symbol: &str,
1642 to_file: Option<&Path>,
1643 max_depth: usize,
1644 ) -> Result<callgraph::TraceToSymbolResult>;
1645}
1646
1647#[derive(Debug, Clone, PartialEq, Eq)]
1648enum OpenRootRepair {
1649 None,
1650 ReRooted,
1651 NeedsRebuild {
1652 previous_roots: Vec<String>,
1653 current_root: String,
1654 reason: String,
1655 },
1656}
1657
1658struct OpenedStore {
1659 store: CallGraphStore,
1660 root_repair: OpenRootRepair,
1661}
1662
1663#[derive(Clone, Debug)]
1664struct LegacyCallgraphPartition {
1665 harness: String,
1666 dir: PathBuf,
1667 key: String,
1668 bytes: u64,
1669 freshness: Option<SystemTime>,
1670}
1671
1672#[derive(Clone, Debug)]
1673struct LegacyCallgraphTarget {
1674 partition: LegacyCallgraphPartition,
1675 sqlite_path: PathBuf,
1676 generation: Option<String>,
1677 source_bytes: u64,
1678 source_blake3: String,
1679}
1680
1681#[derive(Clone, Debug)]
1682struct SourceFingerprint {
1683 bytes: u64,
1684 blake3: String,
1685}
1686
1687#[derive(Clone, Debug)]
1688struct PublishedLegacyMigration {
1689 generation: String,
1690 migrated_bytes: u64,
1691}
1692
1693#[derive(Debug, Clone)]
1694pub struct ColdBuildStats {
1695 pub files: usize,
1696 pub nodes: usize,
1697 pub refs: usize,
1698 pub edges: usize,
1699 pub failed_files: Vec<String>,
1700 pub elapsed_ms: u128,
1701}
1702
1703#[derive(Debug, Clone)]
1704pub struct IncrementalStats {
1705 pub changed_files: Vec<String>,
1706 pub surface_changed: Vec<String>,
1707 pub deleted_files: Vec<String>,
1708 pub dependency_selected_refs: usize,
1709 pub refreshed_own_files: usize,
1710 pub unchanged_extract_files: usize,
1711}
1712
1713#[doc(hidden)]
1715#[derive(Debug, Clone, Default, PartialEq, Eq)]
1716pub struct RefreshFilesProfile {
1717 pub parse: Duration,
1718 pub dependency_selection: Duration,
1719 pub row_deletes: Duration,
1720 pub row_inserts: Duration,
1721 pub dependent_parse: Duration,
1722 pub index_load: Duration,
1723 pub ref_resolution: Duration,
1724 pub method_dispatch: Duration,
1725 pub commit: Duration,
1726 pub total: Duration,
1727}
1728
1729impl RefreshFilesProfile {
1730 pub fn report(&self) -> String {
1731 format!(
1732 "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",
1733 self.parse.as_millis(),
1734 self.dependency_selection.as_millis(),
1735 self.row_deletes.as_millis(),
1736 self.row_inserts.as_millis(),
1737 self.dependent_parse.as_millis(),
1738 self.index_load.as_millis(),
1739 self.ref_resolution.as_millis(),
1740 self.method_dispatch.as_millis(),
1741 self.commit.as_millis(),
1742 self.total.as_millis(),
1743 )
1744 }
1745}
1746
1747#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1748pub struct StoredEdge {
1749 pub source_file: String,
1750 pub source_symbol: String,
1751 pub target_file: String,
1752 pub target_symbol: String,
1753 pub kind: String,
1754 pub line: u32,
1755}
1756
1757#[derive(Debug, Clone, PartialEq, Eq)]
1758pub struct StoreNode {
1759 node_id: String,
1760 pub file: String,
1761 pub symbol: String,
1762 pub name: String,
1763 pub kind: String,
1764 pub line: u32,
1765 pub end_line: u32,
1766 pub signature: Option<String>,
1767 pub exported: bool,
1768 pub is_entry_point: bool,
1769 pub lang: LangId,
1770}
1771
1772#[cfg(test)]
1773impl StoreNode {
1774 pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
1775 Self {
1776 node_id: format!("{file}:{symbol}"),
1777 file: file.to_string(),
1778 symbol: symbol.to_string(),
1779 name: symbol.to_string(),
1780 kind: "function".to_string(),
1781 line: 1,
1782 end_line: 1,
1783 signature: None,
1784 exported: is_entry_point,
1785 is_entry_point,
1786 lang: LangId::TypeScript,
1787 }
1788 }
1789}
1790
1791#[derive(Debug, Clone, PartialEq, Eq)]
1792pub struct StoreCallSite {
1793 pub caller: StoreNode,
1794 pub target_file: String,
1795 pub target_symbol: String,
1796 pub target: Option<StoreNode>,
1797 pub line: u32,
1798 pub byte_start: usize,
1799 pub byte_end: usize,
1800 pub resolved: bool,
1801 pub provenance: String,
1802}
1803
1804impl StoreCallSite {
1805 pub fn approximate(&self) -> bool {
1806 self.provenance == PROVENANCE_NAME_MATCH
1807 }
1808
1809 pub fn resolved_by(&self) -> &str {
1810 &self.provenance
1811 }
1812
1813 pub fn supplemental_resolution(&self) -> Option<&str> {
1814 match self.provenance.as_str() {
1815 PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
1816 _ => None,
1817 }
1818 }
1819}
1820
1821#[derive(Debug, Clone, PartialEq, Eq)]
1822pub struct StoreUnresolvedCall {
1823 pub caller: StoreNode,
1824 pub symbol: String,
1825 pub full_ref: Option<String>,
1826 pub line: u32,
1827 pub byte_start: usize,
1828 pub byte_end: usize,
1829}
1830
1831#[derive(Debug, Clone, PartialEq, Eq)]
1832pub struct StoreCallersResult {
1833 pub target: StoreNode,
1834 pub callers: Vec<StoreCallSite>,
1835 pub scanned_files: usize,
1836 pub depth_limited: bool,
1837 pub truncated: usize,
1838}
1839
1840#[derive(Debug, Clone, PartialEq, Eq)]
1841pub struct StoreImpactCaller {
1842 pub site: StoreCallSite,
1843 pub signature: Option<String>,
1844 pub is_entry_point: bool,
1845 pub call_expression: Option<String>,
1846 pub parameters: Vec<String>,
1847}
1848
1849#[derive(Debug, Clone, PartialEq, Eq)]
1850pub struct StoreImpactResult {
1851 pub target: StoreNode,
1852 pub parameters: Vec<String>,
1853 pub callers: Vec<StoreImpactCaller>,
1854 pub depth_limited: bool,
1855 pub truncated: usize,
1856}
1857
1858#[derive(Debug, Clone)]
1859struct ExtractFailure {
1860 rel_path: String,
1861 freshness: Option<FileFreshness>,
1862}
1863
1864#[derive(Debug, Clone)]
1865struct BuildExtractsResult {
1866 extracts: Vec<FileExtract>,
1867 failures: Vec<ExtractFailure>,
1868}
1869
1870#[derive(Debug, Clone)]
1871enum StoreForwardCall {
1872 Resolved(StoreCallSite),
1873 Unresolved(StoreUnresolvedCall),
1874}
1875
1876impl StoreForwardCall {
1877 fn byte_start(&self) -> usize {
1878 match self {
1879 Self::Resolved(site) => site.byte_start,
1880 Self::Unresolved(call) => call.byte_start,
1881 }
1882 }
1883
1884 fn line(&self) -> u32 {
1885 match self {
1886 Self::Resolved(site) => site.line,
1887 Self::Unresolved(call) => call.line,
1888 }
1889 }
1890}
1891
1892#[derive(Debug, Clone)]
1893struct FileExtract {
1894 rel_path: String,
1895 freshness: FileFreshness,
1896 lang: LangId,
1897 data: FileCallData,
1898 nodes: Vec<NodeRecord>,
1899 raw_refs: Vec<RawRef>,
1900 dispatch_hints: Vec<DispatchHint>,
1901 surface_fingerprint: String,
1902}
1903
1904#[derive(Debug, Clone)]
1905struct NodeRecord {
1906 id: String,
1907 file_path: String,
1908 name: String,
1909 scoped_name: String,
1910 kind: String,
1911 range: Range,
1912 range_ordinal: u32,
1913 signature: Option<String>,
1914 exported: bool,
1915 is_default_export: bool,
1916 is_type_like: bool,
1917 is_callgraph_entry_point: bool,
1918}
1919
1920#[derive(Debug, Clone)]
1921struct RawRef {
1922 ref_id: String,
1923 caller_node: Option<String>,
1924 caller_symbol: Option<String>,
1925 caller_file: String,
1926 kind: String,
1927 short_name: Option<String>,
1928 full_ref: Option<String>,
1929 module_path: Option<String>,
1930 import_kind: Option<String>,
1931 local_name: Option<String>,
1932 requested_name: Option<String>,
1933 namespace_alias: Option<String>,
1934 wildcard: bool,
1935 line: u32,
1936 byte_start: usize,
1937 byte_end: usize,
1938 dependencies: BTreeSet<String>,
1939}
1940
1941#[derive(Debug)]
1945struct StagedRef {
1946 rowid: u64,
1947 raw: RawRef,
1948}
1949
1950#[derive(Debug, Clone)]
1951struct ResolvedRef {
1952 raw: RawRef,
1953 status: String,
1954 target_node: Option<String>,
1955 target_file: Option<String>,
1956 target_symbol: Option<String>,
1957 dependencies: BTreeSet<String>,
1958 edge: Option<EdgeRecord>,
1959}
1960
1961#[derive(Debug, Clone)]
1962struct EdgeRecord {
1963 edge_id: String,
1964 source_node: String,
1965 target_node: Option<String>,
1966 target_file: String,
1967 target_symbol: String,
1968 kind: String,
1969 line: u32,
1970}
1971
1972#[derive(Debug, Clone)]
1973struct DispatchHint {
1974 id: String,
1975 method_name: String,
1976 caller_node: String,
1977 file: String,
1978 line: u32,
1979 byte_start: usize,
1980 byte_end: usize,
1981}
1982
1983#[derive(Debug, Clone)]
1984struct NameMatchRef {
1985 ref_id: String,
1986 caller_node: String,
1987 caller_file: String,
1988 caller_symbol: String,
1989 caller_signature: Option<String>,
1990 receiver_expression: String,
1991 receiver: String,
1992 method_name: String,
1993 colon_dispatch: bool,
1994 line: u32,
1995 lang: String,
1996}
1997
1998#[derive(Debug, Clone)]
1999struct NameMatchCandidate {
2000 node_id: String,
2001 file_path: String,
2002 scoped_name: String,
2003 kind: String,
2004 start_line: u32,
2006}
2007
2008#[derive(Debug, Clone)]
2009struct FileRow {
2010 surface_fingerprint: String,
2011 freshness: FileFreshness,
2012}
2013
2014#[derive(Debug, Clone)]
2015struct DbFileIndex {
2016 lang: Option<LangId>,
2017 exports: HashSet<String>,
2018 default_export: Option<String>,
2019 export_aliases: HashMap<String, String>,
2020 node_by_scoped: HashMap<String, String>,
2021 node_by_bare: HashMap<String, String>,
2022 node_kind_by_id: HashMap<String, String>,
2023 module_targets: HashMap<String, Option<String>>,
2024 reexports: Vec<ReexportIndex>,
2025}
2026
2027#[derive(Debug, Clone)]
2028struct ReexportIndex {
2029 target_file: Option<String>,
2030 named: HashMap<String, String>,
2031 wildcard: bool,
2032}
2033
2034#[derive(Debug, Clone)]
2035struct ProjectIndex<'a> {
2036 project_root: PathBuf,
2037 files: HashMap<String, DbFileIndex>,
2038 caller_data: HashMap<String, &'a FileCallData>,
2039 workspace_crate_prefixes: WorkspaceCratePrefixCache,
2044}
2045
2046trait ResolverIndex {
2050 fn caller_data(&self, file: &str) -> Option<&FileCallData>;
2051 fn lang_for(&self, file: &str) -> Option<LangId>;
2052 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String>;
2053 fn reexports_for(&self, file: &str) -> Vec<ReexportIndex>;
2054 fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String>;
2055 fn node_is_callable(&self, file: &str, node_id: &str) -> bool;
2056 fn export_alias(&self, file: &str, symbol: &str) -> Option<String>;
2057 fn has_export(&self, file: &str, symbol: &str) -> bool;
2058 fn default_export(&self, file: &str) -> Option<String>;
2059 fn contains_file(&self, file: &str) -> bool;
2060 fn crate_src_prefix(&self, crate_name: &str) -> Option<String>;
2061 fn inline_scoped_target(
2062 &self,
2063 caller_file: &str,
2064 module_segments: &[String],
2065 short_name: &str,
2066 ) -> Option<(String, String)>;
2067}
2068
2069impl ResolverIndex for ProjectIndex<'_> {
2070 fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2071 self.caller_data.get(file).copied()
2072 }
2073
2074 fn lang_for(&self, file: &str) -> Option<LangId> {
2075 self.lang_for(file)
2076 }
2077
2078 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2079 self.module_target(caller_file, module_path)
2080 }
2081
2082 fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2083 self.reexports_for(file).to_vec()
2084 }
2085
2086 fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2087 self.node_for_symbol(file, symbol)
2088 }
2089
2090 fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2091 self.node_is_callable(file, node_id)
2092 }
2093
2094 fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2095 self.files
2096 .get(file)
2097 .and_then(|item| item.export_aliases.get(symbol))
2098 .cloned()
2099 }
2100
2101 fn has_export(&self, file: &str, symbol: &str) -> bool {
2102 self.files
2103 .get(file)
2104 .is_some_and(|item| item.exports.contains(symbol))
2105 }
2106
2107 fn default_export(&self, file: &str) -> Option<String> {
2108 self.files
2109 .get(file)
2110 .and_then(|item| item.default_export.clone())
2111 }
2112
2113 fn contains_file(&self, file: &str) -> bool {
2114 self.files.contains_key(file)
2115 }
2116
2117 fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2118 self.workspace_crate_prefixes
2119 .0
2120 .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
2121 .get(crate_name)
2122 .cloned()
2123 }
2124
2125 fn inline_scoped_target(
2126 &self,
2127 caller_file: &str,
2128 module_segments: &[String],
2129 short_name: &str,
2130 ) -> Option<(String, String)> {
2131 let src_prefix = rust_src_prefix(caller_file);
2132 let mut file_paths = self.files.keys().cloned().collect::<Vec<_>>();
2133 file_paths.sort();
2134 if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
2135 let caller = file_paths.remove(position);
2136 file_paths.insert(0, caller);
2137 }
2138 for file_path in file_paths {
2139 if self.lang_for(&file_path) != Some(LangId::Rust)
2140 || rust_src_prefix(&file_path) != src_prefix
2141 {
2142 continue;
2143 }
2144 let file_module_segments = rust_module_segments_for_rel(&file_path);
2145 if !module_segments.starts_with(&file_module_segments) {
2146 continue;
2147 }
2148 let scoped_segments = &module_segments[file_module_segments.len()..];
2149 if scoped_segments.is_empty() {
2150 continue;
2151 }
2152 let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2153 if self.node_for_symbol(&file_path, &scoped_symbol).is_some() {
2154 return Some((file_path, scoped_symbol));
2155 }
2156 }
2157 None
2158 }
2159}
2160
2161struct DiskProjectIndex<'a> {
2165 project_root: &'a Path,
2166 conn: &'a Connection,
2167 caller_file: &'a str,
2168 caller_data: &'a FileCallData,
2169 workspace_crate_prefixes: WorkspaceCratePrefixCache,
2170}
2171
2172impl DiskProjectIndex<'_> {
2173 fn file_index(&self, rel_path: &str) -> Option<DbFileIndex> {
2174 let lang: String = self
2175 .conn
2176 .query_row(
2177 "SELECT lang FROM files WHERE path = ?1",
2178 params![rel_path],
2179 |row| row.get(0),
2180 )
2181 .optional()
2182 .ok()??;
2183 let mut index = DbFileIndex {
2184 lang: lang_from_label(&lang),
2185 exports: HashSet::new(),
2186 default_export: None,
2187 export_aliases: HashMap::new(),
2188 node_by_scoped: HashMap::new(),
2189 node_by_bare: HashMap::new(),
2190 node_kind_by_id: HashMap::new(),
2191 module_targets: HashMap::new(),
2192 reexports: Vec::new(),
2193 };
2194 let mut nodes = self
2195 .conn
2196 .prepare(
2197 "SELECT id, name, scoped_name, kind, exported, is_default_export
2198 FROM nodes WHERE file_path = ?1",
2199 )
2200 .ok()?;
2201 let rows = nodes
2202 .query_map(params![rel_path], |row| {
2203 Ok((
2204 row.get::<_, String>(0)?,
2205 row.get::<_, String>(1)?,
2206 row.get::<_, String>(2)?,
2207 row.get::<_, String>(3)?,
2208 row.get::<_, i64>(4)? != 0,
2209 row.get::<_, i64>(5)? != 0,
2210 ))
2211 })
2212 .ok()?
2213 .collect::<std::result::Result<Vec<_>, _>>()
2214 .ok()?;
2215 drop(nodes);
2216 for (id, name, scoped_name, kind, exported, is_default_export) in rows {
2217 if exported {
2218 index.exports.insert(name.clone());
2219 index.exports.insert(scoped_name.clone());
2220 }
2221 if is_default_export {
2222 index.default_export = Some(scoped_name.clone());
2223 }
2224 index.node_by_scoped.insert(scoped_name, id.clone());
2225 index.node_by_bare.entry(name).or_insert(id.clone());
2226 index.node_kind_by_id.insert(id, kind);
2227 }
2228
2229 let mut refs = self
2230 .conn
2231 .prepare(
2232 "SELECT ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name
2233 FROM refs
2234 WHERE caller_file = ?1 AND kind IN ('import', 'reexport', 'export_alias')",
2235 )
2236 .ok()?;
2237 let rows = refs
2238 .query_map(params![rel_path], |row| {
2239 Ok((
2240 row.get::<_, String>(0)?,
2241 row.get::<_, String>(1)?,
2242 row.get::<_, Option<String>>(2)?,
2243 row.get::<_, Option<String>>(3)?,
2244 row.get::<_, i64>(4)? != 0,
2245 row.get::<_, Option<String>>(5)?,
2246 row.get::<_, Option<String>>(6)?,
2247 ))
2248 })
2249 .ok()?
2250 .collect::<std::result::Result<Vec<_>, _>>()
2251 .ok()?;
2252 drop(refs);
2253 for (ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name) in rows {
2254 if kind == "export_alias" {
2255 if let (Some(exported), Some(source)) = (local_name, requested_name) {
2256 index.export_aliases.insert(exported, source);
2257 }
2258 continue;
2259 }
2260 let Some(module_path) = module_path else {
2261 continue;
2262 };
2263 let target_file = self.disk_module_target(rel_path, &module_path).or_else(|| {
2264 self.conn
2265 .query_row(
2266 "SELECT d.dep_file
2267 FROM file_dependencies d
2268 JOIN files f ON f.path = d.dep_file
2269 WHERE d.file_path = ?1
2270 ORDER BY d.dep_file
2271 LIMIT 1",
2272 params![rel_path],
2273 |row| row.get::<_, String>(0),
2274 )
2275 .optional()
2276 .ok()
2277 .flatten()
2278 });
2279 index
2280 .module_targets
2281 .entry(module_path.clone())
2282 .or_insert_with(|| target_file.clone());
2283 if kind == "reexport" {
2284 let raw = RawRef {
2285 ref_id,
2286 caller_node: None,
2287 caller_symbol: None,
2288 caller_file: rel_path.to_string(),
2289 kind,
2290 short_name: None,
2291 full_ref,
2292 module_path: Some(module_path),
2293 import_kind: Some("reexport".to_string()),
2294 local_name: None,
2295 requested_name: None,
2296 namespace_alias: None,
2297 wildcard,
2298 line: 0,
2299 byte_start: 0,
2300 byte_end: 0,
2301 dependencies: BTreeSet::new(),
2302 };
2303 index
2304 .reexports
2305 .push(reexport_index_from_raw(&raw, target_file));
2306 }
2307 }
2308 Some(index)
2309 }
2310
2311 fn disk_module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2312 let caller_dir = self.project_root.join(caller_file).parent()?.to_path_buf();
2313 let candidate = callgraph::resolve_module_path(&caller_dir, module_path)?;
2314 let rel_path = relative_path(self.project_root, &canonicalize_path(&candidate));
2315 self.contains_file(&rel_path).then_some(rel_path)
2316 }
2317}
2318
2319impl ResolverIndex for DiskProjectIndex<'_> {
2320 fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2321 (file == self.caller_file).then_some(self.caller_data)
2322 }
2323
2324 fn lang_for(&self, file: &str) -> Option<LangId> {
2325 self.file_index(file).and_then(|index| index.lang)
2326 }
2327
2328 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2329 self.file_index(caller_file)
2330 .and_then(|index| index.module_targets.get(module_path).cloned().flatten())
2331 }
2332
2333 fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2334 self.file_index(file)
2335 .map(|index| index.reexports)
2336 .unwrap_or_default()
2337 }
2338
2339 fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2340 self.file_index(file).and_then(|index| {
2341 index
2342 .node_by_scoped
2343 .get(symbol)
2344 .cloned()
2345 .or_else(|| index.node_by_bare.get(symbol).cloned())
2346 })
2347 }
2348
2349 fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2350 self.file_index(file)
2351 .and_then(|index| index.node_kind_by_id.get(node_id).cloned())
2352 .is_some_and(|kind| matches!(kind.as_str(), "function" | "method"))
2353 }
2354
2355 fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2356 self.file_index(file)
2357 .and_then(|index| index.export_aliases.get(symbol).cloned())
2358 }
2359
2360 fn has_export(&self, file: &str, symbol: &str) -> bool {
2361 self.file_index(file)
2362 .is_some_and(|index| index.exports.contains(symbol))
2363 }
2364
2365 fn default_export(&self, file: &str) -> Option<String> {
2366 self.file_index(file).and_then(|index| index.default_export)
2367 }
2368
2369 fn contains_file(&self, file: &str) -> bool {
2370 self.conn
2371 .query_row(
2372 "SELECT 1 FROM files WHERE path = ?1 LIMIT 1",
2373 params![file],
2374 |_| Ok(()),
2375 )
2376 .is_ok()
2377 }
2378
2379 fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2380 self.workspace_crate_prefixes
2381 .0
2382 .get_or_init(|| build_workspace_crate_prefixes(self.project_root))
2383 .get(crate_name)
2384 .cloned()
2385 }
2386
2387 fn inline_scoped_target(
2388 &self,
2389 caller_file: &str,
2390 module_segments: &[String],
2391 short_name: &str,
2392 ) -> Option<(String, String)> {
2393 let src_prefix = rust_src_prefix(caller_file);
2394 let check = |file_path: String| {
2395 let file_module_segments = rust_module_segments_for_rel(&file_path);
2396 if rust_src_prefix(&file_path) != src_prefix
2397 || !module_segments.starts_with(&file_module_segments)
2398 {
2399 return None;
2400 }
2401 let scoped_segments = &module_segments[file_module_segments.len()..];
2402 if scoped_segments.is_empty() {
2403 return None;
2404 }
2405 let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2406 self.node_for_symbol(&file_path, &scoped_symbol)
2407 .map(|_| (file_path, scoped_symbol))
2408 };
2409 if let Some(target) = check(caller_file.to_string()) {
2410 return Some(target);
2411 }
2412 let mut statement = self
2413 .conn
2414 .prepare("SELECT path FROM files WHERE lang = 'rust' AND path <> ?1 ORDER BY path")
2415 .ok()?;
2416 let rows = statement
2417 .query_map(params![caller_file], |row| row.get::<_, String>(0))
2418 .ok()?;
2419 for path in rows.flatten() {
2420 if let Some(target) = check(path) {
2421 return Some(target);
2422 }
2423 }
2424 None
2425 }
2426}
2427
2428impl CallGraphStore {
2429 pub fn open_if_enabled(
2430 options: CallGraphStoreOptions,
2431 callgraph_dir: PathBuf,
2432 project_root: PathBuf,
2433 ) -> Result<Option<Self>> {
2434 if !options.enabled {
2435 return Ok(None);
2436 }
2437 Self::open(callgraph_dir, project_root).map(Some)
2438 }
2439
2440 pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
2441 let project_key = crate::search_index::artifact_cache_key(&project_root);
2442 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2443 else {
2444 return Err(CallGraphStoreError::Unavailable(
2445 "writer capability denied; use the read-only callgraph opener".to_string(),
2446 ));
2447 };
2448 std::fs::create_dir_all(&callgraph_dir)?;
2449 let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
2453 .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
2454 let OpenedStore { store, root_repair } = Self::open_at_path(
2455 project_root.clone(),
2456 project_key,
2457 sqlite_path,
2458 generation,
2459 true,
2460 Some(Arc::clone(&writer_lease)),
2461 None,
2462 )?;
2463 match root_repair {
2464 OpenRootRepair::NeedsRebuild { .. } => {
2465 log_root_repair_rebuild(&root_repair);
2466 drop(store);
2467 drop(writer_lease);
2468 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2469 let (store, _stats) =
2470 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2471 Ok(store)
2472 }
2473 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
2474 }
2475 }
2476
2477 pub fn open_readonly(
2478 callgraph_dir: PathBuf,
2479 project_root: PathBuf,
2480 ) -> Result<Option<ReadonlyCallGraphStore>> {
2481 let project_key = crate::search_index::artifact_cache_key(&project_root);
2482 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2483 {
2484 let conn = open_readonly_connection(&sqlite_path)?;
2485 if !database_ready(&conn).unwrap_or(false) {
2486 return Ok(None);
2487 }
2488 let marker_label = generation.as_deref().unwrap_or("legacy");
2489 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
2490 return Ok(Some(ReadonlyCallGraphStore::from_inner(
2491 Self::from_connection(
2492 project_root,
2493 project_key,
2494 sqlite_path,
2495 callgraph_dir,
2496 false,
2497 generation,
2498 None,
2499 Some(read_marker),
2500 conn,
2501 ),
2502 )));
2503 }
2504
2505 let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
2506 return Ok(None);
2507 };
2508 crate::slog_warn!(
2509 "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
2510 target.partition.harness,
2511 target.sqlite_path.display()
2512 );
2513 let conn = open_readonly_connection(&target.sqlite_path)?;
2514 if !database_ready(&conn).unwrap_or(false) {
2515 return Ok(None);
2516 }
2517 let marker_label =
2518 legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
2519 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
2520 Ok(Some(ReadonlyCallGraphStore::from_inner(
2521 Self::from_connection(
2522 project_root,
2523 project_key,
2524 target.sqlite_path,
2525 callgraph_dir,
2526 true,
2527 target.generation,
2528 None,
2529 Some(read_marker),
2530 conn,
2531 ),
2532 )))
2533 }
2534
2535 pub fn open_ready_repairing(
2541 callgraph_dir: PathBuf,
2542 project_root: PathBuf,
2543 ) -> Result<Option<Self>> {
2544 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true)
2545 }
2546
2547 pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
2551 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false)
2552 }
2553
2554 pub fn open_ready_no_rebuild(
2555 callgraph_dir: PathBuf,
2556 project_root: PathBuf,
2557 ) -> Result<Option<Self>> {
2558 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true)
2559 }
2560
2561 fn open_ready_with_rebuild_policy(
2562 callgraph_dir: PathBuf,
2563 project_root: PathBuf,
2564 allow_cold_build: bool,
2565 allow_root_repair: bool,
2566 ) -> Result<Option<Self>> {
2567 let project_key = crate::search_index::artifact_cache_key(&project_root);
2568 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2569 else {
2570 return Ok(None);
2571 };
2572 let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2573 else {
2574 return Ok(None);
2575 };
2576 let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
2577 project_root.clone(),
2578 project_key.clone(),
2579 sqlite_path,
2580 generation,
2581 true,
2582 Some(Arc::clone(&writer_lease)),
2583 None,
2584 allow_root_repair,
2585 )?;
2586 match root_repair {
2587 OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
2588 log_root_repair_rebuild(&root_repair);
2589 drop(store);
2590 drop(writer_lease);
2591 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2592 let (store, _stats) =
2593 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2594 Ok(Some(store))
2595 }
2596 OpenRootRepair::NeedsRebuild { .. } => {
2597 if let Some(message) = note_repair_entry(&project_key) {
2598 crate::slog_warn!("{message}");
2599 }
2600 Ok(None)
2601 }
2602 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2603 }
2604 }
2605
2606 pub fn cold_build_with_lease(
2607 callgraph_dir: PathBuf,
2608 project_root: PathBuf,
2609 files: &[PathBuf],
2610 ) -> Result<(Self, ColdBuildStats)> {
2611 Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
2612 }
2613
2614 pub fn cold_build_with_lease_chunked(
2615 callgraph_dir: PathBuf,
2616 project_root: PathBuf,
2617 files: &[PathBuf],
2618 chunk_size: usize,
2619 ) -> Result<(Self, ColdBuildStats)> {
2620 Self::cold_build_with_lease_chunked_inner(
2621 callgraph_dir,
2622 project_root,
2623 files,
2624 chunk_size,
2625 false,
2626 )
2627 }
2628
2629 pub(crate) fn force_cold_build_with_lease_chunked(
2630 callgraph_dir: PathBuf,
2631 project_root: PathBuf,
2632 files: &[PathBuf],
2633 chunk_size: usize,
2634 ) -> Result<(Self, ColdBuildStats)> {
2635 Self::cold_build_with_lease_chunked_inner(
2636 callgraph_dir,
2637 project_root,
2638 files,
2639 chunk_size,
2640 true,
2641 )
2642 }
2643
2644 fn cold_build_with_lease_chunked_inner(
2645 callgraph_dir: PathBuf,
2646 project_root: PathBuf,
2647 files: &[PathBuf],
2648 chunk_size: usize,
2649 require_new_publication: bool,
2650 ) -> Result<(Self, ColdBuildStats)> {
2651 let project_key = crate::search_index::artifact_cache_key(&project_root);
2652 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2653 else {
2654 let operation = if require_new_publication {
2655 "forced rebuild"
2656 } else {
2657 "cold build"
2658 };
2659 return Err(CallGraphStoreError::Unavailable(format!(
2660 "{operation} could not acquire writer capability"
2661 )));
2662 };
2663 std::fs::create_dir_all(&callgraph_dir)?;
2664 let (stats, generation) = Self::cold_build_publish_locked(
2665 &callgraph_dir,
2666 &project_root,
2667 &project_key,
2668 files,
2669 chunk_size,
2670 Arc::clone(&writer_lease),
2671 )?;
2672 let store = Self::open_generation(
2673 &callgraph_dir,
2674 project_root,
2675 project_key,
2676 generation,
2677 writer_lease,
2678 )?;
2679 Ok((store, stats))
2680 }
2681
2682 pub fn ensure_built_with_lease(
2683 callgraph_dir: PathBuf,
2684 project_root: PathBuf,
2685 files: &[PathBuf],
2686 ) -> Result<(Self, Option<ColdBuildStats>)> {
2687 Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
2688 }
2689
2690 pub fn ensure_built_with_lease_chunked(
2691 callgraph_dir: PathBuf,
2692 project_root: PathBuf,
2693 files: &[PathBuf],
2694 chunk_size: usize,
2695 ) -> Result<(Self, Option<ColdBuildStats>)> {
2696 let project_key = crate::search_index::artifact_cache_key(&project_root);
2697 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2698 else {
2699 return Err(CallGraphStoreError::Unavailable(
2700 "callgraph ensure could not acquire writer capability".to_string(),
2701 ));
2702 };
2703 std::fs::create_dir_all(&callgraph_dir)?;
2704 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2705 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2712 {
2713 let OpenedStore { store, root_repair } = Self::open_at_path(
2714 project_root.clone(),
2715 project_key.clone(),
2716 sqlite_path,
2717 generation,
2718 true,
2719 Some(Arc::clone(&writer_lease)),
2720 None,
2721 )?;
2722 match root_repair {
2723 OpenRootRepair::NeedsRebuild { .. } => {
2724 log_root_repair_rebuild(&root_repair);
2725 drop(store);
2726 let (stats, generation) = Self::cold_build_publish_locked(
2727 &callgraph_dir,
2728 &project_root,
2729 &project_key,
2730 files,
2731 chunk_size,
2732 Arc::clone(&writer_lease),
2733 )?;
2734 let store = Self::open_generation(
2735 &callgraph_dir,
2736 project_root,
2737 project_key,
2738 generation,
2739 writer_lease,
2740 )?;
2741 return Ok((store, Some(stats)));
2742 }
2743 OpenRootRepair::None | OpenRootRepair::ReRooted => {
2744 return Ok((store, None));
2745 }
2746 }
2747 }
2748 if let Some(store) = try_legacy_migration_or_fallback(
2749 &callgraph_dir,
2750 &project_root,
2751 &project_key,
2752 Arc::clone(&writer_lease),
2753 )? {
2754 return Ok((store, None));
2755 }
2756 let (stats, generation) = Self::cold_build_publish_locked(
2757 &callgraph_dir,
2758 &project_root,
2759 &project_key,
2760 files,
2761 chunk_size,
2762 Arc::clone(&writer_lease),
2763 )?;
2764 let store = Self::open_generation(
2765 &callgraph_dir,
2766 project_root,
2767 project_key,
2768 generation,
2769 writer_lease,
2770 )?;
2771 Ok((store, Some(stats)))
2772 }
2773
2774 pub fn migrate_legacy_with_lease(
2781 callgraph_dir: PathBuf,
2782 project_root: PathBuf,
2783 ) -> Result<Option<Self>> {
2784 let project_key = crate::search_index::artifact_cache_key(&project_root);
2785 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2786 else {
2787 return Ok(None);
2788 };
2789 std::fs::create_dir_all(&callgraph_dir)?;
2790 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2791
2792 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2796 {
2797 let OpenedStore { store, root_repair } = Self::open_at_path(
2798 project_root,
2799 project_key,
2800 sqlite_path,
2801 generation,
2802 true,
2803 Some(writer_lease),
2804 None,
2805 )?;
2806 return match root_repair {
2807 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2808 OpenRootRepair::NeedsRebuild { reason, .. } => {
2809 Err(CallGraphStoreError::Unavailable(format!(
2810 "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
2811 )))
2812 }
2813 };
2814 }
2815
2816 let store = try_legacy_migration_or_fallback(
2817 &callgraph_dir,
2818 &project_root,
2819 &project_key,
2820 writer_lease,
2821 )?;
2822 Ok(store.filter(|store| !store.is_legacy_fallback()))
2826 }
2827
2828 fn cold_build_publish_locked(
2839 callgraph_dir: &Path,
2840 project_root: &Path,
2841 project_key: &str,
2842 files: &[PathBuf],
2843 chunk_size: usize,
2844 writer_lease: Arc<crate::root_cache::WriterLease>,
2845 ) -> Result<(ColdBuildStats, String)> {
2846 if let Some((previous_root, remaining)) =
2847 rebuild_cooldown_denial(callgraph_dir, project_key, project_root, Instant::now())
2848 {
2849 return Err(CallGraphStoreError::Unavailable(format!(
2850 "cache key {project_key} was rebuilt for {} too recently; retry {} ms after the per-key cooldown",
2851 previous_root.display(),
2852 remaining.as_millis()
2853 )));
2854 }
2855 let breaker = crate::build_breaker::BuildDeathBreaker::open(
2856 callgraph_dir.join("build-breaker.sqlite"),
2857 )
2858 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
2859
2860 let generation = generation_file_name(project_key);
2861 let gen_path = callgraph_dir.join(&generation);
2862 let temp_path = callgraph_dir.join(format!("{project_key}.staging.sqlite.tmp.resume"));
2866 let adopting_staging = temp_path.exists();
2867 if !adopting_staging {
2868 remove_sqlite_file_set(&temp_path);
2869 }
2870
2871 let (stats, breaker_key) = {
2872 if adopting_staging {
2873 crate::slog_info!(
2874 "resuming callgraph cold build from staged generation {}",
2875 temp_path.display()
2876 );
2877 }
2878 let temp_store = Self::open_at_path(
2879 project_root.to_path_buf(),
2880 project_key.to_string(),
2881 temp_path.clone(),
2882 None,
2883 false,
2884 Some(Arc::clone(&writer_lease)),
2885 None,
2886 )?
2887 .store;
2888 let admission_fingerprint = corpus_fingerprint_for(project_root, files)?;
2899 let breaker_key = crate::build_breaker::BreakerKey::new(
2900 project_root.display().to_string(),
2901 crate::build_breaker::BuildDomain::CallgraphCold,
2902 admission_fingerprint,
2903 );
2904 match breaker
2905 .admit(&breaker_key, 0)
2906 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?
2907 {
2908 crate::build_breaker::BreakerAdmission::Admitted(_) => {}
2909 crate::build_breaker::BreakerAdmission::Suspended(suspension) => {
2910 return Err(CallGraphStoreError::Suspended(suspension));
2911 }
2912 }
2913 let corpus_fingerprint = temp_store.stage_cold_build_file_inventory(files)?;
2914 let stats = temp_store
2915 .cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)?;
2916 let _ = temp_store.checkpoint_wal_truncate();
2917 temp_store.prepare_for_atomic_swap()?;
2918 (stats, breaker_key)
2919 };
2920
2921 notify_cold_build_before_publish_observer();
2922 let publication = publish_if_current(|| {
2923 verify_writer_lease(&writer_lease)?;
2924 remove_sqlite_file_set(&gen_path);
2927 crate::fs_lock::rename_over(&temp_path, &gen_path)?;
2928 crate::fs_lock::sync_parent(&gen_path);
2929 remove_sqlite_sidecars(&gen_path);
2930
2931 notify_cold_build_swap_observer(&temp_path, &gen_path);
2932
2933 verify_writer_lease(&writer_lease)?;
2935 publish_pointer(callgraph_dir, project_key, &generation)?;
2936 gc_old_generations(callgraph_dir, project_key, &generation);
2937 sweep_orphaned_build_temps_store_wide(callgraph_dir);
2941 if let Some(storage_root) = root_storage_dir(callgraph_dir) {
2942 let inspect_root =
2943 storage_root.join(crate::root_cache::RootCacheDomain::Inspect.as_str());
2944 let live_scope_keys = crate::root_cache::live_scope_keys_for_storage(&storage_root);
2945 crate::inspect::cache::sweep_inspect_scope_dirs(&inspect_root, &live_scope_keys);
2946 }
2947 Ok(())
2948 });
2949 if matches!(publication, Err(CallGraphStoreError::Superseded)) {
2950 remove_sqlite_file_set(&temp_path);
2951 }
2952 publication?;
2953 breaker
2956 .record_ready_publication(&breaker_key)
2957 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
2958 record_successful_rebuild(callgraph_dir, project_key, project_root, Instant::now());
2959 Ok((stats, generation))
2960 }
2961
2962 fn open_generation(
2965 callgraph_dir: &Path,
2966 project_root: PathBuf,
2967 project_key: String,
2968 generation: String,
2969 writer_lease: Arc<crate::root_cache::WriterLease>,
2970 ) -> Result<Self> {
2971 let gen_path = callgraph_dir.join(&generation);
2972 Ok(Self::open_at_path(
2973 project_root,
2974 project_key,
2975 gen_path,
2976 Some(generation),
2977 true,
2978 Some(writer_lease),
2979 None,
2980 )?
2981 .store)
2982 }
2983
2984 pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
2985 let project_key = crate::search_index::artifact_cache_key(project_root);
2986 Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
2989 }
2990
2991 pub fn cold_build_suspension(
2996 callgraph_dir: &Path,
2997 project_root: &Path,
2998 ) -> Result<Option<crate::build_breaker::BuildSuspension>> {
2999 let breaker_path = callgraph_dir.join("build-breaker.sqlite");
3000 if !breaker_path.exists() {
3001 return Ok(None);
3002 }
3003 let key = crate::build_breaker::BreakerKey::new(
3004 project_root.display().to_string(),
3005 crate::build_breaker::BuildDomain::CallgraphCold,
3006 callgraph_corpus_fingerprint(project_root)?,
3007 );
3008 crate::build_breaker::BuildDeathBreaker::open(breaker_path)
3009 .and_then(|breaker| breaker.suspension(&key))
3010 .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))
3011 }
3012
3013 fn open_at_path(
3014 project_root: PathBuf,
3015 project_key: String,
3016 sqlite_path: PathBuf,
3017 generation: Option<String>,
3018 use_wal: bool,
3019 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3020 read_marker: Option<crate::root_cache::ReadMarker>,
3021 ) -> Result<OpenedStore> {
3022 Self::open_at_path_with_root_repair(
3023 project_root,
3024 project_key,
3025 sqlite_path,
3026 generation,
3027 use_wal,
3028 writer_lease,
3029 read_marker,
3030 true,
3031 )
3032 }
3033
3034 fn open_at_path_with_root_repair(
3035 project_root: PathBuf,
3036 project_key: String,
3037 sqlite_path: PathBuf,
3038 generation: Option<String>,
3039 use_wal: bool,
3040 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3041 read_marker: Option<crate::root_cache::ReadMarker>,
3042 allow_root_repair: bool,
3043 ) -> Result<OpenedStore> {
3044 if let Some(lease) = writer_lease.as_ref() {
3045 verify_writer_lease(lease)?;
3046 }
3047 if let Some(parent) = sqlite_path.parent() {
3048 std::fs::create_dir_all(parent)?;
3049 }
3050 let mut conn = Connection::open(&sqlite_path)?;
3051 if use_wal {
3052 configure_connection(&conn)?;
3053 } else {
3054 configure_build_connection(&conn)?;
3055 }
3056 if let Some(lease) = writer_lease.as_ref() {
3057 verify_writer_lease(lease)?;
3058 }
3059 initialize_schema(&conn)?;
3060 if let Some(lease) = writer_lease.as_ref() {
3061 verify_writer_lease(lease)?;
3062 }
3063 let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
3064 let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
3065 (Some(marker), _, _) => Some(marker),
3066 (None, Some(label), Some(cache_dir)) => {
3067 Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
3068 }
3069 (None, _, _) => None,
3070 };
3071 let publication_dir = sqlite_path
3072 .parent()
3073 .map(Path::to_path_buf)
3074 .unwrap_or_default();
3075 let store = Self::from_connection(
3076 project_root,
3077 project_key,
3078 sqlite_path,
3079 publication_dir,
3080 false,
3081 generation,
3082 writer_lease,
3083 read_marker,
3084 conn,
3085 );
3086 Ok(OpenedStore { store, root_repair })
3087 }
3088
3089 fn prepare_for_atomic_swap(&self) -> Result<()> {
3090 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3091 conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
3092 Ok(())
3093 }
3094
3095 fn atomic_swap_checkpoint_sql(&self) -> &'static str {
3096 let protected_reader = self.generation.as_deref().is_some_and(|generation| {
3097 self.sqlite_path
3098 .parent()
3099 .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
3100 });
3101 if protected_reader {
3102 "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
3103 } else {
3104 "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
3105 }
3106 }
3107
3108 fn from_connection(
3109 project_root: PathBuf,
3110 project_key: String,
3111 sqlite_path: PathBuf,
3112 publication_dir: PathBuf,
3113 legacy_fallback: bool,
3114 generation: Option<String>,
3115 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3116 read_marker: Option<crate::root_cache::ReadMarker>,
3117 conn: Connection,
3118 ) -> Self {
3119 let write_metrics = callgraph_write_metrics_for_key(&project_key);
3120 Self {
3121 project_root,
3122 project_key,
3123 sqlite_path,
3124 publication_dir,
3125 legacy_fallback,
3126 generation,
3127 writer_lease,
3128 read_marker,
3129 database_ready: AtomicBool::new(false),
3130 write_metrics,
3131 conn: Mutex::new(conn),
3132 }
3133 }
3134
3135 fn ensure_ready(&self, conn: &Connection) -> Result<()> {
3136 if self.database_ready.load(AtomicOrdering::Acquire) {
3137 return Ok(());
3138 }
3139 ensure_database_ready(conn)?;
3140 self.database_ready.store(true, AtomicOrdering::Release);
3141 Ok(())
3142 }
3143
3144 pub fn project_root(&self) -> &Path {
3145 &self.project_root
3146 }
3147
3148 pub fn project_key(&self) -> &str {
3149 &self.project_key
3150 }
3151
3152 pub fn sqlite_path(&self) -> &Path {
3153 &self.sqlite_path
3154 }
3155
3156 pub(crate) fn projection_generation(&self) -> Option<&str> {
3158 self.generation.as_deref()
3159 }
3160
3161 pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
3163 self.refresh_read_marker()?;
3164 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3165 self.ensure_ready(&conn)?;
3166 projection_write_revision(&conn)
3167 }
3168
3169 pub fn is_legacy_fallback(&self) -> bool {
3172 self.legacy_fallback
3173 }
3174
3175 pub(crate) fn is_legacy_migration(&self) -> bool {
3176 self.generation.as_deref().is_some_and(|generation| {
3177 migration_generation_requires_manifest(generation)
3178 && migration_manifest_valid(&self.publication_dir, generation)
3179 })
3180 }
3181
3182 pub fn writer_epoch_for_test(&self) -> Option<&str> {
3183 self.writer_lease.as_ref().map(|lease| lease.epoch())
3184 }
3185
3186 fn verify_writer_lease(&self) -> Result<()> {
3187 let Some(lease) = self.writer_lease.as_ref() else {
3188 return Err(CallGraphStoreError::Unavailable(
3189 "callgraph store opened read-only; write API is unavailable".to_string(),
3190 ));
3191 };
3192 verify_writer_lease(lease)
3193 }
3194
3195 fn refresh_read_marker(&self) -> Result<()> {
3196 if let Some(marker) = self.read_marker.as_ref() {
3197 marker.touch_if_due()?;
3198 }
3199 Ok(())
3200 }
3201
3202 fn record_commit(&self, total_changes_before: u64, conn: &Connection) {
3203 self.write_metrics
3204 .record_commit(conn.total_changes().saturating_sub(total_changes_before));
3205 }
3206
3207 fn checkpoint_wal_truncate(&self) -> bool {
3208 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3209 checkpoint_wal_truncate(&conn)
3210 }
3211
3212 pub fn is_current(&self) -> bool {
3218 let _ = self.refresh_read_marker();
3219 match (
3220 read_pointer(&self.publication_dir, &self.project_key),
3221 &self.generation,
3222 ) {
3223 (Some(_), _) if self.legacy_fallback => false,
3226 (Some(published), Some(opened)) => &published == opened,
3227 (Some(_), None) => false,
3229 (None, _) => true,
3232 }
3233 }
3234
3235 pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
3236 self.cold_build_chunked(files, COLD_BUILD_EXTRACT_BATCH_FILES)
3237 }
3238
3239 pub fn cold_build_chunked(
3243 &self,
3244 files: &[PathBuf],
3245 chunk_size: usize,
3246 ) -> Result<ColdBuildStats> {
3247 let corpus_fingerprint = self.stage_cold_build_file_inventory(files)?;
3248 self.cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)
3249 }
3250
3251 fn stage_cold_build_file_inventory(&self, files: &[PathBuf]) -> Result<String> {
3252 note_cold_build_phase("enumeration");
3253 if files.is_empty() {
3254 self.stage_cold_build_file_inventory_from(callgraph::walk_project_files(
3255 &self.project_root,
3256 ))
3257 } else {
3258 self.stage_cold_build_file_inventory_from(files.iter().cloned())
3259 }
3260 }
3261
3262 fn stage_cold_build_file_inventory_from<I>(&self, paths: I) -> Result<String>
3263 where
3264 I: IntoIterator<Item = PathBuf>,
3265 {
3266 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3267 self.verify_writer_lease()?;
3268 let total_changes_before = conn.total_changes();
3269 let tx = conn.transaction()?;
3270 tx.execute("DELETE FROM staging_file_inventory", [])?;
3271 tx.commit()?;
3272 self.record_commit(total_changes_before, &conn);
3273
3274 let mut batch = Vec::with_capacity(COLD_BUILD_EXTRACT_BATCH_FILES);
3275 for path in paths {
3276 let path = normalize_file_path(&self.project_root, &path)?;
3277 let rel_path = relative_path(&self.project_root, &path);
3278 let size = std::fs::metadata(&path)
3279 .map(|metadata| metadata.len())
3280 .unwrap_or(0);
3281 batch.push((rel_path, size));
3282 if batch.len() == COLD_BUILD_EXTRACT_BATCH_FILES {
3283 self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3284 batch.clear();
3285 }
3286 }
3287 if !batch.is_empty() {
3288 self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3289 }
3290
3291 staged_corpus_fingerprint(&conn, &self.project_root)
3292 }
3293
3294 fn insert_staged_file_inventory_batch(
3295 &self,
3296 conn: &mut Connection,
3297 batch: &[(String, u64)],
3298 ) -> Result<()> {
3299 self.verify_writer_lease()?;
3300 let total_changes_before = conn.total_changes();
3301 let tx = conn.transaction()?;
3302 {
3303 let mut insert = tx.prepare(
3304 "INSERT OR REPLACE INTO staging_file_inventory(path, size) VALUES(?1, ?2)",
3305 )?;
3306 for (path, size) in batch {
3307 insert.execute(params![path, *size as i64])?;
3308 }
3309 }
3310 tx.commit()?;
3311 self.record_commit(total_changes_before, conn);
3312 Ok(())
3313 }
3314
3315 fn cold_build_chunked_from_staged_inventory(
3316 &self,
3317 chunk_size: usize,
3318 corpus_fingerprint: &str,
3319 ) -> Result<ColdBuildStats> {
3320 let started = Instant::now();
3321 let batch_files = chunk_size.max(1).min(COLD_BUILD_EXTRACT_BATCH_FILES);
3322 let workspace_root = self.project_root.display().to_string();
3323 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3324
3325 self.verify_writer_lease()?;
3326 let mut phase = staged_build_phase(&conn)?;
3327 let staged_fingerprint = staged_string(&conn, STAGED_CORPUS_FINGERPRINT)?;
3328 if phase.as_deref().is_none_or(|phase| phase == "ready")
3329 || staged_fingerprint.as_deref() != Some(corpus_fingerprint)
3330 {
3331 let total_changes_before = conn.total_changes();
3332 let tx = conn.transaction()?;
3333 clear_tables(&tx)?;
3334 tx.execute("DELETE FROM staging_ref_context", [])?;
3335 insert_meta(&tx)?;
3336 drop_cold_build_secondary_indexes(&tx)?;
3337 set_meta_ready(&tx, false)?;
3338 set_staged_build_phase(&tx, "extracting")?;
3339 set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, corpus_fingerprint)?;
3340 set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0)?;
3341 set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, 0)?;
3342 tx.commit()?;
3343 self.record_commit(total_changes_before, &conn);
3344 phase = Some("extracting".to_string());
3345 }
3346
3347 note_cold_build_phase("extraction");
3351 if phase.as_deref() == Some("extracting") {
3352 prune_staged_files_not_in_inventory(&mut conn)?;
3353
3354 let mut after_path = String::new();
3355 loop {
3356 let Some(batch) = load_staged_file_batch(
3357 &conn,
3358 &self.project_root,
3359 &after_path,
3360 batch_files,
3361 COLD_BUILD_EXTRACT_BATCH_BYTES,
3362 )?
3363 else {
3364 break;
3365 };
3366 after_path = batch.last_path;
3367
3368 let mut needs_extract = Vec::with_capacity(batch.paths.len());
3369 for path in batch.paths {
3370 if !staged_content_matches(&conn, &self.project_root, &path)? {
3371 needs_extract.push(path);
3372 }
3373 }
3374 if needs_extract.is_empty() {
3375 continue;
3376 }
3377
3378 let build = build_extracts_parallel(&self.project_root, &needs_extract);
3379 self.verify_writer_lease()?;
3380 let total_changes_before = conn.total_changes();
3381 let tx = conn.transaction()?;
3382 let mut extracted_bytes = 0u64;
3383 {
3384 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3385 for extract in &build.extracts {
3386 delete_staged_file_rows(&tx, &extract.rel_path)?;
3387 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
3388 for raw in &extract.raw_refs {
3389 insert_staged_ref_prepared(&mut inserts, raw)?;
3390 }
3391 extracted_bytes = extracted_bytes.saturating_add(extract.freshness.size);
3392 }
3393 for failure in &build.failures {
3394 insert_backend_state_prepared(
3395 &mut inserts.backend_state,
3396 &workspace_root,
3397 &failure.rel_path,
3398 failure
3399 .freshness
3400 .as_ref()
3401 .map(|freshness| &freshness.content_hash),
3402 "stale",
3403 )?;
3404 }
3405 }
3406 increment_staged_extracted_bytes(&tx, extracted_bytes)?;
3407 note_cold_build_commit_barrier("extraction_batch_before_commit");
3408 tx.commit()?;
3409 note_cold_build_commit_barrier("extraction_batch_committed");
3410 self.record_commit(total_changes_before, &conn);
3411 }
3412
3413 let total_changes_before = conn.total_changes();
3414 let tx = conn.transaction()?;
3415 set_staged_build_phase(&tx, "indexing")?;
3416 tx.commit()?;
3417 self.record_commit(total_changes_before, &conn);
3418 phase = Some("indexing".to_string());
3419 }
3420
3421 note_cold_build_phase("symbol_export_index");
3425 if phase.as_deref() == Some("indexing") {
3426 self.verify_writer_lease()?;
3427 let total_changes_before = conn.total_changes();
3428 let tx = conn.transaction()?;
3429 create_cold_build_secondary_indexes(&tx)?;
3430 set_staged_build_phase(&tx, "resolving")?;
3431 tx.commit()?;
3432 self.record_commit(total_changes_before, &conn);
3433 }
3434
3435 note_cold_build_phase("resolution");
3436 let workspace_crate_prefixes = WorkspaceCratePrefixCache::default();
3437 let mut resolve_cursor = staged_u64(&conn, STAGED_RESOLVE_CURSOR)?;
3438 loop {
3439 let staged = load_staged_ref_window(&conn, resolve_cursor, COLD_BUILD_RESOLVE_WINDOW)?;
3440 let Some(last_rowid) = staged.last().map(|entry| entry.rowid) else {
3441 break;
3442 };
3443
3444 self.verify_writer_lease()?;
3445 let total_changes_before = conn.total_changes();
3446 let tx = conn.transaction()?;
3447 {
3448 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3449 let mut offset = 0;
3450 while offset < staged.len() {
3451 let caller_file = staged[offset].raw.caller_file.clone();
3452 let end = staged[offset..]
3453 .iter()
3454 .position(|entry| entry.raw.caller_file != caller_file)
3455 .map(|relative| offset + relative)
3456 .unwrap_or(staged.len());
3457 let caller_extract = build_file_extract(
3458 &self.project_root,
3459 &self.project_root.join(&caller_file),
3460 );
3461 if let Ok(caller_extract) = caller_extract {
3462 let index = DiskProjectIndex {
3463 project_root: &self.project_root,
3464 conn: &tx,
3465 caller_file: &caller_file,
3466 caller_data: &caller_extract.data,
3467 workspace_crate_prefixes: workspace_crate_prefixes.clone(),
3468 };
3469 for staged_ref in &staged[offset..end] {
3470 let resolved = resolve_ref(staged_ref.raw.clone(), &index)?;
3471 insert_resolved_ref_prepared(&mut inserts, &resolved)?;
3472 }
3473 } else {
3474 for staged_ref in &staged[offset..end] {
3475 let unresolved = unresolved_staged_ref(staged_ref.raw.clone());
3476 insert_resolved_ref_prepared(&mut inserts, &unresolved)?;
3477 }
3478 }
3479 offset = end;
3480 }
3481 }
3482 set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, last_rowid)?;
3483 tx.commit()?;
3484 self.record_commit(total_changes_before, &conn);
3485 resolve_cursor = last_rowid;
3486 }
3487
3488 note_cold_build_phase("publication");
3489 self.verify_writer_lease()?;
3490 let total_changes_before = conn.total_changes();
3491 let tx = conn.transaction()?;
3492 let _supplemental_edge_count =
3493 insert_method_dispatch_edges_chunked(&tx, &self.project_root, batch_files)?;
3494 set_meta_ready(&tx, true)?;
3495 set_staged_build_phase(&tx, "ready")?;
3496 tx.execute("DELETE FROM staging_file_inventory", [])?;
3497 tx.execute("DELETE FROM staging_ref_context", [])?;
3498 bump_projection_write_revision(&tx)?;
3499 tx.commit()?;
3500 self.record_commit(total_changes_before, &conn);
3501
3502 let files = query_count(&conn, "SELECT COUNT(*) FROM files")? as usize;
3503 let nodes = query_count(&conn, "SELECT COUNT(*) FROM nodes")? as usize;
3504 let refs = query_count(&conn, "SELECT COUNT(*) FROM refs")? as usize;
3505 let edges = query_count(&conn, "SELECT COUNT(*) FROM edges")? as usize;
3506 let failed_files = staged_failed_files(&conn)?;
3507 let elapsed_ms = started.elapsed().as_millis();
3508 crate::slog_info!(
3509 "perf callgraph_store bounded cold_build: files={} nodes={} refs={} edges={} committed_extracted_bytes={} ms={}",
3510 files,
3511 nodes,
3512 refs,
3513 edges,
3514 staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES)?,
3515 elapsed_ms
3516 );
3517 Ok(ColdBuildStats {
3518 files,
3519 nodes,
3520 refs,
3521 edges,
3522 failed_files,
3523 elapsed_ms,
3524 })
3525 }
3526
3527 pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
3528 self.refresh_files_with_workspace_crate_prefix_cache(
3529 changed_files,
3530 WorkspaceCratePrefixCache::default(),
3531 )
3532 }
3533
3534 fn refresh_files_with_workspace_crate_prefix_cache(
3535 &self,
3536 changed_files: &[PathBuf],
3537 workspace_crate_prefixes: WorkspaceCratePrefixCache,
3538 ) -> Result<IncrementalStats> {
3539 let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
3540 changed_files,
3541 workspace_crate_prefixes,
3542 )?;
3543 if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
3544 eprintln!("refresh_files phases: {}", profile.report());
3545 }
3546 Ok(stats)
3547 }
3548
3549 #[doc(hidden)]
3551 pub fn refresh_files_profiled(
3552 &self,
3553 changed_files: &[PathBuf],
3554 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
3555 self.refresh_files_profiled_with_workspace_crate_prefix_cache(
3556 changed_files,
3557 WorkspaceCratePrefixCache::default(),
3558 )
3559 }
3560
3561 fn refresh_files_profiled_with_workspace_crate_prefix_cache(
3562 &self,
3563 changed_files: &[PathBuf],
3564 workspace_crate_prefixes: WorkspaceCratePrefixCache,
3565 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
3566 let total_started = Instant::now();
3567 let mut profile = RefreshFilesProfile::default();
3568 self.verify_writer_lease()?;
3569 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3570 ensure_database_ready(&conn)?;
3571 let total_changes_before = conn.total_changes();
3572 let mut changed = Vec::new();
3573 let mut surface_changed = BTreeSet::new();
3574 let mut deleted = BTreeSet::new();
3575 let mut own_refresh = BTreeSet::new();
3576 let mut candidate_own_refresh = BTreeSet::new();
3577 let mut confirmed_fresh = BTreeSet::new();
3578 let mut unchanged_extracts = 0usize;
3579 let mut selected_ref_ids = BTreeSet::new();
3580 let mut selected_refs_by_caller = BTreeMap::new();
3581 let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
3582 let mut fresh_metadata = BTreeMap::new();
3583
3584 for input in changed_files {
3585 let abs_path = normalize_file_path(&self.project_root, input)?;
3586 let rel_path = relative_path(&self.project_root, &abs_path);
3587 changed.push(rel_path.clone());
3588 let old_row = load_file_row(&conn, &rel_path)?;
3589 if !abs_path.exists() {
3590 if old_row.is_some() && deleted.insert(rel_path.clone()) {
3591 surface_changed.insert(rel_path.clone());
3592 let started = Instant::now();
3593 let dependent_refs =
3594 ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3595 profile.dependency_selection += started.elapsed();
3596 record_dependent_refs(
3597 &mut selected_ref_ids,
3598 &mut selected_refs_by_caller,
3599 dependent_refs,
3600 );
3601 }
3602 continue;
3603 }
3604
3605 if let Some(row) = &old_row {
3606 match cache_freshness::verify_file(&abs_path, &row.freshness) {
3607 FreshnessVerdict::HotFresh => {
3608 confirmed_fresh.insert(rel_path.clone());
3613 continue;
3614 }
3615 FreshnessVerdict::ContentFresh {
3616 new_mtime,
3617 new_size,
3618 } => {
3619 fresh_metadata.insert(
3620 rel_path.clone(),
3621 FileFreshness {
3622 content_hash: row.freshness.content_hash,
3623 mtime: new_mtime,
3624 size: new_size,
3625 },
3626 );
3627 continue;
3628 }
3629 FreshnessVerdict::Deleted => {
3630 if deleted.insert(rel_path.clone()) {
3631 surface_changed.insert(rel_path.clone());
3632 let started = Instant::now();
3633 let dependent_refs =
3634 ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3635 profile.dependency_selection += started.elapsed();
3636 record_dependent_refs(
3637 &mut selected_ref_ids,
3638 &mut selected_refs_by_caller,
3639 dependent_refs,
3640 );
3641 }
3642 continue;
3643 }
3644 FreshnessVerdict::Stale => {}
3645 }
3646 }
3647
3648 let started = Instant::now();
3649 let extract = build_file_extract(&self.project_root, &abs_path)?;
3650 profile.parse += started.elapsed();
3651 let surface_is_changed = old_row
3652 .as_ref()
3653 .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
3654 .unwrap_or(true);
3655 if surface_is_changed {
3656 surface_changed.insert(rel_path.clone());
3657 let started = Instant::now();
3658 let dependent_refs = ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3659 profile.dependency_selection += started.elapsed();
3660 record_dependent_refs(
3661 &mut selected_ref_ids,
3662 &mut selected_refs_by_caller,
3663 dependent_refs,
3664 );
3665 }
3666 candidate_own_refresh.insert(rel_path.clone());
3667 changed_extracts.insert(rel_path, extract);
3668 }
3669
3670 let dependency_selected_refs = selected_ref_ids.len();
3671 let mut touched_callers: BTreeSet<String> =
3672 selected_refs_by_caller.keys().cloned().collect();
3673 touched_callers.extend(candidate_own_refresh.iter().cloned());
3674
3675 let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
3676 for rel_path in &touched_callers {
3677 if deleted.contains(rel_path) {
3678 continue;
3679 }
3680 if let Some(extract) = changed_extracts.get(rel_path) {
3681 caller_extracts.insert(rel_path.clone(), extract.clone());
3682 continue;
3683 }
3684 let abs_path = self.project_root.join(rel_path);
3685 if abs_path.exists() {
3686 let started = Instant::now();
3687 let extract = build_file_extract(&self.project_root, &abs_path)?;
3688 profile.dependent_parse += started.elapsed();
3689 caller_extracts.insert(rel_path.clone(), extract);
3690 }
3691 }
3692
3693 let tx = conn.transaction()?;
3694 for (rel_path, freshness) in fresh_metadata {
3695 update_file_fresh_metadata(
3696 &tx,
3697 &self.project_root,
3698 &rel_path,
3699 &freshness.content_hash,
3700 freshness.mtime,
3701 freshness.size,
3702 )?;
3703 }
3704 for rel_path in &confirmed_fresh {
3705 clear_stale_backend_status_for_file(&tx, &self.project_root, rel_path)?;
3706 }
3707 for rel_path in &deleted {
3708 let started = Instant::now();
3709 delete_file_rows(&tx, rel_path)?;
3710 clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
3711 profile.row_deletes += started.elapsed();
3712 }
3713
3714 let started = Instant::now();
3715 let index = ProjectIndex::from_db_and_callers(
3716 &tx,
3717 &self.project_root,
3718 &caller_extracts,
3719 workspace_crate_prefixes,
3720 )?;
3721 profile.index_load += started.elapsed();
3722
3723 let workspace_root = self.project_root.display().to_string();
3724 {
3725 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3726 for rel_path in &candidate_own_refresh {
3727 let Some(extract) = changed_extracts.get(rel_path) else {
3728 continue;
3729 };
3730 if !write_amplification_baseline_enabled()
3731 && stored_extract_matches(&tx, rel_path, extract, &index)?
3732 {
3733 unchanged_extracts += 1;
3734 update_file_fresh_metadata(
3735 &tx,
3736 &self.project_root,
3737 rel_path,
3738 &extract.freshness.content_hash,
3739 extract.freshness.mtime,
3740 extract.freshness.size,
3741 )?;
3742 continue;
3743 }
3744
3745 own_refresh.insert(rel_path.clone());
3746 let started = Instant::now();
3747 delete_file_rows(&tx, rel_path)?;
3748 clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
3749 profile.row_deletes += started.elapsed();
3750 let started = Instant::now();
3751 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
3752 profile.row_inserts += started.elapsed();
3753 }
3754
3755 let dependency_callers = touched_callers
3756 .iter()
3757 .filter(|rel_path| {
3758 !deleted.contains(*rel_path) && !candidate_own_refresh.contains(*rel_path)
3759 })
3760 .cloned()
3761 .collect::<Vec<_>>();
3762 for rel_path in dependency_callers {
3763 let Some(extract) = caller_extracts.get(&rel_path) else {
3764 continue;
3765 };
3766 if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
3767 continue;
3768 }
3769
3770 own_refresh.insert(rel_path.clone());
3771 let started = Instant::now();
3772 delete_file_rows(&tx, &rel_path)?;
3773 clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
3774 profile.row_deletes += started.elapsed();
3775 let started = Instant::now();
3776 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
3777 profile.row_inserts += started.elapsed();
3778 }
3779 let started = Instant::now();
3780 for rel_path in &touched_callers {
3781 if deleted.contains(rel_path) {
3782 continue;
3783 }
3784 let Some(extract) = caller_extracts.get(rel_path) else {
3785 continue;
3786 };
3787 if own_refresh.contains(rel_path) {
3788 delete_refs_for_caller(&tx, rel_path)?;
3789 for raw_ref in &extract.raw_refs {
3790 let resolved = resolve_ref(raw_ref.clone(), &index)?;
3791 insert_resolved_ref_prepared(&mut inserts, &resolved)?;
3792 }
3793 continue;
3794 }
3795
3796 let selected_for_caller = selected_refs_by_caller
3797 .get(rel_path)
3798 .cloned()
3799 .unwrap_or_default();
3800 delete_ref_ids(&tx, &selected_for_caller)?;
3801 for raw_ref in &extract.raw_refs {
3802 if selected_for_caller.contains(&raw_ref.ref_id) {
3803 let resolved = resolve_ref(raw_ref.clone(), &index)?;
3804 insert_resolved_ref_prepared(&mut inserts, &resolved)?;
3805 }
3806 }
3807 }
3808 profile.ref_resolution += started.elapsed();
3809 }
3810
3811 let started = Instant::now();
3812 delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
3813 insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
3814 profile.method_dispatch += started.elapsed();
3815
3816 bump_projection_write_revision(&tx)?;
3817 let started = Instant::now();
3818 commit_incremental_if_current(tx)?;
3819 self.record_commit(total_changes_before, &conn);
3820 profile.commit += started.elapsed();
3821 profile.total = total_started.elapsed();
3822 Ok((
3823 IncrementalStats {
3824 changed_files: changed,
3825 surface_changed: surface_changed.into_iter().collect(),
3826 deleted_files: deleted.into_iter().collect(),
3827 dependency_selected_refs,
3828 refreshed_own_files: own_refresh.len(),
3829 unchanged_extract_files: unchanged_extracts,
3830 },
3831 profile,
3832 ))
3833 }
3834
3835 pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
3836 self.cold_build(current_files)
3837 }
3838
3839 pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
3840 self.verify_writer_lease()?;
3841 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3842 let total_changes_before = conn.total_changes();
3843 let tx = conn.transaction()?;
3844 let mut marked = Vec::new();
3845 for path in files {
3846 let abs_path = normalize_file_path(&self.project_root, path)?;
3847 let rel_path = relative_path(&self.project_root, &abs_path);
3848 let freshness = cache_freshness::collect(&abs_path).ok();
3849 mark_backend_state(
3850 &tx,
3851 &self.project_root,
3852 &rel_path,
3853 freshness.as_ref().map(|freshness| &freshness.content_hash),
3854 "stale",
3855 )?;
3856 marked.push(rel_path);
3857 }
3858 bump_projection_write_revision(&tx)?;
3859 tx.commit()?;
3860 self.record_commit(total_changes_before, &conn);
3861 marked.sort();
3862 marked.dedup();
3863 Ok(marked)
3864 }
3865
3866 pub fn stale_files(&self) -> Result<Vec<String>> {
3867 self.refresh_read_marker()?;
3868 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3869 let mut stmt = conn.prepare(
3870 "SELECT DISTINCT file_path FROM backend_file_state
3871 WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
3872 ORDER BY file_path",
3873 )?;
3874 let rows = stmt.query_map(
3875 params![BACKEND_TREESITTER, self.project_root.display().to_string()],
3876 |row| row.get::<_, String>(0),
3877 )?;
3878 rows.collect::<std::result::Result<Vec<_>, _>>()
3879 .map_err(Into::into)
3880 }
3881
3882 pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
3883 self.refresh_read_marker()?;
3884 let rel_path = relative_path(
3885 &self.project_root,
3886 &normalize_file_path(&self.project_root, file)?,
3887 );
3888 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3889 conn.query_row(
3890 "SELECT status FROM backend_file_state
3891 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
3892 ORDER BY updated_at DESC LIMIT 1",
3893 params![
3894 BACKEND_TREESITTER,
3895 self.project_root.display().to_string(),
3896 rel_path
3897 ],
3898 |row| row.get(0),
3899 )
3900 .optional()
3901 .map_err(Into::into)
3902 }
3903
3904 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3905 self.refresh_read_marker()?;
3906 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3907 self.ensure_ready(&conn)?;
3908 edge_snapshot_with_conn(&conn)
3909 }
3910
3911 pub fn indexed_file_count(&self) -> Result<usize> {
3912 self.refresh_read_marker()?;
3913 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3914 self.ensure_ready(&conn)?;
3915 indexed_file_count(&conn)
3916 }
3917
3918 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3919 self.refresh_read_marker()?;
3920 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3921 let rel_path = relative_path(&self.project_root, &abs_path);
3922 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3923 self.ensure_ready(&conn)?;
3924 resolve_node_for_rel(&conn, &rel_path, symbol)
3925 }
3926
3927 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3932 self.refresh_read_marker()?;
3933 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3934 let rel_path = relative_path(&self.project_root, &abs_path);
3935 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3936 self.ensure_ready(&conn)?;
3937 nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
3938 }
3939
3940 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3942 self.refresh_read_marker()?;
3943 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3944 self.ensure_ready(&conn)?;
3945 nodes_matching_symbol(&conn, symbol)
3946 }
3947
3948 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3950 self.refresh_read_marker()?;
3951 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3952 let rel_path = relative_path(&self.project_root, &abs_path);
3953 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3954 self.ensure_ready(&conn)?;
3955 direct_callers_for_tuple(&conn, &rel_path, symbol)
3956 }
3957
3958 pub fn direct_callers_for_symbols(
3960 &self,
3961 targets: &[(String, String)],
3962 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3963 if targets.is_empty() {
3964 return Ok(HashMap::new());
3965 }
3966 self.refresh_read_marker()?;
3967 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3968 self.ensure_ready(&conn)?;
3969 direct_callers_for_tuples(&conn, targets)
3970 }
3971
3972 pub fn direct_caller_counts_of(
3974 &self,
3975 targets: &[(String, String)],
3976 ) -> Result<HashMap<(String, String), usize>> {
3977 if targets.is_empty() {
3978 return Ok(HashMap::new());
3979 }
3980 self.refresh_read_marker()?;
3981 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3982 self.ensure_ready(&conn)?;
3983 direct_caller_counts_for_tuples(&conn, targets)
3984 }
3985
3986 pub fn callers_of(
3987 &self,
3988 file_rel: &Path,
3989 symbol: &str,
3990 depth: usize,
3991 ) -> Result<StoreCallersResult> {
3992 let target = self.node_for(file_rel, symbol)?;
3993 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3994 self.ensure_ready(&conn)?;
3995 let effective_depth = depth.max(1);
3996 let mut visited = HashSet::new();
3997 let mut callers = Vec::new();
3998 let mut depth_limited = false;
3999 let mut truncated = 0usize;
4000 collect_callers_recursive(
4001 &conn,
4002 &target.file,
4003 &target.symbol,
4004 effective_depth,
4005 0,
4006 &mut visited,
4007 &mut callers,
4008 &mut depth_limited,
4009 &mut truncated,
4010 )?;
4011 Ok(StoreCallersResult {
4012 target,
4013 callers,
4014 scanned_files: indexed_file_count(&conn)?,
4015 depth_limited,
4016 truncated,
4017 })
4018 }
4019
4020 pub fn impact_of(
4021 &self,
4022 file_rel: &Path,
4023 symbol: &str,
4024 depth: usize,
4025 ) -> Result<StoreImpactResult> {
4026 let callers = self.callers_of(file_rel, symbol, depth)?;
4027 let target_parameters = callers
4028 .target
4029 .signature
4030 .as_deref()
4031 .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
4032 .unwrap_or_default();
4033 let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
4034 for site in &callers.callers {
4035 source_lines_by_file
4036 .entry(site.caller.file.clone())
4037 .or_insert_with(|| {
4038 read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
4039 });
4040 }
4041 let enriched = callers
4042 .callers
4043 .iter()
4044 .map(|site| StoreImpactCaller {
4045 site: site.clone(),
4046 signature: site.caller.signature.clone(),
4047 is_entry_point: site.caller.is_entry_point,
4048 call_expression: source_lines_by_file
4049 .get(&site.caller.file)
4050 .and_then(|lines| lines.as_ref())
4051 .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
4052 .cloned(),
4053 parameters: site
4054 .caller
4055 .signature
4056 .as_deref()
4057 .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
4058 .unwrap_or_default(),
4059 })
4060 .collect();
4061 Ok(StoreImpactResult {
4062 target: callers.target,
4063 parameters: target_parameters,
4064 callers: enriched,
4065 depth_limited: callers.depth_limited,
4066 truncated: callers.truncated,
4067 })
4068 }
4069
4070 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4071 self.refresh_read_marker()?;
4072 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4073 self.ensure_ready(&conn)?;
4074 outgoing_calls_for_node(&conn, node)
4075 }
4076
4077 pub fn outgoing_calls_for_symbols(
4079 &self,
4080 sources: &[(String, String)],
4081 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4082 if sources.is_empty() {
4083 return Ok(HashMap::new());
4084 }
4085 self.refresh_read_marker()?;
4086 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4087 self.ensure_ready(&conn)?;
4088 outgoing_calls_for_symbol_tuples(&conn, sources)
4089 }
4090
4091 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4093 self.refresh_read_marker()?;
4094 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4095 self.ensure_ready(&conn)?;
4096 resolved_self_calls_for_node(&conn, node)
4097 }
4098
4099 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4100 self.refresh_read_marker()?;
4101 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4102 self.ensure_ready(&conn)?;
4103 unresolved_calls_for_node(&conn, node)
4104 }
4105
4106 pub fn call_tree(
4107 &self,
4108 file_rel: &Path,
4109 symbol: &str,
4110 max_depth: usize,
4111 ) -> Result<callgraph::CallTreeNode> {
4112 let node = self.node_for(file_rel, symbol)?;
4113 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4114 self.ensure_ready(&conn)?;
4115 let mut visited = HashSet::new();
4116 call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
4117 }
4118
4119 pub fn trace_to(
4120 &self,
4121 file_rel: &Path,
4122 symbol: &str,
4123 max_depth: usize,
4124 ) -> Result<callgraph::TraceToResult> {
4125 let target = self.node_for(file_rel, symbol)?;
4126 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4127 self.ensure_ready(&conn)?;
4128 let effective_max = if max_depth == 0 { 10 } else { max_depth };
4129
4130 #[derive(Clone)]
4131 struct PathElem {
4132 node: StoreNode,
4133 }
4134
4135 let initial = vec![PathElem {
4136 node: target.clone(),
4137 }];
4138 let mut complete_paths = Vec::new();
4139 if target.is_entry_point {
4140 complete_paths.push(initial.clone());
4141 }
4142
4143 let mut queue = vec![(initial, 0usize)];
4144 let mut max_depth_reached = false;
4145 let mut truncated_paths = 0usize;
4146
4147 while let Some((path, depth)) = queue.pop() {
4148 if depth >= effective_max {
4149 max_depth_reached = true;
4150 continue;
4151 }
4152 let Some(current) = path.last() else {
4153 continue;
4154 };
4155 let callers =
4156 direct_callers_for_tuple(&conn, ¤t.node.file, ¤t.node.symbol)?;
4157 if callers.is_empty() {
4158 if path.len() > 1 {
4159 truncated_paths += 1;
4160 }
4161 continue;
4162 }
4163
4164 let mut has_new_path = false;
4165 for site in callers {
4166 if path.iter().any(|elem| {
4167 elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
4168 }) {
4169 continue;
4170 }
4171 has_new_path = true;
4172 let mut new_path = path.clone();
4173 new_path.push(PathElem {
4174 node: site.caller.clone(),
4175 });
4176 if site.caller.is_entry_point {
4177 complete_paths.push(new_path.clone());
4178 }
4179 queue.push((new_path, depth + 1));
4180 }
4181 if !has_new_path && path.len() > 1 {
4182 truncated_paths += 1;
4183 }
4184 }
4185
4186 let mut paths: Vec<callgraph::TracePath> = complete_paths
4187 .into_iter()
4188 .map(|mut elems| {
4189 elems.reverse();
4190 let hops = elems
4191 .iter()
4192 .enumerate()
4193 .map(|(index, elem)| callgraph::TraceHop {
4194 symbol: elem.node.symbol.clone(),
4195 file: elem.node.file.clone(),
4196 line: elem.node.line,
4197 signature: elem.node.signature.clone(),
4198 is_entry_point: index == 0 && elem.node.is_entry_point,
4199 })
4200 .collect();
4201 callgraph::TracePath { hops }
4202 })
4203 .collect();
4204 paths.sort_by(|left, right| {
4205 let left_entry = left
4206 .hops
4207 .first()
4208 .map(|hop| hop.symbol.as_str())
4209 .unwrap_or("");
4210 let right_entry = right
4211 .hops
4212 .first()
4213 .map(|hop| hop.symbol.as_str())
4214 .unwrap_or("");
4215 left_entry
4216 .cmp(right_entry)
4217 .then(left.hops.len().cmp(&right.hops.len()))
4218 });
4219 let entry_points_found = paths
4220 .iter()
4221 .filter_map(|path| path.hops.first())
4222 .filter(|hop| hop.is_entry_point)
4223 .map(|hop| (hop.file.clone(), hop.symbol.clone()))
4224 .collect::<HashSet<_>>()
4225 .len();
4226
4227 Ok(callgraph::TraceToResult {
4228 target_symbol: target.symbol,
4229 target_file: target.file,
4230 total_paths: paths.len(),
4231 paths,
4232 entry_points_found,
4233 max_depth_reached,
4234 truncated_paths,
4235 })
4236 }
4237
4238 pub fn trace_to_symbol_candidates(
4239 &self,
4240 to_symbol: &str,
4241 ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
4242 self.refresh_read_marker()?;
4243 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4244 self.ensure_ready(&conn)?;
4245 let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
4246 for node in nodes_matching_symbol(&conn, to_symbol)? {
4247 candidates_by_file
4248 .entry(node.file)
4249 .and_modify(|line| *line = (*line).min(node.line))
4250 .or_insert(node.line);
4251 }
4252 let mut candidates: Vec<_> = candidates_by_file
4253 .into_iter()
4254 .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
4255 .collect();
4256 candidates
4257 .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
4258 Ok(candidates)
4259 }
4260
4261 pub fn trace_to_symbol(
4262 &self,
4263 file_rel: &Path,
4264 symbol: &str,
4265 to_symbol: &str,
4266 to_file: Option<&Path>,
4267 max_depth: usize,
4268 ) -> Result<callgraph::TraceToSymbolResult> {
4269 let origin = self.node_for(file_rel, symbol)?;
4270 let target_file = to_file
4271 .map(|path| normalize_file_path(&self.project_root, path))
4272 .transpose()?
4273 .map(|path| relative_path(&self.project_root, &path));
4274 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4275 self.ensure_ready(&conn)?;
4276 let effective_max = if max_depth == 0 {
4277 10
4278 } else {
4279 max_depth.min(16)
4280 };
4281
4282 let start_hop = trace_to_symbol_hop(&origin);
4283 if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
4284 return Ok(callgraph::TraceToSymbolResult {
4285 path: Some(vec![start_hop]),
4286 complete: true,
4287 reason: None,
4288 });
4289 }
4290
4291 let mut queue = VecDeque::new();
4292 queue.push_back((origin.clone(), vec![start_hop], 0usize));
4293 let mut visited = HashSet::new();
4294 visited.insert((origin.file.clone(), origin.symbol.clone()));
4295 let mut max_depth_exhausted = false;
4296
4297 while let Some((current, path, depth)) = queue.pop_front() {
4298 let callees = outgoing_calls_for_node(&conn, ¤t)?
4299 .into_iter()
4300 .filter_map(|site| site.target)
4301 .collect::<Vec<_>>();
4302
4303 if depth >= effective_max {
4304 if callees
4305 .iter()
4306 .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
4307 {
4308 max_depth_exhausted = true;
4309 }
4310 continue;
4311 }
4312
4313 for callee in callees {
4314 if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
4315 continue;
4316 }
4317 let mut next_path = path.clone();
4318 next_path.push(trace_to_symbol_hop(&callee));
4319 if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
4320 return Ok(callgraph::TraceToSymbolResult {
4321 path: Some(next_path),
4322 complete: true,
4323 reason: None,
4324 });
4325 }
4326 queue.push_back((callee, next_path, depth + 1));
4327 }
4328 }
4329
4330 if max_depth_exhausted {
4331 Ok(callgraph::TraceToSymbolResult {
4332 path: None,
4333 complete: false,
4334 reason: Some("max_depth_exhausted".to_string()),
4335 })
4336 } else {
4337 Ok(callgraph::TraceToSymbolResult {
4338 path: None,
4339 complete: true,
4340 reason: Some("no_path_found".to_string()),
4341 })
4342 }
4343 }
4344}
4345
4346impl ReadonlyCallGraphStore {
4347 fn from_inner(inner: CallGraphStore) -> Self {
4348 Self { inner }
4349 }
4350
4351 pub fn project_root(&self) -> &Path {
4352 self.inner.project_root()
4353 }
4354
4355 pub fn project_key(&self) -> &str {
4356 self.inner.project_key()
4357 }
4358
4359 pub fn sqlite_path(&self) -> &Path {
4360 self.inner.sqlite_path()
4361 }
4362
4363 pub fn stale_files(&self) -> Result<Vec<String>> {
4364 self.inner.stale_files()
4365 }
4366
4367 pub(crate) fn projection_generation(&self) -> Option<&str> {
4368 self.inner.projection_generation()
4369 }
4370
4371 pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
4372 self.inner.projection_write_revision()
4373 }
4374
4375 pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
4378 crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
4379 }
4380
4381 pub fn is_legacy_fallback(&self) -> bool {
4383 self.inner.is_legacy_fallback()
4384 }
4385
4386 pub fn is_current(&self) -> bool {
4387 self.inner.is_current()
4388 }
4389
4390 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4391 self.inner.edge_snapshot()
4392 }
4393
4394 pub fn indexed_file_count(&self) -> Result<usize> {
4395 self.inner.indexed_file_count()
4396 }
4397
4398 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4399 self.inner.node_for(file_rel, symbol)
4400 }
4401
4402 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4403 self.inner.nodes_for(file_rel, symbol)
4404 }
4405
4406 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4407 self.inner.nodes_matching(symbol)
4408 }
4409
4410 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4411 self.inner.direct_callers_of(file_rel, symbol)
4412 }
4413
4414 pub fn direct_callers_for_symbols(
4415 &self,
4416 targets: &[(String, String)],
4417 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4418 self.inner.direct_callers_for_symbols(targets)
4419 }
4420
4421 pub fn direct_caller_counts_of(
4422 &self,
4423 targets: &[(String, String)],
4424 ) -> Result<HashMap<(String, String), usize>> {
4425 self.inner.direct_caller_counts_of(targets)
4426 }
4427
4428 pub fn callers_of(
4429 &self,
4430 file_rel: &Path,
4431 symbol: &str,
4432 depth: usize,
4433 ) -> Result<StoreCallersResult> {
4434 self.inner.callers_of(file_rel, symbol, depth)
4435 }
4436
4437 pub fn impact_of(
4438 &self,
4439 file_rel: &Path,
4440 symbol: &str,
4441 depth: usize,
4442 ) -> Result<StoreImpactResult> {
4443 self.inner.impact_of(file_rel, symbol, depth)
4444 }
4445
4446 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4447 self.inner.outgoing_calls_of(node)
4448 }
4449
4450 pub fn outgoing_calls_for_symbols(
4451 &self,
4452 sources: &[(String, String)],
4453 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4454 self.inner.outgoing_calls_for_symbols(sources)
4455 }
4456
4457 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4458 self.inner.resolved_self_calls_of(node)
4459 }
4460
4461 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4462 self.inner.unresolved_calls_of(node)
4463 }
4464
4465 pub fn call_tree(
4466 &self,
4467 file_rel: &Path,
4468 symbol: &str,
4469 depth: usize,
4470 ) -> Result<callgraph::CallTreeNode> {
4471 self.inner.call_tree(file_rel, symbol, depth)
4472 }
4473
4474 pub fn trace_to(
4475 &self,
4476 file_rel: &Path,
4477 symbol: &str,
4478 max_depth: usize,
4479 ) -> Result<callgraph::TraceToResult> {
4480 self.inner.trace_to(file_rel, symbol, max_depth)
4481 }
4482
4483 pub fn trace_to_symbol_candidates(
4484 &self,
4485 to_symbol: &str,
4486 ) -> Result<Vec<TraceToSymbolCandidate>> {
4487 self.inner.trace_to_symbol_candidates(to_symbol)
4488 }
4489
4490 pub fn trace_to_symbol(
4491 &self,
4492 file_rel: &Path,
4493 symbol: &str,
4494 to_symbol: &str,
4495 to_file: Option<&Path>,
4496 max_depth: usize,
4497 ) -> Result<callgraph::TraceToSymbolResult> {
4498 self.inner
4499 .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4500 }
4501}
4502
4503impl CallGraphRead for CallGraphStore {
4504 fn project_root(&self) -> &Path {
4505 CallGraphStore::project_root(self)
4506 }
4507 fn project_key(&self) -> &str {
4508 CallGraphStore::project_key(self)
4509 }
4510 fn sqlite_path(&self) -> &Path {
4511 CallGraphStore::sqlite_path(self)
4512 }
4513 fn is_current(&self) -> bool {
4514 CallGraphStore::is_current(self)
4515 }
4516 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4517 CallGraphStore::edge_snapshot(self)
4518 }
4519 fn indexed_file_count(&self) -> Result<usize> {
4520 CallGraphStore::indexed_file_count(self)
4521 }
4522 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4523 CallGraphStore::node_for(self, file_rel, symbol)
4524 }
4525 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4526 CallGraphStore::nodes_for(self, file_rel, symbol)
4527 }
4528 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4529 CallGraphStore::nodes_matching(self, symbol)
4530 }
4531 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4532 CallGraphStore::direct_callers_of(self, file_rel, symbol)
4533 }
4534 fn direct_callers_for_symbols(
4535 &self,
4536 targets: &[(String, String)],
4537 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4538 CallGraphStore::direct_callers_for_symbols(self, targets)
4539 }
4540 fn direct_caller_counts_of(
4541 &self,
4542 targets: &[(String, String)],
4543 ) -> Result<HashMap<(String, String), usize>> {
4544 CallGraphStore::direct_caller_counts_of(self, targets)
4545 }
4546 fn callers_of(
4547 &self,
4548 file_rel: &Path,
4549 symbol: &str,
4550 depth: usize,
4551 ) -> Result<StoreCallersResult> {
4552 CallGraphStore::callers_of(self, file_rel, symbol, depth)
4553 }
4554 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4555 CallGraphStore::impact_of(self, file_rel, symbol, depth)
4556 }
4557 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4558 CallGraphStore::outgoing_calls_of(self, node)
4559 }
4560 fn outgoing_calls_for_symbols(
4561 &self,
4562 sources: &[(String, String)],
4563 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4564 CallGraphStore::outgoing_calls_for_symbols(self, sources)
4565 }
4566 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4567 CallGraphStore::resolved_self_calls_of(self, node)
4568 }
4569 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4570 CallGraphStore::unresolved_calls_of(self, node)
4571 }
4572 fn call_tree(
4573 &self,
4574 file_rel: &Path,
4575 symbol: &str,
4576 depth: usize,
4577 ) -> Result<callgraph::CallTreeNode> {
4578 CallGraphStore::call_tree(self, file_rel, symbol, depth)
4579 }
4580 fn trace_to(
4581 &self,
4582 file_rel: &Path,
4583 symbol: &str,
4584 max_depth: usize,
4585 ) -> Result<callgraph::TraceToResult> {
4586 CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
4587 }
4588 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4589 CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
4590 }
4591 fn trace_to_symbol(
4592 &self,
4593 file_rel: &Path,
4594 symbol: &str,
4595 to_symbol: &str,
4596 to_file: Option<&Path>,
4597 max_depth: usize,
4598 ) -> Result<callgraph::TraceToSymbolResult> {
4599 CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
4600 }
4601}
4602
4603impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
4604 fn project_root(&self) -> &Path {
4605 (**self).project_root()
4606 }
4607 fn project_key(&self) -> &str {
4608 (**self).project_key()
4609 }
4610 fn sqlite_path(&self) -> &Path {
4611 (**self).sqlite_path()
4612 }
4613 fn is_current(&self) -> bool {
4614 (**self).is_current()
4615 }
4616 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4617 (**self).edge_snapshot()
4618 }
4619 fn indexed_file_count(&self) -> Result<usize> {
4620 (**self).indexed_file_count()
4621 }
4622 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4623 (**self).node_for(file_rel, symbol)
4624 }
4625 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4626 (**self).nodes_for(file_rel, symbol)
4627 }
4628 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4629 (**self).nodes_matching(symbol)
4630 }
4631 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4632 (**self).direct_callers_of(file_rel, symbol)
4633 }
4634 fn direct_callers_for_symbols(
4635 &self,
4636 targets: &[(String, String)],
4637 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4638 (**self).direct_callers_for_symbols(targets)
4639 }
4640 fn direct_caller_counts_of(
4641 &self,
4642 targets: &[(String, String)],
4643 ) -> Result<HashMap<(String, String), usize>> {
4644 (**self).direct_caller_counts_of(targets)
4645 }
4646 fn callers_of(
4647 &self,
4648 file_rel: &Path,
4649 symbol: &str,
4650 depth: usize,
4651 ) -> Result<StoreCallersResult> {
4652 (**self).callers_of(file_rel, symbol, depth)
4653 }
4654 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4655 (**self).impact_of(file_rel, symbol, depth)
4656 }
4657 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4658 (**self).outgoing_calls_of(node)
4659 }
4660 fn outgoing_calls_for_symbols(
4661 &self,
4662 sources: &[(String, String)],
4663 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4664 (**self).outgoing_calls_for_symbols(sources)
4665 }
4666 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4667 (**self).resolved_self_calls_of(node)
4668 }
4669 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4670 (**self).unresolved_calls_of(node)
4671 }
4672 fn call_tree(
4673 &self,
4674 file_rel: &Path,
4675 symbol: &str,
4676 depth: usize,
4677 ) -> Result<callgraph::CallTreeNode> {
4678 (**self).call_tree(file_rel, symbol, depth)
4679 }
4680 fn trace_to(
4681 &self,
4682 file_rel: &Path,
4683 symbol: &str,
4684 max_depth: usize,
4685 ) -> Result<callgraph::TraceToResult> {
4686 (**self).trace_to(file_rel, symbol, max_depth)
4687 }
4688 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4689 (**self).trace_to_symbol_candidates(to_symbol)
4690 }
4691 fn trace_to_symbol(
4692 &self,
4693 file_rel: &Path,
4694 symbol: &str,
4695 to_symbol: &str,
4696 to_file: Option<&Path>,
4697 max_depth: usize,
4698 ) -> Result<callgraph::TraceToSymbolResult> {
4699 (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4700 }
4701}
4702
4703impl CallGraphRead for ReadonlyCallGraphStore {
4704 fn project_root(&self) -> &Path {
4705 self.project_root()
4706 }
4707 fn project_key(&self) -> &str {
4708 self.project_key()
4709 }
4710 fn sqlite_path(&self) -> &Path {
4711 self.sqlite_path()
4712 }
4713 fn is_current(&self) -> bool {
4714 self.is_current()
4715 }
4716 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4717 self.edge_snapshot()
4718 }
4719 fn indexed_file_count(&self) -> Result<usize> {
4720 self.indexed_file_count()
4721 }
4722 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4723 self.node_for(file_rel, symbol)
4724 }
4725 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4726 self.nodes_for(file_rel, symbol)
4727 }
4728 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4729 self.nodes_matching(symbol)
4730 }
4731 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4732 self.direct_callers_of(file_rel, symbol)
4733 }
4734 fn direct_callers_for_symbols(
4735 &self,
4736 targets: &[(String, String)],
4737 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4738 self.direct_callers_for_symbols(targets)
4739 }
4740 fn direct_caller_counts_of(
4741 &self,
4742 targets: &[(String, String)],
4743 ) -> Result<HashMap<(String, String), usize>> {
4744 self.direct_caller_counts_of(targets)
4745 }
4746 fn callers_of(
4747 &self,
4748 file_rel: &Path,
4749 symbol: &str,
4750 depth: usize,
4751 ) -> Result<StoreCallersResult> {
4752 self.callers_of(file_rel, symbol, depth)
4753 }
4754 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4755 self.impact_of(file_rel, symbol, depth)
4756 }
4757 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4758 self.outgoing_calls_of(node)
4759 }
4760 fn outgoing_calls_for_symbols(
4761 &self,
4762 sources: &[(String, String)],
4763 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4764 self.outgoing_calls_for_symbols(sources)
4765 }
4766 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4767 self.resolved_self_calls_of(node)
4768 }
4769 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4770 self.unresolved_calls_of(node)
4771 }
4772 fn call_tree(
4773 &self,
4774 file_rel: &Path,
4775 symbol: &str,
4776 depth: usize,
4777 ) -> Result<callgraph::CallTreeNode> {
4778 self.call_tree(file_rel, symbol, depth)
4779 }
4780 fn trace_to(
4781 &self,
4782 file_rel: &Path,
4783 symbol: &str,
4784 max_depth: usize,
4785 ) -> Result<callgraph::TraceToResult> {
4786 self.trace_to(file_rel, symbol, max_depth)
4787 }
4788 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4789 self.trace_to_symbol_candidates(to_symbol)
4790 }
4791 fn trace_to_symbol(
4792 &self,
4793 file_rel: &Path,
4794 symbol: &str,
4795 to_symbol: &str,
4796 to_file: Option<&Path>,
4797 max_depth: usize,
4798 ) -> Result<callgraph::TraceToSymbolResult> {
4799 self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4800 }
4801}
4802
4803fn indexed_file_count(conn: &Connection) -> Result<usize> {
4804 let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
4805 Ok(count.max(0) as usize)
4806}
4807
4808fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
4809 let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
4810 match candidates.as_slice() {
4811 [candidate] => Ok(candidate.clone()),
4812 [] => Err(AftError::SymbolNotFound {
4813 name: symbol.to_string(),
4814 file: rel_path.to_string(),
4815 }
4816 .into()),
4817 _ => Err(AftError::AmbiguousSymbol {
4818 name: symbol.to_string(),
4819 candidates: candidates
4820 .iter()
4821 .map(|candidate| candidate.symbol.clone())
4822 .collect(),
4823 }
4824 .into()),
4825 }
4826}
4827
4828fn nodes_for_file_matching_symbol(
4829 conn: &Connection,
4830 rel_path: &str,
4831 symbol: &str,
4832) -> Result<Vec<StoreNode>> {
4833 let qualified_query = symbol.contains("::");
4834 let sql = if qualified_query {
4835 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4836 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4837 FROM nodes n JOIN files f ON f.path = n.file_path
4838 WHERE n.file_path = ?1 AND n.scoped_name = ?2
4839 ORDER BY n.scoped_name, n.start_line, n.start_col"
4840 } else {
4841 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4842 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4843 FROM nodes n JOIN files f ON f.path = n.file_path
4844 WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
4845 ORDER BY n.scoped_name, n.start_line, n.start_col"
4846 };
4847 let mut stmt = conn.prepare(sql)?;
4848 let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
4849 rows.collect::<std::result::Result<Vec<_>, _>>()
4850 .map_err(Into::into)
4851}
4852
4853fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
4854 let qualified_query = symbol.contains("::");
4855 let sql = if qualified_query {
4856 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4857 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4858 FROM nodes n JOIN files f ON f.path = n.file_path
4859 WHERE n.scoped_name = ?1
4860 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4861 } else {
4862 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4863 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4864 FROM nodes n JOIN files f ON f.path = n.file_path
4865 WHERE n.scoped_name = ?1 OR n.name = ?1
4866 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4867 };
4868 let mut stmt = conn.prepare(sql)?;
4869 let rows = stmt.query_map(params![symbol], store_node_from_row)?;
4870 rows.collect::<std::result::Result<Vec<_>, _>>()
4871 .map_err(Into::into)
4872}
4873
4874fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
4875 store_node_from_row_at(row, 0)
4876}
4877
4878fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
4879 let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
4880 let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
4881 let lang_label_value: String = row.get(offset + 10)?;
4882 Ok(StoreNode {
4883 node_id: row.get(offset)?,
4884 file: row.get(offset + 1)?,
4885 symbol: row.get(offset + 2)?,
4886 name: row.get(offset + 3)?,
4887 kind: row.get(offset + 4)?,
4888 line: start_line.saturating_add(1),
4889 end_line: end_line.saturating_add(1),
4890 signature: row.get(offset + 7)?,
4891 exported: row.get::<_, i64>(offset + 8)? != 0,
4892 is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
4893 lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
4894 })
4895}
4896
4897fn optional_store_node_from_row_at(
4898 row: &rusqlite::Row<'_>,
4899 offset: usize,
4900) -> rusqlite::Result<Option<StoreNode>> {
4901 if row.get::<_, Option<String>>(offset)?.is_some() {
4902 store_node_from_row_at(row, offset).map(Some)
4903 } else {
4904 Ok(None)
4905 }
4906}
4907
4908#[allow(clippy::too_many_arguments)]
4909fn collect_callers_recursive(
4910 conn: &Connection,
4911 file: &str,
4912 symbol: &str,
4913 max_depth: usize,
4914 current_depth: usize,
4915 visited: &mut HashSet<(String, String)>,
4916 result: &mut Vec<StoreCallSite>,
4917 depth_limited: &mut bool,
4918 truncated: &mut usize,
4919) -> Result<()> {
4920 if current_depth >= max_depth {
4921 let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
4922 if omitted > 0 {
4923 *depth_limited = true;
4924 *truncated += omitted;
4925 }
4926 return Ok(());
4927 }
4928
4929 if !visited.insert((file.to_string(), symbol.to_string())) {
4930 return Ok(());
4931 }
4932
4933 let sites = direct_callers_for_tuple(conn, file, symbol)?;
4934 for site in sites {
4935 result.push(site.clone());
4936 if current_depth + 1 < max_depth {
4937 collect_callers_recursive(
4938 conn,
4939 &site.caller.file,
4940 &site.caller.symbol,
4941 max_depth,
4942 current_depth + 1,
4943 visited,
4944 result,
4945 depth_limited,
4946 truncated,
4947 )?;
4948 } else {
4949 let omitted =
4950 direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
4951 if omitted > 0 {
4952 *depth_limited = true;
4953 *truncated += omitted;
4954 }
4955 }
4956 }
4957 Ok(())
4958}
4959
4960const DIRECT_CALLER_BATCH_SIZE: usize = 499;
4962
4963fn direct_caller_counts_for_tuples(
4964 conn: &Connection,
4965 targets: &[(String, String)],
4966) -> Result<HashMap<(String, String), usize>> {
4967 let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
4968 let mut counts = unique_targets
4969 .iter()
4970 .cloned()
4971 .map(|target| (target, 0usize))
4972 .collect::<HashMap<_, _>>();
4973
4974 let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
4975 for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
4976 let requested_values = (0..chunk.len())
4977 .map(|_| "(?, ?)")
4978 .collect::<Vec<_>>()
4979 .join(", ");
4980 let sql = format!(
4981 "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
4982 deduped AS (
4983 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
4984 FROM requested requested
4985 JOIN edges e
4986 ON e.target_file = requested.target_file
4987 AND e.target_symbol = requested.target_symbol
4988 AND e.kind = 'call'
4989 JOIN refs r ON r.ref_id = e.ref_id
4990 JOIN nodes src ON src.id = e.source_node
4991 JOIN files src_file ON src_file.path = src.file_path
4992 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
4993 )
4994 SELECT target_file, target_symbol, COUNT(*)
4995 FROM deduped
4996 GROUP BY target_file, target_symbol"
4997 );
4998 let bindings = chunk
4999 .iter()
5000 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5001 let mut stmt = conn.prepare(&sql)?;
5002 let rows = stmt.query_map(params_from_iter(bindings), |row| {
5003 Ok((
5004 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
5005 row.get::<_, i64>(2)?,
5006 ))
5007 })?;
5008 for row in rows {
5009 let (target, count) = row?;
5010 counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
5011 }
5012 }
5013
5014 Ok(counts)
5015}
5016
5017fn direct_caller_count_for_tuple(
5018 conn: &Connection,
5019 target_file: &str,
5020 target_symbol: &str,
5021) -> Result<usize> {
5022 let count: i64 = conn.query_row(
5023 "SELECT COUNT(*)
5024 FROM edges e
5025 JOIN refs r ON r.ref_id = e.ref_id
5026 JOIN nodes src ON src.id = e.source_node
5027 JOIN files src_file ON src_file.path = src.file_path
5028 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
5029 params![target_file, target_symbol],
5030 |row| row.get(0),
5031 )?;
5032 Ok(usize::try_from(count).unwrap_or(usize::MAX))
5033}
5034
5035fn direct_callers_for_tuple(
5036 conn: &Connection,
5037 target_file: &str,
5038 target_symbol: &str,
5039) -> Result<Vec<StoreCallSite>> {
5040 let mut stmt = conn.prepare(
5041 "SELECT e.target_file, e.target_symbol, e.line,
5042 r.byte_start, r.byte_end, r.status, e.provenance,
5043 src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5044 src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5045 src_file.lang,
5046 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5047 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5048 tgt_file.lang
5049 FROM edges e
5050 JOIN refs r ON r.ref_id = e.ref_id
5051 JOIN nodes src ON src.id = e.source_node
5052 JOIN files src_file ON src_file.path = src.file_path
5053 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5054 ON tgt.id = e.target_node
5055 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
5056 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
5057 )?;
5058 let rows = stmt.query_map(
5059 params![target_file, target_symbol],
5060 direct_call_site_from_row,
5061 )?;
5062 rows.collect::<std::result::Result<Vec<_>, _>>()
5063 .map_err(Into::into)
5064}
5065
5066fn direct_call_site_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreCallSite> {
5067 let caller = store_node_from_row_at(row, 7)?;
5068 let target = optional_store_node_from_row_at(row, 18)?;
5069 Ok(StoreCallSite {
5070 caller,
5071 target_file: row.get(0)?,
5072 target_symbol: row.get(1)?,
5073 target,
5074 line: row.get::<_, i64>(2)?.max(0) as u32,
5075 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5076 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5077 resolved: row.get::<_, String>(5)? == "resolved",
5078 provenance: row.get(6)?,
5079 })
5080}
5081
5082fn direct_callers_for_tuples(
5083 conn: &Connection,
5084 targets: &[(String, String)],
5085) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5086 let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
5087 let mut callers_by_target = unique_targets
5088 .iter()
5089 .cloned()
5090 .map(|target| (target, Vec::new()))
5091 .collect::<HashMap<_, _>>();
5092 let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
5093
5094 for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
5095 let requested_values = (0..chunk.len())
5096 .map(|_| "(?, ?)")
5097 .collect::<Vec<_>>()
5098 .join(", ");
5099 let sql = format!(
5100 "WITH requested(target_file, target_symbol) AS (VALUES {requested_values})
5101 SELECT e.target_file, e.target_symbol, e.line,
5102 r.byte_start, r.byte_end, r.status, e.provenance,
5103 src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5104 src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5105 src_file.lang,
5106 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5107 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5108 tgt_file.lang
5109 FROM requested requested
5110 JOIN edges e
5111 ON e.target_file = requested.target_file
5112 AND e.target_symbol = requested.target_symbol
5113 AND e.kind = 'call'
5114 JOIN refs r ON r.ref_id = e.ref_id
5115 JOIN nodes src ON src.id = e.source_node
5116 JOIN files src_file ON src_file.path = src.file_path
5117 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5118 ON tgt.id = e.target_node
5119 ORDER BY e.target_file, e.target_symbol, e.source_node,
5120 r.byte_start, r.line, r.ref_id"
5121 );
5122 let bindings = chunk
5123 .iter()
5124 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5125 let mut stmt = conn.prepare(&sql)?;
5126 let rows = stmt.query_map(params_from_iter(bindings), |row| {
5127 let call = direct_call_site_from_row(row)?;
5128 let target_key = (call.target_file.clone(), call.target_symbol.clone());
5129 Ok((target_key, call))
5130 })?;
5131 for row in rows {
5132 let (target, call) = row?;
5133 callers_by_target
5134 .get_mut(&target)
5135 .expect("batched caller row belongs to a requested target")
5136 .push(call);
5137 }
5138 }
5139
5140 Ok(callers_by_target)
5141}
5142
5143const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
5145const OUTGOING_NODE_BATCH_SIZE: usize = 999;
5147
5148fn outgoing_calls_for_symbol_tuples(
5149 conn: &Connection,
5150 sources: &[(String, String)],
5151) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5152 let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
5153 let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
5154 let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
5155 let source_nodes = unique_sources
5156 .iter()
5157 .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
5158 .cloned()
5159 .collect::<Vec<_>>();
5160 let source_nodes_by_id = source_nodes
5161 .iter()
5162 .cloned()
5163 .map(|node| (node.node_id.clone(), node))
5164 .collect::<HashMap<_, _>>();
5165 let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
5166
5167 for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
5168 let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
5169 let sql = format!(
5170 "SELECT e.source_node,
5171 e.target_file, e.target_symbol, e.line,
5172 r.byte_start, r.byte_end, r.status, e.provenance,
5173 CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
5174 tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5175 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5176 tgt_file.lang
5177 FROM edges e
5178 JOIN refs r ON r.ref_id = e.ref_id
5179 LEFT JOIN nodes tgt ON tgt.id = e.target_node
5180 LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
5181 WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
5182 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
5183 );
5184 let bindings = chunk.iter().map(|node| node.node_id.as_str());
5185 let mut stmt = conn.prepare(&sql)?;
5186 let rows = stmt.query_map(params_from_iter(bindings), |row| {
5187 let source_node_id = row.get::<_, String>(0)?;
5188 let caller = source_nodes_by_id
5189 .get(&source_node_id)
5190 .expect("batched outgoing row belongs to a requested source node")
5191 .clone();
5192 let target = optional_store_node_from_row_at(row, 8)?;
5193 Ok((
5194 source_node_id,
5195 StoreCallSite {
5196 caller,
5197 target_file: row.get(1)?,
5198 target_symbol: row.get(2)?,
5199 target,
5200 line: row.get::<_, i64>(3)?.max(0) as u32,
5201 byte_start: row.get::<_, i64>(4)?.max(0) as usize,
5202 byte_end: row.get::<_, i64>(5)?.max(0) as usize,
5203 resolved: row.get::<_, String>(6)? == "resolved",
5204 provenance: row.get(7)?,
5205 },
5206 ))
5207 })?;
5208 for row in rows {
5209 let (source_node_id, call) = row?;
5210 calls_by_node.entry(source_node_id).or_default().push(call);
5211 }
5212 }
5213
5214 let mut calls_by_source = HashMap::new();
5215 for source in &unique_sources {
5216 let mut calls = Vec::new();
5217 if let Some(nodes) = source_nodes_by_symbol.get(source) {
5218 for node in nodes {
5219 if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
5220 calls.extend(node_calls);
5221 }
5222 }
5223 }
5224 calls_by_source.insert(source.clone(), calls);
5225 }
5226
5227 let target_tuples = calls_by_source
5230 .values()
5231 .flatten()
5232 .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
5233 .collect::<Vec<_>>();
5234 let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
5235 for calls in calls_by_source.values_mut() {
5236 for call in calls {
5237 if let Some(target) = target_nodes
5238 .get(&(call.target_file.clone(), call.target_symbol.clone()))
5239 .and_then(|nodes| nodes.first())
5240 {
5241 call.target = Some(target.clone());
5242 }
5243 }
5244 }
5245
5246 Ok(calls_by_source)
5247}
5248
5249fn nodes_for_symbol_tuples(
5250 conn: &Connection,
5251 symbols: &[(String, String)],
5252) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
5253 let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
5254 let mut nodes_by_symbol = unique_symbols
5255 .iter()
5256 .cloned()
5257 .map(|symbol| (symbol, Vec::new()))
5258 .collect::<HashMap<_, _>>();
5259 let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
5260
5261 for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
5262 let requested_values = (0..chunk.len())
5263 .map(|_| "(?, ?)")
5264 .collect::<Vec<_>>()
5265 .join(", ");
5266 let sql = format!(
5267 "WITH requested(file, symbol) AS (VALUES {requested_values})
5268 SELECT requested.file, requested.symbol,
5269 node.id, node.file_path, node.scoped_name, node.name, node.kind,
5270 node.start_line, node.end_line, node.signature, node.exported,
5271 node.is_callgraph_entry_point, node_file.lang
5272 FROM requested
5273 JOIN nodes node INDEXED BY idx_nodes_file
5274 ON node.file_path = requested.file
5275 AND node.scoped_name = requested.symbol
5276 JOIN files node_file ON node_file.path = node.file_path
5277 ORDER BY requested.file, requested.symbol,
5278 node.scoped_name, node.start_line, node.end_line,
5279 node.start_col, node.range_ordinal"
5280 );
5281 let bindings = chunk
5282 .iter()
5283 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5284 let mut stmt = conn.prepare(&sql)?;
5285 let rows = stmt.query_map(params_from_iter(bindings), |row| {
5286 Ok((
5287 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
5288 store_node_from_row_at(row, 2)?,
5289 ))
5290 })?;
5291 for row in rows {
5292 let (symbol, node) = row?;
5293 nodes_by_symbol.entry(symbol).or_default().push(node);
5294 }
5295 }
5296
5297 Ok(nodes_by_symbol)
5298}
5299
5300fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5301 let mut stmt = conn.prepare(
5302 "SELECT e.target_file, e.target_symbol, e.line,
5303 r.byte_start, r.byte_end, r.status, e.provenance,
5304 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5305 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5306 tgt_file.lang
5307 FROM edges e
5308 JOIN refs r ON r.ref_id = e.ref_id
5309 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5310 ON tgt.id = e.target_node
5311 WHERE e.kind = 'call' AND e.source_node = ?1
5312 ORDER BY r.byte_start, r.line, r.ref_id",
5313 )?;
5314 let rows = stmt.query_map(params![node.node_id], |row| {
5315 let target = optional_store_node_from_row_at(row, 7)?;
5316 Ok(StoreCallSite {
5317 caller: node.clone(),
5318 target_file: row.get(0)?,
5319 target_symbol: row.get(1)?,
5320 target,
5321 line: row.get::<_, i64>(2)?.max(0) as u32,
5322 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5323 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5324 resolved: row.get::<_, String>(5)? == "resolved",
5325 provenance: row.get(6)?,
5326 })
5327 })?;
5328 rows.collect::<std::result::Result<Vec<_>, _>>()
5329 .map_err(Into::into)
5330}
5331
5332fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5333 let mut stmt = conn.prepare(
5334 "SELECT r.target_file, r.target_symbol, r.line,
5335 r.byte_start, r.byte_end, r.status, r.provenance,
5336 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5337 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5338 tgt_file.lang
5339 FROM refs r
5340 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5341 ON tgt.id = r.target_node
5342 WHERE r.caller_node = ?1
5343 AND r.kind = 'call'
5344 AND r.status <> 'unresolved'
5345 AND r.target_file = ?2
5346 AND r.target_symbol = ?3
5347 AND r.provenance = ?4
5348 AND NOT EXISTS (
5349 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
5350 )
5351 ORDER BY r.byte_start, r.line, r.ref_id",
5352 )?;
5353 let rows = stmt.query_map(
5354 params![
5355 &node.node_id,
5356 &node.file,
5357 &node.symbol,
5358 PROVENANCE_TREESITTER
5359 ],
5360 |row| {
5361 let target = optional_store_node_from_row_at(row, 7)?;
5362 Ok(StoreCallSite {
5363 caller: node.clone(),
5364 target_file: row.get(0)?,
5365 target_symbol: row.get(1)?,
5366 target,
5367 line: row.get::<_, i64>(2)?.max(0) as u32,
5368 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5369 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5370 resolved: row.get::<_, String>(5)? == "resolved",
5371 provenance: row.get(6)?,
5372 })
5373 },
5374 )?;
5375 rows.collect::<std::result::Result<Vec<_>, _>>()
5376 .map_err(Into::into)
5377}
5378
5379fn unresolved_calls_for_node(
5380 conn: &Connection,
5381 node: &StoreNode,
5382) -> Result<Vec<StoreUnresolvedCall>> {
5383 let mut stmt = conn.prepare(
5384 "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
5385 FROM refs
5386 WHERE caller_node = ?1
5387 AND kind = 'call'
5388 AND status = 'unresolved'
5389 AND NOT EXISTS (
5390 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
5391 )
5392 ORDER BY byte_start, line, ref_id",
5393 )?;
5394 let rows = stmt.query_map(params![node.node_id], |row| {
5395 Ok(StoreUnresolvedCall {
5396 caller: node.clone(),
5397 symbol: row.get(0)?,
5398 full_ref: row.get(1)?,
5399 line: row.get::<_, i64>(2)?.max(0) as u32,
5400 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5401 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5402 })
5403 })?;
5404 rows.collect::<std::result::Result<Vec<_>, _>>()
5405 .map_err(Into::into)
5406}
5407
5408fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
5409 let mut calls = Vec::new();
5410 calls.extend(
5411 outgoing_calls_for_node(conn, node)?
5412 .into_iter()
5413 .map(StoreForwardCall::Resolved),
5414 );
5415 calls.extend(
5416 unresolved_calls_for_node(conn, node)?
5417 .into_iter()
5418 .map(StoreForwardCall::Unresolved),
5419 );
5420 calls.sort_by(|left, right| {
5421 left.byte_start()
5422 .cmp(&right.byte_start())
5423 .then(left.line().cmp(&right.line()))
5424 });
5425 Ok(calls)
5426}
5427
5428fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
5429 let resolved_count: i64 = conn.query_row(
5430 "SELECT COUNT(*)
5431 FROM edges e
5432 JOIN refs r ON r.ref_id = e.ref_id
5433 WHERE e.kind = 'call' AND e.source_node = ?1",
5434 params![&node.node_id],
5435 |row| row.get(0),
5436 )?;
5437 let unresolved_count: i64 = conn.query_row(
5438 "SELECT COUNT(*)
5439 FROM refs
5440 WHERE caller_node = ?1
5441 AND kind = 'call'
5442 AND status = 'unresolved'
5443 AND NOT EXISTS (
5444 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
5445 )",
5446 params![&node.node_id],
5447 |row| row.get(0),
5448 )?;
5449 let total = resolved_count.saturating_add(unresolved_count);
5450 Ok(usize::try_from(total).unwrap_or(usize::MAX))
5451}
5452
5453fn call_tree_inner(
5454 conn: &Connection,
5455 node: &StoreNode,
5456 max_depth: usize,
5457 current_depth: usize,
5458 visited: &mut HashSet<(String, String)>,
5459) -> Result<callgraph::CallTreeNode> {
5460 let visit_key = (node.file.clone(), node.symbol.clone());
5461 if visited.contains(&visit_key) {
5462 return Ok(callgraph::CallTreeNode {
5463 name: node.symbol.clone(),
5464 file: node.file.clone(),
5465 line: node.line,
5466 signature: node.signature.clone(),
5467 resolved: true,
5468 children: Vec::new(),
5469 depth_limited: false,
5470 truncated: 0,
5471 });
5472 }
5473 visited.insert(visit_key.clone());
5474
5475 let mut children = Vec::new();
5476 let mut depth_limited = false;
5477 let mut truncated = 0usize;
5478
5479 if current_depth < max_depth {
5480 let calls = forward_calls_for_node(conn, node)?;
5481 for call in calls {
5482 match call {
5483 StoreForwardCall::Resolved(site) => {
5484 if let Some(target) = site.target {
5485 let child =
5486 call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
5487 depth_limited |= child.depth_limited;
5488 truncated += child.truncated;
5489 children.push(child);
5490 } else {
5491 children.push(callgraph::CallTreeNode {
5492 name: site.target_symbol,
5493 file: site.target_file,
5494 line: site.line,
5495 signature: None,
5496 resolved: false,
5497 children: Vec::new(),
5498 depth_limited: false,
5499 truncated: 0,
5500 });
5501 }
5502 }
5503 StoreForwardCall::Unresolved(call) => {
5504 children.push(callgraph::CallTreeNode {
5505 name: call.symbol,
5506 file: call.caller.file,
5507 line: call.line,
5508 signature: None,
5509 resolved: false,
5510 children: Vec::new(),
5511 depth_limited: false,
5512 truncated: 0,
5513 });
5514 }
5515 }
5516 }
5517 } else {
5518 truncated = forward_call_count_for_node(conn, node)?;
5519 depth_limited = truncated > 0;
5520 }
5521
5522 visited.remove(&visit_key);
5523 Ok(callgraph::CallTreeNode {
5524 name: node.symbol.clone(),
5525 file: node.file.clone(),
5526 line: node.line,
5527 signature: node.signature.clone(),
5528 resolved: true,
5529 children,
5530 depth_limited,
5531 truncated,
5532 })
5533}
5534
5535fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
5536 callgraph::TraceToSymbolHop {
5537 symbol: node.symbol.clone(),
5538 file: node.file.clone(),
5539 line: node.line,
5540 }
5541}
5542
5543fn trace_to_symbol_matches_target(
5544 node: &StoreNode,
5545 to_symbol: &str,
5546 to_file: Option<&str>,
5547) -> bool {
5548 if !symbol_query_matches(&node.symbol, to_symbol) {
5549 return false;
5550 }
5551 match to_file {
5552 Some(file) => node.file == file,
5553 None => true,
5554 }
5555}
5556
5557fn symbol_query_matches(symbol: &str, query: &str) -> bool {
5558 symbol == query || unqualified_name(symbol) == query
5559}
5560
5561fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
5562 let source = std::fs::read_to_string(path).ok()?;
5563 Some(source.lines().map(|line| line.trim().to_string()).collect())
5564}
5565
5566#[doc(hidden)]
5567pub fn live_callgraph_edge_snapshot(
5568 project_root: &Path,
5569 files: &[PathBuf],
5570) -> Result<BTreeSet<StoredEdge>> {
5571 let files = normalize_file_list(project_root, files)?;
5572 let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
5573 let mut file_data = Vec::new();
5574 for file in &files {
5575 let canon = canonicalize_path(file);
5576 let data = graph.build_file(&canon)?.clone();
5577 file_data.push((canon, data));
5578 }
5579
5580 let mut edges = BTreeSet::new();
5581 for (caller_file, data) in &file_data {
5582 for (caller_symbol, call_sites) in &data.calls_by_symbol {
5583 for call_site in call_sites {
5584 let resolution = graph.resolve_cross_file_edge(
5585 &call_site.full_callee,
5586 &call_site.callee_name,
5587 caller_file,
5588 &data.import_block,
5589 );
5590 let (target_file, target_symbol) = match resolution {
5591 EdgeResolution::Resolved { file, symbol } => (file, symbol),
5592 EdgeResolution::Unresolved { callee_name } => {
5593 if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
5594 continue;
5595 }
5596 let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
5597 data,
5598 caller_file,
5599 &callee_name,
5600 ) else {
5601 continue;
5602 };
5603 (caller_file.clone(), target_symbol)
5604 }
5605 };
5606 if target_file == *caller_file && target_symbol == *caller_symbol {
5607 continue;
5608 }
5609 edges.insert(StoredEdge {
5610 source_file: relative_path(project_root, caller_file),
5611 source_symbol: caller_symbol.clone(),
5612 target_file: relative_path(project_root, &target_file),
5613 target_symbol,
5614 kind: "call".to_string(),
5615 line: call_site.line,
5616 });
5617 }
5618 }
5619 }
5620 Ok(edges)
5621}
5622
5623fn rebuild_cooldown_records() -> &'static Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>
5624{
5625 SUCCESSFUL_REBUILDS.get_or_init(|| Mutex::new(HashMap::new()))
5626}
5627
5628fn rebuild_cooldown_key(callgraph_dir: &Path, project_key: &str) -> RebuildCooldownKey {
5629 RebuildCooldownKey {
5630 callgraph_dir: std::fs::canonicalize(callgraph_dir)
5631 .unwrap_or_else(|_| callgraph_dir.to_path_buf()),
5632 project_key: project_key.to_string(),
5633 }
5634}
5635
5636fn rebuild_cooldown_denial(
5637 callgraph_dir: &Path,
5638 project_key: &str,
5639 project_root: &Path,
5640 now: Instant,
5641) -> Option<(PathBuf, Duration)> {
5642 let key = rebuild_cooldown_key(callgraph_dir, project_key);
5643 let records = rebuild_cooldown_records()
5644 .lock()
5645 .unwrap_or_else(std::sync::PoisonError::into_inner);
5646 let record = records.get(&key)?;
5647 if record.project_root == project_root || !record.cross_root_cooldown_armed {
5648 return None;
5649 }
5650 let elapsed = now.saturating_duration_since(record.published_at);
5651 (elapsed < REBUILD_COOLDOWN).then(|| (record.project_root.clone(), REBUILD_COOLDOWN - elapsed))
5652}
5653
5654fn record_successful_rebuild(
5655 callgraph_dir: &Path,
5656 project_key: &str,
5657 project_root: &Path,
5658 published_at: Instant,
5659) {
5660 let key = rebuild_cooldown_key(callgraph_dir, project_key);
5661 let mut records = rebuild_cooldown_records()
5662 .lock()
5663 .unwrap_or_else(std::sync::PoisonError::into_inner);
5664 if records.len() >= 4_096 && !records.contains_key(&key) {
5665 if let Some(evict) = records.keys().next().cloned() {
5666 records.remove(&evict);
5667 }
5668 }
5669 let cross_root_cooldown_armed = records.get(&key).is_some_and(|previous| {
5670 previous.cross_root_cooldown_armed || previous.project_root != project_root
5671 });
5672 records.insert(
5673 key,
5674 RebuildCooldownRecord {
5675 project_root: project_root.to_path_buf(),
5676 published_at,
5677 cross_root_cooldown_armed,
5678 },
5679 );
5680}
5681
5682fn acquire_writer_lease(
5683 callgraph_dir: &Path,
5684 project_key: &str,
5685 project_root: &Path,
5686) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
5687 crate::root_cache::WriterLease::acquire_shared(
5688 crate::root_cache::RootCacheDomain::Callgraph,
5689 callgraph_dir,
5690 project_key,
5691 project_root,
5692 )
5693 .map_err(CallGraphStoreError::from)
5694}
5695
5696fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
5697 if lease.verify()? {
5698 Ok(())
5699 } else {
5700 Err(CallGraphStoreError::Unavailable(format!(
5701 "callgraph writer lease for key {} lost epoch {}; aborting write",
5702 lease.key(),
5703 lease.epoch()
5704 )))
5705 }
5706}
5707
5708fn legacy_migration_completion_line(
5709 project_key: &str,
5710 method: &str,
5711 legacy_bytes: u64,
5712 migrated_bytes: u64,
5713) -> String {
5714 format!(
5715 "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
5716 )
5717}
5718
5719fn log_legacy_migration_completion(
5720 project_key: &str,
5721 method: &str,
5722 legacy_bytes: u64,
5723 migrated_bytes: u64,
5724) {
5725 crate::slog_info!(
5726 "{}",
5727 legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
5728 );
5729}
5730
5731fn try_legacy_migration_or_fallback(
5732 callgraph_dir: &Path,
5733 project_root: &Path,
5734 project_key: &str,
5735 writer_lease: Arc<crate::root_cache::WriterLease>,
5736) -> Result<Option<CallGraphStore>> {
5737 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
5738 if partitions.is_empty() {
5739 return Ok(None);
5740 }
5741
5742 for partition in &partitions {
5743 if let Some(source) = newest_superseded_legacy_generation(partition)? {
5744 if !migration_disk_floor_allows(&source, callgraph_dir)? {
5745 return open_legacy_fallback_store(
5746 callgraph_dir,
5747 project_root,
5748 project_key,
5749 &partitions,
5750 );
5751 }
5752 match publish_generation_copy_migration(
5753 callgraph_dir,
5754 project_key,
5755 &source,
5756 Arc::clone(&writer_lease),
5757 ) {
5758 Ok(published) => {
5759 log_legacy_migration_completion(
5760 project_key,
5761 "generation_copy",
5762 source.source_bytes,
5763 published.migrated_bytes,
5764 );
5765 return CallGraphStore::open_generation(
5766 callgraph_dir,
5767 project_root.to_path_buf(),
5768 project_key.to_string(),
5769 published.generation,
5770 writer_lease,
5771 )
5772 .map(Some);
5773 }
5774 Err(error) => {
5775 crate::slog_warn!(
5776 "root-keyed callgraph generation-copy migration failed from {}: {}",
5777 source.sqlite_path.display(),
5778 error
5779 );
5780 return open_legacy_fallback_store(
5781 callgraph_dir,
5782 project_root,
5783 project_key,
5784 &partitions,
5785 );
5786 }
5787 }
5788 }
5789
5790 if let Some(source) = current_legacy_generation(partition)? {
5791 if !migration_disk_floor_allows(&source, callgraph_dir)? {
5792 return open_legacy_fallback_store(
5793 callgraph_dir,
5794 project_root,
5795 project_key,
5796 &partitions,
5797 );
5798 }
5799 match publish_backup_migration(
5800 callgraph_dir,
5801 project_key,
5802 &source,
5803 Arc::clone(&writer_lease),
5804 ) {
5805 Ok(published) => {
5806 log_legacy_migration_completion(
5807 project_key,
5808 "sqlite_backup",
5809 source.source_bytes,
5810 published.migrated_bytes,
5811 );
5812 return CallGraphStore::open_generation(
5813 callgraph_dir,
5814 project_root.to_path_buf(),
5815 project_key.to_string(),
5816 published.generation,
5817 writer_lease,
5818 )
5819 .map(Some);
5820 }
5821 Err(error) => {
5822 crate::slog_warn!(
5823 "root-keyed callgraph backup migration failed from {}: {}",
5824 source.sqlite_path.display(),
5825 error
5826 );
5827 return open_legacy_fallback_store(
5828 callgraph_dir,
5829 project_root,
5830 project_key,
5831 &partitions,
5832 );
5833 }
5834 }
5835 }
5836 }
5837
5838 open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
5839}
5840
5841fn open_legacy_fallback_store(
5842 callgraph_dir: &Path,
5843 project_root: &Path,
5844 project_key: &str,
5845 partitions: &[LegacyCallgraphPartition],
5846) -> Result<Option<CallGraphStore>> {
5847 let Some(target) = first_ready_legacy_target(partitions)? else {
5848 return Ok(None);
5849 };
5850 crate::slog_warn!(
5851 "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
5852 target.partition.harness,
5853 target.sqlite_path.display()
5854 );
5855 let conn = open_readonly_connection(&target.sqlite_path)?;
5856 if !database_ready(&conn).unwrap_or(false) {
5857 return Ok(None);
5858 }
5859 let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
5860 let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
5861 Ok(Some(CallGraphStore::from_connection(
5862 project_root.to_path_buf(),
5863 project_key.to_string(),
5864 target.sqlite_path,
5865 callgraph_dir.to_path_buf(),
5866 true,
5867 target.generation,
5868 None,
5869 Some(read_marker),
5870 conn,
5871 )))
5872}
5873
5874fn migration_disk_floor_allows(
5875 source: &LegacyCallgraphTarget,
5876 callgraph_dir: &Path,
5877) -> Result<bool> {
5878 let available = migration_available_disk(callgraph_dir)?;
5879 let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
5880 source.source_bytes,
5881 available,
5882 );
5883 if decision.should_skip_copy() {
5884 crate::slog_warn!(
5885 "{}",
5886 decision.warning_message(&source.sqlite_path, callgraph_dir)
5887 );
5888 return Ok(false);
5889 }
5890 Ok(true)
5891}
5892
5893fn migration_available_disk(path: &Path) -> Result<u64> {
5894 if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
5895 return Ok(bytes);
5896 }
5897 crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
5898}
5899
5900fn legacy_callgraph_partitions(
5901 callgraph_dir: &Path,
5902 project_key: &str,
5903) -> Result<Vec<LegacyCallgraphPartition>> {
5904 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5905 return Ok(Vec::new());
5906 };
5907 let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
5908 let mut partitions = inventory
5909 .into_iter()
5910 .filter(|entry| {
5911 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
5912 && entry.key == project_key
5913 })
5914 .map(|entry| {
5915 let dir = if entry.path.is_dir() {
5916 entry.path.clone()
5917 } else {
5918 entry
5919 .path
5920 .parent()
5921 .map(Path::to_path_buf)
5922 .unwrap_or_else(|| entry.path.clone())
5923 };
5924 LegacyCallgraphPartition {
5925 harness: entry.harness,
5926 dir,
5927 key: entry.key,
5928 bytes: entry.bytes,
5929 freshness: entry.callgraph_pointer_mtime,
5930 }
5931 })
5932 .collect::<Vec<_>>();
5933 partitions.sort_by(|left, right| {
5934 right
5935 .freshness
5936 .cmp(&left.freshness)
5937 .then_with(|| right.bytes.cmp(&left.bytes))
5938 .then_with(|| left.harness.cmp(&right.harness))
5939 });
5940 Ok(partitions)
5941}
5942
5943fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
5944 let domain_dir = callgraph_dir.parent()?;
5945 if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
5946 return None;
5947 }
5948 domain_dir.parent().map(Path::to_path_buf)
5949}
5950
5951pub(crate) fn all_legacy_partitions_migrated_for_keys(
5952 callgraph_dir: &Path,
5953 configured_keys: &BTreeSet<String>,
5954) -> Result<bool> {
5955 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5956 return Ok(false);
5957 };
5958 let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
5959 .into_iter()
5960 .filter(|entry| {
5961 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
5962 && configured_keys.contains(&entry.key)
5963 })
5964 .map(|entry| entry.key)
5965 .collect::<BTreeSet<_>>();
5966 if legacy_keys.is_empty() {
5967 return Ok(false);
5968 }
5969
5970 for key in legacy_keys {
5971 let migrated_dir = storage_root.join("callgraph").join(&key);
5972 let Some(generation) = read_pointer(&migrated_dir, &key) else {
5973 return Ok(false);
5974 };
5975 if !migration_generation_requires_manifest(&generation)
5976 || !migration_manifest_valid(&migrated_dir, &generation)
5977 {
5978 return Ok(false);
5979 }
5980 }
5981 Ok(true)
5982}
5983
5984fn newest_superseded_legacy_generation(
5985 partition: &LegacyCallgraphPartition,
5986) -> Result<Option<LegacyCallgraphTarget>> {
5987 let Some(current) = read_pointer(&partition.dir, &partition.key) else {
5988 return Ok(None);
5989 };
5990 let prefix = format!("{}.g", partition.key);
5991 let Ok(entries) = std::fs::read_dir(&partition.dir) else {
5992 return Ok(None);
5993 };
5994 let mut candidates = Vec::new();
5995 for entry in entries.flatten() {
5996 let name = entry.file_name().to_string_lossy().to_string();
5997 if name == current
5998 || name.contains(".tmp.")
5999 || !name.starts_with(&prefix)
6000 || !name.ends_with(".sqlite")
6001 {
6002 continue;
6003 }
6004 let path = entry.path();
6005 if !db_path_ready(&path) {
6006 continue;
6007 }
6008 let modified = entry
6009 .metadata()
6010 .and_then(|metadata| metadata.modified())
6011 .unwrap_or(SystemTime::UNIX_EPOCH);
6012 candidates.push((modified, path, name));
6013 }
6014 candidates.sort_by(|left, right| right.0.cmp(&left.0));
6015 let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
6016 return Ok(None);
6017 };
6018 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6019 Ok(Some(LegacyCallgraphTarget {
6020 partition: partition.clone(),
6021 sqlite_path,
6022 generation: Some(generation),
6023 source_bytes,
6024 source_blake3: String::new(),
6025 }))
6026}
6027
6028fn current_legacy_generation(
6029 partition: &LegacyCallgraphPartition,
6030) -> Result<Option<LegacyCallgraphTarget>> {
6031 let Some(target) = ready_legacy_target(partition)? else {
6032 return Ok(None);
6033 };
6034 let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
6035 if has_superseded {
6036 return Ok(None);
6037 }
6038 Ok(Some(target))
6039}
6040
6041fn freshest_legacy_fallback_target(
6042 callgraph_dir: &Path,
6043 project_key: &str,
6044) -> Result<Option<LegacyCallgraphTarget>> {
6045 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
6046 first_ready_legacy_target(&partitions)
6047}
6048
6049fn first_ready_legacy_target(
6050 partitions: &[LegacyCallgraphPartition],
6051) -> Result<Option<LegacyCallgraphTarget>> {
6052 for partition in partitions {
6053 if let Some(target) = ready_legacy_target(partition)? {
6054 return Ok(Some(target));
6055 }
6056 }
6057 Ok(None)
6058}
6059
6060fn ready_legacy_target(
6061 partition: &LegacyCallgraphPartition,
6062) -> Result<Option<LegacyCallgraphTarget>> {
6063 if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
6064 let sqlite_path = partition.dir.join(&generation);
6065 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6066 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6067 return Ok(Some(LegacyCallgraphTarget {
6068 partition: partition.clone(),
6069 sqlite_path,
6070 generation: Some(generation),
6071 source_bytes,
6072 source_blake3: String::new(),
6073 }));
6074 }
6075 }
6076
6077 let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
6078 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6079 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6080 return Ok(Some(LegacyCallgraphTarget {
6081 partition: partition.clone(),
6082 sqlite_path,
6083 generation: None,
6084 source_bytes,
6085 source_blake3: String::new(),
6086 }));
6087 }
6088 Ok(None)
6089}
6090
6091fn publish_generation_copy_migration(
6092 callgraph_dir: &Path,
6093 project_key: &str,
6094 source: &LegacyCallgraphTarget,
6095 writer_lease: Arc<crate::root_cache::WriterLease>,
6096) -> Result<PublishedLegacyMigration> {
6097 let generation = migration_generation_file_name(project_key, "copy");
6098 let temp_path = migration_temp_path(callgraph_dir, &generation);
6099 remove_sqlite_file_set(&temp_path);
6100 copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
6101 fail_after_temp_copy_for_test()?;
6102
6103 let mut source = source.clone();
6104 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
6105 source.source_blake3 = fingerprint.blake3;
6106 let generation = publish_migrated_generation(
6107 callgraph_dir,
6108 project_key,
6109 &generation,
6110 &temp_path,
6111 &source,
6112 fingerprint.bytes,
6113 writer_lease,
6114 "generation_copy",
6115 )?;
6116 Ok(PublishedLegacyMigration {
6117 generation,
6118 migrated_bytes: fingerprint.bytes,
6119 })
6120}
6121
6122fn publish_backup_migration(
6123 callgraph_dir: &Path,
6124 project_key: &str,
6125 source: &LegacyCallgraphTarget,
6126 writer_lease: Arc<crate::root_cache::WriterLease>,
6127) -> Result<PublishedLegacyMigration> {
6128 if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
6129 return Err(CallGraphStoreError::Unavailable(
6130 "legacy callgraph backup migration budget exhausted by test seam".to_string(),
6131 ));
6132 }
6133
6134 let generation = migration_generation_file_name(project_key, "backup");
6135 let temp_path = migration_temp_path(callgraph_dir, &generation);
6136 remove_sqlite_file_set(&temp_path);
6137
6138 let source_conn = open_readonly_connection(&source.sqlite_path)?;
6139 let mut destination = Connection::open(&temp_path)?;
6140 destination.busy_timeout(Duration::from_secs(5))?;
6141 let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
6142 let started = Instant::now();
6143 let mut retries = 0;
6144 loop {
6145 match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
6146 rusqlite::backup::StepResult::Done => break,
6147 rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
6148 rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
6149 retries += 1;
6150 if retries > MIGRATION_BACKUP_RETRY_BUDGET
6151 || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
6152 {
6153 return Err(CallGraphStoreError::Unavailable(format!(
6154 "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
6155 )));
6156 }
6157 std::thread::sleep(Duration::from_millis(20));
6158 }
6159 _ => {
6160 return Err(CallGraphStoreError::Unavailable(
6161 "legacy callgraph backup returned an unknown step result".to_string(),
6162 ));
6163 }
6164 }
6165 }
6166 drop(backup);
6167
6168 let integrity: String =
6169 destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
6170 if integrity != "ok" {
6171 return Err(CallGraphStoreError::Unavailable(format!(
6172 "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
6173 )));
6174 }
6175 if !database_ready(&destination)? {
6176 return Err(CallGraphStoreError::Unavailable(
6177 "legacy callgraph backup produced a database without ready metadata".to_string(),
6178 ));
6179 }
6180 destination.execute_batch("PRAGMA optimize;")?;
6181 drop(destination);
6182 sync_file(&temp_path)?;
6183 fail_after_temp_copy_for_test()?;
6184
6185 let mut source = source.clone();
6186 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
6187 source.source_blake3 = fingerprint.blake3;
6188 let generation = publish_migrated_generation(
6189 callgraph_dir,
6190 project_key,
6191 &generation,
6192 &temp_path,
6193 &source,
6194 fingerprint.bytes,
6195 writer_lease,
6196 "sqlite_backup",
6197 )?;
6198 Ok(PublishedLegacyMigration {
6199 generation,
6200 migrated_bytes: fingerprint.bytes,
6201 })
6202}
6203
6204fn publish_migrated_generation(
6205 callgraph_dir: &Path,
6206 project_key: &str,
6207 generation: &str,
6208 temp_path: &Path,
6209 source: &LegacyCallgraphTarget,
6210 migrated_bytes: u64,
6211 writer_lease: Arc<crate::root_cache::WriterLease>,
6212 method: &str,
6213) -> Result<String> {
6214 let gen_path = callgraph_dir.join(generation);
6215 checkpoint_sqlite_before_publication(temp_path);
6216 let publication = publish_if_current(|| {
6217 verify_writer_lease(&writer_lease)?;
6218 remove_sqlite_file_set(&gen_path);
6219 rename_sqlite_file_set(temp_path, &gen_path)?;
6220 crate::fs_lock::sync_parent(&gen_path);
6221
6222 verify_writer_lease(&writer_lease)?;
6223 publish_pointer(callgraph_dir, project_key, generation)?;
6224 write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
6225 Ok(generation.to_string())
6226 });
6227 if matches!(publication, Err(CallGraphStoreError::Superseded)) {
6228 remove_sqlite_file_set(temp_path);
6229 }
6230 publication
6231}
6232
6233fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
6234 if let Some(parent) = destination.parent() {
6235 std::fs::create_dir_all(parent)?;
6236 }
6237 for suffix in SQLITE_FILE_SET_SUFFIXES {
6238 let source_path = sqlite_file_set_path(source, suffix);
6239 if !source_path.is_file() {
6240 continue;
6241 }
6242 let destination_path = sqlite_file_set_path(destination, suffix);
6243 std::fs::copy(&source_path, &destination_path)?;
6244 sync_file(&destination_path)?;
6245 }
6246 Ok(())
6247}
6248
6249fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
6250 for suffix in SQLITE_FILE_SET_SUFFIXES {
6251 let source_path = sqlite_file_set_path(source, suffix);
6252 if !source_path.exists() {
6253 continue;
6254 }
6255 let destination_path = sqlite_file_set_path(destination, suffix);
6256 if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
6257 let _ = std::fs::remove_file(&source_path);
6258 return Err(error.into());
6259 }
6260 }
6261 Ok(())
6262}
6263
6264fn sqlite_file_set_size(path: &Path) -> Result<u64> {
6265 let mut bytes = 0_u64;
6266 for suffix in SQLITE_FILE_SET_SUFFIXES {
6267 let member = sqlite_file_set_path(path, suffix);
6268 if !member.is_file() {
6269 continue;
6270 }
6271 bytes = bytes.saturating_add(member.metadata()?.len());
6272 }
6273 Ok(bytes)
6274}
6275
6276fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
6277 let mut hasher = blake3::Hasher::new();
6278 let mut bytes = 0_u64;
6279 let mut buffer = [0_u8; 64 * 1024];
6280 for suffix in SQLITE_FILE_SET_SUFFIXES {
6281 let member = sqlite_file_set_path(path, suffix);
6282 if !member.is_file() {
6283 continue;
6284 }
6285 hasher.update(suffix.as_bytes());
6286 let mut file = std::fs::File::open(&member)?;
6287 loop {
6288 let read = file.read(&mut buffer)?;
6289 if read == 0 {
6290 break;
6291 }
6292 bytes = bytes.saturating_add(read as u64);
6293 hasher.update(&buffer[..read]);
6294 }
6295 }
6296 Ok(SourceFingerprint {
6297 bytes,
6298 blake3: hash_to_hex(hasher.finalize()),
6299 })
6300}
6301
6302fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
6303 if suffix.is_empty() {
6304 path.to_path_buf()
6305 } else {
6306 PathBuf::from(format!("{}{suffix}", path.display()))
6307 }
6308}
6309
6310fn sync_file(path: &Path) -> Result<()> {
6311 let file = std::fs::OpenOptions::new()
6312 .read(true)
6313 .write(true)
6314 .open(path)?;
6315 file.sync_all()?;
6316 Ok(())
6317}
6318
6319fn fail_after_temp_copy_for_test() -> Result<()> {
6320 if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
6321 return Err(CallGraphStoreError::Unavailable(
6322 "legacy callgraph migration stopped after temp copy by test seam".to_string(),
6323 ));
6324 }
6325 Ok(())
6326}
6327
6328fn migration_generation_file_name(project_key: &str, method: &str) -> String {
6329 format!(
6330 "{project_key}.g{}.{}{}{}.sqlite",
6331 now_nanos(),
6332 std::process::id(),
6333 MIGRATION_GENERATION_TAG,
6334 method
6335 )
6336}
6337
6338fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
6339 callgraph_dir.join(format!(
6340 "{generation}.tmp.{}.{}",
6341 std::process::id(),
6342 now_nanos()
6343 ))
6344}
6345
6346fn write_migration_manifest(
6347 callgraph_dir: &Path,
6348 generation: &str,
6349 source: &LegacyCallgraphTarget,
6350 migrated_bytes: u64,
6351 method: &str,
6352) -> Result<()> {
6353 let manifest_path = migration_manifest_path(callgraph_dir, generation);
6354 let temp_path = manifest_path.with_extension(format!(
6355 "migration.json.tmp.{}.{}",
6356 std::process::id(),
6357 now_nanos()
6358 ));
6359 let manifest = serde_json::json!({
6360 "version": MIGRATION_MANIFEST_VERSION,
6361 "method": method,
6362 "target_generation": generation,
6363 "source_harness": source.partition.harness,
6364 "source_path": source.sqlite_path.display().to_string(),
6365 "source_generation": source.generation,
6366 "source_bytes": source.source_bytes,
6367 "source_blake3": source.source_blake3,
6368 "migrated_bytes": migrated_bytes,
6369 });
6370 {
6371 use std::io::Write as _;
6372 let mut file = std::fs::File::create(&temp_path)?;
6373 file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
6374 file.write_all(b"\n")?;
6375 file.sync_all()?;
6376 }
6377 if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
6378 let _ = std::fs::remove_file(&temp_path);
6379 return Err(error.into());
6380 }
6381 crate::fs_lock::sync_parent(&manifest_path);
6382 Ok(())
6383}
6384
6385fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
6386 callgraph_dir.join(format!("{generation}.migration.json"))
6387}
6388
6389fn migration_generation_requires_manifest(generation: &str) -> bool {
6390 generation.contains(MIGRATION_GENERATION_TAG)
6391}
6392
6393fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
6394 if !migration_generation_requires_manifest(generation) {
6395 return true;
6396 }
6397 let path = migration_manifest_path(callgraph_dir, generation);
6398 let Ok(bytes) = std::fs::read(path) else {
6399 return false;
6400 };
6401 let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
6402 return false;
6403 };
6404 value.get("version").and_then(serde_json::Value::as_u64)
6405 == Some(MIGRATION_MANIFEST_VERSION as u64)
6406 && value
6407 .get("target_generation")
6408 .and_then(serde_json::Value::as_str)
6409 == Some(generation)
6410 && value
6411 .get("source_bytes")
6412 .and_then(serde_json::Value::as_u64)
6413 .is_some_and(|bytes| bytes > 0)
6414 && value
6415 .get("source_blake3")
6416 .and_then(serde_json::Value::as_str)
6417 .is_some_and(|hash| hash.len() == 64)
6418}
6419
6420fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
6421 let pointer_generation = read_pointer(callgraph_dir, project_key);
6422 if let Some(generation) = pointer_generation.as_deref() {
6423 if migration_generation_requires_manifest(generation)
6424 && !migration_manifest_valid(callgraph_dir, generation)
6425 {
6426 let path = callgraph_dir.join(generation);
6427 remove_sqlite_file_set(&path);
6428 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
6429 let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
6430 }
6431 }
6432
6433 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6434 return;
6435 };
6436 for entry in entries.flatten() {
6437 let name = entry.file_name().to_string_lossy().to_string();
6438 let path = entry.path();
6439 if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
6440 let _ = std::fs::remove_file(path);
6441 continue;
6442 }
6443 if name.starts_with(&format!("{project_key}.g"))
6444 && name.ends_with(".sqlite")
6445 && name.contains(MIGRATION_GENERATION_TAG)
6446 && pointer_generation.as_deref() != Some(&name)
6447 && !migration_manifest_valid(callgraph_dir, &name)
6448 {
6449 remove_sqlite_file_set(&path);
6450 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
6451 }
6452 }
6453 crate::fs_lock::sync_parent(callgraph_dir);
6454}
6455
6456fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
6457 let mut hasher = blake3::Hasher::new();
6458 hasher.update(path.to_string_lossy().as_bytes());
6459 if let Some(generation) = generation {
6460 hasher.update(generation.as_bytes());
6461 }
6462 let digest = hash_to_hex(hasher.finalize());
6463 format!("legacy-{}", &digest[..16])
6464}
6465
6466fn open_readonly_connection(path: &Path) -> Result<Connection> {
6467 let uri = sqlite_readonly_uri(path);
6468 let conn = Connection::open_with_flags(
6469 &uri,
6470 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
6471 )?;
6472 conn.pragma_update(
6473 None,
6474 "synchronous",
6475 if write_amplification_baseline_enabled() {
6476 "FULL"
6477 } else {
6478 "NORMAL"
6479 },
6480 )?;
6481 conn.busy_timeout(reader_busy_timeout())?;
6482 conn.execute_batch("PRAGMA query_only=ON;")?;
6483 Ok(conn)
6484}
6485
6486fn reader_busy_timeout() -> Duration {
6487 let jitter = (now_nanos() % 500) as u64;
6488 Duration::from_millis(250 + jitter)
6489}
6490
6491fn sqlite_readonly_uri(path: &Path) -> String {
6492 let raw = path.to_string_lossy().replace('\\', "/");
6493 let encoded = percent_encode_sqlite_uri_path(&raw);
6494 if raw.starts_with('/') {
6495 format!("file://{encoded}?mode=ro")
6496 } else if raw.as_bytes().get(1) == Some(&b':') {
6497 format!("file:///{encoded}?mode=ro")
6498 } else {
6499 format!("file:{encoded}?mode=ro")
6500 }
6501}
6502
6503fn percent_encode_sqlite_uri_path(path: &str) -> String {
6504 let mut encoded = String::with_capacity(path.len());
6505 for byte in path.bytes() {
6506 match byte {
6507 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
6508 encoded.push(byte as char)
6509 }
6510 _ => encoded.push_str(&format!("%{byte:02X}")),
6511 }
6512 }
6513 encoded
6514}
6515
6516fn configure_connection(conn: &Connection) -> Result<()> {
6517 conn.pragma_update(None, "journal_mode", "WAL")?;
6518 let baseline = write_amplification_baseline_enabled();
6519 conn.pragma_update(
6520 None,
6521 "synchronous",
6522 if baseline { "FULL" } else { "NORMAL" },
6523 )?;
6524 conn.pragma_update(
6525 None,
6526 "wal_autocheckpoint",
6527 if baseline {
6528 1_000
6529 } else {
6530 CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES
6531 },
6532 )?;
6533 conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
6534 conn.pragma_update(None, "busy_timeout", 5_000)?;
6535 Ok(())
6536}
6537
6538fn configure_build_connection(conn: &Connection) -> Result<()> {
6539 conn.pragma_update(None, "journal_mode", "WAL")?;
6542 conn.pragma_update(
6543 None,
6544 "synchronous",
6545 if write_amplification_baseline_enabled() {
6546 "FULL"
6547 } else {
6548 "NORMAL"
6549 },
6550 )?;
6551 conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
6552 conn.pragma_update(None, "busy_timeout", 5_000)?;
6553 Ok(())
6554}
6555
6556fn checkpoint_sqlite_before_publication(path: &Path) {
6560 let Ok(conn) = Connection::open(path) else {
6561 return;
6562 };
6563 let _ = conn.pragma_update(None, "synchronous", "NORMAL");
6564 let _ = conn.busy_timeout(Duration::from_secs(5));
6565 let _ = checkpoint_wal_truncate(&conn);
6566}
6567
6568fn checkpoint_wal_truncate(conn: &Connection) -> bool {
6569 match conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
6570 row.get::<_, i64>(0)
6571 }) {
6572 Ok(0) => true,
6573 Ok(_) => false,
6574 Err(rusqlite::Error::SqliteFailure(error, _))
6575 if matches!(
6576 error.code,
6577 rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
6578 ) =>
6579 {
6580 false
6581 }
6582 Err(error) => {
6583 log::debug!("callgraph WAL truncate checkpoint skipped: {error}");
6584 false
6585 }
6586 }
6587}
6588
6589fn initialize_schema(conn: &Connection) -> Result<()> {
6590 conn.execute_batch(
6591 "CREATE TABLE IF NOT EXISTS files (
6592 path TEXT PRIMARY KEY,
6593 content_hash TEXT NOT NULL,
6594 mtime_ns INTEGER NOT NULL,
6595 size INTEGER NOT NULL,
6596 lang TEXT NOT NULL,
6597 is_dead_code_root INTEGER NOT NULL DEFAULT 0,
6598 is_public_api INTEGER NOT NULL DEFAULT 0,
6599 surface_fingerprint TEXT NOT NULL,
6600 indexed_at INTEGER NOT NULL
6601 );
6602
6603 CREATE TABLE IF NOT EXISTS nodes (
6604 id TEXT PRIMARY KEY,
6605 file_path TEXT NOT NULL,
6606 name TEXT NOT NULL,
6607 scoped_name TEXT NOT NULL,
6608 kind TEXT NOT NULL,
6609 start_line INTEGER NOT NULL,
6610 start_col INTEGER NOT NULL,
6611 end_line INTEGER NOT NULL,
6612 end_col INTEGER NOT NULL,
6613 range_ordinal INTEGER NOT NULL,
6614 signature TEXT,
6615 exported INTEGER NOT NULL,
6616 is_default_export INTEGER NOT NULL,
6617 is_type_like INTEGER NOT NULL,
6618 is_callgraph_entry_point INTEGER NOT NULL,
6619 provenance TEXT NOT NULL,
6620 UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
6621 );
6622 CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
6623 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
6624 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
6625
6626 CREATE TABLE IF NOT EXISTS refs (
6627 ref_id TEXT PRIMARY KEY,
6628 caller_node TEXT,
6629 caller_file TEXT NOT NULL,
6630 kind TEXT NOT NULL,
6631 short_name TEXT,
6632 full_ref TEXT,
6633 module_path TEXT,
6634 import_kind TEXT,
6635 local_name TEXT,
6636 requested_name TEXT,
6637 namespace_alias TEXT,
6638 wildcard INTEGER NOT NULL DEFAULT 0,
6639 line INTEGER NOT NULL,
6640 byte_start INTEGER NOT NULL,
6641 byte_end INTEGER NOT NULL,
6642 status TEXT NOT NULL,
6643 target_node TEXT,
6644 target_file TEXT,
6645 target_symbol TEXT,
6646 provenance TEXT NOT NULL
6647 );
6648 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
6649 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
6650 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
6651 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
6652 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
6653
6654 CREATE TABLE IF NOT EXISTS file_dependencies (
6655 file_path TEXT NOT NULL,
6656 dep_file TEXT NOT NULL,
6657 PRIMARY KEY(file_path, dep_file)
6658 );
6659 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
6660
6661 CREATE TABLE IF NOT EXISTS edges (
6662 edge_id TEXT PRIMARY KEY,
6663 ref_id TEXT NOT NULL,
6664 source_node TEXT NOT NULL,
6665 target_node TEXT,
6666 target_file TEXT NOT NULL,
6667 target_symbol TEXT NOT NULL,
6668 kind TEXT NOT NULL,
6669 line INTEGER NOT NULL,
6670 provenance TEXT NOT NULL
6671 );
6672 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
6673 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
6674 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
6675 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
6676
6677 CREATE TABLE IF NOT EXISTS dispatch_hints (
6678 id TEXT PRIMARY KEY,
6679 method_name TEXT NOT NULL,
6680 caller_node TEXT NOT NULL,
6681 file TEXT NOT NULL,
6682 line INTEGER NOT NULL,
6683 byte_start INTEGER NOT NULL,
6684 byte_end INTEGER NOT NULL,
6685 provenance TEXT NOT NULL
6686 );
6687 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
6688 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
6689
6690 CREATE TABLE IF NOT EXISTS type_ref_names (
6691 name TEXT PRIMARY KEY
6692 );
6693
6694 CREATE TABLE IF NOT EXISTS backend_file_state (
6695 backend TEXT NOT NULL,
6696 workspace_root TEXT NOT NULL,
6697 file_path TEXT NOT NULL,
6698 content_hash TEXT NOT NULL,
6699 status TEXT NOT NULL,
6700 updated_at INTEGER NOT NULL,
6701 PRIMARY KEY(backend, workspace_root, file_path, content_hash)
6702 );
6703 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
6704
6705 CREATE TABLE IF NOT EXISTS meta (
6706 k TEXT PRIMARY KEY,
6707 v TEXT NOT NULL
6708 );
6709
6710 -- The file walk is staged on disk so extraction can page through a
6711 -- deterministic inventory without retaining every source path in heap.
6712 CREATE TABLE IF NOT EXISTS staging_file_inventory (
6713 path TEXT PRIMARY KEY,
6714 size INTEGER NOT NULL
6715 ) WITHOUT ROWID;
6716
6717 -- Context needed only while a generation is staged. Raw refs live in
6718 -- `refs` with status `staged`; this table preserves the caller symbol
6719 -- needed to avoid inventing self edges during the later resolve pass.
6720 CREATE TABLE IF NOT EXISTS staging_ref_context (
6721 ref_id TEXT PRIMARY KEY,
6722 caller_symbol TEXT
6723 );",
6724 )?;
6725 insert_meta(conn)?;
6726 Ok(())
6727}
6728
6729fn insert_meta(conn: &Connection) -> Result<()> {
6730 conn.execute(
6731 "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
6732 params![SCHEMA_VERSION.to_string()],
6733 )?;
6734 conn.execute(
6735 "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
6736 params![schema_fingerprint()],
6737 )?;
6738 conn.execute(
6739 "INSERT OR IGNORE INTO meta(k, v) VALUES('projection_write_revision', '0')",
6740 [],
6741 )?;
6742 Ok(())
6743}
6744
6745fn projection_write_revision(conn: &Connection) -> Result<Option<u64>> {
6749 let revision: Option<String> = conn
6750 .query_row(
6751 "SELECT v FROM meta WHERE k = 'projection_write_revision'",
6752 [],
6753 |row| row.get(0),
6754 )
6755 .optional()?;
6756 revision
6757 .map(|revision| {
6758 revision.parse::<u64>().map_err(|error| {
6759 CallGraphStoreError::Unavailable(format!(
6760 "callgraph projection write revision is invalid: {error}"
6761 ))
6762 })
6763 })
6764 .transpose()
6765}
6766
6767fn bump_projection_write_revision(tx: &Transaction<'_>) -> Result<()> {
6770 tx.execute(
6771 "INSERT INTO meta(k, v) VALUES('projection_write_revision', '1')
6772 ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1",
6773 [],
6774 )?;
6775 Ok(())
6776}
6777
6778fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
6779 conn.execute(
6780 "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
6781 params![if ready { "1" } else { "0" }],
6782 )?;
6783 Ok(())
6784}
6785
6786fn database_ready(conn: &Connection) -> Result<bool> {
6787 let schema_version: Option<String> = conn
6788 .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
6789 row.get(0)
6790 })
6791 .optional()?;
6792 let fingerprint: Option<String> = conn
6793 .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
6794 row.get(0)
6795 })
6796 .optional()?;
6797 let ready: Option<String> = conn
6798 .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
6799 .optional()?;
6800
6801 let expected_schema = SCHEMA_VERSION.to_string();
6802 let expected_fingerprint = schema_fingerprint();
6803 Ok(schema_version.as_deref() == Some(expected_schema.as_str())
6804 && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
6805 && ready.as_deref() == Some("1"))
6806}
6807
6808fn ensure_database_ready(conn: &Connection) -> Result<()> {
6809 if database_ready(conn)? {
6810 Ok(())
6811 } else {
6812 Err(CallGraphStoreError::Unavailable(
6813 "database is missing, stale, or mid-build".to_string(),
6814 ))
6815 }
6816}
6817
6818fn schema_fingerprint() -> String {
6819 let input =
6824 format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
6825 hash_to_hex(blake3::hash(input.as_bytes()))
6826}
6827
6828fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
6829 tx.execute_batch(
6830 "DELETE FROM staging_ref_context;
6831 DELETE FROM edges;
6832 DELETE FROM file_dependencies;
6833 DELETE FROM refs;
6834 DELETE FROM dispatch_hints;
6835 DELETE FROM type_ref_names;
6836 DELETE FROM backend_file_state;
6837 DELETE FROM nodes;
6838 DELETE FROM files;",
6839 )?;
6840 Ok(())
6841}
6842
6843fn staged_build_phase(conn: &Connection) -> Result<Option<String>> {
6844 conn.query_row(
6845 "SELECT v FROM meta WHERE k = ?1",
6846 params![STAGED_BUILD_PHASE],
6847 |row| row.get(0),
6848 )
6849 .optional()
6850 .map_err(Into::into)
6851}
6852
6853fn staged_u64(conn: &Connection, key: &str) -> Result<u64> {
6854 let value = staged_string(conn, key)?;
6855 Ok(value.and_then(|value| value.parse().ok()).unwrap_or(0))
6856}
6857
6858fn staged_string(conn: &Connection, key: &str) -> Result<Option<String>> {
6859 conn.query_row("SELECT v FROM meta WHERE k = ?1", params![key], |row| {
6860 row.get::<_, String>(0)
6861 })
6862 .optional()
6863 .map_err(Into::into)
6864}
6865
6866fn set_staged_build_phase(tx: &Transaction<'_>, phase: &str) -> Result<()> {
6867 tx.execute(
6868 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
6869 params![STAGED_BUILD_PHASE, phase],
6870 )?;
6871 Ok(())
6872}
6873
6874fn set_staged_u64(tx: &Transaction<'_>, key: &str, value: u64) -> Result<()> {
6875 set_staged_string(tx, key, &value.to_string())
6876}
6877
6878fn set_staged_string(tx: &Transaction<'_>, key: &str, value: &str) -> Result<()> {
6879 tx.execute(
6880 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
6881 params![key, value],
6882 )?;
6883 Ok(())
6884}
6885
6886fn increment_staged_extracted_bytes(tx: &Transaction<'_>, bytes: u64) -> Result<()> {
6890 tx.execute(
6891 "INSERT INTO meta(k, v) VALUES(?1, ?2)
6892 ON CONFLICT(k) DO UPDATE SET v = CAST(meta.v AS INTEGER) + excluded.v",
6893 params![STAGED_COMMITTED_EXTRACTED_BYTES, bytes.to_string()],
6894 )?;
6895 Ok(())
6896}
6897
6898fn staged_content_matches(conn: &Connection, project_root: &Path, path: &Path) -> Result<bool> {
6899 let Ok(source) = std::fs::read_to_string(path) else {
6900 return Ok(false);
6901 };
6902 let Ok(freshness) = collect_source_freshness(path, &source) else {
6903 return Ok(false);
6904 };
6905 let rel_path = relative_path(project_root, path);
6906 let staged_hash = conn
6907 .query_row(
6908 "SELECT content_hash FROM files WHERE path = ?1",
6909 params![rel_path],
6910 |row| row.get::<_, String>(0),
6911 )
6912 .optional()?;
6913 Ok(staged_hash.as_deref() == Some(hash_to_hex(freshness.content_hash).as_str()))
6914}
6915
6916fn delete_staged_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
6917 tx.execute(
6918 "DELETE FROM staging_ref_context
6919 WHERE ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?1)",
6920 params![rel_path],
6921 )?;
6922 delete_file_rows(tx, rel_path)
6923}
6924
6925fn prune_staged_files_not_in_inventory(conn: &mut Connection) -> Result<()> {
6926 loop {
6927 let removed = {
6928 let mut statement = conn.prepare(
6929 "SELECT path
6930 FROM files
6931 WHERE NOT EXISTS (
6932 SELECT 1 FROM staging_file_inventory inventory
6933 WHERE inventory.path = files.path
6934 )
6935 ORDER BY path
6936 LIMIT ?1",
6937 )?;
6938 let paths = statement
6939 .query_map(params![COLD_BUILD_EXTRACT_BATCH_FILES as i64], |row| {
6940 row.get::<_, String>(0)
6941 })?
6942 .collect::<std::result::Result<Vec<_>, _>>()?;
6943 paths
6944 };
6945 if removed.is_empty() {
6946 return Ok(());
6947 }
6948 let tx = conn.transaction()?;
6949 for path in removed {
6950 delete_staged_file_rows(&tx, &path)?;
6951 }
6952 tx.commit()?;
6953 }
6954}
6955
6956struct StagedFileBatch {
6957 paths: Vec<PathBuf>,
6958 last_path: String,
6959}
6960
6961fn load_staged_file_batch(
6962 conn: &Connection,
6963 project_root: &Path,
6964 after_path: &str,
6965 max_files: usize,
6966 max_bytes: u64,
6967) -> Result<Option<StagedFileBatch>> {
6968 let mut statement = conn.prepare(
6969 "SELECT path, size
6970 FROM staging_file_inventory
6971 WHERE path > ?1
6972 ORDER BY path
6973 LIMIT ?2",
6974 )?;
6975 let mut rows = statement.query(params![after_path, max_files.max(1) as i64])?;
6976 let mut paths = Vec::with_capacity(max_files.max(1));
6977 let mut last_path = String::new();
6978 let mut batch_bytes = 0u64;
6979 while let Some(row) = rows.next()? {
6980 let rel_path = row.get::<_, String>(0)?;
6981 let size = row.get::<_, i64>(1)?.max(0) as u64;
6982 if !paths.is_empty() && batch_bytes.saturating_add(size) > max_bytes {
6983 break;
6984 }
6985 batch_bytes = batch_bytes.saturating_add(size);
6986 last_path.clone_from(&rel_path);
6987 paths.push(project_root.join(rel_path));
6988 }
6989 if paths.is_empty() {
6990 Ok(None)
6991 } else {
6992 Ok(Some(StagedFileBatch { paths, last_path }))
6993 }
6994}
6995
6996fn staged_corpus_fingerprint(conn: &Connection, project_root: &Path) -> Result<String> {
6997 let mut statement = conn.prepare("SELECT path FROM staging_file_inventory ORDER BY path")?;
6998 let mut rows = statement.query([])?;
6999 let mut fingerprint = CorpusFingerprint::default();
7000 while let Some(row) = rows.next()? {
7001 let rel_path = row.get::<_, String>(0)?;
7002 fingerprint.add_path(project_root, &project_root.join(rel_path));
7003 }
7004 Ok(fingerprint.finish(project_root))
7005}
7006
7007fn load_staged_ref_window(
7008 conn: &Connection,
7009 after_rowid: u64,
7010 limit: usize,
7011) -> Result<Vec<StagedRef>> {
7012 let mut statement = conn.prepare(
7013 "SELECT refs.rowid, refs.ref_id, refs.caller_node, refs.caller_file, refs.kind,
7014 refs.short_name, refs.full_ref, refs.module_path, refs.import_kind,
7015 refs.local_name, refs.requested_name, refs.namespace_alias, refs.wildcard,
7016 refs.line, refs.byte_start, refs.byte_end, staging_ref_context.caller_symbol
7017 FROM refs
7018 LEFT JOIN staging_ref_context ON staging_ref_context.ref_id = refs.ref_id
7019 WHERE refs.status = 'staged' AND refs.rowid > ?1
7020 ORDER BY refs.rowid
7021 LIMIT ?2",
7022 )?;
7023 let rows = statement.query_map(params![after_rowid as i64, limit as i64], |row| {
7024 Ok(StagedRef {
7025 rowid: row.get::<_, i64>(0)? as u64,
7026 raw: RawRef {
7027 ref_id: row.get(1)?,
7028 caller_node: row.get(2)?,
7029 caller_file: row.get(3)?,
7030 kind: row.get(4)?,
7031 short_name: row.get(5)?,
7032 full_ref: row.get(6)?,
7033 module_path: row.get(7)?,
7034 import_kind: row.get(8)?,
7035 local_name: row.get(9)?,
7036 requested_name: row.get(10)?,
7037 namespace_alias: row.get(11)?,
7038 wildcard: row.get::<_, i64>(12)? != 0,
7039 line: row.get::<_, i64>(13)? as u32,
7040 byte_start: row.get::<_, i64>(14)? as usize,
7041 byte_end: row.get::<_, i64>(15)? as usize,
7042 caller_symbol: row.get(16)?,
7043 dependencies: BTreeSet::new(),
7044 },
7045 })
7046 })?;
7047 let mut refs = rows.collect::<std::result::Result<Vec<_>, _>>()?;
7048 drop(statement);
7049
7050 let mut dependencies = HashMap::<String, BTreeSet<String>>::new();
7051 let mut dependency_statement = conn
7052 .prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1 ORDER BY dep_file")?;
7053 for raw in refs.iter_mut().map(|entry| &mut entry.raw) {
7054 if !dependencies.contains_key(&raw.caller_file) {
7055 let rows =
7056 dependency_statement.query_map(params![raw.caller_file], |row| row.get(0))?;
7057 let values = rows.collect::<std::result::Result<BTreeSet<_>, _>>()?;
7058 dependencies.insert(raw.caller_file.clone(), values);
7059 }
7060 raw.dependencies = dependencies
7061 .get(&raw.caller_file)
7062 .cloned()
7063 .unwrap_or_default();
7064 }
7065 Ok(refs)
7066}
7067
7068fn unresolved_staged_ref(raw: RawRef) -> ResolvedRef {
7069 ResolvedRef {
7070 dependencies: raw.dependencies.clone(),
7071 raw,
7072 status: "unresolved".to_string(),
7073 target_node: None,
7074 target_file: None,
7075 target_symbol: None,
7076 edge: None,
7077 }
7078}
7079
7080fn query_count(conn: &Connection, query: &str) -> Result<u64> {
7081 conn.query_row(query, [], |row| row.get::<_, i64>(0))
7082 .map(|count| count.max(0) as u64)
7083 .map_err(Into::into)
7084}
7085
7086fn staged_failed_files(conn: &Connection) -> Result<Vec<String>> {
7087 let mut statement = conn.prepare(
7088 "SELECT DISTINCT file_path FROM backend_file_state WHERE status = 'stale' ORDER BY file_path",
7089 )?;
7090 let rows = statement.query_map([], |row| row.get(0))?;
7091 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
7092}
7093
7094fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
7095 tx.execute_batch(
7096 "DROP INDEX IF EXISTS idx_nodes_file;
7097 DROP INDEX IF EXISTS idx_nodes_name;
7098 DROP INDEX IF EXISTS idx_nodes_scoped;
7099 DROP INDEX IF EXISTS idx_refs_short_name;
7100 DROP INDEX IF EXISTS idx_refs_kind_caller_file;
7101 DROP INDEX IF EXISTS idx_refs_caller_file;
7102 DROP INDEX IF EXISTS idx_refs_caller_node_kind;
7103 DROP INDEX IF EXISTS idx_refs_target_file;
7104 DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
7105 DROP INDEX IF EXISTS idx_edges_source_kind;
7106 DROP INDEX IF EXISTS idx_edges_target_kind;
7107 DROP INDEX IF EXISTS idx_edges_target_file_symbol;
7108 DROP INDEX IF EXISTS idx_edges_ref_id;
7109 DROP INDEX IF EXISTS idx_dispatch_hints_method;
7110 DROP INDEX IF EXISTS idx_dispatch_hints_file;
7111 DROP INDEX IF EXISTS idx_backend_file_state_file;",
7112 )?;
7113 Ok(())
7114}
7115
7116fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
7117 tx.execute_batch(
7118 "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
7119 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
7120 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
7121 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
7122 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
7123 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
7124 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
7125 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
7126 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
7127 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
7128 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
7129 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
7130 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
7131 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
7132 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
7133 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
7134 )?;
7135 Ok(())
7136}
7137
7138const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
7139 ("files", "path"),
7140 ("nodes", "file_path"),
7141 ("refs", "caller_file"),
7142 ("refs", "target_file"),
7143 ("file_dependencies", "file_path"),
7144 ("file_dependencies", "dep_file"),
7145 ("edges", "target_file"),
7146 ("dispatch_hints", "file"),
7147 ("backend_file_state", "file_path"),
7148];
7149
7150fn reconcile_workspace_roots(
7163 conn: &mut Connection,
7164 project_root: &Path,
7165 allow_repair: bool,
7166) -> Result<OpenRootRepair> {
7167 let roots = stored_workspace_roots(conn)?;
7168 let current_root = project_root.display().to_string();
7169 if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
7170 return Ok(OpenRootRepair::None);
7171 }
7172
7173 if let Some(sample) = sample_absolute_data_path(conn)? {
7174 return Ok(OpenRootRepair::NeedsRebuild {
7175 previous_roots: roots,
7176 current_root,
7177 reason: format!("absolute store data path row {sample}"),
7178 });
7179 }
7180
7181 for stored_root in roots.iter() {
7182 if stored_root == ¤t_root {
7183 continue;
7184 }
7185 if Path::new(stored_root).exists() {
7186 let reason = format!(
7187 "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
7188 );
7189 return Ok(OpenRootRepair::NeedsRebuild {
7190 previous_roots: roots,
7191 current_root,
7192 reason,
7193 });
7194 }
7195 }
7196
7197 if !allow_repair {
7198 return Ok(OpenRootRepair::NeedsRebuild {
7199 previous_roots: roots,
7200 current_root,
7201 reason: "workspace root metadata requires deferred repair".to_string(),
7202 });
7203 }
7204
7205 publish_if_current(|| {
7206 let tx = conn.transaction()?;
7207 tx.execute(
7208 "UPDATE OR IGNORE backend_file_state
7209 SET workspace_root = ?1
7210 WHERE workspace_root <> ?1",
7211 params![¤t_root],
7212 )?;
7213 tx.execute(
7214 "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
7215 params![¤t_root],
7216 )?;
7217 tx.commit()?;
7218 Ok(())
7219 })?;
7220
7221 crate::slog_info!(
7222 "callgraph store re-rooted from {} to {}",
7223 roots.join(", "),
7224 current_root
7225 );
7226 Ok(OpenRootRepair::ReRooted)
7227}
7228
7229fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
7230 let mut stmt = conn.prepare(
7231 "SELECT DISTINCT workspace_root
7232 FROM backend_file_state
7233 ORDER BY workspace_root",
7234 )?;
7235 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
7236 rows.collect::<std::result::Result<Vec<_>, _>>()
7237 .map_err(Into::into)
7238}
7239
7240fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
7241 for (table, column) in STORE_DATA_PATH_COLUMNS {
7242 let sql = format!(
7243 "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
7244 );
7245 let mut stmt = conn.prepare(&sql)?;
7246 let mut rows = stmt.query([])?;
7247 while let Some(row) = rows.next()? {
7248 let value: String = row.get(0)?;
7249 if stored_path_is_absolute(&value) {
7250 return Ok(Some(format!("{table}.{column}={value}")));
7251 }
7252 }
7253 }
7254 Ok(None)
7255}
7256
7257fn stored_path_is_absolute(value: &str) -> bool {
7258 if value.is_empty() {
7259 return false;
7260 }
7261 if Path::new(value).is_absolute() || value.starts_with('/') {
7262 return true;
7263 }
7264 let bytes = value.as_bytes();
7265 if bytes.len() >= 3
7266 && bytes[1] == b':'
7267 && (bytes[2] == b'/' || bytes[2] == b'\\')
7268 && bytes[0].is_ascii_alphabetic()
7269 {
7270 return true;
7271 }
7272 value.starts_with("\\\\") || value.starts_with("//")
7273}
7274
7275fn log_root_repair_rebuild(repair: &OpenRootRepair) {
7276 if let OpenRootRepair::NeedsRebuild {
7277 previous_roots,
7278 current_root,
7279 reason,
7280 } = repair
7281 {
7282 crate::slog_info!(
7283 "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
7284 previous_roots.join(", "),
7285 current_root,
7286 reason
7287 );
7288 }
7289}
7290
7291fn now_nanos() -> u128 {
7293 SystemTime::now()
7294 .duration_since(UNIX_EPOCH)
7295 .unwrap_or(Duration::ZERO)
7296 .as_nanos()
7297}
7298
7299fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
7304 callgraph_dir.join(format!("{project_key}.current"))
7305}
7306
7307fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
7311 callgraph_dir.join(format!("{project_key}.sqlite"))
7312}
7313
7314fn generation_file_name(project_key: &str) -> String {
7318 format!(
7319 "{project_key}.g{}.{}.sqlite",
7320 now_nanos(),
7321 std::process::id()
7322 )
7323}
7324
7325fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
7327 let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
7328 let name = text.trim();
7329 if name.is_empty() {
7330 None
7331 } else {
7332 Some(name.to_string())
7333 }
7334}
7335
7336fn db_path_ready(path: &Path) -> bool {
7339 (|| -> Result<bool> {
7340 let conn = open_readonly_connection(path)?;
7341 database_ready(&conn)
7342 })()
7343 .unwrap_or(false)
7344}
7345
7346fn resolve_ready_target(
7354 callgraph_dir: &Path,
7355 project_key: &str,
7356) -> Option<(PathBuf, Option<String>)> {
7357 for _ in 0..5 {
7358 if let Some(generation) = read_pointer(callgraph_dir, project_key) {
7359 let gen_path = callgraph_dir.join(&generation);
7360 if gen_path.is_file() {
7361 return (migration_manifest_valid(callgraph_dir, &generation)
7362 && db_path_ready(&gen_path))
7363 .then_some((gen_path, Some(generation)));
7364 }
7365 std::thread::sleep(Duration::from_millis(5));
7368 continue;
7369 }
7370 let legacy = legacy_sqlite_path(callgraph_dir, project_key);
7372 return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
7373 }
7374 None
7375}
7376
7377fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
7381 let pointer = pointer_path(callgraph_dir, project_key);
7382 let tmp = callgraph_dir.join(format!(
7383 "{project_key}.current.tmp.{}.{}",
7384 std::process::id(),
7385 now_nanos()
7386 ));
7387 {
7388 use std::io::Write as _;
7389 let mut file = std::fs::File::create(&tmp)?;
7390 file.write_all(generation.as_bytes())?;
7391 file.write_all(b"\n")?;
7392 file.sync_all()?;
7393 }
7394 if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
7395 let _ = std::fs::remove_file(&tmp);
7396 return Err(error.into());
7397 }
7398 crate::fs_lock::sync_parent(&pointer);
7399 Ok(())
7400}
7401
7402#[derive(Clone, Debug)]
7403struct GenerationGcCandidate {
7404 name: String,
7405 path: PathBuf,
7406 modified: SystemTime,
7407}
7408
7409fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
7415 let temp_grace = Duration::from_secs(60);
7416 let now = SystemTime::now();
7417 let pointer_current =
7418 read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
7419 let gen_prefix = format!("{project_key}.g");
7420 let tmp_prefixes = [
7421 format!("{project_key}.g"), format!("{project_key}.current."), format!("{project_key}.sqlite.tmp."), ];
7425 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
7426 return;
7427 };
7428 let mut gens: Vec<GenerationGcCandidate> = Vec::new();
7429 for entry in entries.flatten() {
7430 let name = entry.file_name();
7431 let name = name.to_string_lossy().to_string();
7432 let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
7433 let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
7434
7435 if name.contains(".tmp.") {
7437 if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
7438 let _ = std::fs::remove_file(entry.path());
7439 }
7440 continue;
7441 }
7442
7443 if name == format!("{project_key}.sqlite") {
7446 remove_sqlite_file_set(&entry.path());
7447 continue;
7448 }
7449
7450 if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
7451 gens.push(GenerationGcCandidate {
7452 name,
7453 path: entry.path(),
7454 modified: mtime,
7455 });
7456 }
7457 }
7458
7459 let mut superseded = gens
7460 .iter()
7461 .filter(|generation| generation.name != pointer_current)
7462 .collect::<Vec<_>>();
7463 superseded.sort_by(|left, right| {
7464 right
7465 .modified
7466 .cmp(&left.modified)
7467 .then_with(|| right.name.cmp(&left.name))
7468 });
7469 let previous = superseded.first().map(|generation| generation.name.clone());
7470
7471 for generation in gens {
7472 let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
7473 if generation.name == pointer_current
7474 || Some(generation.name.as_str()) == previous.as_deref()
7475 {
7476 continue;
7477 }
7478
7479 let age = now
7480 .duration_since(generation.modified)
7481 .unwrap_or(Duration::ZERO);
7482 if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
7483 continue;
7484 }
7485
7486 remove_sqlite_file_set(&generation.path);
7487 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
7488 let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
7489 callgraph_dir,
7490 &generation.name,
7491 ));
7492 }
7493}
7494
7495fn remove_sqlite_file_set(path: &Path) {
7496 let _ = std::fs::remove_file(path);
7497 remove_sqlite_sidecars(path);
7498}
7499
7500fn remove_sqlite_sidecars(path: &Path) {
7501 let path_text = path.to_string_lossy();
7502 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
7503 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
7504 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
7505}
7506
7507const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
7521
7522fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
7534 sweep_orphaned_build_temps(callgraph_dir);
7535 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
7536 return;
7537 };
7538 let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
7539
7540 if let Ok(entries) = std::fs::read_dir(storage_root.join(domain)) {
7542 for entry in entries.flatten() {
7543 if entry.path().is_dir() {
7544 sweep_orphaned_build_temps(&entry.path());
7545 }
7546 }
7547 }
7548
7549 if let Ok(entries) = std::fs::read_dir(&storage_root) {
7551 for entry in entries.flatten() {
7552 let legacy_dir = entry.path().join(domain);
7553 if legacy_dir.is_dir() {
7554 sweep_orphaned_build_temps(&legacy_dir);
7555 }
7556 }
7557 }
7558}
7559
7560fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
7563 sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
7564}
7565
7566fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
7569 let now = SystemTime::now();
7570 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
7571 return;
7572 };
7573 let mut removed_any = false;
7574 for entry in entries.flatten() {
7575 let name = entry.file_name().to_string_lossy().to_string();
7576 if !name.contains(".sqlite.tmp.") {
7582 continue;
7583 }
7584 let mtime = entry
7585 .metadata()
7586 .and_then(|meta| meta.modified())
7587 .unwrap_or(now);
7588 if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
7589 continue;
7590 }
7591 match std::fs::remove_file(entry.path()) {
7597 Ok(()) => removed_any = true,
7598 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
7599 Err(_) => {}
7600 }
7601 }
7602 if removed_any {
7603 crate::fs_lock::sync_parent(callgraph_dir);
7604 }
7605}
7606
7607fn build_pool_size() -> usize {
7615 std::thread::available_parallelism()
7616 .map(|parallelism| parallelism.get())
7617 .unwrap_or(1)
7618 .div_ceil(2)
7619 .clamp(1, 8)
7620}
7621
7622fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
7623 let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
7624 Ok(extract) => Ok(extract),
7625 Err(error) => {
7626 let abs_path =
7627 normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
7628 let rel_path = relative_path(project_root, &abs_path);
7629 let freshness = cache_freshness::collect(&abs_path).ok();
7630 log::debug!(
7631 "callgraph store: skipping {} during cold build: {}",
7632 abs_path.display(),
7633 error
7634 );
7635 Err(ExtractFailure {
7636 rel_path,
7637 freshness,
7638 })
7639 }
7640 };
7641
7642 let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
7643 files.par_iter().map(extract_one).collect()
7644 };
7645
7646 let results = match rayon::ThreadPoolBuilder::new()
7649 .num_threads(build_pool_size())
7650 .thread_name(|index| format!("aft-callgraph-build-{index}"))
7651 .stack_size(8 * 1024 * 1024)
7652 .build()
7653 {
7654 Ok(pool) => pool.install(run),
7655 Err(error) => {
7656 log::warn!(
7657 "callgraph store: bounded build pool unavailable ({error}); using global pool"
7658 );
7659 run()
7660 }
7661 };
7662
7663 let mut extracts = Vec::new();
7664 let mut failures = Vec::new();
7665 for result in results {
7666 match result {
7667 Ok(extract) => extracts.push(extract),
7668 Err(failure) => failures.push(failure),
7669 }
7670 }
7671 BuildExtractsResult { extracts, failures }
7672}
7673
7674fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
7675 let metadata = std::fs::metadata(path)?;
7676 let size = metadata.len();
7677 let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
7678 cache_freshness::zero_hash()
7679 } else if source.len() as u64 == size {
7680 cache_freshness::hash_bytes(source.as_bytes())
7681 } else {
7682 cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
7683 };
7684 Ok(FileFreshness {
7685 mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
7686 size,
7687 content_hash,
7688 })
7689}
7690
7691fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
7692 let abs_path = normalize_file_path(project_root, path)?;
7693 let rel_path = relative_path(project_root, &abs_path);
7694 let source = std::fs::read_to_string(&abs_path)?;
7695 let freshness = collect_source_freshness(&abs_path, &source)?;
7696 let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
7697 let lang = data.lang;
7698 if lang == LangId::Rust {
7699 extend_rust_imports_with_nested_uses(&source, &mut data);
7700 }
7701 let mut nodes = build_node_records(&rel_path, &source, &data)?;
7702 let node_by_scoped: HashMap<String, String> = nodes
7703 .iter()
7704 .map(|node| (node.scoped_name.clone(), node.id.clone()))
7705 .collect();
7706 let import_dependencies =
7707 import_dependencies(project_root, &abs_path, &data.import_block.imports);
7708 let line_index = LineIndex::new(&source);
7709 let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
7710 let rust_reexports = if lang == LangId::Rust {
7711 collect_rust_pub_use_reexport_refs(
7712 project_root,
7713 &abs_path,
7714 &rel_path,
7715 &data.import_block.imports,
7716 &line_index,
7717 )
7718 } else {
7719 ReexportRefs {
7720 raw_refs: Vec::new(),
7721 surface_parts: Vec::new(),
7722 }
7723 };
7724 let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
7725 let mut raw_refs = Vec::new();
7726 raw_refs.extend(build_call_refs(
7727 &rel_path,
7728 &data,
7729 &node_by_scoped,
7730 &import_dependencies,
7731 ));
7732 raw_refs.extend(build_value_ref_refs(
7733 &rel_path,
7734 &data,
7735 &node_by_scoped,
7736 &import_dependencies,
7737 ));
7738 raw_refs.extend(build_import_refs(
7739 project_root,
7740 &abs_path,
7741 &rel_path,
7742 &data.import_block.imports,
7743 &line_index,
7744 ));
7745 let mut surface_parts = reexports.surface_parts;
7746 surface_parts.extend(rust_reexports.surface_parts);
7747 surface_parts.extend(source_less_exports.surface_parts);
7748 raw_refs.extend(reexports.raw_refs);
7749 raw_refs.extend(rust_reexports.raw_refs);
7750 raw_refs.extend(source_less_exports.raw_refs);
7751 let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
7752 let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
7753
7754 Ok(FileExtract {
7755 rel_path,
7756 freshness,
7757 lang,
7758 data,
7759 nodes,
7760 raw_refs,
7761 dispatch_hints,
7762 surface_fingerprint,
7763 })
7764}
7765
7766fn build_node_records(
7767 rel_path: &str,
7768 source: &str,
7769 data: &FileCallData,
7770) -> Result<Vec<NodeRecord>> {
7771 let mut records = Vec::new();
7772 let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
7773 let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
7774 metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
7775
7776 for (scoped_name, meta) in metadata {
7777 let name = unqualified_name(scoped_name).to_string();
7778 let range = selection_range(source, scoped_name, &name, &meta.range);
7779 let range_key = (
7780 range.start_line,
7781 range.start_col,
7782 range.end_line,
7783 range.end_col,
7784 );
7785 let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
7786 let range_ordinal = *ordinal;
7787 *ordinal += 1;
7788 let id = node_id(rel_path, &range, range_ordinal, scoped_name);
7789 let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
7790 let is_default_export = data
7791 .default_export_symbol
7792 .as_deref()
7793 .map(|default| default == scoped_name || default == name)
7794 .unwrap_or(false);
7795 records.push(NodeRecord {
7796 id,
7797 file_path: rel_path.to_string(),
7798 name: name.clone(),
7799 scoped_name: scoped_name.clone(),
7800 kind: symbol_kind_label(&meta.kind).to_string(),
7801 range,
7802 range_ordinal,
7803 signature: meta.signature.clone(),
7804 exported,
7805 is_default_export,
7806 is_type_like: is_type_like(&meta.kind),
7807 is_callgraph_entry_point: meta.entry_point_attribute.is_some()
7808 || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
7809 });
7810 }
7811
7812 Ok(records)
7813}
7814
7815fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
7816 if scoped_name == TOP_LEVEL_SYMBOL {
7817 return Range {
7818 start_line: 0,
7819 start_col: 0,
7820 end_line: 0,
7821 end_col: 0,
7822 };
7823 }
7824 let Some(line) = source.lines().nth(fallback.start_line as usize) else {
7825 return fallback.clone();
7826 };
7827 let start_col = fallback.start_col as usize;
7828 let search_start = start_col.min(line.len());
7829 if let Some(offset) = line[search_start..].find(name) {
7830 let col = search_start + offset;
7831 return Range {
7832 start_line: fallback.start_line,
7833 start_col: col as u32,
7834 end_line: fallback.start_line,
7835 end_col: (col + name.len()) as u32,
7836 };
7837 }
7838 if let Some(offset) = line.find(name) {
7839 return Range {
7840 start_line: fallback.start_line,
7841 start_col: offset as u32,
7842 end_line: fallback.start_line,
7843 end_col: (offset + name.len()) as u32,
7844 };
7845 }
7846 Range {
7847 start_line: fallback.start_line,
7848 start_col: fallback.start_col,
7849 end_line: fallback.start_line,
7850 end_col: fallback.start_col.saturating_add(name.len() as u32),
7851 }
7852}
7853
7854fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
7855 if scoped_name == TOP_LEVEL_SYMBOL {
7856 return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
7857 }
7858 let input = format!(
7859 "{rel_path}:{}:{}:{}:{}:{ordinal}",
7860 range.start_line, range.start_col, range.end_line, range.end_col
7861 );
7862 format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
7863}
7864
7865fn build_call_refs(
7866 rel_path: &str,
7867 data: &FileCallData,
7868 node_by_scoped: &HashMap<String, String>,
7869 import_dependencies: &BTreeSet<String>,
7870) -> Vec<RawRef> {
7871 build_callable_refs(
7872 rel_path,
7873 &data.calls_by_symbol,
7874 node_by_scoped,
7875 import_dependencies,
7876 "call",
7877 )
7878}
7879
7880fn build_value_ref_refs(
7881 rel_path: &str,
7882 data: &FileCallData,
7883 node_by_scoped: &HashMap<String, String>,
7884 import_dependencies: &BTreeSet<String>,
7885) -> Vec<RawRef> {
7886 build_callable_refs(
7887 rel_path,
7888 &data.value_refs_by_symbol,
7889 node_by_scoped,
7890 import_dependencies,
7891 "value_ref",
7892 )
7893}
7894
7895fn build_callable_refs(
7896 rel_path: &str,
7897 sites_by_symbol: &HashMap<String, Vec<callgraph::CallSite>>,
7898 node_by_scoped: &HashMap<String, String>,
7899 import_dependencies: &BTreeSet<String>,
7900 kind: &str,
7901) -> Vec<RawRef> {
7902 let mut refs = Vec::new();
7903 let mut ordinal = 0usize;
7904 let mut symbols: Vec<_> = sites_by_symbol.iter().collect();
7905 symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
7906 for (caller_symbol, call_sites) in symbols {
7907 let caller_node = node_by_scoped.get(caller_symbol).cloned();
7908 for call_site in call_sites {
7909 ordinal += 1;
7910 let ref_id = ref_id(&[
7911 rel_path,
7912 kind,
7913 caller_symbol,
7914 &call_site.line.to_string(),
7915 &call_site.byte_start.to_string(),
7916 &call_site.byte_end.to_string(),
7917 &call_site.full_callee,
7918 &ordinal.to_string(),
7919 ]);
7920 refs.push(RawRef {
7921 ref_id,
7922 caller_node: caller_node.clone(),
7923 caller_symbol: Some(caller_symbol.clone()),
7924 caller_file: rel_path.to_string(),
7925 kind: kind.to_string(),
7926 short_name: Some(call_site.callee_name.clone()),
7927 full_ref: Some(call_site.full_callee.clone()),
7928 module_path: None,
7929 import_kind: None,
7930 local_name: Some(call_site.callee_name.clone()),
7931 requested_name: Some(call_site.callee_name.clone()),
7932 namespace_alias: namespace_alias(&call_site.full_callee),
7933 wildcard: false,
7934 line: call_site.line,
7935 byte_start: call_site.byte_start,
7936 byte_end: call_site.byte_end,
7937 dependencies: import_dependencies.clone(),
7938 });
7939 }
7940 }
7941 refs
7942}
7943
7944fn build_import_refs(
7945 project_root: &Path,
7946 abs_path: &Path,
7947 rel_path: &str,
7948 imports: &[ImportStatement],
7949 line_index: &LineIndex,
7950) -> Vec<RawRef> {
7951 let mut refs = Vec::new();
7952 for (index, import) in imports.iter().enumerate() {
7953 let import_kind = import_kind_label(import.kind).to_string();
7954 let local_name = import_local_names(import).join(",");
7955 let requested_name = import_requested_names(import).join(",");
7956 let ref_id = ref_id(&[
7957 rel_path,
7958 "import",
7959 &import.byte_range.start.to_string(),
7960 &import.byte_range.end.to_string(),
7961 &import.module_path,
7962 &index.to_string(),
7963 ]);
7964 refs.push(RawRef {
7965 ref_id,
7966 caller_node: None,
7967 caller_symbol: None,
7968 caller_file: rel_path.to_string(),
7969 kind: "import".to_string(),
7970 short_name: None,
7971 full_ref: Some(import.raw_text.clone()),
7972 module_path: Some(import.module_path.clone()),
7973 import_kind: Some(import_kind),
7974 local_name: empty_to_none(local_name),
7975 requested_name: empty_to_none(requested_name),
7976 namespace_alias: import.namespace_import.clone(),
7977 wildcard: import_is_wildcard(import),
7978 line: line_index.byte_to_line(import.byte_range.start),
7979 byte_start: import.byte_range.start,
7980 byte_end: import.byte_range.end,
7981 dependencies: module_dependencies(project_root, abs_path, &import.module_path),
7982 });
7983 }
7984 refs
7985}
7986
7987fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
7988 let grammar = grammar_for(LangId::Rust);
7989 let mut parser = Parser::new();
7990 if parser.set_language(&grammar).is_err() {
7991 return;
7992 }
7993 let Some(tree) = parser.parse(source, None) else {
7994 return;
7995 };
7996
7997 let mut seen = data
7998 .import_block
7999 .imports
8000 .iter()
8001 .map(|import| (import.byte_range.start, import.byte_range.end))
8002 .collect::<HashSet<_>>();
8003 let mut nested_imports = Vec::new();
8004 collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
8005 if nested_imports.is_empty() {
8006 return;
8007 }
8008
8009 data.import_block.imports.extend(nested_imports);
8010 data.import_block
8011 .imports
8012 .sort_by_key(|import| import.byte_range.start);
8013 data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
8014}
8015
8016fn collect_rust_use_imports(
8017 source: &str,
8018 node: Node<'_>,
8019 seen: &mut HashSet<(usize, usize)>,
8020 imports: &mut Vec<ImportStatement>,
8021) {
8022 if node.kind() == "use_declaration" {
8023 let range = node.byte_range();
8024 if seen.insert((range.start, range.end)) {
8025 if let Some(import) = rust_import_from_use_node(source, node) {
8026 imports.push(import);
8027 }
8028 }
8029 }
8030
8031 let mut cursor = node.walk();
8032 if !cursor.goto_first_child() {
8033 return;
8034 }
8035 loop {
8036 collect_rust_use_imports(source, cursor.node(), seen, imports);
8037 if !cursor.goto_next_sibling() {
8038 break;
8039 }
8040 }
8041}
8042
8043fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
8044 let raw_text = source[node.byte_range()].to_string();
8045 let body = rust_use_body(&raw_text)?.to_string();
8046 let visibility = rust_use_visibility(&raw_text);
8047 let names = rust_use_list_names(&body);
8048 let group = classify_rust_import_group(&body);
8049 let byte_range = node.byte_range();
8050
8051 Some(ImportStatement {
8052 module_path: body,
8053 names: names.clone(),
8054 default_import: visibility.clone(),
8055 namespace_import: None,
8056 kind: ImportKind::Value,
8057 group,
8058 byte_range,
8059 raw_text,
8060 form: ImportForm::RustUse {
8061 visibility,
8062 named: names,
8063 },
8064 })
8065}
8066
8067fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
8068 let start = imports.iter().map(|import| import.byte_range.start).min()?;
8069 let end = imports.iter().map(|import| import.byte_range.end).max()?;
8070 Some(start..end)
8071}
8072
8073fn rust_use_visibility(raw_text: &str) -> Option<String> {
8074 let use_pos = raw_text.find("use ")?;
8075 let prefix = raw_text[..use_pos].trim();
8076 if prefix.is_empty() {
8077 None
8078 } else {
8079 Some(prefix.to_string())
8080 }
8081}
8082
8083fn rust_use_body(raw_text: &str) -> Option<&str> {
8084 let use_pos = raw_text.find("use ")?;
8085 Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
8086}
8087
8088fn rust_use_list_names(body: &str) -> Vec<String> {
8089 let Some(open) = body.find("::{") else {
8090 return Vec::new();
8091 };
8092 let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
8093 return Vec::new();
8094 };
8095 body[open + 3..close]
8096 .split(',')
8097 .filter_map(|spec| {
8098 let spec = spec.trim();
8099 if spec.is_empty() {
8100 None
8101 } else {
8102 Some(spec.to_string())
8103 }
8104 })
8105 .collect()
8106}
8107
8108fn classify_rust_import_group(body: &str) -> ImportGroup {
8109 let first = body
8110 .split("::")
8111 .next()
8112 .unwrap_or(body)
8113 .split_whitespace()
8114 .next()
8115 .unwrap_or(body);
8116 match first.trim() {
8117 "std" | "core" | "alloc" => ImportGroup::Stdlib,
8118 "crate" | "self" | "super" => ImportGroup::Internal,
8119 _ => ImportGroup::External,
8120 }
8121}
8122
8123#[derive(Debug, Clone)]
8124struct ReexportRefs {
8125 raw_refs: Vec<RawRef>,
8126 surface_parts: Vec<String>,
8127}
8128
8129fn collect_reexport_refs(
8130 project_root: &Path,
8131 abs_path: &Path,
8132 rel_path: &str,
8133 source: &str,
8134) -> ReexportRefs {
8135 let mut raw_refs = Vec::new();
8136 let mut surface_parts = Vec::new();
8137 let mut search_start = 0usize;
8138 let mut ordinal = 0usize;
8139 while let Some(export_offset) = source[search_start..].find("export") {
8140 let start = search_start + export_offset;
8141 let Some(statement_end_offset) = source[start..].find(';') else {
8142 break;
8143 };
8144 let end = start + statement_end_offset + 1;
8145 let statement = &source[start..end];
8146 search_start = end;
8147 if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
8148 continue;
8149 }
8150 let Some(module_path) = quoted_module_path(statement) else {
8151 continue;
8152 };
8153 ordinal += 1;
8154 let wildcard = statement.contains('*');
8155 let line = source[..start]
8156 .bytes()
8157 .filter(|byte| *byte == b'\n')
8158 .count() as u32
8159 + 1;
8160 let ref_id = ref_id(&[
8161 rel_path,
8162 "reexport",
8163 &start.to_string(),
8164 &end.to_string(),
8165 &module_path,
8166 &ordinal.to_string(),
8167 ]);
8168 surface_parts.push(format!("reexport\t{statement}"));
8169 raw_refs.push(RawRef {
8170 ref_id,
8171 caller_node: None,
8172 caller_symbol: None,
8173 caller_file: rel_path.to_string(),
8174 kind: "reexport".to_string(),
8175 short_name: None,
8176 full_ref: Some(statement.to_string()),
8177 module_path: Some(module_path.clone()),
8178 import_kind: Some("reexport".to_string()),
8179 local_name: None,
8180 requested_name: None,
8181 namespace_alias: None,
8182 wildcard,
8183 line,
8184 byte_start: start,
8185 byte_end: end,
8186 dependencies: module_dependencies(project_root, abs_path, &module_path),
8187 });
8188 }
8189 ReexportRefs {
8190 raw_refs,
8191 surface_parts,
8192 }
8193}
8194
8195fn collect_rust_pub_use_reexport_refs(
8196 project_root: &Path,
8197 abs_path: &Path,
8198 rel_path: &str,
8199 imports: &[ImportStatement],
8200 line_index: &LineIndex,
8201) -> ReexportRefs {
8202 let mut raw_refs = Vec::new();
8203 let mut surface_parts = Vec::new();
8204 let mut ordinal = 0usize;
8205
8206 for import in imports {
8207 let Some(visibility) = &import.default_import else {
8208 continue;
8209 };
8210 if !visibility.starts_with("pub") {
8211 continue;
8212 }
8213 let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
8214 continue;
8215 };
8216 ordinal += 1;
8217 let ref_id = ref_id(&[
8218 rel_path,
8219 "rust_reexport",
8220 &import.byte_range.start.to_string(),
8221 &import.byte_range.end.to_string(),
8222 &module_path,
8223 &ordinal.to_string(),
8224 ]);
8225 surface_parts.push(format!("reexport\t{}", import.raw_text));
8226 raw_refs.push(RawRef {
8227 ref_id,
8228 caller_node: None,
8229 caller_symbol: None,
8230 caller_file: rel_path.to_string(),
8231 kind: "reexport".to_string(),
8232 short_name: None,
8233 full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
8234 module_path: Some(module_path.clone()),
8235 import_kind: Some("reexport".to_string()),
8236 local_name: None,
8237 requested_name: None,
8238 namespace_alias: None,
8239 wildcard,
8240 line: line_index.byte_to_line(import.byte_range.start),
8241 byte_start: import.byte_range.start,
8242 byte_end: import.byte_range.end,
8243 dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
8244 });
8245 }
8246
8247 ReexportRefs {
8248 raw_refs,
8249 surface_parts,
8250 }
8251}
8252
8253fn rust_pub_use_reexport_parts(
8254 import: &ImportStatement,
8255) -> Option<(String, HashMap<String, String>, bool)> {
8256 let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
8257 let body = body.trim();
8258 if let Some(module_path) = body.strip_suffix("::*") {
8259 return Some((module_path.trim().to_string(), HashMap::new(), true));
8260 }
8261
8262 if let Some(brace_start) = body.find("::{") {
8263 let module_path = body[..brace_start].trim().to_string();
8264 let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
8265 if names.is_empty() {
8266 return None;
8267 }
8268 return Some((module_path, names, false));
8269 }
8270
8271 let (module_path, spec) = body.rsplit_once("::")?;
8272 let names = rust_reexport_names_from_specs(spec);
8273 if names.is_empty() {
8274 return None;
8275 }
8276 Some((module_path.trim().to_string(), names, false))
8277}
8278
8279fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
8280 let mut names = HashMap::new();
8281 for spec in specs.split(',') {
8282 let spec = spec.trim();
8283 if spec.is_empty() || spec == "self" {
8284 continue;
8285 }
8286 if let Some((source, local)) = spec.split_once(" as ") {
8287 let source = source.trim();
8288 let local = local.trim();
8289 if !source.is_empty() && !local.is_empty() && source != "self" {
8290 names.insert(local.to_string(), source.to_string());
8291 }
8292 } else {
8293 names.insert(spec.to_string(), spec.to_string());
8294 }
8295 }
8296 names
8297}
8298
8299fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
8300 if named.is_empty() {
8301 return fallback.to_string();
8302 }
8303 let mut specs = named
8304 .iter()
8305 .map(|(local, source)| {
8306 if local == source {
8307 source.clone()
8308 } else {
8309 format!("{source} as {local}")
8310 }
8311 })
8312 .collect::<Vec<_>>();
8313 specs.sort();
8314 format!("pub use {{{}}};", specs.join(", "))
8315}
8316
8317fn quoted_module_path(statement: &str) -> Option<String> {
8318 let quote = match (statement.find('\''), statement.find('"')) {
8319 (Some(single), Some(double)) if single < double => '\'',
8320 (Some(_), Some(_)) => '"',
8321 (Some(_), None) => '\'',
8322 (None, Some(_)) => '"',
8323 (None, None) => return None,
8324 };
8325 let start = statement.find(quote)? + 1;
8326 let end = statement[start..].find(quote)? + start;
8327 Some(statement[start..end].to_string())
8328}
8329
8330#[derive(Debug, Clone)]
8331struct SourceLessExportRefs {
8332 raw_refs: Vec<RawRef>,
8333 surface_parts: Vec<String>,
8334}
8335
8336fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
8337 let mut raw_refs = Vec::new();
8338 let mut surface_parts = Vec::new();
8339 let mut search_start = 0usize;
8340 let mut ordinal = 0usize;
8341 while let Some(export_offset) = source[search_start..].find("export") {
8342 let start = search_start + export_offset;
8343 let Some(statement_end_offset) = source[start..].find(';') else {
8344 break;
8345 };
8346 let end = start + statement_end_offset + 1;
8347 let statement = &source[start..end];
8348 search_start = end;
8349 if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
8350 continue;
8351 }
8352 let aliases = parse_reexport_names(statement);
8353 if aliases.is_empty() {
8354 continue;
8355 }
8356 let line = source[..start]
8357 .bytes()
8358 .filter(|byte| *byte == b'\n')
8359 .count() as u32
8360 + 1;
8361 for (exported, source_symbol) in aliases {
8362 ordinal += 1;
8363 let ref_id = ref_id(&[
8364 rel_path,
8365 "export_alias",
8366 &start.to_string(),
8367 &end.to_string(),
8368 &exported,
8369 &source_symbol,
8370 &ordinal.to_string(),
8371 ]);
8372 surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
8373 raw_refs.push(RawRef {
8374 ref_id,
8375 caller_node: None,
8376 caller_symbol: None,
8377 caller_file: rel_path.to_string(),
8378 kind: "export_alias".to_string(),
8379 short_name: None,
8380 full_ref: Some(statement.to_string()),
8381 module_path: None,
8382 import_kind: Some("export_alias".to_string()),
8383 local_name: Some(exported),
8384 requested_name: Some(source_symbol),
8385 namespace_alias: None,
8386 wildcard: false,
8387 line,
8388 byte_start: start,
8389 byte_end: end,
8390 dependencies: BTreeSet::new(),
8391 });
8392 }
8393 }
8394 SourceLessExportRefs {
8395 raw_refs,
8396 surface_parts,
8397 }
8398}
8399
8400fn build_dispatch_hints(
8401 rel_path: &str,
8402 data: &FileCallData,
8403 node_by_scoped: &HashMap<String, String>,
8404) -> Vec<DispatchHint> {
8405 let mut hints = Vec::new();
8406 let mut ordinal = 0usize;
8407 for (caller_symbol, call_sites) in &data.calls_by_symbol {
8408 let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
8409 continue;
8410 };
8411 for call_site in call_sites {
8412 if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
8413 continue;
8414 }
8415 ordinal += 1;
8416 hints.push(DispatchHint {
8417 id: ref_id(&[
8418 rel_path,
8419 "dispatch",
8420 caller_symbol,
8421 &call_site.line.to_string(),
8422 &call_site.byte_start.to_string(),
8423 &call_site.byte_end.to_string(),
8424 &ordinal.to_string(),
8425 ]),
8426 method_name: call_site.callee_name.clone(),
8427 caller_node: caller_node.clone(),
8428 file: rel_path.to_string(),
8429 line: call_site.line,
8430 byte_start: call_site.byte_start,
8431 byte_end: call_site.byte_end,
8432 });
8433 }
8434 }
8435 hints
8436}
8437
8438fn surface_fingerprint(
8439 nodes: &mut [NodeRecord],
8440 data: &FileCallData,
8441 reexport_parts: &[String],
8442) -> String {
8443 nodes.sort_by(|left, right| {
8444 (left.file_path.as_str(), left.scoped_name.as_str())
8445 .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
8446 });
8447 let mut parts = Vec::new();
8448 for node in nodes.iter() {
8449 parts.push(format!(
8450 "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
8451 node.scoped_name,
8452 node.name,
8453 node.kind,
8454 node.exported,
8455 node.range.start_line,
8456 node.range.start_col,
8457 node.range.end_line,
8458 node.range.end_col,
8459 node.range_ordinal,
8460 node.signature.as_deref().unwrap_or("")
8461 ));
8462 }
8463 let mut exports = data.exported_symbols.clone();
8464 exports.sort();
8465 for export in exports {
8466 parts.push(format!("export\t{export}"));
8467 }
8468 if let Some(default_export) = &data.default_export_symbol {
8469 parts.push(format!("default\t{default_export}"));
8470 }
8471 let mut imports: Vec<String> = data
8472 .import_block
8473 .imports
8474 .iter()
8475 .map(|import| {
8476 format!(
8477 "import\t{}\t{:?}\t{}",
8478 import.module_path, import.form, import.raw_text
8479 )
8480 })
8481 .collect();
8482 imports.sort();
8483 parts.extend(imports);
8484 parts.extend(reexport_parts.iter().cloned());
8485 hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
8486}
8487
8488fn resolve_ref<I: ResolverIndex>(raw: RawRef, index: &I) -> Result<ResolvedRef> {
8489 if !matches!(raw.kind.as_str(), "call" | "value_ref") {
8490 return Ok(ResolvedRef {
8491 dependencies: raw.dependencies.clone(),
8492 raw,
8493 status: "unresolved".to_string(),
8494 target_node: None,
8495 target_file: None,
8496 target_symbol: None,
8497 edge: None,
8498 });
8499 }
8500
8501 let caller_file = raw.caller_file.clone();
8502 let caller_data =
8503 index
8504 .caller_data(&caller_file)
8505 .ok_or_else(|| CallGraphStoreError::MissingCallerData {
8506 file: caller_file.clone(),
8507 })?;
8508 let full_ref = raw.full_ref.as_deref().unwrap_or_default();
8509 let short_name = raw.short_name.as_deref().unwrap_or_default();
8510 let mut dependencies = raw.dependencies.clone();
8511
8512 let resolved = match index.lang_for(&caller_file) {
8513 Some(LangId::Rust) => {
8514 resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
8515 }
8516 Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
8517 resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
8518 }
8519 _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
8520 };
8521
8522 let Some((status, target_file, target_symbol)) = resolved else {
8523 return Ok(ResolvedRef {
8524 raw,
8525 status: "unresolved".to_string(),
8526 target_node: None,
8527 target_file: None,
8528 target_symbol: None,
8529 dependencies,
8530 edge: None,
8531 });
8532 };
8533
8534 dependencies.insert(target_file.clone());
8535 let target_node = index.node_for_symbol(&target_file, &target_symbol);
8536 if raw.kind == "value_ref"
8537 && !target_node
8538 .as_deref()
8539 .is_some_and(|node_id| index.node_is_callable(&target_file, node_id))
8540 {
8541 return Ok(ResolvedRef {
8542 raw,
8543 status: "unresolved".to_string(),
8544 target_node: None,
8545 target_file: None,
8546 target_symbol: None,
8547 dependencies,
8548 edge: None,
8549 });
8550 }
8551 let source_node = raw.caller_node.clone();
8552 let edge = if let Some(source_node) = source_node {
8553 if target_file == caller_file
8554 && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
8555 {
8556 None
8557 } else {
8558 Some(EdgeRecord {
8559 edge_id: ref_id(&[&raw.ref_id, "edge"]),
8560 source_node,
8561 target_node: target_node.clone(),
8562 target_file: target_file.clone(),
8563 target_symbol: target_symbol.clone(),
8564 kind: raw.kind.clone(),
8565 line: raw.line,
8566 })
8567 }
8568 } else {
8569 None
8570 };
8571
8572 Ok(ResolvedRef {
8573 raw,
8574 status,
8575 target_node,
8576 target_file: Some(target_file),
8577 target_symbol: Some(target_symbol),
8578 dependencies,
8579 edge,
8580 })
8581}
8582
8583fn resolve_js_ts_target<I: ResolverIndex>(
8584 index: &I,
8585 caller_file: &str,
8586 full_ref: &str,
8587 short_name: &str,
8588 caller_data: &FileCallData,
8589) -> Option<(String, String, String)> {
8590 if let Some((namespace, member)) = full_ref.split_once('.') {
8591 for import in &caller_data.import_block.imports {
8592 if import.namespace_import.as_deref() == Some(namespace) {
8593 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
8594 if let Some((file, symbol)) =
8595 resolve_exported_symbol(index, &target_file, member, 0)
8596 {
8597 return Some(("resolved".to_string(), file, symbol));
8598 }
8599 }
8600 }
8601 }
8602 }
8603
8604 for import in &caller_data.import_block.imports {
8605 for spec in &import.names {
8606 if crate::imports::specifier_local_name(spec) == short_name {
8607 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
8608 let requested = crate::imports::specifier_imported_name(spec);
8609 let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
8610 .unwrap_or_else(|| (target_file, requested.to_string()));
8611 return Some(("resolved".to_string(), file, symbol));
8612 }
8613 }
8614 }
8615
8616 if import.default_import.as_deref() == Some(short_name) {
8617 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
8618 let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
8619 .or_else(|| {
8620 index
8621 .default_export(&target_file)
8622 .map(|symbol| (target_file.clone(), symbol))
8623 })
8624 .unwrap_or_else(|| {
8625 let file_name = Path::new(&target_file)
8626 .file_name()
8627 .and_then(|name| name.to_str())
8628 .unwrap_or("unknown")
8629 .to_string();
8630 (target_file, format!("<default:{file_name}>"))
8631 });
8632 return Some(("resolved".to_string(), file, symbol));
8633 }
8634 }
8635 }
8636
8637 for import in &caller_data.import_block.imports {
8638 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
8639 if index.has_export(&target_file, short_name) {
8640 return Some(("resolved".to_string(), target_file, short_name.to_string()));
8641 }
8642 }
8643 }
8644
8645 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
8646}
8647
8648fn resolve_exported_symbol<I: ResolverIndex>(
8649 index: &I,
8650 file: &str,
8651 requested: &str,
8652 depth: usize,
8653) -> Option<(String, String)> {
8654 let mut visited = std::collections::HashMap::new();
8655 resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
8656}
8657
8658fn resolve_exported_symbol_inner<I: ResolverIndex>(
8667 index: &I,
8668 file: &str,
8669 requested: &str,
8670 depth: usize,
8671 visited: &mut std::collections::HashMap<(String, String), usize>,
8672) -> Option<(String, String)> {
8673 if depth > 16 {
8674 return None;
8675 }
8676 if requested != "default" {
8677 if let Some(source_symbol) = index.export_alias(file, requested) {
8678 return Some((file.to_string(), source_symbol));
8679 }
8680 if index.has_export(file, requested) {
8681 return Some((file.to_string(), requested.to_string()));
8682 }
8683 } else if let Some(default) = index.default_export(file) {
8684 return Some((file.to_string(), default));
8685 }
8686
8687 match visited.entry((file.to_string(), requested.to_string())) {
8691 std::collections::hash_map::Entry::Occupied(mut seen) => {
8692 if *seen.get() <= depth {
8693 return None;
8694 }
8695 seen.insert(depth);
8696 }
8697 std::collections::hash_map::Entry::Vacant(slot) => {
8698 slot.insert(depth);
8699 }
8700 }
8701
8702 for reexport in index.reexports_for(file) {
8703 let mut next_requested = requested.to_string();
8704 let matches = if reexport.wildcard {
8705 true
8706 } else if let Some(source_name) = reexport.named.get(requested) {
8707 next_requested = source_name.clone();
8708 true
8709 } else {
8710 false
8711 };
8712 if !matches {
8713 continue;
8714 }
8715 if let Some(target_file) = &reexport.target_file {
8716 if let Some(target) = resolve_exported_symbol_inner(
8717 index,
8718 target_file,
8719 &next_requested,
8720 depth + 1,
8721 visited,
8722 ) {
8723 return Some(target);
8724 }
8725 }
8726 }
8727 None
8728}
8729
8730fn resolve_rust_target<I: ResolverIndex>(
8731 index: &I,
8732 caller_file: &str,
8733 full_ref: &str,
8734 short_name: &str,
8735 caller_data: &FileCallData,
8736 raw: &RawRef,
8737) -> Option<(String, String, String)> {
8738 if full_ref.contains("::") {
8739 if let Some((target_file, target_symbol)) =
8740 rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
8741 {
8742 return Some(("resolved".to_string(), target_file, target_symbol));
8743 }
8744 }
8745
8746 for import in &caller_data.import_block.imports {
8747 if let Some((target_file, target_symbol)) =
8748 rust_target_for_use(index, caller_file, import, short_name)
8749 {
8750 return Some(("resolved".to_string(), target_file, target_symbol));
8751 }
8752 }
8753
8754 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
8755}
8756
8757fn rust_target_for_qualified<I: ResolverIndex>(
8758 index: &I,
8759 caller_file: &str,
8760 full_ref: &str,
8761 short_name: &str,
8762 caller_data: &FileCallData,
8763 raw: &RawRef,
8764) -> Option<(String, String)> {
8765 let mut segments: Vec<&str> = full_ref.split("::").collect();
8766 if segments.len() < 2 {
8767 return None;
8768 }
8769 segments.pop();
8770 let requested_symbol = rust_target_symbol(full_ref, short_name);
8771
8772 for path in rust_module_path_candidates(&segments, caller_data, raw) {
8773 let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
8774 if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
8775 if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
8776 return Some(rust_resolve_reexport_if_symbol_missing(
8777 index,
8778 target_file,
8779 requested_symbol.clone(),
8780 ));
8781 }
8782 }
8783
8784 let module_segments = rust_resolve_segments(caller_file, &path_refs)?;
8785 if let Some(target) =
8786 rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
8787 {
8788 return Some(target);
8789 }
8790 if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
8791 return Some(rust_resolve_reexport_if_symbol_missing(
8792 index,
8793 target_file,
8794 requested_symbol.clone(),
8795 ));
8796 }
8797 }
8798 None
8799}
8800
8801fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
8802 full_ref
8803 .rsplit("::")
8804 .next()
8805 .filter(|name| !name.is_empty())
8806 .unwrap_or(short_name)
8807 .to_string()
8808}
8809
8810fn rust_resolve_reexport_if_symbol_missing<I: ResolverIndex>(
8811 index: &I,
8812 target_file: String,
8813 target_symbol: String,
8814) -> (String, String) {
8815 if index
8816 .node_for_symbol(&target_file, &target_symbol)
8817 .is_some()
8818 {
8819 return (target_file, target_symbol);
8820 }
8821 if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
8822 resolved
8823 } else {
8824 (target_file, target_symbol)
8825 }
8826}
8827
8828fn rust_module_path_candidates(
8829 segments: &[&str],
8830 caller_data: &FileCallData,
8831 raw: &RawRef,
8832) -> Vec<Vec<String>> {
8833 let mut candidates = Vec::new();
8834 if let Some(first) = segments.first().copied() {
8835 for import in &caller_data.import_block.imports {
8836 if !rust_import_is_visible_to_call(import, raw) {
8837 continue;
8838 }
8839 let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
8840 continue;
8841 };
8842 if local_name == first {
8843 path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
8844 rust_push_unique_path_candidate(&mut candidates, path_segments);
8845 }
8846 }
8847 }
8848 rust_push_unique_path_candidate(
8849 &mut candidates,
8850 segments
8851 .iter()
8852 .map(|segment| (*segment).to_string())
8853 .collect(),
8854 );
8855 candidates
8856}
8857
8858fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
8859 if !candidates.iter().any(|existing| existing == &candidate) {
8860 candidates.push(candidate);
8861 }
8862}
8863
8864fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
8865 import.byte_range.start <= raw.byte_start
8866}
8867
8868fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
8869 let path = import.module_path.trim().trim_end_matches(';').trim();
8870 if path.contains("::{") || path.contains('{') || path.contains('*') {
8871 return None;
8872 }
8873 let (path_without_alias, alias) = path
8874 .split_once(" as ")
8875 .map(|(left, right)| (left.trim(), Some(right.trim())))
8876 .unwrap_or((path, None));
8877 let segments = path_without_alias
8878 .split("::")
8879 .map(str::trim)
8880 .filter(|segment| !segment.is_empty())
8881 .collect::<Vec<_>>();
8882 let local_name = alias.or_else(|| segments.last().copied())?.to_string();
8883 if local_name.chars().next().is_some_and(char::is_uppercase) {
8884 return None;
8885 }
8886 Some((
8887 local_name,
8888 segments
8889 .into_iter()
8890 .map(|segment| segment.to_string())
8891 .collect(),
8892 ))
8893}
8894
8895fn rust_inline_scoped_target<I: ResolverIndex>(
8896 index: &I,
8897 caller_file: &str,
8898 module_segments: &[String],
8899 short_name: &str,
8900) -> Option<(String, String)> {
8901 index.inline_scoped_target(caller_file, module_segments, short_name)
8902}
8903
8904fn rust_target_for_use<I: ResolverIndex>(
8905 index: &I,
8906 caller_file: &str,
8907 import: &ImportStatement,
8908 short_name: &str,
8909) -> Option<(String, String)> {
8910 let path = import.module_path.trim().trim_end_matches(';');
8911 if let Some(brace_start) = path.find("::{") {
8912 let prefix = &path[..brace_start];
8913 if import.names.iter().any(|name| name == short_name) {
8914 let prefix_segments: Vec<&str> = prefix.split("::").collect();
8915 let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
8916 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
8917 return Some((file, short_name.to_string()));
8918 }
8919 return None;
8920 }
8921
8922 let (path_without_alias, alias) = path
8923 .split_once(" as ")
8924 .map(|(left, right)| (left.trim(), Some(right.trim())))
8925 .unwrap_or((path, None));
8926 let segments: Vec<&str> = path_without_alias.split("::").collect();
8927 let imported = alias.or_else(|| segments.last().copied())?;
8928 if imported != short_name {
8929 return None;
8930 }
8931 if segments.len() < 2 {
8932 return None;
8933 }
8934 let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
8935 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
8936 Some((file, segments.last().unwrap_or(&short_name).to_string()))
8937}
8938
8939fn rust_workspace_file_for_segments<I: ResolverIndex>(
8940 index: &I,
8941 segments: &[&str],
8942) -> Option<String> {
8943 let crate_name = segments.first().copied()?;
8944 let src_prefix = index.crate_src_prefix(crate_name)?;
8945 let module_segments = segments[1..]
8946 .iter()
8947 .map(|segment| segment.to_string())
8948 .collect::<Vec<_>>();
8949 rust_file_for_src_prefix(index, &src_prefix, &module_segments)
8950}
8951
8952#[cfg(test)]
8953static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
8954 OnceLock::new();
8955
8956#[cfg(test)]
8957fn note_workspace_crate_prefix_build(project_root: &Path) {
8958 let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
8959 .get_or_init(|| Mutex::new(HashMap::new()))
8960 .lock()
8961 .expect("workspace crate prefix build counts mutex poisoned");
8962 *counts.entry(project_root.to_path_buf()).or_default() += 1;
8963}
8964
8965#[cfg(not(test))]
8966fn note_workspace_crate_prefix_build(_project_root: &Path) {}
8967
8968#[cfg(test)]
8969fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
8970 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
8971 .get_or_init(|| Mutex::new(HashMap::new()))
8972 .lock()
8973 .expect("workspace crate prefix build counts mutex poisoned")
8974 .remove(project_root);
8975}
8976
8977#[cfg(test)]
8978fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
8979 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
8980 .get_or_init(|| Mutex::new(HashMap::new()))
8981 .lock()
8982 .expect("workspace crate prefix build counts mutex poisoned")
8983 .get(project_root)
8984 .copied()
8985 .unwrap_or(0)
8986}
8987
8988fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
8993 note_workspace_crate_prefix_build(project_root);
8994 let mut prefixes = HashMap::new();
8995 let mut stack = vec![project_root.to_path_buf()];
8996 while let Some(dir) = stack.pop() {
8997 let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
8998 if matches!(name, "target" | "node_modules" | ".git") {
8999 continue;
9000 }
9001 let manifest = dir.join("Cargo.toml");
9002 if manifest.is_file() {
9003 let crate_names = rust_manifest_crate_names(&manifest);
9004 if !crate_names.is_empty() {
9005 let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
9006 for crate_name in crate_names {
9007 prefixes
9008 .entry(crate_name)
9009 .or_insert_with(|| src_prefix.clone());
9010 }
9011 }
9012 }
9013 let Ok(entries) = std::fs::read_dir(&dir) else {
9014 continue;
9015 };
9016 for entry in entries.flatten() {
9017 let path = entry.path();
9018 if path.is_dir() {
9019 stack.push(path);
9020 }
9021 }
9022 }
9023 prefixes
9024}
9025
9026fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
9030 let Ok(source) = std::fs::read_to_string(manifest) else {
9031 return Vec::new();
9032 };
9033 let mut in_lib = false;
9034 let mut package_name = None;
9035 let mut lib_name = None;
9036 for line in source.lines() {
9037 let trimmed = line.trim();
9038 if trimmed.starts_with('[') {
9039 in_lib = trimmed == "[lib]";
9040 continue;
9041 }
9042 let Some((key, value)) = trimmed.split_once('=') else {
9043 continue;
9044 };
9045 let key = key.trim();
9046 let value = value.trim().trim_matches('"');
9047 if in_lib && key == "name" {
9048 lib_name = Some(value.to_string());
9049 } else if !in_lib && key == "name" && package_name.is_none() {
9050 package_name = Some(value.to_string());
9051 }
9052 }
9053 let mut names = Vec::new();
9054 if let Some(lib) = lib_name {
9055 names.push(lib);
9056 }
9057 if let Some(package) = package_name {
9058 let normalized = package.replace('-', "_");
9059 if !names.contains(&normalized) {
9060 names.push(normalized);
9061 }
9062 }
9063 names
9064}
9065
9066fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
9067 if segments.is_empty() {
9068 return Some(Vec::new());
9069 }
9070 let caller_segments = rust_module_segments_for_rel(caller_file);
9071 match segments[0] {
9072 "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
9073 "self" => {
9074 let mut resolved = caller_segments;
9075 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
9076 Some(resolved)
9077 }
9078 "super" => {
9079 let mut resolved = caller_segments;
9080 resolved.pop();
9081 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
9082 Some(resolved)
9083 }
9084 _ => {
9085 let mut resolved = caller_segments;
9086 resolved.pop();
9087 resolved.extend(segments.iter().map(|item| item.to_string()));
9088 Some(resolved)
9089 }
9090 }
9091}
9092
9093fn rust_file_for_segments<I: ResolverIndex>(
9094 index: &I,
9095 caller_file: &str,
9096 segments: &[String],
9097) -> Option<String> {
9098 rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
9099}
9100
9101fn rust_file_for_src_prefix<I: ResolverIndex>(
9102 index: &I,
9103 src_prefix: &str,
9104 segments: &[String],
9105) -> Option<String> {
9106 let candidate = if segments.is_empty() {
9107 [src_prefix, "lib.rs"].join("/")
9108 } else {
9109 format!("{}/{}.rs", src_prefix, segments.join("/"))
9110 };
9111 if index.contains_file(&candidate) {
9112 return Some(candidate);
9113 }
9114 if !segments.is_empty() {
9115 let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
9116 if index.contains_file(&mod_candidate) {
9117 return Some(mod_candidate);
9118 }
9119 }
9120 None
9121}
9122
9123fn rust_src_prefix(rel_path: &str) -> String {
9124 rel_path
9125 .split_once("/src/")
9126 .map(|(prefix, _)| format!("{prefix}/src"))
9127 .unwrap_or_else(|| "src".to_string())
9128}
9129
9130fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
9131 let after_src = rel_path
9132 .split_once("/src/")
9133 .map(|(_, rest)| rest)
9134 .or_else(|| rel_path.strip_prefix("src/"))
9135 .unwrap_or(rel_path);
9136 if matches!(after_src, "lib.rs" | "main.rs") {
9137 return Vec::new();
9138 }
9139 if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
9140 return prefix.split('/').map(|item| item.to_string()).collect();
9141 }
9142 after_src
9143 .strip_suffix(".rs")
9144 .unwrap_or(after_src)
9145 .split('/')
9146 .map(|item| item.to_string())
9147 .collect()
9148}
9149
9150fn resolve_local_target<I: ResolverIndex>(
9151 _index: &I,
9152 caller_file: &str,
9153 full_ref: &str,
9154 short_name: &str,
9155 caller_data: &FileCallData,
9156) -> Option<(String, String, String)> {
9157 if !callgraph::is_bare_callee(full_ref, short_name) {
9158 return None;
9159 }
9160 callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
9161 .ok()
9162 .map(|symbol| {
9163 (
9164 "resolved_local".to_string(),
9165 caller_file.to_string(),
9166 symbol,
9167 )
9168 })
9169}
9170
9171impl<'a> ProjectIndex<'a> {
9172 fn from_parts(
9173 project_root: &Path,
9174 files: HashMap<String, DbFileIndex>,
9175 caller_data: HashMap<String, &'a FileCallData>,
9176 workspace_crate_prefixes: WorkspaceCratePrefixCache,
9177 ) -> Self {
9178 Self {
9179 project_root: project_root.to_path_buf(),
9180 files,
9181 caller_data,
9182 workspace_crate_prefixes,
9183 }
9184 }
9185
9186 fn from_db_and_callers(
9187 tx: &Transaction<'_>,
9188 project_root: &Path,
9189 caller_extracts: &'a HashMap<String, FileExtract>,
9190 workspace_crate_prefixes: WorkspaceCratePrefixCache,
9191 ) -> Result<Self> {
9192 let mut files = load_db_file_indexes(tx, project_root)?;
9193 let mut caller_data = HashMap::new();
9194 for (rel_path, extract) in caller_extracts {
9195 files.insert(
9196 rel_path.clone(),
9197 DbFileIndex::from_extract(project_root, extract),
9198 );
9199 caller_data.insert(rel_path.clone(), &extract.data);
9200 }
9201 Ok(Self::from_parts(
9202 project_root,
9203 files,
9204 caller_data,
9205 workspace_crate_prefixes,
9206 ))
9207 }
9208
9209 fn lang_for(&self, rel_path: &str) -> Option<LangId> {
9210 self.files.get(rel_path).and_then(|file| file.lang)
9211 }
9212
9213 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
9214 self.files
9215 .get(caller_file)
9216 .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
9217 }
9218
9219 fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
9220 self.files
9221 .get(rel_path)
9222 .map(|file| file.reexports.as_slice())
9223 .unwrap_or(&[])
9224 }
9225
9226 fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
9227 self.files.get(rel_path).and_then(|file| {
9228 file.node_by_scoped
9229 .get(symbol)
9230 .cloned()
9231 .or_else(|| file.node_by_bare.get(symbol).cloned())
9232 })
9233 }
9234
9235 fn node_is_callable(&self, rel_path: &str, node_id: &str) -> bool {
9236 self.files
9237 .get(rel_path)
9238 .and_then(|file| file.node_kind_by_id.get(node_id))
9239 .is_some_and(|kind| matches!(kind.as_str(), "function" | "method"))
9240 }
9241}
9242
9243impl DbFileIndex {
9244 fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
9245 let mut node_by_scoped = HashMap::new();
9246 let mut node_by_bare = HashMap::new();
9247 for node in &extract.nodes {
9248 node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
9249 node_by_bare
9250 .entry(node.name.clone())
9251 .or_insert(node.id.clone());
9252 }
9253 let node_kind_by_id = extract
9254 .nodes
9255 .iter()
9256 .map(|node| (node.id.clone(), node.kind.clone()))
9257 .collect();
9258 let mut export_aliases = HashMap::new();
9259 for raw_ref in &extract.raw_refs {
9260 if raw_ref.kind == "export_alias" {
9261 if let (Some(exported), Some(source_symbol)) =
9262 (&raw_ref.local_name, &raw_ref.requested_name)
9263 {
9264 export_aliases.insert(exported.clone(), source_symbol.clone());
9265 }
9266 }
9267 }
9268 let mut module_targets = HashMap::new();
9269 let mut reexports = Vec::new();
9270 for raw_ref in &extract.raw_refs {
9271 if !matches!(raw_ref.kind.as_str(), "import" | "reexport") {
9272 continue;
9273 }
9274 let Some(module_path) = &raw_ref.module_path else {
9275 continue;
9276 };
9277 let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
9278 module_targets
9279 .entry(module_path.clone())
9280 .or_insert_with(|| target_file.clone());
9281 if raw_ref.kind == "reexport" {
9282 reexports.push(reexport_index_from_raw(raw_ref, target_file));
9283 }
9284 }
9285 Self {
9286 lang: Some(extract.lang),
9287 exports: extract.data.exported_symbols.iter().cloned().collect(),
9288 default_export: extract.data.default_export_symbol.clone(),
9289 export_aliases,
9290 node_by_scoped,
9291 node_by_bare,
9292 node_kind_by_id,
9293 module_targets,
9294 reexports,
9295 }
9296 }
9297}
9298
9299fn load_db_file_indexes(
9300 tx: &Transaction<'_>,
9301 project_root: &Path,
9302) -> Result<HashMap<String, DbFileIndex>> {
9303 let mut files = HashMap::new();
9304 let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
9305 let rows = stmt.query_map([], |row| {
9306 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
9307 })?;
9308 for row in rows {
9309 let (rel_path, lang) = row?;
9310 files.insert(
9311 rel_path.clone(),
9312 DbFileIndex {
9313 lang: lang_from_label(&lang),
9314 exports: HashSet::new(),
9315 default_export: None,
9316 export_aliases: HashMap::new(),
9317 node_by_scoped: HashMap::new(),
9318 node_by_bare: HashMap::new(),
9319 node_kind_by_id: HashMap::new(),
9320 module_targets: HashMap::new(),
9321 reexports: Vec::new(),
9322 },
9323 );
9324 }
9325
9326 let mut node_stmt = tx.prepare(
9327 "SELECT file_path, id, name, scoped_name, kind, exported, is_default_export FROM nodes",
9328 )?;
9329 let nodes = node_stmt.query_map([], |row| {
9330 Ok((
9331 row.get::<_, String>(0)?,
9332 row.get::<_, String>(1)?,
9333 row.get::<_, String>(2)?,
9334 row.get::<_, String>(3)?,
9335 row.get::<_, String>(4)?,
9336 row.get::<_, i64>(5)? != 0,
9337 row.get::<_, i64>(6)? != 0,
9338 ))
9339 })?;
9340 for row in nodes {
9341 let (file_path, id, name, scoped_name, kind, exported, is_default_export) = row?;
9342 let file = files
9343 .entry(file_path.clone())
9344 .or_insert_with(|| DbFileIndex {
9345 lang: None,
9346 exports: HashSet::new(),
9347 default_export: None,
9348 export_aliases: HashMap::new(),
9349 node_by_scoped: HashMap::new(),
9350 node_by_bare: HashMap::new(),
9351 node_kind_by_id: HashMap::new(),
9352 module_targets: HashMap::new(),
9353 reexports: Vec::new(),
9354 });
9355 if exported {
9356 file.exports.insert(name.clone());
9357 file.exports.insert(scoped_name.clone());
9358 }
9359 if is_default_export {
9360 file.default_export = Some(scoped_name.clone());
9361 }
9362 file.node_by_scoped.insert(scoped_name, id.clone());
9363 file.node_by_bare.entry(name).or_insert(id.clone());
9364 file.node_kind_by_id.insert(id, kind);
9365 }
9366 let file_keys: HashSet<String> = files.keys().cloned().collect();
9367 let dependencies_by_file = load_file_dependencies_index(tx)?;
9371 let mut ref_stmt = tx.prepare(
9372 "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
9373 FROM refs WHERE kind IN ('reexport', 'export_alias')",
9374 )?;
9375 let ref_rows = ref_stmt.query_map([], |row| {
9376 Ok((
9377 row.get::<_, String>(0)?,
9378 row.get::<_, String>(1)?,
9379 row.get::<_, String>(2)?,
9380 row.get::<_, Option<String>>(3)?,
9381 row.get::<_, Option<String>>(4)?,
9382 row.get::<_, i64>(5)? != 0,
9383 row.get::<_, Option<String>>(6)?,
9384 row.get::<_, Option<String>>(7)?,
9385 ))
9386 })?;
9387 for row in ref_rows {
9388 let (
9389 ref_id,
9390 caller_file,
9391 kind,
9392 module_path,
9393 full_ref,
9394 wildcard,
9395 local_name,
9396 requested_name,
9397 ) = row?;
9398 if kind == "export_alias" {
9399 if let (Some(exported), Some(source_symbol), Some(file)) =
9400 (local_name, requested_name, files.get_mut(&caller_file))
9401 {
9402 file.export_aliases.insert(exported, source_symbol);
9403 }
9404 continue;
9405 }
9406 let Some(module_path) = module_path else {
9407 continue;
9408 };
9409 let file_deps = dependencies_by_file
9410 .get(&caller_file)
9411 .cloned()
9412 .unwrap_or_default();
9413 let deps = stored_dependencies_for_module(
9414 project_root,
9415 &caller_file,
9416 &module_path,
9417 &file_deps,
9418 &file_keys,
9419 );
9420 let target_file = deps
9421 .iter()
9422 .find(|dep| file_keys.contains(*dep))
9423 .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
9424 if let Some(file) = files.get_mut(&caller_file) {
9425 file.module_targets
9426 .entry(module_path.clone())
9427 .or_insert_with(|| target_file.clone());
9428 if kind == "reexport" {
9429 let raw = RawRef {
9430 ref_id,
9431 caller_node: None,
9432 caller_symbol: None,
9433 caller_file,
9434 kind,
9435 short_name: None,
9436 full_ref,
9437 module_path: Some(module_path),
9438 import_kind: Some("reexport".to_string()),
9439 local_name: None,
9440 requested_name: None,
9441 namespace_alias: None,
9442 wildcard,
9443 line: 0,
9444 byte_start: 0,
9445 byte_end: 0,
9446 dependencies: deps,
9447 };
9448 file.reexports
9449 .push(reexport_index_from_raw(&raw, target_file));
9450 }
9451 }
9452 }
9453
9454 Ok(files)
9455}
9456
9457fn stored_dependencies_for_module(
9458 project_root: &Path,
9459 caller_file: &str,
9460 module_path: &str,
9461 caller_dependencies: &BTreeSet<String>,
9462 indexed_files: &HashSet<String>,
9463) -> BTreeSet<String> {
9464 let caller_path = project_root.join(caller_file);
9465 let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
9466 if module_path.starts_with('.') {
9467 let caller_dir = caller_path.parent().unwrap_or(project_root);
9468 for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
9469 let normalized = if candidate.is_file() {
9470 canonicalize_path(&candidate)
9471 } else {
9472 candidate
9473 };
9474 candidates.insert(relative_path(project_root, &normalized));
9475 }
9476 }
9477 let exact = candidates
9478 .intersection(caller_dependencies)
9479 .filter(|dependency| indexed_files.contains(*dependency))
9480 .cloned()
9481 .collect::<BTreeSet<_>>();
9482 if !exact.is_empty() || module_path.starts_with('.') {
9483 return exact;
9484 }
9485
9486 let module_path = rust_module_path_without_alias_or_use_list(module_path)
9487 .trim_matches(|character| matches!(character, '\'' | '"'));
9488 let package_name = module_path
9489 .split('/')
9490 .next_back()
9491 .unwrap_or(module_path)
9492 .replace('_', "-");
9493 let matched = caller_dependencies
9494 .iter()
9495 .filter(|dependency| indexed_files.contains(*dependency))
9496 .filter(|dependency| {
9497 dependency.as_str() == module_path
9498 || dependency.ends_with(&format!("/{module_path}"))
9499 || Path::new(dependency).components().any(|component| {
9500 component.as_os_str().to_string_lossy().replace('_', "-") == package_name
9501 })
9502 })
9503 .cloned()
9504 .collect::<BTreeSet<_>>();
9505 if matched.len() == 1 {
9506 matched
9507 } else {
9508 BTreeSet::new()
9509 }
9510}
9511
9512fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
9513 let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
9514 let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
9515 let rows = stmt.query_map([], |row| {
9516 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
9517 })?;
9518 for row in rows {
9519 let (file_path, dependency) = row?;
9520 by_file.entry(file_path).or_default().insert(dependency);
9521 }
9522 Ok(by_file)
9523}
9524
9525struct ColdBuildInsertStatements<'stmt> {
9526 file: Statement<'stmt>,
9527 node: Statement<'stmt>,
9528 file_dependency: Statement<'stmt>,
9529 dispatch_hint: Statement<'stmt>,
9530 backend_state: Statement<'stmt>,
9531 reference: Statement<'stmt>,
9532 staging_ref_context: Statement<'stmt>,
9533 edge: Statement<'stmt>,
9534}
9535
9536impl<'stmt> ColdBuildInsertStatements<'stmt> {
9537 fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
9538 Ok(Self {
9539 file: tx.prepare(
9540 "INSERT OR REPLACE INTO files(
9541 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
9542 is_public_api, surface_fingerprint, indexed_at
9543 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
9544 )?,
9545 node: tx.prepare(
9546 "INSERT OR REPLACE INTO nodes(
9547 id, file_path, name, scoped_name, kind, start_line, start_col,
9548 end_line, end_col, range_ordinal, signature, exported,
9549 is_default_export, is_type_like, is_callgraph_entry_point, provenance
9550 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
9551 )?,
9552 file_dependency: tx.prepare(
9553 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
9554 )?,
9555 dispatch_hint: tx.prepare(
9556 "INSERT OR REPLACE INTO dispatch_hints(
9557 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
9558 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
9559 )?,
9560 backend_state: tx.prepare(
9561 "INSERT OR REPLACE INTO backend_file_state(
9562 backend, workspace_root, file_path, content_hash, status, updated_at
9563 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
9564 )?,
9565 reference: tx.prepare(
9566 "INSERT OR REPLACE INTO refs(
9567 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
9568 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
9569 byte_start, byte_end, status, target_node, target_file, target_symbol,
9570 provenance
9571 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
9572 )?,
9573 staging_ref_context: tx.prepare(
9574 "INSERT OR REPLACE INTO staging_ref_context(ref_id, caller_symbol) VALUES(?1, ?2)",
9575 )?,
9576 edge: tx.prepare(
9577 "INSERT OR REPLACE INTO edges(
9578 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
9579 kind, line, provenance
9580 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
9581 )?,
9582 })
9583 }
9584}
9585
9586fn insert_file_extract_prepared(
9587 statements: &mut ColdBuildInsertStatements<'_>,
9588 workspace_root: &str,
9589 extract: &FileExtract,
9590) -> Result<()> {
9591 statements.file.execute(params![
9592 extract.rel_path,
9593 hash_to_hex(extract.freshness.content_hash),
9594 system_time_to_ns(extract.freshness.mtime),
9595 extract.freshness.size as i64,
9596 lang_label(extract.lang),
9597 extract.surface_fingerprint,
9598 unix_seconds_now(),
9599 ])?;
9600 for node in &extract.nodes {
9601 statements.node.execute(params![
9602 node.id,
9603 node.file_path,
9604 node.name,
9605 node.scoped_name,
9606 node.kind,
9607 node.range.start_line as i64,
9608 node.range.start_col as i64,
9609 node.range.end_line as i64,
9610 node.range.end_col as i64,
9611 node.range_ordinal as i64,
9612 node.signature,
9613 bool_int(node.exported),
9614 bool_int(node.is_default_export),
9615 bool_int(node.is_type_like),
9616 bool_int(node.is_callgraph_entry_point),
9617 PROVENANCE_TREESITTER,
9618 ])?;
9619 }
9620
9621 let mut dependencies = BTreeSet::new();
9622 for raw_ref in &extract.raw_refs {
9623 dependencies.extend(raw_ref.dependencies.iter().cloned());
9624 }
9625 for dep_file in &dependencies {
9626 statements
9627 .file_dependency
9628 .execute(params![extract.rel_path, dep_file])?;
9629 }
9630
9631 for hint in &extract.dispatch_hints {
9632 statements.dispatch_hint.execute(params![
9633 hint.id,
9634 hint.method_name,
9635 hint.caller_node,
9636 hint.file,
9637 hint.line as i64,
9638 hint.byte_start as i64,
9639 hint.byte_end as i64,
9640 PROVENANCE_TREESITTER,
9641 ])?;
9642 }
9643 insert_backend_state_prepared(
9644 &mut statements.backend_state,
9645 workspace_root,
9646 &extract.rel_path,
9647 Some(&extract.freshness.content_hash),
9648 "fresh",
9649 )?;
9650 Ok(())
9651}
9652
9653fn insert_backend_state_prepared(
9654 stmt: &mut Statement<'_>,
9655 workspace_root: &str,
9656 rel_path: &str,
9657 content_hash: Option<&blake3::Hash>,
9658 status: &str,
9659) -> Result<()> {
9660 let hash = content_hash
9661 .map(|hash| hash_to_hex(*hash))
9662 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
9663 stmt.execute(params![
9664 BACKEND_TREESITTER,
9665 workspace_root,
9666 rel_path,
9667 hash,
9668 status,
9669 unix_seconds_now(),
9670 ])?;
9671 Ok(())
9672}
9673
9674fn insert_staged_ref_prepared(
9675 statements: &mut ColdBuildInsertStatements<'_>,
9676 raw: &RawRef,
9677) -> Result<()> {
9678 statements.reference.execute(params![
9679 raw.ref_id,
9680 raw.caller_node,
9681 raw.caller_file,
9682 raw.kind,
9683 raw.short_name,
9684 raw.full_ref,
9685 raw.module_path,
9686 raw.import_kind,
9687 raw.local_name,
9688 raw.requested_name,
9689 raw.namespace_alias,
9690 bool_int(raw.wildcard),
9691 raw.line as i64,
9692 raw.byte_start as i64,
9693 raw.byte_end as i64,
9694 "staged",
9695 Option::<String>::None,
9696 Option::<String>::None,
9697 Option::<String>::None,
9698 ref_provenance(raw),
9699 ])?;
9700 statements
9701 .staging_ref_context
9702 .execute(params![raw.ref_id, raw.caller_symbol])?;
9703 Ok(())
9704}
9705
9706fn insert_resolved_ref_prepared(
9707 statements: &mut ColdBuildInsertStatements<'_>,
9708 resolved: &ResolvedRef,
9709) -> Result<()> {
9710 let raw = &resolved.raw;
9711 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
9712 statements.reference.execute(params![
9713 raw.ref_id,
9714 raw.caller_node,
9715 raw.caller_file,
9716 raw.kind,
9717 raw.short_name,
9718 raw.full_ref,
9719 raw.module_path,
9720 raw.import_kind,
9721 raw.local_name,
9722 raw.requested_name,
9723 raw.namespace_alias,
9724 bool_int(raw.wildcard),
9725 raw.line as i64,
9726 raw.byte_start as i64,
9727 raw.byte_end as i64,
9728 resolved.status,
9729 resolved.target_node,
9730 resolved.target_file,
9731 resolved.target_symbol,
9732 ref_provenance(raw),
9733 ])?;
9734 if let Some(edge) = &resolved.edge {
9735 statements.edge.execute(params![
9736 edge.edge_id,
9737 raw.ref_id,
9738 edge.source_node,
9739 edge.target_node,
9740 edge.target_file,
9741 edge.target_symbol,
9742 edge.kind,
9743 edge.line as i64,
9744 ref_provenance(raw),
9745 ])?;
9746 }
9747 Ok(())
9748}
9749
9750#[cfg(test)]
9751fn insert_file_extract(
9752 tx: &Transaction<'_>,
9753 project_root: &Path,
9754 extract: &FileExtract,
9755) -> Result<()> {
9756 tx.execute(
9757 "INSERT OR REPLACE INTO files(
9758 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
9759 is_public_api, surface_fingerprint, indexed_at
9760 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
9761 params![
9762 extract.rel_path,
9763 hash_to_hex(extract.freshness.content_hash),
9764 system_time_to_ns(extract.freshness.mtime),
9765 extract.freshness.size as i64,
9766 lang_label(extract.lang),
9767 extract.surface_fingerprint,
9768 unix_seconds_now(),
9769 ],
9770 )?;
9771 for node in &extract.nodes {
9772 tx.execute(
9773 "INSERT OR REPLACE INTO nodes(
9774 id, file_path, name, scoped_name, kind, start_line, start_col,
9775 end_line, end_col, range_ordinal, signature, exported,
9776 is_default_export, is_type_like, is_callgraph_entry_point, provenance
9777 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
9778 params![
9779 node.id,
9780 node.file_path,
9781 node.name,
9782 node.scoped_name,
9783 node.kind,
9784 node.range.start_line as i64,
9785 node.range.start_col as i64,
9786 node.range.end_line as i64,
9787 node.range.end_col as i64,
9788 node.range_ordinal as i64,
9789 node.signature,
9790 bool_int(node.exported),
9791 bool_int(node.is_default_export),
9792 bool_int(node.is_type_like),
9793 bool_int(node.is_callgraph_entry_point),
9794 PROVENANCE_TREESITTER,
9795 ],
9796 )?;
9797 }
9798 let mut dependencies = BTreeSet::new();
9799 for raw_ref in &extract.raw_refs {
9800 dependencies.extend(raw_ref.dependencies.iter().cloned());
9801 }
9802 insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
9803
9804 for hint in &extract.dispatch_hints {
9805 tx.execute(
9806 "INSERT OR REPLACE INTO dispatch_hints(
9807 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
9808 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
9809 params![
9810 hint.id,
9811 hint.method_name,
9812 hint.caller_node,
9813 hint.file,
9814 hint.line as i64,
9815 hint.byte_start as i64,
9816 hint.byte_end as i64,
9817 PROVENANCE_TREESITTER,
9818 ],
9819 )?;
9820 }
9821 mark_backend_state(
9822 tx,
9823 project_root,
9824 &extract.rel_path,
9825 Some(&extract.freshness.content_hash),
9826 "fresh",
9827 )?;
9828 Ok(())
9829}
9830
9831#[cfg(test)]
9832fn insert_file_dependencies(
9833 tx: &Transaction<'_>,
9834 file_path: &str,
9835 dependencies: &BTreeSet<String>,
9836) -> Result<()> {
9837 for dep_file in dependencies {
9838 tx.execute(
9839 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
9840 params![file_path, dep_file],
9841 )?;
9842 }
9843 Ok(())
9844}
9845
9846fn ref_provenance(raw: &RawRef) -> &'static str {
9847 if raw.kind == "value_ref" {
9848 PROVENANCE_VALUE_REF
9849 } else {
9850 PROVENANCE_TREESITTER
9851 }
9852}
9853
9854#[cfg(test)]
9855fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
9856 let raw = &resolved.raw;
9857 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
9858 tx.execute(
9859 "INSERT OR REPLACE INTO refs(
9860 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
9861 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
9862 byte_start, byte_end, status, target_node, target_file, target_symbol,
9863 provenance
9864 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
9865 params![
9866 raw.ref_id,
9867 raw.caller_node,
9868 raw.caller_file,
9869 raw.kind,
9870 raw.short_name,
9871 raw.full_ref,
9872 raw.module_path,
9873 raw.import_kind,
9874 raw.local_name,
9875 raw.requested_name,
9876 raw.namespace_alias,
9877 bool_int(raw.wildcard),
9878 raw.line as i64,
9879 raw.byte_start as i64,
9880 raw.byte_end as i64,
9881 resolved.status,
9882 resolved.target_node,
9883 resolved.target_file,
9884 resolved.target_symbol,
9885 ref_provenance(raw),
9886 ],
9887 )?;
9888 if let Some(edge) = &resolved.edge {
9889 tx.execute(
9890 "INSERT OR REPLACE INTO edges(
9891 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
9892 kind, line, provenance
9893 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
9894 params![
9895 edge.edge_id,
9896 raw.ref_id,
9897 edge.source_node,
9898 edge.target_node,
9899 edge.target_file,
9900 edge.target_symbol,
9901 edge.kind,
9902 edge.line as i64,
9903 ref_provenance(raw),
9904 ],
9905 )?;
9906 }
9907 Ok(())
9908}
9909
9910fn insert_method_dispatch_edges(
9911 tx: &Transaction<'_>,
9912 project_root: &Path,
9913 caller_files: Option<&BTreeSet<String>>,
9914) -> Result<usize> {
9915 let references = load_name_match_refs(tx, caller_files)?;
9916 if references.is_empty() {
9917 return Ok(0);
9918 }
9919
9920 let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
9921 let mut source_cache: DispatchSourceCache = HashMap::new();
9922 let mut inserted = 0usize;
9923 for reference in references {
9924 let key = (reference.method_name.clone(), reference.lang.clone());
9925 let candidates = match candidates_by_name.entry(key) {
9926 Entry::Occupied(entry) => entry.into_mut(),
9927 Entry::Vacant(entry) => {
9928 let candidates =
9929 load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
9930 entry.insert(candidates)
9931 }
9932 };
9933
9934 match infer_receiver_type_state(project_root, &reference, &mut source_cache) {
9935 ReceiverTypeInference::Known(receiver_type) => {
9936 let Some(candidate) =
9937 select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
9938 else {
9939 continue;
9940 };
9941 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
9942 inserted += 1;
9943 continue;
9944 }
9945 ReceiverTypeInference::RustDirectSelfField {
9946 receiver_type,
9947 declaration_file,
9948 module_scope,
9949 } => {
9950 let Some(candidate) = select_rust_direct_self_field_candidate(
9951 project_root,
9952 &reference,
9953 candidates.as_slice(),
9954 &receiver_type,
9955 &declaration_file,
9956 &module_scope,
9957 &mut source_cache,
9958 ) else {
9959 continue;
9960 };
9961 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
9962 inserted += 1;
9963 continue;
9964 }
9965 ReceiverTypeInference::KnownButUnresolved => continue,
9966 ReceiverTypeInference::Unknown => {}
9967 }
9968
9969 if method_name_match_denylisted(&reference.method_name) {
9970 continue;
9971 }
9972
9973 let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
9974 continue;
9975 };
9976 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
9977 inserted += 1;
9978 }
9979 Ok(inserted)
9980}
9981
9982fn insert_method_dispatch_edges_chunked(
9983 tx: &Transaction<'_>,
9984 project_root: &Path,
9985 chunk_size: usize,
9986) -> Result<usize> {
9987 let mut inserted = 0usize;
9988 let mut after_file = String::new();
9989 loop {
9990 let caller_files = {
9991 let mut statement = tx.prepare(
9992 "SELECT DISTINCT caller_file
9993 FROM refs
9994 WHERE caller_file > ?1
9995 ORDER BY caller_file
9996 LIMIT ?2",
9997 )?;
9998 let rows = statement
9999 .query_map(params![after_file, chunk_size.max(1) as i64], |row| {
10000 row.get::<_, String>(0)
10001 })?;
10002 rows.collect::<std::result::Result<BTreeSet<_>, _>>()?
10003 };
10004 let Some(last_file) = caller_files.last().cloned() else {
10005 break;
10006 };
10007 inserted += insert_method_dispatch_edges(tx, project_root, Some(&caller_files))?;
10008 after_file = last_file;
10009 }
10010 Ok(inserted)
10011}
10012
10013fn insert_method_dispatch_edge(
10014 tx: &Transaction<'_>,
10015 reference: &NameMatchRef,
10016 candidate: &NameMatchCandidate,
10017 provenance: &str,
10018) -> Result<()> {
10019 tx.execute(
10020 "INSERT OR REPLACE INTO edges(
10021 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
10022 kind, line, provenance
10023 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
10024 params![
10025 ref_id(&[&reference.ref_id, provenance, "edge"]),
10026 &reference.ref_id,
10027 &reference.caller_node,
10028 &candidate.node_id,
10029 &candidate.file_path,
10030 &candidate.scoped_name,
10031 reference.line as i64,
10032 provenance,
10033 ],
10034 )?;
10035 Ok(())
10036}
10037
10038fn delete_method_dispatch_edges_for_callers(
10039 tx: &Transaction<'_>,
10040 caller_files: &BTreeSet<String>,
10041) -> Result<()> {
10042 if caller_files.is_empty() {
10043 return Ok(());
10044 }
10045
10046 let mut stmt = tx.prepare(
10047 "DELETE FROM edges
10048 WHERE provenance IN (?1, ?2)
10049 AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
10050 )?;
10051 for caller_file in caller_files {
10052 stmt.execute(params![
10053 PROVENANCE_NAME_MATCH,
10054 PROVENANCE_TYPE_MATCH,
10055 caller_file
10056 ])?;
10057 }
10058 Ok(())
10059}
10060
10061fn load_name_match_refs(
10062 tx: &Transaction<'_>,
10063 caller_files: Option<&BTreeSet<String>>,
10064) -> Result<Vec<NameMatchRef>> {
10065 let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
10066 n.signature, r.short_name, r.full_ref, r.line, f.lang
10067 FROM refs r
10068 JOIN files f ON f.path = r.caller_file
10069 JOIN nodes n ON n.id = r.caller_node
10070 WHERE r.kind = 'call'
10071 AND r.status = 'unresolved'
10072 AND r.caller_node IS NOT NULL
10073 AND r.full_ref IS NOT NULL
10074 AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
10075 AND NOT EXISTS (
10076 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
10077 )";
10078 let mut references = Vec::new();
10079
10080 if let Some(caller_files) = caller_files {
10081 if caller_files.is_empty() {
10082 return Ok(references);
10083 }
10084 let sql = format!(
10085 "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
10086 );
10087 let mut stmt = tx.prepare(&sql)?;
10088 for caller_file in caller_files {
10089 let rows = stmt.query_map(params![caller_file], |row| {
10090 Ok((
10091 row.get::<_, String>(0)?,
10092 row.get::<_, Option<String>>(1)?,
10093 row.get::<_, String>(2)?,
10094 row.get::<_, String>(3)?,
10095 row.get::<_, Option<String>>(4)?,
10096 row.get::<_, Option<String>>(5)?,
10097 row.get::<_, Option<String>>(6)?,
10098 row.get::<_, i64>(7)?,
10099 row.get::<_, String>(8)?,
10100 ))
10101 })?;
10102 for row in rows {
10103 let (
10104 ref_id,
10105 caller_node,
10106 caller_file,
10107 caller_symbol,
10108 caller_signature,
10109 short_name,
10110 full_ref,
10111 line,
10112 lang,
10113 ) = row?;
10114 if let Some(reference) = name_match_ref_from_parts(
10115 ref_id,
10116 caller_node,
10117 caller_file,
10118 caller_symbol,
10119 caller_signature,
10120 short_name,
10121 full_ref,
10122 line,
10123 lang,
10124 ) {
10125 references.push(reference);
10126 }
10127 }
10128 }
10129 return Ok(references);
10130 }
10131
10132 let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
10133 let mut stmt = tx.prepare(&sql)?;
10134 let rows = stmt.query_map([], |row| {
10135 Ok((
10136 row.get::<_, String>(0)?,
10137 row.get::<_, Option<String>>(1)?,
10138 row.get::<_, String>(2)?,
10139 row.get::<_, String>(3)?,
10140 row.get::<_, Option<String>>(4)?,
10141 row.get::<_, Option<String>>(5)?,
10142 row.get::<_, Option<String>>(6)?,
10143 row.get::<_, i64>(7)?,
10144 row.get::<_, String>(8)?,
10145 ))
10146 })?;
10147 for row in rows {
10148 let (
10149 ref_id,
10150 caller_node,
10151 caller_file,
10152 caller_symbol,
10153 caller_signature,
10154 short_name,
10155 full_ref,
10156 line,
10157 lang,
10158 ) = row?;
10159 if let Some(reference) = name_match_ref_from_parts(
10160 ref_id,
10161 caller_node,
10162 caller_file,
10163 caller_symbol,
10164 caller_signature,
10165 short_name,
10166 full_ref,
10167 line,
10168 lang,
10169 ) {
10170 references.push(reference);
10171 }
10172 }
10173 Ok(references)
10174}
10175
10176#[allow(clippy::too_many_arguments)]
10177fn name_match_ref_from_parts(
10178 ref_id: String,
10179 caller_node: Option<String>,
10180 caller_file: String,
10181 caller_symbol: String,
10182 caller_signature: Option<String>,
10183 short_name: Option<String>,
10184 full_ref: Option<String>,
10185 line: i64,
10186 lang: String,
10187) -> Option<NameMatchRef> {
10188 let caller_node = caller_node?;
10189 let full_ref = full_ref?;
10190 let (receiver_expression, receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
10191 let method_name = if member.is_empty() {
10192 short_name.as_deref()?.to_string()
10193 } else {
10194 member
10195 };
10196 Some(NameMatchRef {
10197 ref_id,
10198 caller_node,
10199 caller_file,
10200 caller_symbol,
10201 caller_signature,
10202 receiver_expression,
10203 receiver,
10204 method_name,
10205 colon_dispatch,
10206 line: line.max(0) as u32,
10207 lang,
10208 })
10209}
10210
10211fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, String, bool)> {
10212 let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
10213 let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
10214 let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
10215 let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
10216 .into_iter()
10217 .flatten()
10218 .max_by_key(|(index, _, _)| *index)?;
10219 if delimiter == 0 {
10220 return None;
10221 }
10222 let member_start = delimiter + delimiter_len;
10223 if member_start >= full_ref.len() {
10224 return None;
10225 }
10226 let receiver_expression = full_ref[..delimiter].trim();
10227 let receiver = last_name_segment(receiver_expression).trim();
10228 let member = &full_ref[member_start..];
10229 if receiver.is_empty() || member.is_empty() {
10230 return None;
10231 }
10232 Some((
10233 receiver_expression.to_string(),
10234 receiver.to_string(),
10235 member.to_string(),
10236 colon_dispatch,
10237 ))
10238}
10239
10240fn last_name_segment(value: &str) -> &str {
10241 value
10242 .rsplit(['.', ':', '/', '\\', '-', '>'])
10243 .find(|segment| !segment.is_empty())
10244 .unwrap_or(value)
10245}
10246
10247fn load_name_match_candidates(
10248 tx: &Transaction<'_>,
10249 method_name: &str,
10250 lang: &str,
10251) -> Result<Vec<NameMatchCandidate>> {
10252 let mut stmt = tx.prepare(
10253 "SELECT n.id, n.file_path, n.scoped_name, n.kind, n.start_line
10254 FROM nodes n JOIN files f ON f.path = n.file_path
10255 WHERE n.name = ?1
10256 AND f.lang = ?2
10257 AND n.kind IN ('method', 'function')
10258 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
10259 )?;
10260 let rows = stmt.query_map(params![method_name, lang], |row| {
10261 Ok(NameMatchCandidate {
10262 node_id: row.get(0)?,
10263 file_path: row.get(1)?,
10264 scoped_name: row.get(2)?,
10265 kind: row.get(3)?,
10266 start_line: (row.get::<_, i64>(4)?.max(0) as u32).saturating_add(1),
10267 })
10268 })?;
10269 rows.collect::<std::result::Result<Vec<_>, _>>()
10270 .map_err(Into::into)
10271}
10272
10273struct ParsedDispatchSource {
10274 source: String,
10275 tree: tree_sitter::Tree,
10276}
10277
10278type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
10279
10280#[derive(Debug, Clone, PartialEq, Eq)]
10281enum ReceiverTypeInference {
10282 Unknown,
10283 Known(String),
10284 RustDirectSelfField {
10285 receiver_type: String,
10286 declaration_file: String,
10287 module_scope: Vec<(usize, usize)>,
10288 },
10289 KnownButUnresolved,
10290}
10291
10292#[cfg(test)]
10293fn infer_receiver_type(
10294 project_root: &Path,
10295 reference: &NameMatchRef,
10296 source_cache: &mut DispatchSourceCache,
10297) -> Option<String> {
10298 match infer_receiver_type_state(project_root, reference, source_cache) {
10299 ReceiverTypeInference::Known(receiver_type)
10300 | ReceiverTypeInference::RustDirectSelfField { receiver_type, .. } => Some(receiver_type),
10301 ReceiverTypeInference::Unknown | ReceiverTypeInference::KnownButUnresolved => None,
10302 }
10303}
10304
10305fn infer_receiver_type_state(
10306 project_root: &Path,
10307 reference: &NameMatchRef,
10308 source_cache: &mut DispatchSourceCache,
10309) -> ReceiverTypeInference {
10310 let known = |receiver_type| ReceiverTypeInference::Known(receiver_type);
10311 match reference.lang.as_str() {
10312 "rust" => infer_rust_receiver_type(project_root, reference, source_cache),
10313 "java" => {
10314 infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
10315 .map(known)
10316 .unwrap_or(ReceiverTypeInference::Unknown)
10317 }
10318 "kotlin" => {
10319 infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
10320 .map(known)
10321 .unwrap_or(ReceiverTypeInference::Unknown)
10322 }
10323 "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache)
10324 .map(known)
10325 .unwrap_or(ReceiverTypeInference::Unknown),
10326 _ => ReceiverTypeInference::Unknown,
10327 }
10328}
10329
10330fn parse_dispatch_source(
10331 project_root: &Path,
10332 caller_file: &str,
10333 lang: LangId,
10334) -> Option<ParsedDispatchSource> {
10335 let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
10336 let grammar = crate::parser::grammar_for(lang);
10337 let mut parser = tree_sitter::Parser::new();
10338 parser.set_language(&grammar).ok()?;
10339 let tree = parser.parse(&source, None)?;
10340 Some(ParsedDispatchSource { source, tree })
10341}
10342
10343fn parsed_dispatch_source<'a>(
10344 project_root: &Path,
10345 reference: &NameMatchRef,
10346 lang: LangId,
10347 source_cache: &'a mut DispatchSourceCache,
10348) -> Option<&'a ParsedDispatchSource> {
10349 parsed_dispatch_source_for_file(
10350 project_root,
10351 &reference.caller_file,
10352 &reference.lang,
10353 lang,
10354 source_cache,
10355 )
10356}
10357
10358fn parsed_dispatch_source_for_file<'a>(
10359 project_root: &Path,
10360 file_path: &str,
10361 lang_label: &str,
10362 lang: LangId,
10363 source_cache: &'a mut DispatchSourceCache,
10364) -> Option<&'a ParsedDispatchSource> {
10365 let key = (file_path.to_string(), lang_label.to_string());
10366 source_cache
10367 .entry(key)
10368 .or_insert_with(|| parse_dispatch_source(project_root, file_path, lang))
10369 .as_ref()
10370}
10371
10372fn infer_java_like_receiver_type(
10373 project_root: &Path,
10374 reference: &NameMatchRef,
10375 lang: LangId,
10376 source_cache: &mut DispatchSourceCache,
10377) -> Option<String> {
10378 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
10379 return None;
10380 }
10381
10382 let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
10383 let root = parsed.tree.root_node();
10384 let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
10385
10386 let callable_scope = type_node
10387 .and_then(|node| {
10388 find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
10389 })
10390 .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
10391
10392 if let Some(callable_scope) = callable_scope {
10393 if let Some(receiver_type) = infer_java_like_local_receiver_type(
10394 callable_scope,
10395 &parsed.source,
10396 &reference.receiver,
10397 reference.line.max(1),
10398 lang,
10399 ) {
10400 return Some(receiver_type);
10401 }
10402 }
10403
10404 type_node.and_then(|node| {
10405 infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
10406 })
10407}
10408
10409fn infer_cpp_receiver_type(
10410 project_root: &Path,
10411 reference: &NameMatchRef,
10412 source_cache: &mut DispatchSourceCache,
10413) -> Option<String> {
10414 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
10415 return None;
10416 }
10417
10418 let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
10419 let root = parsed.tree.root_node();
10420 let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
10421 infer_cpp_receiver_type_from_scope(
10422 scope,
10423 &parsed.source,
10424 &reference.receiver,
10425 reference.line.max(1),
10426 )
10427}
10428
10429fn find_enclosing_java_like_type_node<'tree>(
10430 root: tree_sitter::Node<'tree>,
10431 source: &str,
10432 reference: &NameMatchRef,
10433 lang: LangId,
10434) -> Option<tree_sitter::Node<'tree>> {
10435 let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
10436 .and_then(|name| simple_type_name(&name));
10437 let line = reference.line.max(1);
10438 let mut best = None;
10439 let mut stack = vec![root];
10440 while let Some(node) = stack.pop() {
10441 if !node_contains_line(node, line) {
10442 continue;
10443 }
10444 if is_java_like_type_kind(node.kind(), lang) {
10445 let name = declaration_name(node, source);
10446 if expected_type
10447 .as_deref()
10448 .is_none_or(|expected| name == Some(expected))
10449 {
10450 best = tighter_node(best, node);
10451 }
10452 }
10453 push_named_children(node, &mut stack);
10454 }
10455 best
10456}
10457
10458fn find_enclosing_java_like_callable_node<'tree>(
10459 root: tree_sitter::Node<'tree>,
10460 source: &str,
10461 reference: &NameMatchRef,
10462 lang: LangId,
10463) -> Option<tree_sitter::Node<'tree>> {
10464 let expected_name = reference.caller_symbol.rsplit("::").next();
10465 let line = reference.line.max(1);
10466 let mut best = None;
10467 let mut stack = vec![root];
10468 while let Some(node) = stack.pop() {
10469 if !node_contains_line(node, line) {
10470 continue;
10471 }
10472 if is_java_like_callable_kind(node.kind(), lang) {
10473 let name = declaration_name(node, source);
10474 if expected_name.is_none_or(|expected| name == Some(expected)) {
10475 best = tighter_node(best, node);
10476 }
10477 }
10478 push_named_children(node, &mut stack);
10479 }
10480 best
10481}
10482
10483fn find_enclosing_cpp_callable_node<'tree>(
10484 root: tree_sitter::Node<'tree>,
10485 _source: &str,
10486 reference: &NameMatchRef,
10487) -> Option<tree_sitter::Node<'tree>> {
10488 let line = reference.line.max(1);
10489 let mut best = None;
10490 let mut stack = vec![root];
10491 while let Some(node) = stack.pop() {
10492 if !node_contains_line(node, line) {
10493 continue;
10494 }
10495 if node.kind() == "function_definition" {
10496 best = tighter_node(best, node);
10497 }
10498 push_named_children(node, &mut stack);
10499 }
10500 best
10501}
10502
10503fn tighter_node<'tree>(
10504 current: Option<tree_sitter::Node<'tree>>,
10505 candidate: tree_sitter::Node<'tree>,
10506) -> Option<tree_sitter::Node<'tree>> {
10507 match current {
10508 Some(current)
10509 if current.start_byte() > candidate.start_byte()
10510 || (current.start_byte() == candidate.start_byte()
10511 && current.end_byte() <= candidate.end_byte()) =>
10512 {
10513 Some(current)
10514 }
10515 _ => Some(candidate),
10516 }
10517}
10518
10519fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
10520 let start = node.start_position().row as u32 + 1;
10521 let end = node.end_position().row as u32 + 1;
10522 start <= line && line <= end
10523}
10524
10525fn push_named_children<'tree>(
10526 node: tree_sitter::Node<'tree>,
10527 stack: &mut Vec<tree_sitter::Node<'tree>>,
10528) {
10529 for index in 0..node.named_child_count() {
10530 if let Some(child) = node.named_child(index as u32) {
10531 stack.push(child);
10532 }
10533 }
10534}
10535
10536fn declaration_name<'source>(
10537 node: tree_sitter::Node<'_>,
10538 source: &'source str,
10539) -> Option<&'source str> {
10540 node.child_by_field_name("name")
10541 .map(|name| node_text(name, source))
10542 .or_else(|| {
10543 first_named_child_text(
10544 node,
10545 source,
10546 &["identifier", "type_identifier", "simple_identifier"],
10547 )
10548 })
10549}
10550
10551fn first_named_child_text<'source>(
10552 node: tree_sitter::Node<'_>,
10553 source: &'source str,
10554 kinds: &[&str],
10555) -> Option<&'source str> {
10556 for index in 0..node.named_child_count() {
10557 let child = node.named_child(index as u32)?;
10558 if kinds.contains(&child.kind()) {
10559 return Some(node_text(child, source));
10560 }
10561 }
10562 None
10563}
10564
10565fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
10566 &source[node.byte_range()]
10567}
10568
10569fn infer_java_like_field_receiver_type(
10570 type_node: tree_sitter::Node<'_>,
10571 source: &str,
10572 receiver: &str,
10573 lang: LangId,
10574) -> Option<String> {
10575 let mut stack = Vec::new();
10576 push_named_children(type_node, &mut stack);
10577 while let Some(node) = stack.pop() {
10578 if is_java_like_field_kind(node.kind(), lang) {
10579 if let Some(receiver_type) =
10580 extract_java_like_declared_type(node_text(node, source), receiver, lang)
10581 {
10582 return Some(receiver_type);
10583 }
10584 }
10585 if is_java_like_type_kind(node.kind(), lang)
10586 || is_java_like_callable_kind(node.kind(), lang)
10587 {
10588 continue;
10589 }
10590 push_named_children(node, &mut stack);
10591 }
10592 None
10593}
10594
10595fn infer_java_like_local_receiver_type(
10596 callable_node: tree_sitter::Node<'_>,
10597 source: &str,
10598 receiver: &str,
10599 call_line: u32,
10600 lang: LangId,
10601) -> Option<String> {
10602 let mut best: Option<(u32, String)> = None;
10603 let mut stack = Vec::new();
10604 push_named_children(callable_node, &mut stack);
10605 while let Some(node) = stack.pop() {
10606 let start_line = node.start_position().row as u32 + 1;
10607 if start_line > call_line {
10608 continue;
10609 }
10610 if is_java_like_local_kind(node.kind(), lang) {
10611 if let Some(receiver_type) =
10612 extract_java_like_declared_type(node_text(node, source), receiver, lang)
10613 {
10614 if best
10615 .as_ref()
10616 .is_none_or(|(best_line, _)| start_line >= *best_line)
10617 {
10618 best = Some((start_line, receiver_type));
10619 }
10620 }
10621 }
10622 if is_java_like_type_kind(node.kind(), lang)
10623 || is_java_like_callable_kind(node.kind(), lang)
10624 {
10625 continue;
10626 }
10627 push_named_children(node, &mut stack);
10628 }
10629 best.map(|(_, receiver_type)| receiver_type)
10630}
10631
10632fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
10633 match lang {
10634 LangId::Java => matches!(
10635 kind,
10636 "class_declaration"
10637 | "interface_declaration"
10638 | "enum_declaration"
10639 | "record_declaration"
10640 | "annotation_type_declaration"
10641 ),
10642 LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
10643 _ => false,
10644 }
10645}
10646
10647fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
10648 match lang {
10649 LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
10650 LangId::Kotlin => kind == "function_declaration",
10651 _ => false,
10652 }
10653}
10654
10655fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
10656 match lang {
10657 LangId::Java => kind == "field_declaration",
10658 LangId::Kotlin => kind == "property_declaration",
10659 _ => false,
10660 }
10661}
10662
10663fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
10664 match lang {
10665 LangId::Java => kind == "local_variable_declaration",
10666 LangId::Kotlin => kind == "property_declaration",
10667 _ => false,
10668 }
10669}
10670
10671fn extract_java_like_declared_type(
10672 declaration: &str,
10673 receiver: &str,
10674 lang: LangId,
10675) -> Option<String> {
10676 match lang {
10677 LangId::Java => extract_java_declared_type(declaration, receiver),
10678 LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
10679 _ => None,
10680 }
10681}
10682
10683fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
10684 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
10685 let after = declaration[receiver_start + receiver.len()..].trim_start();
10686 if after
10687 .chars()
10688 .next()
10689 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
10690 {
10691 return None;
10692 }
10693
10694 let before = declaration[..receiver_start].trim_end();
10695 if before.contains(',') {
10696 return None;
10697 }
10698 normalize_receiver_type_name(strip_java_declaration_prefixes(before))
10699}
10700
10701fn strip_java_declaration_prefixes(mut value: &str) -> &str {
10702 loop {
10703 value = value.trim_start();
10704 if let Some(stripped) = strip_leading_java_annotation(value) {
10705 value = stripped;
10706 continue;
10707 }
10708 if let Some(stripped) = strip_leading_java_modifier(value) {
10709 value = stripped;
10710 continue;
10711 }
10712 return value.trim();
10713 }
10714}
10715
10716fn strip_leading_java_annotation(value: &str) -> Option<&str> {
10717 let value = value.trim_start();
10718 let mut chars = value.char_indices();
10719 let (_, first) = chars.next()?;
10720 if first != '@' {
10721 return None;
10722 }
10723 let mut end = first.len_utf8();
10724 for (index, ch) in chars {
10725 if !(is_code_ident_char(ch) || ch == '.') {
10726 end = index;
10727 break;
10728 }
10729 end = index + ch.len_utf8();
10730 }
10731 let rest = value[end..].trim_start();
10732 if let Some(stripped) = rest.strip_prefix('(') {
10733 let mut depth = 1usize;
10734 for (index, ch) in stripped.char_indices() {
10735 match ch {
10736 '(' => depth += 1,
10737 ')' => {
10738 depth = depth.saturating_sub(1);
10739 if depth == 0 {
10740 return Some(stripped[index + ch.len_utf8()..].trim_start());
10741 }
10742 }
10743 _ => {}
10744 }
10745 }
10746 return Some("");
10747 }
10748 Some(rest)
10749}
10750
10751fn strip_leading_java_modifier(value: &str) -> Option<&str> {
10752 const MODIFIERS: &[&str] = &[
10753 "public",
10754 "protected",
10755 "private",
10756 "abstract",
10757 "static",
10758 "final",
10759 "transient",
10760 "volatile",
10761 "synchronized",
10762 "native",
10763 "strictfp",
10764 ];
10765 MODIFIERS
10766 .iter()
10767 .find_map(|modifier| strip_leading_word(value, modifier))
10768}
10769
10770fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
10771 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
10772 let before = &declaration[..receiver_start];
10773 if find_identifier_occurrence(before, "val").is_none()
10774 && find_identifier_occurrence(before, "var").is_none()
10775 {
10776 return None;
10777 }
10778
10779 let after = declaration[receiver_start + receiver.len()..].trim_start();
10780 if let Some(type_text) = after.strip_prefix(':') {
10781 return normalize_receiver_type_name(read_type_prefix(type_text));
10782 }
10783 after
10784 .strip_prefix('=')
10785 .and_then(infer_kotlin_constructor_type)
10786}
10787
10788fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
10789 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
10790 if rest.trim_start().starts_with('(') {
10791 normalize_receiver_type_name(head)
10792 } else {
10793 None
10794 }
10795}
10796
10797fn read_type_prefix(value: &str) -> &str {
10798 let mut angle_depth = 0usize;
10799 for (index, ch) in value.char_indices() {
10800 match ch {
10801 '<' => angle_depth += 1,
10802 '>' => angle_depth = angle_depth.saturating_sub(1),
10803 '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
10804 return value[..index].trim();
10805 }
10806 _ => {}
10807 }
10808 }
10809 value.trim()
10810}
10811
10812fn infer_cpp_receiver_type_from_scope(
10813 scope: tree_sitter::Node<'_>,
10814 source: &str,
10815 receiver: &str,
10816 call_line: u32,
10817) -> Option<String> {
10818 let lines = source.lines().collect::<Vec<_>>();
10819 if lines.is_empty() {
10820 return None;
10821 }
10822 let scope_start = scope.start_position().row as usize;
10823 let call_index = (call_line as usize)
10824 .saturating_sub(1)
10825 .min(lines.len().saturating_sub(1));
10826 for index in (scope_start..=call_index).rev() {
10827 if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
10828 return Some(receiver_type);
10829 }
10830 }
10831 None
10832}
10833
10834fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
10835 for receiver_start in identifier_occurrences(line, receiver) {
10836 let after = line[receiver_start + receiver.len()..].trim_start();
10837 if after
10838 .chars()
10839 .next()
10840 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
10841 {
10842 continue;
10843 }
10844 let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
10845 let normalized = normalize_cpp_type_name(type_text)?;
10846 if normalized == "auto" {
10847 if let Some(rhs) = after.strip_prefix('=') {
10848 return infer_cpp_auto_receiver_type(rhs);
10849 }
10850 continue;
10851 }
10852 return Some(normalized);
10853 }
10854 None
10855}
10856
10857fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
10858 let candidate = prefix
10859 .rsplit([';', '{', '}', '('])
10860 .next()
10861 .unwrap_or(prefix)
10862 .trim();
10863 if candidate.is_empty() || candidate.ends_with(',') {
10864 None
10865 } else {
10866 Some(candidate)
10867 }
10868}
10869
10870fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
10871 let without_templates = strip_angle_groups(type_text);
10872 let mut cleaned = String::with_capacity(without_templates.len());
10873 for token in without_templates.split_whitespace() {
10874 if matches!(
10875 token,
10876 "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
10877 ) {
10878 continue;
10879 }
10880 if !cleaned.is_empty() {
10881 cleaned.push(' ');
10882 }
10883 cleaned.push_str(token);
10884 }
10885 let token = cleaned
10886 .split_whitespace()
10887 .last()
10888 .unwrap_or(cleaned.trim())
10889 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
10890 .trim_matches(['*', '&']);
10891 let simple = token.rsplit("::").next().unwrap_or(token).trim();
10892 if simple.is_empty() || cpp_non_type_token(simple) {
10893 None
10894 } else {
10895 Some(simple.to_string())
10896 }
10897}
10898
10899fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
10900 let rhs = rhs.trim_start();
10901 if let Some(after_new) = rhs.strip_prefix("new ") {
10902 return infer_cpp_constructor_type(after_new);
10903 }
10904 infer_cpp_make_template_type(rhs)
10905 .or_else(|| infer_cpp_constructor_type(rhs))
10906 .or_else(|| infer_cpp_factory_type(rhs))
10907}
10908
10909fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
10910 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
10911 let normalized = normalize_cpp_type_name(head)?;
10912 if !normalized
10913 .chars()
10914 .next()
10915 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
10916 {
10917 return None;
10918 }
10919 if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
10920 Some(normalized)
10921 } else {
10922 None
10923 }
10924}
10925
10926fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
10927 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
10928 if !rest.trim_start().starts_with('(') {
10929 return None;
10930 }
10931 let base = head.split('<').next().unwrap_or(head);
10932 let base_simple = base.rsplit("::").next().unwrap_or(base);
10933 if !matches!(base_simple, "make_unique" | "make_shared") {
10934 return None;
10935 }
10936 first_angle_arg(head).and_then(normalize_cpp_type_name)
10937}
10938
10939fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
10940 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
10941 if !rest.trim_start().starts_with('(') {
10942 return None;
10943 }
10944 let simple = head
10945 .split('<')
10946 .next()
10947 .unwrap_or(head)
10948 .rsplit("::")
10949 .next()
10950 .unwrap_or(head);
10951 for prefix in ["make", "create", "build"] {
10952 if let Some(suffix) = simple.strip_prefix(prefix) {
10953 if suffix
10954 .chars()
10955 .next()
10956 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
10957 {
10958 return normalize_cpp_type_name(suffix);
10959 }
10960 }
10961 }
10962 None
10963}
10964
10965#[derive(Debug, Clone, Copy)]
10966enum JavaLikeInvocation {
10967 Kotlin,
10968 Cpp,
10969}
10970
10971fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
10972 let value = value.trim_start();
10973 let mut end = 0usize;
10974 for (index, ch) in value.char_indices() {
10975 let allowed_separator = match flavor {
10976 JavaLikeInvocation::Kotlin => ch == '.',
10977 JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
10978 };
10979 if is_code_ident_char(ch) || allowed_separator {
10980 end = index + ch.len_utf8();
10981 continue;
10982 }
10983 break;
10984 }
10985 if end == 0 {
10986 return None;
10987 }
10988 let mut rest = &value[end..];
10989 if let Some(stripped) = rest.trim_start().strip_prefix('<') {
10990 let skipped = skip_balanced_angle(stripped)?;
10991 let rest_start = rest.len() - rest.trim_start().len();
10992 let angle_len = 1 + skipped;
10993 end += rest_start + angle_len;
10994 rest = &value[end..];
10995 }
10996 Some((value[..end].trim(), rest))
10997}
10998
10999fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
11000 let mut depth = 1usize;
11001 for (index, ch) in value_after_open.char_indices() {
11002 match ch {
11003 '<' => depth += 1,
11004 '>' => {
11005 depth = depth.saturating_sub(1);
11006 if depth == 0 {
11007 return Some(index + ch.len_utf8());
11008 }
11009 }
11010 _ => {}
11011 }
11012 }
11013 None
11014}
11015
11016fn first_angle_arg(value: &str) -> Option<&str> {
11017 let open = value.find('<')?;
11018 let inner_len = skip_balanced_angle(&value[open + 1..])?;
11019 let inner = &value[open + 1..open + inner_len];
11020 split_top_level_commas(inner).into_iter().next()
11021}
11022
11023fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
11024 let without_generics = strip_angle_groups(type_text);
11025 let cleaned = without_generics
11026 .replace("[]", " ")
11027 .replace("...", " ")
11028 .replace(['?', '&', '*'], " ");
11029 let token = cleaned
11030 .split_whitespace()
11031 .last()
11032 .unwrap_or(cleaned.trim())
11033 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
11034 let token = token.rsplit("::").next().unwrap_or(token);
11035 let simple = token.rsplit('.').next().unwrap_or(token).trim();
11036 if simple.is_empty()
11037 || java_like_primitive_type(simple)
11038 || !simple
11039 .chars()
11040 .next()
11041 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
11042 {
11043 None
11044 } else {
11045 Some(simple.to_string())
11046 }
11047}
11048
11049fn simple_type_name(scoped_name: &str) -> Option<String> {
11050 scoped_name
11051 .rsplit("::")
11052 .find(|segment| !segment.is_empty())
11053 .and_then(normalize_receiver_type_name)
11054}
11055
11056fn strip_angle_groups(value: &str) -> String {
11057 let mut output = String::with_capacity(value.len());
11058 let mut depth = 0usize;
11059 for ch in value.chars() {
11060 match ch {
11061 '<' => {
11062 if depth == 0 {
11063 output.push(' ');
11064 }
11065 depth += 1;
11066 }
11067 '>' => depth = depth.saturating_sub(1),
11068 _ if depth == 0 => output.push(ch),
11069 _ => {}
11070 }
11071 }
11072 output
11073}
11074
11075fn java_like_primitive_type(value: &str) -> bool {
11076 matches!(
11077 value,
11078 "boolean"
11079 | "byte"
11080 | "char"
11081 | "double"
11082 | "float"
11083 | "int"
11084 | "long"
11085 | "short"
11086 | "void"
11087 | "Boolean"
11088 | "Byte"
11089 | "Char"
11090 | "Double"
11091 | "Float"
11092 | "Int"
11093 | "Long"
11094 | "Short"
11095 | "Unit"
11096 )
11097}
11098
11099fn cpp_non_type_token(value: &str) -> bool {
11100 matches!(
11101 value,
11102 "return"
11103 | "if"
11104 | "else"
11105 | "for"
11106 | "while"
11107 | "do"
11108 | "switch"
11109 | "case"
11110 | "default"
11111 | "break"
11112 | "continue"
11113 | "goto"
11114 | "throw"
11115 | "new"
11116 | "delete"
11117 | "co_await"
11118 | "co_yield"
11119 | "co_return"
11120 | "static_cast"
11121 | "const_cast"
11122 | "dynamic_cast"
11123 | "reinterpret_cast"
11124 | "sizeof"
11125 | "alignof"
11126 | "typeid"
11127 | "and"
11128 | "or"
11129 | "not"
11130 | "xor"
11131 )
11132}
11133
11134fn receiver_is_bare_identifier(value: &str) -> bool {
11135 let mut chars = value.chars();
11136 let Some(first) = chars.next() else {
11137 return false;
11138 };
11139 (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
11140}
11141
11142fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
11143 identifier_occurrences(value, needle).into_iter().next()
11144}
11145
11146fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
11147 value
11148 .match_indices(needle)
11149 .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
11150 .collect()
11151}
11152
11153fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
11154 let before = value[..start].chars().next_back();
11155 let after = value[start + len..].chars().next();
11156 !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
11157}
11158
11159fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
11160 let stripped = value.strip_prefix(word)?;
11161 if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
11162 Some(stripped.trim_start())
11163 } else {
11164 None
11165 }
11166}
11167
11168fn is_code_ident_char(ch: char) -> bool {
11169 ch == '_' || ch.is_ascii_alphanumeric()
11170}
11171
11172fn infer_rust_receiver_type(
11173 project_root: &Path,
11174 reference: &NameMatchRef,
11175 source_cache: &mut DispatchSourceCache,
11176) -> ReceiverTypeInference {
11177 if matches!(reference.receiver.as_str(), "self" | "Self") {
11178 return enclosing_type_from_scoped_name(&reference.caller_symbol)
11179 .map(ReceiverTypeInference::Known)
11180 .unwrap_or(ReceiverTypeInference::Unknown);
11181 }
11182
11183 if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
11184 return ReceiverTypeInference::Known(reference.receiver.clone());
11185 }
11186
11187 if let Some(receiver_type) = reference
11188 .caller_signature
11189 .as_deref()
11190 .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
11191 {
11192 return ReceiverTypeInference::Known(receiver_type);
11193 }
11194
11195 infer_rust_direct_self_field_receiver_type(project_root, reference, source_cache)
11196}
11197
11198fn infer_rust_direct_self_field_receiver_type(
11199 project_root: &Path,
11200 reference: &NameMatchRef,
11201 source_cache: &mut DispatchSourceCache,
11202) -> ReceiverTypeInference {
11203 if reference.colon_dispatch {
11204 return ReceiverTypeInference::Unknown;
11205 }
11206 let Some(field_name) = rust_direct_self_field_name(&reference.receiver_expression) else {
11207 return ReceiverTypeInference::Unknown;
11208 };
11209 if field_name != reference.receiver {
11210 return ReceiverTypeInference::Unknown;
11211 }
11212
11213 let Some(impl_type) = enclosing_type_from_scoped_name(&reference.caller_symbol) else {
11214 return ReceiverTypeInference::Unknown;
11215 };
11216 let Some(struct_name) = rust_direct_nominal_type_name(&impl_type) else {
11217 return ReceiverTypeInference::KnownButUnresolved;
11218 };
11219 let Some(parsed) = parsed_dispatch_source(project_root, reference, LangId::Rust, source_cache)
11220 else {
11221 return ReceiverTypeInference::Unknown;
11222 };
11223 let Some(impl_node) =
11224 find_enclosing_rust_impl_node(parsed.tree.root_node(), reference.line.max(1))
11225 else {
11226 return ReceiverTypeInference::Unknown;
11227 };
11228 if impl_node.child_by_field_name("trait").is_some()
11229 || impl_node.child_by_field_name("type_parameters").is_some()
11230 {
11231 return ReceiverTypeInference::KnownButUnresolved;
11232 }
11233 let Some(impl_target) = impl_node.child_by_field_name("type") else {
11234 return ReceiverTypeInference::KnownButUnresolved;
11235 };
11236 if impl_target.kind() != "type_identifier"
11237 || node_text(impl_target, &parsed.source) != impl_type
11238 {
11239 return ReceiverTypeInference::KnownButUnresolved;
11240 }
11241
11242 let module_scope = rust_module_scope(impl_node);
11243 let Some(struct_node) = find_unique_rust_struct(
11244 parsed.tree.root_node(),
11245 &parsed.source,
11246 struct_name,
11247 &module_scope,
11248 ) else {
11249 return ReceiverTypeInference::KnownButUnresolved;
11250 };
11251 let Some(field_type) = rust_struct_field_type_node(struct_node, &parsed.source, field_name)
11252 else {
11253 return ReceiverTypeInference::KnownButUnresolved;
11254 };
11255 if field_type.kind() != "type_identifier" {
11256 return ReceiverTypeInference::KnownButUnresolved;
11257 }
11258 let field_type_name = node_text(field_type, &parsed.source);
11259 if find_unique_rust_struct(
11260 parsed.tree.root_node(),
11261 &parsed.source,
11262 field_type_name,
11263 &module_scope,
11264 )
11265 .is_none()
11266 {
11267 return ReceiverTypeInference::KnownButUnresolved;
11268 }
11269
11270 ReceiverTypeInference::RustDirectSelfField {
11271 receiver_type: field_type_name.to_string(),
11272 declaration_file: reference.caller_file.clone(),
11273 module_scope,
11274 }
11275}
11276
11277fn rust_direct_self_field_name(receiver_expression: &str) -> Option<&str> {
11278 let (base, field) = receiver_expression.split_once('.')?;
11279 let base = base.trim();
11280 let field = field.trim();
11281 (base == "self" && rust_direct_nominal_type_name(field).is_some()).then_some(field)
11282}
11283
11284fn rust_direct_nominal_type_name(value: &str) -> Option<&str> {
11285 let name = value.rsplit("::").next()?.trim();
11286 (!name.is_empty()
11287 && !name.chars().next().is_some_and(|ch| ch.is_ascii_digit())
11288 && name.chars().all(is_rust_ident_char))
11289 .then_some(name)
11290}
11291
11292fn find_enclosing_rust_impl_node<'tree>(
11293 root: tree_sitter::Node<'tree>,
11294 line: u32,
11295) -> Option<tree_sitter::Node<'tree>> {
11296 let mut best = None;
11297 let mut stack = vec![root];
11298 while let Some(node) = stack.pop() {
11299 if !node_contains_line(node, line) {
11300 continue;
11301 }
11302 if node.kind() == "impl_item" {
11303 best = tighter_node(best, node);
11304 }
11305 push_named_children(node, &mut stack);
11306 }
11307 best
11308}
11309
11310fn rust_module_scope(node: tree_sitter::Node<'_>) -> Vec<(usize, usize)> {
11311 let mut scope = Vec::new();
11312 let mut current = node.parent();
11313 while let Some(parent) = current {
11314 if parent.kind() == "mod_item" {
11315 scope.push((parent.start_byte(), parent.end_byte()));
11316 }
11317 current = parent.parent();
11318 }
11319 scope.reverse();
11320 scope
11321}
11322
11323fn find_unique_rust_struct<'tree>(
11324 root: tree_sitter::Node<'tree>,
11325 source: &str,
11326 expected_name: &str,
11327 module_scope: &[(usize, usize)],
11328) -> Option<tree_sitter::Node<'tree>> {
11329 let mut found = None;
11330 let mut stack = vec![root];
11331 while let Some(node) = stack.pop() {
11332 if node.kind() == "struct_item"
11333 && rust_module_scope(node) == module_scope
11334 && node.child_by_field_name("type_parameters").is_none()
11335 && declaration_name(node, source) == Some(expected_name)
11336 {
11337 if found.is_some() {
11338 return None;
11339 }
11340 found = Some(node);
11341 }
11342 push_named_children(node, &mut stack);
11343 }
11344 found
11345}
11346
11347fn rust_struct_field_type_node<'tree>(
11348 struct_node: tree_sitter::Node<'tree>,
11349 source: &str,
11350 field_name: &str,
11351) -> Option<tree_sitter::Node<'tree>> {
11352 let fields = struct_node.child_by_field_name("body")?;
11353 if fields.kind() != "field_declaration_list" {
11354 return None;
11355 }
11356 for index in 0..fields.named_child_count() {
11357 let field = fields.named_child(index as u32)?;
11358 if field.kind() != "field_declaration"
11359 || declaration_name(field, source) != Some(field_name)
11360 {
11361 continue;
11362 }
11363 return field.child_by_field_name("type");
11364 }
11365 None
11366}
11367
11368fn rust_receiver_looks_type_like(receiver: &str) -> bool {
11369 receiver
11370 .chars()
11371 .next()
11372 .is_some_and(|ch| ch == '_' || ch.is_uppercase())
11373}
11374
11375fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
11376 scoped_name
11377 .rsplit_once("::")
11378 .map(|(enclosing, _)| enclosing)
11379 .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
11380 .map(ToString::to_string)
11381}
11382
11383fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
11384 let params = signature_parameter_text(signature)?;
11385 for param in split_top_level_commas(params) {
11386 let Some((pattern, type_text)) = param.split_once(':') else {
11387 continue;
11388 };
11389 let Some(name) = rust_parameter_name(pattern) else {
11390 continue;
11391 };
11392 if name == receiver {
11393 return normalize_rust_receiver_type(type_text);
11394 }
11395 }
11396 None
11397}
11398
11399fn signature_parameter_text(signature: &str) -> Option<&str> {
11400 let open = signature.find('(')?;
11401 let mut depth = 0usize;
11402 for (offset, ch) in signature[open..].char_indices() {
11403 match ch {
11404 '(' => depth += 1,
11405 ')' => {
11406 depth = depth.saturating_sub(1);
11407 if depth == 0 {
11408 return Some(&signature[open + 1..open + offset]);
11409 }
11410 }
11411 _ => {}
11412 }
11413 }
11414 None
11415}
11416
11417fn split_top_level_commas(value: &str) -> Vec<&str> {
11418 let mut parts = Vec::new();
11419 let mut start = 0usize;
11420 let mut angle_depth = 0usize;
11421 let mut paren_depth = 0usize;
11422 let mut bracket_depth = 0usize;
11423 for (index, ch) in value.char_indices() {
11424 match ch {
11425 '<' => angle_depth += 1,
11426 '>' => angle_depth = angle_depth.saturating_sub(1),
11427 '(' => paren_depth += 1,
11428 ')' => paren_depth = paren_depth.saturating_sub(1),
11429 '[' => bracket_depth += 1,
11430 ']' => bracket_depth = bracket_depth.saturating_sub(1),
11431 ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
11432 let part = value[start..index].trim();
11433 if !part.is_empty() {
11434 parts.push(part);
11435 }
11436 start = index + ch.len_utf8();
11437 }
11438 _ => {}
11439 }
11440 }
11441 let part = value[start..].trim();
11442 if !part.is_empty() {
11443 parts.push(part);
11444 }
11445 parts
11446}
11447
11448fn rust_parameter_name(pattern: &str) -> Option<&str> {
11449 let mut pattern = pattern.trim();
11450 if let Some(stripped) = pattern.strip_prefix("mut ") {
11451 pattern = stripped.trim_start();
11452 }
11453 pattern
11454 .rsplit(|ch: char| !is_rust_ident_char(ch))
11455 .find(|part| !part.is_empty())
11456}
11457
11458fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
11459 let mut ty = strip_leading_rust_type_modifiers(type_text);
11460 let owned_inner;
11461 if let Some(inner) = single_outer_generic_arg(ty) {
11462 owned_inner = inner.trim().to_string();
11463 ty = strip_leading_rust_type_modifiers(&owned_inner);
11464 }
11465 rust_base_type_ident(ty)
11466}
11467
11468fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
11469 loop {
11470 ty = ty.trim_start();
11471 if let Some(stripped) = ty.strip_prefix('&') {
11472 ty = stripped.trim_start();
11473 if let Some(stripped) = strip_leading_lifetime(ty) {
11474 ty = stripped.trim_start();
11475 }
11476 if let Some(stripped) = ty.strip_prefix("mut ") {
11477 ty = stripped.trim_start();
11478 }
11479 continue;
11480 }
11481 if let Some(stripped) = ty.strip_prefix("mut ") {
11482 ty = stripped.trim_start();
11483 continue;
11484 }
11485 if let Some(stripped) = ty.strip_prefix("dyn ") {
11486 ty = stripped.trim_start();
11487 continue;
11488 }
11489 if let Some(stripped) = ty.strip_prefix("impl ") {
11490 ty = stripped.trim_start();
11491 continue;
11492 }
11493 break ty.trim();
11494 }
11495}
11496
11497fn strip_leading_lifetime(value: &str) -> Option<&str> {
11498 let mut chars = value.char_indices();
11499 let (_, first) = chars.next()?;
11500 if first != '\'' {
11501 return None;
11502 }
11503 for (index, ch) in chars {
11504 if !(ch == '_' || ch.is_ascii_alphanumeric()) {
11505 return Some(&value[index..]);
11506 }
11507 }
11508 Some("")
11509}
11510
11511fn single_outer_generic_arg(ty: &str) -> Option<&str> {
11512 let ty = ty.trim();
11513 let open = ty.find('<')?;
11514 let mut depth = 0usize;
11515 let mut close = None;
11516 for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
11517 match ch {
11518 '<' => depth += 1,
11519 '>' => {
11520 depth = depth.saturating_sub(1);
11521 if depth == 0 {
11522 close = Some(index);
11523 break;
11524 }
11525 }
11526 _ => {}
11527 }
11528 }
11529 let close = close?;
11530 if !ty[close + 1..].trim().is_empty() {
11531 return None;
11532 }
11533 let inner = &ty[open + 1..close];
11534 let args = split_top_level_commas(inner);
11535 match args.as_slice() {
11536 [arg] => Some(*arg),
11537 _ => None,
11538 }
11539}
11540
11541fn rust_base_type_ident(ty: &str) -> Option<String> {
11542 let ty = ty.trim();
11543 let head = ty
11544 .split([' ', '+', '='])
11545 .find(|part| !part.is_empty())
11546 .unwrap_or(ty);
11547 let head = head.split('<').next().unwrap_or(head).trim();
11548 let ident = head
11549 .rsplit("::")
11550 .next()
11551 .unwrap_or(head)
11552 .trim_matches(|ch: char| !is_rust_ident_char(ch));
11553 if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
11554 None
11555 } else {
11556 Some(ident.to_string())
11557 }
11558}
11559
11560fn is_rust_ident_char(ch: char) -> bool {
11561 ch == '_' || ch.is_ascii_alphanumeric()
11562}
11563
11564fn select_rust_direct_self_field_candidate(
11565 project_root: &Path,
11566 reference: &NameMatchRef,
11567 candidates: &[NameMatchCandidate],
11568 receiver_type: &str,
11569 declaration_file: &str,
11570 declaration_scope: &[(usize, usize)],
11571 source_cache: &mut DispatchSourceCache,
11572) -> Option<NameMatchCandidate> {
11573 let eligible = candidates
11574 .iter()
11575 .filter(|candidate| candidate.node_id != reference.caller_node)
11576 .filter(|candidate| {
11577 type_candidate_matches(candidate, receiver_type, &reference.method_name)
11578 })
11579 .filter(|candidate| {
11580 rust_direct_self_field_candidate_matches_scope(
11581 project_root,
11582 candidate,
11583 receiver_type,
11584 declaration_file,
11585 declaration_scope,
11586 source_cache,
11587 )
11588 })
11589 .collect::<Vec<_>>();
11590 match eligible.as_slice() {
11591 [candidate] => Some((**candidate).clone()),
11592 _ => None,
11593 }
11594}
11595
11596fn rust_direct_self_field_candidate_matches_scope(
11597 project_root: &Path,
11598 candidate: &NameMatchCandidate,
11599 receiver_type: &str,
11600 declaration_file: &str,
11601 declaration_scope: &[(usize, usize)],
11602 source_cache: &mut DispatchSourceCache,
11603) -> bool {
11604 if candidate.file_path != declaration_file {
11605 return false;
11606 }
11607 let Some(parsed) = parsed_dispatch_source_for_file(
11608 project_root,
11609 &candidate.file_path,
11610 "rust",
11611 LangId::Rust,
11612 source_cache,
11613 ) else {
11614 return false;
11615 };
11616 let Some(impl_node) =
11617 find_enclosing_rust_impl_node(parsed.tree.root_node(), candidate.start_line)
11618 else {
11619 return false;
11620 };
11621 if impl_node.child_by_field_name("trait").is_some()
11622 || impl_node.child_by_field_name("type_parameters").is_some()
11623 {
11624 return false;
11625 }
11626 let Some(impl_target) = impl_node.child_by_field_name("type") else {
11627 return false;
11628 };
11629 impl_target.kind() == "type_identifier"
11630 && node_text(impl_target, &parsed.source) == receiver_type
11631 && rust_module_scope(impl_node) == declaration_scope
11632}
11633
11634fn select_type_match_candidate(
11635 reference: &NameMatchRef,
11636 candidates: &[NameMatchCandidate],
11637 receiver_type: &str,
11638) -> Option<NameMatchCandidate> {
11639 let candidates = candidates
11640 .iter()
11641 .filter(|candidate| candidate.node_id != reference.caller_node)
11642 .filter(|candidate| {
11643 type_candidate_matches(candidate, receiver_type, &reference.method_name)
11644 })
11645 .collect::<Vec<_>>();
11646 match candidates.as_slice() {
11647 [candidate] => Some((**candidate).clone()),
11648 _ => None,
11649 }
11650}
11651
11652fn type_candidate_matches(
11653 candidate: &NameMatchCandidate,
11654 receiver_type: &str,
11655 method_name: &str,
11656) -> bool {
11657 let normalized_type = receiver_type.replace('.', "::");
11658 let suffix = format!("{normalized_type}::{method_name}");
11659 candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
11660}
11661
11662fn select_name_match_candidate(
11663 reference: &NameMatchRef,
11664 candidates: &[NameMatchCandidate],
11665) -> Option<NameMatchCandidate> {
11666 let candidates = candidates
11667 .iter()
11668 .filter(|candidate| candidate.node_id != reference.caller_node)
11669 .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
11670 .collect::<Vec<_>>();
11671 match candidates.as_slice() {
11672 [] => None,
11673 [candidate] => Some((**candidate).clone()),
11674 _ => select_scored_name_match_candidate(reference, &candidates),
11675 }
11676}
11677
11678fn candidate_allowed_for_reference(
11679 reference: &NameMatchRef,
11680 candidate: &NameMatchCandidate,
11681) -> bool {
11682 if !reference.colon_dispatch {
11683 return true;
11684 }
11685
11686 candidate.kind == "method"
11687 && candidate
11688 .scoped_name
11689 .split("::")
11690 .any(|segment| segment == reference.receiver)
11691}
11692
11693fn select_scored_name_match_candidate(
11694 reference: &NameMatchRef,
11695 candidates: &[&NameMatchCandidate],
11696) -> Option<NameMatchCandidate> {
11697 let receiver_words = split_camel_case(&reference.receiver);
11698 if receiver_words.is_empty() {
11699 return None;
11700 }
11701
11702 let mut best: Option<(&NameMatchCandidate, f64)> = None;
11703 let mut tied_best = false;
11704 for candidate in candidates {
11705 let candidate_words = split_camel_case(&candidate.scoped_name);
11706 let overlap = receiver_words
11707 .iter()
11708 .filter(|receiver_word| {
11709 candidate_words
11710 .iter()
11711 .any(|candidate_word| candidate_word == *receiver_word)
11712 })
11713 .count() as f64;
11714 let score =
11715 overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
11716 match best {
11717 None => {
11718 best = Some((*candidate, score));
11719 tied_best = false;
11720 }
11721 Some((_, best_score)) if score > best_score => {
11722 best = Some((*candidate, score));
11723 tied_best = false;
11724 }
11725 Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
11726 tied_best = true;
11727 }
11728 _ => {}
11729 }
11730 }
11731
11732 let (candidate, score) = best?;
11733 if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
11734 Some(candidate.clone())
11735 } else {
11736 None
11737 }
11738}
11739
11740fn method_name_match_denylisted(method_name: &str) -> bool {
11741 matches!(
11742 method_name,
11743 "and_then"
11744 | "as_bytes"
11745 | "as_deref"
11746 | "as_mut"
11747 | "as_ref"
11748 | "as_str"
11749 | "borrow"
11750 | "borrow_mut"
11751 | "clear"
11752 | "clone"
11753 | "collect"
11754 | "contains"
11755 | "contains_key"
11756 | "count"
11757 | "dedup"
11758 | "default"
11759 | "drain"
11760 | "ends_with"
11761 | "entry"
11762 | "err"
11763 | "expect"
11764 | "extend"
11765 | "filter"
11766 | "filter_map"
11767 | "find"
11768 | "from"
11769 | "get"
11770 | "get_mut"
11771 | "insert"
11772 | "into"
11773 | "into_iter"
11774 | "is_empty"
11775 | "is_err"
11776 | "is_none"
11777 | "is_ok"
11778 | "is_some"
11779 | "iter"
11780 | "iter_mut"
11781 | "join"
11782 | "len"
11783 | "lock"
11784 | "map"
11785 | "map_err"
11786 | "max"
11787 | "min"
11788 | "new"
11789 | "next"
11790 | "ok"
11791 | "or_default"
11792 | "or_else"
11793 | "or_insert"
11794 | "or_insert_with"
11795 | "parse"
11796 | "pop"
11797 | "position"
11798 | "push"
11799 | "read"
11800 | "recv"
11801 | "remove"
11802 | "replace"
11803 | "retain"
11804 | "send"
11805 | "sort"
11806 | "sort_by"
11807 | "split"
11808 | "starts_with"
11809 | "sum"
11810 | "take"
11811 | "to_owned"
11812 | "to_string"
11813 | "trim"
11814 | "try_from"
11815 | "try_into"
11816 | "unwrap"
11817 | "unwrap_or"
11818 | "unwrap_or_default"
11819 | "unwrap_or_else"
11820 | "with_capacity"
11821 | "write"
11822 )
11823}
11824
11825fn split_camel_case(value: &str) -> Vec<String> {
11826 let chars = value.chars().collect::<Vec<_>>();
11827 let mut normalized = String::with_capacity(value.len() + 8);
11828 for (index, ch) in chars.iter().enumerate() {
11829 let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
11830 let next = chars.get(index + 1);
11831 let is_separator = ch.is_whitespace()
11832 || matches!(
11833 ch,
11834 '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
11835 );
11836 if is_separator {
11837 normalized.push(' ');
11838 continue;
11839 }
11840 let camel_boundary = previous.is_some_and(|prev| {
11841 (prev.is_lowercase() && ch.is_uppercase())
11842 || (prev.is_ascii_digit() && ch.is_alphabetic())
11843 || (prev.is_uppercase()
11844 && ch.is_uppercase()
11845 && next.is_some_and(|next| next.is_lowercase()))
11846 });
11847 if camel_boundary {
11848 normalized.push(' ');
11849 }
11850 normalized.push(*ch);
11851 }
11852
11853 normalized
11854 .split_whitespace()
11855 .filter(|word| word.len() > 1)
11856 .map(|word| word.to_ascii_lowercase())
11857 .collect()
11858}
11859
11860fn compute_path_proximity(left: &str, right: &str) -> f64 {
11861 let left_dirs = left
11862 .rsplit_once('/')
11863 .map(|(dir, _)| dir)
11864 .unwrap_or_default()
11865 .split('/')
11866 .filter(|part| !part.is_empty());
11867 let right_dirs = right
11868 .rsplit_once('/')
11869 .map(|(dir, _)| dir)
11870 .unwrap_or_default()
11871 .split('/')
11872 .filter(|part| !part.is_empty());
11873
11874 let shared = left_dirs
11875 .zip(right_dirs)
11876 .take_while(|(left, right)| left == right)
11877 .count();
11878 ((shared as f64) * 0.05).min(0.5)
11879}
11880
11881fn mark_backend_state(
11882 tx: &Transaction<'_>,
11883 project_root: &Path,
11884 rel_path: &str,
11885 content_hash: Option<&blake3::Hash>,
11886 status: &str,
11887) -> Result<()> {
11888 clear_backend_state_for_file(tx, project_root, rel_path)?;
11889 let hash = content_hash
11890 .map(|hash| hash_to_hex(*hash))
11891 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
11892 tx.execute(
11893 "INSERT OR REPLACE INTO backend_file_state(
11894 backend, workspace_root, file_path, content_hash, status, updated_at
11895 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
11896 params![
11897 BACKEND_TREESITTER,
11898 project_root.display().to_string(),
11899 rel_path,
11900 hash,
11901 status,
11902 unix_seconds_now(),
11903 ],
11904 )?;
11905 Ok(())
11906}
11907
11908fn clear_backend_state_for_file(
11909 tx: &Transaction<'_>,
11910 project_root: &Path,
11911 rel_path: &str,
11912) -> Result<()> {
11913 tx.execute(
11914 "DELETE FROM backend_file_state
11915 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
11916 params![
11917 BACKEND_TREESITTER,
11918 project_root.display().to_string(),
11919 rel_path
11920 ],
11921 )?;
11922 Ok(())
11923}
11924
11925fn clear_stale_backend_status_for_file(
11931 tx: &Transaction<'_>,
11932 project_root: &Path,
11933 rel_path: &str,
11934) -> Result<()> {
11935 tx.execute(
11936 "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
11937 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3 AND status = 'stale'",
11938 params![
11939 BACKEND_TREESITTER,
11940 project_root.display().to_string(),
11941 rel_path,
11942 unix_seconds_now(),
11943 ],
11944 )?;
11945 Ok(())
11946}
11947
11948fn load_file_row(conn: &Connection, rel_path: &str) -> Result<Option<FileRow>> {
11949 conn.query_row(
11950 "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
11951 params![rel_path],
11952 |row| {
11953 let hash_text: String = row.get(1)?;
11954 Ok(FileRow {
11955 surface_fingerprint: row.get(0)?,
11956 freshness: FileFreshness {
11957 content_hash: hash_from_hex(&hash_text)
11958 .unwrap_or_else(cache_freshness::zero_hash),
11959 mtime: ns_to_system_time(row.get::<_, i64>(2)?),
11960 size: row.get::<_, i64>(3)? as u64,
11961 },
11962 })
11963 },
11964 )
11965 .optional()
11966 .map_err(CallGraphStoreError::from)
11967}
11968
11969fn stored_node_ids_match_extract(
11970 tx: &Transaction<'_>,
11971 rel_path: &str,
11972 extract: &FileExtract,
11973) -> Result<bool> {
11974 let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
11975 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
11976 let mut stored = BTreeSet::new();
11977 for row in rows {
11978 stored.insert(row?);
11979 }
11980 let extracted = extract
11981 .nodes
11982 .iter()
11983 .map(|node| node.id.clone())
11984 .collect::<BTreeSet<_>>();
11985 Ok(stored == extracted)
11986}
11987
11988fn stored_extract_matches(
11992 tx: &Transaction<'_>,
11993 rel_path: &str,
11994 extract: &FileExtract,
11995 index: &ProjectIndex<'_>,
11996) -> Result<bool> {
11997 let stored_file = tx
11998 .query_row(
11999 "SELECT lang, surface_fingerprint FROM files WHERE path = ?1",
12000 params![rel_path],
12001 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
12002 )
12003 .optional()?;
12004 if stored_file
12005 != Some((
12006 lang_label(extract.lang).to_string(),
12007 extract.surface_fingerprint.clone(),
12008 ))
12009 {
12010 return Ok(false);
12011 }
12012
12013 let mut stored_nodes_stmt = tx.prepare(
12014 "SELECT id, file_path, name, scoped_name, kind, start_line, start_col,
12015 end_line, end_col, range_ordinal, signature, exported,
12016 is_default_export, is_type_like, is_callgraph_entry_point, provenance
12017 FROM nodes WHERE file_path = ?1",
12018 )?;
12019 let stored_nodes = stored_nodes_stmt
12020 .query_map(params![rel_path], |row| {
12021 Ok(serde_json::json!([
12022 row.get::<_, String>(0)?,
12023 row.get::<_, String>(1)?,
12024 row.get::<_, String>(2)?,
12025 row.get::<_, String>(3)?,
12026 row.get::<_, String>(4)?,
12027 row.get::<_, i64>(5)?,
12028 row.get::<_, i64>(6)?,
12029 row.get::<_, i64>(7)?,
12030 row.get::<_, i64>(8)?,
12031 row.get::<_, i64>(9)?,
12032 row.get::<_, Option<String>>(10)?,
12033 row.get::<_, i64>(11)?,
12034 row.get::<_, i64>(12)?,
12035 row.get::<_, i64>(13)?,
12036 row.get::<_, i64>(14)?,
12037 row.get::<_, String>(15)?,
12038 ])
12039 .to_string())
12040 })?
12041 .collect::<rusqlite::Result<Vec<_>>>()?;
12042 let expected_nodes = extract
12043 .nodes
12044 .iter()
12045 .map(|node| {
12046 serde_json::json!([
12047 node.id,
12048 node.file_path,
12049 node.name,
12050 node.scoped_name,
12051 node.kind,
12052 node.range.start_line,
12053 node.range.start_col,
12054 node.range.end_line,
12055 node.range.end_col,
12056 node.range_ordinal,
12057 node.signature,
12058 bool_int(node.exported),
12059 bool_int(node.is_default_export),
12060 bool_int(node.is_type_like),
12061 bool_int(node.is_callgraph_entry_point),
12062 PROVENANCE_TREESITTER,
12063 ])
12064 .to_string()
12065 })
12066 .collect::<Vec<_>>();
12067 let mut stored_nodes = stored_nodes;
12068 let mut expected_nodes = expected_nodes;
12069 stored_nodes.sort();
12070 expected_nodes.sort();
12071 if stored_nodes != expected_nodes {
12072 return Ok(false);
12073 }
12074
12075 let resolved_refs = extract
12076 .raw_refs
12077 .iter()
12078 .cloned()
12079 .map(|raw| resolve_ref(raw, index))
12080 .collect::<Result<Vec<_>>>()?;
12081 let mut stored_refs_stmt = tx.prepare(
12082 "SELECT ref_id, caller_node, caller_file, kind, short_name, full_ref,
12083 module_path, import_kind, local_name, requested_name, namespace_alias,
12084 wildcard, line, byte_start, byte_end, status, target_node,
12085 target_file, target_symbol, provenance
12086 FROM refs WHERE caller_file = ?1",
12087 )?;
12088 let stored_refs = stored_refs_stmt
12089 .query_map(params![rel_path], |row| {
12090 Ok(serde_json::json!([
12091 row.get::<_, String>(0)?,
12092 row.get::<_, Option<String>>(1)?,
12093 row.get::<_, String>(2)?,
12094 row.get::<_, String>(3)?,
12095 row.get::<_, Option<String>>(4)?,
12096 row.get::<_, Option<String>>(5)?,
12097 row.get::<_, Option<String>>(6)?,
12098 row.get::<_, Option<String>>(7)?,
12099 row.get::<_, Option<String>>(8)?,
12100 row.get::<_, Option<String>>(9)?,
12101 row.get::<_, Option<String>>(10)?,
12102 row.get::<_, i64>(11)?,
12103 row.get::<_, i64>(12)?,
12104 row.get::<_, i64>(13)?,
12105 row.get::<_, i64>(14)?,
12106 row.get::<_, String>(15)?,
12107 row.get::<_, Option<String>>(16)?,
12108 row.get::<_, Option<String>>(17)?,
12109 row.get::<_, Option<String>>(18)?,
12110 row.get::<_, String>(19)?,
12111 ])
12112 .to_string())
12113 })?
12114 .collect::<rusqlite::Result<Vec<_>>>()?;
12115 let expected_refs = resolved_refs
12116 .iter()
12117 .map(|resolved| {
12118 let raw = &resolved.raw;
12119 serde_json::json!([
12120 raw.ref_id,
12121 raw.caller_node,
12122 raw.caller_file,
12123 raw.kind,
12124 raw.short_name,
12125 raw.full_ref,
12126 raw.module_path,
12127 raw.import_kind,
12128 raw.local_name,
12129 raw.requested_name,
12130 raw.namespace_alias,
12131 bool_int(raw.wildcard),
12132 raw.line,
12133 raw.byte_start,
12134 raw.byte_end,
12135 resolved.status,
12136 resolved.target_node,
12137 resolved.target_file,
12138 resolved.target_symbol,
12139 PROVENANCE_TREESITTER,
12140 ])
12141 .to_string()
12142 })
12143 .collect::<Vec<_>>();
12144 let mut stored_refs = stored_refs;
12145 let mut expected_refs = expected_refs;
12146 stored_refs.sort();
12147 expected_refs.sort();
12148 if stored_refs != expected_refs {
12149 return Ok(false);
12150 }
12151
12152 let mut stored_edges_stmt = tx.prepare(
12153 "SELECT e.edge_id, e.ref_id, e.source_node, e.target_node,
12154 e.target_file, e.target_symbol, e.kind, e.line, e.provenance
12155 FROM edges e JOIN refs r ON r.ref_id = e.ref_id
12156 WHERE r.caller_file = ?1 AND e.provenance = ?2",
12157 )?;
12158 let stored_edges = stored_edges_stmt
12159 .query_map(params![rel_path, PROVENANCE_TREESITTER], |row| {
12160 Ok(serde_json::json!([
12161 row.get::<_, String>(0)?,
12162 row.get::<_, String>(1)?,
12163 row.get::<_, String>(2)?,
12164 row.get::<_, Option<String>>(3)?,
12165 row.get::<_, String>(4)?,
12166 row.get::<_, String>(5)?,
12167 row.get::<_, String>(6)?,
12168 row.get::<_, i64>(7)?,
12169 row.get::<_, String>(8)?,
12170 ])
12171 .to_string())
12172 })?
12173 .collect::<rusqlite::Result<Vec<_>>>()?;
12174 let expected_edges = resolved_refs
12175 .iter()
12176 .filter_map(|resolved| {
12177 resolved.edge.as_ref().map(|edge| {
12178 serde_json::json!([
12179 edge.edge_id,
12180 resolved.raw.ref_id,
12181 edge.source_node,
12182 edge.target_node,
12183 edge.target_file,
12184 edge.target_symbol,
12185 edge.kind,
12186 edge.line,
12187 PROVENANCE_TREESITTER,
12188 ])
12189 .to_string()
12190 })
12191 })
12192 .collect::<Vec<_>>();
12193 let mut stored_edges = stored_edges;
12194 let mut expected_edges = expected_edges;
12195 stored_edges.sort();
12196 expected_edges.sort();
12197 if stored_edges != expected_edges {
12198 return Ok(false);
12199 }
12200
12201 let mut stored_dependencies_stmt =
12202 tx.prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1")?;
12203 let stored_dependencies = stored_dependencies_stmt
12204 .query_map(params![rel_path], |row| row.get::<_, String>(0))?
12205 .collect::<rusqlite::Result<BTreeSet<_>>>()?;
12206 let expected_dependencies = extract
12207 .raw_refs
12208 .iter()
12209 .flat_map(|raw| raw.dependencies.iter().cloned())
12210 .collect::<BTreeSet<_>>();
12211 if stored_dependencies != expected_dependencies {
12212 return Ok(false);
12213 }
12214
12215 let mut stored_hints_stmt = tx.prepare(
12216 "SELECT id, method_name, caller_node, file, line, byte_start, byte_end, provenance
12217 FROM dispatch_hints WHERE file = ?1",
12218 )?;
12219 let stored_hints = stored_hints_stmt
12220 .query_map(params![rel_path], |row| {
12221 Ok(serde_json::json!([
12222 row.get::<_, String>(0)?,
12223 row.get::<_, String>(1)?,
12224 row.get::<_, String>(2)?,
12225 row.get::<_, String>(3)?,
12226 row.get::<_, i64>(4)?,
12227 row.get::<_, i64>(5)?,
12228 row.get::<_, i64>(6)?,
12229 row.get::<_, String>(7)?,
12230 ])
12231 .to_string())
12232 })?
12233 .collect::<rusqlite::Result<Vec<_>>>()?;
12234 let expected_hints = extract
12235 .dispatch_hints
12236 .iter()
12237 .map(|hint| {
12238 serde_json::json!([
12239 hint.id,
12240 hint.method_name,
12241 hint.caller_node,
12242 hint.file,
12243 hint.line,
12244 hint.byte_start,
12245 hint.byte_end,
12246 PROVENANCE_TREESITTER,
12247 ])
12248 .to_string()
12249 })
12250 .collect::<Vec<_>>();
12251 let mut stored_hints = stored_hints;
12252 let mut expected_hints = expected_hints;
12253 stored_hints.sort();
12254 expected_hints.sort();
12255 Ok(stored_hints == expected_hints)
12256}
12257
12258fn update_file_fresh_metadata(
12259 tx: &Transaction<'_>,
12260 project_root: &Path,
12261 rel_path: &str,
12262 hash: &blake3::Hash,
12263 mtime: SystemTime,
12264 size: u64,
12265) -> Result<()> {
12266 tx.execute(
12267 "UPDATE files SET content_hash = ?2, mtime_ns = ?3, size = ?4, indexed_at = ?5
12268 WHERE path = ?1",
12269 params![
12270 rel_path,
12271 hash_to_hex(*hash),
12272 system_time_to_ns(mtime),
12273 size as i64,
12274 unix_seconds_now()
12275 ],
12276 )?;
12277 tx.execute(
12278 "UPDATE backend_file_state SET content_hash = ?3, status = 'fresh', updated_at = ?5
12279 WHERE backend = ?1 AND file_path = ?2 AND workspace_root = ?4",
12280 params![
12281 BACKEND_TREESITTER,
12282 rel_path,
12283 hash_to_hex(*hash),
12284 project_root.display().to_string(),
12285 unix_seconds_now(),
12286 ],
12287 )?;
12288 Ok(())
12289}
12290
12291#[derive(Debug, Clone, PartialEq, Eq)]
12292struct DependentRefSelection {
12293 ref_id: String,
12294 caller_file: String,
12295}
12296
12297fn ref_ids_depending_on(
12298 conn: &Connection,
12299 project_root: &Path,
12300 rel_path: &str,
12301) -> Result<Vec<DependentRefSelection>> {
12302 let mut stmt = conn.prepare(
12303 "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
12304 FROM refs r
12305 WHERE r.caller_file IN (
12306 SELECT file_path FROM file_dependencies WHERE dep_file = ?1
12307 )
12308 OR r.target_file = ?1
12309 ORDER BY r.ref_id",
12310 )?;
12311 let rows = stmt.query_map(params![rel_path], |row| {
12312 Ok(RefDependencyRow {
12313 ref_id: row.get(0)?,
12314 kind: row.get(1)?,
12315 caller_file: row.get(2)?,
12316 module_path: row.get(3)?,
12317 target_file: row.get(4)?,
12318 })
12319 })?;
12320 let mut ids = Vec::new();
12321 for row in rows {
12322 let row = row?;
12323 if ref_dependency_row_depends_on(project_root, &row, rel_path) {
12324 ids.push(DependentRefSelection {
12325 ref_id: row.ref_id,
12326 caller_file: row.caller_file,
12327 });
12328 }
12329 }
12330 Ok(ids)
12331}
12332
12333fn record_dependent_refs(
12334 selected_ref_ids: &mut BTreeSet<String>,
12335 selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
12336 dependent_refs: Vec<DependentRefSelection>,
12337) {
12338 for dependent_ref in dependent_refs {
12339 let DependentRefSelection {
12340 ref_id,
12341 caller_file,
12342 } = dependent_ref;
12343 selected_ref_ids.insert(ref_id.clone());
12344 selected_refs_by_caller
12345 .entry(caller_file)
12346 .or_default()
12347 .insert(ref_id);
12348 }
12349}
12350
12351#[cfg(test)]
12352fn refs_by_caller_for_ref_ids(
12353 tx: &Transaction<'_>,
12354 ref_ids: &BTreeSet<String>,
12355) -> Result<BTreeMap<String, BTreeSet<String>>> {
12356 let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12357 let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
12358 for ref_id in ref_ids {
12359 if let Some(caller) = stmt
12360 .query_row(params![ref_id], |row| row.get::<_, String>(0))
12361 .optional()?
12362 {
12363 by_caller.entry(caller).or_default().insert(ref_id.clone());
12364 }
12365 }
12366 Ok(by_caller)
12367}
12368
12369fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
12370 tx.execute(
12371 "DELETE FROM file_dependencies WHERE file_path = ?1",
12372 params![rel_path],
12373 )?;
12374 delete_refs_for_caller(tx, rel_path)?;
12375 tx.execute(
12376 "DELETE FROM dispatch_hints WHERE file = ?1",
12377 params![rel_path],
12378 )?;
12379 tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
12380 tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
12381 Ok(())
12382}
12383
12384fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
12385 let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
12386 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
12387 let mut ids = BTreeSet::new();
12388 for row in rows {
12389 ids.insert(row?);
12390 }
12391 delete_ref_ids(tx, &ids)
12392}
12393
12394fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
12395 let mut delete_edges = tx.prepare("DELETE FROM edges WHERE ref_id = ?1")?;
12396 let mut delete_refs = tx.prepare("DELETE FROM refs WHERE ref_id = ?1")?;
12397 for ref_id in ref_ids {
12398 delete_edges.execute(params![ref_id])?;
12399 delete_refs.execute(params![ref_id])?;
12400 }
12401 Ok(())
12402}
12403
12404fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
12405 let mut stmt = conn.prepare(
12406 "SELECT source.file_path, source.scoped_name, edges.target_file,
12407 edges.target_symbol, edges.kind, edges.line
12408 FROM edges
12409 JOIN nodes AS source ON source.id = edges.source_node
12410 ORDER BY source.file_path, source.scoped_name, edges.target_file,
12411 edges.target_symbol, edges.kind, edges.line",
12412 )?;
12413 let rows = stmt.query_map([], |row| {
12414 Ok(StoredEdge {
12415 source_file: row.get(0)?,
12416 source_symbol: row.get(1)?,
12417 target_file: row.get(2)?,
12418 target_symbol: row.get(3)?,
12419 kind: row.get(4)?,
12420 line: row.get::<_, i64>(5)? as u32,
12421 })
12422 })?;
12423 let mut edges = BTreeSet::new();
12424 for row in rows {
12425 edges.insert(row?);
12426 }
12427 Ok(edges)
12428}
12429
12430fn module_target_from_dependencies(
12431 project_root: &Path,
12432 dependencies: &BTreeSet<String>,
12433) -> Option<String> {
12434 dependencies.iter().find_map(|dep| {
12435 let path = project_root.join(dep);
12436 if path.is_file() {
12437 Some(relative_path(project_root, &canonicalize_path(&path)))
12438 } else {
12439 None
12440 }
12441 })
12442}
12443
12444fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
12445 let mut named = HashMap::new();
12446 if let Some(full_ref) = &raw_ref.full_ref {
12447 named = parse_reexport_names(full_ref);
12448 }
12449 ReexportIndex {
12450 target_file,
12451 named,
12452 wildcard: raw_ref.wildcard,
12453 }
12454}
12455
12456fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
12457 let mut names = HashMap::new();
12458 let Some(open) = statement.find('{') else {
12459 return names;
12460 };
12461 let Some(close) = statement[open + 1..]
12462 .find('}')
12463 .map(|offset| open + 1 + offset)
12464 else {
12465 return names;
12466 };
12467 for spec in statement[open + 1..close].split(',') {
12468 let spec = spec.trim();
12469 if spec.is_empty() {
12470 continue;
12471 }
12472 if let Some((source, local)) = spec.split_once(" as ") {
12473 names.insert(local.trim().to_string(), source.trim().to_string());
12474 } else {
12475 names.insert(spec.to_string(), spec.to_string());
12476 }
12477 }
12478 names
12479}
12480
12481#[derive(Debug)]
12482struct RefDependencyRow {
12483 ref_id: String,
12484 kind: String,
12485 caller_file: String,
12486 module_path: Option<String>,
12487 target_file: Option<String>,
12488}
12489
12490fn ref_dependency_row_depends_on(
12491 project_root: &Path,
12492 row: &RefDependencyRow,
12493 rel_path: &str,
12494) -> bool {
12495 if row.target_file.as_deref() == Some(rel_path) {
12496 return true;
12497 }
12498
12499 match row.kind.as_str() {
12500 "call" => true,
12501 "import" | "reexport" => row
12502 .module_path
12503 .as_deref()
12504 .map(|module_path| {
12505 module_dependencies_for_ref(project_root, &row.caller_file, module_path)
12506 .contains(rel_path)
12507 })
12508 .unwrap_or(false),
12509 "export_alias" => false,
12510 _ => false,
12511 }
12512}
12513
12514fn module_dependencies_for_ref(
12515 project_root: &Path,
12516 caller_file: &str,
12517 module_path: &str,
12518) -> BTreeSet<String> {
12519 module_dependencies(project_root, &project_root.join(caller_file), module_path)
12520}
12521
12522fn import_dependencies(
12523 project_root: &Path,
12524 abs_path: &Path,
12525 imports: &[ImportStatement],
12526) -> BTreeSet<String> {
12527 let mut deps = BTreeSet::new();
12528 for import in imports {
12529 deps.extend(module_dependencies(
12530 project_root,
12531 abs_path,
12532 &import.module_path,
12533 ));
12534 }
12535 deps
12536}
12537
12538fn module_dependencies(
12539 project_root: &Path,
12540 abs_path: &Path,
12541 module_path: &str,
12542) -> BTreeSet<String> {
12543 let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
12544 let caller_dir = abs_path.parent().unwrap_or(project_root);
12545 if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
12546 deps.insert(relative_path(project_root, &resolved));
12547 }
12548 if module_path.starts_with('.') {
12549 let base = caller_dir.join(module_path);
12550 for candidate in relative_module_candidates(&base) {
12551 deps.insert(relative_path(project_root, &candidate));
12552 }
12553 }
12554 deps
12555}
12556
12557fn rust_module_dependencies(
12558 project_root: &Path,
12559 abs_path: &Path,
12560 module_path: &str,
12561) -> BTreeSet<String> {
12562 let mut deps = BTreeSet::new();
12563 let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
12564 let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
12565 return deps;
12566 };
12567 let src_prefix = rust_src_prefix(&rel_path);
12568 rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
12569 if !path_segments.is_empty() {
12570 rust_push_module_dependency_candidate(
12571 project_root,
12572 &mut deps,
12573 &src_prefix,
12574 &path_segments[..path_segments.len() - 1],
12575 );
12576 }
12577 deps
12578}
12579
12580fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
12581 let path = rust_module_path_without_alias_or_use_list(module_path);
12582 let segments = path
12583 .split("::")
12584 .map(str::trim)
12585 .filter(|segment| !segment.is_empty())
12586 .collect::<Vec<_>>();
12587 if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
12588 return None;
12589 }
12590 rust_resolve_segments(rel_path, &segments)
12591}
12592
12593fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
12594 let path = module_path
12595 .trim()
12596 .trim_end_matches(';')
12597 .split_once(" as ")
12598 .map(|(left, _)| left.trim())
12599 .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
12600 path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
12601}
12602
12603fn rust_push_module_dependency_candidate(
12604 project_root: &Path,
12605 deps: &mut BTreeSet<String>,
12606 src_prefix: &str,
12607 segments: &[String],
12608) {
12609 let candidates = if segments.is_empty() {
12610 vec![
12611 format!("{src_prefix}/lib.rs"),
12612 format!("{src_prefix}/main.rs"),
12613 ]
12614 } else {
12615 vec![
12616 format!("{}/{}.rs", src_prefix, segments.join("/")),
12617 format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
12618 ]
12619 };
12620 for candidate in candidates {
12621 if project_root.join(&candidate).is_file() {
12622 deps.insert(candidate);
12623 }
12624 }
12625}
12626
12627fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
12628 let mut candidates = Vec::new();
12629 if base.extension().is_some() {
12630 candidates.push(base.to_path_buf());
12631 return candidates;
12632 }
12633 for ext in JS_TS_EXTENSIONS {
12634 candidates.push(base.with_extension(ext));
12635 }
12636 for ext in JS_TS_EXTENSIONS {
12637 candidates.push(base.join(format!("index.{ext}")));
12638 }
12639 candidates
12640}
12641
12642fn import_local_names(import: &ImportStatement) -> Vec<String> {
12643 let mut names = Vec::new();
12644 if let Some(default) = &import.default_import {
12645 names.push(default.clone());
12646 }
12647 if let Some(namespace) = &import.namespace_import {
12648 names.push(namespace.clone());
12649 }
12650 for name in &import.names {
12651 names.push(crate::imports::specifier_local_name(name).to_string());
12652 }
12653 names
12654}
12655
12656fn import_requested_names(import: &ImportStatement) -> Vec<String> {
12657 import
12658 .names
12659 .iter()
12660 .map(|name| crate::imports::specifier_imported_name(name).to_string())
12661 .collect()
12662}
12663
12664fn import_is_wildcard(import: &ImportStatement) -> bool {
12665 import.namespace_import.is_some() || import.raw_text.contains('*')
12666}
12667
12668fn namespace_alias(full_ref: &str) -> Option<String> {
12669 full_ref
12670 .split_once('.')
12671 .map(|(namespace, _)| namespace.to_string())
12672}
12673
12674fn import_kind_label(kind: ImportKind) -> &'static str {
12675 match kind {
12676 ImportKind::Value => "value",
12677 ImportKind::Type => "type",
12678 ImportKind::SideEffect => "side_effect",
12679 }
12680}
12681
12682fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
12683 match kind {
12684 SymbolKind::Function => "function",
12685 SymbolKind::Class => "class",
12686 SymbolKind::Method => "method",
12687 SymbolKind::Struct => "struct",
12688 SymbolKind::Interface => "interface",
12689 SymbolKind::Enum => "enum",
12690 SymbolKind::TypeAlias => "type_alias",
12691 SymbolKind::Variable => "variable",
12692 SymbolKind::Heading => "heading",
12693 SymbolKind::FileSummary => "file_summary",
12694 }
12695}
12696
12697fn is_type_like(kind: &SymbolKind) -> bool {
12698 matches!(
12699 kind,
12700 SymbolKind::Class
12701 | SymbolKind::Struct
12702 | SymbolKind::Interface
12703 | SymbolKind::Enum
12704 | SymbolKind::TypeAlias
12705 )
12706}
12707
12708fn lang_label(lang: LangId) -> &'static str {
12709 match lang {
12710 LangId::TypeScript => "typescript",
12711 LangId::Tsx => "tsx",
12712 LangId::JavaScript => "javascript",
12713 LangId::Python => "python",
12714 LangId::Rust => "rust",
12715 LangId::Go => "go",
12716 LangId::C => "c",
12717 LangId::Cpp => "cpp",
12718 LangId::Zig => "zig",
12719 LangId::CSharp => "csharp",
12720 LangId::Bash => "bash",
12721 LangId::Html => "html",
12722 LangId::Markdown => "markdown",
12723 LangId::Solidity => "solidity",
12724 LangId::Scss => "scss",
12725 LangId::Vue => "vue",
12726 LangId::Json => "json",
12727 LangId::Scala => "scala",
12728 LangId::Java => "java",
12729 LangId::Ruby => "ruby",
12730 LangId::Kotlin => "kotlin",
12731 LangId::Swift => "swift",
12732 LangId::Php => "php",
12733 LangId::Lua => "lua",
12734 LangId::Perl => "perl",
12735 LangId::Yaml => "yaml",
12736 LangId::Pascal => "pascal",
12737 LangId::R => "r",
12738 LangId::Groovy => "groovy",
12739 LangId::ObjC => "objc",
12740 }
12741}
12742
12743fn lang_from_label(label: &str) -> Option<LangId> {
12744 match label {
12745 "typescript" => Some(LangId::TypeScript),
12746 "tsx" => Some(LangId::Tsx),
12747 "javascript" => Some(LangId::JavaScript),
12748 "python" => Some(LangId::Python),
12749 "rust" => Some(LangId::Rust),
12750 "go" => Some(LangId::Go),
12751 "c" => Some(LangId::C),
12752 "cpp" => Some(LangId::Cpp),
12753 "zig" => Some(LangId::Zig),
12754 "csharp" => Some(LangId::CSharp),
12755 "bash" => Some(LangId::Bash),
12756 "html" => Some(LangId::Html),
12757 "markdown" => Some(LangId::Markdown),
12758 "solidity" => Some(LangId::Solidity),
12759 "scss" => Some(LangId::Scss),
12760 "vue" => Some(LangId::Vue),
12761 "json" => Some(LangId::Json),
12762 "scala" => Some(LangId::Scala),
12763 "java" => Some(LangId::Java),
12764 "ruby" => Some(LangId::Ruby),
12765 "kotlin" => Some(LangId::Kotlin),
12766 "swift" => Some(LangId::Swift),
12767 "php" => Some(LangId::Php),
12768 "lua" => Some(LangId::Lua),
12769 "perl" => Some(LangId::Perl),
12770 "yaml" => Some(LangId::Yaml),
12771 "pascal" => Some(LangId::Pascal),
12772 "r" => Some(LangId::R),
12773 "groovy" => Some(LangId::Groovy),
12774 "objc" => Some(LangId::ObjC),
12775 _ => None,
12776 }
12777}
12778
12779fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
12780 let mut normalized = if files.is_empty() {
12781 callgraph::walk_project_files(project_root).collect::<Vec<_>>()
12782 } else {
12783 files
12784 .iter()
12785 .map(|path| normalize_file_path(project_root, path))
12786 .collect::<Result<Vec<_>>>()?
12787 };
12788 normalized.sort();
12789 normalized.dedup();
12790 Ok(normalized)
12791}
12792
12793fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
12794 let full_path = if path.is_relative() {
12795 project_root.join(path)
12796 } else {
12797 path.to_path_buf()
12798 };
12799 Ok(canonicalize_path(&full_path))
12800}
12801
12802fn canonicalize_path(path: &Path) -> PathBuf {
12803 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
12804}
12805
12806fn relative_path(project_root: &Path, path: &Path) -> String {
12807 if let Ok(stripped) = path.strip_prefix(project_root) {
12808 return stripped.to_string_lossy().replace('\\', "/");
12809 }
12810 let canon_root = canonicalize_path(project_root);
12811 let canon_path = canonicalize_path(path);
12812 if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
12813 return stripped.to_string_lossy().replace('\\', "/");
12814 }
12815 canon_path.to_string_lossy().replace('\\', "/")
12816}
12817
12818fn unqualified_name(scoped: &str) -> &str {
12819 if scoped == TOP_LEVEL_SYMBOL {
12820 return scoped;
12821 }
12822 scoped
12823 .rsplit("::")
12824 .next()
12825 .unwrap_or(scoped)
12826 .rsplit('.')
12827 .next()
12828 .unwrap_or(scoped)
12829 .rsplit('#')
12830 .next()
12831 .unwrap_or(scoped)
12832}
12833
12834fn ref_id(parts: &[&str]) -> String {
12835 let joined = parts.join("\0");
12836 hash_to_hex(blake3::hash(joined.as_bytes()))
12837}
12838
12839fn callgraph_corpus_fingerprint(project_root: &Path) -> Result<String> {
12840 let mut fingerprint = CorpusFingerprint::default();
12841 for path in callgraph::walk_project_files(project_root) {
12842 fingerprint.add_path(project_root, &path);
12843 }
12844 Ok(fingerprint.finish(project_root))
12845}
12846
12847fn corpus_fingerprint_for(project_root: &Path, files: &[PathBuf]) -> Result<String> {
12851 if files.is_empty() {
12852 return callgraph_corpus_fingerprint(project_root);
12853 }
12854 let mut fingerprint = CorpusFingerprint::default();
12855 for path in files {
12856 fingerprint.add_path(project_root, path);
12857 }
12858 Ok(fingerprint.finish(project_root))
12859}
12860
12861#[derive(Default)]
12862struct CorpusFingerprint {
12863 xor: [u8; 32],
12864 sums: [u64; 4],
12865 files: u64,
12866}
12867
12868impl CorpusFingerprint {
12869 fn add_path(&mut self, project_root: &Path, path: &Path) {
12870 let mut record = blake3::Hasher::new();
12871 record.update(relative_path(project_root, path).as_bytes());
12872 record.update(&[0]);
12873 match hash_file_bounded(path) {
12874 Ok(content_hash) => record.update(content_hash.as_bytes()),
12875 Err(error) => record.update(format!("missing:{error}").as_bytes()),
12878 };
12879 record.update(&[0]);
12880 let record = record.finalize();
12881 for (index, byte) in record.as_bytes().iter().copied().enumerate() {
12882 self.xor[index] ^= byte;
12883 }
12884 for (index, chunk) in record.as_bytes().chunks_exact(8).enumerate() {
12885 let value = u64::from_le_bytes(chunk.try_into().expect("eight-byte digest chunk"));
12886 self.sums[index] = self.sums[index].wrapping_add(value);
12887 }
12888 self.files = self.files.saturating_add(1);
12889 }
12890
12891 fn finish(self, project_root: &Path) -> String {
12892 let mut hasher = blake3::Hasher::new();
12895 hasher.update(b"callgraph-corpus-fingerprint-v2\0");
12896 hasher.update(&self.files.to_le_bytes());
12897 hasher.update(&self.xor);
12898 for sum in self.sums {
12899 hasher.update(&sum.to_le_bytes());
12900 }
12901 let ignore_rules = project_root.join(".gitignore");
12902 if let Ok(contents) = std::fs::read(ignore_rules) {
12903 hasher.update(b".gitignore\0");
12904 hasher.update(blake3::hash(&contents).as_bytes());
12905 }
12906 hash_to_hex(hasher.finalize())
12907 }
12908}
12909
12910fn hash_file_bounded(path: &Path) -> std::io::Result<blake3::Hash> {
12911 let mut file = std::fs::File::open(path)?;
12912 let mut hasher = blake3::Hasher::new();
12913 let mut buffer = [0u8; 64 * 1024];
12914 loop {
12915 let read = file.read(&mut buffer)?;
12916 if read == 0 {
12917 break;
12918 }
12919 hasher.update(&buffer[..read]);
12920 }
12921 Ok(hasher.finalize())
12922}
12923
12924#[cfg(test)]
12925pub(crate) fn callgraph_corpus_fingerprint_for_test(
12926 project_root: &Path,
12927 _files: &[PathBuf],
12928) -> Result<String> {
12929 callgraph_corpus_fingerprint(project_root)
12933}
12934
12935fn hash_to_hex(hash: blake3::Hash) -> String {
12936 hash.to_hex().to_string()
12937}
12938
12939fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
12940 let bytes = hex_to_bytes(value)?;
12941 Some(blake3::Hash::from_bytes(bytes))
12942}
12943
12944fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
12945 if value.len() != 64 {
12946 return None;
12947 }
12948 let mut bytes = [0u8; 32];
12949 for (index, slot) in bytes.iter_mut().enumerate() {
12950 let start = index * 2;
12951 let end = start + 2;
12952 *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
12953 }
12954 Some(bytes)
12955}
12956
12957#[derive(Debug, Clone)]
12958struct LineIndex {
12959 newline_offsets: Vec<usize>,
12960 source_len: usize,
12961}
12962
12963impl LineIndex {
12964 fn new(source: &str) -> Self {
12965 Self {
12966 newline_offsets: source
12967 .bytes()
12968 .enumerate()
12969 .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
12970 .collect(),
12971 source_len: source.len(),
12972 }
12973 }
12974
12975 fn byte_to_line(&self, byte_offset: usize) -> u32 {
12976 let byte_offset = byte_offset.min(self.source_len);
12977 self.newline_offsets
12978 .partition_point(|offset| *offset < byte_offset) as u32
12979 + 1
12980 }
12981}
12982
12983fn empty_to_none(value: String) -> Option<String> {
12984 if value.is_empty() {
12985 None
12986 } else {
12987 Some(value)
12988 }
12989}
12990
12991fn bool_int(value: bool) -> i64 {
12992 if value {
12993 1
12994 } else {
12995 0
12996 }
12997}
12998
12999fn system_time_to_ns(time: SystemTime) -> i64 {
13000 time.duration_since(UNIX_EPOCH)
13001 .unwrap_or_default()
13002 .as_nanos()
13003 .min(i64::MAX as u128) as i64
13004}
13005
13006fn ns_to_system_time(value: i64) -> SystemTime {
13007 UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
13008}
13009
13010pub(crate) fn unix_millis_now() -> u64 {
13011 SystemTime::now()
13012 .duration_since(UNIX_EPOCH)
13013 .unwrap_or_default()
13014 .as_millis()
13015 .min(u128::from(u64::MAX)) as u64
13016}
13017
13018fn unix_seconds_now() -> i64 {
13019 SystemTime::now()
13020 .duration_since(UNIX_EPOCH)
13021 .unwrap_or_default()
13022 .as_secs() as i64
13023}
13024
13025#[cfg(test)]
13030pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
13031
13032#[cfg(test)]
13033mod refresh_worker_tests {
13034 use super::*;
13035 use std::fs;
13036 use tempfile::tempdir;
13037
13038 fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
13039 let temp = tempdir().unwrap();
13040 let root = temp.path().join("root");
13041 fs::create_dir_all(&root).unwrap();
13042 let artifact_key = crate::search_index::artifact_cache_key(&root);
13043 crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
13044 let callgraph_dir = temp
13045 .path()
13046 .join("storage")
13047 .join("callgraph")
13048 .join(artifact_key);
13049 let source = root.join("main.rs");
13050 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
13051 let (store, _) = CallGraphStore::cold_build_with_lease(
13052 callgraph_dir.clone(),
13053 root.clone(),
13054 std::slice::from_ref(&source),
13055 )
13056 .unwrap();
13057 drop(store);
13058 (temp, root, callgraph_dir, source)
13059 }
13060
13061 fn pending_paths() -> PendingCallGraphStorePaths {
13062 Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
13063 }
13064
13065 fn wait_for_refresh_calls(root: &Path, expected: usize) {
13066 let deadline = Instant::now() + Duration::from_secs(12);
13067 while callgraph_refresh_worker_test_counts(root).0 < expected {
13068 assert!(
13069 Instant::now() < deadline,
13070 "timed out waiting for {expected} callgraph refresh worker call(s)"
13071 );
13072 std::thread::sleep(Duration::from_millis(5));
13073 }
13074 }
13075
13076 fn wait_for_refresh_worker_idle() {
13077 let deadline = Instant::now() + Duration::from_secs(12);
13078 loop {
13079 let worker = CALLGRAPH_REFRESH_WORKER
13080 .get_or_init(|| Mutex::new(None))
13081 .lock()
13082 .expect("callgraph refresh worker mutex poisoned")
13083 .clone();
13084 let idle = worker.is_none_or(|worker| {
13085 let queue = worker
13086 .shared
13087 .queue
13088 .lock()
13089 .expect("callgraph refresh queue mutex poisoned");
13090 queue.active.is_none() && queue.order.is_empty()
13091 });
13092 if idle {
13093 return;
13094 }
13095 assert!(
13096 Instant::now() < deadline,
13097 "timed out waiting for callgraph refresh worker to become idle"
13098 );
13099 std::thread::sleep(Duration::from_millis(5));
13100 }
13101 }
13102
13103 fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
13104 let temp = tempdir().unwrap();
13105 let root = temp.path().join("workspace");
13106 fs::create_dir_all(root.join("app/src")).unwrap();
13107 let artifact_key = crate::search_index::artifact_cache_key(&root);
13108 crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
13109 let callgraph_dir = temp
13110 .path()
13111 .join("storage")
13112 .join("callgraph")
13113 .join(artifact_key);
13114 fs::write(
13115 root.join("Cargo.toml"),
13116 "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
13117 )
13118 .unwrap();
13119 fs::write(
13120 root.join("app/Cargo.toml"),
13121 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
13122 )
13123 .unwrap();
13124 let caller = root.join("app/src/lib.rs");
13125 fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
13126 let (store, _) = CallGraphStore::cold_build_with_lease(
13127 callgraph_dir.clone(),
13128 root.clone(),
13129 std::slice::from_ref(&caller),
13130 )
13131 .unwrap();
13132 drop(store);
13133 (temp, root, callgraph_dir, caller)
13134 }
13135
13136 #[test]
13137 fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
13138 let _guard = REFRESH_WORKER_TEST_LOCK
13139 .lock()
13140 .unwrap_or_else(std::sync::PoisonError::into_inner);
13141 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13142 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
13143 reset_workspace_crate_prefix_build_count(&root);
13144 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13145
13146 for revision in ["first", "second"] {
13147 fs::write(
13148 &caller,
13149 format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
13150 )
13151 .unwrap();
13152 enqueue_callgraph_store_refresh(
13153 callgraph_dir.clone(),
13154 root.clone(),
13155 vec![caller.clone()],
13156 pending_paths(),
13157 );
13158 wait_for_refresh_worker_idle();
13159 }
13160
13161 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
13162 assert!(flush_callgraph_store_refreshes_with_budget(
13163 Duration::from_secs(5)
13164 ));
13165 clear_callgraph_refresh_worker_test_seam(&root);
13166 }
13167
13168 #[test]
13169 fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
13170 let _guard = REFRESH_WORKER_TEST_LOCK
13171 .lock()
13172 .unwrap_or_else(std::sync::PoisonError::into_inner);
13173 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13174 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
13175 reset_workspace_crate_prefix_build_count(&root);
13176 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13177
13178 fs::write(
13179 &caller,
13180 "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
13181 )
13182 .unwrap();
13183 enqueue_callgraph_store_refresh(
13184 callgraph_dir.clone(),
13185 root.clone(),
13186 vec![caller.clone()],
13187 pending_paths(),
13188 );
13189 wait_for_refresh_worker_idle();
13190 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
13191
13192 let added_manifest = root.join("added/Cargo.toml");
13193 let added_source = root.join("added/src/lib.rs");
13194 fs::create_dir_all(added_source.parent().unwrap()).unwrap();
13195 fs::write(
13196 root.join("Cargo.toml"),
13197 "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
13198 )
13199 .unwrap();
13200 fs::write(
13201 &added_manifest,
13202 "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
13203 )
13204 .unwrap();
13205 fs::write(&added_source, "pub fn target() {}\n").unwrap();
13206 fs::write(
13207 &caller,
13208 "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
13209 )
13210 .unwrap();
13211
13212 enqueue_callgraph_store_refresh(
13213 callgraph_dir.clone(),
13214 root.clone(),
13215 vec![
13216 root.join("Cargo.toml"),
13217 added_manifest,
13218 added_source,
13219 caller,
13220 ],
13221 pending_paths(),
13222 );
13223 assert!(flush_callgraph_store_refreshes_with_budget(
13224 Duration::from_secs(12)
13225 ));
13226
13227 assert_eq!(workspace_crate_prefix_build_count(&root), 2);
13231 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
13232 .unwrap()
13233 .expect("refreshed workspace store");
13234 let tree = store
13235 .call_tree(Path::new("app/src/lib.rs"), "run", 1)
13236 .unwrap();
13237 assert_eq!(tree.children.len(), 1);
13238 assert_eq!(tree.children[0].file, "added/src/lib.rs");
13239 assert_eq!(tree.children[0].name, "target");
13240 assert!(tree.children[0].resolved);
13241 clear_callgraph_refresh_worker_test_seam(&root);
13242 }
13243
13244 fn linked_worktree_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, PathBuf) {
13245 let temp = tempdir().unwrap();
13246 let main = temp.path().join("main");
13247 let worktree = temp.path().join("worktree");
13248 fs::create_dir_all(&main).unwrap();
13249 let mut git = std::process::Command::new("git");
13250 assert!(
13251 crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
13252 .status()
13253 .unwrap()
13254 .success()
13255 );
13256 fs::write(main.join("lib.rs"), "pub fn marker() {}\n").unwrap();
13257 for args in [
13258 vec![
13259 "-C",
13260 main.to_str().unwrap(),
13261 "config",
13262 "user.email",
13263 "test@example.com",
13264 ],
13265 vec![
13266 "-C",
13267 main.to_str().unwrap(),
13268 "config",
13269 "user.name",
13270 "AFT Test",
13271 ],
13272 vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
13273 vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
13274 ] {
13275 let mut command = std::process::Command::new("git");
13276 assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
13277 .status()
13278 .unwrap()
13279 .success());
13280 }
13281 let mut add_worktree = std::process::Command::new("git");
13282 assert!(crate::test_env::apply_hermetic_git_env(
13283 add_worktree
13284 .arg("-C")
13285 .arg(&main)
13286 .args(["worktree", "add", "--detach"])
13287 .arg(&worktree),
13288 )
13289 .status()
13290 .unwrap()
13291 .success());
13292 let main = fs::canonicalize(main).unwrap();
13293 let worktree = fs::canonicalize(worktree).unwrap();
13294 let project_key = crate::search_index::artifact_cache_key(&main);
13295 assert_eq!(
13296 crate::search_index::artifact_cache_key(&worktree),
13297 project_key
13298 );
13299 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
13300 (temp, main, worktree, project_key, callgraph_dir)
13301 }
13302
13303 #[test]
13304 fn linked_worktree_never_acquires_writer_or_publishes_any_build_path() {
13305 let _git_env = crate::test_env::hermetic_git_env_guard();
13306 let (_temp, _main, root, project_key, callgraph_dir) = linked_worktree_fixture();
13307 crate::root_cache::configure_artifact_access(&root, &project_key, true);
13308 crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
13309 let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13310 let publications_for_observer = Arc::clone(&publications);
13311 set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
13312 publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
13313 })));
13314 let source = root.join("lib.rs");
13315
13316 let open_error = CallGraphStore::open(callgraph_dir.clone(), root.clone())
13317 .expect_err("borrow-only writable open must remain unavailable");
13318 assert!(matches!(open_error, CallGraphStoreError::Unavailable(_)));
13319 assert!(
13320 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), root.clone())
13321 .unwrap()
13322 .is_none()
13323 );
13324 assert!(
13325 CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), root.clone())
13326 .unwrap()
13327 .is_none()
13328 );
13329 assert!(matches!(
13330 CallGraphStore::cold_build_with_lease(
13331 callgraph_dir.clone(),
13332 root.clone(),
13333 std::slice::from_ref(&source),
13334 ),
13335 Err(CallGraphStoreError::Unavailable(_))
13336 ));
13337 assert!(matches!(
13338 CallGraphStore::ensure_built_with_lease(
13339 callgraph_dir.clone(),
13340 root.clone(),
13341 std::slice::from_ref(&source),
13342 ),
13343 Err(CallGraphStoreError::Unavailable(_))
13344 ));
13345 let force_error = CallGraphStore::force_cold_build_with_lease_chunked(
13346 callgraph_dir.clone(),
13347 root.clone(),
13348 &[source],
13349 1,
13350 )
13351 .expect_err("borrow-only forced rebuild must remain unsatisfied");
13352 set_cold_build_swap_observer(None);
13353
13354 assert!(matches!(force_error, CallGraphStoreError::Unavailable(_)));
13355 assert_eq!(
13356 crate::root_cache::writer_lease_acquisition_count_for_test(
13357 crate::root_cache::RootCacheDomain::Callgraph,
13358 &project_key,
13359 &root,
13360 ),
13361 0
13362 );
13363 assert_eq!(publications.load(AtomicOrdering::SeqCst), 0);
13364 assert!(!pointer_path(&callgraph_dir, &project_key).exists());
13365 }
13366
13367 #[test]
13368 fn owner_and_linked_worktree_alternation_rebuilds_storm_generation_once() {
13369 let _git_env = crate::test_env::hermetic_git_env_guard();
13370 let (_temp, owner, worktree, project_key, callgraph_dir) = linked_worktree_fixture();
13371 crate::root_cache::configure_artifact_access(&owner, &project_key, false);
13372 crate::root_cache::configure_artifact_access(&worktree, &project_key, true);
13373 let source = owner.join("lib.rs");
13374 let (store, _) = CallGraphStore::cold_build_with_lease(
13375 callgraph_dir.clone(),
13376 owner.clone(),
13377 std::slice::from_ref(&source),
13378 )
13379 .unwrap();
13380 let sqlite_path = store.sqlite_path().to_path_buf();
13381 drop(store);
13382
13383 let conn = Connection::open(&sqlite_path).unwrap();
13384 conn.execute(
13385 "UPDATE backend_file_state SET workspace_root = ?1",
13386 [worktree.display().to_string()],
13387 )
13388 .unwrap();
13389 drop(conn);
13390
13391 let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13392 let publications_for_observer = Arc::clone(&publications);
13393 set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
13394 publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
13395 })));
13396 crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
13397
13398 let repaired = CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
13399 .unwrap()
13400 .expect("owner should purge the storm-era worktree root");
13401 drop(repaired);
13402 for _ in 0..3 {
13403 let borrower = CallGraphStore::open_readonly(callgraph_dir.clone(), worktree.clone())
13404 .unwrap()
13405 .expect("linked worktree should borrow the owner generation");
13406 drop(borrower);
13407 assert!(
13408 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), worktree.clone())
13409 .unwrap()
13410 .is_none()
13411 );
13412 let owner_store =
13413 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
13414 .unwrap()
13415 .expect("owner generation should remain ready");
13416 drop(owner_store);
13417 }
13418 set_cold_build_swap_observer(None);
13419
13420 assert_eq!(
13421 publications.load(AtomicOrdering::SeqCst),
13422 1,
13423 "the owner performs one expected post-storm purge and alternation stays read-only"
13424 );
13425 assert_eq!(
13426 crate::root_cache::writer_lease_acquisition_count_for_test(
13427 crate::root_cache::RootCacheDomain::Callgraph,
13428 &project_key,
13429 &worktree,
13430 ),
13431 0
13432 );
13433 }
13434
13435 #[test]
13436 fn rebuild_cooldown_records_only_successful_publication_per_cache_key() {
13437 let temp = tempdir().unwrap();
13438 let root = temp.path().join("owner");
13439 let other_root = temp.path().join("other");
13440 fs::create_dir_all(&root).unwrap();
13441 fs::create_dir_all(&other_root).unwrap();
13442 let source = root.join("lib.rs");
13443 fs::write(&source, "pub fn marker() {}\n").unwrap();
13444 let project_key = crate::search_index::artifact_cache_key(&root);
13445 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
13446 crate::root_cache::configure_artifact_access(&root, &project_key, false);
13447 let cooldown_key = rebuild_cooldown_key(&callgraph_dir, &project_key);
13448 rebuild_cooldown_records()
13449 .lock()
13450 .unwrap_or_else(std::sync::PoisonError::into_inner)
13451 .remove(&cooldown_key);
13452 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
13453 let stale_epoch = epoch.current();
13454 epoch.next();
13455
13456 let failed = with_publish_epoch(epoch, stale_epoch, || {
13457 CallGraphStore::cold_build_with_lease(
13458 callgraph_dir.clone(),
13459 root.clone(),
13460 std::slice::from_ref(&source),
13461 )
13462 });
13463 assert!(matches!(failed, Err(CallGraphStoreError::Superseded)));
13464 assert!(
13465 rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
13466 .is_none()
13467 );
13468
13469 let (store, _) = CallGraphStore::cold_build_with_lease(
13470 callgraph_dir.clone(),
13471 root.clone(),
13472 std::slice::from_ref(&source),
13473 )
13474 .unwrap();
13475 drop(store);
13476 assert!(
13477 rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
13478 .is_none()
13479 );
13480
13481 record_successful_rebuild(&callgraph_dir, &project_key, &other_root, Instant::now());
13482 assert!(
13483 rebuild_cooldown_denial(&callgraph_dir, &project_key, &root, Instant::now(),).is_some()
13484 );
13485 }
13486
13487 #[test]
13488 fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
13489 let _guard = REFRESH_WORKER_TEST_LOCK
13490 .lock()
13491 .unwrap_or_else(std::sync::PoisonError::into_inner);
13492 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13493 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13494 let pending = pending_paths();
13495 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13496
13497 let lifecycle = SubcLifecycleAdmission::default();
13498 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
13499 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
13500 let ticket = CallgraphRefreshTicket::new(
13501 lifecycle,
13502 Arc::clone(&generation),
13503 7,
13504 publish_epoch.clone(),
13505 publish_epoch.current(),
13506 );
13507 generation.store(8, std::sync::atomic::Ordering::SeqCst);
13509 let installed = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
13510 .unwrap()
13511 .expect("ready store snapshot");
13512 let refresh_state = CallgraphRefreshState::new(
13513 Arc::new(std::sync::RwLock::new(Some(Arc::new(installed)))),
13514 Arc::new(AtomicBool::new(true)),
13515 );
13516
13517 enqueue_callgraph_store_refresh_fenced_with_state(
13518 callgraph_dir,
13519 root.clone(),
13520 vec![source.clone()],
13521 Arc::clone(&pending),
13522 refresh_state,
13523 ticket,
13524 );
13525 assert!(flush_callgraph_store_refreshes_with_budget(
13526 Duration::from_secs(5)
13527 ));
13528 assert_eq!(
13529 callgraph_refresh_worker_test_counts(&root).0,
13530 0,
13531 "superseded batch must not reach refresh_files or self-replay"
13532 );
13533 assert!(
13534 pending.lock().contains(&source),
13535 "superseded batch must defer its paths to the pending sink"
13536 );
13537 clear_callgraph_refresh_worker_test_seam(&root);
13538 }
13539
13540 #[test]
13541 fn superseded_open_failure_defers_without_self_replay() {
13542 let _guard = REFRESH_WORKER_TEST_LOCK
13543 .lock()
13544 .unwrap_or_else(std::sync::PoisonError::into_inner);
13545 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13546 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13547 let pending = pending_paths();
13548 let installed = Arc::new(
13549 CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
13550 .unwrap()
13551 .expect("ready store snapshot"),
13552 );
13553 let refresh_state = CallgraphRefreshState::new(
13554 Arc::new(std::sync::RwLock::new(Some(Arc::clone(&installed)))),
13555 Arc::new(AtomicBool::new(true)),
13556 );
13557 assert!(!installed.is_legacy_fallback());
13558 assert!(installed.is_current());
13559 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
13560 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13561 set_callgraph_refresh_worker_test_open_failure(root.clone(), true);
13562 let (held_rx, release_tx) = install_callgraph_refresh_worker_test_gate(root.clone());
13563
13564 let lifecycle = SubcLifecycleAdmission::default();
13565 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
13566 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
13567 let ticket = CallgraphRefreshTicket::new(
13568 lifecycle,
13569 Arc::clone(&generation),
13570 7,
13571 publish_epoch.clone(),
13572 publish_epoch.current(),
13573 );
13574 enqueue_callgraph_store_refresh_fenced_with_state(
13575 callgraph_dir,
13576 root.clone(),
13577 vec![source.clone()],
13578 Arc::clone(&pending),
13579 refresh_state,
13580 ticket,
13581 );
13582 held_rx
13583 .recv_timeout(Duration::from_secs(12))
13584 .expect("refresh worker must hold after injected open failure");
13585
13586 generation.store(8, std::sync::atomic::Ordering::SeqCst);
13589 set_callgraph_refresh_worker_test_open_failure(root.clone(), false);
13590 release_tx
13591 .send(())
13592 .expect("release superseded refresh worker");
13593 wait_for_refresh_worker_idle();
13594
13595 assert_eq!(
13596 callgraph_refresh_worker_test_counts(&root).0,
13597 1,
13598 "superseded open-failure batch must not self-replay"
13599 );
13600 assert_eq!(
13601 callgraph_refresh_worker_test_worker_calls(&root),
13602 1,
13603 "superseded open-failure batch must not create another worker call"
13604 );
13605 assert!(
13606 pending.lock().contains(&source),
13607 "superseded open-failure paths must remain in the pending sink"
13608 );
13609 let tree = installed
13610 .call_tree(Path::new("main.rs"), "entry", 1)
13611 .unwrap();
13612 assert_eq!(
13613 tree.children[0].name, "old_leaf",
13614 "superseded open-failure batch must not converge the store"
13615 );
13616 clear_callgraph_refresh_worker_test_seam(&root);
13617 }
13618
13619 #[test]
13620 fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
13621 let _guard = REFRESH_WORKER_TEST_LOCK
13622 .lock()
13623 .unwrap_or_else(std::sync::PoisonError::into_inner);
13624 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13625 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13626 let pending = pending_paths();
13627 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13628
13629 let lifecycle = SubcLifecycleAdmission::default();
13630 let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
13631 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
13632 let expected_epoch = publish_epoch.current();
13633 let ticket = CallgraphRefreshTicket::new(
13634 lifecycle,
13635 generation,
13636 3,
13637 publish_epoch.clone(),
13638 expected_epoch,
13639 );
13640 publish_epoch.next();
13642
13643 enqueue_callgraph_store_refresh_fenced(
13644 callgraph_dir,
13645 root.clone(),
13646 vec![source.clone()],
13647 Arc::clone(&pending),
13648 ticket,
13649 );
13650 assert!(flush_callgraph_store_refreshes_with_budget(
13651 Duration::from_secs(5)
13652 ));
13653 assert_eq!(
13654 callgraph_refresh_worker_test_counts(&root).0,
13655 0,
13656 "epoch-superseded batch must not reach refresh_files"
13657 );
13658 assert!(
13659 pending.lock().contains(&source),
13660 "epoch-superseded batch must defer its paths to the pending sink"
13661 );
13662 clear_callgraph_refresh_worker_test_seam(&root);
13663 }
13664
13665 #[test]
13666 fn fenced_refresh_with_current_ticket_commits_normally() {
13667 let _guard = REFRESH_WORKER_TEST_LOCK
13668 .lock()
13669 .unwrap_or_else(std::sync::PoisonError::into_inner);
13670 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13671 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13672 let pending = pending_paths();
13673 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13674
13675 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
13676
13677 let lifecycle = SubcLifecycleAdmission::default();
13678 let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
13679 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
13680 let ticket = CallgraphRefreshTicket::new(
13681 lifecycle,
13682 generation,
13683 5,
13684 publish_epoch.clone(),
13685 publish_epoch.current(),
13686 );
13687
13688 enqueue_callgraph_store_refresh_fenced(
13689 callgraph_dir.clone(),
13690 root.clone(),
13691 vec![source.clone()],
13692 Arc::clone(&pending),
13693 ticket,
13694 );
13695 assert!(flush_callgraph_store_refreshes_with_budget(
13696 Duration::from_secs(5)
13697 ));
13698 assert_eq!(
13699 callgraph_refresh_worker_test_counts(&root).0,
13700 1,
13701 "current ticket must run the refresh"
13702 );
13703 assert!(
13704 pending.lock().is_empty(),
13705 "committed batch must not defer paths"
13706 );
13707
13708 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
13709 .unwrap()
13710 .expect("published generation must remain readable");
13711 let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
13712 assert_eq!(
13713 tree.children[0].name, "new_leaf",
13714 "fenced commit must actually persist the refreshed content"
13715 );
13716 clear_callgraph_refresh_worker_test_seam(&root);
13717 }
13718
13719 #[test]
13720 fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
13721 let _guard = REFRESH_WORKER_TEST_LOCK
13722 .lock()
13723 .unwrap_or_else(std::sync::PoisonError::into_inner);
13724 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13729 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13730 let pending = pending_paths();
13731 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
13732
13733 enqueue_callgraph_store_refresh(
13734 callgraph_dir.clone(),
13735 root.clone(),
13736 vec![source.clone()],
13737 Arc::clone(&pending),
13738 );
13739 wait_for_refresh_calls(&root, 1);
13740 for _ in 0..3 {
13741 enqueue_callgraph_store_refresh(
13742 callgraph_dir.clone(),
13743 root.clone(),
13744 vec![source.clone()],
13745 Arc::clone(&pending),
13746 );
13747 }
13748
13749 assert!(flush_callgraph_store_refreshes_with_budget(
13750 Duration::from_secs(2)
13751 ));
13752 assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
13753 assert!(pending.lock().is_empty());
13754 clear_callgraph_refresh_worker_test_seam(&root);
13755 }
13756
13757 #[test]
13758 fn queued_refresh_opens_generation_published_after_enqueue() {
13759 let _guard = REFRESH_WORKER_TEST_LOCK
13760 .lock()
13761 .unwrap_or_else(std::sync::PoisonError::into_inner);
13762 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13767 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
13768 let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
13769 set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
13770 let (active_held_rx, active_release_tx) =
13771 install_callgraph_refresh_worker_test_gate(active_root.clone());
13772 set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
13773 enqueue_callgraph_store_refresh(
13774 active_dir,
13775 active_root.clone(),
13776 vec![active_source],
13777 pending_paths(),
13778 );
13779 active_held_rx
13780 .recv_timeout(Duration::from_secs(12))
13781 .expect("active refresh worker holds the queue");
13782
13783 fs::write(
13784 &target_source,
13785 "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
13786 )
13787 .unwrap();
13788 enqueue_callgraph_store_refresh(
13789 target_dir.clone(),
13790 target_root.clone(),
13791 vec![target_source.clone()],
13792 pending_paths(),
13793 );
13794 let (new_generation, _) = CallGraphStore::cold_build_with_lease(
13795 target_dir.clone(),
13796 target_root.clone(),
13797 std::slice::from_ref(&target_source),
13798 )
13799 .unwrap();
13800 fs::write(
13801 &target_source,
13802 "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
13803 )
13804 .unwrap();
13805 drop(new_generation);
13806
13807 active_release_tx
13808 .send(())
13809 .expect("release active refresh worker");
13810 wait_for_refresh_calls(&target_root, 1);
13811 assert!(flush_callgraph_store_refreshes_with_budget(
13812 Duration::from_secs(12)
13813 ));
13814 let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
13815 .unwrap()
13816 .expect("current callgraph generation");
13817 let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
13818 assert_eq!(tree.children[0].name, "worker_leaf");
13819 assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
13820 clear_callgraph_refresh_worker_test_seam(&active_root);
13821 clear_callgraph_refresh_worker_test_seam(&target_root);
13822 }
13823
13824 #[test]
13825 fn refresh_failure_marks_files_stale() {
13826 let _guard = REFRESH_WORKER_TEST_LOCK
13827 .lock()
13828 .unwrap_or_else(std::sync::PoisonError::into_inner);
13829 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13834 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13835 let pending = pending_paths();
13836 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
13837
13838 enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
13839 assert!(flush_callgraph_store_refreshes_with_budget(
13840 Duration::from_secs(2)
13841 ));
13842
13843 assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
13844 let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
13845 .unwrap()
13846 .expect("ready callgraph store");
13847 assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
13848 clear_callgraph_refresh_worker_test_seam(&root);
13849 }
13850
13851 #[test]
13852 fn idle_refresh_truncates_wal() {
13853 let _guard = REFRESH_WORKER_TEST_LOCK
13854 .lock()
13855 .unwrap_or_else(std::sync::PoisonError::into_inner);
13856 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13857 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13858 let generation = read_pointer(
13859 &callgraph_dir,
13860 &crate::search_index::artifact_cache_key(&root),
13861 )
13862 .expect("fixture publishes a generation");
13863 let wal_path = callgraph_dir.join(format!("{generation}-wal"));
13864 let pending = pending_paths();
13865 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13866
13867 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n\n").unwrap();
13868 enqueue_callgraph_store_refresh(
13869 callgraph_dir.clone(),
13870 root.clone(),
13871 vec![source.clone()],
13872 Arc::clone(&pending),
13873 );
13874 wait_for_refresh_calls(&root, 1);
13875 wait_for_refresh_worker_idle();
13876 let checkpoint_deadline = Instant::now() + Duration::from_secs(2);
13877 while fs::metadata(&wal_path)
13878 .map(|metadata| metadata.len())
13879 .unwrap_or(0)
13880 != 0
13881 {
13882 assert!(
13883 Instant::now() < checkpoint_deadline,
13884 "idle checkpoint did not truncate WAL"
13885 );
13886 std::thread::sleep(Duration::from_millis(5));
13887 }
13888 assert_eq!(
13889 fs::metadata(&wal_path)
13890 .map(|metadata| metadata.len())
13891 .unwrap_or(0),
13892 0,
13893 "idle transition truncates the refresh WAL"
13894 );
13895
13896 clear_callgraph_refresh_worker_test_seam(&root);
13897 }
13898
13899 #[test]
13900 fn bounded_shutdown_defers_unprocessed_batches() {
13901 let _guard = REFRESH_WORKER_TEST_LOCK
13902 .lock()
13903 .unwrap_or_else(std::sync::PoisonError::into_inner);
13904 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13909 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
13910 let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
13911 let active_pending = pending_paths();
13912 let queued_pending = pending_paths();
13913 set_callgraph_refresh_worker_test_seam(
13914 active_root.clone(),
13915 Duration::from_millis(300),
13916 false,
13917 );
13918
13919 enqueue_callgraph_store_refresh(
13920 active_dir,
13921 active_root.clone(),
13922 vec![active_source.clone()],
13923 Arc::clone(&active_pending),
13924 );
13925 wait_for_refresh_calls(&active_root, 1);
13926 enqueue_callgraph_store_refresh(
13927 queued_dir,
13928 queued_root.clone(),
13929 vec![queued_source.clone()],
13930 Arc::clone(&queued_pending),
13931 );
13932
13933 assert!(!flush_callgraph_store_refreshes_with_budget(
13934 Duration::from_millis(20)
13935 ));
13936 assert!(active_pending.lock().contains(&active_source));
13937 assert!(queued_pending.lock().contains(&queued_source));
13938 assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
13939 clear_callgraph_refresh_worker_test_seam(&active_root);
13940 }
13941}
13942
13943#[cfg(test)]
13944mod cold_build_insert_tests {
13945 use super::*;
13946 use crate::imports::ImportBlock;
13947 use std::cell::Cell;
13948 use std::fs;
13949 use std::path::{Path, PathBuf};
13950 use tempfile::tempdir;
13951
13952 thread_local! {
13953 static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
13954 static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
13955 static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
13956 }
13957
13958 fn count_caller_traversal_selects(sql: &str) {
13959 let sql = sql.trim_start();
13960 if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
13961 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
13962 }
13963 if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
13964 && sql.contains("e.target_file =")
13965 {
13966 CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
13967 }
13968 if sql.starts_with("WITH requested") && sql.contains("COUNT(*)") {
13969 BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
13970 }
13971 }
13972
13973 #[test]
13974 fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
13975 let dir = tempdir().unwrap();
13976 let previous_root = dir.path().join("previous-root");
13977 let current_root = dir.path().join("current-root");
13978 fs::create_dir_all(&previous_root).unwrap();
13979 fs::create_dir_all(¤t_root).unwrap();
13980 fs::remove_dir(&previous_root).unwrap();
13981 let mut conn = Connection::open_in_memory().unwrap();
13982 initialize_schema(&conn).unwrap();
13983 conn.execute(
13984 "INSERT INTO backend_file_state(
13985 backend, workspace_root, file_path, content_hash, status, updated_at
13986 ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
13987 params![previous_root.display().to_string()],
13988 )
13989 .unwrap();
13990
13991 let repair = reconcile_workspace_roots(&mut conn, ¤t_root, false).unwrap();
13992
13993 assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
13994 assert_eq!(
13995 stored_workspace_roots(&conn).unwrap(),
13996 vec![previous_root.display().to_string()]
13997 );
13998 }
13999
14000 #[test]
14001 fn sqlite_readonly_uri_percent_encodes_windows_paths() {
14002 assert_eq!(
14003 sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
14004 "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
14005 );
14006 }
14007
14008 #[test]
14009 fn legacy_migration_completion_log_has_operator_fields() {
14010 assert_eq!(
14011 legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
14012 "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
14013 );
14014 }
14015
14016 fn write_generation_with_age(
14017 dir: &Path,
14018 project_key: &str,
14019 ordinal: u64,
14020 age: Duration,
14021 ) -> String {
14022 let generation = format!("{project_key}.g{ordinal}.1.sqlite");
14023 let path = dir.join(&generation);
14024 fs::write(&path, b"sqlite placeholder").unwrap();
14025 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
14026 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
14027 generation
14028 }
14029
14030 #[test]
14031 fn gc_old_generations_preserves_live_reader_until_marker_drops() {
14032 let dir = tempfile::tempdir().unwrap();
14033 let project_key = "project";
14034 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
14035 let previous =
14036 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
14037 let pinned =
14038 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
14039 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
14040
14041 gc_old_generations(dir.path(), project_key, ¤t);
14042
14043 assert!(dir.path().join(&previous).is_file());
14044 assert!(dir.path().join(&pinned).is_file());
14045
14046 drop(marker);
14047 gc_old_generations(dir.path(), project_key, ¤t);
14048
14049 assert!(dir.path().join(&previous).is_file());
14050 assert!(!dir.path().join(&pinned).exists());
14051 }
14052
14053 #[test]
14054 fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
14055 let dir = tempfile::tempdir().unwrap();
14056 let project_key = "project";
14057 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
14058 let _previous =
14059 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
14060 let pinned =
14061 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
14062 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
14063 filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
14064
14065 gc_old_generations(dir.path(), project_key, ¤t);
14066
14067 assert!(dir.path().join(&pinned).is_file());
14068 }
14069
14070 #[test]
14071 fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
14072 let dir = tempfile::tempdir().unwrap();
14073 let project_key = "project";
14074 let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
14075 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
14076 let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
14077 let old = write_generation_with_age(
14078 dir.path(),
14079 project_key,
14080 200,
14081 expired + Duration::from_secs(60),
14082 );
14083 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
14084
14085 gc_old_generations(dir.path(), project_key, ¤t);
14086
14087 assert!(dir.path().join(¤t).is_file());
14088 assert!(dir.path().join(&previous).is_file());
14089 assert!(!dir.path().join(&old).exists());
14090 }
14091
14092 fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
14093 let path = dir.join(name);
14094 fs::write(&path, b"temp placeholder").unwrap();
14095 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
14096 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
14097 path
14098 }
14099
14100 #[test]
14101 fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
14102 let dir = tempdir().unwrap();
14103 let aged = "project.g100.1.sqlite.tmp.1.200";
14107 let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
14108 let fresh = "project.g300.1.sqlite.tmp.1.400";
14109 let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
14110 write_build_temp_with_age(dir.path(), aged, aged_age);
14111 write_build_temp_with_age(dir.path(), aged_journal, aged_age);
14112 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
14113
14114 sweep_orphaned_build_temps(dir.path());
14115
14116 assert!(
14117 !dir.path().join(aged).exists(),
14118 "aged orphan must be removed"
14119 );
14120 assert!(
14121 !dir.path().join(aged_journal).exists(),
14122 "aged journal sidecar must be removed"
14123 );
14124 assert!(
14125 dir.path().join(fresh).is_file(),
14126 "fresh temporary must survive"
14127 );
14128 }
14129
14130 #[test]
14131 fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
14132 let storage = tempdir().unwrap();
14133 let storage_root = storage.path();
14134 let legacy_dir = storage_root.join("opencode").join("callgraph");
14140 fs::create_dir_all(&legacy_dir).unwrap();
14141 let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
14142 write_build_temp_with_age(
14143 &legacy_dir,
14144 orphan,
14145 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
14146 );
14147 assert!(
14148 !legacy_dir.join("deadbeef.current").exists(),
14149 "the dead root has no current pointer"
14150 );
14151
14152 let root_keyed_dir = storage_root.join("callgraph").join("livekey");
14153 fs::create_dir_all(&root_keyed_dir).unwrap();
14154
14155 sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
14156
14157 assert!(
14158 !legacy_dir.join(orphan).exists(),
14159 "legacy orphan must be reclaimed by the store-wide sweep"
14160 );
14161 }
14162
14163 #[test]
14164 fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
14165 let dir = tempdir().unwrap();
14171 let fresh = "project.g300.1.sqlite.tmp.1.400";
14172 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
14173
14174 sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
14175
14176 assert!(
14177 !dir.path().join(fresh).exists(),
14178 "with the age predicate forced open, the fresh temporary is removed"
14179 );
14180 }
14181
14182 #[test]
14183 fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
14184 let dir = tempdir().unwrap();
14185 let generation = write_generation_with_age(
14189 dir.path(),
14190 "project",
14191 400,
14192 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
14193 );
14194 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
14195
14196 sweep_orphaned_build_temps(dir.path());
14197
14198 assert!(
14199 dir.path().join(&generation).is_file(),
14200 "completed generation must survive the orphan sweep"
14201 );
14202 assert!(
14203 crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
14204 "read marker must survive the orphan sweep"
14205 );
14206 }
14207
14208 #[test]
14209 fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
14210 let dir = tempfile::tempdir().unwrap();
14211 let project_key = "project".to_string();
14212 let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
14213 let sqlite_path = dir.path().join(&generation);
14214 fs::remove_file(&sqlite_path).unwrap();
14215 let conn = Connection::open(&sqlite_path).unwrap();
14216 let store = CallGraphStore::from_connection(
14217 dir.path().to_path_buf(),
14218 project_key,
14219 sqlite_path,
14220 dir.path().to_path_buf(),
14221 false,
14222 Some(generation.clone()),
14223 None,
14224 None,
14225 conn,
14226 );
14227
14228 let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
14229 assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
14230
14231 drop(marker);
14232 assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
14233 }
14234
14235 #[test]
14236 fn readiness_cache_only_skips_checks_after_a_successful_validation() {
14237 let dir = tempdir().expect("temp dir");
14238 let file = dir.path().join("main.ts");
14239 fs::write(&file, "export function main() {}\n").expect("write fixture");
14240 let store = CallGraphStore::open(
14241 dir.path().join(".store-readiness-cache"),
14242 dir.path().to_path_buf(),
14243 )
14244 .expect("open store");
14245 {
14246 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14247 conn.trace(Some(count_caller_traversal_selects));
14248 }
14249
14250 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
14251 assert!(store.indexed_file_count().is_err());
14252 assert!(store.indexed_file_count().is_err());
14253 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
14254
14255 store
14256 .cold_build(std::slice::from_ref(&file))
14257 .expect("cold build");
14258 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
14259 assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
14260 assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
14261 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
14262
14263 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14264 conn.trace(None);
14265 }
14266
14267 #[test]
14268 fn direct_caller_frontier_chunks_sqlite_selects() {
14269 let dir = tempdir().expect("temp dir");
14270 let file = dir.path().join("main.ts");
14271 fs::write(
14272 &file,
14273 "export function caller() { target(); }\nexport function target() {}\n",
14274 )
14275 .expect("write fixture");
14276 let store = CallGraphStore::open(
14277 dir.path().join(".store-caller-frontier-query"),
14278 dir.path().to_path_buf(),
14279 )
14280 .expect("open store");
14281 store
14282 .cold_build(std::slice::from_ref(&file))
14283 .expect("cold build");
14284 let mut targets = vec![("main.ts".to_string(), "target".to_string())];
14285 targets.extend((1..1_000).map(|index| ("main.ts".to_string(), format!("missing{index}"))));
14286
14287 CALLER_QUERY_SELECTS.with(|count| count.set(0));
14288 BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
14289 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
14290 {
14291 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14292 conn.trace(Some(count_caller_traversal_selects));
14293 }
14294 let callers = store
14295 .direct_callers_for_symbols(&targets)
14296 .expect("batched callers");
14297 {
14298 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14299 conn.trace(None);
14300 }
14301
14302 assert_eq!(callers.len(), 1_000);
14303 assert_eq!(callers.get(&targets[0]).unwrap().len(), 1);
14304 assert_eq!(CALLER_QUERY_SELECTS.with(Cell::get), 3);
14305 assert_eq!(BOUNDARY_COUNT_SELECTS.with(Cell::get), 0);
14306 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
14307 }
14308
14309 #[test]
14310 fn callers_depth_boundary_batches_sqlite_counts() {
14311 const CALLER_COUNT: usize = 1_000;
14312
14313 let dir = tempdir().expect("temp dir");
14314 let file = dir.path().join("main.ts");
14315 let mut source = String::from("export function sharedHotHelper() {}\n");
14316 for index in 0..CALLER_COUNT {
14317 source.push_str(&format!(
14318 "export function caller{index}() {{ sharedHotHelper(); }}\n"
14319 ));
14320 }
14321 fs::write(&file, source).expect("write fixture");
14322
14323 let store = CallGraphStore::open(
14324 dir.path().join(".store-callers-query-fanout"),
14325 dir.path().to_path_buf(),
14326 )
14327 .expect("open store");
14328 store
14329 .cold_build(std::slice::from_ref(&file))
14330 .expect("cold build");
14331
14332 CALLER_QUERY_SELECTS.with(|count| count.set(0));
14333 BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
14334 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
14335 {
14336 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14337 conn.trace(Some(count_caller_traversal_selects));
14338 }
14339
14340 let started = Instant::now();
14341 let result = crate::commands::callgraph_store_adapter::callers_result(
14342 &store,
14343 Path::new("main.ts"),
14344 "sharedHotHelper",
14345 1,
14346 true,
14347 )
14348 .expect("callers result");
14349 let elapsed = started.elapsed();
14350
14351 {
14352 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14353 conn.trace(None);
14354 }
14355 let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
14356 let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
14357 let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
14358 eprintln!(
14359 "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
14360 result.total_callers,
14361 caller_queries,
14362 boundary_queries,
14363 total_selects,
14364 elapsed.as_secs_f64() * 1_000.0
14365 );
14366
14367 assert_eq!(result.total_callers, CALLER_COUNT);
14368 assert_eq!(caller_queries, 1);
14369 assert_eq!(boundary_queries, 3);
14370 assert_eq!(total_selects, 9);
14371 }
14372
14373 #[test]
14374 fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
14375 let dir = tempdir().expect("temp dir");
14376 let file = dir.path().join("main.ts");
14377 fs::write(
14378 &file,
14379 r#"export function topA() {
14380 root();
14381}
14382
14383export function topB() {
14384 root();
14385}
14386
14387export function root() {
14388 leaf();
14389 missing();
14390}
14391
14392export function leaf() {}
14393"#,
14394 )
14395 .expect("write fixture");
14396
14397 let store = CallGraphStore::open(
14398 dir.path().join(".store-depth-boundary-counts"),
14399 dir.path().to_path_buf(),
14400 )
14401 .expect("open store");
14402 store
14403 .cold_build(std::slice::from_ref(&file))
14404 .expect("cold build");
14405
14406 let root = store
14407 .node_for(Path::new("main.ts"), "root")
14408 .expect("root node");
14409 let leaf = store
14410 .node_for(Path::new("main.ts"), "leaf")
14411 .expect("leaf node");
14412
14413 let (full_forward_len, full_direct_len) = {
14414 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
14415 conn.execute(
14416 "INSERT INTO edges (
14417 edge_id, ref_id, source_node, target_node, target_file,
14418 target_symbol, kind, line, provenance
14419 ) VALUES (
14420 'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
14421 ?2, ?3, 'call', 98, ?4
14422 )",
14423 rusqlite::params![
14424 &root.node_id,
14425 &leaf.file,
14426 &leaf.symbol,
14427 PROVENANCE_TREESITTER
14428 ],
14429 )
14430 .expect("insert dangling forward edge");
14431 conn.execute(
14432 "INSERT INTO edges (
14433 edge_id, ref_id, source_node, target_node, target_file,
14434 target_symbol, kind, line, provenance
14435 ) VALUES (
14436 'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
14437 ?1, ?2, ?3, 'call', 99, ?4
14438 )",
14439 rusqlite::params![
14440 &root.node_id,
14441 &root.file,
14442 &root.symbol,
14443 PROVENANCE_TREESITTER
14444 ],
14445 )
14446 .expect("insert dangling direct-caller edge");
14447
14448 let full_forward_len = forward_calls_for_node(&conn, &root)
14449 .expect("full forward calls")
14450 .len();
14451 let counted_forward_len =
14452 forward_call_count_for_node(&conn, &root).expect("counted forward calls");
14453 assert_eq!(
14454 counted_forward_len, full_forward_len,
14455 "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
14456 );
14457
14458 let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
14459 .expect("full direct callers");
14460 let full_direct_len = full_direct.len();
14461 let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
14462 .expect("counted direct callers");
14463 assert_eq!(
14464 counted_direct_len, full_direct_len,
14465 "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
14466 );
14467
14468 let distinct_direct_len = full_direct
14469 .iter()
14470 .map(|site| {
14471 (
14472 site.caller.file.clone(),
14473 site.line,
14474 site.target_file.clone(),
14475 site.target_symbol.clone(),
14476 )
14477 })
14478 .collect::<BTreeSet<_>>()
14479 .len();
14480 let batch_counts = direct_caller_counts_for_tuples(
14481 &conn,
14482 &[
14483 (root.file.clone(), root.symbol.clone()),
14484 (root.file.clone(), root.symbol.clone()),
14485 (leaf.file.clone(), leaf.symbol.clone()),
14486 ],
14487 )
14488 .expect("batched direct-caller counts");
14489 assert_eq!(batch_counts.len(), 2);
14490 assert_eq!(
14491 batch_counts.get(&(root.file.clone(), root.symbol.clone())),
14492 Some(&distinct_direct_len)
14493 );
14494
14495 (full_forward_len, full_direct_len)
14496 };
14497
14498 assert_eq!(
14499 full_forward_len, 2,
14500 "fixture root should have one resolved and one unresolved outgoing call"
14501 );
14502 assert_eq!(
14503 full_direct_len, 2,
14504 "fixture root should have two real direct callers"
14505 );
14506
14507 let tree = store
14508 .call_tree(Path::new("main.ts"), "root", 0)
14509 .expect("call tree");
14510 assert!(tree.depth_limited);
14511 assert_eq!(tree.children.len(), 0);
14512 assert_eq!(
14513 tree.truncated, full_forward_len,
14514 "call_tree depth boundary must report the full forward-call list length"
14515 );
14516
14517 let callers = store
14518 .callers_of(Path::new("main.ts"), "leaf", 0)
14519 .expect("callers");
14520 assert!(callers.depth_limited);
14521 assert_eq!(callers.callers.len(), 1);
14522 assert_eq!(callers.callers[0].caller.symbol, "root");
14523 assert_eq!(
14524 callers.truncated, full_direct_len,
14525 "callers depth boundary must report the full direct-caller list length"
14526 );
14527 }
14528
14529 #[test]
14530 fn source_freshness_matches_cache_collect_for_same_bytes() {
14531 let dir = tempdir().expect("temp dir");
14532 let path = dir.path().join("fixture.ts");
14533 let source = "export function main() { return helper(); }\n";
14534 fs::write(&path, source).expect("write fixture");
14535
14536 let expected = cache_freshness::collect(&path).expect("collect freshness from file");
14537 let actual =
14538 collect_source_freshness(&path, source).expect("collect freshness from source");
14539
14540 assert_eq!(actual, expected);
14541 }
14542
14543 #[test]
14544 fn superseded_cold_build_cannot_publish_after_newer_epoch() {
14545 let root = tempfile::tempdir().unwrap();
14546 let callgraph_dir = tempfile::tempdir().unwrap();
14547 let source_dir = root.path().join("src");
14548 std::fs::create_dir_all(&source_dir).unwrap();
14549 let source = source_dir.join("lib.rs");
14550 std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
14551 let files = vec![source.clone()];
14552 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
14553 let old_epoch = epoch.next();
14554 let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
14555 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
14556 let old_epoch_flag = epoch.clone();
14557 let old_dir = callgraph_dir.path().to_path_buf();
14558 let old_root = root.path().to_path_buf();
14559 let old_files = files.clone();
14560 let old = std::thread::spawn(move || {
14561 set_cold_build_before_publish_observer(Some(Arc::new(move || {
14562 reached_tx.send(()).unwrap();
14563 release_rx.recv().unwrap();
14564 })));
14565 let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
14566 CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
14567 });
14568 set_cold_build_before_publish_observer(None);
14569 result
14570 });
14571 reached_rx
14575 .recv_timeout(Duration::from_secs(30))
14576 .expect("older build did not reach its publication barrier");
14577
14578 std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
14579 let new_epoch = epoch.next();
14580 let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
14581 CallGraphStore::cold_build_with_lease(
14582 callgraph_dir.path().to_path_buf(),
14583 root.path().to_path_buf(),
14584 &files,
14585 )
14586 })
14587 .expect("newer build should publish");
14588 drop(new_store);
14589
14590 release_tx.send(()).unwrap();
14591 assert!(matches!(
14592 old.join().unwrap(),
14593 Err(CallGraphStoreError::Superseded)
14594 ));
14595
14596 let current = CallGraphStore::open_readonly(
14597 callgraph_dir.path().to_path_buf(),
14598 root.path().to_path_buf(),
14599 )
14600 .unwrap()
14601 .expect("current callgraph generation");
14602 assert_eq!(
14603 current
14604 .nodes_matching("new_generation_marker")
14605 .unwrap()
14606 .len(),
14607 1
14608 );
14609 assert!(current
14610 .nodes_matching("old_generation_marker")
14611 .unwrap()
14612 .is_empty());
14613 }
14614
14615 #[test]
14616 fn cold_build_prepared_bulk_insert_matches_reference_rows() {
14617 let dir = tempdir().expect("temp dir");
14618 let project_root = dir.path();
14619 let extract = fixture_extract(project_root);
14620 let resolved = fixture_resolved(&extract);
14621
14622 let reference = build_reference_connection(project_root, &extract, &resolved);
14623 let optimized = build_optimized_connection(project_root, &extract, &resolved);
14624
14625 for table in [
14626 "files",
14627 "nodes",
14628 "file_dependencies",
14629 "dispatch_hints",
14630 "refs",
14631 "edges",
14632 ] {
14633 let excluded: &[&str] = if table == "files" {
14640 &["indexed_at"]
14641 } else {
14642 &[]
14643 };
14644 assert_eq!(
14645 table_rows_without(&reference, table, excluded),
14646 table_rows_without(&optimized, table, excluded),
14647 "table `{table}` rows must match apart from wall-clock columns"
14648 );
14649 }
14650 assert_eq!(
14651 backend_state_rows(&reference),
14652 backend_state_rows(&optimized),
14653 "backend freshness rows must match apart from updated_at"
14654 );
14655 assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
14656 }
14657
14658 #[test]
14659 fn cold_build_chunked_matches_unchunked_logical_rows() {
14660 let dir = tempdir().expect("temp dir");
14661 let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
14662 write_chunked_equivalence_fixture(&project_root);
14663 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
14664 assert!(
14665 files.len() > 6,
14666 "fixture should be large enough to split into multiple chunks"
14667 );
14668
14669 let unchunked = CallGraphStore::open(
14670 project_root.join(".store-unchunked"),
14671 project_root.to_path_buf(),
14672 )
14673 .expect("open unchunked store");
14674 let unchunked_stats = unchunked
14675 .cold_build_chunked(&files, 0)
14676 .expect("unchunked cold build");
14677
14678 let chunked = CallGraphStore::open(
14679 project_root.join(".store-chunked"),
14680 project_root.to_path_buf(),
14681 )
14682 .expect("open chunked store");
14683 let chunked_stats = chunked
14684 .cold_build_chunked(&files, 3)
14685 .expect("chunked cold build");
14686
14687 assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
14688 assert_eq!(
14689 unchunked.edge_snapshot().expect("unchunked edge snapshot"),
14690 chunked.edge_snapshot().expect("chunked edge snapshot"),
14691 "public edge snapshots must match"
14692 );
14693
14694 let dispatch_edges = {
14695 let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
14696 conn.query_row(
14697 "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
14698 [],
14699 |row| row.get::<_, i64>(0),
14700 )
14701 .expect("count dispatch edges")
14702 };
14703 assert!(
14704 dispatch_edges > 0,
14705 "fixture must exercise method-dispatch edge insertion"
14706 );
14707
14708 for table in [
14709 "edges",
14710 "refs",
14711 "nodes",
14712 "file_dependencies",
14713 "dispatch_hints",
14714 ] {
14715 assert_eq!(
14716 graph_table_rows(&unchunked, table),
14717 graph_table_rows(&chunked, table),
14718 "chunked cold build must match unchunked rows for {table}"
14719 );
14720 }
14721 assert_eq!(
14722 graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
14723 graph_table_rows_without(&chunked, "files", &["indexed_at"]),
14724 "files rows must match apart from indexed_at"
14725 );
14726 assert_eq!(
14727 graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
14728 graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
14729 "backend freshness rows must match apart from updated_at"
14730 );
14731
14732 let published_dir = project_root.join(".store-published");
14733 let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
14734 published_dir.clone(),
14735 project_root.to_path_buf(),
14736 &files,
14737 0,
14738 )
14739 .expect("published unchunked cold build");
14740 assert!(
14741 !CallGraphStore::needs_cold_build(&published_dir, &project_root)
14742 .expect("needs_cold_build after publish"),
14743 "published store should be ready"
14744 );
14745 drop(_published);
14746 let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
14747 published_dir,
14748 project_root.to_path_buf(),
14749 &files,
14750 3,
14751 )
14752 .expect("ensure with a different chunk size");
14753 assert!(
14754 rebuild_stats.is_none(),
14755 "changing callgraph_chunk_size must not affect store identity or force a rebuild"
14756 );
14757 }
14758
14759 #[test]
14766 #[ignore]
14767 fn bench_cold_build_chunk() {
14768 let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
14769 let chunk: usize = std::env::var("AFT_PERF_CHUNK")
14770 .expect("AFT_PERF_CHUNK")
14771 .parse()
14772 .expect("AFT_PERF_CHUNK must be a non-negative integer");
14773 let project_root = fs::canonicalize(&repo).expect("canonical repo root");
14774 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
14775 let dir = tempdir().expect("temp dir");
14776 let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
14777 .expect("open store");
14778 let started = Instant::now();
14779 let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
14780 let ms = started.elapsed().as_millis();
14781 println!(
14782 "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
14783 stats.files, stats.nodes, stats.refs, stats.edges
14784 );
14785 }
14786
14787 #[test]
14788 fn persisted_workspace_reexport_selects_its_package_dependency() {
14789 let root = tempdir().expect("temp dir");
14790 let dependencies = BTreeSet::from([
14791 "packages/aft-bridge/src/index.ts".to_string(),
14792 "packages/opencode-plugin/src/types.ts".to_string(),
14793 ]);
14794 let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
14795
14796 assert_eq!(
14797 stored_dependencies_for_module(
14798 root.path(),
14799 "packages/opencode-plugin/src/shared/bash-hints.ts",
14800 "@cortexkit/aft-bridge",
14801 &dependencies,
14802 &indexed_files,
14803 ),
14804 BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
14805 );
14806 }
14807
14808 #[test]
14809 fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
14810 let dir = tempdir().expect("temp dir");
14811 let project_root = dir.path();
14812 let files =
14813 write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
14814 let index_path = project_root.join("src/index.ts");
14815
14816 let store = CallGraphStore::open(
14817 project_root.join(".store-incremental-barrel"),
14818 project_root.to_path_buf(),
14819 )
14820 .expect("open incremental store");
14821 store.cold_build(&files).expect("initial cold build");
14822
14823 {
14824 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14825 let tx = conn.transaction().expect("dependency transaction");
14826 let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
14827 .expect("dependent refs for barrel");
14828 let selected_ref_ids = dependent_refs
14829 .iter()
14830 .map(|dependent_ref| dependent_ref.ref_id.clone())
14831 .collect::<BTreeSet<_>>();
14832 let mut threaded_ref_ids = BTreeSet::new();
14833 let mut threaded_by_caller = BTreeMap::new();
14834 record_dependent_refs(
14835 &mut threaded_ref_ids,
14836 &mut threaded_by_caller,
14837 dependent_refs,
14838 );
14839 let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
14840 .expect("old per-ref caller lookup");
14841
14842 assert_eq!(threaded_ref_ids, selected_ref_ids);
14843 assert_eq!(threaded_by_caller, old_by_caller);
14844 for consumer in [
14845 "src/consumer_a.ts",
14846 "src/consumer_b.ts",
14847 "src/consumer_c.ts",
14848 ] {
14849 assert!(
14850 threaded_by_caller.contains_key(consumer),
14851 "barrel edit should select dependent refs from {consumer}"
14852 );
14853 }
14854 }
14855
14856 fs::write(
14857 &index_path,
14858 "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
14859 )
14860 .expect("edit barrel");
14861 let stats = store
14862 .refresh_files(std::slice::from_ref(&index_path))
14863 .expect("incremental refresh");
14864 assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
14865 assert!(
14866 stats.dependency_selected_refs > 0,
14867 "barrel surface edit should select dependent refs"
14868 );
14869
14870 let cold_store = CallGraphStore::open(
14871 project_root.join(".store-cold-barrel"),
14872 project_root.to_path_buf(),
14873 )
14874 .expect("open cold rebuild store");
14875 cold_store
14876 .cold_build(&files)
14877 .expect("comparison cold build");
14878
14879 for table in [
14880 "nodes",
14881 "refs",
14882 "file_dependencies",
14883 "edges",
14884 "dispatch_hints",
14885 ] {
14886 assert_eq!(
14887 graph_table_rows(&store, table),
14888 graph_table_rows(&cold_store, table),
14889 "incremental refresh {table} rows must match cold rebuild"
14890 );
14891 }
14892
14893 let consumer_path = project_root.join("src/consumer_a.ts");
14894 fs::write(
14895 &consumer_path,
14896 "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
14897 )
14898 .expect("edit barrel consumer");
14899 store
14900 .refresh_files(std::slice::from_ref(&consumer_path))
14901 .expect("refresh consumer through unchanged barrel");
14902 cold_store
14903 .cold_build(&files)
14904 .expect("comparison cold rebuild after consumer refresh");
14905 for table in [
14906 "nodes",
14907 "refs",
14908 "file_dependencies",
14909 "edges",
14910 "dispatch_hints",
14911 ] {
14912 assert_eq!(
14913 graph_table_rows(&store, table),
14914 graph_table_rows(&cold_store, table),
14915 "refresh through a persisted barrel must preserve cold-build {table} rows"
14916 );
14917 }
14918 }
14919
14920 fn build_reference_connection(
14921 project_root: &Path,
14922 extract: &FileExtract,
14923 resolved: &ResolvedRef,
14924 ) -> Connection {
14925 let mut conn = Connection::open_in_memory().expect("open reference db");
14926 configure_build_connection(&conn).expect("configure reference db");
14927 initialize_schema(&conn).expect("initialize reference schema");
14928 {
14929 let tx = conn.transaction().expect("reference transaction");
14930 clear_tables(&tx).expect("reference clear");
14931 insert_meta(&tx).expect("reference meta");
14932 insert_file_extract(&tx, project_root, extract).expect("reference file extract");
14933 insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
14934 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
14935 .expect("reference dispatch edges");
14936 assert_eq!(supplemental, 0);
14937 tx.commit().expect("reference commit");
14938 }
14939 conn
14940 }
14941
14942 fn build_optimized_connection(
14943 project_root: &Path,
14944 extract: &FileExtract,
14945 resolved: &ResolvedRef,
14946 ) -> Connection {
14947 let mut conn = Connection::open_in_memory().expect("open optimized db");
14948 configure_build_connection(&conn).expect("configure optimized db");
14949 initialize_schema(&conn).expect("initialize optimized schema");
14950 {
14951 let tx = conn.transaction().expect("optimized transaction");
14952 clear_tables(&tx).expect("optimized clear");
14953 insert_meta(&tx).expect("optimized meta");
14954 drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
14955 {
14956 let workspace_root = project_root.display().to_string();
14957 let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
14958 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
14959 .expect("optimized file extract");
14960 insert_resolved_ref_prepared(&mut inserts, resolved)
14961 .expect("optimized resolved ref");
14962 }
14963 create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
14964 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
14965 .expect("optimized dispatch edges");
14966 assert_eq!(supplemental, 0);
14967 tx.commit().expect("optimized commit");
14968 }
14969 conn
14970 }
14971
14972 fn fixture_extract(_project_root: &Path) -> FileExtract {
14973 let rel_path = "src/main.ts".to_string();
14974 let target_path = "src/helper.ts".to_string();
14975 let node = NodeRecord {
14976 id: "node-main".to_string(),
14977 file_path: rel_path.clone(),
14978 name: "main".to_string(),
14979 scoped_name: "main".to_string(),
14980 kind: "function".to_string(),
14981 range: Range {
14982 start_line: 0,
14983 start_col: 0,
14984 end_line: 0,
14985 end_col: 32,
14986 },
14987 range_ordinal: 0,
14988 signature: Some("export function main()".to_string()),
14989 exported: true,
14990 is_default_export: false,
14991 is_type_like: false,
14992 is_callgraph_entry_point: true,
14993 };
14994 let mut dependencies = BTreeSet::new();
14995 dependencies.insert(target_path.clone());
14996 let raw_ref = RawRef {
14997 ref_id: "ref-main-helper".to_string(),
14998 caller_node: Some(node.id.clone()),
14999 caller_symbol: Some(node.scoped_name.clone()),
15000 caller_file: rel_path.clone(),
15001 kind: "call".to_string(),
15002 short_name: Some("helper".to_string()),
15003 full_ref: Some("helper".to_string()),
15004 module_path: None,
15005 import_kind: None,
15006 local_name: Some("helper".to_string()),
15007 requested_name: Some("helper".to_string()),
15008 namespace_alias: None,
15009 wildcard: false,
15010 line: 1,
15011 byte_start: 24,
15012 byte_end: 32,
15013 dependencies,
15014 };
15015 FileExtract {
15016 rel_path,
15017 freshness: FileFreshness {
15018 mtime: UNIX_EPOCH + Duration::from_secs(123),
15019 size: 40,
15020 content_hash: cache_freshness::hash_bytes(b"fixture source"),
15021 },
15022 lang: LangId::TypeScript,
15023 data: FileCallData {
15024 calls_by_symbol: HashMap::new(),
15025 value_refs_by_symbol: HashMap::new(),
15026 exported_symbols: Vec::new(),
15027 symbol_metadata: HashMap::new(),
15028 default_export_symbol: None,
15029 import_block: ImportBlock::empty(),
15030 lang: LangId::TypeScript,
15031 },
15032 nodes: vec![node.clone()],
15033 raw_refs: vec![raw_ref],
15034 dispatch_hints: vec![DispatchHint {
15035 id: "dispatch-main-helper".to_string(),
15036 method_name: "helper".to_string(),
15037 caller_node: node.id,
15038 file: "src/main.ts".to_string(),
15039 line: 1,
15040 byte_start: 24,
15041 byte_end: 32,
15042 }],
15043 surface_fingerprint: "surface".to_string(),
15044 }
15045 }
15046
15047 fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
15048 let raw = extract.raw_refs[0].clone();
15049 let mut dependencies = raw.dependencies.clone();
15050 dependencies.insert("src/helper.ts".to_string());
15051 ResolvedRef {
15052 edge: Some(EdgeRecord {
15053 edge_id: "edge-main-helper".to_string(),
15054 source_node: raw.caller_node.clone().expect("caller node"),
15055 target_node: Some("node-helper".to_string()),
15056 target_file: "src/helper.ts".to_string(),
15057 target_symbol: "helper".to_string(),
15058 kind: "call".to_string(),
15059 line: raw.line,
15060 }),
15061 raw,
15062 status: "resolved".to_string(),
15063 target_node: Some("node-helper".to_string()),
15064 target_file: Some("src/helper.ts".to_string()),
15065 target_symbol: Some("helper".to_string()),
15066 dependencies,
15067 }
15068 }
15069
15070 fn write_chunked_equivalence_fixture(project_root: &Path) {
15071 let ts_dir = project_root.join("ts");
15072 fs::create_dir_all(&ts_dir).expect("create ts dir");
15073 fs::write(
15074 ts_dir.join("leaf.ts"),
15075 "export function leaf(value: number) {\n return value + 1;\n}\n",
15076 )
15077 .expect("write ts leaf");
15078 fs::write(
15079 ts_dir.join("mid.ts"),
15080 "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n return leaf(value);\n}\n",
15081 )
15082 .expect("write ts mid");
15083 fs::write(
15084 ts_dir.join("entry.ts"),
15085 "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n return mid(worker.run());\n}\n",
15086 )
15087 .expect("write ts entry");
15088 fs::write(
15089 ts_dir.join("worker.ts"),
15090 "export class Worker {\n run() {\n return 41;\n }\n}\n",
15091 )
15092 .expect("write ts worker");
15093 for idx in 0..4 {
15094 fs::write(
15095 ts_dir.join(format!("extra_{idx}.ts")),
15096 format!(
15097 "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n return entry(new Worker());\n}}\n"
15098 ),
15099 )
15100 .expect("write ts extra");
15101 }
15102
15103 let rust_dir = project_root.join("src");
15104 let commands_dir = rust_dir.join("commands");
15105 fs::create_dir_all(&commands_dir).expect("create rust commands dir");
15106 fs::write(
15107 rust_dir.join("context.rs"),
15108 r#"pub struct AppContext;
15109
15110impl AppContext {
15111 pub fn callgraph_store_for_ops(&self) -> usize {
15112 1
15113 }
15114}
15115"#,
15116 )
15117 .expect("write rust context");
15118 fs::write(
15119 rust_dir.join("lib.rs"),
15120 "pub mod context;\npub mod commands;\n",
15121 )
15122 .expect("write rust lib");
15123 fs::write(
15124 commands_dir.join("mod.rs"),
15125 "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
15126 )
15127 .expect("write rust commands mod");
15128 for name in ["callers", "impact", "trace_to"] {
15129 fs::write(
15130 commands_dir.join(format!("{name}.rs")),
15131 format!(
15132 r#"use crate::context::AppContext;
15133
15134pub fn handle_{name}(ctx: &AppContext) -> usize {{
15135 ctx.callgraph_store_for_ops()
15136}}
15137"#
15138 ),
15139 )
15140 .expect("write rust command");
15141 }
15142 }
15143
15144 fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
15145 let src_dir = project_root.join("src");
15146 fs::create_dir_all(&src_dir).expect("create src dir");
15147
15148 let target_path = src_dir.join("target.ts");
15149 fs::write(&target_path, "export function target() {\n return 1;\n}\n")
15150 .expect("write target");
15151
15152 let index_path = src_dir.join("index.ts");
15153 fs::write(&index_path, barrel_source).expect("write barrel");
15154
15155 let mut files = vec![target_path, index_path];
15156 for (file_name, function_name) in [
15157 ("consumer_a.ts", "consumerA"),
15158 ("consumer_b.ts", "consumerB"),
15159 ("consumer_c.ts", "consumerC"),
15160 ] {
15161 let path = src_dir.join(file_name);
15162 fs::write(
15163 &path,
15164 format!(
15165 "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n return target();\n}}\n"
15166 ),
15167 )
15168 .expect("write consumer");
15169 files.push(path);
15170 }
15171 files
15172 }
15173
15174 fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
15175 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
15176 table_rows(&conn, table)
15177 }
15178
15179 fn graph_table_rows_without(
15180 store: &CallGraphStore,
15181 table: &str,
15182 excluded_columns: &[&str],
15183 ) -> Vec<String> {
15184 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
15185 table_rows_without(&conn, table, excluded_columns)
15186 }
15187
15188 fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
15189 table_rows_without(conn, table, &[])
15190 }
15191
15192 fn table_rows_without(
15193 conn: &Connection,
15194 table: &str,
15195 excluded_columns: &[&str],
15196 ) -> Vec<String> {
15197 let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
15198 let columns: Vec<String> = conn
15199 .prepare(&format!("PRAGMA table_info({table})"))
15200 .expect("prepare table_info")
15201 .query_map([], |row| row.get::<_, String>(1))
15202 .expect("query table_info")
15203 .collect::<std::result::Result<Vec<String>, _>>()
15204 .expect("collect columns")
15205 .into_iter()
15206 .filter(|column| !excluded_columns.contains(column.as_str()))
15207 .collect();
15208 let sql = format!(
15209 "SELECT {} FROM {table} ORDER BY {}",
15210 columns.join(", "),
15211 columns.join(", ")
15212 );
15213 conn.prepare(&sql)
15214 .expect("prepare table rows")
15215 .query_map([], |row| row_to_strings(row, columns.len()))
15216 .expect("query table rows")
15217 .collect::<std::result::Result<_, _>>()
15218 .expect("collect table rows")
15219 }
15220
15221 fn assert_cold_build_stats_match_except_elapsed(
15222 expected: &ColdBuildStats,
15223 actual: &ColdBuildStats,
15224 ) {
15225 assert_eq!(actual.files, expected.files, "file counts must match");
15226 assert_eq!(actual.nodes, expected.nodes, "node counts must match");
15227 assert_eq!(actual.refs, expected.refs, "ref counts must match");
15228 assert_eq!(actual.edges, expected.edges, "edge counts must match");
15229 assert_eq!(
15230 actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
15231 expected
15232 .failed_files
15233 .iter()
15234 .cloned()
15235 .collect::<BTreeSet<_>>(),
15236 "failed file sets must match"
15237 );
15238 }
15239
15240 fn backend_state_rows(conn: &Connection) -> Vec<String> {
15241 conn.prepare(
15242 "SELECT backend, workspace_root, file_path, content_hash, status
15243 FROM backend_file_state
15244 ORDER BY backend, workspace_root, file_path, content_hash, status",
15245 )
15246 .expect("prepare backend rows")
15247 .query_map([], |row| row_to_strings(row, 5))
15248 .expect("query backend rows")
15249 .collect::<std::result::Result<_, _>>()
15250 .expect("collect backend rows")
15251 }
15252
15253 fn secondary_indexes(conn: &Connection) -> Vec<String> {
15254 let mut indexes = Vec::new();
15255 for table in [
15256 "files",
15257 "nodes",
15258 "refs",
15259 "file_dependencies",
15260 "edges",
15261 "dispatch_hints",
15262 "type_ref_names",
15263 "backend_file_state",
15264 "meta",
15265 ] {
15266 let sql = format!("PRAGMA index_list({table})");
15267 let mut stmt = conn.prepare(&sql).expect("prepare index list");
15268 let rows = stmt
15269 .query_map([], |row| row.get::<_, String>(1))
15270 .expect("query index list");
15271 for name in rows {
15272 let name = name.expect("index name");
15273 if name.starts_with("idx_") {
15274 indexes.push(format!("{table}:{name}"));
15275 }
15276 }
15277 }
15278 indexes.sort();
15279 indexes
15280 }
15281
15282 fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
15283 let mut values = Vec::with_capacity(len);
15284 for index in 0..len {
15285 let value = row.get_ref(index)?;
15286 values.push(match value {
15287 rusqlite::types::ValueRef::Null => "NULL".to_string(),
15288 rusqlite::types::ValueRef::Integer(value) => value.to_string(),
15289 rusqlite::types::ValueRef::Real(value) => value.to_string(),
15290 rusqlite::types::ValueRef::Text(value) => {
15291 String::from_utf8_lossy(value).into_owned()
15292 }
15293 rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
15294 });
15295 }
15296 Ok(values.join("\u{1f}"))
15297 }
15298}
15299
15300#[cfg(test)]
15301mod rust_resolution_tests {
15302 use super::*;
15303 use crate::inspect::job::CallgraphSnapshot;
15304 use std::fs;
15305 use tempfile::tempdir;
15306
15307 #[test]
15308 fn rust_function_scoped_module_alias_resolves_and_projects_live() {
15309 let dir = tempdir().expect("tempdir");
15310 let root = dir.path();
15311 write_rust_manifest(root, "scoped-alias-fixture");
15312 write_file(
15313 root,
15314 "src/lib.rs",
15315 r#"pub mod finalization_contract;
15316
15317pub fn run_alias() {
15318 use crate::finalization_contract as fc;
15319 fc::check_mason_contract();
15320}
15321"#,
15322 );
15323 write_file(
15324 root,
15325 "src/finalization_contract.rs",
15326 r#"pub fn check_mason_contract() {}
15327fn planted_dead() {}
15328"#,
15329 );
15330
15331 let (store, snapshot) = cold_build_twice(root);
15332 assert_direct_caller(
15333 &store,
15334 "src/finalization_contract.rs",
15335 "check_mason_contract",
15336 "src/lib.rs",
15337 "run_alias",
15338 );
15339 assert_projected_call(
15340 root,
15341 &snapshot,
15342 "src/finalization_contract.rs",
15343 "check_mason_contract",
15344 );
15345 assert_no_projected_call(
15346 root,
15347 &snapshot,
15348 "src/finalization_contract.rs",
15349 "planted_dead",
15350 );
15351 assert!(
15352 store
15353 .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
15354 .expect("planted dead callers")
15355 .is_empty(),
15356 "planted-dead guard should stay without callers"
15357 );
15358 }
15359
15360 #[test]
15361 fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
15362 let dir = tempdir().expect("tempdir");
15363 let root = dir.path();
15364 write_rust_manifest(root, "inline-module-fixture");
15365 write_file(
15366 root,
15367 "src/lib.rs",
15368 r#"mod work_graph { fn operations() {} }
15369mod manifest { fn operations() {} }
15370mod audit { fn operations() {} }
15371mod dispatch { fn operations() {} }
15372mod finalization { fn operations() {} }
15373
15374pub fn run_inline_operations() {
15375 work_graph::operations();
15376 manifest::operations();
15377 audit::operations();
15378 dispatch::operations();
15379 finalization::operations();
15380}
15381
15382fn planted_dead() {}
15383"#,
15384 );
15385
15386 let (store, snapshot) = cold_build_twice(root);
15387 for module in [
15388 "work_graph",
15389 "manifest",
15390 "audit",
15391 "dispatch",
15392 "finalization",
15393 ] {
15394 assert_direct_caller(
15395 &store,
15396 "src/lib.rs",
15397 &format!("{module}::operations"),
15398 "src/lib.rs",
15399 "run_inline_operations",
15400 );
15401 }
15402 assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
15403 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
15404 }
15405
15406 #[test]
15407 fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
15408 let dir = tempdir().expect("tempdir");
15409 let root = dir.path();
15410 fs::write(
15411 root.join("Cargo.toml"),
15412 "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
15413 )
15414 .expect("write workspace manifest");
15415 write_file(
15416 root,
15417 "crates/but-action/Cargo.toml",
15418 r#"[package]
15419name = "but-action"
15420version = "0.1.0"
15421edition = "2021"
15422"#,
15423 );
15424 write_file(
15425 root,
15426 "crates/but-action/src/lib.rs",
15427 "mod action;\npub use action::{list_actions};\n",
15428 );
15429 write_file(
15430 root,
15431 "crates/but-action/src/action.rs",
15432 "pub fn list_actions() {}\nfn planted_dead() {}\n",
15433 );
15434 write_file(
15435 root,
15436 "crates/app/Cargo.toml",
15437 r#"[package]
15438name = "app"
15439version = "0.1.0"
15440edition = "2021"
15441"#,
15442 );
15443 write_file(
15444 root,
15445 "crates/app/src/lib.rs",
15446 "pub fn run_actions() {\n but_action::list_actions();\n}\n",
15447 );
15448
15449 let (store, snapshot) = cold_build_twice(root);
15450 assert_direct_caller(
15451 &store,
15452 "crates/but-action/src/action.rs",
15453 "list_actions",
15454 "crates/app/src/lib.rs",
15455 "run_actions",
15456 );
15457 assert!(
15458 store
15459 .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
15460 .expect("lib reexport callers")
15461 .is_empty(),
15462 "call should target the reexported source function, not lib.rs"
15463 );
15464 assert_projected_call(
15465 root,
15466 &snapshot,
15467 "crates/but-action/src/action.rs",
15468 "list_actions",
15469 );
15470 assert_no_projected_call(
15471 root,
15472 &snapshot,
15473 "crates/but-action/src/action.rs",
15474 "planted_dead",
15475 );
15476 }
15477
15478 #[test]
15479 fn rust_generic_self_turbofish_method_dispatch_resolves() {
15480 let dir = tempdir().expect("tempdir");
15481 let root = dir.path();
15482 write_rust_manifest(root, "generic-self-fixture");
15483 write_file(
15484 root,
15485 "src/lib.rs",
15486 r#"pub struct Matcher;
15487
15488impl Matcher {
15489 pub fn run(&self) -> bool {
15490 self.fuzzy_match_optimal::<usize>("needle")
15491 }
15492
15493 fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
15494 let _ = std::marker::PhantomData::<T>;
15495 true
15496 }
15497
15498 fn planted_dead(&self) {}
15499}
15500
15501pub fn entry() -> bool {
15502 let matcher = Matcher;
15503 matcher.run()
15504}
15505"#,
15506 );
15507
15508 let (store, snapshot) = cold_build_twice(root);
15509 assert_direct_caller(
15510 &store,
15511 "src/lib.rs",
15512 "Matcher::fuzzy_match_optimal",
15513 "src/lib.rs",
15514 "Matcher::run",
15515 );
15516 assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
15517 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
15518 }
15519
15520 #[test]
15521 fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
15522 let dir = tempdir().expect("tempdir");
15523 let root = dir.path();
15524 write_rust_manifest(root, "manifest-operations-fixture");
15525 write_file(
15526 root,
15527 "src/main.rs",
15528 r#"mod dispatch;
15529use dispatch::{manifest_operations};
15530
15531fn main() {
15532 manifest_operations();
15533}
15534"#,
15535 );
15536 write_file(
15537 root,
15538 "src/dispatch.rs",
15539 r#"mod work_graph { fn operations() {} }
15540mod manifest { fn operations() {} }
15541mod audit { fn operations() {} }
15542mod descriptor { fn operations() {} }
15543mod writer { fn operations() {} }
15544
15545pub fn manifest_operations() {
15546 manifest::operations();
15547}
15548
15549pub fn work_graph_operations() {
15550 work_graph::operations();
15551}
15552
15553pub fn audit_operations() {
15554 audit::operations();
15555}
15556
15557pub fn descriptor_operations() {
15558 descriptor::operations();
15559}
15560
15561pub fn writer_operations() {
15562 writer::operations();
15563}
15564
15565fn planted_dead() {}
15566"#,
15567 );
15568
15569 let (store, snapshot) = cold_build_twice(root);
15570 assert_direct_caller(
15571 &store,
15572 "src/dispatch.rs",
15573 "manifest_operations",
15574 "src/main.rs",
15575 "main",
15576 );
15577 assert_direct_caller(
15578 &store,
15579 "src/dispatch.rs",
15580 "manifest::operations",
15581 "src/dispatch.rs",
15582 "manifest_operations",
15583 );
15584 assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
15585 assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
15586 assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
15587 }
15588
15589 fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
15590 let files = rust_files(root);
15591 let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
15592 .expect("open first store");
15593 first.cold_build(&files).expect("first cold build");
15594 let first_snapshot =
15595 project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
15596
15597 let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
15598 .expect("open second store");
15599 second.cold_build(&files).expect("second cold build");
15600 let second_snapshot =
15601 project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
15602
15603 assert_eq!(
15604 projection_rows(&first_snapshot),
15605 projection_rows(&second_snapshot),
15606 "cold-build projection should be deterministic"
15607 );
15608 (first, first_snapshot)
15609 }
15610
15611 fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
15612 let mut rows = Vec::new();
15613 for export in &snapshot.exported_symbols {
15614 rows.push(format!(
15615 "export\t{}\t{}\t{}\t{}",
15616 export.file.display(),
15617 export.symbol,
15618 export.kind,
15619 export.line
15620 ));
15621 }
15622 for call in &snapshot.outbound_calls {
15623 rows.push(format!(
15624 "call\t{}\t{}\t{}\t{}\t{}",
15625 call.caller_file.display(),
15626 call.caller_symbol,
15627 call.target,
15628 call.line,
15629 call.provenance
15630 ));
15631 }
15632 for file in &snapshot.entry_points {
15633 rows.push(format!("entry_file\t{}", file.display()));
15634 }
15635 for (file, symbols) in &snapshot.entry_point_symbols {
15636 for symbol in symbols {
15637 rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
15638 }
15639 }
15640 rows.sort();
15641 rows
15642 }
15643
15644 fn assert_direct_caller(
15645 store: &CallGraphStore,
15646 target_rel: &str,
15647 target_symbol: &str,
15648 caller_rel: &str,
15649 caller_symbol: &str,
15650 ) {
15651 let callers = store
15652 .direct_callers_of(Path::new(target_rel), target_symbol)
15653 .unwrap_or_else(|error| {
15654 panic!("direct callers for {target_rel}::{target_symbol}: {error}")
15655 });
15656 assert!(
15657 callers.iter().any(|site| {
15658 site.caller.file == caller_rel && site.caller.symbol == caller_symbol
15659 }),
15660 "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
15661 );
15662 }
15663
15664 fn assert_projected_call(
15665 root: &Path,
15666 snapshot: &CallgraphSnapshot,
15667 target_rel: &str,
15668 symbol: &str,
15669 ) {
15670 let target = projected_target(root, target_rel, symbol);
15671 assert!(
15672 snapshot.outbound_calls.iter().any(|call| {
15673 call.target == target
15674 || call.target.starts_with(&format!(
15675 "{target}{}",
15676 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
15677 ))
15678 }),
15679 "expected projected call to {target}; calls: {:#?}",
15680 snapshot.outbound_calls
15681 );
15682 }
15683
15684 fn assert_no_projected_call(
15685 root: &Path,
15686 snapshot: &CallgraphSnapshot,
15687 target_rel: &str,
15688 symbol: &str,
15689 ) {
15690 let target = projected_target(root, target_rel, symbol);
15691 assert!(
15692 snapshot.outbound_calls.iter().all(|call| {
15693 call.target != target
15694 && !call.target.starts_with(&format!(
15695 "{target}{}",
15696 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
15697 ))
15698 }),
15699 "did not expect projected call to {target}; calls: {:#?}",
15700 snapshot.outbound_calls
15701 );
15702 }
15703
15704 fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
15705 let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
15708 format!("{}::{symbol}", path.display())
15709 }
15710
15711 fn write_rust_manifest(root: &Path, name: &str) {
15712 write_file(
15713 root,
15714 "Cargo.toml",
15715 &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
15716 );
15717 }
15718
15719 fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
15720 let path = root.join(rel_path);
15721 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
15722 fs::write(&path, source).expect("write fixture file");
15723 path
15724 }
15725
15726 fn rust_files(root: &Path) -> Vec<PathBuf> {
15727 let mut files = Vec::new();
15728 collect_rust_files(root, &mut files);
15729 files.sort();
15730 files
15731 }
15732
15733 fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
15734 for entry in fs::read_dir(dir).expect("read fixture dir") {
15735 let entry = entry.expect("read fixture entry");
15736 let path = entry.path();
15737 if path.is_dir() {
15738 let name = path
15739 .file_name()
15740 .and_then(|name| name.to_str())
15741 .unwrap_or("");
15742 if !name.starts_with(".store") {
15743 collect_rust_files(&path, files);
15744 }
15745 } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
15746 files.push(path);
15747 }
15748 }
15749 }
15750}
15751
15752#[cfg(test)]
15753mod build_pool_tests {
15754 use super::build_pool_size;
15755
15756 #[test]
15757 fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
15758 let size = build_pool_size();
15759 assert!(size >= 1, "pool size must be at least 1");
15762 assert!(size <= 8, "pool size must be capped at 8, got {size}");
15763
15764 let cores = std::thread::available_parallelism()
15765 .map(|p| p.get())
15766 .unwrap_or(1);
15767 let expected = cores.div_ceil(2).clamp(1, 8);
15768 assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
15769 }
15770}
15771
15772#[cfg(test)]
15773mod reexport_resolution_tests {
15774 use super::*;
15775
15776 fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
15777 ProjectIndex {
15778 project_root: PathBuf::from("/fixture"),
15779 files: files.into_iter().collect(),
15780 caller_data: HashMap::new(),
15781 workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
15782 }
15783 }
15784
15785 fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
15786 DbFileIndex {
15787 lang: None,
15788 exports: HashSet::new(),
15789 default_export: None,
15790 export_aliases: HashMap::new(),
15791 node_by_scoped: HashMap::new(),
15792 node_by_bare: HashMap::new(),
15793 node_kind_by_id: HashMap::new(),
15794 module_targets: HashMap::new(),
15795 reexports: reexport_targets
15796 .iter()
15797 .map(|target| ReexportIndex {
15798 target_file: Some((*target).to_string()),
15799 named: HashMap::new(),
15800 wildcard: true,
15801 })
15802 .collect(),
15803 }
15804 }
15805
15806 #[test]
15813 fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
15814 let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
15815 let files = names
15816 .iter()
15817 .map(|name| {
15818 let targets: Vec<&str> = names
15819 .iter()
15820 .filter(|other| *other != name)
15821 .map(String::as_str)
15822 .collect();
15823 (name.clone(), barrel_file(&targets))
15824 })
15825 .collect();
15826 let index = barrel_index(files);
15827
15828 assert_eq!(
15829 resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
15830 None
15831 );
15832 }
15833
15834 #[test]
15840 fn shallow_revisit_after_deep_capped_visit_still_resolves() {
15841 let mut leaf = barrel_file(&[]);
15842 leaf.exports.insert("deep_symbol".to_string());
15843 let mut files: Vec<(String, DbFileIndex)> = Vec::new();
15844 files.push((
15847 "src/entry.ts".to_string(),
15848 barrel_file(&["src/chain0.ts", "src/shared.ts"]),
15849 ));
15850 for i in 0..15 {
15851 let next = if i == 14 {
15852 "src/shared.ts".to_string()
15853 } else {
15854 format!("src/chain{}.ts", i + 1)
15855 };
15856 files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
15857 }
15858 files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
15859 files.push(("src/leaf.ts".to_string(), leaf));
15860 let index = barrel_index(files);
15861
15862 assert_eq!(
15863 resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
15864 Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
15865 "a shallower re-visit must not be pruned by a deeper capped visit"
15866 );
15867 }
15868
15869 #[test]
15870 fn symbol_reachable_through_reexport_cycle_still_resolves() {
15871 let mut leaf = barrel_file(&[]);
15872 leaf.exports.insert("real_symbol".to_string());
15873 let index = barrel_index(vec![
15874 (
15875 "src/a.ts".to_string(),
15876 barrel_file(&["src/b.ts", "src/a.ts"]),
15877 ),
15878 (
15879 "src/b.ts".to_string(),
15880 barrel_file(&["src/a.ts", "src/leaf.ts"]),
15881 ),
15882 ("src/leaf.ts".to_string(), leaf),
15883 ]);
15884
15885 assert_eq!(
15886 resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
15887 Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
15888 );
15889 }
15890}
15891
15892#[cfg(test)]
15893mod method_dispatch_inference_tests {
15894 use super::*;
15895 use std::fs;
15896 use tempfile::tempdir;
15897
15898 #[test]
15899 fn java_field_receiver_type_selects_declared_class_method() {
15900 let source = r#"class EntryPoint {
15901 private UserService userService;
15902
15903 void handle() {
15904 userService.find();
15905 }
15906}
15907
15908class UserService {
15909 void find() {}
15910}
15911
15912class AuditService {
15913 void find() {}
15914}
15915"#;
15916 let dir = tempdir().expect("temp dir");
15917 let root = dir.path();
15918 write_fixture(root, "src/EntryPoint.java", source);
15919 let reference = reference(
15920 "java",
15921 "src/EntryPoint.java",
15922 "EntryPoint::handle",
15923 "userService",
15924 "find",
15925 line_of(source, "userService.find()"),
15926 );
15927 let mut cache = DispatchSourceCache::new();
15928
15929 let receiver_type =
15930 infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
15931 assert_eq!(receiver_type, "UserService");
15932
15933 let candidates = vec![
15934 method_candidate("audit", "AuditService::find"),
15935 method_candidate("user", "UserService::find"),
15936 ];
15937 let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
15938 .expect("type candidate");
15939 assert_eq!(selected.scoped_name, "UserService::find");
15940
15941 let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
15942 assert!(
15943 select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
15944 );
15945 }
15946
15947 #[test]
15948 fn kotlin_property_and_local_value_types_are_inferred() {
15949 let source = r#"class Handler {
15950 private val auditService: AuditService = AuditService()
15951
15952 fun handle() {
15953 auditService.find()
15954 val userService: UserService = UserService()
15955 userService.find()
15956 val billingService = BillingService()
15957 billingService.find()
15958 }
15959}
15960
15961class UserService { fun find() {} }
15962class AuditService { fun find() {} }
15963class BillingService { fun find() {} }
15964"#;
15965 let dir = tempdir().expect("temp dir");
15966 let root = dir.path();
15967 write_fixture(root, "src/Handler.kt", source);
15968 let mut cache = DispatchSourceCache::new();
15969
15970 let audit_ref = reference(
15971 "kotlin",
15972 "src/Handler.kt",
15973 "Handler::handle",
15974 "auditService",
15975 "find",
15976 line_of(source, "auditService.find()"),
15977 );
15978 assert_eq!(
15979 infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
15980 Some("AuditService")
15981 );
15982
15983 let user_ref = reference(
15984 "kotlin",
15985 "src/Handler.kt",
15986 "Handler::handle",
15987 "userService",
15988 "find",
15989 line_of(source, "userService.find()"),
15990 );
15991 assert_eq!(
15992 infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
15993 Some("UserService")
15994 );
15995
15996 let billing_ref = reference(
15997 "kotlin",
15998 "src/Handler.kt",
15999 "Handler::handle",
16000 "billingService",
16001 "find",
16002 line_of(source, "billingService.find()"),
16003 );
16004 assert_eq!(
16005 infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
16006 Some("BillingService")
16007 );
16008 }
16009
16010 #[test]
16011 fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
16012 let source = r#"struct Foo { void run(); };
16013struct PointerFoo { void run(); };
16014struct FactoryFoo { void run(); };
16015FactoryFoo makeFactoryFoo();
16016
16017void handle() {
16018 Foo foo;
16019 foo.run();
16020 PointerFoo* pointerFoo = nullptr;
16021 pointerFoo->run();
16022 auto factoryFoo = makeFactoryFoo();
16023 factoryFoo.run();
16024}
16025"#;
16026 let dir = tempdir().expect("temp dir");
16027 let root = dir.path();
16028 write_fixture(root, "src/fixture.cpp", source);
16029 let mut cache = DispatchSourceCache::new();
16030
16031 let foo_ref = reference(
16032 "cpp",
16033 "src/fixture.cpp",
16034 "handle",
16035 "foo",
16036 "run",
16037 line_of(source, "foo.run()"),
16038 );
16039 assert_eq!(
16040 infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
16041 Some("Foo")
16042 );
16043
16044 let pointer_ref = reference(
16045 "cpp",
16046 "src/fixture.cpp",
16047 "handle",
16048 "pointerFoo",
16049 "run",
16050 line_of(source, "pointerFoo->run()"),
16051 );
16052 assert_eq!(
16053 infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
16054 Some("PointerFoo")
16055 );
16056
16057 let factory_ref = reference(
16058 "cpp",
16059 "src/fixture.cpp",
16060 "handle",
16061 "factoryFoo",
16062 "run",
16063 line_of(source, "factoryFoo.run()"),
16064 );
16065 assert_eq!(
16066 infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
16067 Some("FactoryFoo")
16068 );
16069 }
16070
16071 #[test]
16072 fn rust_direct_self_field_name_trims_separator_whitespace() {
16073 for receiver_expression in ["self .engine", "self. engine", "self . engine"] {
16074 assert_eq!(
16075 rust_direct_self_field_name(receiver_expression),
16076 Some("engine")
16077 );
16078 }
16079 }
16080
16081 #[test]
16082 fn rust_direct_self_field_receiver_type_is_conservative() {
16083 let source = r#"struct Engine;
16084
16085struct Car {
16086 engine: Engine,
16087}
16088
16089impl Car {
16090 fn run(&self) {
16091 self.engine.start();
16092 }
16093}
16094
16095struct NestedCar {
16096 engine: Engine,
16097}
16098
16099impl NestedCar {
16100 fn run(&self) {
16101 self.inner.engine.start();
16102 }
16103}
16104
16105struct WrappedCar {
16106 engine: Option<Engine>,
16107}
16108
16109impl WrappedCar {
16110 fn run(&self) {
16111 self.engine.start(); // wrapped
16112 }
16113}
16114
16115struct GenericCar<T> {
16116 engine: T,
16117}
16118
16119impl<T> GenericCar<T> {
16120 fn run(&self) {
16121 self.engine.start(); // generic
16122 }
16123}
16124
16125type EngineAlias = Engine;
16126
16127struct AliasCar {
16128 engine: EngineAlias,
16129}
16130
16131impl AliasCar {
16132 fn run(&self) {
16133 self.engine.start(); // alias
16134 }
16135}
16136"#;
16137 let dir = tempdir().expect("temp dir");
16138 let root = dir.path();
16139 write_fixture(root, "src/lib.rs", source);
16140 let mut cache = DispatchSourceCache::new();
16141
16142 let mut direct = reference(
16143 "rust",
16144 "src/lib.rs",
16145 "Car::run",
16146 "engine",
16147 "start",
16148 line_of(source, "self.engine.start()"),
16149 );
16150 direct.receiver_expression = "self.engine".to_string();
16151 assert_eq!(
16152 infer_receiver_type(root, &direct, &mut cache).as_deref(),
16153 Some("Engine")
16154 );
16155
16156 let mut mismatched_impl_target = direct.clone();
16157 mismatched_impl_target.caller_symbol = "other::Car::run".to_string();
16158 assert!(infer_receiver_type(root, &mismatched_impl_target, &mut cache).is_none());
16159
16160 let mut nested = reference(
16161 "rust",
16162 "src/lib.rs",
16163 "NestedCar::run",
16164 "engine",
16165 "start",
16166 line_of(source, "self.inner.engine.start()"),
16167 );
16168 nested.receiver_expression = "self.inner.engine".to_string();
16169 assert!(infer_receiver_type(root, &nested, &mut cache).is_none());
16170
16171 let mut wrapped = reference(
16172 "rust",
16173 "src/lib.rs",
16174 "WrappedCar::run",
16175 "engine",
16176 "start",
16177 line_of(source, "self.engine.start(); // wrapped"),
16178 );
16179 wrapped.receiver_expression = "self.engine".to_string();
16180 assert!(infer_receiver_type(root, &wrapped, &mut cache).is_none());
16181
16182 let mut generic = reference(
16183 "rust",
16184 "src/lib.rs",
16185 "GenericCar::run",
16186 "engine",
16187 "start",
16188 line_of(source, "self.engine.start(); // generic"),
16189 );
16190 generic.receiver_expression = "self.engine".to_string();
16191 assert!(infer_receiver_type(root, &generic, &mut cache).is_none());
16192
16193 let mut alias = reference(
16194 "rust",
16195 "src/lib.rs",
16196 "AliasCar::run",
16197 "engine",
16198 "start",
16199 line_of(source, "self.engine.start(); // alias"),
16200 );
16201 alias.receiver_expression = "self.engine".to_string();
16202 assert!(infer_receiver_type(root, &alias, &mut cache).is_none());
16203 }
16204
16205 #[test]
16206 fn rust_direct_self_reference_field_receiver_is_not_inferred() {
16207 let source = r#"struct Engine;
16208
16209struct Car {
16210 engine: &'static Engine,
16211}
16212
16213impl Car {
16214 fn run(&self) {
16215 self.engine.start();
16216 }
16217}
16218"#;
16219 let dir = tempdir().expect("temp dir");
16220 let root = dir.path();
16221 write_fixture(root, "src/lib.rs", source);
16222 let mut cache = DispatchSourceCache::new();
16223 let mut reference = reference(
16224 "rust",
16225 "src/lib.rs",
16226 "Car::run",
16227 "engine",
16228 "start",
16229 line_of(source, "self.engine.start()"),
16230 );
16231 reference.receiver_expression = "self.engine".to_string();
16232
16233 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
16234 }
16235
16236 #[test]
16237 fn rust_trait_impl_self_field_receiver_is_not_inferred() {
16238 let source = r#"trait Drive {
16239 fn run(&self);
16240}
16241
16242struct Engine;
16243
16244struct Car {
16245 engine: Engine,
16246}
16247
16248impl Drive for Car {
16249 fn run(&self) {
16250 self.engine.start();
16251 }
16252}
16253"#;
16254 let dir = tempdir().expect("temp dir");
16255 let root = dir.path();
16256 write_fixture(root, "src/lib.rs", source);
16257 let mut cache = DispatchSourceCache::new();
16258 let mut reference = reference(
16259 "rust",
16260 "src/lib.rs",
16261 "Car::run",
16262 "engine",
16263 "start",
16264 line_of(source, "self.engine.start()"),
16265 );
16266 reference.receiver_expression = "self.engine".to_string();
16267
16268 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
16269 }
16270
16271 #[test]
16272 fn rust_self_field_does_not_bind_struct_from_another_module() {
16273 let source = r#"struct Engine;
16274
16275mod unrelated {
16276 struct Car {
16277 engine: Engine,
16278 }
16279}
16280
16281impl Car {
16282 fn run(&self) {
16283 self.engine.start();
16284 }
16285}
16286"#;
16287 let dir = tempdir().expect("temp dir");
16288 let root = dir.path();
16289 write_fixture(root, "src/lib.rs", source);
16290 let mut cache = DispatchSourceCache::new();
16291 let mut reference = reference(
16292 "rust",
16293 "src/lib.rs",
16294 "Car::run",
16295 "engine",
16296 "start",
16297 line_of(source, "self.engine.start()"),
16298 );
16299 reference.receiver_expression = "self.engine".to_string();
16300
16301 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
16302 }
16303
16304 #[test]
16305 fn unknown_java_receiver_still_uses_name_match_fallback() {
16306 let source = r#"class EntryPoint {
16307 void handle() {
16308 service.runSpecial();
16309 }
16310}
16311
16312class OnlyService {
16313 void runSpecial() {}
16314}
16315"#;
16316 let dir = tempdir().expect("temp dir");
16317 let root = dir.path();
16318 write_fixture(root, "src/EntryPoint.java", source);
16319 let reference = reference(
16320 "java",
16321 "src/EntryPoint.java",
16322 "EntryPoint::handle",
16323 "service",
16324 "runSpecial",
16325 line_of(source, "service.runSpecial()"),
16326 );
16327 let mut cache = DispatchSourceCache::new();
16328
16329 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
16330 let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
16331 let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
16332 assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
16333 }
16334
16335 fn reference(
16336 lang: &str,
16337 caller_file: &str,
16338 caller_symbol: &str,
16339 receiver: &str,
16340 method_name: &str,
16341 line: u32,
16342 ) -> NameMatchRef {
16343 NameMatchRef {
16344 ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
16345 caller_node: format!("{caller_symbol}:node"),
16346 caller_file: caller_file.to_string(),
16347 caller_symbol: caller_symbol.to_string(),
16348 caller_signature: None,
16349 receiver_expression: receiver.to_string(),
16350 receiver: receiver.to_string(),
16351 method_name: method_name.to_string(),
16352 colon_dispatch: false,
16353 line,
16354 lang: lang.to_string(),
16355 }
16356 }
16357
16358 fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
16359 NameMatchCandidate {
16360 node_id: node_id.to_string(),
16361 file_path: "src/targets.fixture".to_string(),
16362 scoped_name: scoped_name.to_string(),
16363 kind: "method".to_string(),
16364 start_line: 1,
16365 }
16366 }
16367
16368 fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
16369 let path = root.join(rel_path);
16370 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
16371 fs::write(path, source).expect("write fixture");
16372 }
16373
16374 fn line_of(source: &str, needle: &str) -> u32 {
16375 source
16376 .lines()
16377 .position(|line| line.contains(needle))
16378 .map(|index| index as u32 + 1)
16379 .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
16380 }
16381}
16382
16383#[cfg(test)]
16384mod bounded_build_breaker_tests {
16385 use super::*;
16386 use crate::build_breaker::{BreakerAdmission, BreakerKey, BuildDeathBreaker, BuildDomain};
16387 use tempfile::tempdir;
16388
16389 #[test]
16390 fn staged_inventory_drives_ordered_bounded_file_batches() {
16391 let temp = tempdir().unwrap();
16392 let root = temp.path().join("root");
16393 std::fs::create_dir_all(&root).unwrap();
16394 let first = root.join("a.ts");
16395 let second = root.join("b.ts");
16396 let third = root.join("c.ts");
16397 for path in [&first, &second, &third] {
16398 std::fs::write(path, "export function item() {}\n").unwrap();
16399 }
16400 let writer_lease = acquire_writer_lease(temp.path(), "inventory-key", &root)
16401 .unwrap()
16402 .expect("test root may write its private staging database");
16403 let store = CallGraphStore::open_at_path(
16404 root.clone(),
16405 "inventory-key".to_string(),
16406 temp.path().join("inventory.sqlite"),
16407 None,
16408 true,
16409 Some(writer_lease),
16410 None,
16411 )
16412 .unwrap()
16413 .store;
16414 let fingerprint = store
16415 .stage_cold_build_file_inventory(&[
16416 third.clone(),
16417 first.clone(),
16418 second.clone(),
16419 first.clone(),
16420 ])
16421 .unwrap();
16422
16423 let conn = store.conn.lock().unwrap();
16424 assert_eq!(
16425 query_count(&conn, "SELECT COUNT(*) FROM staging_file_inventory").unwrap(),
16426 3,
16427 "the primary key deduplicates caller-supplied paths on disk"
16428 );
16429 let first_batch = load_staged_file_batch(&conn, &root, "", 2, u64::MAX)
16430 .unwrap()
16431 .expect("first batch");
16432 assert_eq!(first_batch.paths, vec![first.clone(), second]);
16433 let second_batch =
16434 load_staged_file_batch(&conn, &root, &first_batch.last_path, 2, u64::MAX)
16435 .unwrap()
16436 .expect("second batch");
16437 assert_eq!(second_batch.paths, vec![third]);
16438 assert_eq!(
16439 fingerprint,
16440 callgraph_corpus_fingerprint(&root).unwrap(),
16441 "staged and direct streaming fingerprints agree without walk-order dependence"
16442 );
16443 }
16444
16445 #[test]
16446 fn resumed_stage_preserves_committed_batch_and_counter() {
16447 let temp = tempdir().unwrap();
16448 let root = temp.path().join("root");
16449 std::fs::create_dir_all(&root).unwrap();
16450 let first = root.join("first.ts");
16451 let second = root.join("second.ts");
16452 std::fs::write(&first, "export function first() {}\n").unwrap();
16453 std::fs::write(&second, "export function second() { first(); }\n").unwrap();
16454 let staging = temp.path().join("stage.sqlite");
16455 let writer_lease = acquire_writer_lease(temp.path(), "test-key", &root)
16456 .unwrap()
16457 .expect("test root may write its private staging database");
16458 let store = CallGraphStore::open_at_path(
16459 root.clone(),
16460 "test-key".to_string(),
16461 staging,
16462 None,
16463 true,
16464 Some(writer_lease),
16465 None,
16466 )
16467 .unwrap()
16468 .store;
16469 let corpus_fingerprint = store
16470 .stage_cold_build_file_inventory(&[first.clone(), second.clone()])
16471 .unwrap();
16472 let first_extract = build_file_extract(&root, &first).unwrap();
16473 let first_bytes = first_extract.freshness.size;
16474 {
16475 let mut conn = store.conn.lock().unwrap();
16476 let tx = conn.transaction().unwrap();
16477 clear_tables(&tx).unwrap();
16478 insert_meta(&tx).unwrap();
16479 drop_cold_build_secondary_indexes(&tx).unwrap();
16480 set_meta_ready(&tx, false).unwrap();
16481 set_staged_build_phase(&tx, "extracting").unwrap();
16482 set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, &corpus_fingerprint).unwrap();
16483 set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0).unwrap();
16484 {
16485 let mut inserts = ColdBuildInsertStatements::new(&tx).unwrap();
16486 insert_file_extract_prepared(
16487 &mut inserts,
16488 &root.display().to_string(),
16489 &first_extract,
16490 )
16491 .unwrap();
16492 for raw in &first_extract.raw_refs {
16493 insert_staged_ref_prepared(&mut inserts, raw).unwrap();
16494 }
16495 }
16496 increment_staged_extracted_bytes(&tx, first_bytes).unwrap();
16497 tx.commit().unwrap();
16498 }
16499
16500 store
16501 .cold_build_chunked(&[first.clone(), second.clone()], 1)
16502 .unwrap();
16503 let conn = store.conn.lock().unwrap();
16504 assert_eq!(query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(), 2);
16505 assert_eq!(
16506 staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
16507 first_bytes + std::fs::metadata(second).unwrap().len(),
16508 "the already committed batch and its credit survive adoption; only the new batch increments credit"
16509 );
16510 assert_eq!(staged_build_phase(&conn).unwrap().as_deref(), Some("ready"));
16511 }
16512
16513 const SPECIMEN_CHILD_TEST: &str =
16514 "callgraph_store::bounded_build_breaker_tests::respawn_loop_build_child";
16515 const SPECIMEN_CHILD_ROOT: &str = "AFT_SPECIMEN_CHILD_ROOT";
16516 const SPECIMEN_CHILD_STORE: &str = "AFT_SPECIMEN_CHILD_STORE";
16517 const SPECIMEN_CHILD_PHASE: &str = "AFT_SPECIMEN_CHILD_PHASE";
16518 const SPECIMEN_CHILD_SIGNAL: &str = "AFT_SPECIMEN_CHILD_SIGNAL";
16519
16520 fn wait_for_child_barrier(path: &Path) {
16521 let deadline = Instant::now() + Duration::from_secs(10);
16522 while !path.exists() {
16523 assert!(
16524 Instant::now() < deadline,
16525 "callgraph child did not reach barrier {}",
16526 path.display()
16527 );
16528 std::thread::sleep(Duration::from_millis(5));
16529 }
16530 }
16531
16532 fn spawn_build_child(root: &Path, store: &Path, phase: Option<&str>) -> std::process::Child {
16533 let signal = store.join("specimen-child.reached");
16534 let _ = std::fs::remove_file(&signal);
16535 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
16536 command
16537 .arg("--exact")
16538 .arg(SPECIMEN_CHILD_TEST)
16539 .arg("--nocapture")
16540 .arg("--test-threads=1")
16541 .env(SPECIMEN_CHILD_ROOT, root)
16542 .env(SPECIMEN_CHILD_STORE, store)
16543 .env(SPECIMEN_CHILD_SIGNAL, &signal)
16544 .stdout(std::process::Stdio::null())
16545 .stderr(std::process::Stdio::null());
16546 if let Some(phase) = phase {
16547 command.env(SPECIMEN_CHILD_PHASE, phase);
16548 }
16549 command.spawn().unwrap()
16550 }
16551
16552 fn staging_path(root: &Path, store: &Path) -> PathBuf {
16553 let project_key = crate::search_index::artifact_cache_key(root);
16554 store.join(format!("{project_key}.staging.sqlite.tmp.resume"))
16555 }
16556
16557 fn durable_staging_state(path: &Path) -> (u64, u64) {
16558 if !path.exists() {
16559 return (0, 0);
16560 }
16561 let conn = Connection::open(path).unwrap();
16562 (
16563 query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(),
16564 staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
16565 )
16566 }
16567
16568 fn kill_barrier_child(child: &mut std::process::Child, signal: &Path) {
16569 wait_for_child_barrier(signal);
16570 child.kill().unwrap();
16571 let _ = child.wait().unwrap();
16572 }
16573
16574 #[test]
16575 fn respawn_loop_build_child() {
16576 let Some(root) = std::env::var_os(SPECIMEN_CHILD_ROOT) else {
16577 return;
16578 };
16579 let root = PathBuf::from(root);
16580 let store = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_STORE).unwrap());
16581 if let Some(phase) = std::env::var_os(SPECIMEN_CHILD_PHASE) {
16582 let phase = phase.to_string_lossy().into_owned();
16583 let signal = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_SIGNAL).unwrap());
16584 set_cold_build_phase_observer(Some(Arc::new(move |observed| {
16585 if observed == phase {
16586 std::fs::write(&signal, observed.as_bytes()).unwrap();
16587 std::thread::sleep(Duration::from_secs(30));
16588 }
16589 })));
16590 }
16591 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
16592 CallGraphStore::cold_build_with_lease_chunked(store, root, &files, 1).unwrap();
16593 }
16594
16595 #[test]
16596 fn issue_250_respawn_loop_converges_or_trips_without_false_readiness() {
16597 let temp = tempdir().unwrap();
16598 let root = temp.path().join("resumable-root");
16599 let store = temp.path().join("resumable-store");
16600 std::fs::create_dir_all(&root).unwrap();
16601 std::fs::create_dir_all(&store).unwrap();
16602 for index in 0..3 {
16603 std::fs::write(
16604 root.join(format!("file-{index}.ts")),
16605 format!("export function specimen{index}() {{ return {index}; }}\n"),
16606 )
16607 .unwrap();
16608 }
16609 let stage = staging_path(&root, &store);
16610 let signal = store.join("specimen-child.reached");
16611
16612 let mut first = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
16613 kill_barrier_child(&mut first, &signal);
16614 let (first_rows, first_bytes) = durable_staging_state(&stage);
16615 assert_eq!(first_rows, 1);
16616 assert!(first_bytes > 0);
16617
16618 let mut second = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
16619 kill_barrier_child(&mut second, &signal);
16620 let (second_rows, second_bytes) = durable_staging_state(&stage);
16621 assert_eq!(second_rows, 2);
16622 assert!(
16623 second_bytes > first_bytes,
16624 "a replacement process must adopt committed bytes instead of restarting from zero"
16625 );
16626
16627 let status = spawn_build_child(&root, &store, None).wait().unwrap();
16628 assert!(status.success(), "uninterrupted replacement build failed");
16629 assert!(!stage.exists(), "published staging file must be renamed");
16630 let ready = CallGraphStore::open_readonly(store.clone(), root.clone())
16631 .unwrap()
16632 .expect("replacement attempts must converge to a published graph");
16633 assert_eq!(ready.indexed_file_count().unwrap(), 3);
16634
16635 let fast_root = temp.path().join("zero-credit-root");
16636 let fast_store = temp.path().join("zero-credit-store");
16637 std::fs::create_dir_all(&fast_root).unwrap();
16638 std::fs::create_dir_all(&fast_store).unwrap();
16639 std::fs::write(
16640 fast_root.join("main.ts"),
16641 "export function neverCommitted() {}\n",
16642 )
16643 .unwrap();
16644 let fast_stage = staging_path(&fast_root, &fast_store);
16645 let fast_signal = fast_store.join("specimen-child.reached");
16646 let breaker_path = fast_store.join("build-breaker.sqlite");
16647 let now = unix_millis_now();
16648
16649 for death in 0..3 {
16650 let mut child = spawn_build_child(&fast_root, &fast_store, Some("enumeration"));
16651 wait_for_child_barrier(&fast_signal);
16652 let attempt_id = Connection::open(&breaker_path)
16653 .unwrap()
16654 .query_row(
16655 "SELECT attempt_id FROM breaker_attempts
16656 WHERE death_charged = 0 ORDER BY rowid DESC LIMIT 1",
16657 [],
16658 |row| row.get::<_, String>(0),
16659 )
16660 .unwrap();
16661 let (_, committed_bytes) = durable_staging_state(&fast_stage);
16662 assert_eq!(
16663 committed_bytes, 0,
16664 "the fast-kill schedule must not cross an extraction commit"
16665 );
16666 child.kill().unwrap();
16667 let _ = child.wait().unwrap();
16668
16669 let key = BreakerKey::new(
16670 fast_root.display().to_string(),
16671 BuildDomain::CallgraphCold,
16672 callgraph_corpus_fingerprint(&fast_root).unwrap(),
16673 );
16674 BuildDeathBreaker::open(&breaker_path)
16675 .unwrap()
16676 .record_attributed_death_at(&key, &attempt_id, committed_bytes, 0, now + death)
16677 .unwrap();
16678 }
16679
16680 let files = crate::callgraph::walk_project_files(&fast_root).collect::<Vec<_>>();
16681 let suspension = CallGraphStore::cold_build_suspension(&fast_store, &fast_root)
16682 .unwrap()
16683 .expect("three zero-credit process deaths must suspend the root");
16684 assert_eq!(suspension.reason, "zero_credit_death_limit");
16685 assert_eq!(suspension.death_count, 3);
16686 let response = crate::commands::callgraph_store_adapter::suspended_response(
16687 "specimen",
16688 "callers",
16689 &suspension,
16690 );
16691 assert_eq!(response.data["code"], serde_json::json!("build_suspended"));
16692 let message = response.data["message"].as_str().unwrap();
16693 assert!(
16694 message.starts_with("callers: build_suspended domain=callgraph_cold deaths=3 age_ms=")
16695 );
16696 assert!(message.ends_with(
16697 " reason=zero_credit_death_limit; run doctor reset-build-breaker to resume"
16698 ));
16699 let refused =
16700 CallGraphStore::cold_build_with_lease_chunked(fast_store, fast_root, &files, 1)
16701 .expect_err("a suspended root must not report a perpetually building worker");
16702 assert!(matches!(refused, CallGraphStoreError::Suspended(_)));
16703 }
16704
16705 #[test]
16706 fn published_callgraph_build_respects_durable_domain_suspension() {
16707 let temp = tempdir().unwrap();
16708 let root = temp.path().join("root");
16709 let store_dir = temp.path().join("store");
16710 std::fs::create_dir_all(&root).unwrap();
16711 let source = root.join("main.ts");
16712 std::fs::write(&source, "export function marker() {}\n").unwrap();
16713 let files = vec![source];
16714 let key = BreakerKey::new(
16715 root.display().to_string(),
16716 BuildDomain::CallgraphCold,
16717 callgraph_corpus_fingerprint(&root).unwrap(),
16718 );
16719 let breaker = BuildDeathBreaker::open(store_dir.join("build-breaker.sqlite")).unwrap();
16720 for _ in 0..3 {
16721 let BreakerAdmission::Admitted(attempt) = breaker.admit(&key, 0).unwrap() else {
16722 panic!("unexpected early suspension");
16723 };
16724 breaker
16725 .record_attributed_death(&key, &attempt.attempt_id, 0, 0)
16726 .unwrap();
16727 }
16728
16729 let error = CallGraphStore::cold_build_with_lease_chunked(store_dir, root, &files, 1)
16730 .expect_err("durably tripped callgraph domain must refuse a new cold build");
16731 assert!(matches!(
16732 error,
16733 CallGraphStoreError::Suspended(ref suspension)
16734 if suspension.domain == BuildDomain::CallgraphCold
16735 && suspension.death_count == 3
16736 ));
16737 }
16738}