1use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
9use crate::callgraph::{self, EdgeResolution, FileCallData, TraceToSymbolCandidate};
10use crate::context::SubcLifecycleAdmission;
11use crate::error::AftError;
12use crate::imports::{ImportForm, ImportGroup, ImportKind, ImportStatement};
13use crate::parser::{grammar_for, LangId};
14use crate::symbols::{Range, SymbolKind};
15use rayon::prelude::*;
16use rusqlite::{
17 params, params_from_iter, Connection, OpenFlags, OptionalExtension, Statement, Transaction,
18};
19use std::collections::{hash_map::Entry, BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
20use std::fmt;
21use std::io::Read;
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
24use std::sync::{Arc, Condvar, Mutex, OnceLock};
25use std::thread::JoinHandle;
26use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
27use tree_sitter::{Node, Parser};
28
29const SCHEMA_VERSION: i64 = 1;
30const BACKEND_TREESITTER: &str = "treesitter";
31const PROVENANCE_TREESITTER: &str = "treesitter+resolver";
32const PROVENANCE_NAME_MATCH: &str = "name_match";
33const PROVENANCE_TYPE_MATCH: &str = "type_match";
34const PROVENANCE_VALUE_REF: &str = "value_ref";
35const NAME_MATCH_SCORE_THRESHOLD: f64 = 2.0;
36const TOP_LEVEL_SYMBOL: &str = "<top-level>";
37const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
38const MIGRATION_MANIFEST_VERSION: u32 = 1;
39const MIGRATION_GENERATION_TAG: &str = ".migrated.";
40const MIGRATION_BACKUP_PAGES_PER_STEP: i32 = 128;
41const MIGRATION_BACKUP_RETRY_BUDGET: usize = 25;
42const MIGRATION_BACKUP_WALL_CLOCK_BUDGET: Duration = Duration::from_secs(10);
43const SQLITE_FILE_SET_SUFFIXES: &[&str] = &["", "-wal", "-shm", "-journal"];
44const MARKED_GENERATION_RETENTION_TTL: Duration = Duration::from_secs(6 * 60 * 60);
49const REFRESH_WORKER_WARN_AFTER: Duration = Duration::from_secs(5);
50const REFRESH_WORKER_FINAL_AFTER: Duration = Duration::from_secs(30);
51pub const REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET: Duration = Duration::from_millis(100);
52const REBUILD_COOLDOWN: Duration = Duration::from_secs(30);
53const ROOT_REPAIR_WARN_INTERVAL: Duration = Duration::from_secs(60);
54const CALLGRAPH_WRITE_METRIC_WINDOW: Duration = Duration::from_secs(60);
55const CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES: i64 = 4_000;
56const REFRESH_IDLE_CHECKPOINT_INTERVAL: Duration = Duration::from_secs(60);
57
58fn write_amplification_baseline_enabled() -> bool {
59 std::env::var_os("AFT_CALLGRAPH_WRITE_AMP_BASELINE").is_some()
60}
61
62type ColdBuildSwapObserver = dyn Fn(&Path, &Path) + Send + Sync + 'static;
63
64#[derive(Clone, Debug, Eq, Hash, PartialEq)]
65struct RebuildCooldownKey {
66 callgraph_dir: PathBuf,
67 project_key: String,
68}
69
70#[derive(Clone, Debug)]
71struct RebuildCooldownRecord {
72 project_root: PathBuf,
73 published_at: Instant,
74 cross_root_cooldown_armed: bool,
75}
76
77static SUCCESSFUL_REBUILDS: OnceLock<Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>> =
82 OnceLock::new();
83
84#[derive(Clone, Debug, Eq, Hash, PartialEq)]
85struct RootRepairWarningKey {
86 project_key: String,
87}
88
89#[derive(Clone, Debug)]
90struct RootRepairWarningRecord {
91 window_start: Instant,
92 last_emitted: Instant,
93 entry_count: u64,
94 suppressed: u64,
95}
96
97static ROOT_REPAIR_WARNINGS: OnceLock<
98 Mutex<HashMap<RootRepairWarningKey, RootRepairWarningRecord>>,
99> = OnceLock::new();
100
101#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
102pub(crate) struct CallgraphWriteMetricsSnapshot {
103 pub commits_60s: u64,
104 pub pages_or_bytes_written_60s: u64,
105}
106
107#[derive(Debug, Default)]
108struct CallgraphWriteMetrics {
109 window_start_ms: AtomicU64,
110 commits_60s: AtomicU64,
111 pages_or_bytes_written_60s: AtomicU64,
112}
113
114static CALLGRAPH_WRITE_METRICS: OnceLock<Mutex<HashMap<String, Arc<CallgraphWriteMetrics>>>> =
115 OnceLock::new();
116
117fn callgraph_write_metrics_for_key(project_key: &str) -> Arc<CallgraphWriteMetrics> {
118 let metrics = CALLGRAPH_WRITE_METRICS.get_or_init(|| Mutex::new(HashMap::new()));
119 let mut metrics = metrics
120 .lock()
121 .expect("callgraph write metrics mutex poisoned");
122 Arc::clone(
123 metrics
124 .entry(project_key.to_string())
125 .or_insert_with(|| Arc::new(CallgraphWriteMetrics::default())),
126 )
127}
128
129fn roll_callgraph_write_metric_window(metrics: &CallgraphWriteMetrics, now_ms: u64) {
130 let current_start = metrics.window_start_ms.load(AtomicOrdering::Acquire);
131 if current_start == 0 {
132 let _ = metrics.window_start_ms.compare_exchange(
133 0,
134 now_ms,
135 AtomicOrdering::AcqRel,
136 AtomicOrdering::Acquire,
137 );
138 return;
139 }
140 if now_ms.saturating_sub(current_start) < CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64 {
141 return;
142 }
143 if metrics
144 .window_start_ms
145 .compare_exchange(
146 current_start,
147 now_ms,
148 AtomicOrdering::AcqRel,
149 AtomicOrdering::Acquire,
150 )
151 .is_ok()
152 {
153 metrics.commits_60s.store(0, AtomicOrdering::Release);
154 metrics
155 .pages_or_bytes_written_60s
156 .store(0, AtomicOrdering::Release);
157 }
158}
159
160impl CallgraphWriteMetrics {
161 fn record_commit(&self, pages_or_bytes_written: u64) {
162 let now_ms = unix_millis_now();
163 roll_callgraph_write_metric_window(self, now_ms);
164 self.commits_60s.fetch_add(1, AtomicOrdering::Relaxed);
165 self.pages_or_bytes_written_60s
166 .fetch_add(pages_or_bytes_written, AtomicOrdering::Relaxed);
167 }
168
169 fn snapshot(&self) -> CallgraphWriteMetricsSnapshot {
170 roll_callgraph_write_metric_window(self, unix_millis_now());
171 CallgraphWriteMetricsSnapshot {
172 commits_60s: self.commits_60s.load(AtomicOrdering::Acquire),
173 pages_or_bytes_written_60s: self
174 .pages_or_bytes_written_60s
175 .load(AtomicOrdering::Acquire),
176 }
177 }
178}
179
180pub(crate) fn callgraph_write_metrics_for_project(
181 project_key: &str,
182) -> CallgraphWriteMetricsSnapshot {
183 callgraph_write_metrics_for_key(project_key).snapshot()
184}
185
186pub(crate) fn callgraph_write_metrics_total() -> CallgraphWriteMetricsSnapshot {
187 let Some(metrics) = CALLGRAPH_WRITE_METRICS.get() else {
188 return CallgraphWriteMetricsSnapshot::default();
189 };
190 let metrics = metrics
191 .lock()
192 .expect("callgraph write metrics mutex poisoned");
193 metrics.values().map(|metrics| metrics.snapshot()).fold(
194 CallgraphWriteMetricsSnapshot::default(),
195 |total, current| CallgraphWriteMetricsSnapshot {
196 commits_60s: total.commits_60s.saturating_add(current.commits_60s),
197 pages_or_bytes_written_60s: total
198 .pages_or_bytes_written_60s
199 .saturating_add(current.pages_or_bytes_written_60s),
200 },
201 )
202}
203
204const ROOT_REPAIR_WARNING_TEXT: &str =
205 "callgraph store root repair requires rebuild; open-only reader reports unavailable";
206
207fn next_root_repair_warning(key: RootRepairWarningKey, now: Instant) -> Option<String> {
208 let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
209 let mut warnings = warnings.lock().ok()?;
210 let entry = warnings.entry(key);
211 let record = match entry {
212 Entry::Vacant(entry) => {
213 entry.insert(RootRepairWarningRecord {
214 window_start: now,
215 last_emitted: now,
216 entry_count: 1,
217 suppressed: 0,
218 });
219 return Some(ROOT_REPAIR_WARNING_TEXT.to_string());
220 }
221 Entry::Occupied(entry) => entry.into_mut(),
222 };
223
224 if now.saturating_duration_since(record.window_start) >= ROOT_REPAIR_WARN_INTERVAL {
225 let suppressed = record.suppressed;
226 record.window_start = now;
227 record.last_emitted = now;
228 record.entry_count = 1;
229 record.suppressed = 0;
230 return Some(if suppressed == 0 {
231 ROOT_REPAIR_WARNING_TEXT.to_string()
232 } else {
233 format!("{ROOT_REPAIR_WARNING_TEXT} (repeated {suppressed}x in 60s)")
234 });
235 }
236
237 record.entry_count = record.entry_count.saturating_add(1);
238 if now.saturating_duration_since(record.last_emitted) < ROOT_REPAIR_WARN_INTERVAL {
239 record.suppressed = record.suppressed.saturating_add(1);
240 None
241 } else {
242 record.last_emitted = now;
243 Some(ROOT_REPAIR_WARNING_TEXT.to_string())
244 }
245}
246
247pub(crate) fn note_repair_entry(project_key: &str) -> Option<String> {
248 next_root_repair_warning(
249 RootRepairWarningKey {
250 project_key: project_key.to_string(),
251 },
252 Instant::now(),
253 )
254}
255
256pub(crate) fn repair_entry_rate(project_key: &str) -> Option<(u64, Instant)> {
261 let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
262 let warnings = warnings.lock().ok()?;
263 let record = warnings.get(&RootRepairWarningKey {
264 project_key: project_key.to_string(),
265 })?;
266 (Instant::now().saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL)
267 .then_some((record.entry_count, record.window_start))
268}
269
270pub(crate) fn repair_entry_rate_total() -> u64 {
271 let Ok(warnings) = ROOT_REPAIR_WARNINGS
272 .get_or_init(|| Mutex::new(HashMap::new()))
273 .lock()
274 else {
275 return 0;
276 };
277 let now = Instant::now();
278 warnings
279 .values()
280 .filter(|record| {
281 now.saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL
282 })
283 .map(|record| record.entry_count)
284 .sum()
285}
286
287#[cfg(test)]
288pub(crate) fn expire_repair_entry_window_for_test(project_key: &str) {
289 let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
290 let mut warnings = warnings.lock().unwrap();
291 if let Some(record) = warnings.get_mut(&RootRepairWarningKey {
292 project_key: project_key.to_string(),
293 }) {
294 record.window_start = Instant::now() - ROOT_REPAIR_WARN_INTERVAL;
295 }
296}
297
298#[cfg(test)]
299mod root_repair_warning_tests {
300 use super::*;
301
302 #[test]
303 fn repair_warning_emits_once_then_reemits_with_suppressed_count() {
304 let key = RootRepairWarningKey {
305 project_key: "test-project".to_string(),
306 };
307 let first_at = Instant::now();
308 let first = next_root_repair_warning(key.clone(), first_at).unwrap();
309 assert_eq!(first, ROOT_REPAIR_WARNING_TEXT);
310 assert!(next_root_repair_warning(key.clone(), first_at + Duration::from_secs(1)).is_none());
311 assert_eq!(
312 repair_entry_rate("test-project").map(|rate| rate.0),
313 Some(2)
314 );
315
316 let repeated = next_root_repair_warning(key, first_at + ROOT_REPAIR_WARN_INTERVAL).unwrap();
317 assert!(repeated.ends_with("(repeated 1x in 60s)"));
318 expire_repair_entry_window_for_test("test-project");
319 assert!(repair_entry_rate("test-project").is_none());
320 }
321}
322
323#[cfg(test)]
324mod write_amplification_tests {
325 use super::*;
326 use std::fs;
327 use tempfile::tempdir;
328
329 #[test]
330 fn callgraph_writer_and_reader_use_bounded_normal_pragmas() {
331 let temp = tempdir().unwrap();
332 let root = temp.path().join("root");
333 fs::create_dir_all(&root).unwrap();
334 let source = root.join("main.ts");
335 fs::write(&source, "export function main() {}\n").unwrap();
336 let store_dir = temp.path().join("store");
337 let store = CallGraphStore::open(store_dir.clone(), root.clone()).unwrap();
338
339 let conn = store.conn.lock().unwrap();
340 let synchronous: i64 = conn
341 .pragma_query_value(None, "synchronous", |row| row.get(0))
342 .unwrap();
343 let autocheckpoint: i64 = conn
344 .pragma_query_value(None, "wal_autocheckpoint", |row| row.get(0))
345 .unwrap();
346 assert_eq!(synchronous, 1, "NORMAL synchronous mode is value 1");
347 assert_eq!(autocheckpoint, CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES);
348 drop(conn);
349 store.cold_build(std::slice::from_ref(&source)).unwrap();
350 drop(store);
351
352 let readonly = CallGraphStore::open_readonly(store_dir, root)
353 .unwrap()
354 .expect("writer-created empty schema should be readable");
355 let conn = readonly.inner.conn.lock().unwrap();
356 let synchronous: i64 = conn
357 .pragma_query_value(None, "synchronous", |row| row.get(0))
358 .unwrap();
359 assert_eq!(synchronous, 1);
360 }
361
362 #[test]
363 fn own_refresh_skips_identical_extract_but_not_position_shift() {
364 let temp = tempdir().unwrap();
365 let root = temp.path().join("root");
366 fs::create_dir_all(&root).unwrap();
367 let source = root.join("main.ts");
368 fs::write(&source, "export function main() { return 1; }\n").unwrap();
369 let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
370 store.cold_build(std::slice::from_ref(&source)).unwrap();
371 let write_metrics = callgraph_write_metrics_for_project(store.project_key());
372 assert!(write_metrics.commits_60s > 0);
373 assert!(write_metrics.pages_or_bytes_written_60s > 0);
374
375 let before = store.conn.lock().unwrap().total_changes();
376 fs::write(&source, "export function main() { return 1; }\n\n").unwrap();
377 let (stats, _) = store
378 .refresh_files_profiled(std::slice::from_ref(&source))
379 .unwrap();
380 let after = store.conn.lock().unwrap().total_changes();
381 assert_eq!(stats.unchanged_extract_files, 1);
382 assert_eq!(stats.refreshed_own_files, 0);
383 assert_eq!(
384 after - before,
385 3,
386 "files, backend freshness, and the durable projection revision update"
387 );
388
389 fs::write(&source, "\nexport function main() { return 1; }\n\n").unwrap();
390 let (shifted_stats, _) = store
391 .refresh_files_profiled(std::slice::from_ref(&source))
392 .unwrap();
393 assert_eq!(shifted_stats.unchanged_extract_files, 0);
394 assert_eq!(shifted_stats.refreshed_own_files, 1);
395 }
396
397 #[test]
398 fn idle_checkpoint_interval_prevents_checkpoint_storms() {
399 let now = Instant::now();
400 assert!(idle_checkpoint_due(None, now));
401 assert!(!idle_checkpoint_due(
402 Some(now),
403 now + Duration::from_secs(REFRESH_IDLE_CHECKPOINT_INTERVAL.as_secs() - 1),
404 ));
405 assert!(idle_checkpoint_due(
406 Some(now),
407 now + REFRESH_IDLE_CHECKPOINT_INTERVAL,
408 ));
409 }
410
411 #[test]
412 fn write_metrics_decay_after_the_sixty_second_window() {
413 let key = format!("metrics-test-{}", now_nanos());
414 let metrics = callgraph_write_metrics_for_key(&key);
415 metrics.record_commit(17);
416 assert_eq!(metrics.snapshot().commits_60s, 1);
417 assert_eq!(metrics.snapshot().pages_or_bytes_written_60s, 17);
418 metrics.window_start_ms.store(
419 unix_millis_now().saturating_sub(CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64),
420 AtomicOrdering::Release,
421 );
422 assert_eq!(metrics.snapshot(), CallgraphWriteMetricsSnapshot::default());
423 }
424}
425
426#[cfg(test)]
427type ColdBuildBeforePublishObserver = dyn Fn() + Send + Sync + 'static;
428thread_local! {
435 static COLD_BUILD_SWAP_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSwapObserver>>> =
436 const { std::cell::RefCell::new(None) };
437 #[cfg(test)]
438 static COLD_BUILD_BEFORE_PUBLISH_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildBeforePublishObserver>>> =
439 const { std::cell::RefCell::new(None) };
440 static MIGRATION_AVAILABLE_DISK_OVERRIDE: std::cell::RefCell<Option<u64>> =
441 const { std::cell::RefCell::new(None) };
442 static MIGRATION_FAIL_AFTER_TEMP_COPY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
443 static MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED: std::cell::Cell<bool> =
444 const { std::cell::Cell::new(false) };
445 static PUBLISH_ADMISSION: std::cell::RefCell<Option<(crate::root_cache::ArtifactPublishEpoch, u64)>> =
446 const { std::cell::RefCell::new(None) };
447 static REFRESH_COMMIT_ADMISSION: std::cell::RefCell<Option<(SubcLifecycleAdmission, Arc<std::sync::atomic::AtomicU64>, u64)>> =
448 const { std::cell::RefCell::new(None) };
449}
450
451mod dead_code_projection;
452pub use dead_code_projection::project_dead_code_snapshot;
453pub(crate) use dead_code_projection::project_dead_code_snapshot_with_revision;
454#[cfg(test)]
455pub(crate) use dead_code_projection::set_projection_before_open_observer;
456
457#[doc(hidden)]
458pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
459 COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
460}
461
462#[cfg(test)]
463fn set_cold_build_before_publish_observer(observer: Option<Arc<ColdBuildBeforePublishObserver>>) {
464 COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
465}
466
467#[cfg(test)]
468fn notify_cold_build_before_publish_observer() {
469 let observer = COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| slot.borrow().clone());
470 if let Some(observer) = observer {
471 observer();
472 }
473}
474
475#[cfg(not(test))]
476fn notify_cold_build_before_publish_observer() {}
477
478#[doc(hidden)]
479pub fn set_legacy_migration_available_disk_for_test(bytes: Option<u64>) {
480 MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow_mut() = bytes);
481}
482
483#[doc(hidden)]
484pub fn set_legacy_migration_fail_after_temp_copy_for_test(enabled: bool) {
485 MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.set(enabled));
486}
487
488#[doc(hidden)]
489pub fn set_legacy_migration_backup_budget_exhausted_for_test(enabled: bool) {
490 MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.set(enabled));
491}
492
493struct PublishAdmissionGuard {
494 previous: Option<(crate::root_cache::ArtifactPublishEpoch, u64)>,
495}
496
497impl Drop for PublishAdmissionGuard {
498 fn drop(&mut self) {
499 PUBLISH_ADMISSION.with(|slot| {
500 *slot.borrow_mut() = self.previous.take();
501 });
502 }
503}
504
505pub(crate) fn with_publish_epoch<R>(
506 epoch: crate::root_cache::ArtifactPublishEpoch,
507 expected: u64,
508 run: impl FnOnce() -> R,
509) -> R {
510 let previous = PUBLISH_ADMISSION.with(|slot| slot.replace(Some((epoch, expected))));
511 let _guard = PublishAdmissionGuard { previous };
512 run()
513}
514
515fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
516 let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
517 match admission {
518 Some((epoch, expected)) => epoch
519 .run_if_current(expected, publish)
520 .unwrap_or(Err(CallGraphStoreError::Superseded)),
521 None => publish(),
522 }
523}
524
525struct RefreshCommitAdmissionGuard {
526 previous: Option<(
527 SubcLifecycleAdmission,
528 Arc<std::sync::atomic::AtomicU64>,
529 u64,
530 )>,
531}
532
533impl Drop for RefreshCommitAdmissionGuard {
534 fn drop(&mut self) {
535 REFRESH_COMMIT_ADMISSION.with(|slot| {
536 *slot.borrow_mut() = self.previous.take();
537 });
538 }
539}
540
541fn with_refresh_commit_admission<R>(
542 lifecycle: SubcLifecycleAdmission,
543 generation_flag: Arc<std::sync::atomic::AtomicU64>,
544 expected_generation: u64,
545 run: impl FnOnce() -> R,
546) -> R {
547 let previous = REFRESH_COMMIT_ADMISSION
548 .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
549 let _guard = RefreshCommitAdmissionGuard { previous };
550 run()
551}
552
553fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
554 let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
555 let commit = || {
556 publish_if_current(|| {
557 tx.commit()?;
558 Ok(())
559 })
560 };
561 match admission {
562 Some((lifecycle, generation_flag, expected_generation)) => lifecycle
563 .run_if_current(generation_flag.as_ref(), expected_generation, commit)
564 .unwrap_or(Err(CallGraphStoreError::Superseded)),
565 None => commit(),
566 }
567}
568
569fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
570 let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
571 if let Some(observer) = observer {
572 observer(temp_path, target_path);
573 }
574}
575
576#[derive(Debug)]
577pub enum CallGraphStoreError {
578 Io(std::io::Error),
579 Sqlite(rusqlite::Error),
580 Json(serde_json::Error),
581 Aft(AftError),
582 Lock(crate::fs_lock::AcquireError),
583 MissingCallerData { file: String },
584 Unavailable(String),
585 Superseded,
586 StaleFiles(Vec<String>),
587}
588
589impl fmt::Display for CallGraphStoreError {
590 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
591 match self {
592 Self::Io(error) => write!(formatter, "I/O error: {error}"),
593 Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
594 Self::Json(error) => write!(formatter, "json error: {error}"),
595 Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
596 Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
597 Self::MissingCallerData { file } => {
598 write!(formatter, "missing extracted caller data for {file}")
599 }
600 Self::Unavailable(message) => {
601 write!(formatter, "callgraph store unavailable: {message}")
602 }
603 Self::Superseded => {
604 write!(formatter, "callgraph store build superseded before publish")
605 }
606 Self::StaleFiles(files) => {
607 write!(
608 formatter,
609 "callgraph store has stale files: {}",
610 files.join(", ")
611 )
612 }
613 }
614 }
615}
616
617impl std::error::Error for CallGraphStoreError {}
618
619impl From<std::io::Error> for CallGraphStoreError {
620 fn from(error: std::io::Error) -> Self {
621 Self::Io(error)
622 }
623}
624
625impl From<rusqlite::Error> for CallGraphStoreError {
626 fn from(error: rusqlite::Error) -> Self {
627 Self::Sqlite(error)
628 }
629}
630
631impl From<serde_json::Error> for CallGraphStoreError {
632 fn from(error: serde_json::Error) -> Self {
633 Self::Json(error)
634 }
635}
636
637impl From<AftError> for CallGraphStoreError {
638 fn from(error: AftError) -> Self {
639 Self::Aft(error)
640 }
641}
642
643impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
644 fn from(error: crate::fs_lock::AcquireError) -> Self {
645 Self::Lock(error)
646 }
647}
648
649pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
650
651pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
655
656#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
657pub struct CallGraphStoreOptions {
658 pub enabled: bool,
659}
660
661pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
662
663#[derive(Clone)]
667pub(crate) struct CallgraphRefreshState {
668 store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
669 heavy_root_work_allowed: Arc<AtomicBool>,
670}
671
672impl CallgraphRefreshState {
673 pub(crate) fn new(
674 store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
675 heavy_root_work_allowed: Arc<AtomicBool>,
676 ) -> Self {
677 Self {
678 store,
679 heavy_root_work_allowed,
680 }
681 }
682
683 fn installed_store_snapshot(&self) -> Option<Arc<ReadonlyCallGraphStore>> {
684 self.store
685 .read()
686 .unwrap_or_else(std::sync::PoisonError::into_inner)
687 .as_ref()
688 .map(Arc::clone)
689 }
690}
691
692type WorkspaceCratePrefixes = HashMap<String, String>;
693
694#[derive(Clone, Debug, Default)]
695struct WorkspaceCratePrefixCache(Arc<OnceLock<WorkspaceCratePrefixes>>);
696
697const REFRESH_WORKSPACE_CACHE_ROOT_CAP: usize = 128;
698
699pub(crate) fn invalidates_workspace_crate_prefix_cache(path: &Path) -> bool {
700 path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")
701}
702
703#[derive(Clone, Debug, Hash, PartialEq, Eq)]
704struct RefreshRoot {
705 callgraph_dir: PathBuf,
706 project_root: PathBuf,
707}
708
709#[derive(Clone)]
710pub(crate) struct CallgraphRefreshTicket {
711 lifecycle: SubcLifecycleAdmission,
712 generation_flag: Arc<std::sync::atomic::AtomicU64>,
713 expected_generation: u64,
714 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
715 expected_publish_epoch: u64,
716}
717
718impl CallgraphRefreshTicket {
719 pub(crate) fn new(
720 lifecycle: SubcLifecycleAdmission,
721 generation_flag: Arc<std::sync::atomic::AtomicU64>,
722 expected_generation: u64,
723 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
724 expected_publish_epoch: u64,
725 ) -> Self {
726 Self {
727 lifecycle,
728 generation_flag,
729 expected_generation,
730 publish_epoch,
731 expected_publish_epoch,
732 }
733 }
734
735 fn is_current(&self) -> bool {
736 self.lifecycle
737 .is_current(self.generation_flag.as_ref(), self.expected_generation)
738 && self.publish_epoch.current() == self.expected_publish_epoch
739 }
740}
741
742#[derive(Clone)]
743struct RefreshBatch {
744 root: RefreshRoot,
745 paths: BTreeSet<PathBuf>,
746 pending_sinks: Vec<PendingCallGraphStorePaths>,
747 refresh_states: Vec<CallgraphRefreshState>,
748 ticket: Option<CallgraphRefreshTicket>,
749}
750
751impl RefreshBatch {
752 fn defer(&self) {
753 for sink in &self.pending_sinks {
754 sink.lock().extend(self.paths.iter().cloned());
755 }
756 }
757
758 fn defer_after_open_failure(&self) {
759 self.defer();
760 if self
761 .ticket
762 .as_ref()
763 .is_some_and(|ticket| !ticket.is_current())
764 || !self
765 .refresh_states
766 .iter()
767 .any(|state| state.heavy_root_work_allowed.load(AtomicOrdering::SeqCst))
768 {
769 return;
770 }
771
772 let ready_store_installed = self.refresh_states.iter().any(|state| {
773 let store = state.installed_store_snapshot();
774 store.is_some_and(|store| {
775 store.project_root() == self.root.project_root
776 && !store.is_legacy_fallback()
777 && store.is_current()
778 })
779 });
780 if !ready_store_installed {
781 return;
782 }
783
784 for sink in &self.pending_sinks {
788 let paths = {
789 let mut pending = sink.lock();
790 self.paths
791 .iter()
792 .filter(|path| pending.remove(*path))
793 .cloned()
794 .collect::<Vec<_>>()
795 };
796 if paths.is_empty() {
797 continue;
798 }
799 let _ = enqueue_callgraph_store_refresh_inner(
800 self.root.callgraph_dir.clone(),
801 self.root.project_root.clone(),
802 paths,
803 Arc::clone(sink),
804 self.refresh_states.clone(),
805 self.ticket.clone(),
806 );
807 }
808 }
809
810 fn merge(
811 &mut self,
812 paths: impl IntoIterator<Item = PathBuf>,
813 sink: PendingCallGraphStorePaths,
814 refresh_states: Vec<CallgraphRefreshState>,
815 ticket: Option<CallgraphRefreshTicket>,
816 ) {
817 self.paths.extend(paths);
818 if ticket.is_some() {
819 self.ticket = ticket;
820 }
821 if !self
822 .pending_sinks
823 .iter()
824 .any(|existing| Arc::ptr_eq(existing, &sink))
825 {
826 self.pending_sinks.push(sink);
827 }
828 for refresh_state in refresh_states {
829 if !self.refresh_states.iter().any(|existing| {
830 Arc::ptr_eq(&existing.store, &refresh_state.store)
831 && Arc::ptr_eq(
832 &existing.heavy_root_work_allowed,
833 &refresh_state.heavy_root_work_allowed,
834 )
835 }) {
836 self.refresh_states.push(refresh_state);
837 }
838 }
839 }
840}
841
842#[derive(Default)]
843struct RefreshQueue {
844 order: VecDeque<RefreshRoot>,
845 queued: HashMap<RefreshRoot, RefreshBatch>,
846 active: Option<RefreshBatch>,
847 shutdown_requested: bool,
848}
849
850struct RefreshWorkerShared {
851 queue: Mutex<RefreshQueue>,
852 wake: Condvar,
853}
854
855struct RefreshWorker {
856 shared: Arc<RefreshWorkerShared>,
857 thread: Mutex<Option<JoinHandle<()>>>,
858}
859
860struct RefreshWorkerWatchdog {
861 first_path: PathBuf,
862 batch_len: usize,
863 started: Instant,
864}
865
866impl RefreshWorkerWatchdog {
867 fn start(paths: &[PathBuf]) -> Self {
868 Self {
869 first_path: paths
870 .first()
871 .expect("non-empty callgraph refresh batch has a first path")
872 .clone(),
873 batch_len: paths.len(),
874 started: Instant::now(),
875 }
876 }
877}
878
879impl Drop for RefreshWorkerWatchdog {
880 fn drop(&mut self) {
881 let elapsed = self.started.elapsed();
882 if elapsed < REFRESH_WORKER_WARN_AFTER {
883 return;
884 }
885 let path = if self.batch_len == 1 {
886 self.first_path.display().to_string()
887 } else {
888 format!(
889 "{} (+{} paths)",
890 self.first_path.display(),
891 self.batch_len - 1
892 )
893 };
894 log::warn!(
895 "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
896 path,
897 elapsed.as_millis()
898 );
899 if elapsed >= REFRESH_WORKER_FINAL_AFTER {
900 log::warn!(
901 "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
902 path,
903 elapsed.as_millis()
904 );
905 }
906 }
907}
908
909impl RefreshWorker {
910 fn spawn() -> Arc<Self> {
911 let shared = Arc::new(RefreshWorkerShared {
912 queue: Mutex::new(RefreshQueue::default()),
913 wake: Condvar::new(),
914 });
915 let thread_shared = Arc::clone(&shared);
916 let thread = std::thread::Builder::new()
917 .name("aft-callgraph-refresh".to_string())
918 .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
919 .expect("failed to spawn callgraph refresh worker");
920 Arc::new(Self {
921 shared,
922 thread: Mutex::new(Some(thread)),
923 })
924 }
925
926 fn enqueue(
927 &self,
928 root: RefreshRoot,
929 paths: Vec<PathBuf>,
930 pending_sink: PendingCallGraphStorePaths,
931 refresh_states: Vec<CallgraphRefreshState>,
932 ticket: Option<CallgraphRefreshTicket>,
933 ) -> bool {
934 let mut queue = self
935 .shared
936 .queue
937 .lock()
938 .expect("callgraph refresh queue mutex poisoned");
939 if queue.shutdown_requested {
940 pending_sink.lock().extend(paths);
941 return false;
942 }
943 if let Some(batch) = queue.queued.get_mut(&root) {
944 batch.merge(paths, pending_sink, refresh_states, ticket);
945 } else {
946 queue.order.push_back(root.clone());
947 queue.queued.insert(
948 root.clone(),
949 RefreshBatch {
950 root,
951 paths: paths.into_iter().collect(),
952 pending_sinks: vec![pending_sink],
953 refresh_states,
954 ticket,
955 },
956 );
957 }
958 self.shared.wake.notify_one();
959 true
960 }
961
962 fn shutdown_with_budget(&self, budget: Duration) -> bool {
963 let deadline = Instant::now() + budget;
964 let mut queue = self
965 .shared
966 .queue
967 .lock()
968 .expect("callgraph refresh queue mutex poisoned");
969 queue.shutdown_requested = true;
970 self.shared.wake.notify_one();
971 while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
972 let remaining = deadline.saturating_duration_since(Instant::now());
973 let (next, _) = self
974 .shared
975 .wake
976 .wait_timeout(queue, remaining)
977 .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
978 queue = next;
979 }
980 let drained = queue.active.is_none() && queue.order.is_empty();
981 if !drained {
982 if let Some(active) = queue.active.as_ref() {
983 active.defer();
984 }
985 for batch in queue.queued.values() {
986 batch.defer();
987 }
988 queue.order.clear();
989 queue.queued.clear();
990 }
991 drop(queue);
992
993 if drained {
994 if let Some(thread) = self
995 .thread
996 .lock()
997 .expect("callgraph refresh worker thread mutex poisoned")
998 .take()
999 {
1000 let _ = thread.join();
1001 }
1002 }
1003 drained
1004 }
1005}
1006
1007static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
1008
1009pub fn enqueue_callgraph_store_refresh(
1010 callgraph_dir: PathBuf,
1011 project_root: PathBuf,
1012 paths: Vec<PathBuf>,
1013 pending_sink: PendingCallGraphStorePaths,
1014) -> bool {
1015 enqueue_callgraph_store_refresh_inner(
1016 callgraph_dir,
1017 project_root,
1018 paths,
1019 pending_sink,
1020 Vec::new(),
1021 None,
1022 )
1023}
1024
1025#[cfg(test)]
1026pub(crate) fn enqueue_callgraph_store_refresh_fenced(
1027 callgraph_dir: PathBuf,
1028 project_root: PathBuf,
1029 paths: Vec<PathBuf>,
1030 pending_sink: PendingCallGraphStorePaths,
1031 ticket: CallgraphRefreshTicket,
1032) -> bool {
1033 enqueue_callgraph_store_refresh_inner(
1034 callgraph_dir,
1035 project_root,
1036 paths,
1037 pending_sink,
1038 Vec::new(),
1039 Some(ticket),
1040 )
1041}
1042
1043pub(crate) fn enqueue_callgraph_store_refresh_fenced_with_state(
1044 callgraph_dir: PathBuf,
1045 project_root: PathBuf,
1046 paths: Vec<PathBuf>,
1047 pending_sink: PendingCallGraphStorePaths,
1048 refresh_state: CallgraphRefreshState,
1049 ticket: CallgraphRefreshTicket,
1050) -> bool {
1051 enqueue_callgraph_store_refresh_inner(
1052 callgraph_dir,
1053 project_root,
1054 paths,
1055 pending_sink,
1056 vec![refresh_state],
1057 Some(ticket),
1058 )
1059}
1060
1061fn enqueue_callgraph_store_refresh_inner(
1062 callgraph_dir: PathBuf,
1063 project_root: PathBuf,
1064 paths: Vec<PathBuf>,
1065 pending_sink: PendingCallGraphStorePaths,
1066 refresh_states: Vec<CallgraphRefreshState>,
1067 ticket: Option<CallgraphRefreshTicket>,
1068) -> bool {
1069 if paths.is_empty() {
1070 return true;
1071 }
1072 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1073 let worker = {
1074 let mut worker = slot
1075 .lock()
1076 .expect("callgraph refresh worker mutex poisoned");
1077 Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
1078 };
1079 worker.enqueue(
1080 RefreshRoot {
1081 callgraph_dir,
1082 project_root,
1083 },
1084 paths,
1085 pending_sink,
1086 refresh_states,
1087 ticket,
1088 )
1089}
1090
1091pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
1092 flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
1093}
1094
1095#[doc(hidden)]
1096pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
1097 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1098 let worker = slot
1099 .lock()
1100 .expect("callgraph refresh worker mutex poisoned")
1101 .clone();
1102 let Some(worker) = worker else {
1103 return true;
1104 };
1105 let drained = worker.shutdown_with_budget(budget);
1106 if drained {
1107 let mut current = slot
1108 .lock()
1109 .expect("callgraph refresh worker mutex poisoned");
1110 if current
1111 .as_ref()
1112 .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
1113 {
1114 *current = None;
1115 }
1116 }
1117 drained
1118}
1119
1120fn idle_checkpoint_due(last: Option<Instant>, now: Instant) -> bool {
1121 last.is_none_or(|last| now.saturating_duration_since(last) >= REFRESH_IDLE_CHECKPOINT_INTERVAL)
1122}
1123
1124fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
1125 let mut workspace_crate_prefixes = HashMap::new();
1128 let mut last_idle_checkpoints: HashMap<RefreshRoot, Instant> = HashMap::new();
1129 loop {
1130 let batch = {
1131 let mut queue = shared
1132 .queue
1133 .lock()
1134 .expect("callgraph refresh queue mutex poisoned");
1135 loop {
1136 if let Some(root) = queue.order.pop_front() {
1137 let batch = queue
1138 .queued
1139 .remove(&root)
1140 .expect("queued callgraph refresh root has a batch");
1141 queue.active = Some(batch.clone());
1142 break batch;
1143 }
1144 if queue.shutdown_requested {
1145 return;
1146 }
1147 queue = shared
1148 .wake
1149 .wait(queue)
1150 .expect("callgraph refresh queue mutex poisoned while waiting");
1151 }
1152 };
1153
1154 let store = process_callgraph_refresh_batch(&batch, &mut workspace_crate_prefixes);
1155
1156 let mut queue = shared
1157 .queue
1158 .lock()
1159 .expect("callgraph refresh queue mutex poisoned");
1160 queue.active = None;
1161 let became_idle = queue.order.is_empty();
1162 shared.wake.notify_all();
1163 drop(queue);
1164
1165 if became_idle {
1166 let checkpoint_due = idle_checkpoint_due(
1167 last_idle_checkpoints.get(&batch.root).copied(),
1168 Instant::now(),
1169 );
1170 if checkpoint_due {
1171 if let Some(store) = store {
1172 if store.checkpoint_wal_truncate() {
1173 last_idle_checkpoints.insert(batch.root.clone(), Instant::now());
1174 }
1175 }
1176 }
1177 }
1178 }
1179}
1180
1181fn process_callgraph_refresh_batch(
1182 batch: &RefreshBatch,
1183 workspace_crate_prefixes: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1184) -> Option<CallGraphStore> {
1185 if batch
1189 .paths
1190 .iter()
1191 .any(|path| invalidates_workspace_crate_prefix_cache(path))
1192 {
1193 workspace_crate_prefixes.remove(&batch.root);
1194 }
1195
1196 let paths = batch
1197 .paths
1198 .iter()
1199 .filter(|path| crate::parser::detect_language(path).is_some())
1200 .cloned()
1201 .collect::<Vec<_>>();
1202 if paths.is_empty() {
1203 return None;
1204 }
1205 note_refresh_worker_batch_for_test(&batch.root.project_root);
1206 if batch
1207 .ticket
1208 .as_ref()
1209 .is_some_and(|ticket| !ticket.is_current())
1210 {
1211 batch.defer();
1214 return None;
1215 }
1216 let workspace_crate_prefix_cache =
1217 workspace_crate_prefix_cache_for_root(workspace_crate_prefixes, &batch.root);
1218 let _watchdog = RefreshWorkerWatchdog::start(&paths);
1219 let test_seam = refresh_worker_test_seam(&batch.root.project_root);
1220 note_refresh_worker_call_for_test(&batch.root.project_root);
1221 let opened = if test_seam.fail_open {
1222 Ok(None)
1223 } else {
1224 CallGraphStore::open_ready(
1225 batch.root.callgraph_dir.clone(),
1226 batch.root.project_root.clone(),
1227 )
1228 };
1229 if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
1230 let _ = gate.held_tx.send(());
1233 let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
1234 }
1235 let store = match opened {
1236 Ok(Some(store)) => store,
1237 Ok(None) => {
1238 batch.defer_after_open_failure();
1239 return None;
1240 }
1241 Err(error) => {
1242 batch.defer_after_open_failure();
1243 crate::slog_warn!(
1244 "callgraph store writer open failed during refresh; deferred paths: {}",
1245 error
1246 );
1247 return None;
1248 }
1249 };
1250 if !test_seam.delay.is_zero() {
1251 std::thread::sleep(test_seam.delay);
1252 }
1253 if batch
1254 .ticket
1255 .as_ref()
1256 .is_some_and(|ticket| !ticket.is_current())
1257 {
1258 batch.defer();
1261 return Some(store);
1262 }
1263 let refresh_result = if test_seam.fail_refresh {
1264 Err(CallGraphStoreError::Unavailable(
1265 "injected refresh worker failure".to_string(),
1266 ))
1267 } else if let Some(ticket) = &batch.ticket {
1268 with_publish_epoch(
1269 ticket.publish_epoch.clone(),
1270 ticket.expected_publish_epoch,
1271 || {
1272 with_refresh_commit_admission(
1273 ticket.lifecycle.clone(),
1274 Arc::clone(&ticket.generation_flag),
1275 ticket.expected_generation,
1276 || {
1277 store
1278 .refresh_files_with_workspace_crate_prefix_cache(
1279 &paths,
1280 workspace_crate_prefix_cache.clone(),
1281 )
1282 .map(|_| ())
1283 },
1284 )
1285 },
1286 )
1287 } else {
1288 store
1289 .refresh_files_with_workspace_crate_prefix_cache(
1290 &paths,
1291 workspace_crate_prefix_cache.clone(),
1292 )
1293 .map(|_| ())
1294 };
1295 if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
1296 batch.defer();
1300 return Some(store);
1301 }
1302 if let Err(error) = refresh_result {
1303 crate::slog_warn!("callgraph store refresh failed: {}", error);
1304 match store.mark_files_stale(&paths) {
1305 Ok(marked) => {
1306 note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
1307 crate::slog_warn!(
1308 "marked {} callgraph store file(s) stale after refresh failure",
1309 marked.len()
1310 );
1311 }
1312 Err(mark_error) => crate::slog_warn!(
1313 "failed to mark callgraph store files stale after refresh failure: {}",
1314 mark_error
1315 ),
1316 }
1317 } else {
1318 crate::logging::note_callgraph_invalidations(paths.len());
1319 }
1320 Some(store)
1321}
1322
1323fn workspace_crate_prefix_cache_for_root(
1324 caches: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1325 root: &RefreshRoot,
1326) -> WorkspaceCratePrefixCache {
1327 if !caches.contains_key(root) && caches.len() >= REFRESH_WORKSPACE_CACHE_ROOT_CAP {
1328 if let Some(evicted) = caches.keys().next().cloned() {
1330 caches.remove(&evicted);
1331 }
1332 }
1333 caches.entry(root.clone()).or_default().clone()
1334}
1335
1336#[derive(Clone, Copy, Default)]
1337struct RefreshWorkerTestSeam {
1338 delay: Duration,
1339 fail_refresh: bool,
1340 fail_open: bool,
1341 refresh_calls: usize,
1342 worker_calls: usize,
1343 stale_marks: usize,
1344}
1345
1346static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
1347 OnceLock::new();
1348
1349struct RefreshWorkerTestGate {
1350 held_tx: crossbeam_channel::Sender<()>,
1351 release_rx: crossbeam_channel::Receiver<()>,
1352}
1353
1354static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
1355 OnceLock::new();
1356
1357#[doc(hidden)]
1358pub fn install_callgraph_refresh_worker_test_gate(
1359 project_root: PathBuf,
1360) -> (
1361 crossbeam_channel::Receiver<()>,
1362 crossbeam_channel::Sender<()>,
1363) {
1364 let (held_tx, held_rx) = crossbeam_channel::bounded(1);
1365 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1366 REFRESH_WORKER_TEST_GATES
1367 .get_or_init(|| Mutex::new(HashMap::new()))
1368 .lock()
1369 .expect("callgraph refresh test gate mutex poisoned")
1370 .insert(
1371 project_root,
1372 RefreshWorkerTestGate {
1373 held_tx,
1374 release_rx,
1375 },
1376 );
1377 (held_rx, release_tx)
1378}
1379
1380fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
1381 REFRESH_WORKER_TEST_GATES
1382 .get_or_init(|| Mutex::new(HashMap::new()))
1383 .lock()
1384 .expect("callgraph refresh test gate mutex poisoned")
1385 .remove(project_root)
1386}
1387
1388fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
1389 let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
1390 return RefreshWorkerTestSeam::default();
1391 };
1392 seams
1393 .lock()
1394 .expect("callgraph refresh test seam mutex poisoned")
1395 .get(project_root)
1396 .copied()
1397 .unwrap_or_default()
1398}
1399
1400fn note_refresh_worker_batch_for_test(project_root: &Path) {
1401 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1402 if let Some(seam) = seams
1403 .lock()
1404 .expect("callgraph refresh test seam mutex poisoned")
1405 .get_mut(project_root)
1406 {
1407 seam.worker_calls += 1;
1408 }
1409 }
1410}
1411
1412fn note_refresh_worker_call_for_test(project_root: &Path) {
1413 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1414 if let Some(seam) = seams
1415 .lock()
1416 .expect("callgraph refresh test seam mutex poisoned")
1417 .get_mut(project_root)
1418 {
1419 seam.refresh_calls += 1;
1420 }
1421 }
1422}
1423
1424fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
1425 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1426 if let Some(seam) = seams
1427 .lock()
1428 .expect("callgraph refresh test seam mutex poisoned")
1429 .get_mut(project_root)
1430 {
1431 seam.stale_marks += 1;
1432 }
1433 }
1434}
1435
1436#[doc(hidden)]
1437pub fn set_callgraph_refresh_worker_test_seam(
1438 project_root: PathBuf,
1439 delay: Duration,
1440 fail_refresh: bool,
1441) {
1442 REFRESH_WORKER_TEST_SEAMS
1443 .get_or_init(|| Mutex::new(HashMap::new()))
1444 .lock()
1445 .expect("callgraph refresh test seam mutex poisoned")
1446 .insert(
1447 project_root,
1448 RefreshWorkerTestSeam {
1449 delay,
1450 fail_refresh,
1451 ..RefreshWorkerTestSeam::default()
1452 },
1453 );
1454}
1455
1456#[doc(hidden)]
1457pub fn set_callgraph_refresh_worker_test_open_failure(project_root: PathBuf, enabled: bool) {
1458 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1459 if let Some(seam) = seams
1460 .lock()
1461 .expect("callgraph refresh test seam mutex poisoned")
1462 .get_mut(&project_root)
1463 {
1464 seam.fail_open = enabled;
1465 }
1466 }
1467}
1468
1469#[doc(hidden)]
1470pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
1471 let seam = refresh_worker_test_seam(project_root);
1472 (seam.refresh_calls, seam.stale_marks)
1473}
1474
1475#[doc(hidden)]
1476pub fn callgraph_refresh_worker_test_worker_calls(project_root: &Path) -> usize {
1477 refresh_worker_test_seam(project_root).worker_calls
1478}
1479
1480#[doc(hidden)]
1481pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
1482 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1483 seams
1484 .lock()
1485 .expect("callgraph refresh test seam mutex poisoned")
1486 .remove(project_root);
1487 }
1488}
1489
1490#[derive(Debug)]
1491pub struct CallGraphStore {
1492 project_root: PathBuf,
1493 project_key: String,
1494 sqlite_path: PathBuf,
1498 publication_dir: PathBuf,
1502 legacy_fallback: bool,
1506 generation: Option<String>,
1511 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1512 read_marker: Option<crate::root_cache::ReadMarker>,
1513 database_ready: AtomicBool,
1516 write_metrics: Arc<CallgraphWriteMetrics>,
1517 conn: Mutex<Connection>,
1518}
1519
1520#[derive(Debug)]
1521pub struct ReadonlyCallGraphStore {
1522 inner: CallGraphStore,
1523}
1524
1525pub trait CallGraphRead {
1526 fn project_root(&self) -> &Path;
1527 fn project_key(&self) -> &str;
1528 fn sqlite_path(&self) -> &Path;
1529 fn is_current(&self) -> bool;
1530 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
1531 fn indexed_file_count(&self) -> Result<usize>;
1532 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
1533 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
1534 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
1535 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
1536 fn direct_callers_for_symbols(
1537 &self,
1538 targets: &[(String, String)],
1539 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
1540 targets
1541 .iter()
1542 .cloned()
1543 .map(|target| {
1544 let callers = self.direct_callers_of(Path::new(&target.0), &target.1)?;
1545 Ok((target, callers))
1546 })
1547 .collect()
1548 }
1549 fn direct_caller_counts_of(
1550 &self,
1551 targets: &[(String, String)],
1552 ) -> Result<HashMap<(String, String), usize>>;
1553 fn outgoing_calls_for_symbols(
1554 &self,
1555 sources: &[(String, String)],
1556 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
1557 fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
1558 -> Result<StoreCallersResult>;
1559 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
1560 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1561 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1562 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
1563 fn call_tree(
1564 &self,
1565 file_rel: &Path,
1566 symbol: &str,
1567 depth: usize,
1568 ) -> Result<callgraph::CallTreeNode>;
1569 fn trace_to(
1570 &self,
1571 file_rel: &Path,
1572 symbol: &str,
1573 max_depth: usize,
1574 ) -> Result<callgraph::TraceToResult>;
1575 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
1576 fn trace_to_symbol(
1577 &self,
1578 file_rel: &Path,
1579 symbol: &str,
1580 to_symbol: &str,
1581 to_file: Option<&Path>,
1582 max_depth: usize,
1583 ) -> Result<callgraph::TraceToSymbolResult>;
1584}
1585
1586#[derive(Debug, Clone, PartialEq, Eq)]
1587enum OpenRootRepair {
1588 None,
1589 ReRooted,
1590 NeedsRebuild {
1591 previous_roots: Vec<String>,
1592 current_root: String,
1593 reason: String,
1594 },
1595}
1596
1597struct OpenedStore {
1598 store: CallGraphStore,
1599 root_repair: OpenRootRepair,
1600}
1601
1602#[derive(Clone, Debug)]
1603struct LegacyCallgraphPartition {
1604 harness: String,
1605 dir: PathBuf,
1606 key: String,
1607 bytes: u64,
1608 freshness: Option<SystemTime>,
1609}
1610
1611#[derive(Clone, Debug)]
1612struct LegacyCallgraphTarget {
1613 partition: LegacyCallgraphPartition,
1614 sqlite_path: PathBuf,
1615 generation: Option<String>,
1616 source_bytes: u64,
1617 source_blake3: String,
1618}
1619
1620#[derive(Clone, Debug)]
1621struct SourceFingerprint {
1622 bytes: u64,
1623 blake3: String,
1624}
1625
1626#[derive(Clone, Debug)]
1627struct PublishedLegacyMigration {
1628 generation: String,
1629 migrated_bytes: u64,
1630}
1631
1632#[derive(Debug, Clone)]
1633pub struct ColdBuildStats {
1634 pub files: usize,
1635 pub nodes: usize,
1636 pub refs: usize,
1637 pub edges: usize,
1638 pub failed_files: Vec<String>,
1639 pub elapsed_ms: u128,
1640}
1641
1642#[derive(Debug, Clone)]
1643pub struct IncrementalStats {
1644 pub changed_files: Vec<String>,
1645 pub surface_changed: Vec<String>,
1646 pub deleted_files: Vec<String>,
1647 pub dependency_selected_refs: usize,
1648 pub refreshed_own_files: usize,
1649 pub unchanged_extract_files: usize,
1650}
1651
1652#[doc(hidden)]
1654#[derive(Debug, Clone, Default, PartialEq, Eq)]
1655pub struct RefreshFilesProfile {
1656 pub parse: Duration,
1657 pub dependency_selection: Duration,
1658 pub row_deletes: Duration,
1659 pub row_inserts: Duration,
1660 pub dependent_parse: Duration,
1661 pub index_load: Duration,
1662 pub ref_resolution: Duration,
1663 pub method_dispatch: Duration,
1664 pub commit: Duration,
1665 pub total: Duration,
1666}
1667
1668impl RefreshFilesProfile {
1669 pub fn report(&self) -> String {
1670 format!(
1671 "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",
1672 self.parse.as_millis(),
1673 self.dependency_selection.as_millis(),
1674 self.row_deletes.as_millis(),
1675 self.row_inserts.as_millis(),
1676 self.dependent_parse.as_millis(),
1677 self.index_load.as_millis(),
1678 self.ref_resolution.as_millis(),
1679 self.method_dispatch.as_millis(),
1680 self.commit.as_millis(),
1681 self.total.as_millis(),
1682 )
1683 }
1684}
1685
1686#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1687pub struct StoredEdge {
1688 pub source_file: String,
1689 pub source_symbol: String,
1690 pub target_file: String,
1691 pub target_symbol: String,
1692 pub kind: String,
1693 pub line: u32,
1694}
1695
1696#[derive(Debug, Clone, PartialEq, Eq)]
1697pub struct StoreNode {
1698 node_id: String,
1699 pub file: String,
1700 pub symbol: String,
1701 pub name: String,
1702 pub kind: String,
1703 pub line: u32,
1704 pub end_line: u32,
1705 pub signature: Option<String>,
1706 pub exported: bool,
1707 pub is_entry_point: bool,
1708 pub lang: LangId,
1709}
1710
1711#[cfg(test)]
1712impl StoreNode {
1713 pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
1714 Self {
1715 node_id: format!("{file}:{symbol}"),
1716 file: file.to_string(),
1717 symbol: symbol.to_string(),
1718 name: symbol.to_string(),
1719 kind: "function".to_string(),
1720 line: 1,
1721 end_line: 1,
1722 signature: None,
1723 exported: is_entry_point,
1724 is_entry_point,
1725 lang: LangId::TypeScript,
1726 }
1727 }
1728}
1729
1730#[derive(Debug, Clone, PartialEq, Eq)]
1731pub struct StoreCallSite {
1732 pub caller: StoreNode,
1733 pub target_file: String,
1734 pub target_symbol: String,
1735 pub target: Option<StoreNode>,
1736 pub line: u32,
1737 pub byte_start: usize,
1738 pub byte_end: usize,
1739 pub resolved: bool,
1740 pub provenance: String,
1741}
1742
1743impl StoreCallSite {
1744 pub fn approximate(&self) -> bool {
1745 self.provenance == PROVENANCE_NAME_MATCH
1746 }
1747
1748 pub fn resolved_by(&self) -> &str {
1749 &self.provenance
1750 }
1751
1752 pub fn supplemental_resolution(&self) -> Option<&str> {
1753 match self.provenance.as_str() {
1754 PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
1755 _ => None,
1756 }
1757 }
1758}
1759
1760#[derive(Debug, Clone, PartialEq, Eq)]
1761pub struct StoreUnresolvedCall {
1762 pub caller: StoreNode,
1763 pub symbol: String,
1764 pub full_ref: Option<String>,
1765 pub line: u32,
1766 pub byte_start: usize,
1767 pub byte_end: usize,
1768}
1769
1770#[derive(Debug, Clone, PartialEq, Eq)]
1771pub struct StoreCallersResult {
1772 pub target: StoreNode,
1773 pub callers: Vec<StoreCallSite>,
1774 pub scanned_files: usize,
1775 pub depth_limited: bool,
1776 pub truncated: usize,
1777}
1778
1779#[derive(Debug, Clone, PartialEq, Eq)]
1780pub struct StoreImpactCaller {
1781 pub site: StoreCallSite,
1782 pub signature: Option<String>,
1783 pub is_entry_point: bool,
1784 pub call_expression: Option<String>,
1785 pub parameters: Vec<String>,
1786}
1787
1788#[derive(Debug, Clone, PartialEq, Eq)]
1789pub struct StoreImpactResult {
1790 pub target: StoreNode,
1791 pub parameters: Vec<String>,
1792 pub callers: Vec<StoreImpactCaller>,
1793 pub depth_limited: bool,
1794 pub truncated: usize,
1795}
1796
1797#[derive(Debug, Clone)]
1798struct ExtractFailure {
1799 rel_path: String,
1800 freshness: Option<FileFreshness>,
1801}
1802
1803#[derive(Debug, Clone)]
1804struct BuildExtractsResult {
1805 extracts: Vec<FileExtract>,
1806 failures: Vec<ExtractFailure>,
1807}
1808
1809#[derive(Debug, Clone)]
1810enum StoreForwardCall {
1811 Resolved(StoreCallSite),
1812 Unresolved(StoreUnresolvedCall),
1813}
1814
1815impl StoreForwardCall {
1816 fn byte_start(&self) -> usize {
1817 match self {
1818 Self::Resolved(site) => site.byte_start,
1819 Self::Unresolved(call) => call.byte_start,
1820 }
1821 }
1822
1823 fn line(&self) -> u32 {
1824 match self {
1825 Self::Resolved(site) => site.line,
1826 Self::Unresolved(call) => call.line,
1827 }
1828 }
1829}
1830
1831#[derive(Debug, Clone)]
1832struct FileExtract {
1833 rel_path: String,
1834 freshness: FileFreshness,
1835 lang: LangId,
1836 data: FileCallData,
1837 nodes: Vec<NodeRecord>,
1838 raw_refs: Vec<RawRef>,
1839 dispatch_hints: Vec<DispatchHint>,
1840 surface_fingerprint: String,
1841}
1842
1843#[derive(Debug, Clone)]
1844struct NodeRecord {
1845 id: String,
1846 file_path: String,
1847 name: String,
1848 scoped_name: String,
1849 kind: String,
1850 range: Range,
1851 range_ordinal: u32,
1852 signature: Option<String>,
1853 exported: bool,
1854 is_default_export: bool,
1855 is_type_like: bool,
1856 is_callgraph_entry_point: bool,
1857}
1858
1859#[derive(Debug, Clone)]
1860struct RawRef {
1861 ref_id: String,
1862 caller_node: Option<String>,
1863 caller_symbol: Option<String>,
1864 caller_file: String,
1865 kind: String,
1866 short_name: Option<String>,
1867 full_ref: Option<String>,
1868 module_path: Option<String>,
1869 import_kind: Option<String>,
1870 local_name: Option<String>,
1871 requested_name: Option<String>,
1872 namespace_alias: Option<String>,
1873 wildcard: bool,
1874 line: u32,
1875 byte_start: usize,
1876 byte_end: usize,
1877 dependencies: BTreeSet<String>,
1878}
1879
1880#[derive(Debug, Clone)]
1881struct ResolvedRef {
1882 raw: RawRef,
1883 status: String,
1884 target_node: Option<String>,
1885 target_file: Option<String>,
1886 target_symbol: Option<String>,
1887 dependencies: BTreeSet<String>,
1888 edge: Option<EdgeRecord>,
1889}
1890
1891#[derive(Debug, Clone)]
1892struct EdgeRecord {
1893 edge_id: String,
1894 source_node: String,
1895 target_node: Option<String>,
1896 target_file: String,
1897 target_symbol: String,
1898 kind: String,
1899 line: u32,
1900}
1901
1902#[derive(Debug, Clone)]
1903struct DispatchHint {
1904 id: String,
1905 method_name: String,
1906 caller_node: String,
1907 file: String,
1908 line: u32,
1909 byte_start: usize,
1910 byte_end: usize,
1911}
1912
1913#[derive(Debug, Clone)]
1914struct NameMatchRef {
1915 ref_id: String,
1916 caller_node: String,
1917 caller_file: String,
1918 caller_symbol: String,
1919 caller_signature: Option<String>,
1920 receiver_expression: String,
1921 receiver: String,
1922 method_name: String,
1923 colon_dispatch: bool,
1924 line: u32,
1925 lang: String,
1926}
1927
1928#[derive(Debug, Clone)]
1929struct NameMatchCandidate {
1930 node_id: String,
1931 file_path: String,
1932 scoped_name: String,
1933 kind: String,
1934 start_line: u32,
1936}
1937
1938#[derive(Debug, Clone)]
1939struct FileRow {
1940 surface_fingerprint: String,
1941 freshness: FileFreshness,
1942}
1943
1944#[derive(Debug, Clone)]
1945struct DbFileIndex {
1946 lang: Option<LangId>,
1947 exports: HashSet<String>,
1948 default_export: Option<String>,
1949 export_aliases: HashMap<String, String>,
1950 node_by_scoped: HashMap<String, String>,
1951 node_by_bare: HashMap<String, String>,
1952 node_kind_by_id: HashMap<String, String>,
1953 module_targets: HashMap<String, Option<String>>,
1954 reexports: Vec<ReexportIndex>,
1955}
1956
1957#[derive(Debug, Clone)]
1958struct ReexportIndex {
1959 target_file: Option<String>,
1960 named: HashMap<String, String>,
1961 wildcard: bool,
1962}
1963
1964#[derive(Debug, Clone)]
1965struct ProjectIndex<'a> {
1966 project_root: PathBuf,
1967 files: HashMap<String, DbFileIndex>,
1968 caller_data: HashMap<String, &'a FileCallData>,
1969 workspace_crate_prefixes: WorkspaceCratePrefixCache,
1974}
1975
1976impl ProjectIndex<'_> {
1977 fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
1980 self.workspace_crate_prefixes
1981 .0
1982 .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
1983 .get(crate_name)
1984 .cloned()
1985 }
1986}
1987
1988impl CallGraphStore {
1989 pub fn open_if_enabled(
1990 options: CallGraphStoreOptions,
1991 callgraph_dir: PathBuf,
1992 project_root: PathBuf,
1993 ) -> Result<Option<Self>> {
1994 if !options.enabled {
1995 return Ok(None);
1996 }
1997 Self::open(callgraph_dir, project_root).map(Some)
1998 }
1999
2000 pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
2001 let project_key = crate::search_index::artifact_cache_key(&project_root);
2002 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2003 else {
2004 return Err(CallGraphStoreError::Unavailable(
2005 "writer capability denied; use the read-only callgraph opener".to_string(),
2006 ));
2007 };
2008 std::fs::create_dir_all(&callgraph_dir)?;
2009 let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
2013 .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
2014 let OpenedStore { store, root_repair } = Self::open_at_path(
2015 project_root.clone(),
2016 project_key,
2017 sqlite_path,
2018 generation,
2019 true,
2020 Some(Arc::clone(&writer_lease)),
2021 None,
2022 )?;
2023 match root_repair {
2024 OpenRootRepair::NeedsRebuild { .. } => {
2025 log_root_repair_rebuild(&root_repair);
2026 drop(store);
2027 drop(writer_lease);
2028 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2029 let (store, _stats) =
2030 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2031 Ok(store)
2032 }
2033 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
2034 }
2035 }
2036
2037 pub fn open_readonly(
2038 callgraph_dir: PathBuf,
2039 project_root: PathBuf,
2040 ) -> Result<Option<ReadonlyCallGraphStore>> {
2041 let project_key = crate::search_index::artifact_cache_key(&project_root);
2042 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2043 {
2044 let conn = open_readonly_connection(&sqlite_path)?;
2045 if !database_ready(&conn).unwrap_or(false) {
2046 return Ok(None);
2047 }
2048 let marker_label = generation.as_deref().unwrap_or("legacy");
2049 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
2050 return Ok(Some(ReadonlyCallGraphStore::from_inner(
2051 Self::from_connection(
2052 project_root,
2053 project_key,
2054 sqlite_path,
2055 callgraph_dir,
2056 false,
2057 generation,
2058 None,
2059 Some(read_marker),
2060 conn,
2061 ),
2062 )));
2063 }
2064
2065 let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
2066 return Ok(None);
2067 };
2068 crate::slog_warn!(
2069 "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
2070 target.partition.harness,
2071 target.sqlite_path.display()
2072 );
2073 let conn = open_readonly_connection(&target.sqlite_path)?;
2074 if !database_ready(&conn).unwrap_or(false) {
2075 return Ok(None);
2076 }
2077 let marker_label =
2078 legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
2079 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
2080 Ok(Some(ReadonlyCallGraphStore::from_inner(
2081 Self::from_connection(
2082 project_root,
2083 project_key,
2084 target.sqlite_path,
2085 callgraph_dir,
2086 true,
2087 target.generation,
2088 None,
2089 Some(read_marker),
2090 conn,
2091 ),
2092 )))
2093 }
2094
2095 pub fn open_ready_repairing(
2101 callgraph_dir: PathBuf,
2102 project_root: PathBuf,
2103 ) -> Result<Option<Self>> {
2104 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true)
2105 }
2106
2107 pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
2111 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false)
2112 }
2113
2114 pub fn open_ready_no_rebuild(
2115 callgraph_dir: PathBuf,
2116 project_root: PathBuf,
2117 ) -> Result<Option<Self>> {
2118 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true)
2119 }
2120
2121 fn open_ready_with_rebuild_policy(
2122 callgraph_dir: PathBuf,
2123 project_root: PathBuf,
2124 allow_cold_build: bool,
2125 allow_root_repair: bool,
2126 ) -> Result<Option<Self>> {
2127 let project_key = crate::search_index::artifact_cache_key(&project_root);
2128 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2129 else {
2130 return Ok(None);
2131 };
2132 let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2133 else {
2134 return Ok(None);
2135 };
2136 let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
2137 project_root.clone(),
2138 project_key.clone(),
2139 sqlite_path,
2140 generation,
2141 true,
2142 Some(Arc::clone(&writer_lease)),
2143 None,
2144 allow_root_repair,
2145 )?;
2146 match root_repair {
2147 OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
2148 log_root_repair_rebuild(&root_repair);
2149 drop(store);
2150 drop(writer_lease);
2151 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2152 let (store, _stats) =
2153 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2154 Ok(Some(store))
2155 }
2156 OpenRootRepair::NeedsRebuild { .. } => {
2157 if let Some(message) = note_repair_entry(&project_key) {
2158 crate::slog_warn!("{message}");
2159 }
2160 Ok(None)
2161 }
2162 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2163 }
2164 }
2165
2166 pub fn cold_build_with_lease(
2167 callgraph_dir: PathBuf,
2168 project_root: PathBuf,
2169 files: &[PathBuf],
2170 ) -> Result<(Self, ColdBuildStats)> {
2171 Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
2172 }
2173
2174 pub fn cold_build_with_lease_chunked(
2175 callgraph_dir: PathBuf,
2176 project_root: PathBuf,
2177 files: &[PathBuf],
2178 chunk_size: usize,
2179 ) -> Result<(Self, ColdBuildStats)> {
2180 Self::cold_build_with_lease_chunked_inner(
2181 callgraph_dir,
2182 project_root,
2183 files,
2184 chunk_size,
2185 false,
2186 )
2187 }
2188
2189 pub(crate) fn force_cold_build_with_lease_chunked(
2190 callgraph_dir: PathBuf,
2191 project_root: PathBuf,
2192 files: &[PathBuf],
2193 chunk_size: usize,
2194 ) -> Result<(Self, ColdBuildStats)> {
2195 Self::cold_build_with_lease_chunked_inner(
2196 callgraph_dir,
2197 project_root,
2198 files,
2199 chunk_size,
2200 true,
2201 )
2202 }
2203
2204 fn cold_build_with_lease_chunked_inner(
2205 callgraph_dir: PathBuf,
2206 project_root: PathBuf,
2207 files: &[PathBuf],
2208 chunk_size: usize,
2209 require_new_publication: bool,
2210 ) -> Result<(Self, ColdBuildStats)> {
2211 let project_key = crate::search_index::artifact_cache_key(&project_root);
2212 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2213 else {
2214 let operation = if require_new_publication {
2215 "forced rebuild"
2216 } else {
2217 "cold build"
2218 };
2219 return Err(CallGraphStoreError::Unavailable(format!(
2220 "{operation} could not acquire writer capability"
2221 )));
2222 };
2223 std::fs::create_dir_all(&callgraph_dir)?;
2224 let (stats, generation) = Self::cold_build_publish_locked(
2225 &callgraph_dir,
2226 &project_root,
2227 &project_key,
2228 files,
2229 chunk_size,
2230 Arc::clone(&writer_lease),
2231 )?;
2232 let store = Self::open_generation(
2233 &callgraph_dir,
2234 project_root,
2235 project_key,
2236 generation,
2237 writer_lease,
2238 )?;
2239 Ok((store, stats))
2240 }
2241
2242 pub fn ensure_built_with_lease(
2243 callgraph_dir: PathBuf,
2244 project_root: PathBuf,
2245 files: &[PathBuf],
2246 ) -> Result<(Self, Option<ColdBuildStats>)> {
2247 Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
2248 }
2249
2250 pub fn ensure_built_with_lease_chunked(
2251 callgraph_dir: PathBuf,
2252 project_root: PathBuf,
2253 files: &[PathBuf],
2254 chunk_size: usize,
2255 ) -> Result<(Self, Option<ColdBuildStats>)> {
2256 let project_key = crate::search_index::artifact_cache_key(&project_root);
2257 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2258 else {
2259 return Err(CallGraphStoreError::Unavailable(
2260 "callgraph ensure could not acquire writer capability".to_string(),
2261 ));
2262 };
2263 std::fs::create_dir_all(&callgraph_dir)?;
2264 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2265 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2272 {
2273 let OpenedStore { store, root_repair } = Self::open_at_path(
2274 project_root.clone(),
2275 project_key.clone(),
2276 sqlite_path,
2277 generation,
2278 true,
2279 Some(Arc::clone(&writer_lease)),
2280 None,
2281 )?;
2282 match root_repair {
2283 OpenRootRepair::NeedsRebuild { .. } => {
2284 log_root_repair_rebuild(&root_repair);
2285 drop(store);
2286 let (stats, generation) = Self::cold_build_publish_locked(
2287 &callgraph_dir,
2288 &project_root,
2289 &project_key,
2290 files,
2291 chunk_size,
2292 Arc::clone(&writer_lease),
2293 )?;
2294 let store = Self::open_generation(
2295 &callgraph_dir,
2296 project_root,
2297 project_key,
2298 generation,
2299 writer_lease,
2300 )?;
2301 return Ok((store, Some(stats)));
2302 }
2303 OpenRootRepair::None | OpenRootRepair::ReRooted => {
2304 return Ok((store, None));
2305 }
2306 }
2307 }
2308 if let Some(store) = try_legacy_migration_or_fallback(
2309 &callgraph_dir,
2310 &project_root,
2311 &project_key,
2312 Arc::clone(&writer_lease),
2313 )? {
2314 return Ok((store, None));
2315 }
2316 let (stats, generation) = Self::cold_build_publish_locked(
2317 &callgraph_dir,
2318 &project_root,
2319 &project_key,
2320 files,
2321 chunk_size,
2322 Arc::clone(&writer_lease),
2323 )?;
2324 let store = Self::open_generation(
2325 &callgraph_dir,
2326 project_root,
2327 project_key,
2328 generation,
2329 writer_lease,
2330 )?;
2331 Ok((store, Some(stats)))
2332 }
2333
2334 pub fn migrate_legacy_with_lease(
2341 callgraph_dir: PathBuf,
2342 project_root: PathBuf,
2343 ) -> Result<Option<Self>> {
2344 let project_key = crate::search_index::artifact_cache_key(&project_root);
2345 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2346 else {
2347 return Ok(None);
2348 };
2349 std::fs::create_dir_all(&callgraph_dir)?;
2350 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2351
2352 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2356 {
2357 let OpenedStore { store, root_repair } = Self::open_at_path(
2358 project_root,
2359 project_key,
2360 sqlite_path,
2361 generation,
2362 true,
2363 Some(writer_lease),
2364 None,
2365 )?;
2366 return match root_repair {
2367 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2368 OpenRootRepair::NeedsRebuild { reason, .. } => {
2369 Err(CallGraphStoreError::Unavailable(format!(
2370 "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
2371 )))
2372 }
2373 };
2374 }
2375
2376 let store = try_legacy_migration_or_fallback(
2377 &callgraph_dir,
2378 &project_root,
2379 &project_key,
2380 writer_lease,
2381 )?;
2382 Ok(store.filter(|store| !store.is_legacy_fallback()))
2386 }
2387
2388 fn cold_build_publish_locked(
2399 callgraph_dir: &Path,
2400 project_root: &Path,
2401 project_key: &str,
2402 files: &[PathBuf],
2403 chunk_size: usize,
2404 writer_lease: Arc<crate::root_cache::WriterLease>,
2405 ) -> Result<(ColdBuildStats, String)> {
2406 if let Some((previous_root, remaining)) =
2407 rebuild_cooldown_denial(callgraph_dir, project_key, project_root, Instant::now())
2408 {
2409 return Err(CallGraphStoreError::Unavailable(format!(
2410 "cache key {project_key} was rebuilt for {} too recently; retry {} ms after the per-key cooldown",
2411 previous_root.display(),
2412 remaining.as_millis()
2413 )));
2414 }
2415 let generation = generation_file_name(project_key);
2416 let gen_path = callgraph_dir.join(&generation);
2417 let temp_path = callgraph_dir.join(format!(
2418 "{generation}.tmp.{}.{}",
2419 std::process::id(),
2420 now_nanos()
2421 ));
2422 remove_sqlite_file_set(&temp_path);
2423
2424 let stats = {
2425 let temp_store = Self::open_at_path(
2426 project_root.to_path_buf(),
2427 project_key.to_string(),
2428 temp_path.clone(),
2429 None,
2430 false,
2431 Some(Arc::clone(&writer_lease)),
2432 None,
2433 )?
2434 .store;
2435 let stats = temp_store.cold_build_chunked(files, chunk_size)?;
2436 let _ = temp_store.checkpoint_wal_truncate();
2437 temp_store.prepare_for_atomic_swap()?;
2438 stats
2439 };
2440
2441 notify_cold_build_before_publish_observer();
2442 let publication = publish_if_current(|| {
2443 verify_writer_lease(&writer_lease)?;
2444 remove_sqlite_file_set(&gen_path);
2447 crate::fs_lock::rename_over(&temp_path, &gen_path)?;
2448 crate::fs_lock::sync_parent(&gen_path);
2449 remove_sqlite_sidecars(&gen_path);
2450
2451 notify_cold_build_swap_observer(&temp_path, &gen_path);
2452
2453 verify_writer_lease(&writer_lease)?;
2455 publish_pointer(callgraph_dir, project_key, &generation)?;
2456 gc_old_generations(callgraph_dir, project_key, &generation);
2457 sweep_orphaned_build_temps_store_wide(callgraph_dir);
2461 if let Some(storage_root) = root_storage_dir(callgraph_dir) {
2462 let inspect_root =
2463 storage_root.join(crate::root_cache::RootCacheDomain::Inspect.as_str());
2464 let live_scope_keys = crate::root_cache::live_scope_keys_for_storage(&storage_root);
2465 crate::inspect::cache::sweep_inspect_scope_dirs(&inspect_root, &live_scope_keys);
2466 }
2467 Ok(())
2468 });
2469 if matches!(publication, Err(CallGraphStoreError::Superseded)) {
2470 remove_sqlite_file_set(&temp_path);
2471 }
2472 publication?;
2473 record_successful_rebuild(callgraph_dir, project_key, project_root, Instant::now());
2474 Ok((stats, generation))
2475 }
2476
2477 fn open_generation(
2480 callgraph_dir: &Path,
2481 project_root: PathBuf,
2482 project_key: String,
2483 generation: String,
2484 writer_lease: Arc<crate::root_cache::WriterLease>,
2485 ) -> Result<Self> {
2486 let gen_path = callgraph_dir.join(&generation);
2487 Ok(Self::open_at_path(
2488 project_root,
2489 project_key,
2490 gen_path,
2491 Some(generation),
2492 true,
2493 Some(writer_lease),
2494 None,
2495 )?
2496 .store)
2497 }
2498
2499 pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
2500 let project_key = crate::search_index::artifact_cache_key(project_root);
2501 Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
2504 }
2505
2506 fn open_at_path(
2507 project_root: PathBuf,
2508 project_key: String,
2509 sqlite_path: PathBuf,
2510 generation: Option<String>,
2511 use_wal: bool,
2512 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2513 read_marker: Option<crate::root_cache::ReadMarker>,
2514 ) -> Result<OpenedStore> {
2515 Self::open_at_path_with_root_repair(
2516 project_root,
2517 project_key,
2518 sqlite_path,
2519 generation,
2520 use_wal,
2521 writer_lease,
2522 read_marker,
2523 true,
2524 )
2525 }
2526
2527 fn open_at_path_with_root_repair(
2528 project_root: PathBuf,
2529 project_key: String,
2530 sqlite_path: PathBuf,
2531 generation: Option<String>,
2532 use_wal: bool,
2533 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2534 read_marker: Option<crate::root_cache::ReadMarker>,
2535 allow_root_repair: bool,
2536 ) -> Result<OpenedStore> {
2537 if let Some(lease) = writer_lease.as_ref() {
2538 verify_writer_lease(lease)?;
2539 }
2540 if let Some(parent) = sqlite_path.parent() {
2541 std::fs::create_dir_all(parent)?;
2542 }
2543 let mut conn = Connection::open(&sqlite_path)?;
2544 if use_wal {
2545 configure_connection(&conn)?;
2546 } else {
2547 configure_build_connection(&conn)?;
2548 }
2549 if let Some(lease) = writer_lease.as_ref() {
2550 verify_writer_lease(lease)?;
2551 }
2552 initialize_schema(&conn)?;
2553 if let Some(lease) = writer_lease.as_ref() {
2554 verify_writer_lease(lease)?;
2555 }
2556 let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
2557 let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
2558 (Some(marker), _, _) => Some(marker),
2559 (None, Some(label), Some(cache_dir)) => {
2560 Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
2561 }
2562 (None, _, _) => None,
2563 };
2564 let publication_dir = sqlite_path
2565 .parent()
2566 .map(Path::to_path_buf)
2567 .unwrap_or_default();
2568 let store = Self::from_connection(
2569 project_root,
2570 project_key,
2571 sqlite_path,
2572 publication_dir,
2573 false,
2574 generation,
2575 writer_lease,
2576 read_marker,
2577 conn,
2578 );
2579 Ok(OpenedStore { store, root_repair })
2580 }
2581
2582 fn prepare_for_atomic_swap(&self) -> Result<()> {
2583 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2584 conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
2585 Ok(())
2586 }
2587
2588 fn atomic_swap_checkpoint_sql(&self) -> &'static str {
2589 let protected_reader = self.generation.as_deref().is_some_and(|generation| {
2590 self.sqlite_path
2591 .parent()
2592 .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
2593 });
2594 if protected_reader {
2595 "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
2596 } else {
2597 "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
2598 }
2599 }
2600
2601 fn from_connection(
2602 project_root: PathBuf,
2603 project_key: String,
2604 sqlite_path: PathBuf,
2605 publication_dir: PathBuf,
2606 legacy_fallback: bool,
2607 generation: Option<String>,
2608 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2609 read_marker: Option<crate::root_cache::ReadMarker>,
2610 conn: Connection,
2611 ) -> Self {
2612 let write_metrics = callgraph_write_metrics_for_key(&project_key);
2613 Self {
2614 project_root,
2615 project_key,
2616 sqlite_path,
2617 publication_dir,
2618 legacy_fallback,
2619 generation,
2620 writer_lease,
2621 read_marker,
2622 database_ready: AtomicBool::new(false),
2623 write_metrics,
2624 conn: Mutex::new(conn),
2625 }
2626 }
2627
2628 fn ensure_ready(&self, conn: &Connection) -> Result<()> {
2629 if self.database_ready.load(AtomicOrdering::Acquire) {
2630 return Ok(());
2631 }
2632 ensure_database_ready(conn)?;
2633 self.database_ready.store(true, AtomicOrdering::Release);
2634 Ok(())
2635 }
2636
2637 pub fn project_root(&self) -> &Path {
2638 &self.project_root
2639 }
2640
2641 pub fn project_key(&self) -> &str {
2642 &self.project_key
2643 }
2644
2645 pub fn sqlite_path(&self) -> &Path {
2646 &self.sqlite_path
2647 }
2648
2649 pub(crate) fn projection_generation(&self) -> Option<&str> {
2651 self.generation.as_deref()
2652 }
2653
2654 pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
2656 self.refresh_read_marker()?;
2657 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2658 self.ensure_ready(&conn)?;
2659 projection_write_revision(&conn)
2660 }
2661
2662 pub fn is_legacy_fallback(&self) -> bool {
2665 self.legacy_fallback
2666 }
2667
2668 pub(crate) fn is_legacy_migration(&self) -> bool {
2669 self.generation.as_deref().is_some_and(|generation| {
2670 migration_generation_requires_manifest(generation)
2671 && migration_manifest_valid(&self.publication_dir, generation)
2672 })
2673 }
2674
2675 pub fn writer_epoch_for_test(&self) -> Option<&str> {
2676 self.writer_lease.as_ref().map(|lease| lease.epoch())
2677 }
2678
2679 fn verify_writer_lease(&self) -> Result<()> {
2680 let Some(lease) = self.writer_lease.as_ref() else {
2681 return Err(CallGraphStoreError::Unavailable(
2682 "callgraph store opened read-only; write API is unavailable".to_string(),
2683 ));
2684 };
2685 verify_writer_lease(lease)
2686 }
2687
2688 fn refresh_read_marker(&self) -> Result<()> {
2689 if let Some(marker) = self.read_marker.as_ref() {
2690 marker.touch_if_due()?;
2691 }
2692 Ok(())
2693 }
2694
2695 fn record_commit(&self, total_changes_before: u64, conn: &Connection) {
2696 self.write_metrics
2697 .record_commit(conn.total_changes().saturating_sub(total_changes_before));
2698 }
2699
2700 fn checkpoint_wal_truncate(&self) -> bool {
2701 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2702 checkpoint_wal_truncate(&conn)
2703 }
2704
2705 pub fn is_current(&self) -> bool {
2711 let _ = self.refresh_read_marker();
2712 match (
2713 read_pointer(&self.publication_dir, &self.project_key),
2714 &self.generation,
2715 ) {
2716 (Some(_), _) if self.legacy_fallback => false,
2719 (Some(published), Some(opened)) => &published == opened,
2720 (Some(_), None) => false,
2722 (None, _) => true,
2725 }
2726 }
2727
2728 pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
2729 self.cold_build_chunked(files, 0)
2730 }
2731
2732 pub fn cold_build_chunked(
2733 &self,
2734 files: &[PathBuf],
2735 chunk_size: usize,
2736 ) -> Result<ColdBuildStats> {
2737 let started = Instant::now();
2738 let bench = std::env::var("AFT_BENCH_COLD").is_ok();
2739 macro_rules! phase {
2740 ($label:expr, $t:expr) => {
2741 if bench {
2742 eprintln!(" cold_build[{}]: {} ms", $label, $t.elapsed().as_millis());
2743 let _ = std::io::Write::flush(&mut std::io::stderr());
2744 }
2745 };
2746 }
2747 let files = normalize_file_list(&self.project_root, files)?;
2748
2749 if chunk_size == 0 {
2750 let t = Instant::now();
2751 let build = build_extracts_parallel(&self.project_root, &files);
2752 phase!("extract_parallel", t);
2753 let extracts = build.extracts;
2754 let failures = build.failures;
2755 let node_count = extracts.iter().map(|extract| extract.nodes.len()).sum();
2756
2757 let t = Instant::now();
2758 let index = ProjectIndex::from_extracts(&self.project_root, &extracts);
2759 phase!("build_index", t);
2760 let t = Instant::now();
2761 let mut resolved_refs = Vec::new();
2762 for extract in &extracts {
2763 for raw_ref in &extract.raw_refs {
2764 resolved_refs.push(resolve_ref(raw_ref.clone(), &index)?);
2765 }
2766 }
2767 phase!("resolve_refs", t);
2768 let ref_count = resolved_refs.len();
2769 let edge_count = resolved_refs
2770 .iter()
2771 .filter(|item| item.edge.is_some())
2772 .count();
2773
2774 let t = Instant::now();
2775 self.verify_writer_lease()?;
2776 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2777 let total_changes_before = conn.total_changes();
2778 let tx = conn.transaction()?;
2779 clear_tables(&tx)?;
2780 insert_meta(&tx)?;
2781 drop_cold_build_secondary_indexes(&tx)?;
2782 {
2783 let workspace_root = self.project_root.display().to_string();
2784 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2785 for extract in &extracts {
2786 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
2787 }
2788 for failure in &failures {
2789 insert_backend_state_prepared(
2790 &mut inserts.backend_state,
2791 &workspace_root,
2792 &failure.rel_path,
2793 failure
2794 .freshness
2795 .as_ref()
2796 .map(|freshness| &freshness.content_hash),
2797 "stale",
2798 )?;
2799 }
2800 for resolved in &resolved_refs {
2801 insert_resolved_ref_prepared(&mut inserts, resolved)?;
2802 }
2803 }
2804 create_cold_build_secondary_indexes(&tx)?;
2805 let supplemental_edge_count =
2806 insert_method_dispatch_edges(&tx, &self.project_root, None)?;
2807 set_meta_ready(&tx, true)?;
2808 tx.commit()?;
2809 self.record_commit(total_changes_before, &conn);
2810 phase!("sqlite_insert", t);
2811
2812 let elapsed_ms = started.elapsed().as_millis();
2813 crate::slog_info!(
2814 "perf callgraph_store cold_build: files={} nodes={} refs={} edges={} ms={}",
2815 extracts.len(),
2816 node_count,
2817 ref_count,
2818 edge_count + supplemental_edge_count,
2819 elapsed_ms
2820 );
2821 return Ok(ColdBuildStats {
2822 files: extracts.len(),
2823 nodes: node_count,
2824 refs: ref_count,
2825 edges: edge_count + supplemental_edge_count,
2826 failed_files: failures
2827 .into_iter()
2828 .map(|failure| failure.rel_path)
2829 .collect(),
2830 elapsed_ms,
2831 });
2832 }
2833
2834 let t = Instant::now();
2837 self.verify_writer_lease()?;
2838 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2839 let total_changes_before = conn.total_changes();
2840 let tx = conn.transaction()?;
2841 clear_tables(&tx)?;
2842 insert_meta(&tx)?;
2843 drop_cold_build_secondary_indexes(&tx)?;
2844
2845 let mut all_raw_refs = Vec::new();
2846 let mut failures = Vec::new();
2847 let mut node_count = 0;
2848 let mut files_parsed = 0;
2849
2850 let mut persistent_call_data = Vec::new();
2851 let mut file_to_call_data_index = HashMap::new();
2852 let mut files_index = HashMap::new();
2853
2854 let workspace_root = self.project_root.display().to_string();
2855
2856 {
2857 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2858 for chunk in files.chunks(chunk_size) {
2859 let build = build_extracts_parallel(&self.project_root, chunk);
2860 failures.extend(build.failures.clone());
2861
2862 for extract in build.extracts {
2863 files_parsed += 1;
2864 node_count += extract.nodes.len();
2865 insert_file_extract_prepared(&mut inserts, &workspace_root, &extract)?;
2866
2867 let db_file_index = DbFileIndex::from_extract(&self.project_root, &extract);
2868 files_index.insert(extract.rel_path.clone(), db_file_index);
2869
2870 persistent_call_data.push(extract.data);
2871 let idx = persistent_call_data.len() - 1;
2872 file_to_call_data_index.insert(extract.rel_path.clone(), idx);
2873
2874 all_raw_refs.push((extract.rel_path, extract.raw_refs));
2875 }
2876 for failure in &build.failures {
2877 insert_backend_state_prepared(
2878 &mut inserts.backend_state,
2879 &workspace_root,
2880 &failure.rel_path,
2881 failure
2882 .freshness
2883 .as_ref()
2884 .map(|freshness| &freshness.content_hash),
2885 "stale",
2886 )?;
2887 }
2888 }
2889 }
2890
2891 let mut caller_data = HashMap::new();
2892 for (rel_path, idx) in &file_to_call_data_index {
2893 caller_data.insert(rel_path.clone(), &persistent_call_data[*idx]);
2894 }
2895 let indexed_caller_files = files_index.keys().cloned().collect::<BTreeSet<_>>();
2896 let index = ProjectIndex::from_parts(
2897 &self.project_root,
2898 files_index,
2899 caller_data,
2900 WorkspaceCratePrefixCache::default(),
2901 );
2902
2903 let mut resolved_refs = Vec::new();
2904 for (_, raw_refs) in all_raw_refs {
2905 for raw_ref in raw_refs {
2906 resolved_refs.push(resolve_ref(raw_ref, &index)?);
2907 }
2908 }
2909
2910 let ref_count = resolved_refs.len();
2911 let edge_count = resolved_refs
2912 .iter()
2913 .filter(|item| item.edge.is_some())
2914 .count();
2915
2916 {
2917 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2918 for resolved in &resolved_refs {
2919 insert_resolved_ref_prepared(&mut inserts, resolved)?;
2920 }
2921 }
2922 create_cold_build_secondary_indexes(&tx)?;
2923 let supplemental_edge_count = insert_method_dispatch_edges_chunked(
2924 &tx,
2925 &self.project_root,
2926 &indexed_caller_files,
2927 chunk_size,
2928 )?;
2929 set_meta_ready(&tx, true)?;
2930 bump_projection_write_revision(&tx)?;
2931 tx.commit()?;
2932 self.record_commit(total_changes_before, &conn);
2933 phase!("sqlite_insert", t);
2934
2935 let elapsed_ms = started.elapsed().as_millis();
2936 crate::slog_info!(
2937 "perf callgraph_store cold_build (chunked): files={} nodes={} refs={} edges={} ms={}",
2938 files_parsed,
2939 node_count,
2940 ref_count,
2941 edge_count + supplemental_edge_count,
2942 elapsed_ms
2943 );
2944 Ok(ColdBuildStats {
2945 files: files_parsed,
2946 nodes: node_count,
2947 refs: ref_count,
2948 edges: edge_count + supplemental_edge_count,
2949 failed_files: failures
2950 .into_iter()
2951 .map(|failure| failure.rel_path)
2952 .collect(),
2953 elapsed_ms,
2954 })
2955 }
2956
2957 pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
2958 self.refresh_files_with_workspace_crate_prefix_cache(
2959 changed_files,
2960 WorkspaceCratePrefixCache::default(),
2961 )
2962 }
2963
2964 fn refresh_files_with_workspace_crate_prefix_cache(
2965 &self,
2966 changed_files: &[PathBuf],
2967 workspace_crate_prefixes: WorkspaceCratePrefixCache,
2968 ) -> Result<IncrementalStats> {
2969 let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
2970 changed_files,
2971 workspace_crate_prefixes,
2972 )?;
2973 if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
2974 eprintln!("refresh_files phases: {}", profile.report());
2975 }
2976 Ok(stats)
2977 }
2978
2979 #[doc(hidden)]
2981 pub fn refresh_files_profiled(
2982 &self,
2983 changed_files: &[PathBuf],
2984 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2985 self.refresh_files_profiled_with_workspace_crate_prefix_cache(
2986 changed_files,
2987 WorkspaceCratePrefixCache::default(),
2988 )
2989 }
2990
2991 fn refresh_files_profiled_with_workspace_crate_prefix_cache(
2992 &self,
2993 changed_files: &[PathBuf],
2994 workspace_crate_prefixes: WorkspaceCratePrefixCache,
2995 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2996 let total_started = Instant::now();
2997 let mut profile = RefreshFilesProfile::default();
2998 self.verify_writer_lease()?;
2999 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3000 ensure_database_ready(&conn)?;
3001 let total_changes_before = conn.total_changes();
3002 let mut changed = Vec::new();
3003 let mut surface_changed = BTreeSet::new();
3004 let mut deleted = BTreeSet::new();
3005 let mut own_refresh = BTreeSet::new();
3006 let mut candidate_own_refresh = BTreeSet::new();
3007 let mut unchanged_extracts = 0usize;
3008 let mut selected_ref_ids = BTreeSet::new();
3009 let mut selected_refs_by_caller = BTreeMap::new();
3010 let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
3011 let mut fresh_metadata = BTreeMap::new();
3012
3013 for input in changed_files {
3014 let abs_path = normalize_file_path(&self.project_root, input)?;
3015 let rel_path = relative_path(&self.project_root, &abs_path);
3016 changed.push(rel_path.clone());
3017 let old_row = load_file_row(&conn, &rel_path)?;
3018 if !abs_path.exists() {
3019 if old_row.is_some() && deleted.insert(rel_path.clone()) {
3020 surface_changed.insert(rel_path.clone());
3021 let started = Instant::now();
3022 let dependent_refs =
3023 ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3024 profile.dependency_selection += started.elapsed();
3025 record_dependent_refs(
3026 &mut selected_ref_ids,
3027 &mut selected_refs_by_caller,
3028 dependent_refs,
3029 );
3030 }
3031 continue;
3032 }
3033
3034 if let Some(row) = &old_row {
3035 match cache_freshness::verify_file(&abs_path, &row.freshness) {
3036 FreshnessVerdict::HotFresh => continue,
3037 FreshnessVerdict::ContentFresh {
3038 new_mtime,
3039 new_size,
3040 } => {
3041 fresh_metadata.insert(
3042 rel_path.clone(),
3043 FileFreshness {
3044 content_hash: row.freshness.content_hash,
3045 mtime: new_mtime,
3046 size: new_size,
3047 },
3048 );
3049 continue;
3050 }
3051 FreshnessVerdict::Deleted => {
3052 if deleted.insert(rel_path.clone()) {
3053 surface_changed.insert(rel_path.clone());
3054 let started = Instant::now();
3055 let dependent_refs =
3056 ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3057 profile.dependency_selection += started.elapsed();
3058 record_dependent_refs(
3059 &mut selected_ref_ids,
3060 &mut selected_refs_by_caller,
3061 dependent_refs,
3062 );
3063 }
3064 continue;
3065 }
3066 FreshnessVerdict::Stale => {}
3067 }
3068 }
3069
3070 let started = Instant::now();
3071 let extract = build_file_extract(&self.project_root, &abs_path)?;
3072 profile.parse += started.elapsed();
3073 let surface_is_changed = old_row
3074 .as_ref()
3075 .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
3076 .unwrap_or(true);
3077 if surface_is_changed {
3078 surface_changed.insert(rel_path.clone());
3079 let started = Instant::now();
3080 let dependent_refs = ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3081 profile.dependency_selection += started.elapsed();
3082 record_dependent_refs(
3083 &mut selected_ref_ids,
3084 &mut selected_refs_by_caller,
3085 dependent_refs,
3086 );
3087 }
3088 candidate_own_refresh.insert(rel_path.clone());
3089 changed_extracts.insert(rel_path, extract);
3090 }
3091
3092 let dependency_selected_refs = selected_ref_ids.len();
3093 let mut touched_callers: BTreeSet<String> =
3094 selected_refs_by_caller.keys().cloned().collect();
3095 touched_callers.extend(candidate_own_refresh.iter().cloned());
3096
3097 let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
3098 for rel_path in &touched_callers {
3099 if deleted.contains(rel_path) {
3100 continue;
3101 }
3102 if let Some(extract) = changed_extracts.get(rel_path) {
3103 caller_extracts.insert(rel_path.clone(), extract.clone());
3104 continue;
3105 }
3106 let abs_path = self.project_root.join(rel_path);
3107 if abs_path.exists() {
3108 let started = Instant::now();
3109 let extract = build_file_extract(&self.project_root, &abs_path)?;
3110 profile.dependent_parse += started.elapsed();
3111 caller_extracts.insert(rel_path.clone(), extract);
3112 }
3113 }
3114
3115 let tx = conn.transaction()?;
3116 for (rel_path, freshness) in fresh_metadata {
3117 update_file_fresh_metadata(
3118 &tx,
3119 &self.project_root,
3120 &rel_path,
3121 &freshness.content_hash,
3122 freshness.mtime,
3123 freshness.size,
3124 )?;
3125 }
3126 for rel_path in &deleted {
3127 let started = Instant::now();
3128 delete_file_rows(&tx, rel_path)?;
3129 clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
3130 profile.row_deletes += started.elapsed();
3131 }
3132
3133 let started = Instant::now();
3134 let index = ProjectIndex::from_db_and_callers(
3135 &tx,
3136 &self.project_root,
3137 &caller_extracts,
3138 workspace_crate_prefixes,
3139 )?;
3140 profile.index_load += started.elapsed();
3141
3142 let workspace_root = self.project_root.display().to_string();
3143 {
3144 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3145 for rel_path in &candidate_own_refresh {
3146 let Some(extract) = changed_extracts.get(rel_path) else {
3147 continue;
3148 };
3149 if !write_amplification_baseline_enabled()
3150 && stored_extract_matches(&tx, rel_path, extract, &index)?
3151 {
3152 unchanged_extracts += 1;
3153 update_file_fresh_metadata(
3154 &tx,
3155 &self.project_root,
3156 rel_path,
3157 &extract.freshness.content_hash,
3158 extract.freshness.mtime,
3159 extract.freshness.size,
3160 )?;
3161 continue;
3162 }
3163
3164 own_refresh.insert(rel_path.clone());
3165 let started = Instant::now();
3166 delete_file_rows(&tx, rel_path)?;
3167 clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
3168 profile.row_deletes += started.elapsed();
3169 let started = Instant::now();
3170 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
3171 profile.row_inserts += started.elapsed();
3172 }
3173
3174 let dependency_callers = touched_callers
3175 .iter()
3176 .filter(|rel_path| {
3177 !deleted.contains(*rel_path) && !candidate_own_refresh.contains(*rel_path)
3178 })
3179 .cloned()
3180 .collect::<Vec<_>>();
3181 for rel_path in dependency_callers {
3182 let Some(extract) = caller_extracts.get(&rel_path) else {
3183 continue;
3184 };
3185 if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
3186 continue;
3187 }
3188
3189 own_refresh.insert(rel_path.clone());
3190 let started = Instant::now();
3191 delete_file_rows(&tx, &rel_path)?;
3192 clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
3193 profile.row_deletes += started.elapsed();
3194 let started = Instant::now();
3195 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
3196 profile.row_inserts += started.elapsed();
3197 }
3198 let started = Instant::now();
3199 for rel_path in &touched_callers {
3200 if deleted.contains(rel_path) {
3201 continue;
3202 }
3203 let Some(extract) = caller_extracts.get(rel_path) else {
3204 continue;
3205 };
3206 if own_refresh.contains(rel_path) {
3207 delete_refs_for_caller(&tx, rel_path)?;
3208 for raw_ref in &extract.raw_refs {
3209 let resolved = resolve_ref(raw_ref.clone(), &index)?;
3210 insert_resolved_ref_prepared(&mut inserts, &resolved)?;
3211 }
3212 continue;
3213 }
3214
3215 let selected_for_caller = selected_refs_by_caller
3216 .get(rel_path)
3217 .cloned()
3218 .unwrap_or_default();
3219 delete_ref_ids(&tx, &selected_for_caller)?;
3220 for raw_ref in &extract.raw_refs {
3221 if selected_for_caller.contains(&raw_ref.ref_id) {
3222 let resolved = resolve_ref(raw_ref.clone(), &index)?;
3223 insert_resolved_ref_prepared(&mut inserts, &resolved)?;
3224 }
3225 }
3226 }
3227 profile.ref_resolution += started.elapsed();
3228 }
3229
3230 let started = Instant::now();
3231 delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
3232 insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
3233 profile.method_dispatch += started.elapsed();
3234
3235 bump_projection_write_revision(&tx)?;
3236 let started = Instant::now();
3237 commit_incremental_if_current(tx)?;
3238 self.record_commit(total_changes_before, &conn);
3239 profile.commit += started.elapsed();
3240 profile.total = total_started.elapsed();
3241 Ok((
3242 IncrementalStats {
3243 changed_files: changed,
3244 surface_changed: surface_changed.into_iter().collect(),
3245 deleted_files: deleted.into_iter().collect(),
3246 dependency_selected_refs,
3247 refreshed_own_files: own_refresh.len(),
3248 unchanged_extract_files: unchanged_extracts,
3249 },
3250 profile,
3251 ))
3252 }
3253
3254 pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
3255 self.cold_build(current_files)
3256 }
3257
3258 pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
3259 self.verify_writer_lease()?;
3260 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3261 let total_changes_before = conn.total_changes();
3262 let tx = conn.transaction()?;
3263 let mut marked = Vec::new();
3264 for path in files {
3265 let abs_path = normalize_file_path(&self.project_root, path)?;
3266 let rel_path = relative_path(&self.project_root, &abs_path);
3267 let freshness = cache_freshness::collect(&abs_path).ok();
3268 mark_backend_state(
3269 &tx,
3270 &self.project_root,
3271 &rel_path,
3272 freshness.as_ref().map(|freshness| &freshness.content_hash),
3273 "stale",
3274 )?;
3275 marked.push(rel_path);
3276 }
3277 bump_projection_write_revision(&tx)?;
3278 tx.commit()?;
3279 self.record_commit(total_changes_before, &conn);
3280 marked.sort();
3281 marked.dedup();
3282 Ok(marked)
3283 }
3284
3285 pub fn stale_files(&self) -> Result<Vec<String>> {
3286 self.refresh_read_marker()?;
3287 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3288 let mut stmt = conn.prepare(
3289 "SELECT DISTINCT file_path FROM backend_file_state
3290 WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
3291 ORDER BY file_path",
3292 )?;
3293 let rows = stmt.query_map(
3294 params![BACKEND_TREESITTER, self.project_root.display().to_string()],
3295 |row| row.get::<_, String>(0),
3296 )?;
3297 rows.collect::<std::result::Result<Vec<_>, _>>()
3298 .map_err(Into::into)
3299 }
3300
3301 pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
3302 self.refresh_read_marker()?;
3303 let rel_path = relative_path(
3304 &self.project_root,
3305 &normalize_file_path(&self.project_root, file)?,
3306 );
3307 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3308 conn.query_row(
3309 "SELECT status FROM backend_file_state
3310 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
3311 ORDER BY updated_at DESC LIMIT 1",
3312 params![
3313 BACKEND_TREESITTER,
3314 self.project_root.display().to_string(),
3315 rel_path
3316 ],
3317 |row| row.get(0),
3318 )
3319 .optional()
3320 .map_err(Into::into)
3321 }
3322
3323 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3324 self.refresh_read_marker()?;
3325 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3326 self.ensure_ready(&conn)?;
3327 edge_snapshot_with_conn(&conn)
3328 }
3329
3330 pub fn indexed_file_count(&self) -> Result<usize> {
3331 self.refresh_read_marker()?;
3332 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3333 self.ensure_ready(&conn)?;
3334 indexed_file_count(&conn)
3335 }
3336
3337 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3338 self.refresh_read_marker()?;
3339 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3340 let rel_path = relative_path(&self.project_root, &abs_path);
3341 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3342 self.ensure_ready(&conn)?;
3343 resolve_node_for_rel(&conn, &rel_path, symbol)
3344 }
3345
3346 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3351 self.refresh_read_marker()?;
3352 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3353 let rel_path = relative_path(&self.project_root, &abs_path);
3354 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3355 self.ensure_ready(&conn)?;
3356 nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
3357 }
3358
3359 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3361 self.refresh_read_marker()?;
3362 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3363 self.ensure_ready(&conn)?;
3364 nodes_matching_symbol(&conn, symbol)
3365 }
3366
3367 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3369 self.refresh_read_marker()?;
3370 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3371 let rel_path = relative_path(&self.project_root, &abs_path);
3372 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3373 self.ensure_ready(&conn)?;
3374 direct_callers_for_tuple(&conn, &rel_path, symbol)
3375 }
3376
3377 pub fn direct_callers_for_symbols(
3379 &self,
3380 targets: &[(String, String)],
3381 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3382 if targets.is_empty() {
3383 return Ok(HashMap::new());
3384 }
3385 self.refresh_read_marker()?;
3386 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3387 self.ensure_ready(&conn)?;
3388 direct_callers_for_tuples(&conn, targets)
3389 }
3390
3391 pub fn direct_caller_counts_of(
3393 &self,
3394 targets: &[(String, String)],
3395 ) -> Result<HashMap<(String, String), usize>> {
3396 if targets.is_empty() {
3397 return Ok(HashMap::new());
3398 }
3399 self.refresh_read_marker()?;
3400 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3401 self.ensure_ready(&conn)?;
3402 direct_caller_counts_for_tuples(&conn, targets)
3403 }
3404
3405 pub fn callers_of(
3406 &self,
3407 file_rel: &Path,
3408 symbol: &str,
3409 depth: usize,
3410 ) -> Result<StoreCallersResult> {
3411 let target = self.node_for(file_rel, symbol)?;
3412 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3413 self.ensure_ready(&conn)?;
3414 let effective_depth = depth.max(1);
3415 let mut visited = HashSet::new();
3416 let mut callers = Vec::new();
3417 let mut depth_limited = false;
3418 let mut truncated = 0usize;
3419 collect_callers_recursive(
3420 &conn,
3421 &target.file,
3422 &target.symbol,
3423 effective_depth,
3424 0,
3425 &mut visited,
3426 &mut callers,
3427 &mut depth_limited,
3428 &mut truncated,
3429 )?;
3430 Ok(StoreCallersResult {
3431 target,
3432 callers,
3433 scanned_files: indexed_file_count(&conn)?,
3434 depth_limited,
3435 truncated,
3436 })
3437 }
3438
3439 pub fn impact_of(
3440 &self,
3441 file_rel: &Path,
3442 symbol: &str,
3443 depth: usize,
3444 ) -> Result<StoreImpactResult> {
3445 let callers = self.callers_of(file_rel, symbol, depth)?;
3446 let target_parameters = callers
3447 .target
3448 .signature
3449 .as_deref()
3450 .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
3451 .unwrap_or_default();
3452 let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
3453 for site in &callers.callers {
3454 source_lines_by_file
3455 .entry(site.caller.file.clone())
3456 .or_insert_with(|| {
3457 read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
3458 });
3459 }
3460 let enriched = callers
3461 .callers
3462 .iter()
3463 .map(|site| StoreImpactCaller {
3464 site: site.clone(),
3465 signature: site.caller.signature.clone(),
3466 is_entry_point: site.caller.is_entry_point,
3467 call_expression: source_lines_by_file
3468 .get(&site.caller.file)
3469 .and_then(|lines| lines.as_ref())
3470 .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
3471 .cloned(),
3472 parameters: site
3473 .caller
3474 .signature
3475 .as_deref()
3476 .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
3477 .unwrap_or_default(),
3478 })
3479 .collect();
3480 Ok(StoreImpactResult {
3481 target: callers.target,
3482 parameters: target_parameters,
3483 callers: enriched,
3484 depth_limited: callers.depth_limited,
3485 truncated: callers.truncated,
3486 })
3487 }
3488
3489 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3490 self.refresh_read_marker()?;
3491 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3492 self.ensure_ready(&conn)?;
3493 outgoing_calls_for_node(&conn, node)
3494 }
3495
3496 pub fn outgoing_calls_for_symbols(
3498 &self,
3499 sources: &[(String, String)],
3500 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3501 if sources.is_empty() {
3502 return Ok(HashMap::new());
3503 }
3504 self.refresh_read_marker()?;
3505 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3506 self.ensure_ready(&conn)?;
3507 outgoing_calls_for_symbol_tuples(&conn, sources)
3508 }
3509
3510 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3512 self.refresh_read_marker()?;
3513 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3514 self.ensure_ready(&conn)?;
3515 resolved_self_calls_for_node(&conn, node)
3516 }
3517
3518 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3519 self.refresh_read_marker()?;
3520 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3521 self.ensure_ready(&conn)?;
3522 unresolved_calls_for_node(&conn, node)
3523 }
3524
3525 pub fn call_tree(
3526 &self,
3527 file_rel: &Path,
3528 symbol: &str,
3529 max_depth: usize,
3530 ) -> Result<callgraph::CallTreeNode> {
3531 let node = self.node_for(file_rel, symbol)?;
3532 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3533 self.ensure_ready(&conn)?;
3534 let mut visited = HashSet::new();
3535 call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
3536 }
3537
3538 pub fn trace_to(
3539 &self,
3540 file_rel: &Path,
3541 symbol: &str,
3542 max_depth: usize,
3543 ) -> Result<callgraph::TraceToResult> {
3544 let target = self.node_for(file_rel, symbol)?;
3545 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3546 self.ensure_ready(&conn)?;
3547 let effective_max = if max_depth == 0 { 10 } else { max_depth };
3548
3549 #[derive(Clone)]
3550 struct PathElem {
3551 node: StoreNode,
3552 }
3553
3554 let initial = vec![PathElem {
3555 node: target.clone(),
3556 }];
3557 let mut complete_paths = Vec::new();
3558 if target.is_entry_point {
3559 complete_paths.push(initial.clone());
3560 }
3561
3562 let mut queue = vec![(initial, 0usize)];
3563 let mut max_depth_reached = false;
3564 let mut truncated_paths = 0usize;
3565
3566 while let Some((path, depth)) = queue.pop() {
3567 if depth >= effective_max {
3568 max_depth_reached = true;
3569 continue;
3570 }
3571 let Some(current) = path.last() else {
3572 continue;
3573 };
3574 let callers =
3575 direct_callers_for_tuple(&conn, ¤t.node.file, ¤t.node.symbol)?;
3576 if callers.is_empty() {
3577 if path.len() > 1 {
3578 truncated_paths += 1;
3579 }
3580 continue;
3581 }
3582
3583 let mut has_new_path = false;
3584 for site in callers {
3585 if path.iter().any(|elem| {
3586 elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
3587 }) {
3588 continue;
3589 }
3590 has_new_path = true;
3591 let mut new_path = path.clone();
3592 new_path.push(PathElem {
3593 node: site.caller.clone(),
3594 });
3595 if site.caller.is_entry_point {
3596 complete_paths.push(new_path.clone());
3597 }
3598 queue.push((new_path, depth + 1));
3599 }
3600 if !has_new_path && path.len() > 1 {
3601 truncated_paths += 1;
3602 }
3603 }
3604
3605 let mut paths: Vec<callgraph::TracePath> = complete_paths
3606 .into_iter()
3607 .map(|mut elems| {
3608 elems.reverse();
3609 let hops = elems
3610 .iter()
3611 .enumerate()
3612 .map(|(index, elem)| callgraph::TraceHop {
3613 symbol: elem.node.symbol.clone(),
3614 file: elem.node.file.clone(),
3615 line: elem.node.line,
3616 signature: elem.node.signature.clone(),
3617 is_entry_point: index == 0 && elem.node.is_entry_point,
3618 })
3619 .collect();
3620 callgraph::TracePath { hops }
3621 })
3622 .collect();
3623 paths.sort_by(|left, right| {
3624 let left_entry = left
3625 .hops
3626 .first()
3627 .map(|hop| hop.symbol.as_str())
3628 .unwrap_or("");
3629 let right_entry = right
3630 .hops
3631 .first()
3632 .map(|hop| hop.symbol.as_str())
3633 .unwrap_or("");
3634 left_entry
3635 .cmp(right_entry)
3636 .then(left.hops.len().cmp(&right.hops.len()))
3637 });
3638 let entry_points_found = paths
3639 .iter()
3640 .filter_map(|path| path.hops.first())
3641 .filter(|hop| hop.is_entry_point)
3642 .map(|hop| (hop.file.clone(), hop.symbol.clone()))
3643 .collect::<HashSet<_>>()
3644 .len();
3645
3646 Ok(callgraph::TraceToResult {
3647 target_symbol: target.symbol,
3648 target_file: target.file,
3649 total_paths: paths.len(),
3650 paths,
3651 entry_points_found,
3652 max_depth_reached,
3653 truncated_paths,
3654 })
3655 }
3656
3657 pub fn trace_to_symbol_candidates(
3658 &self,
3659 to_symbol: &str,
3660 ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
3661 self.refresh_read_marker()?;
3662 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3663 self.ensure_ready(&conn)?;
3664 let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
3665 for node in nodes_matching_symbol(&conn, to_symbol)? {
3666 candidates_by_file
3667 .entry(node.file)
3668 .and_modify(|line| *line = (*line).min(node.line))
3669 .or_insert(node.line);
3670 }
3671 let mut candidates: Vec<_> = candidates_by_file
3672 .into_iter()
3673 .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
3674 .collect();
3675 candidates
3676 .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
3677 Ok(candidates)
3678 }
3679
3680 pub fn trace_to_symbol(
3681 &self,
3682 file_rel: &Path,
3683 symbol: &str,
3684 to_symbol: &str,
3685 to_file: Option<&Path>,
3686 max_depth: usize,
3687 ) -> Result<callgraph::TraceToSymbolResult> {
3688 let origin = self.node_for(file_rel, symbol)?;
3689 let target_file = to_file
3690 .map(|path| normalize_file_path(&self.project_root, path))
3691 .transpose()?
3692 .map(|path| relative_path(&self.project_root, &path));
3693 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3694 self.ensure_ready(&conn)?;
3695 let effective_max = if max_depth == 0 {
3696 10
3697 } else {
3698 max_depth.min(16)
3699 };
3700
3701 let start_hop = trace_to_symbol_hop(&origin);
3702 if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
3703 return Ok(callgraph::TraceToSymbolResult {
3704 path: Some(vec![start_hop]),
3705 complete: true,
3706 reason: None,
3707 });
3708 }
3709
3710 let mut queue = VecDeque::new();
3711 queue.push_back((origin.clone(), vec![start_hop], 0usize));
3712 let mut visited = HashSet::new();
3713 visited.insert((origin.file.clone(), origin.symbol.clone()));
3714 let mut max_depth_exhausted = false;
3715
3716 while let Some((current, path, depth)) = queue.pop_front() {
3717 let callees = outgoing_calls_for_node(&conn, ¤t)?
3718 .into_iter()
3719 .filter_map(|site| site.target)
3720 .collect::<Vec<_>>();
3721
3722 if depth >= effective_max {
3723 if callees
3724 .iter()
3725 .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
3726 {
3727 max_depth_exhausted = true;
3728 }
3729 continue;
3730 }
3731
3732 for callee in callees {
3733 if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
3734 continue;
3735 }
3736 let mut next_path = path.clone();
3737 next_path.push(trace_to_symbol_hop(&callee));
3738 if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
3739 return Ok(callgraph::TraceToSymbolResult {
3740 path: Some(next_path),
3741 complete: true,
3742 reason: None,
3743 });
3744 }
3745 queue.push_back((callee, next_path, depth + 1));
3746 }
3747 }
3748
3749 if max_depth_exhausted {
3750 Ok(callgraph::TraceToSymbolResult {
3751 path: None,
3752 complete: false,
3753 reason: Some("max_depth_exhausted".to_string()),
3754 })
3755 } else {
3756 Ok(callgraph::TraceToSymbolResult {
3757 path: None,
3758 complete: true,
3759 reason: Some("no_path_found".to_string()),
3760 })
3761 }
3762 }
3763}
3764
3765impl ReadonlyCallGraphStore {
3766 fn from_inner(inner: CallGraphStore) -> Self {
3767 Self { inner }
3768 }
3769
3770 pub fn project_root(&self) -> &Path {
3771 self.inner.project_root()
3772 }
3773
3774 pub fn project_key(&self) -> &str {
3775 self.inner.project_key()
3776 }
3777
3778 pub fn sqlite_path(&self) -> &Path {
3779 self.inner.sqlite_path()
3780 }
3781
3782 pub(crate) fn projection_generation(&self) -> Option<&str> {
3783 self.inner.projection_generation()
3784 }
3785
3786 pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
3787 self.inner.projection_write_revision()
3788 }
3789
3790 pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
3793 crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
3794 }
3795
3796 pub fn is_legacy_fallback(&self) -> bool {
3798 self.inner.is_legacy_fallback()
3799 }
3800
3801 pub fn is_current(&self) -> bool {
3802 self.inner.is_current()
3803 }
3804
3805 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3806 self.inner.edge_snapshot()
3807 }
3808
3809 pub fn indexed_file_count(&self) -> Result<usize> {
3810 self.inner.indexed_file_count()
3811 }
3812
3813 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3814 self.inner.node_for(file_rel, symbol)
3815 }
3816
3817 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3818 self.inner.nodes_for(file_rel, symbol)
3819 }
3820
3821 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3822 self.inner.nodes_matching(symbol)
3823 }
3824
3825 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3826 self.inner.direct_callers_of(file_rel, symbol)
3827 }
3828
3829 pub fn direct_callers_for_symbols(
3830 &self,
3831 targets: &[(String, String)],
3832 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3833 self.inner.direct_callers_for_symbols(targets)
3834 }
3835
3836 pub fn direct_caller_counts_of(
3837 &self,
3838 targets: &[(String, String)],
3839 ) -> Result<HashMap<(String, String), usize>> {
3840 self.inner.direct_caller_counts_of(targets)
3841 }
3842
3843 pub fn callers_of(
3844 &self,
3845 file_rel: &Path,
3846 symbol: &str,
3847 depth: usize,
3848 ) -> Result<StoreCallersResult> {
3849 self.inner.callers_of(file_rel, symbol, depth)
3850 }
3851
3852 pub fn impact_of(
3853 &self,
3854 file_rel: &Path,
3855 symbol: &str,
3856 depth: usize,
3857 ) -> Result<StoreImpactResult> {
3858 self.inner.impact_of(file_rel, symbol, depth)
3859 }
3860
3861 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3862 self.inner.outgoing_calls_of(node)
3863 }
3864
3865 pub fn outgoing_calls_for_symbols(
3866 &self,
3867 sources: &[(String, String)],
3868 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3869 self.inner.outgoing_calls_for_symbols(sources)
3870 }
3871
3872 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3873 self.inner.resolved_self_calls_of(node)
3874 }
3875
3876 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3877 self.inner.unresolved_calls_of(node)
3878 }
3879
3880 pub fn call_tree(
3881 &self,
3882 file_rel: &Path,
3883 symbol: &str,
3884 depth: usize,
3885 ) -> Result<callgraph::CallTreeNode> {
3886 self.inner.call_tree(file_rel, symbol, depth)
3887 }
3888
3889 pub fn trace_to(
3890 &self,
3891 file_rel: &Path,
3892 symbol: &str,
3893 max_depth: usize,
3894 ) -> Result<callgraph::TraceToResult> {
3895 self.inner.trace_to(file_rel, symbol, max_depth)
3896 }
3897
3898 pub fn trace_to_symbol_candidates(
3899 &self,
3900 to_symbol: &str,
3901 ) -> Result<Vec<TraceToSymbolCandidate>> {
3902 self.inner.trace_to_symbol_candidates(to_symbol)
3903 }
3904
3905 pub fn trace_to_symbol(
3906 &self,
3907 file_rel: &Path,
3908 symbol: &str,
3909 to_symbol: &str,
3910 to_file: Option<&Path>,
3911 max_depth: usize,
3912 ) -> Result<callgraph::TraceToSymbolResult> {
3913 self.inner
3914 .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3915 }
3916}
3917
3918impl CallGraphRead for CallGraphStore {
3919 fn project_root(&self) -> &Path {
3920 CallGraphStore::project_root(self)
3921 }
3922 fn project_key(&self) -> &str {
3923 CallGraphStore::project_key(self)
3924 }
3925 fn sqlite_path(&self) -> &Path {
3926 CallGraphStore::sqlite_path(self)
3927 }
3928 fn is_current(&self) -> bool {
3929 CallGraphStore::is_current(self)
3930 }
3931 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3932 CallGraphStore::edge_snapshot(self)
3933 }
3934 fn indexed_file_count(&self) -> Result<usize> {
3935 CallGraphStore::indexed_file_count(self)
3936 }
3937 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3938 CallGraphStore::node_for(self, file_rel, symbol)
3939 }
3940 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3941 CallGraphStore::nodes_for(self, file_rel, symbol)
3942 }
3943 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3944 CallGraphStore::nodes_matching(self, symbol)
3945 }
3946 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3947 CallGraphStore::direct_callers_of(self, file_rel, symbol)
3948 }
3949 fn direct_callers_for_symbols(
3950 &self,
3951 targets: &[(String, String)],
3952 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3953 CallGraphStore::direct_callers_for_symbols(self, targets)
3954 }
3955 fn direct_caller_counts_of(
3956 &self,
3957 targets: &[(String, String)],
3958 ) -> Result<HashMap<(String, String), usize>> {
3959 CallGraphStore::direct_caller_counts_of(self, targets)
3960 }
3961 fn callers_of(
3962 &self,
3963 file_rel: &Path,
3964 symbol: &str,
3965 depth: usize,
3966 ) -> Result<StoreCallersResult> {
3967 CallGraphStore::callers_of(self, file_rel, symbol, depth)
3968 }
3969 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3970 CallGraphStore::impact_of(self, file_rel, symbol, depth)
3971 }
3972 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3973 CallGraphStore::outgoing_calls_of(self, node)
3974 }
3975 fn outgoing_calls_for_symbols(
3976 &self,
3977 sources: &[(String, String)],
3978 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3979 CallGraphStore::outgoing_calls_for_symbols(self, sources)
3980 }
3981 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3982 CallGraphStore::resolved_self_calls_of(self, node)
3983 }
3984 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3985 CallGraphStore::unresolved_calls_of(self, node)
3986 }
3987 fn call_tree(
3988 &self,
3989 file_rel: &Path,
3990 symbol: &str,
3991 depth: usize,
3992 ) -> Result<callgraph::CallTreeNode> {
3993 CallGraphStore::call_tree(self, file_rel, symbol, depth)
3994 }
3995 fn trace_to(
3996 &self,
3997 file_rel: &Path,
3998 symbol: &str,
3999 max_depth: usize,
4000 ) -> Result<callgraph::TraceToResult> {
4001 CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
4002 }
4003 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4004 CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
4005 }
4006 fn trace_to_symbol(
4007 &self,
4008 file_rel: &Path,
4009 symbol: &str,
4010 to_symbol: &str,
4011 to_file: Option<&Path>,
4012 max_depth: usize,
4013 ) -> Result<callgraph::TraceToSymbolResult> {
4014 CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
4015 }
4016}
4017
4018impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
4019 fn project_root(&self) -> &Path {
4020 (**self).project_root()
4021 }
4022 fn project_key(&self) -> &str {
4023 (**self).project_key()
4024 }
4025 fn sqlite_path(&self) -> &Path {
4026 (**self).sqlite_path()
4027 }
4028 fn is_current(&self) -> bool {
4029 (**self).is_current()
4030 }
4031 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4032 (**self).edge_snapshot()
4033 }
4034 fn indexed_file_count(&self) -> Result<usize> {
4035 (**self).indexed_file_count()
4036 }
4037 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4038 (**self).node_for(file_rel, symbol)
4039 }
4040 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4041 (**self).nodes_for(file_rel, symbol)
4042 }
4043 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4044 (**self).nodes_matching(symbol)
4045 }
4046 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4047 (**self).direct_callers_of(file_rel, symbol)
4048 }
4049 fn direct_callers_for_symbols(
4050 &self,
4051 targets: &[(String, String)],
4052 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4053 (**self).direct_callers_for_symbols(targets)
4054 }
4055 fn direct_caller_counts_of(
4056 &self,
4057 targets: &[(String, String)],
4058 ) -> Result<HashMap<(String, String), usize>> {
4059 (**self).direct_caller_counts_of(targets)
4060 }
4061 fn callers_of(
4062 &self,
4063 file_rel: &Path,
4064 symbol: &str,
4065 depth: usize,
4066 ) -> Result<StoreCallersResult> {
4067 (**self).callers_of(file_rel, symbol, depth)
4068 }
4069 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4070 (**self).impact_of(file_rel, symbol, depth)
4071 }
4072 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4073 (**self).outgoing_calls_of(node)
4074 }
4075 fn outgoing_calls_for_symbols(
4076 &self,
4077 sources: &[(String, String)],
4078 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4079 (**self).outgoing_calls_for_symbols(sources)
4080 }
4081 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4082 (**self).resolved_self_calls_of(node)
4083 }
4084 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4085 (**self).unresolved_calls_of(node)
4086 }
4087 fn call_tree(
4088 &self,
4089 file_rel: &Path,
4090 symbol: &str,
4091 depth: usize,
4092 ) -> Result<callgraph::CallTreeNode> {
4093 (**self).call_tree(file_rel, symbol, depth)
4094 }
4095 fn trace_to(
4096 &self,
4097 file_rel: &Path,
4098 symbol: &str,
4099 max_depth: usize,
4100 ) -> Result<callgraph::TraceToResult> {
4101 (**self).trace_to(file_rel, symbol, max_depth)
4102 }
4103 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4104 (**self).trace_to_symbol_candidates(to_symbol)
4105 }
4106 fn trace_to_symbol(
4107 &self,
4108 file_rel: &Path,
4109 symbol: &str,
4110 to_symbol: &str,
4111 to_file: Option<&Path>,
4112 max_depth: usize,
4113 ) -> Result<callgraph::TraceToSymbolResult> {
4114 (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4115 }
4116}
4117
4118impl CallGraphRead for ReadonlyCallGraphStore {
4119 fn project_root(&self) -> &Path {
4120 self.project_root()
4121 }
4122 fn project_key(&self) -> &str {
4123 self.project_key()
4124 }
4125 fn sqlite_path(&self) -> &Path {
4126 self.sqlite_path()
4127 }
4128 fn is_current(&self) -> bool {
4129 self.is_current()
4130 }
4131 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4132 self.edge_snapshot()
4133 }
4134 fn indexed_file_count(&self) -> Result<usize> {
4135 self.indexed_file_count()
4136 }
4137 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4138 self.node_for(file_rel, symbol)
4139 }
4140 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4141 self.nodes_for(file_rel, symbol)
4142 }
4143 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4144 self.nodes_matching(symbol)
4145 }
4146 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4147 self.direct_callers_of(file_rel, symbol)
4148 }
4149 fn direct_callers_for_symbols(
4150 &self,
4151 targets: &[(String, String)],
4152 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4153 self.direct_callers_for_symbols(targets)
4154 }
4155 fn direct_caller_counts_of(
4156 &self,
4157 targets: &[(String, String)],
4158 ) -> Result<HashMap<(String, String), usize>> {
4159 self.direct_caller_counts_of(targets)
4160 }
4161 fn callers_of(
4162 &self,
4163 file_rel: &Path,
4164 symbol: &str,
4165 depth: usize,
4166 ) -> Result<StoreCallersResult> {
4167 self.callers_of(file_rel, symbol, depth)
4168 }
4169 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4170 self.impact_of(file_rel, symbol, depth)
4171 }
4172 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4173 self.outgoing_calls_of(node)
4174 }
4175 fn outgoing_calls_for_symbols(
4176 &self,
4177 sources: &[(String, String)],
4178 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4179 self.outgoing_calls_for_symbols(sources)
4180 }
4181 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4182 self.resolved_self_calls_of(node)
4183 }
4184 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4185 self.unresolved_calls_of(node)
4186 }
4187 fn call_tree(
4188 &self,
4189 file_rel: &Path,
4190 symbol: &str,
4191 depth: usize,
4192 ) -> Result<callgraph::CallTreeNode> {
4193 self.call_tree(file_rel, symbol, depth)
4194 }
4195 fn trace_to(
4196 &self,
4197 file_rel: &Path,
4198 symbol: &str,
4199 max_depth: usize,
4200 ) -> Result<callgraph::TraceToResult> {
4201 self.trace_to(file_rel, symbol, max_depth)
4202 }
4203 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4204 self.trace_to_symbol_candidates(to_symbol)
4205 }
4206 fn trace_to_symbol(
4207 &self,
4208 file_rel: &Path,
4209 symbol: &str,
4210 to_symbol: &str,
4211 to_file: Option<&Path>,
4212 max_depth: usize,
4213 ) -> Result<callgraph::TraceToSymbolResult> {
4214 self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4215 }
4216}
4217
4218fn indexed_file_count(conn: &Connection) -> Result<usize> {
4219 let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
4220 Ok(count.max(0) as usize)
4221}
4222
4223fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
4224 let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
4225 match candidates.as_slice() {
4226 [candidate] => Ok(candidate.clone()),
4227 [] => Err(AftError::SymbolNotFound {
4228 name: symbol.to_string(),
4229 file: rel_path.to_string(),
4230 }
4231 .into()),
4232 _ => Err(AftError::AmbiguousSymbol {
4233 name: symbol.to_string(),
4234 candidates: candidates
4235 .iter()
4236 .map(|candidate| candidate.symbol.clone())
4237 .collect(),
4238 }
4239 .into()),
4240 }
4241}
4242
4243fn nodes_for_file_matching_symbol(
4244 conn: &Connection,
4245 rel_path: &str,
4246 symbol: &str,
4247) -> Result<Vec<StoreNode>> {
4248 let qualified_query = symbol.contains("::");
4249 let sql = if qualified_query {
4250 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4251 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4252 FROM nodes n JOIN files f ON f.path = n.file_path
4253 WHERE n.file_path = ?1 AND n.scoped_name = ?2
4254 ORDER BY n.scoped_name, n.start_line, n.start_col"
4255 } else {
4256 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4257 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4258 FROM nodes n JOIN files f ON f.path = n.file_path
4259 WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
4260 ORDER BY n.scoped_name, n.start_line, n.start_col"
4261 };
4262 let mut stmt = conn.prepare(sql)?;
4263 let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
4264 rows.collect::<std::result::Result<Vec<_>, _>>()
4265 .map_err(Into::into)
4266}
4267
4268fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
4269 let qualified_query = symbol.contains("::");
4270 let sql = if qualified_query {
4271 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4272 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4273 FROM nodes n JOIN files f ON f.path = n.file_path
4274 WHERE n.scoped_name = ?1
4275 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4276 } else {
4277 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4278 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4279 FROM nodes n JOIN files f ON f.path = n.file_path
4280 WHERE n.scoped_name = ?1 OR n.name = ?1
4281 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4282 };
4283 let mut stmt = conn.prepare(sql)?;
4284 let rows = stmt.query_map(params![symbol], store_node_from_row)?;
4285 rows.collect::<std::result::Result<Vec<_>, _>>()
4286 .map_err(Into::into)
4287}
4288
4289fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
4290 store_node_from_row_at(row, 0)
4291}
4292
4293fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
4294 let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
4295 let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
4296 let lang_label_value: String = row.get(offset + 10)?;
4297 Ok(StoreNode {
4298 node_id: row.get(offset)?,
4299 file: row.get(offset + 1)?,
4300 symbol: row.get(offset + 2)?,
4301 name: row.get(offset + 3)?,
4302 kind: row.get(offset + 4)?,
4303 line: start_line.saturating_add(1),
4304 end_line: end_line.saturating_add(1),
4305 signature: row.get(offset + 7)?,
4306 exported: row.get::<_, i64>(offset + 8)? != 0,
4307 is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
4308 lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
4309 })
4310}
4311
4312fn optional_store_node_from_row_at(
4313 row: &rusqlite::Row<'_>,
4314 offset: usize,
4315) -> rusqlite::Result<Option<StoreNode>> {
4316 if row.get::<_, Option<String>>(offset)?.is_some() {
4317 store_node_from_row_at(row, offset).map(Some)
4318 } else {
4319 Ok(None)
4320 }
4321}
4322
4323#[allow(clippy::too_many_arguments)]
4324fn collect_callers_recursive(
4325 conn: &Connection,
4326 file: &str,
4327 symbol: &str,
4328 max_depth: usize,
4329 current_depth: usize,
4330 visited: &mut HashSet<(String, String)>,
4331 result: &mut Vec<StoreCallSite>,
4332 depth_limited: &mut bool,
4333 truncated: &mut usize,
4334) -> Result<()> {
4335 if current_depth >= max_depth {
4336 let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
4337 if omitted > 0 {
4338 *depth_limited = true;
4339 *truncated += omitted;
4340 }
4341 return Ok(());
4342 }
4343
4344 if !visited.insert((file.to_string(), symbol.to_string())) {
4345 return Ok(());
4346 }
4347
4348 let sites = direct_callers_for_tuple(conn, file, symbol)?;
4349 for site in sites {
4350 result.push(site.clone());
4351 if current_depth + 1 < max_depth {
4352 collect_callers_recursive(
4353 conn,
4354 &site.caller.file,
4355 &site.caller.symbol,
4356 max_depth,
4357 current_depth + 1,
4358 visited,
4359 result,
4360 depth_limited,
4361 truncated,
4362 )?;
4363 } else {
4364 let omitted =
4365 direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
4366 if omitted > 0 {
4367 *depth_limited = true;
4368 *truncated += omitted;
4369 }
4370 }
4371 }
4372 Ok(())
4373}
4374
4375const DIRECT_CALLER_BATCH_SIZE: usize = 499;
4377
4378fn direct_caller_counts_for_tuples(
4379 conn: &Connection,
4380 targets: &[(String, String)],
4381) -> Result<HashMap<(String, String), usize>> {
4382 let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
4383 let mut counts = unique_targets
4384 .iter()
4385 .cloned()
4386 .map(|target| (target, 0usize))
4387 .collect::<HashMap<_, _>>();
4388
4389 let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
4390 for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
4391 let requested_values = (0..chunk.len())
4392 .map(|_| "(?, ?)")
4393 .collect::<Vec<_>>()
4394 .join(", ");
4395 let sql = format!(
4396 "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
4397 deduped AS (
4398 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
4399 FROM requested requested
4400 JOIN edges e
4401 ON e.target_file = requested.target_file
4402 AND e.target_symbol = requested.target_symbol
4403 AND e.kind = 'call'
4404 JOIN refs r ON r.ref_id = e.ref_id
4405 JOIN nodes src ON src.id = e.source_node
4406 JOIN files src_file ON src_file.path = src.file_path
4407 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
4408 )
4409 SELECT target_file, target_symbol, COUNT(*)
4410 FROM deduped
4411 GROUP BY target_file, target_symbol"
4412 );
4413 let bindings = chunk
4414 .iter()
4415 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4416 let mut stmt = conn.prepare(&sql)?;
4417 let rows = stmt.query_map(params_from_iter(bindings), |row| {
4418 Ok((
4419 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
4420 row.get::<_, i64>(2)?,
4421 ))
4422 })?;
4423 for row in rows {
4424 let (target, count) = row?;
4425 counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
4426 }
4427 }
4428
4429 Ok(counts)
4430}
4431
4432fn direct_caller_count_for_tuple(
4433 conn: &Connection,
4434 target_file: &str,
4435 target_symbol: &str,
4436) -> Result<usize> {
4437 let count: i64 = conn.query_row(
4438 "SELECT COUNT(*)
4439 FROM edges e
4440 JOIN refs r ON r.ref_id = e.ref_id
4441 JOIN nodes src ON src.id = e.source_node
4442 JOIN files src_file ON src_file.path = src.file_path
4443 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
4444 params![target_file, target_symbol],
4445 |row| row.get(0),
4446 )?;
4447 Ok(usize::try_from(count).unwrap_or(usize::MAX))
4448}
4449
4450fn direct_callers_for_tuple(
4451 conn: &Connection,
4452 target_file: &str,
4453 target_symbol: &str,
4454) -> Result<Vec<StoreCallSite>> {
4455 let mut stmt = conn.prepare(
4456 "SELECT e.target_file, e.target_symbol, e.line,
4457 r.byte_start, r.byte_end, r.status, e.provenance,
4458 src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
4459 src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
4460 src_file.lang,
4461 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4462 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4463 tgt_file.lang
4464 FROM edges e
4465 JOIN refs r ON r.ref_id = e.ref_id
4466 JOIN nodes src ON src.id = e.source_node
4467 JOIN files src_file ON src_file.path = src.file_path
4468 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4469 ON tgt.id = e.target_node
4470 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
4471 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
4472 )?;
4473 let rows = stmt.query_map(
4474 params![target_file, target_symbol],
4475 direct_call_site_from_row,
4476 )?;
4477 rows.collect::<std::result::Result<Vec<_>, _>>()
4478 .map_err(Into::into)
4479}
4480
4481fn direct_call_site_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreCallSite> {
4482 let caller = store_node_from_row_at(row, 7)?;
4483 let target = optional_store_node_from_row_at(row, 18)?;
4484 Ok(StoreCallSite {
4485 caller,
4486 target_file: row.get(0)?,
4487 target_symbol: row.get(1)?,
4488 target,
4489 line: row.get::<_, i64>(2)?.max(0) as u32,
4490 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4491 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4492 resolved: row.get::<_, String>(5)? == "resolved",
4493 provenance: row.get(6)?,
4494 })
4495}
4496
4497fn direct_callers_for_tuples(
4498 conn: &Connection,
4499 targets: &[(String, String)],
4500) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4501 let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
4502 let mut callers_by_target = unique_targets
4503 .iter()
4504 .cloned()
4505 .map(|target| (target, Vec::new()))
4506 .collect::<HashMap<_, _>>();
4507 let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
4508
4509 for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
4510 let requested_values = (0..chunk.len())
4511 .map(|_| "(?, ?)")
4512 .collect::<Vec<_>>()
4513 .join(", ");
4514 let sql = format!(
4515 "WITH requested(target_file, target_symbol) AS (VALUES {requested_values})
4516 SELECT e.target_file, e.target_symbol, e.line,
4517 r.byte_start, r.byte_end, r.status, e.provenance,
4518 src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
4519 src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
4520 src_file.lang,
4521 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4522 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4523 tgt_file.lang
4524 FROM requested requested
4525 JOIN edges e
4526 ON e.target_file = requested.target_file
4527 AND e.target_symbol = requested.target_symbol
4528 AND e.kind = 'call'
4529 JOIN refs r ON r.ref_id = e.ref_id
4530 JOIN nodes src ON src.id = e.source_node
4531 JOIN files src_file ON src_file.path = src.file_path
4532 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4533 ON tgt.id = e.target_node
4534 ORDER BY e.target_file, e.target_symbol, e.source_node,
4535 r.byte_start, r.line, r.ref_id"
4536 );
4537 let bindings = chunk
4538 .iter()
4539 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4540 let mut stmt = conn.prepare(&sql)?;
4541 let rows = stmt.query_map(params_from_iter(bindings), |row| {
4542 let call = direct_call_site_from_row(row)?;
4543 let target_key = (call.target_file.clone(), call.target_symbol.clone());
4544 Ok((target_key, call))
4545 })?;
4546 for row in rows {
4547 let (target, call) = row?;
4548 callers_by_target
4549 .get_mut(&target)
4550 .expect("batched caller row belongs to a requested target")
4551 .push(call);
4552 }
4553 }
4554
4555 Ok(callers_by_target)
4556}
4557
4558const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
4560const OUTGOING_NODE_BATCH_SIZE: usize = 999;
4562
4563fn outgoing_calls_for_symbol_tuples(
4564 conn: &Connection,
4565 sources: &[(String, String)],
4566) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4567 let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
4568 let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
4569 let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
4570 let source_nodes = unique_sources
4571 .iter()
4572 .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
4573 .cloned()
4574 .collect::<Vec<_>>();
4575 let source_nodes_by_id = source_nodes
4576 .iter()
4577 .cloned()
4578 .map(|node| (node.node_id.clone(), node))
4579 .collect::<HashMap<_, _>>();
4580 let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
4581
4582 for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
4583 let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
4584 let sql = format!(
4585 "SELECT e.source_node,
4586 e.target_file, e.target_symbol, e.line,
4587 r.byte_start, r.byte_end, r.status, e.provenance,
4588 CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
4589 tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4590 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4591 tgt_file.lang
4592 FROM edges e
4593 JOIN refs r ON r.ref_id = e.ref_id
4594 LEFT JOIN nodes tgt ON tgt.id = e.target_node
4595 LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
4596 WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
4597 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
4598 );
4599 let bindings = chunk.iter().map(|node| node.node_id.as_str());
4600 let mut stmt = conn.prepare(&sql)?;
4601 let rows = stmt.query_map(params_from_iter(bindings), |row| {
4602 let source_node_id = row.get::<_, String>(0)?;
4603 let caller = source_nodes_by_id
4604 .get(&source_node_id)
4605 .expect("batched outgoing row belongs to a requested source node")
4606 .clone();
4607 let target = optional_store_node_from_row_at(row, 8)?;
4608 Ok((
4609 source_node_id,
4610 StoreCallSite {
4611 caller,
4612 target_file: row.get(1)?,
4613 target_symbol: row.get(2)?,
4614 target,
4615 line: row.get::<_, i64>(3)?.max(0) as u32,
4616 byte_start: row.get::<_, i64>(4)?.max(0) as usize,
4617 byte_end: row.get::<_, i64>(5)?.max(0) as usize,
4618 resolved: row.get::<_, String>(6)? == "resolved",
4619 provenance: row.get(7)?,
4620 },
4621 ))
4622 })?;
4623 for row in rows {
4624 let (source_node_id, call) = row?;
4625 calls_by_node.entry(source_node_id).or_default().push(call);
4626 }
4627 }
4628
4629 let mut calls_by_source = HashMap::new();
4630 for source in &unique_sources {
4631 let mut calls = Vec::new();
4632 if let Some(nodes) = source_nodes_by_symbol.get(source) {
4633 for node in nodes {
4634 if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
4635 calls.extend(node_calls);
4636 }
4637 }
4638 }
4639 calls_by_source.insert(source.clone(), calls);
4640 }
4641
4642 let target_tuples = calls_by_source
4645 .values()
4646 .flatten()
4647 .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
4648 .collect::<Vec<_>>();
4649 let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
4650 for calls in calls_by_source.values_mut() {
4651 for call in calls {
4652 if let Some(target) = target_nodes
4653 .get(&(call.target_file.clone(), call.target_symbol.clone()))
4654 .and_then(|nodes| nodes.first())
4655 {
4656 call.target = Some(target.clone());
4657 }
4658 }
4659 }
4660
4661 Ok(calls_by_source)
4662}
4663
4664fn nodes_for_symbol_tuples(
4665 conn: &Connection,
4666 symbols: &[(String, String)],
4667) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
4668 let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
4669 let mut nodes_by_symbol = unique_symbols
4670 .iter()
4671 .cloned()
4672 .map(|symbol| (symbol, Vec::new()))
4673 .collect::<HashMap<_, _>>();
4674 let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
4675
4676 for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
4677 let requested_values = (0..chunk.len())
4678 .map(|_| "(?, ?)")
4679 .collect::<Vec<_>>()
4680 .join(", ");
4681 let sql = format!(
4682 "WITH requested(file, symbol) AS (VALUES {requested_values})
4683 SELECT requested.file, requested.symbol,
4684 node.id, node.file_path, node.scoped_name, node.name, node.kind,
4685 node.start_line, node.end_line, node.signature, node.exported,
4686 node.is_callgraph_entry_point, node_file.lang
4687 FROM requested
4688 JOIN nodes node INDEXED BY idx_nodes_file
4689 ON node.file_path = requested.file
4690 AND node.scoped_name = requested.symbol
4691 JOIN files node_file ON node_file.path = node.file_path
4692 ORDER BY requested.file, requested.symbol,
4693 node.scoped_name, node.start_line, node.end_line,
4694 node.start_col, node.range_ordinal"
4695 );
4696 let bindings = chunk
4697 .iter()
4698 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4699 let mut stmt = conn.prepare(&sql)?;
4700 let rows = stmt.query_map(params_from_iter(bindings), |row| {
4701 Ok((
4702 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
4703 store_node_from_row_at(row, 2)?,
4704 ))
4705 })?;
4706 for row in rows {
4707 let (symbol, node) = row?;
4708 nodes_by_symbol.entry(symbol).or_default().push(node);
4709 }
4710 }
4711
4712 Ok(nodes_by_symbol)
4713}
4714
4715fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4716 let mut stmt = conn.prepare(
4717 "SELECT e.target_file, e.target_symbol, e.line,
4718 r.byte_start, r.byte_end, r.status, e.provenance,
4719 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4720 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4721 tgt_file.lang
4722 FROM edges e
4723 JOIN refs r ON r.ref_id = e.ref_id
4724 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4725 ON tgt.id = e.target_node
4726 WHERE e.kind = 'call' AND e.source_node = ?1
4727 ORDER BY r.byte_start, r.line, r.ref_id",
4728 )?;
4729 let rows = stmt.query_map(params![node.node_id], |row| {
4730 let target = optional_store_node_from_row_at(row, 7)?;
4731 Ok(StoreCallSite {
4732 caller: node.clone(),
4733 target_file: row.get(0)?,
4734 target_symbol: row.get(1)?,
4735 target,
4736 line: row.get::<_, i64>(2)?.max(0) as u32,
4737 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4738 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4739 resolved: row.get::<_, String>(5)? == "resolved",
4740 provenance: row.get(6)?,
4741 })
4742 })?;
4743 rows.collect::<std::result::Result<Vec<_>, _>>()
4744 .map_err(Into::into)
4745}
4746
4747fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4748 let mut stmt = conn.prepare(
4749 "SELECT r.target_file, r.target_symbol, r.line,
4750 r.byte_start, r.byte_end, r.status, r.provenance,
4751 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4752 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4753 tgt_file.lang
4754 FROM refs r
4755 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4756 ON tgt.id = r.target_node
4757 WHERE r.caller_node = ?1
4758 AND r.kind = 'call'
4759 AND r.status <> 'unresolved'
4760 AND r.target_file = ?2
4761 AND r.target_symbol = ?3
4762 AND r.provenance = ?4
4763 AND NOT EXISTS (
4764 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
4765 )
4766 ORDER BY r.byte_start, r.line, r.ref_id",
4767 )?;
4768 let rows = stmt.query_map(
4769 params![
4770 &node.node_id,
4771 &node.file,
4772 &node.symbol,
4773 PROVENANCE_TREESITTER
4774 ],
4775 |row| {
4776 let target = optional_store_node_from_row_at(row, 7)?;
4777 Ok(StoreCallSite {
4778 caller: node.clone(),
4779 target_file: row.get(0)?,
4780 target_symbol: row.get(1)?,
4781 target,
4782 line: row.get::<_, i64>(2)?.max(0) as u32,
4783 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4784 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4785 resolved: row.get::<_, String>(5)? == "resolved",
4786 provenance: row.get(6)?,
4787 })
4788 },
4789 )?;
4790 rows.collect::<std::result::Result<Vec<_>, _>>()
4791 .map_err(Into::into)
4792}
4793
4794fn unresolved_calls_for_node(
4795 conn: &Connection,
4796 node: &StoreNode,
4797) -> Result<Vec<StoreUnresolvedCall>> {
4798 let mut stmt = conn.prepare(
4799 "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
4800 FROM refs
4801 WHERE caller_node = ?1
4802 AND kind = 'call'
4803 AND status = 'unresolved'
4804 AND NOT EXISTS (
4805 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4806 )
4807 ORDER BY byte_start, line, ref_id",
4808 )?;
4809 let rows = stmt.query_map(params![node.node_id], |row| {
4810 Ok(StoreUnresolvedCall {
4811 caller: node.clone(),
4812 symbol: row.get(0)?,
4813 full_ref: row.get(1)?,
4814 line: row.get::<_, i64>(2)?.max(0) as u32,
4815 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4816 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4817 })
4818 })?;
4819 rows.collect::<std::result::Result<Vec<_>, _>>()
4820 .map_err(Into::into)
4821}
4822
4823fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
4824 let mut calls = Vec::new();
4825 calls.extend(
4826 outgoing_calls_for_node(conn, node)?
4827 .into_iter()
4828 .map(StoreForwardCall::Resolved),
4829 );
4830 calls.extend(
4831 unresolved_calls_for_node(conn, node)?
4832 .into_iter()
4833 .map(StoreForwardCall::Unresolved),
4834 );
4835 calls.sort_by(|left, right| {
4836 left.byte_start()
4837 .cmp(&right.byte_start())
4838 .then(left.line().cmp(&right.line()))
4839 });
4840 Ok(calls)
4841}
4842
4843fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
4844 let resolved_count: i64 = conn.query_row(
4845 "SELECT COUNT(*)
4846 FROM edges e
4847 JOIN refs r ON r.ref_id = e.ref_id
4848 WHERE e.kind = 'call' AND e.source_node = ?1",
4849 params![&node.node_id],
4850 |row| row.get(0),
4851 )?;
4852 let unresolved_count: i64 = conn.query_row(
4853 "SELECT COUNT(*)
4854 FROM refs
4855 WHERE caller_node = ?1
4856 AND kind = 'call'
4857 AND status = 'unresolved'
4858 AND NOT EXISTS (
4859 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4860 )",
4861 params![&node.node_id],
4862 |row| row.get(0),
4863 )?;
4864 let total = resolved_count.saturating_add(unresolved_count);
4865 Ok(usize::try_from(total).unwrap_or(usize::MAX))
4866}
4867
4868fn call_tree_inner(
4869 conn: &Connection,
4870 node: &StoreNode,
4871 max_depth: usize,
4872 current_depth: usize,
4873 visited: &mut HashSet<(String, String)>,
4874) -> Result<callgraph::CallTreeNode> {
4875 let visit_key = (node.file.clone(), node.symbol.clone());
4876 if visited.contains(&visit_key) {
4877 return Ok(callgraph::CallTreeNode {
4878 name: node.symbol.clone(),
4879 file: node.file.clone(),
4880 line: node.line,
4881 signature: node.signature.clone(),
4882 resolved: true,
4883 children: Vec::new(),
4884 depth_limited: false,
4885 truncated: 0,
4886 });
4887 }
4888 visited.insert(visit_key.clone());
4889
4890 let mut children = Vec::new();
4891 let mut depth_limited = false;
4892 let mut truncated = 0usize;
4893
4894 if current_depth < max_depth {
4895 let calls = forward_calls_for_node(conn, node)?;
4896 for call in calls {
4897 match call {
4898 StoreForwardCall::Resolved(site) => {
4899 if let Some(target) = site.target {
4900 let child =
4901 call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
4902 depth_limited |= child.depth_limited;
4903 truncated += child.truncated;
4904 children.push(child);
4905 } else {
4906 children.push(callgraph::CallTreeNode {
4907 name: site.target_symbol,
4908 file: site.target_file,
4909 line: site.line,
4910 signature: None,
4911 resolved: false,
4912 children: Vec::new(),
4913 depth_limited: false,
4914 truncated: 0,
4915 });
4916 }
4917 }
4918 StoreForwardCall::Unresolved(call) => {
4919 children.push(callgraph::CallTreeNode {
4920 name: call.symbol,
4921 file: call.caller.file,
4922 line: call.line,
4923 signature: None,
4924 resolved: false,
4925 children: Vec::new(),
4926 depth_limited: false,
4927 truncated: 0,
4928 });
4929 }
4930 }
4931 }
4932 } else {
4933 truncated = forward_call_count_for_node(conn, node)?;
4934 depth_limited = truncated > 0;
4935 }
4936
4937 visited.remove(&visit_key);
4938 Ok(callgraph::CallTreeNode {
4939 name: node.symbol.clone(),
4940 file: node.file.clone(),
4941 line: node.line,
4942 signature: node.signature.clone(),
4943 resolved: true,
4944 children,
4945 depth_limited,
4946 truncated,
4947 })
4948}
4949
4950fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
4951 callgraph::TraceToSymbolHop {
4952 symbol: node.symbol.clone(),
4953 file: node.file.clone(),
4954 line: node.line,
4955 }
4956}
4957
4958fn trace_to_symbol_matches_target(
4959 node: &StoreNode,
4960 to_symbol: &str,
4961 to_file: Option<&str>,
4962) -> bool {
4963 if !symbol_query_matches(&node.symbol, to_symbol) {
4964 return false;
4965 }
4966 match to_file {
4967 Some(file) => node.file == file,
4968 None => true,
4969 }
4970}
4971
4972fn symbol_query_matches(symbol: &str, query: &str) -> bool {
4973 symbol == query || unqualified_name(symbol) == query
4974}
4975
4976fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
4977 let source = std::fs::read_to_string(path).ok()?;
4978 Some(source.lines().map(|line| line.trim().to_string()).collect())
4979}
4980
4981#[doc(hidden)]
4982pub fn live_callgraph_edge_snapshot(
4983 project_root: &Path,
4984 files: &[PathBuf],
4985) -> Result<BTreeSet<StoredEdge>> {
4986 let files = normalize_file_list(project_root, files)?;
4987 let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
4988 let mut file_data = Vec::new();
4989 for file in &files {
4990 let canon = canonicalize_path(file);
4991 let data = graph.build_file(&canon)?.clone();
4992 file_data.push((canon, data));
4993 }
4994
4995 let mut edges = BTreeSet::new();
4996 for (caller_file, data) in &file_data {
4997 for (caller_symbol, call_sites) in &data.calls_by_symbol {
4998 for call_site in call_sites {
4999 let resolution = graph.resolve_cross_file_edge(
5000 &call_site.full_callee,
5001 &call_site.callee_name,
5002 caller_file,
5003 &data.import_block,
5004 );
5005 let (target_file, target_symbol) = match resolution {
5006 EdgeResolution::Resolved { file, symbol } => (file, symbol),
5007 EdgeResolution::Unresolved { callee_name } => {
5008 if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
5009 continue;
5010 }
5011 let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
5012 data,
5013 caller_file,
5014 &callee_name,
5015 ) else {
5016 continue;
5017 };
5018 (caller_file.clone(), target_symbol)
5019 }
5020 };
5021 if target_file == *caller_file && target_symbol == *caller_symbol {
5022 continue;
5023 }
5024 edges.insert(StoredEdge {
5025 source_file: relative_path(project_root, caller_file),
5026 source_symbol: caller_symbol.clone(),
5027 target_file: relative_path(project_root, &target_file),
5028 target_symbol,
5029 kind: "call".to_string(),
5030 line: call_site.line,
5031 });
5032 }
5033 }
5034 }
5035 Ok(edges)
5036}
5037
5038fn rebuild_cooldown_records() -> &'static Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>
5039{
5040 SUCCESSFUL_REBUILDS.get_or_init(|| Mutex::new(HashMap::new()))
5041}
5042
5043fn rebuild_cooldown_key(callgraph_dir: &Path, project_key: &str) -> RebuildCooldownKey {
5044 RebuildCooldownKey {
5045 callgraph_dir: std::fs::canonicalize(callgraph_dir)
5046 .unwrap_or_else(|_| callgraph_dir.to_path_buf()),
5047 project_key: project_key.to_string(),
5048 }
5049}
5050
5051fn rebuild_cooldown_denial(
5052 callgraph_dir: &Path,
5053 project_key: &str,
5054 project_root: &Path,
5055 now: Instant,
5056) -> Option<(PathBuf, Duration)> {
5057 let key = rebuild_cooldown_key(callgraph_dir, project_key);
5058 let records = rebuild_cooldown_records()
5059 .lock()
5060 .unwrap_or_else(std::sync::PoisonError::into_inner);
5061 let record = records.get(&key)?;
5062 if record.project_root == project_root || !record.cross_root_cooldown_armed {
5063 return None;
5064 }
5065 let elapsed = now.saturating_duration_since(record.published_at);
5066 (elapsed < REBUILD_COOLDOWN).then(|| (record.project_root.clone(), REBUILD_COOLDOWN - elapsed))
5067}
5068
5069fn record_successful_rebuild(
5070 callgraph_dir: &Path,
5071 project_key: &str,
5072 project_root: &Path,
5073 published_at: Instant,
5074) {
5075 let key = rebuild_cooldown_key(callgraph_dir, project_key);
5076 let mut records = rebuild_cooldown_records()
5077 .lock()
5078 .unwrap_or_else(std::sync::PoisonError::into_inner);
5079 if records.len() >= 4_096 && !records.contains_key(&key) {
5080 if let Some(evict) = records.keys().next().cloned() {
5081 records.remove(&evict);
5082 }
5083 }
5084 let cross_root_cooldown_armed = records.get(&key).is_some_and(|previous| {
5085 previous.cross_root_cooldown_armed || previous.project_root != project_root
5086 });
5087 records.insert(
5088 key,
5089 RebuildCooldownRecord {
5090 project_root: project_root.to_path_buf(),
5091 published_at,
5092 cross_root_cooldown_armed,
5093 },
5094 );
5095}
5096
5097fn acquire_writer_lease(
5098 callgraph_dir: &Path,
5099 project_key: &str,
5100 project_root: &Path,
5101) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
5102 crate::root_cache::WriterLease::acquire_shared(
5103 crate::root_cache::RootCacheDomain::Callgraph,
5104 callgraph_dir,
5105 project_key,
5106 project_root,
5107 )
5108 .map_err(CallGraphStoreError::from)
5109}
5110
5111fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
5112 if lease.verify()? {
5113 Ok(())
5114 } else {
5115 Err(CallGraphStoreError::Unavailable(format!(
5116 "callgraph writer lease for key {} lost epoch {}; aborting write",
5117 lease.key(),
5118 lease.epoch()
5119 )))
5120 }
5121}
5122
5123fn legacy_migration_completion_line(
5124 project_key: &str,
5125 method: &str,
5126 legacy_bytes: u64,
5127 migrated_bytes: u64,
5128) -> String {
5129 format!(
5130 "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
5131 )
5132}
5133
5134fn log_legacy_migration_completion(
5135 project_key: &str,
5136 method: &str,
5137 legacy_bytes: u64,
5138 migrated_bytes: u64,
5139) {
5140 crate::slog_info!(
5141 "{}",
5142 legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
5143 );
5144}
5145
5146fn try_legacy_migration_or_fallback(
5147 callgraph_dir: &Path,
5148 project_root: &Path,
5149 project_key: &str,
5150 writer_lease: Arc<crate::root_cache::WriterLease>,
5151) -> Result<Option<CallGraphStore>> {
5152 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
5153 if partitions.is_empty() {
5154 return Ok(None);
5155 }
5156
5157 for partition in &partitions {
5158 if let Some(source) = newest_superseded_legacy_generation(partition)? {
5159 if !migration_disk_floor_allows(&source, callgraph_dir)? {
5160 return open_legacy_fallback_store(
5161 callgraph_dir,
5162 project_root,
5163 project_key,
5164 &partitions,
5165 );
5166 }
5167 match publish_generation_copy_migration(
5168 callgraph_dir,
5169 project_key,
5170 &source,
5171 Arc::clone(&writer_lease),
5172 ) {
5173 Ok(published) => {
5174 log_legacy_migration_completion(
5175 project_key,
5176 "generation_copy",
5177 source.source_bytes,
5178 published.migrated_bytes,
5179 );
5180 return CallGraphStore::open_generation(
5181 callgraph_dir,
5182 project_root.to_path_buf(),
5183 project_key.to_string(),
5184 published.generation,
5185 writer_lease,
5186 )
5187 .map(Some);
5188 }
5189 Err(error) => {
5190 crate::slog_warn!(
5191 "root-keyed callgraph generation-copy migration failed from {}: {}",
5192 source.sqlite_path.display(),
5193 error
5194 );
5195 return open_legacy_fallback_store(
5196 callgraph_dir,
5197 project_root,
5198 project_key,
5199 &partitions,
5200 );
5201 }
5202 }
5203 }
5204
5205 if let Some(source) = current_legacy_generation(partition)? {
5206 if !migration_disk_floor_allows(&source, callgraph_dir)? {
5207 return open_legacy_fallback_store(
5208 callgraph_dir,
5209 project_root,
5210 project_key,
5211 &partitions,
5212 );
5213 }
5214 match publish_backup_migration(
5215 callgraph_dir,
5216 project_key,
5217 &source,
5218 Arc::clone(&writer_lease),
5219 ) {
5220 Ok(published) => {
5221 log_legacy_migration_completion(
5222 project_key,
5223 "sqlite_backup",
5224 source.source_bytes,
5225 published.migrated_bytes,
5226 );
5227 return CallGraphStore::open_generation(
5228 callgraph_dir,
5229 project_root.to_path_buf(),
5230 project_key.to_string(),
5231 published.generation,
5232 writer_lease,
5233 )
5234 .map(Some);
5235 }
5236 Err(error) => {
5237 crate::slog_warn!(
5238 "root-keyed callgraph backup migration failed from {}: {}",
5239 source.sqlite_path.display(),
5240 error
5241 );
5242 return open_legacy_fallback_store(
5243 callgraph_dir,
5244 project_root,
5245 project_key,
5246 &partitions,
5247 );
5248 }
5249 }
5250 }
5251 }
5252
5253 open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
5254}
5255
5256fn open_legacy_fallback_store(
5257 callgraph_dir: &Path,
5258 project_root: &Path,
5259 project_key: &str,
5260 partitions: &[LegacyCallgraphPartition],
5261) -> Result<Option<CallGraphStore>> {
5262 let Some(target) = first_ready_legacy_target(partitions)? else {
5263 return Ok(None);
5264 };
5265 crate::slog_warn!(
5266 "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
5267 target.partition.harness,
5268 target.sqlite_path.display()
5269 );
5270 let conn = open_readonly_connection(&target.sqlite_path)?;
5271 if !database_ready(&conn).unwrap_or(false) {
5272 return Ok(None);
5273 }
5274 let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
5275 let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
5276 Ok(Some(CallGraphStore::from_connection(
5277 project_root.to_path_buf(),
5278 project_key.to_string(),
5279 target.sqlite_path,
5280 callgraph_dir.to_path_buf(),
5281 true,
5282 target.generation,
5283 None,
5284 Some(read_marker),
5285 conn,
5286 )))
5287}
5288
5289fn migration_disk_floor_allows(
5290 source: &LegacyCallgraphTarget,
5291 callgraph_dir: &Path,
5292) -> Result<bool> {
5293 let available = migration_available_disk(callgraph_dir)?;
5294 let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
5295 source.source_bytes,
5296 available,
5297 );
5298 if decision.should_skip_copy() {
5299 crate::slog_warn!(
5300 "{}",
5301 decision.warning_message(&source.sqlite_path, callgraph_dir)
5302 );
5303 return Ok(false);
5304 }
5305 Ok(true)
5306}
5307
5308fn migration_available_disk(path: &Path) -> Result<u64> {
5309 if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
5310 return Ok(bytes);
5311 }
5312 crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
5313}
5314
5315fn legacy_callgraph_partitions(
5316 callgraph_dir: &Path,
5317 project_key: &str,
5318) -> Result<Vec<LegacyCallgraphPartition>> {
5319 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5320 return Ok(Vec::new());
5321 };
5322 let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
5323 let mut partitions = inventory
5324 .into_iter()
5325 .filter(|entry| {
5326 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
5327 && entry.key == project_key
5328 })
5329 .map(|entry| {
5330 let dir = if entry.path.is_dir() {
5331 entry.path.clone()
5332 } else {
5333 entry
5334 .path
5335 .parent()
5336 .map(Path::to_path_buf)
5337 .unwrap_or_else(|| entry.path.clone())
5338 };
5339 LegacyCallgraphPartition {
5340 harness: entry.harness,
5341 dir,
5342 key: entry.key,
5343 bytes: entry.bytes,
5344 freshness: entry.callgraph_pointer_mtime,
5345 }
5346 })
5347 .collect::<Vec<_>>();
5348 partitions.sort_by(|left, right| {
5349 right
5350 .freshness
5351 .cmp(&left.freshness)
5352 .then_with(|| right.bytes.cmp(&left.bytes))
5353 .then_with(|| left.harness.cmp(&right.harness))
5354 });
5355 Ok(partitions)
5356}
5357
5358fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
5359 let domain_dir = callgraph_dir.parent()?;
5360 if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
5361 return None;
5362 }
5363 domain_dir.parent().map(Path::to_path_buf)
5364}
5365
5366pub(crate) fn all_legacy_partitions_migrated_for_keys(
5367 callgraph_dir: &Path,
5368 configured_keys: &BTreeSet<String>,
5369) -> Result<bool> {
5370 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5371 return Ok(false);
5372 };
5373 let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
5374 .into_iter()
5375 .filter(|entry| {
5376 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
5377 && configured_keys.contains(&entry.key)
5378 })
5379 .map(|entry| entry.key)
5380 .collect::<BTreeSet<_>>();
5381 if legacy_keys.is_empty() {
5382 return Ok(false);
5383 }
5384
5385 for key in legacy_keys {
5386 let migrated_dir = storage_root.join("callgraph").join(&key);
5387 let Some(generation) = read_pointer(&migrated_dir, &key) else {
5388 return Ok(false);
5389 };
5390 if !migration_generation_requires_manifest(&generation)
5391 || !migration_manifest_valid(&migrated_dir, &generation)
5392 {
5393 return Ok(false);
5394 }
5395 }
5396 Ok(true)
5397}
5398
5399fn newest_superseded_legacy_generation(
5400 partition: &LegacyCallgraphPartition,
5401) -> Result<Option<LegacyCallgraphTarget>> {
5402 let Some(current) = read_pointer(&partition.dir, &partition.key) else {
5403 return Ok(None);
5404 };
5405 let prefix = format!("{}.g", partition.key);
5406 let Ok(entries) = std::fs::read_dir(&partition.dir) else {
5407 return Ok(None);
5408 };
5409 let mut candidates = Vec::new();
5410 for entry in entries.flatten() {
5411 let name = entry.file_name().to_string_lossy().to_string();
5412 if name == current
5413 || name.contains(".tmp.")
5414 || !name.starts_with(&prefix)
5415 || !name.ends_with(".sqlite")
5416 {
5417 continue;
5418 }
5419 let path = entry.path();
5420 if !db_path_ready(&path) {
5421 continue;
5422 }
5423 let modified = entry
5424 .metadata()
5425 .and_then(|metadata| metadata.modified())
5426 .unwrap_or(SystemTime::UNIX_EPOCH);
5427 candidates.push((modified, path, name));
5428 }
5429 candidates.sort_by(|left, right| right.0.cmp(&left.0));
5430 let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
5431 return Ok(None);
5432 };
5433 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5434 Ok(Some(LegacyCallgraphTarget {
5435 partition: partition.clone(),
5436 sqlite_path,
5437 generation: Some(generation),
5438 source_bytes,
5439 source_blake3: String::new(),
5440 }))
5441}
5442
5443fn current_legacy_generation(
5444 partition: &LegacyCallgraphPartition,
5445) -> Result<Option<LegacyCallgraphTarget>> {
5446 let Some(target) = ready_legacy_target(partition)? else {
5447 return Ok(None);
5448 };
5449 let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
5450 if has_superseded {
5451 return Ok(None);
5452 }
5453 Ok(Some(target))
5454}
5455
5456fn freshest_legacy_fallback_target(
5457 callgraph_dir: &Path,
5458 project_key: &str,
5459) -> Result<Option<LegacyCallgraphTarget>> {
5460 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
5461 first_ready_legacy_target(&partitions)
5462}
5463
5464fn first_ready_legacy_target(
5465 partitions: &[LegacyCallgraphPartition],
5466) -> Result<Option<LegacyCallgraphTarget>> {
5467 for partition in partitions {
5468 if let Some(target) = ready_legacy_target(partition)? {
5469 return Ok(Some(target));
5470 }
5471 }
5472 Ok(None)
5473}
5474
5475fn ready_legacy_target(
5476 partition: &LegacyCallgraphPartition,
5477) -> Result<Option<LegacyCallgraphTarget>> {
5478 if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
5479 let sqlite_path = partition.dir.join(&generation);
5480 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
5481 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5482 return Ok(Some(LegacyCallgraphTarget {
5483 partition: partition.clone(),
5484 sqlite_path,
5485 generation: Some(generation),
5486 source_bytes,
5487 source_blake3: String::new(),
5488 }));
5489 }
5490 }
5491
5492 let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
5493 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
5494 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5495 return Ok(Some(LegacyCallgraphTarget {
5496 partition: partition.clone(),
5497 sqlite_path,
5498 generation: None,
5499 source_bytes,
5500 source_blake3: String::new(),
5501 }));
5502 }
5503 Ok(None)
5504}
5505
5506fn publish_generation_copy_migration(
5507 callgraph_dir: &Path,
5508 project_key: &str,
5509 source: &LegacyCallgraphTarget,
5510 writer_lease: Arc<crate::root_cache::WriterLease>,
5511) -> Result<PublishedLegacyMigration> {
5512 let generation = migration_generation_file_name(project_key, "copy");
5513 let temp_path = migration_temp_path(callgraph_dir, &generation);
5514 remove_sqlite_file_set(&temp_path);
5515 copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
5516 fail_after_temp_copy_for_test()?;
5517
5518 let mut source = source.clone();
5519 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
5520 source.source_blake3 = fingerprint.blake3;
5521 let generation = publish_migrated_generation(
5522 callgraph_dir,
5523 project_key,
5524 &generation,
5525 &temp_path,
5526 &source,
5527 fingerprint.bytes,
5528 writer_lease,
5529 "generation_copy",
5530 )?;
5531 Ok(PublishedLegacyMigration {
5532 generation,
5533 migrated_bytes: fingerprint.bytes,
5534 })
5535}
5536
5537fn publish_backup_migration(
5538 callgraph_dir: &Path,
5539 project_key: &str,
5540 source: &LegacyCallgraphTarget,
5541 writer_lease: Arc<crate::root_cache::WriterLease>,
5542) -> Result<PublishedLegacyMigration> {
5543 if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
5544 return Err(CallGraphStoreError::Unavailable(
5545 "legacy callgraph backup migration budget exhausted by test seam".to_string(),
5546 ));
5547 }
5548
5549 let generation = migration_generation_file_name(project_key, "backup");
5550 let temp_path = migration_temp_path(callgraph_dir, &generation);
5551 remove_sqlite_file_set(&temp_path);
5552
5553 let source_conn = open_readonly_connection(&source.sqlite_path)?;
5554 let mut destination = Connection::open(&temp_path)?;
5555 destination.busy_timeout(Duration::from_secs(5))?;
5556 let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
5557 let started = Instant::now();
5558 let mut retries = 0;
5559 loop {
5560 match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
5561 rusqlite::backup::StepResult::Done => break,
5562 rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
5563 rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
5564 retries += 1;
5565 if retries > MIGRATION_BACKUP_RETRY_BUDGET
5566 || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
5567 {
5568 return Err(CallGraphStoreError::Unavailable(format!(
5569 "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
5570 )));
5571 }
5572 std::thread::sleep(Duration::from_millis(20));
5573 }
5574 _ => {
5575 return Err(CallGraphStoreError::Unavailable(
5576 "legacy callgraph backup returned an unknown step result".to_string(),
5577 ));
5578 }
5579 }
5580 }
5581 drop(backup);
5582
5583 let integrity: String =
5584 destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
5585 if integrity != "ok" {
5586 return Err(CallGraphStoreError::Unavailable(format!(
5587 "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
5588 )));
5589 }
5590 if !database_ready(&destination)? {
5591 return Err(CallGraphStoreError::Unavailable(
5592 "legacy callgraph backup produced a database without ready metadata".to_string(),
5593 ));
5594 }
5595 destination.execute_batch("PRAGMA optimize;")?;
5596 drop(destination);
5597 sync_file(&temp_path)?;
5598 fail_after_temp_copy_for_test()?;
5599
5600 let mut source = source.clone();
5601 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
5602 source.source_blake3 = fingerprint.blake3;
5603 let generation = publish_migrated_generation(
5604 callgraph_dir,
5605 project_key,
5606 &generation,
5607 &temp_path,
5608 &source,
5609 fingerprint.bytes,
5610 writer_lease,
5611 "sqlite_backup",
5612 )?;
5613 Ok(PublishedLegacyMigration {
5614 generation,
5615 migrated_bytes: fingerprint.bytes,
5616 })
5617}
5618
5619fn publish_migrated_generation(
5620 callgraph_dir: &Path,
5621 project_key: &str,
5622 generation: &str,
5623 temp_path: &Path,
5624 source: &LegacyCallgraphTarget,
5625 migrated_bytes: u64,
5626 writer_lease: Arc<crate::root_cache::WriterLease>,
5627 method: &str,
5628) -> Result<String> {
5629 let gen_path = callgraph_dir.join(generation);
5630 checkpoint_sqlite_before_publication(temp_path);
5631 let publication = publish_if_current(|| {
5632 verify_writer_lease(&writer_lease)?;
5633 remove_sqlite_file_set(&gen_path);
5634 rename_sqlite_file_set(temp_path, &gen_path)?;
5635 crate::fs_lock::sync_parent(&gen_path);
5636
5637 verify_writer_lease(&writer_lease)?;
5638 publish_pointer(callgraph_dir, project_key, generation)?;
5639 write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
5640 Ok(generation.to_string())
5641 });
5642 if matches!(publication, Err(CallGraphStoreError::Superseded)) {
5643 remove_sqlite_file_set(temp_path);
5644 }
5645 publication
5646}
5647
5648fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
5649 if let Some(parent) = destination.parent() {
5650 std::fs::create_dir_all(parent)?;
5651 }
5652 for suffix in SQLITE_FILE_SET_SUFFIXES {
5653 let source_path = sqlite_file_set_path(source, suffix);
5654 if !source_path.is_file() {
5655 continue;
5656 }
5657 let destination_path = sqlite_file_set_path(destination, suffix);
5658 std::fs::copy(&source_path, &destination_path)?;
5659 sync_file(&destination_path)?;
5660 }
5661 Ok(())
5662}
5663
5664fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
5665 for suffix in SQLITE_FILE_SET_SUFFIXES {
5666 let source_path = sqlite_file_set_path(source, suffix);
5667 if !source_path.exists() {
5668 continue;
5669 }
5670 let destination_path = sqlite_file_set_path(destination, suffix);
5671 if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
5672 let _ = std::fs::remove_file(&source_path);
5673 return Err(error.into());
5674 }
5675 }
5676 Ok(())
5677}
5678
5679fn sqlite_file_set_size(path: &Path) -> Result<u64> {
5680 let mut bytes = 0_u64;
5681 for suffix in SQLITE_FILE_SET_SUFFIXES {
5682 let member = sqlite_file_set_path(path, suffix);
5683 if !member.is_file() {
5684 continue;
5685 }
5686 bytes = bytes.saturating_add(member.metadata()?.len());
5687 }
5688 Ok(bytes)
5689}
5690
5691fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
5692 let mut hasher = blake3::Hasher::new();
5693 let mut bytes = 0_u64;
5694 let mut buffer = [0_u8; 64 * 1024];
5695 for suffix in SQLITE_FILE_SET_SUFFIXES {
5696 let member = sqlite_file_set_path(path, suffix);
5697 if !member.is_file() {
5698 continue;
5699 }
5700 hasher.update(suffix.as_bytes());
5701 let mut file = std::fs::File::open(&member)?;
5702 loop {
5703 let read = file.read(&mut buffer)?;
5704 if read == 0 {
5705 break;
5706 }
5707 bytes = bytes.saturating_add(read as u64);
5708 hasher.update(&buffer[..read]);
5709 }
5710 }
5711 Ok(SourceFingerprint {
5712 bytes,
5713 blake3: hash_to_hex(hasher.finalize()),
5714 })
5715}
5716
5717fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
5718 if suffix.is_empty() {
5719 path.to_path_buf()
5720 } else {
5721 PathBuf::from(format!("{}{suffix}", path.display()))
5722 }
5723}
5724
5725fn sync_file(path: &Path) -> Result<()> {
5726 let file = std::fs::OpenOptions::new()
5727 .read(true)
5728 .write(true)
5729 .open(path)?;
5730 file.sync_all()?;
5731 Ok(())
5732}
5733
5734fn fail_after_temp_copy_for_test() -> Result<()> {
5735 if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
5736 return Err(CallGraphStoreError::Unavailable(
5737 "legacy callgraph migration stopped after temp copy by test seam".to_string(),
5738 ));
5739 }
5740 Ok(())
5741}
5742
5743fn migration_generation_file_name(project_key: &str, method: &str) -> String {
5744 format!(
5745 "{project_key}.g{}.{}{}{}.sqlite",
5746 now_nanos(),
5747 std::process::id(),
5748 MIGRATION_GENERATION_TAG,
5749 method
5750 )
5751}
5752
5753fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
5754 callgraph_dir.join(format!(
5755 "{generation}.tmp.{}.{}",
5756 std::process::id(),
5757 now_nanos()
5758 ))
5759}
5760
5761fn write_migration_manifest(
5762 callgraph_dir: &Path,
5763 generation: &str,
5764 source: &LegacyCallgraphTarget,
5765 migrated_bytes: u64,
5766 method: &str,
5767) -> Result<()> {
5768 let manifest_path = migration_manifest_path(callgraph_dir, generation);
5769 let temp_path = manifest_path.with_extension(format!(
5770 "migration.json.tmp.{}.{}",
5771 std::process::id(),
5772 now_nanos()
5773 ));
5774 let manifest = serde_json::json!({
5775 "version": MIGRATION_MANIFEST_VERSION,
5776 "method": method,
5777 "target_generation": generation,
5778 "source_harness": source.partition.harness,
5779 "source_path": source.sqlite_path.display().to_string(),
5780 "source_generation": source.generation,
5781 "source_bytes": source.source_bytes,
5782 "source_blake3": source.source_blake3,
5783 "migrated_bytes": migrated_bytes,
5784 });
5785 {
5786 use std::io::Write as _;
5787 let mut file = std::fs::File::create(&temp_path)?;
5788 file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
5789 file.write_all(b"\n")?;
5790 file.sync_all()?;
5791 }
5792 if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
5793 let _ = std::fs::remove_file(&temp_path);
5794 return Err(error.into());
5795 }
5796 crate::fs_lock::sync_parent(&manifest_path);
5797 Ok(())
5798}
5799
5800fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
5801 callgraph_dir.join(format!("{generation}.migration.json"))
5802}
5803
5804fn migration_generation_requires_manifest(generation: &str) -> bool {
5805 generation.contains(MIGRATION_GENERATION_TAG)
5806}
5807
5808fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
5809 if !migration_generation_requires_manifest(generation) {
5810 return true;
5811 }
5812 let path = migration_manifest_path(callgraph_dir, generation);
5813 let Ok(bytes) = std::fs::read(path) else {
5814 return false;
5815 };
5816 let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
5817 return false;
5818 };
5819 value.get("version").and_then(serde_json::Value::as_u64)
5820 == Some(MIGRATION_MANIFEST_VERSION as u64)
5821 && value
5822 .get("target_generation")
5823 .and_then(serde_json::Value::as_str)
5824 == Some(generation)
5825 && value
5826 .get("source_bytes")
5827 .and_then(serde_json::Value::as_u64)
5828 .is_some_and(|bytes| bytes > 0)
5829 && value
5830 .get("source_blake3")
5831 .and_then(serde_json::Value::as_str)
5832 .is_some_and(|hash| hash.len() == 64)
5833}
5834
5835fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
5836 let pointer_generation = read_pointer(callgraph_dir, project_key);
5837 if let Some(generation) = pointer_generation.as_deref() {
5838 if migration_generation_requires_manifest(generation)
5839 && !migration_manifest_valid(callgraph_dir, generation)
5840 {
5841 let path = callgraph_dir.join(generation);
5842 remove_sqlite_file_set(&path);
5843 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
5844 let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
5845 }
5846 }
5847
5848 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
5849 return;
5850 };
5851 for entry in entries.flatten() {
5852 let name = entry.file_name().to_string_lossy().to_string();
5853 let path = entry.path();
5854 if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
5855 let _ = std::fs::remove_file(path);
5856 continue;
5857 }
5858 if name.starts_with(&format!("{project_key}.g"))
5859 && name.ends_with(".sqlite")
5860 && name.contains(MIGRATION_GENERATION_TAG)
5861 && pointer_generation.as_deref() != Some(&name)
5862 && !migration_manifest_valid(callgraph_dir, &name)
5863 {
5864 remove_sqlite_file_set(&path);
5865 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
5866 }
5867 }
5868 crate::fs_lock::sync_parent(callgraph_dir);
5869}
5870
5871fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
5872 let mut hasher = blake3::Hasher::new();
5873 hasher.update(path.to_string_lossy().as_bytes());
5874 if let Some(generation) = generation {
5875 hasher.update(generation.as_bytes());
5876 }
5877 let digest = hash_to_hex(hasher.finalize());
5878 format!("legacy-{}", &digest[..16])
5879}
5880
5881fn open_readonly_connection(path: &Path) -> Result<Connection> {
5882 let uri = sqlite_readonly_uri(path);
5883 let conn = Connection::open_with_flags(
5884 &uri,
5885 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
5886 )?;
5887 conn.pragma_update(
5888 None,
5889 "synchronous",
5890 if write_amplification_baseline_enabled() {
5891 "FULL"
5892 } else {
5893 "NORMAL"
5894 },
5895 )?;
5896 conn.busy_timeout(reader_busy_timeout())?;
5897 conn.execute_batch("PRAGMA query_only=ON;")?;
5898 Ok(conn)
5899}
5900
5901fn reader_busy_timeout() -> Duration {
5902 let jitter = (now_nanos() % 500) as u64;
5903 Duration::from_millis(250 + jitter)
5904}
5905
5906fn sqlite_readonly_uri(path: &Path) -> String {
5907 let raw = path.to_string_lossy().replace('\\', "/");
5908 let encoded = percent_encode_sqlite_uri_path(&raw);
5909 if raw.starts_with('/') {
5910 format!("file://{encoded}?mode=ro")
5911 } else if raw.as_bytes().get(1) == Some(&b':') {
5912 format!("file:///{encoded}?mode=ro")
5913 } else {
5914 format!("file:{encoded}?mode=ro")
5915 }
5916}
5917
5918fn percent_encode_sqlite_uri_path(path: &str) -> String {
5919 let mut encoded = String::with_capacity(path.len());
5920 for byte in path.bytes() {
5921 match byte {
5922 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
5923 encoded.push(byte as char)
5924 }
5925 _ => encoded.push_str(&format!("%{byte:02X}")),
5926 }
5927 }
5928 encoded
5929}
5930
5931fn configure_connection(conn: &Connection) -> Result<()> {
5932 conn.pragma_update(None, "journal_mode", "WAL")?;
5933 let baseline = write_amplification_baseline_enabled();
5934 conn.pragma_update(
5935 None,
5936 "synchronous",
5937 if baseline { "FULL" } else { "NORMAL" },
5938 )?;
5939 conn.pragma_update(
5940 None,
5941 "wal_autocheckpoint",
5942 if baseline {
5943 1_000
5944 } else {
5945 CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES
5946 },
5947 )?;
5948 conn.pragma_update(None, "busy_timeout", 5_000)?;
5949 Ok(())
5950}
5951
5952fn configure_build_connection(conn: &Connection) -> Result<()> {
5953 conn.pragma_update(None, "journal_mode", "DELETE")?;
5954 conn.pragma_update(
5955 None,
5956 "synchronous",
5957 if write_amplification_baseline_enabled() {
5958 "FULL"
5959 } else {
5960 "NORMAL"
5961 },
5962 )?;
5963 conn.pragma_update(None, "busy_timeout", 5_000)?;
5964 Ok(())
5965}
5966
5967fn checkpoint_sqlite_before_publication(path: &Path) {
5971 let Ok(conn) = Connection::open(path) else {
5972 return;
5973 };
5974 let _ = conn.pragma_update(None, "synchronous", "NORMAL");
5975 let _ = conn.busy_timeout(Duration::from_secs(5));
5976 let _ = checkpoint_wal_truncate(&conn);
5977}
5978
5979fn checkpoint_wal_truncate(conn: &Connection) -> bool {
5980 match conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
5981 row.get::<_, i64>(0)
5982 }) {
5983 Ok(0) => true,
5984 Ok(_) => false,
5985 Err(rusqlite::Error::SqliteFailure(error, _))
5986 if matches!(
5987 error.code,
5988 rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
5989 ) =>
5990 {
5991 false
5992 }
5993 Err(error) => {
5994 log::debug!("callgraph WAL truncate checkpoint skipped: {error}");
5995 false
5996 }
5997 }
5998}
5999
6000fn initialize_schema(conn: &Connection) -> Result<()> {
6001 conn.execute_batch(
6002 "CREATE TABLE IF NOT EXISTS files (
6003 path TEXT PRIMARY KEY,
6004 content_hash TEXT NOT NULL,
6005 mtime_ns INTEGER NOT NULL,
6006 size INTEGER NOT NULL,
6007 lang TEXT NOT NULL,
6008 is_dead_code_root INTEGER NOT NULL DEFAULT 0,
6009 is_public_api INTEGER NOT NULL DEFAULT 0,
6010 surface_fingerprint TEXT NOT NULL,
6011 indexed_at INTEGER NOT NULL
6012 );
6013
6014 CREATE TABLE IF NOT EXISTS nodes (
6015 id TEXT PRIMARY KEY,
6016 file_path TEXT NOT NULL,
6017 name TEXT NOT NULL,
6018 scoped_name TEXT NOT NULL,
6019 kind TEXT NOT NULL,
6020 start_line INTEGER NOT NULL,
6021 start_col INTEGER NOT NULL,
6022 end_line INTEGER NOT NULL,
6023 end_col INTEGER NOT NULL,
6024 range_ordinal INTEGER NOT NULL,
6025 signature TEXT,
6026 exported INTEGER NOT NULL,
6027 is_default_export INTEGER NOT NULL,
6028 is_type_like INTEGER NOT NULL,
6029 is_callgraph_entry_point INTEGER NOT NULL,
6030 provenance TEXT NOT NULL,
6031 UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
6032 );
6033 CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
6034 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
6035 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
6036
6037 CREATE TABLE IF NOT EXISTS refs (
6038 ref_id TEXT PRIMARY KEY,
6039 caller_node TEXT,
6040 caller_file TEXT NOT NULL,
6041 kind TEXT NOT NULL,
6042 short_name TEXT,
6043 full_ref TEXT,
6044 module_path TEXT,
6045 import_kind TEXT,
6046 local_name TEXT,
6047 requested_name TEXT,
6048 namespace_alias TEXT,
6049 wildcard INTEGER NOT NULL DEFAULT 0,
6050 line INTEGER NOT NULL,
6051 byte_start INTEGER NOT NULL,
6052 byte_end INTEGER NOT NULL,
6053 status TEXT NOT NULL,
6054 target_node TEXT,
6055 target_file TEXT,
6056 target_symbol TEXT,
6057 provenance TEXT NOT NULL
6058 );
6059 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
6060 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
6061 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
6062 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
6063 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
6064
6065 CREATE TABLE IF NOT EXISTS file_dependencies (
6066 file_path TEXT NOT NULL,
6067 dep_file TEXT NOT NULL,
6068 PRIMARY KEY(file_path, dep_file)
6069 );
6070 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
6071
6072 CREATE TABLE IF NOT EXISTS edges (
6073 edge_id TEXT PRIMARY KEY,
6074 ref_id TEXT NOT NULL,
6075 source_node TEXT NOT NULL,
6076 target_node TEXT,
6077 target_file TEXT NOT NULL,
6078 target_symbol TEXT NOT NULL,
6079 kind TEXT NOT NULL,
6080 line INTEGER NOT NULL,
6081 provenance TEXT NOT NULL
6082 );
6083 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
6084 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
6085 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
6086 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
6087
6088 CREATE TABLE IF NOT EXISTS dispatch_hints (
6089 id TEXT PRIMARY KEY,
6090 method_name TEXT NOT NULL,
6091 caller_node TEXT NOT NULL,
6092 file TEXT NOT NULL,
6093 line INTEGER NOT NULL,
6094 byte_start INTEGER NOT NULL,
6095 byte_end INTEGER NOT NULL,
6096 provenance TEXT NOT NULL
6097 );
6098 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
6099 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
6100
6101 CREATE TABLE IF NOT EXISTS type_ref_names (
6102 name TEXT PRIMARY KEY
6103 );
6104
6105 CREATE TABLE IF NOT EXISTS backend_file_state (
6106 backend TEXT NOT NULL,
6107 workspace_root TEXT NOT NULL,
6108 file_path TEXT NOT NULL,
6109 content_hash TEXT NOT NULL,
6110 status TEXT NOT NULL,
6111 updated_at INTEGER NOT NULL,
6112 PRIMARY KEY(backend, workspace_root, file_path, content_hash)
6113 );
6114 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
6115
6116 CREATE TABLE IF NOT EXISTS meta (
6117 k TEXT PRIMARY KEY,
6118 v TEXT NOT NULL
6119 );",
6120 )?;
6121 insert_meta(conn)?;
6122 Ok(())
6123}
6124
6125fn insert_meta(conn: &Connection) -> Result<()> {
6126 conn.execute(
6127 "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
6128 params![SCHEMA_VERSION.to_string()],
6129 )?;
6130 conn.execute(
6131 "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
6132 params![schema_fingerprint()],
6133 )?;
6134 conn.execute(
6135 "INSERT OR IGNORE INTO meta(k, v) VALUES('projection_write_revision', '0')",
6136 [],
6137 )?;
6138 Ok(())
6139}
6140
6141fn projection_write_revision(conn: &Connection) -> Result<Option<u64>> {
6145 let revision: Option<String> = conn
6146 .query_row(
6147 "SELECT v FROM meta WHERE k = 'projection_write_revision'",
6148 [],
6149 |row| row.get(0),
6150 )
6151 .optional()?;
6152 revision
6153 .map(|revision| {
6154 revision.parse::<u64>().map_err(|error| {
6155 CallGraphStoreError::Unavailable(format!(
6156 "callgraph projection write revision is invalid: {error}"
6157 ))
6158 })
6159 })
6160 .transpose()
6161}
6162
6163fn bump_projection_write_revision(tx: &Transaction<'_>) -> Result<()> {
6166 tx.execute(
6167 "INSERT INTO meta(k, v) VALUES('projection_write_revision', '1')
6168 ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1",
6169 [],
6170 )?;
6171 Ok(())
6172}
6173
6174fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
6175 conn.execute(
6176 "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
6177 params![if ready { "1" } else { "0" }],
6178 )?;
6179 Ok(())
6180}
6181
6182fn database_ready(conn: &Connection) -> Result<bool> {
6183 let schema_version: Option<String> = conn
6184 .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
6185 row.get(0)
6186 })
6187 .optional()?;
6188 let fingerprint: Option<String> = conn
6189 .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
6190 row.get(0)
6191 })
6192 .optional()?;
6193 let ready: Option<String> = conn
6194 .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
6195 .optional()?;
6196
6197 let expected_schema = SCHEMA_VERSION.to_string();
6198 let expected_fingerprint = schema_fingerprint();
6199 Ok(schema_version.as_deref() == Some(expected_schema.as_str())
6200 && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
6201 && ready.as_deref() == Some("1"))
6202}
6203
6204fn ensure_database_ready(conn: &Connection) -> Result<()> {
6205 if database_ready(conn)? {
6206 Ok(())
6207 } else {
6208 Err(CallGraphStoreError::Unavailable(
6209 "database is missing, stale, or mid-build".to_string(),
6210 ))
6211 }
6212}
6213
6214fn schema_fingerprint() -> String {
6215 let input =
6220 format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
6221 hash_to_hex(blake3::hash(input.as_bytes()))
6222}
6223
6224fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
6225 tx.execute_batch(
6226 "DELETE FROM edges;
6227 DELETE FROM file_dependencies;
6228 DELETE FROM refs;
6229 DELETE FROM dispatch_hints;
6230 DELETE FROM type_ref_names;
6231 DELETE FROM backend_file_state;
6232 DELETE FROM nodes;
6233 DELETE FROM files;",
6234 )?;
6235 Ok(())
6236}
6237
6238fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
6239 tx.execute_batch(
6240 "DROP INDEX IF EXISTS idx_nodes_file;
6241 DROP INDEX IF EXISTS idx_nodes_name;
6242 DROP INDEX IF EXISTS idx_nodes_scoped;
6243 DROP INDEX IF EXISTS idx_refs_short_name;
6244 DROP INDEX IF EXISTS idx_refs_kind_caller_file;
6245 DROP INDEX IF EXISTS idx_refs_caller_file;
6246 DROP INDEX IF EXISTS idx_refs_caller_node_kind;
6247 DROP INDEX IF EXISTS idx_refs_target_file;
6248 DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
6249 DROP INDEX IF EXISTS idx_edges_source_kind;
6250 DROP INDEX IF EXISTS idx_edges_target_kind;
6251 DROP INDEX IF EXISTS idx_edges_target_file_symbol;
6252 DROP INDEX IF EXISTS idx_edges_ref_id;
6253 DROP INDEX IF EXISTS idx_dispatch_hints_method;
6254 DROP INDEX IF EXISTS idx_dispatch_hints_file;
6255 DROP INDEX IF EXISTS idx_backend_file_state_file;",
6256 )?;
6257 Ok(())
6258}
6259
6260fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
6261 tx.execute_batch(
6262 "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
6263 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
6264 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
6265 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
6266 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
6267 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
6268 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
6269 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
6270 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
6271 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
6272 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
6273 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
6274 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
6275 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
6276 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
6277 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
6278 )?;
6279 Ok(())
6280}
6281
6282const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
6283 ("files", "path"),
6284 ("nodes", "file_path"),
6285 ("refs", "caller_file"),
6286 ("refs", "target_file"),
6287 ("file_dependencies", "file_path"),
6288 ("file_dependencies", "dep_file"),
6289 ("edges", "target_file"),
6290 ("dispatch_hints", "file"),
6291 ("backend_file_state", "file_path"),
6292];
6293
6294fn reconcile_workspace_roots(
6307 conn: &mut Connection,
6308 project_root: &Path,
6309 allow_repair: bool,
6310) -> Result<OpenRootRepair> {
6311 let roots = stored_workspace_roots(conn)?;
6312 let current_root = project_root.display().to_string();
6313 if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
6314 return Ok(OpenRootRepair::None);
6315 }
6316
6317 if let Some(sample) = sample_absolute_data_path(conn)? {
6318 return Ok(OpenRootRepair::NeedsRebuild {
6319 previous_roots: roots,
6320 current_root,
6321 reason: format!("absolute store data path row {sample}"),
6322 });
6323 }
6324
6325 for stored_root in roots.iter() {
6326 if stored_root == ¤t_root {
6327 continue;
6328 }
6329 if Path::new(stored_root).exists() {
6330 let reason = format!(
6331 "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
6332 );
6333 return Ok(OpenRootRepair::NeedsRebuild {
6334 previous_roots: roots,
6335 current_root,
6336 reason,
6337 });
6338 }
6339 }
6340
6341 if !allow_repair {
6342 return Ok(OpenRootRepair::NeedsRebuild {
6343 previous_roots: roots,
6344 current_root,
6345 reason: "workspace root metadata requires deferred repair".to_string(),
6346 });
6347 }
6348
6349 publish_if_current(|| {
6350 let tx = conn.transaction()?;
6351 tx.execute(
6352 "UPDATE OR IGNORE backend_file_state
6353 SET workspace_root = ?1
6354 WHERE workspace_root <> ?1",
6355 params![¤t_root],
6356 )?;
6357 tx.execute(
6358 "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
6359 params![¤t_root],
6360 )?;
6361 tx.commit()?;
6362 Ok(())
6363 })?;
6364
6365 crate::slog_info!(
6366 "callgraph store re-rooted from {} to {}",
6367 roots.join(", "),
6368 current_root
6369 );
6370 Ok(OpenRootRepair::ReRooted)
6371}
6372
6373fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
6374 let mut stmt = conn.prepare(
6375 "SELECT DISTINCT workspace_root
6376 FROM backend_file_state
6377 ORDER BY workspace_root",
6378 )?;
6379 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
6380 rows.collect::<std::result::Result<Vec<_>, _>>()
6381 .map_err(Into::into)
6382}
6383
6384fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
6385 for (table, column) in STORE_DATA_PATH_COLUMNS {
6386 let sql = format!(
6387 "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
6388 );
6389 let mut stmt = conn.prepare(&sql)?;
6390 let mut rows = stmt.query([])?;
6391 while let Some(row) = rows.next()? {
6392 let value: String = row.get(0)?;
6393 if stored_path_is_absolute(&value) {
6394 return Ok(Some(format!("{table}.{column}={value}")));
6395 }
6396 }
6397 }
6398 Ok(None)
6399}
6400
6401fn stored_path_is_absolute(value: &str) -> bool {
6402 if value.is_empty() {
6403 return false;
6404 }
6405 if Path::new(value).is_absolute() || value.starts_with('/') {
6406 return true;
6407 }
6408 let bytes = value.as_bytes();
6409 if bytes.len() >= 3
6410 && bytes[1] == b':'
6411 && (bytes[2] == b'/' || bytes[2] == b'\\')
6412 && bytes[0].is_ascii_alphabetic()
6413 {
6414 return true;
6415 }
6416 value.starts_with("\\\\") || value.starts_with("//")
6417}
6418
6419fn log_root_repair_rebuild(repair: &OpenRootRepair) {
6420 if let OpenRootRepair::NeedsRebuild {
6421 previous_roots,
6422 current_root,
6423 reason,
6424 } = repair
6425 {
6426 crate::slog_info!(
6427 "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
6428 previous_roots.join(", "),
6429 current_root,
6430 reason
6431 );
6432 }
6433}
6434
6435fn now_nanos() -> u128 {
6437 SystemTime::now()
6438 .duration_since(UNIX_EPOCH)
6439 .unwrap_or(Duration::ZERO)
6440 .as_nanos()
6441}
6442
6443fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
6448 callgraph_dir.join(format!("{project_key}.current"))
6449}
6450
6451fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
6455 callgraph_dir.join(format!("{project_key}.sqlite"))
6456}
6457
6458fn generation_file_name(project_key: &str) -> String {
6462 format!(
6463 "{project_key}.g{}.{}.sqlite",
6464 now_nanos(),
6465 std::process::id()
6466 )
6467}
6468
6469fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
6471 let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
6472 let name = text.trim();
6473 if name.is_empty() {
6474 None
6475 } else {
6476 Some(name.to_string())
6477 }
6478}
6479
6480fn db_path_ready(path: &Path) -> bool {
6483 (|| -> Result<bool> {
6484 let conn = open_readonly_connection(path)?;
6485 database_ready(&conn)
6486 })()
6487 .unwrap_or(false)
6488}
6489
6490fn resolve_ready_target(
6498 callgraph_dir: &Path,
6499 project_key: &str,
6500) -> Option<(PathBuf, Option<String>)> {
6501 for _ in 0..5 {
6502 if let Some(generation) = read_pointer(callgraph_dir, project_key) {
6503 let gen_path = callgraph_dir.join(&generation);
6504 if gen_path.is_file() {
6505 return (migration_manifest_valid(callgraph_dir, &generation)
6506 && db_path_ready(&gen_path))
6507 .then_some((gen_path, Some(generation)));
6508 }
6509 std::thread::sleep(Duration::from_millis(5));
6512 continue;
6513 }
6514 let legacy = legacy_sqlite_path(callgraph_dir, project_key);
6516 return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
6517 }
6518 None
6519}
6520
6521fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
6525 let pointer = pointer_path(callgraph_dir, project_key);
6526 let tmp = callgraph_dir.join(format!(
6527 "{project_key}.current.tmp.{}.{}",
6528 std::process::id(),
6529 now_nanos()
6530 ));
6531 {
6532 use std::io::Write as _;
6533 let mut file = std::fs::File::create(&tmp)?;
6534 file.write_all(generation.as_bytes())?;
6535 file.write_all(b"\n")?;
6536 file.sync_all()?;
6537 }
6538 if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
6539 let _ = std::fs::remove_file(&tmp);
6540 return Err(error.into());
6541 }
6542 crate::fs_lock::sync_parent(&pointer);
6543 Ok(())
6544}
6545
6546#[derive(Clone, Debug)]
6547struct GenerationGcCandidate {
6548 name: String,
6549 path: PathBuf,
6550 modified: SystemTime,
6551}
6552
6553fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
6559 let temp_grace = Duration::from_secs(60);
6560 let now = SystemTime::now();
6561 let pointer_current =
6562 read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
6563 let gen_prefix = format!("{project_key}.g");
6564 let tmp_prefixes = [
6565 format!("{project_key}.g"), format!("{project_key}.current."), format!("{project_key}.sqlite.tmp."), ];
6569 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6570 return;
6571 };
6572 let mut gens: Vec<GenerationGcCandidate> = Vec::new();
6573 for entry in entries.flatten() {
6574 let name = entry.file_name();
6575 let name = name.to_string_lossy().to_string();
6576 let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
6577 let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
6578
6579 if name.contains(".tmp.") {
6581 if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
6582 let _ = std::fs::remove_file(entry.path());
6583 }
6584 continue;
6585 }
6586
6587 if name == format!("{project_key}.sqlite") {
6590 remove_sqlite_file_set(&entry.path());
6591 continue;
6592 }
6593
6594 if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
6595 gens.push(GenerationGcCandidate {
6596 name,
6597 path: entry.path(),
6598 modified: mtime,
6599 });
6600 }
6601 }
6602
6603 let mut superseded = gens
6604 .iter()
6605 .filter(|generation| generation.name != pointer_current)
6606 .collect::<Vec<_>>();
6607 superseded.sort_by(|left, right| {
6608 right
6609 .modified
6610 .cmp(&left.modified)
6611 .then_with(|| right.name.cmp(&left.name))
6612 });
6613 let previous = superseded.first().map(|generation| generation.name.clone());
6614
6615 for generation in gens {
6616 let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
6617 if generation.name == pointer_current
6618 || Some(generation.name.as_str()) == previous.as_deref()
6619 {
6620 continue;
6621 }
6622
6623 let age = now
6624 .duration_since(generation.modified)
6625 .unwrap_or(Duration::ZERO);
6626 if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
6627 continue;
6628 }
6629
6630 remove_sqlite_file_set(&generation.path);
6631 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
6632 let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
6633 callgraph_dir,
6634 &generation.name,
6635 ));
6636 }
6637}
6638
6639fn remove_sqlite_file_set(path: &Path) {
6640 let _ = std::fs::remove_file(path);
6641 remove_sqlite_sidecars(path);
6642}
6643
6644fn remove_sqlite_sidecars(path: &Path) {
6645 let path_text = path.to_string_lossy();
6646 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
6647 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
6648 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
6649}
6650
6651const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
6665
6666fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
6678 sweep_orphaned_build_temps(callgraph_dir);
6679 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6680 return;
6681 };
6682 let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
6683
6684 if let Ok(entries) = std::fs::read_dir(storage_root.join(domain)) {
6686 for entry in entries.flatten() {
6687 if entry.path().is_dir() {
6688 sweep_orphaned_build_temps(&entry.path());
6689 }
6690 }
6691 }
6692
6693 if let Ok(entries) = std::fs::read_dir(&storage_root) {
6695 for entry in entries.flatten() {
6696 let legacy_dir = entry.path().join(domain);
6697 if legacy_dir.is_dir() {
6698 sweep_orphaned_build_temps(&legacy_dir);
6699 }
6700 }
6701 }
6702}
6703
6704fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
6707 sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
6708}
6709
6710fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
6713 let now = SystemTime::now();
6714 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6715 return;
6716 };
6717 let mut removed_any = false;
6718 for entry in entries.flatten() {
6719 let name = entry.file_name().to_string_lossy().to_string();
6720 if !name.contains(".sqlite.tmp.") {
6726 continue;
6727 }
6728 let mtime = entry
6729 .metadata()
6730 .and_then(|meta| meta.modified())
6731 .unwrap_or(now);
6732 if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
6733 continue;
6734 }
6735 match std::fs::remove_file(entry.path()) {
6741 Ok(()) => removed_any = true,
6742 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
6743 Err(_) => {}
6744 }
6745 }
6746 if removed_any {
6747 crate::fs_lock::sync_parent(callgraph_dir);
6748 }
6749}
6750
6751fn build_pool_size() -> usize {
6759 std::thread::available_parallelism()
6760 .map(|parallelism| parallelism.get())
6761 .unwrap_or(1)
6762 .div_ceil(2)
6763 .clamp(1, 8)
6764}
6765
6766fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
6767 let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
6768 Ok(extract) => Ok(extract),
6769 Err(error) => {
6770 let abs_path =
6771 normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
6772 let rel_path = relative_path(project_root, &abs_path);
6773 let freshness = cache_freshness::collect(&abs_path).ok();
6774 log::debug!(
6775 "callgraph store: skipping {} during cold build: {}",
6776 abs_path.display(),
6777 error
6778 );
6779 Err(ExtractFailure {
6780 rel_path,
6781 freshness,
6782 })
6783 }
6784 };
6785
6786 let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
6787 files.par_iter().map(extract_one).collect()
6788 };
6789
6790 let results = match rayon::ThreadPoolBuilder::new()
6793 .num_threads(build_pool_size())
6794 .thread_name(|index| format!("aft-callgraph-build-{index}"))
6795 .stack_size(8 * 1024 * 1024)
6796 .build()
6797 {
6798 Ok(pool) => pool.install(run),
6799 Err(error) => {
6800 log::warn!(
6801 "callgraph store: bounded build pool unavailable ({error}); using global pool"
6802 );
6803 run()
6804 }
6805 };
6806
6807 let mut extracts = Vec::new();
6808 let mut failures = Vec::new();
6809 for result in results {
6810 match result {
6811 Ok(extract) => extracts.push(extract),
6812 Err(failure) => failures.push(failure),
6813 }
6814 }
6815 BuildExtractsResult { extracts, failures }
6816}
6817
6818fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
6819 let metadata = std::fs::metadata(path)?;
6820 let size = metadata.len();
6821 let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
6822 cache_freshness::zero_hash()
6823 } else if source.len() as u64 == size {
6824 cache_freshness::hash_bytes(source.as_bytes())
6825 } else {
6826 cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
6827 };
6828 Ok(FileFreshness {
6829 mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
6830 size,
6831 content_hash,
6832 })
6833}
6834
6835fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
6836 let abs_path = normalize_file_path(project_root, path)?;
6837 let rel_path = relative_path(project_root, &abs_path);
6838 let source = std::fs::read_to_string(&abs_path)?;
6839 let freshness = collect_source_freshness(&abs_path, &source)?;
6840 let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
6841 let lang = data.lang;
6842 if lang == LangId::Rust {
6843 extend_rust_imports_with_nested_uses(&source, &mut data);
6844 }
6845 let mut nodes = build_node_records(&rel_path, &source, &data)?;
6846 let node_by_scoped: HashMap<String, String> = nodes
6847 .iter()
6848 .map(|node| (node.scoped_name.clone(), node.id.clone()))
6849 .collect();
6850 let import_dependencies =
6851 import_dependencies(project_root, &abs_path, &data.import_block.imports);
6852 let line_index = LineIndex::new(&source);
6853 let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
6854 let rust_reexports = if lang == LangId::Rust {
6855 collect_rust_pub_use_reexport_refs(
6856 project_root,
6857 &abs_path,
6858 &rel_path,
6859 &data.import_block.imports,
6860 &line_index,
6861 )
6862 } else {
6863 ReexportRefs {
6864 raw_refs: Vec::new(),
6865 surface_parts: Vec::new(),
6866 }
6867 };
6868 let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
6869 let mut raw_refs = Vec::new();
6870 raw_refs.extend(build_call_refs(
6871 &rel_path,
6872 &data,
6873 &node_by_scoped,
6874 &import_dependencies,
6875 ));
6876 raw_refs.extend(build_value_ref_refs(
6877 &rel_path,
6878 &data,
6879 &node_by_scoped,
6880 &import_dependencies,
6881 ));
6882 raw_refs.extend(build_import_refs(
6883 project_root,
6884 &abs_path,
6885 &rel_path,
6886 &data.import_block.imports,
6887 &line_index,
6888 ));
6889 let mut surface_parts = reexports.surface_parts;
6890 surface_parts.extend(rust_reexports.surface_parts);
6891 surface_parts.extend(source_less_exports.surface_parts);
6892 raw_refs.extend(reexports.raw_refs);
6893 raw_refs.extend(rust_reexports.raw_refs);
6894 raw_refs.extend(source_less_exports.raw_refs);
6895 let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
6896 let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
6897
6898 Ok(FileExtract {
6899 rel_path,
6900 freshness,
6901 lang,
6902 data,
6903 nodes,
6904 raw_refs,
6905 dispatch_hints,
6906 surface_fingerprint,
6907 })
6908}
6909
6910fn build_node_records(
6911 rel_path: &str,
6912 source: &str,
6913 data: &FileCallData,
6914) -> Result<Vec<NodeRecord>> {
6915 let mut records = Vec::new();
6916 let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
6917 let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
6918 metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
6919
6920 for (scoped_name, meta) in metadata {
6921 let name = unqualified_name(scoped_name).to_string();
6922 let range = selection_range(source, scoped_name, &name, &meta.range);
6923 let range_key = (
6924 range.start_line,
6925 range.start_col,
6926 range.end_line,
6927 range.end_col,
6928 );
6929 let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
6930 let range_ordinal = *ordinal;
6931 *ordinal += 1;
6932 let id = node_id(rel_path, &range, range_ordinal, scoped_name);
6933 let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
6934 let is_default_export = data
6935 .default_export_symbol
6936 .as_deref()
6937 .map(|default| default == scoped_name || default == name)
6938 .unwrap_or(false);
6939 records.push(NodeRecord {
6940 id,
6941 file_path: rel_path.to_string(),
6942 name: name.clone(),
6943 scoped_name: scoped_name.clone(),
6944 kind: symbol_kind_label(&meta.kind).to_string(),
6945 range,
6946 range_ordinal,
6947 signature: meta.signature.clone(),
6948 exported,
6949 is_default_export,
6950 is_type_like: is_type_like(&meta.kind),
6951 is_callgraph_entry_point: meta.entry_point_attribute.is_some()
6952 || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
6953 });
6954 }
6955
6956 Ok(records)
6957}
6958
6959fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
6960 if scoped_name == TOP_LEVEL_SYMBOL {
6961 return Range {
6962 start_line: 0,
6963 start_col: 0,
6964 end_line: 0,
6965 end_col: 0,
6966 };
6967 }
6968 let Some(line) = source.lines().nth(fallback.start_line as usize) else {
6969 return fallback.clone();
6970 };
6971 let start_col = fallback.start_col as usize;
6972 let search_start = start_col.min(line.len());
6973 if let Some(offset) = line[search_start..].find(name) {
6974 let col = search_start + offset;
6975 return Range {
6976 start_line: fallback.start_line,
6977 start_col: col as u32,
6978 end_line: fallback.start_line,
6979 end_col: (col + name.len()) as u32,
6980 };
6981 }
6982 if let Some(offset) = line.find(name) {
6983 return Range {
6984 start_line: fallback.start_line,
6985 start_col: offset as u32,
6986 end_line: fallback.start_line,
6987 end_col: (offset + name.len()) as u32,
6988 };
6989 }
6990 Range {
6991 start_line: fallback.start_line,
6992 start_col: fallback.start_col,
6993 end_line: fallback.start_line,
6994 end_col: fallback.start_col.saturating_add(name.len() as u32),
6995 }
6996}
6997
6998fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
6999 if scoped_name == TOP_LEVEL_SYMBOL {
7000 return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
7001 }
7002 let input = format!(
7003 "{rel_path}:{}:{}:{}:{}:{ordinal}",
7004 range.start_line, range.start_col, range.end_line, range.end_col
7005 );
7006 format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
7007}
7008
7009fn build_call_refs(
7010 rel_path: &str,
7011 data: &FileCallData,
7012 node_by_scoped: &HashMap<String, String>,
7013 import_dependencies: &BTreeSet<String>,
7014) -> Vec<RawRef> {
7015 build_callable_refs(
7016 rel_path,
7017 &data.calls_by_symbol,
7018 node_by_scoped,
7019 import_dependencies,
7020 "call",
7021 )
7022}
7023
7024fn build_value_ref_refs(
7025 rel_path: &str,
7026 data: &FileCallData,
7027 node_by_scoped: &HashMap<String, String>,
7028 import_dependencies: &BTreeSet<String>,
7029) -> Vec<RawRef> {
7030 build_callable_refs(
7031 rel_path,
7032 &data.value_refs_by_symbol,
7033 node_by_scoped,
7034 import_dependencies,
7035 "value_ref",
7036 )
7037}
7038
7039fn build_callable_refs(
7040 rel_path: &str,
7041 sites_by_symbol: &HashMap<String, Vec<callgraph::CallSite>>,
7042 node_by_scoped: &HashMap<String, String>,
7043 import_dependencies: &BTreeSet<String>,
7044 kind: &str,
7045) -> Vec<RawRef> {
7046 let mut refs = Vec::new();
7047 let mut ordinal = 0usize;
7048 let mut symbols: Vec<_> = sites_by_symbol.iter().collect();
7049 symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
7050 for (caller_symbol, call_sites) in symbols {
7051 let caller_node = node_by_scoped.get(caller_symbol).cloned();
7052 for call_site in call_sites {
7053 ordinal += 1;
7054 let ref_id = ref_id(&[
7055 rel_path,
7056 kind,
7057 caller_symbol,
7058 &call_site.line.to_string(),
7059 &call_site.byte_start.to_string(),
7060 &call_site.byte_end.to_string(),
7061 &call_site.full_callee,
7062 &ordinal.to_string(),
7063 ]);
7064 refs.push(RawRef {
7065 ref_id,
7066 caller_node: caller_node.clone(),
7067 caller_symbol: Some(caller_symbol.clone()),
7068 caller_file: rel_path.to_string(),
7069 kind: kind.to_string(),
7070 short_name: Some(call_site.callee_name.clone()),
7071 full_ref: Some(call_site.full_callee.clone()),
7072 module_path: None,
7073 import_kind: None,
7074 local_name: Some(call_site.callee_name.clone()),
7075 requested_name: Some(call_site.callee_name.clone()),
7076 namespace_alias: namespace_alias(&call_site.full_callee),
7077 wildcard: false,
7078 line: call_site.line,
7079 byte_start: call_site.byte_start,
7080 byte_end: call_site.byte_end,
7081 dependencies: import_dependencies.clone(),
7082 });
7083 }
7084 }
7085 refs
7086}
7087
7088fn build_import_refs(
7089 project_root: &Path,
7090 abs_path: &Path,
7091 rel_path: &str,
7092 imports: &[ImportStatement],
7093 line_index: &LineIndex,
7094) -> Vec<RawRef> {
7095 let mut refs = Vec::new();
7096 for (index, import) in imports.iter().enumerate() {
7097 let import_kind = import_kind_label(import.kind).to_string();
7098 let local_name = import_local_names(import).join(",");
7099 let requested_name = import_requested_names(import).join(",");
7100 let ref_id = ref_id(&[
7101 rel_path,
7102 "import",
7103 &import.byte_range.start.to_string(),
7104 &import.byte_range.end.to_string(),
7105 &import.module_path,
7106 &index.to_string(),
7107 ]);
7108 refs.push(RawRef {
7109 ref_id,
7110 caller_node: None,
7111 caller_symbol: None,
7112 caller_file: rel_path.to_string(),
7113 kind: "import".to_string(),
7114 short_name: None,
7115 full_ref: Some(import.raw_text.clone()),
7116 module_path: Some(import.module_path.clone()),
7117 import_kind: Some(import_kind),
7118 local_name: empty_to_none(local_name),
7119 requested_name: empty_to_none(requested_name),
7120 namespace_alias: import.namespace_import.clone(),
7121 wildcard: import_is_wildcard(import),
7122 line: line_index.byte_to_line(import.byte_range.start),
7123 byte_start: import.byte_range.start,
7124 byte_end: import.byte_range.end,
7125 dependencies: module_dependencies(project_root, abs_path, &import.module_path),
7126 });
7127 }
7128 refs
7129}
7130
7131fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
7132 let grammar = grammar_for(LangId::Rust);
7133 let mut parser = Parser::new();
7134 if parser.set_language(&grammar).is_err() {
7135 return;
7136 }
7137 let Some(tree) = parser.parse(source, None) else {
7138 return;
7139 };
7140
7141 let mut seen = data
7142 .import_block
7143 .imports
7144 .iter()
7145 .map(|import| (import.byte_range.start, import.byte_range.end))
7146 .collect::<HashSet<_>>();
7147 let mut nested_imports = Vec::new();
7148 collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
7149 if nested_imports.is_empty() {
7150 return;
7151 }
7152
7153 data.import_block.imports.extend(nested_imports);
7154 data.import_block
7155 .imports
7156 .sort_by_key(|import| import.byte_range.start);
7157 data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
7158}
7159
7160fn collect_rust_use_imports(
7161 source: &str,
7162 node: Node<'_>,
7163 seen: &mut HashSet<(usize, usize)>,
7164 imports: &mut Vec<ImportStatement>,
7165) {
7166 if node.kind() == "use_declaration" {
7167 let range = node.byte_range();
7168 if seen.insert((range.start, range.end)) {
7169 if let Some(import) = rust_import_from_use_node(source, node) {
7170 imports.push(import);
7171 }
7172 }
7173 }
7174
7175 let mut cursor = node.walk();
7176 if !cursor.goto_first_child() {
7177 return;
7178 }
7179 loop {
7180 collect_rust_use_imports(source, cursor.node(), seen, imports);
7181 if !cursor.goto_next_sibling() {
7182 break;
7183 }
7184 }
7185}
7186
7187fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
7188 let raw_text = source[node.byte_range()].to_string();
7189 let body = rust_use_body(&raw_text)?.to_string();
7190 let visibility = rust_use_visibility(&raw_text);
7191 let names = rust_use_list_names(&body);
7192 let group = classify_rust_import_group(&body);
7193 let byte_range = node.byte_range();
7194
7195 Some(ImportStatement {
7196 module_path: body,
7197 names: names.clone(),
7198 default_import: visibility.clone(),
7199 namespace_import: None,
7200 kind: ImportKind::Value,
7201 group,
7202 byte_range,
7203 raw_text,
7204 form: ImportForm::RustUse {
7205 visibility,
7206 named: names,
7207 },
7208 })
7209}
7210
7211fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
7212 let start = imports.iter().map(|import| import.byte_range.start).min()?;
7213 let end = imports.iter().map(|import| import.byte_range.end).max()?;
7214 Some(start..end)
7215}
7216
7217fn rust_use_visibility(raw_text: &str) -> Option<String> {
7218 let use_pos = raw_text.find("use ")?;
7219 let prefix = raw_text[..use_pos].trim();
7220 if prefix.is_empty() {
7221 None
7222 } else {
7223 Some(prefix.to_string())
7224 }
7225}
7226
7227fn rust_use_body(raw_text: &str) -> Option<&str> {
7228 let use_pos = raw_text.find("use ")?;
7229 Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
7230}
7231
7232fn rust_use_list_names(body: &str) -> Vec<String> {
7233 let Some(open) = body.find("::{") else {
7234 return Vec::new();
7235 };
7236 let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
7237 return Vec::new();
7238 };
7239 body[open + 3..close]
7240 .split(',')
7241 .filter_map(|spec| {
7242 let spec = spec.trim();
7243 if spec.is_empty() {
7244 None
7245 } else {
7246 Some(spec.to_string())
7247 }
7248 })
7249 .collect()
7250}
7251
7252fn classify_rust_import_group(body: &str) -> ImportGroup {
7253 let first = body
7254 .split("::")
7255 .next()
7256 .unwrap_or(body)
7257 .split_whitespace()
7258 .next()
7259 .unwrap_or(body);
7260 match first.trim() {
7261 "std" | "core" | "alloc" => ImportGroup::Stdlib,
7262 "crate" | "self" | "super" => ImportGroup::Internal,
7263 _ => ImportGroup::External,
7264 }
7265}
7266
7267#[derive(Debug, Clone)]
7268struct ReexportRefs {
7269 raw_refs: Vec<RawRef>,
7270 surface_parts: Vec<String>,
7271}
7272
7273fn collect_reexport_refs(
7274 project_root: &Path,
7275 abs_path: &Path,
7276 rel_path: &str,
7277 source: &str,
7278) -> ReexportRefs {
7279 let mut raw_refs = Vec::new();
7280 let mut surface_parts = Vec::new();
7281 let mut search_start = 0usize;
7282 let mut ordinal = 0usize;
7283 while let Some(export_offset) = source[search_start..].find("export") {
7284 let start = search_start + export_offset;
7285 let Some(statement_end_offset) = source[start..].find(';') else {
7286 break;
7287 };
7288 let end = start + statement_end_offset + 1;
7289 let statement = &source[start..end];
7290 search_start = end;
7291 if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
7292 continue;
7293 }
7294 let Some(module_path) = quoted_module_path(statement) else {
7295 continue;
7296 };
7297 ordinal += 1;
7298 let wildcard = statement.contains('*');
7299 let line = source[..start]
7300 .bytes()
7301 .filter(|byte| *byte == b'\n')
7302 .count() as u32
7303 + 1;
7304 let ref_id = ref_id(&[
7305 rel_path,
7306 "reexport",
7307 &start.to_string(),
7308 &end.to_string(),
7309 &module_path,
7310 &ordinal.to_string(),
7311 ]);
7312 surface_parts.push(format!("reexport\t{statement}"));
7313 raw_refs.push(RawRef {
7314 ref_id,
7315 caller_node: None,
7316 caller_symbol: None,
7317 caller_file: rel_path.to_string(),
7318 kind: "reexport".to_string(),
7319 short_name: None,
7320 full_ref: Some(statement.to_string()),
7321 module_path: Some(module_path.clone()),
7322 import_kind: Some("reexport".to_string()),
7323 local_name: None,
7324 requested_name: None,
7325 namespace_alias: None,
7326 wildcard,
7327 line,
7328 byte_start: start,
7329 byte_end: end,
7330 dependencies: module_dependencies(project_root, abs_path, &module_path),
7331 });
7332 }
7333 ReexportRefs {
7334 raw_refs,
7335 surface_parts,
7336 }
7337}
7338
7339fn collect_rust_pub_use_reexport_refs(
7340 project_root: &Path,
7341 abs_path: &Path,
7342 rel_path: &str,
7343 imports: &[ImportStatement],
7344 line_index: &LineIndex,
7345) -> ReexportRefs {
7346 let mut raw_refs = Vec::new();
7347 let mut surface_parts = Vec::new();
7348 let mut ordinal = 0usize;
7349
7350 for import in imports {
7351 let Some(visibility) = &import.default_import else {
7352 continue;
7353 };
7354 if !visibility.starts_with("pub") {
7355 continue;
7356 }
7357 let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
7358 continue;
7359 };
7360 ordinal += 1;
7361 let ref_id = ref_id(&[
7362 rel_path,
7363 "rust_reexport",
7364 &import.byte_range.start.to_string(),
7365 &import.byte_range.end.to_string(),
7366 &module_path,
7367 &ordinal.to_string(),
7368 ]);
7369 surface_parts.push(format!("reexport\t{}", import.raw_text));
7370 raw_refs.push(RawRef {
7371 ref_id,
7372 caller_node: None,
7373 caller_symbol: None,
7374 caller_file: rel_path.to_string(),
7375 kind: "reexport".to_string(),
7376 short_name: None,
7377 full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
7378 module_path: Some(module_path.clone()),
7379 import_kind: Some("reexport".to_string()),
7380 local_name: None,
7381 requested_name: None,
7382 namespace_alias: None,
7383 wildcard,
7384 line: line_index.byte_to_line(import.byte_range.start),
7385 byte_start: import.byte_range.start,
7386 byte_end: import.byte_range.end,
7387 dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
7388 });
7389 }
7390
7391 ReexportRefs {
7392 raw_refs,
7393 surface_parts,
7394 }
7395}
7396
7397fn rust_pub_use_reexport_parts(
7398 import: &ImportStatement,
7399) -> Option<(String, HashMap<String, String>, bool)> {
7400 let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
7401 let body = body.trim();
7402 if let Some(module_path) = body.strip_suffix("::*") {
7403 return Some((module_path.trim().to_string(), HashMap::new(), true));
7404 }
7405
7406 if let Some(brace_start) = body.find("::{") {
7407 let module_path = body[..brace_start].trim().to_string();
7408 let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
7409 if names.is_empty() {
7410 return None;
7411 }
7412 return Some((module_path, names, false));
7413 }
7414
7415 let (module_path, spec) = body.rsplit_once("::")?;
7416 let names = rust_reexport_names_from_specs(spec);
7417 if names.is_empty() {
7418 return None;
7419 }
7420 Some((module_path.trim().to_string(), names, false))
7421}
7422
7423fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
7424 let mut names = HashMap::new();
7425 for spec in specs.split(',') {
7426 let spec = spec.trim();
7427 if spec.is_empty() || spec == "self" {
7428 continue;
7429 }
7430 if let Some((source, local)) = spec.split_once(" as ") {
7431 let source = source.trim();
7432 let local = local.trim();
7433 if !source.is_empty() && !local.is_empty() && source != "self" {
7434 names.insert(local.to_string(), source.to_string());
7435 }
7436 } else {
7437 names.insert(spec.to_string(), spec.to_string());
7438 }
7439 }
7440 names
7441}
7442
7443fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
7444 if named.is_empty() {
7445 return fallback.to_string();
7446 }
7447 let mut specs = named
7448 .iter()
7449 .map(|(local, source)| {
7450 if local == source {
7451 source.clone()
7452 } else {
7453 format!("{source} as {local}")
7454 }
7455 })
7456 .collect::<Vec<_>>();
7457 specs.sort();
7458 format!("pub use {{{}}};", specs.join(", "))
7459}
7460
7461fn quoted_module_path(statement: &str) -> Option<String> {
7462 let quote = match (statement.find('\''), statement.find('"')) {
7463 (Some(single), Some(double)) if single < double => '\'',
7464 (Some(_), Some(_)) => '"',
7465 (Some(_), None) => '\'',
7466 (None, Some(_)) => '"',
7467 (None, None) => return None,
7468 };
7469 let start = statement.find(quote)? + 1;
7470 let end = statement[start..].find(quote)? + start;
7471 Some(statement[start..end].to_string())
7472}
7473
7474#[derive(Debug, Clone)]
7475struct SourceLessExportRefs {
7476 raw_refs: Vec<RawRef>,
7477 surface_parts: Vec<String>,
7478}
7479
7480fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
7481 let mut raw_refs = Vec::new();
7482 let mut surface_parts = Vec::new();
7483 let mut search_start = 0usize;
7484 let mut ordinal = 0usize;
7485 while let Some(export_offset) = source[search_start..].find("export") {
7486 let start = search_start + export_offset;
7487 let Some(statement_end_offset) = source[start..].find(';') else {
7488 break;
7489 };
7490 let end = start + statement_end_offset + 1;
7491 let statement = &source[start..end];
7492 search_start = end;
7493 if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
7494 continue;
7495 }
7496 let aliases = parse_reexport_names(statement);
7497 if aliases.is_empty() {
7498 continue;
7499 }
7500 let line = source[..start]
7501 .bytes()
7502 .filter(|byte| *byte == b'\n')
7503 .count() as u32
7504 + 1;
7505 for (exported, source_symbol) in aliases {
7506 ordinal += 1;
7507 let ref_id = ref_id(&[
7508 rel_path,
7509 "export_alias",
7510 &start.to_string(),
7511 &end.to_string(),
7512 &exported,
7513 &source_symbol,
7514 &ordinal.to_string(),
7515 ]);
7516 surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
7517 raw_refs.push(RawRef {
7518 ref_id,
7519 caller_node: None,
7520 caller_symbol: None,
7521 caller_file: rel_path.to_string(),
7522 kind: "export_alias".to_string(),
7523 short_name: None,
7524 full_ref: Some(statement.to_string()),
7525 module_path: None,
7526 import_kind: Some("export_alias".to_string()),
7527 local_name: Some(exported),
7528 requested_name: Some(source_symbol),
7529 namespace_alias: None,
7530 wildcard: false,
7531 line,
7532 byte_start: start,
7533 byte_end: end,
7534 dependencies: BTreeSet::new(),
7535 });
7536 }
7537 }
7538 SourceLessExportRefs {
7539 raw_refs,
7540 surface_parts,
7541 }
7542}
7543
7544fn build_dispatch_hints(
7545 rel_path: &str,
7546 data: &FileCallData,
7547 node_by_scoped: &HashMap<String, String>,
7548) -> Vec<DispatchHint> {
7549 let mut hints = Vec::new();
7550 let mut ordinal = 0usize;
7551 for (caller_symbol, call_sites) in &data.calls_by_symbol {
7552 let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
7553 continue;
7554 };
7555 for call_site in call_sites {
7556 if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
7557 continue;
7558 }
7559 ordinal += 1;
7560 hints.push(DispatchHint {
7561 id: ref_id(&[
7562 rel_path,
7563 "dispatch",
7564 caller_symbol,
7565 &call_site.line.to_string(),
7566 &call_site.byte_start.to_string(),
7567 &call_site.byte_end.to_string(),
7568 &ordinal.to_string(),
7569 ]),
7570 method_name: call_site.callee_name.clone(),
7571 caller_node: caller_node.clone(),
7572 file: rel_path.to_string(),
7573 line: call_site.line,
7574 byte_start: call_site.byte_start,
7575 byte_end: call_site.byte_end,
7576 });
7577 }
7578 }
7579 hints
7580}
7581
7582fn surface_fingerprint(
7583 nodes: &mut [NodeRecord],
7584 data: &FileCallData,
7585 reexport_parts: &[String],
7586) -> String {
7587 nodes.sort_by(|left, right| {
7588 (left.file_path.as_str(), left.scoped_name.as_str())
7589 .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
7590 });
7591 let mut parts = Vec::new();
7592 for node in nodes.iter() {
7593 parts.push(format!(
7594 "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
7595 node.scoped_name,
7596 node.name,
7597 node.kind,
7598 node.exported,
7599 node.range.start_line,
7600 node.range.start_col,
7601 node.range.end_line,
7602 node.range.end_col,
7603 node.range_ordinal,
7604 node.signature.as_deref().unwrap_or("")
7605 ));
7606 }
7607 let mut exports = data.exported_symbols.clone();
7608 exports.sort();
7609 for export in exports {
7610 parts.push(format!("export\t{export}"));
7611 }
7612 if let Some(default_export) = &data.default_export_symbol {
7613 parts.push(format!("default\t{default_export}"));
7614 }
7615 let mut imports: Vec<String> = data
7616 .import_block
7617 .imports
7618 .iter()
7619 .map(|import| {
7620 format!(
7621 "import\t{}\t{:?}\t{}",
7622 import.module_path, import.form, import.raw_text
7623 )
7624 })
7625 .collect();
7626 imports.sort();
7627 parts.extend(imports);
7628 parts.extend(reexport_parts.iter().cloned());
7629 hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
7630}
7631
7632fn resolve_ref(raw: RawRef, index: &ProjectIndex<'_>) -> Result<ResolvedRef> {
7633 if !matches!(raw.kind.as_str(), "call" | "value_ref") {
7634 return Ok(ResolvedRef {
7635 dependencies: raw.dependencies.clone(),
7636 raw,
7637 status: "unresolved".to_string(),
7638 target_node: None,
7639 target_file: None,
7640 target_symbol: None,
7641 edge: None,
7642 });
7643 }
7644
7645 let caller_file = raw.caller_file.clone();
7646 let caller_data = index.caller_data.get(&caller_file).ok_or_else(|| {
7647 CallGraphStoreError::MissingCallerData {
7648 file: caller_file.clone(),
7649 }
7650 })?;
7651 let full_ref = raw.full_ref.as_deref().unwrap_or_default();
7652 let short_name = raw.short_name.as_deref().unwrap_or_default();
7653 let mut dependencies = raw.dependencies.clone();
7654
7655 let resolved = match index.lang_for(&caller_file) {
7656 Some(LangId::Rust) => {
7657 resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
7658 }
7659 Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
7660 resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
7661 }
7662 _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
7663 };
7664
7665 let Some((status, target_file, target_symbol)) = resolved else {
7666 return Ok(ResolvedRef {
7667 raw,
7668 status: "unresolved".to_string(),
7669 target_node: None,
7670 target_file: None,
7671 target_symbol: None,
7672 dependencies,
7673 edge: None,
7674 });
7675 };
7676
7677 dependencies.insert(target_file.clone());
7678 let target_node = index.node_for_symbol(&target_file, &target_symbol);
7679 if raw.kind == "value_ref"
7680 && !target_node
7681 .as_deref()
7682 .is_some_and(|node_id| index.node_is_callable(&target_file, node_id))
7683 {
7684 return Ok(ResolvedRef {
7685 raw,
7686 status: "unresolved".to_string(),
7687 target_node: None,
7688 target_file: None,
7689 target_symbol: None,
7690 dependencies,
7691 edge: None,
7692 });
7693 }
7694 let source_node = raw.caller_node.clone();
7695 let edge = if let Some(source_node) = source_node {
7696 if target_file == caller_file
7697 && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
7698 {
7699 None
7700 } else {
7701 Some(EdgeRecord {
7702 edge_id: ref_id(&[&raw.ref_id, "edge"]),
7703 source_node,
7704 target_node: target_node.clone(),
7705 target_file: target_file.clone(),
7706 target_symbol: target_symbol.clone(),
7707 kind: raw.kind.clone(),
7708 line: raw.line,
7709 })
7710 }
7711 } else {
7712 None
7713 };
7714
7715 Ok(ResolvedRef {
7716 raw,
7717 status,
7718 target_node,
7719 target_file: Some(target_file),
7720 target_symbol: Some(target_symbol),
7721 dependencies,
7722 edge,
7723 })
7724}
7725
7726fn resolve_js_ts_target(
7727 index: &ProjectIndex<'_>,
7728 caller_file: &str,
7729 full_ref: &str,
7730 short_name: &str,
7731 caller_data: &FileCallData,
7732) -> Option<(String, String, String)> {
7733 if let Some((namespace, member)) = full_ref.split_once('.') {
7734 for import in &caller_data.import_block.imports {
7735 if import.namespace_import.as_deref() == Some(namespace) {
7736 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7737 if let Some((file, symbol)) =
7738 resolve_exported_symbol(index, &target_file, member, 0)
7739 {
7740 return Some(("resolved".to_string(), file, symbol));
7741 }
7742 }
7743 }
7744 }
7745 }
7746
7747 for import in &caller_data.import_block.imports {
7748 for spec in &import.names {
7749 if crate::imports::specifier_local_name(spec) == short_name {
7750 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7751 let requested = crate::imports::specifier_imported_name(spec);
7752 let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
7753 .unwrap_or_else(|| (target_file, requested.to_string()));
7754 return Some(("resolved".to_string(), file, symbol));
7755 }
7756 }
7757 }
7758
7759 if import.default_import.as_deref() == Some(short_name) {
7760 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7761 let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
7762 .or_else(|| {
7763 index
7764 .files
7765 .get(&target_file)
7766 .and_then(|file| file.default_export.clone())
7767 .map(|symbol| (target_file.clone(), symbol))
7768 })
7769 .unwrap_or_else(|| {
7770 let file_name = Path::new(&target_file)
7771 .file_name()
7772 .and_then(|name| name.to_str())
7773 .unwrap_or("unknown")
7774 .to_string();
7775 (target_file, format!("<default:{file_name}>"))
7776 });
7777 return Some(("resolved".to_string(), file, symbol));
7778 }
7779 }
7780 }
7781
7782 for import in &caller_data.import_block.imports {
7783 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7784 if index
7785 .files
7786 .get(&target_file)
7787 .map(|file| file.exports.contains(short_name))
7788 .unwrap_or(false)
7789 {
7790 return Some(("resolved".to_string(), target_file, short_name.to_string()));
7791 }
7792 }
7793 }
7794
7795 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
7796}
7797
7798fn resolve_exported_symbol(
7799 index: &ProjectIndex<'_>,
7800 file: &str,
7801 requested: &str,
7802 depth: usize,
7803) -> Option<(String, String)> {
7804 let mut visited = std::collections::HashMap::new();
7805 resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
7806}
7807
7808fn resolve_exported_symbol_inner(
7817 index: &ProjectIndex<'_>,
7818 file: &str,
7819 requested: &str,
7820 depth: usize,
7821 visited: &mut std::collections::HashMap<(String, String), usize>,
7822) -> Option<(String, String)> {
7823 if depth > 16 {
7824 return None;
7825 }
7826 if requested != "default" {
7827 if let Some(source_symbol) = index
7828 .files
7829 .get(file)
7830 .and_then(|item| item.export_aliases.get(requested))
7831 {
7832 return Some((file.to_string(), source_symbol.clone()));
7833 }
7834 if index
7835 .files
7836 .get(file)
7837 .map(|item| item.exports.contains(requested))
7838 .unwrap_or(false)
7839 {
7840 return Some((file.to_string(), requested.to_string()));
7841 }
7842 } else if let Some(default) = index
7843 .files
7844 .get(file)
7845 .and_then(|item| item.default_export.clone())
7846 {
7847 return Some((file.to_string(), default));
7848 }
7849
7850 match visited.entry((file.to_string(), requested.to_string())) {
7854 std::collections::hash_map::Entry::Occupied(mut seen) => {
7855 if *seen.get() <= depth {
7856 return None;
7857 }
7858 seen.insert(depth);
7859 }
7860 std::collections::hash_map::Entry::Vacant(slot) => {
7861 slot.insert(depth);
7862 }
7863 }
7864
7865 for reexport in index.reexports_for(file) {
7866 let mut next_requested = requested.to_string();
7867 let matches = if reexport.wildcard {
7868 true
7869 } else if let Some(source_name) = reexport.named.get(requested) {
7870 next_requested = source_name.clone();
7871 true
7872 } else {
7873 false
7874 };
7875 if !matches {
7876 continue;
7877 }
7878 if let Some(target_file) = &reexport.target_file {
7879 if let Some(target) = resolve_exported_symbol_inner(
7880 index,
7881 target_file,
7882 &next_requested,
7883 depth + 1,
7884 visited,
7885 ) {
7886 return Some(target);
7887 }
7888 }
7889 }
7890 None
7891}
7892
7893fn resolve_rust_target(
7894 index: &ProjectIndex<'_>,
7895 caller_file: &str,
7896 full_ref: &str,
7897 short_name: &str,
7898 caller_data: &FileCallData,
7899 raw: &RawRef,
7900) -> Option<(String, String, String)> {
7901 if full_ref.contains("::") {
7902 if let Some((target_file, target_symbol)) =
7903 rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
7904 {
7905 return Some(("resolved".to_string(), target_file, target_symbol));
7906 }
7907 }
7908
7909 for import in &caller_data.import_block.imports {
7910 if let Some((target_file, target_symbol)) =
7911 rust_target_for_use(index, caller_file, import, short_name)
7912 {
7913 return Some(("resolved".to_string(), target_file, target_symbol));
7914 }
7915 }
7916
7917 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
7918}
7919
7920fn rust_target_for_qualified(
7921 index: &ProjectIndex<'_>,
7922 caller_file: &str,
7923 full_ref: &str,
7924 short_name: &str,
7925 caller_data: &FileCallData,
7926 raw: &RawRef,
7927) -> Option<(String, String)> {
7928 let mut segments: Vec<&str> = full_ref.split("::").collect();
7929 if segments.len() < 2 {
7930 return None;
7931 }
7932 segments.pop();
7933 let requested_symbol = rust_target_symbol(full_ref, short_name);
7934
7935 for path in rust_module_path_candidates(&segments, caller_data, raw) {
7936 let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
7937 if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
7938 if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
7939 return Some(rust_resolve_reexport_if_symbol_missing(
7940 index,
7941 target_file,
7942 requested_symbol.clone(),
7943 ));
7944 }
7945 }
7946
7947 let module_segments = rust_resolve_segments(caller_file, &path_refs)?;
7948 if let Some(target) =
7949 rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
7950 {
7951 return Some(target);
7952 }
7953 if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
7954 return Some(rust_resolve_reexport_if_symbol_missing(
7955 index,
7956 target_file,
7957 requested_symbol.clone(),
7958 ));
7959 }
7960 }
7961 None
7962}
7963
7964fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
7965 full_ref
7966 .rsplit("::")
7967 .next()
7968 .filter(|name| !name.is_empty())
7969 .unwrap_or(short_name)
7970 .to_string()
7971}
7972
7973fn rust_resolve_reexport_if_symbol_missing(
7974 index: &ProjectIndex<'_>,
7975 target_file: String,
7976 target_symbol: String,
7977) -> (String, String) {
7978 if index
7979 .node_for_symbol(&target_file, &target_symbol)
7980 .is_some()
7981 {
7982 return (target_file, target_symbol);
7983 }
7984 if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
7985 resolved
7986 } else {
7987 (target_file, target_symbol)
7988 }
7989}
7990
7991fn rust_module_path_candidates(
7992 segments: &[&str],
7993 caller_data: &FileCallData,
7994 raw: &RawRef,
7995) -> Vec<Vec<String>> {
7996 let mut candidates = Vec::new();
7997 if let Some(first) = segments.first().copied() {
7998 for import in &caller_data.import_block.imports {
7999 if !rust_import_is_visible_to_call(import, raw) {
8000 continue;
8001 }
8002 let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
8003 continue;
8004 };
8005 if local_name == first {
8006 path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
8007 rust_push_unique_path_candidate(&mut candidates, path_segments);
8008 }
8009 }
8010 }
8011 rust_push_unique_path_candidate(
8012 &mut candidates,
8013 segments
8014 .iter()
8015 .map(|segment| (*segment).to_string())
8016 .collect(),
8017 );
8018 candidates
8019}
8020
8021fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
8022 if !candidates.iter().any(|existing| existing == &candidate) {
8023 candidates.push(candidate);
8024 }
8025}
8026
8027fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
8028 import.byte_range.start <= raw.byte_start
8029}
8030
8031fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
8032 let path = import.module_path.trim().trim_end_matches(';').trim();
8033 if path.contains("::{") || path.contains('{') || path.contains('*') {
8034 return None;
8035 }
8036 let (path_without_alias, alias) = path
8037 .split_once(" as ")
8038 .map(|(left, right)| (left.trim(), Some(right.trim())))
8039 .unwrap_or((path, None));
8040 let segments = path_without_alias
8041 .split("::")
8042 .map(str::trim)
8043 .filter(|segment| !segment.is_empty())
8044 .collect::<Vec<_>>();
8045 let local_name = alias.or_else(|| segments.last().copied())?.to_string();
8046 if local_name.chars().next().is_some_and(char::is_uppercase) {
8047 return None;
8048 }
8049 Some((
8050 local_name,
8051 segments
8052 .into_iter()
8053 .map(|segment| segment.to_string())
8054 .collect(),
8055 ))
8056}
8057
8058fn rust_inline_scoped_target(
8059 index: &ProjectIndex<'_>,
8060 caller_file: &str,
8061 module_segments: &[String],
8062 short_name: &str,
8063) -> Option<(String, String)> {
8064 let src_prefix = rust_src_prefix(caller_file);
8065 let mut file_paths = index.files.keys().cloned().collect::<Vec<_>>();
8066 file_paths.sort();
8067 if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
8068 let caller = file_paths.remove(position);
8069 file_paths.insert(0, caller);
8070 }
8071
8072 for file_path in file_paths {
8073 if index.lang_for(&file_path) != Some(LangId::Rust)
8074 || rust_src_prefix(&file_path) != src_prefix
8075 {
8076 continue;
8077 }
8078 let file_module_segments = rust_module_segments_for_rel(&file_path);
8079 if !module_segments.starts_with(&file_module_segments) {
8080 continue;
8081 }
8082 let scoped_segments = &module_segments[file_module_segments.len()..];
8083 if scoped_segments.is_empty() {
8084 continue;
8085 }
8086 let mut scoped_symbol = scoped_segments.join("::");
8087 scoped_symbol.push_str("::");
8088 scoped_symbol.push_str(short_name);
8089 if index.node_for_symbol(&file_path, &scoped_symbol).is_some() {
8090 return Some((file_path, scoped_symbol));
8091 }
8092 }
8093 None
8094}
8095
8096fn rust_target_for_use(
8097 index: &ProjectIndex<'_>,
8098 caller_file: &str,
8099 import: &ImportStatement,
8100 short_name: &str,
8101) -> Option<(String, String)> {
8102 let path = import.module_path.trim().trim_end_matches(';');
8103 if let Some(brace_start) = path.find("::{") {
8104 let prefix = &path[..brace_start];
8105 if import.names.iter().any(|name| name == short_name) {
8106 let prefix_segments: Vec<&str> = prefix.split("::").collect();
8107 let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
8108 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
8109 return Some((file, short_name.to_string()));
8110 }
8111 return None;
8112 }
8113
8114 let (path_without_alias, alias) = path
8115 .split_once(" as ")
8116 .map(|(left, right)| (left.trim(), Some(right.trim())))
8117 .unwrap_or((path, None));
8118 let segments: Vec<&str> = path_without_alias.split("::").collect();
8119 let imported = alias.or_else(|| segments.last().copied())?;
8120 if imported != short_name {
8121 return None;
8122 }
8123 if segments.len() < 2 {
8124 return None;
8125 }
8126 let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
8127 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
8128 Some((file, segments.last().unwrap_or(&short_name).to_string()))
8129}
8130
8131fn rust_workspace_file_for_segments(index: &ProjectIndex<'_>, segments: &[&str]) -> Option<String> {
8132 let crate_name = segments.first().copied()?;
8133 let src_prefix = index.crate_src_prefix(crate_name)?;
8134 let module_segments = segments[1..]
8135 .iter()
8136 .map(|segment| segment.to_string())
8137 .collect::<Vec<_>>();
8138 rust_file_for_src_prefix(index, &src_prefix, &module_segments)
8139}
8140
8141#[cfg(test)]
8142static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
8143 OnceLock::new();
8144
8145#[cfg(test)]
8146fn note_workspace_crate_prefix_build(project_root: &Path) {
8147 let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
8148 .get_or_init(|| Mutex::new(HashMap::new()))
8149 .lock()
8150 .expect("workspace crate prefix build counts mutex poisoned");
8151 *counts.entry(project_root.to_path_buf()).or_default() += 1;
8152}
8153
8154#[cfg(not(test))]
8155fn note_workspace_crate_prefix_build(_project_root: &Path) {}
8156
8157#[cfg(test)]
8158fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
8159 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
8160 .get_or_init(|| Mutex::new(HashMap::new()))
8161 .lock()
8162 .expect("workspace crate prefix build counts mutex poisoned")
8163 .remove(project_root);
8164}
8165
8166#[cfg(test)]
8167fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
8168 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
8169 .get_or_init(|| Mutex::new(HashMap::new()))
8170 .lock()
8171 .expect("workspace crate prefix build counts mutex poisoned")
8172 .get(project_root)
8173 .copied()
8174 .unwrap_or(0)
8175}
8176
8177fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
8182 note_workspace_crate_prefix_build(project_root);
8183 let mut prefixes = HashMap::new();
8184 let mut stack = vec![project_root.to_path_buf()];
8185 while let Some(dir) = stack.pop() {
8186 let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
8187 if matches!(name, "target" | "node_modules" | ".git") {
8188 continue;
8189 }
8190 let manifest = dir.join("Cargo.toml");
8191 if manifest.is_file() {
8192 let crate_names = rust_manifest_crate_names(&manifest);
8193 if !crate_names.is_empty() {
8194 let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
8195 for crate_name in crate_names {
8196 prefixes
8197 .entry(crate_name)
8198 .or_insert_with(|| src_prefix.clone());
8199 }
8200 }
8201 }
8202 let Ok(entries) = std::fs::read_dir(&dir) else {
8203 continue;
8204 };
8205 for entry in entries.flatten() {
8206 let path = entry.path();
8207 if path.is_dir() {
8208 stack.push(path);
8209 }
8210 }
8211 }
8212 prefixes
8213}
8214
8215fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
8219 let Ok(source) = std::fs::read_to_string(manifest) else {
8220 return Vec::new();
8221 };
8222 let mut in_lib = false;
8223 let mut package_name = None;
8224 let mut lib_name = None;
8225 for line in source.lines() {
8226 let trimmed = line.trim();
8227 if trimmed.starts_with('[') {
8228 in_lib = trimmed == "[lib]";
8229 continue;
8230 }
8231 let Some((key, value)) = trimmed.split_once('=') else {
8232 continue;
8233 };
8234 let key = key.trim();
8235 let value = value.trim().trim_matches('"');
8236 if in_lib && key == "name" {
8237 lib_name = Some(value.to_string());
8238 } else if !in_lib && key == "name" && package_name.is_none() {
8239 package_name = Some(value.to_string());
8240 }
8241 }
8242 let mut names = Vec::new();
8243 if let Some(lib) = lib_name {
8244 names.push(lib);
8245 }
8246 if let Some(package) = package_name {
8247 let normalized = package.replace('-', "_");
8248 if !names.contains(&normalized) {
8249 names.push(normalized);
8250 }
8251 }
8252 names
8253}
8254
8255fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
8256 if segments.is_empty() {
8257 return Some(Vec::new());
8258 }
8259 let caller_segments = rust_module_segments_for_rel(caller_file);
8260 match segments[0] {
8261 "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
8262 "self" => {
8263 let mut resolved = caller_segments;
8264 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
8265 Some(resolved)
8266 }
8267 "super" => {
8268 let mut resolved = caller_segments;
8269 resolved.pop();
8270 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
8271 Some(resolved)
8272 }
8273 _ => {
8274 let mut resolved = caller_segments;
8275 resolved.pop();
8276 resolved.extend(segments.iter().map(|item| item.to_string()));
8277 Some(resolved)
8278 }
8279 }
8280}
8281
8282fn rust_file_for_segments(
8283 index: &ProjectIndex<'_>,
8284 caller_file: &str,
8285 segments: &[String],
8286) -> Option<String> {
8287 rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
8288}
8289
8290fn rust_file_for_src_prefix(
8291 index: &ProjectIndex<'_>,
8292 src_prefix: &str,
8293 segments: &[String],
8294) -> Option<String> {
8295 let candidate = if segments.is_empty() {
8296 [src_prefix, "lib.rs"].join("/")
8297 } else {
8298 format!("{}/{}.rs", src_prefix, segments.join("/"))
8299 };
8300 if index.files.contains_key(&candidate) {
8301 return Some(candidate);
8302 }
8303 if !segments.is_empty() {
8304 let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
8305 if index.files.contains_key(&mod_candidate) {
8306 return Some(mod_candidate);
8307 }
8308 }
8309 None
8310}
8311
8312fn rust_src_prefix(rel_path: &str) -> String {
8313 rel_path
8314 .split_once("/src/")
8315 .map(|(prefix, _)| format!("{prefix}/src"))
8316 .unwrap_or_else(|| "src".to_string())
8317}
8318
8319fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
8320 let after_src = rel_path
8321 .split_once("/src/")
8322 .map(|(_, rest)| rest)
8323 .or_else(|| rel_path.strip_prefix("src/"))
8324 .unwrap_or(rel_path);
8325 if matches!(after_src, "lib.rs" | "main.rs") {
8326 return Vec::new();
8327 }
8328 if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
8329 return prefix.split('/').map(|item| item.to_string()).collect();
8330 }
8331 after_src
8332 .strip_suffix(".rs")
8333 .unwrap_or(after_src)
8334 .split('/')
8335 .map(|item| item.to_string())
8336 .collect()
8337}
8338
8339fn resolve_local_target(
8340 _index: &ProjectIndex<'_>,
8341 caller_file: &str,
8342 full_ref: &str,
8343 short_name: &str,
8344 caller_data: &FileCallData,
8345) -> Option<(String, String, String)> {
8346 if !callgraph::is_bare_callee(full_ref, short_name) {
8347 return None;
8348 }
8349 callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
8350 .ok()
8351 .map(|symbol| {
8352 (
8353 "resolved_local".to_string(),
8354 caller_file.to_string(),
8355 symbol,
8356 )
8357 })
8358}
8359
8360impl<'a> ProjectIndex<'a> {
8361 fn from_parts(
8362 project_root: &Path,
8363 files: HashMap<String, DbFileIndex>,
8364 caller_data: HashMap<String, &'a FileCallData>,
8365 workspace_crate_prefixes: WorkspaceCratePrefixCache,
8366 ) -> Self {
8367 Self {
8368 project_root: project_root.to_path_buf(),
8369 files,
8370 caller_data,
8371 workspace_crate_prefixes,
8372 }
8373 }
8374
8375 fn from_extracts(project_root: &Path, extracts: &'a [FileExtract]) -> Self {
8376 let mut files = HashMap::new();
8377 let mut caller_data = HashMap::new();
8378 for extract in extracts {
8379 let index = DbFileIndex::from_extract(project_root, extract);
8380 caller_data.insert(extract.rel_path.clone(), &extract.data);
8381 files.insert(extract.rel_path.clone(), index);
8382 }
8383 Self::from_parts(
8384 project_root,
8385 files,
8386 caller_data,
8387 WorkspaceCratePrefixCache::default(),
8388 )
8389 }
8390
8391 fn from_db_and_callers(
8392 tx: &Transaction<'_>,
8393 project_root: &Path,
8394 caller_extracts: &'a HashMap<String, FileExtract>,
8395 workspace_crate_prefixes: WorkspaceCratePrefixCache,
8396 ) -> Result<Self> {
8397 let mut files = load_db_file_indexes(tx, project_root)?;
8398 let mut caller_data = HashMap::new();
8399 for (rel_path, extract) in caller_extracts {
8400 files.insert(
8401 rel_path.clone(),
8402 DbFileIndex::from_extract(project_root, extract),
8403 );
8404 caller_data.insert(rel_path.clone(), &extract.data);
8405 }
8406 Ok(Self::from_parts(
8407 project_root,
8408 files,
8409 caller_data,
8410 workspace_crate_prefixes,
8411 ))
8412 }
8413
8414 fn lang_for(&self, rel_path: &str) -> Option<LangId> {
8415 self.files.get(rel_path).and_then(|file| file.lang)
8416 }
8417
8418 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
8419 self.files
8420 .get(caller_file)
8421 .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
8422 }
8423
8424 fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
8425 self.files
8426 .get(rel_path)
8427 .map(|file| file.reexports.as_slice())
8428 .unwrap_or(&[])
8429 }
8430
8431 fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
8432 self.files.get(rel_path).and_then(|file| {
8433 file.node_by_scoped
8434 .get(symbol)
8435 .cloned()
8436 .or_else(|| file.node_by_bare.get(symbol).cloned())
8437 })
8438 }
8439
8440 fn node_is_callable(&self, rel_path: &str, node_id: &str) -> bool {
8441 self.files
8442 .get(rel_path)
8443 .and_then(|file| file.node_kind_by_id.get(node_id))
8444 .is_some_and(|kind| matches!(kind.as_str(), "function" | "method"))
8445 }
8446}
8447
8448impl DbFileIndex {
8449 fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
8450 let mut node_by_scoped = HashMap::new();
8451 let mut node_by_bare = HashMap::new();
8452 for node in &extract.nodes {
8453 node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
8454 node_by_bare
8455 .entry(node.name.clone())
8456 .or_insert(node.id.clone());
8457 }
8458 let node_kind_by_id = extract
8459 .nodes
8460 .iter()
8461 .map(|node| (node.id.clone(), node.kind.clone()))
8462 .collect();
8463 let mut export_aliases = HashMap::new();
8464 for raw_ref in &extract.raw_refs {
8465 if raw_ref.kind == "export_alias" {
8466 if let (Some(exported), Some(source_symbol)) =
8467 (&raw_ref.local_name, &raw_ref.requested_name)
8468 {
8469 export_aliases.insert(exported.clone(), source_symbol.clone());
8470 }
8471 }
8472 }
8473 let mut module_targets = HashMap::new();
8474 let mut reexports = Vec::new();
8475 for raw_ref in &extract.raw_refs {
8476 if !matches!(raw_ref.kind.as_str(), "import" | "reexport") {
8477 continue;
8478 }
8479 let Some(module_path) = &raw_ref.module_path else {
8480 continue;
8481 };
8482 let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
8483 module_targets
8484 .entry(module_path.clone())
8485 .or_insert_with(|| target_file.clone());
8486 if raw_ref.kind == "reexport" {
8487 reexports.push(reexport_index_from_raw(raw_ref, target_file));
8488 }
8489 }
8490 Self {
8491 lang: Some(extract.lang),
8492 exports: extract.data.exported_symbols.iter().cloned().collect(),
8493 default_export: extract.data.default_export_symbol.clone(),
8494 export_aliases,
8495 node_by_scoped,
8496 node_by_bare,
8497 node_kind_by_id,
8498 module_targets,
8499 reexports,
8500 }
8501 }
8502}
8503
8504fn load_db_file_indexes(
8505 tx: &Transaction<'_>,
8506 project_root: &Path,
8507) -> Result<HashMap<String, DbFileIndex>> {
8508 let mut files = HashMap::new();
8509 let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
8510 let rows = stmt.query_map([], |row| {
8511 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8512 })?;
8513 for row in rows {
8514 let (rel_path, lang) = row?;
8515 files.insert(
8516 rel_path.clone(),
8517 DbFileIndex {
8518 lang: lang_from_label(&lang),
8519 exports: HashSet::new(),
8520 default_export: None,
8521 export_aliases: HashMap::new(),
8522 node_by_scoped: HashMap::new(),
8523 node_by_bare: HashMap::new(),
8524 node_kind_by_id: HashMap::new(),
8525 module_targets: HashMap::new(),
8526 reexports: Vec::new(),
8527 },
8528 );
8529 }
8530
8531 let mut node_stmt = tx.prepare(
8532 "SELECT file_path, id, name, scoped_name, kind, exported, is_default_export FROM nodes",
8533 )?;
8534 let nodes = node_stmt.query_map([], |row| {
8535 Ok((
8536 row.get::<_, String>(0)?,
8537 row.get::<_, String>(1)?,
8538 row.get::<_, String>(2)?,
8539 row.get::<_, String>(3)?,
8540 row.get::<_, String>(4)?,
8541 row.get::<_, i64>(5)? != 0,
8542 row.get::<_, i64>(6)? != 0,
8543 ))
8544 })?;
8545 for row in nodes {
8546 let (file_path, id, name, scoped_name, kind, exported, is_default_export) = row?;
8547 let file = files
8548 .entry(file_path.clone())
8549 .or_insert_with(|| DbFileIndex {
8550 lang: None,
8551 exports: HashSet::new(),
8552 default_export: None,
8553 export_aliases: HashMap::new(),
8554 node_by_scoped: HashMap::new(),
8555 node_by_bare: HashMap::new(),
8556 node_kind_by_id: HashMap::new(),
8557 module_targets: HashMap::new(),
8558 reexports: Vec::new(),
8559 });
8560 if exported {
8561 file.exports.insert(name.clone());
8562 file.exports.insert(scoped_name.clone());
8563 }
8564 if is_default_export {
8565 file.default_export = Some(scoped_name.clone());
8566 }
8567 file.node_by_scoped.insert(scoped_name, id.clone());
8568 file.node_by_bare.entry(name).or_insert(id.clone());
8569 file.node_kind_by_id.insert(id, kind);
8570 }
8571 let file_keys: HashSet<String> = files.keys().cloned().collect();
8572 let dependencies_by_file = load_file_dependencies_index(tx)?;
8576 let mut ref_stmt = tx.prepare(
8577 "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
8578 FROM refs WHERE kind IN ('reexport', 'export_alias')",
8579 )?;
8580 let ref_rows = ref_stmt.query_map([], |row| {
8581 Ok((
8582 row.get::<_, String>(0)?,
8583 row.get::<_, String>(1)?,
8584 row.get::<_, String>(2)?,
8585 row.get::<_, Option<String>>(3)?,
8586 row.get::<_, Option<String>>(4)?,
8587 row.get::<_, i64>(5)? != 0,
8588 row.get::<_, Option<String>>(6)?,
8589 row.get::<_, Option<String>>(7)?,
8590 ))
8591 })?;
8592 for row in ref_rows {
8593 let (
8594 ref_id,
8595 caller_file,
8596 kind,
8597 module_path,
8598 full_ref,
8599 wildcard,
8600 local_name,
8601 requested_name,
8602 ) = row?;
8603 if kind == "export_alias" {
8604 if let (Some(exported), Some(source_symbol), Some(file)) =
8605 (local_name, requested_name, files.get_mut(&caller_file))
8606 {
8607 file.export_aliases.insert(exported, source_symbol);
8608 }
8609 continue;
8610 }
8611 let Some(module_path) = module_path else {
8612 continue;
8613 };
8614 let file_deps = dependencies_by_file
8615 .get(&caller_file)
8616 .cloned()
8617 .unwrap_or_default();
8618 let deps = stored_dependencies_for_module(
8619 project_root,
8620 &caller_file,
8621 &module_path,
8622 &file_deps,
8623 &file_keys,
8624 );
8625 let target_file = deps
8626 .iter()
8627 .find(|dep| file_keys.contains(*dep))
8628 .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
8629 if let Some(file) = files.get_mut(&caller_file) {
8630 file.module_targets
8631 .entry(module_path.clone())
8632 .or_insert_with(|| target_file.clone());
8633 if kind == "reexport" {
8634 let raw = RawRef {
8635 ref_id,
8636 caller_node: None,
8637 caller_symbol: None,
8638 caller_file,
8639 kind,
8640 short_name: None,
8641 full_ref,
8642 module_path: Some(module_path),
8643 import_kind: Some("reexport".to_string()),
8644 local_name: None,
8645 requested_name: None,
8646 namespace_alias: None,
8647 wildcard,
8648 line: 0,
8649 byte_start: 0,
8650 byte_end: 0,
8651 dependencies: deps,
8652 };
8653 file.reexports
8654 .push(reexport_index_from_raw(&raw, target_file));
8655 }
8656 }
8657 }
8658
8659 Ok(files)
8660}
8661
8662fn stored_dependencies_for_module(
8663 project_root: &Path,
8664 caller_file: &str,
8665 module_path: &str,
8666 caller_dependencies: &BTreeSet<String>,
8667 indexed_files: &HashSet<String>,
8668) -> BTreeSet<String> {
8669 let caller_path = project_root.join(caller_file);
8670 let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
8671 if module_path.starts_with('.') {
8672 let caller_dir = caller_path.parent().unwrap_or(project_root);
8673 for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
8674 let normalized = if candidate.is_file() {
8675 canonicalize_path(&candidate)
8676 } else {
8677 candidate
8678 };
8679 candidates.insert(relative_path(project_root, &normalized));
8680 }
8681 }
8682 let exact = candidates
8683 .intersection(caller_dependencies)
8684 .filter(|dependency| indexed_files.contains(*dependency))
8685 .cloned()
8686 .collect::<BTreeSet<_>>();
8687 if !exact.is_empty() || module_path.starts_with('.') {
8688 return exact;
8689 }
8690
8691 let module_path = rust_module_path_without_alias_or_use_list(module_path)
8692 .trim_matches(|character| matches!(character, '\'' | '"'));
8693 let package_name = module_path
8694 .split('/')
8695 .next_back()
8696 .unwrap_or(module_path)
8697 .replace('_', "-");
8698 let matched = caller_dependencies
8699 .iter()
8700 .filter(|dependency| indexed_files.contains(*dependency))
8701 .filter(|dependency| {
8702 dependency.as_str() == module_path
8703 || dependency.ends_with(&format!("/{module_path}"))
8704 || Path::new(dependency).components().any(|component| {
8705 component.as_os_str().to_string_lossy().replace('_', "-") == package_name
8706 })
8707 })
8708 .cloned()
8709 .collect::<BTreeSet<_>>();
8710 if matched.len() == 1 {
8711 matched
8712 } else {
8713 BTreeSet::new()
8714 }
8715}
8716
8717fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
8718 let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
8719 let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
8720 let rows = stmt.query_map([], |row| {
8721 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8722 })?;
8723 for row in rows {
8724 let (file_path, dependency) = row?;
8725 by_file.entry(file_path).or_default().insert(dependency);
8726 }
8727 Ok(by_file)
8728}
8729
8730struct ColdBuildInsertStatements<'stmt> {
8731 file: Statement<'stmt>,
8732 node: Statement<'stmt>,
8733 file_dependency: Statement<'stmt>,
8734 dispatch_hint: Statement<'stmt>,
8735 backend_state: Statement<'stmt>,
8736 reference: Statement<'stmt>,
8737 edge: Statement<'stmt>,
8738}
8739
8740impl<'stmt> ColdBuildInsertStatements<'stmt> {
8741 fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
8742 Ok(Self {
8743 file: tx.prepare(
8744 "INSERT OR REPLACE INTO files(
8745 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
8746 is_public_api, surface_fingerprint, indexed_at
8747 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
8748 )?,
8749 node: tx.prepare(
8750 "INSERT OR REPLACE INTO nodes(
8751 id, file_path, name, scoped_name, kind, start_line, start_col,
8752 end_line, end_col, range_ordinal, signature, exported,
8753 is_default_export, is_type_like, is_callgraph_entry_point, provenance
8754 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
8755 )?,
8756 file_dependency: tx.prepare(
8757 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
8758 )?,
8759 dispatch_hint: tx.prepare(
8760 "INSERT OR REPLACE INTO dispatch_hints(
8761 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
8762 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
8763 )?,
8764 backend_state: tx.prepare(
8765 "INSERT OR REPLACE INTO backend_file_state(
8766 backend, workspace_root, file_path, content_hash, status, updated_at
8767 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
8768 )?,
8769 reference: tx.prepare(
8770 "INSERT OR REPLACE INTO refs(
8771 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
8772 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
8773 byte_start, byte_end, status, target_node, target_file, target_symbol,
8774 provenance
8775 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
8776 )?,
8777 edge: tx.prepare(
8778 "INSERT OR REPLACE INTO edges(
8779 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8780 kind, line, provenance
8781 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
8782 )?,
8783 })
8784 }
8785}
8786
8787fn insert_file_extract_prepared(
8788 statements: &mut ColdBuildInsertStatements<'_>,
8789 workspace_root: &str,
8790 extract: &FileExtract,
8791) -> Result<()> {
8792 statements.file.execute(params![
8793 extract.rel_path,
8794 hash_to_hex(extract.freshness.content_hash),
8795 system_time_to_ns(extract.freshness.mtime),
8796 extract.freshness.size as i64,
8797 lang_label(extract.lang),
8798 extract.surface_fingerprint,
8799 unix_seconds_now(),
8800 ])?;
8801 for node in &extract.nodes {
8802 statements.node.execute(params![
8803 node.id,
8804 node.file_path,
8805 node.name,
8806 node.scoped_name,
8807 node.kind,
8808 node.range.start_line as i64,
8809 node.range.start_col as i64,
8810 node.range.end_line as i64,
8811 node.range.end_col as i64,
8812 node.range_ordinal as i64,
8813 node.signature,
8814 bool_int(node.exported),
8815 bool_int(node.is_default_export),
8816 bool_int(node.is_type_like),
8817 bool_int(node.is_callgraph_entry_point),
8818 PROVENANCE_TREESITTER,
8819 ])?;
8820 }
8821
8822 let mut dependencies = BTreeSet::new();
8823 for raw_ref in &extract.raw_refs {
8824 dependencies.extend(raw_ref.dependencies.iter().cloned());
8825 }
8826 for dep_file in &dependencies {
8827 statements
8828 .file_dependency
8829 .execute(params![extract.rel_path, dep_file])?;
8830 }
8831
8832 for hint in &extract.dispatch_hints {
8833 statements.dispatch_hint.execute(params![
8834 hint.id,
8835 hint.method_name,
8836 hint.caller_node,
8837 hint.file,
8838 hint.line as i64,
8839 hint.byte_start as i64,
8840 hint.byte_end as i64,
8841 PROVENANCE_TREESITTER,
8842 ])?;
8843 }
8844 insert_backend_state_prepared(
8845 &mut statements.backend_state,
8846 workspace_root,
8847 &extract.rel_path,
8848 Some(&extract.freshness.content_hash),
8849 "fresh",
8850 )?;
8851 Ok(())
8852}
8853
8854fn insert_backend_state_prepared(
8855 stmt: &mut Statement<'_>,
8856 workspace_root: &str,
8857 rel_path: &str,
8858 content_hash: Option<&blake3::Hash>,
8859 status: &str,
8860) -> Result<()> {
8861 let hash = content_hash
8862 .map(|hash| hash_to_hex(*hash))
8863 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
8864 stmt.execute(params![
8865 BACKEND_TREESITTER,
8866 workspace_root,
8867 rel_path,
8868 hash,
8869 status,
8870 unix_seconds_now(),
8871 ])?;
8872 Ok(())
8873}
8874
8875fn insert_resolved_ref_prepared(
8876 statements: &mut ColdBuildInsertStatements<'_>,
8877 resolved: &ResolvedRef,
8878) -> Result<()> {
8879 let raw = &resolved.raw;
8880 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
8881 statements.reference.execute(params![
8882 raw.ref_id,
8883 raw.caller_node,
8884 raw.caller_file,
8885 raw.kind,
8886 raw.short_name,
8887 raw.full_ref,
8888 raw.module_path,
8889 raw.import_kind,
8890 raw.local_name,
8891 raw.requested_name,
8892 raw.namespace_alias,
8893 bool_int(raw.wildcard),
8894 raw.line as i64,
8895 raw.byte_start as i64,
8896 raw.byte_end as i64,
8897 resolved.status,
8898 resolved.target_node,
8899 resolved.target_file,
8900 resolved.target_symbol,
8901 ref_provenance(raw),
8902 ])?;
8903 if let Some(edge) = &resolved.edge {
8904 statements.edge.execute(params![
8905 edge.edge_id,
8906 raw.ref_id,
8907 edge.source_node,
8908 edge.target_node,
8909 edge.target_file,
8910 edge.target_symbol,
8911 edge.kind,
8912 edge.line as i64,
8913 ref_provenance(raw),
8914 ])?;
8915 }
8916 Ok(())
8917}
8918
8919#[cfg(test)]
8920fn insert_file_extract(
8921 tx: &Transaction<'_>,
8922 project_root: &Path,
8923 extract: &FileExtract,
8924) -> Result<()> {
8925 tx.execute(
8926 "INSERT OR REPLACE INTO files(
8927 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
8928 is_public_api, surface_fingerprint, indexed_at
8929 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
8930 params![
8931 extract.rel_path,
8932 hash_to_hex(extract.freshness.content_hash),
8933 system_time_to_ns(extract.freshness.mtime),
8934 extract.freshness.size as i64,
8935 lang_label(extract.lang),
8936 extract.surface_fingerprint,
8937 unix_seconds_now(),
8938 ],
8939 )?;
8940 for node in &extract.nodes {
8941 tx.execute(
8942 "INSERT OR REPLACE INTO nodes(
8943 id, file_path, name, scoped_name, kind, start_line, start_col,
8944 end_line, end_col, range_ordinal, signature, exported,
8945 is_default_export, is_type_like, is_callgraph_entry_point, provenance
8946 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
8947 params![
8948 node.id,
8949 node.file_path,
8950 node.name,
8951 node.scoped_name,
8952 node.kind,
8953 node.range.start_line as i64,
8954 node.range.start_col as i64,
8955 node.range.end_line as i64,
8956 node.range.end_col as i64,
8957 node.range_ordinal as i64,
8958 node.signature,
8959 bool_int(node.exported),
8960 bool_int(node.is_default_export),
8961 bool_int(node.is_type_like),
8962 bool_int(node.is_callgraph_entry_point),
8963 PROVENANCE_TREESITTER,
8964 ],
8965 )?;
8966 }
8967 let mut dependencies = BTreeSet::new();
8968 for raw_ref in &extract.raw_refs {
8969 dependencies.extend(raw_ref.dependencies.iter().cloned());
8970 }
8971 insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
8972
8973 for hint in &extract.dispatch_hints {
8974 tx.execute(
8975 "INSERT OR REPLACE INTO dispatch_hints(
8976 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
8977 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
8978 params![
8979 hint.id,
8980 hint.method_name,
8981 hint.caller_node,
8982 hint.file,
8983 hint.line as i64,
8984 hint.byte_start as i64,
8985 hint.byte_end as i64,
8986 PROVENANCE_TREESITTER,
8987 ],
8988 )?;
8989 }
8990 mark_backend_state(
8991 tx,
8992 project_root,
8993 &extract.rel_path,
8994 Some(&extract.freshness.content_hash),
8995 "fresh",
8996 )?;
8997 Ok(())
8998}
8999
9000#[cfg(test)]
9001fn insert_file_dependencies(
9002 tx: &Transaction<'_>,
9003 file_path: &str,
9004 dependencies: &BTreeSet<String>,
9005) -> Result<()> {
9006 for dep_file in dependencies {
9007 tx.execute(
9008 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
9009 params![file_path, dep_file],
9010 )?;
9011 }
9012 Ok(())
9013}
9014
9015fn ref_provenance(raw: &RawRef) -> &'static str {
9016 if raw.kind == "value_ref" {
9017 PROVENANCE_VALUE_REF
9018 } else {
9019 PROVENANCE_TREESITTER
9020 }
9021}
9022
9023#[cfg(test)]
9024fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
9025 let raw = &resolved.raw;
9026 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
9027 tx.execute(
9028 "INSERT OR REPLACE INTO refs(
9029 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
9030 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
9031 byte_start, byte_end, status, target_node, target_file, target_symbol,
9032 provenance
9033 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
9034 params![
9035 raw.ref_id,
9036 raw.caller_node,
9037 raw.caller_file,
9038 raw.kind,
9039 raw.short_name,
9040 raw.full_ref,
9041 raw.module_path,
9042 raw.import_kind,
9043 raw.local_name,
9044 raw.requested_name,
9045 raw.namespace_alias,
9046 bool_int(raw.wildcard),
9047 raw.line as i64,
9048 raw.byte_start as i64,
9049 raw.byte_end as i64,
9050 resolved.status,
9051 resolved.target_node,
9052 resolved.target_file,
9053 resolved.target_symbol,
9054 ref_provenance(raw),
9055 ],
9056 )?;
9057 if let Some(edge) = &resolved.edge {
9058 tx.execute(
9059 "INSERT OR REPLACE INTO edges(
9060 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
9061 kind, line, provenance
9062 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
9063 params![
9064 edge.edge_id,
9065 raw.ref_id,
9066 edge.source_node,
9067 edge.target_node,
9068 edge.target_file,
9069 edge.target_symbol,
9070 edge.kind,
9071 edge.line as i64,
9072 ref_provenance(raw),
9073 ],
9074 )?;
9075 }
9076 Ok(())
9077}
9078
9079fn insert_method_dispatch_edges(
9080 tx: &Transaction<'_>,
9081 project_root: &Path,
9082 caller_files: Option<&BTreeSet<String>>,
9083) -> Result<usize> {
9084 let references = load_name_match_refs(tx, caller_files)?;
9085 if references.is_empty() {
9086 return Ok(0);
9087 }
9088
9089 let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
9090 let mut source_cache: DispatchSourceCache = HashMap::new();
9091 let mut inserted = 0usize;
9092 for reference in references {
9093 let key = (reference.method_name.clone(), reference.lang.clone());
9094 let candidates = match candidates_by_name.entry(key) {
9095 Entry::Occupied(entry) => entry.into_mut(),
9096 Entry::Vacant(entry) => {
9097 let candidates =
9098 load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
9099 entry.insert(candidates)
9100 }
9101 };
9102
9103 match infer_receiver_type_state(project_root, &reference, &mut source_cache) {
9104 ReceiverTypeInference::Known(receiver_type) => {
9105 let Some(candidate) =
9106 select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
9107 else {
9108 continue;
9109 };
9110 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
9111 inserted += 1;
9112 continue;
9113 }
9114 ReceiverTypeInference::RustDirectSelfField {
9115 receiver_type,
9116 declaration_file,
9117 module_scope,
9118 } => {
9119 let Some(candidate) = select_rust_direct_self_field_candidate(
9120 project_root,
9121 &reference,
9122 candidates.as_slice(),
9123 &receiver_type,
9124 &declaration_file,
9125 &module_scope,
9126 &mut source_cache,
9127 ) else {
9128 continue;
9129 };
9130 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
9131 inserted += 1;
9132 continue;
9133 }
9134 ReceiverTypeInference::KnownButUnresolved => continue,
9135 ReceiverTypeInference::Unknown => {}
9136 }
9137
9138 if method_name_match_denylisted(&reference.method_name) {
9139 continue;
9140 }
9141
9142 let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
9143 continue;
9144 };
9145 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
9146 inserted += 1;
9147 }
9148 Ok(inserted)
9149}
9150
9151fn insert_method_dispatch_edges_chunked(
9152 tx: &Transaction<'_>,
9153 project_root: &Path,
9154 caller_files: &BTreeSet<String>,
9155 chunk_size: usize,
9156) -> Result<usize> {
9157 if caller_files.is_empty() {
9158 return Ok(0);
9159 }
9160 if chunk_size == 0 || caller_files.len() <= chunk_size {
9161 return insert_method_dispatch_edges(tx, project_root, Some(caller_files));
9162 }
9163
9164 let mut inserted = 0usize;
9165 let mut batch = BTreeSet::new();
9166 for caller_file in caller_files {
9167 batch.insert(caller_file.clone());
9168 if batch.len() == chunk_size {
9169 inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
9170 batch.clear();
9171 }
9172 }
9173 if !batch.is_empty() {
9174 inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
9175 }
9176 Ok(inserted)
9177}
9178
9179fn insert_method_dispatch_edge(
9180 tx: &Transaction<'_>,
9181 reference: &NameMatchRef,
9182 candidate: &NameMatchCandidate,
9183 provenance: &str,
9184) -> Result<()> {
9185 tx.execute(
9186 "INSERT OR REPLACE INTO edges(
9187 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
9188 kind, line, provenance
9189 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
9190 params![
9191 ref_id(&[&reference.ref_id, provenance, "edge"]),
9192 &reference.ref_id,
9193 &reference.caller_node,
9194 &candidate.node_id,
9195 &candidate.file_path,
9196 &candidate.scoped_name,
9197 reference.line as i64,
9198 provenance,
9199 ],
9200 )?;
9201 Ok(())
9202}
9203
9204fn delete_method_dispatch_edges_for_callers(
9205 tx: &Transaction<'_>,
9206 caller_files: &BTreeSet<String>,
9207) -> Result<()> {
9208 if caller_files.is_empty() {
9209 return Ok(());
9210 }
9211
9212 let mut stmt = tx.prepare(
9213 "DELETE FROM edges
9214 WHERE provenance IN (?1, ?2)
9215 AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
9216 )?;
9217 for caller_file in caller_files {
9218 stmt.execute(params![
9219 PROVENANCE_NAME_MATCH,
9220 PROVENANCE_TYPE_MATCH,
9221 caller_file
9222 ])?;
9223 }
9224 Ok(())
9225}
9226
9227fn load_name_match_refs(
9228 tx: &Transaction<'_>,
9229 caller_files: Option<&BTreeSet<String>>,
9230) -> Result<Vec<NameMatchRef>> {
9231 let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
9232 n.signature, r.short_name, r.full_ref, r.line, f.lang
9233 FROM refs r
9234 JOIN files f ON f.path = r.caller_file
9235 JOIN nodes n ON n.id = r.caller_node
9236 WHERE r.kind = 'call'
9237 AND r.status = 'unresolved'
9238 AND r.caller_node IS NOT NULL
9239 AND r.full_ref IS NOT NULL
9240 AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
9241 AND NOT EXISTS (
9242 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
9243 )";
9244 let mut references = Vec::new();
9245
9246 if let Some(caller_files) = caller_files {
9247 if caller_files.is_empty() {
9248 return Ok(references);
9249 }
9250 let sql = format!(
9251 "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
9252 );
9253 let mut stmt = tx.prepare(&sql)?;
9254 for caller_file in caller_files {
9255 let rows = stmt.query_map(params![caller_file], |row| {
9256 Ok((
9257 row.get::<_, String>(0)?,
9258 row.get::<_, Option<String>>(1)?,
9259 row.get::<_, String>(2)?,
9260 row.get::<_, String>(3)?,
9261 row.get::<_, Option<String>>(4)?,
9262 row.get::<_, Option<String>>(5)?,
9263 row.get::<_, Option<String>>(6)?,
9264 row.get::<_, i64>(7)?,
9265 row.get::<_, String>(8)?,
9266 ))
9267 })?;
9268 for row in rows {
9269 let (
9270 ref_id,
9271 caller_node,
9272 caller_file,
9273 caller_symbol,
9274 caller_signature,
9275 short_name,
9276 full_ref,
9277 line,
9278 lang,
9279 ) = row?;
9280 if let Some(reference) = name_match_ref_from_parts(
9281 ref_id,
9282 caller_node,
9283 caller_file,
9284 caller_symbol,
9285 caller_signature,
9286 short_name,
9287 full_ref,
9288 line,
9289 lang,
9290 ) {
9291 references.push(reference);
9292 }
9293 }
9294 }
9295 return Ok(references);
9296 }
9297
9298 let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
9299 let mut stmt = tx.prepare(&sql)?;
9300 let rows = stmt.query_map([], |row| {
9301 Ok((
9302 row.get::<_, String>(0)?,
9303 row.get::<_, Option<String>>(1)?,
9304 row.get::<_, String>(2)?,
9305 row.get::<_, String>(3)?,
9306 row.get::<_, Option<String>>(4)?,
9307 row.get::<_, Option<String>>(5)?,
9308 row.get::<_, Option<String>>(6)?,
9309 row.get::<_, i64>(7)?,
9310 row.get::<_, String>(8)?,
9311 ))
9312 })?;
9313 for row in rows {
9314 let (
9315 ref_id,
9316 caller_node,
9317 caller_file,
9318 caller_symbol,
9319 caller_signature,
9320 short_name,
9321 full_ref,
9322 line,
9323 lang,
9324 ) = row?;
9325 if let Some(reference) = name_match_ref_from_parts(
9326 ref_id,
9327 caller_node,
9328 caller_file,
9329 caller_symbol,
9330 caller_signature,
9331 short_name,
9332 full_ref,
9333 line,
9334 lang,
9335 ) {
9336 references.push(reference);
9337 }
9338 }
9339 Ok(references)
9340}
9341
9342#[allow(clippy::too_many_arguments)]
9343fn name_match_ref_from_parts(
9344 ref_id: String,
9345 caller_node: Option<String>,
9346 caller_file: String,
9347 caller_symbol: String,
9348 caller_signature: Option<String>,
9349 short_name: Option<String>,
9350 full_ref: Option<String>,
9351 line: i64,
9352 lang: String,
9353) -> Option<NameMatchRef> {
9354 let caller_node = caller_node?;
9355 let full_ref = full_ref?;
9356 let (receiver_expression, receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
9357 let method_name = if member.is_empty() {
9358 short_name.as_deref()?.to_string()
9359 } else {
9360 member
9361 };
9362 Some(NameMatchRef {
9363 ref_id,
9364 caller_node,
9365 caller_file,
9366 caller_symbol,
9367 caller_signature,
9368 receiver_expression,
9369 receiver,
9370 method_name,
9371 colon_dispatch,
9372 line: line.max(0) as u32,
9373 lang,
9374 })
9375}
9376
9377fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, String, bool)> {
9378 let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
9379 let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
9380 let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
9381 let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
9382 .into_iter()
9383 .flatten()
9384 .max_by_key(|(index, _, _)| *index)?;
9385 if delimiter == 0 {
9386 return None;
9387 }
9388 let member_start = delimiter + delimiter_len;
9389 if member_start >= full_ref.len() {
9390 return None;
9391 }
9392 let receiver_expression = full_ref[..delimiter].trim();
9393 let receiver = last_name_segment(receiver_expression).trim();
9394 let member = &full_ref[member_start..];
9395 if receiver.is_empty() || member.is_empty() {
9396 return None;
9397 }
9398 Some((
9399 receiver_expression.to_string(),
9400 receiver.to_string(),
9401 member.to_string(),
9402 colon_dispatch,
9403 ))
9404}
9405
9406fn last_name_segment(value: &str) -> &str {
9407 value
9408 .rsplit(['.', ':', '/', '\\', '-', '>'])
9409 .find(|segment| !segment.is_empty())
9410 .unwrap_or(value)
9411}
9412
9413fn load_name_match_candidates(
9414 tx: &Transaction<'_>,
9415 method_name: &str,
9416 lang: &str,
9417) -> Result<Vec<NameMatchCandidate>> {
9418 let mut stmt = tx.prepare(
9419 "SELECT n.id, n.file_path, n.scoped_name, n.kind, n.start_line
9420 FROM nodes n JOIN files f ON f.path = n.file_path
9421 WHERE n.name = ?1
9422 AND f.lang = ?2
9423 AND n.kind IN ('method', 'function')
9424 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
9425 )?;
9426 let rows = stmt.query_map(params![method_name, lang], |row| {
9427 Ok(NameMatchCandidate {
9428 node_id: row.get(0)?,
9429 file_path: row.get(1)?,
9430 scoped_name: row.get(2)?,
9431 kind: row.get(3)?,
9432 start_line: (row.get::<_, i64>(4)?.max(0) as u32).saturating_add(1),
9433 })
9434 })?;
9435 rows.collect::<std::result::Result<Vec<_>, _>>()
9436 .map_err(Into::into)
9437}
9438
9439struct ParsedDispatchSource {
9440 source: String,
9441 tree: tree_sitter::Tree,
9442}
9443
9444type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
9445
9446#[derive(Debug, Clone, PartialEq, Eq)]
9447enum ReceiverTypeInference {
9448 Unknown,
9449 Known(String),
9450 RustDirectSelfField {
9451 receiver_type: String,
9452 declaration_file: String,
9453 module_scope: Vec<(usize, usize)>,
9454 },
9455 KnownButUnresolved,
9456}
9457
9458#[cfg(test)]
9459fn infer_receiver_type(
9460 project_root: &Path,
9461 reference: &NameMatchRef,
9462 source_cache: &mut DispatchSourceCache,
9463) -> Option<String> {
9464 match infer_receiver_type_state(project_root, reference, source_cache) {
9465 ReceiverTypeInference::Known(receiver_type)
9466 | ReceiverTypeInference::RustDirectSelfField { receiver_type, .. } => Some(receiver_type),
9467 ReceiverTypeInference::Unknown | ReceiverTypeInference::KnownButUnresolved => None,
9468 }
9469}
9470
9471fn infer_receiver_type_state(
9472 project_root: &Path,
9473 reference: &NameMatchRef,
9474 source_cache: &mut DispatchSourceCache,
9475) -> ReceiverTypeInference {
9476 let known = |receiver_type| ReceiverTypeInference::Known(receiver_type);
9477 match reference.lang.as_str() {
9478 "rust" => infer_rust_receiver_type(project_root, reference, source_cache),
9479 "java" => {
9480 infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
9481 .map(known)
9482 .unwrap_or(ReceiverTypeInference::Unknown)
9483 }
9484 "kotlin" => {
9485 infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
9486 .map(known)
9487 .unwrap_or(ReceiverTypeInference::Unknown)
9488 }
9489 "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache)
9490 .map(known)
9491 .unwrap_or(ReceiverTypeInference::Unknown),
9492 _ => ReceiverTypeInference::Unknown,
9493 }
9494}
9495
9496fn parse_dispatch_source(
9497 project_root: &Path,
9498 caller_file: &str,
9499 lang: LangId,
9500) -> Option<ParsedDispatchSource> {
9501 let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
9502 let grammar = crate::parser::grammar_for(lang);
9503 let mut parser = tree_sitter::Parser::new();
9504 parser.set_language(&grammar).ok()?;
9505 let tree = parser.parse(&source, None)?;
9506 Some(ParsedDispatchSource { source, tree })
9507}
9508
9509fn parsed_dispatch_source<'a>(
9510 project_root: &Path,
9511 reference: &NameMatchRef,
9512 lang: LangId,
9513 source_cache: &'a mut DispatchSourceCache,
9514) -> Option<&'a ParsedDispatchSource> {
9515 parsed_dispatch_source_for_file(
9516 project_root,
9517 &reference.caller_file,
9518 &reference.lang,
9519 lang,
9520 source_cache,
9521 )
9522}
9523
9524fn parsed_dispatch_source_for_file<'a>(
9525 project_root: &Path,
9526 file_path: &str,
9527 lang_label: &str,
9528 lang: LangId,
9529 source_cache: &'a mut DispatchSourceCache,
9530) -> Option<&'a ParsedDispatchSource> {
9531 let key = (file_path.to_string(), lang_label.to_string());
9532 source_cache
9533 .entry(key)
9534 .or_insert_with(|| parse_dispatch_source(project_root, file_path, lang))
9535 .as_ref()
9536}
9537
9538fn infer_java_like_receiver_type(
9539 project_root: &Path,
9540 reference: &NameMatchRef,
9541 lang: LangId,
9542 source_cache: &mut DispatchSourceCache,
9543) -> Option<String> {
9544 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
9545 return None;
9546 }
9547
9548 let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
9549 let root = parsed.tree.root_node();
9550 let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
9551
9552 let callable_scope = type_node
9553 .and_then(|node| {
9554 find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
9555 })
9556 .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
9557
9558 if let Some(callable_scope) = callable_scope {
9559 if let Some(receiver_type) = infer_java_like_local_receiver_type(
9560 callable_scope,
9561 &parsed.source,
9562 &reference.receiver,
9563 reference.line.max(1),
9564 lang,
9565 ) {
9566 return Some(receiver_type);
9567 }
9568 }
9569
9570 type_node.and_then(|node| {
9571 infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
9572 })
9573}
9574
9575fn infer_cpp_receiver_type(
9576 project_root: &Path,
9577 reference: &NameMatchRef,
9578 source_cache: &mut DispatchSourceCache,
9579) -> Option<String> {
9580 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
9581 return None;
9582 }
9583
9584 let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
9585 let root = parsed.tree.root_node();
9586 let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
9587 infer_cpp_receiver_type_from_scope(
9588 scope,
9589 &parsed.source,
9590 &reference.receiver,
9591 reference.line.max(1),
9592 )
9593}
9594
9595fn find_enclosing_java_like_type_node<'tree>(
9596 root: tree_sitter::Node<'tree>,
9597 source: &str,
9598 reference: &NameMatchRef,
9599 lang: LangId,
9600) -> Option<tree_sitter::Node<'tree>> {
9601 let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
9602 .and_then(|name| simple_type_name(&name));
9603 let line = reference.line.max(1);
9604 let mut best = None;
9605 let mut stack = vec![root];
9606 while let Some(node) = stack.pop() {
9607 if !node_contains_line(node, line) {
9608 continue;
9609 }
9610 if is_java_like_type_kind(node.kind(), lang) {
9611 let name = declaration_name(node, source);
9612 if expected_type
9613 .as_deref()
9614 .is_none_or(|expected| name == Some(expected))
9615 {
9616 best = tighter_node(best, node);
9617 }
9618 }
9619 push_named_children(node, &mut stack);
9620 }
9621 best
9622}
9623
9624fn find_enclosing_java_like_callable_node<'tree>(
9625 root: tree_sitter::Node<'tree>,
9626 source: &str,
9627 reference: &NameMatchRef,
9628 lang: LangId,
9629) -> Option<tree_sitter::Node<'tree>> {
9630 let expected_name = reference.caller_symbol.rsplit("::").next();
9631 let line = reference.line.max(1);
9632 let mut best = None;
9633 let mut stack = vec![root];
9634 while let Some(node) = stack.pop() {
9635 if !node_contains_line(node, line) {
9636 continue;
9637 }
9638 if is_java_like_callable_kind(node.kind(), lang) {
9639 let name = declaration_name(node, source);
9640 if expected_name.is_none_or(|expected| name == Some(expected)) {
9641 best = tighter_node(best, node);
9642 }
9643 }
9644 push_named_children(node, &mut stack);
9645 }
9646 best
9647}
9648
9649fn find_enclosing_cpp_callable_node<'tree>(
9650 root: tree_sitter::Node<'tree>,
9651 _source: &str,
9652 reference: &NameMatchRef,
9653) -> Option<tree_sitter::Node<'tree>> {
9654 let line = reference.line.max(1);
9655 let mut best = None;
9656 let mut stack = vec![root];
9657 while let Some(node) = stack.pop() {
9658 if !node_contains_line(node, line) {
9659 continue;
9660 }
9661 if node.kind() == "function_definition" {
9662 best = tighter_node(best, node);
9663 }
9664 push_named_children(node, &mut stack);
9665 }
9666 best
9667}
9668
9669fn tighter_node<'tree>(
9670 current: Option<tree_sitter::Node<'tree>>,
9671 candidate: tree_sitter::Node<'tree>,
9672) -> Option<tree_sitter::Node<'tree>> {
9673 match current {
9674 Some(current)
9675 if current.start_byte() > candidate.start_byte()
9676 || (current.start_byte() == candidate.start_byte()
9677 && current.end_byte() <= candidate.end_byte()) =>
9678 {
9679 Some(current)
9680 }
9681 _ => Some(candidate),
9682 }
9683}
9684
9685fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
9686 let start = node.start_position().row as u32 + 1;
9687 let end = node.end_position().row as u32 + 1;
9688 start <= line && line <= end
9689}
9690
9691fn push_named_children<'tree>(
9692 node: tree_sitter::Node<'tree>,
9693 stack: &mut Vec<tree_sitter::Node<'tree>>,
9694) {
9695 for index in 0..node.named_child_count() {
9696 if let Some(child) = node.named_child(index as u32) {
9697 stack.push(child);
9698 }
9699 }
9700}
9701
9702fn declaration_name<'source>(
9703 node: tree_sitter::Node<'_>,
9704 source: &'source str,
9705) -> Option<&'source str> {
9706 node.child_by_field_name("name")
9707 .map(|name| node_text(name, source))
9708 .or_else(|| {
9709 first_named_child_text(
9710 node,
9711 source,
9712 &["identifier", "type_identifier", "simple_identifier"],
9713 )
9714 })
9715}
9716
9717fn first_named_child_text<'source>(
9718 node: tree_sitter::Node<'_>,
9719 source: &'source str,
9720 kinds: &[&str],
9721) -> Option<&'source str> {
9722 for index in 0..node.named_child_count() {
9723 let child = node.named_child(index as u32)?;
9724 if kinds.contains(&child.kind()) {
9725 return Some(node_text(child, source));
9726 }
9727 }
9728 None
9729}
9730
9731fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
9732 &source[node.byte_range()]
9733}
9734
9735fn infer_java_like_field_receiver_type(
9736 type_node: tree_sitter::Node<'_>,
9737 source: &str,
9738 receiver: &str,
9739 lang: LangId,
9740) -> Option<String> {
9741 let mut stack = Vec::new();
9742 push_named_children(type_node, &mut stack);
9743 while let Some(node) = stack.pop() {
9744 if is_java_like_field_kind(node.kind(), lang) {
9745 if let Some(receiver_type) =
9746 extract_java_like_declared_type(node_text(node, source), receiver, lang)
9747 {
9748 return Some(receiver_type);
9749 }
9750 }
9751 if is_java_like_type_kind(node.kind(), lang)
9752 || is_java_like_callable_kind(node.kind(), lang)
9753 {
9754 continue;
9755 }
9756 push_named_children(node, &mut stack);
9757 }
9758 None
9759}
9760
9761fn infer_java_like_local_receiver_type(
9762 callable_node: tree_sitter::Node<'_>,
9763 source: &str,
9764 receiver: &str,
9765 call_line: u32,
9766 lang: LangId,
9767) -> Option<String> {
9768 let mut best: Option<(u32, String)> = None;
9769 let mut stack = Vec::new();
9770 push_named_children(callable_node, &mut stack);
9771 while let Some(node) = stack.pop() {
9772 let start_line = node.start_position().row as u32 + 1;
9773 if start_line > call_line {
9774 continue;
9775 }
9776 if is_java_like_local_kind(node.kind(), lang) {
9777 if let Some(receiver_type) =
9778 extract_java_like_declared_type(node_text(node, source), receiver, lang)
9779 {
9780 if best
9781 .as_ref()
9782 .is_none_or(|(best_line, _)| start_line >= *best_line)
9783 {
9784 best = Some((start_line, receiver_type));
9785 }
9786 }
9787 }
9788 if is_java_like_type_kind(node.kind(), lang)
9789 || is_java_like_callable_kind(node.kind(), lang)
9790 {
9791 continue;
9792 }
9793 push_named_children(node, &mut stack);
9794 }
9795 best.map(|(_, receiver_type)| receiver_type)
9796}
9797
9798fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
9799 match lang {
9800 LangId::Java => matches!(
9801 kind,
9802 "class_declaration"
9803 | "interface_declaration"
9804 | "enum_declaration"
9805 | "record_declaration"
9806 | "annotation_type_declaration"
9807 ),
9808 LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
9809 _ => false,
9810 }
9811}
9812
9813fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
9814 match lang {
9815 LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
9816 LangId::Kotlin => kind == "function_declaration",
9817 _ => false,
9818 }
9819}
9820
9821fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
9822 match lang {
9823 LangId::Java => kind == "field_declaration",
9824 LangId::Kotlin => kind == "property_declaration",
9825 _ => false,
9826 }
9827}
9828
9829fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
9830 match lang {
9831 LangId::Java => kind == "local_variable_declaration",
9832 LangId::Kotlin => kind == "property_declaration",
9833 _ => false,
9834 }
9835}
9836
9837fn extract_java_like_declared_type(
9838 declaration: &str,
9839 receiver: &str,
9840 lang: LangId,
9841) -> Option<String> {
9842 match lang {
9843 LangId::Java => extract_java_declared_type(declaration, receiver),
9844 LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
9845 _ => None,
9846 }
9847}
9848
9849fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
9850 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
9851 let after = declaration[receiver_start + receiver.len()..].trim_start();
9852 if after
9853 .chars()
9854 .next()
9855 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
9856 {
9857 return None;
9858 }
9859
9860 let before = declaration[..receiver_start].trim_end();
9861 if before.contains(',') {
9862 return None;
9863 }
9864 normalize_receiver_type_name(strip_java_declaration_prefixes(before))
9865}
9866
9867fn strip_java_declaration_prefixes(mut value: &str) -> &str {
9868 loop {
9869 value = value.trim_start();
9870 if let Some(stripped) = strip_leading_java_annotation(value) {
9871 value = stripped;
9872 continue;
9873 }
9874 if let Some(stripped) = strip_leading_java_modifier(value) {
9875 value = stripped;
9876 continue;
9877 }
9878 return value.trim();
9879 }
9880}
9881
9882fn strip_leading_java_annotation(value: &str) -> Option<&str> {
9883 let value = value.trim_start();
9884 let mut chars = value.char_indices();
9885 let (_, first) = chars.next()?;
9886 if first != '@' {
9887 return None;
9888 }
9889 let mut end = first.len_utf8();
9890 for (index, ch) in chars {
9891 if !(is_code_ident_char(ch) || ch == '.') {
9892 end = index;
9893 break;
9894 }
9895 end = index + ch.len_utf8();
9896 }
9897 let rest = value[end..].trim_start();
9898 if let Some(stripped) = rest.strip_prefix('(') {
9899 let mut depth = 1usize;
9900 for (index, ch) in stripped.char_indices() {
9901 match ch {
9902 '(' => depth += 1,
9903 ')' => {
9904 depth = depth.saturating_sub(1);
9905 if depth == 0 {
9906 return Some(stripped[index + ch.len_utf8()..].trim_start());
9907 }
9908 }
9909 _ => {}
9910 }
9911 }
9912 return Some("");
9913 }
9914 Some(rest)
9915}
9916
9917fn strip_leading_java_modifier(value: &str) -> Option<&str> {
9918 const MODIFIERS: &[&str] = &[
9919 "public",
9920 "protected",
9921 "private",
9922 "abstract",
9923 "static",
9924 "final",
9925 "transient",
9926 "volatile",
9927 "synchronized",
9928 "native",
9929 "strictfp",
9930 ];
9931 MODIFIERS
9932 .iter()
9933 .find_map(|modifier| strip_leading_word(value, modifier))
9934}
9935
9936fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
9937 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
9938 let before = &declaration[..receiver_start];
9939 if find_identifier_occurrence(before, "val").is_none()
9940 && find_identifier_occurrence(before, "var").is_none()
9941 {
9942 return None;
9943 }
9944
9945 let after = declaration[receiver_start + receiver.len()..].trim_start();
9946 if let Some(type_text) = after.strip_prefix(':') {
9947 return normalize_receiver_type_name(read_type_prefix(type_text));
9948 }
9949 after
9950 .strip_prefix('=')
9951 .and_then(infer_kotlin_constructor_type)
9952}
9953
9954fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
9955 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
9956 if rest.trim_start().starts_with('(') {
9957 normalize_receiver_type_name(head)
9958 } else {
9959 None
9960 }
9961}
9962
9963fn read_type_prefix(value: &str) -> &str {
9964 let mut angle_depth = 0usize;
9965 for (index, ch) in value.char_indices() {
9966 match ch {
9967 '<' => angle_depth += 1,
9968 '>' => angle_depth = angle_depth.saturating_sub(1),
9969 '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
9970 return value[..index].trim();
9971 }
9972 _ => {}
9973 }
9974 }
9975 value.trim()
9976}
9977
9978fn infer_cpp_receiver_type_from_scope(
9979 scope: tree_sitter::Node<'_>,
9980 source: &str,
9981 receiver: &str,
9982 call_line: u32,
9983) -> Option<String> {
9984 let lines = source.lines().collect::<Vec<_>>();
9985 if lines.is_empty() {
9986 return None;
9987 }
9988 let scope_start = scope.start_position().row as usize;
9989 let call_index = (call_line as usize)
9990 .saturating_sub(1)
9991 .min(lines.len().saturating_sub(1));
9992 for index in (scope_start..=call_index).rev() {
9993 if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
9994 return Some(receiver_type);
9995 }
9996 }
9997 None
9998}
9999
10000fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
10001 for receiver_start in identifier_occurrences(line, receiver) {
10002 let after = line[receiver_start + receiver.len()..].trim_start();
10003 if after
10004 .chars()
10005 .next()
10006 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
10007 {
10008 continue;
10009 }
10010 let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
10011 let normalized = normalize_cpp_type_name(type_text)?;
10012 if normalized == "auto" {
10013 if let Some(rhs) = after.strip_prefix('=') {
10014 return infer_cpp_auto_receiver_type(rhs);
10015 }
10016 continue;
10017 }
10018 return Some(normalized);
10019 }
10020 None
10021}
10022
10023fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
10024 let candidate = prefix
10025 .rsplit([';', '{', '}', '('])
10026 .next()
10027 .unwrap_or(prefix)
10028 .trim();
10029 if candidate.is_empty() || candidate.ends_with(',') {
10030 None
10031 } else {
10032 Some(candidate)
10033 }
10034}
10035
10036fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
10037 let without_templates = strip_angle_groups(type_text);
10038 let mut cleaned = String::with_capacity(without_templates.len());
10039 for token in without_templates.split_whitespace() {
10040 if matches!(
10041 token,
10042 "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
10043 ) {
10044 continue;
10045 }
10046 if !cleaned.is_empty() {
10047 cleaned.push(' ');
10048 }
10049 cleaned.push_str(token);
10050 }
10051 let token = cleaned
10052 .split_whitespace()
10053 .last()
10054 .unwrap_or(cleaned.trim())
10055 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
10056 .trim_matches(['*', '&']);
10057 let simple = token.rsplit("::").next().unwrap_or(token).trim();
10058 if simple.is_empty() || cpp_non_type_token(simple) {
10059 None
10060 } else {
10061 Some(simple.to_string())
10062 }
10063}
10064
10065fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
10066 let rhs = rhs.trim_start();
10067 if let Some(after_new) = rhs.strip_prefix("new ") {
10068 return infer_cpp_constructor_type(after_new);
10069 }
10070 infer_cpp_make_template_type(rhs)
10071 .or_else(|| infer_cpp_constructor_type(rhs))
10072 .or_else(|| infer_cpp_factory_type(rhs))
10073}
10074
10075fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
10076 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
10077 let normalized = normalize_cpp_type_name(head)?;
10078 if !normalized
10079 .chars()
10080 .next()
10081 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
10082 {
10083 return None;
10084 }
10085 if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
10086 Some(normalized)
10087 } else {
10088 None
10089 }
10090}
10091
10092fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
10093 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
10094 if !rest.trim_start().starts_with('(') {
10095 return None;
10096 }
10097 let base = head.split('<').next().unwrap_or(head);
10098 let base_simple = base.rsplit("::").next().unwrap_or(base);
10099 if !matches!(base_simple, "make_unique" | "make_shared") {
10100 return None;
10101 }
10102 first_angle_arg(head).and_then(normalize_cpp_type_name)
10103}
10104
10105fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
10106 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
10107 if !rest.trim_start().starts_with('(') {
10108 return None;
10109 }
10110 let simple = head
10111 .split('<')
10112 .next()
10113 .unwrap_or(head)
10114 .rsplit("::")
10115 .next()
10116 .unwrap_or(head);
10117 for prefix in ["make", "create", "build"] {
10118 if let Some(suffix) = simple.strip_prefix(prefix) {
10119 if suffix
10120 .chars()
10121 .next()
10122 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
10123 {
10124 return normalize_cpp_type_name(suffix);
10125 }
10126 }
10127 }
10128 None
10129}
10130
10131#[derive(Debug, Clone, Copy)]
10132enum JavaLikeInvocation {
10133 Kotlin,
10134 Cpp,
10135}
10136
10137fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
10138 let value = value.trim_start();
10139 let mut end = 0usize;
10140 for (index, ch) in value.char_indices() {
10141 let allowed_separator = match flavor {
10142 JavaLikeInvocation::Kotlin => ch == '.',
10143 JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
10144 };
10145 if is_code_ident_char(ch) || allowed_separator {
10146 end = index + ch.len_utf8();
10147 continue;
10148 }
10149 break;
10150 }
10151 if end == 0 {
10152 return None;
10153 }
10154 let mut rest = &value[end..];
10155 if let Some(stripped) = rest.trim_start().strip_prefix('<') {
10156 let skipped = skip_balanced_angle(stripped)?;
10157 let rest_start = rest.len() - rest.trim_start().len();
10158 let angle_len = 1 + skipped;
10159 end += rest_start + angle_len;
10160 rest = &value[end..];
10161 }
10162 Some((value[..end].trim(), rest))
10163}
10164
10165fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
10166 let mut depth = 1usize;
10167 for (index, ch) in value_after_open.char_indices() {
10168 match ch {
10169 '<' => depth += 1,
10170 '>' => {
10171 depth = depth.saturating_sub(1);
10172 if depth == 0 {
10173 return Some(index + ch.len_utf8());
10174 }
10175 }
10176 _ => {}
10177 }
10178 }
10179 None
10180}
10181
10182fn first_angle_arg(value: &str) -> Option<&str> {
10183 let open = value.find('<')?;
10184 let inner_len = skip_balanced_angle(&value[open + 1..])?;
10185 let inner = &value[open + 1..open + inner_len];
10186 split_top_level_commas(inner).into_iter().next()
10187}
10188
10189fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
10190 let without_generics = strip_angle_groups(type_text);
10191 let cleaned = without_generics
10192 .replace("[]", " ")
10193 .replace("...", " ")
10194 .replace(['?', '&', '*'], " ");
10195 let token = cleaned
10196 .split_whitespace()
10197 .last()
10198 .unwrap_or(cleaned.trim())
10199 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
10200 let token = token.rsplit("::").next().unwrap_or(token);
10201 let simple = token.rsplit('.').next().unwrap_or(token).trim();
10202 if simple.is_empty()
10203 || java_like_primitive_type(simple)
10204 || !simple
10205 .chars()
10206 .next()
10207 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
10208 {
10209 None
10210 } else {
10211 Some(simple.to_string())
10212 }
10213}
10214
10215fn simple_type_name(scoped_name: &str) -> Option<String> {
10216 scoped_name
10217 .rsplit("::")
10218 .find(|segment| !segment.is_empty())
10219 .and_then(normalize_receiver_type_name)
10220}
10221
10222fn strip_angle_groups(value: &str) -> String {
10223 let mut output = String::with_capacity(value.len());
10224 let mut depth = 0usize;
10225 for ch in value.chars() {
10226 match ch {
10227 '<' => {
10228 if depth == 0 {
10229 output.push(' ');
10230 }
10231 depth += 1;
10232 }
10233 '>' => depth = depth.saturating_sub(1),
10234 _ if depth == 0 => output.push(ch),
10235 _ => {}
10236 }
10237 }
10238 output
10239}
10240
10241fn java_like_primitive_type(value: &str) -> bool {
10242 matches!(
10243 value,
10244 "boolean"
10245 | "byte"
10246 | "char"
10247 | "double"
10248 | "float"
10249 | "int"
10250 | "long"
10251 | "short"
10252 | "void"
10253 | "Boolean"
10254 | "Byte"
10255 | "Char"
10256 | "Double"
10257 | "Float"
10258 | "Int"
10259 | "Long"
10260 | "Short"
10261 | "Unit"
10262 )
10263}
10264
10265fn cpp_non_type_token(value: &str) -> bool {
10266 matches!(
10267 value,
10268 "return"
10269 | "if"
10270 | "else"
10271 | "for"
10272 | "while"
10273 | "do"
10274 | "switch"
10275 | "case"
10276 | "default"
10277 | "break"
10278 | "continue"
10279 | "goto"
10280 | "throw"
10281 | "new"
10282 | "delete"
10283 | "co_await"
10284 | "co_yield"
10285 | "co_return"
10286 | "static_cast"
10287 | "const_cast"
10288 | "dynamic_cast"
10289 | "reinterpret_cast"
10290 | "sizeof"
10291 | "alignof"
10292 | "typeid"
10293 | "and"
10294 | "or"
10295 | "not"
10296 | "xor"
10297 )
10298}
10299
10300fn receiver_is_bare_identifier(value: &str) -> bool {
10301 let mut chars = value.chars();
10302 let Some(first) = chars.next() else {
10303 return false;
10304 };
10305 (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
10306}
10307
10308fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
10309 identifier_occurrences(value, needle).into_iter().next()
10310}
10311
10312fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
10313 value
10314 .match_indices(needle)
10315 .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
10316 .collect()
10317}
10318
10319fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
10320 let before = value[..start].chars().next_back();
10321 let after = value[start + len..].chars().next();
10322 !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
10323}
10324
10325fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
10326 let stripped = value.strip_prefix(word)?;
10327 if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
10328 Some(stripped.trim_start())
10329 } else {
10330 None
10331 }
10332}
10333
10334fn is_code_ident_char(ch: char) -> bool {
10335 ch == '_' || ch.is_ascii_alphanumeric()
10336}
10337
10338fn infer_rust_receiver_type(
10339 project_root: &Path,
10340 reference: &NameMatchRef,
10341 source_cache: &mut DispatchSourceCache,
10342) -> ReceiverTypeInference {
10343 if matches!(reference.receiver.as_str(), "self" | "Self") {
10344 return enclosing_type_from_scoped_name(&reference.caller_symbol)
10345 .map(ReceiverTypeInference::Known)
10346 .unwrap_or(ReceiverTypeInference::Unknown);
10347 }
10348
10349 if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
10350 return ReceiverTypeInference::Known(reference.receiver.clone());
10351 }
10352
10353 if let Some(receiver_type) = reference
10354 .caller_signature
10355 .as_deref()
10356 .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
10357 {
10358 return ReceiverTypeInference::Known(receiver_type);
10359 }
10360
10361 infer_rust_direct_self_field_receiver_type(project_root, reference, source_cache)
10362}
10363
10364fn infer_rust_direct_self_field_receiver_type(
10365 project_root: &Path,
10366 reference: &NameMatchRef,
10367 source_cache: &mut DispatchSourceCache,
10368) -> ReceiverTypeInference {
10369 if reference.colon_dispatch {
10370 return ReceiverTypeInference::Unknown;
10371 }
10372 let Some(field_name) = rust_direct_self_field_name(&reference.receiver_expression) else {
10373 return ReceiverTypeInference::Unknown;
10374 };
10375 if field_name != reference.receiver {
10376 return ReceiverTypeInference::Unknown;
10377 }
10378
10379 let Some(impl_type) = enclosing_type_from_scoped_name(&reference.caller_symbol) else {
10380 return ReceiverTypeInference::Unknown;
10381 };
10382 let Some(struct_name) = rust_direct_nominal_type_name(&impl_type) else {
10383 return ReceiverTypeInference::KnownButUnresolved;
10384 };
10385 let Some(parsed) = parsed_dispatch_source(project_root, reference, LangId::Rust, source_cache)
10386 else {
10387 return ReceiverTypeInference::Unknown;
10388 };
10389 let Some(impl_node) =
10390 find_enclosing_rust_impl_node(parsed.tree.root_node(), reference.line.max(1))
10391 else {
10392 return ReceiverTypeInference::Unknown;
10393 };
10394 if impl_node.child_by_field_name("trait").is_some()
10395 || impl_node.child_by_field_name("type_parameters").is_some()
10396 {
10397 return ReceiverTypeInference::KnownButUnresolved;
10398 }
10399 let Some(impl_target) = impl_node.child_by_field_name("type") else {
10400 return ReceiverTypeInference::KnownButUnresolved;
10401 };
10402 if impl_target.kind() != "type_identifier"
10403 || node_text(impl_target, &parsed.source) != impl_type
10404 {
10405 return ReceiverTypeInference::KnownButUnresolved;
10406 }
10407
10408 let module_scope = rust_module_scope(impl_node);
10409 let Some(struct_node) = find_unique_rust_struct(
10410 parsed.tree.root_node(),
10411 &parsed.source,
10412 struct_name,
10413 &module_scope,
10414 ) else {
10415 return ReceiverTypeInference::KnownButUnresolved;
10416 };
10417 let Some(field_type) = rust_struct_field_type_node(struct_node, &parsed.source, field_name)
10418 else {
10419 return ReceiverTypeInference::KnownButUnresolved;
10420 };
10421 if field_type.kind() != "type_identifier" {
10422 return ReceiverTypeInference::KnownButUnresolved;
10423 }
10424 let field_type_name = node_text(field_type, &parsed.source);
10425 if find_unique_rust_struct(
10426 parsed.tree.root_node(),
10427 &parsed.source,
10428 field_type_name,
10429 &module_scope,
10430 )
10431 .is_none()
10432 {
10433 return ReceiverTypeInference::KnownButUnresolved;
10434 }
10435
10436 ReceiverTypeInference::RustDirectSelfField {
10437 receiver_type: field_type_name.to_string(),
10438 declaration_file: reference.caller_file.clone(),
10439 module_scope,
10440 }
10441}
10442
10443fn rust_direct_self_field_name(receiver_expression: &str) -> Option<&str> {
10444 let (base, field) = receiver_expression.split_once('.')?;
10445 let base = base.trim();
10446 let field = field.trim();
10447 (base == "self" && rust_direct_nominal_type_name(field).is_some()).then_some(field)
10448}
10449
10450fn rust_direct_nominal_type_name(value: &str) -> Option<&str> {
10451 let name = value.rsplit("::").next()?.trim();
10452 (!name.is_empty()
10453 && !name.chars().next().is_some_and(|ch| ch.is_ascii_digit())
10454 && name.chars().all(is_rust_ident_char))
10455 .then_some(name)
10456}
10457
10458fn find_enclosing_rust_impl_node<'tree>(
10459 root: tree_sitter::Node<'tree>,
10460 line: u32,
10461) -> Option<tree_sitter::Node<'tree>> {
10462 let mut best = None;
10463 let mut stack = vec![root];
10464 while let Some(node) = stack.pop() {
10465 if !node_contains_line(node, line) {
10466 continue;
10467 }
10468 if node.kind() == "impl_item" {
10469 best = tighter_node(best, node);
10470 }
10471 push_named_children(node, &mut stack);
10472 }
10473 best
10474}
10475
10476fn rust_module_scope(node: tree_sitter::Node<'_>) -> Vec<(usize, usize)> {
10477 let mut scope = Vec::new();
10478 let mut current = node.parent();
10479 while let Some(parent) = current {
10480 if parent.kind() == "mod_item" {
10481 scope.push((parent.start_byte(), parent.end_byte()));
10482 }
10483 current = parent.parent();
10484 }
10485 scope.reverse();
10486 scope
10487}
10488
10489fn find_unique_rust_struct<'tree>(
10490 root: tree_sitter::Node<'tree>,
10491 source: &str,
10492 expected_name: &str,
10493 module_scope: &[(usize, usize)],
10494) -> Option<tree_sitter::Node<'tree>> {
10495 let mut found = None;
10496 let mut stack = vec![root];
10497 while let Some(node) = stack.pop() {
10498 if node.kind() == "struct_item"
10499 && rust_module_scope(node) == module_scope
10500 && node.child_by_field_name("type_parameters").is_none()
10501 && declaration_name(node, source) == Some(expected_name)
10502 {
10503 if found.is_some() {
10504 return None;
10505 }
10506 found = Some(node);
10507 }
10508 push_named_children(node, &mut stack);
10509 }
10510 found
10511}
10512
10513fn rust_struct_field_type_node<'tree>(
10514 struct_node: tree_sitter::Node<'tree>,
10515 source: &str,
10516 field_name: &str,
10517) -> Option<tree_sitter::Node<'tree>> {
10518 let fields = struct_node.child_by_field_name("body")?;
10519 if fields.kind() != "field_declaration_list" {
10520 return None;
10521 }
10522 for index in 0..fields.named_child_count() {
10523 let field = fields.named_child(index as u32)?;
10524 if field.kind() != "field_declaration"
10525 || declaration_name(field, source) != Some(field_name)
10526 {
10527 continue;
10528 }
10529 return field.child_by_field_name("type");
10530 }
10531 None
10532}
10533
10534fn rust_receiver_looks_type_like(receiver: &str) -> bool {
10535 receiver
10536 .chars()
10537 .next()
10538 .is_some_and(|ch| ch == '_' || ch.is_uppercase())
10539}
10540
10541fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
10542 scoped_name
10543 .rsplit_once("::")
10544 .map(|(enclosing, _)| enclosing)
10545 .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
10546 .map(ToString::to_string)
10547}
10548
10549fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
10550 let params = signature_parameter_text(signature)?;
10551 for param in split_top_level_commas(params) {
10552 let Some((pattern, type_text)) = param.split_once(':') else {
10553 continue;
10554 };
10555 let Some(name) = rust_parameter_name(pattern) else {
10556 continue;
10557 };
10558 if name == receiver {
10559 return normalize_rust_receiver_type(type_text);
10560 }
10561 }
10562 None
10563}
10564
10565fn signature_parameter_text(signature: &str) -> Option<&str> {
10566 let open = signature.find('(')?;
10567 let mut depth = 0usize;
10568 for (offset, ch) in signature[open..].char_indices() {
10569 match ch {
10570 '(' => depth += 1,
10571 ')' => {
10572 depth = depth.saturating_sub(1);
10573 if depth == 0 {
10574 return Some(&signature[open + 1..open + offset]);
10575 }
10576 }
10577 _ => {}
10578 }
10579 }
10580 None
10581}
10582
10583fn split_top_level_commas(value: &str) -> Vec<&str> {
10584 let mut parts = Vec::new();
10585 let mut start = 0usize;
10586 let mut angle_depth = 0usize;
10587 let mut paren_depth = 0usize;
10588 let mut bracket_depth = 0usize;
10589 for (index, ch) in value.char_indices() {
10590 match ch {
10591 '<' => angle_depth += 1,
10592 '>' => angle_depth = angle_depth.saturating_sub(1),
10593 '(' => paren_depth += 1,
10594 ')' => paren_depth = paren_depth.saturating_sub(1),
10595 '[' => bracket_depth += 1,
10596 ']' => bracket_depth = bracket_depth.saturating_sub(1),
10597 ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
10598 let part = value[start..index].trim();
10599 if !part.is_empty() {
10600 parts.push(part);
10601 }
10602 start = index + ch.len_utf8();
10603 }
10604 _ => {}
10605 }
10606 }
10607 let part = value[start..].trim();
10608 if !part.is_empty() {
10609 parts.push(part);
10610 }
10611 parts
10612}
10613
10614fn rust_parameter_name(pattern: &str) -> Option<&str> {
10615 let mut pattern = pattern.trim();
10616 if let Some(stripped) = pattern.strip_prefix("mut ") {
10617 pattern = stripped.trim_start();
10618 }
10619 pattern
10620 .rsplit(|ch: char| !is_rust_ident_char(ch))
10621 .find(|part| !part.is_empty())
10622}
10623
10624fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
10625 let mut ty = strip_leading_rust_type_modifiers(type_text);
10626 let owned_inner;
10627 if let Some(inner) = single_outer_generic_arg(ty) {
10628 owned_inner = inner.trim().to_string();
10629 ty = strip_leading_rust_type_modifiers(&owned_inner);
10630 }
10631 rust_base_type_ident(ty)
10632}
10633
10634fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
10635 loop {
10636 ty = ty.trim_start();
10637 if let Some(stripped) = ty.strip_prefix('&') {
10638 ty = stripped.trim_start();
10639 if let Some(stripped) = strip_leading_lifetime(ty) {
10640 ty = stripped.trim_start();
10641 }
10642 if let Some(stripped) = ty.strip_prefix("mut ") {
10643 ty = stripped.trim_start();
10644 }
10645 continue;
10646 }
10647 if let Some(stripped) = ty.strip_prefix("mut ") {
10648 ty = stripped.trim_start();
10649 continue;
10650 }
10651 if let Some(stripped) = ty.strip_prefix("dyn ") {
10652 ty = stripped.trim_start();
10653 continue;
10654 }
10655 if let Some(stripped) = ty.strip_prefix("impl ") {
10656 ty = stripped.trim_start();
10657 continue;
10658 }
10659 break ty.trim();
10660 }
10661}
10662
10663fn strip_leading_lifetime(value: &str) -> Option<&str> {
10664 let mut chars = value.char_indices();
10665 let (_, first) = chars.next()?;
10666 if first != '\'' {
10667 return None;
10668 }
10669 for (index, ch) in chars {
10670 if !(ch == '_' || ch.is_ascii_alphanumeric()) {
10671 return Some(&value[index..]);
10672 }
10673 }
10674 Some("")
10675}
10676
10677fn single_outer_generic_arg(ty: &str) -> Option<&str> {
10678 let ty = ty.trim();
10679 let open = ty.find('<')?;
10680 let mut depth = 0usize;
10681 let mut close = None;
10682 for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
10683 match ch {
10684 '<' => depth += 1,
10685 '>' => {
10686 depth = depth.saturating_sub(1);
10687 if depth == 0 {
10688 close = Some(index);
10689 break;
10690 }
10691 }
10692 _ => {}
10693 }
10694 }
10695 let close = close?;
10696 if !ty[close + 1..].trim().is_empty() {
10697 return None;
10698 }
10699 let inner = &ty[open + 1..close];
10700 let args = split_top_level_commas(inner);
10701 match args.as_slice() {
10702 [arg] => Some(*arg),
10703 _ => None,
10704 }
10705}
10706
10707fn rust_base_type_ident(ty: &str) -> Option<String> {
10708 let ty = ty.trim();
10709 let head = ty
10710 .split([' ', '+', '='])
10711 .find(|part| !part.is_empty())
10712 .unwrap_or(ty);
10713 let head = head.split('<').next().unwrap_or(head).trim();
10714 let ident = head
10715 .rsplit("::")
10716 .next()
10717 .unwrap_or(head)
10718 .trim_matches(|ch: char| !is_rust_ident_char(ch));
10719 if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
10720 None
10721 } else {
10722 Some(ident.to_string())
10723 }
10724}
10725
10726fn is_rust_ident_char(ch: char) -> bool {
10727 ch == '_' || ch.is_ascii_alphanumeric()
10728}
10729
10730fn select_rust_direct_self_field_candidate(
10731 project_root: &Path,
10732 reference: &NameMatchRef,
10733 candidates: &[NameMatchCandidate],
10734 receiver_type: &str,
10735 declaration_file: &str,
10736 declaration_scope: &[(usize, usize)],
10737 source_cache: &mut DispatchSourceCache,
10738) -> Option<NameMatchCandidate> {
10739 let eligible = candidates
10740 .iter()
10741 .filter(|candidate| candidate.node_id != reference.caller_node)
10742 .filter(|candidate| {
10743 type_candidate_matches(candidate, receiver_type, &reference.method_name)
10744 })
10745 .filter(|candidate| {
10746 rust_direct_self_field_candidate_matches_scope(
10747 project_root,
10748 candidate,
10749 receiver_type,
10750 declaration_file,
10751 declaration_scope,
10752 source_cache,
10753 )
10754 })
10755 .collect::<Vec<_>>();
10756 match eligible.as_slice() {
10757 [candidate] => Some((**candidate).clone()),
10758 _ => None,
10759 }
10760}
10761
10762fn rust_direct_self_field_candidate_matches_scope(
10763 project_root: &Path,
10764 candidate: &NameMatchCandidate,
10765 receiver_type: &str,
10766 declaration_file: &str,
10767 declaration_scope: &[(usize, usize)],
10768 source_cache: &mut DispatchSourceCache,
10769) -> bool {
10770 if candidate.file_path != declaration_file {
10771 return false;
10772 }
10773 let Some(parsed) = parsed_dispatch_source_for_file(
10774 project_root,
10775 &candidate.file_path,
10776 "rust",
10777 LangId::Rust,
10778 source_cache,
10779 ) else {
10780 return false;
10781 };
10782 let Some(impl_node) =
10783 find_enclosing_rust_impl_node(parsed.tree.root_node(), candidate.start_line)
10784 else {
10785 return false;
10786 };
10787 if impl_node.child_by_field_name("trait").is_some()
10788 || impl_node.child_by_field_name("type_parameters").is_some()
10789 {
10790 return false;
10791 }
10792 let Some(impl_target) = impl_node.child_by_field_name("type") else {
10793 return false;
10794 };
10795 impl_target.kind() == "type_identifier"
10796 && node_text(impl_target, &parsed.source) == receiver_type
10797 && rust_module_scope(impl_node) == declaration_scope
10798}
10799
10800fn select_type_match_candidate(
10801 reference: &NameMatchRef,
10802 candidates: &[NameMatchCandidate],
10803 receiver_type: &str,
10804) -> Option<NameMatchCandidate> {
10805 let candidates = candidates
10806 .iter()
10807 .filter(|candidate| candidate.node_id != reference.caller_node)
10808 .filter(|candidate| {
10809 type_candidate_matches(candidate, receiver_type, &reference.method_name)
10810 })
10811 .collect::<Vec<_>>();
10812 match candidates.as_slice() {
10813 [candidate] => Some((**candidate).clone()),
10814 _ => None,
10815 }
10816}
10817
10818fn type_candidate_matches(
10819 candidate: &NameMatchCandidate,
10820 receiver_type: &str,
10821 method_name: &str,
10822) -> bool {
10823 let normalized_type = receiver_type.replace('.', "::");
10824 let suffix = format!("{normalized_type}::{method_name}");
10825 candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
10826}
10827
10828fn select_name_match_candidate(
10829 reference: &NameMatchRef,
10830 candidates: &[NameMatchCandidate],
10831) -> Option<NameMatchCandidate> {
10832 let candidates = candidates
10833 .iter()
10834 .filter(|candidate| candidate.node_id != reference.caller_node)
10835 .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
10836 .collect::<Vec<_>>();
10837 match candidates.as_slice() {
10838 [] => None,
10839 [candidate] => Some((**candidate).clone()),
10840 _ => select_scored_name_match_candidate(reference, &candidates),
10841 }
10842}
10843
10844fn candidate_allowed_for_reference(
10845 reference: &NameMatchRef,
10846 candidate: &NameMatchCandidate,
10847) -> bool {
10848 if !reference.colon_dispatch {
10849 return true;
10850 }
10851
10852 candidate.kind == "method"
10853 && candidate
10854 .scoped_name
10855 .split("::")
10856 .any(|segment| segment == reference.receiver)
10857}
10858
10859fn select_scored_name_match_candidate(
10860 reference: &NameMatchRef,
10861 candidates: &[&NameMatchCandidate],
10862) -> Option<NameMatchCandidate> {
10863 let receiver_words = split_camel_case(&reference.receiver);
10864 if receiver_words.is_empty() {
10865 return None;
10866 }
10867
10868 let mut best: Option<(&NameMatchCandidate, f64)> = None;
10869 let mut tied_best = false;
10870 for candidate in candidates {
10871 let candidate_words = split_camel_case(&candidate.scoped_name);
10872 let overlap = receiver_words
10873 .iter()
10874 .filter(|receiver_word| {
10875 candidate_words
10876 .iter()
10877 .any(|candidate_word| candidate_word == *receiver_word)
10878 })
10879 .count() as f64;
10880 let score =
10881 overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
10882 match best {
10883 None => {
10884 best = Some((*candidate, score));
10885 tied_best = false;
10886 }
10887 Some((_, best_score)) if score > best_score => {
10888 best = Some((*candidate, score));
10889 tied_best = false;
10890 }
10891 Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
10892 tied_best = true;
10893 }
10894 _ => {}
10895 }
10896 }
10897
10898 let (candidate, score) = best?;
10899 if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
10900 Some(candidate.clone())
10901 } else {
10902 None
10903 }
10904}
10905
10906fn method_name_match_denylisted(method_name: &str) -> bool {
10907 matches!(
10908 method_name,
10909 "and_then"
10910 | "as_bytes"
10911 | "as_deref"
10912 | "as_mut"
10913 | "as_ref"
10914 | "as_str"
10915 | "borrow"
10916 | "borrow_mut"
10917 | "clear"
10918 | "clone"
10919 | "collect"
10920 | "contains"
10921 | "contains_key"
10922 | "count"
10923 | "dedup"
10924 | "default"
10925 | "drain"
10926 | "ends_with"
10927 | "entry"
10928 | "err"
10929 | "expect"
10930 | "extend"
10931 | "filter"
10932 | "filter_map"
10933 | "find"
10934 | "from"
10935 | "get"
10936 | "get_mut"
10937 | "insert"
10938 | "into"
10939 | "into_iter"
10940 | "is_empty"
10941 | "is_err"
10942 | "is_none"
10943 | "is_ok"
10944 | "is_some"
10945 | "iter"
10946 | "iter_mut"
10947 | "join"
10948 | "len"
10949 | "lock"
10950 | "map"
10951 | "map_err"
10952 | "max"
10953 | "min"
10954 | "new"
10955 | "next"
10956 | "ok"
10957 | "or_default"
10958 | "or_else"
10959 | "or_insert"
10960 | "or_insert_with"
10961 | "parse"
10962 | "pop"
10963 | "position"
10964 | "push"
10965 | "read"
10966 | "recv"
10967 | "remove"
10968 | "replace"
10969 | "retain"
10970 | "send"
10971 | "sort"
10972 | "sort_by"
10973 | "split"
10974 | "starts_with"
10975 | "sum"
10976 | "take"
10977 | "to_owned"
10978 | "to_string"
10979 | "trim"
10980 | "try_from"
10981 | "try_into"
10982 | "unwrap"
10983 | "unwrap_or"
10984 | "unwrap_or_default"
10985 | "unwrap_or_else"
10986 | "with_capacity"
10987 | "write"
10988 )
10989}
10990
10991fn split_camel_case(value: &str) -> Vec<String> {
10992 let chars = value.chars().collect::<Vec<_>>();
10993 let mut normalized = String::with_capacity(value.len() + 8);
10994 for (index, ch) in chars.iter().enumerate() {
10995 let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
10996 let next = chars.get(index + 1);
10997 let is_separator = ch.is_whitespace()
10998 || matches!(
10999 ch,
11000 '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
11001 );
11002 if is_separator {
11003 normalized.push(' ');
11004 continue;
11005 }
11006 let camel_boundary = previous.is_some_and(|prev| {
11007 (prev.is_lowercase() && ch.is_uppercase())
11008 || (prev.is_ascii_digit() && ch.is_alphabetic())
11009 || (prev.is_uppercase()
11010 && ch.is_uppercase()
11011 && next.is_some_and(|next| next.is_lowercase()))
11012 });
11013 if camel_boundary {
11014 normalized.push(' ');
11015 }
11016 normalized.push(*ch);
11017 }
11018
11019 normalized
11020 .split_whitespace()
11021 .filter(|word| word.len() > 1)
11022 .map(|word| word.to_ascii_lowercase())
11023 .collect()
11024}
11025
11026fn compute_path_proximity(left: &str, right: &str) -> f64 {
11027 let left_dirs = left
11028 .rsplit_once('/')
11029 .map(|(dir, _)| dir)
11030 .unwrap_or_default()
11031 .split('/')
11032 .filter(|part| !part.is_empty());
11033 let right_dirs = right
11034 .rsplit_once('/')
11035 .map(|(dir, _)| dir)
11036 .unwrap_or_default()
11037 .split('/')
11038 .filter(|part| !part.is_empty());
11039
11040 let shared = left_dirs
11041 .zip(right_dirs)
11042 .take_while(|(left, right)| left == right)
11043 .count();
11044 ((shared as f64) * 0.05).min(0.5)
11045}
11046
11047fn mark_backend_state(
11048 tx: &Transaction<'_>,
11049 project_root: &Path,
11050 rel_path: &str,
11051 content_hash: Option<&blake3::Hash>,
11052 status: &str,
11053) -> Result<()> {
11054 clear_backend_state_for_file(tx, project_root, rel_path)?;
11055 let hash = content_hash
11056 .map(|hash| hash_to_hex(*hash))
11057 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
11058 tx.execute(
11059 "INSERT OR REPLACE INTO backend_file_state(
11060 backend, workspace_root, file_path, content_hash, status, updated_at
11061 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
11062 params![
11063 BACKEND_TREESITTER,
11064 project_root.display().to_string(),
11065 rel_path,
11066 hash,
11067 status,
11068 unix_seconds_now(),
11069 ],
11070 )?;
11071 Ok(())
11072}
11073
11074fn clear_backend_state_for_file(
11075 tx: &Transaction<'_>,
11076 project_root: &Path,
11077 rel_path: &str,
11078) -> Result<()> {
11079 tx.execute(
11080 "DELETE FROM backend_file_state
11081 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
11082 params![
11083 BACKEND_TREESITTER,
11084 project_root.display().to_string(),
11085 rel_path
11086 ],
11087 )?;
11088 Ok(())
11089}
11090
11091fn load_file_row(conn: &Connection, rel_path: &str) -> Result<Option<FileRow>> {
11092 conn.query_row(
11093 "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
11094 params![rel_path],
11095 |row| {
11096 let hash_text: String = row.get(1)?;
11097 Ok(FileRow {
11098 surface_fingerprint: row.get(0)?,
11099 freshness: FileFreshness {
11100 content_hash: hash_from_hex(&hash_text)
11101 .unwrap_or_else(cache_freshness::zero_hash),
11102 mtime: ns_to_system_time(row.get::<_, i64>(2)?),
11103 size: row.get::<_, i64>(3)? as u64,
11104 },
11105 })
11106 },
11107 )
11108 .optional()
11109 .map_err(CallGraphStoreError::from)
11110}
11111
11112fn stored_node_ids_match_extract(
11113 tx: &Transaction<'_>,
11114 rel_path: &str,
11115 extract: &FileExtract,
11116) -> Result<bool> {
11117 let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
11118 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
11119 let mut stored = BTreeSet::new();
11120 for row in rows {
11121 stored.insert(row?);
11122 }
11123 let extracted = extract
11124 .nodes
11125 .iter()
11126 .map(|node| node.id.clone())
11127 .collect::<BTreeSet<_>>();
11128 Ok(stored == extracted)
11129}
11130
11131fn stored_extract_matches(
11135 tx: &Transaction<'_>,
11136 rel_path: &str,
11137 extract: &FileExtract,
11138 index: &ProjectIndex<'_>,
11139) -> Result<bool> {
11140 let stored_file = tx
11141 .query_row(
11142 "SELECT lang, surface_fingerprint FROM files WHERE path = ?1",
11143 params![rel_path],
11144 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
11145 )
11146 .optional()?;
11147 if stored_file
11148 != Some((
11149 lang_label(extract.lang).to_string(),
11150 extract.surface_fingerprint.clone(),
11151 ))
11152 {
11153 return Ok(false);
11154 }
11155
11156 let mut stored_nodes_stmt = tx.prepare(
11157 "SELECT id, file_path, name, scoped_name, kind, start_line, start_col,
11158 end_line, end_col, range_ordinal, signature, exported,
11159 is_default_export, is_type_like, is_callgraph_entry_point, provenance
11160 FROM nodes WHERE file_path = ?1",
11161 )?;
11162 let stored_nodes = stored_nodes_stmt
11163 .query_map(params![rel_path], |row| {
11164 Ok(serde_json::json!([
11165 row.get::<_, String>(0)?,
11166 row.get::<_, String>(1)?,
11167 row.get::<_, String>(2)?,
11168 row.get::<_, String>(3)?,
11169 row.get::<_, String>(4)?,
11170 row.get::<_, i64>(5)?,
11171 row.get::<_, i64>(6)?,
11172 row.get::<_, i64>(7)?,
11173 row.get::<_, i64>(8)?,
11174 row.get::<_, i64>(9)?,
11175 row.get::<_, Option<String>>(10)?,
11176 row.get::<_, i64>(11)?,
11177 row.get::<_, i64>(12)?,
11178 row.get::<_, i64>(13)?,
11179 row.get::<_, i64>(14)?,
11180 row.get::<_, String>(15)?,
11181 ])
11182 .to_string())
11183 })?
11184 .collect::<rusqlite::Result<Vec<_>>>()?;
11185 let expected_nodes = extract
11186 .nodes
11187 .iter()
11188 .map(|node| {
11189 serde_json::json!([
11190 node.id,
11191 node.file_path,
11192 node.name,
11193 node.scoped_name,
11194 node.kind,
11195 node.range.start_line,
11196 node.range.start_col,
11197 node.range.end_line,
11198 node.range.end_col,
11199 node.range_ordinal,
11200 node.signature,
11201 bool_int(node.exported),
11202 bool_int(node.is_default_export),
11203 bool_int(node.is_type_like),
11204 bool_int(node.is_callgraph_entry_point),
11205 PROVENANCE_TREESITTER,
11206 ])
11207 .to_string()
11208 })
11209 .collect::<Vec<_>>();
11210 let mut stored_nodes = stored_nodes;
11211 let mut expected_nodes = expected_nodes;
11212 stored_nodes.sort();
11213 expected_nodes.sort();
11214 if stored_nodes != expected_nodes {
11215 return Ok(false);
11216 }
11217
11218 let resolved_refs = extract
11219 .raw_refs
11220 .iter()
11221 .cloned()
11222 .map(|raw| resolve_ref(raw, index))
11223 .collect::<Result<Vec<_>>>()?;
11224 let mut stored_refs_stmt = tx.prepare(
11225 "SELECT ref_id, caller_node, caller_file, kind, short_name, full_ref,
11226 module_path, import_kind, local_name, requested_name, namespace_alias,
11227 wildcard, line, byte_start, byte_end, status, target_node,
11228 target_file, target_symbol, provenance
11229 FROM refs WHERE caller_file = ?1",
11230 )?;
11231 let stored_refs = stored_refs_stmt
11232 .query_map(params![rel_path], |row| {
11233 Ok(serde_json::json!([
11234 row.get::<_, String>(0)?,
11235 row.get::<_, Option<String>>(1)?,
11236 row.get::<_, String>(2)?,
11237 row.get::<_, String>(3)?,
11238 row.get::<_, Option<String>>(4)?,
11239 row.get::<_, Option<String>>(5)?,
11240 row.get::<_, Option<String>>(6)?,
11241 row.get::<_, Option<String>>(7)?,
11242 row.get::<_, Option<String>>(8)?,
11243 row.get::<_, Option<String>>(9)?,
11244 row.get::<_, Option<String>>(10)?,
11245 row.get::<_, i64>(11)?,
11246 row.get::<_, i64>(12)?,
11247 row.get::<_, i64>(13)?,
11248 row.get::<_, i64>(14)?,
11249 row.get::<_, String>(15)?,
11250 row.get::<_, Option<String>>(16)?,
11251 row.get::<_, Option<String>>(17)?,
11252 row.get::<_, Option<String>>(18)?,
11253 row.get::<_, String>(19)?,
11254 ])
11255 .to_string())
11256 })?
11257 .collect::<rusqlite::Result<Vec<_>>>()?;
11258 let expected_refs = resolved_refs
11259 .iter()
11260 .map(|resolved| {
11261 let raw = &resolved.raw;
11262 serde_json::json!([
11263 raw.ref_id,
11264 raw.caller_node,
11265 raw.caller_file,
11266 raw.kind,
11267 raw.short_name,
11268 raw.full_ref,
11269 raw.module_path,
11270 raw.import_kind,
11271 raw.local_name,
11272 raw.requested_name,
11273 raw.namespace_alias,
11274 bool_int(raw.wildcard),
11275 raw.line,
11276 raw.byte_start,
11277 raw.byte_end,
11278 resolved.status,
11279 resolved.target_node,
11280 resolved.target_file,
11281 resolved.target_symbol,
11282 PROVENANCE_TREESITTER,
11283 ])
11284 .to_string()
11285 })
11286 .collect::<Vec<_>>();
11287 let mut stored_refs = stored_refs;
11288 let mut expected_refs = expected_refs;
11289 stored_refs.sort();
11290 expected_refs.sort();
11291 if stored_refs != expected_refs {
11292 return Ok(false);
11293 }
11294
11295 let mut stored_edges_stmt = tx.prepare(
11296 "SELECT e.edge_id, e.ref_id, e.source_node, e.target_node,
11297 e.target_file, e.target_symbol, e.kind, e.line, e.provenance
11298 FROM edges e JOIN refs r ON r.ref_id = e.ref_id
11299 WHERE r.caller_file = ?1 AND e.provenance = ?2",
11300 )?;
11301 let stored_edges = stored_edges_stmt
11302 .query_map(params![rel_path, PROVENANCE_TREESITTER], |row| {
11303 Ok(serde_json::json!([
11304 row.get::<_, String>(0)?,
11305 row.get::<_, String>(1)?,
11306 row.get::<_, String>(2)?,
11307 row.get::<_, Option<String>>(3)?,
11308 row.get::<_, String>(4)?,
11309 row.get::<_, String>(5)?,
11310 row.get::<_, String>(6)?,
11311 row.get::<_, i64>(7)?,
11312 row.get::<_, String>(8)?,
11313 ])
11314 .to_string())
11315 })?
11316 .collect::<rusqlite::Result<Vec<_>>>()?;
11317 let expected_edges = resolved_refs
11318 .iter()
11319 .filter_map(|resolved| {
11320 resolved.edge.as_ref().map(|edge| {
11321 serde_json::json!([
11322 edge.edge_id,
11323 resolved.raw.ref_id,
11324 edge.source_node,
11325 edge.target_node,
11326 edge.target_file,
11327 edge.target_symbol,
11328 edge.kind,
11329 edge.line,
11330 PROVENANCE_TREESITTER,
11331 ])
11332 .to_string()
11333 })
11334 })
11335 .collect::<Vec<_>>();
11336 let mut stored_edges = stored_edges;
11337 let mut expected_edges = expected_edges;
11338 stored_edges.sort();
11339 expected_edges.sort();
11340 if stored_edges != expected_edges {
11341 return Ok(false);
11342 }
11343
11344 let mut stored_dependencies_stmt =
11345 tx.prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1")?;
11346 let stored_dependencies = stored_dependencies_stmt
11347 .query_map(params![rel_path], |row| row.get::<_, String>(0))?
11348 .collect::<rusqlite::Result<BTreeSet<_>>>()?;
11349 let expected_dependencies = extract
11350 .raw_refs
11351 .iter()
11352 .flat_map(|raw| raw.dependencies.iter().cloned())
11353 .collect::<BTreeSet<_>>();
11354 if stored_dependencies != expected_dependencies {
11355 return Ok(false);
11356 }
11357
11358 let mut stored_hints_stmt = tx.prepare(
11359 "SELECT id, method_name, caller_node, file, line, byte_start, byte_end, provenance
11360 FROM dispatch_hints WHERE file = ?1",
11361 )?;
11362 let stored_hints = stored_hints_stmt
11363 .query_map(params![rel_path], |row| {
11364 Ok(serde_json::json!([
11365 row.get::<_, String>(0)?,
11366 row.get::<_, String>(1)?,
11367 row.get::<_, String>(2)?,
11368 row.get::<_, String>(3)?,
11369 row.get::<_, i64>(4)?,
11370 row.get::<_, i64>(5)?,
11371 row.get::<_, i64>(6)?,
11372 row.get::<_, String>(7)?,
11373 ])
11374 .to_string())
11375 })?
11376 .collect::<rusqlite::Result<Vec<_>>>()?;
11377 let expected_hints = extract
11378 .dispatch_hints
11379 .iter()
11380 .map(|hint| {
11381 serde_json::json!([
11382 hint.id,
11383 hint.method_name,
11384 hint.caller_node,
11385 hint.file,
11386 hint.line,
11387 hint.byte_start,
11388 hint.byte_end,
11389 PROVENANCE_TREESITTER,
11390 ])
11391 .to_string()
11392 })
11393 .collect::<Vec<_>>();
11394 let mut stored_hints = stored_hints;
11395 let mut expected_hints = expected_hints;
11396 stored_hints.sort();
11397 expected_hints.sort();
11398 Ok(stored_hints == expected_hints)
11399}
11400
11401fn update_file_fresh_metadata(
11402 tx: &Transaction<'_>,
11403 project_root: &Path,
11404 rel_path: &str,
11405 hash: &blake3::Hash,
11406 mtime: SystemTime,
11407 size: u64,
11408) -> Result<()> {
11409 tx.execute(
11410 "UPDATE files SET content_hash = ?2, mtime_ns = ?3, size = ?4, indexed_at = ?5
11411 WHERE path = ?1",
11412 params![
11413 rel_path,
11414 hash_to_hex(*hash),
11415 system_time_to_ns(mtime),
11416 size as i64,
11417 unix_seconds_now()
11418 ],
11419 )?;
11420 tx.execute(
11421 "UPDATE backend_file_state SET content_hash = ?3, status = 'fresh', updated_at = ?5
11422 WHERE backend = ?1 AND file_path = ?2 AND workspace_root = ?4",
11423 params![
11424 BACKEND_TREESITTER,
11425 rel_path,
11426 hash_to_hex(*hash),
11427 project_root.display().to_string(),
11428 unix_seconds_now(),
11429 ],
11430 )?;
11431 Ok(())
11432}
11433
11434#[derive(Debug, Clone, PartialEq, Eq)]
11435struct DependentRefSelection {
11436 ref_id: String,
11437 caller_file: String,
11438}
11439
11440fn ref_ids_depending_on(
11441 conn: &Connection,
11442 project_root: &Path,
11443 rel_path: &str,
11444) -> Result<Vec<DependentRefSelection>> {
11445 let mut stmt = conn.prepare(
11446 "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
11447 FROM refs r
11448 WHERE r.caller_file IN (
11449 SELECT file_path FROM file_dependencies WHERE dep_file = ?1
11450 )
11451 OR r.target_file = ?1
11452 ORDER BY r.ref_id",
11453 )?;
11454 let rows = stmt.query_map(params![rel_path], |row| {
11455 Ok(RefDependencyRow {
11456 ref_id: row.get(0)?,
11457 kind: row.get(1)?,
11458 caller_file: row.get(2)?,
11459 module_path: row.get(3)?,
11460 target_file: row.get(4)?,
11461 })
11462 })?;
11463 let mut ids = Vec::new();
11464 for row in rows {
11465 let row = row?;
11466 if ref_dependency_row_depends_on(project_root, &row, rel_path) {
11467 ids.push(DependentRefSelection {
11468 ref_id: row.ref_id,
11469 caller_file: row.caller_file,
11470 });
11471 }
11472 }
11473 Ok(ids)
11474}
11475
11476fn record_dependent_refs(
11477 selected_ref_ids: &mut BTreeSet<String>,
11478 selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
11479 dependent_refs: Vec<DependentRefSelection>,
11480) {
11481 for dependent_ref in dependent_refs {
11482 let DependentRefSelection {
11483 ref_id,
11484 caller_file,
11485 } = dependent_ref;
11486 selected_ref_ids.insert(ref_id.clone());
11487 selected_refs_by_caller
11488 .entry(caller_file)
11489 .or_default()
11490 .insert(ref_id);
11491 }
11492}
11493
11494#[cfg(test)]
11495fn refs_by_caller_for_ref_ids(
11496 tx: &Transaction<'_>,
11497 ref_ids: &BTreeSet<String>,
11498) -> Result<BTreeMap<String, BTreeSet<String>>> {
11499 let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11500 let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
11501 for ref_id in ref_ids {
11502 if let Some(caller) = stmt
11503 .query_row(params![ref_id], |row| row.get::<_, String>(0))
11504 .optional()?
11505 {
11506 by_caller.entry(caller).or_default().insert(ref_id.clone());
11507 }
11508 }
11509 Ok(by_caller)
11510}
11511
11512fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
11513 tx.execute(
11514 "DELETE FROM file_dependencies WHERE file_path = ?1",
11515 params![rel_path],
11516 )?;
11517 delete_refs_for_caller(tx, rel_path)?;
11518 tx.execute(
11519 "DELETE FROM dispatch_hints WHERE file = ?1",
11520 params![rel_path],
11521 )?;
11522 tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
11523 tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
11524 Ok(())
11525}
11526
11527fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
11528 let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
11529 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
11530 let mut ids = BTreeSet::new();
11531 for row in rows {
11532 ids.insert(row?);
11533 }
11534 delete_ref_ids(tx, &ids)
11535}
11536
11537fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
11538 let mut delete_edges = tx.prepare("DELETE FROM edges WHERE ref_id = ?1")?;
11539 let mut delete_refs = tx.prepare("DELETE FROM refs WHERE ref_id = ?1")?;
11540 for ref_id in ref_ids {
11541 delete_edges.execute(params![ref_id])?;
11542 delete_refs.execute(params![ref_id])?;
11543 }
11544 Ok(())
11545}
11546
11547fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
11548 let mut stmt = conn.prepare(
11549 "SELECT source.file_path, source.scoped_name, edges.target_file,
11550 edges.target_symbol, edges.kind, edges.line
11551 FROM edges
11552 JOIN nodes AS source ON source.id = edges.source_node
11553 ORDER BY source.file_path, source.scoped_name, edges.target_file,
11554 edges.target_symbol, edges.kind, edges.line",
11555 )?;
11556 let rows = stmt.query_map([], |row| {
11557 Ok(StoredEdge {
11558 source_file: row.get(0)?,
11559 source_symbol: row.get(1)?,
11560 target_file: row.get(2)?,
11561 target_symbol: row.get(3)?,
11562 kind: row.get(4)?,
11563 line: row.get::<_, i64>(5)? as u32,
11564 })
11565 })?;
11566 let mut edges = BTreeSet::new();
11567 for row in rows {
11568 edges.insert(row?);
11569 }
11570 Ok(edges)
11571}
11572
11573fn module_target_from_dependencies(
11574 project_root: &Path,
11575 dependencies: &BTreeSet<String>,
11576) -> Option<String> {
11577 dependencies.iter().find_map(|dep| {
11578 let path = project_root.join(dep);
11579 if path.is_file() {
11580 Some(relative_path(project_root, &canonicalize_path(&path)))
11581 } else {
11582 None
11583 }
11584 })
11585}
11586
11587fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
11588 let mut named = HashMap::new();
11589 if let Some(full_ref) = &raw_ref.full_ref {
11590 named = parse_reexport_names(full_ref);
11591 }
11592 ReexportIndex {
11593 target_file,
11594 named,
11595 wildcard: raw_ref.wildcard,
11596 }
11597}
11598
11599fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
11600 let mut names = HashMap::new();
11601 let Some(open) = statement.find('{') else {
11602 return names;
11603 };
11604 let Some(close) = statement[open + 1..]
11605 .find('}')
11606 .map(|offset| open + 1 + offset)
11607 else {
11608 return names;
11609 };
11610 for spec in statement[open + 1..close].split(',') {
11611 let spec = spec.trim();
11612 if spec.is_empty() {
11613 continue;
11614 }
11615 if let Some((source, local)) = spec.split_once(" as ") {
11616 names.insert(local.trim().to_string(), source.trim().to_string());
11617 } else {
11618 names.insert(spec.to_string(), spec.to_string());
11619 }
11620 }
11621 names
11622}
11623
11624#[derive(Debug)]
11625struct RefDependencyRow {
11626 ref_id: String,
11627 kind: String,
11628 caller_file: String,
11629 module_path: Option<String>,
11630 target_file: Option<String>,
11631}
11632
11633fn ref_dependency_row_depends_on(
11634 project_root: &Path,
11635 row: &RefDependencyRow,
11636 rel_path: &str,
11637) -> bool {
11638 if row.target_file.as_deref() == Some(rel_path) {
11639 return true;
11640 }
11641
11642 match row.kind.as_str() {
11643 "call" => true,
11644 "import" | "reexport" => row
11645 .module_path
11646 .as_deref()
11647 .map(|module_path| {
11648 module_dependencies_for_ref(project_root, &row.caller_file, module_path)
11649 .contains(rel_path)
11650 })
11651 .unwrap_or(false),
11652 "export_alias" => false,
11653 _ => false,
11654 }
11655}
11656
11657fn module_dependencies_for_ref(
11658 project_root: &Path,
11659 caller_file: &str,
11660 module_path: &str,
11661) -> BTreeSet<String> {
11662 module_dependencies(project_root, &project_root.join(caller_file), module_path)
11663}
11664
11665fn import_dependencies(
11666 project_root: &Path,
11667 abs_path: &Path,
11668 imports: &[ImportStatement],
11669) -> BTreeSet<String> {
11670 let mut deps = BTreeSet::new();
11671 for import in imports {
11672 deps.extend(module_dependencies(
11673 project_root,
11674 abs_path,
11675 &import.module_path,
11676 ));
11677 }
11678 deps
11679}
11680
11681fn module_dependencies(
11682 project_root: &Path,
11683 abs_path: &Path,
11684 module_path: &str,
11685) -> BTreeSet<String> {
11686 let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
11687 let caller_dir = abs_path.parent().unwrap_or(project_root);
11688 if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
11689 deps.insert(relative_path(project_root, &resolved));
11690 }
11691 if module_path.starts_with('.') {
11692 let base = caller_dir.join(module_path);
11693 for candidate in relative_module_candidates(&base) {
11694 deps.insert(relative_path(project_root, &candidate));
11695 }
11696 }
11697 deps
11698}
11699
11700fn rust_module_dependencies(
11701 project_root: &Path,
11702 abs_path: &Path,
11703 module_path: &str,
11704) -> BTreeSet<String> {
11705 let mut deps = BTreeSet::new();
11706 let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
11707 let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
11708 return deps;
11709 };
11710 let src_prefix = rust_src_prefix(&rel_path);
11711 rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
11712 if !path_segments.is_empty() {
11713 rust_push_module_dependency_candidate(
11714 project_root,
11715 &mut deps,
11716 &src_prefix,
11717 &path_segments[..path_segments.len() - 1],
11718 );
11719 }
11720 deps
11721}
11722
11723fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
11724 let path = rust_module_path_without_alias_or_use_list(module_path);
11725 let segments = path
11726 .split("::")
11727 .map(str::trim)
11728 .filter(|segment| !segment.is_empty())
11729 .collect::<Vec<_>>();
11730 if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
11731 return None;
11732 }
11733 rust_resolve_segments(rel_path, &segments)
11734}
11735
11736fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
11737 let path = module_path
11738 .trim()
11739 .trim_end_matches(';')
11740 .split_once(" as ")
11741 .map(|(left, _)| left.trim())
11742 .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
11743 path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
11744}
11745
11746fn rust_push_module_dependency_candidate(
11747 project_root: &Path,
11748 deps: &mut BTreeSet<String>,
11749 src_prefix: &str,
11750 segments: &[String],
11751) {
11752 let candidates = if segments.is_empty() {
11753 vec![
11754 format!("{src_prefix}/lib.rs"),
11755 format!("{src_prefix}/main.rs"),
11756 ]
11757 } else {
11758 vec![
11759 format!("{}/{}.rs", src_prefix, segments.join("/")),
11760 format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
11761 ]
11762 };
11763 for candidate in candidates {
11764 if project_root.join(&candidate).is_file() {
11765 deps.insert(candidate);
11766 }
11767 }
11768}
11769
11770fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
11771 let mut candidates = Vec::new();
11772 if base.extension().is_some() {
11773 candidates.push(base.to_path_buf());
11774 return candidates;
11775 }
11776 for ext in JS_TS_EXTENSIONS {
11777 candidates.push(base.with_extension(ext));
11778 }
11779 for ext in JS_TS_EXTENSIONS {
11780 candidates.push(base.join(format!("index.{ext}")));
11781 }
11782 candidates
11783}
11784
11785fn import_local_names(import: &ImportStatement) -> Vec<String> {
11786 let mut names = Vec::new();
11787 if let Some(default) = &import.default_import {
11788 names.push(default.clone());
11789 }
11790 if let Some(namespace) = &import.namespace_import {
11791 names.push(namespace.clone());
11792 }
11793 for name in &import.names {
11794 names.push(crate::imports::specifier_local_name(name).to_string());
11795 }
11796 names
11797}
11798
11799fn import_requested_names(import: &ImportStatement) -> Vec<String> {
11800 import
11801 .names
11802 .iter()
11803 .map(|name| crate::imports::specifier_imported_name(name).to_string())
11804 .collect()
11805}
11806
11807fn import_is_wildcard(import: &ImportStatement) -> bool {
11808 import.namespace_import.is_some() || import.raw_text.contains('*')
11809}
11810
11811fn namespace_alias(full_ref: &str) -> Option<String> {
11812 full_ref
11813 .split_once('.')
11814 .map(|(namespace, _)| namespace.to_string())
11815}
11816
11817fn import_kind_label(kind: ImportKind) -> &'static str {
11818 match kind {
11819 ImportKind::Value => "value",
11820 ImportKind::Type => "type",
11821 ImportKind::SideEffect => "side_effect",
11822 }
11823}
11824
11825fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
11826 match kind {
11827 SymbolKind::Function => "function",
11828 SymbolKind::Class => "class",
11829 SymbolKind::Method => "method",
11830 SymbolKind::Struct => "struct",
11831 SymbolKind::Interface => "interface",
11832 SymbolKind::Enum => "enum",
11833 SymbolKind::TypeAlias => "type_alias",
11834 SymbolKind::Variable => "variable",
11835 SymbolKind::Heading => "heading",
11836 SymbolKind::FileSummary => "file_summary",
11837 }
11838}
11839
11840fn is_type_like(kind: &SymbolKind) -> bool {
11841 matches!(
11842 kind,
11843 SymbolKind::Class
11844 | SymbolKind::Struct
11845 | SymbolKind::Interface
11846 | SymbolKind::Enum
11847 | SymbolKind::TypeAlias
11848 )
11849}
11850
11851fn lang_label(lang: LangId) -> &'static str {
11852 match lang {
11853 LangId::TypeScript => "typescript",
11854 LangId::Tsx => "tsx",
11855 LangId::JavaScript => "javascript",
11856 LangId::Python => "python",
11857 LangId::Rust => "rust",
11858 LangId::Go => "go",
11859 LangId::C => "c",
11860 LangId::Cpp => "cpp",
11861 LangId::Zig => "zig",
11862 LangId::CSharp => "csharp",
11863 LangId::Bash => "bash",
11864 LangId::Html => "html",
11865 LangId::Markdown => "markdown",
11866 LangId::Solidity => "solidity",
11867 LangId::Scss => "scss",
11868 LangId::Vue => "vue",
11869 LangId::Json => "json",
11870 LangId::Scala => "scala",
11871 LangId::Java => "java",
11872 LangId::Ruby => "ruby",
11873 LangId::Kotlin => "kotlin",
11874 LangId::Swift => "swift",
11875 LangId::Php => "php",
11876 LangId::Lua => "lua",
11877 LangId::Perl => "perl",
11878 LangId::Yaml => "yaml",
11879 LangId::Pascal => "pascal",
11880 LangId::R => "r",
11881 LangId::Groovy => "groovy",
11882 LangId::ObjC => "objc",
11883 }
11884}
11885
11886fn lang_from_label(label: &str) -> Option<LangId> {
11887 match label {
11888 "typescript" => Some(LangId::TypeScript),
11889 "tsx" => Some(LangId::Tsx),
11890 "javascript" => Some(LangId::JavaScript),
11891 "python" => Some(LangId::Python),
11892 "rust" => Some(LangId::Rust),
11893 "go" => Some(LangId::Go),
11894 "c" => Some(LangId::C),
11895 "cpp" => Some(LangId::Cpp),
11896 "zig" => Some(LangId::Zig),
11897 "csharp" => Some(LangId::CSharp),
11898 "bash" => Some(LangId::Bash),
11899 "html" => Some(LangId::Html),
11900 "markdown" => Some(LangId::Markdown),
11901 "solidity" => Some(LangId::Solidity),
11902 "scss" => Some(LangId::Scss),
11903 "vue" => Some(LangId::Vue),
11904 "json" => Some(LangId::Json),
11905 "scala" => Some(LangId::Scala),
11906 "java" => Some(LangId::Java),
11907 "ruby" => Some(LangId::Ruby),
11908 "kotlin" => Some(LangId::Kotlin),
11909 "swift" => Some(LangId::Swift),
11910 "php" => Some(LangId::Php),
11911 "lua" => Some(LangId::Lua),
11912 "perl" => Some(LangId::Perl),
11913 "yaml" => Some(LangId::Yaml),
11914 "pascal" => Some(LangId::Pascal),
11915 "r" => Some(LangId::R),
11916 "groovy" => Some(LangId::Groovy),
11917 "objc" => Some(LangId::ObjC),
11918 _ => None,
11919 }
11920}
11921
11922fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
11923 let mut normalized = if files.is_empty() {
11924 callgraph::walk_project_files(project_root).collect::<Vec<_>>()
11925 } else {
11926 files
11927 .iter()
11928 .map(|path| normalize_file_path(project_root, path))
11929 .collect::<Result<Vec<_>>>()?
11930 };
11931 normalized.sort();
11932 normalized.dedup();
11933 Ok(normalized)
11934}
11935
11936fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
11937 let full_path = if path.is_relative() {
11938 project_root.join(path)
11939 } else {
11940 path.to_path_buf()
11941 };
11942 Ok(canonicalize_path(&full_path))
11943}
11944
11945fn canonicalize_path(path: &Path) -> PathBuf {
11946 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
11947}
11948
11949fn relative_path(project_root: &Path, path: &Path) -> String {
11950 if let Ok(stripped) = path.strip_prefix(project_root) {
11951 return stripped.to_string_lossy().replace('\\', "/");
11952 }
11953 let canon_root = canonicalize_path(project_root);
11954 let canon_path = canonicalize_path(path);
11955 if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
11956 return stripped.to_string_lossy().replace('\\', "/");
11957 }
11958 canon_path.to_string_lossy().replace('\\', "/")
11959}
11960
11961fn unqualified_name(scoped: &str) -> &str {
11962 if scoped == TOP_LEVEL_SYMBOL {
11963 return scoped;
11964 }
11965 scoped
11966 .rsplit("::")
11967 .next()
11968 .unwrap_or(scoped)
11969 .rsplit('.')
11970 .next()
11971 .unwrap_or(scoped)
11972 .rsplit('#')
11973 .next()
11974 .unwrap_or(scoped)
11975}
11976
11977fn ref_id(parts: &[&str]) -> String {
11978 let joined = parts.join("\0");
11979 hash_to_hex(blake3::hash(joined.as_bytes()))
11980}
11981
11982fn hash_to_hex(hash: blake3::Hash) -> String {
11983 hash.to_hex().to_string()
11984}
11985
11986fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
11987 let bytes = hex_to_bytes(value)?;
11988 Some(blake3::Hash::from_bytes(bytes))
11989}
11990
11991fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
11992 if value.len() != 64 {
11993 return None;
11994 }
11995 let mut bytes = [0u8; 32];
11996 for (index, slot) in bytes.iter_mut().enumerate() {
11997 let start = index * 2;
11998 let end = start + 2;
11999 *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
12000 }
12001 Some(bytes)
12002}
12003
12004#[derive(Debug, Clone)]
12005struct LineIndex {
12006 newline_offsets: Vec<usize>,
12007 source_len: usize,
12008}
12009
12010impl LineIndex {
12011 fn new(source: &str) -> Self {
12012 Self {
12013 newline_offsets: source
12014 .bytes()
12015 .enumerate()
12016 .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
12017 .collect(),
12018 source_len: source.len(),
12019 }
12020 }
12021
12022 fn byte_to_line(&self, byte_offset: usize) -> u32 {
12023 let byte_offset = byte_offset.min(self.source_len);
12024 self.newline_offsets
12025 .partition_point(|offset| *offset < byte_offset) as u32
12026 + 1
12027 }
12028}
12029
12030fn empty_to_none(value: String) -> Option<String> {
12031 if value.is_empty() {
12032 None
12033 } else {
12034 Some(value)
12035 }
12036}
12037
12038fn bool_int(value: bool) -> i64 {
12039 if value {
12040 1
12041 } else {
12042 0
12043 }
12044}
12045
12046fn system_time_to_ns(time: SystemTime) -> i64 {
12047 time.duration_since(UNIX_EPOCH)
12048 .unwrap_or_default()
12049 .as_nanos()
12050 .min(i64::MAX as u128) as i64
12051}
12052
12053fn ns_to_system_time(value: i64) -> SystemTime {
12054 UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
12055}
12056
12057fn unix_millis_now() -> u64 {
12058 SystemTime::now()
12059 .duration_since(UNIX_EPOCH)
12060 .unwrap_or_default()
12061 .as_millis()
12062 .min(u128::from(u64::MAX)) as u64
12063}
12064
12065fn unix_seconds_now() -> i64 {
12066 SystemTime::now()
12067 .duration_since(UNIX_EPOCH)
12068 .unwrap_or_default()
12069 .as_secs() as i64
12070}
12071
12072#[cfg(test)]
12077pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
12078
12079#[cfg(test)]
12080mod refresh_worker_tests {
12081 use super::*;
12082 use std::fs;
12083 use tempfile::tempdir;
12084
12085 fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
12086 let temp = tempdir().unwrap();
12087 let root = temp.path().join("root");
12088 fs::create_dir_all(&root).unwrap();
12089 let artifact_key = crate::search_index::artifact_cache_key(&root);
12090 crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
12091 let callgraph_dir = temp
12092 .path()
12093 .join("storage")
12094 .join("callgraph")
12095 .join(artifact_key);
12096 let source = root.join("main.rs");
12097 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
12098 let (store, _) = CallGraphStore::cold_build_with_lease(
12099 callgraph_dir.clone(),
12100 root.clone(),
12101 std::slice::from_ref(&source),
12102 )
12103 .unwrap();
12104 drop(store);
12105 (temp, root, callgraph_dir, source)
12106 }
12107
12108 fn pending_paths() -> PendingCallGraphStorePaths {
12109 Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
12110 }
12111
12112 fn wait_for_refresh_calls(root: &Path, expected: usize) {
12113 let deadline = Instant::now() + Duration::from_secs(12);
12114 while callgraph_refresh_worker_test_counts(root).0 < expected {
12115 assert!(
12116 Instant::now() < deadline,
12117 "timed out waiting for {expected} callgraph refresh worker call(s)"
12118 );
12119 std::thread::sleep(Duration::from_millis(5));
12120 }
12121 }
12122
12123 fn wait_for_refresh_worker_idle() {
12124 let deadline = Instant::now() + Duration::from_secs(12);
12125 loop {
12126 let worker = CALLGRAPH_REFRESH_WORKER
12127 .get_or_init(|| Mutex::new(None))
12128 .lock()
12129 .expect("callgraph refresh worker mutex poisoned")
12130 .clone();
12131 let idle = worker.is_none_or(|worker| {
12132 let queue = worker
12133 .shared
12134 .queue
12135 .lock()
12136 .expect("callgraph refresh queue mutex poisoned");
12137 queue.active.is_none() && queue.order.is_empty()
12138 });
12139 if idle {
12140 return;
12141 }
12142 assert!(
12143 Instant::now() < deadline,
12144 "timed out waiting for callgraph refresh worker to become idle"
12145 );
12146 std::thread::sleep(Duration::from_millis(5));
12147 }
12148 }
12149
12150 fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
12151 let temp = tempdir().unwrap();
12152 let root = temp.path().join("workspace");
12153 fs::create_dir_all(root.join("app/src")).unwrap();
12154 let artifact_key = crate::search_index::artifact_cache_key(&root);
12155 crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
12156 let callgraph_dir = temp
12157 .path()
12158 .join("storage")
12159 .join("callgraph")
12160 .join(artifact_key);
12161 fs::write(
12162 root.join("Cargo.toml"),
12163 "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
12164 )
12165 .unwrap();
12166 fs::write(
12167 root.join("app/Cargo.toml"),
12168 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
12169 )
12170 .unwrap();
12171 let caller = root.join("app/src/lib.rs");
12172 fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
12173 let (store, _) = CallGraphStore::cold_build_with_lease(
12174 callgraph_dir.clone(),
12175 root.clone(),
12176 std::slice::from_ref(&caller),
12177 )
12178 .unwrap();
12179 drop(store);
12180 (temp, root, callgraph_dir, caller)
12181 }
12182
12183 #[test]
12184 fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
12185 let _guard = REFRESH_WORKER_TEST_LOCK
12186 .lock()
12187 .unwrap_or_else(std::sync::PoisonError::into_inner);
12188 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12189 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
12190 reset_workspace_crate_prefix_build_count(&root);
12191 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12192
12193 for revision in ["first", "second"] {
12194 fs::write(
12195 &caller,
12196 format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
12197 )
12198 .unwrap();
12199 enqueue_callgraph_store_refresh(
12200 callgraph_dir.clone(),
12201 root.clone(),
12202 vec![caller.clone()],
12203 pending_paths(),
12204 );
12205 wait_for_refresh_worker_idle();
12206 }
12207
12208 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
12209 assert!(flush_callgraph_store_refreshes_with_budget(
12210 Duration::from_secs(5)
12211 ));
12212 clear_callgraph_refresh_worker_test_seam(&root);
12213 }
12214
12215 #[test]
12216 fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
12217 let _guard = REFRESH_WORKER_TEST_LOCK
12218 .lock()
12219 .unwrap_or_else(std::sync::PoisonError::into_inner);
12220 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12221 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
12222 reset_workspace_crate_prefix_build_count(&root);
12223 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12224
12225 fs::write(
12226 &caller,
12227 "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
12228 )
12229 .unwrap();
12230 enqueue_callgraph_store_refresh(
12231 callgraph_dir.clone(),
12232 root.clone(),
12233 vec![caller.clone()],
12234 pending_paths(),
12235 );
12236 wait_for_refresh_worker_idle();
12237 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
12238
12239 let added_manifest = root.join("added/Cargo.toml");
12240 let added_source = root.join("added/src/lib.rs");
12241 fs::create_dir_all(added_source.parent().unwrap()).unwrap();
12242 fs::write(
12243 root.join("Cargo.toml"),
12244 "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
12245 )
12246 .unwrap();
12247 fs::write(
12248 &added_manifest,
12249 "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
12250 )
12251 .unwrap();
12252 fs::write(&added_source, "pub fn target() {}\n").unwrap();
12253 fs::write(
12254 &caller,
12255 "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
12256 )
12257 .unwrap();
12258
12259 enqueue_callgraph_store_refresh(
12260 callgraph_dir.clone(),
12261 root.clone(),
12262 vec![
12263 root.join("Cargo.toml"),
12264 added_manifest,
12265 added_source,
12266 caller,
12267 ],
12268 pending_paths(),
12269 );
12270 assert!(flush_callgraph_store_refreshes_with_budget(
12271 Duration::from_secs(12)
12272 ));
12273
12274 assert_eq!(workspace_crate_prefix_build_count(&root), 2);
12278 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
12279 .unwrap()
12280 .expect("refreshed workspace store");
12281 let tree = store
12282 .call_tree(Path::new("app/src/lib.rs"), "run", 1)
12283 .unwrap();
12284 assert_eq!(tree.children.len(), 1);
12285 assert_eq!(tree.children[0].file, "added/src/lib.rs");
12286 assert_eq!(tree.children[0].name, "target");
12287 assert!(tree.children[0].resolved);
12288 clear_callgraph_refresh_worker_test_seam(&root);
12289 }
12290
12291 fn linked_worktree_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, PathBuf) {
12292 let temp = tempdir().unwrap();
12293 let main = temp.path().join("main");
12294 let worktree = temp.path().join("worktree");
12295 fs::create_dir_all(&main).unwrap();
12296 let mut git = std::process::Command::new("git");
12297 assert!(
12298 crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
12299 .status()
12300 .unwrap()
12301 .success()
12302 );
12303 fs::write(main.join("lib.rs"), "pub fn marker() {}\n").unwrap();
12304 for args in [
12305 vec![
12306 "-C",
12307 main.to_str().unwrap(),
12308 "config",
12309 "user.email",
12310 "test@example.com",
12311 ],
12312 vec![
12313 "-C",
12314 main.to_str().unwrap(),
12315 "config",
12316 "user.name",
12317 "AFT Test",
12318 ],
12319 vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
12320 vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
12321 ] {
12322 let mut command = std::process::Command::new("git");
12323 assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
12324 .status()
12325 .unwrap()
12326 .success());
12327 }
12328 let mut add_worktree = std::process::Command::new("git");
12329 assert!(crate::test_env::apply_hermetic_git_env(
12330 add_worktree
12331 .arg("-C")
12332 .arg(&main)
12333 .args(["worktree", "add", "--detach"])
12334 .arg(&worktree),
12335 )
12336 .status()
12337 .unwrap()
12338 .success());
12339 let main = fs::canonicalize(main).unwrap();
12340 let worktree = fs::canonicalize(worktree).unwrap();
12341 let project_key = crate::search_index::artifact_cache_key(&main);
12342 assert_eq!(
12343 crate::search_index::artifact_cache_key(&worktree),
12344 project_key
12345 );
12346 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
12347 (temp, main, worktree, project_key, callgraph_dir)
12348 }
12349
12350 #[test]
12351 fn linked_worktree_never_acquires_writer_or_publishes_any_build_path() {
12352 let _git_env = crate::test_env::hermetic_git_env_guard();
12353 let (_temp, _main, root, project_key, callgraph_dir) = linked_worktree_fixture();
12354 crate::root_cache::configure_artifact_access(&root, &project_key, true);
12355 crate::root_cache::reset_writer_lease_acquisition_counts_for_test();
12356 let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
12357 let publications_for_observer = Arc::clone(&publications);
12358 set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
12359 publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
12360 })));
12361 let source = root.join("lib.rs");
12362
12363 let open_error = CallGraphStore::open(callgraph_dir.clone(), root.clone())
12364 .expect_err("borrow-only writable open must remain unavailable");
12365 assert!(matches!(open_error, CallGraphStoreError::Unavailable(_)));
12366 assert!(
12367 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), root.clone())
12368 .unwrap()
12369 .is_none()
12370 );
12371 assert!(
12372 CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), root.clone())
12373 .unwrap()
12374 .is_none()
12375 );
12376 assert!(matches!(
12377 CallGraphStore::cold_build_with_lease(
12378 callgraph_dir.clone(),
12379 root.clone(),
12380 std::slice::from_ref(&source),
12381 ),
12382 Err(CallGraphStoreError::Unavailable(_))
12383 ));
12384 assert!(matches!(
12385 CallGraphStore::ensure_built_with_lease(
12386 callgraph_dir.clone(),
12387 root.clone(),
12388 std::slice::from_ref(&source),
12389 ),
12390 Err(CallGraphStoreError::Unavailable(_))
12391 ));
12392 let force_error = CallGraphStore::force_cold_build_with_lease_chunked(
12393 callgraph_dir.clone(),
12394 root.clone(),
12395 &[source],
12396 1,
12397 )
12398 .expect_err("borrow-only forced rebuild must remain unsatisfied");
12399 set_cold_build_swap_observer(None);
12400
12401 assert!(matches!(force_error, CallGraphStoreError::Unavailable(_)));
12402 assert_eq!(
12403 crate::root_cache::writer_lease_acquisition_count_for_test(
12404 crate::root_cache::RootCacheDomain::Callgraph,
12405 &project_key,
12406 &root,
12407 ),
12408 0
12409 );
12410 assert_eq!(publications.load(AtomicOrdering::SeqCst), 0);
12411 assert!(!pointer_path(&callgraph_dir, &project_key).exists());
12412 }
12413
12414 #[test]
12415 fn owner_and_linked_worktree_alternation_rebuilds_storm_generation_once() {
12416 let _git_env = crate::test_env::hermetic_git_env_guard();
12417 let (_temp, owner, worktree, project_key, callgraph_dir) = linked_worktree_fixture();
12418 crate::root_cache::configure_artifact_access(&owner, &project_key, false);
12419 crate::root_cache::configure_artifact_access(&worktree, &project_key, true);
12420 let source = owner.join("lib.rs");
12421 let (store, _) = CallGraphStore::cold_build_with_lease(
12422 callgraph_dir.clone(),
12423 owner.clone(),
12424 std::slice::from_ref(&source),
12425 )
12426 .unwrap();
12427 let sqlite_path = store.sqlite_path().to_path_buf();
12428 drop(store);
12429
12430 let conn = Connection::open(&sqlite_path).unwrap();
12431 conn.execute(
12432 "UPDATE backend_file_state SET workspace_root = ?1",
12433 [worktree.display().to_string()],
12434 )
12435 .unwrap();
12436 drop(conn);
12437
12438 let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
12439 let publications_for_observer = Arc::clone(&publications);
12440 set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
12441 publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
12442 })));
12443 crate::root_cache::reset_writer_lease_acquisition_counts_for_test();
12444
12445 let repaired = CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
12446 .unwrap()
12447 .expect("owner should purge the storm-era worktree root");
12448 drop(repaired);
12449 for _ in 0..3 {
12450 let borrower = CallGraphStore::open_readonly(callgraph_dir.clone(), worktree.clone())
12451 .unwrap()
12452 .expect("linked worktree should borrow the owner generation");
12453 drop(borrower);
12454 assert!(
12455 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), worktree.clone())
12456 .unwrap()
12457 .is_none()
12458 );
12459 let owner_store =
12460 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
12461 .unwrap()
12462 .expect("owner generation should remain ready");
12463 drop(owner_store);
12464 }
12465 set_cold_build_swap_observer(None);
12466
12467 assert_eq!(
12468 publications.load(AtomicOrdering::SeqCst),
12469 1,
12470 "the owner performs one expected post-storm purge and alternation stays read-only"
12471 );
12472 assert_eq!(
12473 crate::root_cache::writer_lease_acquisition_count_for_test(
12474 crate::root_cache::RootCacheDomain::Callgraph,
12475 &project_key,
12476 &worktree,
12477 ),
12478 0
12479 );
12480 }
12481
12482 #[test]
12483 fn rebuild_cooldown_records_only_successful_publication_per_cache_key() {
12484 let temp = tempdir().unwrap();
12485 let root = temp.path().join("owner");
12486 let other_root = temp.path().join("other");
12487 fs::create_dir_all(&root).unwrap();
12488 fs::create_dir_all(&other_root).unwrap();
12489 let source = root.join("lib.rs");
12490 fs::write(&source, "pub fn marker() {}\n").unwrap();
12491 let project_key = crate::search_index::artifact_cache_key(&root);
12492 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
12493 crate::root_cache::configure_artifact_access(&root, &project_key, false);
12494 let cooldown_key = rebuild_cooldown_key(&callgraph_dir, &project_key);
12495 rebuild_cooldown_records()
12496 .lock()
12497 .unwrap_or_else(std::sync::PoisonError::into_inner)
12498 .remove(&cooldown_key);
12499 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
12500 let stale_epoch = epoch.current();
12501 epoch.next();
12502
12503 let failed = with_publish_epoch(epoch, stale_epoch, || {
12504 CallGraphStore::cold_build_with_lease(
12505 callgraph_dir.clone(),
12506 root.clone(),
12507 std::slice::from_ref(&source),
12508 )
12509 });
12510 assert!(matches!(failed, Err(CallGraphStoreError::Superseded)));
12511 assert!(
12512 rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
12513 .is_none()
12514 );
12515
12516 let (store, _) = CallGraphStore::cold_build_with_lease(
12517 callgraph_dir.clone(),
12518 root.clone(),
12519 std::slice::from_ref(&source),
12520 )
12521 .unwrap();
12522 drop(store);
12523 assert!(
12524 rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
12525 .is_none()
12526 );
12527
12528 record_successful_rebuild(&callgraph_dir, &project_key, &other_root, Instant::now());
12529 assert!(
12530 rebuild_cooldown_denial(&callgraph_dir, &project_key, &root, Instant::now(),).is_some()
12531 );
12532 }
12533
12534 #[test]
12535 fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
12536 let _guard = REFRESH_WORKER_TEST_LOCK
12537 .lock()
12538 .unwrap_or_else(std::sync::PoisonError::into_inner);
12539 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12540 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12541 let pending = pending_paths();
12542 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12543
12544 let lifecycle = SubcLifecycleAdmission::default();
12545 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
12546 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12547 let ticket = CallgraphRefreshTicket::new(
12548 lifecycle,
12549 Arc::clone(&generation),
12550 7,
12551 publish_epoch.clone(),
12552 publish_epoch.current(),
12553 );
12554 generation.store(8, std::sync::atomic::Ordering::SeqCst);
12556 let installed = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
12557 .unwrap()
12558 .expect("ready store snapshot");
12559 let refresh_state = CallgraphRefreshState::new(
12560 Arc::new(std::sync::RwLock::new(Some(Arc::new(installed)))),
12561 Arc::new(AtomicBool::new(true)),
12562 );
12563
12564 enqueue_callgraph_store_refresh_fenced_with_state(
12565 callgraph_dir,
12566 root.clone(),
12567 vec![source.clone()],
12568 Arc::clone(&pending),
12569 refresh_state,
12570 ticket,
12571 );
12572 assert!(flush_callgraph_store_refreshes_with_budget(
12573 Duration::from_secs(5)
12574 ));
12575 assert_eq!(
12576 callgraph_refresh_worker_test_counts(&root).0,
12577 0,
12578 "superseded batch must not reach refresh_files or self-replay"
12579 );
12580 assert!(
12581 pending.lock().contains(&source),
12582 "superseded batch must defer its paths to the pending sink"
12583 );
12584 clear_callgraph_refresh_worker_test_seam(&root);
12585 }
12586
12587 #[test]
12588 fn superseded_open_failure_defers_without_self_replay() {
12589 let _guard = REFRESH_WORKER_TEST_LOCK
12590 .lock()
12591 .unwrap_or_else(std::sync::PoisonError::into_inner);
12592 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12593 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12594 let pending = pending_paths();
12595 let installed = Arc::new(
12596 CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
12597 .unwrap()
12598 .expect("ready store snapshot"),
12599 );
12600 let refresh_state = CallgraphRefreshState::new(
12601 Arc::new(std::sync::RwLock::new(Some(Arc::clone(&installed)))),
12602 Arc::new(AtomicBool::new(true)),
12603 );
12604 assert!(!installed.is_legacy_fallback());
12605 assert!(installed.is_current());
12606 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
12607 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12608 set_callgraph_refresh_worker_test_open_failure(root.clone(), true);
12609 let (held_rx, release_tx) = install_callgraph_refresh_worker_test_gate(root.clone());
12610
12611 let lifecycle = SubcLifecycleAdmission::default();
12612 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
12613 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12614 let ticket = CallgraphRefreshTicket::new(
12615 lifecycle,
12616 Arc::clone(&generation),
12617 7,
12618 publish_epoch.clone(),
12619 publish_epoch.current(),
12620 );
12621 enqueue_callgraph_store_refresh_fenced_with_state(
12622 callgraph_dir,
12623 root.clone(),
12624 vec![source.clone()],
12625 Arc::clone(&pending),
12626 refresh_state,
12627 ticket,
12628 );
12629 held_rx
12630 .recv_timeout(Duration::from_secs(12))
12631 .expect("refresh worker must hold after injected open failure");
12632
12633 generation.store(8, std::sync::atomic::Ordering::SeqCst);
12636 set_callgraph_refresh_worker_test_open_failure(root.clone(), false);
12637 release_tx
12638 .send(())
12639 .expect("release superseded refresh worker");
12640 wait_for_refresh_worker_idle();
12641
12642 assert_eq!(
12643 callgraph_refresh_worker_test_counts(&root).0,
12644 1,
12645 "superseded open-failure batch must not self-replay"
12646 );
12647 assert_eq!(
12648 callgraph_refresh_worker_test_worker_calls(&root),
12649 1,
12650 "superseded open-failure batch must not create another worker call"
12651 );
12652 assert!(
12653 pending.lock().contains(&source),
12654 "superseded open-failure paths must remain in the pending sink"
12655 );
12656 let tree = installed
12657 .call_tree(Path::new("main.rs"), "entry", 1)
12658 .unwrap();
12659 assert_eq!(
12660 tree.children[0].name, "old_leaf",
12661 "superseded open-failure batch must not converge the store"
12662 );
12663 clear_callgraph_refresh_worker_test_seam(&root);
12664 }
12665
12666 #[test]
12667 fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
12668 let _guard = REFRESH_WORKER_TEST_LOCK
12669 .lock()
12670 .unwrap_or_else(std::sync::PoisonError::into_inner);
12671 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::ZERO, false);
12675
12676 let lifecycle = SubcLifecycleAdmission::default();
12677 let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
12678 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12679 let expected_epoch = publish_epoch.current();
12680 let ticket = CallgraphRefreshTicket::new(
12681 lifecycle,
12682 generation,
12683 3,
12684 publish_epoch.clone(),
12685 expected_epoch,
12686 );
12687 publish_epoch.next();
12689
12690 enqueue_callgraph_store_refresh_fenced(
12691 callgraph_dir,
12692 root.clone(),
12693 vec![source.clone()],
12694 Arc::clone(&pending),
12695 ticket,
12696 );
12697 assert!(flush_callgraph_store_refreshes_with_budget(
12698 Duration::from_secs(5)
12699 ));
12700 assert_eq!(
12701 callgraph_refresh_worker_test_counts(&root).0,
12702 0,
12703 "epoch-superseded batch must not reach refresh_files"
12704 );
12705 assert!(
12706 pending.lock().contains(&source),
12707 "epoch-superseded batch must defer its paths to the pending sink"
12708 );
12709 clear_callgraph_refresh_worker_test_seam(&root);
12710 }
12711
12712 #[test]
12713 fn fenced_refresh_with_current_ticket_commits_normally() {
12714 let _guard = REFRESH_WORKER_TEST_LOCK
12715 .lock()
12716 .unwrap_or_else(std::sync::PoisonError::into_inner);
12717 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12718 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12719 let pending = pending_paths();
12720 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12721
12722 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
12723
12724 let lifecycle = SubcLifecycleAdmission::default();
12725 let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
12726 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12727 let ticket = CallgraphRefreshTicket::new(
12728 lifecycle,
12729 generation,
12730 5,
12731 publish_epoch.clone(),
12732 publish_epoch.current(),
12733 );
12734
12735 enqueue_callgraph_store_refresh_fenced(
12736 callgraph_dir.clone(),
12737 root.clone(),
12738 vec![source.clone()],
12739 Arc::clone(&pending),
12740 ticket,
12741 );
12742 assert!(flush_callgraph_store_refreshes_with_budget(
12743 Duration::from_secs(5)
12744 ));
12745 assert_eq!(
12746 callgraph_refresh_worker_test_counts(&root).0,
12747 1,
12748 "current ticket must run the refresh"
12749 );
12750 assert!(
12751 pending.lock().is_empty(),
12752 "committed batch must not defer paths"
12753 );
12754
12755 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
12756 .unwrap()
12757 .expect("published generation must remain readable");
12758 let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
12759 assert_eq!(
12760 tree.children[0].name, "new_leaf",
12761 "fenced commit must actually persist the refreshed content"
12762 );
12763 clear_callgraph_refresh_worker_test_seam(&root);
12764 }
12765
12766 #[test]
12767 fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
12768 let _guard = REFRESH_WORKER_TEST_LOCK
12769 .lock()
12770 .unwrap_or_else(std::sync::PoisonError::into_inner);
12771 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12776 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12777 let pending = pending_paths();
12778 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
12779
12780 enqueue_callgraph_store_refresh(
12781 callgraph_dir.clone(),
12782 root.clone(),
12783 vec![source.clone()],
12784 Arc::clone(&pending),
12785 );
12786 wait_for_refresh_calls(&root, 1);
12787 for _ in 0..3 {
12788 enqueue_callgraph_store_refresh(
12789 callgraph_dir.clone(),
12790 root.clone(),
12791 vec![source.clone()],
12792 Arc::clone(&pending),
12793 );
12794 }
12795
12796 assert!(flush_callgraph_store_refreshes_with_budget(
12797 Duration::from_secs(2)
12798 ));
12799 assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
12800 assert!(pending.lock().is_empty());
12801 clear_callgraph_refresh_worker_test_seam(&root);
12802 }
12803
12804 #[test]
12805 fn queued_refresh_opens_generation_published_after_enqueue() {
12806 let _guard = REFRESH_WORKER_TEST_LOCK
12807 .lock()
12808 .unwrap_or_else(std::sync::PoisonError::into_inner);
12809 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12814 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
12815 let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
12816 set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
12817 let (active_held_rx, active_release_tx) =
12818 install_callgraph_refresh_worker_test_gate(active_root.clone());
12819 set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
12820 enqueue_callgraph_store_refresh(
12821 active_dir,
12822 active_root.clone(),
12823 vec![active_source],
12824 pending_paths(),
12825 );
12826 active_held_rx
12827 .recv_timeout(Duration::from_secs(12))
12828 .expect("active refresh worker holds the queue");
12829
12830 fs::write(
12831 &target_source,
12832 "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
12833 )
12834 .unwrap();
12835 enqueue_callgraph_store_refresh(
12836 target_dir.clone(),
12837 target_root.clone(),
12838 vec![target_source.clone()],
12839 pending_paths(),
12840 );
12841 let (new_generation, _) = CallGraphStore::cold_build_with_lease(
12842 target_dir.clone(),
12843 target_root.clone(),
12844 std::slice::from_ref(&target_source),
12845 )
12846 .unwrap();
12847 fs::write(
12848 &target_source,
12849 "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
12850 )
12851 .unwrap();
12852 drop(new_generation);
12853
12854 active_release_tx
12855 .send(())
12856 .expect("release active refresh worker");
12857 wait_for_refresh_calls(&target_root, 1);
12858 assert!(flush_callgraph_store_refreshes_with_budget(
12859 Duration::from_secs(12)
12860 ));
12861 let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
12862 .unwrap()
12863 .expect("current callgraph generation");
12864 let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
12865 assert_eq!(tree.children[0].name, "worker_leaf");
12866 assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
12867 clear_callgraph_refresh_worker_test_seam(&active_root);
12868 clear_callgraph_refresh_worker_test_seam(&target_root);
12869 }
12870
12871 #[test]
12872 fn refresh_failure_marks_files_stale() {
12873 let _guard = REFRESH_WORKER_TEST_LOCK
12874 .lock()
12875 .unwrap_or_else(std::sync::PoisonError::into_inner);
12876 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12881 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12882 let pending = pending_paths();
12883 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
12884
12885 enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
12886 assert!(flush_callgraph_store_refreshes_with_budget(
12887 Duration::from_secs(2)
12888 ));
12889
12890 assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
12891 let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
12892 .unwrap()
12893 .expect("ready callgraph store");
12894 assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
12895 clear_callgraph_refresh_worker_test_seam(&root);
12896 }
12897
12898 #[test]
12899 fn idle_refresh_truncates_wal() {
12900 let _guard = REFRESH_WORKER_TEST_LOCK
12901 .lock()
12902 .unwrap_or_else(std::sync::PoisonError::into_inner);
12903 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12904 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12905 let generation = read_pointer(
12906 &callgraph_dir,
12907 &crate::search_index::artifact_cache_key(&root),
12908 )
12909 .expect("fixture publishes a generation");
12910 let wal_path = callgraph_dir.join(format!("{generation}-wal"));
12911 let pending = pending_paths();
12912 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12913
12914 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n\n").unwrap();
12915 enqueue_callgraph_store_refresh(
12916 callgraph_dir.clone(),
12917 root.clone(),
12918 vec![source.clone()],
12919 Arc::clone(&pending),
12920 );
12921 wait_for_refresh_calls(&root, 1);
12922 wait_for_refresh_worker_idle();
12923 let checkpoint_deadline = Instant::now() + Duration::from_secs(2);
12924 while fs::metadata(&wal_path)
12925 .map(|metadata| metadata.len())
12926 .unwrap_or(0)
12927 != 0
12928 {
12929 assert!(
12930 Instant::now() < checkpoint_deadline,
12931 "idle checkpoint did not truncate WAL"
12932 );
12933 std::thread::sleep(Duration::from_millis(5));
12934 }
12935 assert_eq!(
12936 fs::metadata(&wal_path)
12937 .map(|metadata| metadata.len())
12938 .unwrap_or(0),
12939 0,
12940 "idle transition truncates the refresh WAL"
12941 );
12942
12943 clear_callgraph_refresh_worker_test_seam(&root);
12944 }
12945
12946 #[test]
12947 fn bounded_shutdown_defers_unprocessed_batches() {
12948 let _guard = REFRESH_WORKER_TEST_LOCK
12949 .lock()
12950 .unwrap_or_else(std::sync::PoisonError::into_inner);
12951 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12956 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
12957 let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
12958 let active_pending = pending_paths();
12959 let queued_pending = pending_paths();
12960 set_callgraph_refresh_worker_test_seam(
12961 active_root.clone(),
12962 Duration::from_millis(300),
12963 false,
12964 );
12965
12966 enqueue_callgraph_store_refresh(
12967 active_dir,
12968 active_root.clone(),
12969 vec![active_source.clone()],
12970 Arc::clone(&active_pending),
12971 );
12972 wait_for_refresh_calls(&active_root, 1);
12973 enqueue_callgraph_store_refresh(
12974 queued_dir,
12975 queued_root.clone(),
12976 vec![queued_source.clone()],
12977 Arc::clone(&queued_pending),
12978 );
12979
12980 assert!(!flush_callgraph_store_refreshes_with_budget(
12981 Duration::from_millis(20)
12982 ));
12983 assert!(active_pending.lock().contains(&active_source));
12984 assert!(queued_pending.lock().contains(&queued_source));
12985 assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
12986 clear_callgraph_refresh_worker_test_seam(&active_root);
12987 }
12988}
12989
12990#[cfg(test)]
12991mod cold_build_insert_tests {
12992 use super::*;
12993 use crate::imports::ImportBlock;
12994 use std::cell::Cell;
12995 use std::fs;
12996 use std::path::{Path, PathBuf};
12997 use tempfile::tempdir;
12998
12999 thread_local! {
13000 static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
13001 static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
13002 static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
13003 }
13004
13005 fn count_caller_traversal_selects(sql: &str) {
13006 let sql = sql.trim_start();
13007 if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
13008 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
13009 }
13010 if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
13011 && sql.contains("e.target_file =")
13012 {
13013 CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
13014 }
13015 if sql.starts_with("WITH requested") && sql.contains("COUNT(*)") {
13016 BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
13017 }
13018 }
13019
13020 #[test]
13021 fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
13022 let dir = tempdir().unwrap();
13023 let previous_root = dir.path().join("previous-root");
13024 let current_root = dir.path().join("current-root");
13025 fs::create_dir_all(&previous_root).unwrap();
13026 fs::create_dir_all(¤t_root).unwrap();
13027 fs::remove_dir(&previous_root).unwrap();
13028 let mut conn = Connection::open_in_memory().unwrap();
13029 initialize_schema(&conn).unwrap();
13030 conn.execute(
13031 "INSERT INTO backend_file_state(
13032 backend, workspace_root, file_path, content_hash, status, updated_at
13033 ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
13034 params![previous_root.display().to_string()],
13035 )
13036 .unwrap();
13037
13038 let repair = reconcile_workspace_roots(&mut conn, ¤t_root, false).unwrap();
13039
13040 assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
13041 assert_eq!(
13042 stored_workspace_roots(&conn).unwrap(),
13043 vec![previous_root.display().to_string()]
13044 );
13045 }
13046
13047 #[test]
13048 fn sqlite_readonly_uri_percent_encodes_windows_paths() {
13049 assert_eq!(
13050 sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
13051 "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
13052 );
13053 }
13054
13055 #[test]
13056 fn legacy_migration_completion_log_has_operator_fields() {
13057 assert_eq!(
13058 legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
13059 "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
13060 );
13061 }
13062
13063 fn write_generation_with_age(
13064 dir: &Path,
13065 project_key: &str,
13066 ordinal: u64,
13067 age: Duration,
13068 ) -> String {
13069 let generation = format!("{project_key}.g{ordinal}.1.sqlite");
13070 let path = dir.join(&generation);
13071 fs::write(&path, b"sqlite placeholder").unwrap();
13072 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
13073 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
13074 generation
13075 }
13076
13077 #[test]
13078 fn gc_old_generations_preserves_live_reader_until_marker_drops() {
13079 let dir = tempfile::tempdir().unwrap();
13080 let project_key = "project";
13081 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
13082 let previous =
13083 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
13084 let pinned =
13085 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
13086 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
13087
13088 gc_old_generations(dir.path(), project_key, ¤t);
13089
13090 assert!(dir.path().join(&previous).is_file());
13091 assert!(dir.path().join(&pinned).is_file());
13092
13093 drop(marker);
13094 gc_old_generations(dir.path(), project_key, ¤t);
13095
13096 assert!(dir.path().join(&previous).is_file());
13097 assert!(!dir.path().join(&pinned).exists());
13098 }
13099
13100 #[test]
13101 fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
13102 let dir = tempfile::tempdir().unwrap();
13103 let project_key = "project";
13104 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
13105 let _previous =
13106 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
13107 let pinned =
13108 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
13109 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
13110 filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
13111
13112 gc_old_generations(dir.path(), project_key, ¤t);
13113
13114 assert!(dir.path().join(&pinned).is_file());
13115 }
13116
13117 #[test]
13118 fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
13119 let dir = tempfile::tempdir().unwrap();
13120 let project_key = "project";
13121 let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
13122 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
13123 let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
13124 let old = write_generation_with_age(
13125 dir.path(),
13126 project_key,
13127 200,
13128 expired + Duration::from_secs(60),
13129 );
13130 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
13131
13132 gc_old_generations(dir.path(), project_key, ¤t);
13133
13134 assert!(dir.path().join(¤t).is_file());
13135 assert!(dir.path().join(&previous).is_file());
13136 assert!(!dir.path().join(&old).exists());
13137 }
13138
13139 fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
13140 let path = dir.join(name);
13141 fs::write(&path, b"temp placeholder").unwrap();
13142 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
13143 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
13144 path
13145 }
13146
13147 #[test]
13148 fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
13149 let dir = tempdir().unwrap();
13150 let aged = "project.g100.1.sqlite.tmp.1.200";
13154 let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
13155 let fresh = "project.g300.1.sqlite.tmp.1.400";
13156 let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
13157 write_build_temp_with_age(dir.path(), aged, aged_age);
13158 write_build_temp_with_age(dir.path(), aged_journal, aged_age);
13159 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
13160
13161 sweep_orphaned_build_temps(dir.path());
13162
13163 assert!(
13164 !dir.path().join(aged).exists(),
13165 "aged orphan must be removed"
13166 );
13167 assert!(
13168 !dir.path().join(aged_journal).exists(),
13169 "aged journal sidecar must be removed"
13170 );
13171 assert!(
13172 dir.path().join(fresh).is_file(),
13173 "fresh temporary must survive"
13174 );
13175 }
13176
13177 #[test]
13178 fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
13179 let storage = tempdir().unwrap();
13180 let storage_root = storage.path();
13181 let legacy_dir = storage_root.join("opencode").join("callgraph");
13187 fs::create_dir_all(&legacy_dir).unwrap();
13188 let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
13189 write_build_temp_with_age(
13190 &legacy_dir,
13191 orphan,
13192 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
13193 );
13194 assert!(
13195 !legacy_dir.join("deadbeef.current").exists(),
13196 "the dead root has no current pointer"
13197 );
13198
13199 let root_keyed_dir = storage_root.join("callgraph").join("livekey");
13200 fs::create_dir_all(&root_keyed_dir).unwrap();
13201
13202 sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
13203
13204 assert!(
13205 !legacy_dir.join(orphan).exists(),
13206 "legacy orphan must be reclaimed by the store-wide sweep"
13207 );
13208 }
13209
13210 #[test]
13211 fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
13212 let dir = tempdir().unwrap();
13218 let fresh = "project.g300.1.sqlite.tmp.1.400";
13219 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
13220
13221 sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
13222
13223 assert!(
13224 !dir.path().join(fresh).exists(),
13225 "with the age predicate forced open, the fresh temporary is removed"
13226 );
13227 }
13228
13229 #[test]
13230 fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
13231 let dir = tempdir().unwrap();
13232 let generation = write_generation_with_age(
13236 dir.path(),
13237 "project",
13238 400,
13239 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
13240 );
13241 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
13242
13243 sweep_orphaned_build_temps(dir.path());
13244
13245 assert!(
13246 dir.path().join(&generation).is_file(),
13247 "completed generation must survive the orphan sweep"
13248 );
13249 assert!(
13250 crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
13251 "read marker must survive the orphan sweep"
13252 );
13253 }
13254
13255 #[test]
13256 fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
13257 let dir = tempfile::tempdir().unwrap();
13258 let project_key = "project".to_string();
13259 let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
13260 let sqlite_path = dir.path().join(&generation);
13261 fs::remove_file(&sqlite_path).unwrap();
13262 let conn = Connection::open(&sqlite_path).unwrap();
13263 let store = CallGraphStore::from_connection(
13264 dir.path().to_path_buf(),
13265 project_key,
13266 sqlite_path,
13267 dir.path().to_path_buf(),
13268 false,
13269 Some(generation.clone()),
13270 None,
13271 None,
13272 conn,
13273 );
13274
13275 let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
13276 assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
13277
13278 drop(marker);
13279 assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
13280 }
13281
13282 #[test]
13283 fn readiness_cache_only_skips_checks_after_a_successful_validation() {
13284 let dir = tempdir().expect("temp dir");
13285 let file = dir.path().join("main.ts");
13286 fs::write(&file, "export function main() {}\n").expect("write fixture");
13287 let store = CallGraphStore::open(
13288 dir.path().join(".store-readiness-cache"),
13289 dir.path().to_path_buf(),
13290 )
13291 .expect("open store");
13292 {
13293 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13294 conn.trace(Some(count_caller_traversal_selects));
13295 }
13296
13297 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13298 assert!(store.indexed_file_count().is_err());
13299 assert!(store.indexed_file_count().is_err());
13300 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
13301
13302 store
13303 .cold_build(std::slice::from_ref(&file))
13304 .expect("cold build");
13305 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13306 assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
13307 assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
13308 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
13309
13310 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13311 conn.trace(None);
13312 }
13313
13314 #[test]
13315 fn direct_caller_frontier_chunks_sqlite_selects() {
13316 let dir = tempdir().expect("temp dir");
13317 let file = dir.path().join("main.ts");
13318 fs::write(
13319 &file,
13320 "export function caller() { target(); }\nexport function target() {}\n",
13321 )
13322 .expect("write fixture");
13323 let store = CallGraphStore::open(
13324 dir.path().join(".store-caller-frontier-query"),
13325 dir.path().to_path_buf(),
13326 )
13327 .expect("open store");
13328 store
13329 .cold_build(std::slice::from_ref(&file))
13330 .expect("cold build");
13331 let mut targets = vec![("main.ts".to_string(), "target".to_string())];
13332 targets.extend((1..1_000).map(|index| ("main.ts".to_string(), format!("missing{index}"))));
13333
13334 CALLER_QUERY_SELECTS.with(|count| count.set(0));
13335 BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
13336 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13337 {
13338 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13339 conn.trace(Some(count_caller_traversal_selects));
13340 }
13341 let callers = store
13342 .direct_callers_for_symbols(&targets)
13343 .expect("batched callers");
13344 {
13345 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13346 conn.trace(None);
13347 }
13348
13349 assert_eq!(callers.len(), 1_000);
13350 assert_eq!(callers.get(&targets[0]).unwrap().len(), 1);
13351 assert_eq!(CALLER_QUERY_SELECTS.with(Cell::get), 3);
13352 assert_eq!(BOUNDARY_COUNT_SELECTS.with(Cell::get), 0);
13353 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
13354 }
13355
13356 #[test]
13357 fn callers_depth_boundary_batches_sqlite_counts() {
13358 const CALLER_COUNT: usize = 1_000;
13359
13360 let dir = tempdir().expect("temp dir");
13361 let file = dir.path().join("main.ts");
13362 let mut source = String::from("export function sharedHotHelper() {}\n");
13363 for index in 0..CALLER_COUNT {
13364 source.push_str(&format!(
13365 "export function caller{index}() {{ sharedHotHelper(); }}\n"
13366 ));
13367 }
13368 fs::write(&file, source).expect("write fixture");
13369
13370 let store = CallGraphStore::open(
13371 dir.path().join(".store-callers-query-fanout"),
13372 dir.path().to_path_buf(),
13373 )
13374 .expect("open store");
13375 store
13376 .cold_build(std::slice::from_ref(&file))
13377 .expect("cold build");
13378
13379 CALLER_QUERY_SELECTS.with(|count| count.set(0));
13380 BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
13381 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13382 {
13383 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13384 conn.trace(Some(count_caller_traversal_selects));
13385 }
13386
13387 let started = Instant::now();
13388 let result = crate::commands::callgraph_store_adapter::callers_result(
13389 &store,
13390 Path::new("main.ts"),
13391 "sharedHotHelper",
13392 1,
13393 true,
13394 )
13395 .expect("callers result");
13396 let elapsed = started.elapsed();
13397
13398 {
13399 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13400 conn.trace(None);
13401 }
13402 let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
13403 let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
13404 let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
13405 eprintln!(
13406 "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
13407 result.total_callers,
13408 caller_queries,
13409 boundary_queries,
13410 total_selects,
13411 elapsed.as_secs_f64() * 1_000.0
13412 );
13413
13414 assert_eq!(result.total_callers, CALLER_COUNT);
13415 assert_eq!(caller_queries, 1);
13416 assert_eq!(boundary_queries, 3);
13417 assert_eq!(total_selects, 9);
13418 }
13419
13420 #[test]
13421 fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
13422 let dir = tempdir().expect("temp dir");
13423 let file = dir.path().join("main.ts");
13424 fs::write(
13425 &file,
13426 r#"export function topA() {
13427 root();
13428}
13429
13430export function topB() {
13431 root();
13432}
13433
13434export function root() {
13435 leaf();
13436 missing();
13437}
13438
13439export function leaf() {}
13440"#,
13441 )
13442 .expect("write fixture");
13443
13444 let store = CallGraphStore::open(
13445 dir.path().join(".store-depth-boundary-counts"),
13446 dir.path().to_path_buf(),
13447 )
13448 .expect("open store");
13449 store
13450 .cold_build(std::slice::from_ref(&file))
13451 .expect("cold build");
13452
13453 let root = store
13454 .node_for(Path::new("main.ts"), "root")
13455 .expect("root node");
13456 let leaf = store
13457 .node_for(Path::new("main.ts"), "leaf")
13458 .expect("leaf node");
13459
13460 let (full_forward_len, full_direct_len) = {
13461 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
13462 conn.execute(
13463 "INSERT INTO edges (
13464 edge_id, ref_id, source_node, target_node, target_file,
13465 target_symbol, kind, line, provenance
13466 ) VALUES (
13467 'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
13468 ?2, ?3, 'call', 98, ?4
13469 )",
13470 rusqlite::params![
13471 &root.node_id,
13472 &leaf.file,
13473 &leaf.symbol,
13474 PROVENANCE_TREESITTER
13475 ],
13476 )
13477 .expect("insert dangling forward edge");
13478 conn.execute(
13479 "INSERT INTO edges (
13480 edge_id, ref_id, source_node, target_node, target_file,
13481 target_symbol, kind, line, provenance
13482 ) VALUES (
13483 'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
13484 ?1, ?2, ?3, 'call', 99, ?4
13485 )",
13486 rusqlite::params![
13487 &root.node_id,
13488 &root.file,
13489 &root.symbol,
13490 PROVENANCE_TREESITTER
13491 ],
13492 )
13493 .expect("insert dangling direct-caller edge");
13494
13495 let full_forward_len = forward_calls_for_node(&conn, &root)
13496 .expect("full forward calls")
13497 .len();
13498 let counted_forward_len =
13499 forward_call_count_for_node(&conn, &root).expect("counted forward calls");
13500 assert_eq!(
13501 counted_forward_len, full_forward_len,
13502 "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
13503 );
13504
13505 let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
13506 .expect("full direct callers");
13507 let full_direct_len = full_direct.len();
13508 let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
13509 .expect("counted direct callers");
13510 assert_eq!(
13511 counted_direct_len, full_direct_len,
13512 "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
13513 );
13514
13515 let distinct_direct_len = full_direct
13516 .iter()
13517 .map(|site| {
13518 (
13519 site.caller.file.clone(),
13520 site.line,
13521 site.target_file.clone(),
13522 site.target_symbol.clone(),
13523 )
13524 })
13525 .collect::<BTreeSet<_>>()
13526 .len();
13527 let batch_counts = direct_caller_counts_for_tuples(
13528 &conn,
13529 &[
13530 (root.file.clone(), root.symbol.clone()),
13531 (root.file.clone(), root.symbol.clone()),
13532 (leaf.file.clone(), leaf.symbol.clone()),
13533 ],
13534 )
13535 .expect("batched direct-caller counts");
13536 assert_eq!(batch_counts.len(), 2);
13537 assert_eq!(
13538 batch_counts.get(&(root.file.clone(), root.symbol.clone())),
13539 Some(&distinct_direct_len)
13540 );
13541
13542 (full_forward_len, full_direct_len)
13543 };
13544
13545 assert_eq!(
13546 full_forward_len, 2,
13547 "fixture root should have one resolved and one unresolved outgoing call"
13548 );
13549 assert_eq!(
13550 full_direct_len, 2,
13551 "fixture root should have two real direct callers"
13552 );
13553
13554 let tree = store
13555 .call_tree(Path::new("main.ts"), "root", 0)
13556 .expect("call tree");
13557 assert!(tree.depth_limited);
13558 assert_eq!(tree.children.len(), 0);
13559 assert_eq!(
13560 tree.truncated, full_forward_len,
13561 "call_tree depth boundary must report the full forward-call list length"
13562 );
13563
13564 let callers = store
13565 .callers_of(Path::new("main.ts"), "leaf", 0)
13566 .expect("callers");
13567 assert!(callers.depth_limited);
13568 assert_eq!(callers.callers.len(), 1);
13569 assert_eq!(callers.callers[0].caller.symbol, "root");
13570 assert_eq!(
13571 callers.truncated, full_direct_len,
13572 "callers depth boundary must report the full direct-caller list length"
13573 );
13574 }
13575
13576 #[test]
13577 fn source_freshness_matches_cache_collect_for_same_bytes() {
13578 let dir = tempdir().expect("temp dir");
13579 let path = dir.path().join("fixture.ts");
13580 let source = "export function main() { return helper(); }\n";
13581 fs::write(&path, source).expect("write fixture");
13582
13583 let expected = cache_freshness::collect(&path).expect("collect freshness from file");
13584 let actual =
13585 collect_source_freshness(&path, source).expect("collect freshness from source");
13586
13587 assert_eq!(actual, expected);
13588 }
13589
13590 #[test]
13591 fn superseded_cold_build_cannot_publish_after_newer_epoch() {
13592 let root = tempfile::tempdir().unwrap();
13593 let callgraph_dir = tempfile::tempdir().unwrap();
13594 let source_dir = root.path().join("src");
13595 std::fs::create_dir_all(&source_dir).unwrap();
13596 let source = source_dir.join("lib.rs");
13597 std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
13598 let files = vec![source.clone()];
13599 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
13600 let old_epoch = epoch.next();
13601 let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
13602 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
13603 let old_epoch_flag = epoch.clone();
13604 let old_dir = callgraph_dir.path().to_path_buf();
13605 let old_root = root.path().to_path_buf();
13606 let old_files = files.clone();
13607 let old = std::thread::spawn(move || {
13608 set_cold_build_before_publish_observer(Some(Arc::new(move || {
13609 reached_tx.send(()).unwrap();
13610 release_rx.recv().unwrap();
13611 })));
13612 let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
13613 CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
13614 });
13615 set_cold_build_before_publish_observer(None);
13616 result
13617 });
13618 reached_rx
13622 .recv_timeout(Duration::from_secs(30))
13623 .expect("older build did not reach its publication barrier");
13624
13625 std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
13626 let new_epoch = epoch.next();
13627 let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
13628 CallGraphStore::cold_build_with_lease(
13629 callgraph_dir.path().to_path_buf(),
13630 root.path().to_path_buf(),
13631 &files,
13632 )
13633 })
13634 .expect("newer build should publish");
13635 drop(new_store);
13636
13637 release_tx.send(()).unwrap();
13638 assert!(matches!(
13639 old.join().unwrap(),
13640 Err(CallGraphStoreError::Superseded)
13641 ));
13642
13643 let current = CallGraphStore::open_readonly(
13644 callgraph_dir.path().to_path_buf(),
13645 root.path().to_path_buf(),
13646 )
13647 .unwrap()
13648 .expect("current callgraph generation");
13649 assert_eq!(
13650 current
13651 .nodes_matching("new_generation_marker")
13652 .unwrap()
13653 .len(),
13654 1
13655 );
13656 assert!(current
13657 .nodes_matching("old_generation_marker")
13658 .unwrap()
13659 .is_empty());
13660 }
13661
13662 #[test]
13663 fn cold_build_prepared_bulk_insert_matches_reference_rows() {
13664 let dir = tempdir().expect("temp dir");
13665 let project_root = dir.path();
13666 let extract = fixture_extract(project_root);
13667 let resolved = fixture_resolved(&extract);
13668
13669 let reference = build_reference_connection(project_root, &extract, &resolved);
13670 let optimized = build_optimized_connection(project_root, &extract, &resolved);
13671
13672 for table in [
13673 "files",
13674 "nodes",
13675 "file_dependencies",
13676 "dispatch_hints",
13677 "refs",
13678 "edges",
13679 ] {
13680 let excluded: &[&str] = if table == "files" {
13687 &["indexed_at"]
13688 } else {
13689 &[]
13690 };
13691 assert_eq!(
13692 table_rows_without(&reference, table, excluded),
13693 table_rows_without(&optimized, table, excluded),
13694 "table `{table}` rows must match apart from wall-clock columns"
13695 );
13696 }
13697 assert_eq!(
13698 backend_state_rows(&reference),
13699 backend_state_rows(&optimized),
13700 "backend freshness rows must match apart from updated_at"
13701 );
13702 assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
13703 }
13704
13705 #[test]
13706 fn cold_build_chunked_matches_unchunked_logical_rows() {
13707 let dir = tempdir().expect("temp dir");
13708 let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
13709 write_chunked_equivalence_fixture(&project_root);
13710 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
13711 assert!(
13712 files.len() > 6,
13713 "fixture should be large enough to split into multiple chunks"
13714 );
13715
13716 let unchunked = CallGraphStore::open(
13717 project_root.join(".store-unchunked"),
13718 project_root.to_path_buf(),
13719 )
13720 .expect("open unchunked store");
13721 let unchunked_stats = unchunked
13722 .cold_build_chunked(&files, 0)
13723 .expect("unchunked cold build");
13724
13725 let chunked = CallGraphStore::open(
13726 project_root.join(".store-chunked"),
13727 project_root.to_path_buf(),
13728 )
13729 .expect("open chunked store");
13730 let chunked_stats = chunked
13731 .cold_build_chunked(&files, 3)
13732 .expect("chunked cold build");
13733
13734 assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
13735 assert_eq!(
13736 unchunked.edge_snapshot().expect("unchunked edge snapshot"),
13737 chunked.edge_snapshot().expect("chunked edge snapshot"),
13738 "public edge snapshots must match"
13739 );
13740
13741 let dispatch_edges = {
13742 let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
13743 conn.query_row(
13744 "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
13745 [],
13746 |row| row.get::<_, i64>(0),
13747 )
13748 .expect("count dispatch edges")
13749 };
13750 assert!(
13751 dispatch_edges > 0,
13752 "fixture must exercise method-dispatch edge insertion"
13753 );
13754
13755 for table in [
13756 "edges",
13757 "refs",
13758 "nodes",
13759 "file_dependencies",
13760 "dispatch_hints",
13761 ] {
13762 assert_eq!(
13763 graph_table_rows(&unchunked, table),
13764 graph_table_rows(&chunked, table),
13765 "chunked cold build must match unchunked rows for {table}"
13766 );
13767 }
13768 assert_eq!(
13769 graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
13770 graph_table_rows_without(&chunked, "files", &["indexed_at"]),
13771 "files rows must match apart from indexed_at"
13772 );
13773 assert_eq!(
13774 graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
13775 graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
13776 "backend freshness rows must match apart from updated_at"
13777 );
13778
13779 let published_dir = project_root.join(".store-published");
13780 let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
13781 published_dir.clone(),
13782 project_root.to_path_buf(),
13783 &files,
13784 0,
13785 )
13786 .expect("published unchunked cold build");
13787 assert!(
13788 !CallGraphStore::needs_cold_build(&published_dir, &project_root)
13789 .expect("needs_cold_build after publish"),
13790 "published store should be ready"
13791 );
13792 drop(_published);
13793 let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
13794 published_dir,
13795 project_root.to_path_buf(),
13796 &files,
13797 3,
13798 )
13799 .expect("ensure with a different chunk size");
13800 assert!(
13801 rebuild_stats.is_none(),
13802 "changing callgraph_chunk_size must not affect store identity or force a rebuild"
13803 );
13804 }
13805
13806 #[test]
13813 #[ignore]
13814 fn bench_cold_build_chunk() {
13815 let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
13816 let chunk: usize = std::env::var("AFT_PERF_CHUNK")
13817 .expect("AFT_PERF_CHUNK")
13818 .parse()
13819 .expect("AFT_PERF_CHUNK must be a non-negative integer");
13820 let project_root = fs::canonicalize(&repo).expect("canonical repo root");
13821 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
13822 let dir = tempdir().expect("temp dir");
13823 let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
13824 .expect("open store");
13825 let started = Instant::now();
13826 let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
13827 let ms = started.elapsed().as_millis();
13828 println!(
13829 "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
13830 stats.files, stats.nodes, stats.refs, stats.edges
13831 );
13832 }
13833
13834 #[test]
13835 fn persisted_workspace_reexport_selects_its_package_dependency() {
13836 let root = tempdir().expect("temp dir");
13837 let dependencies = BTreeSet::from([
13838 "packages/aft-bridge/src/index.ts".to_string(),
13839 "packages/opencode-plugin/src/types.ts".to_string(),
13840 ]);
13841 let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
13842
13843 assert_eq!(
13844 stored_dependencies_for_module(
13845 root.path(),
13846 "packages/opencode-plugin/src/shared/bash-hints.ts",
13847 "@cortexkit/aft-bridge",
13848 &dependencies,
13849 &indexed_files,
13850 ),
13851 BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
13852 );
13853 }
13854
13855 #[test]
13856 fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
13857 let dir = tempdir().expect("temp dir");
13858 let project_root = dir.path();
13859 let files =
13860 write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
13861 let index_path = project_root.join("src/index.ts");
13862
13863 let store = CallGraphStore::open(
13864 project_root.join(".store-incremental-barrel"),
13865 project_root.to_path_buf(),
13866 )
13867 .expect("open incremental store");
13868 store.cold_build(&files).expect("initial cold build");
13869
13870 {
13871 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13872 let tx = conn.transaction().expect("dependency transaction");
13873 let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
13874 .expect("dependent refs for barrel");
13875 let selected_ref_ids = dependent_refs
13876 .iter()
13877 .map(|dependent_ref| dependent_ref.ref_id.clone())
13878 .collect::<BTreeSet<_>>();
13879 let mut threaded_ref_ids = BTreeSet::new();
13880 let mut threaded_by_caller = BTreeMap::new();
13881 record_dependent_refs(
13882 &mut threaded_ref_ids,
13883 &mut threaded_by_caller,
13884 dependent_refs,
13885 );
13886 let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
13887 .expect("old per-ref caller lookup");
13888
13889 assert_eq!(threaded_ref_ids, selected_ref_ids);
13890 assert_eq!(threaded_by_caller, old_by_caller);
13891 for consumer in [
13892 "src/consumer_a.ts",
13893 "src/consumer_b.ts",
13894 "src/consumer_c.ts",
13895 ] {
13896 assert!(
13897 threaded_by_caller.contains_key(consumer),
13898 "barrel edit should select dependent refs from {consumer}"
13899 );
13900 }
13901 }
13902
13903 fs::write(
13904 &index_path,
13905 "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
13906 )
13907 .expect("edit barrel");
13908 let stats = store
13909 .refresh_files(std::slice::from_ref(&index_path))
13910 .expect("incremental refresh");
13911 assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
13912 assert!(
13913 stats.dependency_selected_refs > 0,
13914 "barrel surface edit should select dependent refs"
13915 );
13916
13917 let cold_store = CallGraphStore::open(
13918 project_root.join(".store-cold-barrel"),
13919 project_root.to_path_buf(),
13920 )
13921 .expect("open cold rebuild store");
13922 cold_store
13923 .cold_build(&files)
13924 .expect("comparison cold build");
13925
13926 for table in [
13927 "nodes",
13928 "refs",
13929 "file_dependencies",
13930 "edges",
13931 "dispatch_hints",
13932 ] {
13933 assert_eq!(
13934 graph_table_rows(&store, table),
13935 graph_table_rows(&cold_store, table),
13936 "incremental refresh {table} rows must match cold rebuild"
13937 );
13938 }
13939
13940 let consumer_path = project_root.join("src/consumer_a.ts");
13941 fs::write(
13942 &consumer_path,
13943 "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
13944 )
13945 .expect("edit barrel consumer");
13946 store
13947 .refresh_files(std::slice::from_ref(&consumer_path))
13948 .expect("refresh consumer through unchanged barrel");
13949 cold_store
13950 .cold_build(&files)
13951 .expect("comparison cold rebuild after consumer refresh");
13952 for table in [
13953 "nodes",
13954 "refs",
13955 "file_dependencies",
13956 "edges",
13957 "dispatch_hints",
13958 ] {
13959 assert_eq!(
13960 graph_table_rows(&store, table),
13961 graph_table_rows(&cold_store, table),
13962 "refresh through a persisted barrel must preserve cold-build {table} rows"
13963 );
13964 }
13965 }
13966
13967 fn build_reference_connection(
13968 project_root: &Path,
13969 extract: &FileExtract,
13970 resolved: &ResolvedRef,
13971 ) -> Connection {
13972 let mut conn = Connection::open_in_memory().expect("open reference db");
13973 configure_build_connection(&conn).expect("configure reference db");
13974 initialize_schema(&conn).expect("initialize reference schema");
13975 {
13976 let tx = conn.transaction().expect("reference transaction");
13977 clear_tables(&tx).expect("reference clear");
13978 insert_meta(&tx).expect("reference meta");
13979 insert_file_extract(&tx, project_root, extract).expect("reference file extract");
13980 insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
13981 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
13982 .expect("reference dispatch edges");
13983 assert_eq!(supplemental, 0);
13984 tx.commit().expect("reference commit");
13985 }
13986 conn
13987 }
13988
13989 fn build_optimized_connection(
13990 project_root: &Path,
13991 extract: &FileExtract,
13992 resolved: &ResolvedRef,
13993 ) -> Connection {
13994 let mut conn = Connection::open_in_memory().expect("open optimized db");
13995 configure_build_connection(&conn).expect("configure optimized db");
13996 initialize_schema(&conn).expect("initialize optimized schema");
13997 {
13998 let tx = conn.transaction().expect("optimized transaction");
13999 clear_tables(&tx).expect("optimized clear");
14000 insert_meta(&tx).expect("optimized meta");
14001 drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
14002 {
14003 let workspace_root = project_root.display().to_string();
14004 let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
14005 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
14006 .expect("optimized file extract");
14007 insert_resolved_ref_prepared(&mut inserts, resolved)
14008 .expect("optimized resolved ref");
14009 }
14010 create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
14011 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
14012 .expect("optimized dispatch edges");
14013 assert_eq!(supplemental, 0);
14014 tx.commit().expect("optimized commit");
14015 }
14016 conn
14017 }
14018
14019 fn fixture_extract(_project_root: &Path) -> FileExtract {
14020 let rel_path = "src/main.ts".to_string();
14021 let target_path = "src/helper.ts".to_string();
14022 let node = NodeRecord {
14023 id: "node-main".to_string(),
14024 file_path: rel_path.clone(),
14025 name: "main".to_string(),
14026 scoped_name: "main".to_string(),
14027 kind: "function".to_string(),
14028 range: Range {
14029 start_line: 0,
14030 start_col: 0,
14031 end_line: 0,
14032 end_col: 32,
14033 },
14034 range_ordinal: 0,
14035 signature: Some("export function main()".to_string()),
14036 exported: true,
14037 is_default_export: false,
14038 is_type_like: false,
14039 is_callgraph_entry_point: true,
14040 };
14041 let mut dependencies = BTreeSet::new();
14042 dependencies.insert(target_path.clone());
14043 let raw_ref = RawRef {
14044 ref_id: "ref-main-helper".to_string(),
14045 caller_node: Some(node.id.clone()),
14046 caller_symbol: Some(node.scoped_name.clone()),
14047 caller_file: rel_path.clone(),
14048 kind: "call".to_string(),
14049 short_name: Some("helper".to_string()),
14050 full_ref: Some("helper".to_string()),
14051 module_path: None,
14052 import_kind: None,
14053 local_name: Some("helper".to_string()),
14054 requested_name: Some("helper".to_string()),
14055 namespace_alias: None,
14056 wildcard: false,
14057 line: 1,
14058 byte_start: 24,
14059 byte_end: 32,
14060 dependencies,
14061 };
14062 FileExtract {
14063 rel_path,
14064 freshness: FileFreshness {
14065 mtime: UNIX_EPOCH + Duration::from_secs(123),
14066 size: 40,
14067 content_hash: cache_freshness::hash_bytes(b"fixture source"),
14068 },
14069 lang: LangId::TypeScript,
14070 data: FileCallData {
14071 calls_by_symbol: HashMap::new(),
14072 value_refs_by_symbol: HashMap::new(),
14073 exported_symbols: Vec::new(),
14074 symbol_metadata: HashMap::new(),
14075 default_export_symbol: None,
14076 import_block: ImportBlock::empty(),
14077 lang: LangId::TypeScript,
14078 },
14079 nodes: vec![node.clone()],
14080 raw_refs: vec![raw_ref],
14081 dispatch_hints: vec![DispatchHint {
14082 id: "dispatch-main-helper".to_string(),
14083 method_name: "helper".to_string(),
14084 caller_node: node.id,
14085 file: "src/main.ts".to_string(),
14086 line: 1,
14087 byte_start: 24,
14088 byte_end: 32,
14089 }],
14090 surface_fingerprint: "surface".to_string(),
14091 }
14092 }
14093
14094 fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
14095 let raw = extract.raw_refs[0].clone();
14096 let mut dependencies = raw.dependencies.clone();
14097 dependencies.insert("src/helper.ts".to_string());
14098 ResolvedRef {
14099 edge: Some(EdgeRecord {
14100 edge_id: "edge-main-helper".to_string(),
14101 source_node: raw.caller_node.clone().expect("caller node"),
14102 target_node: Some("node-helper".to_string()),
14103 target_file: "src/helper.ts".to_string(),
14104 target_symbol: "helper".to_string(),
14105 kind: "call".to_string(),
14106 line: raw.line,
14107 }),
14108 raw,
14109 status: "resolved".to_string(),
14110 target_node: Some("node-helper".to_string()),
14111 target_file: Some("src/helper.ts".to_string()),
14112 target_symbol: Some("helper".to_string()),
14113 dependencies,
14114 }
14115 }
14116
14117 fn write_chunked_equivalence_fixture(project_root: &Path) {
14118 let ts_dir = project_root.join("ts");
14119 fs::create_dir_all(&ts_dir).expect("create ts dir");
14120 fs::write(
14121 ts_dir.join("leaf.ts"),
14122 "export function leaf(value: number) {\n return value + 1;\n}\n",
14123 )
14124 .expect("write ts leaf");
14125 fs::write(
14126 ts_dir.join("mid.ts"),
14127 "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n return leaf(value);\n}\n",
14128 )
14129 .expect("write ts mid");
14130 fs::write(
14131 ts_dir.join("entry.ts"),
14132 "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n return mid(worker.run());\n}\n",
14133 )
14134 .expect("write ts entry");
14135 fs::write(
14136 ts_dir.join("worker.ts"),
14137 "export class Worker {\n run() {\n return 41;\n }\n}\n",
14138 )
14139 .expect("write ts worker");
14140 for idx in 0..4 {
14141 fs::write(
14142 ts_dir.join(format!("extra_{idx}.ts")),
14143 format!(
14144 "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n return entry(new Worker());\n}}\n"
14145 ),
14146 )
14147 .expect("write ts extra");
14148 }
14149
14150 let rust_dir = project_root.join("src");
14151 let commands_dir = rust_dir.join("commands");
14152 fs::create_dir_all(&commands_dir).expect("create rust commands dir");
14153 fs::write(
14154 rust_dir.join("context.rs"),
14155 r#"pub struct AppContext;
14156
14157impl AppContext {
14158 pub fn callgraph_store_for_ops(&self) -> usize {
14159 1
14160 }
14161}
14162"#,
14163 )
14164 .expect("write rust context");
14165 fs::write(
14166 rust_dir.join("lib.rs"),
14167 "pub mod context;\npub mod commands;\n",
14168 )
14169 .expect("write rust lib");
14170 fs::write(
14171 commands_dir.join("mod.rs"),
14172 "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
14173 )
14174 .expect("write rust commands mod");
14175 for name in ["callers", "impact", "trace_to"] {
14176 fs::write(
14177 commands_dir.join(format!("{name}.rs")),
14178 format!(
14179 r#"use crate::context::AppContext;
14180
14181pub fn handle_{name}(ctx: &AppContext) -> usize {{
14182 ctx.callgraph_store_for_ops()
14183}}
14184"#
14185 ),
14186 )
14187 .expect("write rust command");
14188 }
14189 }
14190
14191 fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
14192 let src_dir = project_root.join("src");
14193 fs::create_dir_all(&src_dir).expect("create src dir");
14194
14195 let target_path = src_dir.join("target.ts");
14196 fs::write(&target_path, "export function target() {\n return 1;\n}\n")
14197 .expect("write target");
14198
14199 let index_path = src_dir.join("index.ts");
14200 fs::write(&index_path, barrel_source).expect("write barrel");
14201
14202 let mut files = vec![target_path, index_path];
14203 for (file_name, function_name) in [
14204 ("consumer_a.ts", "consumerA"),
14205 ("consumer_b.ts", "consumerB"),
14206 ("consumer_c.ts", "consumerC"),
14207 ] {
14208 let path = src_dir.join(file_name);
14209 fs::write(
14210 &path,
14211 format!(
14212 "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n return target();\n}}\n"
14213 ),
14214 )
14215 .expect("write consumer");
14216 files.push(path);
14217 }
14218 files
14219 }
14220
14221 fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
14222 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
14223 table_rows(&conn, table)
14224 }
14225
14226 fn graph_table_rows_without(
14227 store: &CallGraphStore,
14228 table: &str,
14229 excluded_columns: &[&str],
14230 ) -> Vec<String> {
14231 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
14232 table_rows_without(&conn, table, excluded_columns)
14233 }
14234
14235 fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
14236 table_rows_without(conn, table, &[])
14237 }
14238
14239 fn table_rows_without(
14240 conn: &Connection,
14241 table: &str,
14242 excluded_columns: &[&str],
14243 ) -> Vec<String> {
14244 let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
14245 let columns: Vec<String> = conn
14246 .prepare(&format!("PRAGMA table_info({table})"))
14247 .expect("prepare table_info")
14248 .query_map([], |row| row.get::<_, String>(1))
14249 .expect("query table_info")
14250 .collect::<std::result::Result<Vec<String>, _>>()
14251 .expect("collect columns")
14252 .into_iter()
14253 .filter(|column| !excluded_columns.contains(column.as_str()))
14254 .collect();
14255 let sql = format!(
14256 "SELECT {} FROM {table} ORDER BY {}",
14257 columns.join(", "),
14258 columns.join(", ")
14259 );
14260 conn.prepare(&sql)
14261 .expect("prepare table rows")
14262 .query_map([], |row| row_to_strings(row, columns.len()))
14263 .expect("query table rows")
14264 .collect::<std::result::Result<_, _>>()
14265 .expect("collect table rows")
14266 }
14267
14268 fn assert_cold_build_stats_match_except_elapsed(
14269 expected: &ColdBuildStats,
14270 actual: &ColdBuildStats,
14271 ) {
14272 assert_eq!(actual.files, expected.files, "file counts must match");
14273 assert_eq!(actual.nodes, expected.nodes, "node counts must match");
14274 assert_eq!(actual.refs, expected.refs, "ref counts must match");
14275 assert_eq!(actual.edges, expected.edges, "edge counts must match");
14276 assert_eq!(
14277 actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
14278 expected
14279 .failed_files
14280 .iter()
14281 .cloned()
14282 .collect::<BTreeSet<_>>(),
14283 "failed file sets must match"
14284 );
14285 }
14286
14287 fn backend_state_rows(conn: &Connection) -> Vec<String> {
14288 conn.prepare(
14289 "SELECT backend, workspace_root, file_path, content_hash, status
14290 FROM backend_file_state
14291 ORDER BY backend, workspace_root, file_path, content_hash, status",
14292 )
14293 .expect("prepare backend rows")
14294 .query_map([], |row| row_to_strings(row, 5))
14295 .expect("query backend rows")
14296 .collect::<std::result::Result<_, _>>()
14297 .expect("collect backend rows")
14298 }
14299
14300 fn secondary_indexes(conn: &Connection) -> Vec<String> {
14301 let mut indexes = Vec::new();
14302 for table in [
14303 "files",
14304 "nodes",
14305 "refs",
14306 "file_dependencies",
14307 "edges",
14308 "dispatch_hints",
14309 "type_ref_names",
14310 "backend_file_state",
14311 "meta",
14312 ] {
14313 let sql = format!("PRAGMA index_list({table})");
14314 let mut stmt = conn.prepare(&sql).expect("prepare index list");
14315 let rows = stmt
14316 .query_map([], |row| row.get::<_, String>(1))
14317 .expect("query index list");
14318 for name in rows {
14319 let name = name.expect("index name");
14320 if name.starts_with("idx_") {
14321 indexes.push(format!("{table}:{name}"));
14322 }
14323 }
14324 }
14325 indexes.sort();
14326 indexes
14327 }
14328
14329 fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
14330 let mut values = Vec::with_capacity(len);
14331 for index in 0..len {
14332 let value = row.get_ref(index)?;
14333 values.push(match value {
14334 rusqlite::types::ValueRef::Null => "NULL".to_string(),
14335 rusqlite::types::ValueRef::Integer(value) => value.to_string(),
14336 rusqlite::types::ValueRef::Real(value) => value.to_string(),
14337 rusqlite::types::ValueRef::Text(value) => {
14338 String::from_utf8_lossy(value).into_owned()
14339 }
14340 rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
14341 });
14342 }
14343 Ok(values.join("\u{1f}"))
14344 }
14345}
14346
14347#[cfg(test)]
14348mod rust_resolution_tests {
14349 use super::*;
14350 use crate::inspect::job::CallgraphSnapshot;
14351 use std::fs;
14352 use tempfile::tempdir;
14353
14354 #[test]
14355 fn rust_function_scoped_module_alias_resolves_and_projects_live() {
14356 let dir = tempdir().expect("tempdir");
14357 let root = dir.path();
14358 write_rust_manifest(root, "scoped-alias-fixture");
14359 write_file(
14360 root,
14361 "src/lib.rs",
14362 r#"pub mod finalization_contract;
14363
14364pub fn run_alias() {
14365 use crate::finalization_contract as fc;
14366 fc::check_mason_contract();
14367}
14368"#,
14369 );
14370 write_file(
14371 root,
14372 "src/finalization_contract.rs",
14373 r#"pub fn check_mason_contract() {}
14374fn planted_dead() {}
14375"#,
14376 );
14377
14378 let (store, snapshot) = cold_build_twice(root);
14379 assert_direct_caller(
14380 &store,
14381 "src/finalization_contract.rs",
14382 "check_mason_contract",
14383 "src/lib.rs",
14384 "run_alias",
14385 );
14386 assert_projected_call(
14387 root,
14388 &snapshot,
14389 "src/finalization_contract.rs",
14390 "check_mason_contract",
14391 );
14392 assert_no_projected_call(
14393 root,
14394 &snapshot,
14395 "src/finalization_contract.rs",
14396 "planted_dead",
14397 );
14398 assert!(
14399 store
14400 .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
14401 .expect("planted dead callers")
14402 .is_empty(),
14403 "planted-dead guard should stay without callers"
14404 );
14405 }
14406
14407 #[test]
14408 fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
14409 let dir = tempdir().expect("tempdir");
14410 let root = dir.path();
14411 write_rust_manifest(root, "inline-module-fixture");
14412 write_file(
14413 root,
14414 "src/lib.rs",
14415 r#"mod work_graph { fn operations() {} }
14416mod manifest { fn operations() {} }
14417mod audit { fn operations() {} }
14418mod dispatch { fn operations() {} }
14419mod finalization { fn operations() {} }
14420
14421pub fn run_inline_operations() {
14422 work_graph::operations();
14423 manifest::operations();
14424 audit::operations();
14425 dispatch::operations();
14426 finalization::operations();
14427}
14428
14429fn planted_dead() {}
14430"#,
14431 );
14432
14433 let (store, snapshot) = cold_build_twice(root);
14434 for module in [
14435 "work_graph",
14436 "manifest",
14437 "audit",
14438 "dispatch",
14439 "finalization",
14440 ] {
14441 assert_direct_caller(
14442 &store,
14443 "src/lib.rs",
14444 &format!("{module}::operations"),
14445 "src/lib.rs",
14446 "run_inline_operations",
14447 );
14448 }
14449 assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
14450 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
14451 }
14452
14453 #[test]
14454 fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
14455 let dir = tempdir().expect("tempdir");
14456 let root = dir.path();
14457 fs::write(
14458 root.join("Cargo.toml"),
14459 "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
14460 )
14461 .expect("write workspace manifest");
14462 write_file(
14463 root,
14464 "crates/but-action/Cargo.toml",
14465 r#"[package]
14466name = "but-action"
14467version = "0.1.0"
14468edition = "2021"
14469"#,
14470 );
14471 write_file(
14472 root,
14473 "crates/but-action/src/lib.rs",
14474 "mod action;\npub use action::{list_actions};\n",
14475 );
14476 write_file(
14477 root,
14478 "crates/but-action/src/action.rs",
14479 "pub fn list_actions() {}\nfn planted_dead() {}\n",
14480 );
14481 write_file(
14482 root,
14483 "crates/app/Cargo.toml",
14484 r#"[package]
14485name = "app"
14486version = "0.1.0"
14487edition = "2021"
14488"#,
14489 );
14490 write_file(
14491 root,
14492 "crates/app/src/lib.rs",
14493 "pub fn run_actions() {\n but_action::list_actions();\n}\n",
14494 );
14495
14496 let (store, snapshot) = cold_build_twice(root);
14497 assert_direct_caller(
14498 &store,
14499 "crates/but-action/src/action.rs",
14500 "list_actions",
14501 "crates/app/src/lib.rs",
14502 "run_actions",
14503 );
14504 assert!(
14505 store
14506 .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
14507 .expect("lib reexport callers")
14508 .is_empty(),
14509 "call should target the reexported source function, not lib.rs"
14510 );
14511 assert_projected_call(
14512 root,
14513 &snapshot,
14514 "crates/but-action/src/action.rs",
14515 "list_actions",
14516 );
14517 assert_no_projected_call(
14518 root,
14519 &snapshot,
14520 "crates/but-action/src/action.rs",
14521 "planted_dead",
14522 );
14523 }
14524
14525 #[test]
14526 fn rust_generic_self_turbofish_method_dispatch_resolves() {
14527 let dir = tempdir().expect("tempdir");
14528 let root = dir.path();
14529 write_rust_manifest(root, "generic-self-fixture");
14530 write_file(
14531 root,
14532 "src/lib.rs",
14533 r#"pub struct Matcher;
14534
14535impl Matcher {
14536 pub fn run(&self) -> bool {
14537 self.fuzzy_match_optimal::<usize>("needle")
14538 }
14539
14540 fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
14541 let _ = std::marker::PhantomData::<T>;
14542 true
14543 }
14544
14545 fn planted_dead(&self) {}
14546}
14547
14548pub fn entry() -> bool {
14549 let matcher = Matcher;
14550 matcher.run()
14551}
14552"#,
14553 );
14554
14555 let (store, snapshot) = cold_build_twice(root);
14556 assert_direct_caller(
14557 &store,
14558 "src/lib.rs",
14559 "Matcher::fuzzy_match_optimal",
14560 "src/lib.rs",
14561 "Matcher::run",
14562 );
14563 assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
14564 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
14565 }
14566
14567 #[test]
14568 fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
14569 let dir = tempdir().expect("tempdir");
14570 let root = dir.path();
14571 write_rust_manifest(root, "manifest-operations-fixture");
14572 write_file(
14573 root,
14574 "src/main.rs",
14575 r#"mod dispatch;
14576use dispatch::{manifest_operations};
14577
14578fn main() {
14579 manifest_operations();
14580}
14581"#,
14582 );
14583 write_file(
14584 root,
14585 "src/dispatch.rs",
14586 r#"mod work_graph { fn operations() {} }
14587mod manifest { fn operations() {} }
14588mod audit { fn operations() {} }
14589mod descriptor { fn operations() {} }
14590mod writer { fn operations() {} }
14591
14592pub fn manifest_operations() {
14593 manifest::operations();
14594}
14595
14596pub fn work_graph_operations() {
14597 work_graph::operations();
14598}
14599
14600pub fn audit_operations() {
14601 audit::operations();
14602}
14603
14604pub fn descriptor_operations() {
14605 descriptor::operations();
14606}
14607
14608pub fn writer_operations() {
14609 writer::operations();
14610}
14611
14612fn planted_dead() {}
14613"#,
14614 );
14615
14616 let (store, snapshot) = cold_build_twice(root);
14617 assert_direct_caller(
14618 &store,
14619 "src/dispatch.rs",
14620 "manifest_operations",
14621 "src/main.rs",
14622 "main",
14623 );
14624 assert_direct_caller(
14625 &store,
14626 "src/dispatch.rs",
14627 "manifest::operations",
14628 "src/dispatch.rs",
14629 "manifest_operations",
14630 );
14631 assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
14632 assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
14633 assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
14634 }
14635
14636 fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
14637 let files = rust_files(root);
14638 let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
14639 .expect("open first store");
14640 first.cold_build(&files).expect("first cold build");
14641 let first_snapshot =
14642 project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
14643
14644 let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
14645 .expect("open second store");
14646 second.cold_build(&files).expect("second cold build");
14647 let second_snapshot =
14648 project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
14649
14650 assert_eq!(
14651 projection_rows(&first_snapshot),
14652 projection_rows(&second_snapshot),
14653 "cold-build projection should be deterministic"
14654 );
14655 (first, first_snapshot)
14656 }
14657
14658 fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
14659 let mut rows = Vec::new();
14660 for export in &snapshot.exported_symbols {
14661 rows.push(format!(
14662 "export\t{}\t{}\t{}\t{}",
14663 export.file.display(),
14664 export.symbol,
14665 export.kind,
14666 export.line
14667 ));
14668 }
14669 for call in &snapshot.outbound_calls {
14670 rows.push(format!(
14671 "call\t{}\t{}\t{}\t{}\t{}",
14672 call.caller_file.display(),
14673 call.caller_symbol,
14674 call.target,
14675 call.line,
14676 call.provenance
14677 ));
14678 }
14679 for file in &snapshot.entry_points {
14680 rows.push(format!("entry_file\t{}", file.display()));
14681 }
14682 for (file, symbols) in &snapshot.entry_point_symbols {
14683 for symbol in symbols {
14684 rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
14685 }
14686 }
14687 rows.sort();
14688 rows
14689 }
14690
14691 fn assert_direct_caller(
14692 store: &CallGraphStore,
14693 target_rel: &str,
14694 target_symbol: &str,
14695 caller_rel: &str,
14696 caller_symbol: &str,
14697 ) {
14698 let callers = store
14699 .direct_callers_of(Path::new(target_rel), target_symbol)
14700 .unwrap_or_else(|error| {
14701 panic!("direct callers for {target_rel}::{target_symbol}: {error}")
14702 });
14703 assert!(
14704 callers.iter().any(|site| {
14705 site.caller.file == caller_rel && site.caller.symbol == caller_symbol
14706 }),
14707 "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
14708 );
14709 }
14710
14711 fn assert_projected_call(
14712 root: &Path,
14713 snapshot: &CallgraphSnapshot,
14714 target_rel: &str,
14715 symbol: &str,
14716 ) {
14717 let target = projected_target(root, target_rel, symbol);
14718 assert!(
14719 snapshot.outbound_calls.iter().any(|call| {
14720 call.target == target
14721 || call.target.starts_with(&format!(
14722 "{target}{}",
14723 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
14724 ))
14725 }),
14726 "expected projected call to {target}; calls: {:#?}",
14727 snapshot.outbound_calls
14728 );
14729 }
14730
14731 fn assert_no_projected_call(
14732 root: &Path,
14733 snapshot: &CallgraphSnapshot,
14734 target_rel: &str,
14735 symbol: &str,
14736 ) {
14737 let target = projected_target(root, target_rel, symbol);
14738 assert!(
14739 snapshot.outbound_calls.iter().all(|call| {
14740 call.target != target
14741 && !call.target.starts_with(&format!(
14742 "{target}{}",
14743 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
14744 ))
14745 }),
14746 "did not expect projected call to {target}; calls: {:#?}",
14747 snapshot.outbound_calls
14748 );
14749 }
14750
14751 fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
14752 let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
14755 format!("{}::{symbol}", path.display())
14756 }
14757
14758 fn write_rust_manifest(root: &Path, name: &str) {
14759 write_file(
14760 root,
14761 "Cargo.toml",
14762 &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
14763 );
14764 }
14765
14766 fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
14767 let path = root.join(rel_path);
14768 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
14769 fs::write(&path, source).expect("write fixture file");
14770 path
14771 }
14772
14773 fn rust_files(root: &Path) -> Vec<PathBuf> {
14774 let mut files = Vec::new();
14775 collect_rust_files(root, &mut files);
14776 files.sort();
14777 files
14778 }
14779
14780 fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
14781 for entry in fs::read_dir(dir).expect("read fixture dir") {
14782 let entry = entry.expect("read fixture entry");
14783 let path = entry.path();
14784 if path.is_dir() {
14785 let name = path
14786 .file_name()
14787 .and_then(|name| name.to_str())
14788 .unwrap_or("");
14789 if !name.starts_with(".store") {
14790 collect_rust_files(&path, files);
14791 }
14792 } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
14793 files.push(path);
14794 }
14795 }
14796 }
14797}
14798
14799#[cfg(test)]
14800mod build_pool_tests {
14801 use super::build_pool_size;
14802
14803 #[test]
14804 fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
14805 let size = build_pool_size();
14806 assert!(size >= 1, "pool size must be at least 1");
14809 assert!(size <= 8, "pool size must be capped at 8, got {size}");
14810
14811 let cores = std::thread::available_parallelism()
14812 .map(|p| p.get())
14813 .unwrap_or(1);
14814 let expected = cores.div_ceil(2).clamp(1, 8);
14815 assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
14816 }
14817}
14818
14819#[cfg(test)]
14820mod reexport_resolution_tests {
14821 use super::*;
14822
14823 fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
14824 ProjectIndex {
14825 project_root: PathBuf::from("/fixture"),
14826 files: files.into_iter().collect(),
14827 caller_data: HashMap::new(),
14828 workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
14829 }
14830 }
14831
14832 fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
14833 DbFileIndex {
14834 lang: None,
14835 exports: HashSet::new(),
14836 default_export: None,
14837 export_aliases: HashMap::new(),
14838 node_by_scoped: HashMap::new(),
14839 node_by_bare: HashMap::new(),
14840 node_kind_by_id: HashMap::new(),
14841 module_targets: HashMap::new(),
14842 reexports: reexport_targets
14843 .iter()
14844 .map(|target| ReexportIndex {
14845 target_file: Some((*target).to_string()),
14846 named: HashMap::new(),
14847 wildcard: true,
14848 })
14849 .collect(),
14850 }
14851 }
14852
14853 #[test]
14860 fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
14861 let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
14862 let files = names
14863 .iter()
14864 .map(|name| {
14865 let targets: Vec<&str> = names
14866 .iter()
14867 .filter(|other| *other != name)
14868 .map(String::as_str)
14869 .collect();
14870 (name.clone(), barrel_file(&targets))
14871 })
14872 .collect();
14873 let index = barrel_index(files);
14874
14875 assert_eq!(
14876 resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
14877 None
14878 );
14879 }
14880
14881 #[test]
14887 fn shallow_revisit_after_deep_capped_visit_still_resolves() {
14888 let mut leaf = barrel_file(&[]);
14889 leaf.exports.insert("deep_symbol".to_string());
14890 let mut files: Vec<(String, DbFileIndex)> = Vec::new();
14891 files.push((
14894 "src/entry.ts".to_string(),
14895 barrel_file(&["src/chain0.ts", "src/shared.ts"]),
14896 ));
14897 for i in 0..15 {
14898 let next = if i == 14 {
14899 "src/shared.ts".to_string()
14900 } else {
14901 format!("src/chain{}.ts", i + 1)
14902 };
14903 files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
14904 }
14905 files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
14906 files.push(("src/leaf.ts".to_string(), leaf));
14907 let index = barrel_index(files);
14908
14909 assert_eq!(
14910 resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
14911 Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
14912 "a shallower re-visit must not be pruned by a deeper capped visit"
14913 );
14914 }
14915
14916 #[test]
14917 fn symbol_reachable_through_reexport_cycle_still_resolves() {
14918 let mut leaf = barrel_file(&[]);
14919 leaf.exports.insert("real_symbol".to_string());
14920 let index = barrel_index(vec![
14921 (
14922 "src/a.ts".to_string(),
14923 barrel_file(&["src/b.ts", "src/a.ts"]),
14924 ),
14925 (
14926 "src/b.ts".to_string(),
14927 barrel_file(&["src/a.ts", "src/leaf.ts"]),
14928 ),
14929 ("src/leaf.ts".to_string(), leaf),
14930 ]);
14931
14932 assert_eq!(
14933 resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
14934 Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
14935 );
14936 }
14937}
14938
14939#[cfg(test)]
14940mod method_dispatch_inference_tests {
14941 use super::*;
14942 use std::fs;
14943 use tempfile::tempdir;
14944
14945 #[test]
14946 fn java_field_receiver_type_selects_declared_class_method() {
14947 let source = r#"class EntryPoint {
14948 private UserService userService;
14949
14950 void handle() {
14951 userService.find();
14952 }
14953}
14954
14955class UserService {
14956 void find() {}
14957}
14958
14959class AuditService {
14960 void find() {}
14961}
14962"#;
14963 let dir = tempdir().expect("temp dir");
14964 let root = dir.path();
14965 write_fixture(root, "src/EntryPoint.java", source);
14966 let reference = reference(
14967 "java",
14968 "src/EntryPoint.java",
14969 "EntryPoint::handle",
14970 "userService",
14971 "find",
14972 line_of(source, "userService.find()"),
14973 );
14974 let mut cache = DispatchSourceCache::new();
14975
14976 let receiver_type =
14977 infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
14978 assert_eq!(receiver_type, "UserService");
14979
14980 let candidates = vec![
14981 method_candidate("audit", "AuditService::find"),
14982 method_candidate("user", "UserService::find"),
14983 ];
14984 let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
14985 .expect("type candidate");
14986 assert_eq!(selected.scoped_name, "UserService::find");
14987
14988 let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
14989 assert!(
14990 select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
14991 );
14992 }
14993
14994 #[test]
14995 fn kotlin_property_and_local_value_types_are_inferred() {
14996 let source = r#"class Handler {
14997 private val auditService: AuditService = AuditService()
14998
14999 fun handle() {
15000 auditService.find()
15001 val userService: UserService = UserService()
15002 userService.find()
15003 val billingService = BillingService()
15004 billingService.find()
15005 }
15006}
15007
15008class UserService { fun find() {} }
15009class AuditService { fun find() {} }
15010class BillingService { fun find() {} }
15011"#;
15012 let dir = tempdir().expect("temp dir");
15013 let root = dir.path();
15014 write_fixture(root, "src/Handler.kt", source);
15015 let mut cache = DispatchSourceCache::new();
15016
15017 let audit_ref = reference(
15018 "kotlin",
15019 "src/Handler.kt",
15020 "Handler::handle",
15021 "auditService",
15022 "find",
15023 line_of(source, "auditService.find()"),
15024 );
15025 assert_eq!(
15026 infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
15027 Some("AuditService")
15028 );
15029
15030 let user_ref = reference(
15031 "kotlin",
15032 "src/Handler.kt",
15033 "Handler::handle",
15034 "userService",
15035 "find",
15036 line_of(source, "userService.find()"),
15037 );
15038 assert_eq!(
15039 infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
15040 Some("UserService")
15041 );
15042
15043 let billing_ref = reference(
15044 "kotlin",
15045 "src/Handler.kt",
15046 "Handler::handle",
15047 "billingService",
15048 "find",
15049 line_of(source, "billingService.find()"),
15050 );
15051 assert_eq!(
15052 infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
15053 Some("BillingService")
15054 );
15055 }
15056
15057 #[test]
15058 fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
15059 let source = r#"struct Foo { void run(); };
15060struct PointerFoo { void run(); };
15061struct FactoryFoo { void run(); };
15062FactoryFoo makeFactoryFoo();
15063
15064void handle() {
15065 Foo foo;
15066 foo.run();
15067 PointerFoo* pointerFoo = nullptr;
15068 pointerFoo->run();
15069 auto factoryFoo = makeFactoryFoo();
15070 factoryFoo.run();
15071}
15072"#;
15073 let dir = tempdir().expect("temp dir");
15074 let root = dir.path();
15075 write_fixture(root, "src/fixture.cpp", source);
15076 let mut cache = DispatchSourceCache::new();
15077
15078 let foo_ref = reference(
15079 "cpp",
15080 "src/fixture.cpp",
15081 "handle",
15082 "foo",
15083 "run",
15084 line_of(source, "foo.run()"),
15085 );
15086 assert_eq!(
15087 infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
15088 Some("Foo")
15089 );
15090
15091 let pointer_ref = reference(
15092 "cpp",
15093 "src/fixture.cpp",
15094 "handle",
15095 "pointerFoo",
15096 "run",
15097 line_of(source, "pointerFoo->run()"),
15098 );
15099 assert_eq!(
15100 infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
15101 Some("PointerFoo")
15102 );
15103
15104 let factory_ref = reference(
15105 "cpp",
15106 "src/fixture.cpp",
15107 "handle",
15108 "factoryFoo",
15109 "run",
15110 line_of(source, "factoryFoo.run()"),
15111 );
15112 assert_eq!(
15113 infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
15114 Some("FactoryFoo")
15115 );
15116 }
15117
15118 #[test]
15119 fn rust_direct_self_field_name_trims_separator_whitespace() {
15120 for receiver_expression in ["self .engine", "self. engine", "self . engine"] {
15121 assert_eq!(
15122 rust_direct_self_field_name(receiver_expression),
15123 Some("engine")
15124 );
15125 }
15126 }
15127
15128 #[test]
15129 fn rust_direct_self_field_receiver_type_is_conservative() {
15130 let source = r#"struct Engine;
15131
15132struct Car {
15133 engine: Engine,
15134}
15135
15136impl Car {
15137 fn run(&self) {
15138 self.engine.start();
15139 }
15140}
15141
15142struct NestedCar {
15143 engine: Engine,
15144}
15145
15146impl NestedCar {
15147 fn run(&self) {
15148 self.inner.engine.start();
15149 }
15150}
15151
15152struct WrappedCar {
15153 engine: Option<Engine>,
15154}
15155
15156impl WrappedCar {
15157 fn run(&self) {
15158 self.engine.start(); // wrapped
15159 }
15160}
15161
15162struct GenericCar<T> {
15163 engine: T,
15164}
15165
15166impl<T> GenericCar<T> {
15167 fn run(&self) {
15168 self.engine.start(); // generic
15169 }
15170}
15171
15172type EngineAlias = Engine;
15173
15174struct AliasCar {
15175 engine: EngineAlias,
15176}
15177
15178impl AliasCar {
15179 fn run(&self) {
15180 self.engine.start(); // alias
15181 }
15182}
15183"#;
15184 let dir = tempdir().expect("temp dir");
15185 let root = dir.path();
15186 write_fixture(root, "src/lib.rs", source);
15187 let mut cache = DispatchSourceCache::new();
15188
15189 let mut direct = reference(
15190 "rust",
15191 "src/lib.rs",
15192 "Car::run",
15193 "engine",
15194 "start",
15195 line_of(source, "self.engine.start()"),
15196 );
15197 direct.receiver_expression = "self.engine".to_string();
15198 assert_eq!(
15199 infer_receiver_type(root, &direct, &mut cache).as_deref(),
15200 Some("Engine")
15201 );
15202
15203 let mut mismatched_impl_target = direct.clone();
15204 mismatched_impl_target.caller_symbol = "other::Car::run".to_string();
15205 assert!(infer_receiver_type(root, &mismatched_impl_target, &mut cache).is_none());
15206
15207 let mut nested = reference(
15208 "rust",
15209 "src/lib.rs",
15210 "NestedCar::run",
15211 "engine",
15212 "start",
15213 line_of(source, "self.inner.engine.start()"),
15214 );
15215 nested.receiver_expression = "self.inner.engine".to_string();
15216 assert!(infer_receiver_type(root, &nested, &mut cache).is_none());
15217
15218 let mut wrapped = reference(
15219 "rust",
15220 "src/lib.rs",
15221 "WrappedCar::run",
15222 "engine",
15223 "start",
15224 line_of(source, "self.engine.start(); // wrapped"),
15225 );
15226 wrapped.receiver_expression = "self.engine".to_string();
15227 assert!(infer_receiver_type(root, &wrapped, &mut cache).is_none());
15228
15229 let mut generic = reference(
15230 "rust",
15231 "src/lib.rs",
15232 "GenericCar::run",
15233 "engine",
15234 "start",
15235 line_of(source, "self.engine.start(); // generic"),
15236 );
15237 generic.receiver_expression = "self.engine".to_string();
15238 assert!(infer_receiver_type(root, &generic, &mut cache).is_none());
15239
15240 let mut alias = reference(
15241 "rust",
15242 "src/lib.rs",
15243 "AliasCar::run",
15244 "engine",
15245 "start",
15246 line_of(source, "self.engine.start(); // alias"),
15247 );
15248 alias.receiver_expression = "self.engine".to_string();
15249 assert!(infer_receiver_type(root, &alias, &mut cache).is_none());
15250 }
15251
15252 #[test]
15253 fn rust_direct_self_reference_field_receiver_is_not_inferred() {
15254 let source = r#"struct Engine;
15255
15256struct Car {
15257 engine: &'static Engine,
15258}
15259
15260impl Car {
15261 fn run(&self) {
15262 self.engine.start();
15263 }
15264}
15265"#;
15266 let dir = tempdir().expect("temp dir");
15267 let root = dir.path();
15268 write_fixture(root, "src/lib.rs", source);
15269 let mut cache = DispatchSourceCache::new();
15270 let mut reference = reference(
15271 "rust",
15272 "src/lib.rs",
15273 "Car::run",
15274 "engine",
15275 "start",
15276 line_of(source, "self.engine.start()"),
15277 );
15278 reference.receiver_expression = "self.engine".to_string();
15279
15280 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15281 }
15282
15283 #[test]
15284 fn rust_trait_impl_self_field_receiver_is_not_inferred() {
15285 let source = r#"trait Drive {
15286 fn run(&self);
15287}
15288
15289struct Engine;
15290
15291struct Car {
15292 engine: Engine,
15293}
15294
15295impl Drive for Car {
15296 fn run(&self) {
15297 self.engine.start();
15298 }
15299}
15300"#;
15301 let dir = tempdir().expect("temp dir");
15302 let root = dir.path();
15303 write_fixture(root, "src/lib.rs", source);
15304 let mut cache = DispatchSourceCache::new();
15305 let mut reference = reference(
15306 "rust",
15307 "src/lib.rs",
15308 "Car::run",
15309 "engine",
15310 "start",
15311 line_of(source, "self.engine.start()"),
15312 );
15313 reference.receiver_expression = "self.engine".to_string();
15314
15315 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15316 }
15317
15318 #[test]
15319 fn rust_self_field_does_not_bind_struct_from_another_module() {
15320 let source = r#"struct Engine;
15321
15322mod unrelated {
15323 struct Car {
15324 engine: Engine,
15325 }
15326}
15327
15328impl Car {
15329 fn run(&self) {
15330 self.engine.start();
15331 }
15332}
15333"#;
15334 let dir = tempdir().expect("temp dir");
15335 let root = dir.path();
15336 write_fixture(root, "src/lib.rs", source);
15337 let mut cache = DispatchSourceCache::new();
15338 let mut reference = reference(
15339 "rust",
15340 "src/lib.rs",
15341 "Car::run",
15342 "engine",
15343 "start",
15344 line_of(source, "self.engine.start()"),
15345 );
15346 reference.receiver_expression = "self.engine".to_string();
15347
15348 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15349 }
15350
15351 #[test]
15352 fn unknown_java_receiver_still_uses_name_match_fallback() {
15353 let source = r#"class EntryPoint {
15354 void handle() {
15355 service.runSpecial();
15356 }
15357}
15358
15359class OnlyService {
15360 void runSpecial() {}
15361}
15362"#;
15363 let dir = tempdir().expect("temp dir");
15364 let root = dir.path();
15365 write_fixture(root, "src/EntryPoint.java", source);
15366 let reference = reference(
15367 "java",
15368 "src/EntryPoint.java",
15369 "EntryPoint::handle",
15370 "service",
15371 "runSpecial",
15372 line_of(source, "service.runSpecial()"),
15373 );
15374 let mut cache = DispatchSourceCache::new();
15375
15376 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15377 let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
15378 let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
15379 assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
15380 }
15381
15382 fn reference(
15383 lang: &str,
15384 caller_file: &str,
15385 caller_symbol: &str,
15386 receiver: &str,
15387 method_name: &str,
15388 line: u32,
15389 ) -> NameMatchRef {
15390 NameMatchRef {
15391 ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
15392 caller_node: format!("{caller_symbol}:node"),
15393 caller_file: caller_file.to_string(),
15394 caller_symbol: caller_symbol.to_string(),
15395 caller_signature: None,
15396 receiver_expression: receiver.to_string(),
15397 receiver: receiver.to_string(),
15398 method_name: method_name.to_string(),
15399 colon_dispatch: false,
15400 line,
15401 lang: lang.to_string(),
15402 }
15403 }
15404
15405 fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
15406 NameMatchCandidate {
15407 node_id: node_id.to_string(),
15408 file_path: "src/targets.fixture".to_string(),
15409 scoped_name: scoped_name.to_string(),
15410 kind: "method".to_string(),
15411 start_line: 1,
15412 }
15413 }
15414
15415 fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
15416 let path = root.join(rel_path);
15417 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
15418 fs::write(path, source).expect("write fixture");
15419 }
15420
15421 fn line_of(source: &str, needle: &str) -> u32 {
15422 source
15423 .lines()
15424 .position(|line| line.contains(needle))
15425 .map(|index| index as u32 + 1)
15426 .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
15427 }
15428}