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 NAME_MATCH_SCORE_THRESHOLD: f64 = 2.0;
35const TOP_LEVEL_SYMBOL: &str = "<top-level>";
36const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
37const MIGRATION_MANIFEST_VERSION: u32 = 1;
38const MIGRATION_GENERATION_TAG: &str = ".migrated.";
39const MIGRATION_BACKUP_PAGES_PER_STEP: i32 = 128;
40const MIGRATION_BACKUP_RETRY_BUDGET: usize = 25;
41const MIGRATION_BACKUP_WALL_CLOCK_BUDGET: Duration = Duration::from_secs(10);
42const SQLITE_FILE_SET_SUFFIXES: &[&str] = &["", "-wal", "-shm", "-journal"];
43const MARKED_GENERATION_RETENTION_TTL: Duration = Duration::from_secs(6 * 60 * 60);
48const REFRESH_WORKER_WARN_AFTER: Duration = Duration::from_secs(5);
49const REFRESH_WORKER_FINAL_AFTER: Duration = Duration::from_secs(30);
50pub const REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET: Duration = Duration::from_millis(100);
51const REBUILD_COOLDOWN: Duration = Duration::from_secs(30);
52const ROOT_REPAIR_WARN_INTERVAL: Duration = Duration::from_secs(60);
53const CALLGRAPH_WRITE_METRIC_WINDOW: Duration = Duration::from_secs(60);
54const CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES: i64 = 4_000;
55const REFRESH_IDLE_CHECKPOINT_INTERVAL: Duration = Duration::from_secs(60);
56
57fn write_amplification_baseline_enabled() -> bool {
58 std::env::var_os("AFT_CALLGRAPH_WRITE_AMP_BASELINE").is_some()
59}
60
61type ColdBuildSwapObserver = dyn Fn(&Path, &Path) + Send + Sync + 'static;
62
63#[derive(Clone, Debug, Eq, Hash, PartialEq)]
64struct RebuildCooldownKey {
65 callgraph_dir: PathBuf,
66 project_key: String,
67}
68
69#[derive(Clone, Debug)]
70struct RebuildCooldownRecord {
71 project_root: PathBuf,
72 published_at: Instant,
73 cross_root_cooldown_armed: bool,
74}
75
76static SUCCESSFUL_REBUILDS: OnceLock<Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>> =
81 OnceLock::new();
82
83#[derive(Clone, Debug, Eq, Hash, PartialEq)]
84struct RootRepairWarningKey {
85 project_key: String,
86}
87
88#[derive(Clone, Debug)]
89struct RootRepairWarningRecord {
90 window_start: Instant,
91 last_emitted: Instant,
92 entry_count: u64,
93 suppressed: u64,
94}
95
96static ROOT_REPAIR_WARNINGS: OnceLock<
97 Mutex<HashMap<RootRepairWarningKey, RootRepairWarningRecord>>,
98> = OnceLock::new();
99
100#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
101pub(crate) struct CallgraphWriteMetricsSnapshot {
102 pub commits_60s: u64,
103 pub pages_or_bytes_written_60s: u64,
104}
105
106#[derive(Debug, Default)]
107struct CallgraphWriteMetrics {
108 window_start_ms: AtomicU64,
109 commits_60s: AtomicU64,
110 pages_or_bytes_written_60s: AtomicU64,
111}
112
113static CALLGRAPH_WRITE_METRICS: OnceLock<Mutex<HashMap<String, Arc<CallgraphWriteMetrics>>>> =
114 OnceLock::new();
115
116fn callgraph_write_metrics_for_key(project_key: &str) -> Arc<CallgraphWriteMetrics> {
117 let metrics = CALLGRAPH_WRITE_METRICS.get_or_init(|| Mutex::new(HashMap::new()));
118 let mut metrics = metrics
119 .lock()
120 .expect("callgraph write metrics mutex poisoned");
121 Arc::clone(
122 metrics
123 .entry(project_key.to_string())
124 .or_insert_with(|| Arc::new(CallgraphWriteMetrics::default())),
125 )
126}
127
128fn roll_callgraph_write_metric_window(metrics: &CallgraphWriteMetrics, now_ms: u64) {
129 let current_start = metrics.window_start_ms.load(AtomicOrdering::Acquire);
130 if current_start == 0 {
131 let _ = metrics.window_start_ms.compare_exchange(
132 0,
133 now_ms,
134 AtomicOrdering::AcqRel,
135 AtomicOrdering::Acquire,
136 );
137 return;
138 }
139 if now_ms.saturating_sub(current_start) < CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64 {
140 return;
141 }
142 if metrics
143 .window_start_ms
144 .compare_exchange(
145 current_start,
146 now_ms,
147 AtomicOrdering::AcqRel,
148 AtomicOrdering::Acquire,
149 )
150 .is_ok()
151 {
152 metrics.commits_60s.store(0, AtomicOrdering::Release);
153 metrics
154 .pages_or_bytes_written_60s
155 .store(0, AtomicOrdering::Release);
156 }
157}
158
159impl CallgraphWriteMetrics {
160 fn record_commit(&self, pages_or_bytes_written: u64) {
161 let now_ms = unix_millis_now();
162 roll_callgraph_write_metric_window(self, now_ms);
163 self.commits_60s.fetch_add(1, AtomicOrdering::Relaxed);
164 self.pages_or_bytes_written_60s
165 .fetch_add(pages_or_bytes_written, AtomicOrdering::Relaxed);
166 }
167
168 fn snapshot(&self) -> CallgraphWriteMetricsSnapshot {
169 roll_callgraph_write_metric_window(self, unix_millis_now());
170 CallgraphWriteMetricsSnapshot {
171 commits_60s: self.commits_60s.load(AtomicOrdering::Acquire),
172 pages_or_bytes_written_60s: self
173 .pages_or_bytes_written_60s
174 .load(AtomicOrdering::Acquire),
175 }
176 }
177}
178
179pub(crate) fn callgraph_write_metrics_for_project(
180 project_key: &str,
181) -> CallgraphWriteMetricsSnapshot {
182 callgraph_write_metrics_for_key(project_key).snapshot()
183}
184
185pub(crate) fn callgraph_write_metrics_total() -> CallgraphWriteMetricsSnapshot {
186 let Some(metrics) = CALLGRAPH_WRITE_METRICS.get() else {
187 return CallgraphWriteMetricsSnapshot::default();
188 };
189 let metrics = metrics
190 .lock()
191 .expect("callgraph write metrics mutex poisoned");
192 metrics.values().map(|metrics| metrics.snapshot()).fold(
193 CallgraphWriteMetricsSnapshot::default(),
194 |total, current| CallgraphWriteMetricsSnapshot {
195 commits_60s: total.commits_60s.saturating_add(current.commits_60s),
196 pages_or_bytes_written_60s: total
197 .pages_or_bytes_written_60s
198 .saturating_add(current.pages_or_bytes_written_60s),
199 },
200 )
201}
202
203const ROOT_REPAIR_WARNING_TEXT: &str =
204 "callgraph store root repair requires rebuild; open-only reader reports unavailable";
205
206fn next_root_repair_warning(key: RootRepairWarningKey, now: Instant) -> Option<String> {
207 let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
208 let mut warnings = warnings.lock().ok()?;
209 let entry = warnings.entry(key);
210 let record = match entry {
211 Entry::Vacant(entry) => {
212 entry.insert(RootRepairWarningRecord {
213 window_start: now,
214 last_emitted: now,
215 entry_count: 1,
216 suppressed: 0,
217 });
218 return Some(ROOT_REPAIR_WARNING_TEXT.to_string());
219 }
220 Entry::Occupied(entry) => entry.into_mut(),
221 };
222
223 if now.saturating_duration_since(record.window_start) >= ROOT_REPAIR_WARN_INTERVAL {
224 let suppressed = record.suppressed;
225 record.window_start = now;
226 record.last_emitted = now;
227 record.entry_count = 1;
228 record.suppressed = 0;
229 return Some(if suppressed == 0 {
230 ROOT_REPAIR_WARNING_TEXT.to_string()
231 } else {
232 format!("{ROOT_REPAIR_WARNING_TEXT} (repeated {suppressed}x in 60s)")
233 });
234 }
235
236 record.entry_count = record.entry_count.saturating_add(1);
237 if now.saturating_duration_since(record.last_emitted) < ROOT_REPAIR_WARN_INTERVAL {
238 record.suppressed = record.suppressed.saturating_add(1);
239 None
240 } else {
241 record.last_emitted = now;
242 Some(ROOT_REPAIR_WARNING_TEXT.to_string())
243 }
244}
245
246pub(crate) fn note_repair_entry(project_key: &str) -> Option<String> {
247 next_root_repair_warning(
248 RootRepairWarningKey {
249 project_key: project_key.to_string(),
250 },
251 Instant::now(),
252 )
253}
254
255pub(crate) fn repair_entry_rate(project_key: &str) -> Option<(u64, Instant)> {
260 let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
261 let warnings = warnings.lock().ok()?;
262 let record = warnings.get(&RootRepairWarningKey {
263 project_key: project_key.to_string(),
264 })?;
265 (Instant::now().saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL)
266 .then_some((record.entry_count, record.window_start))
267}
268
269pub(crate) fn repair_entry_rate_total() -> u64 {
270 let Ok(warnings) = ROOT_REPAIR_WARNINGS
271 .get_or_init(|| Mutex::new(HashMap::new()))
272 .lock()
273 else {
274 return 0;
275 };
276 let now = Instant::now();
277 warnings
278 .values()
279 .filter(|record| {
280 now.saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL
281 })
282 .map(|record| record.entry_count)
283 .sum()
284}
285
286#[cfg(test)]
287pub(crate) fn expire_repair_entry_window_for_test(project_key: &str) {
288 let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
289 let mut warnings = warnings.lock().unwrap();
290 if let Some(record) = warnings.get_mut(&RootRepairWarningKey {
291 project_key: project_key.to_string(),
292 }) {
293 record.window_start = Instant::now() - ROOT_REPAIR_WARN_INTERVAL;
294 }
295}
296
297#[cfg(test)]
298mod root_repair_warning_tests {
299 use super::*;
300
301 #[test]
302 fn repair_warning_emits_once_then_reemits_with_suppressed_count() {
303 let key = RootRepairWarningKey {
304 project_key: "test-project".to_string(),
305 };
306 let first_at = Instant::now();
307 let first = next_root_repair_warning(key.clone(), first_at).unwrap();
308 assert_eq!(first, ROOT_REPAIR_WARNING_TEXT);
309 assert!(next_root_repair_warning(key.clone(), first_at + Duration::from_secs(1)).is_none());
310 assert_eq!(
311 repair_entry_rate("test-project").map(|rate| rate.0),
312 Some(2)
313 );
314
315 let repeated = next_root_repair_warning(key, first_at + ROOT_REPAIR_WARN_INTERVAL).unwrap();
316 assert!(repeated.ends_with("(repeated 1x in 60s)"));
317 expire_repair_entry_window_for_test("test-project");
318 assert!(repair_entry_rate("test-project").is_none());
319 }
320}
321
322#[cfg(test)]
323mod write_amplification_tests {
324 use super::*;
325 use std::fs;
326 use tempfile::tempdir;
327
328 #[test]
329 fn callgraph_writer_and_reader_use_bounded_normal_pragmas() {
330 let temp = tempdir().unwrap();
331 let root = temp.path().join("root");
332 fs::create_dir_all(&root).unwrap();
333 let source = root.join("main.ts");
334 fs::write(&source, "export function main() {}\n").unwrap();
335 let store_dir = temp.path().join("store");
336 let store = CallGraphStore::open(store_dir.clone(), root.clone()).unwrap();
337
338 let conn = store.conn.lock().unwrap();
339 let synchronous: i64 = conn
340 .pragma_query_value(None, "synchronous", |row| row.get(0))
341 .unwrap();
342 let autocheckpoint: i64 = conn
343 .pragma_query_value(None, "wal_autocheckpoint", |row| row.get(0))
344 .unwrap();
345 assert_eq!(synchronous, 1, "NORMAL synchronous mode is value 1");
346 assert_eq!(autocheckpoint, CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES);
347 drop(conn);
348 store.cold_build(std::slice::from_ref(&source)).unwrap();
349 drop(store);
350
351 let readonly = CallGraphStore::open_readonly(store_dir, root)
352 .unwrap()
353 .expect("writer-created empty schema should be readable");
354 let conn = readonly.inner.conn.lock().unwrap();
355 let synchronous: i64 = conn
356 .pragma_query_value(None, "synchronous", |row| row.get(0))
357 .unwrap();
358 assert_eq!(synchronous, 1);
359 }
360
361 #[test]
362 fn own_refresh_skips_identical_extract_but_not_position_shift() {
363 let temp = tempdir().unwrap();
364 let root = temp.path().join("root");
365 fs::create_dir_all(&root).unwrap();
366 let source = root.join("main.ts");
367 fs::write(&source, "export function main() { return 1; }\n").unwrap();
368 let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
369 store.cold_build(std::slice::from_ref(&source)).unwrap();
370 let write_metrics = callgraph_write_metrics_for_project(store.project_key());
371 assert!(write_metrics.commits_60s > 0);
372 assert!(write_metrics.pages_or_bytes_written_60s > 0);
373
374 let before = store.conn.lock().unwrap().total_changes();
375 fs::write(&source, "export function main() { return 1; }\n\n").unwrap();
376 let (stats, _) = store
377 .refresh_files_profiled(std::slice::from_ref(&source))
378 .unwrap();
379 let after = store.conn.lock().unwrap().total_changes();
380 assert_eq!(stats.unchanged_extract_files, 1);
381 assert_eq!(stats.refreshed_own_files, 0);
382 assert_eq!(
383 after - before,
384 3,
385 "files, backend freshness, and the durable projection revision update"
386 );
387
388 fs::write(&source, "\nexport function main() { return 1; }\n\n").unwrap();
389 let (shifted_stats, _) = store
390 .refresh_files_profiled(std::slice::from_ref(&source))
391 .unwrap();
392 assert_eq!(shifted_stats.unchanged_extract_files, 0);
393 assert_eq!(shifted_stats.refreshed_own_files, 1);
394 }
395
396 #[test]
397 fn idle_checkpoint_interval_prevents_checkpoint_storms() {
398 let now = Instant::now();
399 assert!(idle_checkpoint_due(None, now));
400 assert!(!idle_checkpoint_due(
401 Some(now),
402 now + Duration::from_secs(REFRESH_IDLE_CHECKPOINT_INTERVAL.as_secs() - 1),
403 ));
404 assert!(idle_checkpoint_due(
405 Some(now),
406 now + REFRESH_IDLE_CHECKPOINT_INTERVAL,
407 ));
408 }
409
410 #[test]
411 fn write_metrics_decay_after_the_sixty_second_window() {
412 let key = format!("metrics-test-{}", now_nanos());
413 let metrics = callgraph_write_metrics_for_key(&key);
414 metrics.record_commit(17);
415 assert_eq!(metrics.snapshot().commits_60s, 1);
416 assert_eq!(metrics.snapshot().pages_or_bytes_written_60s, 17);
417 metrics.window_start_ms.store(
418 unix_millis_now().saturating_sub(CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64),
419 AtomicOrdering::Release,
420 );
421 assert_eq!(metrics.snapshot(), CallgraphWriteMetricsSnapshot::default());
422 }
423}
424
425#[cfg(test)]
426type ColdBuildBeforePublishObserver = dyn Fn() + Send + Sync + 'static;
427thread_local! {
434 static COLD_BUILD_SWAP_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSwapObserver>>> =
435 const { std::cell::RefCell::new(None) };
436 #[cfg(test)]
437 static COLD_BUILD_BEFORE_PUBLISH_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildBeforePublishObserver>>> =
438 const { std::cell::RefCell::new(None) };
439 static MIGRATION_AVAILABLE_DISK_OVERRIDE: std::cell::RefCell<Option<u64>> =
440 const { std::cell::RefCell::new(None) };
441 static MIGRATION_FAIL_AFTER_TEMP_COPY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
442 static MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED: std::cell::Cell<bool> =
443 const { std::cell::Cell::new(false) };
444 static PUBLISH_ADMISSION: std::cell::RefCell<Option<(crate::root_cache::ArtifactPublishEpoch, u64)>> =
445 const { std::cell::RefCell::new(None) };
446 static REFRESH_COMMIT_ADMISSION: std::cell::RefCell<Option<(SubcLifecycleAdmission, Arc<std::sync::atomic::AtomicU64>, u64)>> =
447 const { std::cell::RefCell::new(None) };
448}
449
450mod dead_code_projection;
451pub use dead_code_projection::project_dead_code_snapshot;
452pub(crate) use dead_code_projection::project_dead_code_snapshot_with_revision;
453#[cfg(test)]
454pub(crate) use dead_code_projection::set_projection_before_open_observer;
455
456#[doc(hidden)]
457pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
458 COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
459}
460
461#[cfg(test)]
462fn set_cold_build_before_publish_observer(observer: Option<Arc<ColdBuildBeforePublishObserver>>) {
463 COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
464}
465
466#[cfg(test)]
467fn notify_cold_build_before_publish_observer() {
468 let observer = COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| slot.borrow().clone());
469 if let Some(observer) = observer {
470 observer();
471 }
472}
473
474#[cfg(not(test))]
475fn notify_cold_build_before_publish_observer() {}
476
477#[doc(hidden)]
478pub fn set_legacy_migration_available_disk_for_test(bytes: Option<u64>) {
479 MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow_mut() = bytes);
480}
481
482#[doc(hidden)]
483pub fn set_legacy_migration_fail_after_temp_copy_for_test(enabled: bool) {
484 MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.set(enabled));
485}
486
487#[doc(hidden)]
488pub fn set_legacy_migration_backup_budget_exhausted_for_test(enabled: bool) {
489 MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.set(enabled));
490}
491
492struct PublishAdmissionGuard {
493 previous: Option<(crate::root_cache::ArtifactPublishEpoch, u64)>,
494}
495
496impl Drop for PublishAdmissionGuard {
497 fn drop(&mut self) {
498 PUBLISH_ADMISSION.with(|slot| {
499 *slot.borrow_mut() = self.previous.take();
500 });
501 }
502}
503
504pub(crate) fn with_publish_epoch<R>(
505 epoch: crate::root_cache::ArtifactPublishEpoch,
506 expected: u64,
507 run: impl FnOnce() -> R,
508) -> R {
509 let previous = PUBLISH_ADMISSION.with(|slot| slot.replace(Some((epoch, expected))));
510 let _guard = PublishAdmissionGuard { previous };
511 run()
512}
513
514fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
515 let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
516 match admission {
517 Some((epoch, expected)) => epoch
518 .run_if_current(expected, publish)
519 .unwrap_or(Err(CallGraphStoreError::Superseded)),
520 None => publish(),
521 }
522}
523
524struct RefreshCommitAdmissionGuard {
525 previous: Option<(
526 SubcLifecycleAdmission,
527 Arc<std::sync::atomic::AtomicU64>,
528 u64,
529 )>,
530}
531
532impl Drop for RefreshCommitAdmissionGuard {
533 fn drop(&mut self) {
534 REFRESH_COMMIT_ADMISSION.with(|slot| {
535 *slot.borrow_mut() = self.previous.take();
536 });
537 }
538}
539
540fn with_refresh_commit_admission<R>(
541 lifecycle: SubcLifecycleAdmission,
542 generation_flag: Arc<std::sync::atomic::AtomicU64>,
543 expected_generation: u64,
544 run: impl FnOnce() -> R,
545) -> R {
546 let previous = REFRESH_COMMIT_ADMISSION
547 .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
548 let _guard = RefreshCommitAdmissionGuard { previous };
549 run()
550}
551
552fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
553 let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
554 let commit = || {
555 publish_if_current(|| {
556 tx.commit()?;
557 Ok(())
558 })
559 };
560 match admission {
561 Some((lifecycle, generation_flag, expected_generation)) => lifecycle
562 .run_if_current(generation_flag.as_ref(), expected_generation, commit)
563 .unwrap_or(Err(CallGraphStoreError::Superseded)),
564 None => commit(),
565 }
566}
567
568fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
569 let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
570 if let Some(observer) = observer {
571 observer(temp_path, target_path);
572 }
573}
574
575#[derive(Debug)]
576pub enum CallGraphStoreError {
577 Io(std::io::Error),
578 Sqlite(rusqlite::Error),
579 Json(serde_json::Error),
580 Aft(AftError),
581 Lock(crate::fs_lock::AcquireError),
582 MissingCallerData { file: String },
583 Unavailable(String),
584 Superseded,
585 StaleFiles(Vec<String>),
586}
587
588impl fmt::Display for CallGraphStoreError {
589 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
590 match self {
591 Self::Io(error) => write!(formatter, "I/O error: {error}"),
592 Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
593 Self::Json(error) => write!(formatter, "json error: {error}"),
594 Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
595 Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
596 Self::MissingCallerData { file } => {
597 write!(formatter, "missing extracted caller data for {file}")
598 }
599 Self::Unavailable(message) => {
600 write!(formatter, "callgraph store unavailable: {message}")
601 }
602 Self::Superseded => {
603 write!(formatter, "callgraph store build superseded before publish")
604 }
605 Self::StaleFiles(files) => {
606 write!(
607 formatter,
608 "callgraph store has stale files: {}",
609 files.join(", ")
610 )
611 }
612 }
613 }
614}
615
616impl std::error::Error for CallGraphStoreError {}
617
618impl From<std::io::Error> for CallGraphStoreError {
619 fn from(error: std::io::Error) -> Self {
620 Self::Io(error)
621 }
622}
623
624impl From<rusqlite::Error> for CallGraphStoreError {
625 fn from(error: rusqlite::Error) -> Self {
626 Self::Sqlite(error)
627 }
628}
629
630impl From<serde_json::Error> for CallGraphStoreError {
631 fn from(error: serde_json::Error) -> Self {
632 Self::Json(error)
633 }
634}
635
636impl From<AftError> for CallGraphStoreError {
637 fn from(error: AftError) -> Self {
638 Self::Aft(error)
639 }
640}
641
642impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
643 fn from(error: crate::fs_lock::AcquireError) -> Self {
644 Self::Lock(error)
645 }
646}
647
648pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
649
650pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
654
655#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
656pub struct CallGraphStoreOptions {
657 pub enabled: bool,
658}
659
660pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
661
662#[derive(Clone)]
666pub(crate) struct CallgraphRefreshState {
667 store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
668 heavy_root_work_allowed: Arc<AtomicBool>,
669}
670
671impl CallgraphRefreshState {
672 pub(crate) fn new(
673 store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
674 heavy_root_work_allowed: Arc<AtomicBool>,
675 ) -> Self {
676 Self {
677 store,
678 heavy_root_work_allowed,
679 }
680 }
681
682 fn installed_store_snapshot(&self) -> Option<Arc<ReadonlyCallGraphStore>> {
683 self.store
684 .read()
685 .unwrap_or_else(std::sync::PoisonError::into_inner)
686 .as_ref()
687 .map(Arc::clone)
688 }
689}
690
691type WorkspaceCratePrefixes = HashMap<String, String>;
692
693#[derive(Clone, Debug, Default)]
694struct WorkspaceCratePrefixCache(Arc<OnceLock<WorkspaceCratePrefixes>>);
695
696const REFRESH_WORKSPACE_CACHE_ROOT_CAP: usize = 128;
697
698pub(crate) fn invalidates_workspace_crate_prefix_cache(path: &Path) -> bool {
699 path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")
700}
701
702#[derive(Clone, Debug, Hash, PartialEq, Eq)]
703struct RefreshRoot {
704 callgraph_dir: PathBuf,
705 project_root: PathBuf,
706}
707
708#[derive(Clone)]
709pub(crate) struct CallgraphRefreshTicket {
710 lifecycle: SubcLifecycleAdmission,
711 generation_flag: Arc<std::sync::atomic::AtomicU64>,
712 expected_generation: u64,
713 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
714 expected_publish_epoch: u64,
715}
716
717impl CallgraphRefreshTicket {
718 pub(crate) fn new(
719 lifecycle: SubcLifecycleAdmission,
720 generation_flag: Arc<std::sync::atomic::AtomicU64>,
721 expected_generation: u64,
722 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
723 expected_publish_epoch: u64,
724 ) -> Self {
725 Self {
726 lifecycle,
727 generation_flag,
728 expected_generation,
729 publish_epoch,
730 expected_publish_epoch,
731 }
732 }
733
734 fn is_current(&self) -> bool {
735 self.lifecycle
736 .is_current(self.generation_flag.as_ref(), self.expected_generation)
737 && self.publish_epoch.current() == self.expected_publish_epoch
738 }
739}
740
741#[derive(Clone)]
742struct RefreshBatch {
743 root: RefreshRoot,
744 paths: BTreeSet<PathBuf>,
745 pending_sinks: Vec<PendingCallGraphStorePaths>,
746 refresh_states: Vec<CallgraphRefreshState>,
747 ticket: Option<CallgraphRefreshTicket>,
748}
749
750impl RefreshBatch {
751 fn defer(&self) {
752 for sink in &self.pending_sinks {
753 sink.lock().extend(self.paths.iter().cloned());
754 }
755 }
756
757 fn defer_after_open_failure(&self) {
758 self.defer();
759 if self
760 .ticket
761 .as_ref()
762 .is_some_and(|ticket| !ticket.is_current())
763 || !self
764 .refresh_states
765 .iter()
766 .any(|state| state.heavy_root_work_allowed.load(AtomicOrdering::SeqCst))
767 {
768 return;
769 }
770
771 let ready_store_installed = self.refresh_states.iter().any(|state| {
772 let store = state.installed_store_snapshot();
773 store.is_some_and(|store| {
774 store.project_root() == self.root.project_root
775 && !store.is_legacy_fallback()
776 && store.is_current()
777 })
778 });
779 if !ready_store_installed {
780 return;
781 }
782
783 for sink in &self.pending_sinks {
787 let paths = {
788 let mut pending = sink.lock();
789 self.paths
790 .iter()
791 .filter(|path| pending.remove(*path))
792 .cloned()
793 .collect::<Vec<_>>()
794 };
795 if paths.is_empty() {
796 continue;
797 }
798 let _ = enqueue_callgraph_store_refresh_inner(
799 self.root.callgraph_dir.clone(),
800 self.root.project_root.clone(),
801 paths,
802 Arc::clone(sink),
803 self.refresh_states.clone(),
804 self.ticket.clone(),
805 );
806 }
807 }
808
809 fn merge(
810 &mut self,
811 paths: impl IntoIterator<Item = PathBuf>,
812 sink: PendingCallGraphStorePaths,
813 refresh_states: Vec<CallgraphRefreshState>,
814 ticket: Option<CallgraphRefreshTicket>,
815 ) {
816 self.paths.extend(paths);
817 if ticket.is_some() {
818 self.ticket = ticket;
819 }
820 if !self
821 .pending_sinks
822 .iter()
823 .any(|existing| Arc::ptr_eq(existing, &sink))
824 {
825 self.pending_sinks.push(sink);
826 }
827 for refresh_state in refresh_states {
828 if !self.refresh_states.iter().any(|existing| {
829 Arc::ptr_eq(&existing.store, &refresh_state.store)
830 && Arc::ptr_eq(
831 &existing.heavy_root_work_allowed,
832 &refresh_state.heavy_root_work_allowed,
833 )
834 }) {
835 self.refresh_states.push(refresh_state);
836 }
837 }
838 }
839}
840
841#[derive(Default)]
842struct RefreshQueue {
843 order: VecDeque<RefreshRoot>,
844 queued: HashMap<RefreshRoot, RefreshBatch>,
845 active: Option<RefreshBatch>,
846 shutdown_requested: bool,
847}
848
849struct RefreshWorkerShared {
850 queue: Mutex<RefreshQueue>,
851 wake: Condvar,
852}
853
854struct RefreshWorker {
855 shared: Arc<RefreshWorkerShared>,
856 thread: Mutex<Option<JoinHandle<()>>>,
857}
858
859struct RefreshWorkerWatchdog {
860 first_path: PathBuf,
861 batch_len: usize,
862 started: Instant,
863}
864
865impl RefreshWorkerWatchdog {
866 fn start(paths: &[PathBuf]) -> Self {
867 Self {
868 first_path: paths
869 .first()
870 .expect("non-empty callgraph refresh batch has a first path")
871 .clone(),
872 batch_len: paths.len(),
873 started: Instant::now(),
874 }
875 }
876}
877
878impl Drop for RefreshWorkerWatchdog {
879 fn drop(&mut self) {
880 let elapsed = self.started.elapsed();
881 if elapsed < REFRESH_WORKER_WARN_AFTER {
882 return;
883 }
884 let path = if self.batch_len == 1 {
885 self.first_path.display().to_string()
886 } else {
887 format!(
888 "{} (+{} paths)",
889 self.first_path.display(),
890 self.batch_len - 1
891 )
892 };
893 log::warn!(
894 "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
895 path,
896 elapsed.as_millis()
897 );
898 if elapsed >= REFRESH_WORKER_FINAL_AFTER {
899 log::warn!(
900 "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
901 path,
902 elapsed.as_millis()
903 );
904 }
905 }
906}
907
908impl RefreshWorker {
909 fn spawn() -> Arc<Self> {
910 let shared = Arc::new(RefreshWorkerShared {
911 queue: Mutex::new(RefreshQueue::default()),
912 wake: Condvar::new(),
913 });
914 let thread_shared = Arc::clone(&shared);
915 let thread = std::thread::Builder::new()
916 .name("aft-callgraph-refresh".to_string())
917 .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
918 .expect("failed to spawn callgraph refresh worker");
919 Arc::new(Self {
920 shared,
921 thread: Mutex::new(Some(thread)),
922 })
923 }
924
925 fn enqueue(
926 &self,
927 root: RefreshRoot,
928 paths: Vec<PathBuf>,
929 pending_sink: PendingCallGraphStorePaths,
930 refresh_states: Vec<CallgraphRefreshState>,
931 ticket: Option<CallgraphRefreshTicket>,
932 ) -> bool {
933 let mut queue = self
934 .shared
935 .queue
936 .lock()
937 .expect("callgraph refresh queue mutex poisoned");
938 if queue.shutdown_requested {
939 pending_sink.lock().extend(paths);
940 return false;
941 }
942 if let Some(batch) = queue.queued.get_mut(&root) {
943 batch.merge(paths, pending_sink, refresh_states, ticket);
944 } else {
945 queue.order.push_back(root.clone());
946 queue.queued.insert(
947 root.clone(),
948 RefreshBatch {
949 root,
950 paths: paths.into_iter().collect(),
951 pending_sinks: vec![pending_sink],
952 refresh_states,
953 ticket,
954 },
955 );
956 }
957 self.shared.wake.notify_one();
958 true
959 }
960
961 fn shutdown_with_budget(&self, budget: Duration) -> bool {
962 let deadline = Instant::now() + budget;
963 let mut queue = self
964 .shared
965 .queue
966 .lock()
967 .expect("callgraph refresh queue mutex poisoned");
968 queue.shutdown_requested = true;
969 self.shared.wake.notify_one();
970 while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
971 let remaining = deadline.saturating_duration_since(Instant::now());
972 let (next, _) = self
973 .shared
974 .wake
975 .wait_timeout(queue, remaining)
976 .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
977 queue = next;
978 }
979 let drained = queue.active.is_none() && queue.order.is_empty();
980 if !drained {
981 if let Some(active) = queue.active.as_ref() {
982 active.defer();
983 }
984 for batch in queue.queued.values() {
985 batch.defer();
986 }
987 queue.order.clear();
988 queue.queued.clear();
989 }
990 drop(queue);
991
992 if drained {
993 if let Some(thread) = self
994 .thread
995 .lock()
996 .expect("callgraph refresh worker thread mutex poisoned")
997 .take()
998 {
999 let _ = thread.join();
1000 }
1001 }
1002 drained
1003 }
1004}
1005
1006static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
1007
1008pub fn enqueue_callgraph_store_refresh(
1009 callgraph_dir: PathBuf,
1010 project_root: PathBuf,
1011 paths: Vec<PathBuf>,
1012 pending_sink: PendingCallGraphStorePaths,
1013) -> bool {
1014 enqueue_callgraph_store_refresh_inner(
1015 callgraph_dir,
1016 project_root,
1017 paths,
1018 pending_sink,
1019 Vec::new(),
1020 None,
1021 )
1022}
1023
1024#[cfg(test)]
1025pub(crate) fn enqueue_callgraph_store_refresh_fenced(
1026 callgraph_dir: PathBuf,
1027 project_root: PathBuf,
1028 paths: Vec<PathBuf>,
1029 pending_sink: PendingCallGraphStorePaths,
1030 ticket: CallgraphRefreshTicket,
1031) -> bool {
1032 enqueue_callgraph_store_refresh_inner(
1033 callgraph_dir,
1034 project_root,
1035 paths,
1036 pending_sink,
1037 Vec::new(),
1038 Some(ticket),
1039 )
1040}
1041
1042pub(crate) fn enqueue_callgraph_store_refresh_fenced_with_state(
1043 callgraph_dir: PathBuf,
1044 project_root: PathBuf,
1045 paths: Vec<PathBuf>,
1046 pending_sink: PendingCallGraphStorePaths,
1047 refresh_state: CallgraphRefreshState,
1048 ticket: CallgraphRefreshTicket,
1049) -> bool {
1050 enqueue_callgraph_store_refresh_inner(
1051 callgraph_dir,
1052 project_root,
1053 paths,
1054 pending_sink,
1055 vec![refresh_state],
1056 Some(ticket),
1057 )
1058}
1059
1060fn enqueue_callgraph_store_refresh_inner(
1061 callgraph_dir: PathBuf,
1062 project_root: PathBuf,
1063 paths: Vec<PathBuf>,
1064 pending_sink: PendingCallGraphStorePaths,
1065 refresh_states: Vec<CallgraphRefreshState>,
1066 ticket: Option<CallgraphRefreshTicket>,
1067) -> bool {
1068 if paths.is_empty() {
1069 return true;
1070 }
1071 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1072 let worker = {
1073 let mut worker = slot
1074 .lock()
1075 .expect("callgraph refresh worker mutex poisoned");
1076 Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
1077 };
1078 worker.enqueue(
1079 RefreshRoot {
1080 callgraph_dir,
1081 project_root,
1082 },
1083 paths,
1084 pending_sink,
1085 refresh_states,
1086 ticket,
1087 )
1088}
1089
1090pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
1091 flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
1092}
1093
1094#[doc(hidden)]
1095pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
1096 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1097 let worker = slot
1098 .lock()
1099 .expect("callgraph refresh worker mutex poisoned")
1100 .clone();
1101 let Some(worker) = worker else {
1102 return true;
1103 };
1104 let drained = worker.shutdown_with_budget(budget);
1105 if drained {
1106 let mut current = slot
1107 .lock()
1108 .expect("callgraph refresh worker mutex poisoned");
1109 if current
1110 .as_ref()
1111 .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
1112 {
1113 *current = None;
1114 }
1115 }
1116 drained
1117}
1118
1119fn idle_checkpoint_due(last: Option<Instant>, now: Instant) -> bool {
1120 last.is_none_or(|last| now.saturating_duration_since(last) >= REFRESH_IDLE_CHECKPOINT_INTERVAL)
1121}
1122
1123fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
1124 let mut workspace_crate_prefixes = HashMap::new();
1127 let mut last_idle_checkpoints: HashMap<RefreshRoot, Instant> = HashMap::new();
1128 loop {
1129 let batch = {
1130 let mut queue = shared
1131 .queue
1132 .lock()
1133 .expect("callgraph refresh queue mutex poisoned");
1134 loop {
1135 if let Some(root) = queue.order.pop_front() {
1136 let batch = queue
1137 .queued
1138 .remove(&root)
1139 .expect("queued callgraph refresh root has a batch");
1140 queue.active = Some(batch.clone());
1141 break batch;
1142 }
1143 if queue.shutdown_requested {
1144 return;
1145 }
1146 queue = shared
1147 .wake
1148 .wait(queue)
1149 .expect("callgraph refresh queue mutex poisoned while waiting");
1150 }
1151 };
1152
1153 let store = process_callgraph_refresh_batch(&batch, &mut workspace_crate_prefixes);
1154
1155 let mut queue = shared
1156 .queue
1157 .lock()
1158 .expect("callgraph refresh queue mutex poisoned");
1159 queue.active = None;
1160 let became_idle = queue.order.is_empty();
1161 shared.wake.notify_all();
1162 drop(queue);
1163
1164 if became_idle {
1165 let checkpoint_due = idle_checkpoint_due(
1166 last_idle_checkpoints.get(&batch.root).copied(),
1167 Instant::now(),
1168 );
1169 if checkpoint_due {
1170 if let Some(store) = store {
1171 if store.checkpoint_wal_truncate() {
1172 last_idle_checkpoints.insert(batch.root.clone(), Instant::now());
1173 }
1174 }
1175 }
1176 }
1177 }
1178}
1179
1180fn process_callgraph_refresh_batch(
1181 batch: &RefreshBatch,
1182 workspace_crate_prefixes: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1183) -> Option<CallGraphStore> {
1184 if batch
1188 .paths
1189 .iter()
1190 .any(|path| invalidates_workspace_crate_prefix_cache(path))
1191 {
1192 workspace_crate_prefixes.remove(&batch.root);
1193 }
1194
1195 let paths = batch
1196 .paths
1197 .iter()
1198 .filter(|path| crate::parser::detect_language(path).is_some())
1199 .cloned()
1200 .collect::<Vec<_>>();
1201 if paths.is_empty() {
1202 return None;
1203 }
1204 note_refresh_worker_batch_for_test(&batch.root.project_root);
1205 if batch
1206 .ticket
1207 .as_ref()
1208 .is_some_and(|ticket| !ticket.is_current())
1209 {
1210 batch.defer();
1213 return None;
1214 }
1215 let workspace_crate_prefix_cache =
1216 workspace_crate_prefix_cache_for_root(workspace_crate_prefixes, &batch.root);
1217 let _watchdog = RefreshWorkerWatchdog::start(&paths);
1218 let test_seam = refresh_worker_test_seam(&batch.root.project_root);
1219 note_refresh_worker_call_for_test(&batch.root.project_root);
1220 let opened = if test_seam.fail_open {
1221 Ok(None)
1222 } else {
1223 CallGraphStore::open_ready(
1224 batch.root.callgraph_dir.clone(),
1225 batch.root.project_root.clone(),
1226 )
1227 };
1228 if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
1229 let _ = gate.held_tx.send(());
1232 let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
1233 }
1234 let store = match opened {
1235 Ok(Some(store)) => store,
1236 Ok(None) => {
1237 batch.defer_after_open_failure();
1238 return None;
1239 }
1240 Err(error) => {
1241 batch.defer_after_open_failure();
1242 crate::slog_warn!(
1243 "callgraph store writer open failed during refresh; deferred paths: {}",
1244 error
1245 );
1246 return None;
1247 }
1248 };
1249 if !test_seam.delay.is_zero() {
1250 std::thread::sleep(test_seam.delay);
1251 }
1252 if batch
1253 .ticket
1254 .as_ref()
1255 .is_some_and(|ticket| !ticket.is_current())
1256 {
1257 batch.defer();
1260 return Some(store);
1261 }
1262 let refresh_result = if test_seam.fail_refresh {
1263 Err(CallGraphStoreError::Unavailable(
1264 "injected refresh worker failure".to_string(),
1265 ))
1266 } else if let Some(ticket) = &batch.ticket {
1267 with_publish_epoch(
1268 ticket.publish_epoch.clone(),
1269 ticket.expected_publish_epoch,
1270 || {
1271 with_refresh_commit_admission(
1272 ticket.lifecycle.clone(),
1273 Arc::clone(&ticket.generation_flag),
1274 ticket.expected_generation,
1275 || {
1276 store
1277 .refresh_files_with_workspace_crate_prefix_cache(
1278 &paths,
1279 workspace_crate_prefix_cache.clone(),
1280 )
1281 .map(|_| ())
1282 },
1283 )
1284 },
1285 )
1286 } else {
1287 store
1288 .refresh_files_with_workspace_crate_prefix_cache(
1289 &paths,
1290 workspace_crate_prefix_cache.clone(),
1291 )
1292 .map(|_| ())
1293 };
1294 if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
1295 batch.defer();
1299 return Some(store);
1300 }
1301 if let Err(error) = refresh_result {
1302 crate::slog_warn!("callgraph store refresh failed: {}", error);
1303 match store.mark_files_stale(&paths) {
1304 Ok(marked) => {
1305 note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
1306 crate::slog_warn!(
1307 "marked {} callgraph store file(s) stale after refresh failure",
1308 marked.len()
1309 );
1310 }
1311 Err(mark_error) => crate::slog_warn!(
1312 "failed to mark callgraph store files stale after refresh failure: {}",
1313 mark_error
1314 ),
1315 }
1316 } else {
1317 crate::logging::note_callgraph_invalidations(paths.len());
1318 }
1319 Some(store)
1320}
1321
1322fn workspace_crate_prefix_cache_for_root(
1323 caches: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1324 root: &RefreshRoot,
1325) -> WorkspaceCratePrefixCache {
1326 if !caches.contains_key(root) && caches.len() >= REFRESH_WORKSPACE_CACHE_ROOT_CAP {
1327 if let Some(evicted) = caches.keys().next().cloned() {
1329 caches.remove(&evicted);
1330 }
1331 }
1332 caches.entry(root.clone()).or_default().clone()
1333}
1334
1335#[derive(Clone, Copy, Default)]
1336struct RefreshWorkerTestSeam {
1337 delay: Duration,
1338 fail_refresh: bool,
1339 fail_open: bool,
1340 refresh_calls: usize,
1341 worker_calls: usize,
1342 stale_marks: usize,
1343}
1344
1345static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
1346 OnceLock::new();
1347
1348struct RefreshWorkerTestGate {
1349 held_tx: crossbeam_channel::Sender<()>,
1350 release_rx: crossbeam_channel::Receiver<()>,
1351}
1352
1353static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
1354 OnceLock::new();
1355
1356#[doc(hidden)]
1357pub fn install_callgraph_refresh_worker_test_gate(
1358 project_root: PathBuf,
1359) -> (
1360 crossbeam_channel::Receiver<()>,
1361 crossbeam_channel::Sender<()>,
1362) {
1363 let (held_tx, held_rx) = crossbeam_channel::bounded(1);
1364 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1365 REFRESH_WORKER_TEST_GATES
1366 .get_or_init(|| Mutex::new(HashMap::new()))
1367 .lock()
1368 .expect("callgraph refresh test gate mutex poisoned")
1369 .insert(
1370 project_root,
1371 RefreshWorkerTestGate {
1372 held_tx,
1373 release_rx,
1374 },
1375 );
1376 (held_rx, release_tx)
1377}
1378
1379fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
1380 REFRESH_WORKER_TEST_GATES
1381 .get_or_init(|| Mutex::new(HashMap::new()))
1382 .lock()
1383 .expect("callgraph refresh test gate mutex poisoned")
1384 .remove(project_root)
1385}
1386
1387fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
1388 let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
1389 return RefreshWorkerTestSeam::default();
1390 };
1391 seams
1392 .lock()
1393 .expect("callgraph refresh test seam mutex poisoned")
1394 .get(project_root)
1395 .copied()
1396 .unwrap_or_default()
1397}
1398
1399fn note_refresh_worker_batch_for_test(project_root: &Path) {
1400 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1401 if let Some(seam) = seams
1402 .lock()
1403 .expect("callgraph refresh test seam mutex poisoned")
1404 .get_mut(project_root)
1405 {
1406 seam.worker_calls += 1;
1407 }
1408 }
1409}
1410
1411fn note_refresh_worker_call_for_test(project_root: &Path) {
1412 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1413 if let Some(seam) = seams
1414 .lock()
1415 .expect("callgraph refresh test seam mutex poisoned")
1416 .get_mut(project_root)
1417 {
1418 seam.refresh_calls += 1;
1419 }
1420 }
1421}
1422
1423fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
1424 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1425 if let Some(seam) = seams
1426 .lock()
1427 .expect("callgraph refresh test seam mutex poisoned")
1428 .get_mut(project_root)
1429 {
1430 seam.stale_marks += 1;
1431 }
1432 }
1433}
1434
1435#[doc(hidden)]
1436pub fn set_callgraph_refresh_worker_test_seam(
1437 project_root: PathBuf,
1438 delay: Duration,
1439 fail_refresh: bool,
1440) {
1441 REFRESH_WORKER_TEST_SEAMS
1442 .get_or_init(|| Mutex::new(HashMap::new()))
1443 .lock()
1444 .expect("callgraph refresh test seam mutex poisoned")
1445 .insert(
1446 project_root,
1447 RefreshWorkerTestSeam {
1448 delay,
1449 fail_refresh,
1450 ..RefreshWorkerTestSeam::default()
1451 },
1452 );
1453}
1454
1455#[doc(hidden)]
1456pub fn set_callgraph_refresh_worker_test_open_failure(project_root: PathBuf, enabled: bool) {
1457 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1458 if let Some(seam) = seams
1459 .lock()
1460 .expect("callgraph refresh test seam mutex poisoned")
1461 .get_mut(&project_root)
1462 {
1463 seam.fail_open = enabled;
1464 }
1465 }
1466}
1467
1468#[doc(hidden)]
1469pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
1470 let seam = refresh_worker_test_seam(project_root);
1471 (seam.refresh_calls, seam.stale_marks)
1472}
1473
1474#[doc(hidden)]
1475pub fn callgraph_refresh_worker_test_worker_calls(project_root: &Path) -> usize {
1476 refresh_worker_test_seam(project_root).worker_calls
1477}
1478
1479#[doc(hidden)]
1480pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
1481 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1482 seams
1483 .lock()
1484 .expect("callgraph refresh test seam mutex poisoned")
1485 .remove(project_root);
1486 }
1487}
1488
1489#[derive(Debug)]
1490pub struct CallGraphStore {
1491 project_root: PathBuf,
1492 project_key: String,
1493 sqlite_path: PathBuf,
1497 publication_dir: PathBuf,
1501 legacy_fallback: bool,
1505 generation: Option<String>,
1510 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1511 read_marker: Option<crate::root_cache::ReadMarker>,
1512 database_ready: AtomicBool,
1515 write_metrics: Arc<CallgraphWriteMetrics>,
1516 conn: Mutex<Connection>,
1517}
1518
1519#[derive(Debug)]
1520pub struct ReadonlyCallGraphStore {
1521 inner: CallGraphStore,
1522}
1523
1524pub trait CallGraphRead {
1525 fn project_root(&self) -> &Path;
1526 fn project_key(&self) -> &str;
1527 fn sqlite_path(&self) -> &Path;
1528 fn is_current(&self) -> bool;
1529 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
1530 fn indexed_file_count(&self) -> Result<usize>;
1531 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
1532 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
1533 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
1534 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
1535 fn direct_callers_for_symbols(
1536 &self,
1537 targets: &[(String, String)],
1538 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
1539 targets
1540 .iter()
1541 .cloned()
1542 .map(|target| {
1543 let callers = self.direct_callers_of(Path::new(&target.0), &target.1)?;
1544 Ok((target, callers))
1545 })
1546 .collect()
1547 }
1548 fn direct_caller_counts_of(
1549 &self,
1550 targets: &[(String, String)],
1551 ) -> Result<HashMap<(String, String), usize>>;
1552 fn outgoing_calls_for_symbols(
1553 &self,
1554 sources: &[(String, String)],
1555 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
1556 fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
1557 -> Result<StoreCallersResult>;
1558 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
1559 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1560 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1561 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
1562 fn call_tree(
1563 &self,
1564 file_rel: &Path,
1565 symbol: &str,
1566 depth: usize,
1567 ) -> Result<callgraph::CallTreeNode>;
1568 fn trace_to(
1569 &self,
1570 file_rel: &Path,
1571 symbol: &str,
1572 max_depth: usize,
1573 ) -> Result<callgraph::TraceToResult>;
1574 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
1575 fn trace_to_symbol(
1576 &self,
1577 file_rel: &Path,
1578 symbol: &str,
1579 to_symbol: &str,
1580 to_file: Option<&Path>,
1581 max_depth: usize,
1582 ) -> Result<callgraph::TraceToSymbolResult>;
1583}
1584
1585#[derive(Debug, Clone, PartialEq, Eq)]
1586enum OpenRootRepair {
1587 None,
1588 ReRooted,
1589 NeedsRebuild {
1590 previous_roots: Vec<String>,
1591 current_root: String,
1592 reason: String,
1593 },
1594}
1595
1596struct OpenedStore {
1597 store: CallGraphStore,
1598 root_repair: OpenRootRepair,
1599}
1600
1601#[derive(Clone, Debug)]
1602struct LegacyCallgraphPartition {
1603 harness: String,
1604 dir: PathBuf,
1605 key: String,
1606 bytes: u64,
1607 freshness: Option<SystemTime>,
1608}
1609
1610#[derive(Clone, Debug)]
1611struct LegacyCallgraphTarget {
1612 partition: LegacyCallgraphPartition,
1613 sqlite_path: PathBuf,
1614 generation: Option<String>,
1615 source_bytes: u64,
1616 source_blake3: String,
1617}
1618
1619#[derive(Clone, Debug)]
1620struct SourceFingerprint {
1621 bytes: u64,
1622 blake3: String,
1623}
1624
1625#[derive(Clone, Debug)]
1626struct PublishedLegacyMigration {
1627 generation: String,
1628 migrated_bytes: u64,
1629}
1630
1631#[derive(Debug, Clone)]
1632pub struct ColdBuildStats {
1633 pub files: usize,
1634 pub nodes: usize,
1635 pub refs: usize,
1636 pub edges: usize,
1637 pub failed_files: Vec<String>,
1638 pub elapsed_ms: u128,
1639}
1640
1641#[derive(Debug, Clone)]
1642pub struct IncrementalStats {
1643 pub changed_files: Vec<String>,
1644 pub surface_changed: Vec<String>,
1645 pub deleted_files: Vec<String>,
1646 pub dependency_selected_refs: usize,
1647 pub refreshed_own_files: usize,
1648 pub unchanged_extract_files: usize,
1649}
1650
1651#[doc(hidden)]
1653#[derive(Debug, Clone, Default, PartialEq, Eq)]
1654pub struct RefreshFilesProfile {
1655 pub parse: Duration,
1656 pub dependency_selection: Duration,
1657 pub row_deletes: Duration,
1658 pub row_inserts: Duration,
1659 pub dependent_parse: Duration,
1660 pub index_load: Duration,
1661 pub ref_resolution: Duration,
1662 pub method_dispatch: Duration,
1663 pub commit: Duration,
1664 pub total: Duration,
1665}
1666
1667impl RefreshFilesProfile {
1668 pub fn report(&self) -> String {
1669 format!(
1670 "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",
1671 self.parse.as_millis(),
1672 self.dependency_selection.as_millis(),
1673 self.row_deletes.as_millis(),
1674 self.row_inserts.as_millis(),
1675 self.dependent_parse.as_millis(),
1676 self.index_load.as_millis(),
1677 self.ref_resolution.as_millis(),
1678 self.method_dispatch.as_millis(),
1679 self.commit.as_millis(),
1680 self.total.as_millis(),
1681 )
1682 }
1683}
1684
1685#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1686pub struct StoredEdge {
1687 pub source_file: String,
1688 pub source_symbol: String,
1689 pub target_file: String,
1690 pub target_symbol: String,
1691 pub kind: String,
1692 pub line: u32,
1693}
1694
1695#[derive(Debug, Clone, PartialEq, Eq)]
1696pub struct StoreNode {
1697 node_id: String,
1698 pub file: String,
1699 pub symbol: String,
1700 pub name: String,
1701 pub kind: String,
1702 pub line: u32,
1703 pub end_line: u32,
1704 pub signature: Option<String>,
1705 pub exported: bool,
1706 pub is_entry_point: bool,
1707 pub lang: LangId,
1708}
1709
1710#[cfg(test)]
1711impl StoreNode {
1712 pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
1713 Self {
1714 node_id: format!("{file}:{symbol}"),
1715 file: file.to_string(),
1716 symbol: symbol.to_string(),
1717 name: symbol.to_string(),
1718 kind: "function".to_string(),
1719 line: 1,
1720 end_line: 1,
1721 signature: None,
1722 exported: is_entry_point,
1723 is_entry_point,
1724 lang: LangId::TypeScript,
1725 }
1726 }
1727}
1728
1729#[derive(Debug, Clone, PartialEq, Eq)]
1730pub struct StoreCallSite {
1731 pub caller: StoreNode,
1732 pub target_file: String,
1733 pub target_symbol: String,
1734 pub target: Option<StoreNode>,
1735 pub line: u32,
1736 pub byte_start: usize,
1737 pub byte_end: usize,
1738 pub resolved: bool,
1739 pub provenance: String,
1740}
1741
1742impl StoreCallSite {
1743 pub fn approximate(&self) -> bool {
1744 self.provenance == PROVENANCE_NAME_MATCH
1745 }
1746
1747 pub fn resolved_by(&self) -> &str {
1748 &self.provenance
1749 }
1750
1751 pub fn supplemental_resolution(&self) -> Option<&str> {
1752 match self.provenance.as_str() {
1753 PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
1754 _ => None,
1755 }
1756 }
1757}
1758
1759#[derive(Debug, Clone, PartialEq, Eq)]
1760pub struct StoreUnresolvedCall {
1761 pub caller: StoreNode,
1762 pub symbol: String,
1763 pub full_ref: Option<String>,
1764 pub line: u32,
1765 pub byte_start: usize,
1766 pub byte_end: usize,
1767}
1768
1769#[derive(Debug, Clone, PartialEq, Eq)]
1770pub struct StoreCallersResult {
1771 pub target: StoreNode,
1772 pub callers: Vec<StoreCallSite>,
1773 pub scanned_files: usize,
1774 pub depth_limited: bool,
1775 pub truncated: usize,
1776}
1777
1778#[derive(Debug, Clone, PartialEq, Eq)]
1779pub struct StoreImpactCaller {
1780 pub site: StoreCallSite,
1781 pub signature: Option<String>,
1782 pub is_entry_point: bool,
1783 pub call_expression: Option<String>,
1784 pub parameters: Vec<String>,
1785}
1786
1787#[derive(Debug, Clone, PartialEq, Eq)]
1788pub struct StoreImpactResult {
1789 pub target: StoreNode,
1790 pub parameters: Vec<String>,
1791 pub callers: Vec<StoreImpactCaller>,
1792 pub depth_limited: bool,
1793 pub truncated: usize,
1794}
1795
1796#[derive(Debug, Clone)]
1797struct ExtractFailure {
1798 rel_path: String,
1799 freshness: Option<FileFreshness>,
1800}
1801
1802#[derive(Debug, Clone)]
1803struct BuildExtractsResult {
1804 extracts: Vec<FileExtract>,
1805 failures: Vec<ExtractFailure>,
1806}
1807
1808#[derive(Debug, Clone)]
1809enum StoreForwardCall {
1810 Resolved(StoreCallSite),
1811 Unresolved(StoreUnresolvedCall),
1812}
1813
1814impl StoreForwardCall {
1815 fn byte_start(&self) -> usize {
1816 match self {
1817 Self::Resolved(site) => site.byte_start,
1818 Self::Unresolved(call) => call.byte_start,
1819 }
1820 }
1821
1822 fn line(&self) -> u32 {
1823 match self {
1824 Self::Resolved(site) => site.line,
1825 Self::Unresolved(call) => call.line,
1826 }
1827 }
1828}
1829
1830#[derive(Debug, Clone)]
1831struct FileExtract {
1832 rel_path: String,
1833 freshness: FileFreshness,
1834 lang: LangId,
1835 data: FileCallData,
1836 nodes: Vec<NodeRecord>,
1837 raw_refs: Vec<RawRef>,
1838 dispatch_hints: Vec<DispatchHint>,
1839 surface_fingerprint: String,
1840}
1841
1842#[derive(Debug, Clone)]
1843struct NodeRecord {
1844 id: String,
1845 file_path: String,
1846 name: String,
1847 scoped_name: String,
1848 kind: String,
1849 range: Range,
1850 range_ordinal: u32,
1851 signature: Option<String>,
1852 exported: bool,
1853 is_default_export: bool,
1854 is_type_like: bool,
1855 is_callgraph_entry_point: bool,
1856}
1857
1858#[derive(Debug, Clone)]
1859struct RawRef {
1860 ref_id: String,
1861 caller_node: Option<String>,
1862 caller_symbol: Option<String>,
1863 caller_file: String,
1864 kind: String,
1865 short_name: Option<String>,
1866 full_ref: Option<String>,
1867 module_path: Option<String>,
1868 import_kind: Option<String>,
1869 local_name: Option<String>,
1870 requested_name: Option<String>,
1871 namespace_alias: Option<String>,
1872 wildcard: bool,
1873 line: u32,
1874 byte_start: usize,
1875 byte_end: usize,
1876 dependencies: BTreeSet<String>,
1877}
1878
1879#[derive(Debug, Clone)]
1880struct ResolvedRef {
1881 raw: RawRef,
1882 status: String,
1883 target_node: Option<String>,
1884 target_file: Option<String>,
1885 target_symbol: Option<String>,
1886 dependencies: BTreeSet<String>,
1887 edge: Option<EdgeRecord>,
1888}
1889
1890#[derive(Debug, Clone)]
1891struct EdgeRecord {
1892 edge_id: String,
1893 source_node: String,
1894 target_node: Option<String>,
1895 target_file: String,
1896 target_symbol: String,
1897 kind: String,
1898 line: u32,
1899}
1900
1901#[derive(Debug, Clone)]
1902struct DispatchHint {
1903 id: String,
1904 method_name: String,
1905 caller_node: String,
1906 file: String,
1907 line: u32,
1908 byte_start: usize,
1909 byte_end: usize,
1910}
1911
1912#[derive(Debug, Clone)]
1913struct NameMatchRef {
1914 ref_id: String,
1915 caller_node: String,
1916 caller_file: String,
1917 caller_symbol: String,
1918 caller_signature: Option<String>,
1919 receiver_expression: String,
1920 receiver: String,
1921 method_name: String,
1922 colon_dispatch: bool,
1923 line: u32,
1924 lang: String,
1925}
1926
1927#[derive(Debug, Clone)]
1928struct NameMatchCandidate {
1929 node_id: String,
1930 file_path: String,
1931 scoped_name: String,
1932 kind: String,
1933 start_line: u32,
1935}
1936
1937#[derive(Debug, Clone)]
1938struct FileRow {
1939 surface_fingerprint: String,
1940 freshness: FileFreshness,
1941}
1942
1943#[derive(Debug, Clone)]
1944struct DbFileIndex {
1945 lang: Option<LangId>,
1946 exports: HashSet<String>,
1947 default_export: Option<String>,
1948 export_aliases: HashMap<String, String>,
1949 node_by_scoped: HashMap<String, String>,
1950 node_by_bare: HashMap<String, String>,
1951 module_targets: HashMap<String, Option<String>>,
1952 reexports: Vec<ReexportIndex>,
1953}
1954
1955#[derive(Debug, Clone)]
1956struct ReexportIndex {
1957 target_file: Option<String>,
1958 named: HashMap<String, String>,
1959 wildcard: bool,
1960}
1961
1962#[derive(Debug, Clone)]
1963struct ProjectIndex<'a> {
1964 project_root: PathBuf,
1965 files: HashMap<String, DbFileIndex>,
1966 caller_data: HashMap<String, &'a FileCallData>,
1967 workspace_crate_prefixes: WorkspaceCratePrefixCache,
1972}
1973
1974impl ProjectIndex<'_> {
1975 fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
1978 self.workspace_crate_prefixes
1979 .0
1980 .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
1981 .get(crate_name)
1982 .cloned()
1983 }
1984}
1985
1986impl CallGraphStore {
1987 pub fn open_if_enabled(
1988 options: CallGraphStoreOptions,
1989 callgraph_dir: PathBuf,
1990 project_root: PathBuf,
1991 ) -> Result<Option<Self>> {
1992 if !options.enabled {
1993 return Ok(None);
1994 }
1995 Self::open(callgraph_dir, project_root).map(Some)
1996 }
1997
1998 pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
1999 let project_key = crate::search_index::artifact_cache_key(&project_root);
2000 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2001 else {
2002 return Err(CallGraphStoreError::Unavailable(
2003 "writer capability denied; use the read-only callgraph opener".to_string(),
2004 ));
2005 };
2006 std::fs::create_dir_all(&callgraph_dir)?;
2007 let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
2011 .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
2012 let OpenedStore { store, root_repair } = Self::open_at_path(
2013 project_root.clone(),
2014 project_key,
2015 sqlite_path,
2016 generation,
2017 true,
2018 Some(Arc::clone(&writer_lease)),
2019 None,
2020 )?;
2021 match root_repair {
2022 OpenRootRepair::NeedsRebuild { .. } => {
2023 log_root_repair_rebuild(&root_repair);
2024 drop(store);
2025 drop(writer_lease);
2026 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2027 let (store, _stats) =
2028 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2029 Ok(store)
2030 }
2031 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
2032 }
2033 }
2034
2035 pub fn open_readonly(
2036 callgraph_dir: PathBuf,
2037 project_root: PathBuf,
2038 ) -> Result<Option<ReadonlyCallGraphStore>> {
2039 let project_key = crate::search_index::artifact_cache_key(&project_root);
2040 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2041 {
2042 let conn = open_readonly_connection(&sqlite_path)?;
2043 if !database_ready(&conn).unwrap_or(false) {
2044 return Ok(None);
2045 }
2046 let marker_label = generation.as_deref().unwrap_or("legacy");
2047 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
2048 return Ok(Some(ReadonlyCallGraphStore::from_inner(
2049 Self::from_connection(
2050 project_root,
2051 project_key,
2052 sqlite_path,
2053 callgraph_dir,
2054 false,
2055 generation,
2056 None,
2057 Some(read_marker),
2058 conn,
2059 ),
2060 )));
2061 }
2062
2063 let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
2064 return Ok(None);
2065 };
2066 crate::slog_warn!(
2067 "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
2068 target.partition.harness,
2069 target.sqlite_path.display()
2070 );
2071 let conn = open_readonly_connection(&target.sqlite_path)?;
2072 if !database_ready(&conn).unwrap_or(false) {
2073 return Ok(None);
2074 }
2075 let marker_label =
2076 legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
2077 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
2078 Ok(Some(ReadonlyCallGraphStore::from_inner(
2079 Self::from_connection(
2080 project_root,
2081 project_key,
2082 target.sqlite_path,
2083 callgraph_dir,
2084 true,
2085 target.generation,
2086 None,
2087 Some(read_marker),
2088 conn,
2089 ),
2090 )))
2091 }
2092
2093 pub fn open_ready_repairing(
2099 callgraph_dir: PathBuf,
2100 project_root: PathBuf,
2101 ) -> Result<Option<Self>> {
2102 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true)
2103 }
2104
2105 pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
2109 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false)
2110 }
2111
2112 pub fn open_ready_no_rebuild(
2113 callgraph_dir: PathBuf,
2114 project_root: PathBuf,
2115 ) -> Result<Option<Self>> {
2116 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true)
2117 }
2118
2119 fn open_ready_with_rebuild_policy(
2120 callgraph_dir: PathBuf,
2121 project_root: PathBuf,
2122 allow_cold_build: bool,
2123 allow_root_repair: bool,
2124 ) -> Result<Option<Self>> {
2125 let project_key = crate::search_index::artifact_cache_key(&project_root);
2126 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2127 else {
2128 return Ok(None);
2129 };
2130 let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2131 else {
2132 return Ok(None);
2133 };
2134 let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
2135 project_root.clone(),
2136 project_key.clone(),
2137 sqlite_path,
2138 generation,
2139 true,
2140 Some(Arc::clone(&writer_lease)),
2141 None,
2142 allow_root_repair,
2143 )?;
2144 match root_repair {
2145 OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
2146 log_root_repair_rebuild(&root_repair);
2147 drop(store);
2148 drop(writer_lease);
2149 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2150 let (store, _stats) =
2151 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2152 Ok(Some(store))
2153 }
2154 OpenRootRepair::NeedsRebuild { .. } => {
2155 if let Some(message) = note_repair_entry(&project_key) {
2156 crate::slog_warn!("{message}");
2157 }
2158 Ok(None)
2159 }
2160 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2161 }
2162 }
2163
2164 pub fn cold_build_with_lease(
2165 callgraph_dir: PathBuf,
2166 project_root: PathBuf,
2167 files: &[PathBuf],
2168 ) -> Result<(Self, ColdBuildStats)> {
2169 Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
2170 }
2171
2172 pub fn cold_build_with_lease_chunked(
2173 callgraph_dir: PathBuf,
2174 project_root: PathBuf,
2175 files: &[PathBuf],
2176 chunk_size: usize,
2177 ) -> Result<(Self, ColdBuildStats)> {
2178 Self::cold_build_with_lease_chunked_inner(
2179 callgraph_dir,
2180 project_root,
2181 files,
2182 chunk_size,
2183 false,
2184 )
2185 }
2186
2187 pub(crate) fn force_cold_build_with_lease_chunked(
2188 callgraph_dir: PathBuf,
2189 project_root: PathBuf,
2190 files: &[PathBuf],
2191 chunk_size: usize,
2192 ) -> Result<(Self, ColdBuildStats)> {
2193 Self::cold_build_with_lease_chunked_inner(
2194 callgraph_dir,
2195 project_root,
2196 files,
2197 chunk_size,
2198 true,
2199 )
2200 }
2201
2202 fn cold_build_with_lease_chunked_inner(
2203 callgraph_dir: PathBuf,
2204 project_root: PathBuf,
2205 files: &[PathBuf],
2206 chunk_size: usize,
2207 require_new_publication: bool,
2208 ) -> Result<(Self, ColdBuildStats)> {
2209 let project_key = crate::search_index::artifact_cache_key(&project_root);
2210 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2211 else {
2212 let operation = if require_new_publication {
2213 "forced rebuild"
2214 } else {
2215 "cold build"
2216 };
2217 return Err(CallGraphStoreError::Unavailable(format!(
2218 "{operation} could not acquire writer capability"
2219 )));
2220 };
2221 std::fs::create_dir_all(&callgraph_dir)?;
2222 let (stats, generation) = Self::cold_build_publish_locked(
2223 &callgraph_dir,
2224 &project_root,
2225 &project_key,
2226 files,
2227 chunk_size,
2228 Arc::clone(&writer_lease),
2229 )?;
2230 let store = Self::open_generation(
2231 &callgraph_dir,
2232 project_root,
2233 project_key,
2234 generation,
2235 writer_lease,
2236 )?;
2237 Ok((store, stats))
2238 }
2239
2240 pub fn ensure_built_with_lease(
2241 callgraph_dir: PathBuf,
2242 project_root: PathBuf,
2243 files: &[PathBuf],
2244 ) -> Result<(Self, Option<ColdBuildStats>)> {
2245 Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
2246 }
2247
2248 pub fn ensure_built_with_lease_chunked(
2249 callgraph_dir: PathBuf,
2250 project_root: PathBuf,
2251 files: &[PathBuf],
2252 chunk_size: usize,
2253 ) -> Result<(Self, Option<ColdBuildStats>)> {
2254 let project_key = crate::search_index::artifact_cache_key(&project_root);
2255 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2256 else {
2257 return Err(CallGraphStoreError::Unavailable(
2258 "callgraph ensure could not acquire writer capability".to_string(),
2259 ));
2260 };
2261 std::fs::create_dir_all(&callgraph_dir)?;
2262 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2263 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2270 {
2271 let OpenedStore { store, root_repair } = Self::open_at_path(
2272 project_root.clone(),
2273 project_key.clone(),
2274 sqlite_path,
2275 generation,
2276 true,
2277 Some(Arc::clone(&writer_lease)),
2278 None,
2279 )?;
2280 match root_repair {
2281 OpenRootRepair::NeedsRebuild { .. } => {
2282 log_root_repair_rebuild(&root_repair);
2283 drop(store);
2284 let (stats, generation) = Self::cold_build_publish_locked(
2285 &callgraph_dir,
2286 &project_root,
2287 &project_key,
2288 files,
2289 chunk_size,
2290 Arc::clone(&writer_lease),
2291 )?;
2292 let store = Self::open_generation(
2293 &callgraph_dir,
2294 project_root,
2295 project_key,
2296 generation,
2297 writer_lease,
2298 )?;
2299 return Ok((store, Some(stats)));
2300 }
2301 OpenRootRepair::None | OpenRootRepair::ReRooted => {
2302 return Ok((store, None));
2303 }
2304 }
2305 }
2306 if let Some(store) = try_legacy_migration_or_fallback(
2307 &callgraph_dir,
2308 &project_root,
2309 &project_key,
2310 Arc::clone(&writer_lease),
2311 )? {
2312 return Ok((store, None));
2313 }
2314 let (stats, generation) = Self::cold_build_publish_locked(
2315 &callgraph_dir,
2316 &project_root,
2317 &project_key,
2318 files,
2319 chunk_size,
2320 Arc::clone(&writer_lease),
2321 )?;
2322 let store = Self::open_generation(
2323 &callgraph_dir,
2324 project_root,
2325 project_key,
2326 generation,
2327 writer_lease,
2328 )?;
2329 Ok((store, Some(stats)))
2330 }
2331
2332 pub fn migrate_legacy_with_lease(
2339 callgraph_dir: PathBuf,
2340 project_root: PathBuf,
2341 ) -> Result<Option<Self>> {
2342 let project_key = crate::search_index::artifact_cache_key(&project_root);
2343 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2344 else {
2345 return Ok(None);
2346 };
2347 std::fs::create_dir_all(&callgraph_dir)?;
2348 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2349
2350 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2354 {
2355 let OpenedStore { store, root_repair } = Self::open_at_path(
2356 project_root,
2357 project_key,
2358 sqlite_path,
2359 generation,
2360 true,
2361 Some(writer_lease),
2362 None,
2363 )?;
2364 return match root_repair {
2365 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2366 OpenRootRepair::NeedsRebuild { reason, .. } => {
2367 Err(CallGraphStoreError::Unavailable(format!(
2368 "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
2369 )))
2370 }
2371 };
2372 }
2373
2374 let store = try_legacy_migration_or_fallback(
2375 &callgraph_dir,
2376 &project_root,
2377 &project_key,
2378 writer_lease,
2379 )?;
2380 Ok(store.filter(|store| !store.is_legacy_fallback()))
2384 }
2385
2386 fn cold_build_publish_locked(
2397 callgraph_dir: &Path,
2398 project_root: &Path,
2399 project_key: &str,
2400 files: &[PathBuf],
2401 chunk_size: usize,
2402 writer_lease: Arc<crate::root_cache::WriterLease>,
2403 ) -> Result<(ColdBuildStats, String)> {
2404 if let Some((previous_root, remaining)) =
2405 rebuild_cooldown_denial(callgraph_dir, project_key, project_root, Instant::now())
2406 {
2407 return Err(CallGraphStoreError::Unavailable(format!(
2408 "cache key {project_key} was rebuilt for {} too recently; retry {} ms after the per-key cooldown",
2409 previous_root.display(),
2410 remaining.as_millis()
2411 )));
2412 }
2413 let generation = generation_file_name(project_key);
2414 let gen_path = callgraph_dir.join(&generation);
2415 let temp_path = callgraph_dir.join(format!(
2416 "{generation}.tmp.{}.{}",
2417 std::process::id(),
2418 now_nanos()
2419 ));
2420 remove_sqlite_file_set(&temp_path);
2421
2422 let stats = {
2423 let temp_store = Self::open_at_path(
2424 project_root.to_path_buf(),
2425 project_key.to_string(),
2426 temp_path.clone(),
2427 None,
2428 false,
2429 Some(Arc::clone(&writer_lease)),
2430 None,
2431 )?
2432 .store;
2433 let stats = temp_store.cold_build_chunked(files, chunk_size)?;
2434 let _ = temp_store.checkpoint_wal_truncate();
2435 temp_store.prepare_for_atomic_swap()?;
2436 stats
2437 };
2438
2439 notify_cold_build_before_publish_observer();
2440 let publication = publish_if_current(|| {
2441 verify_writer_lease(&writer_lease)?;
2442 remove_sqlite_file_set(&gen_path);
2445 crate::fs_lock::rename_over(&temp_path, &gen_path)?;
2446 crate::fs_lock::sync_parent(&gen_path);
2447 remove_sqlite_sidecars(&gen_path);
2448
2449 notify_cold_build_swap_observer(&temp_path, &gen_path);
2450
2451 verify_writer_lease(&writer_lease)?;
2453 publish_pointer(callgraph_dir, project_key, &generation)?;
2454 gc_old_generations(callgraph_dir, project_key, &generation);
2455 sweep_orphaned_build_temps_store_wide(callgraph_dir);
2459 if let Some(storage_root) = root_storage_dir(callgraph_dir) {
2460 let inspect_root =
2461 storage_root.join(crate::root_cache::RootCacheDomain::Inspect.as_str());
2462 let live_scope_keys = crate::root_cache::live_scope_keys_for_storage(&storage_root);
2463 crate::inspect::cache::sweep_inspect_scope_dirs(&inspect_root, &live_scope_keys);
2464 }
2465 Ok(())
2466 });
2467 if matches!(publication, Err(CallGraphStoreError::Superseded)) {
2468 remove_sqlite_file_set(&temp_path);
2469 }
2470 publication?;
2471 record_successful_rebuild(callgraph_dir, project_key, project_root, Instant::now());
2472 Ok((stats, generation))
2473 }
2474
2475 fn open_generation(
2478 callgraph_dir: &Path,
2479 project_root: PathBuf,
2480 project_key: String,
2481 generation: String,
2482 writer_lease: Arc<crate::root_cache::WriterLease>,
2483 ) -> Result<Self> {
2484 let gen_path = callgraph_dir.join(&generation);
2485 Ok(Self::open_at_path(
2486 project_root,
2487 project_key,
2488 gen_path,
2489 Some(generation),
2490 true,
2491 Some(writer_lease),
2492 None,
2493 )?
2494 .store)
2495 }
2496
2497 pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
2498 let project_key = crate::search_index::artifact_cache_key(project_root);
2499 Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
2502 }
2503
2504 fn open_at_path(
2505 project_root: PathBuf,
2506 project_key: String,
2507 sqlite_path: PathBuf,
2508 generation: Option<String>,
2509 use_wal: bool,
2510 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2511 read_marker: Option<crate::root_cache::ReadMarker>,
2512 ) -> Result<OpenedStore> {
2513 Self::open_at_path_with_root_repair(
2514 project_root,
2515 project_key,
2516 sqlite_path,
2517 generation,
2518 use_wal,
2519 writer_lease,
2520 read_marker,
2521 true,
2522 )
2523 }
2524
2525 fn open_at_path_with_root_repair(
2526 project_root: PathBuf,
2527 project_key: String,
2528 sqlite_path: PathBuf,
2529 generation: Option<String>,
2530 use_wal: bool,
2531 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2532 read_marker: Option<crate::root_cache::ReadMarker>,
2533 allow_root_repair: bool,
2534 ) -> Result<OpenedStore> {
2535 if let Some(lease) = writer_lease.as_ref() {
2536 verify_writer_lease(lease)?;
2537 }
2538 if let Some(parent) = sqlite_path.parent() {
2539 std::fs::create_dir_all(parent)?;
2540 }
2541 let mut conn = Connection::open(&sqlite_path)?;
2542 if use_wal {
2543 configure_connection(&conn)?;
2544 } else {
2545 configure_build_connection(&conn)?;
2546 }
2547 if let Some(lease) = writer_lease.as_ref() {
2548 verify_writer_lease(lease)?;
2549 }
2550 initialize_schema(&conn)?;
2551 if let Some(lease) = writer_lease.as_ref() {
2552 verify_writer_lease(lease)?;
2553 }
2554 let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
2555 let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
2556 (Some(marker), _, _) => Some(marker),
2557 (None, Some(label), Some(cache_dir)) => {
2558 Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
2559 }
2560 (None, _, _) => None,
2561 };
2562 let publication_dir = sqlite_path
2563 .parent()
2564 .map(Path::to_path_buf)
2565 .unwrap_or_default();
2566 let store = Self::from_connection(
2567 project_root,
2568 project_key,
2569 sqlite_path,
2570 publication_dir,
2571 false,
2572 generation,
2573 writer_lease,
2574 read_marker,
2575 conn,
2576 );
2577 Ok(OpenedStore { store, root_repair })
2578 }
2579
2580 fn prepare_for_atomic_swap(&self) -> Result<()> {
2581 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2582 conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
2583 Ok(())
2584 }
2585
2586 fn atomic_swap_checkpoint_sql(&self) -> &'static str {
2587 let protected_reader = self.generation.as_deref().is_some_and(|generation| {
2588 self.sqlite_path
2589 .parent()
2590 .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
2591 });
2592 if protected_reader {
2593 "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
2594 } else {
2595 "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
2596 }
2597 }
2598
2599 fn from_connection(
2600 project_root: PathBuf,
2601 project_key: String,
2602 sqlite_path: PathBuf,
2603 publication_dir: PathBuf,
2604 legacy_fallback: bool,
2605 generation: Option<String>,
2606 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2607 read_marker: Option<crate::root_cache::ReadMarker>,
2608 conn: Connection,
2609 ) -> Self {
2610 let write_metrics = callgraph_write_metrics_for_key(&project_key);
2611 Self {
2612 project_root,
2613 project_key,
2614 sqlite_path,
2615 publication_dir,
2616 legacy_fallback,
2617 generation,
2618 writer_lease,
2619 read_marker,
2620 database_ready: AtomicBool::new(false),
2621 write_metrics,
2622 conn: Mutex::new(conn),
2623 }
2624 }
2625
2626 fn ensure_ready(&self, conn: &Connection) -> Result<()> {
2627 if self.database_ready.load(AtomicOrdering::Acquire) {
2628 return Ok(());
2629 }
2630 ensure_database_ready(conn)?;
2631 self.database_ready.store(true, AtomicOrdering::Release);
2632 Ok(())
2633 }
2634
2635 pub fn project_root(&self) -> &Path {
2636 &self.project_root
2637 }
2638
2639 pub fn project_key(&self) -> &str {
2640 &self.project_key
2641 }
2642
2643 pub fn sqlite_path(&self) -> &Path {
2644 &self.sqlite_path
2645 }
2646
2647 pub(crate) fn projection_generation(&self) -> Option<&str> {
2649 self.generation.as_deref()
2650 }
2651
2652 pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
2654 self.refresh_read_marker()?;
2655 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2656 self.ensure_ready(&conn)?;
2657 projection_write_revision(&conn)
2658 }
2659
2660 pub fn is_legacy_fallback(&self) -> bool {
2663 self.legacy_fallback
2664 }
2665
2666 pub(crate) fn is_legacy_migration(&self) -> bool {
2667 self.generation.as_deref().is_some_and(|generation| {
2668 migration_generation_requires_manifest(generation)
2669 && migration_manifest_valid(&self.publication_dir, generation)
2670 })
2671 }
2672
2673 pub fn writer_epoch_for_test(&self) -> Option<&str> {
2674 self.writer_lease.as_ref().map(|lease| lease.epoch())
2675 }
2676
2677 fn verify_writer_lease(&self) -> Result<()> {
2678 let Some(lease) = self.writer_lease.as_ref() else {
2679 return Err(CallGraphStoreError::Unavailable(
2680 "callgraph store opened read-only; write API is unavailable".to_string(),
2681 ));
2682 };
2683 verify_writer_lease(lease)
2684 }
2685
2686 fn refresh_read_marker(&self) -> Result<()> {
2687 if let Some(marker) = self.read_marker.as_ref() {
2688 marker.touch_if_due()?;
2689 }
2690 Ok(())
2691 }
2692
2693 fn record_commit(&self, total_changes_before: u64, conn: &Connection) {
2694 self.write_metrics
2695 .record_commit(conn.total_changes().saturating_sub(total_changes_before));
2696 }
2697
2698 fn checkpoint_wal_truncate(&self) -> bool {
2699 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2700 checkpoint_wal_truncate(&conn)
2701 }
2702
2703 pub fn is_current(&self) -> bool {
2709 let _ = self.refresh_read_marker();
2710 match (
2711 read_pointer(&self.publication_dir, &self.project_key),
2712 &self.generation,
2713 ) {
2714 (Some(_), _) if self.legacy_fallback => false,
2717 (Some(published), Some(opened)) => &published == opened,
2718 (Some(_), None) => false,
2720 (None, _) => true,
2723 }
2724 }
2725
2726 pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
2727 self.cold_build_chunked(files, 0)
2728 }
2729
2730 pub fn cold_build_chunked(
2731 &self,
2732 files: &[PathBuf],
2733 chunk_size: usize,
2734 ) -> Result<ColdBuildStats> {
2735 let started = Instant::now();
2736 let bench = std::env::var("AFT_BENCH_COLD").is_ok();
2737 macro_rules! phase {
2738 ($label:expr, $t:expr) => {
2739 if bench {
2740 eprintln!(" cold_build[{}]: {} ms", $label, $t.elapsed().as_millis());
2741 let _ = std::io::Write::flush(&mut std::io::stderr());
2742 }
2743 };
2744 }
2745 let files = normalize_file_list(&self.project_root, files)?;
2746
2747 if chunk_size == 0 {
2748 let t = Instant::now();
2749 let build = build_extracts_parallel(&self.project_root, &files);
2750 phase!("extract_parallel", t);
2751 let extracts = build.extracts;
2752 let failures = build.failures;
2753 let node_count = extracts.iter().map(|extract| extract.nodes.len()).sum();
2754
2755 let t = Instant::now();
2756 let index = ProjectIndex::from_extracts(&self.project_root, &extracts);
2757 phase!("build_index", t);
2758 let t = Instant::now();
2759 let mut resolved_refs = Vec::new();
2760 for extract in &extracts {
2761 for raw_ref in &extract.raw_refs {
2762 resolved_refs.push(resolve_ref(raw_ref.clone(), &index)?);
2763 }
2764 }
2765 phase!("resolve_refs", t);
2766 let ref_count = resolved_refs.len();
2767 let edge_count = resolved_refs
2768 .iter()
2769 .filter(|item| item.edge.is_some())
2770 .count();
2771
2772 let t = Instant::now();
2773 self.verify_writer_lease()?;
2774 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2775 let total_changes_before = conn.total_changes();
2776 let tx = conn.transaction()?;
2777 clear_tables(&tx)?;
2778 insert_meta(&tx)?;
2779 drop_cold_build_secondary_indexes(&tx)?;
2780 {
2781 let workspace_root = self.project_root.display().to_string();
2782 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2783 for extract in &extracts {
2784 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
2785 }
2786 for failure in &failures {
2787 insert_backend_state_prepared(
2788 &mut inserts.backend_state,
2789 &workspace_root,
2790 &failure.rel_path,
2791 failure
2792 .freshness
2793 .as_ref()
2794 .map(|freshness| &freshness.content_hash),
2795 "stale",
2796 )?;
2797 }
2798 for resolved in &resolved_refs {
2799 insert_resolved_ref_prepared(&mut inserts, resolved)?;
2800 }
2801 }
2802 create_cold_build_secondary_indexes(&tx)?;
2803 let supplemental_edge_count =
2804 insert_method_dispatch_edges(&tx, &self.project_root, None)?;
2805 set_meta_ready(&tx, true)?;
2806 tx.commit()?;
2807 self.record_commit(total_changes_before, &conn);
2808 phase!("sqlite_insert", t);
2809
2810 let elapsed_ms = started.elapsed().as_millis();
2811 crate::slog_info!(
2812 "perf callgraph_store cold_build: files={} nodes={} refs={} edges={} ms={}",
2813 extracts.len(),
2814 node_count,
2815 ref_count,
2816 edge_count + supplemental_edge_count,
2817 elapsed_ms
2818 );
2819 return Ok(ColdBuildStats {
2820 files: extracts.len(),
2821 nodes: node_count,
2822 refs: ref_count,
2823 edges: edge_count + supplemental_edge_count,
2824 failed_files: failures
2825 .into_iter()
2826 .map(|failure| failure.rel_path)
2827 .collect(),
2828 elapsed_ms,
2829 });
2830 }
2831
2832 let t = Instant::now();
2835 self.verify_writer_lease()?;
2836 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2837 let total_changes_before = conn.total_changes();
2838 let tx = conn.transaction()?;
2839 clear_tables(&tx)?;
2840 insert_meta(&tx)?;
2841 drop_cold_build_secondary_indexes(&tx)?;
2842
2843 let mut all_raw_refs = Vec::new();
2844 let mut failures = Vec::new();
2845 let mut node_count = 0;
2846 let mut files_parsed = 0;
2847
2848 let mut persistent_call_data = Vec::new();
2849 let mut file_to_call_data_index = HashMap::new();
2850 let mut files_index = HashMap::new();
2851
2852 let workspace_root = self.project_root.display().to_string();
2853
2854 {
2855 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2856 for chunk in files.chunks(chunk_size) {
2857 let build = build_extracts_parallel(&self.project_root, chunk);
2858 failures.extend(build.failures.clone());
2859
2860 for extract in build.extracts {
2861 files_parsed += 1;
2862 node_count += extract.nodes.len();
2863 insert_file_extract_prepared(&mut inserts, &workspace_root, &extract)?;
2864
2865 let db_file_index = DbFileIndex::from_extract(&self.project_root, &extract);
2866 files_index.insert(extract.rel_path.clone(), db_file_index);
2867
2868 persistent_call_data.push(extract.data);
2869 let idx = persistent_call_data.len() - 1;
2870 file_to_call_data_index.insert(extract.rel_path.clone(), idx);
2871
2872 all_raw_refs.push((extract.rel_path, extract.raw_refs));
2873 }
2874 for failure in &build.failures {
2875 insert_backend_state_prepared(
2876 &mut inserts.backend_state,
2877 &workspace_root,
2878 &failure.rel_path,
2879 failure
2880 .freshness
2881 .as_ref()
2882 .map(|freshness| &freshness.content_hash),
2883 "stale",
2884 )?;
2885 }
2886 }
2887 }
2888
2889 let mut caller_data = HashMap::new();
2890 for (rel_path, idx) in &file_to_call_data_index {
2891 caller_data.insert(rel_path.clone(), &persistent_call_data[*idx]);
2892 }
2893 let indexed_caller_files = files_index.keys().cloned().collect::<BTreeSet<_>>();
2894 let index = ProjectIndex::from_parts(
2895 &self.project_root,
2896 files_index,
2897 caller_data,
2898 WorkspaceCratePrefixCache::default(),
2899 );
2900
2901 let mut resolved_refs = Vec::new();
2902 for (_, raw_refs) in all_raw_refs {
2903 for raw_ref in raw_refs {
2904 resolved_refs.push(resolve_ref(raw_ref, &index)?);
2905 }
2906 }
2907
2908 let ref_count = resolved_refs.len();
2909 let edge_count = resolved_refs
2910 .iter()
2911 .filter(|item| item.edge.is_some())
2912 .count();
2913
2914 {
2915 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2916 for resolved in &resolved_refs {
2917 insert_resolved_ref_prepared(&mut inserts, resolved)?;
2918 }
2919 }
2920 create_cold_build_secondary_indexes(&tx)?;
2921 let supplemental_edge_count = insert_method_dispatch_edges_chunked(
2922 &tx,
2923 &self.project_root,
2924 &indexed_caller_files,
2925 chunk_size,
2926 )?;
2927 set_meta_ready(&tx, true)?;
2928 bump_projection_write_revision(&tx)?;
2929 tx.commit()?;
2930 self.record_commit(total_changes_before, &conn);
2931 phase!("sqlite_insert", t);
2932
2933 let elapsed_ms = started.elapsed().as_millis();
2934 crate::slog_info!(
2935 "perf callgraph_store cold_build (chunked): files={} nodes={} refs={} edges={} ms={}",
2936 files_parsed,
2937 node_count,
2938 ref_count,
2939 edge_count + supplemental_edge_count,
2940 elapsed_ms
2941 );
2942 Ok(ColdBuildStats {
2943 files: files_parsed,
2944 nodes: node_count,
2945 refs: ref_count,
2946 edges: edge_count + supplemental_edge_count,
2947 failed_files: failures
2948 .into_iter()
2949 .map(|failure| failure.rel_path)
2950 .collect(),
2951 elapsed_ms,
2952 })
2953 }
2954
2955 pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
2956 self.refresh_files_with_workspace_crate_prefix_cache(
2957 changed_files,
2958 WorkspaceCratePrefixCache::default(),
2959 )
2960 }
2961
2962 fn refresh_files_with_workspace_crate_prefix_cache(
2963 &self,
2964 changed_files: &[PathBuf],
2965 workspace_crate_prefixes: WorkspaceCratePrefixCache,
2966 ) -> Result<IncrementalStats> {
2967 let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
2968 changed_files,
2969 workspace_crate_prefixes,
2970 )?;
2971 if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
2972 eprintln!("refresh_files phases: {}", profile.report());
2973 }
2974 Ok(stats)
2975 }
2976
2977 #[doc(hidden)]
2979 pub fn refresh_files_profiled(
2980 &self,
2981 changed_files: &[PathBuf],
2982 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2983 self.refresh_files_profiled_with_workspace_crate_prefix_cache(
2984 changed_files,
2985 WorkspaceCratePrefixCache::default(),
2986 )
2987 }
2988
2989 fn refresh_files_profiled_with_workspace_crate_prefix_cache(
2990 &self,
2991 changed_files: &[PathBuf],
2992 workspace_crate_prefixes: WorkspaceCratePrefixCache,
2993 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2994 let total_started = Instant::now();
2995 let mut profile = RefreshFilesProfile::default();
2996 self.verify_writer_lease()?;
2997 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2998 let total_changes_before = conn.total_changes();
2999 let tx = conn.transaction()?;
3000 ensure_database_ready(&tx)?;
3001 let mut changed = Vec::new();
3002 let mut surface_changed = BTreeSet::new();
3003 let mut deleted = BTreeSet::new();
3004 let mut own_refresh = BTreeSet::new();
3005 let mut candidate_own_refresh = BTreeSet::new();
3006 let mut unchanged_extracts = 0usize;
3007 let mut selected_ref_ids = BTreeSet::new();
3008 let mut selected_refs_by_caller = BTreeMap::new();
3009 let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
3010
3011 for input in changed_files {
3012 let abs_path = normalize_file_path(&self.project_root, input)?;
3013 let rel_path = relative_path(&self.project_root, &abs_path);
3014 changed.push(rel_path.clone());
3015 let old_row = load_file_row(&tx, &rel_path)?;
3016 if !abs_path.exists() {
3017 if old_row.is_some() {
3018 surface_changed.insert(rel_path.clone());
3019 deleted.insert(rel_path.clone());
3020 let started = Instant::now();
3021 let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
3022 profile.dependency_selection += started.elapsed();
3023 record_dependent_refs(
3024 &mut selected_ref_ids,
3025 &mut selected_refs_by_caller,
3026 dependent_refs,
3027 );
3028 let started = Instant::now();
3029 delete_file_rows(&tx, &rel_path)?;
3030 clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
3031 profile.row_deletes += started.elapsed();
3032 }
3033 continue;
3034 }
3035
3036 if let Some(row) = &old_row {
3037 match cache_freshness::verify_file(&abs_path, &row.freshness) {
3038 FreshnessVerdict::HotFresh => continue,
3039 FreshnessVerdict::ContentFresh {
3040 new_mtime,
3041 new_size,
3042 } => {
3043 update_file_fresh_metadata(
3044 &tx,
3045 &self.project_root,
3046 &rel_path,
3047 &row.freshness.content_hash,
3048 new_mtime,
3049 new_size,
3050 )?;
3051 continue;
3052 }
3053 FreshnessVerdict::Deleted => {
3054 surface_changed.insert(rel_path.clone());
3055 deleted.insert(rel_path.clone());
3056 let started = Instant::now();
3057 let dependent_refs =
3058 ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
3059 profile.dependency_selection += started.elapsed();
3060 record_dependent_refs(
3061 &mut selected_ref_ids,
3062 &mut selected_refs_by_caller,
3063 dependent_refs,
3064 );
3065 let started = Instant::now();
3066 delete_file_rows(&tx, &rel_path)?;
3067 clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
3068 profile.row_deletes += started.elapsed();
3069 continue;
3070 }
3071 FreshnessVerdict::Stale => {}
3072 }
3073 }
3074
3075 let started = Instant::now();
3076 let extract = build_file_extract(&self.project_root, &abs_path)?;
3077 profile.parse += started.elapsed();
3078 let surface_is_changed = old_row
3079 .as_ref()
3080 .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
3081 .unwrap_or(true);
3082 if surface_is_changed {
3083 surface_changed.insert(rel_path.clone());
3084 let started = Instant::now();
3085 let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
3086 profile.dependency_selection += started.elapsed();
3087 record_dependent_refs(
3088 &mut selected_ref_ids,
3089 &mut selected_refs_by_caller,
3090 dependent_refs,
3091 );
3092 }
3093 candidate_own_refresh.insert(rel_path.clone());
3094 changed_extracts.insert(rel_path, extract);
3095 }
3096
3097 let dependency_selected_refs = selected_ref_ids.len();
3098 let mut touched_callers: BTreeSet<String> =
3099 selected_refs_by_caller.keys().cloned().collect();
3100 touched_callers.extend(candidate_own_refresh.iter().cloned());
3101
3102 let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
3103 for rel_path in &touched_callers {
3104 if deleted.contains(rel_path) {
3105 continue;
3106 }
3107 if let Some(extract) = changed_extracts.get(rel_path) {
3108 caller_extracts.insert(rel_path.clone(), extract.clone());
3109 continue;
3110 }
3111 let abs_path = self.project_root.join(rel_path);
3112 if abs_path.exists() {
3113 let started = Instant::now();
3114 let extract = build_file_extract(&self.project_root, &abs_path)?;
3115 profile.dependent_parse += started.elapsed();
3116 caller_extracts.insert(rel_path.clone(), extract);
3117 }
3118 }
3119
3120 let started = Instant::now();
3121 let index = ProjectIndex::from_db_and_callers(
3122 &tx,
3123 &self.project_root,
3124 &caller_extracts,
3125 workspace_crate_prefixes,
3126 )?;
3127 profile.index_load += started.elapsed();
3128
3129 for rel_path in &candidate_own_refresh {
3130 let Some(extract) = changed_extracts.get(rel_path) else {
3131 continue;
3132 };
3133 if !write_amplification_baseline_enabled()
3134 && stored_extract_matches(&tx, rel_path, extract, &index)?
3135 {
3136 unchanged_extracts += 1;
3137 update_file_fresh_metadata(
3138 &tx,
3139 &self.project_root,
3140 rel_path,
3141 &extract.freshness.content_hash,
3142 extract.freshness.mtime,
3143 extract.freshness.size,
3144 )?;
3145 continue;
3146 }
3147
3148 own_refresh.insert(rel_path.clone());
3149 let started = Instant::now();
3150 delete_file_rows(&tx, rel_path)?;
3151 profile.row_deletes += started.elapsed();
3152 let started = Instant::now();
3153 insert_file_extract(&tx, &self.project_root, extract)?;
3154 profile.row_inserts += started.elapsed();
3155 }
3156
3157 let dependency_callers = touched_callers
3158 .iter()
3159 .filter(|rel_path| {
3160 !deleted.contains(*rel_path) && !candidate_own_refresh.contains(*rel_path)
3161 })
3162 .cloned()
3163 .collect::<Vec<_>>();
3164 for rel_path in dependency_callers {
3165 let Some(extract) = caller_extracts.get(&rel_path) else {
3166 continue;
3167 };
3168 if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
3169 continue;
3170 }
3171
3172 own_refresh.insert(rel_path.clone());
3173 let started = Instant::now();
3174 delete_file_rows(&tx, &rel_path)?;
3175 profile.row_deletes += started.elapsed();
3176 let started = Instant::now();
3177 insert_file_extract(&tx, &self.project_root, extract)?;
3178 profile.row_inserts += started.elapsed();
3179 }
3180 let started = Instant::now();
3181 for rel_path in &touched_callers {
3182 if deleted.contains(rel_path) {
3183 continue;
3184 }
3185 let Some(extract) = caller_extracts.get(rel_path) else {
3186 continue;
3187 };
3188 if own_refresh.contains(rel_path) {
3189 delete_refs_for_caller(&tx, rel_path)?;
3190 for raw_ref in &extract.raw_refs {
3191 let resolved = resolve_ref(raw_ref.clone(), &index)?;
3192 insert_resolved_ref(&tx, &resolved)?;
3193 }
3194 continue;
3195 }
3196
3197 let selected_for_caller = selected_refs_by_caller
3198 .get(rel_path)
3199 .cloned()
3200 .unwrap_or_default();
3201 delete_ref_ids(&tx, &selected_for_caller)?;
3202 for raw_ref in &extract.raw_refs {
3203 if selected_for_caller.contains(&raw_ref.ref_id) {
3204 let resolved = resolve_ref(raw_ref.clone(), &index)?;
3205 insert_resolved_ref(&tx, &resolved)?;
3206 }
3207 }
3208 }
3209 profile.ref_resolution += started.elapsed();
3210
3211 let started = Instant::now();
3212 delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
3213 insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
3214 profile.method_dispatch += started.elapsed();
3215
3216 bump_projection_write_revision(&tx)?;
3217 let started = Instant::now();
3218 commit_incremental_if_current(tx)?;
3219 self.record_commit(total_changes_before, &conn);
3220 profile.commit += started.elapsed();
3221 profile.total = total_started.elapsed();
3222 Ok((
3223 IncrementalStats {
3224 changed_files: changed,
3225 surface_changed: surface_changed.into_iter().collect(),
3226 deleted_files: deleted.into_iter().collect(),
3227 dependency_selected_refs,
3228 refreshed_own_files: own_refresh.len(),
3229 unchanged_extract_files: unchanged_extracts,
3230 },
3231 profile,
3232 ))
3233 }
3234
3235 pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
3236 self.cold_build(current_files)
3237 }
3238
3239 pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
3240 self.verify_writer_lease()?;
3241 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3242 let total_changes_before = conn.total_changes();
3243 let tx = conn.transaction()?;
3244 let mut marked = Vec::new();
3245 for path in files {
3246 let abs_path = normalize_file_path(&self.project_root, path)?;
3247 let rel_path = relative_path(&self.project_root, &abs_path);
3248 let freshness = cache_freshness::collect(&abs_path).ok();
3249 mark_backend_state(
3250 &tx,
3251 &self.project_root,
3252 &rel_path,
3253 freshness.as_ref().map(|freshness| &freshness.content_hash),
3254 "stale",
3255 )?;
3256 marked.push(rel_path);
3257 }
3258 bump_projection_write_revision(&tx)?;
3259 tx.commit()?;
3260 self.record_commit(total_changes_before, &conn);
3261 marked.sort();
3262 marked.dedup();
3263 Ok(marked)
3264 }
3265
3266 pub fn stale_files(&self) -> Result<Vec<String>> {
3267 self.refresh_read_marker()?;
3268 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3269 let mut stmt = conn.prepare(
3270 "SELECT DISTINCT file_path FROM backend_file_state
3271 WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
3272 ORDER BY file_path",
3273 )?;
3274 let rows = stmt.query_map(
3275 params![BACKEND_TREESITTER, self.project_root.display().to_string()],
3276 |row| row.get::<_, String>(0),
3277 )?;
3278 rows.collect::<std::result::Result<Vec<_>, _>>()
3279 .map_err(Into::into)
3280 }
3281
3282 pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
3283 self.refresh_read_marker()?;
3284 let rel_path = relative_path(
3285 &self.project_root,
3286 &normalize_file_path(&self.project_root, file)?,
3287 );
3288 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3289 conn.query_row(
3290 "SELECT status FROM backend_file_state
3291 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
3292 ORDER BY updated_at DESC LIMIT 1",
3293 params![
3294 BACKEND_TREESITTER,
3295 self.project_root.display().to_string(),
3296 rel_path
3297 ],
3298 |row| row.get(0),
3299 )
3300 .optional()
3301 .map_err(Into::into)
3302 }
3303
3304 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3305 self.refresh_read_marker()?;
3306 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3307 self.ensure_ready(&conn)?;
3308 edge_snapshot_with_conn(&conn)
3309 }
3310
3311 pub fn indexed_file_count(&self) -> Result<usize> {
3312 self.refresh_read_marker()?;
3313 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3314 self.ensure_ready(&conn)?;
3315 indexed_file_count(&conn)
3316 }
3317
3318 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3319 self.refresh_read_marker()?;
3320 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3321 let rel_path = relative_path(&self.project_root, &abs_path);
3322 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3323 self.ensure_ready(&conn)?;
3324 resolve_node_for_rel(&conn, &rel_path, symbol)
3325 }
3326
3327 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3332 self.refresh_read_marker()?;
3333 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3334 let rel_path = relative_path(&self.project_root, &abs_path);
3335 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3336 self.ensure_ready(&conn)?;
3337 nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
3338 }
3339
3340 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3342 self.refresh_read_marker()?;
3343 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3344 self.ensure_ready(&conn)?;
3345 nodes_matching_symbol(&conn, symbol)
3346 }
3347
3348 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3350 self.refresh_read_marker()?;
3351 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3352 let rel_path = relative_path(&self.project_root, &abs_path);
3353 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3354 self.ensure_ready(&conn)?;
3355 direct_callers_for_tuple(&conn, &rel_path, symbol)
3356 }
3357
3358 pub fn direct_callers_for_symbols(
3360 &self,
3361 targets: &[(String, String)],
3362 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3363 if targets.is_empty() {
3364 return Ok(HashMap::new());
3365 }
3366 self.refresh_read_marker()?;
3367 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3368 self.ensure_ready(&conn)?;
3369 direct_callers_for_tuples(&conn, targets)
3370 }
3371
3372 pub fn direct_caller_counts_of(
3374 &self,
3375 targets: &[(String, String)],
3376 ) -> Result<HashMap<(String, String), usize>> {
3377 if targets.is_empty() {
3378 return Ok(HashMap::new());
3379 }
3380 self.refresh_read_marker()?;
3381 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3382 self.ensure_ready(&conn)?;
3383 direct_caller_counts_for_tuples(&conn, targets)
3384 }
3385
3386 pub fn callers_of(
3387 &self,
3388 file_rel: &Path,
3389 symbol: &str,
3390 depth: usize,
3391 ) -> Result<StoreCallersResult> {
3392 let target = self.node_for(file_rel, symbol)?;
3393 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3394 self.ensure_ready(&conn)?;
3395 let effective_depth = depth.max(1);
3396 let mut visited = HashSet::new();
3397 let mut callers = Vec::new();
3398 let mut depth_limited = false;
3399 let mut truncated = 0usize;
3400 collect_callers_recursive(
3401 &conn,
3402 &target.file,
3403 &target.symbol,
3404 effective_depth,
3405 0,
3406 &mut visited,
3407 &mut callers,
3408 &mut depth_limited,
3409 &mut truncated,
3410 )?;
3411 Ok(StoreCallersResult {
3412 target,
3413 callers,
3414 scanned_files: indexed_file_count(&conn)?,
3415 depth_limited,
3416 truncated,
3417 })
3418 }
3419
3420 pub fn impact_of(
3421 &self,
3422 file_rel: &Path,
3423 symbol: &str,
3424 depth: usize,
3425 ) -> Result<StoreImpactResult> {
3426 let callers = self.callers_of(file_rel, symbol, depth)?;
3427 let target_parameters = callers
3428 .target
3429 .signature
3430 .as_deref()
3431 .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
3432 .unwrap_or_default();
3433 let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
3434 for site in &callers.callers {
3435 source_lines_by_file
3436 .entry(site.caller.file.clone())
3437 .or_insert_with(|| {
3438 read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
3439 });
3440 }
3441 let enriched = callers
3442 .callers
3443 .iter()
3444 .map(|site| StoreImpactCaller {
3445 site: site.clone(),
3446 signature: site.caller.signature.clone(),
3447 is_entry_point: site.caller.is_entry_point,
3448 call_expression: source_lines_by_file
3449 .get(&site.caller.file)
3450 .and_then(|lines| lines.as_ref())
3451 .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
3452 .cloned(),
3453 parameters: site
3454 .caller
3455 .signature
3456 .as_deref()
3457 .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
3458 .unwrap_or_default(),
3459 })
3460 .collect();
3461 Ok(StoreImpactResult {
3462 target: callers.target,
3463 parameters: target_parameters,
3464 callers: enriched,
3465 depth_limited: callers.depth_limited,
3466 truncated: callers.truncated,
3467 })
3468 }
3469
3470 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3471 self.refresh_read_marker()?;
3472 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3473 self.ensure_ready(&conn)?;
3474 outgoing_calls_for_node(&conn, node)
3475 }
3476
3477 pub fn outgoing_calls_for_symbols(
3479 &self,
3480 sources: &[(String, String)],
3481 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3482 if sources.is_empty() {
3483 return Ok(HashMap::new());
3484 }
3485 self.refresh_read_marker()?;
3486 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3487 self.ensure_ready(&conn)?;
3488 outgoing_calls_for_symbol_tuples(&conn, sources)
3489 }
3490
3491 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3493 self.refresh_read_marker()?;
3494 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3495 self.ensure_ready(&conn)?;
3496 resolved_self_calls_for_node(&conn, node)
3497 }
3498
3499 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3500 self.refresh_read_marker()?;
3501 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3502 self.ensure_ready(&conn)?;
3503 unresolved_calls_for_node(&conn, node)
3504 }
3505
3506 pub fn call_tree(
3507 &self,
3508 file_rel: &Path,
3509 symbol: &str,
3510 max_depth: usize,
3511 ) -> Result<callgraph::CallTreeNode> {
3512 let node = self.node_for(file_rel, symbol)?;
3513 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3514 self.ensure_ready(&conn)?;
3515 let mut visited = HashSet::new();
3516 call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
3517 }
3518
3519 pub fn trace_to(
3520 &self,
3521 file_rel: &Path,
3522 symbol: &str,
3523 max_depth: usize,
3524 ) -> Result<callgraph::TraceToResult> {
3525 let target = self.node_for(file_rel, symbol)?;
3526 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3527 self.ensure_ready(&conn)?;
3528 let effective_max = if max_depth == 0 { 10 } else { max_depth };
3529
3530 #[derive(Clone)]
3531 struct PathElem {
3532 node: StoreNode,
3533 }
3534
3535 let initial = vec![PathElem {
3536 node: target.clone(),
3537 }];
3538 let mut complete_paths = Vec::new();
3539 if target.is_entry_point {
3540 complete_paths.push(initial.clone());
3541 }
3542
3543 let mut queue = vec![(initial, 0usize)];
3544 let mut max_depth_reached = false;
3545 let mut truncated_paths = 0usize;
3546
3547 while let Some((path, depth)) = queue.pop() {
3548 if depth >= effective_max {
3549 max_depth_reached = true;
3550 continue;
3551 }
3552 let Some(current) = path.last() else {
3553 continue;
3554 };
3555 let callers =
3556 direct_callers_for_tuple(&conn, ¤t.node.file, ¤t.node.symbol)?;
3557 if callers.is_empty() {
3558 if path.len() > 1 {
3559 truncated_paths += 1;
3560 }
3561 continue;
3562 }
3563
3564 let mut has_new_path = false;
3565 for site in callers {
3566 if path.iter().any(|elem| {
3567 elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
3568 }) {
3569 continue;
3570 }
3571 has_new_path = true;
3572 let mut new_path = path.clone();
3573 new_path.push(PathElem {
3574 node: site.caller.clone(),
3575 });
3576 if site.caller.is_entry_point {
3577 complete_paths.push(new_path.clone());
3578 }
3579 queue.push((new_path, depth + 1));
3580 }
3581 if !has_new_path && path.len() > 1 {
3582 truncated_paths += 1;
3583 }
3584 }
3585
3586 let mut paths: Vec<callgraph::TracePath> = complete_paths
3587 .into_iter()
3588 .map(|mut elems| {
3589 elems.reverse();
3590 let hops = elems
3591 .iter()
3592 .enumerate()
3593 .map(|(index, elem)| callgraph::TraceHop {
3594 symbol: elem.node.symbol.clone(),
3595 file: elem.node.file.clone(),
3596 line: elem.node.line,
3597 signature: elem.node.signature.clone(),
3598 is_entry_point: index == 0 && elem.node.is_entry_point,
3599 })
3600 .collect();
3601 callgraph::TracePath { hops }
3602 })
3603 .collect();
3604 paths.sort_by(|left, right| {
3605 let left_entry = left
3606 .hops
3607 .first()
3608 .map(|hop| hop.symbol.as_str())
3609 .unwrap_or("");
3610 let right_entry = right
3611 .hops
3612 .first()
3613 .map(|hop| hop.symbol.as_str())
3614 .unwrap_or("");
3615 left_entry
3616 .cmp(right_entry)
3617 .then(left.hops.len().cmp(&right.hops.len()))
3618 });
3619 let entry_points_found = paths
3620 .iter()
3621 .filter_map(|path| path.hops.first())
3622 .filter(|hop| hop.is_entry_point)
3623 .map(|hop| (hop.file.clone(), hop.symbol.clone()))
3624 .collect::<HashSet<_>>()
3625 .len();
3626
3627 Ok(callgraph::TraceToResult {
3628 target_symbol: target.symbol,
3629 target_file: target.file,
3630 total_paths: paths.len(),
3631 paths,
3632 entry_points_found,
3633 max_depth_reached,
3634 truncated_paths,
3635 })
3636 }
3637
3638 pub fn trace_to_symbol_candidates(
3639 &self,
3640 to_symbol: &str,
3641 ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
3642 self.refresh_read_marker()?;
3643 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3644 self.ensure_ready(&conn)?;
3645 let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
3646 for node in nodes_matching_symbol(&conn, to_symbol)? {
3647 candidates_by_file
3648 .entry(node.file)
3649 .and_modify(|line| *line = (*line).min(node.line))
3650 .or_insert(node.line);
3651 }
3652 let mut candidates: Vec<_> = candidates_by_file
3653 .into_iter()
3654 .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
3655 .collect();
3656 candidates
3657 .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
3658 Ok(candidates)
3659 }
3660
3661 pub fn trace_to_symbol(
3662 &self,
3663 file_rel: &Path,
3664 symbol: &str,
3665 to_symbol: &str,
3666 to_file: Option<&Path>,
3667 max_depth: usize,
3668 ) -> Result<callgraph::TraceToSymbolResult> {
3669 let origin = self.node_for(file_rel, symbol)?;
3670 let target_file = to_file
3671 .map(|path| normalize_file_path(&self.project_root, path))
3672 .transpose()?
3673 .map(|path| relative_path(&self.project_root, &path));
3674 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3675 self.ensure_ready(&conn)?;
3676 let effective_max = if max_depth == 0 {
3677 10
3678 } else {
3679 max_depth.min(16)
3680 };
3681
3682 let start_hop = trace_to_symbol_hop(&origin);
3683 if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
3684 return Ok(callgraph::TraceToSymbolResult {
3685 path: Some(vec![start_hop]),
3686 complete: true,
3687 reason: None,
3688 });
3689 }
3690
3691 let mut queue = VecDeque::new();
3692 queue.push_back((origin.clone(), vec![start_hop], 0usize));
3693 let mut visited = HashSet::new();
3694 visited.insert((origin.file.clone(), origin.symbol.clone()));
3695 let mut max_depth_exhausted = false;
3696
3697 while let Some((current, path, depth)) = queue.pop_front() {
3698 let callees = outgoing_calls_for_node(&conn, ¤t)?
3699 .into_iter()
3700 .filter_map(|site| site.target)
3701 .collect::<Vec<_>>();
3702
3703 if depth >= effective_max {
3704 if callees
3705 .iter()
3706 .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
3707 {
3708 max_depth_exhausted = true;
3709 }
3710 continue;
3711 }
3712
3713 for callee in callees {
3714 if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
3715 continue;
3716 }
3717 let mut next_path = path.clone();
3718 next_path.push(trace_to_symbol_hop(&callee));
3719 if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
3720 return Ok(callgraph::TraceToSymbolResult {
3721 path: Some(next_path),
3722 complete: true,
3723 reason: None,
3724 });
3725 }
3726 queue.push_back((callee, next_path, depth + 1));
3727 }
3728 }
3729
3730 if max_depth_exhausted {
3731 Ok(callgraph::TraceToSymbolResult {
3732 path: None,
3733 complete: false,
3734 reason: Some("max_depth_exhausted".to_string()),
3735 })
3736 } else {
3737 Ok(callgraph::TraceToSymbolResult {
3738 path: None,
3739 complete: true,
3740 reason: Some("no_path_found".to_string()),
3741 })
3742 }
3743 }
3744}
3745
3746impl ReadonlyCallGraphStore {
3747 fn from_inner(inner: CallGraphStore) -> Self {
3748 Self { inner }
3749 }
3750
3751 pub fn project_root(&self) -> &Path {
3752 self.inner.project_root()
3753 }
3754
3755 pub fn project_key(&self) -> &str {
3756 self.inner.project_key()
3757 }
3758
3759 pub fn sqlite_path(&self) -> &Path {
3760 self.inner.sqlite_path()
3761 }
3762
3763 pub(crate) fn projection_generation(&self) -> Option<&str> {
3764 self.inner.projection_generation()
3765 }
3766
3767 pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
3768 self.inner.projection_write_revision()
3769 }
3770
3771 pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
3774 crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
3775 }
3776
3777 pub fn is_legacy_fallback(&self) -> bool {
3779 self.inner.is_legacy_fallback()
3780 }
3781
3782 pub fn is_current(&self) -> bool {
3783 self.inner.is_current()
3784 }
3785
3786 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3787 self.inner.edge_snapshot()
3788 }
3789
3790 pub fn indexed_file_count(&self) -> Result<usize> {
3791 self.inner.indexed_file_count()
3792 }
3793
3794 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3795 self.inner.node_for(file_rel, symbol)
3796 }
3797
3798 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3799 self.inner.nodes_for(file_rel, symbol)
3800 }
3801
3802 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3803 self.inner.nodes_matching(symbol)
3804 }
3805
3806 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3807 self.inner.direct_callers_of(file_rel, symbol)
3808 }
3809
3810 pub fn direct_callers_for_symbols(
3811 &self,
3812 targets: &[(String, String)],
3813 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3814 self.inner.direct_callers_for_symbols(targets)
3815 }
3816
3817 pub fn direct_caller_counts_of(
3818 &self,
3819 targets: &[(String, String)],
3820 ) -> Result<HashMap<(String, String), usize>> {
3821 self.inner.direct_caller_counts_of(targets)
3822 }
3823
3824 pub fn callers_of(
3825 &self,
3826 file_rel: &Path,
3827 symbol: &str,
3828 depth: usize,
3829 ) -> Result<StoreCallersResult> {
3830 self.inner.callers_of(file_rel, symbol, depth)
3831 }
3832
3833 pub fn impact_of(
3834 &self,
3835 file_rel: &Path,
3836 symbol: &str,
3837 depth: usize,
3838 ) -> Result<StoreImpactResult> {
3839 self.inner.impact_of(file_rel, symbol, depth)
3840 }
3841
3842 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3843 self.inner.outgoing_calls_of(node)
3844 }
3845
3846 pub fn outgoing_calls_for_symbols(
3847 &self,
3848 sources: &[(String, String)],
3849 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3850 self.inner.outgoing_calls_for_symbols(sources)
3851 }
3852
3853 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3854 self.inner.resolved_self_calls_of(node)
3855 }
3856
3857 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3858 self.inner.unresolved_calls_of(node)
3859 }
3860
3861 pub fn call_tree(
3862 &self,
3863 file_rel: &Path,
3864 symbol: &str,
3865 depth: usize,
3866 ) -> Result<callgraph::CallTreeNode> {
3867 self.inner.call_tree(file_rel, symbol, depth)
3868 }
3869
3870 pub fn trace_to(
3871 &self,
3872 file_rel: &Path,
3873 symbol: &str,
3874 max_depth: usize,
3875 ) -> Result<callgraph::TraceToResult> {
3876 self.inner.trace_to(file_rel, symbol, max_depth)
3877 }
3878
3879 pub fn trace_to_symbol_candidates(
3880 &self,
3881 to_symbol: &str,
3882 ) -> Result<Vec<TraceToSymbolCandidate>> {
3883 self.inner.trace_to_symbol_candidates(to_symbol)
3884 }
3885
3886 pub fn trace_to_symbol(
3887 &self,
3888 file_rel: &Path,
3889 symbol: &str,
3890 to_symbol: &str,
3891 to_file: Option<&Path>,
3892 max_depth: usize,
3893 ) -> Result<callgraph::TraceToSymbolResult> {
3894 self.inner
3895 .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3896 }
3897}
3898
3899impl CallGraphRead for CallGraphStore {
3900 fn project_root(&self) -> &Path {
3901 CallGraphStore::project_root(self)
3902 }
3903 fn project_key(&self) -> &str {
3904 CallGraphStore::project_key(self)
3905 }
3906 fn sqlite_path(&self) -> &Path {
3907 CallGraphStore::sqlite_path(self)
3908 }
3909 fn is_current(&self) -> bool {
3910 CallGraphStore::is_current(self)
3911 }
3912 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3913 CallGraphStore::edge_snapshot(self)
3914 }
3915 fn indexed_file_count(&self) -> Result<usize> {
3916 CallGraphStore::indexed_file_count(self)
3917 }
3918 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3919 CallGraphStore::node_for(self, file_rel, symbol)
3920 }
3921 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3922 CallGraphStore::nodes_for(self, file_rel, symbol)
3923 }
3924 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3925 CallGraphStore::nodes_matching(self, symbol)
3926 }
3927 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3928 CallGraphStore::direct_callers_of(self, file_rel, symbol)
3929 }
3930 fn direct_callers_for_symbols(
3931 &self,
3932 targets: &[(String, String)],
3933 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3934 CallGraphStore::direct_callers_for_symbols(self, targets)
3935 }
3936 fn direct_caller_counts_of(
3937 &self,
3938 targets: &[(String, String)],
3939 ) -> Result<HashMap<(String, String), usize>> {
3940 CallGraphStore::direct_caller_counts_of(self, targets)
3941 }
3942 fn callers_of(
3943 &self,
3944 file_rel: &Path,
3945 symbol: &str,
3946 depth: usize,
3947 ) -> Result<StoreCallersResult> {
3948 CallGraphStore::callers_of(self, file_rel, symbol, depth)
3949 }
3950 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3951 CallGraphStore::impact_of(self, file_rel, symbol, depth)
3952 }
3953 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3954 CallGraphStore::outgoing_calls_of(self, node)
3955 }
3956 fn outgoing_calls_for_symbols(
3957 &self,
3958 sources: &[(String, String)],
3959 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3960 CallGraphStore::outgoing_calls_for_symbols(self, sources)
3961 }
3962 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3963 CallGraphStore::resolved_self_calls_of(self, node)
3964 }
3965 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3966 CallGraphStore::unresolved_calls_of(self, node)
3967 }
3968 fn call_tree(
3969 &self,
3970 file_rel: &Path,
3971 symbol: &str,
3972 depth: usize,
3973 ) -> Result<callgraph::CallTreeNode> {
3974 CallGraphStore::call_tree(self, file_rel, symbol, depth)
3975 }
3976 fn trace_to(
3977 &self,
3978 file_rel: &Path,
3979 symbol: &str,
3980 max_depth: usize,
3981 ) -> Result<callgraph::TraceToResult> {
3982 CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
3983 }
3984 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3985 CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
3986 }
3987 fn trace_to_symbol(
3988 &self,
3989 file_rel: &Path,
3990 symbol: &str,
3991 to_symbol: &str,
3992 to_file: Option<&Path>,
3993 max_depth: usize,
3994 ) -> Result<callgraph::TraceToSymbolResult> {
3995 CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
3996 }
3997}
3998
3999impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
4000 fn project_root(&self) -> &Path {
4001 (**self).project_root()
4002 }
4003 fn project_key(&self) -> &str {
4004 (**self).project_key()
4005 }
4006 fn sqlite_path(&self) -> &Path {
4007 (**self).sqlite_path()
4008 }
4009 fn is_current(&self) -> bool {
4010 (**self).is_current()
4011 }
4012 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4013 (**self).edge_snapshot()
4014 }
4015 fn indexed_file_count(&self) -> Result<usize> {
4016 (**self).indexed_file_count()
4017 }
4018 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4019 (**self).node_for(file_rel, symbol)
4020 }
4021 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4022 (**self).nodes_for(file_rel, symbol)
4023 }
4024 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4025 (**self).nodes_matching(symbol)
4026 }
4027 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4028 (**self).direct_callers_of(file_rel, symbol)
4029 }
4030 fn direct_callers_for_symbols(
4031 &self,
4032 targets: &[(String, String)],
4033 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4034 (**self).direct_callers_for_symbols(targets)
4035 }
4036 fn direct_caller_counts_of(
4037 &self,
4038 targets: &[(String, String)],
4039 ) -> Result<HashMap<(String, String), usize>> {
4040 (**self).direct_caller_counts_of(targets)
4041 }
4042 fn callers_of(
4043 &self,
4044 file_rel: &Path,
4045 symbol: &str,
4046 depth: usize,
4047 ) -> Result<StoreCallersResult> {
4048 (**self).callers_of(file_rel, symbol, depth)
4049 }
4050 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4051 (**self).impact_of(file_rel, symbol, depth)
4052 }
4053 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4054 (**self).outgoing_calls_of(node)
4055 }
4056 fn outgoing_calls_for_symbols(
4057 &self,
4058 sources: &[(String, String)],
4059 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4060 (**self).outgoing_calls_for_symbols(sources)
4061 }
4062 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4063 (**self).resolved_self_calls_of(node)
4064 }
4065 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4066 (**self).unresolved_calls_of(node)
4067 }
4068 fn call_tree(
4069 &self,
4070 file_rel: &Path,
4071 symbol: &str,
4072 depth: usize,
4073 ) -> Result<callgraph::CallTreeNode> {
4074 (**self).call_tree(file_rel, symbol, depth)
4075 }
4076 fn trace_to(
4077 &self,
4078 file_rel: &Path,
4079 symbol: &str,
4080 max_depth: usize,
4081 ) -> Result<callgraph::TraceToResult> {
4082 (**self).trace_to(file_rel, symbol, max_depth)
4083 }
4084 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4085 (**self).trace_to_symbol_candidates(to_symbol)
4086 }
4087 fn trace_to_symbol(
4088 &self,
4089 file_rel: &Path,
4090 symbol: &str,
4091 to_symbol: &str,
4092 to_file: Option<&Path>,
4093 max_depth: usize,
4094 ) -> Result<callgraph::TraceToSymbolResult> {
4095 (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4096 }
4097}
4098
4099impl CallGraphRead for ReadonlyCallGraphStore {
4100 fn project_root(&self) -> &Path {
4101 self.project_root()
4102 }
4103 fn project_key(&self) -> &str {
4104 self.project_key()
4105 }
4106 fn sqlite_path(&self) -> &Path {
4107 self.sqlite_path()
4108 }
4109 fn is_current(&self) -> bool {
4110 self.is_current()
4111 }
4112 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4113 self.edge_snapshot()
4114 }
4115 fn indexed_file_count(&self) -> Result<usize> {
4116 self.indexed_file_count()
4117 }
4118 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4119 self.node_for(file_rel, symbol)
4120 }
4121 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4122 self.nodes_for(file_rel, symbol)
4123 }
4124 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4125 self.nodes_matching(symbol)
4126 }
4127 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4128 self.direct_callers_of(file_rel, symbol)
4129 }
4130 fn direct_callers_for_symbols(
4131 &self,
4132 targets: &[(String, String)],
4133 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4134 self.direct_callers_for_symbols(targets)
4135 }
4136 fn direct_caller_counts_of(
4137 &self,
4138 targets: &[(String, String)],
4139 ) -> Result<HashMap<(String, String), usize>> {
4140 self.direct_caller_counts_of(targets)
4141 }
4142 fn callers_of(
4143 &self,
4144 file_rel: &Path,
4145 symbol: &str,
4146 depth: usize,
4147 ) -> Result<StoreCallersResult> {
4148 self.callers_of(file_rel, symbol, depth)
4149 }
4150 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4151 self.impact_of(file_rel, symbol, depth)
4152 }
4153 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4154 self.outgoing_calls_of(node)
4155 }
4156 fn outgoing_calls_for_symbols(
4157 &self,
4158 sources: &[(String, String)],
4159 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4160 self.outgoing_calls_for_symbols(sources)
4161 }
4162 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4163 self.resolved_self_calls_of(node)
4164 }
4165 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4166 self.unresolved_calls_of(node)
4167 }
4168 fn call_tree(
4169 &self,
4170 file_rel: &Path,
4171 symbol: &str,
4172 depth: usize,
4173 ) -> Result<callgraph::CallTreeNode> {
4174 self.call_tree(file_rel, symbol, depth)
4175 }
4176 fn trace_to(
4177 &self,
4178 file_rel: &Path,
4179 symbol: &str,
4180 max_depth: usize,
4181 ) -> Result<callgraph::TraceToResult> {
4182 self.trace_to(file_rel, symbol, max_depth)
4183 }
4184 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4185 self.trace_to_symbol_candidates(to_symbol)
4186 }
4187 fn trace_to_symbol(
4188 &self,
4189 file_rel: &Path,
4190 symbol: &str,
4191 to_symbol: &str,
4192 to_file: Option<&Path>,
4193 max_depth: usize,
4194 ) -> Result<callgraph::TraceToSymbolResult> {
4195 self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4196 }
4197}
4198
4199fn indexed_file_count(conn: &Connection) -> Result<usize> {
4200 let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
4201 Ok(count.max(0) as usize)
4202}
4203
4204fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
4205 let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
4206 match candidates.as_slice() {
4207 [candidate] => Ok(candidate.clone()),
4208 [] => Err(AftError::SymbolNotFound {
4209 name: symbol.to_string(),
4210 file: rel_path.to_string(),
4211 }
4212 .into()),
4213 _ => Err(AftError::AmbiguousSymbol {
4214 name: symbol.to_string(),
4215 candidates: candidates
4216 .iter()
4217 .map(|candidate| candidate.symbol.clone())
4218 .collect(),
4219 }
4220 .into()),
4221 }
4222}
4223
4224fn nodes_for_file_matching_symbol(
4225 conn: &Connection,
4226 rel_path: &str,
4227 symbol: &str,
4228) -> Result<Vec<StoreNode>> {
4229 let qualified_query = symbol.contains("::");
4230 let sql = if qualified_query {
4231 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4232 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4233 FROM nodes n JOIN files f ON f.path = n.file_path
4234 WHERE n.file_path = ?1 AND n.scoped_name = ?2
4235 ORDER BY n.scoped_name, n.start_line, n.start_col"
4236 } else {
4237 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4238 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4239 FROM nodes n JOIN files f ON f.path = n.file_path
4240 WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
4241 ORDER BY n.scoped_name, n.start_line, n.start_col"
4242 };
4243 let mut stmt = conn.prepare(sql)?;
4244 let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
4245 rows.collect::<std::result::Result<Vec<_>, _>>()
4246 .map_err(Into::into)
4247}
4248
4249fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
4250 let qualified_query = symbol.contains("::");
4251 let sql = if qualified_query {
4252 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4253 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4254 FROM nodes n JOIN files f ON f.path = n.file_path
4255 WHERE n.scoped_name = ?1
4256 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4257 } else {
4258 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4259 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4260 FROM nodes n JOIN files f ON f.path = n.file_path
4261 WHERE n.scoped_name = ?1 OR n.name = ?1
4262 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4263 };
4264 let mut stmt = conn.prepare(sql)?;
4265 let rows = stmt.query_map(params![symbol], store_node_from_row)?;
4266 rows.collect::<std::result::Result<Vec<_>, _>>()
4267 .map_err(Into::into)
4268}
4269
4270fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
4271 store_node_from_row_at(row, 0)
4272}
4273
4274fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
4275 let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
4276 let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
4277 let lang_label_value: String = row.get(offset + 10)?;
4278 Ok(StoreNode {
4279 node_id: row.get(offset)?,
4280 file: row.get(offset + 1)?,
4281 symbol: row.get(offset + 2)?,
4282 name: row.get(offset + 3)?,
4283 kind: row.get(offset + 4)?,
4284 line: start_line.saturating_add(1),
4285 end_line: end_line.saturating_add(1),
4286 signature: row.get(offset + 7)?,
4287 exported: row.get::<_, i64>(offset + 8)? != 0,
4288 is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
4289 lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
4290 })
4291}
4292
4293fn optional_store_node_from_row_at(
4294 row: &rusqlite::Row<'_>,
4295 offset: usize,
4296) -> rusqlite::Result<Option<StoreNode>> {
4297 if row.get::<_, Option<String>>(offset)?.is_some() {
4298 store_node_from_row_at(row, offset).map(Some)
4299 } else {
4300 Ok(None)
4301 }
4302}
4303
4304#[allow(clippy::too_many_arguments)]
4305fn collect_callers_recursive(
4306 conn: &Connection,
4307 file: &str,
4308 symbol: &str,
4309 max_depth: usize,
4310 current_depth: usize,
4311 visited: &mut HashSet<(String, String)>,
4312 result: &mut Vec<StoreCallSite>,
4313 depth_limited: &mut bool,
4314 truncated: &mut usize,
4315) -> Result<()> {
4316 if current_depth >= max_depth {
4317 let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
4318 if omitted > 0 {
4319 *depth_limited = true;
4320 *truncated += omitted;
4321 }
4322 return Ok(());
4323 }
4324
4325 if !visited.insert((file.to_string(), symbol.to_string())) {
4326 return Ok(());
4327 }
4328
4329 let sites = direct_callers_for_tuple(conn, file, symbol)?;
4330 for site in sites {
4331 result.push(site.clone());
4332 if current_depth + 1 < max_depth {
4333 collect_callers_recursive(
4334 conn,
4335 &site.caller.file,
4336 &site.caller.symbol,
4337 max_depth,
4338 current_depth + 1,
4339 visited,
4340 result,
4341 depth_limited,
4342 truncated,
4343 )?;
4344 } else {
4345 let omitted =
4346 direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
4347 if omitted > 0 {
4348 *depth_limited = true;
4349 *truncated += omitted;
4350 }
4351 }
4352 }
4353 Ok(())
4354}
4355
4356const DIRECT_CALLER_BATCH_SIZE: usize = 499;
4358
4359fn direct_caller_counts_for_tuples(
4360 conn: &Connection,
4361 targets: &[(String, String)],
4362) -> Result<HashMap<(String, String), usize>> {
4363 let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
4364 let mut counts = unique_targets
4365 .iter()
4366 .cloned()
4367 .map(|target| (target, 0usize))
4368 .collect::<HashMap<_, _>>();
4369
4370 let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
4371 for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
4372 let requested_values = (0..chunk.len())
4373 .map(|_| "(?, ?)")
4374 .collect::<Vec<_>>()
4375 .join(", ");
4376 let sql = format!(
4377 "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
4378 deduped AS (
4379 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
4380 FROM requested requested
4381 JOIN edges e
4382 ON e.target_file = requested.target_file
4383 AND e.target_symbol = requested.target_symbol
4384 AND e.kind = 'call'
4385 JOIN refs r ON r.ref_id = e.ref_id
4386 JOIN nodes src ON src.id = e.source_node
4387 JOIN files src_file ON src_file.path = src.file_path
4388 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
4389 )
4390 SELECT target_file, target_symbol, COUNT(*)
4391 FROM deduped
4392 GROUP BY target_file, target_symbol"
4393 );
4394 let bindings = chunk
4395 .iter()
4396 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4397 let mut stmt = conn.prepare(&sql)?;
4398 let rows = stmt.query_map(params_from_iter(bindings), |row| {
4399 Ok((
4400 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
4401 row.get::<_, i64>(2)?,
4402 ))
4403 })?;
4404 for row in rows {
4405 let (target, count) = row?;
4406 counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
4407 }
4408 }
4409
4410 Ok(counts)
4411}
4412
4413fn direct_caller_count_for_tuple(
4414 conn: &Connection,
4415 target_file: &str,
4416 target_symbol: &str,
4417) -> Result<usize> {
4418 let count: i64 = conn.query_row(
4419 "SELECT COUNT(*)
4420 FROM edges e
4421 JOIN refs r ON r.ref_id = e.ref_id
4422 JOIN nodes src ON src.id = e.source_node
4423 JOIN files src_file ON src_file.path = src.file_path
4424 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
4425 params![target_file, target_symbol],
4426 |row| row.get(0),
4427 )?;
4428 Ok(usize::try_from(count).unwrap_or(usize::MAX))
4429}
4430
4431fn direct_callers_for_tuple(
4432 conn: &Connection,
4433 target_file: &str,
4434 target_symbol: &str,
4435) -> Result<Vec<StoreCallSite>> {
4436 let mut stmt = conn.prepare(
4437 "SELECT e.target_file, e.target_symbol, e.line,
4438 r.byte_start, r.byte_end, r.status, e.provenance,
4439 src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
4440 src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
4441 src_file.lang,
4442 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4443 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4444 tgt_file.lang
4445 FROM edges e
4446 JOIN refs r ON r.ref_id = e.ref_id
4447 JOIN nodes src ON src.id = e.source_node
4448 JOIN files src_file ON src_file.path = src.file_path
4449 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4450 ON tgt.id = e.target_node
4451 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
4452 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
4453 )?;
4454 let rows = stmt.query_map(
4455 params![target_file, target_symbol],
4456 direct_call_site_from_row,
4457 )?;
4458 rows.collect::<std::result::Result<Vec<_>, _>>()
4459 .map_err(Into::into)
4460}
4461
4462fn direct_call_site_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreCallSite> {
4463 let caller = store_node_from_row_at(row, 7)?;
4464 let target = optional_store_node_from_row_at(row, 18)?;
4465 Ok(StoreCallSite {
4466 caller,
4467 target_file: row.get(0)?,
4468 target_symbol: row.get(1)?,
4469 target,
4470 line: row.get::<_, i64>(2)?.max(0) as u32,
4471 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4472 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4473 resolved: row.get::<_, String>(5)? == "resolved",
4474 provenance: row.get(6)?,
4475 })
4476}
4477
4478fn direct_callers_for_tuples(
4479 conn: &Connection,
4480 targets: &[(String, String)],
4481) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4482 let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
4483 let mut callers_by_target = unique_targets
4484 .iter()
4485 .cloned()
4486 .map(|target| (target, Vec::new()))
4487 .collect::<HashMap<_, _>>();
4488 let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
4489
4490 for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
4491 let requested_values = (0..chunk.len())
4492 .map(|_| "(?, ?)")
4493 .collect::<Vec<_>>()
4494 .join(", ");
4495 let sql = format!(
4496 "WITH requested(target_file, target_symbol) AS (VALUES {requested_values})
4497 SELECT e.target_file, e.target_symbol, e.line,
4498 r.byte_start, r.byte_end, r.status, e.provenance,
4499 src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
4500 src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
4501 src_file.lang,
4502 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4503 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4504 tgt_file.lang
4505 FROM requested requested
4506 JOIN edges e
4507 ON e.target_file = requested.target_file
4508 AND e.target_symbol = requested.target_symbol
4509 AND e.kind = 'call'
4510 JOIN refs r ON r.ref_id = e.ref_id
4511 JOIN nodes src ON src.id = e.source_node
4512 JOIN files src_file ON src_file.path = src.file_path
4513 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4514 ON tgt.id = e.target_node
4515 ORDER BY e.target_file, e.target_symbol, e.source_node,
4516 r.byte_start, r.line, r.ref_id"
4517 );
4518 let bindings = chunk
4519 .iter()
4520 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4521 let mut stmt = conn.prepare(&sql)?;
4522 let rows = stmt.query_map(params_from_iter(bindings), |row| {
4523 let call = direct_call_site_from_row(row)?;
4524 let target_key = (call.target_file.clone(), call.target_symbol.clone());
4525 Ok((target_key, call))
4526 })?;
4527 for row in rows {
4528 let (target, call) = row?;
4529 callers_by_target
4530 .get_mut(&target)
4531 .expect("batched caller row belongs to a requested target")
4532 .push(call);
4533 }
4534 }
4535
4536 Ok(callers_by_target)
4537}
4538
4539const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
4541const OUTGOING_NODE_BATCH_SIZE: usize = 999;
4543
4544fn outgoing_calls_for_symbol_tuples(
4545 conn: &Connection,
4546 sources: &[(String, String)],
4547) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4548 let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
4549 let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
4550 let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
4551 let source_nodes = unique_sources
4552 .iter()
4553 .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
4554 .cloned()
4555 .collect::<Vec<_>>();
4556 let source_nodes_by_id = source_nodes
4557 .iter()
4558 .cloned()
4559 .map(|node| (node.node_id.clone(), node))
4560 .collect::<HashMap<_, _>>();
4561 let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
4562
4563 for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
4564 let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
4565 let sql = format!(
4566 "SELECT e.source_node,
4567 e.target_file, e.target_symbol, e.line,
4568 r.byte_start, r.byte_end, r.status, e.provenance,
4569 CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
4570 tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4571 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4572 tgt_file.lang
4573 FROM edges e
4574 JOIN refs r ON r.ref_id = e.ref_id
4575 LEFT JOIN nodes tgt ON tgt.id = e.target_node
4576 LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
4577 WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
4578 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
4579 );
4580 let bindings = chunk.iter().map(|node| node.node_id.as_str());
4581 let mut stmt = conn.prepare(&sql)?;
4582 let rows = stmt.query_map(params_from_iter(bindings), |row| {
4583 let source_node_id = row.get::<_, String>(0)?;
4584 let caller = source_nodes_by_id
4585 .get(&source_node_id)
4586 .expect("batched outgoing row belongs to a requested source node")
4587 .clone();
4588 let target = optional_store_node_from_row_at(row, 8)?;
4589 Ok((
4590 source_node_id,
4591 StoreCallSite {
4592 caller,
4593 target_file: row.get(1)?,
4594 target_symbol: row.get(2)?,
4595 target,
4596 line: row.get::<_, i64>(3)?.max(0) as u32,
4597 byte_start: row.get::<_, i64>(4)?.max(0) as usize,
4598 byte_end: row.get::<_, i64>(5)?.max(0) as usize,
4599 resolved: row.get::<_, String>(6)? == "resolved",
4600 provenance: row.get(7)?,
4601 },
4602 ))
4603 })?;
4604 for row in rows {
4605 let (source_node_id, call) = row?;
4606 calls_by_node.entry(source_node_id).or_default().push(call);
4607 }
4608 }
4609
4610 let mut calls_by_source = HashMap::new();
4611 for source in &unique_sources {
4612 let mut calls = Vec::new();
4613 if let Some(nodes) = source_nodes_by_symbol.get(source) {
4614 for node in nodes {
4615 if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
4616 calls.extend(node_calls);
4617 }
4618 }
4619 }
4620 calls_by_source.insert(source.clone(), calls);
4621 }
4622
4623 let target_tuples = calls_by_source
4626 .values()
4627 .flatten()
4628 .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
4629 .collect::<Vec<_>>();
4630 let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
4631 for calls in calls_by_source.values_mut() {
4632 for call in calls {
4633 if let Some(target) = target_nodes
4634 .get(&(call.target_file.clone(), call.target_symbol.clone()))
4635 .and_then(|nodes| nodes.first())
4636 {
4637 call.target = Some(target.clone());
4638 }
4639 }
4640 }
4641
4642 Ok(calls_by_source)
4643}
4644
4645fn nodes_for_symbol_tuples(
4646 conn: &Connection,
4647 symbols: &[(String, String)],
4648) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
4649 let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
4650 let mut nodes_by_symbol = unique_symbols
4651 .iter()
4652 .cloned()
4653 .map(|symbol| (symbol, Vec::new()))
4654 .collect::<HashMap<_, _>>();
4655 let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
4656
4657 for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
4658 let requested_values = (0..chunk.len())
4659 .map(|_| "(?, ?)")
4660 .collect::<Vec<_>>()
4661 .join(", ");
4662 let sql = format!(
4663 "WITH requested(file, symbol) AS (VALUES {requested_values})
4664 SELECT requested.file, requested.symbol,
4665 node.id, node.file_path, node.scoped_name, node.name, node.kind,
4666 node.start_line, node.end_line, node.signature, node.exported,
4667 node.is_callgraph_entry_point, node_file.lang
4668 FROM requested
4669 JOIN nodes node INDEXED BY idx_nodes_file
4670 ON node.file_path = requested.file
4671 AND node.scoped_name = requested.symbol
4672 JOIN files node_file ON node_file.path = node.file_path
4673 ORDER BY requested.file, requested.symbol,
4674 node.scoped_name, node.start_line, node.end_line,
4675 node.start_col, node.range_ordinal"
4676 );
4677 let bindings = chunk
4678 .iter()
4679 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4680 let mut stmt = conn.prepare(&sql)?;
4681 let rows = stmt.query_map(params_from_iter(bindings), |row| {
4682 Ok((
4683 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
4684 store_node_from_row_at(row, 2)?,
4685 ))
4686 })?;
4687 for row in rows {
4688 let (symbol, node) = row?;
4689 nodes_by_symbol.entry(symbol).or_default().push(node);
4690 }
4691 }
4692
4693 Ok(nodes_by_symbol)
4694}
4695
4696fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4697 let mut stmt = conn.prepare(
4698 "SELECT e.target_file, e.target_symbol, e.line,
4699 r.byte_start, r.byte_end, r.status, e.provenance,
4700 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4701 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4702 tgt_file.lang
4703 FROM edges e
4704 JOIN refs r ON r.ref_id = e.ref_id
4705 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4706 ON tgt.id = e.target_node
4707 WHERE e.kind = 'call' AND e.source_node = ?1
4708 ORDER BY r.byte_start, r.line, r.ref_id",
4709 )?;
4710 let rows = stmt.query_map(params![node.node_id], |row| {
4711 let target = optional_store_node_from_row_at(row, 7)?;
4712 Ok(StoreCallSite {
4713 caller: node.clone(),
4714 target_file: row.get(0)?,
4715 target_symbol: row.get(1)?,
4716 target,
4717 line: row.get::<_, i64>(2)?.max(0) as u32,
4718 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4719 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4720 resolved: row.get::<_, String>(5)? == "resolved",
4721 provenance: row.get(6)?,
4722 })
4723 })?;
4724 rows.collect::<std::result::Result<Vec<_>, _>>()
4725 .map_err(Into::into)
4726}
4727
4728fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4729 let mut stmt = conn.prepare(
4730 "SELECT r.target_file, r.target_symbol, r.line,
4731 r.byte_start, r.byte_end, r.status, r.provenance,
4732 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4733 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4734 tgt_file.lang
4735 FROM refs r
4736 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4737 ON tgt.id = r.target_node
4738 WHERE r.caller_node = ?1
4739 AND r.kind = 'call'
4740 AND r.status <> 'unresolved'
4741 AND r.target_file = ?2
4742 AND r.target_symbol = ?3
4743 AND r.provenance = ?4
4744 AND NOT EXISTS (
4745 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
4746 )
4747 ORDER BY r.byte_start, r.line, r.ref_id",
4748 )?;
4749 let rows = stmt.query_map(
4750 params![
4751 &node.node_id,
4752 &node.file,
4753 &node.symbol,
4754 PROVENANCE_TREESITTER
4755 ],
4756 |row| {
4757 let target = optional_store_node_from_row_at(row, 7)?;
4758 Ok(StoreCallSite {
4759 caller: node.clone(),
4760 target_file: row.get(0)?,
4761 target_symbol: row.get(1)?,
4762 target,
4763 line: row.get::<_, i64>(2)?.max(0) as u32,
4764 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4765 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4766 resolved: row.get::<_, String>(5)? == "resolved",
4767 provenance: row.get(6)?,
4768 })
4769 },
4770 )?;
4771 rows.collect::<std::result::Result<Vec<_>, _>>()
4772 .map_err(Into::into)
4773}
4774
4775fn unresolved_calls_for_node(
4776 conn: &Connection,
4777 node: &StoreNode,
4778) -> Result<Vec<StoreUnresolvedCall>> {
4779 let mut stmt = conn.prepare(
4780 "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
4781 FROM refs
4782 WHERE caller_node = ?1
4783 AND kind = 'call'
4784 AND status = 'unresolved'
4785 AND NOT EXISTS (
4786 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4787 )
4788 ORDER BY byte_start, line, ref_id",
4789 )?;
4790 let rows = stmt.query_map(params![node.node_id], |row| {
4791 Ok(StoreUnresolvedCall {
4792 caller: node.clone(),
4793 symbol: row.get(0)?,
4794 full_ref: row.get(1)?,
4795 line: row.get::<_, i64>(2)?.max(0) as u32,
4796 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4797 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4798 })
4799 })?;
4800 rows.collect::<std::result::Result<Vec<_>, _>>()
4801 .map_err(Into::into)
4802}
4803
4804fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
4805 let mut calls = Vec::new();
4806 calls.extend(
4807 outgoing_calls_for_node(conn, node)?
4808 .into_iter()
4809 .map(StoreForwardCall::Resolved),
4810 );
4811 calls.extend(
4812 unresolved_calls_for_node(conn, node)?
4813 .into_iter()
4814 .map(StoreForwardCall::Unresolved),
4815 );
4816 calls.sort_by(|left, right| {
4817 left.byte_start()
4818 .cmp(&right.byte_start())
4819 .then(left.line().cmp(&right.line()))
4820 });
4821 Ok(calls)
4822}
4823
4824fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
4825 let resolved_count: i64 = conn.query_row(
4826 "SELECT COUNT(*)
4827 FROM edges e
4828 JOIN refs r ON r.ref_id = e.ref_id
4829 WHERE e.kind = 'call' AND e.source_node = ?1",
4830 params![&node.node_id],
4831 |row| row.get(0),
4832 )?;
4833 let unresolved_count: i64 = conn.query_row(
4834 "SELECT COUNT(*)
4835 FROM refs
4836 WHERE caller_node = ?1
4837 AND kind = 'call'
4838 AND status = 'unresolved'
4839 AND NOT EXISTS (
4840 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4841 )",
4842 params![&node.node_id],
4843 |row| row.get(0),
4844 )?;
4845 let total = resolved_count.saturating_add(unresolved_count);
4846 Ok(usize::try_from(total).unwrap_or(usize::MAX))
4847}
4848
4849fn call_tree_inner(
4850 conn: &Connection,
4851 node: &StoreNode,
4852 max_depth: usize,
4853 current_depth: usize,
4854 visited: &mut HashSet<(String, String)>,
4855) -> Result<callgraph::CallTreeNode> {
4856 let visit_key = (node.file.clone(), node.symbol.clone());
4857 if visited.contains(&visit_key) {
4858 return Ok(callgraph::CallTreeNode {
4859 name: node.symbol.clone(),
4860 file: node.file.clone(),
4861 line: node.line,
4862 signature: node.signature.clone(),
4863 resolved: true,
4864 children: Vec::new(),
4865 depth_limited: false,
4866 truncated: 0,
4867 });
4868 }
4869 visited.insert(visit_key.clone());
4870
4871 let mut children = Vec::new();
4872 let mut depth_limited = false;
4873 let mut truncated = 0usize;
4874
4875 if current_depth < max_depth {
4876 let calls = forward_calls_for_node(conn, node)?;
4877 for call in calls {
4878 match call {
4879 StoreForwardCall::Resolved(site) => {
4880 if let Some(target) = site.target {
4881 let child =
4882 call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
4883 depth_limited |= child.depth_limited;
4884 truncated += child.truncated;
4885 children.push(child);
4886 } else {
4887 children.push(callgraph::CallTreeNode {
4888 name: site.target_symbol,
4889 file: site.target_file,
4890 line: site.line,
4891 signature: None,
4892 resolved: false,
4893 children: Vec::new(),
4894 depth_limited: false,
4895 truncated: 0,
4896 });
4897 }
4898 }
4899 StoreForwardCall::Unresolved(call) => {
4900 children.push(callgraph::CallTreeNode {
4901 name: call.symbol,
4902 file: call.caller.file,
4903 line: call.line,
4904 signature: None,
4905 resolved: false,
4906 children: Vec::new(),
4907 depth_limited: false,
4908 truncated: 0,
4909 });
4910 }
4911 }
4912 }
4913 } else {
4914 truncated = forward_call_count_for_node(conn, node)?;
4915 depth_limited = truncated > 0;
4916 }
4917
4918 visited.remove(&visit_key);
4919 Ok(callgraph::CallTreeNode {
4920 name: node.symbol.clone(),
4921 file: node.file.clone(),
4922 line: node.line,
4923 signature: node.signature.clone(),
4924 resolved: true,
4925 children,
4926 depth_limited,
4927 truncated,
4928 })
4929}
4930
4931fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
4932 callgraph::TraceToSymbolHop {
4933 symbol: node.symbol.clone(),
4934 file: node.file.clone(),
4935 line: node.line,
4936 }
4937}
4938
4939fn trace_to_symbol_matches_target(
4940 node: &StoreNode,
4941 to_symbol: &str,
4942 to_file: Option<&str>,
4943) -> bool {
4944 if !symbol_query_matches(&node.symbol, to_symbol) {
4945 return false;
4946 }
4947 match to_file {
4948 Some(file) => node.file == file,
4949 None => true,
4950 }
4951}
4952
4953fn symbol_query_matches(symbol: &str, query: &str) -> bool {
4954 symbol == query || unqualified_name(symbol) == query
4955}
4956
4957fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
4958 let source = std::fs::read_to_string(path).ok()?;
4959 Some(source.lines().map(|line| line.trim().to_string()).collect())
4960}
4961
4962#[doc(hidden)]
4963pub fn live_callgraph_edge_snapshot(
4964 project_root: &Path,
4965 files: &[PathBuf],
4966) -> Result<BTreeSet<StoredEdge>> {
4967 let files = normalize_file_list(project_root, files)?;
4968 let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
4969 let mut file_data = Vec::new();
4970 for file in &files {
4971 let canon = canonicalize_path(file);
4972 let data = graph.build_file(&canon)?.clone();
4973 file_data.push((canon, data));
4974 }
4975
4976 let mut edges = BTreeSet::new();
4977 for (caller_file, data) in &file_data {
4978 for (caller_symbol, call_sites) in &data.calls_by_symbol {
4979 for call_site in call_sites {
4980 let resolution = graph.resolve_cross_file_edge(
4981 &call_site.full_callee,
4982 &call_site.callee_name,
4983 caller_file,
4984 &data.import_block,
4985 );
4986 let (target_file, target_symbol) = match resolution {
4987 EdgeResolution::Resolved { file, symbol } => (file, symbol),
4988 EdgeResolution::Unresolved { callee_name } => {
4989 if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
4990 continue;
4991 }
4992 let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
4993 data,
4994 caller_file,
4995 &callee_name,
4996 ) else {
4997 continue;
4998 };
4999 (caller_file.clone(), target_symbol)
5000 }
5001 };
5002 if target_file == *caller_file && target_symbol == *caller_symbol {
5003 continue;
5004 }
5005 edges.insert(StoredEdge {
5006 source_file: relative_path(project_root, caller_file),
5007 source_symbol: caller_symbol.clone(),
5008 target_file: relative_path(project_root, &target_file),
5009 target_symbol,
5010 kind: "call".to_string(),
5011 line: call_site.line,
5012 });
5013 }
5014 }
5015 }
5016 Ok(edges)
5017}
5018
5019fn rebuild_cooldown_records() -> &'static Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>
5020{
5021 SUCCESSFUL_REBUILDS.get_or_init(|| Mutex::new(HashMap::new()))
5022}
5023
5024fn rebuild_cooldown_key(callgraph_dir: &Path, project_key: &str) -> RebuildCooldownKey {
5025 RebuildCooldownKey {
5026 callgraph_dir: std::fs::canonicalize(callgraph_dir)
5027 .unwrap_or_else(|_| callgraph_dir.to_path_buf()),
5028 project_key: project_key.to_string(),
5029 }
5030}
5031
5032fn rebuild_cooldown_denial(
5033 callgraph_dir: &Path,
5034 project_key: &str,
5035 project_root: &Path,
5036 now: Instant,
5037) -> Option<(PathBuf, Duration)> {
5038 let key = rebuild_cooldown_key(callgraph_dir, project_key);
5039 let records = rebuild_cooldown_records()
5040 .lock()
5041 .unwrap_or_else(std::sync::PoisonError::into_inner);
5042 let record = records.get(&key)?;
5043 if record.project_root == project_root || !record.cross_root_cooldown_armed {
5044 return None;
5045 }
5046 let elapsed = now.saturating_duration_since(record.published_at);
5047 (elapsed < REBUILD_COOLDOWN).then(|| (record.project_root.clone(), REBUILD_COOLDOWN - elapsed))
5048}
5049
5050fn record_successful_rebuild(
5051 callgraph_dir: &Path,
5052 project_key: &str,
5053 project_root: &Path,
5054 published_at: Instant,
5055) {
5056 let key = rebuild_cooldown_key(callgraph_dir, project_key);
5057 let mut records = rebuild_cooldown_records()
5058 .lock()
5059 .unwrap_or_else(std::sync::PoisonError::into_inner);
5060 if records.len() >= 4_096 && !records.contains_key(&key) {
5061 if let Some(evict) = records.keys().next().cloned() {
5062 records.remove(&evict);
5063 }
5064 }
5065 let cross_root_cooldown_armed = records.get(&key).is_some_and(|previous| {
5066 previous.cross_root_cooldown_armed || previous.project_root != project_root
5067 });
5068 records.insert(
5069 key,
5070 RebuildCooldownRecord {
5071 project_root: project_root.to_path_buf(),
5072 published_at,
5073 cross_root_cooldown_armed,
5074 },
5075 );
5076}
5077
5078fn acquire_writer_lease(
5079 callgraph_dir: &Path,
5080 project_key: &str,
5081 project_root: &Path,
5082) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
5083 crate::root_cache::WriterLease::acquire_shared(
5084 crate::root_cache::RootCacheDomain::Callgraph,
5085 callgraph_dir,
5086 project_key,
5087 project_root,
5088 )
5089 .map_err(CallGraphStoreError::from)
5090}
5091
5092fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
5093 if lease.verify()? {
5094 Ok(())
5095 } else {
5096 Err(CallGraphStoreError::Unavailable(format!(
5097 "callgraph writer lease for key {} lost epoch {}; aborting write",
5098 lease.key(),
5099 lease.epoch()
5100 )))
5101 }
5102}
5103
5104fn legacy_migration_completion_line(
5105 project_key: &str,
5106 method: &str,
5107 legacy_bytes: u64,
5108 migrated_bytes: u64,
5109) -> String {
5110 format!(
5111 "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
5112 )
5113}
5114
5115fn log_legacy_migration_completion(
5116 project_key: &str,
5117 method: &str,
5118 legacy_bytes: u64,
5119 migrated_bytes: u64,
5120) {
5121 crate::slog_info!(
5122 "{}",
5123 legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
5124 );
5125}
5126
5127fn try_legacy_migration_or_fallback(
5128 callgraph_dir: &Path,
5129 project_root: &Path,
5130 project_key: &str,
5131 writer_lease: Arc<crate::root_cache::WriterLease>,
5132) -> Result<Option<CallGraphStore>> {
5133 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
5134 if partitions.is_empty() {
5135 return Ok(None);
5136 }
5137
5138 for partition in &partitions {
5139 if let Some(source) = newest_superseded_legacy_generation(partition)? {
5140 if !migration_disk_floor_allows(&source, callgraph_dir)? {
5141 return open_legacy_fallback_store(
5142 callgraph_dir,
5143 project_root,
5144 project_key,
5145 &partitions,
5146 );
5147 }
5148 match publish_generation_copy_migration(
5149 callgraph_dir,
5150 project_key,
5151 &source,
5152 Arc::clone(&writer_lease),
5153 ) {
5154 Ok(published) => {
5155 log_legacy_migration_completion(
5156 project_key,
5157 "generation_copy",
5158 source.source_bytes,
5159 published.migrated_bytes,
5160 );
5161 return CallGraphStore::open_generation(
5162 callgraph_dir,
5163 project_root.to_path_buf(),
5164 project_key.to_string(),
5165 published.generation,
5166 writer_lease,
5167 )
5168 .map(Some);
5169 }
5170 Err(error) => {
5171 crate::slog_warn!(
5172 "root-keyed callgraph generation-copy migration failed from {}: {}",
5173 source.sqlite_path.display(),
5174 error
5175 );
5176 return open_legacy_fallback_store(
5177 callgraph_dir,
5178 project_root,
5179 project_key,
5180 &partitions,
5181 );
5182 }
5183 }
5184 }
5185
5186 if let Some(source) = current_legacy_generation(partition)? {
5187 if !migration_disk_floor_allows(&source, callgraph_dir)? {
5188 return open_legacy_fallback_store(
5189 callgraph_dir,
5190 project_root,
5191 project_key,
5192 &partitions,
5193 );
5194 }
5195 match publish_backup_migration(
5196 callgraph_dir,
5197 project_key,
5198 &source,
5199 Arc::clone(&writer_lease),
5200 ) {
5201 Ok(published) => {
5202 log_legacy_migration_completion(
5203 project_key,
5204 "sqlite_backup",
5205 source.source_bytes,
5206 published.migrated_bytes,
5207 );
5208 return CallGraphStore::open_generation(
5209 callgraph_dir,
5210 project_root.to_path_buf(),
5211 project_key.to_string(),
5212 published.generation,
5213 writer_lease,
5214 )
5215 .map(Some);
5216 }
5217 Err(error) => {
5218 crate::slog_warn!(
5219 "root-keyed callgraph backup migration failed from {}: {}",
5220 source.sqlite_path.display(),
5221 error
5222 );
5223 return open_legacy_fallback_store(
5224 callgraph_dir,
5225 project_root,
5226 project_key,
5227 &partitions,
5228 );
5229 }
5230 }
5231 }
5232 }
5233
5234 open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
5235}
5236
5237fn open_legacy_fallback_store(
5238 callgraph_dir: &Path,
5239 project_root: &Path,
5240 project_key: &str,
5241 partitions: &[LegacyCallgraphPartition],
5242) -> Result<Option<CallGraphStore>> {
5243 let Some(target) = first_ready_legacy_target(partitions)? else {
5244 return Ok(None);
5245 };
5246 crate::slog_warn!(
5247 "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
5248 target.partition.harness,
5249 target.sqlite_path.display()
5250 );
5251 let conn = open_readonly_connection(&target.sqlite_path)?;
5252 if !database_ready(&conn).unwrap_or(false) {
5253 return Ok(None);
5254 }
5255 let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
5256 let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
5257 Ok(Some(CallGraphStore::from_connection(
5258 project_root.to_path_buf(),
5259 project_key.to_string(),
5260 target.sqlite_path,
5261 callgraph_dir.to_path_buf(),
5262 true,
5263 target.generation,
5264 None,
5265 Some(read_marker),
5266 conn,
5267 )))
5268}
5269
5270fn migration_disk_floor_allows(
5271 source: &LegacyCallgraphTarget,
5272 callgraph_dir: &Path,
5273) -> Result<bool> {
5274 let available = migration_available_disk(callgraph_dir)?;
5275 let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
5276 source.source_bytes,
5277 available,
5278 );
5279 if decision.should_skip_copy() {
5280 crate::slog_warn!(
5281 "{}",
5282 decision.warning_message(&source.sqlite_path, callgraph_dir)
5283 );
5284 return Ok(false);
5285 }
5286 Ok(true)
5287}
5288
5289fn migration_available_disk(path: &Path) -> Result<u64> {
5290 if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
5291 return Ok(bytes);
5292 }
5293 crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
5294}
5295
5296fn legacy_callgraph_partitions(
5297 callgraph_dir: &Path,
5298 project_key: &str,
5299) -> Result<Vec<LegacyCallgraphPartition>> {
5300 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5301 return Ok(Vec::new());
5302 };
5303 let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
5304 let mut partitions = inventory
5305 .into_iter()
5306 .filter(|entry| {
5307 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
5308 && entry.key == project_key
5309 })
5310 .map(|entry| {
5311 let dir = if entry.path.is_dir() {
5312 entry.path.clone()
5313 } else {
5314 entry
5315 .path
5316 .parent()
5317 .map(Path::to_path_buf)
5318 .unwrap_or_else(|| entry.path.clone())
5319 };
5320 LegacyCallgraphPartition {
5321 harness: entry.harness,
5322 dir,
5323 key: entry.key,
5324 bytes: entry.bytes,
5325 freshness: entry.callgraph_pointer_mtime,
5326 }
5327 })
5328 .collect::<Vec<_>>();
5329 partitions.sort_by(|left, right| {
5330 right
5331 .freshness
5332 .cmp(&left.freshness)
5333 .then_with(|| right.bytes.cmp(&left.bytes))
5334 .then_with(|| left.harness.cmp(&right.harness))
5335 });
5336 Ok(partitions)
5337}
5338
5339fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
5340 let domain_dir = callgraph_dir.parent()?;
5341 if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
5342 return None;
5343 }
5344 domain_dir.parent().map(Path::to_path_buf)
5345}
5346
5347pub(crate) fn all_legacy_partitions_migrated_for_keys(
5348 callgraph_dir: &Path,
5349 configured_keys: &BTreeSet<String>,
5350) -> Result<bool> {
5351 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5352 return Ok(false);
5353 };
5354 let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
5355 .into_iter()
5356 .filter(|entry| {
5357 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
5358 && configured_keys.contains(&entry.key)
5359 })
5360 .map(|entry| entry.key)
5361 .collect::<BTreeSet<_>>();
5362 if legacy_keys.is_empty() {
5363 return Ok(false);
5364 }
5365
5366 for key in legacy_keys {
5367 let migrated_dir = storage_root.join("callgraph").join(&key);
5368 let Some(generation) = read_pointer(&migrated_dir, &key) else {
5369 return Ok(false);
5370 };
5371 if !migration_generation_requires_manifest(&generation)
5372 || !migration_manifest_valid(&migrated_dir, &generation)
5373 {
5374 return Ok(false);
5375 }
5376 }
5377 Ok(true)
5378}
5379
5380fn newest_superseded_legacy_generation(
5381 partition: &LegacyCallgraphPartition,
5382) -> Result<Option<LegacyCallgraphTarget>> {
5383 let Some(current) = read_pointer(&partition.dir, &partition.key) else {
5384 return Ok(None);
5385 };
5386 let prefix = format!("{}.g", partition.key);
5387 let Ok(entries) = std::fs::read_dir(&partition.dir) else {
5388 return Ok(None);
5389 };
5390 let mut candidates = Vec::new();
5391 for entry in entries.flatten() {
5392 let name = entry.file_name().to_string_lossy().to_string();
5393 if name == current
5394 || name.contains(".tmp.")
5395 || !name.starts_with(&prefix)
5396 || !name.ends_with(".sqlite")
5397 {
5398 continue;
5399 }
5400 let path = entry.path();
5401 if !db_path_ready(&path) {
5402 continue;
5403 }
5404 let modified = entry
5405 .metadata()
5406 .and_then(|metadata| metadata.modified())
5407 .unwrap_or(SystemTime::UNIX_EPOCH);
5408 candidates.push((modified, path, name));
5409 }
5410 candidates.sort_by(|left, right| right.0.cmp(&left.0));
5411 let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
5412 return Ok(None);
5413 };
5414 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5415 Ok(Some(LegacyCallgraphTarget {
5416 partition: partition.clone(),
5417 sqlite_path,
5418 generation: Some(generation),
5419 source_bytes,
5420 source_blake3: String::new(),
5421 }))
5422}
5423
5424fn current_legacy_generation(
5425 partition: &LegacyCallgraphPartition,
5426) -> Result<Option<LegacyCallgraphTarget>> {
5427 let Some(target) = ready_legacy_target(partition)? else {
5428 return Ok(None);
5429 };
5430 let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
5431 if has_superseded {
5432 return Ok(None);
5433 }
5434 Ok(Some(target))
5435}
5436
5437fn freshest_legacy_fallback_target(
5438 callgraph_dir: &Path,
5439 project_key: &str,
5440) -> Result<Option<LegacyCallgraphTarget>> {
5441 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
5442 first_ready_legacy_target(&partitions)
5443}
5444
5445fn first_ready_legacy_target(
5446 partitions: &[LegacyCallgraphPartition],
5447) -> Result<Option<LegacyCallgraphTarget>> {
5448 for partition in partitions {
5449 if let Some(target) = ready_legacy_target(partition)? {
5450 return Ok(Some(target));
5451 }
5452 }
5453 Ok(None)
5454}
5455
5456fn ready_legacy_target(
5457 partition: &LegacyCallgraphPartition,
5458) -> Result<Option<LegacyCallgraphTarget>> {
5459 if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
5460 let sqlite_path = partition.dir.join(&generation);
5461 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
5462 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5463 return Ok(Some(LegacyCallgraphTarget {
5464 partition: partition.clone(),
5465 sqlite_path,
5466 generation: Some(generation),
5467 source_bytes,
5468 source_blake3: String::new(),
5469 }));
5470 }
5471 }
5472
5473 let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
5474 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
5475 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5476 return Ok(Some(LegacyCallgraphTarget {
5477 partition: partition.clone(),
5478 sqlite_path,
5479 generation: None,
5480 source_bytes,
5481 source_blake3: String::new(),
5482 }));
5483 }
5484 Ok(None)
5485}
5486
5487fn publish_generation_copy_migration(
5488 callgraph_dir: &Path,
5489 project_key: &str,
5490 source: &LegacyCallgraphTarget,
5491 writer_lease: Arc<crate::root_cache::WriterLease>,
5492) -> Result<PublishedLegacyMigration> {
5493 let generation = migration_generation_file_name(project_key, "copy");
5494 let temp_path = migration_temp_path(callgraph_dir, &generation);
5495 remove_sqlite_file_set(&temp_path);
5496 copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
5497 fail_after_temp_copy_for_test()?;
5498
5499 let mut source = source.clone();
5500 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
5501 source.source_blake3 = fingerprint.blake3;
5502 let generation = publish_migrated_generation(
5503 callgraph_dir,
5504 project_key,
5505 &generation,
5506 &temp_path,
5507 &source,
5508 fingerprint.bytes,
5509 writer_lease,
5510 "generation_copy",
5511 )?;
5512 Ok(PublishedLegacyMigration {
5513 generation,
5514 migrated_bytes: fingerprint.bytes,
5515 })
5516}
5517
5518fn publish_backup_migration(
5519 callgraph_dir: &Path,
5520 project_key: &str,
5521 source: &LegacyCallgraphTarget,
5522 writer_lease: Arc<crate::root_cache::WriterLease>,
5523) -> Result<PublishedLegacyMigration> {
5524 if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
5525 return Err(CallGraphStoreError::Unavailable(
5526 "legacy callgraph backup migration budget exhausted by test seam".to_string(),
5527 ));
5528 }
5529
5530 let generation = migration_generation_file_name(project_key, "backup");
5531 let temp_path = migration_temp_path(callgraph_dir, &generation);
5532 remove_sqlite_file_set(&temp_path);
5533
5534 let source_conn = open_readonly_connection(&source.sqlite_path)?;
5535 let mut destination = Connection::open(&temp_path)?;
5536 destination.busy_timeout(Duration::from_secs(5))?;
5537 let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
5538 let started = Instant::now();
5539 let mut retries = 0;
5540 loop {
5541 match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
5542 rusqlite::backup::StepResult::Done => break,
5543 rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
5544 rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
5545 retries += 1;
5546 if retries > MIGRATION_BACKUP_RETRY_BUDGET
5547 || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
5548 {
5549 return Err(CallGraphStoreError::Unavailable(format!(
5550 "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
5551 )));
5552 }
5553 std::thread::sleep(Duration::from_millis(20));
5554 }
5555 _ => {
5556 return Err(CallGraphStoreError::Unavailable(
5557 "legacy callgraph backup returned an unknown step result".to_string(),
5558 ));
5559 }
5560 }
5561 }
5562 drop(backup);
5563
5564 let integrity: String =
5565 destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
5566 if integrity != "ok" {
5567 return Err(CallGraphStoreError::Unavailable(format!(
5568 "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
5569 )));
5570 }
5571 if !database_ready(&destination)? {
5572 return Err(CallGraphStoreError::Unavailable(
5573 "legacy callgraph backup produced a database without ready metadata".to_string(),
5574 ));
5575 }
5576 destination.execute_batch("PRAGMA optimize;")?;
5577 drop(destination);
5578 sync_file(&temp_path)?;
5579 fail_after_temp_copy_for_test()?;
5580
5581 let mut source = source.clone();
5582 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
5583 source.source_blake3 = fingerprint.blake3;
5584 let generation = publish_migrated_generation(
5585 callgraph_dir,
5586 project_key,
5587 &generation,
5588 &temp_path,
5589 &source,
5590 fingerprint.bytes,
5591 writer_lease,
5592 "sqlite_backup",
5593 )?;
5594 Ok(PublishedLegacyMigration {
5595 generation,
5596 migrated_bytes: fingerprint.bytes,
5597 })
5598}
5599
5600fn publish_migrated_generation(
5601 callgraph_dir: &Path,
5602 project_key: &str,
5603 generation: &str,
5604 temp_path: &Path,
5605 source: &LegacyCallgraphTarget,
5606 migrated_bytes: u64,
5607 writer_lease: Arc<crate::root_cache::WriterLease>,
5608 method: &str,
5609) -> Result<String> {
5610 let gen_path = callgraph_dir.join(generation);
5611 checkpoint_sqlite_before_publication(temp_path);
5612 let publication = publish_if_current(|| {
5613 verify_writer_lease(&writer_lease)?;
5614 remove_sqlite_file_set(&gen_path);
5615 rename_sqlite_file_set(temp_path, &gen_path)?;
5616 crate::fs_lock::sync_parent(&gen_path);
5617
5618 verify_writer_lease(&writer_lease)?;
5619 publish_pointer(callgraph_dir, project_key, generation)?;
5620 write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
5621 Ok(generation.to_string())
5622 });
5623 if matches!(publication, Err(CallGraphStoreError::Superseded)) {
5624 remove_sqlite_file_set(temp_path);
5625 }
5626 publication
5627}
5628
5629fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
5630 if let Some(parent) = destination.parent() {
5631 std::fs::create_dir_all(parent)?;
5632 }
5633 for suffix in SQLITE_FILE_SET_SUFFIXES {
5634 let source_path = sqlite_file_set_path(source, suffix);
5635 if !source_path.is_file() {
5636 continue;
5637 }
5638 let destination_path = sqlite_file_set_path(destination, suffix);
5639 std::fs::copy(&source_path, &destination_path)?;
5640 sync_file(&destination_path)?;
5641 }
5642 Ok(())
5643}
5644
5645fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
5646 for suffix in SQLITE_FILE_SET_SUFFIXES {
5647 let source_path = sqlite_file_set_path(source, suffix);
5648 if !source_path.exists() {
5649 continue;
5650 }
5651 let destination_path = sqlite_file_set_path(destination, suffix);
5652 if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
5653 let _ = std::fs::remove_file(&source_path);
5654 return Err(error.into());
5655 }
5656 }
5657 Ok(())
5658}
5659
5660fn sqlite_file_set_size(path: &Path) -> Result<u64> {
5661 let mut bytes = 0_u64;
5662 for suffix in SQLITE_FILE_SET_SUFFIXES {
5663 let member = sqlite_file_set_path(path, suffix);
5664 if !member.is_file() {
5665 continue;
5666 }
5667 bytes = bytes.saturating_add(member.metadata()?.len());
5668 }
5669 Ok(bytes)
5670}
5671
5672fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
5673 let mut hasher = blake3::Hasher::new();
5674 let mut bytes = 0_u64;
5675 let mut buffer = [0_u8; 64 * 1024];
5676 for suffix in SQLITE_FILE_SET_SUFFIXES {
5677 let member = sqlite_file_set_path(path, suffix);
5678 if !member.is_file() {
5679 continue;
5680 }
5681 hasher.update(suffix.as_bytes());
5682 let mut file = std::fs::File::open(&member)?;
5683 loop {
5684 let read = file.read(&mut buffer)?;
5685 if read == 0 {
5686 break;
5687 }
5688 bytes = bytes.saturating_add(read as u64);
5689 hasher.update(&buffer[..read]);
5690 }
5691 }
5692 Ok(SourceFingerprint {
5693 bytes,
5694 blake3: hash_to_hex(hasher.finalize()),
5695 })
5696}
5697
5698fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
5699 if suffix.is_empty() {
5700 path.to_path_buf()
5701 } else {
5702 PathBuf::from(format!("{}{suffix}", path.display()))
5703 }
5704}
5705
5706fn sync_file(path: &Path) -> Result<()> {
5707 let file = std::fs::OpenOptions::new()
5708 .read(true)
5709 .write(true)
5710 .open(path)?;
5711 file.sync_all()?;
5712 Ok(())
5713}
5714
5715fn fail_after_temp_copy_for_test() -> Result<()> {
5716 if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
5717 return Err(CallGraphStoreError::Unavailable(
5718 "legacy callgraph migration stopped after temp copy by test seam".to_string(),
5719 ));
5720 }
5721 Ok(())
5722}
5723
5724fn migration_generation_file_name(project_key: &str, method: &str) -> String {
5725 format!(
5726 "{project_key}.g{}.{}{}{}.sqlite",
5727 now_nanos(),
5728 std::process::id(),
5729 MIGRATION_GENERATION_TAG,
5730 method
5731 )
5732}
5733
5734fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
5735 callgraph_dir.join(format!(
5736 "{generation}.tmp.{}.{}",
5737 std::process::id(),
5738 now_nanos()
5739 ))
5740}
5741
5742fn write_migration_manifest(
5743 callgraph_dir: &Path,
5744 generation: &str,
5745 source: &LegacyCallgraphTarget,
5746 migrated_bytes: u64,
5747 method: &str,
5748) -> Result<()> {
5749 let manifest_path = migration_manifest_path(callgraph_dir, generation);
5750 let temp_path = manifest_path.with_extension(format!(
5751 "migration.json.tmp.{}.{}",
5752 std::process::id(),
5753 now_nanos()
5754 ));
5755 let manifest = serde_json::json!({
5756 "version": MIGRATION_MANIFEST_VERSION,
5757 "method": method,
5758 "target_generation": generation,
5759 "source_harness": source.partition.harness,
5760 "source_path": source.sqlite_path.display().to_string(),
5761 "source_generation": source.generation,
5762 "source_bytes": source.source_bytes,
5763 "source_blake3": source.source_blake3,
5764 "migrated_bytes": migrated_bytes,
5765 });
5766 {
5767 use std::io::Write as _;
5768 let mut file = std::fs::File::create(&temp_path)?;
5769 file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
5770 file.write_all(b"\n")?;
5771 file.sync_all()?;
5772 }
5773 if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
5774 let _ = std::fs::remove_file(&temp_path);
5775 return Err(error.into());
5776 }
5777 crate::fs_lock::sync_parent(&manifest_path);
5778 Ok(())
5779}
5780
5781fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
5782 callgraph_dir.join(format!("{generation}.migration.json"))
5783}
5784
5785fn migration_generation_requires_manifest(generation: &str) -> bool {
5786 generation.contains(MIGRATION_GENERATION_TAG)
5787}
5788
5789fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
5790 if !migration_generation_requires_manifest(generation) {
5791 return true;
5792 }
5793 let path = migration_manifest_path(callgraph_dir, generation);
5794 let Ok(bytes) = std::fs::read(path) else {
5795 return false;
5796 };
5797 let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
5798 return false;
5799 };
5800 value.get("version").and_then(serde_json::Value::as_u64)
5801 == Some(MIGRATION_MANIFEST_VERSION as u64)
5802 && value
5803 .get("target_generation")
5804 .and_then(serde_json::Value::as_str)
5805 == Some(generation)
5806 && value
5807 .get("source_bytes")
5808 .and_then(serde_json::Value::as_u64)
5809 .is_some_and(|bytes| bytes > 0)
5810 && value
5811 .get("source_blake3")
5812 .and_then(serde_json::Value::as_str)
5813 .is_some_and(|hash| hash.len() == 64)
5814}
5815
5816fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
5817 let pointer_generation = read_pointer(callgraph_dir, project_key);
5818 if let Some(generation) = pointer_generation.as_deref() {
5819 if migration_generation_requires_manifest(generation)
5820 && !migration_manifest_valid(callgraph_dir, generation)
5821 {
5822 let path = callgraph_dir.join(generation);
5823 remove_sqlite_file_set(&path);
5824 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
5825 let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
5826 }
5827 }
5828
5829 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
5830 return;
5831 };
5832 for entry in entries.flatten() {
5833 let name = entry.file_name().to_string_lossy().to_string();
5834 let path = entry.path();
5835 if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
5836 let _ = std::fs::remove_file(path);
5837 continue;
5838 }
5839 if name.starts_with(&format!("{project_key}.g"))
5840 && name.ends_with(".sqlite")
5841 && name.contains(MIGRATION_GENERATION_TAG)
5842 && pointer_generation.as_deref() != Some(&name)
5843 && !migration_manifest_valid(callgraph_dir, &name)
5844 {
5845 remove_sqlite_file_set(&path);
5846 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
5847 }
5848 }
5849 crate::fs_lock::sync_parent(callgraph_dir);
5850}
5851
5852fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
5853 let mut hasher = blake3::Hasher::new();
5854 hasher.update(path.to_string_lossy().as_bytes());
5855 if let Some(generation) = generation {
5856 hasher.update(generation.as_bytes());
5857 }
5858 let digest = hash_to_hex(hasher.finalize());
5859 format!("legacy-{}", &digest[..16])
5860}
5861
5862fn open_readonly_connection(path: &Path) -> Result<Connection> {
5863 let uri = sqlite_readonly_uri(path);
5864 let conn = Connection::open_with_flags(
5865 &uri,
5866 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
5867 )?;
5868 conn.pragma_update(
5869 None,
5870 "synchronous",
5871 if write_amplification_baseline_enabled() {
5872 "FULL"
5873 } else {
5874 "NORMAL"
5875 },
5876 )?;
5877 conn.busy_timeout(reader_busy_timeout())?;
5878 conn.execute_batch("PRAGMA query_only=ON;")?;
5879 Ok(conn)
5880}
5881
5882fn reader_busy_timeout() -> Duration {
5883 let jitter = (now_nanos() % 500) as u64;
5884 Duration::from_millis(250 + jitter)
5885}
5886
5887fn sqlite_readonly_uri(path: &Path) -> String {
5888 let raw = path.to_string_lossy().replace('\\', "/");
5889 let encoded = percent_encode_sqlite_uri_path(&raw);
5890 if raw.starts_with('/') {
5891 format!("file://{encoded}?mode=ro")
5892 } else if raw.as_bytes().get(1) == Some(&b':') {
5893 format!("file:///{encoded}?mode=ro")
5894 } else {
5895 format!("file:{encoded}?mode=ro")
5896 }
5897}
5898
5899fn percent_encode_sqlite_uri_path(path: &str) -> String {
5900 let mut encoded = String::with_capacity(path.len());
5901 for byte in path.bytes() {
5902 match byte {
5903 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
5904 encoded.push(byte as char)
5905 }
5906 _ => encoded.push_str(&format!("%{byte:02X}")),
5907 }
5908 }
5909 encoded
5910}
5911
5912fn configure_connection(conn: &Connection) -> Result<()> {
5913 conn.pragma_update(None, "journal_mode", "WAL")?;
5914 let baseline = write_amplification_baseline_enabled();
5915 conn.pragma_update(
5916 None,
5917 "synchronous",
5918 if baseline { "FULL" } else { "NORMAL" },
5919 )?;
5920 conn.pragma_update(
5921 None,
5922 "wal_autocheckpoint",
5923 if baseline {
5924 1_000
5925 } else {
5926 CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES
5927 },
5928 )?;
5929 conn.pragma_update(None, "busy_timeout", 5_000)?;
5930 Ok(())
5931}
5932
5933fn configure_build_connection(conn: &Connection) -> Result<()> {
5934 conn.pragma_update(None, "journal_mode", "DELETE")?;
5935 conn.pragma_update(
5936 None,
5937 "synchronous",
5938 if write_amplification_baseline_enabled() {
5939 "FULL"
5940 } else {
5941 "NORMAL"
5942 },
5943 )?;
5944 conn.pragma_update(None, "busy_timeout", 5_000)?;
5945 Ok(())
5946}
5947
5948fn checkpoint_sqlite_before_publication(path: &Path) {
5952 let Ok(conn) = Connection::open(path) else {
5953 return;
5954 };
5955 let _ = conn.pragma_update(None, "synchronous", "NORMAL");
5956 let _ = conn.busy_timeout(Duration::from_secs(5));
5957 let _ = checkpoint_wal_truncate(&conn);
5958}
5959
5960fn checkpoint_wal_truncate(conn: &Connection) -> bool {
5961 match conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
5962 row.get::<_, i64>(0)
5963 }) {
5964 Ok(0) => true,
5965 Ok(_) => false,
5966 Err(rusqlite::Error::SqliteFailure(error, _))
5967 if matches!(
5968 error.code,
5969 rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
5970 ) =>
5971 {
5972 false
5973 }
5974 Err(error) => {
5975 log::debug!("callgraph WAL truncate checkpoint skipped: {error}");
5976 false
5977 }
5978 }
5979}
5980
5981fn initialize_schema(conn: &Connection) -> Result<()> {
5982 conn.execute_batch(
5983 "CREATE TABLE IF NOT EXISTS files (
5984 path TEXT PRIMARY KEY,
5985 content_hash TEXT NOT NULL,
5986 mtime_ns INTEGER NOT NULL,
5987 size INTEGER NOT NULL,
5988 lang TEXT NOT NULL,
5989 is_dead_code_root INTEGER NOT NULL DEFAULT 0,
5990 is_public_api INTEGER NOT NULL DEFAULT 0,
5991 surface_fingerprint TEXT NOT NULL,
5992 indexed_at INTEGER NOT NULL
5993 );
5994
5995 CREATE TABLE IF NOT EXISTS nodes (
5996 id TEXT PRIMARY KEY,
5997 file_path TEXT NOT NULL,
5998 name TEXT NOT NULL,
5999 scoped_name TEXT NOT NULL,
6000 kind TEXT NOT NULL,
6001 start_line INTEGER NOT NULL,
6002 start_col INTEGER NOT NULL,
6003 end_line INTEGER NOT NULL,
6004 end_col INTEGER NOT NULL,
6005 range_ordinal INTEGER NOT NULL,
6006 signature TEXT,
6007 exported INTEGER NOT NULL,
6008 is_default_export INTEGER NOT NULL,
6009 is_type_like INTEGER NOT NULL,
6010 is_callgraph_entry_point INTEGER NOT NULL,
6011 provenance TEXT NOT NULL,
6012 UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
6013 );
6014 CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
6015 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
6016 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
6017
6018 CREATE TABLE IF NOT EXISTS refs (
6019 ref_id TEXT PRIMARY KEY,
6020 caller_node TEXT,
6021 caller_file TEXT NOT NULL,
6022 kind TEXT NOT NULL,
6023 short_name TEXT,
6024 full_ref TEXT,
6025 module_path TEXT,
6026 import_kind TEXT,
6027 local_name TEXT,
6028 requested_name TEXT,
6029 namespace_alias TEXT,
6030 wildcard INTEGER NOT NULL DEFAULT 0,
6031 line INTEGER NOT NULL,
6032 byte_start INTEGER NOT NULL,
6033 byte_end INTEGER NOT NULL,
6034 status TEXT NOT NULL,
6035 target_node TEXT,
6036 target_file TEXT,
6037 target_symbol TEXT,
6038 provenance TEXT NOT NULL
6039 );
6040 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
6041 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
6042 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
6043 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
6044 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
6045
6046 CREATE TABLE IF NOT EXISTS file_dependencies (
6047 file_path TEXT NOT NULL,
6048 dep_file TEXT NOT NULL,
6049 PRIMARY KEY(file_path, dep_file)
6050 );
6051 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
6052
6053 CREATE TABLE IF NOT EXISTS edges (
6054 edge_id TEXT PRIMARY KEY,
6055 ref_id TEXT NOT NULL,
6056 source_node TEXT NOT NULL,
6057 target_node TEXT,
6058 target_file TEXT NOT NULL,
6059 target_symbol TEXT NOT NULL,
6060 kind TEXT NOT NULL,
6061 line INTEGER NOT NULL,
6062 provenance TEXT NOT NULL
6063 );
6064 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
6065 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
6066 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
6067 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
6068
6069 CREATE TABLE IF NOT EXISTS dispatch_hints (
6070 id TEXT PRIMARY KEY,
6071 method_name TEXT NOT NULL,
6072 caller_node TEXT NOT NULL,
6073 file TEXT NOT NULL,
6074 line INTEGER NOT NULL,
6075 byte_start INTEGER NOT NULL,
6076 byte_end INTEGER NOT NULL,
6077 provenance TEXT NOT NULL
6078 );
6079 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
6080
6081 CREATE TABLE IF NOT EXISTS type_ref_names (
6082 name TEXT PRIMARY KEY
6083 );
6084
6085 CREATE TABLE IF NOT EXISTS backend_file_state (
6086 backend TEXT NOT NULL,
6087 workspace_root TEXT NOT NULL,
6088 file_path TEXT NOT NULL,
6089 content_hash TEXT NOT NULL,
6090 status TEXT NOT NULL,
6091 updated_at INTEGER NOT NULL,
6092 PRIMARY KEY(backend, workspace_root, file_path, content_hash)
6093 );
6094 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
6095
6096 CREATE TABLE IF NOT EXISTS meta (
6097 k TEXT PRIMARY KEY,
6098 v TEXT NOT NULL
6099 );",
6100 )?;
6101 insert_meta(conn)?;
6102 Ok(())
6103}
6104
6105fn insert_meta(conn: &Connection) -> Result<()> {
6106 conn.execute(
6107 "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
6108 params![SCHEMA_VERSION.to_string()],
6109 )?;
6110 conn.execute(
6111 "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
6112 params![schema_fingerprint()],
6113 )?;
6114 conn.execute(
6115 "INSERT OR IGNORE INTO meta(k, v) VALUES('projection_write_revision', '0')",
6116 [],
6117 )?;
6118 Ok(())
6119}
6120
6121fn projection_write_revision(conn: &Connection) -> Result<Option<u64>> {
6125 let revision: Option<String> = conn
6126 .query_row(
6127 "SELECT v FROM meta WHERE k = 'projection_write_revision'",
6128 [],
6129 |row| row.get(0),
6130 )
6131 .optional()?;
6132 revision
6133 .map(|revision| {
6134 revision.parse::<u64>().map_err(|error| {
6135 CallGraphStoreError::Unavailable(format!(
6136 "callgraph projection write revision is invalid: {error}"
6137 ))
6138 })
6139 })
6140 .transpose()
6141}
6142
6143fn bump_projection_write_revision(tx: &Transaction<'_>) -> Result<()> {
6146 tx.execute(
6147 "INSERT INTO meta(k, v) VALUES('projection_write_revision', '1')
6148 ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1",
6149 [],
6150 )?;
6151 Ok(())
6152}
6153
6154fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
6155 conn.execute(
6156 "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
6157 params![if ready { "1" } else { "0" }],
6158 )?;
6159 Ok(())
6160}
6161
6162fn database_ready(conn: &Connection) -> Result<bool> {
6163 let schema_version: Option<String> = conn
6164 .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
6165 row.get(0)
6166 })
6167 .optional()?;
6168 let fingerprint: Option<String> = conn
6169 .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
6170 row.get(0)
6171 })
6172 .optional()?;
6173 let ready: Option<String> = conn
6174 .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
6175 .optional()?;
6176
6177 let expected_schema = SCHEMA_VERSION.to_string();
6178 let expected_fingerprint = schema_fingerprint();
6179 Ok(schema_version.as_deref() == Some(expected_schema.as_str())
6180 && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
6181 && ready.as_deref() == Some("1"))
6182}
6183
6184fn ensure_database_ready(conn: &Connection) -> Result<()> {
6185 if database_ready(conn)? {
6186 Ok(())
6187 } else {
6188 Err(CallGraphStoreError::Unavailable(
6189 "database is missing, stale, or mid-build".to_string(),
6190 ))
6191 }
6192}
6193
6194fn schema_fingerprint() -> String {
6195 let input =
6200 format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
6201 hash_to_hex(blake3::hash(input.as_bytes()))
6202}
6203
6204fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
6205 tx.execute_batch(
6206 "DELETE FROM edges;
6207 DELETE FROM file_dependencies;
6208 DELETE FROM refs;
6209 DELETE FROM dispatch_hints;
6210 DELETE FROM type_ref_names;
6211 DELETE FROM backend_file_state;
6212 DELETE FROM nodes;
6213 DELETE FROM files;",
6214 )?;
6215 Ok(())
6216}
6217
6218fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
6219 tx.execute_batch(
6220 "DROP INDEX IF EXISTS idx_nodes_file;
6221 DROP INDEX IF EXISTS idx_nodes_name;
6222 DROP INDEX IF EXISTS idx_nodes_scoped;
6223 DROP INDEX IF EXISTS idx_refs_short_name;
6224 DROP INDEX IF EXISTS idx_refs_kind_caller_file;
6225 DROP INDEX IF EXISTS idx_refs_caller_file;
6226 DROP INDEX IF EXISTS idx_refs_caller_node_kind;
6227 DROP INDEX IF EXISTS idx_refs_target_file;
6228 DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
6229 DROP INDEX IF EXISTS idx_edges_source_kind;
6230 DROP INDEX IF EXISTS idx_edges_target_kind;
6231 DROP INDEX IF EXISTS idx_edges_target_file_symbol;
6232 DROP INDEX IF EXISTS idx_edges_ref_id;
6233 DROP INDEX IF EXISTS idx_dispatch_hints_method;
6234 DROP INDEX IF EXISTS idx_backend_file_state_file;",
6235 )?;
6236 Ok(())
6237}
6238
6239fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
6240 tx.execute_batch(
6241 "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
6242 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
6243 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
6244 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
6245 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
6246 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
6247 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
6248 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
6249 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
6250 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
6251 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
6252 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
6253 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
6254 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
6255 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
6256 )?;
6257 Ok(())
6258}
6259
6260const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
6261 ("files", "path"),
6262 ("nodes", "file_path"),
6263 ("refs", "caller_file"),
6264 ("refs", "target_file"),
6265 ("file_dependencies", "file_path"),
6266 ("file_dependencies", "dep_file"),
6267 ("edges", "target_file"),
6268 ("dispatch_hints", "file"),
6269 ("backend_file_state", "file_path"),
6270];
6271
6272fn reconcile_workspace_roots(
6285 conn: &mut Connection,
6286 project_root: &Path,
6287 allow_repair: bool,
6288) -> Result<OpenRootRepair> {
6289 let roots = stored_workspace_roots(conn)?;
6290 let current_root = project_root.display().to_string();
6291 if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
6292 return Ok(OpenRootRepair::None);
6293 }
6294
6295 if let Some(sample) = sample_absolute_data_path(conn)? {
6296 return Ok(OpenRootRepair::NeedsRebuild {
6297 previous_roots: roots,
6298 current_root,
6299 reason: format!("absolute store data path row {sample}"),
6300 });
6301 }
6302
6303 for stored_root in roots.iter() {
6304 if stored_root == ¤t_root {
6305 continue;
6306 }
6307 if Path::new(stored_root).exists() {
6308 let reason = format!(
6309 "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
6310 );
6311 return Ok(OpenRootRepair::NeedsRebuild {
6312 previous_roots: roots,
6313 current_root,
6314 reason,
6315 });
6316 }
6317 }
6318
6319 if !allow_repair {
6320 return Ok(OpenRootRepair::NeedsRebuild {
6321 previous_roots: roots,
6322 current_root,
6323 reason: "workspace root metadata requires deferred repair".to_string(),
6324 });
6325 }
6326
6327 publish_if_current(|| {
6328 let tx = conn.transaction()?;
6329 tx.execute(
6330 "UPDATE OR IGNORE backend_file_state
6331 SET workspace_root = ?1
6332 WHERE workspace_root <> ?1",
6333 params![¤t_root],
6334 )?;
6335 tx.execute(
6336 "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
6337 params![¤t_root],
6338 )?;
6339 tx.commit()?;
6340 Ok(())
6341 })?;
6342
6343 crate::slog_info!(
6344 "callgraph store re-rooted from {} to {}",
6345 roots.join(", "),
6346 current_root
6347 );
6348 Ok(OpenRootRepair::ReRooted)
6349}
6350
6351fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
6352 let mut stmt = conn.prepare(
6353 "SELECT DISTINCT workspace_root
6354 FROM backend_file_state
6355 ORDER BY workspace_root",
6356 )?;
6357 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
6358 rows.collect::<std::result::Result<Vec<_>, _>>()
6359 .map_err(Into::into)
6360}
6361
6362fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
6363 for (table, column) in STORE_DATA_PATH_COLUMNS {
6364 let sql = format!(
6365 "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
6366 );
6367 let mut stmt = conn.prepare(&sql)?;
6368 let mut rows = stmt.query([])?;
6369 while let Some(row) = rows.next()? {
6370 let value: String = row.get(0)?;
6371 if stored_path_is_absolute(&value) {
6372 return Ok(Some(format!("{table}.{column}={value}")));
6373 }
6374 }
6375 }
6376 Ok(None)
6377}
6378
6379fn stored_path_is_absolute(value: &str) -> bool {
6380 if value.is_empty() {
6381 return false;
6382 }
6383 if Path::new(value).is_absolute() || value.starts_with('/') {
6384 return true;
6385 }
6386 let bytes = value.as_bytes();
6387 if bytes.len() >= 3
6388 && bytes[1] == b':'
6389 && (bytes[2] == b'/' || bytes[2] == b'\\')
6390 && bytes[0].is_ascii_alphabetic()
6391 {
6392 return true;
6393 }
6394 value.starts_with("\\\\") || value.starts_with("//")
6395}
6396
6397fn log_root_repair_rebuild(repair: &OpenRootRepair) {
6398 if let OpenRootRepair::NeedsRebuild {
6399 previous_roots,
6400 current_root,
6401 reason,
6402 } = repair
6403 {
6404 crate::slog_info!(
6405 "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
6406 previous_roots.join(", "),
6407 current_root,
6408 reason
6409 );
6410 }
6411}
6412
6413fn now_nanos() -> u128 {
6415 SystemTime::now()
6416 .duration_since(UNIX_EPOCH)
6417 .unwrap_or(Duration::ZERO)
6418 .as_nanos()
6419}
6420
6421fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
6426 callgraph_dir.join(format!("{project_key}.current"))
6427}
6428
6429fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
6433 callgraph_dir.join(format!("{project_key}.sqlite"))
6434}
6435
6436fn generation_file_name(project_key: &str) -> String {
6440 format!(
6441 "{project_key}.g{}.{}.sqlite",
6442 now_nanos(),
6443 std::process::id()
6444 )
6445}
6446
6447fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
6449 let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
6450 let name = text.trim();
6451 if name.is_empty() {
6452 None
6453 } else {
6454 Some(name.to_string())
6455 }
6456}
6457
6458fn db_path_ready(path: &Path) -> bool {
6461 (|| -> Result<bool> {
6462 let conn = open_readonly_connection(path)?;
6463 database_ready(&conn)
6464 })()
6465 .unwrap_or(false)
6466}
6467
6468fn resolve_ready_target(
6476 callgraph_dir: &Path,
6477 project_key: &str,
6478) -> Option<(PathBuf, Option<String>)> {
6479 for _ in 0..5 {
6480 if let Some(generation) = read_pointer(callgraph_dir, project_key) {
6481 let gen_path = callgraph_dir.join(&generation);
6482 if gen_path.is_file() {
6483 return (migration_manifest_valid(callgraph_dir, &generation)
6484 && db_path_ready(&gen_path))
6485 .then_some((gen_path, Some(generation)));
6486 }
6487 std::thread::sleep(Duration::from_millis(5));
6490 continue;
6491 }
6492 let legacy = legacy_sqlite_path(callgraph_dir, project_key);
6494 return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
6495 }
6496 None
6497}
6498
6499fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
6503 let pointer = pointer_path(callgraph_dir, project_key);
6504 let tmp = callgraph_dir.join(format!(
6505 "{project_key}.current.tmp.{}.{}",
6506 std::process::id(),
6507 now_nanos()
6508 ));
6509 {
6510 use std::io::Write as _;
6511 let mut file = std::fs::File::create(&tmp)?;
6512 file.write_all(generation.as_bytes())?;
6513 file.write_all(b"\n")?;
6514 file.sync_all()?;
6515 }
6516 if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
6517 let _ = std::fs::remove_file(&tmp);
6518 return Err(error.into());
6519 }
6520 crate::fs_lock::sync_parent(&pointer);
6521 Ok(())
6522}
6523
6524#[derive(Clone, Debug)]
6525struct GenerationGcCandidate {
6526 name: String,
6527 path: PathBuf,
6528 modified: SystemTime,
6529}
6530
6531fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
6537 let temp_grace = Duration::from_secs(60);
6538 let now = SystemTime::now();
6539 let pointer_current =
6540 read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
6541 let gen_prefix = format!("{project_key}.g");
6542 let tmp_prefixes = [
6543 format!("{project_key}.g"), format!("{project_key}.current."), format!("{project_key}.sqlite.tmp."), ];
6547 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6548 return;
6549 };
6550 let mut gens: Vec<GenerationGcCandidate> = Vec::new();
6551 for entry in entries.flatten() {
6552 let name = entry.file_name();
6553 let name = name.to_string_lossy().to_string();
6554 let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
6555 let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
6556
6557 if name.contains(".tmp.") {
6559 if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
6560 let _ = std::fs::remove_file(entry.path());
6561 }
6562 continue;
6563 }
6564
6565 if name == format!("{project_key}.sqlite") {
6568 remove_sqlite_file_set(&entry.path());
6569 continue;
6570 }
6571
6572 if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
6573 gens.push(GenerationGcCandidate {
6574 name,
6575 path: entry.path(),
6576 modified: mtime,
6577 });
6578 }
6579 }
6580
6581 let mut superseded = gens
6582 .iter()
6583 .filter(|generation| generation.name != pointer_current)
6584 .collect::<Vec<_>>();
6585 superseded.sort_by(|left, right| {
6586 right
6587 .modified
6588 .cmp(&left.modified)
6589 .then_with(|| right.name.cmp(&left.name))
6590 });
6591 let previous = superseded.first().map(|generation| generation.name.clone());
6592
6593 for generation in gens {
6594 let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
6595 if generation.name == pointer_current
6596 || Some(generation.name.as_str()) == previous.as_deref()
6597 {
6598 continue;
6599 }
6600
6601 let age = now
6602 .duration_since(generation.modified)
6603 .unwrap_or(Duration::ZERO);
6604 if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
6605 continue;
6606 }
6607
6608 remove_sqlite_file_set(&generation.path);
6609 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
6610 let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
6611 callgraph_dir,
6612 &generation.name,
6613 ));
6614 }
6615}
6616
6617fn remove_sqlite_file_set(path: &Path) {
6618 let _ = std::fs::remove_file(path);
6619 remove_sqlite_sidecars(path);
6620}
6621
6622fn remove_sqlite_sidecars(path: &Path) {
6623 let path_text = path.to_string_lossy();
6624 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
6625 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
6626 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
6627}
6628
6629const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
6643
6644fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
6656 sweep_orphaned_build_temps(callgraph_dir);
6657 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6658 return;
6659 };
6660 let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
6661
6662 if let Ok(entries) = std::fs::read_dir(storage_root.join(domain)) {
6664 for entry in entries.flatten() {
6665 if entry.path().is_dir() {
6666 sweep_orphaned_build_temps(&entry.path());
6667 }
6668 }
6669 }
6670
6671 if let Ok(entries) = std::fs::read_dir(&storage_root) {
6673 for entry in entries.flatten() {
6674 let legacy_dir = entry.path().join(domain);
6675 if legacy_dir.is_dir() {
6676 sweep_orphaned_build_temps(&legacy_dir);
6677 }
6678 }
6679 }
6680}
6681
6682fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
6685 sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
6686}
6687
6688fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
6691 let now = SystemTime::now();
6692 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6693 return;
6694 };
6695 let mut removed_any = false;
6696 for entry in entries.flatten() {
6697 let name = entry.file_name().to_string_lossy().to_string();
6698 if !name.contains(".sqlite.tmp.") {
6704 continue;
6705 }
6706 let mtime = entry
6707 .metadata()
6708 .and_then(|meta| meta.modified())
6709 .unwrap_or(now);
6710 if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
6711 continue;
6712 }
6713 match std::fs::remove_file(entry.path()) {
6719 Ok(()) => removed_any = true,
6720 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
6721 Err(_) => {}
6722 }
6723 }
6724 if removed_any {
6725 crate::fs_lock::sync_parent(callgraph_dir);
6726 }
6727}
6728
6729fn build_pool_size() -> usize {
6737 std::thread::available_parallelism()
6738 .map(|parallelism| parallelism.get())
6739 .unwrap_or(1)
6740 .div_ceil(2)
6741 .clamp(1, 8)
6742}
6743
6744fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
6745 let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
6746 Ok(extract) => Ok(extract),
6747 Err(error) => {
6748 let abs_path =
6749 normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
6750 let rel_path = relative_path(project_root, &abs_path);
6751 let freshness = cache_freshness::collect(&abs_path).ok();
6752 log::debug!(
6753 "callgraph store: skipping {} during cold build: {}",
6754 abs_path.display(),
6755 error
6756 );
6757 Err(ExtractFailure {
6758 rel_path,
6759 freshness,
6760 })
6761 }
6762 };
6763
6764 let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
6765 files.par_iter().map(extract_one).collect()
6766 };
6767
6768 let results = match rayon::ThreadPoolBuilder::new()
6771 .num_threads(build_pool_size())
6772 .thread_name(|index| format!("aft-callgraph-build-{index}"))
6773 .stack_size(8 * 1024 * 1024)
6774 .build()
6775 {
6776 Ok(pool) => pool.install(run),
6777 Err(error) => {
6778 log::warn!(
6779 "callgraph store: bounded build pool unavailable ({error}); using global pool"
6780 );
6781 run()
6782 }
6783 };
6784
6785 let mut extracts = Vec::new();
6786 let mut failures = Vec::new();
6787 for result in results {
6788 match result {
6789 Ok(extract) => extracts.push(extract),
6790 Err(failure) => failures.push(failure),
6791 }
6792 }
6793 BuildExtractsResult { extracts, failures }
6794}
6795
6796fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
6797 let metadata = std::fs::metadata(path)?;
6798 let size = metadata.len();
6799 let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
6800 cache_freshness::zero_hash()
6801 } else if source.len() as u64 == size {
6802 cache_freshness::hash_bytes(source.as_bytes())
6803 } else {
6804 cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
6805 };
6806 Ok(FileFreshness {
6807 mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
6808 size,
6809 content_hash,
6810 })
6811}
6812
6813fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
6814 let abs_path = normalize_file_path(project_root, path)?;
6815 let rel_path = relative_path(project_root, &abs_path);
6816 let source = std::fs::read_to_string(&abs_path)?;
6817 let freshness = collect_source_freshness(&abs_path, &source)?;
6818 let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
6819 let lang = data.lang;
6820 if lang == LangId::Rust {
6821 extend_rust_imports_with_nested_uses(&source, &mut data);
6822 }
6823 let mut nodes = build_node_records(&rel_path, &source, &data)?;
6824 let node_by_scoped: HashMap<String, String> = nodes
6825 .iter()
6826 .map(|node| (node.scoped_name.clone(), node.id.clone()))
6827 .collect();
6828 let import_dependencies =
6829 import_dependencies(project_root, &abs_path, &data.import_block.imports);
6830 let line_index = LineIndex::new(&source);
6831 let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
6832 let rust_reexports = if lang == LangId::Rust {
6833 collect_rust_pub_use_reexport_refs(
6834 project_root,
6835 &abs_path,
6836 &rel_path,
6837 &data.import_block.imports,
6838 &line_index,
6839 )
6840 } else {
6841 ReexportRefs {
6842 raw_refs: Vec::new(),
6843 surface_parts: Vec::new(),
6844 }
6845 };
6846 let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
6847 let mut raw_refs = Vec::new();
6848 raw_refs.extend(build_call_refs(
6849 &rel_path,
6850 &data,
6851 &node_by_scoped,
6852 &import_dependencies,
6853 ));
6854 raw_refs.extend(build_import_refs(
6855 project_root,
6856 &abs_path,
6857 &rel_path,
6858 &data.import_block.imports,
6859 &line_index,
6860 ));
6861 let mut surface_parts = reexports.surface_parts;
6862 surface_parts.extend(rust_reexports.surface_parts);
6863 surface_parts.extend(source_less_exports.surface_parts);
6864 raw_refs.extend(reexports.raw_refs);
6865 raw_refs.extend(rust_reexports.raw_refs);
6866 raw_refs.extend(source_less_exports.raw_refs);
6867 let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
6868 let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
6869
6870 Ok(FileExtract {
6871 rel_path,
6872 freshness,
6873 lang,
6874 data,
6875 nodes,
6876 raw_refs,
6877 dispatch_hints,
6878 surface_fingerprint,
6879 })
6880}
6881
6882fn build_node_records(
6883 rel_path: &str,
6884 source: &str,
6885 data: &FileCallData,
6886) -> Result<Vec<NodeRecord>> {
6887 let mut records = Vec::new();
6888 let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
6889 let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
6890 metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
6891
6892 for (scoped_name, meta) in metadata {
6893 let name = unqualified_name(scoped_name).to_string();
6894 let range = selection_range(source, scoped_name, &name, &meta.range);
6895 let range_key = (
6896 range.start_line,
6897 range.start_col,
6898 range.end_line,
6899 range.end_col,
6900 );
6901 let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
6902 let range_ordinal = *ordinal;
6903 *ordinal += 1;
6904 let id = node_id(rel_path, &range, range_ordinal, scoped_name);
6905 let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
6906 let is_default_export = data
6907 .default_export_symbol
6908 .as_deref()
6909 .map(|default| default == scoped_name || default == name)
6910 .unwrap_or(false);
6911 records.push(NodeRecord {
6912 id,
6913 file_path: rel_path.to_string(),
6914 name: name.clone(),
6915 scoped_name: scoped_name.clone(),
6916 kind: symbol_kind_label(&meta.kind).to_string(),
6917 range,
6918 range_ordinal,
6919 signature: meta.signature.clone(),
6920 exported,
6921 is_default_export,
6922 is_type_like: is_type_like(&meta.kind),
6923 is_callgraph_entry_point: meta.entry_point_attribute.is_some()
6924 || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
6925 });
6926 }
6927
6928 Ok(records)
6929}
6930
6931fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
6932 if scoped_name == TOP_LEVEL_SYMBOL {
6933 return Range {
6934 start_line: 0,
6935 start_col: 0,
6936 end_line: 0,
6937 end_col: 0,
6938 };
6939 }
6940 let Some(line) = source.lines().nth(fallback.start_line as usize) else {
6941 return fallback.clone();
6942 };
6943 let start_col = fallback.start_col as usize;
6944 let search_start = start_col.min(line.len());
6945 if let Some(offset) = line[search_start..].find(name) {
6946 let col = search_start + offset;
6947 return Range {
6948 start_line: fallback.start_line,
6949 start_col: col as u32,
6950 end_line: fallback.start_line,
6951 end_col: (col + name.len()) as u32,
6952 };
6953 }
6954 if let Some(offset) = line.find(name) {
6955 return Range {
6956 start_line: fallback.start_line,
6957 start_col: offset as u32,
6958 end_line: fallback.start_line,
6959 end_col: (offset + name.len()) as u32,
6960 };
6961 }
6962 Range {
6963 start_line: fallback.start_line,
6964 start_col: fallback.start_col,
6965 end_line: fallback.start_line,
6966 end_col: fallback.start_col.saturating_add(name.len() as u32),
6967 }
6968}
6969
6970fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
6971 if scoped_name == TOP_LEVEL_SYMBOL {
6972 return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
6973 }
6974 let input = format!(
6975 "{rel_path}:{}:{}:{}:{}:{ordinal}",
6976 range.start_line, range.start_col, range.end_line, range.end_col
6977 );
6978 format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
6979}
6980
6981fn build_call_refs(
6982 rel_path: &str,
6983 data: &FileCallData,
6984 node_by_scoped: &HashMap<String, String>,
6985 import_dependencies: &BTreeSet<String>,
6986) -> Vec<RawRef> {
6987 let mut refs = Vec::new();
6988 let mut ordinal = 0usize;
6989 let mut symbols: Vec<_> = data.calls_by_symbol.iter().collect();
6990 symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
6991 for (caller_symbol, call_sites) in symbols {
6992 let caller_node = node_by_scoped.get(caller_symbol).cloned();
6993 for call_site in call_sites {
6994 ordinal += 1;
6995 let ref_id = ref_id(&[
6996 rel_path,
6997 "call",
6998 caller_symbol,
6999 &call_site.line.to_string(),
7000 &call_site.byte_start.to_string(),
7001 &call_site.byte_end.to_string(),
7002 &call_site.full_callee,
7003 &ordinal.to_string(),
7004 ]);
7005 refs.push(RawRef {
7006 ref_id,
7007 caller_node: caller_node.clone(),
7008 caller_symbol: Some(caller_symbol.clone()),
7009 caller_file: rel_path.to_string(),
7010 kind: "call".to_string(),
7011 short_name: Some(call_site.callee_name.clone()),
7012 full_ref: Some(call_site.full_callee.clone()),
7013 module_path: None,
7014 import_kind: None,
7015 local_name: Some(call_site.callee_name.clone()),
7016 requested_name: Some(call_site.callee_name.clone()),
7017 namespace_alias: namespace_alias(&call_site.full_callee),
7018 wildcard: false,
7019 line: call_site.line,
7020 byte_start: call_site.byte_start,
7021 byte_end: call_site.byte_end,
7022 dependencies: import_dependencies.clone(),
7023 });
7024 }
7025 }
7026 refs
7027}
7028
7029fn build_import_refs(
7030 project_root: &Path,
7031 abs_path: &Path,
7032 rel_path: &str,
7033 imports: &[ImportStatement],
7034 line_index: &LineIndex,
7035) -> Vec<RawRef> {
7036 let mut refs = Vec::new();
7037 for (index, import) in imports.iter().enumerate() {
7038 let import_kind = import_kind_label(import.kind).to_string();
7039 let local_name = import_local_names(import).join(",");
7040 let requested_name = import_requested_names(import).join(",");
7041 let ref_id = ref_id(&[
7042 rel_path,
7043 "import",
7044 &import.byte_range.start.to_string(),
7045 &import.byte_range.end.to_string(),
7046 &import.module_path,
7047 &index.to_string(),
7048 ]);
7049 refs.push(RawRef {
7050 ref_id,
7051 caller_node: None,
7052 caller_symbol: None,
7053 caller_file: rel_path.to_string(),
7054 kind: "import".to_string(),
7055 short_name: None,
7056 full_ref: Some(import.raw_text.clone()),
7057 module_path: Some(import.module_path.clone()),
7058 import_kind: Some(import_kind),
7059 local_name: empty_to_none(local_name),
7060 requested_name: empty_to_none(requested_name),
7061 namespace_alias: import.namespace_import.clone(),
7062 wildcard: import_is_wildcard(import),
7063 line: line_index.byte_to_line(import.byte_range.start),
7064 byte_start: import.byte_range.start,
7065 byte_end: import.byte_range.end,
7066 dependencies: module_dependencies(project_root, abs_path, &import.module_path),
7067 });
7068 }
7069 refs
7070}
7071
7072fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
7073 let grammar = grammar_for(LangId::Rust);
7074 let mut parser = Parser::new();
7075 if parser.set_language(&grammar).is_err() {
7076 return;
7077 }
7078 let Some(tree) = parser.parse(source, None) else {
7079 return;
7080 };
7081
7082 let mut seen = data
7083 .import_block
7084 .imports
7085 .iter()
7086 .map(|import| (import.byte_range.start, import.byte_range.end))
7087 .collect::<HashSet<_>>();
7088 let mut nested_imports = Vec::new();
7089 collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
7090 if nested_imports.is_empty() {
7091 return;
7092 }
7093
7094 data.import_block.imports.extend(nested_imports);
7095 data.import_block
7096 .imports
7097 .sort_by_key(|import| import.byte_range.start);
7098 data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
7099}
7100
7101fn collect_rust_use_imports(
7102 source: &str,
7103 node: Node<'_>,
7104 seen: &mut HashSet<(usize, usize)>,
7105 imports: &mut Vec<ImportStatement>,
7106) {
7107 if node.kind() == "use_declaration" {
7108 let range = node.byte_range();
7109 if seen.insert((range.start, range.end)) {
7110 if let Some(import) = rust_import_from_use_node(source, node) {
7111 imports.push(import);
7112 }
7113 }
7114 }
7115
7116 let mut cursor = node.walk();
7117 if !cursor.goto_first_child() {
7118 return;
7119 }
7120 loop {
7121 collect_rust_use_imports(source, cursor.node(), seen, imports);
7122 if !cursor.goto_next_sibling() {
7123 break;
7124 }
7125 }
7126}
7127
7128fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
7129 let raw_text = source[node.byte_range()].to_string();
7130 let body = rust_use_body(&raw_text)?.to_string();
7131 let visibility = rust_use_visibility(&raw_text);
7132 let names = rust_use_list_names(&body);
7133 let group = classify_rust_import_group(&body);
7134 let byte_range = node.byte_range();
7135
7136 Some(ImportStatement {
7137 module_path: body,
7138 names: names.clone(),
7139 default_import: visibility.clone(),
7140 namespace_import: None,
7141 kind: ImportKind::Value,
7142 group,
7143 byte_range,
7144 raw_text,
7145 form: ImportForm::RustUse {
7146 visibility,
7147 named: names,
7148 },
7149 })
7150}
7151
7152fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
7153 let start = imports.iter().map(|import| import.byte_range.start).min()?;
7154 let end = imports.iter().map(|import| import.byte_range.end).max()?;
7155 Some(start..end)
7156}
7157
7158fn rust_use_visibility(raw_text: &str) -> Option<String> {
7159 let use_pos = raw_text.find("use ")?;
7160 let prefix = raw_text[..use_pos].trim();
7161 if prefix.is_empty() {
7162 None
7163 } else {
7164 Some(prefix.to_string())
7165 }
7166}
7167
7168fn rust_use_body(raw_text: &str) -> Option<&str> {
7169 let use_pos = raw_text.find("use ")?;
7170 Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
7171}
7172
7173fn rust_use_list_names(body: &str) -> Vec<String> {
7174 let Some(open) = body.find("::{") else {
7175 return Vec::new();
7176 };
7177 let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
7178 return Vec::new();
7179 };
7180 body[open + 3..close]
7181 .split(',')
7182 .filter_map(|spec| {
7183 let spec = spec.trim();
7184 if spec.is_empty() {
7185 None
7186 } else {
7187 Some(spec.to_string())
7188 }
7189 })
7190 .collect()
7191}
7192
7193fn classify_rust_import_group(body: &str) -> ImportGroup {
7194 let first = body
7195 .split("::")
7196 .next()
7197 .unwrap_or(body)
7198 .split_whitespace()
7199 .next()
7200 .unwrap_or(body);
7201 match first.trim() {
7202 "std" | "core" | "alloc" => ImportGroup::Stdlib,
7203 "crate" | "self" | "super" => ImportGroup::Internal,
7204 _ => ImportGroup::External,
7205 }
7206}
7207
7208#[derive(Debug, Clone)]
7209struct ReexportRefs {
7210 raw_refs: Vec<RawRef>,
7211 surface_parts: Vec<String>,
7212}
7213
7214fn collect_reexport_refs(
7215 project_root: &Path,
7216 abs_path: &Path,
7217 rel_path: &str,
7218 source: &str,
7219) -> ReexportRefs {
7220 let mut raw_refs = Vec::new();
7221 let mut surface_parts = Vec::new();
7222 let mut search_start = 0usize;
7223 let mut ordinal = 0usize;
7224 while let Some(export_offset) = source[search_start..].find("export") {
7225 let start = search_start + export_offset;
7226 let Some(statement_end_offset) = source[start..].find(';') else {
7227 break;
7228 };
7229 let end = start + statement_end_offset + 1;
7230 let statement = &source[start..end];
7231 search_start = end;
7232 if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
7233 continue;
7234 }
7235 let Some(module_path) = quoted_module_path(statement) else {
7236 continue;
7237 };
7238 ordinal += 1;
7239 let wildcard = statement.contains('*');
7240 let line = source[..start]
7241 .bytes()
7242 .filter(|byte| *byte == b'\n')
7243 .count() as u32
7244 + 1;
7245 let ref_id = ref_id(&[
7246 rel_path,
7247 "reexport",
7248 &start.to_string(),
7249 &end.to_string(),
7250 &module_path,
7251 &ordinal.to_string(),
7252 ]);
7253 surface_parts.push(format!("reexport\t{statement}"));
7254 raw_refs.push(RawRef {
7255 ref_id,
7256 caller_node: None,
7257 caller_symbol: None,
7258 caller_file: rel_path.to_string(),
7259 kind: "reexport".to_string(),
7260 short_name: None,
7261 full_ref: Some(statement.to_string()),
7262 module_path: Some(module_path.clone()),
7263 import_kind: Some("reexport".to_string()),
7264 local_name: None,
7265 requested_name: None,
7266 namespace_alias: None,
7267 wildcard,
7268 line,
7269 byte_start: start,
7270 byte_end: end,
7271 dependencies: module_dependencies(project_root, abs_path, &module_path),
7272 });
7273 }
7274 ReexportRefs {
7275 raw_refs,
7276 surface_parts,
7277 }
7278}
7279
7280fn collect_rust_pub_use_reexport_refs(
7281 project_root: &Path,
7282 abs_path: &Path,
7283 rel_path: &str,
7284 imports: &[ImportStatement],
7285 line_index: &LineIndex,
7286) -> ReexportRefs {
7287 let mut raw_refs = Vec::new();
7288 let mut surface_parts = Vec::new();
7289 let mut ordinal = 0usize;
7290
7291 for import in imports {
7292 let Some(visibility) = &import.default_import else {
7293 continue;
7294 };
7295 if !visibility.starts_with("pub") {
7296 continue;
7297 }
7298 let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
7299 continue;
7300 };
7301 ordinal += 1;
7302 let ref_id = ref_id(&[
7303 rel_path,
7304 "rust_reexport",
7305 &import.byte_range.start.to_string(),
7306 &import.byte_range.end.to_string(),
7307 &module_path,
7308 &ordinal.to_string(),
7309 ]);
7310 surface_parts.push(format!("reexport\t{}", import.raw_text));
7311 raw_refs.push(RawRef {
7312 ref_id,
7313 caller_node: None,
7314 caller_symbol: None,
7315 caller_file: rel_path.to_string(),
7316 kind: "reexport".to_string(),
7317 short_name: None,
7318 full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
7319 module_path: Some(module_path.clone()),
7320 import_kind: Some("reexport".to_string()),
7321 local_name: None,
7322 requested_name: None,
7323 namespace_alias: None,
7324 wildcard,
7325 line: line_index.byte_to_line(import.byte_range.start),
7326 byte_start: import.byte_range.start,
7327 byte_end: import.byte_range.end,
7328 dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
7329 });
7330 }
7331
7332 ReexportRefs {
7333 raw_refs,
7334 surface_parts,
7335 }
7336}
7337
7338fn rust_pub_use_reexport_parts(
7339 import: &ImportStatement,
7340) -> Option<(String, HashMap<String, String>, bool)> {
7341 let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
7342 let body = body.trim();
7343 if let Some(module_path) = body.strip_suffix("::*") {
7344 return Some((module_path.trim().to_string(), HashMap::new(), true));
7345 }
7346
7347 if let Some(brace_start) = body.find("::{") {
7348 let module_path = body[..brace_start].trim().to_string();
7349 let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
7350 if names.is_empty() {
7351 return None;
7352 }
7353 return Some((module_path, names, false));
7354 }
7355
7356 let (module_path, spec) = body.rsplit_once("::")?;
7357 let names = rust_reexport_names_from_specs(spec);
7358 if names.is_empty() {
7359 return None;
7360 }
7361 Some((module_path.trim().to_string(), names, false))
7362}
7363
7364fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
7365 let mut names = HashMap::new();
7366 for spec in specs.split(',') {
7367 let spec = spec.trim();
7368 if spec.is_empty() || spec == "self" {
7369 continue;
7370 }
7371 if let Some((source, local)) = spec.split_once(" as ") {
7372 let source = source.trim();
7373 let local = local.trim();
7374 if !source.is_empty() && !local.is_empty() && source != "self" {
7375 names.insert(local.to_string(), source.to_string());
7376 }
7377 } else {
7378 names.insert(spec.to_string(), spec.to_string());
7379 }
7380 }
7381 names
7382}
7383
7384fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
7385 if named.is_empty() {
7386 return fallback.to_string();
7387 }
7388 let mut specs = named
7389 .iter()
7390 .map(|(local, source)| {
7391 if local == source {
7392 source.clone()
7393 } else {
7394 format!("{source} as {local}")
7395 }
7396 })
7397 .collect::<Vec<_>>();
7398 specs.sort();
7399 format!("pub use {{{}}};", specs.join(", "))
7400}
7401
7402fn quoted_module_path(statement: &str) -> Option<String> {
7403 let quote = match (statement.find('\''), statement.find('"')) {
7404 (Some(single), Some(double)) if single < double => '\'',
7405 (Some(_), Some(_)) => '"',
7406 (Some(_), None) => '\'',
7407 (None, Some(_)) => '"',
7408 (None, None) => return None,
7409 };
7410 let start = statement.find(quote)? + 1;
7411 let end = statement[start..].find(quote)? + start;
7412 Some(statement[start..end].to_string())
7413}
7414
7415#[derive(Debug, Clone)]
7416struct SourceLessExportRefs {
7417 raw_refs: Vec<RawRef>,
7418 surface_parts: Vec<String>,
7419}
7420
7421fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
7422 let mut raw_refs = Vec::new();
7423 let mut surface_parts = Vec::new();
7424 let mut search_start = 0usize;
7425 let mut ordinal = 0usize;
7426 while let Some(export_offset) = source[search_start..].find("export") {
7427 let start = search_start + export_offset;
7428 let Some(statement_end_offset) = source[start..].find(';') else {
7429 break;
7430 };
7431 let end = start + statement_end_offset + 1;
7432 let statement = &source[start..end];
7433 search_start = end;
7434 if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
7435 continue;
7436 }
7437 let aliases = parse_reexport_names(statement);
7438 if aliases.is_empty() {
7439 continue;
7440 }
7441 let line = source[..start]
7442 .bytes()
7443 .filter(|byte| *byte == b'\n')
7444 .count() as u32
7445 + 1;
7446 for (exported, source_symbol) in aliases {
7447 ordinal += 1;
7448 let ref_id = ref_id(&[
7449 rel_path,
7450 "export_alias",
7451 &start.to_string(),
7452 &end.to_string(),
7453 &exported,
7454 &source_symbol,
7455 &ordinal.to_string(),
7456 ]);
7457 surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
7458 raw_refs.push(RawRef {
7459 ref_id,
7460 caller_node: None,
7461 caller_symbol: None,
7462 caller_file: rel_path.to_string(),
7463 kind: "export_alias".to_string(),
7464 short_name: None,
7465 full_ref: Some(statement.to_string()),
7466 module_path: None,
7467 import_kind: Some("export_alias".to_string()),
7468 local_name: Some(exported),
7469 requested_name: Some(source_symbol),
7470 namespace_alias: None,
7471 wildcard: false,
7472 line,
7473 byte_start: start,
7474 byte_end: end,
7475 dependencies: BTreeSet::new(),
7476 });
7477 }
7478 }
7479 SourceLessExportRefs {
7480 raw_refs,
7481 surface_parts,
7482 }
7483}
7484
7485fn build_dispatch_hints(
7486 rel_path: &str,
7487 data: &FileCallData,
7488 node_by_scoped: &HashMap<String, String>,
7489) -> Vec<DispatchHint> {
7490 let mut hints = Vec::new();
7491 let mut ordinal = 0usize;
7492 for (caller_symbol, call_sites) in &data.calls_by_symbol {
7493 let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
7494 continue;
7495 };
7496 for call_site in call_sites {
7497 if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
7498 continue;
7499 }
7500 ordinal += 1;
7501 hints.push(DispatchHint {
7502 id: ref_id(&[
7503 rel_path,
7504 "dispatch",
7505 caller_symbol,
7506 &call_site.line.to_string(),
7507 &call_site.byte_start.to_string(),
7508 &call_site.byte_end.to_string(),
7509 &ordinal.to_string(),
7510 ]),
7511 method_name: call_site.callee_name.clone(),
7512 caller_node: caller_node.clone(),
7513 file: rel_path.to_string(),
7514 line: call_site.line,
7515 byte_start: call_site.byte_start,
7516 byte_end: call_site.byte_end,
7517 });
7518 }
7519 }
7520 hints
7521}
7522
7523fn surface_fingerprint(
7524 nodes: &mut [NodeRecord],
7525 data: &FileCallData,
7526 reexport_parts: &[String],
7527) -> String {
7528 nodes.sort_by(|left, right| {
7529 (left.file_path.as_str(), left.scoped_name.as_str())
7530 .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
7531 });
7532 let mut parts = Vec::new();
7533 for node in nodes.iter() {
7534 parts.push(format!(
7535 "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
7536 node.scoped_name,
7537 node.name,
7538 node.kind,
7539 node.exported,
7540 node.range.start_line,
7541 node.range.start_col,
7542 node.range.end_line,
7543 node.range.end_col,
7544 node.range_ordinal,
7545 node.signature.as_deref().unwrap_or("")
7546 ));
7547 }
7548 let mut exports = data.exported_symbols.clone();
7549 exports.sort();
7550 for export in exports {
7551 parts.push(format!("export\t{export}"));
7552 }
7553 if let Some(default_export) = &data.default_export_symbol {
7554 parts.push(format!("default\t{default_export}"));
7555 }
7556 let mut imports: Vec<String> = data
7557 .import_block
7558 .imports
7559 .iter()
7560 .map(|import| {
7561 format!(
7562 "import\t{}\t{:?}\t{}",
7563 import.module_path, import.form, import.raw_text
7564 )
7565 })
7566 .collect();
7567 imports.sort();
7568 parts.extend(imports);
7569 parts.extend(reexport_parts.iter().cloned());
7570 hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
7571}
7572
7573fn resolve_ref(raw: RawRef, index: &ProjectIndex<'_>) -> Result<ResolvedRef> {
7574 if raw.kind != "call" {
7575 return Ok(ResolvedRef {
7576 dependencies: raw.dependencies.clone(),
7577 raw,
7578 status: "unresolved".to_string(),
7579 target_node: None,
7580 target_file: None,
7581 target_symbol: None,
7582 edge: None,
7583 });
7584 }
7585
7586 let caller_file = raw.caller_file.clone();
7587 let caller_data = index.caller_data.get(&caller_file).ok_or_else(|| {
7588 CallGraphStoreError::MissingCallerData {
7589 file: caller_file.clone(),
7590 }
7591 })?;
7592 let full_ref = raw.full_ref.as_deref().unwrap_or_default();
7593 let short_name = raw.short_name.as_deref().unwrap_or_default();
7594 let mut dependencies = raw.dependencies.clone();
7595
7596 let resolved = match index.lang_for(&caller_file) {
7597 Some(LangId::Rust) => {
7598 resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
7599 }
7600 Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
7601 resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
7602 }
7603 _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
7604 };
7605
7606 let Some((status, target_file, target_symbol)) = resolved else {
7607 return Ok(ResolvedRef {
7608 raw,
7609 status: "unresolved".to_string(),
7610 target_node: None,
7611 target_file: None,
7612 target_symbol: None,
7613 dependencies,
7614 edge: None,
7615 });
7616 };
7617
7618 dependencies.insert(target_file.clone());
7619 let target_node = index.node_for_symbol(&target_file, &target_symbol);
7620 let source_node = raw.caller_node.clone();
7621 let edge = if let Some(source_node) = source_node {
7622 if target_file == caller_file
7623 && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
7624 {
7625 None
7626 } else {
7627 Some(EdgeRecord {
7628 edge_id: ref_id(&[&raw.ref_id, "edge"]),
7629 source_node,
7630 target_node: target_node.clone(),
7631 target_file: target_file.clone(),
7632 target_symbol: target_symbol.clone(),
7633 kind: "call".to_string(),
7634 line: raw.line,
7635 })
7636 }
7637 } else {
7638 None
7639 };
7640
7641 Ok(ResolvedRef {
7642 raw,
7643 status,
7644 target_node,
7645 target_file: Some(target_file),
7646 target_symbol: Some(target_symbol),
7647 dependencies,
7648 edge,
7649 })
7650}
7651
7652fn resolve_js_ts_target(
7653 index: &ProjectIndex<'_>,
7654 caller_file: &str,
7655 full_ref: &str,
7656 short_name: &str,
7657 caller_data: &FileCallData,
7658) -> Option<(String, String, String)> {
7659 if let Some((namespace, member)) = full_ref.split_once('.') {
7660 for import in &caller_data.import_block.imports {
7661 if import.namespace_import.as_deref() == Some(namespace) {
7662 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7663 if let Some((file, symbol)) =
7664 resolve_exported_symbol(index, &target_file, member, 0)
7665 {
7666 return Some(("resolved".to_string(), file, symbol));
7667 }
7668 }
7669 }
7670 }
7671 }
7672
7673 for import in &caller_data.import_block.imports {
7674 for spec in &import.names {
7675 if crate::imports::specifier_local_name(spec) == short_name {
7676 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7677 let requested = crate::imports::specifier_imported_name(spec);
7678 let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
7679 .unwrap_or_else(|| (target_file, requested.to_string()));
7680 return Some(("resolved".to_string(), file, symbol));
7681 }
7682 }
7683 }
7684
7685 if import.default_import.as_deref() == Some(short_name) {
7686 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7687 let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
7688 .or_else(|| {
7689 index
7690 .files
7691 .get(&target_file)
7692 .and_then(|file| file.default_export.clone())
7693 .map(|symbol| (target_file.clone(), symbol))
7694 })
7695 .unwrap_or_else(|| {
7696 let file_name = Path::new(&target_file)
7697 .file_name()
7698 .and_then(|name| name.to_str())
7699 .unwrap_or("unknown")
7700 .to_string();
7701 (target_file, format!("<default:{file_name}>"))
7702 });
7703 return Some(("resolved".to_string(), file, symbol));
7704 }
7705 }
7706 }
7707
7708 for import in &caller_data.import_block.imports {
7709 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7710 if index
7711 .files
7712 .get(&target_file)
7713 .map(|file| file.exports.contains(short_name))
7714 .unwrap_or(false)
7715 {
7716 return Some(("resolved".to_string(), target_file, short_name.to_string()));
7717 }
7718 }
7719 }
7720
7721 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
7722}
7723
7724fn resolve_exported_symbol(
7725 index: &ProjectIndex<'_>,
7726 file: &str,
7727 requested: &str,
7728 depth: usize,
7729) -> Option<(String, String)> {
7730 let mut visited = std::collections::HashMap::new();
7731 resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
7732}
7733
7734fn resolve_exported_symbol_inner(
7743 index: &ProjectIndex<'_>,
7744 file: &str,
7745 requested: &str,
7746 depth: usize,
7747 visited: &mut std::collections::HashMap<(String, String), usize>,
7748) -> Option<(String, String)> {
7749 if depth > 16 {
7750 return None;
7751 }
7752 if requested != "default" {
7753 if let Some(source_symbol) = index
7754 .files
7755 .get(file)
7756 .and_then(|item| item.export_aliases.get(requested))
7757 {
7758 return Some((file.to_string(), source_symbol.clone()));
7759 }
7760 if index
7761 .files
7762 .get(file)
7763 .map(|item| item.exports.contains(requested))
7764 .unwrap_or(false)
7765 {
7766 return Some((file.to_string(), requested.to_string()));
7767 }
7768 } else if let Some(default) = index
7769 .files
7770 .get(file)
7771 .and_then(|item| item.default_export.clone())
7772 {
7773 return Some((file.to_string(), default));
7774 }
7775
7776 match visited.entry((file.to_string(), requested.to_string())) {
7780 std::collections::hash_map::Entry::Occupied(mut seen) => {
7781 if *seen.get() <= depth {
7782 return None;
7783 }
7784 seen.insert(depth);
7785 }
7786 std::collections::hash_map::Entry::Vacant(slot) => {
7787 slot.insert(depth);
7788 }
7789 }
7790
7791 for reexport in index.reexports_for(file) {
7792 let mut next_requested = requested.to_string();
7793 let matches = if reexport.wildcard {
7794 true
7795 } else if let Some(source_name) = reexport.named.get(requested) {
7796 next_requested = source_name.clone();
7797 true
7798 } else {
7799 false
7800 };
7801 if !matches {
7802 continue;
7803 }
7804 if let Some(target_file) = &reexport.target_file {
7805 if let Some(target) = resolve_exported_symbol_inner(
7806 index,
7807 target_file,
7808 &next_requested,
7809 depth + 1,
7810 visited,
7811 ) {
7812 return Some(target);
7813 }
7814 }
7815 }
7816 None
7817}
7818
7819fn resolve_rust_target(
7820 index: &ProjectIndex<'_>,
7821 caller_file: &str,
7822 full_ref: &str,
7823 short_name: &str,
7824 caller_data: &FileCallData,
7825 raw: &RawRef,
7826) -> Option<(String, String, String)> {
7827 if full_ref.contains("::") {
7828 if let Some((target_file, target_symbol)) =
7829 rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
7830 {
7831 return Some(("resolved".to_string(), target_file, target_symbol));
7832 }
7833 }
7834
7835 for import in &caller_data.import_block.imports {
7836 if let Some((target_file, target_symbol)) =
7837 rust_target_for_use(index, caller_file, import, short_name)
7838 {
7839 return Some(("resolved".to_string(), target_file, target_symbol));
7840 }
7841 }
7842
7843 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
7844}
7845
7846fn rust_target_for_qualified(
7847 index: &ProjectIndex<'_>,
7848 caller_file: &str,
7849 full_ref: &str,
7850 short_name: &str,
7851 caller_data: &FileCallData,
7852 raw: &RawRef,
7853) -> Option<(String, String)> {
7854 let mut segments: Vec<&str> = full_ref.split("::").collect();
7855 if segments.len() < 2 {
7856 return None;
7857 }
7858 segments.pop();
7859 let requested_symbol = rust_target_symbol(full_ref, short_name);
7860
7861 for path in rust_module_path_candidates(&segments, caller_data, raw) {
7862 let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
7863 if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
7864 if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
7865 return Some(rust_resolve_reexport_if_symbol_missing(
7866 index,
7867 target_file,
7868 requested_symbol.clone(),
7869 ));
7870 }
7871 }
7872
7873 let module_segments = rust_resolve_segments(caller_file, &path_refs)?;
7874 if let Some(target) =
7875 rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
7876 {
7877 return Some(target);
7878 }
7879 if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
7880 return Some(rust_resolve_reexport_if_symbol_missing(
7881 index,
7882 target_file,
7883 requested_symbol.clone(),
7884 ));
7885 }
7886 }
7887 None
7888}
7889
7890fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
7891 full_ref
7892 .rsplit("::")
7893 .next()
7894 .filter(|name| !name.is_empty())
7895 .unwrap_or(short_name)
7896 .to_string()
7897}
7898
7899fn rust_resolve_reexport_if_symbol_missing(
7900 index: &ProjectIndex<'_>,
7901 target_file: String,
7902 target_symbol: String,
7903) -> (String, String) {
7904 if index
7905 .node_for_symbol(&target_file, &target_symbol)
7906 .is_some()
7907 {
7908 return (target_file, target_symbol);
7909 }
7910 if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
7911 resolved
7912 } else {
7913 (target_file, target_symbol)
7914 }
7915}
7916
7917fn rust_module_path_candidates(
7918 segments: &[&str],
7919 caller_data: &FileCallData,
7920 raw: &RawRef,
7921) -> Vec<Vec<String>> {
7922 let mut candidates = Vec::new();
7923 if let Some(first) = segments.first().copied() {
7924 for import in &caller_data.import_block.imports {
7925 if !rust_import_is_visible_to_call(import, raw) {
7926 continue;
7927 }
7928 let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
7929 continue;
7930 };
7931 if local_name == first {
7932 path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
7933 rust_push_unique_path_candidate(&mut candidates, path_segments);
7934 }
7935 }
7936 }
7937 rust_push_unique_path_candidate(
7938 &mut candidates,
7939 segments
7940 .iter()
7941 .map(|segment| (*segment).to_string())
7942 .collect(),
7943 );
7944 candidates
7945}
7946
7947fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
7948 if !candidates.iter().any(|existing| existing == &candidate) {
7949 candidates.push(candidate);
7950 }
7951}
7952
7953fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
7954 import.byte_range.start <= raw.byte_start
7955}
7956
7957fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
7958 let path = import.module_path.trim().trim_end_matches(';').trim();
7959 if path.contains("::{") || path.contains('{') || path.contains('*') {
7960 return None;
7961 }
7962 let (path_without_alias, alias) = path
7963 .split_once(" as ")
7964 .map(|(left, right)| (left.trim(), Some(right.trim())))
7965 .unwrap_or((path, None));
7966 let segments = path_without_alias
7967 .split("::")
7968 .map(str::trim)
7969 .filter(|segment| !segment.is_empty())
7970 .collect::<Vec<_>>();
7971 let local_name = alias.or_else(|| segments.last().copied())?.to_string();
7972 if local_name.chars().next().is_some_and(char::is_uppercase) {
7973 return None;
7974 }
7975 Some((
7976 local_name,
7977 segments
7978 .into_iter()
7979 .map(|segment| segment.to_string())
7980 .collect(),
7981 ))
7982}
7983
7984fn rust_inline_scoped_target(
7985 index: &ProjectIndex<'_>,
7986 caller_file: &str,
7987 module_segments: &[String],
7988 short_name: &str,
7989) -> Option<(String, String)> {
7990 let src_prefix = rust_src_prefix(caller_file);
7991 let mut file_paths = index.files.keys().cloned().collect::<Vec<_>>();
7992 file_paths.sort();
7993 if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
7994 let caller = file_paths.remove(position);
7995 file_paths.insert(0, caller);
7996 }
7997
7998 for file_path in file_paths {
7999 if index.lang_for(&file_path) != Some(LangId::Rust)
8000 || rust_src_prefix(&file_path) != src_prefix
8001 {
8002 continue;
8003 }
8004 let file_module_segments = rust_module_segments_for_rel(&file_path);
8005 if !module_segments.starts_with(&file_module_segments) {
8006 continue;
8007 }
8008 let scoped_segments = &module_segments[file_module_segments.len()..];
8009 if scoped_segments.is_empty() {
8010 continue;
8011 }
8012 let mut scoped_symbol = scoped_segments.join("::");
8013 scoped_symbol.push_str("::");
8014 scoped_symbol.push_str(short_name);
8015 if index.node_for_symbol(&file_path, &scoped_symbol).is_some() {
8016 return Some((file_path, scoped_symbol));
8017 }
8018 }
8019 None
8020}
8021
8022fn rust_target_for_use(
8023 index: &ProjectIndex<'_>,
8024 caller_file: &str,
8025 import: &ImportStatement,
8026 short_name: &str,
8027) -> Option<(String, String)> {
8028 let path = import.module_path.trim().trim_end_matches(';');
8029 if let Some(brace_start) = path.find("::{") {
8030 let prefix = &path[..brace_start];
8031 if import.names.iter().any(|name| name == short_name) {
8032 let prefix_segments: Vec<&str> = prefix.split("::").collect();
8033 let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
8034 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
8035 return Some((file, short_name.to_string()));
8036 }
8037 return None;
8038 }
8039
8040 let (path_without_alias, alias) = path
8041 .split_once(" as ")
8042 .map(|(left, right)| (left.trim(), Some(right.trim())))
8043 .unwrap_or((path, None));
8044 let segments: Vec<&str> = path_without_alias.split("::").collect();
8045 let imported = alias.or_else(|| segments.last().copied())?;
8046 if imported != short_name {
8047 return None;
8048 }
8049 if segments.len() < 2 {
8050 return None;
8051 }
8052 let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
8053 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
8054 Some((file, segments.last().unwrap_or(&short_name).to_string()))
8055}
8056
8057fn rust_workspace_file_for_segments(index: &ProjectIndex<'_>, segments: &[&str]) -> Option<String> {
8058 let crate_name = segments.first().copied()?;
8059 let src_prefix = index.crate_src_prefix(crate_name)?;
8060 let module_segments = segments[1..]
8061 .iter()
8062 .map(|segment| segment.to_string())
8063 .collect::<Vec<_>>();
8064 rust_file_for_src_prefix(index, &src_prefix, &module_segments)
8065}
8066
8067#[cfg(test)]
8068static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
8069 OnceLock::new();
8070
8071#[cfg(test)]
8072fn note_workspace_crate_prefix_build(project_root: &Path) {
8073 let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
8074 .get_or_init(|| Mutex::new(HashMap::new()))
8075 .lock()
8076 .expect("workspace crate prefix build counts mutex poisoned");
8077 *counts.entry(project_root.to_path_buf()).or_default() += 1;
8078}
8079
8080#[cfg(not(test))]
8081fn note_workspace_crate_prefix_build(_project_root: &Path) {}
8082
8083#[cfg(test)]
8084fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
8085 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
8086 .get_or_init(|| Mutex::new(HashMap::new()))
8087 .lock()
8088 .expect("workspace crate prefix build counts mutex poisoned")
8089 .remove(project_root);
8090}
8091
8092#[cfg(test)]
8093fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
8094 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
8095 .get_or_init(|| Mutex::new(HashMap::new()))
8096 .lock()
8097 .expect("workspace crate prefix build counts mutex poisoned")
8098 .get(project_root)
8099 .copied()
8100 .unwrap_or(0)
8101}
8102
8103fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
8108 note_workspace_crate_prefix_build(project_root);
8109 let mut prefixes = HashMap::new();
8110 let mut stack = vec![project_root.to_path_buf()];
8111 while let Some(dir) = stack.pop() {
8112 let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
8113 if matches!(name, "target" | "node_modules" | ".git") {
8114 continue;
8115 }
8116 let manifest = dir.join("Cargo.toml");
8117 if manifest.is_file() {
8118 let crate_names = rust_manifest_crate_names(&manifest);
8119 if !crate_names.is_empty() {
8120 let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
8121 for crate_name in crate_names {
8122 prefixes
8123 .entry(crate_name)
8124 .or_insert_with(|| src_prefix.clone());
8125 }
8126 }
8127 }
8128 let Ok(entries) = std::fs::read_dir(&dir) else {
8129 continue;
8130 };
8131 for entry in entries.flatten() {
8132 let path = entry.path();
8133 if path.is_dir() {
8134 stack.push(path);
8135 }
8136 }
8137 }
8138 prefixes
8139}
8140
8141fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
8145 let Ok(source) = std::fs::read_to_string(manifest) else {
8146 return Vec::new();
8147 };
8148 let mut in_lib = false;
8149 let mut package_name = None;
8150 let mut lib_name = None;
8151 for line in source.lines() {
8152 let trimmed = line.trim();
8153 if trimmed.starts_with('[') {
8154 in_lib = trimmed == "[lib]";
8155 continue;
8156 }
8157 let Some((key, value)) = trimmed.split_once('=') else {
8158 continue;
8159 };
8160 let key = key.trim();
8161 let value = value.trim().trim_matches('"');
8162 if in_lib && key == "name" {
8163 lib_name = Some(value.to_string());
8164 } else if !in_lib && key == "name" && package_name.is_none() {
8165 package_name = Some(value.to_string());
8166 }
8167 }
8168 let mut names = Vec::new();
8169 if let Some(lib) = lib_name {
8170 names.push(lib);
8171 }
8172 if let Some(package) = package_name {
8173 let normalized = package.replace('-', "_");
8174 if !names.contains(&normalized) {
8175 names.push(normalized);
8176 }
8177 }
8178 names
8179}
8180
8181fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
8182 if segments.is_empty() {
8183 return Some(Vec::new());
8184 }
8185 let caller_segments = rust_module_segments_for_rel(caller_file);
8186 match segments[0] {
8187 "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
8188 "self" => {
8189 let mut resolved = caller_segments;
8190 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
8191 Some(resolved)
8192 }
8193 "super" => {
8194 let mut resolved = caller_segments;
8195 resolved.pop();
8196 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
8197 Some(resolved)
8198 }
8199 _ => {
8200 let mut resolved = caller_segments;
8201 resolved.pop();
8202 resolved.extend(segments.iter().map(|item| item.to_string()));
8203 Some(resolved)
8204 }
8205 }
8206}
8207
8208fn rust_file_for_segments(
8209 index: &ProjectIndex<'_>,
8210 caller_file: &str,
8211 segments: &[String],
8212) -> Option<String> {
8213 rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
8214}
8215
8216fn rust_file_for_src_prefix(
8217 index: &ProjectIndex<'_>,
8218 src_prefix: &str,
8219 segments: &[String],
8220) -> Option<String> {
8221 let candidate = if segments.is_empty() {
8222 [src_prefix, "lib.rs"].join("/")
8223 } else {
8224 format!("{}/{}.rs", src_prefix, segments.join("/"))
8225 };
8226 if index.files.contains_key(&candidate) {
8227 return Some(candidate);
8228 }
8229 if !segments.is_empty() {
8230 let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
8231 if index.files.contains_key(&mod_candidate) {
8232 return Some(mod_candidate);
8233 }
8234 }
8235 None
8236}
8237
8238fn rust_src_prefix(rel_path: &str) -> String {
8239 rel_path
8240 .split_once("/src/")
8241 .map(|(prefix, _)| format!("{prefix}/src"))
8242 .unwrap_or_else(|| "src".to_string())
8243}
8244
8245fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
8246 let after_src = rel_path
8247 .split_once("/src/")
8248 .map(|(_, rest)| rest)
8249 .or_else(|| rel_path.strip_prefix("src/"))
8250 .unwrap_or(rel_path);
8251 if matches!(after_src, "lib.rs" | "main.rs") {
8252 return Vec::new();
8253 }
8254 if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
8255 return prefix.split('/').map(|item| item.to_string()).collect();
8256 }
8257 after_src
8258 .strip_suffix(".rs")
8259 .unwrap_or(after_src)
8260 .split('/')
8261 .map(|item| item.to_string())
8262 .collect()
8263}
8264
8265fn resolve_local_target(
8266 _index: &ProjectIndex<'_>,
8267 caller_file: &str,
8268 full_ref: &str,
8269 short_name: &str,
8270 caller_data: &FileCallData,
8271) -> Option<(String, String, String)> {
8272 if !callgraph::is_bare_callee(full_ref, short_name) {
8273 return None;
8274 }
8275 callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
8276 .ok()
8277 .map(|symbol| {
8278 (
8279 "resolved_local".to_string(),
8280 caller_file.to_string(),
8281 symbol,
8282 )
8283 })
8284}
8285
8286impl<'a> ProjectIndex<'a> {
8287 fn from_parts(
8288 project_root: &Path,
8289 files: HashMap<String, DbFileIndex>,
8290 caller_data: HashMap<String, &'a FileCallData>,
8291 workspace_crate_prefixes: WorkspaceCratePrefixCache,
8292 ) -> Self {
8293 Self {
8294 project_root: project_root.to_path_buf(),
8295 files,
8296 caller_data,
8297 workspace_crate_prefixes,
8298 }
8299 }
8300
8301 fn from_extracts(project_root: &Path, extracts: &'a [FileExtract]) -> Self {
8302 let mut files = HashMap::new();
8303 let mut caller_data = HashMap::new();
8304 for extract in extracts {
8305 let index = DbFileIndex::from_extract(project_root, extract);
8306 caller_data.insert(extract.rel_path.clone(), &extract.data);
8307 files.insert(extract.rel_path.clone(), index);
8308 }
8309 Self::from_parts(
8310 project_root,
8311 files,
8312 caller_data,
8313 WorkspaceCratePrefixCache::default(),
8314 )
8315 }
8316
8317 fn from_db_and_callers(
8318 tx: &Transaction<'_>,
8319 project_root: &Path,
8320 caller_extracts: &'a HashMap<String, FileExtract>,
8321 workspace_crate_prefixes: WorkspaceCratePrefixCache,
8322 ) -> Result<Self> {
8323 let mut files = load_db_file_indexes(tx, project_root)?;
8324 let mut caller_data = HashMap::new();
8325 for (rel_path, extract) in caller_extracts {
8326 files.insert(
8327 rel_path.clone(),
8328 DbFileIndex::from_extract(project_root, extract),
8329 );
8330 caller_data.insert(rel_path.clone(), &extract.data);
8331 }
8332 Ok(Self::from_parts(
8333 project_root,
8334 files,
8335 caller_data,
8336 workspace_crate_prefixes,
8337 ))
8338 }
8339
8340 fn lang_for(&self, rel_path: &str) -> Option<LangId> {
8341 self.files.get(rel_path).and_then(|file| file.lang)
8342 }
8343
8344 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
8345 self.files
8346 .get(caller_file)
8347 .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
8348 }
8349
8350 fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
8351 self.files
8352 .get(rel_path)
8353 .map(|file| file.reexports.as_slice())
8354 .unwrap_or(&[])
8355 }
8356
8357 fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
8358 self.files.get(rel_path).and_then(|file| {
8359 file.node_by_scoped
8360 .get(symbol)
8361 .cloned()
8362 .or_else(|| file.node_by_bare.get(symbol).cloned())
8363 })
8364 }
8365}
8366
8367impl DbFileIndex {
8368 fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
8369 let mut node_by_scoped = HashMap::new();
8370 let mut node_by_bare = HashMap::new();
8371 for node in &extract.nodes {
8372 node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
8373 node_by_bare
8374 .entry(node.name.clone())
8375 .or_insert(node.id.clone());
8376 }
8377 let mut export_aliases = HashMap::new();
8378 for raw_ref in &extract.raw_refs {
8379 if raw_ref.kind == "export_alias" {
8380 if let (Some(exported), Some(source_symbol)) =
8381 (&raw_ref.local_name, &raw_ref.requested_name)
8382 {
8383 export_aliases.insert(exported.clone(), source_symbol.clone());
8384 }
8385 }
8386 }
8387 let mut module_targets = HashMap::new();
8388 let mut reexports = Vec::new();
8389 for raw_ref in &extract.raw_refs {
8390 if !matches!(raw_ref.kind.as_str(), "import" | "reexport") {
8391 continue;
8392 }
8393 let Some(module_path) = &raw_ref.module_path else {
8394 continue;
8395 };
8396 let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
8397 module_targets
8398 .entry(module_path.clone())
8399 .or_insert_with(|| target_file.clone());
8400 if raw_ref.kind == "reexport" {
8401 reexports.push(reexport_index_from_raw(raw_ref, target_file));
8402 }
8403 }
8404 Self {
8405 lang: Some(extract.lang),
8406 exports: extract.data.exported_symbols.iter().cloned().collect(),
8407 default_export: extract.data.default_export_symbol.clone(),
8408 export_aliases,
8409 node_by_scoped,
8410 node_by_bare,
8411 module_targets,
8412 reexports,
8413 }
8414 }
8415}
8416
8417fn load_db_file_indexes(
8418 tx: &Transaction<'_>,
8419 project_root: &Path,
8420) -> Result<HashMap<String, DbFileIndex>> {
8421 let mut files = HashMap::new();
8422 let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
8423 let rows = stmt.query_map([], |row| {
8424 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8425 })?;
8426 for row in rows {
8427 let (rel_path, lang) = row?;
8428 files.insert(
8429 rel_path.clone(),
8430 DbFileIndex {
8431 lang: lang_from_label(&lang),
8432 exports: HashSet::new(),
8433 default_export: None,
8434 export_aliases: HashMap::new(),
8435 node_by_scoped: HashMap::new(),
8436 node_by_bare: HashMap::new(),
8437 module_targets: HashMap::new(),
8438 reexports: Vec::new(),
8439 },
8440 );
8441 }
8442
8443 let mut node_stmt = tx.prepare(
8444 "SELECT file_path, id, name, scoped_name, exported, is_default_export FROM nodes",
8445 )?;
8446 let nodes = node_stmt.query_map([], |row| {
8447 Ok((
8448 row.get::<_, String>(0)?,
8449 row.get::<_, String>(1)?,
8450 row.get::<_, String>(2)?,
8451 row.get::<_, String>(3)?,
8452 row.get::<_, i64>(4)? != 0,
8453 row.get::<_, i64>(5)? != 0,
8454 ))
8455 })?;
8456 for row in nodes {
8457 let (file_path, id, name, scoped_name, exported, is_default_export) = row?;
8458 let file = files
8459 .entry(file_path.clone())
8460 .or_insert_with(|| DbFileIndex {
8461 lang: None,
8462 exports: HashSet::new(),
8463 default_export: None,
8464 export_aliases: HashMap::new(),
8465 node_by_scoped: HashMap::new(),
8466 node_by_bare: HashMap::new(),
8467 module_targets: HashMap::new(),
8468 reexports: Vec::new(),
8469 });
8470 if exported {
8471 file.exports.insert(name.clone());
8472 file.exports.insert(scoped_name.clone());
8473 }
8474 if is_default_export {
8475 file.default_export = Some(scoped_name.clone());
8476 }
8477 file.node_by_scoped.insert(scoped_name, id.clone());
8478 file.node_by_bare.entry(name).or_insert(id);
8479 }
8480 let file_keys: HashSet<String> = files.keys().cloned().collect();
8481 let dependencies_by_file = load_file_dependencies_index(tx)?;
8485 let mut ref_stmt = tx.prepare(
8486 "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
8487 FROM refs WHERE kind IN ('reexport', 'export_alias')",
8488 )?;
8489 let ref_rows = ref_stmt.query_map([], |row| {
8490 Ok((
8491 row.get::<_, String>(0)?,
8492 row.get::<_, String>(1)?,
8493 row.get::<_, String>(2)?,
8494 row.get::<_, Option<String>>(3)?,
8495 row.get::<_, Option<String>>(4)?,
8496 row.get::<_, i64>(5)? != 0,
8497 row.get::<_, Option<String>>(6)?,
8498 row.get::<_, Option<String>>(7)?,
8499 ))
8500 })?;
8501 for row in ref_rows {
8502 let (
8503 ref_id,
8504 caller_file,
8505 kind,
8506 module_path,
8507 full_ref,
8508 wildcard,
8509 local_name,
8510 requested_name,
8511 ) = row?;
8512 if kind == "export_alias" {
8513 if let (Some(exported), Some(source_symbol), Some(file)) =
8514 (local_name, requested_name, files.get_mut(&caller_file))
8515 {
8516 file.export_aliases.insert(exported, source_symbol);
8517 }
8518 continue;
8519 }
8520 let Some(module_path) = module_path else {
8521 continue;
8522 };
8523 let file_deps = dependencies_by_file
8524 .get(&caller_file)
8525 .cloned()
8526 .unwrap_or_default();
8527 let deps = stored_dependencies_for_module(
8528 project_root,
8529 &caller_file,
8530 &module_path,
8531 &file_deps,
8532 &file_keys,
8533 );
8534 let target_file = deps
8535 .iter()
8536 .find(|dep| file_keys.contains(*dep))
8537 .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
8538 if let Some(file) = files.get_mut(&caller_file) {
8539 file.module_targets
8540 .entry(module_path.clone())
8541 .or_insert_with(|| target_file.clone());
8542 if kind == "reexport" {
8543 let raw = RawRef {
8544 ref_id,
8545 caller_node: None,
8546 caller_symbol: None,
8547 caller_file,
8548 kind,
8549 short_name: None,
8550 full_ref,
8551 module_path: Some(module_path),
8552 import_kind: Some("reexport".to_string()),
8553 local_name: None,
8554 requested_name: None,
8555 namespace_alias: None,
8556 wildcard,
8557 line: 0,
8558 byte_start: 0,
8559 byte_end: 0,
8560 dependencies: deps,
8561 };
8562 file.reexports
8563 .push(reexport_index_from_raw(&raw, target_file));
8564 }
8565 }
8566 }
8567
8568 Ok(files)
8569}
8570
8571fn stored_dependencies_for_module(
8572 project_root: &Path,
8573 caller_file: &str,
8574 module_path: &str,
8575 caller_dependencies: &BTreeSet<String>,
8576 indexed_files: &HashSet<String>,
8577) -> BTreeSet<String> {
8578 let caller_path = project_root.join(caller_file);
8579 let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
8580 if module_path.starts_with('.') {
8581 let caller_dir = caller_path.parent().unwrap_or(project_root);
8582 for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
8583 let normalized = if candidate.is_file() {
8584 canonicalize_path(&candidate)
8585 } else {
8586 candidate
8587 };
8588 candidates.insert(relative_path(project_root, &normalized));
8589 }
8590 }
8591 let exact = candidates
8592 .intersection(caller_dependencies)
8593 .filter(|dependency| indexed_files.contains(*dependency))
8594 .cloned()
8595 .collect::<BTreeSet<_>>();
8596 if !exact.is_empty() || module_path.starts_with('.') {
8597 return exact;
8598 }
8599
8600 let module_path = rust_module_path_without_alias_or_use_list(module_path)
8601 .trim_matches(|character| matches!(character, '\'' | '"'));
8602 let package_name = module_path
8603 .split('/')
8604 .next_back()
8605 .unwrap_or(module_path)
8606 .replace('_', "-");
8607 let matched = caller_dependencies
8608 .iter()
8609 .filter(|dependency| indexed_files.contains(*dependency))
8610 .filter(|dependency| {
8611 dependency.as_str() == module_path
8612 || dependency.ends_with(&format!("/{module_path}"))
8613 || Path::new(dependency).components().any(|component| {
8614 component.as_os_str().to_string_lossy().replace('_', "-") == package_name
8615 })
8616 })
8617 .cloned()
8618 .collect::<BTreeSet<_>>();
8619 if matched.len() == 1 {
8620 matched
8621 } else {
8622 BTreeSet::new()
8623 }
8624}
8625
8626fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
8627 let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
8628 let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
8629 let rows = stmt.query_map([], |row| {
8630 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8631 })?;
8632 for row in rows {
8633 let (file_path, dependency) = row?;
8634 by_file.entry(file_path).or_default().insert(dependency);
8635 }
8636 Ok(by_file)
8637}
8638
8639struct ColdBuildInsertStatements<'stmt> {
8640 file: Statement<'stmt>,
8641 node: Statement<'stmt>,
8642 file_dependency: Statement<'stmt>,
8643 dispatch_hint: Statement<'stmt>,
8644 backend_state: Statement<'stmt>,
8645 reference: Statement<'stmt>,
8646 edge: Statement<'stmt>,
8647}
8648
8649impl<'stmt> ColdBuildInsertStatements<'stmt> {
8650 fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
8651 Ok(Self {
8652 file: tx.prepare(
8653 "INSERT OR REPLACE INTO files(
8654 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
8655 is_public_api, surface_fingerprint, indexed_at
8656 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
8657 )?,
8658 node: tx.prepare(
8659 "INSERT OR REPLACE INTO nodes(
8660 id, file_path, name, scoped_name, kind, start_line, start_col,
8661 end_line, end_col, range_ordinal, signature, exported,
8662 is_default_export, is_type_like, is_callgraph_entry_point, provenance
8663 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
8664 )?,
8665 file_dependency: tx.prepare(
8666 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
8667 )?,
8668 dispatch_hint: tx.prepare(
8669 "INSERT OR REPLACE INTO dispatch_hints(
8670 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
8671 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
8672 )?,
8673 backend_state: tx.prepare(
8674 "INSERT OR REPLACE INTO backend_file_state(
8675 backend, workspace_root, file_path, content_hash, status, updated_at
8676 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
8677 )?,
8678 reference: tx.prepare(
8679 "INSERT OR REPLACE INTO refs(
8680 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
8681 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
8682 byte_start, byte_end, status, target_node, target_file, target_symbol,
8683 provenance
8684 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
8685 )?,
8686 edge: tx.prepare(
8687 "INSERT OR REPLACE INTO edges(
8688 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8689 kind, line, provenance
8690 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
8691 )?,
8692 })
8693 }
8694}
8695
8696fn insert_file_extract_prepared(
8697 statements: &mut ColdBuildInsertStatements<'_>,
8698 workspace_root: &str,
8699 extract: &FileExtract,
8700) -> Result<()> {
8701 statements.file.execute(params![
8702 extract.rel_path,
8703 hash_to_hex(extract.freshness.content_hash),
8704 system_time_to_ns(extract.freshness.mtime),
8705 extract.freshness.size as i64,
8706 lang_label(extract.lang),
8707 extract.surface_fingerprint,
8708 unix_seconds_now(),
8709 ])?;
8710 for node in &extract.nodes {
8711 statements.node.execute(params![
8712 node.id,
8713 node.file_path,
8714 node.name,
8715 node.scoped_name,
8716 node.kind,
8717 node.range.start_line as i64,
8718 node.range.start_col as i64,
8719 node.range.end_line as i64,
8720 node.range.end_col as i64,
8721 node.range_ordinal as i64,
8722 node.signature,
8723 bool_int(node.exported),
8724 bool_int(node.is_default_export),
8725 bool_int(node.is_type_like),
8726 bool_int(node.is_callgraph_entry_point),
8727 PROVENANCE_TREESITTER,
8728 ])?;
8729 }
8730
8731 let mut dependencies = BTreeSet::new();
8732 for raw_ref in &extract.raw_refs {
8733 dependencies.extend(raw_ref.dependencies.iter().cloned());
8734 }
8735 for dep_file in &dependencies {
8736 statements
8737 .file_dependency
8738 .execute(params![extract.rel_path, dep_file])?;
8739 }
8740
8741 for hint in &extract.dispatch_hints {
8742 statements.dispatch_hint.execute(params![
8743 hint.id,
8744 hint.method_name,
8745 hint.caller_node,
8746 hint.file,
8747 hint.line as i64,
8748 hint.byte_start as i64,
8749 hint.byte_end as i64,
8750 PROVENANCE_TREESITTER,
8751 ])?;
8752 }
8753 insert_backend_state_prepared(
8754 &mut statements.backend_state,
8755 workspace_root,
8756 &extract.rel_path,
8757 Some(&extract.freshness.content_hash),
8758 "fresh",
8759 )?;
8760 Ok(())
8761}
8762
8763fn insert_backend_state_prepared(
8764 stmt: &mut Statement<'_>,
8765 workspace_root: &str,
8766 rel_path: &str,
8767 content_hash: Option<&blake3::Hash>,
8768 status: &str,
8769) -> Result<()> {
8770 let hash = content_hash
8771 .map(|hash| hash_to_hex(*hash))
8772 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
8773 stmt.execute(params![
8774 BACKEND_TREESITTER,
8775 workspace_root,
8776 rel_path,
8777 hash,
8778 status,
8779 unix_seconds_now(),
8780 ])?;
8781 Ok(())
8782}
8783
8784fn insert_resolved_ref_prepared(
8785 statements: &mut ColdBuildInsertStatements<'_>,
8786 resolved: &ResolvedRef,
8787) -> Result<()> {
8788 let raw = &resolved.raw;
8789 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
8790 statements.reference.execute(params![
8791 raw.ref_id,
8792 raw.caller_node,
8793 raw.caller_file,
8794 raw.kind,
8795 raw.short_name,
8796 raw.full_ref,
8797 raw.module_path,
8798 raw.import_kind,
8799 raw.local_name,
8800 raw.requested_name,
8801 raw.namespace_alias,
8802 bool_int(raw.wildcard),
8803 raw.line as i64,
8804 raw.byte_start as i64,
8805 raw.byte_end as i64,
8806 resolved.status,
8807 resolved.target_node,
8808 resolved.target_file,
8809 resolved.target_symbol,
8810 PROVENANCE_TREESITTER,
8811 ])?;
8812 if let Some(edge) = &resolved.edge {
8813 statements.edge.execute(params![
8814 edge.edge_id,
8815 raw.ref_id,
8816 edge.source_node,
8817 edge.target_node,
8818 edge.target_file,
8819 edge.target_symbol,
8820 edge.kind,
8821 edge.line as i64,
8822 PROVENANCE_TREESITTER,
8823 ])?;
8824 }
8825 Ok(())
8826}
8827
8828fn insert_file_extract(
8829 tx: &Transaction<'_>,
8830 project_root: &Path,
8831 extract: &FileExtract,
8832) -> Result<()> {
8833 tx.execute(
8834 "INSERT OR REPLACE INTO files(
8835 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
8836 is_public_api, surface_fingerprint, indexed_at
8837 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
8838 params![
8839 extract.rel_path,
8840 hash_to_hex(extract.freshness.content_hash),
8841 system_time_to_ns(extract.freshness.mtime),
8842 extract.freshness.size as i64,
8843 lang_label(extract.lang),
8844 extract.surface_fingerprint,
8845 unix_seconds_now(),
8846 ],
8847 )?;
8848 for node in &extract.nodes {
8849 tx.execute(
8850 "INSERT OR REPLACE INTO nodes(
8851 id, file_path, name, scoped_name, kind, start_line, start_col,
8852 end_line, end_col, range_ordinal, signature, exported,
8853 is_default_export, is_type_like, is_callgraph_entry_point, provenance
8854 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
8855 params![
8856 node.id,
8857 node.file_path,
8858 node.name,
8859 node.scoped_name,
8860 node.kind,
8861 node.range.start_line as i64,
8862 node.range.start_col as i64,
8863 node.range.end_line as i64,
8864 node.range.end_col as i64,
8865 node.range_ordinal as i64,
8866 node.signature,
8867 bool_int(node.exported),
8868 bool_int(node.is_default_export),
8869 bool_int(node.is_type_like),
8870 bool_int(node.is_callgraph_entry_point),
8871 PROVENANCE_TREESITTER,
8872 ],
8873 )?;
8874 }
8875 let mut dependencies = BTreeSet::new();
8876 for raw_ref in &extract.raw_refs {
8877 dependencies.extend(raw_ref.dependencies.iter().cloned());
8878 }
8879 insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
8880
8881 for hint in &extract.dispatch_hints {
8882 tx.execute(
8883 "INSERT OR REPLACE INTO dispatch_hints(
8884 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
8885 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
8886 params![
8887 hint.id,
8888 hint.method_name,
8889 hint.caller_node,
8890 hint.file,
8891 hint.line as i64,
8892 hint.byte_start as i64,
8893 hint.byte_end as i64,
8894 PROVENANCE_TREESITTER,
8895 ],
8896 )?;
8897 }
8898 mark_backend_state(
8899 tx,
8900 project_root,
8901 &extract.rel_path,
8902 Some(&extract.freshness.content_hash),
8903 "fresh",
8904 )?;
8905 Ok(())
8906}
8907
8908fn insert_file_dependencies(
8909 tx: &Transaction<'_>,
8910 file_path: &str,
8911 dependencies: &BTreeSet<String>,
8912) -> Result<()> {
8913 for dep_file in dependencies {
8914 tx.execute(
8915 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
8916 params![file_path, dep_file],
8917 )?;
8918 }
8919 Ok(())
8920}
8921
8922fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
8923 let raw = &resolved.raw;
8924 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
8925 tx.execute(
8926 "INSERT OR REPLACE INTO refs(
8927 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
8928 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
8929 byte_start, byte_end, status, target_node, target_file, target_symbol,
8930 provenance
8931 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
8932 params![
8933 raw.ref_id,
8934 raw.caller_node,
8935 raw.caller_file,
8936 raw.kind,
8937 raw.short_name,
8938 raw.full_ref,
8939 raw.module_path,
8940 raw.import_kind,
8941 raw.local_name,
8942 raw.requested_name,
8943 raw.namespace_alias,
8944 bool_int(raw.wildcard),
8945 raw.line as i64,
8946 raw.byte_start as i64,
8947 raw.byte_end as i64,
8948 resolved.status,
8949 resolved.target_node,
8950 resolved.target_file,
8951 resolved.target_symbol,
8952 PROVENANCE_TREESITTER,
8953 ],
8954 )?;
8955 if let Some(edge) = &resolved.edge {
8956 tx.execute(
8957 "INSERT OR REPLACE INTO edges(
8958 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8959 kind, line, provenance
8960 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
8961 params![
8962 edge.edge_id,
8963 raw.ref_id,
8964 edge.source_node,
8965 edge.target_node,
8966 edge.target_file,
8967 edge.target_symbol,
8968 edge.kind,
8969 edge.line as i64,
8970 PROVENANCE_TREESITTER,
8971 ],
8972 )?;
8973 }
8974 Ok(())
8975}
8976
8977fn insert_method_dispatch_edges(
8978 tx: &Transaction<'_>,
8979 project_root: &Path,
8980 caller_files: Option<&BTreeSet<String>>,
8981) -> Result<usize> {
8982 let references = load_name_match_refs(tx, caller_files)?;
8983 if references.is_empty() {
8984 return Ok(0);
8985 }
8986
8987 let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
8988 let mut source_cache: DispatchSourceCache = HashMap::new();
8989 let mut inserted = 0usize;
8990 for reference in references {
8991 let key = (reference.method_name.clone(), reference.lang.clone());
8992 let candidates = match candidates_by_name.entry(key) {
8993 Entry::Occupied(entry) => entry.into_mut(),
8994 Entry::Vacant(entry) => {
8995 let candidates =
8996 load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
8997 entry.insert(candidates)
8998 }
8999 };
9000
9001 match infer_receiver_type_state(project_root, &reference, &mut source_cache) {
9002 ReceiverTypeInference::Known(receiver_type) => {
9003 let Some(candidate) =
9004 select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
9005 else {
9006 continue;
9007 };
9008 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
9009 inserted += 1;
9010 continue;
9011 }
9012 ReceiverTypeInference::RustDirectSelfField {
9013 receiver_type,
9014 declaration_file,
9015 module_scope,
9016 } => {
9017 let Some(candidate) = select_rust_direct_self_field_candidate(
9018 project_root,
9019 &reference,
9020 candidates.as_slice(),
9021 &receiver_type,
9022 &declaration_file,
9023 &module_scope,
9024 &mut source_cache,
9025 ) else {
9026 continue;
9027 };
9028 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
9029 inserted += 1;
9030 continue;
9031 }
9032 ReceiverTypeInference::KnownButUnresolved => continue,
9033 ReceiverTypeInference::Unknown => {}
9034 }
9035
9036 if method_name_match_denylisted(&reference.method_name) {
9037 continue;
9038 }
9039
9040 let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
9041 continue;
9042 };
9043 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
9044 inserted += 1;
9045 }
9046 Ok(inserted)
9047}
9048
9049fn insert_method_dispatch_edges_chunked(
9050 tx: &Transaction<'_>,
9051 project_root: &Path,
9052 caller_files: &BTreeSet<String>,
9053 chunk_size: usize,
9054) -> Result<usize> {
9055 if caller_files.is_empty() {
9056 return Ok(0);
9057 }
9058 if chunk_size == 0 || caller_files.len() <= chunk_size {
9059 return insert_method_dispatch_edges(tx, project_root, Some(caller_files));
9060 }
9061
9062 let mut inserted = 0usize;
9063 let mut batch = BTreeSet::new();
9064 for caller_file in caller_files {
9065 batch.insert(caller_file.clone());
9066 if batch.len() == chunk_size {
9067 inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
9068 batch.clear();
9069 }
9070 }
9071 if !batch.is_empty() {
9072 inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
9073 }
9074 Ok(inserted)
9075}
9076
9077fn insert_method_dispatch_edge(
9078 tx: &Transaction<'_>,
9079 reference: &NameMatchRef,
9080 candidate: &NameMatchCandidate,
9081 provenance: &str,
9082) -> Result<()> {
9083 tx.execute(
9084 "INSERT OR REPLACE INTO edges(
9085 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
9086 kind, line, provenance
9087 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
9088 params![
9089 ref_id(&[&reference.ref_id, provenance, "edge"]),
9090 &reference.ref_id,
9091 &reference.caller_node,
9092 &candidate.node_id,
9093 &candidate.file_path,
9094 &candidate.scoped_name,
9095 reference.line as i64,
9096 provenance,
9097 ],
9098 )?;
9099 Ok(())
9100}
9101
9102fn delete_method_dispatch_edges_for_callers(
9103 tx: &Transaction<'_>,
9104 caller_files: &BTreeSet<String>,
9105) -> Result<()> {
9106 if caller_files.is_empty() {
9107 return Ok(());
9108 }
9109
9110 let mut stmt = tx.prepare(
9111 "DELETE FROM edges
9112 WHERE provenance IN (?1, ?2)
9113 AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
9114 )?;
9115 for caller_file in caller_files {
9116 stmt.execute(params![
9117 PROVENANCE_NAME_MATCH,
9118 PROVENANCE_TYPE_MATCH,
9119 caller_file
9120 ])?;
9121 }
9122 Ok(())
9123}
9124
9125fn load_name_match_refs(
9126 tx: &Transaction<'_>,
9127 caller_files: Option<&BTreeSet<String>>,
9128) -> Result<Vec<NameMatchRef>> {
9129 let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
9130 n.signature, r.short_name, r.full_ref, r.line, f.lang
9131 FROM refs r
9132 JOIN files f ON f.path = r.caller_file
9133 JOIN nodes n ON n.id = r.caller_node
9134 WHERE r.kind = 'call'
9135 AND r.status = 'unresolved'
9136 AND r.caller_node IS NOT NULL
9137 AND r.full_ref IS NOT NULL
9138 AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
9139 AND NOT EXISTS (
9140 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
9141 )";
9142 let mut references = Vec::new();
9143
9144 if let Some(caller_files) = caller_files {
9145 if caller_files.is_empty() {
9146 return Ok(references);
9147 }
9148 let sql = format!(
9149 "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
9150 );
9151 let mut stmt = tx.prepare(&sql)?;
9152 for caller_file in caller_files {
9153 let rows = stmt.query_map(params![caller_file], |row| {
9154 Ok((
9155 row.get::<_, String>(0)?,
9156 row.get::<_, Option<String>>(1)?,
9157 row.get::<_, String>(2)?,
9158 row.get::<_, String>(3)?,
9159 row.get::<_, Option<String>>(4)?,
9160 row.get::<_, Option<String>>(5)?,
9161 row.get::<_, Option<String>>(6)?,
9162 row.get::<_, i64>(7)?,
9163 row.get::<_, String>(8)?,
9164 ))
9165 })?;
9166 for row in rows {
9167 let (
9168 ref_id,
9169 caller_node,
9170 caller_file,
9171 caller_symbol,
9172 caller_signature,
9173 short_name,
9174 full_ref,
9175 line,
9176 lang,
9177 ) = row?;
9178 if let Some(reference) = name_match_ref_from_parts(
9179 ref_id,
9180 caller_node,
9181 caller_file,
9182 caller_symbol,
9183 caller_signature,
9184 short_name,
9185 full_ref,
9186 line,
9187 lang,
9188 ) {
9189 references.push(reference);
9190 }
9191 }
9192 }
9193 return Ok(references);
9194 }
9195
9196 let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
9197 let mut stmt = tx.prepare(&sql)?;
9198 let rows = stmt.query_map([], |row| {
9199 Ok((
9200 row.get::<_, String>(0)?,
9201 row.get::<_, Option<String>>(1)?,
9202 row.get::<_, String>(2)?,
9203 row.get::<_, String>(3)?,
9204 row.get::<_, Option<String>>(4)?,
9205 row.get::<_, Option<String>>(5)?,
9206 row.get::<_, Option<String>>(6)?,
9207 row.get::<_, i64>(7)?,
9208 row.get::<_, String>(8)?,
9209 ))
9210 })?;
9211 for row in rows {
9212 let (
9213 ref_id,
9214 caller_node,
9215 caller_file,
9216 caller_symbol,
9217 caller_signature,
9218 short_name,
9219 full_ref,
9220 line,
9221 lang,
9222 ) = row?;
9223 if let Some(reference) = name_match_ref_from_parts(
9224 ref_id,
9225 caller_node,
9226 caller_file,
9227 caller_symbol,
9228 caller_signature,
9229 short_name,
9230 full_ref,
9231 line,
9232 lang,
9233 ) {
9234 references.push(reference);
9235 }
9236 }
9237 Ok(references)
9238}
9239
9240#[allow(clippy::too_many_arguments)]
9241fn name_match_ref_from_parts(
9242 ref_id: String,
9243 caller_node: Option<String>,
9244 caller_file: String,
9245 caller_symbol: String,
9246 caller_signature: Option<String>,
9247 short_name: Option<String>,
9248 full_ref: Option<String>,
9249 line: i64,
9250 lang: String,
9251) -> Option<NameMatchRef> {
9252 let caller_node = caller_node?;
9253 let full_ref = full_ref?;
9254 let (receiver_expression, receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
9255 let method_name = if member.is_empty() {
9256 short_name.as_deref()?.to_string()
9257 } else {
9258 member
9259 };
9260 Some(NameMatchRef {
9261 ref_id,
9262 caller_node,
9263 caller_file,
9264 caller_symbol,
9265 caller_signature,
9266 receiver_expression,
9267 receiver,
9268 method_name,
9269 colon_dispatch,
9270 line: line.max(0) as u32,
9271 lang,
9272 })
9273}
9274
9275fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, String, bool)> {
9276 let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
9277 let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
9278 let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
9279 let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
9280 .into_iter()
9281 .flatten()
9282 .max_by_key(|(index, _, _)| *index)?;
9283 if delimiter == 0 {
9284 return None;
9285 }
9286 let member_start = delimiter + delimiter_len;
9287 if member_start >= full_ref.len() {
9288 return None;
9289 }
9290 let receiver_expression = full_ref[..delimiter].trim();
9291 let receiver = last_name_segment(receiver_expression).trim();
9292 let member = &full_ref[member_start..];
9293 if receiver.is_empty() || member.is_empty() {
9294 return None;
9295 }
9296 Some((
9297 receiver_expression.to_string(),
9298 receiver.to_string(),
9299 member.to_string(),
9300 colon_dispatch,
9301 ))
9302}
9303
9304fn last_name_segment(value: &str) -> &str {
9305 value
9306 .rsplit(['.', ':', '/', '\\', '-', '>'])
9307 .find(|segment| !segment.is_empty())
9308 .unwrap_or(value)
9309}
9310
9311fn load_name_match_candidates(
9312 tx: &Transaction<'_>,
9313 method_name: &str,
9314 lang: &str,
9315) -> Result<Vec<NameMatchCandidate>> {
9316 let mut stmt = tx.prepare(
9317 "SELECT n.id, n.file_path, n.scoped_name, n.kind, n.start_line
9318 FROM nodes n JOIN files f ON f.path = n.file_path
9319 WHERE n.name = ?1
9320 AND f.lang = ?2
9321 AND n.kind IN ('method', 'function')
9322 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
9323 )?;
9324 let rows = stmt.query_map(params![method_name, lang], |row| {
9325 Ok(NameMatchCandidate {
9326 node_id: row.get(0)?,
9327 file_path: row.get(1)?,
9328 scoped_name: row.get(2)?,
9329 kind: row.get(3)?,
9330 start_line: (row.get::<_, i64>(4)?.max(0) as u32).saturating_add(1),
9331 })
9332 })?;
9333 rows.collect::<std::result::Result<Vec<_>, _>>()
9334 .map_err(Into::into)
9335}
9336
9337struct ParsedDispatchSource {
9338 source: String,
9339 tree: tree_sitter::Tree,
9340}
9341
9342type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
9343
9344#[derive(Debug, Clone, PartialEq, Eq)]
9345enum ReceiverTypeInference {
9346 Unknown,
9347 Known(String),
9348 RustDirectSelfField {
9349 receiver_type: String,
9350 declaration_file: String,
9351 module_scope: Vec<(usize, usize)>,
9352 },
9353 KnownButUnresolved,
9354}
9355
9356#[cfg(test)]
9357fn infer_receiver_type(
9358 project_root: &Path,
9359 reference: &NameMatchRef,
9360 source_cache: &mut DispatchSourceCache,
9361) -> Option<String> {
9362 match infer_receiver_type_state(project_root, reference, source_cache) {
9363 ReceiverTypeInference::Known(receiver_type)
9364 | ReceiverTypeInference::RustDirectSelfField { receiver_type, .. } => Some(receiver_type),
9365 ReceiverTypeInference::Unknown | ReceiverTypeInference::KnownButUnresolved => None,
9366 }
9367}
9368
9369fn infer_receiver_type_state(
9370 project_root: &Path,
9371 reference: &NameMatchRef,
9372 source_cache: &mut DispatchSourceCache,
9373) -> ReceiverTypeInference {
9374 let known = |receiver_type| ReceiverTypeInference::Known(receiver_type);
9375 match reference.lang.as_str() {
9376 "rust" => infer_rust_receiver_type(project_root, reference, source_cache),
9377 "java" => {
9378 infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
9379 .map(known)
9380 .unwrap_or(ReceiverTypeInference::Unknown)
9381 }
9382 "kotlin" => {
9383 infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
9384 .map(known)
9385 .unwrap_or(ReceiverTypeInference::Unknown)
9386 }
9387 "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache)
9388 .map(known)
9389 .unwrap_or(ReceiverTypeInference::Unknown),
9390 _ => ReceiverTypeInference::Unknown,
9391 }
9392}
9393
9394fn parse_dispatch_source(
9395 project_root: &Path,
9396 caller_file: &str,
9397 lang: LangId,
9398) -> Option<ParsedDispatchSource> {
9399 let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
9400 let grammar = crate::parser::grammar_for(lang);
9401 let mut parser = tree_sitter::Parser::new();
9402 parser.set_language(&grammar).ok()?;
9403 let tree = parser.parse(&source, None)?;
9404 Some(ParsedDispatchSource { source, tree })
9405}
9406
9407fn parsed_dispatch_source<'a>(
9408 project_root: &Path,
9409 reference: &NameMatchRef,
9410 lang: LangId,
9411 source_cache: &'a mut DispatchSourceCache,
9412) -> Option<&'a ParsedDispatchSource> {
9413 parsed_dispatch_source_for_file(
9414 project_root,
9415 &reference.caller_file,
9416 &reference.lang,
9417 lang,
9418 source_cache,
9419 )
9420}
9421
9422fn parsed_dispatch_source_for_file<'a>(
9423 project_root: &Path,
9424 file_path: &str,
9425 lang_label: &str,
9426 lang: LangId,
9427 source_cache: &'a mut DispatchSourceCache,
9428) -> Option<&'a ParsedDispatchSource> {
9429 let key = (file_path.to_string(), lang_label.to_string());
9430 source_cache
9431 .entry(key)
9432 .or_insert_with(|| parse_dispatch_source(project_root, file_path, lang))
9433 .as_ref()
9434}
9435
9436fn infer_java_like_receiver_type(
9437 project_root: &Path,
9438 reference: &NameMatchRef,
9439 lang: LangId,
9440 source_cache: &mut DispatchSourceCache,
9441) -> Option<String> {
9442 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
9443 return None;
9444 }
9445
9446 let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
9447 let root = parsed.tree.root_node();
9448 let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
9449
9450 let callable_scope = type_node
9451 .and_then(|node| {
9452 find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
9453 })
9454 .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
9455
9456 if let Some(callable_scope) = callable_scope {
9457 if let Some(receiver_type) = infer_java_like_local_receiver_type(
9458 callable_scope,
9459 &parsed.source,
9460 &reference.receiver,
9461 reference.line.max(1),
9462 lang,
9463 ) {
9464 return Some(receiver_type);
9465 }
9466 }
9467
9468 type_node.and_then(|node| {
9469 infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
9470 })
9471}
9472
9473fn infer_cpp_receiver_type(
9474 project_root: &Path,
9475 reference: &NameMatchRef,
9476 source_cache: &mut DispatchSourceCache,
9477) -> Option<String> {
9478 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
9479 return None;
9480 }
9481
9482 let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
9483 let root = parsed.tree.root_node();
9484 let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
9485 infer_cpp_receiver_type_from_scope(
9486 scope,
9487 &parsed.source,
9488 &reference.receiver,
9489 reference.line.max(1),
9490 )
9491}
9492
9493fn find_enclosing_java_like_type_node<'tree>(
9494 root: tree_sitter::Node<'tree>,
9495 source: &str,
9496 reference: &NameMatchRef,
9497 lang: LangId,
9498) -> Option<tree_sitter::Node<'tree>> {
9499 let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
9500 .and_then(|name| simple_type_name(&name));
9501 let line = reference.line.max(1);
9502 let mut best = None;
9503 let mut stack = vec![root];
9504 while let Some(node) = stack.pop() {
9505 if !node_contains_line(node, line) {
9506 continue;
9507 }
9508 if is_java_like_type_kind(node.kind(), lang) {
9509 let name = declaration_name(node, source);
9510 if expected_type
9511 .as_deref()
9512 .is_none_or(|expected| name == Some(expected))
9513 {
9514 best = tighter_node(best, node);
9515 }
9516 }
9517 push_named_children(node, &mut stack);
9518 }
9519 best
9520}
9521
9522fn find_enclosing_java_like_callable_node<'tree>(
9523 root: tree_sitter::Node<'tree>,
9524 source: &str,
9525 reference: &NameMatchRef,
9526 lang: LangId,
9527) -> Option<tree_sitter::Node<'tree>> {
9528 let expected_name = reference.caller_symbol.rsplit("::").next();
9529 let line = reference.line.max(1);
9530 let mut best = None;
9531 let mut stack = vec![root];
9532 while let Some(node) = stack.pop() {
9533 if !node_contains_line(node, line) {
9534 continue;
9535 }
9536 if is_java_like_callable_kind(node.kind(), lang) {
9537 let name = declaration_name(node, source);
9538 if expected_name.is_none_or(|expected| name == Some(expected)) {
9539 best = tighter_node(best, node);
9540 }
9541 }
9542 push_named_children(node, &mut stack);
9543 }
9544 best
9545}
9546
9547fn find_enclosing_cpp_callable_node<'tree>(
9548 root: tree_sitter::Node<'tree>,
9549 _source: &str,
9550 reference: &NameMatchRef,
9551) -> Option<tree_sitter::Node<'tree>> {
9552 let line = reference.line.max(1);
9553 let mut best = None;
9554 let mut stack = vec![root];
9555 while let Some(node) = stack.pop() {
9556 if !node_contains_line(node, line) {
9557 continue;
9558 }
9559 if node.kind() == "function_definition" {
9560 best = tighter_node(best, node);
9561 }
9562 push_named_children(node, &mut stack);
9563 }
9564 best
9565}
9566
9567fn tighter_node<'tree>(
9568 current: Option<tree_sitter::Node<'tree>>,
9569 candidate: tree_sitter::Node<'tree>,
9570) -> Option<tree_sitter::Node<'tree>> {
9571 match current {
9572 Some(current)
9573 if current.start_byte() > candidate.start_byte()
9574 || (current.start_byte() == candidate.start_byte()
9575 && current.end_byte() <= candidate.end_byte()) =>
9576 {
9577 Some(current)
9578 }
9579 _ => Some(candidate),
9580 }
9581}
9582
9583fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
9584 let start = node.start_position().row as u32 + 1;
9585 let end = node.end_position().row as u32 + 1;
9586 start <= line && line <= end
9587}
9588
9589fn push_named_children<'tree>(
9590 node: tree_sitter::Node<'tree>,
9591 stack: &mut Vec<tree_sitter::Node<'tree>>,
9592) {
9593 for index in 0..node.named_child_count() {
9594 if let Some(child) = node.named_child(index as u32) {
9595 stack.push(child);
9596 }
9597 }
9598}
9599
9600fn declaration_name<'source>(
9601 node: tree_sitter::Node<'_>,
9602 source: &'source str,
9603) -> Option<&'source str> {
9604 node.child_by_field_name("name")
9605 .map(|name| node_text(name, source))
9606 .or_else(|| {
9607 first_named_child_text(
9608 node,
9609 source,
9610 &["identifier", "type_identifier", "simple_identifier"],
9611 )
9612 })
9613}
9614
9615fn first_named_child_text<'source>(
9616 node: tree_sitter::Node<'_>,
9617 source: &'source str,
9618 kinds: &[&str],
9619) -> Option<&'source str> {
9620 for index in 0..node.named_child_count() {
9621 let child = node.named_child(index as u32)?;
9622 if kinds.contains(&child.kind()) {
9623 return Some(node_text(child, source));
9624 }
9625 }
9626 None
9627}
9628
9629fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
9630 &source[node.byte_range()]
9631}
9632
9633fn infer_java_like_field_receiver_type(
9634 type_node: tree_sitter::Node<'_>,
9635 source: &str,
9636 receiver: &str,
9637 lang: LangId,
9638) -> Option<String> {
9639 let mut stack = Vec::new();
9640 push_named_children(type_node, &mut stack);
9641 while let Some(node) = stack.pop() {
9642 if is_java_like_field_kind(node.kind(), lang) {
9643 if let Some(receiver_type) =
9644 extract_java_like_declared_type(node_text(node, source), receiver, lang)
9645 {
9646 return Some(receiver_type);
9647 }
9648 }
9649 if is_java_like_type_kind(node.kind(), lang)
9650 || is_java_like_callable_kind(node.kind(), lang)
9651 {
9652 continue;
9653 }
9654 push_named_children(node, &mut stack);
9655 }
9656 None
9657}
9658
9659fn infer_java_like_local_receiver_type(
9660 callable_node: tree_sitter::Node<'_>,
9661 source: &str,
9662 receiver: &str,
9663 call_line: u32,
9664 lang: LangId,
9665) -> Option<String> {
9666 let mut best: Option<(u32, String)> = None;
9667 let mut stack = Vec::new();
9668 push_named_children(callable_node, &mut stack);
9669 while let Some(node) = stack.pop() {
9670 let start_line = node.start_position().row as u32 + 1;
9671 if start_line > call_line {
9672 continue;
9673 }
9674 if is_java_like_local_kind(node.kind(), lang) {
9675 if let Some(receiver_type) =
9676 extract_java_like_declared_type(node_text(node, source), receiver, lang)
9677 {
9678 if best
9679 .as_ref()
9680 .is_none_or(|(best_line, _)| start_line >= *best_line)
9681 {
9682 best = Some((start_line, receiver_type));
9683 }
9684 }
9685 }
9686 if is_java_like_type_kind(node.kind(), lang)
9687 || is_java_like_callable_kind(node.kind(), lang)
9688 {
9689 continue;
9690 }
9691 push_named_children(node, &mut stack);
9692 }
9693 best.map(|(_, receiver_type)| receiver_type)
9694}
9695
9696fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
9697 match lang {
9698 LangId::Java => matches!(
9699 kind,
9700 "class_declaration"
9701 | "interface_declaration"
9702 | "enum_declaration"
9703 | "record_declaration"
9704 | "annotation_type_declaration"
9705 ),
9706 LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
9707 _ => false,
9708 }
9709}
9710
9711fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
9712 match lang {
9713 LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
9714 LangId::Kotlin => kind == "function_declaration",
9715 _ => false,
9716 }
9717}
9718
9719fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
9720 match lang {
9721 LangId::Java => kind == "field_declaration",
9722 LangId::Kotlin => kind == "property_declaration",
9723 _ => false,
9724 }
9725}
9726
9727fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
9728 match lang {
9729 LangId::Java => kind == "local_variable_declaration",
9730 LangId::Kotlin => kind == "property_declaration",
9731 _ => false,
9732 }
9733}
9734
9735fn extract_java_like_declared_type(
9736 declaration: &str,
9737 receiver: &str,
9738 lang: LangId,
9739) -> Option<String> {
9740 match lang {
9741 LangId::Java => extract_java_declared_type(declaration, receiver),
9742 LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
9743 _ => None,
9744 }
9745}
9746
9747fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
9748 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
9749 let after = declaration[receiver_start + receiver.len()..].trim_start();
9750 if after
9751 .chars()
9752 .next()
9753 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
9754 {
9755 return None;
9756 }
9757
9758 let before = declaration[..receiver_start].trim_end();
9759 if before.contains(',') {
9760 return None;
9761 }
9762 normalize_receiver_type_name(strip_java_declaration_prefixes(before))
9763}
9764
9765fn strip_java_declaration_prefixes(mut value: &str) -> &str {
9766 loop {
9767 value = value.trim_start();
9768 if let Some(stripped) = strip_leading_java_annotation(value) {
9769 value = stripped;
9770 continue;
9771 }
9772 if let Some(stripped) = strip_leading_java_modifier(value) {
9773 value = stripped;
9774 continue;
9775 }
9776 return value.trim();
9777 }
9778}
9779
9780fn strip_leading_java_annotation(value: &str) -> Option<&str> {
9781 let value = value.trim_start();
9782 let mut chars = value.char_indices();
9783 let (_, first) = chars.next()?;
9784 if first != '@' {
9785 return None;
9786 }
9787 let mut end = first.len_utf8();
9788 for (index, ch) in chars {
9789 if !(is_code_ident_char(ch) || ch == '.') {
9790 end = index;
9791 break;
9792 }
9793 end = index + ch.len_utf8();
9794 }
9795 let rest = value[end..].trim_start();
9796 if let Some(stripped) = rest.strip_prefix('(') {
9797 let mut depth = 1usize;
9798 for (index, ch) in stripped.char_indices() {
9799 match ch {
9800 '(' => depth += 1,
9801 ')' => {
9802 depth = depth.saturating_sub(1);
9803 if depth == 0 {
9804 return Some(stripped[index + ch.len_utf8()..].trim_start());
9805 }
9806 }
9807 _ => {}
9808 }
9809 }
9810 return Some("");
9811 }
9812 Some(rest)
9813}
9814
9815fn strip_leading_java_modifier(value: &str) -> Option<&str> {
9816 const MODIFIERS: &[&str] = &[
9817 "public",
9818 "protected",
9819 "private",
9820 "abstract",
9821 "static",
9822 "final",
9823 "transient",
9824 "volatile",
9825 "synchronized",
9826 "native",
9827 "strictfp",
9828 ];
9829 MODIFIERS
9830 .iter()
9831 .find_map(|modifier| strip_leading_word(value, modifier))
9832}
9833
9834fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
9835 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
9836 let before = &declaration[..receiver_start];
9837 if find_identifier_occurrence(before, "val").is_none()
9838 && find_identifier_occurrence(before, "var").is_none()
9839 {
9840 return None;
9841 }
9842
9843 let after = declaration[receiver_start + receiver.len()..].trim_start();
9844 if let Some(type_text) = after.strip_prefix(':') {
9845 return normalize_receiver_type_name(read_type_prefix(type_text));
9846 }
9847 after
9848 .strip_prefix('=')
9849 .and_then(infer_kotlin_constructor_type)
9850}
9851
9852fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
9853 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
9854 if rest.trim_start().starts_with('(') {
9855 normalize_receiver_type_name(head)
9856 } else {
9857 None
9858 }
9859}
9860
9861fn read_type_prefix(value: &str) -> &str {
9862 let mut angle_depth = 0usize;
9863 for (index, ch) in value.char_indices() {
9864 match ch {
9865 '<' => angle_depth += 1,
9866 '>' => angle_depth = angle_depth.saturating_sub(1),
9867 '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
9868 return value[..index].trim();
9869 }
9870 _ => {}
9871 }
9872 }
9873 value.trim()
9874}
9875
9876fn infer_cpp_receiver_type_from_scope(
9877 scope: tree_sitter::Node<'_>,
9878 source: &str,
9879 receiver: &str,
9880 call_line: u32,
9881) -> Option<String> {
9882 let lines = source.lines().collect::<Vec<_>>();
9883 if lines.is_empty() {
9884 return None;
9885 }
9886 let scope_start = scope.start_position().row as usize;
9887 let call_index = (call_line as usize)
9888 .saturating_sub(1)
9889 .min(lines.len().saturating_sub(1));
9890 for index in (scope_start..=call_index).rev() {
9891 if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
9892 return Some(receiver_type);
9893 }
9894 }
9895 None
9896}
9897
9898fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
9899 for receiver_start in identifier_occurrences(line, receiver) {
9900 let after = line[receiver_start + receiver.len()..].trim_start();
9901 if after
9902 .chars()
9903 .next()
9904 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
9905 {
9906 continue;
9907 }
9908 let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
9909 let normalized = normalize_cpp_type_name(type_text)?;
9910 if normalized == "auto" {
9911 if let Some(rhs) = after.strip_prefix('=') {
9912 return infer_cpp_auto_receiver_type(rhs);
9913 }
9914 continue;
9915 }
9916 return Some(normalized);
9917 }
9918 None
9919}
9920
9921fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
9922 let candidate = prefix
9923 .rsplit([';', '{', '}', '('])
9924 .next()
9925 .unwrap_or(prefix)
9926 .trim();
9927 if candidate.is_empty() || candidate.ends_with(',') {
9928 None
9929 } else {
9930 Some(candidate)
9931 }
9932}
9933
9934fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
9935 let without_templates = strip_angle_groups(type_text);
9936 let mut cleaned = String::with_capacity(without_templates.len());
9937 for token in without_templates.split_whitespace() {
9938 if matches!(
9939 token,
9940 "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
9941 ) {
9942 continue;
9943 }
9944 if !cleaned.is_empty() {
9945 cleaned.push(' ');
9946 }
9947 cleaned.push_str(token);
9948 }
9949 let token = cleaned
9950 .split_whitespace()
9951 .last()
9952 .unwrap_or(cleaned.trim())
9953 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
9954 .trim_matches(['*', '&']);
9955 let simple = token.rsplit("::").next().unwrap_or(token).trim();
9956 if simple.is_empty() || cpp_non_type_token(simple) {
9957 None
9958 } else {
9959 Some(simple.to_string())
9960 }
9961}
9962
9963fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
9964 let rhs = rhs.trim_start();
9965 if let Some(after_new) = rhs.strip_prefix("new ") {
9966 return infer_cpp_constructor_type(after_new);
9967 }
9968 infer_cpp_make_template_type(rhs)
9969 .or_else(|| infer_cpp_constructor_type(rhs))
9970 .or_else(|| infer_cpp_factory_type(rhs))
9971}
9972
9973fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
9974 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9975 let normalized = normalize_cpp_type_name(head)?;
9976 if !normalized
9977 .chars()
9978 .next()
9979 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
9980 {
9981 return None;
9982 }
9983 if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
9984 Some(normalized)
9985 } else {
9986 None
9987 }
9988}
9989
9990fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
9991 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9992 if !rest.trim_start().starts_with('(') {
9993 return None;
9994 }
9995 let base = head.split('<').next().unwrap_or(head);
9996 let base_simple = base.rsplit("::").next().unwrap_or(base);
9997 if !matches!(base_simple, "make_unique" | "make_shared") {
9998 return None;
9999 }
10000 first_angle_arg(head).and_then(normalize_cpp_type_name)
10001}
10002
10003fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
10004 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
10005 if !rest.trim_start().starts_with('(') {
10006 return None;
10007 }
10008 let simple = head
10009 .split('<')
10010 .next()
10011 .unwrap_or(head)
10012 .rsplit("::")
10013 .next()
10014 .unwrap_or(head);
10015 for prefix in ["make", "create", "build"] {
10016 if let Some(suffix) = simple.strip_prefix(prefix) {
10017 if suffix
10018 .chars()
10019 .next()
10020 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
10021 {
10022 return normalize_cpp_type_name(suffix);
10023 }
10024 }
10025 }
10026 None
10027}
10028
10029#[derive(Debug, Clone, Copy)]
10030enum JavaLikeInvocation {
10031 Kotlin,
10032 Cpp,
10033}
10034
10035fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
10036 let value = value.trim_start();
10037 let mut end = 0usize;
10038 for (index, ch) in value.char_indices() {
10039 let allowed_separator = match flavor {
10040 JavaLikeInvocation::Kotlin => ch == '.',
10041 JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
10042 };
10043 if is_code_ident_char(ch) || allowed_separator {
10044 end = index + ch.len_utf8();
10045 continue;
10046 }
10047 break;
10048 }
10049 if end == 0 {
10050 return None;
10051 }
10052 let mut rest = &value[end..];
10053 if let Some(stripped) = rest.trim_start().strip_prefix('<') {
10054 let skipped = skip_balanced_angle(stripped)?;
10055 let rest_start = rest.len() - rest.trim_start().len();
10056 let angle_len = 1 + skipped;
10057 end += rest_start + angle_len;
10058 rest = &value[end..];
10059 }
10060 Some((value[..end].trim(), rest))
10061}
10062
10063fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
10064 let mut depth = 1usize;
10065 for (index, ch) in value_after_open.char_indices() {
10066 match ch {
10067 '<' => depth += 1,
10068 '>' => {
10069 depth = depth.saturating_sub(1);
10070 if depth == 0 {
10071 return Some(index + ch.len_utf8());
10072 }
10073 }
10074 _ => {}
10075 }
10076 }
10077 None
10078}
10079
10080fn first_angle_arg(value: &str) -> Option<&str> {
10081 let open = value.find('<')?;
10082 let inner_len = skip_balanced_angle(&value[open + 1..])?;
10083 let inner = &value[open + 1..open + inner_len];
10084 split_top_level_commas(inner).into_iter().next()
10085}
10086
10087fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
10088 let without_generics = strip_angle_groups(type_text);
10089 let cleaned = without_generics
10090 .replace("[]", " ")
10091 .replace("...", " ")
10092 .replace(['?', '&', '*'], " ");
10093 let token = cleaned
10094 .split_whitespace()
10095 .last()
10096 .unwrap_or(cleaned.trim())
10097 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
10098 let token = token.rsplit("::").next().unwrap_or(token);
10099 let simple = token.rsplit('.').next().unwrap_or(token).trim();
10100 if simple.is_empty()
10101 || java_like_primitive_type(simple)
10102 || !simple
10103 .chars()
10104 .next()
10105 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
10106 {
10107 None
10108 } else {
10109 Some(simple.to_string())
10110 }
10111}
10112
10113fn simple_type_name(scoped_name: &str) -> Option<String> {
10114 scoped_name
10115 .rsplit("::")
10116 .find(|segment| !segment.is_empty())
10117 .and_then(normalize_receiver_type_name)
10118}
10119
10120fn strip_angle_groups(value: &str) -> String {
10121 let mut output = String::with_capacity(value.len());
10122 let mut depth = 0usize;
10123 for ch in value.chars() {
10124 match ch {
10125 '<' => {
10126 if depth == 0 {
10127 output.push(' ');
10128 }
10129 depth += 1;
10130 }
10131 '>' => depth = depth.saturating_sub(1),
10132 _ if depth == 0 => output.push(ch),
10133 _ => {}
10134 }
10135 }
10136 output
10137}
10138
10139fn java_like_primitive_type(value: &str) -> bool {
10140 matches!(
10141 value,
10142 "boolean"
10143 | "byte"
10144 | "char"
10145 | "double"
10146 | "float"
10147 | "int"
10148 | "long"
10149 | "short"
10150 | "void"
10151 | "Boolean"
10152 | "Byte"
10153 | "Char"
10154 | "Double"
10155 | "Float"
10156 | "Int"
10157 | "Long"
10158 | "Short"
10159 | "Unit"
10160 )
10161}
10162
10163fn cpp_non_type_token(value: &str) -> bool {
10164 matches!(
10165 value,
10166 "return"
10167 | "if"
10168 | "else"
10169 | "for"
10170 | "while"
10171 | "do"
10172 | "switch"
10173 | "case"
10174 | "default"
10175 | "break"
10176 | "continue"
10177 | "goto"
10178 | "throw"
10179 | "new"
10180 | "delete"
10181 | "co_await"
10182 | "co_yield"
10183 | "co_return"
10184 | "static_cast"
10185 | "const_cast"
10186 | "dynamic_cast"
10187 | "reinterpret_cast"
10188 | "sizeof"
10189 | "alignof"
10190 | "typeid"
10191 | "and"
10192 | "or"
10193 | "not"
10194 | "xor"
10195 )
10196}
10197
10198fn receiver_is_bare_identifier(value: &str) -> bool {
10199 let mut chars = value.chars();
10200 let Some(first) = chars.next() else {
10201 return false;
10202 };
10203 (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
10204}
10205
10206fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
10207 identifier_occurrences(value, needle).into_iter().next()
10208}
10209
10210fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
10211 value
10212 .match_indices(needle)
10213 .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
10214 .collect()
10215}
10216
10217fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
10218 let before = value[..start].chars().next_back();
10219 let after = value[start + len..].chars().next();
10220 !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
10221}
10222
10223fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
10224 let stripped = value.strip_prefix(word)?;
10225 if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
10226 Some(stripped.trim_start())
10227 } else {
10228 None
10229 }
10230}
10231
10232fn is_code_ident_char(ch: char) -> bool {
10233 ch == '_' || ch.is_ascii_alphanumeric()
10234}
10235
10236fn infer_rust_receiver_type(
10237 project_root: &Path,
10238 reference: &NameMatchRef,
10239 source_cache: &mut DispatchSourceCache,
10240) -> ReceiverTypeInference {
10241 if matches!(reference.receiver.as_str(), "self" | "Self") {
10242 return enclosing_type_from_scoped_name(&reference.caller_symbol)
10243 .map(ReceiverTypeInference::Known)
10244 .unwrap_or(ReceiverTypeInference::Unknown);
10245 }
10246
10247 if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
10248 return ReceiverTypeInference::Known(reference.receiver.clone());
10249 }
10250
10251 if let Some(receiver_type) = reference
10252 .caller_signature
10253 .as_deref()
10254 .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
10255 {
10256 return ReceiverTypeInference::Known(receiver_type);
10257 }
10258
10259 infer_rust_direct_self_field_receiver_type(project_root, reference, source_cache)
10260}
10261
10262fn infer_rust_direct_self_field_receiver_type(
10263 project_root: &Path,
10264 reference: &NameMatchRef,
10265 source_cache: &mut DispatchSourceCache,
10266) -> ReceiverTypeInference {
10267 if reference.colon_dispatch {
10268 return ReceiverTypeInference::Unknown;
10269 }
10270 let Some(field_name) = rust_direct_self_field_name(&reference.receiver_expression) else {
10271 return ReceiverTypeInference::Unknown;
10272 };
10273 if field_name != reference.receiver {
10274 return ReceiverTypeInference::Unknown;
10275 }
10276
10277 let Some(impl_type) = enclosing_type_from_scoped_name(&reference.caller_symbol) else {
10278 return ReceiverTypeInference::Unknown;
10279 };
10280 let Some(struct_name) = rust_direct_nominal_type_name(&impl_type) else {
10281 return ReceiverTypeInference::KnownButUnresolved;
10282 };
10283 let Some(parsed) = parsed_dispatch_source(project_root, reference, LangId::Rust, source_cache)
10284 else {
10285 return ReceiverTypeInference::Unknown;
10286 };
10287 let Some(impl_node) =
10288 find_enclosing_rust_impl_node(parsed.tree.root_node(), reference.line.max(1))
10289 else {
10290 return ReceiverTypeInference::Unknown;
10291 };
10292 if impl_node.child_by_field_name("trait").is_some()
10293 || impl_node.child_by_field_name("type_parameters").is_some()
10294 {
10295 return ReceiverTypeInference::KnownButUnresolved;
10296 }
10297 let Some(impl_target) = impl_node.child_by_field_name("type") else {
10298 return ReceiverTypeInference::KnownButUnresolved;
10299 };
10300 if impl_target.kind() != "type_identifier"
10301 || node_text(impl_target, &parsed.source) != impl_type
10302 {
10303 return ReceiverTypeInference::KnownButUnresolved;
10304 }
10305
10306 let module_scope = rust_module_scope(impl_node);
10307 let Some(struct_node) = find_unique_rust_struct(
10308 parsed.tree.root_node(),
10309 &parsed.source,
10310 struct_name,
10311 &module_scope,
10312 ) else {
10313 return ReceiverTypeInference::KnownButUnresolved;
10314 };
10315 let Some(field_type) = rust_struct_field_type_node(struct_node, &parsed.source, field_name)
10316 else {
10317 return ReceiverTypeInference::KnownButUnresolved;
10318 };
10319 if field_type.kind() != "type_identifier" {
10320 return ReceiverTypeInference::KnownButUnresolved;
10321 }
10322 let field_type_name = node_text(field_type, &parsed.source);
10323 if find_unique_rust_struct(
10324 parsed.tree.root_node(),
10325 &parsed.source,
10326 field_type_name,
10327 &module_scope,
10328 )
10329 .is_none()
10330 {
10331 return ReceiverTypeInference::KnownButUnresolved;
10332 }
10333
10334 ReceiverTypeInference::RustDirectSelfField {
10335 receiver_type: field_type_name.to_string(),
10336 declaration_file: reference.caller_file.clone(),
10337 module_scope,
10338 }
10339}
10340
10341fn rust_direct_self_field_name(receiver_expression: &str) -> Option<&str> {
10342 let (base, field) = receiver_expression.split_once('.')?;
10343 let base = base.trim();
10344 let field = field.trim();
10345 (base == "self" && rust_direct_nominal_type_name(field).is_some()).then_some(field)
10346}
10347
10348fn rust_direct_nominal_type_name(value: &str) -> Option<&str> {
10349 let name = value.rsplit("::").next()?.trim();
10350 (!name.is_empty()
10351 && !name.chars().next().is_some_and(|ch| ch.is_ascii_digit())
10352 && name.chars().all(is_rust_ident_char))
10353 .then_some(name)
10354}
10355
10356fn find_enclosing_rust_impl_node<'tree>(
10357 root: tree_sitter::Node<'tree>,
10358 line: u32,
10359) -> Option<tree_sitter::Node<'tree>> {
10360 let mut best = None;
10361 let mut stack = vec![root];
10362 while let Some(node) = stack.pop() {
10363 if !node_contains_line(node, line) {
10364 continue;
10365 }
10366 if node.kind() == "impl_item" {
10367 best = tighter_node(best, node);
10368 }
10369 push_named_children(node, &mut stack);
10370 }
10371 best
10372}
10373
10374fn rust_module_scope(node: tree_sitter::Node<'_>) -> Vec<(usize, usize)> {
10375 let mut scope = Vec::new();
10376 let mut current = node.parent();
10377 while let Some(parent) = current {
10378 if parent.kind() == "mod_item" {
10379 scope.push((parent.start_byte(), parent.end_byte()));
10380 }
10381 current = parent.parent();
10382 }
10383 scope.reverse();
10384 scope
10385}
10386
10387fn find_unique_rust_struct<'tree>(
10388 root: tree_sitter::Node<'tree>,
10389 source: &str,
10390 expected_name: &str,
10391 module_scope: &[(usize, usize)],
10392) -> Option<tree_sitter::Node<'tree>> {
10393 let mut found = None;
10394 let mut stack = vec![root];
10395 while let Some(node) = stack.pop() {
10396 if node.kind() == "struct_item"
10397 && rust_module_scope(node) == module_scope
10398 && node.child_by_field_name("type_parameters").is_none()
10399 && declaration_name(node, source) == Some(expected_name)
10400 {
10401 if found.is_some() {
10402 return None;
10403 }
10404 found = Some(node);
10405 }
10406 push_named_children(node, &mut stack);
10407 }
10408 found
10409}
10410
10411fn rust_struct_field_type_node<'tree>(
10412 struct_node: tree_sitter::Node<'tree>,
10413 source: &str,
10414 field_name: &str,
10415) -> Option<tree_sitter::Node<'tree>> {
10416 let fields = struct_node.child_by_field_name("body")?;
10417 if fields.kind() != "field_declaration_list" {
10418 return None;
10419 }
10420 for index in 0..fields.named_child_count() {
10421 let field = fields.named_child(index as u32)?;
10422 if field.kind() != "field_declaration"
10423 || declaration_name(field, source) != Some(field_name)
10424 {
10425 continue;
10426 }
10427 return field.child_by_field_name("type");
10428 }
10429 None
10430}
10431
10432fn rust_receiver_looks_type_like(receiver: &str) -> bool {
10433 receiver
10434 .chars()
10435 .next()
10436 .is_some_and(|ch| ch == '_' || ch.is_uppercase())
10437}
10438
10439fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
10440 scoped_name
10441 .rsplit_once("::")
10442 .map(|(enclosing, _)| enclosing)
10443 .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
10444 .map(ToString::to_string)
10445}
10446
10447fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
10448 let params = signature_parameter_text(signature)?;
10449 for param in split_top_level_commas(params) {
10450 let Some((pattern, type_text)) = param.split_once(':') else {
10451 continue;
10452 };
10453 let Some(name) = rust_parameter_name(pattern) else {
10454 continue;
10455 };
10456 if name == receiver {
10457 return normalize_rust_receiver_type(type_text);
10458 }
10459 }
10460 None
10461}
10462
10463fn signature_parameter_text(signature: &str) -> Option<&str> {
10464 let open = signature.find('(')?;
10465 let mut depth = 0usize;
10466 for (offset, ch) in signature[open..].char_indices() {
10467 match ch {
10468 '(' => depth += 1,
10469 ')' => {
10470 depth = depth.saturating_sub(1);
10471 if depth == 0 {
10472 return Some(&signature[open + 1..open + offset]);
10473 }
10474 }
10475 _ => {}
10476 }
10477 }
10478 None
10479}
10480
10481fn split_top_level_commas(value: &str) -> Vec<&str> {
10482 let mut parts = Vec::new();
10483 let mut start = 0usize;
10484 let mut angle_depth = 0usize;
10485 let mut paren_depth = 0usize;
10486 let mut bracket_depth = 0usize;
10487 for (index, ch) in value.char_indices() {
10488 match ch {
10489 '<' => angle_depth += 1,
10490 '>' => angle_depth = angle_depth.saturating_sub(1),
10491 '(' => paren_depth += 1,
10492 ')' => paren_depth = paren_depth.saturating_sub(1),
10493 '[' => bracket_depth += 1,
10494 ']' => bracket_depth = bracket_depth.saturating_sub(1),
10495 ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
10496 let part = value[start..index].trim();
10497 if !part.is_empty() {
10498 parts.push(part);
10499 }
10500 start = index + ch.len_utf8();
10501 }
10502 _ => {}
10503 }
10504 }
10505 let part = value[start..].trim();
10506 if !part.is_empty() {
10507 parts.push(part);
10508 }
10509 parts
10510}
10511
10512fn rust_parameter_name(pattern: &str) -> Option<&str> {
10513 let mut pattern = pattern.trim();
10514 if let Some(stripped) = pattern.strip_prefix("mut ") {
10515 pattern = stripped.trim_start();
10516 }
10517 pattern
10518 .rsplit(|ch: char| !is_rust_ident_char(ch))
10519 .find(|part| !part.is_empty())
10520}
10521
10522fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
10523 let mut ty = strip_leading_rust_type_modifiers(type_text);
10524 let owned_inner;
10525 if let Some(inner) = single_outer_generic_arg(ty) {
10526 owned_inner = inner.trim().to_string();
10527 ty = strip_leading_rust_type_modifiers(&owned_inner);
10528 }
10529 rust_base_type_ident(ty)
10530}
10531
10532fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
10533 loop {
10534 ty = ty.trim_start();
10535 if let Some(stripped) = ty.strip_prefix('&') {
10536 ty = stripped.trim_start();
10537 if let Some(stripped) = strip_leading_lifetime(ty) {
10538 ty = stripped.trim_start();
10539 }
10540 if let Some(stripped) = ty.strip_prefix("mut ") {
10541 ty = stripped.trim_start();
10542 }
10543 continue;
10544 }
10545 if let Some(stripped) = ty.strip_prefix("mut ") {
10546 ty = stripped.trim_start();
10547 continue;
10548 }
10549 if let Some(stripped) = ty.strip_prefix("dyn ") {
10550 ty = stripped.trim_start();
10551 continue;
10552 }
10553 if let Some(stripped) = ty.strip_prefix("impl ") {
10554 ty = stripped.trim_start();
10555 continue;
10556 }
10557 break ty.trim();
10558 }
10559}
10560
10561fn strip_leading_lifetime(value: &str) -> Option<&str> {
10562 let mut chars = value.char_indices();
10563 let (_, first) = chars.next()?;
10564 if first != '\'' {
10565 return None;
10566 }
10567 for (index, ch) in chars {
10568 if !(ch == '_' || ch.is_ascii_alphanumeric()) {
10569 return Some(&value[index..]);
10570 }
10571 }
10572 Some("")
10573}
10574
10575fn single_outer_generic_arg(ty: &str) -> Option<&str> {
10576 let ty = ty.trim();
10577 let open = ty.find('<')?;
10578 let mut depth = 0usize;
10579 let mut close = None;
10580 for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
10581 match ch {
10582 '<' => depth += 1,
10583 '>' => {
10584 depth = depth.saturating_sub(1);
10585 if depth == 0 {
10586 close = Some(index);
10587 break;
10588 }
10589 }
10590 _ => {}
10591 }
10592 }
10593 let close = close?;
10594 if !ty[close + 1..].trim().is_empty() {
10595 return None;
10596 }
10597 let inner = &ty[open + 1..close];
10598 let args = split_top_level_commas(inner);
10599 match args.as_slice() {
10600 [arg] => Some(*arg),
10601 _ => None,
10602 }
10603}
10604
10605fn rust_base_type_ident(ty: &str) -> Option<String> {
10606 let ty = ty.trim();
10607 let head = ty
10608 .split([' ', '+', '='])
10609 .find(|part| !part.is_empty())
10610 .unwrap_or(ty);
10611 let head = head.split('<').next().unwrap_or(head).trim();
10612 let ident = head
10613 .rsplit("::")
10614 .next()
10615 .unwrap_or(head)
10616 .trim_matches(|ch: char| !is_rust_ident_char(ch));
10617 if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
10618 None
10619 } else {
10620 Some(ident.to_string())
10621 }
10622}
10623
10624fn is_rust_ident_char(ch: char) -> bool {
10625 ch == '_' || ch.is_ascii_alphanumeric()
10626}
10627
10628fn select_rust_direct_self_field_candidate(
10629 project_root: &Path,
10630 reference: &NameMatchRef,
10631 candidates: &[NameMatchCandidate],
10632 receiver_type: &str,
10633 declaration_file: &str,
10634 declaration_scope: &[(usize, usize)],
10635 source_cache: &mut DispatchSourceCache,
10636) -> Option<NameMatchCandidate> {
10637 let eligible = candidates
10638 .iter()
10639 .filter(|candidate| candidate.node_id != reference.caller_node)
10640 .filter(|candidate| {
10641 type_candidate_matches(candidate, receiver_type, &reference.method_name)
10642 })
10643 .filter(|candidate| {
10644 rust_direct_self_field_candidate_matches_scope(
10645 project_root,
10646 candidate,
10647 receiver_type,
10648 declaration_file,
10649 declaration_scope,
10650 source_cache,
10651 )
10652 })
10653 .collect::<Vec<_>>();
10654 match eligible.as_slice() {
10655 [candidate] => Some((**candidate).clone()),
10656 _ => None,
10657 }
10658}
10659
10660fn rust_direct_self_field_candidate_matches_scope(
10661 project_root: &Path,
10662 candidate: &NameMatchCandidate,
10663 receiver_type: &str,
10664 declaration_file: &str,
10665 declaration_scope: &[(usize, usize)],
10666 source_cache: &mut DispatchSourceCache,
10667) -> bool {
10668 if candidate.file_path != declaration_file {
10669 return false;
10670 }
10671 let Some(parsed) = parsed_dispatch_source_for_file(
10672 project_root,
10673 &candidate.file_path,
10674 "rust",
10675 LangId::Rust,
10676 source_cache,
10677 ) else {
10678 return false;
10679 };
10680 let Some(impl_node) =
10681 find_enclosing_rust_impl_node(parsed.tree.root_node(), candidate.start_line)
10682 else {
10683 return false;
10684 };
10685 if impl_node.child_by_field_name("trait").is_some()
10686 || impl_node.child_by_field_name("type_parameters").is_some()
10687 {
10688 return false;
10689 }
10690 let Some(impl_target) = impl_node.child_by_field_name("type") else {
10691 return false;
10692 };
10693 impl_target.kind() == "type_identifier"
10694 && node_text(impl_target, &parsed.source) == receiver_type
10695 && rust_module_scope(impl_node) == declaration_scope
10696}
10697
10698fn select_type_match_candidate(
10699 reference: &NameMatchRef,
10700 candidates: &[NameMatchCandidate],
10701 receiver_type: &str,
10702) -> Option<NameMatchCandidate> {
10703 let candidates = candidates
10704 .iter()
10705 .filter(|candidate| candidate.node_id != reference.caller_node)
10706 .filter(|candidate| {
10707 type_candidate_matches(candidate, receiver_type, &reference.method_name)
10708 })
10709 .collect::<Vec<_>>();
10710 match candidates.as_slice() {
10711 [candidate] => Some((**candidate).clone()),
10712 _ => None,
10713 }
10714}
10715
10716fn type_candidate_matches(
10717 candidate: &NameMatchCandidate,
10718 receiver_type: &str,
10719 method_name: &str,
10720) -> bool {
10721 let normalized_type = receiver_type.replace('.', "::");
10722 let suffix = format!("{normalized_type}::{method_name}");
10723 candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
10724}
10725
10726fn select_name_match_candidate(
10727 reference: &NameMatchRef,
10728 candidates: &[NameMatchCandidate],
10729) -> Option<NameMatchCandidate> {
10730 let candidates = candidates
10731 .iter()
10732 .filter(|candidate| candidate.node_id != reference.caller_node)
10733 .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
10734 .collect::<Vec<_>>();
10735 match candidates.as_slice() {
10736 [] => None,
10737 [candidate] => Some((**candidate).clone()),
10738 _ => select_scored_name_match_candidate(reference, &candidates),
10739 }
10740}
10741
10742fn candidate_allowed_for_reference(
10743 reference: &NameMatchRef,
10744 candidate: &NameMatchCandidate,
10745) -> bool {
10746 if !reference.colon_dispatch {
10747 return true;
10748 }
10749
10750 candidate.kind == "method"
10751 && candidate
10752 .scoped_name
10753 .split("::")
10754 .any(|segment| segment == reference.receiver)
10755}
10756
10757fn select_scored_name_match_candidate(
10758 reference: &NameMatchRef,
10759 candidates: &[&NameMatchCandidate],
10760) -> Option<NameMatchCandidate> {
10761 let receiver_words = split_camel_case(&reference.receiver);
10762 if receiver_words.is_empty() {
10763 return None;
10764 }
10765
10766 let mut best: Option<(&NameMatchCandidate, f64)> = None;
10767 let mut tied_best = false;
10768 for candidate in candidates {
10769 let candidate_words = split_camel_case(&candidate.scoped_name);
10770 let overlap = receiver_words
10771 .iter()
10772 .filter(|receiver_word| {
10773 candidate_words
10774 .iter()
10775 .any(|candidate_word| candidate_word == *receiver_word)
10776 })
10777 .count() as f64;
10778 let score =
10779 overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
10780 match best {
10781 None => {
10782 best = Some((*candidate, score));
10783 tied_best = false;
10784 }
10785 Some((_, best_score)) if score > best_score => {
10786 best = Some((*candidate, score));
10787 tied_best = false;
10788 }
10789 Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
10790 tied_best = true;
10791 }
10792 _ => {}
10793 }
10794 }
10795
10796 let (candidate, score) = best?;
10797 if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
10798 Some(candidate.clone())
10799 } else {
10800 None
10801 }
10802}
10803
10804fn method_name_match_denylisted(method_name: &str) -> bool {
10805 matches!(
10806 method_name,
10807 "and_then"
10808 | "as_bytes"
10809 | "as_deref"
10810 | "as_mut"
10811 | "as_ref"
10812 | "as_str"
10813 | "borrow"
10814 | "borrow_mut"
10815 | "clear"
10816 | "clone"
10817 | "collect"
10818 | "contains"
10819 | "contains_key"
10820 | "count"
10821 | "dedup"
10822 | "default"
10823 | "drain"
10824 | "ends_with"
10825 | "entry"
10826 | "err"
10827 | "expect"
10828 | "extend"
10829 | "filter"
10830 | "filter_map"
10831 | "find"
10832 | "from"
10833 | "get"
10834 | "get_mut"
10835 | "insert"
10836 | "into"
10837 | "into_iter"
10838 | "is_empty"
10839 | "is_err"
10840 | "is_none"
10841 | "is_ok"
10842 | "is_some"
10843 | "iter"
10844 | "iter_mut"
10845 | "join"
10846 | "len"
10847 | "lock"
10848 | "map"
10849 | "map_err"
10850 | "max"
10851 | "min"
10852 | "new"
10853 | "next"
10854 | "ok"
10855 | "or_default"
10856 | "or_else"
10857 | "or_insert"
10858 | "or_insert_with"
10859 | "parse"
10860 | "pop"
10861 | "position"
10862 | "push"
10863 | "read"
10864 | "recv"
10865 | "remove"
10866 | "replace"
10867 | "retain"
10868 | "send"
10869 | "sort"
10870 | "sort_by"
10871 | "split"
10872 | "starts_with"
10873 | "sum"
10874 | "take"
10875 | "to_owned"
10876 | "to_string"
10877 | "trim"
10878 | "try_from"
10879 | "try_into"
10880 | "unwrap"
10881 | "unwrap_or"
10882 | "unwrap_or_default"
10883 | "unwrap_or_else"
10884 | "with_capacity"
10885 | "write"
10886 )
10887}
10888
10889fn split_camel_case(value: &str) -> Vec<String> {
10890 let chars = value.chars().collect::<Vec<_>>();
10891 let mut normalized = String::with_capacity(value.len() + 8);
10892 for (index, ch) in chars.iter().enumerate() {
10893 let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
10894 let next = chars.get(index + 1);
10895 let is_separator = ch.is_whitespace()
10896 || matches!(
10897 ch,
10898 '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
10899 );
10900 if is_separator {
10901 normalized.push(' ');
10902 continue;
10903 }
10904 let camel_boundary = previous.is_some_and(|prev| {
10905 (prev.is_lowercase() && ch.is_uppercase())
10906 || (prev.is_ascii_digit() && ch.is_alphabetic())
10907 || (prev.is_uppercase()
10908 && ch.is_uppercase()
10909 && next.is_some_and(|next| next.is_lowercase()))
10910 });
10911 if camel_boundary {
10912 normalized.push(' ');
10913 }
10914 normalized.push(*ch);
10915 }
10916
10917 normalized
10918 .split_whitespace()
10919 .filter(|word| word.len() > 1)
10920 .map(|word| word.to_ascii_lowercase())
10921 .collect()
10922}
10923
10924fn compute_path_proximity(left: &str, right: &str) -> f64 {
10925 let left_dirs = left
10926 .rsplit_once('/')
10927 .map(|(dir, _)| dir)
10928 .unwrap_or_default()
10929 .split('/')
10930 .filter(|part| !part.is_empty());
10931 let right_dirs = right
10932 .rsplit_once('/')
10933 .map(|(dir, _)| dir)
10934 .unwrap_or_default()
10935 .split('/')
10936 .filter(|part| !part.is_empty());
10937
10938 let shared = left_dirs
10939 .zip(right_dirs)
10940 .take_while(|(left, right)| left == right)
10941 .count();
10942 ((shared as f64) * 0.05).min(0.5)
10943}
10944
10945fn mark_backend_state(
10946 tx: &Transaction<'_>,
10947 project_root: &Path,
10948 rel_path: &str,
10949 content_hash: Option<&blake3::Hash>,
10950 status: &str,
10951) -> Result<()> {
10952 clear_backend_state_for_file(tx, project_root, rel_path)?;
10953 let hash = content_hash
10954 .map(|hash| hash_to_hex(*hash))
10955 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
10956 tx.execute(
10957 "INSERT OR REPLACE INTO backend_file_state(
10958 backend, workspace_root, file_path, content_hash, status, updated_at
10959 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
10960 params![
10961 BACKEND_TREESITTER,
10962 project_root.display().to_string(),
10963 rel_path,
10964 hash,
10965 status,
10966 unix_seconds_now(),
10967 ],
10968 )?;
10969 Ok(())
10970}
10971
10972fn clear_backend_state_for_file(
10973 tx: &Transaction<'_>,
10974 project_root: &Path,
10975 rel_path: &str,
10976) -> Result<()> {
10977 tx.execute(
10978 "DELETE FROM backend_file_state
10979 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
10980 params![
10981 BACKEND_TREESITTER,
10982 project_root.display().to_string(),
10983 rel_path
10984 ],
10985 )?;
10986 Ok(())
10987}
10988
10989fn load_file_row(tx: &Transaction<'_>, rel_path: &str) -> Result<Option<FileRow>> {
10990 tx.query_row(
10991 "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
10992 params![rel_path],
10993 |row| {
10994 let hash_text: String = row.get(1)?;
10995 Ok(FileRow {
10996 surface_fingerprint: row.get(0)?,
10997 freshness: FileFreshness {
10998 content_hash: hash_from_hex(&hash_text)
10999 .unwrap_or_else(cache_freshness::zero_hash),
11000 mtime: ns_to_system_time(row.get::<_, i64>(2)?),
11001 size: row.get::<_, i64>(3)? as u64,
11002 },
11003 })
11004 },
11005 )
11006 .optional()
11007 .map_err(CallGraphStoreError::from)
11008}
11009
11010fn stored_node_ids_match_extract(
11011 tx: &Transaction<'_>,
11012 rel_path: &str,
11013 extract: &FileExtract,
11014) -> Result<bool> {
11015 let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
11016 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
11017 let mut stored = BTreeSet::new();
11018 for row in rows {
11019 stored.insert(row?);
11020 }
11021 let extracted = extract
11022 .nodes
11023 .iter()
11024 .map(|node| node.id.clone())
11025 .collect::<BTreeSet<_>>();
11026 Ok(stored == extracted)
11027}
11028
11029fn stored_extract_matches(
11033 tx: &Transaction<'_>,
11034 rel_path: &str,
11035 extract: &FileExtract,
11036 index: &ProjectIndex<'_>,
11037) -> Result<bool> {
11038 let stored_file = tx
11039 .query_row(
11040 "SELECT lang, surface_fingerprint FROM files WHERE path = ?1",
11041 params![rel_path],
11042 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
11043 )
11044 .optional()?;
11045 if stored_file
11046 != Some((
11047 lang_label(extract.lang).to_string(),
11048 extract.surface_fingerprint.clone(),
11049 ))
11050 {
11051 return Ok(false);
11052 }
11053
11054 let mut stored_nodes_stmt = tx.prepare(
11055 "SELECT id, file_path, name, scoped_name, kind, start_line, start_col,
11056 end_line, end_col, range_ordinal, signature, exported,
11057 is_default_export, is_type_like, is_callgraph_entry_point, provenance
11058 FROM nodes WHERE file_path = ?1",
11059 )?;
11060 let stored_nodes = stored_nodes_stmt
11061 .query_map(params![rel_path], |row| {
11062 Ok(serde_json::json!([
11063 row.get::<_, String>(0)?,
11064 row.get::<_, String>(1)?,
11065 row.get::<_, String>(2)?,
11066 row.get::<_, String>(3)?,
11067 row.get::<_, String>(4)?,
11068 row.get::<_, i64>(5)?,
11069 row.get::<_, i64>(6)?,
11070 row.get::<_, i64>(7)?,
11071 row.get::<_, i64>(8)?,
11072 row.get::<_, i64>(9)?,
11073 row.get::<_, Option<String>>(10)?,
11074 row.get::<_, i64>(11)?,
11075 row.get::<_, i64>(12)?,
11076 row.get::<_, i64>(13)?,
11077 row.get::<_, i64>(14)?,
11078 row.get::<_, String>(15)?,
11079 ])
11080 .to_string())
11081 })?
11082 .collect::<rusqlite::Result<Vec<_>>>()?;
11083 let expected_nodes = extract
11084 .nodes
11085 .iter()
11086 .map(|node| {
11087 serde_json::json!([
11088 node.id,
11089 node.file_path,
11090 node.name,
11091 node.scoped_name,
11092 node.kind,
11093 node.range.start_line,
11094 node.range.start_col,
11095 node.range.end_line,
11096 node.range.end_col,
11097 node.range_ordinal,
11098 node.signature,
11099 bool_int(node.exported),
11100 bool_int(node.is_default_export),
11101 bool_int(node.is_type_like),
11102 bool_int(node.is_callgraph_entry_point),
11103 PROVENANCE_TREESITTER,
11104 ])
11105 .to_string()
11106 })
11107 .collect::<Vec<_>>();
11108 let mut stored_nodes = stored_nodes;
11109 let mut expected_nodes = expected_nodes;
11110 stored_nodes.sort();
11111 expected_nodes.sort();
11112 if stored_nodes != expected_nodes {
11113 return Ok(false);
11114 }
11115
11116 let resolved_refs = extract
11117 .raw_refs
11118 .iter()
11119 .cloned()
11120 .map(|raw| resolve_ref(raw, index))
11121 .collect::<Result<Vec<_>>>()?;
11122 let mut stored_refs_stmt = tx.prepare(
11123 "SELECT ref_id, caller_node, caller_file, kind, short_name, full_ref,
11124 module_path, import_kind, local_name, requested_name, namespace_alias,
11125 wildcard, line, byte_start, byte_end, status, target_node,
11126 target_file, target_symbol, provenance
11127 FROM refs WHERE caller_file = ?1",
11128 )?;
11129 let stored_refs = stored_refs_stmt
11130 .query_map(params![rel_path], |row| {
11131 Ok(serde_json::json!([
11132 row.get::<_, String>(0)?,
11133 row.get::<_, Option<String>>(1)?,
11134 row.get::<_, String>(2)?,
11135 row.get::<_, String>(3)?,
11136 row.get::<_, Option<String>>(4)?,
11137 row.get::<_, Option<String>>(5)?,
11138 row.get::<_, Option<String>>(6)?,
11139 row.get::<_, Option<String>>(7)?,
11140 row.get::<_, Option<String>>(8)?,
11141 row.get::<_, Option<String>>(9)?,
11142 row.get::<_, Option<String>>(10)?,
11143 row.get::<_, i64>(11)?,
11144 row.get::<_, i64>(12)?,
11145 row.get::<_, i64>(13)?,
11146 row.get::<_, i64>(14)?,
11147 row.get::<_, String>(15)?,
11148 row.get::<_, Option<String>>(16)?,
11149 row.get::<_, Option<String>>(17)?,
11150 row.get::<_, Option<String>>(18)?,
11151 row.get::<_, String>(19)?,
11152 ])
11153 .to_string())
11154 })?
11155 .collect::<rusqlite::Result<Vec<_>>>()?;
11156 let expected_refs = resolved_refs
11157 .iter()
11158 .map(|resolved| {
11159 let raw = &resolved.raw;
11160 serde_json::json!([
11161 raw.ref_id,
11162 raw.caller_node,
11163 raw.caller_file,
11164 raw.kind,
11165 raw.short_name,
11166 raw.full_ref,
11167 raw.module_path,
11168 raw.import_kind,
11169 raw.local_name,
11170 raw.requested_name,
11171 raw.namespace_alias,
11172 bool_int(raw.wildcard),
11173 raw.line,
11174 raw.byte_start,
11175 raw.byte_end,
11176 resolved.status,
11177 resolved.target_node,
11178 resolved.target_file,
11179 resolved.target_symbol,
11180 PROVENANCE_TREESITTER,
11181 ])
11182 .to_string()
11183 })
11184 .collect::<Vec<_>>();
11185 let mut stored_refs = stored_refs;
11186 let mut expected_refs = expected_refs;
11187 stored_refs.sort();
11188 expected_refs.sort();
11189 if stored_refs != expected_refs {
11190 return Ok(false);
11191 }
11192
11193 let mut stored_edges_stmt = tx.prepare(
11194 "SELECT e.edge_id, e.ref_id, e.source_node, e.target_node,
11195 e.target_file, e.target_symbol, e.kind, e.line, e.provenance
11196 FROM edges e JOIN refs r ON r.ref_id = e.ref_id
11197 WHERE r.caller_file = ?1 AND e.provenance = ?2",
11198 )?;
11199 let stored_edges = stored_edges_stmt
11200 .query_map(params![rel_path, PROVENANCE_TREESITTER], |row| {
11201 Ok(serde_json::json!([
11202 row.get::<_, String>(0)?,
11203 row.get::<_, String>(1)?,
11204 row.get::<_, String>(2)?,
11205 row.get::<_, Option<String>>(3)?,
11206 row.get::<_, String>(4)?,
11207 row.get::<_, String>(5)?,
11208 row.get::<_, String>(6)?,
11209 row.get::<_, i64>(7)?,
11210 row.get::<_, String>(8)?,
11211 ])
11212 .to_string())
11213 })?
11214 .collect::<rusqlite::Result<Vec<_>>>()?;
11215 let expected_edges = resolved_refs
11216 .iter()
11217 .filter_map(|resolved| {
11218 resolved.edge.as_ref().map(|edge| {
11219 serde_json::json!([
11220 edge.edge_id,
11221 resolved.raw.ref_id,
11222 edge.source_node,
11223 edge.target_node,
11224 edge.target_file,
11225 edge.target_symbol,
11226 edge.kind,
11227 edge.line,
11228 PROVENANCE_TREESITTER,
11229 ])
11230 .to_string()
11231 })
11232 })
11233 .collect::<Vec<_>>();
11234 let mut stored_edges = stored_edges;
11235 let mut expected_edges = expected_edges;
11236 stored_edges.sort();
11237 expected_edges.sort();
11238 if stored_edges != expected_edges {
11239 return Ok(false);
11240 }
11241
11242 let mut stored_dependencies_stmt =
11243 tx.prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1")?;
11244 let stored_dependencies = stored_dependencies_stmt
11245 .query_map(params![rel_path], |row| row.get::<_, String>(0))?
11246 .collect::<rusqlite::Result<BTreeSet<_>>>()?;
11247 let expected_dependencies = extract
11248 .raw_refs
11249 .iter()
11250 .flat_map(|raw| raw.dependencies.iter().cloned())
11251 .collect::<BTreeSet<_>>();
11252 if stored_dependencies != expected_dependencies {
11253 return Ok(false);
11254 }
11255
11256 let mut stored_hints_stmt = tx.prepare(
11257 "SELECT id, method_name, caller_node, file, line, byte_start, byte_end, provenance
11258 FROM dispatch_hints WHERE file = ?1",
11259 )?;
11260 let stored_hints = stored_hints_stmt
11261 .query_map(params![rel_path], |row| {
11262 Ok(serde_json::json!([
11263 row.get::<_, String>(0)?,
11264 row.get::<_, String>(1)?,
11265 row.get::<_, String>(2)?,
11266 row.get::<_, String>(3)?,
11267 row.get::<_, i64>(4)?,
11268 row.get::<_, i64>(5)?,
11269 row.get::<_, i64>(6)?,
11270 row.get::<_, String>(7)?,
11271 ])
11272 .to_string())
11273 })?
11274 .collect::<rusqlite::Result<Vec<_>>>()?;
11275 let expected_hints = extract
11276 .dispatch_hints
11277 .iter()
11278 .map(|hint| {
11279 serde_json::json!([
11280 hint.id,
11281 hint.method_name,
11282 hint.caller_node,
11283 hint.file,
11284 hint.line,
11285 hint.byte_start,
11286 hint.byte_end,
11287 PROVENANCE_TREESITTER,
11288 ])
11289 .to_string()
11290 })
11291 .collect::<Vec<_>>();
11292 let mut stored_hints = stored_hints;
11293 let mut expected_hints = expected_hints;
11294 stored_hints.sort();
11295 expected_hints.sort();
11296 Ok(stored_hints == expected_hints)
11297}
11298
11299fn update_file_fresh_metadata(
11300 tx: &Transaction<'_>,
11301 project_root: &Path,
11302 rel_path: &str,
11303 hash: &blake3::Hash,
11304 mtime: SystemTime,
11305 size: u64,
11306) -> Result<()> {
11307 tx.execute(
11308 "UPDATE files SET content_hash = ?2, mtime_ns = ?3, size = ?4, indexed_at = ?5
11309 WHERE path = ?1",
11310 params![
11311 rel_path,
11312 hash_to_hex(*hash),
11313 system_time_to_ns(mtime),
11314 size as i64,
11315 unix_seconds_now()
11316 ],
11317 )?;
11318 tx.execute(
11319 "UPDATE backend_file_state SET content_hash = ?3, status = 'fresh', updated_at = ?5
11320 WHERE backend = ?1 AND file_path = ?2 AND workspace_root = ?4",
11321 params![
11322 BACKEND_TREESITTER,
11323 rel_path,
11324 hash_to_hex(*hash),
11325 project_root.display().to_string(),
11326 unix_seconds_now(),
11327 ],
11328 )?;
11329 Ok(())
11330}
11331
11332#[derive(Debug, Clone, PartialEq, Eq)]
11333struct DependentRefSelection {
11334 ref_id: String,
11335 caller_file: String,
11336}
11337
11338fn ref_ids_depending_on(
11339 tx: &Transaction<'_>,
11340 project_root: &Path,
11341 rel_path: &str,
11342) -> Result<Vec<DependentRefSelection>> {
11343 let mut stmt = tx.prepare(
11344 "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
11345 FROM refs r
11346 WHERE r.caller_file IN (
11347 SELECT file_path FROM file_dependencies WHERE dep_file = ?1
11348 )
11349 OR r.target_file = ?1
11350 ORDER BY r.ref_id",
11351 )?;
11352 let rows = stmt.query_map(params![rel_path], |row| {
11353 Ok(RefDependencyRow {
11354 ref_id: row.get(0)?,
11355 kind: row.get(1)?,
11356 caller_file: row.get(2)?,
11357 module_path: row.get(3)?,
11358 target_file: row.get(4)?,
11359 })
11360 })?;
11361 let mut ids = Vec::new();
11362 for row in rows {
11363 let row = row?;
11364 if ref_dependency_row_depends_on(project_root, &row, rel_path) {
11365 ids.push(DependentRefSelection {
11366 ref_id: row.ref_id,
11367 caller_file: row.caller_file,
11368 });
11369 }
11370 }
11371 Ok(ids)
11372}
11373
11374fn record_dependent_refs(
11375 selected_ref_ids: &mut BTreeSet<String>,
11376 selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
11377 dependent_refs: Vec<DependentRefSelection>,
11378) {
11379 for dependent_ref in dependent_refs {
11380 let DependentRefSelection {
11381 ref_id,
11382 caller_file,
11383 } = dependent_ref;
11384 selected_ref_ids.insert(ref_id.clone());
11385 selected_refs_by_caller
11386 .entry(caller_file)
11387 .or_default()
11388 .insert(ref_id);
11389 }
11390}
11391
11392#[cfg(test)]
11393fn refs_by_caller_for_ref_ids(
11394 tx: &Transaction<'_>,
11395 ref_ids: &BTreeSet<String>,
11396) -> Result<BTreeMap<String, BTreeSet<String>>> {
11397 let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11398 let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
11399 for ref_id in ref_ids {
11400 if let Some(caller) = stmt
11401 .query_row(params![ref_id], |row| row.get::<_, String>(0))
11402 .optional()?
11403 {
11404 by_caller.entry(caller).or_default().insert(ref_id.clone());
11405 }
11406 }
11407 Ok(by_caller)
11408}
11409
11410fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
11411 tx.execute(
11412 "DELETE FROM file_dependencies WHERE file_path = ?1",
11413 params![rel_path],
11414 )?;
11415 delete_refs_for_caller(tx, rel_path)?;
11416 tx.execute(
11417 "DELETE FROM dispatch_hints WHERE file = ?1",
11418 params![rel_path],
11419 )?;
11420 tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
11421 tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
11422 Ok(())
11423}
11424
11425fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
11426 let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
11427 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
11428 let mut ids = BTreeSet::new();
11429 for row in rows {
11430 ids.insert(row?);
11431 }
11432 delete_ref_ids(tx, &ids)
11433}
11434
11435fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
11436 for ref_id in ref_ids {
11437 tx.execute("DELETE FROM edges WHERE ref_id = ?1", params![ref_id])?;
11438 tx.execute("DELETE FROM refs WHERE ref_id = ?1", params![ref_id])?;
11439 }
11440 Ok(())
11441}
11442
11443fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
11444 let mut stmt = conn.prepare(
11445 "SELECT source.file_path, source.scoped_name, edges.target_file,
11446 edges.target_symbol, edges.kind, edges.line
11447 FROM edges
11448 JOIN nodes AS source ON source.id = edges.source_node
11449 ORDER BY source.file_path, source.scoped_name, edges.target_file,
11450 edges.target_symbol, edges.kind, edges.line",
11451 )?;
11452 let rows = stmt.query_map([], |row| {
11453 Ok(StoredEdge {
11454 source_file: row.get(0)?,
11455 source_symbol: row.get(1)?,
11456 target_file: row.get(2)?,
11457 target_symbol: row.get(3)?,
11458 kind: row.get(4)?,
11459 line: row.get::<_, i64>(5)? as u32,
11460 })
11461 })?;
11462 let mut edges = BTreeSet::new();
11463 for row in rows {
11464 edges.insert(row?);
11465 }
11466 Ok(edges)
11467}
11468
11469fn module_target_from_dependencies(
11470 project_root: &Path,
11471 dependencies: &BTreeSet<String>,
11472) -> Option<String> {
11473 dependencies.iter().find_map(|dep| {
11474 let path = project_root.join(dep);
11475 if path.is_file() {
11476 Some(relative_path(project_root, &canonicalize_path(&path)))
11477 } else {
11478 None
11479 }
11480 })
11481}
11482
11483fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
11484 let mut named = HashMap::new();
11485 if let Some(full_ref) = &raw_ref.full_ref {
11486 named = parse_reexport_names(full_ref);
11487 }
11488 ReexportIndex {
11489 target_file,
11490 named,
11491 wildcard: raw_ref.wildcard,
11492 }
11493}
11494
11495fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
11496 let mut names = HashMap::new();
11497 let Some(open) = statement.find('{') else {
11498 return names;
11499 };
11500 let Some(close) = statement[open + 1..]
11501 .find('}')
11502 .map(|offset| open + 1 + offset)
11503 else {
11504 return names;
11505 };
11506 for spec in statement[open + 1..close].split(',') {
11507 let spec = spec.trim();
11508 if spec.is_empty() {
11509 continue;
11510 }
11511 if let Some((source, local)) = spec.split_once(" as ") {
11512 names.insert(local.trim().to_string(), source.trim().to_string());
11513 } else {
11514 names.insert(spec.to_string(), spec.to_string());
11515 }
11516 }
11517 names
11518}
11519
11520#[derive(Debug)]
11521struct RefDependencyRow {
11522 ref_id: String,
11523 kind: String,
11524 caller_file: String,
11525 module_path: Option<String>,
11526 target_file: Option<String>,
11527}
11528
11529fn ref_dependency_row_depends_on(
11530 project_root: &Path,
11531 row: &RefDependencyRow,
11532 rel_path: &str,
11533) -> bool {
11534 if row.target_file.as_deref() == Some(rel_path) {
11535 return true;
11536 }
11537
11538 match row.kind.as_str() {
11539 "call" => true,
11540 "import" | "reexport" => row
11541 .module_path
11542 .as_deref()
11543 .map(|module_path| {
11544 module_dependencies_for_ref(project_root, &row.caller_file, module_path)
11545 .contains(rel_path)
11546 })
11547 .unwrap_or(false),
11548 "export_alias" => false,
11549 _ => false,
11550 }
11551}
11552
11553fn module_dependencies_for_ref(
11554 project_root: &Path,
11555 caller_file: &str,
11556 module_path: &str,
11557) -> BTreeSet<String> {
11558 module_dependencies(project_root, &project_root.join(caller_file), module_path)
11559}
11560
11561fn import_dependencies(
11562 project_root: &Path,
11563 abs_path: &Path,
11564 imports: &[ImportStatement],
11565) -> BTreeSet<String> {
11566 let mut deps = BTreeSet::new();
11567 for import in imports {
11568 deps.extend(module_dependencies(
11569 project_root,
11570 abs_path,
11571 &import.module_path,
11572 ));
11573 }
11574 deps
11575}
11576
11577fn module_dependencies(
11578 project_root: &Path,
11579 abs_path: &Path,
11580 module_path: &str,
11581) -> BTreeSet<String> {
11582 let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
11583 let caller_dir = abs_path.parent().unwrap_or(project_root);
11584 if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
11585 deps.insert(relative_path(project_root, &resolved));
11586 }
11587 if module_path.starts_with('.') {
11588 let base = caller_dir.join(module_path);
11589 for candidate in relative_module_candidates(&base) {
11590 deps.insert(relative_path(project_root, &candidate));
11591 }
11592 }
11593 deps
11594}
11595
11596fn rust_module_dependencies(
11597 project_root: &Path,
11598 abs_path: &Path,
11599 module_path: &str,
11600) -> BTreeSet<String> {
11601 let mut deps = BTreeSet::new();
11602 let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
11603 let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
11604 return deps;
11605 };
11606 let src_prefix = rust_src_prefix(&rel_path);
11607 rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
11608 if !path_segments.is_empty() {
11609 rust_push_module_dependency_candidate(
11610 project_root,
11611 &mut deps,
11612 &src_prefix,
11613 &path_segments[..path_segments.len() - 1],
11614 );
11615 }
11616 deps
11617}
11618
11619fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
11620 let path = rust_module_path_without_alias_or_use_list(module_path);
11621 let segments = path
11622 .split("::")
11623 .map(str::trim)
11624 .filter(|segment| !segment.is_empty())
11625 .collect::<Vec<_>>();
11626 if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
11627 return None;
11628 }
11629 rust_resolve_segments(rel_path, &segments)
11630}
11631
11632fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
11633 let path = module_path
11634 .trim()
11635 .trim_end_matches(';')
11636 .split_once(" as ")
11637 .map(|(left, _)| left.trim())
11638 .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
11639 path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
11640}
11641
11642fn rust_push_module_dependency_candidate(
11643 project_root: &Path,
11644 deps: &mut BTreeSet<String>,
11645 src_prefix: &str,
11646 segments: &[String],
11647) {
11648 let candidates = if segments.is_empty() {
11649 vec![
11650 format!("{src_prefix}/lib.rs"),
11651 format!("{src_prefix}/main.rs"),
11652 ]
11653 } else {
11654 vec![
11655 format!("{}/{}.rs", src_prefix, segments.join("/")),
11656 format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
11657 ]
11658 };
11659 for candidate in candidates {
11660 if project_root.join(&candidate).is_file() {
11661 deps.insert(candidate);
11662 }
11663 }
11664}
11665
11666fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
11667 let mut candidates = Vec::new();
11668 if base.extension().is_some() {
11669 candidates.push(base.to_path_buf());
11670 return candidates;
11671 }
11672 for ext in JS_TS_EXTENSIONS {
11673 candidates.push(base.with_extension(ext));
11674 }
11675 for ext in JS_TS_EXTENSIONS {
11676 candidates.push(base.join(format!("index.{ext}")));
11677 }
11678 candidates
11679}
11680
11681fn import_local_names(import: &ImportStatement) -> Vec<String> {
11682 let mut names = Vec::new();
11683 if let Some(default) = &import.default_import {
11684 names.push(default.clone());
11685 }
11686 if let Some(namespace) = &import.namespace_import {
11687 names.push(namespace.clone());
11688 }
11689 for name in &import.names {
11690 names.push(crate::imports::specifier_local_name(name).to_string());
11691 }
11692 names
11693}
11694
11695fn import_requested_names(import: &ImportStatement) -> Vec<String> {
11696 import
11697 .names
11698 .iter()
11699 .map(|name| crate::imports::specifier_imported_name(name).to_string())
11700 .collect()
11701}
11702
11703fn import_is_wildcard(import: &ImportStatement) -> bool {
11704 import.namespace_import.is_some() || import.raw_text.contains('*')
11705}
11706
11707fn namespace_alias(full_ref: &str) -> Option<String> {
11708 full_ref
11709 .split_once('.')
11710 .map(|(namespace, _)| namespace.to_string())
11711}
11712
11713fn import_kind_label(kind: ImportKind) -> &'static str {
11714 match kind {
11715 ImportKind::Value => "value",
11716 ImportKind::Type => "type",
11717 ImportKind::SideEffect => "side_effect",
11718 }
11719}
11720
11721fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
11722 match kind {
11723 SymbolKind::Function => "function",
11724 SymbolKind::Class => "class",
11725 SymbolKind::Method => "method",
11726 SymbolKind::Struct => "struct",
11727 SymbolKind::Interface => "interface",
11728 SymbolKind::Enum => "enum",
11729 SymbolKind::TypeAlias => "type_alias",
11730 SymbolKind::Variable => "variable",
11731 SymbolKind::Heading => "heading",
11732 SymbolKind::FileSummary => "file_summary",
11733 }
11734}
11735
11736fn is_type_like(kind: &SymbolKind) -> bool {
11737 matches!(
11738 kind,
11739 SymbolKind::Class
11740 | SymbolKind::Struct
11741 | SymbolKind::Interface
11742 | SymbolKind::Enum
11743 | SymbolKind::TypeAlias
11744 )
11745}
11746
11747fn lang_label(lang: LangId) -> &'static str {
11748 match lang {
11749 LangId::TypeScript => "typescript",
11750 LangId::Tsx => "tsx",
11751 LangId::JavaScript => "javascript",
11752 LangId::Python => "python",
11753 LangId::Rust => "rust",
11754 LangId::Go => "go",
11755 LangId::C => "c",
11756 LangId::Cpp => "cpp",
11757 LangId::Zig => "zig",
11758 LangId::CSharp => "csharp",
11759 LangId::Bash => "bash",
11760 LangId::Html => "html",
11761 LangId::Markdown => "markdown",
11762 LangId::Solidity => "solidity",
11763 LangId::Scss => "scss",
11764 LangId::Vue => "vue",
11765 LangId::Json => "json",
11766 LangId::Scala => "scala",
11767 LangId::Java => "java",
11768 LangId::Ruby => "ruby",
11769 LangId::Kotlin => "kotlin",
11770 LangId::Swift => "swift",
11771 LangId::Php => "php",
11772 LangId::Lua => "lua",
11773 LangId::Perl => "perl",
11774 LangId::Yaml => "yaml",
11775 LangId::Pascal => "pascal",
11776 LangId::R => "r",
11777 LangId::Groovy => "groovy",
11778 LangId::ObjC => "objc",
11779 }
11780}
11781
11782fn lang_from_label(label: &str) -> Option<LangId> {
11783 match label {
11784 "typescript" => Some(LangId::TypeScript),
11785 "tsx" => Some(LangId::Tsx),
11786 "javascript" => Some(LangId::JavaScript),
11787 "python" => Some(LangId::Python),
11788 "rust" => Some(LangId::Rust),
11789 "go" => Some(LangId::Go),
11790 "c" => Some(LangId::C),
11791 "cpp" => Some(LangId::Cpp),
11792 "zig" => Some(LangId::Zig),
11793 "csharp" => Some(LangId::CSharp),
11794 "bash" => Some(LangId::Bash),
11795 "html" => Some(LangId::Html),
11796 "markdown" => Some(LangId::Markdown),
11797 "solidity" => Some(LangId::Solidity),
11798 "scss" => Some(LangId::Scss),
11799 "vue" => Some(LangId::Vue),
11800 "json" => Some(LangId::Json),
11801 "scala" => Some(LangId::Scala),
11802 "java" => Some(LangId::Java),
11803 "ruby" => Some(LangId::Ruby),
11804 "kotlin" => Some(LangId::Kotlin),
11805 "swift" => Some(LangId::Swift),
11806 "php" => Some(LangId::Php),
11807 "lua" => Some(LangId::Lua),
11808 "perl" => Some(LangId::Perl),
11809 "yaml" => Some(LangId::Yaml),
11810 "pascal" => Some(LangId::Pascal),
11811 "r" => Some(LangId::R),
11812 "groovy" => Some(LangId::Groovy),
11813 "objc" => Some(LangId::ObjC),
11814 _ => None,
11815 }
11816}
11817
11818fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
11819 let mut normalized = if files.is_empty() {
11820 callgraph::walk_project_files(project_root).collect::<Vec<_>>()
11821 } else {
11822 files
11823 .iter()
11824 .map(|path| normalize_file_path(project_root, path))
11825 .collect::<Result<Vec<_>>>()?
11826 };
11827 normalized.sort();
11828 normalized.dedup();
11829 Ok(normalized)
11830}
11831
11832fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
11833 let full_path = if path.is_relative() {
11834 project_root.join(path)
11835 } else {
11836 path.to_path_buf()
11837 };
11838 Ok(canonicalize_path(&full_path))
11839}
11840
11841fn canonicalize_path(path: &Path) -> PathBuf {
11842 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
11843}
11844
11845fn relative_path(project_root: &Path, path: &Path) -> String {
11846 if let Ok(stripped) = path.strip_prefix(project_root) {
11847 return stripped.to_string_lossy().replace('\\', "/");
11848 }
11849 let canon_root = canonicalize_path(project_root);
11850 let canon_path = canonicalize_path(path);
11851 if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
11852 return stripped.to_string_lossy().replace('\\', "/");
11853 }
11854 canon_path.to_string_lossy().replace('\\', "/")
11855}
11856
11857fn unqualified_name(scoped: &str) -> &str {
11858 if scoped == TOP_LEVEL_SYMBOL {
11859 return scoped;
11860 }
11861 scoped
11862 .rsplit("::")
11863 .next()
11864 .unwrap_or(scoped)
11865 .rsplit('.')
11866 .next()
11867 .unwrap_or(scoped)
11868 .rsplit('#')
11869 .next()
11870 .unwrap_or(scoped)
11871}
11872
11873fn ref_id(parts: &[&str]) -> String {
11874 let joined = parts.join("\0");
11875 hash_to_hex(blake3::hash(joined.as_bytes()))
11876}
11877
11878fn hash_to_hex(hash: blake3::Hash) -> String {
11879 hash.to_hex().to_string()
11880}
11881
11882fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
11883 let bytes = hex_to_bytes(value)?;
11884 Some(blake3::Hash::from_bytes(bytes))
11885}
11886
11887fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
11888 if value.len() != 64 {
11889 return None;
11890 }
11891 let mut bytes = [0u8; 32];
11892 for (index, slot) in bytes.iter_mut().enumerate() {
11893 let start = index * 2;
11894 let end = start + 2;
11895 *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
11896 }
11897 Some(bytes)
11898}
11899
11900#[derive(Debug, Clone)]
11901struct LineIndex {
11902 newline_offsets: Vec<usize>,
11903 source_len: usize,
11904}
11905
11906impl LineIndex {
11907 fn new(source: &str) -> Self {
11908 Self {
11909 newline_offsets: source
11910 .bytes()
11911 .enumerate()
11912 .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
11913 .collect(),
11914 source_len: source.len(),
11915 }
11916 }
11917
11918 fn byte_to_line(&self, byte_offset: usize) -> u32 {
11919 let byte_offset = byte_offset.min(self.source_len);
11920 self.newline_offsets
11921 .partition_point(|offset| *offset < byte_offset) as u32
11922 + 1
11923 }
11924}
11925
11926fn empty_to_none(value: String) -> Option<String> {
11927 if value.is_empty() {
11928 None
11929 } else {
11930 Some(value)
11931 }
11932}
11933
11934fn bool_int(value: bool) -> i64 {
11935 if value {
11936 1
11937 } else {
11938 0
11939 }
11940}
11941
11942fn system_time_to_ns(time: SystemTime) -> i64 {
11943 time.duration_since(UNIX_EPOCH)
11944 .unwrap_or_default()
11945 .as_nanos()
11946 .min(i64::MAX as u128) as i64
11947}
11948
11949fn ns_to_system_time(value: i64) -> SystemTime {
11950 UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
11951}
11952
11953fn unix_millis_now() -> u64 {
11954 SystemTime::now()
11955 .duration_since(UNIX_EPOCH)
11956 .unwrap_or_default()
11957 .as_millis()
11958 .min(u128::from(u64::MAX)) as u64
11959}
11960
11961fn unix_seconds_now() -> i64 {
11962 SystemTime::now()
11963 .duration_since(UNIX_EPOCH)
11964 .unwrap_or_default()
11965 .as_secs() as i64
11966}
11967
11968#[cfg(test)]
11973pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
11974
11975#[cfg(test)]
11976mod refresh_worker_tests {
11977 use super::*;
11978 use std::fs;
11979 use tempfile::tempdir;
11980
11981 fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
11982 let temp = tempdir().unwrap();
11983 let root = temp.path().join("root");
11984 fs::create_dir_all(&root).unwrap();
11985 let artifact_key = crate::search_index::artifact_cache_key(&root);
11986 crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
11987 let callgraph_dir = temp
11988 .path()
11989 .join("storage")
11990 .join("callgraph")
11991 .join(artifact_key);
11992 let source = root.join("main.rs");
11993 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
11994 let (store, _) = CallGraphStore::cold_build_with_lease(
11995 callgraph_dir.clone(),
11996 root.clone(),
11997 std::slice::from_ref(&source),
11998 )
11999 .unwrap();
12000 drop(store);
12001 (temp, root, callgraph_dir, source)
12002 }
12003
12004 fn pending_paths() -> PendingCallGraphStorePaths {
12005 Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
12006 }
12007
12008 fn wait_for_refresh_calls(root: &Path, expected: usize) {
12009 let deadline = Instant::now() + Duration::from_secs(12);
12010 while callgraph_refresh_worker_test_counts(root).0 < expected {
12011 assert!(
12012 Instant::now() < deadline,
12013 "timed out waiting for {expected} callgraph refresh worker call(s)"
12014 );
12015 std::thread::sleep(Duration::from_millis(5));
12016 }
12017 }
12018
12019 fn wait_for_refresh_worker_idle() {
12020 let deadline = Instant::now() + Duration::from_secs(12);
12021 loop {
12022 let worker = CALLGRAPH_REFRESH_WORKER
12023 .get_or_init(|| Mutex::new(None))
12024 .lock()
12025 .expect("callgraph refresh worker mutex poisoned")
12026 .clone();
12027 let idle = worker.is_none_or(|worker| {
12028 let queue = worker
12029 .shared
12030 .queue
12031 .lock()
12032 .expect("callgraph refresh queue mutex poisoned");
12033 queue.active.is_none() && queue.order.is_empty()
12034 });
12035 if idle {
12036 return;
12037 }
12038 assert!(
12039 Instant::now() < deadline,
12040 "timed out waiting for callgraph refresh worker to become idle"
12041 );
12042 std::thread::sleep(Duration::from_millis(5));
12043 }
12044 }
12045
12046 fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
12047 let temp = tempdir().unwrap();
12048 let root = temp.path().join("workspace");
12049 fs::create_dir_all(root.join("app/src")).unwrap();
12050 let artifact_key = crate::search_index::artifact_cache_key(&root);
12051 crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
12052 let callgraph_dir = temp
12053 .path()
12054 .join("storage")
12055 .join("callgraph")
12056 .join(artifact_key);
12057 fs::write(
12058 root.join("Cargo.toml"),
12059 "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
12060 )
12061 .unwrap();
12062 fs::write(
12063 root.join("app/Cargo.toml"),
12064 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
12065 )
12066 .unwrap();
12067 let caller = root.join("app/src/lib.rs");
12068 fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
12069 let (store, _) = CallGraphStore::cold_build_with_lease(
12070 callgraph_dir.clone(),
12071 root.clone(),
12072 std::slice::from_ref(&caller),
12073 )
12074 .unwrap();
12075 drop(store);
12076 (temp, root, callgraph_dir, caller)
12077 }
12078
12079 #[test]
12080 fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
12081 let _guard = REFRESH_WORKER_TEST_LOCK
12082 .lock()
12083 .unwrap_or_else(std::sync::PoisonError::into_inner);
12084 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12085 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
12086 reset_workspace_crate_prefix_build_count(&root);
12087 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12088
12089 for revision in ["first", "second"] {
12090 fs::write(
12091 &caller,
12092 format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
12093 )
12094 .unwrap();
12095 enqueue_callgraph_store_refresh(
12096 callgraph_dir.clone(),
12097 root.clone(),
12098 vec![caller.clone()],
12099 pending_paths(),
12100 );
12101 wait_for_refresh_worker_idle();
12102 }
12103
12104 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
12105 assert!(flush_callgraph_store_refreshes_with_budget(
12106 Duration::from_secs(5)
12107 ));
12108 clear_callgraph_refresh_worker_test_seam(&root);
12109 }
12110
12111 #[test]
12112 fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
12113 let _guard = REFRESH_WORKER_TEST_LOCK
12114 .lock()
12115 .unwrap_or_else(std::sync::PoisonError::into_inner);
12116 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12117 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
12118 reset_workspace_crate_prefix_build_count(&root);
12119 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12120
12121 fs::write(
12122 &caller,
12123 "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
12124 )
12125 .unwrap();
12126 enqueue_callgraph_store_refresh(
12127 callgraph_dir.clone(),
12128 root.clone(),
12129 vec![caller.clone()],
12130 pending_paths(),
12131 );
12132 wait_for_refresh_worker_idle();
12133 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
12134
12135 let added_manifest = root.join("added/Cargo.toml");
12136 let added_source = root.join("added/src/lib.rs");
12137 fs::create_dir_all(added_source.parent().unwrap()).unwrap();
12138 fs::write(
12139 root.join("Cargo.toml"),
12140 "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
12141 )
12142 .unwrap();
12143 fs::write(
12144 &added_manifest,
12145 "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
12146 )
12147 .unwrap();
12148 fs::write(&added_source, "pub fn target() {}\n").unwrap();
12149 fs::write(
12150 &caller,
12151 "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
12152 )
12153 .unwrap();
12154
12155 enqueue_callgraph_store_refresh(
12156 callgraph_dir.clone(),
12157 root.clone(),
12158 vec![
12159 root.join("Cargo.toml"),
12160 added_manifest,
12161 added_source,
12162 caller,
12163 ],
12164 pending_paths(),
12165 );
12166 assert!(flush_callgraph_store_refreshes_with_budget(
12167 Duration::from_secs(12)
12168 ));
12169
12170 assert_eq!(workspace_crate_prefix_build_count(&root), 2);
12174 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
12175 .unwrap()
12176 .expect("refreshed workspace store");
12177 let tree = store
12178 .call_tree(Path::new("app/src/lib.rs"), "run", 1)
12179 .unwrap();
12180 assert_eq!(tree.children.len(), 1);
12181 assert_eq!(tree.children[0].file, "added/src/lib.rs");
12182 assert_eq!(tree.children[0].name, "target");
12183 assert!(tree.children[0].resolved);
12184 clear_callgraph_refresh_worker_test_seam(&root);
12185 }
12186
12187 fn linked_worktree_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, PathBuf) {
12188 let temp = tempdir().unwrap();
12189 let main = temp.path().join("main");
12190 let worktree = temp.path().join("worktree");
12191 fs::create_dir_all(&main).unwrap();
12192 let mut git = std::process::Command::new("git");
12193 assert!(
12194 crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
12195 .status()
12196 .unwrap()
12197 .success()
12198 );
12199 fs::write(main.join("lib.rs"), "pub fn marker() {}\n").unwrap();
12200 for args in [
12201 vec![
12202 "-C",
12203 main.to_str().unwrap(),
12204 "config",
12205 "user.email",
12206 "test@example.com",
12207 ],
12208 vec![
12209 "-C",
12210 main.to_str().unwrap(),
12211 "config",
12212 "user.name",
12213 "AFT Test",
12214 ],
12215 vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
12216 vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
12217 ] {
12218 let mut command = std::process::Command::new("git");
12219 assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
12220 .status()
12221 .unwrap()
12222 .success());
12223 }
12224 let mut add_worktree = std::process::Command::new("git");
12225 assert!(crate::test_env::apply_hermetic_git_env(
12226 add_worktree
12227 .arg("-C")
12228 .arg(&main)
12229 .args(["worktree", "add", "--detach"])
12230 .arg(&worktree),
12231 )
12232 .status()
12233 .unwrap()
12234 .success());
12235 let main = fs::canonicalize(main).unwrap();
12236 let worktree = fs::canonicalize(worktree).unwrap();
12237 let project_key = crate::search_index::artifact_cache_key(&main);
12238 assert_eq!(
12239 crate::search_index::artifact_cache_key(&worktree),
12240 project_key
12241 );
12242 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
12243 (temp, main, worktree, project_key, callgraph_dir)
12244 }
12245
12246 #[test]
12247 fn linked_worktree_never_acquires_writer_or_publishes_any_build_path() {
12248 let _git_env = crate::test_env::hermetic_git_env_guard();
12249 let (_temp, _main, root, project_key, callgraph_dir) = linked_worktree_fixture();
12250 crate::root_cache::configure_artifact_access(&root, &project_key, true);
12251 crate::root_cache::reset_writer_lease_acquisition_counts_for_test();
12252 let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
12253 let publications_for_observer = Arc::clone(&publications);
12254 set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
12255 publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
12256 })));
12257 let source = root.join("lib.rs");
12258
12259 let open_error = CallGraphStore::open(callgraph_dir.clone(), root.clone())
12260 .expect_err("borrow-only writable open must remain unavailable");
12261 assert!(matches!(open_error, CallGraphStoreError::Unavailable(_)));
12262 assert!(
12263 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), root.clone())
12264 .unwrap()
12265 .is_none()
12266 );
12267 assert!(
12268 CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), root.clone())
12269 .unwrap()
12270 .is_none()
12271 );
12272 assert!(matches!(
12273 CallGraphStore::cold_build_with_lease(
12274 callgraph_dir.clone(),
12275 root.clone(),
12276 std::slice::from_ref(&source),
12277 ),
12278 Err(CallGraphStoreError::Unavailable(_))
12279 ));
12280 assert!(matches!(
12281 CallGraphStore::ensure_built_with_lease(
12282 callgraph_dir.clone(),
12283 root.clone(),
12284 std::slice::from_ref(&source),
12285 ),
12286 Err(CallGraphStoreError::Unavailable(_))
12287 ));
12288 let force_error = CallGraphStore::force_cold_build_with_lease_chunked(
12289 callgraph_dir.clone(),
12290 root.clone(),
12291 &[source],
12292 1,
12293 )
12294 .expect_err("borrow-only forced rebuild must remain unsatisfied");
12295 set_cold_build_swap_observer(None);
12296
12297 assert!(matches!(force_error, CallGraphStoreError::Unavailable(_)));
12298 assert_eq!(
12299 crate::root_cache::writer_lease_acquisition_count_for_test(
12300 crate::root_cache::RootCacheDomain::Callgraph,
12301 &project_key,
12302 &root,
12303 ),
12304 0
12305 );
12306 assert_eq!(publications.load(AtomicOrdering::SeqCst), 0);
12307 assert!(!pointer_path(&callgraph_dir, &project_key).exists());
12308 }
12309
12310 #[test]
12311 fn owner_and_linked_worktree_alternation_rebuilds_storm_generation_once() {
12312 let _git_env = crate::test_env::hermetic_git_env_guard();
12313 let (_temp, owner, worktree, project_key, callgraph_dir) = linked_worktree_fixture();
12314 crate::root_cache::configure_artifact_access(&owner, &project_key, false);
12315 crate::root_cache::configure_artifact_access(&worktree, &project_key, true);
12316 let source = owner.join("lib.rs");
12317 let (store, _) = CallGraphStore::cold_build_with_lease(
12318 callgraph_dir.clone(),
12319 owner.clone(),
12320 std::slice::from_ref(&source),
12321 )
12322 .unwrap();
12323 let sqlite_path = store.sqlite_path().to_path_buf();
12324 drop(store);
12325
12326 let conn = Connection::open(&sqlite_path).unwrap();
12327 conn.execute(
12328 "UPDATE backend_file_state SET workspace_root = ?1",
12329 [worktree.display().to_string()],
12330 )
12331 .unwrap();
12332 drop(conn);
12333
12334 let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
12335 let publications_for_observer = Arc::clone(&publications);
12336 set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
12337 publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
12338 })));
12339 crate::root_cache::reset_writer_lease_acquisition_counts_for_test();
12340
12341 let repaired = CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
12342 .unwrap()
12343 .expect("owner should purge the storm-era worktree root");
12344 drop(repaired);
12345 for _ in 0..3 {
12346 let borrower = CallGraphStore::open_readonly(callgraph_dir.clone(), worktree.clone())
12347 .unwrap()
12348 .expect("linked worktree should borrow the owner generation");
12349 drop(borrower);
12350 assert!(
12351 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), worktree.clone())
12352 .unwrap()
12353 .is_none()
12354 );
12355 let owner_store =
12356 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
12357 .unwrap()
12358 .expect("owner generation should remain ready");
12359 drop(owner_store);
12360 }
12361 set_cold_build_swap_observer(None);
12362
12363 assert_eq!(
12364 publications.load(AtomicOrdering::SeqCst),
12365 1,
12366 "the owner performs one expected post-storm purge and alternation stays read-only"
12367 );
12368 assert_eq!(
12369 crate::root_cache::writer_lease_acquisition_count_for_test(
12370 crate::root_cache::RootCacheDomain::Callgraph,
12371 &project_key,
12372 &worktree,
12373 ),
12374 0
12375 );
12376 }
12377
12378 #[test]
12379 fn rebuild_cooldown_records_only_successful_publication_per_cache_key() {
12380 let temp = tempdir().unwrap();
12381 let root = temp.path().join("owner");
12382 let other_root = temp.path().join("other");
12383 fs::create_dir_all(&root).unwrap();
12384 fs::create_dir_all(&other_root).unwrap();
12385 let source = root.join("lib.rs");
12386 fs::write(&source, "pub fn marker() {}\n").unwrap();
12387 let project_key = crate::search_index::artifact_cache_key(&root);
12388 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
12389 crate::root_cache::configure_artifact_access(&root, &project_key, false);
12390 let cooldown_key = rebuild_cooldown_key(&callgraph_dir, &project_key);
12391 rebuild_cooldown_records()
12392 .lock()
12393 .unwrap_or_else(std::sync::PoisonError::into_inner)
12394 .remove(&cooldown_key);
12395 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
12396 let stale_epoch = epoch.current();
12397 epoch.next();
12398
12399 let failed = with_publish_epoch(epoch, stale_epoch, || {
12400 CallGraphStore::cold_build_with_lease(
12401 callgraph_dir.clone(),
12402 root.clone(),
12403 std::slice::from_ref(&source),
12404 )
12405 });
12406 assert!(matches!(failed, Err(CallGraphStoreError::Superseded)));
12407 assert!(
12408 rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
12409 .is_none()
12410 );
12411
12412 let (store, _) = CallGraphStore::cold_build_with_lease(
12413 callgraph_dir.clone(),
12414 root.clone(),
12415 std::slice::from_ref(&source),
12416 )
12417 .unwrap();
12418 drop(store);
12419 assert!(
12420 rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
12421 .is_none()
12422 );
12423
12424 record_successful_rebuild(&callgraph_dir, &project_key, &other_root, Instant::now());
12425 assert!(
12426 rebuild_cooldown_denial(&callgraph_dir, &project_key, &root, Instant::now(),).is_some()
12427 );
12428 }
12429
12430 #[test]
12431 fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
12432 let _guard = REFRESH_WORKER_TEST_LOCK
12433 .lock()
12434 .unwrap_or_else(std::sync::PoisonError::into_inner);
12435 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12436 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12437 let pending = pending_paths();
12438 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12439
12440 let lifecycle = SubcLifecycleAdmission::default();
12441 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
12442 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12443 let ticket = CallgraphRefreshTicket::new(
12444 lifecycle,
12445 Arc::clone(&generation),
12446 7,
12447 publish_epoch.clone(),
12448 publish_epoch.current(),
12449 );
12450 generation.store(8, std::sync::atomic::Ordering::SeqCst);
12452 let installed = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
12453 .unwrap()
12454 .expect("ready store snapshot");
12455 let refresh_state = CallgraphRefreshState::new(
12456 Arc::new(std::sync::RwLock::new(Some(Arc::new(installed)))),
12457 Arc::new(AtomicBool::new(true)),
12458 );
12459
12460 enqueue_callgraph_store_refresh_fenced_with_state(
12461 callgraph_dir,
12462 root.clone(),
12463 vec![source.clone()],
12464 Arc::clone(&pending),
12465 refresh_state,
12466 ticket,
12467 );
12468 assert!(flush_callgraph_store_refreshes_with_budget(
12469 Duration::from_secs(5)
12470 ));
12471 assert_eq!(
12472 callgraph_refresh_worker_test_counts(&root).0,
12473 0,
12474 "superseded batch must not reach refresh_files or self-replay"
12475 );
12476 assert!(
12477 pending.lock().contains(&source),
12478 "superseded batch must defer its paths to the pending sink"
12479 );
12480 clear_callgraph_refresh_worker_test_seam(&root);
12481 }
12482
12483 #[test]
12484 fn superseded_open_failure_defers_without_self_replay() {
12485 let _guard = REFRESH_WORKER_TEST_LOCK
12486 .lock()
12487 .unwrap_or_else(std::sync::PoisonError::into_inner);
12488 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12489 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12490 let pending = pending_paths();
12491 let installed = Arc::new(
12492 CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
12493 .unwrap()
12494 .expect("ready store snapshot"),
12495 );
12496 let refresh_state = CallgraphRefreshState::new(
12497 Arc::new(std::sync::RwLock::new(Some(Arc::clone(&installed)))),
12498 Arc::new(AtomicBool::new(true)),
12499 );
12500 assert!(!installed.is_legacy_fallback());
12501 assert!(installed.is_current());
12502 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
12503 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12504 set_callgraph_refresh_worker_test_open_failure(root.clone(), true);
12505 let (held_rx, release_tx) = install_callgraph_refresh_worker_test_gate(root.clone());
12506
12507 let lifecycle = SubcLifecycleAdmission::default();
12508 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
12509 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12510 let ticket = CallgraphRefreshTicket::new(
12511 lifecycle,
12512 Arc::clone(&generation),
12513 7,
12514 publish_epoch.clone(),
12515 publish_epoch.current(),
12516 );
12517 enqueue_callgraph_store_refresh_fenced_with_state(
12518 callgraph_dir,
12519 root.clone(),
12520 vec![source.clone()],
12521 Arc::clone(&pending),
12522 refresh_state,
12523 ticket,
12524 );
12525 held_rx
12526 .recv_timeout(Duration::from_secs(12))
12527 .expect("refresh worker must hold after injected open failure");
12528
12529 generation.store(8, std::sync::atomic::Ordering::SeqCst);
12532 set_callgraph_refresh_worker_test_open_failure(root.clone(), false);
12533 release_tx
12534 .send(())
12535 .expect("release superseded refresh worker");
12536 wait_for_refresh_worker_idle();
12537
12538 assert_eq!(
12539 callgraph_refresh_worker_test_counts(&root).0,
12540 1,
12541 "superseded open-failure batch must not self-replay"
12542 );
12543 assert_eq!(
12544 callgraph_refresh_worker_test_worker_calls(&root),
12545 1,
12546 "superseded open-failure batch must not create another worker call"
12547 );
12548 assert!(
12549 pending.lock().contains(&source),
12550 "superseded open-failure paths must remain in the pending sink"
12551 );
12552 let tree = installed
12553 .call_tree(Path::new("main.rs"), "entry", 1)
12554 .unwrap();
12555 assert_eq!(
12556 tree.children[0].name, "old_leaf",
12557 "superseded open-failure batch must not converge the store"
12558 );
12559 clear_callgraph_refresh_worker_test_seam(&root);
12560 }
12561
12562 #[test]
12563 fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
12564 let _guard = REFRESH_WORKER_TEST_LOCK
12565 .lock()
12566 .unwrap_or_else(std::sync::PoisonError::into_inner);
12567 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12568 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12569 let pending = pending_paths();
12570 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12571
12572 let lifecycle = SubcLifecycleAdmission::default();
12573 let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
12574 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12575 let expected_epoch = publish_epoch.current();
12576 let ticket = CallgraphRefreshTicket::new(
12577 lifecycle,
12578 generation,
12579 3,
12580 publish_epoch.clone(),
12581 expected_epoch,
12582 );
12583 publish_epoch.next();
12585
12586 enqueue_callgraph_store_refresh_fenced(
12587 callgraph_dir,
12588 root.clone(),
12589 vec![source.clone()],
12590 Arc::clone(&pending),
12591 ticket,
12592 );
12593 assert!(flush_callgraph_store_refreshes_with_budget(
12594 Duration::from_secs(5)
12595 ));
12596 assert_eq!(
12597 callgraph_refresh_worker_test_counts(&root).0,
12598 0,
12599 "epoch-superseded batch must not reach refresh_files"
12600 );
12601 assert!(
12602 pending.lock().contains(&source),
12603 "epoch-superseded batch must defer its paths to the pending sink"
12604 );
12605 clear_callgraph_refresh_worker_test_seam(&root);
12606 }
12607
12608 #[test]
12609 fn fenced_refresh_with_current_ticket_commits_normally() {
12610 let _guard = REFRESH_WORKER_TEST_LOCK
12611 .lock()
12612 .unwrap_or_else(std::sync::PoisonError::into_inner);
12613 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12614 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12615 let pending = pending_paths();
12616 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12617
12618 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
12619
12620 let lifecycle = SubcLifecycleAdmission::default();
12621 let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
12622 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12623 let ticket = CallgraphRefreshTicket::new(
12624 lifecycle,
12625 generation,
12626 5,
12627 publish_epoch.clone(),
12628 publish_epoch.current(),
12629 );
12630
12631 enqueue_callgraph_store_refresh_fenced(
12632 callgraph_dir.clone(),
12633 root.clone(),
12634 vec![source.clone()],
12635 Arc::clone(&pending),
12636 ticket,
12637 );
12638 assert!(flush_callgraph_store_refreshes_with_budget(
12639 Duration::from_secs(5)
12640 ));
12641 assert_eq!(
12642 callgraph_refresh_worker_test_counts(&root).0,
12643 1,
12644 "current ticket must run the refresh"
12645 );
12646 assert!(
12647 pending.lock().is_empty(),
12648 "committed batch must not defer paths"
12649 );
12650
12651 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
12652 .unwrap()
12653 .expect("published generation must remain readable");
12654 let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
12655 assert_eq!(
12656 tree.children[0].name, "new_leaf",
12657 "fenced commit must actually persist the refreshed content"
12658 );
12659 clear_callgraph_refresh_worker_test_seam(&root);
12660 }
12661
12662 #[test]
12663 fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
12664 let _guard = REFRESH_WORKER_TEST_LOCK
12665 .lock()
12666 .unwrap_or_else(std::sync::PoisonError::into_inner);
12667 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12672 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12673 let pending = pending_paths();
12674 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
12675
12676 enqueue_callgraph_store_refresh(
12677 callgraph_dir.clone(),
12678 root.clone(),
12679 vec![source.clone()],
12680 Arc::clone(&pending),
12681 );
12682 wait_for_refresh_calls(&root, 1);
12683 for _ in 0..3 {
12684 enqueue_callgraph_store_refresh(
12685 callgraph_dir.clone(),
12686 root.clone(),
12687 vec![source.clone()],
12688 Arc::clone(&pending),
12689 );
12690 }
12691
12692 assert!(flush_callgraph_store_refreshes_with_budget(
12693 Duration::from_secs(2)
12694 ));
12695 assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
12696 assert!(pending.lock().is_empty());
12697 clear_callgraph_refresh_worker_test_seam(&root);
12698 }
12699
12700 #[test]
12701 fn queued_refresh_opens_generation_published_after_enqueue() {
12702 let _guard = REFRESH_WORKER_TEST_LOCK
12703 .lock()
12704 .unwrap_or_else(std::sync::PoisonError::into_inner);
12705 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12710 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
12711 let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
12712 set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
12713 let (active_held_rx, active_release_tx) =
12714 install_callgraph_refresh_worker_test_gate(active_root.clone());
12715 set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
12716 enqueue_callgraph_store_refresh(
12717 active_dir,
12718 active_root.clone(),
12719 vec![active_source],
12720 pending_paths(),
12721 );
12722 active_held_rx
12723 .recv_timeout(Duration::from_secs(12))
12724 .expect("active refresh worker holds the queue");
12725
12726 fs::write(
12727 &target_source,
12728 "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
12729 )
12730 .unwrap();
12731 enqueue_callgraph_store_refresh(
12732 target_dir.clone(),
12733 target_root.clone(),
12734 vec![target_source.clone()],
12735 pending_paths(),
12736 );
12737 let (new_generation, _) = CallGraphStore::cold_build_with_lease(
12738 target_dir.clone(),
12739 target_root.clone(),
12740 std::slice::from_ref(&target_source),
12741 )
12742 .unwrap();
12743 fs::write(
12744 &target_source,
12745 "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
12746 )
12747 .unwrap();
12748 drop(new_generation);
12749
12750 active_release_tx
12751 .send(())
12752 .expect("release active refresh worker");
12753 wait_for_refresh_calls(&target_root, 1);
12754 assert!(flush_callgraph_store_refreshes_with_budget(
12755 Duration::from_secs(12)
12756 ));
12757 let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
12758 .unwrap()
12759 .expect("current callgraph generation");
12760 let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
12761 assert_eq!(tree.children[0].name, "worker_leaf");
12762 assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
12763 clear_callgraph_refresh_worker_test_seam(&active_root);
12764 clear_callgraph_refresh_worker_test_seam(&target_root);
12765 }
12766
12767 #[test]
12768 fn refresh_failure_marks_files_stale() {
12769 let _guard = REFRESH_WORKER_TEST_LOCK
12770 .lock()
12771 .unwrap_or_else(std::sync::PoisonError::into_inner);
12772 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12777 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12778 let pending = pending_paths();
12779 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
12780
12781 enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
12782 assert!(flush_callgraph_store_refreshes_with_budget(
12783 Duration::from_secs(2)
12784 ));
12785
12786 assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
12787 let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
12788 .unwrap()
12789 .expect("ready callgraph store");
12790 assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
12791 clear_callgraph_refresh_worker_test_seam(&root);
12792 }
12793
12794 #[test]
12795 fn idle_refresh_truncates_wal() {
12796 let _guard = REFRESH_WORKER_TEST_LOCK
12797 .lock()
12798 .unwrap_or_else(std::sync::PoisonError::into_inner);
12799 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12800 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12801 let generation = read_pointer(
12802 &callgraph_dir,
12803 &crate::search_index::artifact_cache_key(&root),
12804 )
12805 .expect("fixture publishes a generation");
12806 let wal_path = callgraph_dir.join(format!("{generation}-wal"));
12807 let pending = pending_paths();
12808 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12809
12810 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n\n").unwrap();
12811 enqueue_callgraph_store_refresh(
12812 callgraph_dir.clone(),
12813 root.clone(),
12814 vec![source.clone()],
12815 Arc::clone(&pending),
12816 );
12817 wait_for_refresh_calls(&root, 1);
12818 wait_for_refresh_worker_idle();
12819 let checkpoint_deadline = Instant::now() + Duration::from_secs(2);
12820 while fs::metadata(&wal_path)
12821 .map(|metadata| metadata.len())
12822 .unwrap_or(0)
12823 != 0
12824 {
12825 assert!(
12826 Instant::now() < checkpoint_deadline,
12827 "idle checkpoint did not truncate WAL"
12828 );
12829 std::thread::sleep(Duration::from_millis(5));
12830 }
12831 assert_eq!(
12832 fs::metadata(&wal_path)
12833 .map(|metadata| metadata.len())
12834 .unwrap_or(0),
12835 0,
12836 "idle transition truncates the refresh WAL"
12837 );
12838
12839 clear_callgraph_refresh_worker_test_seam(&root);
12840 }
12841
12842 #[test]
12843 fn bounded_shutdown_defers_unprocessed_batches() {
12844 let _guard = REFRESH_WORKER_TEST_LOCK
12845 .lock()
12846 .unwrap_or_else(std::sync::PoisonError::into_inner);
12847 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12852 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
12853 let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
12854 let active_pending = pending_paths();
12855 let queued_pending = pending_paths();
12856 set_callgraph_refresh_worker_test_seam(
12857 active_root.clone(),
12858 Duration::from_millis(300),
12859 false,
12860 );
12861
12862 enqueue_callgraph_store_refresh(
12863 active_dir,
12864 active_root.clone(),
12865 vec![active_source.clone()],
12866 Arc::clone(&active_pending),
12867 );
12868 wait_for_refresh_calls(&active_root, 1);
12869 enqueue_callgraph_store_refresh(
12870 queued_dir,
12871 queued_root.clone(),
12872 vec![queued_source.clone()],
12873 Arc::clone(&queued_pending),
12874 );
12875
12876 assert!(!flush_callgraph_store_refreshes_with_budget(
12877 Duration::from_millis(20)
12878 ));
12879 assert!(active_pending.lock().contains(&active_source));
12880 assert!(queued_pending.lock().contains(&queued_source));
12881 assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
12882 clear_callgraph_refresh_worker_test_seam(&active_root);
12883 }
12884}
12885
12886#[cfg(test)]
12887mod cold_build_insert_tests {
12888 use super::*;
12889 use crate::imports::ImportBlock;
12890 use std::cell::Cell;
12891 use std::fs;
12892 use std::path::{Path, PathBuf};
12893 use tempfile::tempdir;
12894
12895 thread_local! {
12896 static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
12897 static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
12898 static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
12899 }
12900
12901 fn count_caller_traversal_selects(sql: &str) {
12902 let sql = sql.trim_start();
12903 if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
12904 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
12905 }
12906 if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
12907 && sql.contains("e.target_file =")
12908 {
12909 CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
12910 }
12911 if sql.starts_with("WITH requested") && sql.contains("COUNT(*)") {
12912 BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
12913 }
12914 }
12915
12916 #[test]
12917 fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
12918 let dir = tempdir().unwrap();
12919 let previous_root = dir.path().join("previous-root");
12920 let current_root = dir.path().join("current-root");
12921 fs::create_dir_all(&previous_root).unwrap();
12922 fs::create_dir_all(¤t_root).unwrap();
12923 fs::remove_dir(&previous_root).unwrap();
12924 let mut conn = Connection::open_in_memory().unwrap();
12925 initialize_schema(&conn).unwrap();
12926 conn.execute(
12927 "INSERT INTO backend_file_state(
12928 backend, workspace_root, file_path, content_hash, status, updated_at
12929 ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
12930 params![previous_root.display().to_string()],
12931 )
12932 .unwrap();
12933
12934 let repair = reconcile_workspace_roots(&mut conn, ¤t_root, false).unwrap();
12935
12936 assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
12937 assert_eq!(
12938 stored_workspace_roots(&conn).unwrap(),
12939 vec![previous_root.display().to_string()]
12940 );
12941 }
12942
12943 #[test]
12944 fn sqlite_readonly_uri_percent_encodes_windows_paths() {
12945 assert_eq!(
12946 sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
12947 "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
12948 );
12949 }
12950
12951 #[test]
12952 fn legacy_migration_completion_log_has_operator_fields() {
12953 assert_eq!(
12954 legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
12955 "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
12956 );
12957 }
12958
12959 fn write_generation_with_age(
12960 dir: &Path,
12961 project_key: &str,
12962 ordinal: u64,
12963 age: Duration,
12964 ) -> String {
12965 let generation = format!("{project_key}.g{ordinal}.1.sqlite");
12966 let path = dir.join(&generation);
12967 fs::write(&path, b"sqlite placeholder").unwrap();
12968 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
12969 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
12970 generation
12971 }
12972
12973 #[test]
12974 fn gc_old_generations_preserves_live_reader_until_marker_drops() {
12975 let dir = tempfile::tempdir().unwrap();
12976 let project_key = "project";
12977 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
12978 let previous =
12979 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
12980 let pinned =
12981 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
12982 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
12983
12984 gc_old_generations(dir.path(), project_key, ¤t);
12985
12986 assert!(dir.path().join(&previous).is_file());
12987 assert!(dir.path().join(&pinned).is_file());
12988
12989 drop(marker);
12990 gc_old_generations(dir.path(), project_key, ¤t);
12991
12992 assert!(dir.path().join(&previous).is_file());
12993 assert!(!dir.path().join(&pinned).exists());
12994 }
12995
12996 #[test]
12997 fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
12998 let dir = tempfile::tempdir().unwrap();
12999 let project_key = "project";
13000 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
13001 let _previous =
13002 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
13003 let pinned =
13004 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
13005 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
13006 filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
13007
13008 gc_old_generations(dir.path(), project_key, ¤t);
13009
13010 assert!(dir.path().join(&pinned).is_file());
13011 }
13012
13013 #[test]
13014 fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
13015 let dir = tempfile::tempdir().unwrap();
13016 let project_key = "project";
13017 let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
13018 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
13019 let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
13020 let old = write_generation_with_age(
13021 dir.path(),
13022 project_key,
13023 200,
13024 expired + Duration::from_secs(60),
13025 );
13026 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
13027
13028 gc_old_generations(dir.path(), project_key, ¤t);
13029
13030 assert!(dir.path().join(¤t).is_file());
13031 assert!(dir.path().join(&previous).is_file());
13032 assert!(!dir.path().join(&old).exists());
13033 }
13034
13035 fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
13036 let path = dir.join(name);
13037 fs::write(&path, b"temp placeholder").unwrap();
13038 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
13039 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
13040 path
13041 }
13042
13043 #[test]
13044 fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
13045 let dir = tempdir().unwrap();
13046 let aged = "project.g100.1.sqlite.tmp.1.200";
13050 let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
13051 let fresh = "project.g300.1.sqlite.tmp.1.400";
13052 let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
13053 write_build_temp_with_age(dir.path(), aged, aged_age);
13054 write_build_temp_with_age(dir.path(), aged_journal, aged_age);
13055 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
13056
13057 sweep_orphaned_build_temps(dir.path());
13058
13059 assert!(
13060 !dir.path().join(aged).exists(),
13061 "aged orphan must be removed"
13062 );
13063 assert!(
13064 !dir.path().join(aged_journal).exists(),
13065 "aged journal sidecar must be removed"
13066 );
13067 assert!(
13068 dir.path().join(fresh).is_file(),
13069 "fresh temporary must survive"
13070 );
13071 }
13072
13073 #[test]
13074 fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
13075 let storage = tempdir().unwrap();
13076 let storage_root = storage.path();
13077 let legacy_dir = storage_root.join("opencode").join("callgraph");
13083 fs::create_dir_all(&legacy_dir).unwrap();
13084 let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
13085 write_build_temp_with_age(
13086 &legacy_dir,
13087 orphan,
13088 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
13089 );
13090 assert!(
13091 !legacy_dir.join("deadbeef.current").exists(),
13092 "the dead root has no current pointer"
13093 );
13094
13095 let root_keyed_dir = storage_root.join("callgraph").join("livekey");
13096 fs::create_dir_all(&root_keyed_dir).unwrap();
13097
13098 sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
13099
13100 assert!(
13101 !legacy_dir.join(orphan).exists(),
13102 "legacy orphan must be reclaimed by the store-wide sweep"
13103 );
13104 }
13105
13106 #[test]
13107 fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
13108 let dir = tempdir().unwrap();
13114 let fresh = "project.g300.1.sqlite.tmp.1.400";
13115 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
13116
13117 sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
13118
13119 assert!(
13120 !dir.path().join(fresh).exists(),
13121 "with the age predicate forced open, the fresh temporary is removed"
13122 );
13123 }
13124
13125 #[test]
13126 fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
13127 let dir = tempdir().unwrap();
13128 let generation = write_generation_with_age(
13132 dir.path(),
13133 "project",
13134 400,
13135 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
13136 );
13137 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
13138
13139 sweep_orphaned_build_temps(dir.path());
13140
13141 assert!(
13142 dir.path().join(&generation).is_file(),
13143 "completed generation must survive the orphan sweep"
13144 );
13145 assert!(
13146 crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
13147 "read marker must survive the orphan sweep"
13148 );
13149 }
13150
13151 #[test]
13152 fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
13153 let dir = tempfile::tempdir().unwrap();
13154 let project_key = "project".to_string();
13155 let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
13156 let sqlite_path = dir.path().join(&generation);
13157 fs::remove_file(&sqlite_path).unwrap();
13158 let conn = Connection::open(&sqlite_path).unwrap();
13159 let store = CallGraphStore::from_connection(
13160 dir.path().to_path_buf(),
13161 project_key,
13162 sqlite_path,
13163 dir.path().to_path_buf(),
13164 false,
13165 Some(generation.clone()),
13166 None,
13167 None,
13168 conn,
13169 );
13170
13171 let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
13172 assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
13173
13174 drop(marker);
13175 assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
13176 }
13177
13178 #[test]
13179 fn readiness_cache_only_skips_checks_after_a_successful_validation() {
13180 let dir = tempdir().expect("temp dir");
13181 let file = dir.path().join("main.ts");
13182 fs::write(&file, "export function main() {}\n").expect("write fixture");
13183 let store = CallGraphStore::open(
13184 dir.path().join(".store-readiness-cache"),
13185 dir.path().to_path_buf(),
13186 )
13187 .expect("open store");
13188 {
13189 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13190 conn.trace(Some(count_caller_traversal_selects));
13191 }
13192
13193 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13194 assert!(store.indexed_file_count().is_err());
13195 assert!(store.indexed_file_count().is_err());
13196 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
13197
13198 store
13199 .cold_build(std::slice::from_ref(&file))
13200 .expect("cold build");
13201 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13202 assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
13203 assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
13204 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
13205
13206 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13207 conn.trace(None);
13208 }
13209
13210 #[test]
13211 fn direct_caller_frontier_chunks_sqlite_selects() {
13212 let dir = tempdir().expect("temp dir");
13213 let file = dir.path().join("main.ts");
13214 fs::write(
13215 &file,
13216 "export function caller() { target(); }\nexport function target() {}\n",
13217 )
13218 .expect("write fixture");
13219 let store = CallGraphStore::open(
13220 dir.path().join(".store-caller-frontier-query"),
13221 dir.path().to_path_buf(),
13222 )
13223 .expect("open store");
13224 store
13225 .cold_build(std::slice::from_ref(&file))
13226 .expect("cold build");
13227 let mut targets = vec![("main.ts".to_string(), "target".to_string())];
13228 targets.extend((1..1_000).map(|index| ("main.ts".to_string(), format!("missing{index}"))));
13229
13230 CALLER_QUERY_SELECTS.with(|count| count.set(0));
13231 BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
13232 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13233 {
13234 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13235 conn.trace(Some(count_caller_traversal_selects));
13236 }
13237 let callers = store
13238 .direct_callers_for_symbols(&targets)
13239 .expect("batched callers");
13240 {
13241 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13242 conn.trace(None);
13243 }
13244
13245 assert_eq!(callers.len(), 1_000);
13246 assert_eq!(callers.get(&targets[0]).unwrap().len(), 1);
13247 assert_eq!(CALLER_QUERY_SELECTS.with(Cell::get), 3);
13248 assert_eq!(BOUNDARY_COUNT_SELECTS.with(Cell::get), 0);
13249 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
13250 }
13251
13252 #[test]
13253 fn callers_depth_boundary_batches_sqlite_counts() {
13254 const CALLER_COUNT: usize = 1_000;
13255
13256 let dir = tempdir().expect("temp dir");
13257 let file = dir.path().join("main.ts");
13258 let mut source = String::from("export function sharedHotHelper() {}\n");
13259 for index in 0..CALLER_COUNT {
13260 source.push_str(&format!(
13261 "export function caller{index}() {{ sharedHotHelper(); }}\n"
13262 ));
13263 }
13264 fs::write(&file, source).expect("write fixture");
13265
13266 let store = CallGraphStore::open(
13267 dir.path().join(".store-callers-query-fanout"),
13268 dir.path().to_path_buf(),
13269 )
13270 .expect("open store");
13271 store
13272 .cold_build(std::slice::from_ref(&file))
13273 .expect("cold build");
13274
13275 CALLER_QUERY_SELECTS.with(|count| count.set(0));
13276 BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
13277 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13278 {
13279 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13280 conn.trace(Some(count_caller_traversal_selects));
13281 }
13282
13283 let started = Instant::now();
13284 let result = crate::commands::callgraph_store_adapter::callers_result(
13285 &store,
13286 Path::new("main.ts"),
13287 "sharedHotHelper",
13288 1,
13289 true,
13290 )
13291 .expect("callers result");
13292 let elapsed = started.elapsed();
13293
13294 {
13295 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13296 conn.trace(None);
13297 }
13298 let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
13299 let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
13300 let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
13301 eprintln!(
13302 "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
13303 result.total_callers,
13304 caller_queries,
13305 boundary_queries,
13306 total_selects,
13307 elapsed.as_secs_f64() * 1_000.0
13308 );
13309
13310 assert_eq!(result.total_callers, CALLER_COUNT);
13311 assert_eq!(caller_queries, 1);
13312 assert_eq!(boundary_queries, 3);
13313 assert_eq!(total_selects, 9);
13314 }
13315
13316 #[test]
13317 fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
13318 let dir = tempdir().expect("temp dir");
13319 let file = dir.path().join("main.ts");
13320 fs::write(
13321 &file,
13322 r#"export function topA() {
13323 root();
13324}
13325
13326export function topB() {
13327 root();
13328}
13329
13330export function root() {
13331 leaf();
13332 missing();
13333}
13334
13335export function leaf() {}
13336"#,
13337 )
13338 .expect("write fixture");
13339
13340 let store = CallGraphStore::open(
13341 dir.path().join(".store-depth-boundary-counts"),
13342 dir.path().to_path_buf(),
13343 )
13344 .expect("open store");
13345 store
13346 .cold_build(std::slice::from_ref(&file))
13347 .expect("cold build");
13348
13349 let root = store
13350 .node_for(Path::new("main.ts"), "root")
13351 .expect("root node");
13352 let leaf = store
13353 .node_for(Path::new("main.ts"), "leaf")
13354 .expect("leaf node");
13355
13356 let (full_forward_len, full_direct_len) = {
13357 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
13358 conn.execute(
13359 "INSERT INTO edges (
13360 edge_id, ref_id, source_node, target_node, target_file,
13361 target_symbol, kind, line, provenance
13362 ) VALUES (
13363 'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
13364 ?2, ?3, 'call', 98, ?4
13365 )",
13366 rusqlite::params![
13367 &root.node_id,
13368 &leaf.file,
13369 &leaf.symbol,
13370 PROVENANCE_TREESITTER
13371 ],
13372 )
13373 .expect("insert dangling forward edge");
13374 conn.execute(
13375 "INSERT INTO edges (
13376 edge_id, ref_id, source_node, target_node, target_file,
13377 target_symbol, kind, line, provenance
13378 ) VALUES (
13379 'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
13380 ?1, ?2, ?3, 'call', 99, ?4
13381 )",
13382 rusqlite::params![
13383 &root.node_id,
13384 &root.file,
13385 &root.symbol,
13386 PROVENANCE_TREESITTER
13387 ],
13388 )
13389 .expect("insert dangling direct-caller edge");
13390
13391 let full_forward_len = forward_calls_for_node(&conn, &root)
13392 .expect("full forward calls")
13393 .len();
13394 let counted_forward_len =
13395 forward_call_count_for_node(&conn, &root).expect("counted forward calls");
13396 assert_eq!(
13397 counted_forward_len, full_forward_len,
13398 "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
13399 );
13400
13401 let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
13402 .expect("full direct callers");
13403 let full_direct_len = full_direct.len();
13404 let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
13405 .expect("counted direct callers");
13406 assert_eq!(
13407 counted_direct_len, full_direct_len,
13408 "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
13409 );
13410
13411 let distinct_direct_len = full_direct
13412 .iter()
13413 .map(|site| {
13414 (
13415 site.caller.file.clone(),
13416 site.line,
13417 site.target_file.clone(),
13418 site.target_symbol.clone(),
13419 )
13420 })
13421 .collect::<BTreeSet<_>>()
13422 .len();
13423 let batch_counts = direct_caller_counts_for_tuples(
13424 &conn,
13425 &[
13426 (root.file.clone(), root.symbol.clone()),
13427 (root.file.clone(), root.symbol.clone()),
13428 (leaf.file.clone(), leaf.symbol.clone()),
13429 ],
13430 )
13431 .expect("batched direct-caller counts");
13432 assert_eq!(batch_counts.len(), 2);
13433 assert_eq!(
13434 batch_counts.get(&(root.file.clone(), root.symbol.clone())),
13435 Some(&distinct_direct_len)
13436 );
13437
13438 (full_forward_len, full_direct_len)
13439 };
13440
13441 assert_eq!(
13442 full_forward_len, 2,
13443 "fixture root should have one resolved and one unresolved outgoing call"
13444 );
13445 assert_eq!(
13446 full_direct_len, 2,
13447 "fixture root should have two real direct callers"
13448 );
13449
13450 let tree = store
13451 .call_tree(Path::new("main.ts"), "root", 0)
13452 .expect("call tree");
13453 assert!(tree.depth_limited);
13454 assert_eq!(tree.children.len(), 0);
13455 assert_eq!(
13456 tree.truncated, full_forward_len,
13457 "call_tree depth boundary must report the full forward-call list length"
13458 );
13459
13460 let callers = store
13461 .callers_of(Path::new("main.ts"), "leaf", 0)
13462 .expect("callers");
13463 assert!(callers.depth_limited);
13464 assert_eq!(callers.callers.len(), 1);
13465 assert_eq!(callers.callers[0].caller.symbol, "root");
13466 assert_eq!(
13467 callers.truncated, full_direct_len,
13468 "callers depth boundary must report the full direct-caller list length"
13469 );
13470 }
13471
13472 #[test]
13473 fn source_freshness_matches_cache_collect_for_same_bytes() {
13474 let dir = tempdir().expect("temp dir");
13475 let path = dir.path().join("fixture.ts");
13476 let source = "export function main() { return helper(); }\n";
13477 fs::write(&path, source).expect("write fixture");
13478
13479 let expected = cache_freshness::collect(&path).expect("collect freshness from file");
13480 let actual =
13481 collect_source_freshness(&path, source).expect("collect freshness from source");
13482
13483 assert_eq!(actual, expected);
13484 }
13485
13486 #[test]
13487 fn superseded_cold_build_cannot_publish_after_newer_epoch() {
13488 let root = tempfile::tempdir().unwrap();
13489 let callgraph_dir = tempfile::tempdir().unwrap();
13490 let source_dir = root.path().join("src");
13491 std::fs::create_dir_all(&source_dir).unwrap();
13492 let source = source_dir.join("lib.rs");
13493 std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
13494 let files = vec![source.clone()];
13495 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
13496 let old_epoch = epoch.next();
13497 let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
13498 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
13499 let old_epoch_flag = epoch.clone();
13500 let old_dir = callgraph_dir.path().to_path_buf();
13501 let old_root = root.path().to_path_buf();
13502 let old_files = files.clone();
13503 let old = std::thread::spawn(move || {
13504 set_cold_build_before_publish_observer(Some(Arc::new(move || {
13505 reached_tx.send(()).unwrap();
13506 release_rx.recv().unwrap();
13507 })));
13508 let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
13509 CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
13510 });
13511 set_cold_build_before_publish_observer(None);
13512 result
13513 });
13514 reached_rx
13518 .recv_timeout(Duration::from_secs(30))
13519 .expect("older build did not reach its publication barrier");
13520
13521 std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
13522 let new_epoch = epoch.next();
13523 let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
13524 CallGraphStore::cold_build_with_lease(
13525 callgraph_dir.path().to_path_buf(),
13526 root.path().to_path_buf(),
13527 &files,
13528 )
13529 })
13530 .expect("newer build should publish");
13531 drop(new_store);
13532
13533 release_tx.send(()).unwrap();
13534 assert!(matches!(
13535 old.join().unwrap(),
13536 Err(CallGraphStoreError::Superseded)
13537 ));
13538
13539 let current = CallGraphStore::open_readonly(
13540 callgraph_dir.path().to_path_buf(),
13541 root.path().to_path_buf(),
13542 )
13543 .unwrap()
13544 .expect("current callgraph generation");
13545 assert_eq!(
13546 current
13547 .nodes_matching("new_generation_marker")
13548 .unwrap()
13549 .len(),
13550 1
13551 );
13552 assert!(current
13553 .nodes_matching("old_generation_marker")
13554 .unwrap()
13555 .is_empty());
13556 }
13557
13558 #[test]
13559 fn cold_build_prepared_bulk_insert_matches_reference_rows() {
13560 let dir = tempdir().expect("temp dir");
13561 let project_root = dir.path();
13562 let extract = fixture_extract(project_root);
13563 let resolved = fixture_resolved(&extract);
13564
13565 let reference = build_reference_connection(project_root, &extract, &resolved);
13566 let optimized = build_optimized_connection(project_root, &extract, &resolved);
13567
13568 for table in [
13569 "files",
13570 "nodes",
13571 "file_dependencies",
13572 "dispatch_hints",
13573 "refs",
13574 "edges",
13575 ] {
13576 let excluded: &[&str] = if table == "files" {
13583 &["indexed_at"]
13584 } else {
13585 &[]
13586 };
13587 assert_eq!(
13588 table_rows_without(&reference, table, excluded),
13589 table_rows_without(&optimized, table, excluded),
13590 "table `{table}` rows must match apart from wall-clock columns"
13591 );
13592 }
13593 assert_eq!(
13594 backend_state_rows(&reference),
13595 backend_state_rows(&optimized),
13596 "backend freshness rows must match apart from updated_at"
13597 );
13598 assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
13599 }
13600
13601 #[test]
13602 fn cold_build_chunked_matches_unchunked_logical_rows() {
13603 let dir = tempdir().expect("temp dir");
13604 let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
13605 write_chunked_equivalence_fixture(&project_root);
13606 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
13607 assert!(
13608 files.len() > 6,
13609 "fixture should be large enough to split into multiple chunks"
13610 );
13611
13612 let unchunked = CallGraphStore::open(
13613 project_root.join(".store-unchunked"),
13614 project_root.to_path_buf(),
13615 )
13616 .expect("open unchunked store");
13617 let unchunked_stats = unchunked
13618 .cold_build_chunked(&files, 0)
13619 .expect("unchunked cold build");
13620
13621 let chunked = CallGraphStore::open(
13622 project_root.join(".store-chunked"),
13623 project_root.to_path_buf(),
13624 )
13625 .expect("open chunked store");
13626 let chunked_stats = chunked
13627 .cold_build_chunked(&files, 3)
13628 .expect("chunked cold build");
13629
13630 assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
13631 assert_eq!(
13632 unchunked.edge_snapshot().expect("unchunked edge snapshot"),
13633 chunked.edge_snapshot().expect("chunked edge snapshot"),
13634 "public edge snapshots must match"
13635 );
13636
13637 let dispatch_edges = {
13638 let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
13639 conn.query_row(
13640 "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
13641 [],
13642 |row| row.get::<_, i64>(0),
13643 )
13644 .expect("count dispatch edges")
13645 };
13646 assert!(
13647 dispatch_edges > 0,
13648 "fixture must exercise method-dispatch edge insertion"
13649 );
13650
13651 for table in [
13652 "edges",
13653 "refs",
13654 "nodes",
13655 "file_dependencies",
13656 "dispatch_hints",
13657 ] {
13658 assert_eq!(
13659 graph_table_rows(&unchunked, table),
13660 graph_table_rows(&chunked, table),
13661 "chunked cold build must match unchunked rows for {table}"
13662 );
13663 }
13664 assert_eq!(
13665 graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
13666 graph_table_rows_without(&chunked, "files", &["indexed_at"]),
13667 "files rows must match apart from indexed_at"
13668 );
13669 assert_eq!(
13670 graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
13671 graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
13672 "backend freshness rows must match apart from updated_at"
13673 );
13674
13675 let published_dir = project_root.join(".store-published");
13676 let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
13677 published_dir.clone(),
13678 project_root.to_path_buf(),
13679 &files,
13680 0,
13681 )
13682 .expect("published unchunked cold build");
13683 assert!(
13684 !CallGraphStore::needs_cold_build(&published_dir, &project_root)
13685 .expect("needs_cold_build after publish"),
13686 "published store should be ready"
13687 );
13688 drop(_published);
13689 let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
13690 published_dir,
13691 project_root.to_path_buf(),
13692 &files,
13693 3,
13694 )
13695 .expect("ensure with a different chunk size");
13696 assert!(
13697 rebuild_stats.is_none(),
13698 "changing callgraph_chunk_size must not affect store identity or force a rebuild"
13699 );
13700 }
13701
13702 #[test]
13709 #[ignore]
13710 fn bench_cold_build_chunk() {
13711 let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
13712 let chunk: usize = std::env::var("AFT_PERF_CHUNK")
13713 .expect("AFT_PERF_CHUNK")
13714 .parse()
13715 .expect("AFT_PERF_CHUNK must be a non-negative integer");
13716 let project_root = fs::canonicalize(&repo).expect("canonical repo root");
13717 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
13718 let dir = tempdir().expect("temp dir");
13719 let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
13720 .expect("open store");
13721 let started = Instant::now();
13722 let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
13723 let ms = started.elapsed().as_millis();
13724 println!(
13725 "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
13726 stats.files, stats.nodes, stats.refs, stats.edges
13727 );
13728 }
13729
13730 #[test]
13731 fn persisted_workspace_reexport_selects_its_package_dependency() {
13732 let root = tempdir().expect("temp dir");
13733 let dependencies = BTreeSet::from([
13734 "packages/aft-bridge/src/index.ts".to_string(),
13735 "packages/opencode-plugin/src/types.ts".to_string(),
13736 ]);
13737 let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
13738
13739 assert_eq!(
13740 stored_dependencies_for_module(
13741 root.path(),
13742 "packages/opencode-plugin/src/shared/bash-hints.ts",
13743 "@cortexkit/aft-bridge",
13744 &dependencies,
13745 &indexed_files,
13746 ),
13747 BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
13748 );
13749 }
13750
13751 #[test]
13752 fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
13753 let dir = tempdir().expect("temp dir");
13754 let project_root = dir.path();
13755 let files =
13756 write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
13757 let index_path = project_root.join("src/index.ts");
13758
13759 let store = CallGraphStore::open(
13760 project_root.join(".store-incremental-barrel"),
13761 project_root.to_path_buf(),
13762 )
13763 .expect("open incremental store");
13764 store.cold_build(&files).expect("initial cold build");
13765
13766 {
13767 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13768 let tx = conn.transaction().expect("dependency transaction");
13769 let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
13770 .expect("dependent refs for barrel");
13771 let selected_ref_ids = dependent_refs
13772 .iter()
13773 .map(|dependent_ref| dependent_ref.ref_id.clone())
13774 .collect::<BTreeSet<_>>();
13775 let mut threaded_ref_ids = BTreeSet::new();
13776 let mut threaded_by_caller = BTreeMap::new();
13777 record_dependent_refs(
13778 &mut threaded_ref_ids,
13779 &mut threaded_by_caller,
13780 dependent_refs,
13781 );
13782 let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
13783 .expect("old per-ref caller lookup");
13784
13785 assert_eq!(threaded_ref_ids, selected_ref_ids);
13786 assert_eq!(threaded_by_caller, old_by_caller);
13787 for consumer in [
13788 "src/consumer_a.ts",
13789 "src/consumer_b.ts",
13790 "src/consumer_c.ts",
13791 ] {
13792 assert!(
13793 threaded_by_caller.contains_key(consumer),
13794 "barrel edit should select dependent refs from {consumer}"
13795 );
13796 }
13797 }
13798
13799 fs::write(
13800 &index_path,
13801 "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
13802 )
13803 .expect("edit barrel");
13804 let stats = store
13805 .refresh_files(std::slice::from_ref(&index_path))
13806 .expect("incremental refresh");
13807 assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
13808 assert!(
13809 stats.dependency_selected_refs > 0,
13810 "barrel surface edit should select dependent refs"
13811 );
13812
13813 let cold_store = CallGraphStore::open(
13814 project_root.join(".store-cold-barrel"),
13815 project_root.to_path_buf(),
13816 )
13817 .expect("open cold rebuild store");
13818 cold_store
13819 .cold_build(&files)
13820 .expect("comparison cold build");
13821
13822 for table in [
13823 "nodes",
13824 "refs",
13825 "file_dependencies",
13826 "edges",
13827 "dispatch_hints",
13828 ] {
13829 assert_eq!(
13830 graph_table_rows(&store, table),
13831 graph_table_rows(&cold_store, table),
13832 "incremental refresh {table} rows must match cold rebuild"
13833 );
13834 }
13835
13836 let consumer_path = project_root.join("src/consumer_a.ts");
13837 fs::write(
13838 &consumer_path,
13839 "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
13840 )
13841 .expect("edit barrel consumer");
13842 store
13843 .refresh_files(std::slice::from_ref(&consumer_path))
13844 .expect("refresh consumer through unchanged barrel");
13845 cold_store
13846 .cold_build(&files)
13847 .expect("comparison cold rebuild after consumer refresh");
13848 for table in [
13849 "nodes",
13850 "refs",
13851 "file_dependencies",
13852 "edges",
13853 "dispatch_hints",
13854 ] {
13855 assert_eq!(
13856 graph_table_rows(&store, table),
13857 graph_table_rows(&cold_store, table),
13858 "refresh through a persisted barrel must preserve cold-build {table} rows"
13859 );
13860 }
13861 }
13862
13863 fn build_reference_connection(
13864 project_root: &Path,
13865 extract: &FileExtract,
13866 resolved: &ResolvedRef,
13867 ) -> Connection {
13868 let mut conn = Connection::open_in_memory().expect("open reference db");
13869 configure_build_connection(&conn).expect("configure reference db");
13870 initialize_schema(&conn).expect("initialize reference schema");
13871 {
13872 let tx = conn.transaction().expect("reference transaction");
13873 clear_tables(&tx).expect("reference clear");
13874 insert_meta(&tx).expect("reference meta");
13875 insert_file_extract(&tx, project_root, extract).expect("reference file extract");
13876 insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
13877 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
13878 .expect("reference dispatch edges");
13879 assert_eq!(supplemental, 0);
13880 tx.commit().expect("reference commit");
13881 }
13882 conn
13883 }
13884
13885 fn build_optimized_connection(
13886 project_root: &Path,
13887 extract: &FileExtract,
13888 resolved: &ResolvedRef,
13889 ) -> Connection {
13890 let mut conn = Connection::open_in_memory().expect("open optimized db");
13891 configure_build_connection(&conn).expect("configure optimized db");
13892 initialize_schema(&conn).expect("initialize optimized schema");
13893 {
13894 let tx = conn.transaction().expect("optimized transaction");
13895 clear_tables(&tx).expect("optimized clear");
13896 insert_meta(&tx).expect("optimized meta");
13897 drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
13898 {
13899 let workspace_root = project_root.display().to_string();
13900 let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
13901 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
13902 .expect("optimized file extract");
13903 insert_resolved_ref_prepared(&mut inserts, resolved)
13904 .expect("optimized resolved ref");
13905 }
13906 create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
13907 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
13908 .expect("optimized dispatch edges");
13909 assert_eq!(supplemental, 0);
13910 tx.commit().expect("optimized commit");
13911 }
13912 conn
13913 }
13914
13915 fn fixture_extract(_project_root: &Path) -> FileExtract {
13916 let rel_path = "src/main.ts".to_string();
13917 let target_path = "src/helper.ts".to_string();
13918 let node = NodeRecord {
13919 id: "node-main".to_string(),
13920 file_path: rel_path.clone(),
13921 name: "main".to_string(),
13922 scoped_name: "main".to_string(),
13923 kind: "function".to_string(),
13924 range: Range {
13925 start_line: 0,
13926 start_col: 0,
13927 end_line: 0,
13928 end_col: 32,
13929 },
13930 range_ordinal: 0,
13931 signature: Some("export function main()".to_string()),
13932 exported: true,
13933 is_default_export: false,
13934 is_type_like: false,
13935 is_callgraph_entry_point: true,
13936 };
13937 let mut dependencies = BTreeSet::new();
13938 dependencies.insert(target_path.clone());
13939 let raw_ref = RawRef {
13940 ref_id: "ref-main-helper".to_string(),
13941 caller_node: Some(node.id.clone()),
13942 caller_symbol: Some(node.scoped_name.clone()),
13943 caller_file: rel_path.clone(),
13944 kind: "call".to_string(),
13945 short_name: Some("helper".to_string()),
13946 full_ref: Some("helper".to_string()),
13947 module_path: None,
13948 import_kind: None,
13949 local_name: Some("helper".to_string()),
13950 requested_name: Some("helper".to_string()),
13951 namespace_alias: None,
13952 wildcard: false,
13953 line: 1,
13954 byte_start: 24,
13955 byte_end: 32,
13956 dependencies,
13957 };
13958 FileExtract {
13959 rel_path,
13960 freshness: FileFreshness {
13961 mtime: UNIX_EPOCH + Duration::from_secs(123),
13962 size: 40,
13963 content_hash: cache_freshness::hash_bytes(b"fixture source"),
13964 },
13965 lang: LangId::TypeScript,
13966 data: FileCallData {
13967 calls_by_symbol: HashMap::new(),
13968 exported_symbols: Vec::new(),
13969 symbol_metadata: HashMap::new(),
13970 default_export_symbol: None,
13971 import_block: ImportBlock::empty(),
13972 lang: LangId::TypeScript,
13973 },
13974 nodes: vec![node.clone()],
13975 raw_refs: vec![raw_ref],
13976 dispatch_hints: vec![DispatchHint {
13977 id: "dispatch-main-helper".to_string(),
13978 method_name: "helper".to_string(),
13979 caller_node: node.id,
13980 file: "src/main.ts".to_string(),
13981 line: 1,
13982 byte_start: 24,
13983 byte_end: 32,
13984 }],
13985 surface_fingerprint: "surface".to_string(),
13986 }
13987 }
13988
13989 fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
13990 let raw = extract.raw_refs[0].clone();
13991 let mut dependencies = raw.dependencies.clone();
13992 dependencies.insert("src/helper.ts".to_string());
13993 ResolvedRef {
13994 edge: Some(EdgeRecord {
13995 edge_id: "edge-main-helper".to_string(),
13996 source_node: raw.caller_node.clone().expect("caller node"),
13997 target_node: Some("node-helper".to_string()),
13998 target_file: "src/helper.ts".to_string(),
13999 target_symbol: "helper".to_string(),
14000 kind: "call".to_string(),
14001 line: raw.line,
14002 }),
14003 raw,
14004 status: "resolved".to_string(),
14005 target_node: Some("node-helper".to_string()),
14006 target_file: Some("src/helper.ts".to_string()),
14007 target_symbol: Some("helper".to_string()),
14008 dependencies,
14009 }
14010 }
14011
14012 fn write_chunked_equivalence_fixture(project_root: &Path) {
14013 let ts_dir = project_root.join("ts");
14014 fs::create_dir_all(&ts_dir).expect("create ts dir");
14015 fs::write(
14016 ts_dir.join("leaf.ts"),
14017 "export function leaf(value: number) {\n return value + 1;\n}\n",
14018 )
14019 .expect("write ts leaf");
14020 fs::write(
14021 ts_dir.join("mid.ts"),
14022 "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n return leaf(value);\n}\n",
14023 )
14024 .expect("write ts mid");
14025 fs::write(
14026 ts_dir.join("entry.ts"),
14027 "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n return mid(worker.run());\n}\n",
14028 )
14029 .expect("write ts entry");
14030 fs::write(
14031 ts_dir.join("worker.ts"),
14032 "export class Worker {\n run() {\n return 41;\n }\n}\n",
14033 )
14034 .expect("write ts worker");
14035 for idx in 0..4 {
14036 fs::write(
14037 ts_dir.join(format!("extra_{idx}.ts")),
14038 format!(
14039 "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n return entry(new Worker());\n}}\n"
14040 ),
14041 )
14042 .expect("write ts extra");
14043 }
14044
14045 let rust_dir = project_root.join("src");
14046 let commands_dir = rust_dir.join("commands");
14047 fs::create_dir_all(&commands_dir).expect("create rust commands dir");
14048 fs::write(
14049 rust_dir.join("context.rs"),
14050 r#"pub struct AppContext;
14051
14052impl AppContext {
14053 pub fn callgraph_store_for_ops(&self) -> usize {
14054 1
14055 }
14056}
14057"#,
14058 )
14059 .expect("write rust context");
14060 fs::write(
14061 rust_dir.join("lib.rs"),
14062 "pub mod context;\npub mod commands;\n",
14063 )
14064 .expect("write rust lib");
14065 fs::write(
14066 commands_dir.join("mod.rs"),
14067 "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
14068 )
14069 .expect("write rust commands mod");
14070 for name in ["callers", "impact", "trace_to"] {
14071 fs::write(
14072 commands_dir.join(format!("{name}.rs")),
14073 format!(
14074 r#"use crate::context::AppContext;
14075
14076pub fn handle_{name}(ctx: &AppContext) -> usize {{
14077 ctx.callgraph_store_for_ops()
14078}}
14079"#
14080 ),
14081 )
14082 .expect("write rust command");
14083 }
14084 }
14085
14086 fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
14087 let src_dir = project_root.join("src");
14088 fs::create_dir_all(&src_dir).expect("create src dir");
14089
14090 let target_path = src_dir.join("target.ts");
14091 fs::write(&target_path, "export function target() {\n return 1;\n}\n")
14092 .expect("write target");
14093
14094 let index_path = src_dir.join("index.ts");
14095 fs::write(&index_path, barrel_source).expect("write barrel");
14096
14097 let mut files = vec![target_path, index_path];
14098 for (file_name, function_name) in [
14099 ("consumer_a.ts", "consumerA"),
14100 ("consumer_b.ts", "consumerB"),
14101 ("consumer_c.ts", "consumerC"),
14102 ] {
14103 let path = src_dir.join(file_name);
14104 fs::write(
14105 &path,
14106 format!(
14107 "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n return target();\n}}\n"
14108 ),
14109 )
14110 .expect("write consumer");
14111 files.push(path);
14112 }
14113 files
14114 }
14115
14116 fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
14117 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
14118 table_rows(&conn, table)
14119 }
14120
14121 fn graph_table_rows_without(
14122 store: &CallGraphStore,
14123 table: &str,
14124 excluded_columns: &[&str],
14125 ) -> Vec<String> {
14126 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
14127 table_rows_without(&conn, table, excluded_columns)
14128 }
14129
14130 fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
14131 table_rows_without(conn, table, &[])
14132 }
14133
14134 fn table_rows_without(
14135 conn: &Connection,
14136 table: &str,
14137 excluded_columns: &[&str],
14138 ) -> Vec<String> {
14139 let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
14140 let columns: Vec<String> = conn
14141 .prepare(&format!("PRAGMA table_info({table})"))
14142 .expect("prepare table_info")
14143 .query_map([], |row| row.get::<_, String>(1))
14144 .expect("query table_info")
14145 .collect::<std::result::Result<Vec<String>, _>>()
14146 .expect("collect columns")
14147 .into_iter()
14148 .filter(|column| !excluded_columns.contains(column.as_str()))
14149 .collect();
14150 let sql = format!(
14151 "SELECT {} FROM {table} ORDER BY {}",
14152 columns.join(", "),
14153 columns.join(", ")
14154 );
14155 conn.prepare(&sql)
14156 .expect("prepare table rows")
14157 .query_map([], |row| row_to_strings(row, columns.len()))
14158 .expect("query table rows")
14159 .collect::<std::result::Result<_, _>>()
14160 .expect("collect table rows")
14161 }
14162
14163 fn assert_cold_build_stats_match_except_elapsed(
14164 expected: &ColdBuildStats,
14165 actual: &ColdBuildStats,
14166 ) {
14167 assert_eq!(actual.files, expected.files, "file counts must match");
14168 assert_eq!(actual.nodes, expected.nodes, "node counts must match");
14169 assert_eq!(actual.refs, expected.refs, "ref counts must match");
14170 assert_eq!(actual.edges, expected.edges, "edge counts must match");
14171 assert_eq!(
14172 actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
14173 expected
14174 .failed_files
14175 .iter()
14176 .cloned()
14177 .collect::<BTreeSet<_>>(),
14178 "failed file sets must match"
14179 );
14180 }
14181
14182 fn backend_state_rows(conn: &Connection) -> Vec<String> {
14183 conn.prepare(
14184 "SELECT backend, workspace_root, file_path, content_hash, status
14185 FROM backend_file_state
14186 ORDER BY backend, workspace_root, file_path, content_hash, status",
14187 )
14188 .expect("prepare backend rows")
14189 .query_map([], |row| row_to_strings(row, 5))
14190 .expect("query backend rows")
14191 .collect::<std::result::Result<_, _>>()
14192 .expect("collect backend rows")
14193 }
14194
14195 fn secondary_indexes(conn: &Connection) -> Vec<String> {
14196 let mut indexes = Vec::new();
14197 for table in [
14198 "files",
14199 "nodes",
14200 "refs",
14201 "file_dependencies",
14202 "edges",
14203 "dispatch_hints",
14204 "type_ref_names",
14205 "backend_file_state",
14206 "meta",
14207 ] {
14208 let sql = format!("PRAGMA index_list({table})");
14209 let mut stmt = conn.prepare(&sql).expect("prepare index list");
14210 let rows = stmt
14211 .query_map([], |row| row.get::<_, String>(1))
14212 .expect("query index list");
14213 for name in rows {
14214 let name = name.expect("index name");
14215 if name.starts_with("idx_") {
14216 indexes.push(format!("{table}:{name}"));
14217 }
14218 }
14219 }
14220 indexes.sort();
14221 indexes
14222 }
14223
14224 fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
14225 let mut values = Vec::with_capacity(len);
14226 for index in 0..len {
14227 let value = row.get_ref(index)?;
14228 values.push(match value {
14229 rusqlite::types::ValueRef::Null => "NULL".to_string(),
14230 rusqlite::types::ValueRef::Integer(value) => value.to_string(),
14231 rusqlite::types::ValueRef::Real(value) => value.to_string(),
14232 rusqlite::types::ValueRef::Text(value) => {
14233 String::from_utf8_lossy(value).into_owned()
14234 }
14235 rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
14236 });
14237 }
14238 Ok(values.join("\u{1f}"))
14239 }
14240}
14241
14242#[cfg(test)]
14243mod rust_resolution_tests {
14244 use super::*;
14245 use crate::inspect::job::CallgraphSnapshot;
14246 use std::fs;
14247 use tempfile::tempdir;
14248
14249 #[test]
14250 fn rust_function_scoped_module_alias_resolves_and_projects_live() {
14251 let dir = tempdir().expect("tempdir");
14252 let root = dir.path();
14253 write_rust_manifest(root, "scoped-alias-fixture");
14254 write_file(
14255 root,
14256 "src/lib.rs",
14257 r#"pub mod finalization_contract;
14258
14259pub fn run_alias() {
14260 use crate::finalization_contract as fc;
14261 fc::check_mason_contract();
14262}
14263"#,
14264 );
14265 write_file(
14266 root,
14267 "src/finalization_contract.rs",
14268 r#"pub fn check_mason_contract() {}
14269fn planted_dead() {}
14270"#,
14271 );
14272
14273 let (store, snapshot) = cold_build_twice(root);
14274 assert_direct_caller(
14275 &store,
14276 "src/finalization_contract.rs",
14277 "check_mason_contract",
14278 "src/lib.rs",
14279 "run_alias",
14280 );
14281 assert_projected_call(
14282 root,
14283 &snapshot,
14284 "src/finalization_contract.rs",
14285 "check_mason_contract",
14286 );
14287 assert_no_projected_call(
14288 root,
14289 &snapshot,
14290 "src/finalization_contract.rs",
14291 "planted_dead",
14292 );
14293 assert!(
14294 store
14295 .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
14296 .expect("planted dead callers")
14297 .is_empty(),
14298 "planted-dead guard should stay without callers"
14299 );
14300 }
14301
14302 #[test]
14303 fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
14304 let dir = tempdir().expect("tempdir");
14305 let root = dir.path();
14306 write_rust_manifest(root, "inline-module-fixture");
14307 write_file(
14308 root,
14309 "src/lib.rs",
14310 r#"mod work_graph { fn operations() {} }
14311mod manifest { fn operations() {} }
14312mod audit { fn operations() {} }
14313mod dispatch { fn operations() {} }
14314mod finalization { fn operations() {} }
14315
14316pub fn run_inline_operations() {
14317 work_graph::operations();
14318 manifest::operations();
14319 audit::operations();
14320 dispatch::operations();
14321 finalization::operations();
14322}
14323
14324fn planted_dead() {}
14325"#,
14326 );
14327
14328 let (store, snapshot) = cold_build_twice(root);
14329 for module in [
14330 "work_graph",
14331 "manifest",
14332 "audit",
14333 "dispatch",
14334 "finalization",
14335 ] {
14336 assert_direct_caller(
14337 &store,
14338 "src/lib.rs",
14339 &format!("{module}::operations"),
14340 "src/lib.rs",
14341 "run_inline_operations",
14342 );
14343 }
14344 assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
14345 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
14346 }
14347
14348 #[test]
14349 fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
14350 let dir = tempdir().expect("tempdir");
14351 let root = dir.path();
14352 fs::write(
14353 root.join("Cargo.toml"),
14354 "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
14355 )
14356 .expect("write workspace manifest");
14357 write_file(
14358 root,
14359 "crates/but-action/Cargo.toml",
14360 r#"[package]
14361name = "but-action"
14362version = "0.1.0"
14363edition = "2021"
14364"#,
14365 );
14366 write_file(
14367 root,
14368 "crates/but-action/src/lib.rs",
14369 "mod action;\npub use action::{list_actions};\n",
14370 );
14371 write_file(
14372 root,
14373 "crates/but-action/src/action.rs",
14374 "pub fn list_actions() {}\nfn planted_dead() {}\n",
14375 );
14376 write_file(
14377 root,
14378 "crates/app/Cargo.toml",
14379 r#"[package]
14380name = "app"
14381version = "0.1.0"
14382edition = "2021"
14383"#,
14384 );
14385 write_file(
14386 root,
14387 "crates/app/src/lib.rs",
14388 "pub fn run_actions() {\n but_action::list_actions();\n}\n",
14389 );
14390
14391 let (store, snapshot) = cold_build_twice(root);
14392 assert_direct_caller(
14393 &store,
14394 "crates/but-action/src/action.rs",
14395 "list_actions",
14396 "crates/app/src/lib.rs",
14397 "run_actions",
14398 );
14399 assert!(
14400 store
14401 .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
14402 .expect("lib reexport callers")
14403 .is_empty(),
14404 "call should target the reexported source function, not lib.rs"
14405 );
14406 assert_projected_call(
14407 root,
14408 &snapshot,
14409 "crates/but-action/src/action.rs",
14410 "list_actions",
14411 );
14412 assert_no_projected_call(
14413 root,
14414 &snapshot,
14415 "crates/but-action/src/action.rs",
14416 "planted_dead",
14417 );
14418 }
14419
14420 #[test]
14421 fn rust_generic_self_turbofish_method_dispatch_resolves() {
14422 let dir = tempdir().expect("tempdir");
14423 let root = dir.path();
14424 write_rust_manifest(root, "generic-self-fixture");
14425 write_file(
14426 root,
14427 "src/lib.rs",
14428 r#"pub struct Matcher;
14429
14430impl Matcher {
14431 pub fn run(&self) -> bool {
14432 self.fuzzy_match_optimal::<usize>("needle")
14433 }
14434
14435 fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
14436 let _ = std::marker::PhantomData::<T>;
14437 true
14438 }
14439
14440 fn planted_dead(&self) {}
14441}
14442
14443pub fn entry() -> bool {
14444 let matcher = Matcher;
14445 matcher.run()
14446}
14447"#,
14448 );
14449
14450 let (store, snapshot) = cold_build_twice(root);
14451 assert_direct_caller(
14452 &store,
14453 "src/lib.rs",
14454 "Matcher::fuzzy_match_optimal",
14455 "src/lib.rs",
14456 "Matcher::run",
14457 );
14458 assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
14459 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
14460 }
14461
14462 #[test]
14463 fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
14464 let dir = tempdir().expect("tempdir");
14465 let root = dir.path();
14466 write_rust_manifest(root, "manifest-operations-fixture");
14467 write_file(
14468 root,
14469 "src/main.rs",
14470 r#"mod dispatch;
14471use dispatch::{manifest_operations};
14472
14473fn main() {
14474 manifest_operations();
14475}
14476"#,
14477 );
14478 write_file(
14479 root,
14480 "src/dispatch.rs",
14481 r#"mod work_graph { fn operations() {} }
14482mod manifest { fn operations() {} }
14483mod audit { fn operations() {} }
14484mod descriptor { fn operations() {} }
14485mod writer { fn operations() {} }
14486
14487pub fn manifest_operations() {
14488 manifest::operations();
14489}
14490
14491pub fn work_graph_operations() {
14492 work_graph::operations();
14493}
14494
14495pub fn audit_operations() {
14496 audit::operations();
14497}
14498
14499pub fn descriptor_operations() {
14500 descriptor::operations();
14501}
14502
14503pub fn writer_operations() {
14504 writer::operations();
14505}
14506
14507fn planted_dead() {}
14508"#,
14509 );
14510
14511 let (store, snapshot) = cold_build_twice(root);
14512 assert_direct_caller(
14513 &store,
14514 "src/dispatch.rs",
14515 "manifest_operations",
14516 "src/main.rs",
14517 "main",
14518 );
14519 assert_direct_caller(
14520 &store,
14521 "src/dispatch.rs",
14522 "manifest::operations",
14523 "src/dispatch.rs",
14524 "manifest_operations",
14525 );
14526 assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
14527 assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
14528 assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
14529 }
14530
14531 fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
14532 let files = rust_files(root);
14533 let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
14534 .expect("open first store");
14535 first.cold_build(&files).expect("first cold build");
14536 let first_snapshot =
14537 project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
14538
14539 let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
14540 .expect("open second store");
14541 second.cold_build(&files).expect("second cold build");
14542 let second_snapshot =
14543 project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
14544
14545 assert_eq!(
14546 projection_rows(&first_snapshot),
14547 projection_rows(&second_snapshot),
14548 "cold-build projection should be deterministic"
14549 );
14550 (first, first_snapshot)
14551 }
14552
14553 fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
14554 let mut rows = Vec::new();
14555 for export in &snapshot.exported_symbols {
14556 rows.push(format!(
14557 "export\t{}\t{}\t{}\t{}",
14558 export.file.display(),
14559 export.symbol,
14560 export.kind,
14561 export.line
14562 ));
14563 }
14564 for call in &snapshot.outbound_calls {
14565 rows.push(format!(
14566 "call\t{}\t{}\t{}\t{}\t{}",
14567 call.caller_file.display(),
14568 call.caller_symbol,
14569 call.target,
14570 call.line,
14571 call.provenance
14572 ));
14573 }
14574 for file in &snapshot.entry_points {
14575 rows.push(format!("entry_file\t{}", file.display()));
14576 }
14577 for (file, symbols) in &snapshot.entry_point_symbols {
14578 for symbol in symbols {
14579 rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
14580 }
14581 }
14582 rows.sort();
14583 rows
14584 }
14585
14586 fn assert_direct_caller(
14587 store: &CallGraphStore,
14588 target_rel: &str,
14589 target_symbol: &str,
14590 caller_rel: &str,
14591 caller_symbol: &str,
14592 ) {
14593 let callers = store
14594 .direct_callers_of(Path::new(target_rel), target_symbol)
14595 .unwrap_or_else(|error| {
14596 panic!("direct callers for {target_rel}::{target_symbol}: {error}")
14597 });
14598 assert!(
14599 callers.iter().any(|site| {
14600 site.caller.file == caller_rel && site.caller.symbol == caller_symbol
14601 }),
14602 "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
14603 );
14604 }
14605
14606 fn assert_projected_call(
14607 root: &Path,
14608 snapshot: &CallgraphSnapshot,
14609 target_rel: &str,
14610 symbol: &str,
14611 ) {
14612 let target = projected_target(root, target_rel, symbol);
14613 assert!(
14614 snapshot.outbound_calls.iter().any(|call| {
14615 call.target == target
14616 || call.target.starts_with(&format!(
14617 "{target}{}",
14618 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
14619 ))
14620 }),
14621 "expected projected call to {target}; calls: {:#?}",
14622 snapshot.outbound_calls
14623 );
14624 }
14625
14626 fn assert_no_projected_call(
14627 root: &Path,
14628 snapshot: &CallgraphSnapshot,
14629 target_rel: &str,
14630 symbol: &str,
14631 ) {
14632 let target = projected_target(root, target_rel, symbol);
14633 assert!(
14634 snapshot.outbound_calls.iter().all(|call| {
14635 call.target != target
14636 && !call.target.starts_with(&format!(
14637 "{target}{}",
14638 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
14639 ))
14640 }),
14641 "did not expect projected call to {target}; calls: {:#?}",
14642 snapshot.outbound_calls
14643 );
14644 }
14645
14646 fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
14647 let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
14650 format!("{}::{symbol}", path.display())
14651 }
14652
14653 fn write_rust_manifest(root: &Path, name: &str) {
14654 write_file(
14655 root,
14656 "Cargo.toml",
14657 &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
14658 );
14659 }
14660
14661 fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
14662 let path = root.join(rel_path);
14663 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
14664 fs::write(&path, source).expect("write fixture file");
14665 path
14666 }
14667
14668 fn rust_files(root: &Path) -> Vec<PathBuf> {
14669 let mut files = Vec::new();
14670 collect_rust_files(root, &mut files);
14671 files.sort();
14672 files
14673 }
14674
14675 fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
14676 for entry in fs::read_dir(dir).expect("read fixture dir") {
14677 let entry = entry.expect("read fixture entry");
14678 let path = entry.path();
14679 if path.is_dir() {
14680 let name = path
14681 .file_name()
14682 .and_then(|name| name.to_str())
14683 .unwrap_or("");
14684 if !name.starts_with(".store") {
14685 collect_rust_files(&path, files);
14686 }
14687 } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
14688 files.push(path);
14689 }
14690 }
14691 }
14692}
14693
14694#[cfg(test)]
14695mod build_pool_tests {
14696 use super::build_pool_size;
14697
14698 #[test]
14699 fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
14700 let size = build_pool_size();
14701 assert!(size >= 1, "pool size must be at least 1");
14704 assert!(size <= 8, "pool size must be capped at 8, got {size}");
14705
14706 let cores = std::thread::available_parallelism()
14707 .map(|p| p.get())
14708 .unwrap_or(1);
14709 let expected = cores.div_ceil(2).clamp(1, 8);
14710 assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
14711 }
14712}
14713
14714#[cfg(test)]
14715mod reexport_resolution_tests {
14716 use super::*;
14717
14718 fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
14719 ProjectIndex {
14720 project_root: PathBuf::from("/fixture"),
14721 files: files.into_iter().collect(),
14722 caller_data: HashMap::new(),
14723 workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
14724 }
14725 }
14726
14727 fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
14728 DbFileIndex {
14729 lang: None,
14730 exports: HashSet::new(),
14731 default_export: None,
14732 export_aliases: HashMap::new(),
14733 node_by_scoped: HashMap::new(),
14734 node_by_bare: HashMap::new(),
14735 module_targets: HashMap::new(),
14736 reexports: reexport_targets
14737 .iter()
14738 .map(|target| ReexportIndex {
14739 target_file: Some((*target).to_string()),
14740 named: HashMap::new(),
14741 wildcard: true,
14742 })
14743 .collect(),
14744 }
14745 }
14746
14747 #[test]
14754 fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
14755 let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
14756 let files = names
14757 .iter()
14758 .map(|name| {
14759 let targets: Vec<&str> = names
14760 .iter()
14761 .filter(|other| *other != name)
14762 .map(String::as_str)
14763 .collect();
14764 (name.clone(), barrel_file(&targets))
14765 })
14766 .collect();
14767 let index = barrel_index(files);
14768
14769 assert_eq!(
14770 resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
14771 None
14772 );
14773 }
14774
14775 #[test]
14781 fn shallow_revisit_after_deep_capped_visit_still_resolves() {
14782 let mut leaf = barrel_file(&[]);
14783 leaf.exports.insert("deep_symbol".to_string());
14784 let mut files: Vec<(String, DbFileIndex)> = Vec::new();
14785 files.push((
14788 "src/entry.ts".to_string(),
14789 barrel_file(&["src/chain0.ts", "src/shared.ts"]),
14790 ));
14791 for i in 0..15 {
14792 let next = if i == 14 {
14793 "src/shared.ts".to_string()
14794 } else {
14795 format!("src/chain{}.ts", i + 1)
14796 };
14797 files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
14798 }
14799 files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
14800 files.push(("src/leaf.ts".to_string(), leaf));
14801 let index = barrel_index(files);
14802
14803 assert_eq!(
14804 resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
14805 Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
14806 "a shallower re-visit must not be pruned by a deeper capped visit"
14807 );
14808 }
14809
14810 #[test]
14811 fn symbol_reachable_through_reexport_cycle_still_resolves() {
14812 let mut leaf = barrel_file(&[]);
14813 leaf.exports.insert("real_symbol".to_string());
14814 let index = barrel_index(vec![
14815 (
14816 "src/a.ts".to_string(),
14817 barrel_file(&["src/b.ts", "src/a.ts"]),
14818 ),
14819 (
14820 "src/b.ts".to_string(),
14821 barrel_file(&["src/a.ts", "src/leaf.ts"]),
14822 ),
14823 ("src/leaf.ts".to_string(), leaf),
14824 ]);
14825
14826 assert_eq!(
14827 resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
14828 Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
14829 );
14830 }
14831}
14832
14833#[cfg(test)]
14834mod method_dispatch_inference_tests {
14835 use super::*;
14836 use std::fs;
14837 use tempfile::tempdir;
14838
14839 #[test]
14840 fn java_field_receiver_type_selects_declared_class_method() {
14841 let source = r#"class EntryPoint {
14842 private UserService userService;
14843
14844 void handle() {
14845 userService.find();
14846 }
14847}
14848
14849class UserService {
14850 void find() {}
14851}
14852
14853class AuditService {
14854 void find() {}
14855}
14856"#;
14857 let dir = tempdir().expect("temp dir");
14858 let root = dir.path();
14859 write_fixture(root, "src/EntryPoint.java", source);
14860 let reference = reference(
14861 "java",
14862 "src/EntryPoint.java",
14863 "EntryPoint::handle",
14864 "userService",
14865 "find",
14866 line_of(source, "userService.find()"),
14867 );
14868 let mut cache = DispatchSourceCache::new();
14869
14870 let receiver_type =
14871 infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
14872 assert_eq!(receiver_type, "UserService");
14873
14874 let candidates = vec![
14875 method_candidate("audit", "AuditService::find"),
14876 method_candidate("user", "UserService::find"),
14877 ];
14878 let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
14879 .expect("type candidate");
14880 assert_eq!(selected.scoped_name, "UserService::find");
14881
14882 let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
14883 assert!(
14884 select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
14885 );
14886 }
14887
14888 #[test]
14889 fn kotlin_property_and_local_value_types_are_inferred() {
14890 let source = r#"class Handler {
14891 private val auditService: AuditService = AuditService()
14892
14893 fun handle() {
14894 auditService.find()
14895 val userService: UserService = UserService()
14896 userService.find()
14897 val billingService = BillingService()
14898 billingService.find()
14899 }
14900}
14901
14902class UserService { fun find() {} }
14903class AuditService { fun find() {} }
14904class BillingService { fun find() {} }
14905"#;
14906 let dir = tempdir().expect("temp dir");
14907 let root = dir.path();
14908 write_fixture(root, "src/Handler.kt", source);
14909 let mut cache = DispatchSourceCache::new();
14910
14911 let audit_ref = reference(
14912 "kotlin",
14913 "src/Handler.kt",
14914 "Handler::handle",
14915 "auditService",
14916 "find",
14917 line_of(source, "auditService.find()"),
14918 );
14919 assert_eq!(
14920 infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
14921 Some("AuditService")
14922 );
14923
14924 let user_ref = reference(
14925 "kotlin",
14926 "src/Handler.kt",
14927 "Handler::handle",
14928 "userService",
14929 "find",
14930 line_of(source, "userService.find()"),
14931 );
14932 assert_eq!(
14933 infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
14934 Some("UserService")
14935 );
14936
14937 let billing_ref = reference(
14938 "kotlin",
14939 "src/Handler.kt",
14940 "Handler::handle",
14941 "billingService",
14942 "find",
14943 line_of(source, "billingService.find()"),
14944 );
14945 assert_eq!(
14946 infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
14947 Some("BillingService")
14948 );
14949 }
14950
14951 #[test]
14952 fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
14953 let source = r#"struct Foo { void run(); };
14954struct PointerFoo { void run(); };
14955struct FactoryFoo { void run(); };
14956FactoryFoo makeFactoryFoo();
14957
14958void handle() {
14959 Foo foo;
14960 foo.run();
14961 PointerFoo* pointerFoo = nullptr;
14962 pointerFoo->run();
14963 auto factoryFoo = makeFactoryFoo();
14964 factoryFoo.run();
14965}
14966"#;
14967 let dir = tempdir().expect("temp dir");
14968 let root = dir.path();
14969 write_fixture(root, "src/fixture.cpp", source);
14970 let mut cache = DispatchSourceCache::new();
14971
14972 let foo_ref = reference(
14973 "cpp",
14974 "src/fixture.cpp",
14975 "handle",
14976 "foo",
14977 "run",
14978 line_of(source, "foo.run()"),
14979 );
14980 assert_eq!(
14981 infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
14982 Some("Foo")
14983 );
14984
14985 let pointer_ref = reference(
14986 "cpp",
14987 "src/fixture.cpp",
14988 "handle",
14989 "pointerFoo",
14990 "run",
14991 line_of(source, "pointerFoo->run()"),
14992 );
14993 assert_eq!(
14994 infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
14995 Some("PointerFoo")
14996 );
14997
14998 let factory_ref = reference(
14999 "cpp",
15000 "src/fixture.cpp",
15001 "handle",
15002 "factoryFoo",
15003 "run",
15004 line_of(source, "factoryFoo.run()"),
15005 );
15006 assert_eq!(
15007 infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
15008 Some("FactoryFoo")
15009 );
15010 }
15011
15012 #[test]
15013 fn rust_direct_self_field_name_trims_separator_whitespace() {
15014 for receiver_expression in ["self .engine", "self. engine", "self . engine"] {
15015 assert_eq!(
15016 rust_direct_self_field_name(receiver_expression),
15017 Some("engine")
15018 );
15019 }
15020 }
15021
15022 #[test]
15023 fn rust_direct_self_field_receiver_type_is_conservative() {
15024 let source = r#"struct Engine;
15025
15026struct Car {
15027 engine: Engine,
15028}
15029
15030impl Car {
15031 fn run(&self) {
15032 self.engine.start();
15033 }
15034}
15035
15036struct NestedCar {
15037 engine: Engine,
15038}
15039
15040impl NestedCar {
15041 fn run(&self) {
15042 self.inner.engine.start();
15043 }
15044}
15045
15046struct WrappedCar {
15047 engine: Option<Engine>,
15048}
15049
15050impl WrappedCar {
15051 fn run(&self) {
15052 self.engine.start(); // wrapped
15053 }
15054}
15055
15056struct GenericCar<T> {
15057 engine: T,
15058}
15059
15060impl<T> GenericCar<T> {
15061 fn run(&self) {
15062 self.engine.start(); // generic
15063 }
15064}
15065
15066type EngineAlias = Engine;
15067
15068struct AliasCar {
15069 engine: EngineAlias,
15070}
15071
15072impl AliasCar {
15073 fn run(&self) {
15074 self.engine.start(); // alias
15075 }
15076}
15077"#;
15078 let dir = tempdir().expect("temp dir");
15079 let root = dir.path();
15080 write_fixture(root, "src/lib.rs", source);
15081 let mut cache = DispatchSourceCache::new();
15082
15083 let mut direct = reference(
15084 "rust",
15085 "src/lib.rs",
15086 "Car::run",
15087 "engine",
15088 "start",
15089 line_of(source, "self.engine.start()"),
15090 );
15091 direct.receiver_expression = "self.engine".to_string();
15092 assert_eq!(
15093 infer_receiver_type(root, &direct, &mut cache).as_deref(),
15094 Some("Engine")
15095 );
15096
15097 let mut mismatched_impl_target = direct.clone();
15098 mismatched_impl_target.caller_symbol = "other::Car::run".to_string();
15099 assert!(infer_receiver_type(root, &mismatched_impl_target, &mut cache).is_none());
15100
15101 let mut nested = reference(
15102 "rust",
15103 "src/lib.rs",
15104 "NestedCar::run",
15105 "engine",
15106 "start",
15107 line_of(source, "self.inner.engine.start()"),
15108 );
15109 nested.receiver_expression = "self.inner.engine".to_string();
15110 assert!(infer_receiver_type(root, &nested, &mut cache).is_none());
15111
15112 let mut wrapped = reference(
15113 "rust",
15114 "src/lib.rs",
15115 "WrappedCar::run",
15116 "engine",
15117 "start",
15118 line_of(source, "self.engine.start(); // wrapped"),
15119 );
15120 wrapped.receiver_expression = "self.engine".to_string();
15121 assert!(infer_receiver_type(root, &wrapped, &mut cache).is_none());
15122
15123 let mut generic = reference(
15124 "rust",
15125 "src/lib.rs",
15126 "GenericCar::run",
15127 "engine",
15128 "start",
15129 line_of(source, "self.engine.start(); // generic"),
15130 );
15131 generic.receiver_expression = "self.engine".to_string();
15132 assert!(infer_receiver_type(root, &generic, &mut cache).is_none());
15133
15134 let mut alias = reference(
15135 "rust",
15136 "src/lib.rs",
15137 "AliasCar::run",
15138 "engine",
15139 "start",
15140 line_of(source, "self.engine.start(); // alias"),
15141 );
15142 alias.receiver_expression = "self.engine".to_string();
15143 assert!(infer_receiver_type(root, &alias, &mut cache).is_none());
15144 }
15145
15146 #[test]
15147 fn rust_direct_self_reference_field_receiver_is_not_inferred() {
15148 let source = r#"struct Engine;
15149
15150struct Car {
15151 engine: &'static Engine,
15152}
15153
15154impl Car {
15155 fn run(&self) {
15156 self.engine.start();
15157 }
15158}
15159"#;
15160 let dir = tempdir().expect("temp dir");
15161 let root = dir.path();
15162 write_fixture(root, "src/lib.rs", source);
15163 let mut cache = DispatchSourceCache::new();
15164 let mut reference = reference(
15165 "rust",
15166 "src/lib.rs",
15167 "Car::run",
15168 "engine",
15169 "start",
15170 line_of(source, "self.engine.start()"),
15171 );
15172 reference.receiver_expression = "self.engine".to_string();
15173
15174 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15175 }
15176
15177 #[test]
15178 fn rust_trait_impl_self_field_receiver_is_not_inferred() {
15179 let source = r#"trait Drive {
15180 fn run(&self);
15181}
15182
15183struct Engine;
15184
15185struct Car {
15186 engine: Engine,
15187}
15188
15189impl Drive for Car {
15190 fn run(&self) {
15191 self.engine.start();
15192 }
15193}
15194"#;
15195 let dir = tempdir().expect("temp dir");
15196 let root = dir.path();
15197 write_fixture(root, "src/lib.rs", source);
15198 let mut cache = DispatchSourceCache::new();
15199 let mut reference = reference(
15200 "rust",
15201 "src/lib.rs",
15202 "Car::run",
15203 "engine",
15204 "start",
15205 line_of(source, "self.engine.start()"),
15206 );
15207 reference.receiver_expression = "self.engine".to_string();
15208
15209 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15210 }
15211
15212 #[test]
15213 fn rust_self_field_does_not_bind_struct_from_another_module() {
15214 let source = r#"struct Engine;
15215
15216mod unrelated {
15217 struct Car {
15218 engine: Engine,
15219 }
15220}
15221
15222impl Car {
15223 fn run(&self) {
15224 self.engine.start();
15225 }
15226}
15227"#;
15228 let dir = tempdir().expect("temp dir");
15229 let root = dir.path();
15230 write_fixture(root, "src/lib.rs", source);
15231 let mut cache = DispatchSourceCache::new();
15232 let mut reference = reference(
15233 "rust",
15234 "src/lib.rs",
15235 "Car::run",
15236 "engine",
15237 "start",
15238 line_of(source, "self.engine.start()"),
15239 );
15240 reference.receiver_expression = "self.engine".to_string();
15241
15242 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15243 }
15244
15245 #[test]
15246 fn unknown_java_receiver_still_uses_name_match_fallback() {
15247 let source = r#"class EntryPoint {
15248 void handle() {
15249 service.runSpecial();
15250 }
15251}
15252
15253class OnlyService {
15254 void runSpecial() {}
15255}
15256"#;
15257 let dir = tempdir().expect("temp dir");
15258 let root = dir.path();
15259 write_fixture(root, "src/EntryPoint.java", source);
15260 let reference = reference(
15261 "java",
15262 "src/EntryPoint.java",
15263 "EntryPoint::handle",
15264 "service",
15265 "runSpecial",
15266 line_of(source, "service.runSpecial()"),
15267 );
15268 let mut cache = DispatchSourceCache::new();
15269
15270 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15271 let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
15272 let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
15273 assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
15274 }
15275
15276 fn reference(
15277 lang: &str,
15278 caller_file: &str,
15279 caller_symbol: &str,
15280 receiver: &str,
15281 method_name: &str,
15282 line: u32,
15283 ) -> NameMatchRef {
15284 NameMatchRef {
15285 ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
15286 caller_node: format!("{caller_symbol}:node"),
15287 caller_file: caller_file.to_string(),
15288 caller_symbol: caller_symbol.to_string(),
15289 caller_signature: None,
15290 receiver_expression: receiver.to_string(),
15291 receiver: receiver.to_string(),
15292 method_name: method_name.to_string(),
15293 colon_dispatch: false,
15294 line,
15295 lang: lang.to_string(),
15296 }
15297 }
15298
15299 fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
15300 NameMatchCandidate {
15301 node_id: node_id.to_string(),
15302 file_path: "src/targets.fixture".to_string(),
15303 scoped_name: scoped_name.to_string(),
15304 kind: "method".to_string(),
15305 start_line: 1,
15306 }
15307 }
15308
15309 fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
15310 let path = root.join(rel_path);
15311 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
15312 fs::write(path, source).expect("write fixture");
15313 }
15314
15315 fn line_of(source: &str, needle: &str) -> u32 {
15316 source
15317 .lines()
15318 .position(|line| line.contains(needle))
15319 .map(|index| index as u32 + 1)
15320 .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
15321 }
15322}