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 let total_changes_before = conn.total_changes();
3001 let tx = conn.transaction()?;
3002 ensure_database_ready(&tx)?;
3003 let mut changed = Vec::new();
3004 let mut surface_changed = BTreeSet::new();
3005 let mut deleted = BTreeSet::new();
3006 let mut own_refresh = BTreeSet::new();
3007 let mut candidate_own_refresh = BTreeSet::new();
3008 let mut unchanged_extracts = 0usize;
3009 let mut selected_ref_ids = BTreeSet::new();
3010 let mut selected_refs_by_caller = BTreeMap::new();
3011 let mut changed_extracts: HashMap<String, FileExtract> = HashMap::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(&tx, &rel_path)?;
3018 if !abs_path.exists() {
3019 if old_row.is_some() {
3020 surface_changed.insert(rel_path.clone());
3021 deleted.insert(rel_path.clone());
3022 let started = Instant::now();
3023 let dependent_refs = ref_ids_depending_on(&tx, &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 let started = Instant::now();
3031 delete_file_rows(&tx, &rel_path)?;
3032 clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
3033 profile.row_deletes += started.elapsed();
3034 }
3035 continue;
3036 }
3037
3038 if let Some(row) = &old_row {
3039 match cache_freshness::verify_file(&abs_path, &row.freshness) {
3040 FreshnessVerdict::HotFresh => continue,
3041 FreshnessVerdict::ContentFresh {
3042 new_mtime,
3043 new_size,
3044 } => {
3045 update_file_fresh_metadata(
3046 &tx,
3047 &self.project_root,
3048 &rel_path,
3049 &row.freshness.content_hash,
3050 new_mtime,
3051 new_size,
3052 )?;
3053 continue;
3054 }
3055 FreshnessVerdict::Deleted => {
3056 surface_changed.insert(rel_path.clone());
3057 deleted.insert(rel_path.clone());
3058 let started = Instant::now();
3059 let dependent_refs =
3060 ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
3061 profile.dependency_selection += started.elapsed();
3062 record_dependent_refs(
3063 &mut selected_ref_ids,
3064 &mut selected_refs_by_caller,
3065 dependent_refs,
3066 );
3067 let started = Instant::now();
3068 delete_file_rows(&tx, &rel_path)?;
3069 clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
3070 profile.row_deletes += started.elapsed();
3071 continue;
3072 }
3073 FreshnessVerdict::Stale => {}
3074 }
3075 }
3076
3077 let started = Instant::now();
3078 let extract = build_file_extract(&self.project_root, &abs_path)?;
3079 profile.parse += started.elapsed();
3080 let surface_is_changed = old_row
3081 .as_ref()
3082 .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
3083 .unwrap_or(true);
3084 if surface_is_changed {
3085 surface_changed.insert(rel_path.clone());
3086 let started = Instant::now();
3087 let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
3088 profile.dependency_selection += started.elapsed();
3089 record_dependent_refs(
3090 &mut selected_ref_ids,
3091 &mut selected_refs_by_caller,
3092 dependent_refs,
3093 );
3094 }
3095 candidate_own_refresh.insert(rel_path.clone());
3096 changed_extracts.insert(rel_path, extract);
3097 }
3098
3099 let dependency_selected_refs = selected_ref_ids.len();
3100 let mut touched_callers: BTreeSet<String> =
3101 selected_refs_by_caller.keys().cloned().collect();
3102 touched_callers.extend(candidate_own_refresh.iter().cloned());
3103
3104 let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
3105 for rel_path in &touched_callers {
3106 if deleted.contains(rel_path) {
3107 continue;
3108 }
3109 if let Some(extract) = changed_extracts.get(rel_path) {
3110 caller_extracts.insert(rel_path.clone(), extract.clone());
3111 continue;
3112 }
3113 let abs_path = self.project_root.join(rel_path);
3114 if abs_path.exists() {
3115 let started = Instant::now();
3116 let extract = build_file_extract(&self.project_root, &abs_path)?;
3117 profile.dependent_parse += started.elapsed();
3118 caller_extracts.insert(rel_path.clone(), extract);
3119 }
3120 }
3121
3122 let started = Instant::now();
3123 let index = ProjectIndex::from_db_and_callers(
3124 &tx,
3125 &self.project_root,
3126 &caller_extracts,
3127 workspace_crate_prefixes,
3128 )?;
3129 profile.index_load += started.elapsed();
3130
3131 for rel_path in &candidate_own_refresh {
3132 let Some(extract) = changed_extracts.get(rel_path) else {
3133 continue;
3134 };
3135 if !write_amplification_baseline_enabled()
3136 && stored_extract_matches(&tx, rel_path, extract, &index)?
3137 {
3138 unchanged_extracts += 1;
3139 update_file_fresh_metadata(
3140 &tx,
3141 &self.project_root,
3142 rel_path,
3143 &extract.freshness.content_hash,
3144 extract.freshness.mtime,
3145 extract.freshness.size,
3146 )?;
3147 continue;
3148 }
3149
3150 own_refresh.insert(rel_path.clone());
3151 let started = Instant::now();
3152 delete_file_rows(&tx, rel_path)?;
3153 profile.row_deletes += started.elapsed();
3154 let started = Instant::now();
3155 insert_file_extract(&tx, &self.project_root, extract)?;
3156 profile.row_inserts += started.elapsed();
3157 }
3158
3159 let dependency_callers = touched_callers
3160 .iter()
3161 .filter(|rel_path| {
3162 !deleted.contains(*rel_path) && !candidate_own_refresh.contains(*rel_path)
3163 })
3164 .cloned()
3165 .collect::<Vec<_>>();
3166 for rel_path in dependency_callers {
3167 let Some(extract) = caller_extracts.get(&rel_path) else {
3168 continue;
3169 };
3170 if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
3171 continue;
3172 }
3173
3174 own_refresh.insert(rel_path.clone());
3175 let started = Instant::now();
3176 delete_file_rows(&tx, &rel_path)?;
3177 profile.row_deletes += started.elapsed();
3178 let started = Instant::now();
3179 insert_file_extract(&tx, &self.project_root, extract)?;
3180 profile.row_inserts += started.elapsed();
3181 }
3182 let started = Instant::now();
3183 for rel_path in &touched_callers {
3184 if deleted.contains(rel_path) {
3185 continue;
3186 }
3187 let Some(extract) = caller_extracts.get(rel_path) else {
3188 continue;
3189 };
3190 if own_refresh.contains(rel_path) {
3191 delete_refs_for_caller(&tx, rel_path)?;
3192 for raw_ref in &extract.raw_refs {
3193 let resolved = resolve_ref(raw_ref.clone(), &index)?;
3194 insert_resolved_ref(&tx, &resolved)?;
3195 }
3196 continue;
3197 }
3198
3199 let selected_for_caller = selected_refs_by_caller
3200 .get(rel_path)
3201 .cloned()
3202 .unwrap_or_default();
3203 delete_ref_ids(&tx, &selected_for_caller)?;
3204 for raw_ref in &extract.raw_refs {
3205 if selected_for_caller.contains(&raw_ref.ref_id) {
3206 let resolved = resolve_ref(raw_ref.clone(), &index)?;
3207 insert_resolved_ref(&tx, &resolved)?;
3208 }
3209 }
3210 }
3211 profile.ref_resolution += started.elapsed();
3212
3213 let started = Instant::now();
3214 delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
3215 insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
3216 profile.method_dispatch += started.elapsed();
3217
3218 bump_projection_write_revision(&tx)?;
3219 let started = Instant::now();
3220 commit_incremental_if_current(tx)?;
3221 self.record_commit(total_changes_before, &conn);
3222 profile.commit += started.elapsed();
3223 profile.total = total_started.elapsed();
3224 Ok((
3225 IncrementalStats {
3226 changed_files: changed,
3227 surface_changed: surface_changed.into_iter().collect(),
3228 deleted_files: deleted.into_iter().collect(),
3229 dependency_selected_refs,
3230 refreshed_own_files: own_refresh.len(),
3231 unchanged_extract_files: unchanged_extracts,
3232 },
3233 profile,
3234 ))
3235 }
3236
3237 pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
3238 self.cold_build(current_files)
3239 }
3240
3241 pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
3242 self.verify_writer_lease()?;
3243 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3244 let total_changes_before = conn.total_changes();
3245 let tx = conn.transaction()?;
3246 let mut marked = Vec::new();
3247 for path in files {
3248 let abs_path = normalize_file_path(&self.project_root, path)?;
3249 let rel_path = relative_path(&self.project_root, &abs_path);
3250 let freshness = cache_freshness::collect(&abs_path).ok();
3251 mark_backend_state(
3252 &tx,
3253 &self.project_root,
3254 &rel_path,
3255 freshness.as_ref().map(|freshness| &freshness.content_hash),
3256 "stale",
3257 )?;
3258 marked.push(rel_path);
3259 }
3260 bump_projection_write_revision(&tx)?;
3261 tx.commit()?;
3262 self.record_commit(total_changes_before, &conn);
3263 marked.sort();
3264 marked.dedup();
3265 Ok(marked)
3266 }
3267
3268 pub fn stale_files(&self) -> Result<Vec<String>> {
3269 self.refresh_read_marker()?;
3270 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3271 let mut stmt = conn.prepare(
3272 "SELECT DISTINCT file_path FROM backend_file_state
3273 WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
3274 ORDER BY file_path",
3275 )?;
3276 let rows = stmt.query_map(
3277 params![BACKEND_TREESITTER, self.project_root.display().to_string()],
3278 |row| row.get::<_, String>(0),
3279 )?;
3280 rows.collect::<std::result::Result<Vec<_>, _>>()
3281 .map_err(Into::into)
3282 }
3283
3284 pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
3285 self.refresh_read_marker()?;
3286 let rel_path = relative_path(
3287 &self.project_root,
3288 &normalize_file_path(&self.project_root, file)?,
3289 );
3290 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3291 conn.query_row(
3292 "SELECT status FROM backend_file_state
3293 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
3294 ORDER BY updated_at DESC LIMIT 1",
3295 params![
3296 BACKEND_TREESITTER,
3297 self.project_root.display().to_string(),
3298 rel_path
3299 ],
3300 |row| row.get(0),
3301 )
3302 .optional()
3303 .map_err(Into::into)
3304 }
3305
3306 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3307 self.refresh_read_marker()?;
3308 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3309 self.ensure_ready(&conn)?;
3310 edge_snapshot_with_conn(&conn)
3311 }
3312
3313 pub fn indexed_file_count(&self) -> Result<usize> {
3314 self.refresh_read_marker()?;
3315 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3316 self.ensure_ready(&conn)?;
3317 indexed_file_count(&conn)
3318 }
3319
3320 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3321 self.refresh_read_marker()?;
3322 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3323 let rel_path = relative_path(&self.project_root, &abs_path);
3324 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3325 self.ensure_ready(&conn)?;
3326 resolve_node_for_rel(&conn, &rel_path, symbol)
3327 }
3328
3329 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3334 self.refresh_read_marker()?;
3335 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3336 let rel_path = relative_path(&self.project_root, &abs_path);
3337 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3338 self.ensure_ready(&conn)?;
3339 nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
3340 }
3341
3342 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3344 self.refresh_read_marker()?;
3345 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3346 self.ensure_ready(&conn)?;
3347 nodes_matching_symbol(&conn, symbol)
3348 }
3349
3350 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3352 self.refresh_read_marker()?;
3353 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3354 let rel_path = relative_path(&self.project_root, &abs_path);
3355 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3356 self.ensure_ready(&conn)?;
3357 direct_callers_for_tuple(&conn, &rel_path, symbol)
3358 }
3359
3360 pub fn direct_callers_for_symbols(
3362 &self,
3363 targets: &[(String, String)],
3364 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3365 if targets.is_empty() {
3366 return Ok(HashMap::new());
3367 }
3368 self.refresh_read_marker()?;
3369 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3370 self.ensure_ready(&conn)?;
3371 direct_callers_for_tuples(&conn, targets)
3372 }
3373
3374 pub fn direct_caller_counts_of(
3376 &self,
3377 targets: &[(String, String)],
3378 ) -> Result<HashMap<(String, String), usize>> {
3379 if targets.is_empty() {
3380 return Ok(HashMap::new());
3381 }
3382 self.refresh_read_marker()?;
3383 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3384 self.ensure_ready(&conn)?;
3385 direct_caller_counts_for_tuples(&conn, targets)
3386 }
3387
3388 pub fn callers_of(
3389 &self,
3390 file_rel: &Path,
3391 symbol: &str,
3392 depth: usize,
3393 ) -> Result<StoreCallersResult> {
3394 let target = self.node_for(file_rel, symbol)?;
3395 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3396 self.ensure_ready(&conn)?;
3397 let effective_depth = depth.max(1);
3398 let mut visited = HashSet::new();
3399 let mut callers = Vec::new();
3400 let mut depth_limited = false;
3401 let mut truncated = 0usize;
3402 collect_callers_recursive(
3403 &conn,
3404 &target.file,
3405 &target.symbol,
3406 effective_depth,
3407 0,
3408 &mut visited,
3409 &mut callers,
3410 &mut depth_limited,
3411 &mut truncated,
3412 )?;
3413 Ok(StoreCallersResult {
3414 target,
3415 callers,
3416 scanned_files: indexed_file_count(&conn)?,
3417 depth_limited,
3418 truncated,
3419 })
3420 }
3421
3422 pub fn impact_of(
3423 &self,
3424 file_rel: &Path,
3425 symbol: &str,
3426 depth: usize,
3427 ) -> Result<StoreImpactResult> {
3428 let callers = self.callers_of(file_rel, symbol, depth)?;
3429 let target_parameters = callers
3430 .target
3431 .signature
3432 .as_deref()
3433 .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
3434 .unwrap_or_default();
3435 let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
3436 for site in &callers.callers {
3437 source_lines_by_file
3438 .entry(site.caller.file.clone())
3439 .or_insert_with(|| {
3440 read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
3441 });
3442 }
3443 let enriched = callers
3444 .callers
3445 .iter()
3446 .map(|site| StoreImpactCaller {
3447 site: site.clone(),
3448 signature: site.caller.signature.clone(),
3449 is_entry_point: site.caller.is_entry_point,
3450 call_expression: source_lines_by_file
3451 .get(&site.caller.file)
3452 .and_then(|lines| lines.as_ref())
3453 .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
3454 .cloned(),
3455 parameters: site
3456 .caller
3457 .signature
3458 .as_deref()
3459 .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
3460 .unwrap_or_default(),
3461 })
3462 .collect();
3463 Ok(StoreImpactResult {
3464 target: callers.target,
3465 parameters: target_parameters,
3466 callers: enriched,
3467 depth_limited: callers.depth_limited,
3468 truncated: callers.truncated,
3469 })
3470 }
3471
3472 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3473 self.refresh_read_marker()?;
3474 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3475 self.ensure_ready(&conn)?;
3476 outgoing_calls_for_node(&conn, node)
3477 }
3478
3479 pub fn outgoing_calls_for_symbols(
3481 &self,
3482 sources: &[(String, String)],
3483 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3484 if sources.is_empty() {
3485 return Ok(HashMap::new());
3486 }
3487 self.refresh_read_marker()?;
3488 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3489 self.ensure_ready(&conn)?;
3490 outgoing_calls_for_symbol_tuples(&conn, sources)
3491 }
3492
3493 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3495 self.refresh_read_marker()?;
3496 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3497 self.ensure_ready(&conn)?;
3498 resolved_self_calls_for_node(&conn, node)
3499 }
3500
3501 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3502 self.refresh_read_marker()?;
3503 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3504 self.ensure_ready(&conn)?;
3505 unresolved_calls_for_node(&conn, node)
3506 }
3507
3508 pub fn call_tree(
3509 &self,
3510 file_rel: &Path,
3511 symbol: &str,
3512 max_depth: usize,
3513 ) -> Result<callgraph::CallTreeNode> {
3514 let node = self.node_for(file_rel, symbol)?;
3515 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3516 self.ensure_ready(&conn)?;
3517 let mut visited = HashSet::new();
3518 call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
3519 }
3520
3521 pub fn trace_to(
3522 &self,
3523 file_rel: &Path,
3524 symbol: &str,
3525 max_depth: usize,
3526 ) -> Result<callgraph::TraceToResult> {
3527 let target = self.node_for(file_rel, symbol)?;
3528 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3529 self.ensure_ready(&conn)?;
3530 let effective_max = if max_depth == 0 { 10 } else { max_depth };
3531
3532 #[derive(Clone)]
3533 struct PathElem {
3534 node: StoreNode,
3535 }
3536
3537 let initial = vec![PathElem {
3538 node: target.clone(),
3539 }];
3540 let mut complete_paths = Vec::new();
3541 if target.is_entry_point {
3542 complete_paths.push(initial.clone());
3543 }
3544
3545 let mut queue = vec![(initial, 0usize)];
3546 let mut max_depth_reached = false;
3547 let mut truncated_paths = 0usize;
3548
3549 while let Some((path, depth)) = queue.pop() {
3550 if depth >= effective_max {
3551 max_depth_reached = true;
3552 continue;
3553 }
3554 let Some(current) = path.last() else {
3555 continue;
3556 };
3557 let callers =
3558 direct_callers_for_tuple(&conn, ¤t.node.file, ¤t.node.symbol)?;
3559 if callers.is_empty() {
3560 if path.len() > 1 {
3561 truncated_paths += 1;
3562 }
3563 continue;
3564 }
3565
3566 let mut has_new_path = false;
3567 for site in callers {
3568 if path.iter().any(|elem| {
3569 elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
3570 }) {
3571 continue;
3572 }
3573 has_new_path = true;
3574 let mut new_path = path.clone();
3575 new_path.push(PathElem {
3576 node: site.caller.clone(),
3577 });
3578 if site.caller.is_entry_point {
3579 complete_paths.push(new_path.clone());
3580 }
3581 queue.push((new_path, depth + 1));
3582 }
3583 if !has_new_path && path.len() > 1 {
3584 truncated_paths += 1;
3585 }
3586 }
3587
3588 let mut paths: Vec<callgraph::TracePath> = complete_paths
3589 .into_iter()
3590 .map(|mut elems| {
3591 elems.reverse();
3592 let hops = elems
3593 .iter()
3594 .enumerate()
3595 .map(|(index, elem)| callgraph::TraceHop {
3596 symbol: elem.node.symbol.clone(),
3597 file: elem.node.file.clone(),
3598 line: elem.node.line,
3599 signature: elem.node.signature.clone(),
3600 is_entry_point: index == 0 && elem.node.is_entry_point,
3601 })
3602 .collect();
3603 callgraph::TracePath { hops }
3604 })
3605 .collect();
3606 paths.sort_by(|left, right| {
3607 let left_entry = left
3608 .hops
3609 .first()
3610 .map(|hop| hop.symbol.as_str())
3611 .unwrap_or("");
3612 let right_entry = right
3613 .hops
3614 .first()
3615 .map(|hop| hop.symbol.as_str())
3616 .unwrap_or("");
3617 left_entry
3618 .cmp(right_entry)
3619 .then(left.hops.len().cmp(&right.hops.len()))
3620 });
3621 let entry_points_found = paths
3622 .iter()
3623 .filter_map(|path| path.hops.first())
3624 .filter(|hop| hop.is_entry_point)
3625 .map(|hop| (hop.file.clone(), hop.symbol.clone()))
3626 .collect::<HashSet<_>>()
3627 .len();
3628
3629 Ok(callgraph::TraceToResult {
3630 target_symbol: target.symbol,
3631 target_file: target.file,
3632 total_paths: paths.len(),
3633 paths,
3634 entry_points_found,
3635 max_depth_reached,
3636 truncated_paths,
3637 })
3638 }
3639
3640 pub fn trace_to_symbol_candidates(
3641 &self,
3642 to_symbol: &str,
3643 ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
3644 self.refresh_read_marker()?;
3645 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3646 self.ensure_ready(&conn)?;
3647 let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
3648 for node in nodes_matching_symbol(&conn, to_symbol)? {
3649 candidates_by_file
3650 .entry(node.file)
3651 .and_modify(|line| *line = (*line).min(node.line))
3652 .or_insert(node.line);
3653 }
3654 let mut candidates: Vec<_> = candidates_by_file
3655 .into_iter()
3656 .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
3657 .collect();
3658 candidates
3659 .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
3660 Ok(candidates)
3661 }
3662
3663 pub fn trace_to_symbol(
3664 &self,
3665 file_rel: &Path,
3666 symbol: &str,
3667 to_symbol: &str,
3668 to_file: Option<&Path>,
3669 max_depth: usize,
3670 ) -> Result<callgraph::TraceToSymbolResult> {
3671 let origin = self.node_for(file_rel, symbol)?;
3672 let target_file = to_file
3673 .map(|path| normalize_file_path(&self.project_root, path))
3674 .transpose()?
3675 .map(|path| relative_path(&self.project_root, &path));
3676 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3677 self.ensure_ready(&conn)?;
3678 let effective_max = if max_depth == 0 {
3679 10
3680 } else {
3681 max_depth.min(16)
3682 };
3683
3684 let start_hop = trace_to_symbol_hop(&origin);
3685 if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
3686 return Ok(callgraph::TraceToSymbolResult {
3687 path: Some(vec![start_hop]),
3688 complete: true,
3689 reason: None,
3690 });
3691 }
3692
3693 let mut queue = VecDeque::new();
3694 queue.push_back((origin.clone(), vec![start_hop], 0usize));
3695 let mut visited = HashSet::new();
3696 visited.insert((origin.file.clone(), origin.symbol.clone()));
3697 let mut max_depth_exhausted = false;
3698
3699 while let Some((current, path, depth)) = queue.pop_front() {
3700 let callees = outgoing_calls_for_node(&conn, ¤t)?
3701 .into_iter()
3702 .filter_map(|site| site.target)
3703 .collect::<Vec<_>>();
3704
3705 if depth >= effective_max {
3706 if callees
3707 .iter()
3708 .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
3709 {
3710 max_depth_exhausted = true;
3711 }
3712 continue;
3713 }
3714
3715 for callee in callees {
3716 if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
3717 continue;
3718 }
3719 let mut next_path = path.clone();
3720 next_path.push(trace_to_symbol_hop(&callee));
3721 if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
3722 return Ok(callgraph::TraceToSymbolResult {
3723 path: Some(next_path),
3724 complete: true,
3725 reason: None,
3726 });
3727 }
3728 queue.push_back((callee, next_path, depth + 1));
3729 }
3730 }
3731
3732 if max_depth_exhausted {
3733 Ok(callgraph::TraceToSymbolResult {
3734 path: None,
3735 complete: false,
3736 reason: Some("max_depth_exhausted".to_string()),
3737 })
3738 } else {
3739 Ok(callgraph::TraceToSymbolResult {
3740 path: None,
3741 complete: true,
3742 reason: Some("no_path_found".to_string()),
3743 })
3744 }
3745 }
3746}
3747
3748impl ReadonlyCallGraphStore {
3749 fn from_inner(inner: CallGraphStore) -> Self {
3750 Self { inner }
3751 }
3752
3753 pub fn project_root(&self) -> &Path {
3754 self.inner.project_root()
3755 }
3756
3757 pub fn project_key(&self) -> &str {
3758 self.inner.project_key()
3759 }
3760
3761 pub fn sqlite_path(&self) -> &Path {
3762 self.inner.sqlite_path()
3763 }
3764
3765 pub(crate) fn projection_generation(&self) -> Option<&str> {
3766 self.inner.projection_generation()
3767 }
3768
3769 pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
3770 self.inner.projection_write_revision()
3771 }
3772
3773 pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
3776 crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
3777 }
3778
3779 pub fn is_legacy_fallback(&self) -> bool {
3781 self.inner.is_legacy_fallback()
3782 }
3783
3784 pub fn is_current(&self) -> bool {
3785 self.inner.is_current()
3786 }
3787
3788 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3789 self.inner.edge_snapshot()
3790 }
3791
3792 pub fn indexed_file_count(&self) -> Result<usize> {
3793 self.inner.indexed_file_count()
3794 }
3795
3796 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3797 self.inner.node_for(file_rel, symbol)
3798 }
3799
3800 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3801 self.inner.nodes_for(file_rel, symbol)
3802 }
3803
3804 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3805 self.inner.nodes_matching(symbol)
3806 }
3807
3808 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3809 self.inner.direct_callers_of(file_rel, symbol)
3810 }
3811
3812 pub fn direct_callers_for_symbols(
3813 &self,
3814 targets: &[(String, String)],
3815 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3816 self.inner.direct_callers_for_symbols(targets)
3817 }
3818
3819 pub fn direct_caller_counts_of(
3820 &self,
3821 targets: &[(String, String)],
3822 ) -> Result<HashMap<(String, String), usize>> {
3823 self.inner.direct_caller_counts_of(targets)
3824 }
3825
3826 pub fn callers_of(
3827 &self,
3828 file_rel: &Path,
3829 symbol: &str,
3830 depth: usize,
3831 ) -> Result<StoreCallersResult> {
3832 self.inner.callers_of(file_rel, symbol, depth)
3833 }
3834
3835 pub fn impact_of(
3836 &self,
3837 file_rel: &Path,
3838 symbol: &str,
3839 depth: usize,
3840 ) -> Result<StoreImpactResult> {
3841 self.inner.impact_of(file_rel, symbol, depth)
3842 }
3843
3844 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3845 self.inner.outgoing_calls_of(node)
3846 }
3847
3848 pub fn outgoing_calls_for_symbols(
3849 &self,
3850 sources: &[(String, String)],
3851 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3852 self.inner.outgoing_calls_for_symbols(sources)
3853 }
3854
3855 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3856 self.inner.resolved_self_calls_of(node)
3857 }
3858
3859 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3860 self.inner.unresolved_calls_of(node)
3861 }
3862
3863 pub fn call_tree(
3864 &self,
3865 file_rel: &Path,
3866 symbol: &str,
3867 depth: usize,
3868 ) -> Result<callgraph::CallTreeNode> {
3869 self.inner.call_tree(file_rel, symbol, depth)
3870 }
3871
3872 pub fn trace_to(
3873 &self,
3874 file_rel: &Path,
3875 symbol: &str,
3876 max_depth: usize,
3877 ) -> Result<callgraph::TraceToResult> {
3878 self.inner.trace_to(file_rel, symbol, max_depth)
3879 }
3880
3881 pub fn trace_to_symbol_candidates(
3882 &self,
3883 to_symbol: &str,
3884 ) -> Result<Vec<TraceToSymbolCandidate>> {
3885 self.inner.trace_to_symbol_candidates(to_symbol)
3886 }
3887
3888 pub fn trace_to_symbol(
3889 &self,
3890 file_rel: &Path,
3891 symbol: &str,
3892 to_symbol: &str,
3893 to_file: Option<&Path>,
3894 max_depth: usize,
3895 ) -> Result<callgraph::TraceToSymbolResult> {
3896 self.inner
3897 .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3898 }
3899}
3900
3901impl CallGraphRead for CallGraphStore {
3902 fn project_root(&self) -> &Path {
3903 CallGraphStore::project_root(self)
3904 }
3905 fn project_key(&self) -> &str {
3906 CallGraphStore::project_key(self)
3907 }
3908 fn sqlite_path(&self) -> &Path {
3909 CallGraphStore::sqlite_path(self)
3910 }
3911 fn is_current(&self) -> bool {
3912 CallGraphStore::is_current(self)
3913 }
3914 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3915 CallGraphStore::edge_snapshot(self)
3916 }
3917 fn indexed_file_count(&self) -> Result<usize> {
3918 CallGraphStore::indexed_file_count(self)
3919 }
3920 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3921 CallGraphStore::node_for(self, file_rel, symbol)
3922 }
3923 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3924 CallGraphStore::nodes_for(self, file_rel, symbol)
3925 }
3926 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3927 CallGraphStore::nodes_matching(self, symbol)
3928 }
3929 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3930 CallGraphStore::direct_callers_of(self, file_rel, symbol)
3931 }
3932 fn direct_callers_for_symbols(
3933 &self,
3934 targets: &[(String, String)],
3935 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3936 CallGraphStore::direct_callers_for_symbols(self, targets)
3937 }
3938 fn direct_caller_counts_of(
3939 &self,
3940 targets: &[(String, String)],
3941 ) -> Result<HashMap<(String, String), usize>> {
3942 CallGraphStore::direct_caller_counts_of(self, targets)
3943 }
3944 fn callers_of(
3945 &self,
3946 file_rel: &Path,
3947 symbol: &str,
3948 depth: usize,
3949 ) -> Result<StoreCallersResult> {
3950 CallGraphStore::callers_of(self, file_rel, symbol, depth)
3951 }
3952 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3953 CallGraphStore::impact_of(self, file_rel, symbol, depth)
3954 }
3955 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3956 CallGraphStore::outgoing_calls_of(self, node)
3957 }
3958 fn outgoing_calls_for_symbols(
3959 &self,
3960 sources: &[(String, String)],
3961 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3962 CallGraphStore::outgoing_calls_for_symbols(self, sources)
3963 }
3964 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3965 CallGraphStore::resolved_self_calls_of(self, node)
3966 }
3967 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3968 CallGraphStore::unresolved_calls_of(self, node)
3969 }
3970 fn call_tree(
3971 &self,
3972 file_rel: &Path,
3973 symbol: &str,
3974 depth: usize,
3975 ) -> Result<callgraph::CallTreeNode> {
3976 CallGraphStore::call_tree(self, file_rel, symbol, depth)
3977 }
3978 fn trace_to(
3979 &self,
3980 file_rel: &Path,
3981 symbol: &str,
3982 max_depth: usize,
3983 ) -> Result<callgraph::TraceToResult> {
3984 CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
3985 }
3986 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3987 CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
3988 }
3989 fn trace_to_symbol(
3990 &self,
3991 file_rel: &Path,
3992 symbol: &str,
3993 to_symbol: &str,
3994 to_file: Option<&Path>,
3995 max_depth: usize,
3996 ) -> Result<callgraph::TraceToSymbolResult> {
3997 CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
3998 }
3999}
4000
4001impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
4002 fn project_root(&self) -> &Path {
4003 (**self).project_root()
4004 }
4005 fn project_key(&self) -> &str {
4006 (**self).project_key()
4007 }
4008 fn sqlite_path(&self) -> &Path {
4009 (**self).sqlite_path()
4010 }
4011 fn is_current(&self) -> bool {
4012 (**self).is_current()
4013 }
4014 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4015 (**self).edge_snapshot()
4016 }
4017 fn indexed_file_count(&self) -> Result<usize> {
4018 (**self).indexed_file_count()
4019 }
4020 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4021 (**self).node_for(file_rel, symbol)
4022 }
4023 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4024 (**self).nodes_for(file_rel, symbol)
4025 }
4026 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4027 (**self).nodes_matching(symbol)
4028 }
4029 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4030 (**self).direct_callers_of(file_rel, symbol)
4031 }
4032 fn direct_callers_for_symbols(
4033 &self,
4034 targets: &[(String, String)],
4035 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4036 (**self).direct_callers_for_symbols(targets)
4037 }
4038 fn direct_caller_counts_of(
4039 &self,
4040 targets: &[(String, String)],
4041 ) -> Result<HashMap<(String, String), usize>> {
4042 (**self).direct_caller_counts_of(targets)
4043 }
4044 fn callers_of(
4045 &self,
4046 file_rel: &Path,
4047 symbol: &str,
4048 depth: usize,
4049 ) -> Result<StoreCallersResult> {
4050 (**self).callers_of(file_rel, symbol, depth)
4051 }
4052 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4053 (**self).impact_of(file_rel, symbol, depth)
4054 }
4055 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4056 (**self).outgoing_calls_of(node)
4057 }
4058 fn outgoing_calls_for_symbols(
4059 &self,
4060 sources: &[(String, String)],
4061 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4062 (**self).outgoing_calls_for_symbols(sources)
4063 }
4064 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4065 (**self).resolved_self_calls_of(node)
4066 }
4067 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4068 (**self).unresolved_calls_of(node)
4069 }
4070 fn call_tree(
4071 &self,
4072 file_rel: &Path,
4073 symbol: &str,
4074 depth: usize,
4075 ) -> Result<callgraph::CallTreeNode> {
4076 (**self).call_tree(file_rel, symbol, depth)
4077 }
4078 fn trace_to(
4079 &self,
4080 file_rel: &Path,
4081 symbol: &str,
4082 max_depth: usize,
4083 ) -> Result<callgraph::TraceToResult> {
4084 (**self).trace_to(file_rel, symbol, max_depth)
4085 }
4086 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4087 (**self).trace_to_symbol_candidates(to_symbol)
4088 }
4089 fn trace_to_symbol(
4090 &self,
4091 file_rel: &Path,
4092 symbol: &str,
4093 to_symbol: &str,
4094 to_file: Option<&Path>,
4095 max_depth: usize,
4096 ) -> Result<callgraph::TraceToSymbolResult> {
4097 (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4098 }
4099}
4100
4101impl CallGraphRead for ReadonlyCallGraphStore {
4102 fn project_root(&self) -> &Path {
4103 self.project_root()
4104 }
4105 fn project_key(&self) -> &str {
4106 self.project_key()
4107 }
4108 fn sqlite_path(&self) -> &Path {
4109 self.sqlite_path()
4110 }
4111 fn is_current(&self) -> bool {
4112 self.is_current()
4113 }
4114 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4115 self.edge_snapshot()
4116 }
4117 fn indexed_file_count(&self) -> Result<usize> {
4118 self.indexed_file_count()
4119 }
4120 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4121 self.node_for(file_rel, symbol)
4122 }
4123 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4124 self.nodes_for(file_rel, symbol)
4125 }
4126 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4127 self.nodes_matching(symbol)
4128 }
4129 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4130 self.direct_callers_of(file_rel, symbol)
4131 }
4132 fn direct_callers_for_symbols(
4133 &self,
4134 targets: &[(String, String)],
4135 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4136 self.direct_callers_for_symbols(targets)
4137 }
4138 fn direct_caller_counts_of(
4139 &self,
4140 targets: &[(String, String)],
4141 ) -> Result<HashMap<(String, String), usize>> {
4142 self.direct_caller_counts_of(targets)
4143 }
4144 fn callers_of(
4145 &self,
4146 file_rel: &Path,
4147 symbol: &str,
4148 depth: usize,
4149 ) -> Result<StoreCallersResult> {
4150 self.callers_of(file_rel, symbol, depth)
4151 }
4152 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4153 self.impact_of(file_rel, symbol, depth)
4154 }
4155 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4156 self.outgoing_calls_of(node)
4157 }
4158 fn outgoing_calls_for_symbols(
4159 &self,
4160 sources: &[(String, String)],
4161 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4162 self.outgoing_calls_for_symbols(sources)
4163 }
4164 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4165 self.resolved_self_calls_of(node)
4166 }
4167 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4168 self.unresolved_calls_of(node)
4169 }
4170 fn call_tree(
4171 &self,
4172 file_rel: &Path,
4173 symbol: &str,
4174 depth: usize,
4175 ) -> Result<callgraph::CallTreeNode> {
4176 self.call_tree(file_rel, symbol, depth)
4177 }
4178 fn trace_to(
4179 &self,
4180 file_rel: &Path,
4181 symbol: &str,
4182 max_depth: usize,
4183 ) -> Result<callgraph::TraceToResult> {
4184 self.trace_to(file_rel, symbol, max_depth)
4185 }
4186 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4187 self.trace_to_symbol_candidates(to_symbol)
4188 }
4189 fn trace_to_symbol(
4190 &self,
4191 file_rel: &Path,
4192 symbol: &str,
4193 to_symbol: &str,
4194 to_file: Option<&Path>,
4195 max_depth: usize,
4196 ) -> Result<callgraph::TraceToSymbolResult> {
4197 self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4198 }
4199}
4200
4201fn indexed_file_count(conn: &Connection) -> Result<usize> {
4202 let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
4203 Ok(count.max(0) as usize)
4204}
4205
4206fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
4207 let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
4208 match candidates.as_slice() {
4209 [candidate] => Ok(candidate.clone()),
4210 [] => Err(AftError::SymbolNotFound {
4211 name: symbol.to_string(),
4212 file: rel_path.to_string(),
4213 }
4214 .into()),
4215 _ => Err(AftError::AmbiguousSymbol {
4216 name: symbol.to_string(),
4217 candidates: candidates
4218 .iter()
4219 .map(|candidate| candidate.symbol.clone())
4220 .collect(),
4221 }
4222 .into()),
4223 }
4224}
4225
4226fn nodes_for_file_matching_symbol(
4227 conn: &Connection,
4228 rel_path: &str,
4229 symbol: &str,
4230) -> Result<Vec<StoreNode>> {
4231 let qualified_query = symbol.contains("::");
4232 let sql = if qualified_query {
4233 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4234 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4235 FROM nodes n JOIN files f ON f.path = n.file_path
4236 WHERE n.file_path = ?1 AND n.scoped_name = ?2
4237 ORDER BY n.scoped_name, n.start_line, n.start_col"
4238 } else {
4239 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4240 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4241 FROM nodes n JOIN files f ON f.path = n.file_path
4242 WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
4243 ORDER BY n.scoped_name, n.start_line, n.start_col"
4244 };
4245 let mut stmt = conn.prepare(sql)?;
4246 let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
4247 rows.collect::<std::result::Result<Vec<_>, _>>()
4248 .map_err(Into::into)
4249}
4250
4251fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
4252 let qualified_query = symbol.contains("::");
4253 let sql = if qualified_query {
4254 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4255 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4256 FROM nodes n JOIN files f ON f.path = n.file_path
4257 WHERE n.scoped_name = ?1
4258 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4259 } else {
4260 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4261 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4262 FROM nodes n JOIN files f ON f.path = n.file_path
4263 WHERE n.scoped_name = ?1 OR n.name = ?1
4264 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4265 };
4266 let mut stmt = conn.prepare(sql)?;
4267 let rows = stmt.query_map(params![symbol], store_node_from_row)?;
4268 rows.collect::<std::result::Result<Vec<_>, _>>()
4269 .map_err(Into::into)
4270}
4271
4272fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
4273 store_node_from_row_at(row, 0)
4274}
4275
4276fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
4277 let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
4278 let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
4279 let lang_label_value: String = row.get(offset + 10)?;
4280 Ok(StoreNode {
4281 node_id: row.get(offset)?,
4282 file: row.get(offset + 1)?,
4283 symbol: row.get(offset + 2)?,
4284 name: row.get(offset + 3)?,
4285 kind: row.get(offset + 4)?,
4286 line: start_line.saturating_add(1),
4287 end_line: end_line.saturating_add(1),
4288 signature: row.get(offset + 7)?,
4289 exported: row.get::<_, i64>(offset + 8)? != 0,
4290 is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
4291 lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
4292 })
4293}
4294
4295fn optional_store_node_from_row_at(
4296 row: &rusqlite::Row<'_>,
4297 offset: usize,
4298) -> rusqlite::Result<Option<StoreNode>> {
4299 if row.get::<_, Option<String>>(offset)?.is_some() {
4300 store_node_from_row_at(row, offset).map(Some)
4301 } else {
4302 Ok(None)
4303 }
4304}
4305
4306#[allow(clippy::too_many_arguments)]
4307fn collect_callers_recursive(
4308 conn: &Connection,
4309 file: &str,
4310 symbol: &str,
4311 max_depth: usize,
4312 current_depth: usize,
4313 visited: &mut HashSet<(String, String)>,
4314 result: &mut Vec<StoreCallSite>,
4315 depth_limited: &mut bool,
4316 truncated: &mut usize,
4317) -> Result<()> {
4318 if current_depth >= max_depth {
4319 let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
4320 if omitted > 0 {
4321 *depth_limited = true;
4322 *truncated += omitted;
4323 }
4324 return Ok(());
4325 }
4326
4327 if !visited.insert((file.to_string(), symbol.to_string())) {
4328 return Ok(());
4329 }
4330
4331 let sites = direct_callers_for_tuple(conn, file, symbol)?;
4332 for site in sites {
4333 result.push(site.clone());
4334 if current_depth + 1 < max_depth {
4335 collect_callers_recursive(
4336 conn,
4337 &site.caller.file,
4338 &site.caller.symbol,
4339 max_depth,
4340 current_depth + 1,
4341 visited,
4342 result,
4343 depth_limited,
4344 truncated,
4345 )?;
4346 } else {
4347 let omitted =
4348 direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
4349 if omitted > 0 {
4350 *depth_limited = true;
4351 *truncated += omitted;
4352 }
4353 }
4354 }
4355 Ok(())
4356}
4357
4358const DIRECT_CALLER_BATCH_SIZE: usize = 499;
4360
4361fn direct_caller_counts_for_tuples(
4362 conn: &Connection,
4363 targets: &[(String, String)],
4364) -> Result<HashMap<(String, String), usize>> {
4365 let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
4366 let mut counts = unique_targets
4367 .iter()
4368 .cloned()
4369 .map(|target| (target, 0usize))
4370 .collect::<HashMap<_, _>>();
4371
4372 let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
4373 for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
4374 let requested_values = (0..chunk.len())
4375 .map(|_| "(?, ?)")
4376 .collect::<Vec<_>>()
4377 .join(", ");
4378 let sql = format!(
4379 "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
4380 deduped AS (
4381 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
4382 FROM requested requested
4383 JOIN edges e
4384 ON e.target_file = requested.target_file
4385 AND e.target_symbol = requested.target_symbol
4386 AND e.kind = 'call'
4387 JOIN refs r ON r.ref_id = e.ref_id
4388 JOIN nodes src ON src.id = e.source_node
4389 JOIN files src_file ON src_file.path = src.file_path
4390 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
4391 )
4392 SELECT target_file, target_symbol, COUNT(*)
4393 FROM deduped
4394 GROUP BY target_file, target_symbol"
4395 );
4396 let bindings = chunk
4397 .iter()
4398 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4399 let mut stmt = conn.prepare(&sql)?;
4400 let rows = stmt.query_map(params_from_iter(bindings), |row| {
4401 Ok((
4402 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
4403 row.get::<_, i64>(2)?,
4404 ))
4405 })?;
4406 for row in rows {
4407 let (target, count) = row?;
4408 counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
4409 }
4410 }
4411
4412 Ok(counts)
4413}
4414
4415fn direct_caller_count_for_tuple(
4416 conn: &Connection,
4417 target_file: &str,
4418 target_symbol: &str,
4419) -> Result<usize> {
4420 let count: i64 = conn.query_row(
4421 "SELECT COUNT(*)
4422 FROM edges e
4423 JOIN refs r ON r.ref_id = e.ref_id
4424 JOIN nodes src ON src.id = e.source_node
4425 JOIN files src_file ON src_file.path = src.file_path
4426 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
4427 params![target_file, target_symbol],
4428 |row| row.get(0),
4429 )?;
4430 Ok(usize::try_from(count).unwrap_or(usize::MAX))
4431}
4432
4433fn direct_callers_for_tuple(
4434 conn: &Connection,
4435 target_file: &str,
4436 target_symbol: &str,
4437) -> Result<Vec<StoreCallSite>> {
4438 let mut stmt = conn.prepare(
4439 "SELECT e.target_file, e.target_symbol, e.line,
4440 r.byte_start, r.byte_end, r.status, e.provenance,
4441 src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
4442 src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
4443 src_file.lang,
4444 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4445 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4446 tgt_file.lang
4447 FROM edges e
4448 JOIN refs r ON r.ref_id = e.ref_id
4449 JOIN nodes src ON src.id = e.source_node
4450 JOIN files src_file ON src_file.path = src.file_path
4451 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4452 ON tgt.id = e.target_node
4453 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
4454 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
4455 )?;
4456 let rows = stmt.query_map(
4457 params![target_file, target_symbol],
4458 direct_call_site_from_row,
4459 )?;
4460 rows.collect::<std::result::Result<Vec<_>, _>>()
4461 .map_err(Into::into)
4462}
4463
4464fn direct_call_site_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreCallSite> {
4465 let caller = store_node_from_row_at(row, 7)?;
4466 let target = optional_store_node_from_row_at(row, 18)?;
4467 Ok(StoreCallSite {
4468 caller,
4469 target_file: row.get(0)?,
4470 target_symbol: row.get(1)?,
4471 target,
4472 line: row.get::<_, i64>(2)?.max(0) as u32,
4473 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4474 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4475 resolved: row.get::<_, String>(5)? == "resolved",
4476 provenance: row.get(6)?,
4477 })
4478}
4479
4480fn direct_callers_for_tuples(
4481 conn: &Connection,
4482 targets: &[(String, String)],
4483) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4484 let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
4485 let mut callers_by_target = unique_targets
4486 .iter()
4487 .cloned()
4488 .map(|target| (target, Vec::new()))
4489 .collect::<HashMap<_, _>>();
4490 let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
4491
4492 for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
4493 let requested_values = (0..chunk.len())
4494 .map(|_| "(?, ?)")
4495 .collect::<Vec<_>>()
4496 .join(", ");
4497 let sql = format!(
4498 "WITH requested(target_file, target_symbol) AS (VALUES {requested_values})
4499 SELECT e.target_file, e.target_symbol, e.line,
4500 r.byte_start, r.byte_end, r.status, e.provenance,
4501 src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
4502 src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
4503 src_file.lang,
4504 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4505 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4506 tgt_file.lang
4507 FROM requested requested
4508 JOIN edges e
4509 ON e.target_file = requested.target_file
4510 AND e.target_symbol = requested.target_symbol
4511 AND e.kind = 'call'
4512 JOIN refs r ON r.ref_id = e.ref_id
4513 JOIN nodes src ON src.id = e.source_node
4514 JOIN files src_file ON src_file.path = src.file_path
4515 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4516 ON tgt.id = e.target_node
4517 ORDER BY e.target_file, e.target_symbol, e.source_node,
4518 r.byte_start, r.line, r.ref_id"
4519 );
4520 let bindings = chunk
4521 .iter()
4522 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4523 let mut stmt = conn.prepare(&sql)?;
4524 let rows = stmt.query_map(params_from_iter(bindings), |row| {
4525 let call = direct_call_site_from_row(row)?;
4526 let target_key = (call.target_file.clone(), call.target_symbol.clone());
4527 Ok((target_key, call))
4528 })?;
4529 for row in rows {
4530 let (target, call) = row?;
4531 callers_by_target
4532 .get_mut(&target)
4533 .expect("batched caller row belongs to a requested target")
4534 .push(call);
4535 }
4536 }
4537
4538 Ok(callers_by_target)
4539}
4540
4541const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
4543const OUTGOING_NODE_BATCH_SIZE: usize = 999;
4545
4546fn outgoing_calls_for_symbol_tuples(
4547 conn: &Connection,
4548 sources: &[(String, String)],
4549) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4550 let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
4551 let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
4552 let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
4553 let source_nodes = unique_sources
4554 .iter()
4555 .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
4556 .cloned()
4557 .collect::<Vec<_>>();
4558 let source_nodes_by_id = source_nodes
4559 .iter()
4560 .cloned()
4561 .map(|node| (node.node_id.clone(), node))
4562 .collect::<HashMap<_, _>>();
4563 let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
4564
4565 for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
4566 let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
4567 let sql = format!(
4568 "SELECT e.source_node,
4569 e.target_file, e.target_symbol, e.line,
4570 r.byte_start, r.byte_end, r.status, e.provenance,
4571 CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
4572 tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4573 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4574 tgt_file.lang
4575 FROM edges e
4576 JOIN refs r ON r.ref_id = e.ref_id
4577 LEFT JOIN nodes tgt ON tgt.id = e.target_node
4578 LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
4579 WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
4580 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
4581 );
4582 let bindings = chunk.iter().map(|node| node.node_id.as_str());
4583 let mut stmt = conn.prepare(&sql)?;
4584 let rows = stmt.query_map(params_from_iter(bindings), |row| {
4585 let source_node_id = row.get::<_, String>(0)?;
4586 let caller = source_nodes_by_id
4587 .get(&source_node_id)
4588 .expect("batched outgoing row belongs to a requested source node")
4589 .clone();
4590 let target = optional_store_node_from_row_at(row, 8)?;
4591 Ok((
4592 source_node_id,
4593 StoreCallSite {
4594 caller,
4595 target_file: row.get(1)?,
4596 target_symbol: row.get(2)?,
4597 target,
4598 line: row.get::<_, i64>(3)?.max(0) as u32,
4599 byte_start: row.get::<_, i64>(4)?.max(0) as usize,
4600 byte_end: row.get::<_, i64>(5)?.max(0) as usize,
4601 resolved: row.get::<_, String>(6)? == "resolved",
4602 provenance: row.get(7)?,
4603 },
4604 ))
4605 })?;
4606 for row in rows {
4607 let (source_node_id, call) = row?;
4608 calls_by_node.entry(source_node_id).or_default().push(call);
4609 }
4610 }
4611
4612 let mut calls_by_source = HashMap::new();
4613 for source in &unique_sources {
4614 let mut calls = Vec::new();
4615 if let Some(nodes) = source_nodes_by_symbol.get(source) {
4616 for node in nodes {
4617 if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
4618 calls.extend(node_calls);
4619 }
4620 }
4621 }
4622 calls_by_source.insert(source.clone(), calls);
4623 }
4624
4625 let target_tuples = calls_by_source
4628 .values()
4629 .flatten()
4630 .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
4631 .collect::<Vec<_>>();
4632 let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
4633 for calls in calls_by_source.values_mut() {
4634 for call in calls {
4635 if let Some(target) = target_nodes
4636 .get(&(call.target_file.clone(), call.target_symbol.clone()))
4637 .and_then(|nodes| nodes.first())
4638 {
4639 call.target = Some(target.clone());
4640 }
4641 }
4642 }
4643
4644 Ok(calls_by_source)
4645}
4646
4647fn nodes_for_symbol_tuples(
4648 conn: &Connection,
4649 symbols: &[(String, String)],
4650) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
4651 let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
4652 let mut nodes_by_symbol = unique_symbols
4653 .iter()
4654 .cloned()
4655 .map(|symbol| (symbol, Vec::new()))
4656 .collect::<HashMap<_, _>>();
4657 let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
4658
4659 for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
4660 let requested_values = (0..chunk.len())
4661 .map(|_| "(?, ?)")
4662 .collect::<Vec<_>>()
4663 .join(", ");
4664 let sql = format!(
4665 "WITH requested(file, symbol) AS (VALUES {requested_values})
4666 SELECT requested.file, requested.symbol,
4667 node.id, node.file_path, node.scoped_name, node.name, node.kind,
4668 node.start_line, node.end_line, node.signature, node.exported,
4669 node.is_callgraph_entry_point, node_file.lang
4670 FROM requested
4671 JOIN nodes node INDEXED BY idx_nodes_file
4672 ON node.file_path = requested.file
4673 AND node.scoped_name = requested.symbol
4674 JOIN files node_file ON node_file.path = node.file_path
4675 ORDER BY requested.file, requested.symbol,
4676 node.scoped_name, node.start_line, node.end_line,
4677 node.start_col, node.range_ordinal"
4678 );
4679 let bindings = chunk
4680 .iter()
4681 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4682 let mut stmt = conn.prepare(&sql)?;
4683 let rows = stmt.query_map(params_from_iter(bindings), |row| {
4684 Ok((
4685 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
4686 store_node_from_row_at(row, 2)?,
4687 ))
4688 })?;
4689 for row in rows {
4690 let (symbol, node) = row?;
4691 nodes_by_symbol.entry(symbol).or_default().push(node);
4692 }
4693 }
4694
4695 Ok(nodes_by_symbol)
4696}
4697
4698fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4699 let mut stmt = conn.prepare(
4700 "SELECT e.target_file, e.target_symbol, e.line,
4701 r.byte_start, r.byte_end, r.status, e.provenance,
4702 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4703 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4704 tgt_file.lang
4705 FROM edges e
4706 JOIN refs r ON r.ref_id = e.ref_id
4707 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4708 ON tgt.id = e.target_node
4709 WHERE e.kind = 'call' AND e.source_node = ?1
4710 ORDER BY r.byte_start, r.line, r.ref_id",
4711 )?;
4712 let rows = stmt.query_map(params![node.node_id], |row| {
4713 let target = optional_store_node_from_row_at(row, 7)?;
4714 Ok(StoreCallSite {
4715 caller: node.clone(),
4716 target_file: row.get(0)?,
4717 target_symbol: row.get(1)?,
4718 target,
4719 line: row.get::<_, i64>(2)?.max(0) as u32,
4720 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4721 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4722 resolved: row.get::<_, String>(5)? == "resolved",
4723 provenance: row.get(6)?,
4724 })
4725 })?;
4726 rows.collect::<std::result::Result<Vec<_>, _>>()
4727 .map_err(Into::into)
4728}
4729
4730fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4731 let mut stmt = conn.prepare(
4732 "SELECT r.target_file, r.target_symbol, r.line,
4733 r.byte_start, r.byte_end, r.status, r.provenance,
4734 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4735 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4736 tgt_file.lang
4737 FROM refs r
4738 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4739 ON tgt.id = r.target_node
4740 WHERE r.caller_node = ?1
4741 AND r.kind = 'call'
4742 AND r.status <> 'unresolved'
4743 AND r.target_file = ?2
4744 AND r.target_symbol = ?3
4745 AND r.provenance = ?4
4746 AND NOT EXISTS (
4747 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
4748 )
4749 ORDER BY r.byte_start, r.line, r.ref_id",
4750 )?;
4751 let rows = stmt.query_map(
4752 params![
4753 &node.node_id,
4754 &node.file,
4755 &node.symbol,
4756 PROVENANCE_TREESITTER
4757 ],
4758 |row| {
4759 let target = optional_store_node_from_row_at(row, 7)?;
4760 Ok(StoreCallSite {
4761 caller: node.clone(),
4762 target_file: row.get(0)?,
4763 target_symbol: row.get(1)?,
4764 target,
4765 line: row.get::<_, i64>(2)?.max(0) as u32,
4766 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4767 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4768 resolved: row.get::<_, String>(5)? == "resolved",
4769 provenance: row.get(6)?,
4770 })
4771 },
4772 )?;
4773 rows.collect::<std::result::Result<Vec<_>, _>>()
4774 .map_err(Into::into)
4775}
4776
4777fn unresolved_calls_for_node(
4778 conn: &Connection,
4779 node: &StoreNode,
4780) -> Result<Vec<StoreUnresolvedCall>> {
4781 let mut stmt = conn.prepare(
4782 "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
4783 FROM refs
4784 WHERE caller_node = ?1
4785 AND kind = 'call'
4786 AND status = 'unresolved'
4787 AND NOT EXISTS (
4788 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4789 )
4790 ORDER BY byte_start, line, ref_id",
4791 )?;
4792 let rows = stmt.query_map(params![node.node_id], |row| {
4793 Ok(StoreUnresolvedCall {
4794 caller: node.clone(),
4795 symbol: row.get(0)?,
4796 full_ref: row.get(1)?,
4797 line: row.get::<_, i64>(2)?.max(0) as u32,
4798 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4799 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4800 })
4801 })?;
4802 rows.collect::<std::result::Result<Vec<_>, _>>()
4803 .map_err(Into::into)
4804}
4805
4806fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
4807 let mut calls = Vec::new();
4808 calls.extend(
4809 outgoing_calls_for_node(conn, node)?
4810 .into_iter()
4811 .map(StoreForwardCall::Resolved),
4812 );
4813 calls.extend(
4814 unresolved_calls_for_node(conn, node)?
4815 .into_iter()
4816 .map(StoreForwardCall::Unresolved),
4817 );
4818 calls.sort_by(|left, right| {
4819 left.byte_start()
4820 .cmp(&right.byte_start())
4821 .then(left.line().cmp(&right.line()))
4822 });
4823 Ok(calls)
4824}
4825
4826fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
4827 let resolved_count: i64 = conn.query_row(
4828 "SELECT COUNT(*)
4829 FROM edges e
4830 JOIN refs r ON r.ref_id = e.ref_id
4831 WHERE e.kind = 'call' AND e.source_node = ?1",
4832 params![&node.node_id],
4833 |row| row.get(0),
4834 )?;
4835 let unresolved_count: i64 = conn.query_row(
4836 "SELECT COUNT(*)
4837 FROM refs
4838 WHERE caller_node = ?1
4839 AND kind = 'call'
4840 AND status = 'unresolved'
4841 AND NOT EXISTS (
4842 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4843 )",
4844 params![&node.node_id],
4845 |row| row.get(0),
4846 )?;
4847 let total = resolved_count.saturating_add(unresolved_count);
4848 Ok(usize::try_from(total).unwrap_or(usize::MAX))
4849}
4850
4851fn call_tree_inner(
4852 conn: &Connection,
4853 node: &StoreNode,
4854 max_depth: usize,
4855 current_depth: usize,
4856 visited: &mut HashSet<(String, String)>,
4857) -> Result<callgraph::CallTreeNode> {
4858 let visit_key = (node.file.clone(), node.symbol.clone());
4859 if visited.contains(&visit_key) {
4860 return Ok(callgraph::CallTreeNode {
4861 name: node.symbol.clone(),
4862 file: node.file.clone(),
4863 line: node.line,
4864 signature: node.signature.clone(),
4865 resolved: true,
4866 children: Vec::new(),
4867 depth_limited: false,
4868 truncated: 0,
4869 });
4870 }
4871 visited.insert(visit_key.clone());
4872
4873 let mut children = Vec::new();
4874 let mut depth_limited = false;
4875 let mut truncated = 0usize;
4876
4877 if current_depth < max_depth {
4878 let calls = forward_calls_for_node(conn, node)?;
4879 for call in calls {
4880 match call {
4881 StoreForwardCall::Resolved(site) => {
4882 if let Some(target) = site.target {
4883 let child =
4884 call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
4885 depth_limited |= child.depth_limited;
4886 truncated += child.truncated;
4887 children.push(child);
4888 } else {
4889 children.push(callgraph::CallTreeNode {
4890 name: site.target_symbol,
4891 file: site.target_file,
4892 line: site.line,
4893 signature: None,
4894 resolved: false,
4895 children: Vec::new(),
4896 depth_limited: false,
4897 truncated: 0,
4898 });
4899 }
4900 }
4901 StoreForwardCall::Unresolved(call) => {
4902 children.push(callgraph::CallTreeNode {
4903 name: call.symbol,
4904 file: call.caller.file,
4905 line: call.line,
4906 signature: None,
4907 resolved: false,
4908 children: Vec::new(),
4909 depth_limited: false,
4910 truncated: 0,
4911 });
4912 }
4913 }
4914 }
4915 } else {
4916 truncated = forward_call_count_for_node(conn, node)?;
4917 depth_limited = truncated > 0;
4918 }
4919
4920 visited.remove(&visit_key);
4921 Ok(callgraph::CallTreeNode {
4922 name: node.symbol.clone(),
4923 file: node.file.clone(),
4924 line: node.line,
4925 signature: node.signature.clone(),
4926 resolved: true,
4927 children,
4928 depth_limited,
4929 truncated,
4930 })
4931}
4932
4933fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
4934 callgraph::TraceToSymbolHop {
4935 symbol: node.symbol.clone(),
4936 file: node.file.clone(),
4937 line: node.line,
4938 }
4939}
4940
4941fn trace_to_symbol_matches_target(
4942 node: &StoreNode,
4943 to_symbol: &str,
4944 to_file: Option<&str>,
4945) -> bool {
4946 if !symbol_query_matches(&node.symbol, to_symbol) {
4947 return false;
4948 }
4949 match to_file {
4950 Some(file) => node.file == file,
4951 None => true,
4952 }
4953}
4954
4955fn symbol_query_matches(symbol: &str, query: &str) -> bool {
4956 symbol == query || unqualified_name(symbol) == query
4957}
4958
4959fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
4960 let source = std::fs::read_to_string(path).ok()?;
4961 Some(source.lines().map(|line| line.trim().to_string()).collect())
4962}
4963
4964#[doc(hidden)]
4965pub fn live_callgraph_edge_snapshot(
4966 project_root: &Path,
4967 files: &[PathBuf],
4968) -> Result<BTreeSet<StoredEdge>> {
4969 let files = normalize_file_list(project_root, files)?;
4970 let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
4971 let mut file_data = Vec::new();
4972 for file in &files {
4973 let canon = canonicalize_path(file);
4974 let data = graph.build_file(&canon)?.clone();
4975 file_data.push((canon, data));
4976 }
4977
4978 let mut edges = BTreeSet::new();
4979 for (caller_file, data) in &file_data {
4980 for (caller_symbol, call_sites) in &data.calls_by_symbol {
4981 for call_site in call_sites {
4982 let resolution = graph.resolve_cross_file_edge(
4983 &call_site.full_callee,
4984 &call_site.callee_name,
4985 caller_file,
4986 &data.import_block,
4987 );
4988 let (target_file, target_symbol) = match resolution {
4989 EdgeResolution::Resolved { file, symbol } => (file, symbol),
4990 EdgeResolution::Unresolved { callee_name } => {
4991 if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
4992 continue;
4993 }
4994 let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
4995 data,
4996 caller_file,
4997 &callee_name,
4998 ) else {
4999 continue;
5000 };
5001 (caller_file.clone(), target_symbol)
5002 }
5003 };
5004 if target_file == *caller_file && target_symbol == *caller_symbol {
5005 continue;
5006 }
5007 edges.insert(StoredEdge {
5008 source_file: relative_path(project_root, caller_file),
5009 source_symbol: caller_symbol.clone(),
5010 target_file: relative_path(project_root, &target_file),
5011 target_symbol,
5012 kind: "call".to_string(),
5013 line: call_site.line,
5014 });
5015 }
5016 }
5017 }
5018 Ok(edges)
5019}
5020
5021fn rebuild_cooldown_records() -> &'static Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>
5022{
5023 SUCCESSFUL_REBUILDS.get_or_init(|| Mutex::new(HashMap::new()))
5024}
5025
5026fn rebuild_cooldown_key(callgraph_dir: &Path, project_key: &str) -> RebuildCooldownKey {
5027 RebuildCooldownKey {
5028 callgraph_dir: std::fs::canonicalize(callgraph_dir)
5029 .unwrap_or_else(|_| callgraph_dir.to_path_buf()),
5030 project_key: project_key.to_string(),
5031 }
5032}
5033
5034fn rebuild_cooldown_denial(
5035 callgraph_dir: &Path,
5036 project_key: &str,
5037 project_root: &Path,
5038 now: Instant,
5039) -> Option<(PathBuf, Duration)> {
5040 let key = rebuild_cooldown_key(callgraph_dir, project_key);
5041 let records = rebuild_cooldown_records()
5042 .lock()
5043 .unwrap_or_else(std::sync::PoisonError::into_inner);
5044 let record = records.get(&key)?;
5045 if record.project_root == project_root || !record.cross_root_cooldown_armed {
5046 return None;
5047 }
5048 let elapsed = now.saturating_duration_since(record.published_at);
5049 (elapsed < REBUILD_COOLDOWN).then(|| (record.project_root.clone(), REBUILD_COOLDOWN - elapsed))
5050}
5051
5052fn record_successful_rebuild(
5053 callgraph_dir: &Path,
5054 project_key: &str,
5055 project_root: &Path,
5056 published_at: Instant,
5057) {
5058 let key = rebuild_cooldown_key(callgraph_dir, project_key);
5059 let mut records = rebuild_cooldown_records()
5060 .lock()
5061 .unwrap_or_else(std::sync::PoisonError::into_inner);
5062 if records.len() >= 4_096 && !records.contains_key(&key) {
5063 if let Some(evict) = records.keys().next().cloned() {
5064 records.remove(&evict);
5065 }
5066 }
5067 let cross_root_cooldown_armed = records.get(&key).is_some_and(|previous| {
5068 previous.cross_root_cooldown_armed || previous.project_root != project_root
5069 });
5070 records.insert(
5071 key,
5072 RebuildCooldownRecord {
5073 project_root: project_root.to_path_buf(),
5074 published_at,
5075 cross_root_cooldown_armed,
5076 },
5077 );
5078}
5079
5080fn acquire_writer_lease(
5081 callgraph_dir: &Path,
5082 project_key: &str,
5083 project_root: &Path,
5084) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
5085 crate::root_cache::WriterLease::acquire_shared(
5086 crate::root_cache::RootCacheDomain::Callgraph,
5087 callgraph_dir,
5088 project_key,
5089 project_root,
5090 )
5091 .map_err(CallGraphStoreError::from)
5092}
5093
5094fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
5095 if lease.verify()? {
5096 Ok(())
5097 } else {
5098 Err(CallGraphStoreError::Unavailable(format!(
5099 "callgraph writer lease for key {} lost epoch {}; aborting write",
5100 lease.key(),
5101 lease.epoch()
5102 )))
5103 }
5104}
5105
5106fn legacy_migration_completion_line(
5107 project_key: &str,
5108 method: &str,
5109 legacy_bytes: u64,
5110 migrated_bytes: u64,
5111) -> String {
5112 format!(
5113 "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
5114 )
5115}
5116
5117fn log_legacy_migration_completion(
5118 project_key: &str,
5119 method: &str,
5120 legacy_bytes: u64,
5121 migrated_bytes: u64,
5122) {
5123 crate::slog_info!(
5124 "{}",
5125 legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
5126 );
5127}
5128
5129fn try_legacy_migration_or_fallback(
5130 callgraph_dir: &Path,
5131 project_root: &Path,
5132 project_key: &str,
5133 writer_lease: Arc<crate::root_cache::WriterLease>,
5134) -> Result<Option<CallGraphStore>> {
5135 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
5136 if partitions.is_empty() {
5137 return Ok(None);
5138 }
5139
5140 for partition in &partitions {
5141 if let Some(source) = newest_superseded_legacy_generation(partition)? {
5142 if !migration_disk_floor_allows(&source, callgraph_dir)? {
5143 return open_legacy_fallback_store(
5144 callgraph_dir,
5145 project_root,
5146 project_key,
5147 &partitions,
5148 );
5149 }
5150 match publish_generation_copy_migration(
5151 callgraph_dir,
5152 project_key,
5153 &source,
5154 Arc::clone(&writer_lease),
5155 ) {
5156 Ok(published) => {
5157 log_legacy_migration_completion(
5158 project_key,
5159 "generation_copy",
5160 source.source_bytes,
5161 published.migrated_bytes,
5162 );
5163 return CallGraphStore::open_generation(
5164 callgraph_dir,
5165 project_root.to_path_buf(),
5166 project_key.to_string(),
5167 published.generation,
5168 writer_lease,
5169 )
5170 .map(Some);
5171 }
5172 Err(error) => {
5173 crate::slog_warn!(
5174 "root-keyed callgraph generation-copy migration failed from {}: {}",
5175 source.sqlite_path.display(),
5176 error
5177 );
5178 return open_legacy_fallback_store(
5179 callgraph_dir,
5180 project_root,
5181 project_key,
5182 &partitions,
5183 );
5184 }
5185 }
5186 }
5187
5188 if let Some(source) = current_legacy_generation(partition)? {
5189 if !migration_disk_floor_allows(&source, callgraph_dir)? {
5190 return open_legacy_fallback_store(
5191 callgraph_dir,
5192 project_root,
5193 project_key,
5194 &partitions,
5195 );
5196 }
5197 match publish_backup_migration(
5198 callgraph_dir,
5199 project_key,
5200 &source,
5201 Arc::clone(&writer_lease),
5202 ) {
5203 Ok(published) => {
5204 log_legacy_migration_completion(
5205 project_key,
5206 "sqlite_backup",
5207 source.source_bytes,
5208 published.migrated_bytes,
5209 );
5210 return CallGraphStore::open_generation(
5211 callgraph_dir,
5212 project_root.to_path_buf(),
5213 project_key.to_string(),
5214 published.generation,
5215 writer_lease,
5216 )
5217 .map(Some);
5218 }
5219 Err(error) => {
5220 crate::slog_warn!(
5221 "root-keyed callgraph backup migration failed from {}: {}",
5222 source.sqlite_path.display(),
5223 error
5224 );
5225 return open_legacy_fallback_store(
5226 callgraph_dir,
5227 project_root,
5228 project_key,
5229 &partitions,
5230 );
5231 }
5232 }
5233 }
5234 }
5235
5236 open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
5237}
5238
5239fn open_legacy_fallback_store(
5240 callgraph_dir: &Path,
5241 project_root: &Path,
5242 project_key: &str,
5243 partitions: &[LegacyCallgraphPartition],
5244) -> Result<Option<CallGraphStore>> {
5245 let Some(target) = first_ready_legacy_target(partitions)? else {
5246 return Ok(None);
5247 };
5248 crate::slog_warn!(
5249 "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
5250 target.partition.harness,
5251 target.sqlite_path.display()
5252 );
5253 let conn = open_readonly_connection(&target.sqlite_path)?;
5254 if !database_ready(&conn).unwrap_or(false) {
5255 return Ok(None);
5256 }
5257 let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
5258 let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
5259 Ok(Some(CallGraphStore::from_connection(
5260 project_root.to_path_buf(),
5261 project_key.to_string(),
5262 target.sqlite_path,
5263 callgraph_dir.to_path_buf(),
5264 true,
5265 target.generation,
5266 None,
5267 Some(read_marker),
5268 conn,
5269 )))
5270}
5271
5272fn migration_disk_floor_allows(
5273 source: &LegacyCallgraphTarget,
5274 callgraph_dir: &Path,
5275) -> Result<bool> {
5276 let available = migration_available_disk(callgraph_dir)?;
5277 let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
5278 source.source_bytes,
5279 available,
5280 );
5281 if decision.should_skip_copy() {
5282 crate::slog_warn!(
5283 "{}",
5284 decision.warning_message(&source.sqlite_path, callgraph_dir)
5285 );
5286 return Ok(false);
5287 }
5288 Ok(true)
5289}
5290
5291fn migration_available_disk(path: &Path) -> Result<u64> {
5292 if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
5293 return Ok(bytes);
5294 }
5295 crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
5296}
5297
5298fn legacy_callgraph_partitions(
5299 callgraph_dir: &Path,
5300 project_key: &str,
5301) -> Result<Vec<LegacyCallgraphPartition>> {
5302 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5303 return Ok(Vec::new());
5304 };
5305 let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
5306 let mut partitions = inventory
5307 .into_iter()
5308 .filter(|entry| {
5309 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
5310 && entry.key == project_key
5311 })
5312 .map(|entry| {
5313 let dir = if entry.path.is_dir() {
5314 entry.path.clone()
5315 } else {
5316 entry
5317 .path
5318 .parent()
5319 .map(Path::to_path_buf)
5320 .unwrap_or_else(|| entry.path.clone())
5321 };
5322 LegacyCallgraphPartition {
5323 harness: entry.harness,
5324 dir,
5325 key: entry.key,
5326 bytes: entry.bytes,
5327 freshness: entry.callgraph_pointer_mtime,
5328 }
5329 })
5330 .collect::<Vec<_>>();
5331 partitions.sort_by(|left, right| {
5332 right
5333 .freshness
5334 .cmp(&left.freshness)
5335 .then_with(|| right.bytes.cmp(&left.bytes))
5336 .then_with(|| left.harness.cmp(&right.harness))
5337 });
5338 Ok(partitions)
5339}
5340
5341fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
5342 let domain_dir = callgraph_dir.parent()?;
5343 if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
5344 return None;
5345 }
5346 domain_dir.parent().map(Path::to_path_buf)
5347}
5348
5349pub(crate) fn all_legacy_partitions_migrated_for_keys(
5350 callgraph_dir: &Path,
5351 configured_keys: &BTreeSet<String>,
5352) -> Result<bool> {
5353 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5354 return Ok(false);
5355 };
5356 let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
5357 .into_iter()
5358 .filter(|entry| {
5359 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
5360 && configured_keys.contains(&entry.key)
5361 })
5362 .map(|entry| entry.key)
5363 .collect::<BTreeSet<_>>();
5364 if legacy_keys.is_empty() {
5365 return Ok(false);
5366 }
5367
5368 for key in legacy_keys {
5369 let migrated_dir = storage_root.join("callgraph").join(&key);
5370 let Some(generation) = read_pointer(&migrated_dir, &key) else {
5371 return Ok(false);
5372 };
5373 if !migration_generation_requires_manifest(&generation)
5374 || !migration_manifest_valid(&migrated_dir, &generation)
5375 {
5376 return Ok(false);
5377 }
5378 }
5379 Ok(true)
5380}
5381
5382fn newest_superseded_legacy_generation(
5383 partition: &LegacyCallgraphPartition,
5384) -> Result<Option<LegacyCallgraphTarget>> {
5385 let Some(current) = read_pointer(&partition.dir, &partition.key) else {
5386 return Ok(None);
5387 };
5388 let prefix = format!("{}.g", partition.key);
5389 let Ok(entries) = std::fs::read_dir(&partition.dir) else {
5390 return Ok(None);
5391 };
5392 let mut candidates = Vec::new();
5393 for entry in entries.flatten() {
5394 let name = entry.file_name().to_string_lossy().to_string();
5395 if name == current
5396 || name.contains(".tmp.")
5397 || !name.starts_with(&prefix)
5398 || !name.ends_with(".sqlite")
5399 {
5400 continue;
5401 }
5402 let path = entry.path();
5403 if !db_path_ready(&path) {
5404 continue;
5405 }
5406 let modified = entry
5407 .metadata()
5408 .and_then(|metadata| metadata.modified())
5409 .unwrap_or(SystemTime::UNIX_EPOCH);
5410 candidates.push((modified, path, name));
5411 }
5412 candidates.sort_by(|left, right| right.0.cmp(&left.0));
5413 let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
5414 return Ok(None);
5415 };
5416 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5417 Ok(Some(LegacyCallgraphTarget {
5418 partition: partition.clone(),
5419 sqlite_path,
5420 generation: Some(generation),
5421 source_bytes,
5422 source_blake3: String::new(),
5423 }))
5424}
5425
5426fn current_legacy_generation(
5427 partition: &LegacyCallgraphPartition,
5428) -> Result<Option<LegacyCallgraphTarget>> {
5429 let Some(target) = ready_legacy_target(partition)? else {
5430 return Ok(None);
5431 };
5432 let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
5433 if has_superseded {
5434 return Ok(None);
5435 }
5436 Ok(Some(target))
5437}
5438
5439fn freshest_legacy_fallback_target(
5440 callgraph_dir: &Path,
5441 project_key: &str,
5442) -> Result<Option<LegacyCallgraphTarget>> {
5443 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
5444 first_ready_legacy_target(&partitions)
5445}
5446
5447fn first_ready_legacy_target(
5448 partitions: &[LegacyCallgraphPartition],
5449) -> Result<Option<LegacyCallgraphTarget>> {
5450 for partition in partitions {
5451 if let Some(target) = ready_legacy_target(partition)? {
5452 return Ok(Some(target));
5453 }
5454 }
5455 Ok(None)
5456}
5457
5458fn ready_legacy_target(
5459 partition: &LegacyCallgraphPartition,
5460) -> Result<Option<LegacyCallgraphTarget>> {
5461 if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
5462 let sqlite_path = partition.dir.join(&generation);
5463 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
5464 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5465 return Ok(Some(LegacyCallgraphTarget {
5466 partition: partition.clone(),
5467 sqlite_path,
5468 generation: Some(generation),
5469 source_bytes,
5470 source_blake3: String::new(),
5471 }));
5472 }
5473 }
5474
5475 let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
5476 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
5477 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5478 return Ok(Some(LegacyCallgraphTarget {
5479 partition: partition.clone(),
5480 sqlite_path,
5481 generation: None,
5482 source_bytes,
5483 source_blake3: String::new(),
5484 }));
5485 }
5486 Ok(None)
5487}
5488
5489fn publish_generation_copy_migration(
5490 callgraph_dir: &Path,
5491 project_key: &str,
5492 source: &LegacyCallgraphTarget,
5493 writer_lease: Arc<crate::root_cache::WriterLease>,
5494) -> Result<PublishedLegacyMigration> {
5495 let generation = migration_generation_file_name(project_key, "copy");
5496 let temp_path = migration_temp_path(callgraph_dir, &generation);
5497 remove_sqlite_file_set(&temp_path);
5498 copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
5499 fail_after_temp_copy_for_test()?;
5500
5501 let mut source = source.clone();
5502 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
5503 source.source_blake3 = fingerprint.blake3;
5504 let generation = publish_migrated_generation(
5505 callgraph_dir,
5506 project_key,
5507 &generation,
5508 &temp_path,
5509 &source,
5510 fingerprint.bytes,
5511 writer_lease,
5512 "generation_copy",
5513 )?;
5514 Ok(PublishedLegacyMigration {
5515 generation,
5516 migrated_bytes: fingerprint.bytes,
5517 })
5518}
5519
5520fn publish_backup_migration(
5521 callgraph_dir: &Path,
5522 project_key: &str,
5523 source: &LegacyCallgraphTarget,
5524 writer_lease: Arc<crate::root_cache::WriterLease>,
5525) -> Result<PublishedLegacyMigration> {
5526 if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
5527 return Err(CallGraphStoreError::Unavailable(
5528 "legacy callgraph backup migration budget exhausted by test seam".to_string(),
5529 ));
5530 }
5531
5532 let generation = migration_generation_file_name(project_key, "backup");
5533 let temp_path = migration_temp_path(callgraph_dir, &generation);
5534 remove_sqlite_file_set(&temp_path);
5535
5536 let source_conn = open_readonly_connection(&source.sqlite_path)?;
5537 let mut destination = Connection::open(&temp_path)?;
5538 destination.busy_timeout(Duration::from_secs(5))?;
5539 let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
5540 let started = Instant::now();
5541 let mut retries = 0;
5542 loop {
5543 match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
5544 rusqlite::backup::StepResult::Done => break,
5545 rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
5546 rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
5547 retries += 1;
5548 if retries > MIGRATION_BACKUP_RETRY_BUDGET
5549 || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
5550 {
5551 return Err(CallGraphStoreError::Unavailable(format!(
5552 "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
5553 )));
5554 }
5555 std::thread::sleep(Duration::from_millis(20));
5556 }
5557 _ => {
5558 return Err(CallGraphStoreError::Unavailable(
5559 "legacy callgraph backup returned an unknown step result".to_string(),
5560 ));
5561 }
5562 }
5563 }
5564 drop(backup);
5565
5566 let integrity: String =
5567 destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
5568 if integrity != "ok" {
5569 return Err(CallGraphStoreError::Unavailable(format!(
5570 "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
5571 )));
5572 }
5573 if !database_ready(&destination)? {
5574 return Err(CallGraphStoreError::Unavailable(
5575 "legacy callgraph backup produced a database without ready metadata".to_string(),
5576 ));
5577 }
5578 destination.execute_batch("PRAGMA optimize;")?;
5579 drop(destination);
5580 sync_file(&temp_path)?;
5581 fail_after_temp_copy_for_test()?;
5582
5583 let mut source = source.clone();
5584 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
5585 source.source_blake3 = fingerprint.blake3;
5586 let generation = publish_migrated_generation(
5587 callgraph_dir,
5588 project_key,
5589 &generation,
5590 &temp_path,
5591 &source,
5592 fingerprint.bytes,
5593 writer_lease,
5594 "sqlite_backup",
5595 )?;
5596 Ok(PublishedLegacyMigration {
5597 generation,
5598 migrated_bytes: fingerprint.bytes,
5599 })
5600}
5601
5602fn publish_migrated_generation(
5603 callgraph_dir: &Path,
5604 project_key: &str,
5605 generation: &str,
5606 temp_path: &Path,
5607 source: &LegacyCallgraphTarget,
5608 migrated_bytes: u64,
5609 writer_lease: Arc<crate::root_cache::WriterLease>,
5610 method: &str,
5611) -> Result<String> {
5612 let gen_path = callgraph_dir.join(generation);
5613 checkpoint_sqlite_before_publication(temp_path);
5614 let publication = publish_if_current(|| {
5615 verify_writer_lease(&writer_lease)?;
5616 remove_sqlite_file_set(&gen_path);
5617 rename_sqlite_file_set(temp_path, &gen_path)?;
5618 crate::fs_lock::sync_parent(&gen_path);
5619
5620 verify_writer_lease(&writer_lease)?;
5621 publish_pointer(callgraph_dir, project_key, generation)?;
5622 write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
5623 Ok(generation.to_string())
5624 });
5625 if matches!(publication, Err(CallGraphStoreError::Superseded)) {
5626 remove_sqlite_file_set(temp_path);
5627 }
5628 publication
5629}
5630
5631fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
5632 if let Some(parent) = destination.parent() {
5633 std::fs::create_dir_all(parent)?;
5634 }
5635 for suffix in SQLITE_FILE_SET_SUFFIXES {
5636 let source_path = sqlite_file_set_path(source, suffix);
5637 if !source_path.is_file() {
5638 continue;
5639 }
5640 let destination_path = sqlite_file_set_path(destination, suffix);
5641 std::fs::copy(&source_path, &destination_path)?;
5642 sync_file(&destination_path)?;
5643 }
5644 Ok(())
5645}
5646
5647fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
5648 for suffix in SQLITE_FILE_SET_SUFFIXES {
5649 let source_path = sqlite_file_set_path(source, suffix);
5650 if !source_path.exists() {
5651 continue;
5652 }
5653 let destination_path = sqlite_file_set_path(destination, suffix);
5654 if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
5655 let _ = std::fs::remove_file(&source_path);
5656 return Err(error.into());
5657 }
5658 }
5659 Ok(())
5660}
5661
5662fn sqlite_file_set_size(path: &Path) -> Result<u64> {
5663 let mut bytes = 0_u64;
5664 for suffix in SQLITE_FILE_SET_SUFFIXES {
5665 let member = sqlite_file_set_path(path, suffix);
5666 if !member.is_file() {
5667 continue;
5668 }
5669 bytes = bytes.saturating_add(member.metadata()?.len());
5670 }
5671 Ok(bytes)
5672}
5673
5674fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
5675 let mut hasher = blake3::Hasher::new();
5676 let mut bytes = 0_u64;
5677 let mut buffer = [0_u8; 64 * 1024];
5678 for suffix in SQLITE_FILE_SET_SUFFIXES {
5679 let member = sqlite_file_set_path(path, suffix);
5680 if !member.is_file() {
5681 continue;
5682 }
5683 hasher.update(suffix.as_bytes());
5684 let mut file = std::fs::File::open(&member)?;
5685 loop {
5686 let read = file.read(&mut buffer)?;
5687 if read == 0 {
5688 break;
5689 }
5690 bytes = bytes.saturating_add(read as u64);
5691 hasher.update(&buffer[..read]);
5692 }
5693 }
5694 Ok(SourceFingerprint {
5695 bytes,
5696 blake3: hash_to_hex(hasher.finalize()),
5697 })
5698}
5699
5700fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
5701 if suffix.is_empty() {
5702 path.to_path_buf()
5703 } else {
5704 PathBuf::from(format!("{}{suffix}", path.display()))
5705 }
5706}
5707
5708fn sync_file(path: &Path) -> Result<()> {
5709 let file = std::fs::OpenOptions::new()
5710 .read(true)
5711 .write(true)
5712 .open(path)?;
5713 file.sync_all()?;
5714 Ok(())
5715}
5716
5717fn fail_after_temp_copy_for_test() -> Result<()> {
5718 if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
5719 return Err(CallGraphStoreError::Unavailable(
5720 "legacy callgraph migration stopped after temp copy by test seam".to_string(),
5721 ));
5722 }
5723 Ok(())
5724}
5725
5726fn migration_generation_file_name(project_key: &str, method: &str) -> String {
5727 format!(
5728 "{project_key}.g{}.{}{}{}.sqlite",
5729 now_nanos(),
5730 std::process::id(),
5731 MIGRATION_GENERATION_TAG,
5732 method
5733 )
5734}
5735
5736fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
5737 callgraph_dir.join(format!(
5738 "{generation}.tmp.{}.{}",
5739 std::process::id(),
5740 now_nanos()
5741 ))
5742}
5743
5744fn write_migration_manifest(
5745 callgraph_dir: &Path,
5746 generation: &str,
5747 source: &LegacyCallgraphTarget,
5748 migrated_bytes: u64,
5749 method: &str,
5750) -> Result<()> {
5751 let manifest_path = migration_manifest_path(callgraph_dir, generation);
5752 let temp_path = manifest_path.with_extension(format!(
5753 "migration.json.tmp.{}.{}",
5754 std::process::id(),
5755 now_nanos()
5756 ));
5757 let manifest = serde_json::json!({
5758 "version": MIGRATION_MANIFEST_VERSION,
5759 "method": method,
5760 "target_generation": generation,
5761 "source_harness": source.partition.harness,
5762 "source_path": source.sqlite_path.display().to_string(),
5763 "source_generation": source.generation,
5764 "source_bytes": source.source_bytes,
5765 "source_blake3": source.source_blake3,
5766 "migrated_bytes": migrated_bytes,
5767 });
5768 {
5769 use std::io::Write as _;
5770 let mut file = std::fs::File::create(&temp_path)?;
5771 file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
5772 file.write_all(b"\n")?;
5773 file.sync_all()?;
5774 }
5775 if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
5776 let _ = std::fs::remove_file(&temp_path);
5777 return Err(error.into());
5778 }
5779 crate::fs_lock::sync_parent(&manifest_path);
5780 Ok(())
5781}
5782
5783fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
5784 callgraph_dir.join(format!("{generation}.migration.json"))
5785}
5786
5787fn migration_generation_requires_manifest(generation: &str) -> bool {
5788 generation.contains(MIGRATION_GENERATION_TAG)
5789}
5790
5791fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
5792 if !migration_generation_requires_manifest(generation) {
5793 return true;
5794 }
5795 let path = migration_manifest_path(callgraph_dir, generation);
5796 let Ok(bytes) = std::fs::read(path) else {
5797 return false;
5798 };
5799 let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
5800 return false;
5801 };
5802 value.get("version").and_then(serde_json::Value::as_u64)
5803 == Some(MIGRATION_MANIFEST_VERSION as u64)
5804 && value
5805 .get("target_generation")
5806 .and_then(serde_json::Value::as_str)
5807 == Some(generation)
5808 && value
5809 .get("source_bytes")
5810 .and_then(serde_json::Value::as_u64)
5811 .is_some_and(|bytes| bytes > 0)
5812 && value
5813 .get("source_blake3")
5814 .and_then(serde_json::Value::as_str)
5815 .is_some_and(|hash| hash.len() == 64)
5816}
5817
5818fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
5819 let pointer_generation = read_pointer(callgraph_dir, project_key);
5820 if let Some(generation) = pointer_generation.as_deref() {
5821 if migration_generation_requires_manifest(generation)
5822 && !migration_manifest_valid(callgraph_dir, generation)
5823 {
5824 let path = callgraph_dir.join(generation);
5825 remove_sqlite_file_set(&path);
5826 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
5827 let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
5828 }
5829 }
5830
5831 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
5832 return;
5833 };
5834 for entry in entries.flatten() {
5835 let name = entry.file_name().to_string_lossy().to_string();
5836 let path = entry.path();
5837 if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
5838 let _ = std::fs::remove_file(path);
5839 continue;
5840 }
5841 if name.starts_with(&format!("{project_key}.g"))
5842 && name.ends_with(".sqlite")
5843 && name.contains(MIGRATION_GENERATION_TAG)
5844 && pointer_generation.as_deref() != Some(&name)
5845 && !migration_manifest_valid(callgraph_dir, &name)
5846 {
5847 remove_sqlite_file_set(&path);
5848 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
5849 }
5850 }
5851 crate::fs_lock::sync_parent(callgraph_dir);
5852}
5853
5854fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
5855 let mut hasher = blake3::Hasher::new();
5856 hasher.update(path.to_string_lossy().as_bytes());
5857 if let Some(generation) = generation {
5858 hasher.update(generation.as_bytes());
5859 }
5860 let digest = hash_to_hex(hasher.finalize());
5861 format!("legacy-{}", &digest[..16])
5862}
5863
5864fn open_readonly_connection(path: &Path) -> Result<Connection> {
5865 let uri = sqlite_readonly_uri(path);
5866 let conn = Connection::open_with_flags(
5867 &uri,
5868 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
5869 )?;
5870 conn.pragma_update(
5871 None,
5872 "synchronous",
5873 if write_amplification_baseline_enabled() {
5874 "FULL"
5875 } else {
5876 "NORMAL"
5877 },
5878 )?;
5879 conn.busy_timeout(reader_busy_timeout())?;
5880 conn.execute_batch("PRAGMA query_only=ON;")?;
5881 Ok(conn)
5882}
5883
5884fn reader_busy_timeout() -> Duration {
5885 let jitter = (now_nanos() % 500) as u64;
5886 Duration::from_millis(250 + jitter)
5887}
5888
5889fn sqlite_readonly_uri(path: &Path) -> String {
5890 let raw = path.to_string_lossy().replace('\\', "/");
5891 let encoded = percent_encode_sqlite_uri_path(&raw);
5892 if raw.starts_with('/') {
5893 format!("file://{encoded}?mode=ro")
5894 } else if raw.as_bytes().get(1) == Some(&b':') {
5895 format!("file:///{encoded}?mode=ro")
5896 } else {
5897 format!("file:{encoded}?mode=ro")
5898 }
5899}
5900
5901fn percent_encode_sqlite_uri_path(path: &str) -> String {
5902 let mut encoded = String::with_capacity(path.len());
5903 for byte in path.bytes() {
5904 match byte {
5905 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
5906 encoded.push(byte as char)
5907 }
5908 _ => encoded.push_str(&format!("%{byte:02X}")),
5909 }
5910 }
5911 encoded
5912}
5913
5914fn configure_connection(conn: &Connection) -> Result<()> {
5915 conn.pragma_update(None, "journal_mode", "WAL")?;
5916 let baseline = write_amplification_baseline_enabled();
5917 conn.pragma_update(
5918 None,
5919 "synchronous",
5920 if baseline { "FULL" } else { "NORMAL" },
5921 )?;
5922 conn.pragma_update(
5923 None,
5924 "wal_autocheckpoint",
5925 if baseline {
5926 1_000
5927 } else {
5928 CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES
5929 },
5930 )?;
5931 conn.pragma_update(None, "busy_timeout", 5_000)?;
5932 Ok(())
5933}
5934
5935fn configure_build_connection(conn: &Connection) -> Result<()> {
5936 conn.pragma_update(None, "journal_mode", "DELETE")?;
5937 conn.pragma_update(
5938 None,
5939 "synchronous",
5940 if write_amplification_baseline_enabled() {
5941 "FULL"
5942 } else {
5943 "NORMAL"
5944 },
5945 )?;
5946 conn.pragma_update(None, "busy_timeout", 5_000)?;
5947 Ok(())
5948}
5949
5950fn checkpoint_sqlite_before_publication(path: &Path) {
5954 let Ok(conn) = Connection::open(path) else {
5955 return;
5956 };
5957 let _ = conn.pragma_update(None, "synchronous", "NORMAL");
5958 let _ = conn.busy_timeout(Duration::from_secs(5));
5959 let _ = checkpoint_wal_truncate(&conn);
5960}
5961
5962fn checkpoint_wal_truncate(conn: &Connection) -> bool {
5963 match conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
5964 row.get::<_, i64>(0)
5965 }) {
5966 Ok(0) => true,
5967 Ok(_) => false,
5968 Err(rusqlite::Error::SqliteFailure(error, _))
5969 if matches!(
5970 error.code,
5971 rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
5972 ) =>
5973 {
5974 false
5975 }
5976 Err(error) => {
5977 log::debug!("callgraph WAL truncate checkpoint skipped: {error}");
5978 false
5979 }
5980 }
5981}
5982
5983fn initialize_schema(conn: &Connection) -> Result<()> {
5984 conn.execute_batch(
5985 "CREATE TABLE IF NOT EXISTS files (
5986 path TEXT PRIMARY KEY,
5987 content_hash TEXT NOT NULL,
5988 mtime_ns INTEGER NOT NULL,
5989 size INTEGER NOT NULL,
5990 lang TEXT NOT NULL,
5991 is_dead_code_root INTEGER NOT NULL DEFAULT 0,
5992 is_public_api INTEGER NOT NULL DEFAULT 0,
5993 surface_fingerprint TEXT NOT NULL,
5994 indexed_at INTEGER NOT NULL
5995 );
5996
5997 CREATE TABLE IF NOT EXISTS nodes (
5998 id TEXT PRIMARY KEY,
5999 file_path TEXT NOT NULL,
6000 name TEXT NOT NULL,
6001 scoped_name TEXT NOT NULL,
6002 kind TEXT NOT NULL,
6003 start_line INTEGER NOT NULL,
6004 start_col INTEGER NOT NULL,
6005 end_line INTEGER NOT NULL,
6006 end_col INTEGER NOT NULL,
6007 range_ordinal INTEGER NOT NULL,
6008 signature TEXT,
6009 exported INTEGER NOT NULL,
6010 is_default_export INTEGER NOT NULL,
6011 is_type_like INTEGER NOT NULL,
6012 is_callgraph_entry_point INTEGER NOT NULL,
6013 provenance TEXT NOT NULL,
6014 UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
6015 );
6016 CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
6017 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
6018 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
6019
6020 CREATE TABLE IF NOT EXISTS refs (
6021 ref_id TEXT PRIMARY KEY,
6022 caller_node TEXT,
6023 caller_file TEXT NOT NULL,
6024 kind TEXT NOT NULL,
6025 short_name TEXT,
6026 full_ref TEXT,
6027 module_path TEXT,
6028 import_kind TEXT,
6029 local_name TEXT,
6030 requested_name TEXT,
6031 namespace_alias TEXT,
6032 wildcard INTEGER NOT NULL DEFAULT 0,
6033 line INTEGER NOT NULL,
6034 byte_start INTEGER NOT NULL,
6035 byte_end INTEGER NOT NULL,
6036 status TEXT NOT NULL,
6037 target_node TEXT,
6038 target_file TEXT,
6039 target_symbol TEXT,
6040 provenance TEXT NOT NULL
6041 );
6042 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
6043 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
6044 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
6045 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
6046 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
6047
6048 CREATE TABLE IF NOT EXISTS file_dependencies (
6049 file_path TEXT NOT NULL,
6050 dep_file TEXT NOT NULL,
6051 PRIMARY KEY(file_path, dep_file)
6052 );
6053 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
6054
6055 CREATE TABLE IF NOT EXISTS edges (
6056 edge_id TEXT PRIMARY KEY,
6057 ref_id TEXT NOT NULL,
6058 source_node TEXT NOT NULL,
6059 target_node TEXT,
6060 target_file TEXT NOT NULL,
6061 target_symbol TEXT NOT NULL,
6062 kind TEXT NOT NULL,
6063 line INTEGER NOT NULL,
6064 provenance TEXT NOT NULL
6065 );
6066 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
6067 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
6068 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
6069 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
6070
6071 CREATE TABLE IF NOT EXISTS dispatch_hints (
6072 id TEXT PRIMARY KEY,
6073 method_name TEXT NOT NULL,
6074 caller_node TEXT NOT NULL,
6075 file TEXT NOT NULL,
6076 line INTEGER NOT NULL,
6077 byte_start INTEGER NOT NULL,
6078 byte_end INTEGER NOT NULL,
6079 provenance TEXT NOT NULL
6080 );
6081 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
6082
6083 CREATE TABLE IF NOT EXISTS type_ref_names (
6084 name TEXT PRIMARY KEY
6085 );
6086
6087 CREATE TABLE IF NOT EXISTS backend_file_state (
6088 backend TEXT NOT NULL,
6089 workspace_root TEXT NOT NULL,
6090 file_path TEXT NOT NULL,
6091 content_hash TEXT NOT NULL,
6092 status TEXT NOT NULL,
6093 updated_at INTEGER NOT NULL,
6094 PRIMARY KEY(backend, workspace_root, file_path, content_hash)
6095 );
6096 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
6097
6098 CREATE TABLE IF NOT EXISTS meta (
6099 k TEXT PRIMARY KEY,
6100 v TEXT NOT NULL
6101 );",
6102 )?;
6103 insert_meta(conn)?;
6104 Ok(())
6105}
6106
6107fn insert_meta(conn: &Connection) -> Result<()> {
6108 conn.execute(
6109 "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
6110 params![SCHEMA_VERSION.to_string()],
6111 )?;
6112 conn.execute(
6113 "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
6114 params![schema_fingerprint()],
6115 )?;
6116 conn.execute(
6117 "INSERT OR IGNORE INTO meta(k, v) VALUES('projection_write_revision', '0')",
6118 [],
6119 )?;
6120 Ok(())
6121}
6122
6123fn projection_write_revision(conn: &Connection) -> Result<Option<u64>> {
6127 let revision: Option<String> = conn
6128 .query_row(
6129 "SELECT v FROM meta WHERE k = 'projection_write_revision'",
6130 [],
6131 |row| row.get(0),
6132 )
6133 .optional()?;
6134 revision
6135 .map(|revision| {
6136 revision.parse::<u64>().map_err(|error| {
6137 CallGraphStoreError::Unavailable(format!(
6138 "callgraph projection write revision is invalid: {error}"
6139 ))
6140 })
6141 })
6142 .transpose()
6143}
6144
6145fn bump_projection_write_revision(tx: &Transaction<'_>) -> Result<()> {
6148 tx.execute(
6149 "INSERT INTO meta(k, v) VALUES('projection_write_revision', '1')
6150 ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1",
6151 [],
6152 )?;
6153 Ok(())
6154}
6155
6156fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
6157 conn.execute(
6158 "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
6159 params![if ready { "1" } else { "0" }],
6160 )?;
6161 Ok(())
6162}
6163
6164fn database_ready(conn: &Connection) -> Result<bool> {
6165 let schema_version: Option<String> = conn
6166 .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
6167 row.get(0)
6168 })
6169 .optional()?;
6170 let fingerprint: Option<String> = conn
6171 .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
6172 row.get(0)
6173 })
6174 .optional()?;
6175 let ready: Option<String> = conn
6176 .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
6177 .optional()?;
6178
6179 let expected_schema = SCHEMA_VERSION.to_string();
6180 let expected_fingerprint = schema_fingerprint();
6181 Ok(schema_version.as_deref() == Some(expected_schema.as_str())
6182 && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
6183 && ready.as_deref() == Some("1"))
6184}
6185
6186fn ensure_database_ready(conn: &Connection) -> Result<()> {
6187 if database_ready(conn)? {
6188 Ok(())
6189 } else {
6190 Err(CallGraphStoreError::Unavailable(
6191 "database is missing, stale, or mid-build".to_string(),
6192 ))
6193 }
6194}
6195
6196fn schema_fingerprint() -> String {
6197 let input =
6202 format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
6203 hash_to_hex(blake3::hash(input.as_bytes()))
6204}
6205
6206fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
6207 tx.execute_batch(
6208 "DELETE FROM edges;
6209 DELETE FROM file_dependencies;
6210 DELETE FROM refs;
6211 DELETE FROM dispatch_hints;
6212 DELETE FROM type_ref_names;
6213 DELETE FROM backend_file_state;
6214 DELETE FROM nodes;
6215 DELETE FROM files;",
6216 )?;
6217 Ok(())
6218}
6219
6220fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
6221 tx.execute_batch(
6222 "DROP INDEX IF EXISTS idx_nodes_file;
6223 DROP INDEX IF EXISTS idx_nodes_name;
6224 DROP INDEX IF EXISTS idx_nodes_scoped;
6225 DROP INDEX IF EXISTS idx_refs_short_name;
6226 DROP INDEX IF EXISTS idx_refs_kind_caller_file;
6227 DROP INDEX IF EXISTS idx_refs_caller_file;
6228 DROP INDEX IF EXISTS idx_refs_caller_node_kind;
6229 DROP INDEX IF EXISTS idx_refs_target_file;
6230 DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
6231 DROP INDEX IF EXISTS idx_edges_source_kind;
6232 DROP INDEX IF EXISTS idx_edges_target_kind;
6233 DROP INDEX IF EXISTS idx_edges_target_file_symbol;
6234 DROP INDEX IF EXISTS idx_edges_ref_id;
6235 DROP INDEX IF EXISTS idx_dispatch_hints_method;
6236 DROP INDEX IF EXISTS idx_backend_file_state_file;",
6237 )?;
6238 Ok(())
6239}
6240
6241fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
6242 tx.execute_batch(
6243 "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
6244 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
6245 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
6246 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
6247 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
6248 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
6249 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
6250 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
6251 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
6252 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
6253 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
6254 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
6255 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
6256 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
6257 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
6258 )?;
6259 Ok(())
6260}
6261
6262const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
6263 ("files", "path"),
6264 ("nodes", "file_path"),
6265 ("refs", "caller_file"),
6266 ("refs", "target_file"),
6267 ("file_dependencies", "file_path"),
6268 ("file_dependencies", "dep_file"),
6269 ("edges", "target_file"),
6270 ("dispatch_hints", "file"),
6271 ("backend_file_state", "file_path"),
6272];
6273
6274fn reconcile_workspace_roots(
6287 conn: &mut Connection,
6288 project_root: &Path,
6289 allow_repair: bool,
6290) -> Result<OpenRootRepair> {
6291 let roots = stored_workspace_roots(conn)?;
6292 let current_root = project_root.display().to_string();
6293 if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
6294 return Ok(OpenRootRepair::None);
6295 }
6296
6297 if let Some(sample) = sample_absolute_data_path(conn)? {
6298 return Ok(OpenRootRepair::NeedsRebuild {
6299 previous_roots: roots,
6300 current_root,
6301 reason: format!("absolute store data path row {sample}"),
6302 });
6303 }
6304
6305 for stored_root in roots.iter() {
6306 if stored_root == ¤t_root {
6307 continue;
6308 }
6309 if Path::new(stored_root).exists() {
6310 let reason = format!(
6311 "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
6312 );
6313 return Ok(OpenRootRepair::NeedsRebuild {
6314 previous_roots: roots,
6315 current_root,
6316 reason,
6317 });
6318 }
6319 }
6320
6321 if !allow_repair {
6322 return Ok(OpenRootRepair::NeedsRebuild {
6323 previous_roots: roots,
6324 current_root,
6325 reason: "workspace root metadata requires deferred repair".to_string(),
6326 });
6327 }
6328
6329 publish_if_current(|| {
6330 let tx = conn.transaction()?;
6331 tx.execute(
6332 "UPDATE OR IGNORE backend_file_state
6333 SET workspace_root = ?1
6334 WHERE workspace_root <> ?1",
6335 params![¤t_root],
6336 )?;
6337 tx.execute(
6338 "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
6339 params![¤t_root],
6340 )?;
6341 tx.commit()?;
6342 Ok(())
6343 })?;
6344
6345 crate::slog_info!(
6346 "callgraph store re-rooted from {} to {}",
6347 roots.join(", "),
6348 current_root
6349 );
6350 Ok(OpenRootRepair::ReRooted)
6351}
6352
6353fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
6354 let mut stmt = conn.prepare(
6355 "SELECT DISTINCT workspace_root
6356 FROM backend_file_state
6357 ORDER BY workspace_root",
6358 )?;
6359 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
6360 rows.collect::<std::result::Result<Vec<_>, _>>()
6361 .map_err(Into::into)
6362}
6363
6364fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
6365 for (table, column) in STORE_DATA_PATH_COLUMNS {
6366 let sql = format!(
6367 "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
6368 );
6369 let mut stmt = conn.prepare(&sql)?;
6370 let mut rows = stmt.query([])?;
6371 while let Some(row) = rows.next()? {
6372 let value: String = row.get(0)?;
6373 if stored_path_is_absolute(&value) {
6374 return Ok(Some(format!("{table}.{column}={value}")));
6375 }
6376 }
6377 }
6378 Ok(None)
6379}
6380
6381fn stored_path_is_absolute(value: &str) -> bool {
6382 if value.is_empty() {
6383 return false;
6384 }
6385 if Path::new(value).is_absolute() || value.starts_with('/') {
6386 return true;
6387 }
6388 let bytes = value.as_bytes();
6389 if bytes.len() >= 3
6390 && bytes[1] == b':'
6391 && (bytes[2] == b'/' || bytes[2] == b'\\')
6392 && bytes[0].is_ascii_alphabetic()
6393 {
6394 return true;
6395 }
6396 value.starts_with("\\\\") || value.starts_with("//")
6397}
6398
6399fn log_root_repair_rebuild(repair: &OpenRootRepair) {
6400 if let OpenRootRepair::NeedsRebuild {
6401 previous_roots,
6402 current_root,
6403 reason,
6404 } = repair
6405 {
6406 crate::slog_info!(
6407 "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
6408 previous_roots.join(", "),
6409 current_root,
6410 reason
6411 );
6412 }
6413}
6414
6415fn now_nanos() -> u128 {
6417 SystemTime::now()
6418 .duration_since(UNIX_EPOCH)
6419 .unwrap_or(Duration::ZERO)
6420 .as_nanos()
6421}
6422
6423fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
6428 callgraph_dir.join(format!("{project_key}.current"))
6429}
6430
6431fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
6435 callgraph_dir.join(format!("{project_key}.sqlite"))
6436}
6437
6438fn generation_file_name(project_key: &str) -> String {
6442 format!(
6443 "{project_key}.g{}.{}.sqlite",
6444 now_nanos(),
6445 std::process::id()
6446 )
6447}
6448
6449fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
6451 let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
6452 let name = text.trim();
6453 if name.is_empty() {
6454 None
6455 } else {
6456 Some(name.to_string())
6457 }
6458}
6459
6460fn db_path_ready(path: &Path) -> bool {
6463 (|| -> Result<bool> {
6464 let conn = open_readonly_connection(path)?;
6465 database_ready(&conn)
6466 })()
6467 .unwrap_or(false)
6468}
6469
6470fn resolve_ready_target(
6478 callgraph_dir: &Path,
6479 project_key: &str,
6480) -> Option<(PathBuf, Option<String>)> {
6481 for _ in 0..5 {
6482 if let Some(generation) = read_pointer(callgraph_dir, project_key) {
6483 let gen_path = callgraph_dir.join(&generation);
6484 if gen_path.is_file() {
6485 return (migration_manifest_valid(callgraph_dir, &generation)
6486 && db_path_ready(&gen_path))
6487 .then_some((gen_path, Some(generation)));
6488 }
6489 std::thread::sleep(Duration::from_millis(5));
6492 continue;
6493 }
6494 let legacy = legacy_sqlite_path(callgraph_dir, project_key);
6496 return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
6497 }
6498 None
6499}
6500
6501fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
6505 let pointer = pointer_path(callgraph_dir, project_key);
6506 let tmp = callgraph_dir.join(format!(
6507 "{project_key}.current.tmp.{}.{}",
6508 std::process::id(),
6509 now_nanos()
6510 ));
6511 {
6512 use std::io::Write as _;
6513 let mut file = std::fs::File::create(&tmp)?;
6514 file.write_all(generation.as_bytes())?;
6515 file.write_all(b"\n")?;
6516 file.sync_all()?;
6517 }
6518 if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
6519 let _ = std::fs::remove_file(&tmp);
6520 return Err(error.into());
6521 }
6522 crate::fs_lock::sync_parent(&pointer);
6523 Ok(())
6524}
6525
6526#[derive(Clone, Debug)]
6527struct GenerationGcCandidate {
6528 name: String,
6529 path: PathBuf,
6530 modified: SystemTime,
6531}
6532
6533fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
6539 let temp_grace = Duration::from_secs(60);
6540 let now = SystemTime::now();
6541 let pointer_current =
6542 read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
6543 let gen_prefix = format!("{project_key}.g");
6544 let tmp_prefixes = [
6545 format!("{project_key}.g"), format!("{project_key}.current."), format!("{project_key}.sqlite.tmp."), ];
6549 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6550 return;
6551 };
6552 let mut gens: Vec<GenerationGcCandidate> = Vec::new();
6553 for entry in entries.flatten() {
6554 let name = entry.file_name();
6555 let name = name.to_string_lossy().to_string();
6556 let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
6557 let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
6558
6559 if name.contains(".tmp.") {
6561 if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
6562 let _ = std::fs::remove_file(entry.path());
6563 }
6564 continue;
6565 }
6566
6567 if name == format!("{project_key}.sqlite") {
6570 remove_sqlite_file_set(&entry.path());
6571 continue;
6572 }
6573
6574 if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
6575 gens.push(GenerationGcCandidate {
6576 name,
6577 path: entry.path(),
6578 modified: mtime,
6579 });
6580 }
6581 }
6582
6583 let mut superseded = gens
6584 .iter()
6585 .filter(|generation| generation.name != pointer_current)
6586 .collect::<Vec<_>>();
6587 superseded.sort_by(|left, right| {
6588 right
6589 .modified
6590 .cmp(&left.modified)
6591 .then_with(|| right.name.cmp(&left.name))
6592 });
6593 let previous = superseded.first().map(|generation| generation.name.clone());
6594
6595 for generation in gens {
6596 let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
6597 if generation.name == pointer_current
6598 || Some(generation.name.as_str()) == previous.as_deref()
6599 {
6600 continue;
6601 }
6602
6603 let age = now
6604 .duration_since(generation.modified)
6605 .unwrap_or(Duration::ZERO);
6606 if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
6607 continue;
6608 }
6609
6610 remove_sqlite_file_set(&generation.path);
6611 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
6612 let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
6613 callgraph_dir,
6614 &generation.name,
6615 ));
6616 }
6617}
6618
6619fn remove_sqlite_file_set(path: &Path) {
6620 let _ = std::fs::remove_file(path);
6621 remove_sqlite_sidecars(path);
6622}
6623
6624fn remove_sqlite_sidecars(path: &Path) {
6625 let path_text = path.to_string_lossy();
6626 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
6627 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
6628 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
6629}
6630
6631const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
6645
6646fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
6658 sweep_orphaned_build_temps(callgraph_dir);
6659 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6660 return;
6661 };
6662 let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
6663
6664 if let Ok(entries) = std::fs::read_dir(storage_root.join(domain)) {
6666 for entry in entries.flatten() {
6667 if entry.path().is_dir() {
6668 sweep_orphaned_build_temps(&entry.path());
6669 }
6670 }
6671 }
6672
6673 if let Ok(entries) = std::fs::read_dir(&storage_root) {
6675 for entry in entries.flatten() {
6676 let legacy_dir = entry.path().join(domain);
6677 if legacy_dir.is_dir() {
6678 sweep_orphaned_build_temps(&legacy_dir);
6679 }
6680 }
6681 }
6682}
6683
6684fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
6687 sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
6688}
6689
6690fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
6693 let now = SystemTime::now();
6694 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6695 return;
6696 };
6697 let mut removed_any = false;
6698 for entry in entries.flatten() {
6699 let name = entry.file_name().to_string_lossy().to_string();
6700 if !name.contains(".sqlite.tmp.") {
6706 continue;
6707 }
6708 let mtime = entry
6709 .metadata()
6710 .and_then(|meta| meta.modified())
6711 .unwrap_or(now);
6712 if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
6713 continue;
6714 }
6715 match std::fs::remove_file(entry.path()) {
6721 Ok(()) => removed_any = true,
6722 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
6723 Err(_) => {}
6724 }
6725 }
6726 if removed_any {
6727 crate::fs_lock::sync_parent(callgraph_dir);
6728 }
6729}
6730
6731fn build_pool_size() -> usize {
6739 std::thread::available_parallelism()
6740 .map(|parallelism| parallelism.get())
6741 .unwrap_or(1)
6742 .div_ceil(2)
6743 .clamp(1, 8)
6744}
6745
6746fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
6747 let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
6748 Ok(extract) => Ok(extract),
6749 Err(error) => {
6750 let abs_path =
6751 normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
6752 let rel_path = relative_path(project_root, &abs_path);
6753 let freshness = cache_freshness::collect(&abs_path).ok();
6754 log::debug!(
6755 "callgraph store: skipping {} during cold build: {}",
6756 abs_path.display(),
6757 error
6758 );
6759 Err(ExtractFailure {
6760 rel_path,
6761 freshness,
6762 })
6763 }
6764 };
6765
6766 let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
6767 files.par_iter().map(extract_one).collect()
6768 };
6769
6770 let results = match rayon::ThreadPoolBuilder::new()
6773 .num_threads(build_pool_size())
6774 .thread_name(|index| format!("aft-callgraph-build-{index}"))
6775 .stack_size(8 * 1024 * 1024)
6776 .build()
6777 {
6778 Ok(pool) => pool.install(run),
6779 Err(error) => {
6780 log::warn!(
6781 "callgraph store: bounded build pool unavailable ({error}); using global pool"
6782 );
6783 run()
6784 }
6785 };
6786
6787 let mut extracts = Vec::new();
6788 let mut failures = Vec::new();
6789 for result in results {
6790 match result {
6791 Ok(extract) => extracts.push(extract),
6792 Err(failure) => failures.push(failure),
6793 }
6794 }
6795 BuildExtractsResult { extracts, failures }
6796}
6797
6798fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
6799 let metadata = std::fs::metadata(path)?;
6800 let size = metadata.len();
6801 let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
6802 cache_freshness::zero_hash()
6803 } else if source.len() as u64 == size {
6804 cache_freshness::hash_bytes(source.as_bytes())
6805 } else {
6806 cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
6807 };
6808 Ok(FileFreshness {
6809 mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
6810 size,
6811 content_hash,
6812 })
6813}
6814
6815fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
6816 let abs_path = normalize_file_path(project_root, path)?;
6817 let rel_path = relative_path(project_root, &abs_path);
6818 let source = std::fs::read_to_string(&abs_path)?;
6819 let freshness = collect_source_freshness(&abs_path, &source)?;
6820 let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
6821 let lang = data.lang;
6822 if lang == LangId::Rust {
6823 extend_rust_imports_with_nested_uses(&source, &mut data);
6824 }
6825 let mut nodes = build_node_records(&rel_path, &source, &data)?;
6826 let node_by_scoped: HashMap<String, String> = nodes
6827 .iter()
6828 .map(|node| (node.scoped_name.clone(), node.id.clone()))
6829 .collect();
6830 let import_dependencies =
6831 import_dependencies(project_root, &abs_path, &data.import_block.imports);
6832 let line_index = LineIndex::new(&source);
6833 let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
6834 let rust_reexports = if lang == LangId::Rust {
6835 collect_rust_pub_use_reexport_refs(
6836 project_root,
6837 &abs_path,
6838 &rel_path,
6839 &data.import_block.imports,
6840 &line_index,
6841 )
6842 } else {
6843 ReexportRefs {
6844 raw_refs: Vec::new(),
6845 surface_parts: Vec::new(),
6846 }
6847 };
6848 let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
6849 let mut raw_refs = Vec::new();
6850 raw_refs.extend(build_call_refs(
6851 &rel_path,
6852 &data,
6853 &node_by_scoped,
6854 &import_dependencies,
6855 ));
6856 raw_refs.extend(build_value_ref_refs(
6857 &rel_path,
6858 &data,
6859 &node_by_scoped,
6860 &import_dependencies,
6861 ));
6862 raw_refs.extend(build_import_refs(
6863 project_root,
6864 &abs_path,
6865 &rel_path,
6866 &data.import_block.imports,
6867 &line_index,
6868 ));
6869 let mut surface_parts = reexports.surface_parts;
6870 surface_parts.extend(rust_reexports.surface_parts);
6871 surface_parts.extend(source_less_exports.surface_parts);
6872 raw_refs.extend(reexports.raw_refs);
6873 raw_refs.extend(rust_reexports.raw_refs);
6874 raw_refs.extend(source_less_exports.raw_refs);
6875 let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
6876 let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
6877
6878 Ok(FileExtract {
6879 rel_path,
6880 freshness,
6881 lang,
6882 data,
6883 nodes,
6884 raw_refs,
6885 dispatch_hints,
6886 surface_fingerprint,
6887 })
6888}
6889
6890fn build_node_records(
6891 rel_path: &str,
6892 source: &str,
6893 data: &FileCallData,
6894) -> Result<Vec<NodeRecord>> {
6895 let mut records = Vec::new();
6896 let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
6897 let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
6898 metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
6899
6900 for (scoped_name, meta) in metadata {
6901 let name = unqualified_name(scoped_name).to_string();
6902 let range = selection_range(source, scoped_name, &name, &meta.range);
6903 let range_key = (
6904 range.start_line,
6905 range.start_col,
6906 range.end_line,
6907 range.end_col,
6908 );
6909 let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
6910 let range_ordinal = *ordinal;
6911 *ordinal += 1;
6912 let id = node_id(rel_path, &range, range_ordinal, scoped_name);
6913 let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
6914 let is_default_export = data
6915 .default_export_symbol
6916 .as_deref()
6917 .map(|default| default == scoped_name || default == name)
6918 .unwrap_or(false);
6919 records.push(NodeRecord {
6920 id,
6921 file_path: rel_path.to_string(),
6922 name: name.clone(),
6923 scoped_name: scoped_name.clone(),
6924 kind: symbol_kind_label(&meta.kind).to_string(),
6925 range,
6926 range_ordinal,
6927 signature: meta.signature.clone(),
6928 exported,
6929 is_default_export,
6930 is_type_like: is_type_like(&meta.kind),
6931 is_callgraph_entry_point: meta.entry_point_attribute.is_some()
6932 || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
6933 });
6934 }
6935
6936 Ok(records)
6937}
6938
6939fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
6940 if scoped_name == TOP_LEVEL_SYMBOL {
6941 return Range {
6942 start_line: 0,
6943 start_col: 0,
6944 end_line: 0,
6945 end_col: 0,
6946 };
6947 }
6948 let Some(line) = source.lines().nth(fallback.start_line as usize) else {
6949 return fallback.clone();
6950 };
6951 let start_col = fallback.start_col as usize;
6952 let search_start = start_col.min(line.len());
6953 if let Some(offset) = line[search_start..].find(name) {
6954 let col = search_start + offset;
6955 return Range {
6956 start_line: fallback.start_line,
6957 start_col: col as u32,
6958 end_line: fallback.start_line,
6959 end_col: (col + name.len()) as u32,
6960 };
6961 }
6962 if let Some(offset) = line.find(name) {
6963 return Range {
6964 start_line: fallback.start_line,
6965 start_col: offset as u32,
6966 end_line: fallback.start_line,
6967 end_col: (offset + name.len()) as u32,
6968 };
6969 }
6970 Range {
6971 start_line: fallback.start_line,
6972 start_col: fallback.start_col,
6973 end_line: fallback.start_line,
6974 end_col: fallback.start_col.saturating_add(name.len() as u32),
6975 }
6976}
6977
6978fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
6979 if scoped_name == TOP_LEVEL_SYMBOL {
6980 return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
6981 }
6982 let input = format!(
6983 "{rel_path}:{}:{}:{}:{}:{ordinal}",
6984 range.start_line, range.start_col, range.end_line, range.end_col
6985 );
6986 format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
6987}
6988
6989fn build_call_refs(
6990 rel_path: &str,
6991 data: &FileCallData,
6992 node_by_scoped: &HashMap<String, String>,
6993 import_dependencies: &BTreeSet<String>,
6994) -> Vec<RawRef> {
6995 build_callable_refs(
6996 rel_path,
6997 &data.calls_by_symbol,
6998 node_by_scoped,
6999 import_dependencies,
7000 "call",
7001 )
7002}
7003
7004fn build_value_ref_refs(
7005 rel_path: &str,
7006 data: &FileCallData,
7007 node_by_scoped: &HashMap<String, String>,
7008 import_dependencies: &BTreeSet<String>,
7009) -> Vec<RawRef> {
7010 build_callable_refs(
7011 rel_path,
7012 &data.value_refs_by_symbol,
7013 node_by_scoped,
7014 import_dependencies,
7015 "value_ref",
7016 )
7017}
7018
7019fn build_callable_refs(
7020 rel_path: &str,
7021 sites_by_symbol: &HashMap<String, Vec<callgraph::CallSite>>,
7022 node_by_scoped: &HashMap<String, String>,
7023 import_dependencies: &BTreeSet<String>,
7024 kind: &str,
7025) -> Vec<RawRef> {
7026 let mut refs = Vec::new();
7027 let mut ordinal = 0usize;
7028 let mut symbols: Vec<_> = sites_by_symbol.iter().collect();
7029 symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
7030 for (caller_symbol, call_sites) in symbols {
7031 let caller_node = node_by_scoped.get(caller_symbol).cloned();
7032 for call_site in call_sites {
7033 ordinal += 1;
7034 let ref_id = ref_id(&[
7035 rel_path,
7036 kind,
7037 caller_symbol,
7038 &call_site.line.to_string(),
7039 &call_site.byte_start.to_string(),
7040 &call_site.byte_end.to_string(),
7041 &call_site.full_callee,
7042 &ordinal.to_string(),
7043 ]);
7044 refs.push(RawRef {
7045 ref_id,
7046 caller_node: caller_node.clone(),
7047 caller_symbol: Some(caller_symbol.clone()),
7048 caller_file: rel_path.to_string(),
7049 kind: kind.to_string(),
7050 short_name: Some(call_site.callee_name.clone()),
7051 full_ref: Some(call_site.full_callee.clone()),
7052 module_path: None,
7053 import_kind: None,
7054 local_name: Some(call_site.callee_name.clone()),
7055 requested_name: Some(call_site.callee_name.clone()),
7056 namespace_alias: namespace_alias(&call_site.full_callee),
7057 wildcard: false,
7058 line: call_site.line,
7059 byte_start: call_site.byte_start,
7060 byte_end: call_site.byte_end,
7061 dependencies: import_dependencies.clone(),
7062 });
7063 }
7064 }
7065 refs
7066}
7067
7068fn build_import_refs(
7069 project_root: &Path,
7070 abs_path: &Path,
7071 rel_path: &str,
7072 imports: &[ImportStatement],
7073 line_index: &LineIndex,
7074) -> Vec<RawRef> {
7075 let mut refs = Vec::new();
7076 for (index, import) in imports.iter().enumerate() {
7077 let import_kind = import_kind_label(import.kind).to_string();
7078 let local_name = import_local_names(import).join(",");
7079 let requested_name = import_requested_names(import).join(",");
7080 let ref_id = ref_id(&[
7081 rel_path,
7082 "import",
7083 &import.byte_range.start.to_string(),
7084 &import.byte_range.end.to_string(),
7085 &import.module_path,
7086 &index.to_string(),
7087 ]);
7088 refs.push(RawRef {
7089 ref_id,
7090 caller_node: None,
7091 caller_symbol: None,
7092 caller_file: rel_path.to_string(),
7093 kind: "import".to_string(),
7094 short_name: None,
7095 full_ref: Some(import.raw_text.clone()),
7096 module_path: Some(import.module_path.clone()),
7097 import_kind: Some(import_kind),
7098 local_name: empty_to_none(local_name),
7099 requested_name: empty_to_none(requested_name),
7100 namespace_alias: import.namespace_import.clone(),
7101 wildcard: import_is_wildcard(import),
7102 line: line_index.byte_to_line(import.byte_range.start),
7103 byte_start: import.byte_range.start,
7104 byte_end: import.byte_range.end,
7105 dependencies: module_dependencies(project_root, abs_path, &import.module_path),
7106 });
7107 }
7108 refs
7109}
7110
7111fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
7112 let grammar = grammar_for(LangId::Rust);
7113 let mut parser = Parser::new();
7114 if parser.set_language(&grammar).is_err() {
7115 return;
7116 }
7117 let Some(tree) = parser.parse(source, None) else {
7118 return;
7119 };
7120
7121 let mut seen = data
7122 .import_block
7123 .imports
7124 .iter()
7125 .map(|import| (import.byte_range.start, import.byte_range.end))
7126 .collect::<HashSet<_>>();
7127 let mut nested_imports = Vec::new();
7128 collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
7129 if nested_imports.is_empty() {
7130 return;
7131 }
7132
7133 data.import_block.imports.extend(nested_imports);
7134 data.import_block
7135 .imports
7136 .sort_by_key(|import| import.byte_range.start);
7137 data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
7138}
7139
7140fn collect_rust_use_imports(
7141 source: &str,
7142 node: Node<'_>,
7143 seen: &mut HashSet<(usize, usize)>,
7144 imports: &mut Vec<ImportStatement>,
7145) {
7146 if node.kind() == "use_declaration" {
7147 let range = node.byte_range();
7148 if seen.insert((range.start, range.end)) {
7149 if let Some(import) = rust_import_from_use_node(source, node) {
7150 imports.push(import);
7151 }
7152 }
7153 }
7154
7155 let mut cursor = node.walk();
7156 if !cursor.goto_first_child() {
7157 return;
7158 }
7159 loop {
7160 collect_rust_use_imports(source, cursor.node(), seen, imports);
7161 if !cursor.goto_next_sibling() {
7162 break;
7163 }
7164 }
7165}
7166
7167fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
7168 let raw_text = source[node.byte_range()].to_string();
7169 let body = rust_use_body(&raw_text)?.to_string();
7170 let visibility = rust_use_visibility(&raw_text);
7171 let names = rust_use_list_names(&body);
7172 let group = classify_rust_import_group(&body);
7173 let byte_range = node.byte_range();
7174
7175 Some(ImportStatement {
7176 module_path: body,
7177 names: names.clone(),
7178 default_import: visibility.clone(),
7179 namespace_import: None,
7180 kind: ImportKind::Value,
7181 group,
7182 byte_range,
7183 raw_text,
7184 form: ImportForm::RustUse {
7185 visibility,
7186 named: names,
7187 },
7188 })
7189}
7190
7191fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
7192 let start = imports.iter().map(|import| import.byte_range.start).min()?;
7193 let end = imports.iter().map(|import| import.byte_range.end).max()?;
7194 Some(start..end)
7195}
7196
7197fn rust_use_visibility(raw_text: &str) -> Option<String> {
7198 let use_pos = raw_text.find("use ")?;
7199 let prefix = raw_text[..use_pos].trim();
7200 if prefix.is_empty() {
7201 None
7202 } else {
7203 Some(prefix.to_string())
7204 }
7205}
7206
7207fn rust_use_body(raw_text: &str) -> Option<&str> {
7208 let use_pos = raw_text.find("use ")?;
7209 Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
7210}
7211
7212fn rust_use_list_names(body: &str) -> Vec<String> {
7213 let Some(open) = body.find("::{") else {
7214 return Vec::new();
7215 };
7216 let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
7217 return Vec::new();
7218 };
7219 body[open + 3..close]
7220 .split(',')
7221 .filter_map(|spec| {
7222 let spec = spec.trim();
7223 if spec.is_empty() {
7224 None
7225 } else {
7226 Some(spec.to_string())
7227 }
7228 })
7229 .collect()
7230}
7231
7232fn classify_rust_import_group(body: &str) -> ImportGroup {
7233 let first = body
7234 .split("::")
7235 .next()
7236 .unwrap_or(body)
7237 .split_whitespace()
7238 .next()
7239 .unwrap_or(body);
7240 match first.trim() {
7241 "std" | "core" | "alloc" => ImportGroup::Stdlib,
7242 "crate" | "self" | "super" => ImportGroup::Internal,
7243 _ => ImportGroup::External,
7244 }
7245}
7246
7247#[derive(Debug, Clone)]
7248struct ReexportRefs {
7249 raw_refs: Vec<RawRef>,
7250 surface_parts: Vec<String>,
7251}
7252
7253fn collect_reexport_refs(
7254 project_root: &Path,
7255 abs_path: &Path,
7256 rel_path: &str,
7257 source: &str,
7258) -> ReexportRefs {
7259 let mut raw_refs = Vec::new();
7260 let mut surface_parts = Vec::new();
7261 let mut search_start = 0usize;
7262 let mut ordinal = 0usize;
7263 while let Some(export_offset) = source[search_start..].find("export") {
7264 let start = search_start + export_offset;
7265 let Some(statement_end_offset) = source[start..].find(';') else {
7266 break;
7267 };
7268 let end = start + statement_end_offset + 1;
7269 let statement = &source[start..end];
7270 search_start = end;
7271 if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
7272 continue;
7273 }
7274 let Some(module_path) = quoted_module_path(statement) else {
7275 continue;
7276 };
7277 ordinal += 1;
7278 let wildcard = statement.contains('*');
7279 let line = source[..start]
7280 .bytes()
7281 .filter(|byte| *byte == b'\n')
7282 .count() as u32
7283 + 1;
7284 let ref_id = ref_id(&[
7285 rel_path,
7286 "reexport",
7287 &start.to_string(),
7288 &end.to_string(),
7289 &module_path,
7290 &ordinal.to_string(),
7291 ]);
7292 surface_parts.push(format!("reexport\t{statement}"));
7293 raw_refs.push(RawRef {
7294 ref_id,
7295 caller_node: None,
7296 caller_symbol: None,
7297 caller_file: rel_path.to_string(),
7298 kind: "reexport".to_string(),
7299 short_name: None,
7300 full_ref: Some(statement.to_string()),
7301 module_path: Some(module_path.clone()),
7302 import_kind: Some("reexport".to_string()),
7303 local_name: None,
7304 requested_name: None,
7305 namespace_alias: None,
7306 wildcard,
7307 line,
7308 byte_start: start,
7309 byte_end: end,
7310 dependencies: module_dependencies(project_root, abs_path, &module_path),
7311 });
7312 }
7313 ReexportRefs {
7314 raw_refs,
7315 surface_parts,
7316 }
7317}
7318
7319fn collect_rust_pub_use_reexport_refs(
7320 project_root: &Path,
7321 abs_path: &Path,
7322 rel_path: &str,
7323 imports: &[ImportStatement],
7324 line_index: &LineIndex,
7325) -> ReexportRefs {
7326 let mut raw_refs = Vec::new();
7327 let mut surface_parts = Vec::new();
7328 let mut ordinal = 0usize;
7329
7330 for import in imports {
7331 let Some(visibility) = &import.default_import else {
7332 continue;
7333 };
7334 if !visibility.starts_with("pub") {
7335 continue;
7336 }
7337 let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
7338 continue;
7339 };
7340 ordinal += 1;
7341 let ref_id = ref_id(&[
7342 rel_path,
7343 "rust_reexport",
7344 &import.byte_range.start.to_string(),
7345 &import.byte_range.end.to_string(),
7346 &module_path,
7347 &ordinal.to_string(),
7348 ]);
7349 surface_parts.push(format!("reexport\t{}", import.raw_text));
7350 raw_refs.push(RawRef {
7351 ref_id,
7352 caller_node: None,
7353 caller_symbol: None,
7354 caller_file: rel_path.to_string(),
7355 kind: "reexport".to_string(),
7356 short_name: None,
7357 full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
7358 module_path: Some(module_path.clone()),
7359 import_kind: Some("reexport".to_string()),
7360 local_name: None,
7361 requested_name: None,
7362 namespace_alias: None,
7363 wildcard,
7364 line: line_index.byte_to_line(import.byte_range.start),
7365 byte_start: import.byte_range.start,
7366 byte_end: import.byte_range.end,
7367 dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
7368 });
7369 }
7370
7371 ReexportRefs {
7372 raw_refs,
7373 surface_parts,
7374 }
7375}
7376
7377fn rust_pub_use_reexport_parts(
7378 import: &ImportStatement,
7379) -> Option<(String, HashMap<String, String>, bool)> {
7380 let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
7381 let body = body.trim();
7382 if let Some(module_path) = body.strip_suffix("::*") {
7383 return Some((module_path.trim().to_string(), HashMap::new(), true));
7384 }
7385
7386 if let Some(brace_start) = body.find("::{") {
7387 let module_path = body[..brace_start].trim().to_string();
7388 let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
7389 if names.is_empty() {
7390 return None;
7391 }
7392 return Some((module_path, names, false));
7393 }
7394
7395 let (module_path, spec) = body.rsplit_once("::")?;
7396 let names = rust_reexport_names_from_specs(spec);
7397 if names.is_empty() {
7398 return None;
7399 }
7400 Some((module_path.trim().to_string(), names, false))
7401}
7402
7403fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
7404 let mut names = HashMap::new();
7405 for spec in specs.split(',') {
7406 let spec = spec.trim();
7407 if spec.is_empty() || spec == "self" {
7408 continue;
7409 }
7410 if let Some((source, local)) = spec.split_once(" as ") {
7411 let source = source.trim();
7412 let local = local.trim();
7413 if !source.is_empty() && !local.is_empty() && source != "self" {
7414 names.insert(local.to_string(), source.to_string());
7415 }
7416 } else {
7417 names.insert(spec.to_string(), spec.to_string());
7418 }
7419 }
7420 names
7421}
7422
7423fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
7424 if named.is_empty() {
7425 return fallback.to_string();
7426 }
7427 let mut specs = named
7428 .iter()
7429 .map(|(local, source)| {
7430 if local == source {
7431 source.clone()
7432 } else {
7433 format!("{source} as {local}")
7434 }
7435 })
7436 .collect::<Vec<_>>();
7437 specs.sort();
7438 format!("pub use {{{}}};", specs.join(", "))
7439}
7440
7441fn quoted_module_path(statement: &str) -> Option<String> {
7442 let quote = match (statement.find('\''), statement.find('"')) {
7443 (Some(single), Some(double)) if single < double => '\'',
7444 (Some(_), Some(_)) => '"',
7445 (Some(_), None) => '\'',
7446 (None, Some(_)) => '"',
7447 (None, None) => return None,
7448 };
7449 let start = statement.find(quote)? + 1;
7450 let end = statement[start..].find(quote)? + start;
7451 Some(statement[start..end].to_string())
7452}
7453
7454#[derive(Debug, Clone)]
7455struct SourceLessExportRefs {
7456 raw_refs: Vec<RawRef>,
7457 surface_parts: Vec<String>,
7458}
7459
7460fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
7461 let mut raw_refs = Vec::new();
7462 let mut surface_parts = Vec::new();
7463 let mut search_start = 0usize;
7464 let mut ordinal = 0usize;
7465 while let Some(export_offset) = source[search_start..].find("export") {
7466 let start = search_start + export_offset;
7467 let Some(statement_end_offset) = source[start..].find(';') else {
7468 break;
7469 };
7470 let end = start + statement_end_offset + 1;
7471 let statement = &source[start..end];
7472 search_start = end;
7473 if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
7474 continue;
7475 }
7476 let aliases = parse_reexport_names(statement);
7477 if aliases.is_empty() {
7478 continue;
7479 }
7480 let line = source[..start]
7481 .bytes()
7482 .filter(|byte| *byte == b'\n')
7483 .count() as u32
7484 + 1;
7485 for (exported, source_symbol) in aliases {
7486 ordinal += 1;
7487 let ref_id = ref_id(&[
7488 rel_path,
7489 "export_alias",
7490 &start.to_string(),
7491 &end.to_string(),
7492 &exported,
7493 &source_symbol,
7494 &ordinal.to_string(),
7495 ]);
7496 surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
7497 raw_refs.push(RawRef {
7498 ref_id,
7499 caller_node: None,
7500 caller_symbol: None,
7501 caller_file: rel_path.to_string(),
7502 kind: "export_alias".to_string(),
7503 short_name: None,
7504 full_ref: Some(statement.to_string()),
7505 module_path: None,
7506 import_kind: Some("export_alias".to_string()),
7507 local_name: Some(exported),
7508 requested_name: Some(source_symbol),
7509 namespace_alias: None,
7510 wildcard: false,
7511 line,
7512 byte_start: start,
7513 byte_end: end,
7514 dependencies: BTreeSet::new(),
7515 });
7516 }
7517 }
7518 SourceLessExportRefs {
7519 raw_refs,
7520 surface_parts,
7521 }
7522}
7523
7524fn build_dispatch_hints(
7525 rel_path: &str,
7526 data: &FileCallData,
7527 node_by_scoped: &HashMap<String, String>,
7528) -> Vec<DispatchHint> {
7529 let mut hints = Vec::new();
7530 let mut ordinal = 0usize;
7531 for (caller_symbol, call_sites) in &data.calls_by_symbol {
7532 let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
7533 continue;
7534 };
7535 for call_site in call_sites {
7536 if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
7537 continue;
7538 }
7539 ordinal += 1;
7540 hints.push(DispatchHint {
7541 id: ref_id(&[
7542 rel_path,
7543 "dispatch",
7544 caller_symbol,
7545 &call_site.line.to_string(),
7546 &call_site.byte_start.to_string(),
7547 &call_site.byte_end.to_string(),
7548 &ordinal.to_string(),
7549 ]),
7550 method_name: call_site.callee_name.clone(),
7551 caller_node: caller_node.clone(),
7552 file: rel_path.to_string(),
7553 line: call_site.line,
7554 byte_start: call_site.byte_start,
7555 byte_end: call_site.byte_end,
7556 });
7557 }
7558 }
7559 hints
7560}
7561
7562fn surface_fingerprint(
7563 nodes: &mut [NodeRecord],
7564 data: &FileCallData,
7565 reexport_parts: &[String],
7566) -> String {
7567 nodes.sort_by(|left, right| {
7568 (left.file_path.as_str(), left.scoped_name.as_str())
7569 .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
7570 });
7571 let mut parts = Vec::new();
7572 for node in nodes.iter() {
7573 parts.push(format!(
7574 "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
7575 node.scoped_name,
7576 node.name,
7577 node.kind,
7578 node.exported,
7579 node.range.start_line,
7580 node.range.start_col,
7581 node.range.end_line,
7582 node.range.end_col,
7583 node.range_ordinal,
7584 node.signature.as_deref().unwrap_or("")
7585 ));
7586 }
7587 let mut exports = data.exported_symbols.clone();
7588 exports.sort();
7589 for export in exports {
7590 parts.push(format!("export\t{export}"));
7591 }
7592 if let Some(default_export) = &data.default_export_symbol {
7593 parts.push(format!("default\t{default_export}"));
7594 }
7595 let mut imports: Vec<String> = data
7596 .import_block
7597 .imports
7598 .iter()
7599 .map(|import| {
7600 format!(
7601 "import\t{}\t{:?}\t{}",
7602 import.module_path, import.form, import.raw_text
7603 )
7604 })
7605 .collect();
7606 imports.sort();
7607 parts.extend(imports);
7608 parts.extend(reexport_parts.iter().cloned());
7609 hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
7610}
7611
7612fn resolve_ref(raw: RawRef, index: &ProjectIndex<'_>) -> Result<ResolvedRef> {
7613 if !matches!(raw.kind.as_str(), "call" | "value_ref") {
7614 return Ok(ResolvedRef {
7615 dependencies: raw.dependencies.clone(),
7616 raw,
7617 status: "unresolved".to_string(),
7618 target_node: None,
7619 target_file: None,
7620 target_symbol: None,
7621 edge: None,
7622 });
7623 }
7624
7625 let caller_file = raw.caller_file.clone();
7626 let caller_data = index.caller_data.get(&caller_file).ok_or_else(|| {
7627 CallGraphStoreError::MissingCallerData {
7628 file: caller_file.clone(),
7629 }
7630 })?;
7631 let full_ref = raw.full_ref.as_deref().unwrap_or_default();
7632 let short_name = raw.short_name.as_deref().unwrap_or_default();
7633 let mut dependencies = raw.dependencies.clone();
7634
7635 let resolved = match index.lang_for(&caller_file) {
7636 Some(LangId::Rust) => {
7637 resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
7638 }
7639 Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
7640 resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
7641 }
7642 _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
7643 };
7644
7645 let Some((status, target_file, target_symbol)) = resolved else {
7646 return Ok(ResolvedRef {
7647 raw,
7648 status: "unresolved".to_string(),
7649 target_node: None,
7650 target_file: None,
7651 target_symbol: None,
7652 dependencies,
7653 edge: None,
7654 });
7655 };
7656
7657 dependencies.insert(target_file.clone());
7658 let target_node = index.node_for_symbol(&target_file, &target_symbol);
7659 if raw.kind == "value_ref"
7660 && !target_node
7661 .as_deref()
7662 .is_some_and(|node_id| index.node_is_callable(&target_file, node_id))
7663 {
7664 return Ok(ResolvedRef {
7665 raw,
7666 status: "unresolved".to_string(),
7667 target_node: None,
7668 target_file: None,
7669 target_symbol: None,
7670 dependencies,
7671 edge: None,
7672 });
7673 }
7674 let source_node = raw.caller_node.clone();
7675 let edge = if let Some(source_node) = source_node {
7676 if target_file == caller_file
7677 && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
7678 {
7679 None
7680 } else {
7681 Some(EdgeRecord {
7682 edge_id: ref_id(&[&raw.ref_id, "edge"]),
7683 source_node,
7684 target_node: target_node.clone(),
7685 target_file: target_file.clone(),
7686 target_symbol: target_symbol.clone(),
7687 kind: raw.kind.clone(),
7688 line: raw.line,
7689 })
7690 }
7691 } else {
7692 None
7693 };
7694
7695 Ok(ResolvedRef {
7696 raw,
7697 status,
7698 target_node,
7699 target_file: Some(target_file),
7700 target_symbol: Some(target_symbol),
7701 dependencies,
7702 edge,
7703 })
7704}
7705
7706fn resolve_js_ts_target(
7707 index: &ProjectIndex<'_>,
7708 caller_file: &str,
7709 full_ref: &str,
7710 short_name: &str,
7711 caller_data: &FileCallData,
7712) -> Option<(String, String, String)> {
7713 if let Some((namespace, member)) = full_ref.split_once('.') {
7714 for import in &caller_data.import_block.imports {
7715 if import.namespace_import.as_deref() == Some(namespace) {
7716 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7717 if let Some((file, symbol)) =
7718 resolve_exported_symbol(index, &target_file, member, 0)
7719 {
7720 return Some(("resolved".to_string(), file, symbol));
7721 }
7722 }
7723 }
7724 }
7725 }
7726
7727 for import in &caller_data.import_block.imports {
7728 for spec in &import.names {
7729 if crate::imports::specifier_local_name(spec) == short_name {
7730 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7731 let requested = crate::imports::specifier_imported_name(spec);
7732 let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
7733 .unwrap_or_else(|| (target_file, requested.to_string()));
7734 return Some(("resolved".to_string(), file, symbol));
7735 }
7736 }
7737 }
7738
7739 if import.default_import.as_deref() == Some(short_name) {
7740 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7741 let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
7742 .or_else(|| {
7743 index
7744 .files
7745 .get(&target_file)
7746 .and_then(|file| file.default_export.clone())
7747 .map(|symbol| (target_file.clone(), symbol))
7748 })
7749 .unwrap_or_else(|| {
7750 let file_name = Path::new(&target_file)
7751 .file_name()
7752 .and_then(|name| name.to_str())
7753 .unwrap_or("unknown")
7754 .to_string();
7755 (target_file, format!("<default:{file_name}>"))
7756 });
7757 return Some(("resolved".to_string(), file, symbol));
7758 }
7759 }
7760 }
7761
7762 for import in &caller_data.import_block.imports {
7763 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7764 if index
7765 .files
7766 .get(&target_file)
7767 .map(|file| file.exports.contains(short_name))
7768 .unwrap_or(false)
7769 {
7770 return Some(("resolved".to_string(), target_file, short_name.to_string()));
7771 }
7772 }
7773 }
7774
7775 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
7776}
7777
7778fn resolve_exported_symbol(
7779 index: &ProjectIndex<'_>,
7780 file: &str,
7781 requested: &str,
7782 depth: usize,
7783) -> Option<(String, String)> {
7784 let mut visited = std::collections::HashMap::new();
7785 resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
7786}
7787
7788fn resolve_exported_symbol_inner(
7797 index: &ProjectIndex<'_>,
7798 file: &str,
7799 requested: &str,
7800 depth: usize,
7801 visited: &mut std::collections::HashMap<(String, String), usize>,
7802) -> Option<(String, String)> {
7803 if depth > 16 {
7804 return None;
7805 }
7806 if requested != "default" {
7807 if let Some(source_symbol) = index
7808 .files
7809 .get(file)
7810 .and_then(|item| item.export_aliases.get(requested))
7811 {
7812 return Some((file.to_string(), source_symbol.clone()));
7813 }
7814 if index
7815 .files
7816 .get(file)
7817 .map(|item| item.exports.contains(requested))
7818 .unwrap_or(false)
7819 {
7820 return Some((file.to_string(), requested.to_string()));
7821 }
7822 } else if let Some(default) = index
7823 .files
7824 .get(file)
7825 .and_then(|item| item.default_export.clone())
7826 {
7827 return Some((file.to_string(), default));
7828 }
7829
7830 match visited.entry((file.to_string(), requested.to_string())) {
7834 std::collections::hash_map::Entry::Occupied(mut seen) => {
7835 if *seen.get() <= depth {
7836 return None;
7837 }
7838 seen.insert(depth);
7839 }
7840 std::collections::hash_map::Entry::Vacant(slot) => {
7841 slot.insert(depth);
7842 }
7843 }
7844
7845 for reexport in index.reexports_for(file) {
7846 let mut next_requested = requested.to_string();
7847 let matches = if reexport.wildcard {
7848 true
7849 } else if let Some(source_name) = reexport.named.get(requested) {
7850 next_requested = source_name.clone();
7851 true
7852 } else {
7853 false
7854 };
7855 if !matches {
7856 continue;
7857 }
7858 if let Some(target_file) = &reexport.target_file {
7859 if let Some(target) = resolve_exported_symbol_inner(
7860 index,
7861 target_file,
7862 &next_requested,
7863 depth + 1,
7864 visited,
7865 ) {
7866 return Some(target);
7867 }
7868 }
7869 }
7870 None
7871}
7872
7873fn resolve_rust_target(
7874 index: &ProjectIndex<'_>,
7875 caller_file: &str,
7876 full_ref: &str,
7877 short_name: &str,
7878 caller_data: &FileCallData,
7879 raw: &RawRef,
7880) -> Option<(String, String, String)> {
7881 if full_ref.contains("::") {
7882 if let Some((target_file, target_symbol)) =
7883 rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
7884 {
7885 return Some(("resolved".to_string(), target_file, target_symbol));
7886 }
7887 }
7888
7889 for import in &caller_data.import_block.imports {
7890 if let Some((target_file, target_symbol)) =
7891 rust_target_for_use(index, caller_file, import, short_name)
7892 {
7893 return Some(("resolved".to_string(), target_file, target_symbol));
7894 }
7895 }
7896
7897 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
7898}
7899
7900fn rust_target_for_qualified(
7901 index: &ProjectIndex<'_>,
7902 caller_file: &str,
7903 full_ref: &str,
7904 short_name: &str,
7905 caller_data: &FileCallData,
7906 raw: &RawRef,
7907) -> Option<(String, String)> {
7908 let mut segments: Vec<&str> = full_ref.split("::").collect();
7909 if segments.len() < 2 {
7910 return None;
7911 }
7912 segments.pop();
7913 let requested_symbol = rust_target_symbol(full_ref, short_name);
7914
7915 for path in rust_module_path_candidates(&segments, caller_data, raw) {
7916 let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
7917 if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
7918 if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
7919 return Some(rust_resolve_reexport_if_symbol_missing(
7920 index,
7921 target_file,
7922 requested_symbol.clone(),
7923 ));
7924 }
7925 }
7926
7927 let module_segments = rust_resolve_segments(caller_file, &path_refs)?;
7928 if let Some(target) =
7929 rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
7930 {
7931 return Some(target);
7932 }
7933 if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
7934 return Some(rust_resolve_reexport_if_symbol_missing(
7935 index,
7936 target_file,
7937 requested_symbol.clone(),
7938 ));
7939 }
7940 }
7941 None
7942}
7943
7944fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
7945 full_ref
7946 .rsplit("::")
7947 .next()
7948 .filter(|name| !name.is_empty())
7949 .unwrap_or(short_name)
7950 .to_string()
7951}
7952
7953fn rust_resolve_reexport_if_symbol_missing(
7954 index: &ProjectIndex<'_>,
7955 target_file: String,
7956 target_symbol: String,
7957) -> (String, String) {
7958 if index
7959 .node_for_symbol(&target_file, &target_symbol)
7960 .is_some()
7961 {
7962 return (target_file, target_symbol);
7963 }
7964 if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
7965 resolved
7966 } else {
7967 (target_file, target_symbol)
7968 }
7969}
7970
7971fn rust_module_path_candidates(
7972 segments: &[&str],
7973 caller_data: &FileCallData,
7974 raw: &RawRef,
7975) -> Vec<Vec<String>> {
7976 let mut candidates = Vec::new();
7977 if let Some(first) = segments.first().copied() {
7978 for import in &caller_data.import_block.imports {
7979 if !rust_import_is_visible_to_call(import, raw) {
7980 continue;
7981 }
7982 let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
7983 continue;
7984 };
7985 if local_name == first {
7986 path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
7987 rust_push_unique_path_candidate(&mut candidates, path_segments);
7988 }
7989 }
7990 }
7991 rust_push_unique_path_candidate(
7992 &mut candidates,
7993 segments
7994 .iter()
7995 .map(|segment| (*segment).to_string())
7996 .collect(),
7997 );
7998 candidates
7999}
8000
8001fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
8002 if !candidates.iter().any(|existing| existing == &candidate) {
8003 candidates.push(candidate);
8004 }
8005}
8006
8007fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
8008 import.byte_range.start <= raw.byte_start
8009}
8010
8011fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
8012 let path = import.module_path.trim().trim_end_matches(';').trim();
8013 if path.contains("::{") || path.contains('{') || path.contains('*') {
8014 return None;
8015 }
8016 let (path_without_alias, alias) = path
8017 .split_once(" as ")
8018 .map(|(left, right)| (left.trim(), Some(right.trim())))
8019 .unwrap_or((path, None));
8020 let segments = path_without_alias
8021 .split("::")
8022 .map(str::trim)
8023 .filter(|segment| !segment.is_empty())
8024 .collect::<Vec<_>>();
8025 let local_name = alias.or_else(|| segments.last().copied())?.to_string();
8026 if local_name.chars().next().is_some_and(char::is_uppercase) {
8027 return None;
8028 }
8029 Some((
8030 local_name,
8031 segments
8032 .into_iter()
8033 .map(|segment| segment.to_string())
8034 .collect(),
8035 ))
8036}
8037
8038fn rust_inline_scoped_target(
8039 index: &ProjectIndex<'_>,
8040 caller_file: &str,
8041 module_segments: &[String],
8042 short_name: &str,
8043) -> Option<(String, String)> {
8044 let src_prefix = rust_src_prefix(caller_file);
8045 let mut file_paths = index.files.keys().cloned().collect::<Vec<_>>();
8046 file_paths.sort();
8047 if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
8048 let caller = file_paths.remove(position);
8049 file_paths.insert(0, caller);
8050 }
8051
8052 for file_path in file_paths {
8053 if index.lang_for(&file_path) != Some(LangId::Rust)
8054 || rust_src_prefix(&file_path) != src_prefix
8055 {
8056 continue;
8057 }
8058 let file_module_segments = rust_module_segments_for_rel(&file_path);
8059 if !module_segments.starts_with(&file_module_segments) {
8060 continue;
8061 }
8062 let scoped_segments = &module_segments[file_module_segments.len()..];
8063 if scoped_segments.is_empty() {
8064 continue;
8065 }
8066 let mut scoped_symbol = scoped_segments.join("::");
8067 scoped_symbol.push_str("::");
8068 scoped_symbol.push_str(short_name);
8069 if index.node_for_symbol(&file_path, &scoped_symbol).is_some() {
8070 return Some((file_path, scoped_symbol));
8071 }
8072 }
8073 None
8074}
8075
8076fn rust_target_for_use(
8077 index: &ProjectIndex<'_>,
8078 caller_file: &str,
8079 import: &ImportStatement,
8080 short_name: &str,
8081) -> Option<(String, String)> {
8082 let path = import.module_path.trim().trim_end_matches(';');
8083 if let Some(brace_start) = path.find("::{") {
8084 let prefix = &path[..brace_start];
8085 if import.names.iter().any(|name| name == short_name) {
8086 let prefix_segments: Vec<&str> = prefix.split("::").collect();
8087 let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
8088 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
8089 return Some((file, short_name.to_string()));
8090 }
8091 return None;
8092 }
8093
8094 let (path_without_alias, alias) = path
8095 .split_once(" as ")
8096 .map(|(left, right)| (left.trim(), Some(right.trim())))
8097 .unwrap_or((path, None));
8098 let segments: Vec<&str> = path_without_alias.split("::").collect();
8099 let imported = alias.or_else(|| segments.last().copied())?;
8100 if imported != short_name {
8101 return None;
8102 }
8103 if segments.len() < 2 {
8104 return None;
8105 }
8106 let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
8107 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
8108 Some((file, segments.last().unwrap_or(&short_name).to_string()))
8109}
8110
8111fn rust_workspace_file_for_segments(index: &ProjectIndex<'_>, segments: &[&str]) -> Option<String> {
8112 let crate_name = segments.first().copied()?;
8113 let src_prefix = index.crate_src_prefix(crate_name)?;
8114 let module_segments = segments[1..]
8115 .iter()
8116 .map(|segment| segment.to_string())
8117 .collect::<Vec<_>>();
8118 rust_file_for_src_prefix(index, &src_prefix, &module_segments)
8119}
8120
8121#[cfg(test)]
8122static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
8123 OnceLock::new();
8124
8125#[cfg(test)]
8126fn note_workspace_crate_prefix_build(project_root: &Path) {
8127 let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
8128 .get_or_init(|| Mutex::new(HashMap::new()))
8129 .lock()
8130 .expect("workspace crate prefix build counts mutex poisoned");
8131 *counts.entry(project_root.to_path_buf()).or_default() += 1;
8132}
8133
8134#[cfg(not(test))]
8135fn note_workspace_crate_prefix_build(_project_root: &Path) {}
8136
8137#[cfg(test)]
8138fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
8139 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
8140 .get_or_init(|| Mutex::new(HashMap::new()))
8141 .lock()
8142 .expect("workspace crate prefix build counts mutex poisoned")
8143 .remove(project_root);
8144}
8145
8146#[cfg(test)]
8147fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
8148 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
8149 .get_or_init(|| Mutex::new(HashMap::new()))
8150 .lock()
8151 .expect("workspace crate prefix build counts mutex poisoned")
8152 .get(project_root)
8153 .copied()
8154 .unwrap_or(0)
8155}
8156
8157fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
8162 note_workspace_crate_prefix_build(project_root);
8163 let mut prefixes = HashMap::new();
8164 let mut stack = vec![project_root.to_path_buf()];
8165 while let Some(dir) = stack.pop() {
8166 let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
8167 if matches!(name, "target" | "node_modules" | ".git") {
8168 continue;
8169 }
8170 let manifest = dir.join("Cargo.toml");
8171 if manifest.is_file() {
8172 let crate_names = rust_manifest_crate_names(&manifest);
8173 if !crate_names.is_empty() {
8174 let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
8175 for crate_name in crate_names {
8176 prefixes
8177 .entry(crate_name)
8178 .or_insert_with(|| src_prefix.clone());
8179 }
8180 }
8181 }
8182 let Ok(entries) = std::fs::read_dir(&dir) else {
8183 continue;
8184 };
8185 for entry in entries.flatten() {
8186 let path = entry.path();
8187 if path.is_dir() {
8188 stack.push(path);
8189 }
8190 }
8191 }
8192 prefixes
8193}
8194
8195fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
8199 let Ok(source) = std::fs::read_to_string(manifest) else {
8200 return Vec::new();
8201 };
8202 let mut in_lib = false;
8203 let mut package_name = None;
8204 let mut lib_name = None;
8205 for line in source.lines() {
8206 let trimmed = line.trim();
8207 if trimmed.starts_with('[') {
8208 in_lib = trimmed == "[lib]";
8209 continue;
8210 }
8211 let Some((key, value)) = trimmed.split_once('=') else {
8212 continue;
8213 };
8214 let key = key.trim();
8215 let value = value.trim().trim_matches('"');
8216 if in_lib && key == "name" {
8217 lib_name = Some(value.to_string());
8218 } else if !in_lib && key == "name" && package_name.is_none() {
8219 package_name = Some(value.to_string());
8220 }
8221 }
8222 let mut names = Vec::new();
8223 if let Some(lib) = lib_name {
8224 names.push(lib);
8225 }
8226 if let Some(package) = package_name {
8227 let normalized = package.replace('-', "_");
8228 if !names.contains(&normalized) {
8229 names.push(normalized);
8230 }
8231 }
8232 names
8233}
8234
8235fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
8236 if segments.is_empty() {
8237 return Some(Vec::new());
8238 }
8239 let caller_segments = rust_module_segments_for_rel(caller_file);
8240 match segments[0] {
8241 "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
8242 "self" => {
8243 let mut resolved = caller_segments;
8244 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
8245 Some(resolved)
8246 }
8247 "super" => {
8248 let mut resolved = caller_segments;
8249 resolved.pop();
8250 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
8251 Some(resolved)
8252 }
8253 _ => {
8254 let mut resolved = caller_segments;
8255 resolved.pop();
8256 resolved.extend(segments.iter().map(|item| item.to_string()));
8257 Some(resolved)
8258 }
8259 }
8260}
8261
8262fn rust_file_for_segments(
8263 index: &ProjectIndex<'_>,
8264 caller_file: &str,
8265 segments: &[String],
8266) -> Option<String> {
8267 rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
8268}
8269
8270fn rust_file_for_src_prefix(
8271 index: &ProjectIndex<'_>,
8272 src_prefix: &str,
8273 segments: &[String],
8274) -> Option<String> {
8275 let candidate = if segments.is_empty() {
8276 [src_prefix, "lib.rs"].join("/")
8277 } else {
8278 format!("{}/{}.rs", src_prefix, segments.join("/"))
8279 };
8280 if index.files.contains_key(&candidate) {
8281 return Some(candidate);
8282 }
8283 if !segments.is_empty() {
8284 let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
8285 if index.files.contains_key(&mod_candidate) {
8286 return Some(mod_candidate);
8287 }
8288 }
8289 None
8290}
8291
8292fn rust_src_prefix(rel_path: &str) -> String {
8293 rel_path
8294 .split_once("/src/")
8295 .map(|(prefix, _)| format!("{prefix}/src"))
8296 .unwrap_or_else(|| "src".to_string())
8297}
8298
8299fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
8300 let after_src = rel_path
8301 .split_once("/src/")
8302 .map(|(_, rest)| rest)
8303 .or_else(|| rel_path.strip_prefix("src/"))
8304 .unwrap_or(rel_path);
8305 if matches!(after_src, "lib.rs" | "main.rs") {
8306 return Vec::new();
8307 }
8308 if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
8309 return prefix.split('/').map(|item| item.to_string()).collect();
8310 }
8311 after_src
8312 .strip_suffix(".rs")
8313 .unwrap_or(after_src)
8314 .split('/')
8315 .map(|item| item.to_string())
8316 .collect()
8317}
8318
8319fn resolve_local_target(
8320 _index: &ProjectIndex<'_>,
8321 caller_file: &str,
8322 full_ref: &str,
8323 short_name: &str,
8324 caller_data: &FileCallData,
8325) -> Option<(String, String, String)> {
8326 if !callgraph::is_bare_callee(full_ref, short_name) {
8327 return None;
8328 }
8329 callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
8330 .ok()
8331 .map(|symbol| {
8332 (
8333 "resolved_local".to_string(),
8334 caller_file.to_string(),
8335 symbol,
8336 )
8337 })
8338}
8339
8340impl<'a> ProjectIndex<'a> {
8341 fn from_parts(
8342 project_root: &Path,
8343 files: HashMap<String, DbFileIndex>,
8344 caller_data: HashMap<String, &'a FileCallData>,
8345 workspace_crate_prefixes: WorkspaceCratePrefixCache,
8346 ) -> Self {
8347 Self {
8348 project_root: project_root.to_path_buf(),
8349 files,
8350 caller_data,
8351 workspace_crate_prefixes,
8352 }
8353 }
8354
8355 fn from_extracts(project_root: &Path, extracts: &'a [FileExtract]) -> Self {
8356 let mut files = HashMap::new();
8357 let mut caller_data = HashMap::new();
8358 for extract in extracts {
8359 let index = DbFileIndex::from_extract(project_root, extract);
8360 caller_data.insert(extract.rel_path.clone(), &extract.data);
8361 files.insert(extract.rel_path.clone(), index);
8362 }
8363 Self::from_parts(
8364 project_root,
8365 files,
8366 caller_data,
8367 WorkspaceCratePrefixCache::default(),
8368 )
8369 }
8370
8371 fn from_db_and_callers(
8372 tx: &Transaction<'_>,
8373 project_root: &Path,
8374 caller_extracts: &'a HashMap<String, FileExtract>,
8375 workspace_crate_prefixes: WorkspaceCratePrefixCache,
8376 ) -> Result<Self> {
8377 let mut files = load_db_file_indexes(tx, project_root)?;
8378 let mut caller_data = HashMap::new();
8379 for (rel_path, extract) in caller_extracts {
8380 files.insert(
8381 rel_path.clone(),
8382 DbFileIndex::from_extract(project_root, extract),
8383 );
8384 caller_data.insert(rel_path.clone(), &extract.data);
8385 }
8386 Ok(Self::from_parts(
8387 project_root,
8388 files,
8389 caller_data,
8390 workspace_crate_prefixes,
8391 ))
8392 }
8393
8394 fn lang_for(&self, rel_path: &str) -> Option<LangId> {
8395 self.files.get(rel_path).and_then(|file| file.lang)
8396 }
8397
8398 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
8399 self.files
8400 .get(caller_file)
8401 .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
8402 }
8403
8404 fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
8405 self.files
8406 .get(rel_path)
8407 .map(|file| file.reexports.as_slice())
8408 .unwrap_or(&[])
8409 }
8410
8411 fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
8412 self.files.get(rel_path).and_then(|file| {
8413 file.node_by_scoped
8414 .get(symbol)
8415 .cloned()
8416 .or_else(|| file.node_by_bare.get(symbol).cloned())
8417 })
8418 }
8419
8420 fn node_is_callable(&self, rel_path: &str, node_id: &str) -> bool {
8421 self.files
8422 .get(rel_path)
8423 .and_then(|file| file.node_kind_by_id.get(node_id))
8424 .is_some_and(|kind| matches!(kind.as_str(), "function" | "method"))
8425 }
8426}
8427
8428impl DbFileIndex {
8429 fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
8430 let mut node_by_scoped = HashMap::new();
8431 let mut node_by_bare = HashMap::new();
8432 for node in &extract.nodes {
8433 node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
8434 node_by_bare
8435 .entry(node.name.clone())
8436 .or_insert(node.id.clone());
8437 }
8438 let node_kind_by_id = extract
8439 .nodes
8440 .iter()
8441 .map(|node| (node.id.clone(), node.kind.clone()))
8442 .collect();
8443 let mut export_aliases = HashMap::new();
8444 for raw_ref in &extract.raw_refs {
8445 if raw_ref.kind == "export_alias" {
8446 if let (Some(exported), Some(source_symbol)) =
8447 (&raw_ref.local_name, &raw_ref.requested_name)
8448 {
8449 export_aliases.insert(exported.clone(), source_symbol.clone());
8450 }
8451 }
8452 }
8453 let mut module_targets = HashMap::new();
8454 let mut reexports = Vec::new();
8455 for raw_ref in &extract.raw_refs {
8456 if !matches!(raw_ref.kind.as_str(), "import" | "reexport") {
8457 continue;
8458 }
8459 let Some(module_path) = &raw_ref.module_path else {
8460 continue;
8461 };
8462 let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
8463 module_targets
8464 .entry(module_path.clone())
8465 .or_insert_with(|| target_file.clone());
8466 if raw_ref.kind == "reexport" {
8467 reexports.push(reexport_index_from_raw(raw_ref, target_file));
8468 }
8469 }
8470 Self {
8471 lang: Some(extract.lang),
8472 exports: extract.data.exported_symbols.iter().cloned().collect(),
8473 default_export: extract.data.default_export_symbol.clone(),
8474 export_aliases,
8475 node_by_scoped,
8476 node_by_bare,
8477 node_kind_by_id,
8478 module_targets,
8479 reexports,
8480 }
8481 }
8482}
8483
8484fn load_db_file_indexes(
8485 tx: &Transaction<'_>,
8486 project_root: &Path,
8487) -> Result<HashMap<String, DbFileIndex>> {
8488 let mut files = HashMap::new();
8489 let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
8490 let rows = stmt.query_map([], |row| {
8491 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8492 })?;
8493 for row in rows {
8494 let (rel_path, lang) = row?;
8495 files.insert(
8496 rel_path.clone(),
8497 DbFileIndex {
8498 lang: lang_from_label(&lang),
8499 exports: HashSet::new(),
8500 default_export: None,
8501 export_aliases: HashMap::new(),
8502 node_by_scoped: HashMap::new(),
8503 node_by_bare: HashMap::new(),
8504 node_kind_by_id: HashMap::new(),
8505 module_targets: HashMap::new(),
8506 reexports: Vec::new(),
8507 },
8508 );
8509 }
8510
8511 let mut node_stmt = tx.prepare(
8512 "SELECT file_path, id, name, scoped_name, kind, exported, is_default_export FROM nodes",
8513 )?;
8514 let nodes = node_stmt.query_map([], |row| {
8515 Ok((
8516 row.get::<_, String>(0)?,
8517 row.get::<_, String>(1)?,
8518 row.get::<_, String>(2)?,
8519 row.get::<_, String>(3)?,
8520 row.get::<_, String>(4)?,
8521 row.get::<_, i64>(5)? != 0,
8522 row.get::<_, i64>(6)? != 0,
8523 ))
8524 })?;
8525 for row in nodes {
8526 let (file_path, id, name, scoped_name, kind, exported, is_default_export) = row?;
8527 let file = files
8528 .entry(file_path.clone())
8529 .or_insert_with(|| DbFileIndex {
8530 lang: None,
8531 exports: HashSet::new(),
8532 default_export: None,
8533 export_aliases: HashMap::new(),
8534 node_by_scoped: HashMap::new(),
8535 node_by_bare: HashMap::new(),
8536 node_kind_by_id: HashMap::new(),
8537 module_targets: HashMap::new(),
8538 reexports: Vec::new(),
8539 });
8540 if exported {
8541 file.exports.insert(name.clone());
8542 file.exports.insert(scoped_name.clone());
8543 }
8544 if is_default_export {
8545 file.default_export = Some(scoped_name.clone());
8546 }
8547 file.node_by_scoped.insert(scoped_name, id.clone());
8548 file.node_by_bare.entry(name).or_insert(id.clone());
8549 file.node_kind_by_id.insert(id, kind);
8550 }
8551 let file_keys: HashSet<String> = files.keys().cloned().collect();
8552 let dependencies_by_file = load_file_dependencies_index(tx)?;
8556 let mut ref_stmt = tx.prepare(
8557 "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
8558 FROM refs WHERE kind IN ('reexport', 'export_alias')",
8559 )?;
8560 let ref_rows = ref_stmt.query_map([], |row| {
8561 Ok((
8562 row.get::<_, String>(0)?,
8563 row.get::<_, String>(1)?,
8564 row.get::<_, String>(2)?,
8565 row.get::<_, Option<String>>(3)?,
8566 row.get::<_, Option<String>>(4)?,
8567 row.get::<_, i64>(5)? != 0,
8568 row.get::<_, Option<String>>(6)?,
8569 row.get::<_, Option<String>>(7)?,
8570 ))
8571 })?;
8572 for row in ref_rows {
8573 let (
8574 ref_id,
8575 caller_file,
8576 kind,
8577 module_path,
8578 full_ref,
8579 wildcard,
8580 local_name,
8581 requested_name,
8582 ) = row?;
8583 if kind == "export_alias" {
8584 if let (Some(exported), Some(source_symbol), Some(file)) =
8585 (local_name, requested_name, files.get_mut(&caller_file))
8586 {
8587 file.export_aliases.insert(exported, source_symbol);
8588 }
8589 continue;
8590 }
8591 let Some(module_path) = module_path else {
8592 continue;
8593 };
8594 let file_deps = dependencies_by_file
8595 .get(&caller_file)
8596 .cloned()
8597 .unwrap_or_default();
8598 let deps = stored_dependencies_for_module(
8599 project_root,
8600 &caller_file,
8601 &module_path,
8602 &file_deps,
8603 &file_keys,
8604 );
8605 let target_file = deps
8606 .iter()
8607 .find(|dep| file_keys.contains(*dep))
8608 .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
8609 if let Some(file) = files.get_mut(&caller_file) {
8610 file.module_targets
8611 .entry(module_path.clone())
8612 .or_insert_with(|| target_file.clone());
8613 if kind == "reexport" {
8614 let raw = RawRef {
8615 ref_id,
8616 caller_node: None,
8617 caller_symbol: None,
8618 caller_file,
8619 kind,
8620 short_name: None,
8621 full_ref,
8622 module_path: Some(module_path),
8623 import_kind: Some("reexport".to_string()),
8624 local_name: None,
8625 requested_name: None,
8626 namespace_alias: None,
8627 wildcard,
8628 line: 0,
8629 byte_start: 0,
8630 byte_end: 0,
8631 dependencies: deps,
8632 };
8633 file.reexports
8634 .push(reexport_index_from_raw(&raw, target_file));
8635 }
8636 }
8637 }
8638
8639 Ok(files)
8640}
8641
8642fn stored_dependencies_for_module(
8643 project_root: &Path,
8644 caller_file: &str,
8645 module_path: &str,
8646 caller_dependencies: &BTreeSet<String>,
8647 indexed_files: &HashSet<String>,
8648) -> BTreeSet<String> {
8649 let caller_path = project_root.join(caller_file);
8650 let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
8651 if module_path.starts_with('.') {
8652 let caller_dir = caller_path.parent().unwrap_or(project_root);
8653 for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
8654 let normalized = if candidate.is_file() {
8655 canonicalize_path(&candidate)
8656 } else {
8657 candidate
8658 };
8659 candidates.insert(relative_path(project_root, &normalized));
8660 }
8661 }
8662 let exact = candidates
8663 .intersection(caller_dependencies)
8664 .filter(|dependency| indexed_files.contains(*dependency))
8665 .cloned()
8666 .collect::<BTreeSet<_>>();
8667 if !exact.is_empty() || module_path.starts_with('.') {
8668 return exact;
8669 }
8670
8671 let module_path = rust_module_path_without_alias_or_use_list(module_path)
8672 .trim_matches(|character| matches!(character, '\'' | '"'));
8673 let package_name = module_path
8674 .split('/')
8675 .next_back()
8676 .unwrap_or(module_path)
8677 .replace('_', "-");
8678 let matched = caller_dependencies
8679 .iter()
8680 .filter(|dependency| indexed_files.contains(*dependency))
8681 .filter(|dependency| {
8682 dependency.as_str() == module_path
8683 || dependency.ends_with(&format!("/{module_path}"))
8684 || Path::new(dependency).components().any(|component| {
8685 component.as_os_str().to_string_lossy().replace('_', "-") == package_name
8686 })
8687 })
8688 .cloned()
8689 .collect::<BTreeSet<_>>();
8690 if matched.len() == 1 {
8691 matched
8692 } else {
8693 BTreeSet::new()
8694 }
8695}
8696
8697fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
8698 let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
8699 let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
8700 let rows = stmt.query_map([], |row| {
8701 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8702 })?;
8703 for row in rows {
8704 let (file_path, dependency) = row?;
8705 by_file.entry(file_path).or_default().insert(dependency);
8706 }
8707 Ok(by_file)
8708}
8709
8710struct ColdBuildInsertStatements<'stmt> {
8711 file: Statement<'stmt>,
8712 node: Statement<'stmt>,
8713 file_dependency: Statement<'stmt>,
8714 dispatch_hint: Statement<'stmt>,
8715 backend_state: Statement<'stmt>,
8716 reference: Statement<'stmt>,
8717 edge: Statement<'stmt>,
8718}
8719
8720impl<'stmt> ColdBuildInsertStatements<'stmt> {
8721 fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
8722 Ok(Self {
8723 file: tx.prepare(
8724 "INSERT OR REPLACE INTO files(
8725 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
8726 is_public_api, surface_fingerprint, indexed_at
8727 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
8728 )?,
8729 node: tx.prepare(
8730 "INSERT OR REPLACE INTO nodes(
8731 id, file_path, name, scoped_name, kind, start_line, start_col,
8732 end_line, end_col, range_ordinal, signature, exported,
8733 is_default_export, is_type_like, is_callgraph_entry_point, provenance
8734 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
8735 )?,
8736 file_dependency: tx.prepare(
8737 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
8738 )?,
8739 dispatch_hint: tx.prepare(
8740 "INSERT OR REPLACE INTO dispatch_hints(
8741 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
8742 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
8743 )?,
8744 backend_state: tx.prepare(
8745 "INSERT OR REPLACE INTO backend_file_state(
8746 backend, workspace_root, file_path, content_hash, status, updated_at
8747 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
8748 )?,
8749 reference: tx.prepare(
8750 "INSERT OR REPLACE INTO refs(
8751 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
8752 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
8753 byte_start, byte_end, status, target_node, target_file, target_symbol,
8754 provenance
8755 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
8756 )?,
8757 edge: tx.prepare(
8758 "INSERT OR REPLACE INTO edges(
8759 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8760 kind, line, provenance
8761 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
8762 )?,
8763 })
8764 }
8765}
8766
8767fn insert_file_extract_prepared(
8768 statements: &mut ColdBuildInsertStatements<'_>,
8769 workspace_root: &str,
8770 extract: &FileExtract,
8771) -> Result<()> {
8772 statements.file.execute(params![
8773 extract.rel_path,
8774 hash_to_hex(extract.freshness.content_hash),
8775 system_time_to_ns(extract.freshness.mtime),
8776 extract.freshness.size as i64,
8777 lang_label(extract.lang),
8778 extract.surface_fingerprint,
8779 unix_seconds_now(),
8780 ])?;
8781 for node in &extract.nodes {
8782 statements.node.execute(params![
8783 node.id,
8784 node.file_path,
8785 node.name,
8786 node.scoped_name,
8787 node.kind,
8788 node.range.start_line as i64,
8789 node.range.start_col as i64,
8790 node.range.end_line as i64,
8791 node.range.end_col as i64,
8792 node.range_ordinal as i64,
8793 node.signature,
8794 bool_int(node.exported),
8795 bool_int(node.is_default_export),
8796 bool_int(node.is_type_like),
8797 bool_int(node.is_callgraph_entry_point),
8798 PROVENANCE_TREESITTER,
8799 ])?;
8800 }
8801
8802 let mut dependencies = BTreeSet::new();
8803 for raw_ref in &extract.raw_refs {
8804 dependencies.extend(raw_ref.dependencies.iter().cloned());
8805 }
8806 for dep_file in &dependencies {
8807 statements
8808 .file_dependency
8809 .execute(params![extract.rel_path, dep_file])?;
8810 }
8811
8812 for hint in &extract.dispatch_hints {
8813 statements.dispatch_hint.execute(params![
8814 hint.id,
8815 hint.method_name,
8816 hint.caller_node,
8817 hint.file,
8818 hint.line as i64,
8819 hint.byte_start as i64,
8820 hint.byte_end as i64,
8821 PROVENANCE_TREESITTER,
8822 ])?;
8823 }
8824 insert_backend_state_prepared(
8825 &mut statements.backend_state,
8826 workspace_root,
8827 &extract.rel_path,
8828 Some(&extract.freshness.content_hash),
8829 "fresh",
8830 )?;
8831 Ok(())
8832}
8833
8834fn insert_backend_state_prepared(
8835 stmt: &mut Statement<'_>,
8836 workspace_root: &str,
8837 rel_path: &str,
8838 content_hash: Option<&blake3::Hash>,
8839 status: &str,
8840) -> Result<()> {
8841 let hash = content_hash
8842 .map(|hash| hash_to_hex(*hash))
8843 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
8844 stmt.execute(params![
8845 BACKEND_TREESITTER,
8846 workspace_root,
8847 rel_path,
8848 hash,
8849 status,
8850 unix_seconds_now(),
8851 ])?;
8852 Ok(())
8853}
8854
8855fn insert_resolved_ref_prepared(
8856 statements: &mut ColdBuildInsertStatements<'_>,
8857 resolved: &ResolvedRef,
8858) -> Result<()> {
8859 let raw = &resolved.raw;
8860 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
8861 statements.reference.execute(params![
8862 raw.ref_id,
8863 raw.caller_node,
8864 raw.caller_file,
8865 raw.kind,
8866 raw.short_name,
8867 raw.full_ref,
8868 raw.module_path,
8869 raw.import_kind,
8870 raw.local_name,
8871 raw.requested_name,
8872 raw.namespace_alias,
8873 bool_int(raw.wildcard),
8874 raw.line as i64,
8875 raw.byte_start as i64,
8876 raw.byte_end as i64,
8877 resolved.status,
8878 resolved.target_node,
8879 resolved.target_file,
8880 resolved.target_symbol,
8881 ref_provenance(raw),
8882 ])?;
8883 if let Some(edge) = &resolved.edge {
8884 statements.edge.execute(params![
8885 edge.edge_id,
8886 raw.ref_id,
8887 edge.source_node,
8888 edge.target_node,
8889 edge.target_file,
8890 edge.target_symbol,
8891 edge.kind,
8892 edge.line as i64,
8893 ref_provenance(raw),
8894 ])?;
8895 }
8896 Ok(())
8897}
8898
8899fn insert_file_extract(
8900 tx: &Transaction<'_>,
8901 project_root: &Path,
8902 extract: &FileExtract,
8903) -> Result<()> {
8904 tx.execute(
8905 "INSERT OR REPLACE INTO files(
8906 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
8907 is_public_api, surface_fingerprint, indexed_at
8908 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
8909 params![
8910 extract.rel_path,
8911 hash_to_hex(extract.freshness.content_hash),
8912 system_time_to_ns(extract.freshness.mtime),
8913 extract.freshness.size as i64,
8914 lang_label(extract.lang),
8915 extract.surface_fingerprint,
8916 unix_seconds_now(),
8917 ],
8918 )?;
8919 for node in &extract.nodes {
8920 tx.execute(
8921 "INSERT OR REPLACE INTO nodes(
8922 id, file_path, name, scoped_name, kind, start_line, start_col,
8923 end_line, end_col, range_ordinal, signature, exported,
8924 is_default_export, is_type_like, is_callgraph_entry_point, provenance
8925 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
8926 params![
8927 node.id,
8928 node.file_path,
8929 node.name,
8930 node.scoped_name,
8931 node.kind,
8932 node.range.start_line as i64,
8933 node.range.start_col as i64,
8934 node.range.end_line as i64,
8935 node.range.end_col as i64,
8936 node.range_ordinal as i64,
8937 node.signature,
8938 bool_int(node.exported),
8939 bool_int(node.is_default_export),
8940 bool_int(node.is_type_like),
8941 bool_int(node.is_callgraph_entry_point),
8942 PROVENANCE_TREESITTER,
8943 ],
8944 )?;
8945 }
8946 let mut dependencies = BTreeSet::new();
8947 for raw_ref in &extract.raw_refs {
8948 dependencies.extend(raw_ref.dependencies.iter().cloned());
8949 }
8950 insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
8951
8952 for hint in &extract.dispatch_hints {
8953 tx.execute(
8954 "INSERT OR REPLACE INTO dispatch_hints(
8955 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
8956 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
8957 params![
8958 hint.id,
8959 hint.method_name,
8960 hint.caller_node,
8961 hint.file,
8962 hint.line as i64,
8963 hint.byte_start as i64,
8964 hint.byte_end as i64,
8965 PROVENANCE_TREESITTER,
8966 ],
8967 )?;
8968 }
8969 mark_backend_state(
8970 tx,
8971 project_root,
8972 &extract.rel_path,
8973 Some(&extract.freshness.content_hash),
8974 "fresh",
8975 )?;
8976 Ok(())
8977}
8978
8979fn insert_file_dependencies(
8980 tx: &Transaction<'_>,
8981 file_path: &str,
8982 dependencies: &BTreeSet<String>,
8983) -> Result<()> {
8984 for dep_file in dependencies {
8985 tx.execute(
8986 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
8987 params![file_path, dep_file],
8988 )?;
8989 }
8990 Ok(())
8991}
8992
8993fn ref_provenance(raw: &RawRef) -> &'static str {
8994 if raw.kind == "value_ref" {
8995 PROVENANCE_VALUE_REF
8996 } else {
8997 PROVENANCE_TREESITTER
8998 }
8999}
9000
9001fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
9002 let raw = &resolved.raw;
9003 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
9004 tx.execute(
9005 "INSERT OR REPLACE INTO refs(
9006 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
9007 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
9008 byte_start, byte_end, status, target_node, target_file, target_symbol,
9009 provenance
9010 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
9011 params![
9012 raw.ref_id,
9013 raw.caller_node,
9014 raw.caller_file,
9015 raw.kind,
9016 raw.short_name,
9017 raw.full_ref,
9018 raw.module_path,
9019 raw.import_kind,
9020 raw.local_name,
9021 raw.requested_name,
9022 raw.namespace_alias,
9023 bool_int(raw.wildcard),
9024 raw.line as i64,
9025 raw.byte_start as i64,
9026 raw.byte_end as i64,
9027 resolved.status,
9028 resolved.target_node,
9029 resolved.target_file,
9030 resolved.target_symbol,
9031 ref_provenance(raw),
9032 ],
9033 )?;
9034 if let Some(edge) = &resolved.edge {
9035 tx.execute(
9036 "INSERT OR REPLACE INTO edges(
9037 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
9038 kind, line, provenance
9039 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
9040 params![
9041 edge.edge_id,
9042 raw.ref_id,
9043 edge.source_node,
9044 edge.target_node,
9045 edge.target_file,
9046 edge.target_symbol,
9047 edge.kind,
9048 edge.line as i64,
9049 ref_provenance(raw),
9050 ],
9051 )?;
9052 }
9053 Ok(())
9054}
9055
9056fn insert_method_dispatch_edges(
9057 tx: &Transaction<'_>,
9058 project_root: &Path,
9059 caller_files: Option<&BTreeSet<String>>,
9060) -> Result<usize> {
9061 let references = load_name_match_refs(tx, caller_files)?;
9062 if references.is_empty() {
9063 return Ok(0);
9064 }
9065
9066 let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
9067 let mut source_cache: DispatchSourceCache = HashMap::new();
9068 let mut inserted = 0usize;
9069 for reference in references {
9070 let key = (reference.method_name.clone(), reference.lang.clone());
9071 let candidates = match candidates_by_name.entry(key) {
9072 Entry::Occupied(entry) => entry.into_mut(),
9073 Entry::Vacant(entry) => {
9074 let candidates =
9075 load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
9076 entry.insert(candidates)
9077 }
9078 };
9079
9080 match infer_receiver_type_state(project_root, &reference, &mut source_cache) {
9081 ReceiverTypeInference::Known(receiver_type) => {
9082 let Some(candidate) =
9083 select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
9084 else {
9085 continue;
9086 };
9087 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
9088 inserted += 1;
9089 continue;
9090 }
9091 ReceiverTypeInference::RustDirectSelfField {
9092 receiver_type,
9093 declaration_file,
9094 module_scope,
9095 } => {
9096 let Some(candidate) = select_rust_direct_self_field_candidate(
9097 project_root,
9098 &reference,
9099 candidates.as_slice(),
9100 &receiver_type,
9101 &declaration_file,
9102 &module_scope,
9103 &mut source_cache,
9104 ) else {
9105 continue;
9106 };
9107 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
9108 inserted += 1;
9109 continue;
9110 }
9111 ReceiverTypeInference::KnownButUnresolved => continue,
9112 ReceiverTypeInference::Unknown => {}
9113 }
9114
9115 if method_name_match_denylisted(&reference.method_name) {
9116 continue;
9117 }
9118
9119 let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
9120 continue;
9121 };
9122 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
9123 inserted += 1;
9124 }
9125 Ok(inserted)
9126}
9127
9128fn insert_method_dispatch_edges_chunked(
9129 tx: &Transaction<'_>,
9130 project_root: &Path,
9131 caller_files: &BTreeSet<String>,
9132 chunk_size: usize,
9133) -> Result<usize> {
9134 if caller_files.is_empty() {
9135 return Ok(0);
9136 }
9137 if chunk_size == 0 || caller_files.len() <= chunk_size {
9138 return insert_method_dispatch_edges(tx, project_root, Some(caller_files));
9139 }
9140
9141 let mut inserted = 0usize;
9142 let mut batch = BTreeSet::new();
9143 for caller_file in caller_files {
9144 batch.insert(caller_file.clone());
9145 if batch.len() == chunk_size {
9146 inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
9147 batch.clear();
9148 }
9149 }
9150 if !batch.is_empty() {
9151 inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
9152 }
9153 Ok(inserted)
9154}
9155
9156fn insert_method_dispatch_edge(
9157 tx: &Transaction<'_>,
9158 reference: &NameMatchRef,
9159 candidate: &NameMatchCandidate,
9160 provenance: &str,
9161) -> Result<()> {
9162 tx.execute(
9163 "INSERT OR REPLACE INTO edges(
9164 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
9165 kind, line, provenance
9166 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
9167 params![
9168 ref_id(&[&reference.ref_id, provenance, "edge"]),
9169 &reference.ref_id,
9170 &reference.caller_node,
9171 &candidate.node_id,
9172 &candidate.file_path,
9173 &candidate.scoped_name,
9174 reference.line as i64,
9175 provenance,
9176 ],
9177 )?;
9178 Ok(())
9179}
9180
9181fn delete_method_dispatch_edges_for_callers(
9182 tx: &Transaction<'_>,
9183 caller_files: &BTreeSet<String>,
9184) -> Result<()> {
9185 if caller_files.is_empty() {
9186 return Ok(());
9187 }
9188
9189 let mut stmt = tx.prepare(
9190 "DELETE FROM edges
9191 WHERE provenance IN (?1, ?2)
9192 AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
9193 )?;
9194 for caller_file in caller_files {
9195 stmt.execute(params![
9196 PROVENANCE_NAME_MATCH,
9197 PROVENANCE_TYPE_MATCH,
9198 caller_file
9199 ])?;
9200 }
9201 Ok(())
9202}
9203
9204fn load_name_match_refs(
9205 tx: &Transaction<'_>,
9206 caller_files: Option<&BTreeSet<String>>,
9207) -> Result<Vec<NameMatchRef>> {
9208 let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
9209 n.signature, r.short_name, r.full_ref, r.line, f.lang
9210 FROM refs r
9211 JOIN files f ON f.path = r.caller_file
9212 JOIN nodes n ON n.id = r.caller_node
9213 WHERE r.kind = 'call'
9214 AND r.status = 'unresolved'
9215 AND r.caller_node IS NOT NULL
9216 AND r.full_ref IS NOT NULL
9217 AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
9218 AND NOT EXISTS (
9219 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
9220 )";
9221 let mut references = Vec::new();
9222
9223 if let Some(caller_files) = caller_files {
9224 if caller_files.is_empty() {
9225 return Ok(references);
9226 }
9227 let sql = format!(
9228 "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
9229 );
9230 let mut stmt = tx.prepare(&sql)?;
9231 for caller_file in caller_files {
9232 let rows = stmt.query_map(params![caller_file], |row| {
9233 Ok((
9234 row.get::<_, String>(0)?,
9235 row.get::<_, Option<String>>(1)?,
9236 row.get::<_, String>(2)?,
9237 row.get::<_, String>(3)?,
9238 row.get::<_, Option<String>>(4)?,
9239 row.get::<_, Option<String>>(5)?,
9240 row.get::<_, Option<String>>(6)?,
9241 row.get::<_, i64>(7)?,
9242 row.get::<_, String>(8)?,
9243 ))
9244 })?;
9245 for row in rows {
9246 let (
9247 ref_id,
9248 caller_node,
9249 caller_file,
9250 caller_symbol,
9251 caller_signature,
9252 short_name,
9253 full_ref,
9254 line,
9255 lang,
9256 ) = row?;
9257 if let Some(reference) = name_match_ref_from_parts(
9258 ref_id,
9259 caller_node,
9260 caller_file,
9261 caller_symbol,
9262 caller_signature,
9263 short_name,
9264 full_ref,
9265 line,
9266 lang,
9267 ) {
9268 references.push(reference);
9269 }
9270 }
9271 }
9272 return Ok(references);
9273 }
9274
9275 let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
9276 let mut stmt = tx.prepare(&sql)?;
9277 let rows = stmt.query_map([], |row| {
9278 Ok((
9279 row.get::<_, String>(0)?,
9280 row.get::<_, Option<String>>(1)?,
9281 row.get::<_, String>(2)?,
9282 row.get::<_, String>(3)?,
9283 row.get::<_, Option<String>>(4)?,
9284 row.get::<_, Option<String>>(5)?,
9285 row.get::<_, Option<String>>(6)?,
9286 row.get::<_, i64>(7)?,
9287 row.get::<_, String>(8)?,
9288 ))
9289 })?;
9290 for row in rows {
9291 let (
9292 ref_id,
9293 caller_node,
9294 caller_file,
9295 caller_symbol,
9296 caller_signature,
9297 short_name,
9298 full_ref,
9299 line,
9300 lang,
9301 ) = row?;
9302 if let Some(reference) = name_match_ref_from_parts(
9303 ref_id,
9304 caller_node,
9305 caller_file,
9306 caller_symbol,
9307 caller_signature,
9308 short_name,
9309 full_ref,
9310 line,
9311 lang,
9312 ) {
9313 references.push(reference);
9314 }
9315 }
9316 Ok(references)
9317}
9318
9319#[allow(clippy::too_many_arguments)]
9320fn name_match_ref_from_parts(
9321 ref_id: String,
9322 caller_node: Option<String>,
9323 caller_file: String,
9324 caller_symbol: String,
9325 caller_signature: Option<String>,
9326 short_name: Option<String>,
9327 full_ref: Option<String>,
9328 line: i64,
9329 lang: String,
9330) -> Option<NameMatchRef> {
9331 let caller_node = caller_node?;
9332 let full_ref = full_ref?;
9333 let (receiver_expression, receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
9334 let method_name = if member.is_empty() {
9335 short_name.as_deref()?.to_string()
9336 } else {
9337 member
9338 };
9339 Some(NameMatchRef {
9340 ref_id,
9341 caller_node,
9342 caller_file,
9343 caller_symbol,
9344 caller_signature,
9345 receiver_expression,
9346 receiver,
9347 method_name,
9348 colon_dispatch,
9349 line: line.max(0) as u32,
9350 lang,
9351 })
9352}
9353
9354fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, String, bool)> {
9355 let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
9356 let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
9357 let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
9358 let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
9359 .into_iter()
9360 .flatten()
9361 .max_by_key(|(index, _, _)| *index)?;
9362 if delimiter == 0 {
9363 return None;
9364 }
9365 let member_start = delimiter + delimiter_len;
9366 if member_start >= full_ref.len() {
9367 return None;
9368 }
9369 let receiver_expression = full_ref[..delimiter].trim();
9370 let receiver = last_name_segment(receiver_expression).trim();
9371 let member = &full_ref[member_start..];
9372 if receiver.is_empty() || member.is_empty() {
9373 return None;
9374 }
9375 Some((
9376 receiver_expression.to_string(),
9377 receiver.to_string(),
9378 member.to_string(),
9379 colon_dispatch,
9380 ))
9381}
9382
9383fn last_name_segment(value: &str) -> &str {
9384 value
9385 .rsplit(['.', ':', '/', '\\', '-', '>'])
9386 .find(|segment| !segment.is_empty())
9387 .unwrap_or(value)
9388}
9389
9390fn load_name_match_candidates(
9391 tx: &Transaction<'_>,
9392 method_name: &str,
9393 lang: &str,
9394) -> Result<Vec<NameMatchCandidate>> {
9395 let mut stmt = tx.prepare(
9396 "SELECT n.id, n.file_path, n.scoped_name, n.kind, n.start_line
9397 FROM nodes n JOIN files f ON f.path = n.file_path
9398 WHERE n.name = ?1
9399 AND f.lang = ?2
9400 AND n.kind IN ('method', 'function')
9401 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
9402 )?;
9403 let rows = stmt.query_map(params![method_name, lang], |row| {
9404 Ok(NameMatchCandidate {
9405 node_id: row.get(0)?,
9406 file_path: row.get(1)?,
9407 scoped_name: row.get(2)?,
9408 kind: row.get(3)?,
9409 start_line: (row.get::<_, i64>(4)?.max(0) as u32).saturating_add(1),
9410 })
9411 })?;
9412 rows.collect::<std::result::Result<Vec<_>, _>>()
9413 .map_err(Into::into)
9414}
9415
9416struct ParsedDispatchSource {
9417 source: String,
9418 tree: tree_sitter::Tree,
9419}
9420
9421type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
9422
9423#[derive(Debug, Clone, PartialEq, Eq)]
9424enum ReceiverTypeInference {
9425 Unknown,
9426 Known(String),
9427 RustDirectSelfField {
9428 receiver_type: String,
9429 declaration_file: String,
9430 module_scope: Vec<(usize, usize)>,
9431 },
9432 KnownButUnresolved,
9433}
9434
9435#[cfg(test)]
9436fn infer_receiver_type(
9437 project_root: &Path,
9438 reference: &NameMatchRef,
9439 source_cache: &mut DispatchSourceCache,
9440) -> Option<String> {
9441 match infer_receiver_type_state(project_root, reference, source_cache) {
9442 ReceiverTypeInference::Known(receiver_type)
9443 | ReceiverTypeInference::RustDirectSelfField { receiver_type, .. } => Some(receiver_type),
9444 ReceiverTypeInference::Unknown | ReceiverTypeInference::KnownButUnresolved => None,
9445 }
9446}
9447
9448fn infer_receiver_type_state(
9449 project_root: &Path,
9450 reference: &NameMatchRef,
9451 source_cache: &mut DispatchSourceCache,
9452) -> ReceiverTypeInference {
9453 let known = |receiver_type| ReceiverTypeInference::Known(receiver_type);
9454 match reference.lang.as_str() {
9455 "rust" => infer_rust_receiver_type(project_root, reference, source_cache),
9456 "java" => {
9457 infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
9458 .map(known)
9459 .unwrap_or(ReceiverTypeInference::Unknown)
9460 }
9461 "kotlin" => {
9462 infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
9463 .map(known)
9464 .unwrap_or(ReceiverTypeInference::Unknown)
9465 }
9466 "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache)
9467 .map(known)
9468 .unwrap_or(ReceiverTypeInference::Unknown),
9469 _ => ReceiverTypeInference::Unknown,
9470 }
9471}
9472
9473fn parse_dispatch_source(
9474 project_root: &Path,
9475 caller_file: &str,
9476 lang: LangId,
9477) -> Option<ParsedDispatchSource> {
9478 let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
9479 let grammar = crate::parser::grammar_for(lang);
9480 let mut parser = tree_sitter::Parser::new();
9481 parser.set_language(&grammar).ok()?;
9482 let tree = parser.parse(&source, None)?;
9483 Some(ParsedDispatchSource { source, tree })
9484}
9485
9486fn parsed_dispatch_source<'a>(
9487 project_root: &Path,
9488 reference: &NameMatchRef,
9489 lang: LangId,
9490 source_cache: &'a mut DispatchSourceCache,
9491) -> Option<&'a ParsedDispatchSource> {
9492 parsed_dispatch_source_for_file(
9493 project_root,
9494 &reference.caller_file,
9495 &reference.lang,
9496 lang,
9497 source_cache,
9498 )
9499}
9500
9501fn parsed_dispatch_source_for_file<'a>(
9502 project_root: &Path,
9503 file_path: &str,
9504 lang_label: &str,
9505 lang: LangId,
9506 source_cache: &'a mut DispatchSourceCache,
9507) -> Option<&'a ParsedDispatchSource> {
9508 let key = (file_path.to_string(), lang_label.to_string());
9509 source_cache
9510 .entry(key)
9511 .or_insert_with(|| parse_dispatch_source(project_root, file_path, lang))
9512 .as_ref()
9513}
9514
9515fn infer_java_like_receiver_type(
9516 project_root: &Path,
9517 reference: &NameMatchRef,
9518 lang: LangId,
9519 source_cache: &mut DispatchSourceCache,
9520) -> Option<String> {
9521 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
9522 return None;
9523 }
9524
9525 let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
9526 let root = parsed.tree.root_node();
9527 let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
9528
9529 let callable_scope = type_node
9530 .and_then(|node| {
9531 find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
9532 })
9533 .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
9534
9535 if let Some(callable_scope) = callable_scope {
9536 if let Some(receiver_type) = infer_java_like_local_receiver_type(
9537 callable_scope,
9538 &parsed.source,
9539 &reference.receiver,
9540 reference.line.max(1),
9541 lang,
9542 ) {
9543 return Some(receiver_type);
9544 }
9545 }
9546
9547 type_node.and_then(|node| {
9548 infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
9549 })
9550}
9551
9552fn infer_cpp_receiver_type(
9553 project_root: &Path,
9554 reference: &NameMatchRef,
9555 source_cache: &mut DispatchSourceCache,
9556) -> Option<String> {
9557 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
9558 return None;
9559 }
9560
9561 let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
9562 let root = parsed.tree.root_node();
9563 let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
9564 infer_cpp_receiver_type_from_scope(
9565 scope,
9566 &parsed.source,
9567 &reference.receiver,
9568 reference.line.max(1),
9569 )
9570}
9571
9572fn find_enclosing_java_like_type_node<'tree>(
9573 root: tree_sitter::Node<'tree>,
9574 source: &str,
9575 reference: &NameMatchRef,
9576 lang: LangId,
9577) -> Option<tree_sitter::Node<'tree>> {
9578 let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
9579 .and_then(|name| simple_type_name(&name));
9580 let line = reference.line.max(1);
9581 let mut best = None;
9582 let mut stack = vec![root];
9583 while let Some(node) = stack.pop() {
9584 if !node_contains_line(node, line) {
9585 continue;
9586 }
9587 if is_java_like_type_kind(node.kind(), lang) {
9588 let name = declaration_name(node, source);
9589 if expected_type
9590 .as_deref()
9591 .is_none_or(|expected| name == Some(expected))
9592 {
9593 best = tighter_node(best, node);
9594 }
9595 }
9596 push_named_children(node, &mut stack);
9597 }
9598 best
9599}
9600
9601fn find_enclosing_java_like_callable_node<'tree>(
9602 root: tree_sitter::Node<'tree>,
9603 source: &str,
9604 reference: &NameMatchRef,
9605 lang: LangId,
9606) -> Option<tree_sitter::Node<'tree>> {
9607 let expected_name = reference.caller_symbol.rsplit("::").next();
9608 let line = reference.line.max(1);
9609 let mut best = None;
9610 let mut stack = vec![root];
9611 while let Some(node) = stack.pop() {
9612 if !node_contains_line(node, line) {
9613 continue;
9614 }
9615 if is_java_like_callable_kind(node.kind(), lang) {
9616 let name = declaration_name(node, source);
9617 if expected_name.is_none_or(|expected| name == Some(expected)) {
9618 best = tighter_node(best, node);
9619 }
9620 }
9621 push_named_children(node, &mut stack);
9622 }
9623 best
9624}
9625
9626fn find_enclosing_cpp_callable_node<'tree>(
9627 root: tree_sitter::Node<'tree>,
9628 _source: &str,
9629 reference: &NameMatchRef,
9630) -> Option<tree_sitter::Node<'tree>> {
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 node.kind() == "function_definition" {
9639 best = tighter_node(best, node);
9640 }
9641 push_named_children(node, &mut stack);
9642 }
9643 best
9644}
9645
9646fn tighter_node<'tree>(
9647 current: Option<tree_sitter::Node<'tree>>,
9648 candidate: tree_sitter::Node<'tree>,
9649) -> Option<tree_sitter::Node<'tree>> {
9650 match current {
9651 Some(current)
9652 if current.start_byte() > candidate.start_byte()
9653 || (current.start_byte() == candidate.start_byte()
9654 && current.end_byte() <= candidate.end_byte()) =>
9655 {
9656 Some(current)
9657 }
9658 _ => Some(candidate),
9659 }
9660}
9661
9662fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
9663 let start = node.start_position().row as u32 + 1;
9664 let end = node.end_position().row as u32 + 1;
9665 start <= line && line <= end
9666}
9667
9668fn push_named_children<'tree>(
9669 node: tree_sitter::Node<'tree>,
9670 stack: &mut Vec<tree_sitter::Node<'tree>>,
9671) {
9672 for index in 0..node.named_child_count() {
9673 if let Some(child) = node.named_child(index as u32) {
9674 stack.push(child);
9675 }
9676 }
9677}
9678
9679fn declaration_name<'source>(
9680 node: tree_sitter::Node<'_>,
9681 source: &'source str,
9682) -> Option<&'source str> {
9683 node.child_by_field_name("name")
9684 .map(|name| node_text(name, source))
9685 .or_else(|| {
9686 first_named_child_text(
9687 node,
9688 source,
9689 &["identifier", "type_identifier", "simple_identifier"],
9690 )
9691 })
9692}
9693
9694fn first_named_child_text<'source>(
9695 node: tree_sitter::Node<'_>,
9696 source: &'source str,
9697 kinds: &[&str],
9698) -> Option<&'source str> {
9699 for index in 0..node.named_child_count() {
9700 let child = node.named_child(index as u32)?;
9701 if kinds.contains(&child.kind()) {
9702 return Some(node_text(child, source));
9703 }
9704 }
9705 None
9706}
9707
9708fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
9709 &source[node.byte_range()]
9710}
9711
9712fn infer_java_like_field_receiver_type(
9713 type_node: tree_sitter::Node<'_>,
9714 source: &str,
9715 receiver: &str,
9716 lang: LangId,
9717) -> Option<String> {
9718 let mut stack = Vec::new();
9719 push_named_children(type_node, &mut stack);
9720 while let Some(node) = stack.pop() {
9721 if is_java_like_field_kind(node.kind(), lang) {
9722 if let Some(receiver_type) =
9723 extract_java_like_declared_type(node_text(node, source), receiver, lang)
9724 {
9725 return Some(receiver_type);
9726 }
9727 }
9728 if is_java_like_type_kind(node.kind(), lang)
9729 || is_java_like_callable_kind(node.kind(), lang)
9730 {
9731 continue;
9732 }
9733 push_named_children(node, &mut stack);
9734 }
9735 None
9736}
9737
9738fn infer_java_like_local_receiver_type(
9739 callable_node: tree_sitter::Node<'_>,
9740 source: &str,
9741 receiver: &str,
9742 call_line: u32,
9743 lang: LangId,
9744) -> Option<String> {
9745 let mut best: Option<(u32, String)> = None;
9746 let mut stack = Vec::new();
9747 push_named_children(callable_node, &mut stack);
9748 while let Some(node) = stack.pop() {
9749 let start_line = node.start_position().row as u32 + 1;
9750 if start_line > call_line {
9751 continue;
9752 }
9753 if is_java_like_local_kind(node.kind(), lang) {
9754 if let Some(receiver_type) =
9755 extract_java_like_declared_type(node_text(node, source), receiver, lang)
9756 {
9757 if best
9758 .as_ref()
9759 .is_none_or(|(best_line, _)| start_line >= *best_line)
9760 {
9761 best = Some((start_line, receiver_type));
9762 }
9763 }
9764 }
9765 if is_java_like_type_kind(node.kind(), lang)
9766 || is_java_like_callable_kind(node.kind(), lang)
9767 {
9768 continue;
9769 }
9770 push_named_children(node, &mut stack);
9771 }
9772 best.map(|(_, receiver_type)| receiver_type)
9773}
9774
9775fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
9776 match lang {
9777 LangId::Java => matches!(
9778 kind,
9779 "class_declaration"
9780 | "interface_declaration"
9781 | "enum_declaration"
9782 | "record_declaration"
9783 | "annotation_type_declaration"
9784 ),
9785 LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
9786 _ => false,
9787 }
9788}
9789
9790fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
9791 match lang {
9792 LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
9793 LangId::Kotlin => kind == "function_declaration",
9794 _ => false,
9795 }
9796}
9797
9798fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
9799 match lang {
9800 LangId::Java => kind == "field_declaration",
9801 LangId::Kotlin => kind == "property_declaration",
9802 _ => false,
9803 }
9804}
9805
9806fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
9807 match lang {
9808 LangId::Java => kind == "local_variable_declaration",
9809 LangId::Kotlin => kind == "property_declaration",
9810 _ => false,
9811 }
9812}
9813
9814fn extract_java_like_declared_type(
9815 declaration: &str,
9816 receiver: &str,
9817 lang: LangId,
9818) -> Option<String> {
9819 match lang {
9820 LangId::Java => extract_java_declared_type(declaration, receiver),
9821 LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
9822 _ => None,
9823 }
9824}
9825
9826fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
9827 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
9828 let after = declaration[receiver_start + receiver.len()..].trim_start();
9829 if after
9830 .chars()
9831 .next()
9832 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
9833 {
9834 return None;
9835 }
9836
9837 let before = declaration[..receiver_start].trim_end();
9838 if before.contains(',') {
9839 return None;
9840 }
9841 normalize_receiver_type_name(strip_java_declaration_prefixes(before))
9842}
9843
9844fn strip_java_declaration_prefixes(mut value: &str) -> &str {
9845 loop {
9846 value = value.trim_start();
9847 if let Some(stripped) = strip_leading_java_annotation(value) {
9848 value = stripped;
9849 continue;
9850 }
9851 if let Some(stripped) = strip_leading_java_modifier(value) {
9852 value = stripped;
9853 continue;
9854 }
9855 return value.trim();
9856 }
9857}
9858
9859fn strip_leading_java_annotation(value: &str) -> Option<&str> {
9860 let value = value.trim_start();
9861 let mut chars = value.char_indices();
9862 let (_, first) = chars.next()?;
9863 if first != '@' {
9864 return None;
9865 }
9866 let mut end = first.len_utf8();
9867 for (index, ch) in chars {
9868 if !(is_code_ident_char(ch) || ch == '.') {
9869 end = index;
9870 break;
9871 }
9872 end = index + ch.len_utf8();
9873 }
9874 let rest = value[end..].trim_start();
9875 if let Some(stripped) = rest.strip_prefix('(') {
9876 let mut depth = 1usize;
9877 for (index, ch) in stripped.char_indices() {
9878 match ch {
9879 '(' => depth += 1,
9880 ')' => {
9881 depth = depth.saturating_sub(1);
9882 if depth == 0 {
9883 return Some(stripped[index + ch.len_utf8()..].trim_start());
9884 }
9885 }
9886 _ => {}
9887 }
9888 }
9889 return Some("");
9890 }
9891 Some(rest)
9892}
9893
9894fn strip_leading_java_modifier(value: &str) -> Option<&str> {
9895 const MODIFIERS: &[&str] = &[
9896 "public",
9897 "protected",
9898 "private",
9899 "abstract",
9900 "static",
9901 "final",
9902 "transient",
9903 "volatile",
9904 "synchronized",
9905 "native",
9906 "strictfp",
9907 ];
9908 MODIFIERS
9909 .iter()
9910 .find_map(|modifier| strip_leading_word(value, modifier))
9911}
9912
9913fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
9914 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
9915 let before = &declaration[..receiver_start];
9916 if find_identifier_occurrence(before, "val").is_none()
9917 && find_identifier_occurrence(before, "var").is_none()
9918 {
9919 return None;
9920 }
9921
9922 let after = declaration[receiver_start + receiver.len()..].trim_start();
9923 if let Some(type_text) = after.strip_prefix(':') {
9924 return normalize_receiver_type_name(read_type_prefix(type_text));
9925 }
9926 after
9927 .strip_prefix('=')
9928 .and_then(infer_kotlin_constructor_type)
9929}
9930
9931fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
9932 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
9933 if rest.trim_start().starts_with('(') {
9934 normalize_receiver_type_name(head)
9935 } else {
9936 None
9937 }
9938}
9939
9940fn read_type_prefix(value: &str) -> &str {
9941 let mut angle_depth = 0usize;
9942 for (index, ch) in value.char_indices() {
9943 match ch {
9944 '<' => angle_depth += 1,
9945 '>' => angle_depth = angle_depth.saturating_sub(1),
9946 '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
9947 return value[..index].trim();
9948 }
9949 _ => {}
9950 }
9951 }
9952 value.trim()
9953}
9954
9955fn infer_cpp_receiver_type_from_scope(
9956 scope: tree_sitter::Node<'_>,
9957 source: &str,
9958 receiver: &str,
9959 call_line: u32,
9960) -> Option<String> {
9961 let lines = source.lines().collect::<Vec<_>>();
9962 if lines.is_empty() {
9963 return None;
9964 }
9965 let scope_start = scope.start_position().row as usize;
9966 let call_index = (call_line as usize)
9967 .saturating_sub(1)
9968 .min(lines.len().saturating_sub(1));
9969 for index in (scope_start..=call_index).rev() {
9970 if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
9971 return Some(receiver_type);
9972 }
9973 }
9974 None
9975}
9976
9977fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
9978 for receiver_start in identifier_occurrences(line, receiver) {
9979 let after = line[receiver_start + receiver.len()..].trim_start();
9980 if after
9981 .chars()
9982 .next()
9983 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
9984 {
9985 continue;
9986 }
9987 let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
9988 let normalized = normalize_cpp_type_name(type_text)?;
9989 if normalized == "auto" {
9990 if let Some(rhs) = after.strip_prefix('=') {
9991 return infer_cpp_auto_receiver_type(rhs);
9992 }
9993 continue;
9994 }
9995 return Some(normalized);
9996 }
9997 None
9998}
9999
10000fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
10001 let candidate = prefix
10002 .rsplit([';', '{', '}', '('])
10003 .next()
10004 .unwrap_or(prefix)
10005 .trim();
10006 if candidate.is_empty() || candidate.ends_with(',') {
10007 None
10008 } else {
10009 Some(candidate)
10010 }
10011}
10012
10013fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
10014 let without_templates = strip_angle_groups(type_text);
10015 let mut cleaned = String::with_capacity(without_templates.len());
10016 for token in without_templates.split_whitespace() {
10017 if matches!(
10018 token,
10019 "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
10020 ) {
10021 continue;
10022 }
10023 if !cleaned.is_empty() {
10024 cleaned.push(' ');
10025 }
10026 cleaned.push_str(token);
10027 }
10028 let token = cleaned
10029 .split_whitespace()
10030 .last()
10031 .unwrap_or(cleaned.trim())
10032 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
10033 .trim_matches(['*', '&']);
10034 let simple = token.rsplit("::").next().unwrap_or(token).trim();
10035 if simple.is_empty() || cpp_non_type_token(simple) {
10036 None
10037 } else {
10038 Some(simple.to_string())
10039 }
10040}
10041
10042fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
10043 let rhs = rhs.trim_start();
10044 if let Some(after_new) = rhs.strip_prefix("new ") {
10045 return infer_cpp_constructor_type(after_new);
10046 }
10047 infer_cpp_make_template_type(rhs)
10048 .or_else(|| infer_cpp_constructor_type(rhs))
10049 .or_else(|| infer_cpp_factory_type(rhs))
10050}
10051
10052fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
10053 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
10054 let normalized = normalize_cpp_type_name(head)?;
10055 if !normalized
10056 .chars()
10057 .next()
10058 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
10059 {
10060 return None;
10061 }
10062 if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
10063 Some(normalized)
10064 } else {
10065 None
10066 }
10067}
10068
10069fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
10070 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
10071 if !rest.trim_start().starts_with('(') {
10072 return None;
10073 }
10074 let base = head.split('<').next().unwrap_or(head);
10075 let base_simple = base.rsplit("::").next().unwrap_or(base);
10076 if !matches!(base_simple, "make_unique" | "make_shared") {
10077 return None;
10078 }
10079 first_angle_arg(head).and_then(normalize_cpp_type_name)
10080}
10081
10082fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
10083 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
10084 if !rest.trim_start().starts_with('(') {
10085 return None;
10086 }
10087 let simple = head
10088 .split('<')
10089 .next()
10090 .unwrap_or(head)
10091 .rsplit("::")
10092 .next()
10093 .unwrap_or(head);
10094 for prefix in ["make", "create", "build"] {
10095 if let Some(suffix) = simple.strip_prefix(prefix) {
10096 if suffix
10097 .chars()
10098 .next()
10099 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
10100 {
10101 return normalize_cpp_type_name(suffix);
10102 }
10103 }
10104 }
10105 None
10106}
10107
10108#[derive(Debug, Clone, Copy)]
10109enum JavaLikeInvocation {
10110 Kotlin,
10111 Cpp,
10112}
10113
10114fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
10115 let value = value.trim_start();
10116 let mut end = 0usize;
10117 for (index, ch) in value.char_indices() {
10118 let allowed_separator = match flavor {
10119 JavaLikeInvocation::Kotlin => ch == '.',
10120 JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
10121 };
10122 if is_code_ident_char(ch) || allowed_separator {
10123 end = index + ch.len_utf8();
10124 continue;
10125 }
10126 break;
10127 }
10128 if end == 0 {
10129 return None;
10130 }
10131 let mut rest = &value[end..];
10132 if let Some(stripped) = rest.trim_start().strip_prefix('<') {
10133 let skipped = skip_balanced_angle(stripped)?;
10134 let rest_start = rest.len() - rest.trim_start().len();
10135 let angle_len = 1 + skipped;
10136 end += rest_start + angle_len;
10137 rest = &value[end..];
10138 }
10139 Some((value[..end].trim(), rest))
10140}
10141
10142fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
10143 let mut depth = 1usize;
10144 for (index, ch) in value_after_open.char_indices() {
10145 match ch {
10146 '<' => depth += 1,
10147 '>' => {
10148 depth = depth.saturating_sub(1);
10149 if depth == 0 {
10150 return Some(index + ch.len_utf8());
10151 }
10152 }
10153 _ => {}
10154 }
10155 }
10156 None
10157}
10158
10159fn first_angle_arg(value: &str) -> Option<&str> {
10160 let open = value.find('<')?;
10161 let inner_len = skip_balanced_angle(&value[open + 1..])?;
10162 let inner = &value[open + 1..open + inner_len];
10163 split_top_level_commas(inner).into_iter().next()
10164}
10165
10166fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
10167 let without_generics = strip_angle_groups(type_text);
10168 let cleaned = without_generics
10169 .replace("[]", " ")
10170 .replace("...", " ")
10171 .replace(['?', '&', '*'], " ");
10172 let token = cleaned
10173 .split_whitespace()
10174 .last()
10175 .unwrap_or(cleaned.trim())
10176 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
10177 let token = token.rsplit("::").next().unwrap_or(token);
10178 let simple = token.rsplit('.').next().unwrap_or(token).trim();
10179 if simple.is_empty()
10180 || java_like_primitive_type(simple)
10181 || !simple
10182 .chars()
10183 .next()
10184 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
10185 {
10186 None
10187 } else {
10188 Some(simple.to_string())
10189 }
10190}
10191
10192fn simple_type_name(scoped_name: &str) -> Option<String> {
10193 scoped_name
10194 .rsplit("::")
10195 .find(|segment| !segment.is_empty())
10196 .and_then(normalize_receiver_type_name)
10197}
10198
10199fn strip_angle_groups(value: &str) -> String {
10200 let mut output = String::with_capacity(value.len());
10201 let mut depth = 0usize;
10202 for ch in value.chars() {
10203 match ch {
10204 '<' => {
10205 if depth == 0 {
10206 output.push(' ');
10207 }
10208 depth += 1;
10209 }
10210 '>' => depth = depth.saturating_sub(1),
10211 _ if depth == 0 => output.push(ch),
10212 _ => {}
10213 }
10214 }
10215 output
10216}
10217
10218fn java_like_primitive_type(value: &str) -> bool {
10219 matches!(
10220 value,
10221 "boolean"
10222 | "byte"
10223 | "char"
10224 | "double"
10225 | "float"
10226 | "int"
10227 | "long"
10228 | "short"
10229 | "void"
10230 | "Boolean"
10231 | "Byte"
10232 | "Char"
10233 | "Double"
10234 | "Float"
10235 | "Int"
10236 | "Long"
10237 | "Short"
10238 | "Unit"
10239 )
10240}
10241
10242fn cpp_non_type_token(value: &str) -> bool {
10243 matches!(
10244 value,
10245 "return"
10246 | "if"
10247 | "else"
10248 | "for"
10249 | "while"
10250 | "do"
10251 | "switch"
10252 | "case"
10253 | "default"
10254 | "break"
10255 | "continue"
10256 | "goto"
10257 | "throw"
10258 | "new"
10259 | "delete"
10260 | "co_await"
10261 | "co_yield"
10262 | "co_return"
10263 | "static_cast"
10264 | "const_cast"
10265 | "dynamic_cast"
10266 | "reinterpret_cast"
10267 | "sizeof"
10268 | "alignof"
10269 | "typeid"
10270 | "and"
10271 | "or"
10272 | "not"
10273 | "xor"
10274 )
10275}
10276
10277fn receiver_is_bare_identifier(value: &str) -> bool {
10278 let mut chars = value.chars();
10279 let Some(first) = chars.next() else {
10280 return false;
10281 };
10282 (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
10283}
10284
10285fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
10286 identifier_occurrences(value, needle).into_iter().next()
10287}
10288
10289fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
10290 value
10291 .match_indices(needle)
10292 .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
10293 .collect()
10294}
10295
10296fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
10297 let before = value[..start].chars().next_back();
10298 let after = value[start + len..].chars().next();
10299 !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
10300}
10301
10302fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
10303 let stripped = value.strip_prefix(word)?;
10304 if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
10305 Some(stripped.trim_start())
10306 } else {
10307 None
10308 }
10309}
10310
10311fn is_code_ident_char(ch: char) -> bool {
10312 ch == '_' || ch.is_ascii_alphanumeric()
10313}
10314
10315fn infer_rust_receiver_type(
10316 project_root: &Path,
10317 reference: &NameMatchRef,
10318 source_cache: &mut DispatchSourceCache,
10319) -> ReceiverTypeInference {
10320 if matches!(reference.receiver.as_str(), "self" | "Self") {
10321 return enclosing_type_from_scoped_name(&reference.caller_symbol)
10322 .map(ReceiverTypeInference::Known)
10323 .unwrap_or(ReceiverTypeInference::Unknown);
10324 }
10325
10326 if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
10327 return ReceiverTypeInference::Known(reference.receiver.clone());
10328 }
10329
10330 if let Some(receiver_type) = reference
10331 .caller_signature
10332 .as_deref()
10333 .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
10334 {
10335 return ReceiverTypeInference::Known(receiver_type);
10336 }
10337
10338 infer_rust_direct_self_field_receiver_type(project_root, reference, source_cache)
10339}
10340
10341fn infer_rust_direct_self_field_receiver_type(
10342 project_root: &Path,
10343 reference: &NameMatchRef,
10344 source_cache: &mut DispatchSourceCache,
10345) -> ReceiverTypeInference {
10346 if reference.colon_dispatch {
10347 return ReceiverTypeInference::Unknown;
10348 }
10349 let Some(field_name) = rust_direct_self_field_name(&reference.receiver_expression) else {
10350 return ReceiverTypeInference::Unknown;
10351 };
10352 if field_name != reference.receiver {
10353 return ReceiverTypeInference::Unknown;
10354 }
10355
10356 let Some(impl_type) = enclosing_type_from_scoped_name(&reference.caller_symbol) else {
10357 return ReceiverTypeInference::Unknown;
10358 };
10359 let Some(struct_name) = rust_direct_nominal_type_name(&impl_type) else {
10360 return ReceiverTypeInference::KnownButUnresolved;
10361 };
10362 let Some(parsed) = parsed_dispatch_source(project_root, reference, LangId::Rust, source_cache)
10363 else {
10364 return ReceiverTypeInference::Unknown;
10365 };
10366 let Some(impl_node) =
10367 find_enclosing_rust_impl_node(parsed.tree.root_node(), reference.line.max(1))
10368 else {
10369 return ReceiverTypeInference::Unknown;
10370 };
10371 if impl_node.child_by_field_name("trait").is_some()
10372 || impl_node.child_by_field_name("type_parameters").is_some()
10373 {
10374 return ReceiverTypeInference::KnownButUnresolved;
10375 }
10376 let Some(impl_target) = impl_node.child_by_field_name("type") else {
10377 return ReceiverTypeInference::KnownButUnresolved;
10378 };
10379 if impl_target.kind() != "type_identifier"
10380 || node_text(impl_target, &parsed.source) != impl_type
10381 {
10382 return ReceiverTypeInference::KnownButUnresolved;
10383 }
10384
10385 let module_scope = rust_module_scope(impl_node);
10386 let Some(struct_node) = find_unique_rust_struct(
10387 parsed.tree.root_node(),
10388 &parsed.source,
10389 struct_name,
10390 &module_scope,
10391 ) else {
10392 return ReceiverTypeInference::KnownButUnresolved;
10393 };
10394 let Some(field_type) = rust_struct_field_type_node(struct_node, &parsed.source, field_name)
10395 else {
10396 return ReceiverTypeInference::KnownButUnresolved;
10397 };
10398 if field_type.kind() != "type_identifier" {
10399 return ReceiverTypeInference::KnownButUnresolved;
10400 }
10401 let field_type_name = node_text(field_type, &parsed.source);
10402 if find_unique_rust_struct(
10403 parsed.tree.root_node(),
10404 &parsed.source,
10405 field_type_name,
10406 &module_scope,
10407 )
10408 .is_none()
10409 {
10410 return ReceiverTypeInference::KnownButUnresolved;
10411 }
10412
10413 ReceiverTypeInference::RustDirectSelfField {
10414 receiver_type: field_type_name.to_string(),
10415 declaration_file: reference.caller_file.clone(),
10416 module_scope,
10417 }
10418}
10419
10420fn rust_direct_self_field_name(receiver_expression: &str) -> Option<&str> {
10421 let (base, field) = receiver_expression.split_once('.')?;
10422 let base = base.trim();
10423 let field = field.trim();
10424 (base == "self" && rust_direct_nominal_type_name(field).is_some()).then_some(field)
10425}
10426
10427fn rust_direct_nominal_type_name(value: &str) -> Option<&str> {
10428 let name = value.rsplit("::").next()?.trim();
10429 (!name.is_empty()
10430 && !name.chars().next().is_some_and(|ch| ch.is_ascii_digit())
10431 && name.chars().all(is_rust_ident_char))
10432 .then_some(name)
10433}
10434
10435fn find_enclosing_rust_impl_node<'tree>(
10436 root: tree_sitter::Node<'tree>,
10437 line: u32,
10438) -> Option<tree_sitter::Node<'tree>> {
10439 let mut best = None;
10440 let mut stack = vec![root];
10441 while let Some(node) = stack.pop() {
10442 if !node_contains_line(node, line) {
10443 continue;
10444 }
10445 if node.kind() == "impl_item" {
10446 best = tighter_node(best, node);
10447 }
10448 push_named_children(node, &mut stack);
10449 }
10450 best
10451}
10452
10453fn rust_module_scope(node: tree_sitter::Node<'_>) -> Vec<(usize, usize)> {
10454 let mut scope = Vec::new();
10455 let mut current = node.parent();
10456 while let Some(parent) = current {
10457 if parent.kind() == "mod_item" {
10458 scope.push((parent.start_byte(), parent.end_byte()));
10459 }
10460 current = parent.parent();
10461 }
10462 scope.reverse();
10463 scope
10464}
10465
10466fn find_unique_rust_struct<'tree>(
10467 root: tree_sitter::Node<'tree>,
10468 source: &str,
10469 expected_name: &str,
10470 module_scope: &[(usize, usize)],
10471) -> Option<tree_sitter::Node<'tree>> {
10472 let mut found = None;
10473 let mut stack = vec![root];
10474 while let Some(node) = stack.pop() {
10475 if node.kind() == "struct_item"
10476 && rust_module_scope(node) == module_scope
10477 && node.child_by_field_name("type_parameters").is_none()
10478 && declaration_name(node, source) == Some(expected_name)
10479 {
10480 if found.is_some() {
10481 return None;
10482 }
10483 found = Some(node);
10484 }
10485 push_named_children(node, &mut stack);
10486 }
10487 found
10488}
10489
10490fn rust_struct_field_type_node<'tree>(
10491 struct_node: tree_sitter::Node<'tree>,
10492 source: &str,
10493 field_name: &str,
10494) -> Option<tree_sitter::Node<'tree>> {
10495 let fields = struct_node.child_by_field_name("body")?;
10496 if fields.kind() != "field_declaration_list" {
10497 return None;
10498 }
10499 for index in 0..fields.named_child_count() {
10500 let field = fields.named_child(index as u32)?;
10501 if field.kind() != "field_declaration"
10502 || declaration_name(field, source) != Some(field_name)
10503 {
10504 continue;
10505 }
10506 return field.child_by_field_name("type");
10507 }
10508 None
10509}
10510
10511fn rust_receiver_looks_type_like(receiver: &str) -> bool {
10512 receiver
10513 .chars()
10514 .next()
10515 .is_some_and(|ch| ch == '_' || ch.is_uppercase())
10516}
10517
10518fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
10519 scoped_name
10520 .rsplit_once("::")
10521 .map(|(enclosing, _)| enclosing)
10522 .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
10523 .map(ToString::to_string)
10524}
10525
10526fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
10527 let params = signature_parameter_text(signature)?;
10528 for param in split_top_level_commas(params) {
10529 let Some((pattern, type_text)) = param.split_once(':') else {
10530 continue;
10531 };
10532 let Some(name) = rust_parameter_name(pattern) else {
10533 continue;
10534 };
10535 if name == receiver {
10536 return normalize_rust_receiver_type(type_text);
10537 }
10538 }
10539 None
10540}
10541
10542fn signature_parameter_text(signature: &str) -> Option<&str> {
10543 let open = signature.find('(')?;
10544 let mut depth = 0usize;
10545 for (offset, ch) in signature[open..].char_indices() {
10546 match ch {
10547 '(' => depth += 1,
10548 ')' => {
10549 depth = depth.saturating_sub(1);
10550 if depth == 0 {
10551 return Some(&signature[open + 1..open + offset]);
10552 }
10553 }
10554 _ => {}
10555 }
10556 }
10557 None
10558}
10559
10560fn split_top_level_commas(value: &str) -> Vec<&str> {
10561 let mut parts = Vec::new();
10562 let mut start = 0usize;
10563 let mut angle_depth = 0usize;
10564 let mut paren_depth = 0usize;
10565 let mut bracket_depth = 0usize;
10566 for (index, ch) in value.char_indices() {
10567 match ch {
10568 '<' => angle_depth += 1,
10569 '>' => angle_depth = angle_depth.saturating_sub(1),
10570 '(' => paren_depth += 1,
10571 ')' => paren_depth = paren_depth.saturating_sub(1),
10572 '[' => bracket_depth += 1,
10573 ']' => bracket_depth = bracket_depth.saturating_sub(1),
10574 ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
10575 let part = value[start..index].trim();
10576 if !part.is_empty() {
10577 parts.push(part);
10578 }
10579 start = index + ch.len_utf8();
10580 }
10581 _ => {}
10582 }
10583 }
10584 let part = value[start..].trim();
10585 if !part.is_empty() {
10586 parts.push(part);
10587 }
10588 parts
10589}
10590
10591fn rust_parameter_name(pattern: &str) -> Option<&str> {
10592 let mut pattern = pattern.trim();
10593 if let Some(stripped) = pattern.strip_prefix("mut ") {
10594 pattern = stripped.trim_start();
10595 }
10596 pattern
10597 .rsplit(|ch: char| !is_rust_ident_char(ch))
10598 .find(|part| !part.is_empty())
10599}
10600
10601fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
10602 let mut ty = strip_leading_rust_type_modifiers(type_text);
10603 let owned_inner;
10604 if let Some(inner) = single_outer_generic_arg(ty) {
10605 owned_inner = inner.trim().to_string();
10606 ty = strip_leading_rust_type_modifiers(&owned_inner);
10607 }
10608 rust_base_type_ident(ty)
10609}
10610
10611fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
10612 loop {
10613 ty = ty.trim_start();
10614 if let Some(stripped) = ty.strip_prefix('&') {
10615 ty = stripped.trim_start();
10616 if let Some(stripped) = strip_leading_lifetime(ty) {
10617 ty = stripped.trim_start();
10618 }
10619 if let Some(stripped) = ty.strip_prefix("mut ") {
10620 ty = stripped.trim_start();
10621 }
10622 continue;
10623 }
10624 if let Some(stripped) = ty.strip_prefix("mut ") {
10625 ty = stripped.trim_start();
10626 continue;
10627 }
10628 if let Some(stripped) = ty.strip_prefix("dyn ") {
10629 ty = stripped.trim_start();
10630 continue;
10631 }
10632 if let Some(stripped) = ty.strip_prefix("impl ") {
10633 ty = stripped.trim_start();
10634 continue;
10635 }
10636 break ty.trim();
10637 }
10638}
10639
10640fn strip_leading_lifetime(value: &str) -> Option<&str> {
10641 let mut chars = value.char_indices();
10642 let (_, first) = chars.next()?;
10643 if first != '\'' {
10644 return None;
10645 }
10646 for (index, ch) in chars {
10647 if !(ch == '_' || ch.is_ascii_alphanumeric()) {
10648 return Some(&value[index..]);
10649 }
10650 }
10651 Some("")
10652}
10653
10654fn single_outer_generic_arg(ty: &str) -> Option<&str> {
10655 let ty = ty.trim();
10656 let open = ty.find('<')?;
10657 let mut depth = 0usize;
10658 let mut close = None;
10659 for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
10660 match ch {
10661 '<' => depth += 1,
10662 '>' => {
10663 depth = depth.saturating_sub(1);
10664 if depth == 0 {
10665 close = Some(index);
10666 break;
10667 }
10668 }
10669 _ => {}
10670 }
10671 }
10672 let close = close?;
10673 if !ty[close + 1..].trim().is_empty() {
10674 return None;
10675 }
10676 let inner = &ty[open + 1..close];
10677 let args = split_top_level_commas(inner);
10678 match args.as_slice() {
10679 [arg] => Some(*arg),
10680 _ => None,
10681 }
10682}
10683
10684fn rust_base_type_ident(ty: &str) -> Option<String> {
10685 let ty = ty.trim();
10686 let head = ty
10687 .split([' ', '+', '='])
10688 .find(|part| !part.is_empty())
10689 .unwrap_or(ty);
10690 let head = head.split('<').next().unwrap_or(head).trim();
10691 let ident = head
10692 .rsplit("::")
10693 .next()
10694 .unwrap_or(head)
10695 .trim_matches(|ch: char| !is_rust_ident_char(ch));
10696 if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
10697 None
10698 } else {
10699 Some(ident.to_string())
10700 }
10701}
10702
10703fn is_rust_ident_char(ch: char) -> bool {
10704 ch == '_' || ch.is_ascii_alphanumeric()
10705}
10706
10707fn select_rust_direct_self_field_candidate(
10708 project_root: &Path,
10709 reference: &NameMatchRef,
10710 candidates: &[NameMatchCandidate],
10711 receiver_type: &str,
10712 declaration_file: &str,
10713 declaration_scope: &[(usize, usize)],
10714 source_cache: &mut DispatchSourceCache,
10715) -> Option<NameMatchCandidate> {
10716 let eligible = candidates
10717 .iter()
10718 .filter(|candidate| candidate.node_id != reference.caller_node)
10719 .filter(|candidate| {
10720 type_candidate_matches(candidate, receiver_type, &reference.method_name)
10721 })
10722 .filter(|candidate| {
10723 rust_direct_self_field_candidate_matches_scope(
10724 project_root,
10725 candidate,
10726 receiver_type,
10727 declaration_file,
10728 declaration_scope,
10729 source_cache,
10730 )
10731 })
10732 .collect::<Vec<_>>();
10733 match eligible.as_slice() {
10734 [candidate] => Some((**candidate).clone()),
10735 _ => None,
10736 }
10737}
10738
10739fn rust_direct_self_field_candidate_matches_scope(
10740 project_root: &Path,
10741 candidate: &NameMatchCandidate,
10742 receiver_type: &str,
10743 declaration_file: &str,
10744 declaration_scope: &[(usize, usize)],
10745 source_cache: &mut DispatchSourceCache,
10746) -> bool {
10747 if candidate.file_path != declaration_file {
10748 return false;
10749 }
10750 let Some(parsed) = parsed_dispatch_source_for_file(
10751 project_root,
10752 &candidate.file_path,
10753 "rust",
10754 LangId::Rust,
10755 source_cache,
10756 ) else {
10757 return false;
10758 };
10759 let Some(impl_node) =
10760 find_enclosing_rust_impl_node(parsed.tree.root_node(), candidate.start_line)
10761 else {
10762 return false;
10763 };
10764 if impl_node.child_by_field_name("trait").is_some()
10765 || impl_node.child_by_field_name("type_parameters").is_some()
10766 {
10767 return false;
10768 }
10769 let Some(impl_target) = impl_node.child_by_field_name("type") else {
10770 return false;
10771 };
10772 impl_target.kind() == "type_identifier"
10773 && node_text(impl_target, &parsed.source) == receiver_type
10774 && rust_module_scope(impl_node) == declaration_scope
10775}
10776
10777fn select_type_match_candidate(
10778 reference: &NameMatchRef,
10779 candidates: &[NameMatchCandidate],
10780 receiver_type: &str,
10781) -> Option<NameMatchCandidate> {
10782 let candidates = candidates
10783 .iter()
10784 .filter(|candidate| candidate.node_id != reference.caller_node)
10785 .filter(|candidate| {
10786 type_candidate_matches(candidate, receiver_type, &reference.method_name)
10787 })
10788 .collect::<Vec<_>>();
10789 match candidates.as_slice() {
10790 [candidate] => Some((**candidate).clone()),
10791 _ => None,
10792 }
10793}
10794
10795fn type_candidate_matches(
10796 candidate: &NameMatchCandidate,
10797 receiver_type: &str,
10798 method_name: &str,
10799) -> bool {
10800 let normalized_type = receiver_type.replace('.', "::");
10801 let suffix = format!("{normalized_type}::{method_name}");
10802 candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
10803}
10804
10805fn select_name_match_candidate(
10806 reference: &NameMatchRef,
10807 candidates: &[NameMatchCandidate],
10808) -> Option<NameMatchCandidate> {
10809 let candidates = candidates
10810 .iter()
10811 .filter(|candidate| candidate.node_id != reference.caller_node)
10812 .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
10813 .collect::<Vec<_>>();
10814 match candidates.as_slice() {
10815 [] => None,
10816 [candidate] => Some((**candidate).clone()),
10817 _ => select_scored_name_match_candidate(reference, &candidates),
10818 }
10819}
10820
10821fn candidate_allowed_for_reference(
10822 reference: &NameMatchRef,
10823 candidate: &NameMatchCandidate,
10824) -> bool {
10825 if !reference.colon_dispatch {
10826 return true;
10827 }
10828
10829 candidate.kind == "method"
10830 && candidate
10831 .scoped_name
10832 .split("::")
10833 .any(|segment| segment == reference.receiver)
10834}
10835
10836fn select_scored_name_match_candidate(
10837 reference: &NameMatchRef,
10838 candidates: &[&NameMatchCandidate],
10839) -> Option<NameMatchCandidate> {
10840 let receiver_words = split_camel_case(&reference.receiver);
10841 if receiver_words.is_empty() {
10842 return None;
10843 }
10844
10845 let mut best: Option<(&NameMatchCandidate, f64)> = None;
10846 let mut tied_best = false;
10847 for candidate in candidates {
10848 let candidate_words = split_camel_case(&candidate.scoped_name);
10849 let overlap = receiver_words
10850 .iter()
10851 .filter(|receiver_word| {
10852 candidate_words
10853 .iter()
10854 .any(|candidate_word| candidate_word == *receiver_word)
10855 })
10856 .count() as f64;
10857 let score =
10858 overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
10859 match best {
10860 None => {
10861 best = Some((*candidate, score));
10862 tied_best = false;
10863 }
10864 Some((_, best_score)) if score > best_score => {
10865 best = Some((*candidate, score));
10866 tied_best = false;
10867 }
10868 Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
10869 tied_best = true;
10870 }
10871 _ => {}
10872 }
10873 }
10874
10875 let (candidate, score) = best?;
10876 if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
10877 Some(candidate.clone())
10878 } else {
10879 None
10880 }
10881}
10882
10883fn method_name_match_denylisted(method_name: &str) -> bool {
10884 matches!(
10885 method_name,
10886 "and_then"
10887 | "as_bytes"
10888 | "as_deref"
10889 | "as_mut"
10890 | "as_ref"
10891 | "as_str"
10892 | "borrow"
10893 | "borrow_mut"
10894 | "clear"
10895 | "clone"
10896 | "collect"
10897 | "contains"
10898 | "contains_key"
10899 | "count"
10900 | "dedup"
10901 | "default"
10902 | "drain"
10903 | "ends_with"
10904 | "entry"
10905 | "err"
10906 | "expect"
10907 | "extend"
10908 | "filter"
10909 | "filter_map"
10910 | "find"
10911 | "from"
10912 | "get"
10913 | "get_mut"
10914 | "insert"
10915 | "into"
10916 | "into_iter"
10917 | "is_empty"
10918 | "is_err"
10919 | "is_none"
10920 | "is_ok"
10921 | "is_some"
10922 | "iter"
10923 | "iter_mut"
10924 | "join"
10925 | "len"
10926 | "lock"
10927 | "map"
10928 | "map_err"
10929 | "max"
10930 | "min"
10931 | "new"
10932 | "next"
10933 | "ok"
10934 | "or_default"
10935 | "or_else"
10936 | "or_insert"
10937 | "or_insert_with"
10938 | "parse"
10939 | "pop"
10940 | "position"
10941 | "push"
10942 | "read"
10943 | "recv"
10944 | "remove"
10945 | "replace"
10946 | "retain"
10947 | "send"
10948 | "sort"
10949 | "sort_by"
10950 | "split"
10951 | "starts_with"
10952 | "sum"
10953 | "take"
10954 | "to_owned"
10955 | "to_string"
10956 | "trim"
10957 | "try_from"
10958 | "try_into"
10959 | "unwrap"
10960 | "unwrap_or"
10961 | "unwrap_or_default"
10962 | "unwrap_or_else"
10963 | "with_capacity"
10964 | "write"
10965 )
10966}
10967
10968fn split_camel_case(value: &str) -> Vec<String> {
10969 let chars = value.chars().collect::<Vec<_>>();
10970 let mut normalized = String::with_capacity(value.len() + 8);
10971 for (index, ch) in chars.iter().enumerate() {
10972 let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
10973 let next = chars.get(index + 1);
10974 let is_separator = ch.is_whitespace()
10975 || matches!(
10976 ch,
10977 '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
10978 );
10979 if is_separator {
10980 normalized.push(' ');
10981 continue;
10982 }
10983 let camel_boundary = previous.is_some_and(|prev| {
10984 (prev.is_lowercase() && ch.is_uppercase())
10985 || (prev.is_ascii_digit() && ch.is_alphabetic())
10986 || (prev.is_uppercase()
10987 && ch.is_uppercase()
10988 && next.is_some_and(|next| next.is_lowercase()))
10989 });
10990 if camel_boundary {
10991 normalized.push(' ');
10992 }
10993 normalized.push(*ch);
10994 }
10995
10996 normalized
10997 .split_whitespace()
10998 .filter(|word| word.len() > 1)
10999 .map(|word| word.to_ascii_lowercase())
11000 .collect()
11001}
11002
11003fn compute_path_proximity(left: &str, right: &str) -> f64 {
11004 let left_dirs = left
11005 .rsplit_once('/')
11006 .map(|(dir, _)| dir)
11007 .unwrap_or_default()
11008 .split('/')
11009 .filter(|part| !part.is_empty());
11010 let right_dirs = right
11011 .rsplit_once('/')
11012 .map(|(dir, _)| dir)
11013 .unwrap_or_default()
11014 .split('/')
11015 .filter(|part| !part.is_empty());
11016
11017 let shared = left_dirs
11018 .zip(right_dirs)
11019 .take_while(|(left, right)| left == right)
11020 .count();
11021 ((shared as f64) * 0.05).min(0.5)
11022}
11023
11024fn mark_backend_state(
11025 tx: &Transaction<'_>,
11026 project_root: &Path,
11027 rel_path: &str,
11028 content_hash: Option<&blake3::Hash>,
11029 status: &str,
11030) -> Result<()> {
11031 clear_backend_state_for_file(tx, project_root, rel_path)?;
11032 let hash = content_hash
11033 .map(|hash| hash_to_hex(*hash))
11034 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
11035 tx.execute(
11036 "INSERT OR REPLACE INTO backend_file_state(
11037 backend, workspace_root, file_path, content_hash, status, updated_at
11038 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
11039 params![
11040 BACKEND_TREESITTER,
11041 project_root.display().to_string(),
11042 rel_path,
11043 hash,
11044 status,
11045 unix_seconds_now(),
11046 ],
11047 )?;
11048 Ok(())
11049}
11050
11051fn clear_backend_state_for_file(
11052 tx: &Transaction<'_>,
11053 project_root: &Path,
11054 rel_path: &str,
11055) -> Result<()> {
11056 tx.execute(
11057 "DELETE FROM backend_file_state
11058 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
11059 params![
11060 BACKEND_TREESITTER,
11061 project_root.display().to_string(),
11062 rel_path
11063 ],
11064 )?;
11065 Ok(())
11066}
11067
11068fn load_file_row(tx: &Transaction<'_>, rel_path: &str) -> Result<Option<FileRow>> {
11069 tx.query_row(
11070 "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
11071 params![rel_path],
11072 |row| {
11073 let hash_text: String = row.get(1)?;
11074 Ok(FileRow {
11075 surface_fingerprint: row.get(0)?,
11076 freshness: FileFreshness {
11077 content_hash: hash_from_hex(&hash_text)
11078 .unwrap_or_else(cache_freshness::zero_hash),
11079 mtime: ns_to_system_time(row.get::<_, i64>(2)?),
11080 size: row.get::<_, i64>(3)? as u64,
11081 },
11082 })
11083 },
11084 )
11085 .optional()
11086 .map_err(CallGraphStoreError::from)
11087}
11088
11089fn stored_node_ids_match_extract(
11090 tx: &Transaction<'_>,
11091 rel_path: &str,
11092 extract: &FileExtract,
11093) -> Result<bool> {
11094 let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
11095 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
11096 let mut stored = BTreeSet::new();
11097 for row in rows {
11098 stored.insert(row?);
11099 }
11100 let extracted = extract
11101 .nodes
11102 .iter()
11103 .map(|node| node.id.clone())
11104 .collect::<BTreeSet<_>>();
11105 Ok(stored == extracted)
11106}
11107
11108fn stored_extract_matches(
11112 tx: &Transaction<'_>,
11113 rel_path: &str,
11114 extract: &FileExtract,
11115 index: &ProjectIndex<'_>,
11116) -> Result<bool> {
11117 let stored_file = tx
11118 .query_row(
11119 "SELECT lang, surface_fingerprint FROM files WHERE path = ?1",
11120 params![rel_path],
11121 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
11122 )
11123 .optional()?;
11124 if stored_file
11125 != Some((
11126 lang_label(extract.lang).to_string(),
11127 extract.surface_fingerprint.clone(),
11128 ))
11129 {
11130 return Ok(false);
11131 }
11132
11133 let mut stored_nodes_stmt = tx.prepare(
11134 "SELECT id, file_path, name, scoped_name, kind, start_line, start_col,
11135 end_line, end_col, range_ordinal, signature, exported,
11136 is_default_export, is_type_like, is_callgraph_entry_point, provenance
11137 FROM nodes WHERE file_path = ?1",
11138 )?;
11139 let stored_nodes = stored_nodes_stmt
11140 .query_map(params![rel_path], |row| {
11141 Ok(serde_json::json!([
11142 row.get::<_, String>(0)?,
11143 row.get::<_, String>(1)?,
11144 row.get::<_, String>(2)?,
11145 row.get::<_, String>(3)?,
11146 row.get::<_, String>(4)?,
11147 row.get::<_, i64>(5)?,
11148 row.get::<_, i64>(6)?,
11149 row.get::<_, i64>(7)?,
11150 row.get::<_, i64>(8)?,
11151 row.get::<_, i64>(9)?,
11152 row.get::<_, Option<String>>(10)?,
11153 row.get::<_, i64>(11)?,
11154 row.get::<_, i64>(12)?,
11155 row.get::<_, i64>(13)?,
11156 row.get::<_, i64>(14)?,
11157 row.get::<_, String>(15)?,
11158 ])
11159 .to_string())
11160 })?
11161 .collect::<rusqlite::Result<Vec<_>>>()?;
11162 let expected_nodes = extract
11163 .nodes
11164 .iter()
11165 .map(|node| {
11166 serde_json::json!([
11167 node.id,
11168 node.file_path,
11169 node.name,
11170 node.scoped_name,
11171 node.kind,
11172 node.range.start_line,
11173 node.range.start_col,
11174 node.range.end_line,
11175 node.range.end_col,
11176 node.range_ordinal,
11177 node.signature,
11178 bool_int(node.exported),
11179 bool_int(node.is_default_export),
11180 bool_int(node.is_type_like),
11181 bool_int(node.is_callgraph_entry_point),
11182 PROVENANCE_TREESITTER,
11183 ])
11184 .to_string()
11185 })
11186 .collect::<Vec<_>>();
11187 let mut stored_nodes = stored_nodes;
11188 let mut expected_nodes = expected_nodes;
11189 stored_nodes.sort();
11190 expected_nodes.sort();
11191 if stored_nodes != expected_nodes {
11192 return Ok(false);
11193 }
11194
11195 let resolved_refs = extract
11196 .raw_refs
11197 .iter()
11198 .cloned()
11199 .map(|raw| resolve_ref(raw, index))
11200 .collect::<Result<Vec<_>>>()?;
11201 let mut stored_refs_stmt = tx.prepare(
11202 "SELECT ref_id, caller_node, caller_file, kind, short_name, full_ref,
11203 module_path, import_kind, local_name, requested_name, namespace_alias,
11204 wildcard, line, byte_start, byte_end, status, target_node,
11205 target_file, target_symbol, provenance
11206 FROM refs WHERE caller_file = ?1",
11207 )?;
11208 let stored_refs = stored_refs_stmt
11209 .query_map(params![rel_path], |row| {
11210 Ok(serde_json::json!([
11211 row.get::<_, String>(0)?,
11212 row.get::<_, Option<String>>(1)?,
11213 row.get::<_, String>(2)?,
11214 row.get::<_, String>(3)?,
11215 row.get::<_, Option<String>>(4)?,
11216 row.get::<_, Option<String>>(5)?,
11217 row.get::<_, Option<String>>(6)?,
11218 row.get::<_, Option<String>>(7)?,
11219 row.get::<_, Option<String>>(8)?,
11220 row.get::<_, Option<String>>(9)?,
11221 row.get::<_, Option<String>>(10)?,
11222 row.get::<_, i64>(11)?,
11223 row.get::<_, i64>(12)?,
11224 row.get::<_, i64>(13)?,
11225 row.get::<_, i64>(14)?,
11226 row.get::<_, String>(15)?,
11227 row.get::<_, Option<String>>(16)?,
11228 row.get::<_, Option<String>>(17)?,
11229 row.get::<_, Option<String>>(18)?,
11230 row.get::<_, String>(19)?,
11231 ])
11232 .to_string())
11233 })?
11234 .collect::<rusqlite::Result<Vec<_>>>()?;
11235 let expected_refs = resolved_refs
11236 .iter()
11237 .map(|resolved| {
11238 let raw = &resolved.raw;
11239 serde_json::json!([
11240 raw.ref_id,
11241 raw.caller_node,
11242 raw.caller_file,
11243 raw.kind,
11244 raw.short_name,
11245 raw.full_ref,
11246 raw.module_path,
11247 raw.import_kind,
11248 raw.local_name,
11249 raw.requested_name,
11250 raw.namespace_alias,
11251 bool_int(raw.wildcard),
11252 raw.line,
11253 raw.byte_start,
11254 raw.byte_end,
11255 resolved.status,
11256 resolved.target_node,
11257 resolved.target_file,
11258 resolved.target_symbol,
11259 PROVENANCE_TREESITTER,
11260 ])
11261 .to_string()
11262 })
11263 .collect::<Vec<_>>();
11264 let mut stored_refs = stored_refs;
11265 let mut expected_refs = expected_refs;
11266 stored_refs.sort();
11267 expected_refs.sort();
11268 if stored_refs != expected_refs {
11269 return Ok(false);
11270 }
11271
11272 let mut stored_edges_stmt = tx.prepare(
11273 "SELECT e.edge_id, e.ref_id, e.source_node, e.target_node,
11274 e.target_file, e.target_symbol, e.kind, e.line, e.provenance
11275 FROM edges e JOIN refs r ON r.ref_id = e.ref_id
11276 WHERE r.caller_file = ?1 AND e.provenance = ?2",
11277 )?;
11278 let stored_edges = stored_edges_stmt
11279 .query_map(params![rel_path, PROVENANCE_TREESITTER], |row| {
11280 Ok(serde_json::json!([
11281 row.get::<_, String>(0)?,
11282 row.get::<_, String>(1)?,
11283 row.get::<_, String>(2)?,
11284 row.get::<_, Option<String>>(3)?,
11285 row.get::<_, String>(4)?,
11286 row.get::<_, String>(5)?,
11287 row.get::<_, String>(6)?,
11288 row.get::<_, i64>(7)?,
11289 row.get::<_, String>(8)?,
11290 ])
11291 .to_string())
11292 })?
11293 .collect::<rusqlite::Result<Vec<_>>>()?;
11294 let expected_edges = resolved_refs
11295 .iter()
11296 .filter_map(|resolved| {
11297 resolved.edge.as_ref().map(|edge| {
11298 serde_json::json!([
11299 edge.edge_id,
11300 resolved.raw.ref_id,
11301 edge.source_node,
11302 edge.target_node,
11303 edge.target_file,
11304 edge.target_symbol,
11305 edge.kind,
11306 edge.line,
11307 PROVENANCE_TREESITTER,
11308 ])
11309 .to_string()
11310 })
11311 })
11312 .collect::<Vec<_>>();
11313 let mut stored_edges = stored_edges;
11314 let mut expected_edges = expected_edges;
11315 stored_edges.sort();
11316 expected_edges.sort();
11317 if stored_edges != expected_edges {
11318 return Ok(false);
11319 }
11320
11321 let mut stored_dependencies_stmt =
11322 tx.prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1")?;
11323 let stored_dependencies = stored_dependencies_stmt
11324 .query_map(params![rel_path], |row| row.get::<_, String>(0))?
11325 .collect::<rusqlite::Result<BTreeSet<_>>>()?;
11326 let expected_dependencies = extract
11327 .raw_refs
11328 .iter()
11329 .flat_map(|raw| raw.dependencies.iter().cloned())
11330 .collect::<BTreeSet<_>>();
11331 if stored_dependencies != expected_dependencies {
11332 return Ok(false);
11333 }
11334
11335 let mut stored_hints_stmt = tx.prepare(
11336 "SELECT id, method_name, caller_node, file, line, byte_start, byte_end, provenance
11337 FROM dispatch_hints WHERE file = ?1",
11338 )?;
11339 let stored_hints = stored_hints_stmt
11340 .query_map(params![rel_path], |row| {
11341 Ok(serde_json::json!([
11342 row.get::<_, String>(0)?,
11343 row.get::<_, String>(1)?,
11344 row.get::<_, String>(2)?,
11345 row.get::<_, String>(3)?,
11346 row.get::<_, i64>(4)?,
11347 row.get::<_, i64>(5)?,
11348 row.get::<_, i64>(6)?,
11349 row.get::<_, String>(7)?,
11350 ])
11351 .to_string())
11352 })?
11353 .collect::<rusqlite::Result<Vec<_>>>()?;
11354 let expected_hints = extract
11355 .dispatch_hints
11356 .iter()
11357 .map(|hint| {
11358 serde_json::json!([
11359 hint.id,
11360 hint.method_name,
11361 hint.caller_node,
11362 hint.file,
11363 hint.line,
11364 hint.byte_start,
11365 hint.byte_end,
11366 PROVENANCE_TREESITTER,
11367 ])
11368 .to_string()
11369 })
11370 .collect::<Vec<_>>();
11371 let mut stored_hints = stored_hints;
11372 let mut expected_hints = expected_hints;
11373 stored_hints.sort();
11374 expected_hints.sort();
11375 Ok(stored_hints == expected_hints)
11376}
11377
11378fn update_file_fresh_metadata(
11379 tx: &Transaction<'_>,
11380 project_root: &Path,
11381 rel_path: &str,
11382 hash: &blake3::Hash,
11383 mtime: SystemTime,
11384 size: u64,
11385) -> Result<()> {
11386 tx.execute(
11387 "UPDATE files SET content_hash = ?2, mtime_ns = ?3, size = ?4, indexed_at = ?5
11388 WHERE path = ?1",
11389 params![
11390 rel_path,
11391 hash_to_hex(*hash),
11392 system_time_to_ns(mtime),
11393 size as i64,
11394 unix_seconds_now()
11395 ],
11396 )?;
11397 tx.execute(
11398 "UPDATE backend_file_state SET content_hash = ?3, status = 'fresh', updated_at = ?5
11399 WHERE backend = ?1 AND file_path = ?2 AND workspace_root = ?4",
11400 params![
11401 BACKEND_TREESITTER,
11402 rel_path,
11403 hash_to_hex(*hash),
11404 project_root.display().to_string(),
11405 unix_seconds_now(),
11406 ],
11407 )?;
11408 Ok(())
11409}
11410
11411#[derive(Debug, Clone, PartialEq, Eq)]
11412struct DependentRefSelection {
11413 ref_id: String,
11414 caller_file: String,
11415}
11416
11417fn ref_ids_depending_on(
11418 tx: &Transaction<'_>,
11419 project_root: &Path,
11420 rel_path: &str,
11421) -> Result<Vec<DependentRefSelection>> {
11422 let mut stmt = tx.prepare(
11423 "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
11424 FROM refs r
11425 WHERE r.caller_file IN (
11426 SELECT file_path FROM file_dependencies WHERE dep_file = ?1
11427 )
11428 OR r.target_file = ?1
11429 ORDER BY r.ref_id",
11430 )?;
11431 let rows = stmt.query_map(params![rel_path], |row| {
11432 Ok(RefDependencyRow {
11433 ref_id: row.get(0)?,
11434 kind: row.get(1)?,
11435 caller_file: row.get(2)?,
11436 module_path: row.get(3)?,
11437 target_file: row.get(4)?,
11438 })
11439 })?;
11440 let mut ids = Vec::new();
11441 for row in rows {
11442 let row = row?;
11443 if ref_dependency_row_depends_on(project_root, &row, rel_path) {
11444 ids.push(DependentRefSelection {
11445 ref_id: row.ref_id,
11446 caller_file: row.caller_file,
11447 });
11448 }
11449 }
11450 Ok(ids)
11451}
11452
11453fn record_dependent_refs(
11454 selected_ref_ids: &mut BTreeSet<String>,
11455 selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
11456 dependent_refs: Vec<DependentRefSelection>,
11457) {
11458 for dependent_ref in dependent_refs {
11459 let DependentRefSelection {
11460 ref_id,
11461 caller_file,
11462 } = dependent_ref;
11463 selected_ref_ids.insert(ref_id.clone());
11464 selected_refs_by_caller
11465 .entry(caller_file)
11466 .or_default()
11467 .insert(ref_id);
11468 }
11469}
11470
11471#[cfg(test)]
11472fn refs_by_caller_for_ref_ids(
11473 tx: &Transaction<'_>,
11474 ref_ids: &BTreeSet<String>,
11475) -> Result<BTreeMap<String, BTreeSet<String>>> {
11476 let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11477 let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
11478 for ref_id in ref_ids {
11479 if let Some(caller) = stmt
11480 .query_row(params![ref_id], |row| row.get::<_, String>(0))
11481 .optional()?
11482 {
11483 by_caller.entry(caller).or_default().insert(ref_id.clone());
11484 }
11485 }
11486 Ok(by_caller)
11487}
11488
11489fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
11490 tx.execute(
11491 "DELETE FROM file_dependencies WHERE file_path = ?1",
11492 params![rel_path],
11493 )?;
11494 delete_refs_for_caller(tx, rel_path)?;
11495 tx.execute(
11496 "DELETE FROM dispatch_hints WHERE file = ?1",
11497 params![rel_path],
11498 )?;
11499 tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
11500 tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
11501 Ok(())
11502}
11503
11504fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
11505 let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
11506 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
11507 let mut ids = BTreeSet::new();
11508 for row in rows {
11509 ids.insert(row?);
11510 }
11511 delete_ref_ids(tx, &ids)
11512}
11513
11514fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
11515 for ref_id in ref_ids {
11516 tx.execute("DELETE FROM edges WHERE ref_id = ?1", params![ref_id])?;
11517 tx.execute("DELETE FROM refs WHERE ref_id = ?1", params![ref_id])?;
11518 }
11519 Ok(())
11520}
11521
11522fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
11523 let mut stmt = conn.prepare(
11524 "SELECT source.file_path, source.scoped_name, edges.target_file,
11525 edges.target_symbol, edges.kind, edges.line
11526 FROM edges
11527 JOIN nodes AS source ON source.id = edges.source_node
11528 ORDER BY source.file_path, source.scoped_name, edges.target_file,
11529 edges.target_symbol, edges.kind, edges.line",
11530 )?;
11531 let rows = stmt.query_map([], |row| {
11532 Ok(StoredEdge {
11533 source_file: row.get(0)?,
11534 source_symbol: row.get(1)?,
11535 target_file: row.get(2)?,
11536 target_symbol: row.get(3)?,
11537 kind: row.get(4)?,
11538 line: row.get::<_, i64>(5)? as u32,
11539 })
11540 })?;
11541 let mut edges = BTreeSet::new();
11542 for row in rows {
11543 edges.insert(row?);
11544 }
11545 Ok(edges)
11546}
11547
11548fn module_target_from_dependencies(
11549 project_root: &Path,
11550 dependencies: &BTreeSet<String>,
11551) -> Option<String> {
11552 dependencies.iter().find_map(|dep| {
11553 let path = project_root.join(dep);
11554 if path.is_file() {
11555 Some(relative_path(project_root, &canonicalize_path(&path)))
11556 } else {
11557 None
11558 }
11559 })
11560}
11561
11562fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
11563 let mut named = HashMap::new();
11564 if let Some(full_ref) = &raw_ref.full_ref {
11565 named = parse_reexport_names(full_ref);
11566 }
11567 ReexportIndex {
11568 target_file,
11569 named,
11570 wildcard: raw_ref.wildcard,
11571 }
11572}
11573
11574fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
11575 let mut names = HashMap::new();
11576 let Some(open) = statement.find('{') else {
11577 return names;
11578 };
11579 let Some(close) = statement[open + 1..]
11580 .find('}')
11581 .map(|offset| open + 1 + offset)
11582 else {
11583 return names;
11584 };
11585 for spec in statement[open + 1..close].split(',') {
11586 let spec = spec.trim();
11587 if spec.is_empty() {
11588 continue;
11589 }
11590 if let Some((source, local)) = spec.split_once(" as ") {
11591 names.insert(local.trim().to_string(), source.trim().to_string());
11592 } else {
11593 names.insert(spec.to_string(), spec.to_string());
11594 }
11595 }
11596 names
11597}
11598
11599#[derive(Debug)]
11600struct RefDependencyRow {
11601 ref_id: String,
11602 kind: String,
11603 caller_file: String,
11604 module_path: Option<String>,
11605 target_file: Option<String>,
11606}
11607
11608fn ref_dependency_row_depends_on(
11609 project_root: &Path,
11610 row: &RefDependencyRow,
11611 rel_path: &str,
11612) -> bool {
11613 if row.target_file.as_deref() == Some(rel_path) {
11614 return true;
11615 }
11616
11617 match row.kind.as_str() {
11618 "call" => true,
11619 "import" | "reexport" => row
11620 .module_path
11621 .as_deref()
11622 .map(|module_path| {
11623 module_dependencies_for_ref(project_root, &row.caller_file, module_path)
11624 .contains(rel_path)
11625 })
11626 .unwrap_or(false),
11627 "export_alias" => false,
11628 _ => false,
11629 }
11630}
11631
11632fn module_dependencies_for_ref(
11633 project_root: &Path,
11634 caller_file: &str,
11635 module_path: &str,
11636) -> BTreeSet<String> {
11637 module_dependencies(project_root, &project_root.join(caller_file), module_path)
11638}
11639
11640fn import_dependencies(
11641 project_root: &Path,
11642 abs_path: &Path,
11643 imports: &[ImportStatement],
11644) -> BTreeSet<String> {
11645 let mut deps = BTreeSet::new();
11646 for import in imports {
11647 deps.extend(module_dependencies(
11648 project_root,
11649 abs_path,
11650 &import.module_path,
11651 ));
11652 }
11653 deps
11654}
11655
11656fn module_dependencies(
11657 project_root: &Path,
11658 abs_path: &Path,
11659 module_path: &str,
11660) -> BTreeSet<String> {
11661 let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
11662 let caller_dir = abs_path.parent().unwrap_or(project_root);
11663 if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
11664 deps.insert(relative_path(project_root, &resolved));
11665 }
11666 if module_path.starts_with('.') {
11667 let base = caller_dir.join(module_path);
11668 for candidate in relative_module_candidates(&base) {
11669 deps.insert(relative_path(project_root, &candidate));
11670 }
11671 }
11672 deps
11673}
11674
11675fn rust_module_dependencies(
11676 project_root: &Path,
11677 abs_path: &Path,
11678 module_path: &str,
11679) -> BTreeSet<String> {
11680 let mut deps = BTreeSet::new();
11681 let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
11682 let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
11683 return deps;
11684 };
11685 let src_prefix = rust_src_prefix(&rel_path);
11686 rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
11687 if !path_segments.is_empty() {
11688 rust_push_module_dependency_candidate(
11689 project_root,
11690 &mut deps,
11691 &src_prefix,
11692 &path_segments[..path_segments.len() - 1],
11693 );
11694 }
11695 deps
11696}
11697
11698fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
11699 let path = rust_module_path_without_alias_or_use_list(module_path);
11700 let segments = path
11701 .split("::")
11702 .map(str::trim)
11703 .filter(|segment| !segment.is_empty())
11704 .collect::<Vec<_>>();
11705 if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
11706 return None;
11707 }
11708 rust_resolve_segments(rel_path, &segments)
11709}
11710
11711fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
11712 let path = module_path
11713 .trim()
11714 .trim_end_matches(';')
11715 .split_once(" as ")
11716 .map(|(left, _)| left.trim())
11717 .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
11718 path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
11719}
11720
11721fn rust_push_module_dependency_candidate(
11722 project_root: &Path,
11723 deps: &mut BTreeSet<String>,
11724 src_prefix: &str,
11725 segments: &[String],
11726) {
11727 let candidates = if segments.is_empty() {
11728 vec![
11729 format!("{src_prefix}/lib.rs"),
11730 format!("{src_prefix}/main.rs"),
11731 ]
11732 } else {
11733 vec![
11734 format!("{}/{}.rs", src_prefix, segments.join("/")),
11735 format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
11736 ]
11737 };
11738 for candidate in candidates {
11739 if project_root.join(&candidate).is_file() {
11740 deps.insert(candidate);
11741 }
11742 }
11743}
11744
11745fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
11746 let mut candidates = Vec::new();
11747 if base.extension().is_some() {
11748 candidates.push(base.to_path_buf());
11749 return candidates;
11750 }
11751 for ext in JS_TS_EXTENSIONS {
11752 candidates.push(base.with_extension(ext));
11753 }
11754 for ext in JS_TS_EXTENSIONS {
11755 candidates.push(base.join(format!("index.{ext}")));
11756 }
11757 candidates
11758}
11759
11760fn import_local_names(import: &ImportStatement) -> Vec<String> {
11761 let mut names = Vec::new();
11762 if let Some(default) = &import.default_import {
11763 names.push(default.clone());
11764 }
11765 if let Some(namespace) = &import.namespace_import {
11766 names.push(namespace.clone());
11767 }
11768 for name in &import.names {
11769 names.push(crate::imports::specifier_local_name(name).to_string());
11770 }
11771 names
11772}
11773
11774fn import_requested_names(import: &ImportStatement) -> Vec<String> {
11775 import
11776 .names
11777 .iter()
11778 .map(|name| crate::imports::specifier_imported_name(name).to_string())
11779 .collect()
11780}
11781
11782fn import_is_wildcard(import: &ImportStatement) -> bool {
11783 import.namespace_import.is_some() || import.raw_text.contains('*')
11784}
11785
11786fn namespace_alias(full_ref: &str) -> Option<String> {
11787 full_ref
11788 .split_once('.')
11789 .map(|(namespace, _)| namespace.to_string())
11790}
11791
11792fn import_kind_label(kind: ImportKind) -> &'static str {
11793 match kind {
11794 ImportKind::Value => "value",
11795 ImportKind::Type => "type",
11796 ImportKind::SideEffect => "side_effect",
11797 }
11798}
11799
11800fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
11801 match kind {
11802 SymbolKind::Function => "function",
11803 SymbolKind::Class => "class",
11804 SymbolKind::Method => "method",
11805 SymbolKind::Struct => "struct",
11806 SymbolKind::Interface => "interface",
11807 SymbolKind::Enum => "enum",
11808 SymbolKind::TypeAlias => "type_alias",
11809 SymbolKind::Variable => "variable",
11810 SymbolKind::Heading => "heading",
11811 SymbolKind::FileSummary => "file_summary",
11812 }
11813}
11814
11815fn is_type_like(kind: &SymbolKind) -> bool {
11816 matches!(
11817 kind,
11818 SymbolKind::Class
11819 | SymbolKind::Struct
11820 | SymbolKind::Interface
11821 | SymbolKind::Enum
11822 | SymbolKind::TypeAlias
11823 )
11824}
11825
11826fn lang_label(lang: LangId) -> &'static str {
11827 match lang {
11828 LangId::TypeScript => "typescript",
11829 LangId::Tsx => "tsx",
11830 LangId::JavaScript => "javascript",
11831 LangId::Python => "python",
11832 LangId::Rust => "rust",
11833 LangId::Go => "go",
11834 LangId::C => "c",
11835 LangId::Cpp => "cpp",
11836 LangId::Zig => "zig",
11837 LangId::CSharp => "csharp",
11838 LangId::Bash => "bash",
11839 LangId::Html => "html",
11840 LangId::Markdown => "markdown",
11841 LangId::Solidity => "solidity",
11842 LangId::Scss => "scss",
11843 LangId::Vue => "vue",
11844 LangId::Json => "json",
11845 LangId::Scala => "scala",
11846 LangId::Java => "java",
11847 LangId::Ruby => "ruby",
11848 LangId::Kotlin => "kotlin",
11849 LangId::Swift => "swift",
11850 LangId::Php => "php",
11851 LangId::Lua => "lua",
11852 LangId::Perl => "perl",
11853 LangId::Yaml => "yaml",
11854 LangId::Pascal => "pascal",
11855 LangId::R => "r",
11856 LangId::Groovy => "groovy",
11857 LangId::ObjC => "objc",
11858 }
11859}
11860
11861fn lang_from_label(label: &str) -> Option<LangId> {
11862 match label {
11863 "typescript" => Some(LangId::TypeScript),
11864 "tsx" => Some(LangId::Tsx),
11865 "javascript" => Some(LangId::JavaScript),
11866 "python" => Some(LangId::Python),
11867 "rust" => Some(LangId::Rust),
11868 "go" => Some(LangId::Go),
11869 "c" => Some(LangId::C),
11870 "cpp" => Some(LangId::Cpp),
11871 "zig" => Some(LangId::Zig),
11872 "csharp" => Some(LangId::CSharp),
11873 "bash" => Some(LangId::Bash),
11874 "html" => Some(LangId::Html),
11875 "markdown" => Some(LangId::Markdown),
11876 "solidity" => Some(LangId::Solidity),
11877 "scss" => Some(LangId::Scss),
11878 "vue" => Some(LangId::Vue),
11879 "json" => Some(LangId::Json),
11880 "scala" => Some(LangId::Scala),
11881 "java" => Some(LangId::Java),
11882 "ruby" => Some(LangId::Ruby),
11883 "kotlin" => Some(LangId::Kotlin),
11884 "swift" => Some(LangId::Swift),
11885 "php" => Some(LangId::Php),
11886 "lua" => Some(LangId::Lua),
11887 "perl" => Some(LangId::Perl),
11888 "yaml" => Some(LangId::Yaml),
11889 "pascal" => Some(LangId::Pascal),
11890 "r" => Some(LangId::R),
11891 "groovy" => Some(LangId::Groovy),
11892 "objc" => Some(LangId::ObjC),
11893 _ => None,
11894 }
11895}
11896
11897fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
11898 let mut normalized = if files.is_empty() {
11899 callgraph::walk_project_files(project_root).collect::<Vec<_>>()
11900 } else {
11901 files
11902 .iter()
11903 .map(|path| normalize_file_path(project_root, path))
11904 .collect::<Result<Vec<_>>>()?
11905 };
11906 normalized.sort();
11907 normalized.dedup();
11908 Ok(normalized)
11909}
11910
11911fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
11912 let full_path = if path.is_relative() {
11913 project_root.join(path)
11914 } else {
11915 path.to_path_buf()
11916 };
11917 Ok(canonicalize_path(&full_path))
11918}
11919
11920fn canonicalize_path(path: &Path) -> PathBuf {
11921 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
11922}
11923
11924fn relative_path(project_root: &Path, path: &Path) -> String {
11925 if let Ok(stripped) = path.strip_prefix(project_root) {
11926 return stripped.to_string_lossy().replace('\\', "/");
11927 }
11928 let canon_root = canonicalize_path(project_root);
11929 let canon_path = canonicalize_path(path);
11930 if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
11931 return stripped.to_string_lossy().replace('\\', "/");
11932 }
11933 canon_path.to_string_lossy().replace('\\', "/")
11934}
11935
11936fn unqualified_name(scoped: &str) -> &str {
11937 if scoped == TOP_LEVEL_SYMBOL {
11938 return scoped;
11939 }
11940 scoped
11941 .rsplit("::")
11942 .next()
11943 .unwrap_or(scoped)
11944 .rsplit('.')
11945 .next()
11946 .unwrap_or(scoped)
11947 .rsplit('#')
11948 .next()
11949 .unwrap_or(scoped)
11950}
11951
11952fn ref_id(parts: &[&str]) -> String {
11953 let joined = parts.join("\0");
11954 hash_to_hex(blake3::hash(joined.as_bytes()))
11955}
11956
11957fn hash_to_hex(hash: blake3::Hash) -> String {
11958 hash.to_hex().to_string()
11959}
11960
11961fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
11962 let bytes = hex_to_bytes(value)?;
11963 Some(blake3::Hash::from_bytes(bytes))
11964}
11965
11966fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
11967 if value.len() != 64 {
11968 return None;
11969 }
11970 let mut bytes = [0u8; 32];
11971 for (index, slot) in bytes.iter_mut().enumerate() {
11972 let start = index * 2;
11973 let end = start + 2;
11974 *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
11975 }
11976 Some(bytes)
11977}
11978
11979#[derive(Debug, Clone)]
11980struct LineIndex {
11981 newline_offsets: Vec<usize>,
11982 source_len: usize,
11983}
11984
11985impl LineIndex {
11986 fn new(source: &str) -> Self {
11987 Self {
11988 newline_offsets: source
11989 .bytes()
11990 .enumerate()
11991 .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
11992 .collect(),
11993 source_len: source.len(),
11994 }
11995 }
11996
11997 fn byte_to_line(&self, byte_offset: usize) -> u32 {
11998 let byte_offset = byte_offset.min(self.source_len);
11999 self.newline_offsets
12000 .partition_point(|offset| *offset < byte_offset) as u32
12001 + 1
12002 }
12003}
12004
12005fn empty_to_none(value: String) -> Option<String> {
12006 if value.is_empty() {
12007 None
12008 } else {
12009 Some(value)
12010 }
12011}
12012
12013fn bool_int(value: bool) -> i64 {
12014 if value {
12015 1
12016 } else {
12017 0
12018 }
12019}
12020
12021fn system_time_to_ns(time: SystemTime) -> i64 {
12022 time.duration_since(UNIX_EPOCH)
12023 .unwrap_or_default()
12024 .as_nanos()
12025 .min(i64::MAX as u128) as i64
12026}
12027
12028fn ns_to_system_time(value: i64) -> SystemTime {
12029 UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
12030}
12031
12032fn unix_millis_now() -> u64 {
12033 SystemTime::now()
12034 .duration_since(UNIX_EPOCH)
12035 .unwrap_or_default()
12036 .as_millis()
12037 .min(u128::from(u64::MAX)) as u64
12038}
12039
12040fn unix_seconds_now() -> i64 {
12041 SystemTime::now()
12042 .duration_since(UNIX_EPOCH)
12043 .unwrap_or_default()
12044 .as_secs() as i64
12045}
12046
12047#[cfg(test)]
12052pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
12053
12054#[cfg(test)]
12055mod refresh_worker_tests {
12056 use super::*;
12057 use std::fs;
12058 use tempfile::tempdir;
12059
12060 fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
12061 let temp = tempdir().unwrap();
12062 let root = temp.path().join("root");
12063 fs::create_dir_all(&root).unwrap();
12064 let artifact_key = crate::search_index::artifact_cache_key(&root);
12065 crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
12066 let callgraph_dir = temp
12067 .path()
12068 .join("storage")
12069 .join("callgraph")
12070 .join(artifact_key);
12071 let source = root.join("main.rs");
12072 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
12073 let (store, _) = CallGraphStore::cold_build_with_lease(
12074 callgraph_dir.clone(),
12075 root.clone(),
12076 std::slice::from_ref(&source),
12077 )
12078 .unwrap();
12079 drop(store);
12080 (temp, root, callgraph_dir, source)
12081 }
12082
12083 fn pending_paths() -> PendingCallGraphStorePaths {
12084 Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
12085 }
12086
12087 fn wait_for_refresh_calls(root: &Path, expected: usize) {
12088 let deadline = Instant::now() + Duration::from_secs(12);
12089 while callgraph_refresh_worker_test_counts(root).0 < expected {
12090 assert!(
12091 Instant::now() < deadline,
12092 "timed out waiting for {expected} callgraph refresh worker call(s)"
12093 );
12094 std::thread::sleep(Duration::from_millis(5));
12095 }
12096 }
12097
12098 fn wait_for_refresh_worker_idle() {
12099 let deadline = Instant::now() + Duration::from_secs(12);
12100 loop {
12101 let worker = CALLGRAPH_REFRESH_WORKER
12102 .get_or_init(|| Mutex::new(None))
12103 .lock()
12104 .expect("callgraph refresh worker mutex poisoned")
12105 .clone();
12106 let idle = worker.is_none_or(|worker| {
12107 let queue = worker
12108 .shared
12109 .queue
12110 .lock()
12111 .expect("callgraph refresh queue mutex poisoned");
12112 queue.active.is_none() && queue.order.is_empty()
12113 });
12114 if idle {
12115 return;
12116 }
12117 assert!(
12118 Instant::now() < deadline,
12119 "timed out waiting for callgraph refresh worker to become idle"
12120 );
12121 std::thread::sleep(Duration::from_millis(5));
12122 }
12123 }
12124
12125 fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
12126 let temp = tempdir().unwrap();
12127 let root = temp.path().join("workspace");
12128 fs::create_dir_all(root.join("app/src")).unwrap();
12129 let artifact_key = crate::search_index::artifact_cache_key(&root);
12130 crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
12131 let callgraph_dir = temp
12132 .path()
12133 .join("storage")
12134 .join("callgraph")
12135 .join(artifact_key);
12136 fs::write(
12137 root.join("Cargo.toml"),
12138 "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
12139 )
12140 .unwrap();
12141 fs::write(
12142 root.join("app/Cargo.toml"),
12143 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
12144 )
12145 .unwrap();
12146 let caller = root.join("app/src/lib.rs");
12147 fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
12148 let (store, _) = CallGraphStore::cold_build_with_lease(
12149 callgraph_dir.clone(),
12150 root.clone(),
12151 std::slice::from_ref(&caller),
12152 )
12153 .unwrap();
12154 drop(store);
12155 (temp, root, callgraph_dir, caller)
12156 }
12157
12158 #[test]
12159 fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
12160 let _guard = REFRESH_WORKER_TEST_LOCK
12161 .lock()
12162 .unwrap_or_else(std::sync::PoisonError::into_inner);
12163 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12164 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
12165 reset_workspace_crate_prefix_build_count(&root);
12166 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12167
12168 for revision in ["first", "second"] {
12169 fs::write(
12170 &caller,
12171 format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
12172 )
12173 .unwrap();
12174 enqueue_callgraph_store_refresh(
12175 callgraph_dir.clone(),
12176 root.clone(),
12177 vec![caller.clone()],
12178 pending_paths(),
12179 );
12180 wait_for_refresh_worker_idle();
12181 }
12182
12183 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
12184 assert!(flush_callgraph_store_refreshes_with_budget(
12185 Duration::from_secs(5)
12186 ));
12187 clear_callgraph_refresh_worker_test_seam(&root);
12188 }
12189
12190 #[test]
12191 fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
12192 let _guard = REFRESH_WORKER_TEST_LOCK
12193 .lock()
12194 .unwrap_or_else(std::sync::PoisonError::into_inner);
12195 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12196 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
12197 reset_workspace_crate_prefix_build_count(&root);
12198 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12199
12200 fs::write(
12201 &caller,
12202 "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
12203 )
12204 .unwrap();
12205 enqueue_callgraph_store_refresh(
12206 callgraph_dir.clone(),
12207 root.clone(),
12208 vec![caller.clone()],
12209 pending_paths(),
12210 );
12211 wait_for_refresh_worker_idle();
12212 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
12213
12214 let added_manifest = root.join("added/Cargo.toml");
12215 let added_source = root.join("added/src/lib.rs");
12216 fs::create_dir_all(added_source.parent().unwrap()).unwrap();
12217 fs::write(
12218 root.join("Cargo.toml"),
12219 "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
12220 )
12221 .unwrap();
12222 fs::write(
12223 &added_manifest,
12224 "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
12225 )
12226 .unwrap();
12227 fs::write(&added_source, "pub fn target() {}\n").unwrap();
12228 fs::write(
12229 &caller,
12230 "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
12231 )
12232 .unwrap();
12233
12234 enqueue_callgraph_store_refresh(
12235 callgraph_dir.clone(),
12236 root.clone(),
12237 vec![
12238 root.join("Cargo.toml"),
12239 added_manifest,
12240 added_source,
12241 caller,
12242 ],
12243 pending_paths(),
12244 );
12245 assert!(flush_callgraph_store_refreshes_with_budget(
12246 Duration::from_secs(12)
12247 ));
12248
12249 assert_eq!(workspace_crate_prefix_build_count(&root), 2);
12253 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
12254 .unwrap()
12255 .expect("refreshed workspace store");
12256 let tree = store
12257 .call_tree(Path::new("app/src/lib.rs"), "run", 1)
12258 .unwrap();
12259 assert_eq!(tree.children.len(), 1);
12260 assert_eq!(tree.children[0].file, "added/src/lib.rs");
12261 assert_eq!(tree.children[0].name, "target");
12262 assert!(tree.children[0].resolved);
12263 clear_callgraph_refresh_worker_test_seam(&root);
12264 }
12265
12266 fn linked_worktree_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, PathBuf) {
12267 let temp = tempdir().unwrap();
12268 let main = temp.path().join("main");
12269 let worktree = temp.path().join("worktree");
12270 fs::create_dir_all(&main).unwrap();
12271 let mut git = std::process::Command::new("git");
12272 assert!(
12273 crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
12274 .status()
12275 .unwrap()
12276 .success()
12277 );
12278 fs::write(main.join("lib.rs"), "pub fn marker() {}\n").unwrap();
12279 for args in [
12280 vec![
12281 "-C",
12282 main.to_str().unwrap(),
12283 "config",
12284 "user.email",
12285 "test@example.com",
12286 ],
12287 vec![
12288 "-C",
12289 main.to_str().unwrap(),
12290 "config",
12291 "user.name",
12292 "AFT Test",
12293 ],
12294 vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
12295 vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
12296 ] {
12297 let mut command = std::process::Command::new("git");
12298 assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
12299 .status()
12300 .unwrap()
12301 .success());
12302 }
12303 let mut add_worktree = std::process::Command::new("git");
12304 assert!(crate::test_env::apply_hermetic_git_env(
12305 add_worktree
12306 .arg("-C")
12307 .arg(&main)
12308 .args(["worktree", "add", "--detach"])
12309 .arg(&worktree),
12310 )
12311 .status()
12312 .unwrap()
12313 .success());
12314 let main = fs::canonicalize(main).unwrap();
12315 let worktree = fs::canonicalize(worktree).unwrap();
12316 let project_key = crate::search_index::artifact_cache_key(&main);
12317 assert_eq!(
12318 crate::search_index::artifact_cache_key(&worktree),
12319 project_key
12320 );
12321 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
12322 (temp, main, worktree, project_key, callgraph_dir)
12323 }
12324
12325 #[test]
12326 fn linked_worktree_never_acquires_writer_or_publishes_any_build_path() {
12327 let _git_env = crate::test_env::hermetic_git_env_guard();
12328 let (_temp, _main, root, project_key, callgraph_dir) = linked_worktree_fixture();
12329 crate::root_cache::configure_artifact_access(&root, &project_key, true);
12330 crate::root_cache::reset_writer_lease_acquisition_counts_for_test();
12331 let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
12332 let publications_for_observer = Arc::clone(&publications);
12333 set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
12334 publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
12335 })));
12336 let source = root.join("lib.rs");
12337
12338 let open_error = CallGraphStore::open(callgraph_dir.clone(), root.clone())
12339 .expect_err("borrow-only writable open must remain unavailable");
12340 assert!(matches!(open_error, CallGraphStoreError::Unavailable(_)));
12341 assert!(
12342 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), root.clone())
12343 .unwrap()
12344 .is_none()
12345 );
12346 assert!(
12347 CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), root.clone())
12348 .unwrap()
12349 .is_none()
12350 );
12351 assert!(matches!(
12352 CallGraphStore::cold_build_with_lease(
12353 callgraph_dir.clone(),
12354 root.clone(),
12355 std::slice::from_ref(&source),
12356 ),
12357 Err(CallGraphStoreError::Unavailable(_))
12358 ));
12359 assert!(matches!(
12360 CallGraphStore::ensure_built_with_lease(
12361 callgraph_dir.clone(),
12362 root.clone(),
12363 std::slice::from_ref(&source),
12364 ),
12365 Err(CallGraphStoreError::Unavailable(_))
12366 ));
12367 let force_error = CallGraphStore::force_cold_build_with_lease_chunked(
12368 callgraph_dir.clone(),
12369 root.clone(),
12370 &[source],
12371 1,
12372 )
12373 .expect_err("borrow-only forced rebuild must remain unsatisfied");
12374 set_cold_build_swap_observer(None);
12375
12376 assert!(matches!(force_error, CallGraphStoreError::Unavailable(_)));
12377 assert_eq!(
12378 crate::root_cache::writer_lease_acquisition_count_for_test(
12379 crate::root_cache::RootCacheDomain::Callgraph,
12380 &project_key,
12381 &root,
12382 ),
12383 0
12384 );
12385 assert_eq!(publications.load(AtomicOrdering::SeqCst), 0);
12386 assert!(!pointer_path(&callgraph_dir, &project_key).exists());
12387 }
12388
12389 #[test]
12390 fn owner_and_linked_worktree_alternation_rebuilds_storm_generation_once() {
12391 let _git_env = crate::test_env::hermetic_git_env_guard();
12392 let (_temp, owner, worktree, project_key, callgraph_dir) = linked_worktree_fixture();
12393 crate::root_cache::configure_artifact_access(&owner, &project_key, false);
12394 crate::root_cache::configure_artifact_access(&worktree, &project_key, true);
12395 let source = owner.join("lib.rs");
12396 let (store, _) = CallGraphStore::cold_build_with_lease(
12397 callgraph_dir.clone(),
12398 owner.clone(),
12399 std::slice::from_ref(&source),
12400 )
12401 .unwrap();
12402 let sqlite_path = store.sqlite_path().to_path_buf();
12403 drop(store);
12404
12405 let conn = Connection::open(&sqlite_path).unwrap();
12406 conn.execute(
12407 "UPDATE backend_file_state SET workspace_root = ?1",
12408 [worktree.display().to_string()],
12409 )
12410 .unwrap();
12411 drop(conn);
12412
12413 let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
12414 let publications_for_observer = Arc::clone(&publications);
12415 set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
12416 publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
12417 })));
12418 crate::root_cache::reset_writer_lease_acquisition_counts_for_test();
12419
12420 let repaired = CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
12421 .unwrap()
12422 .expect("owner should purge the storm-era worktree root");
12423 drop(repaired);
12424 for _ in 0..3 {
12425 let borrower = CallGraphStore::open_readonly(callgraph_dir.clone(), worktree.clone())
12426 .unwrap()
12427 .expect("linked worktree should borrow the owner generation");
12428 drop(borrower);
12429 assert!(
12430 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), worktree.clone())
12431 .unwrap()
12432 .is_none()
12433 );
12434 let owner_store =
12435 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
12436 .unwrap()
12437 .expect("owner generation should remain ready");
12438 drop(owner_store);
12439 }
12440 set_cold_build_swap_observer(None);
12441
12442 assert_eq!(
12443 publications.load(AtomicOrdering::SeqCst),
12444 1,
12445 "the owner performs one expected post-storm purge and alternation stays read-only"
12446 );
12447 assert_eq!(
12448 crate::root_cache::writer_lease_acquisition_count_for_test(
12449 crate::root_cache::RootCacheDomain::Callgraph,
12450 &project_key,
12451 &worktree,
12452 ),
12453 0
12454 );
12455 }
12456
12457 #[test]
12458 fn rebuild_cooldown_records_only_successful_publication_per_cache_key() {
12459 let temp = tempdir().unwrap();
12460 let root = temp.path().join("owner");
12461 let other_root = temp.path().join("other");
12462 fs::create_dir_all(&root).unwrap();
12463 fs::create_dir_all(&other_root).unwrap();
12464 let source = root.join("lib.rs");
12465 fs::write(&source, "pub fn marker() {}\n").unwrap();
12466 let project_key = crate::search_index::artifact_cache_key(&root);
12467 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
12468 crate::root_cache::configure_artifact_access(&root, &project_key, false);
12469 let cooldown_key = rebuild_cooldown_key(&callgraph_dir, &project_key);
12470 rebuild_cooldown_records()
12471 .lock()
12472 .unwrap_or_else(std::sync::PoisonError::into_inner)
12473 .remove(&cooldown_key);
12474 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
12475 let stale_epoch = epoch.current();
12476 epoch.next();
12477
12478 let failed = with_publish_epoch(epoch, stale_epoch, || {
12479 CallGraphStore::cold_build_with_lease(
12480 callgraph_dir.clone(),
12481 root.clone(),
12482 std::slice::from_ref(&source),
12483 )
12484 });
12485 assert!(matches!(failed, Err(CallGraphStoreError::Superseded)));
12486 assert!(
12487 rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
12488 .is_none()
12489 );
12490
12491 let (store, _) = CallGraphStore::cold_build_with_lease(
12492 callgraph_dir.clone(),
12493 root.clone(),
12494 std::slice::from_ref(&source),
12495 )
12496 .unwrap();
12497 drop(store);
12498 assert!(
12499 rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
12500 .is_none()
12501 );
12502
12503 record_successful_rebuild(&callgraph_dir, &project_key, &other_root, Instant::now());
12504 assert!(
12505 rebuild_cooldown_denial(&callgraph_dir, &project_key, &root, Instant::now(),).is_some()
12506 );
12507 }
12508
12509 #[test]
12510 fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
12511 let _guard = REFRESH_WORKER_TEST_LOCK
12512 .lock()
12513 .unwrap_or_else(std::sync::PoisonError::into_inner);
12514 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12515 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12516 let pending = pending_paths();
12517 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12518
12519 let lifecycle = SubcLifecycleAdmission::default();
12520 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
12521 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12522 let ticket = CallgraphRefreshTicket::new(
12523 lifecycle,
12524 Arc::clone(&generation),
12525 7,
12526 publish_epoch.clone(),
12527 publish_epoch.current(),
12528 );
12529 generation.store(8, std::sync::atomic::Ordering::SeqCst);
12531 let installed = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
12532 .unwrap()
12533 .expect("ready store snapshot");
12534 let refresh_state = CallgraphRefreshState::new(
12535 Arc::new(std::sync::RwLock::new(Some(Arc::new(installed)))),
12536 Arc::new(AtomicBool::new(true)),
12537 );
12538
12539 enqueue_callgraph_store_refresh_fenced_with_state(
12540 callgraph_dir,
12541 root.clone(),
12542 vec![source.clone()],
12543 Arc::clone(&pending),
12544 refresh_state,
12545 ticket,
12546 );
12547 assert!(flush_callgraph_store_refreshes_with_budget(
12548 Duration::from_secs(5)
12549 ));
12550 assert_eq!(
12551 callgraph_refresh_worker_test_counts(&root).0,
12552 0,
12553 "superseded batch must not reach refresh_files or self-replay"
12554 );
12555 assert!(
12556 pending.lock().contains(&source),
12557 "superseded batch must defer its paths to the pending sink"
12558 );
12559 clear_callgraph_refresh_worker_test_seam(&root);
12560 }
12561
12562 #[test]
12563 fn superseded_open_failure_defers_without_self_replay() {
12564 let _guard = REFRESH_WORKER_TEST_LOCK
12565 .lock()
12566 .unwrap_or_else(std::sync::PoisonError::into_inner);
12567 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12568 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12569 let pending = pending_paths();
12570 let installed = Arc::new(
12571 CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
12572 .unwrap()
12573 .expect("ready store snapshot"),
12574 );
12575 let refresh_state = CallgraphRefreshState::new(
12576 Arc::new(std::sync::RwLock::new(Some(Arc::clone(&installed)))),
12577 Arc::new(AtomicBool::new(true)),
12578 );
12579 assert!(!installed.is_legacy_fallback());
12580 assert!(installed.is_current());
12581 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
12582 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12583 set_callgraph_refresh_worker_test_open_failure(root.clone(), true);
12584 let (held_rx, release_tx) = install_callgraph_refresh_worker_test_gate(root.clone());
12585
12586 let lifecycle = SubcLifecycleAdmission::default();
12587 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
12588 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12589 let ticket = CallgraphRefreshTicket::new(
12590 lifecycle,
12591 Arc::clone(&generation),
12592 7,
12593 publish_epoch.clone(),
12594 publish_epoch.current(),
12595 );
12596 enqueue_callgraph_store_refresh_fenced_with_state(
12597 callgraph_dir,
12598 root.clone(),
12599 vec![source.clone()],
12600 Arc::clone(&pending),
12601 refresh_state,
12602 ticket,
12603 );
12604 held_rx
12605 .recv_timeout(Duration::from_secs(12))
12606 .expect("refresh worker must hold after injected open failure");
12607
12608 generation.store(8, std::sync::atomic::Ordering::SeqCst);
12611 set_callgraph_refresh_worker_test_open_failure(root.clone(), false);
12612 release_tx
12613 .send(())
12614 .expect("release superseded refresh worker");
12615 wait_for_refresh_worker_idle();
12616
12617 assert_eq!(
12618 callgraph_refresh_worker_test_counts(&root).0,
12619 1,
12620 "superseded open-failure batch must not self-replay"
12621 );
12622 assert_eq!(
12623 callgraph_refresh_worker_test_worker_calls(&root),
12624 1,
12625 "superseded open-failure batch must not create another worker call"
12626 );
12627 assert!(
12628 pending.lock().contains(&source),
12629 "superseded open-failure paths must remain in the pending sink"
12630 );
12631 let tree = installed
12632 .call_tree(Path::new("main.rs"), "entry", 1)
12633 .unwrap();
12634 assert_eq!(
12635 tree.children[0].name, "old_leaf",
12636 "superseded open-failure batch must not converge the store"
12637 );
12638 clear_callgraph_refresh_worker_test_seam(&root);
12639 }
12640
12641 #[test]
12642 fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
12643 let _guard = REFRESH_WORKER_TEST_LOCK
12644 .lock()
12645 .unwrap_or_else(std::sync::PoisonError::into_inner);
12646 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12647 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12648 let pending = pending_paths();
12649 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12650
12651 let lifecycle = SubcLifecycleAdmission::default();
12652 let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
12653 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12654 let expected_epoch = publish_epoch.current();
12655 let ticket = CallgraphRefreshTicket::new(
12656 lifecycle,
12657 generation,
12658 3,
12659 publish_epoch.clone(),
12660 expected_epoch,
12661 );
12662 publish_epoch.next();
12664
12665 enqueue_callgraph_store_refresh_fenced(
12666 callgraph_dir,
12667 root.clone(),
12668 vec![source.clone()],
12669 Arc::clone(&pending),
12670 ticket,
12671 );
12672 assert!(flush_callgraph_store_refreshes_with_budget(
12673 Duration::from_secs(5)
12674 ));
12675 assert_eq!(
12676 callgraph_refresh_worker_test_counts(&root).0,
12677 0,
12678 "epoch-superseded batch must not reach refresh_files"
12679 );
12680 assert!(
12681 pending.lock().contains(&source),
12682 "epoch-superseded batch must defer its paths to the pending sink"
12683 );
12684 clear_callgraph_refresh_worker_test_seam(&root);
12685 }
12686
12687 #[test]
12688 fn fenced_refresh_with_current_ticket_commits_normally() {
12689 let _guard = REFRESH_WORKER_TEST_LOCK
12690 .lock()
12691 .unwrap_or_else(std::sync::PoisonError::into_inner);
12692 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12693 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12694 let pending = pending_paths();
12695 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12696
12697 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
12698
12699 let lifecycle = SubcLifecycleAdmission::default();
12700 let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
12701 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12702 let ticket = CallgraphRefreshTicket::new(
12703 lifecycle,
12704 generation,
12705 5,
12706 publish_epoch.clone(),
12707 publish_epoch.current(),
12708 );
12709
12710 enqueue_callgraph_store_refresh_fenced(
12711 callgraph_dir.clone(),
12712 root.clone(),
12713 vec![source.clone()],
12714 Arc::clone(&pending),
12715 ticket,
12716 );
12717 assert!(flush_callgraph_store_refreshes_with_budget(
12718 Duration::from_secs(5)
12719 ));
12720 assert_eq!(
12721 callgraph_refresh_worker_test_counts(&root).0,
12722 1,
12723 "current ticket must run the refresh"
12724 );
12725 assert!(
12726 pending.lock().is_empty(),
12727 "committed batch must not defer paths"
12728 );
12729
12730 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
12731 .unwrap()
12732 .expect("published generation must remain readable");
12733 let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
12734 assert_eq!(
12735 tree.children[0].name, "new_leaf",
12736 "fenced commit must actually persist the refreshed content"
12737 );
12738 clear_callgraph_refresh_worker_test_seam(&root);
12739 }
12740
12741 #[test]
12742 fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
12743 let _guard = REFRESH_WORKER_TEST_LOCK
12744 .lock()
12745 .unwrap_or_else(std::sync::PoisonError::into_inner);
12746 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12751 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12752 let pending = pending_paths();
12753 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
12754
12755 enqueue_callgraph_store_refresh(
12756 callgraph_dir.clone(),
12757 root.clone(),
12758 vec![source.clone()],
12759 Arc::clone(&pending),
12760 );
12761 wait_for_refresh_calls(&root, 1);
12762 for _ in 0..3 {
12763 enqueue_callgraph_store_refresh(
12764 callgraph_dir.clone(),
12765 root.clone(),
12766 vec![source.clone()],
12767 Arc::clone(&pending),
12768 );
12769 }
12770
12771 assert!(flush_callgraph_store_refreshes_with_budget(
12772 Duration::from_secs(2)
12773 ));
12774 assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
12775 assert!(pending.lock().is_empty());
12776 clear_callgraph_refresh_worker_test_seam(&root);
12777 }
12778
12779 #[test]
12780 fn queued_refresh_opens_generation_published_after_enqueue() {
12781 let _guard = REFRESH_WORKER_TEST_LOCK
12782 .lock()
12783 .unwrap_or_else(std::sync::PoisonError::into_inner);
12784 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12789 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
12790 let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
12791 set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
12792 let (active_held_rx, active_release_tx) =
12793 install_callgraph_refresh_worker_test_gate(active_root.clone());
12794 set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
12795 enqueue_callgraph_store_refresh(
12796 active_dir,
12797 active_root.clone(),
12798 vec![active_source],
12799 pending_paths(),
12800 );
12801 active_held_rx
12802 .recv_timeout(Duration::from_secs(12))
12803 .expect("active refresh worker holds the queue");
12804
12805 fs::write(
12806 &target_source,
12807 "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
12808 )
12809 .unwrap();
12810 enqueue_callgraph_store_refresh(
12811 target_dir.clone(),
12812 target_root.clone(),
12813 vec![target_source.clone()],
12814 pending_paths(),
12815 );
12816 let (new_generation, _) = CallGraphStore::cold_build_with_lease(
12817 target_dir.clone(),
12818 target_root.clone(),
12819 std::slice::from_ref(&target_source),
12820 )
12821 .unwrap();
12822 fs::write(
12823 &target_source,
12824 "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
12825 )
12826 .unwrap();
12827 drop(new_generation);
12828
12829 active_release_tx
12830 .send(())
12831 .expect("release active refresh worker");
12832 wait_for_refresh_calls(&target_root, 1);
12833 assert!(flush_callgraph_store_refreshes_with_budget(
12834 Duration::from_secs(12)
12835 ));
12836 let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
12837 .unwrap()
12838 .expect("current callgraph generation");
12839 let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
12840 assert_eq!(tree.children[0].name, "worker_leaf");
12841 assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
12842 clear_callgraph_refresh_worker_test_seam(&active_root);
12843 clear_callgraph_refresh_worker_test_seam(&target_root);
12844 }
12845
12846 #[test]
12847 fn refresh_failure_marks_files_stale() {
12848 let _guard = REFRESH_WORKER_TEST_LOCK
12849 .lock()
12850 .unwrap_or_else(std::sync::PoisonError::into_inner);
12851 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12856 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12857 let pending = pending_paths();
12858 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
12859
12860 enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
12861 assert!(flush_callgraph_store_refreshes_with_budget(
12862 Duration::from_secs(2)
12863 ));
12864
12865 assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
12866 let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
12867 .unwrap()
12868 .expect("ready callgraph store");
12869 assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
12870 clear_callgraph_refresh_worker_test_seam(&root);
12871 }
12872
12873 #[test]
12874 fn idle_refresh_truncates_wal() {
12875 let _guard = REFRESH_WORKER_TEST_LOCK
12876 .lock()
12877 .unwrap_or_else(std::sync::PoisonError::into_inner);
12878 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12879 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12880 let generation = read_pointer(
12881 &callgraph_dir,
12882 &crate::search_index::artifact_cache_key(&root),
12883 )
12884 .expect("fixture publishes a generation");
12885 let wal_path = callgraph_dir.join(format!("{generation}-wal"));
12886 let pending = pending_paths();
12887 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12888
12889 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n\n").unwrap();
12890 enqueue_callgraph_store_refresh(
12891 callgraph_dir.clone(),
12892 root.clone(),
12893 vec![source.clone()],
12894 Arc::clone(&pending),
12895 );
12896 wait_for_refresh_calls(&root, 1);
12897 wait_for_refresh_worker_idle();
12898 let checkpoint_deadline = Instant::now() + Duration::from_secs(2);
12899 while fs::metadata(&wal_path)
12900 .map(|metadata| metadata.len())
12901 .unwrap_or(0)
12902 != 0
12903 {
12904 assert!(
12905 Instant::now() < checkpoint_deadline,
12906 "idle checkpoint did not truncate WAL"
12907 );
12908 std::thread::sleep(Duration::from_millis(5));
12909 }
12910 assert_eq!(
12911 fs::metadata(&wal_path)
12912 .map(|metadata| metadata.len())
12913 .unwrap_or(0),
12914 0,
12915 "idle transition truncates the refresh WAL"
12916 );
12917
12918 clear_callgraph_refresh_worker_test_seam(&root);
12919 }
12920
12921 #[test]
12922 fn bounded_shutdown_defers_unprocessed_batches() {
12923 let _guard = REFRESH_WORKER_TEST_LOCK
12924 .lock()
12925 .unwrap_or_else(std::sync::PoisonError::into_inner);
12926 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12931 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
12932 let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
12933 let active_pending = pending_paths();
12934 let queued_pending = pending_paths();
12935 set_callgraph_refresh_worker_test_seam(
12936 active_root.clone(),
12937 Duration::from_millis(300),
12938 false,
12939 );
12940
12941 enqueue_callgraph_store_refresh(
12942 active_dir,
12943 active_root.clone(),
12944 vec![active_source.clone()],
12945 Arc::clone(&active_pending),
12946 );
12947 wait_for_refresh_calls(&active_root, 1);
12948 enqueue_callgraph_store_refresh(
12949 queued_dir,
12950 queued_root.clone(),
12951 vec![queued_source.clone()],
12952 Arc::clone(&queued_pending),
12953 );
12954
12955 assert!(!flush_callgraph_store_refreshes_with_budget(
12956 Duration::from_millis(20)
12957 ));
12958 assert!(active_pending.lock().contains(&active_source));
12959 assert!(queued_pending.lock().contains(&queued_source));
12960 assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
12961 clear_callgraph_refresh_worker_test_seam(&active_root);
12962 }
12963}
12964
12965#[cfg(test)]
12966mod cold_build_insert_tests {
12967 use super::*;
12968 use crate::imports::ImportBlock;
12969 use std::cell::Cell;
12970 use std::fs;
12971 use std::path::{Path, PathBuf};
12972 use tempfile::tempdir;
12973
12974 thread_local! {
12975 static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
12976 static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
12977 static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
12978 }
12979
12980 fn count_caller_traversal_selects(sql: &str) {
12981 let sql = sql.trim_start();
12982 if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
12983 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
12984 }
12985 if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
12986 && sql.contains("e.target_file =")
12987 {
12988 CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
12989 }
12990 if sql.starts_with("WITH requested") && sql.contains("COUNT(*)") {
12991 BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
12992 }
12993 }
12994
12995 #[test]
12996 fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
12997 let dir = tempdir().unwrap();
12998 let previous_root = dir.path().join("previous-root");
12999 let current_root = dir.path().join("current-root");
13000 fs::create_dir_all(&previous_root).unwrap();
13001 fs::create_dir_all(¤t_root).unwrap();
13002 fs::remove_dir(&previous_root).unwrap();
13003 let mut conn = Connection::open_in_memory().unwrap();
13004 initialize_schema(&conn).unwrap();
13005 conn.execute(
13006 "INSERT INTO backend_file_state(
13007 backend, workspace_root, file_path, content_hash, status, updated_at
13008 ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
13009 params![previous_root.display().to_string()],
13010 )
13011 .unwrap();
13012
13013 let repair = reconcile_workspace_roots(&mut conn, ¤t_root, false).unwrap();
13014
13015 assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
13016 assert_eq!(
13017 stored_workspace_roots(&conn).unwrap(),
13018 vec![previous_root.display().to_string()]
13019 );
13020 }
13021
13022 #[test]
13023 fn sqlite_readonly_uri_percent_encodes_windows_paths() {
13024 assert_eq!(
13025 sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
13026 "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
13027 );
13028 }
13029
13030 #[test]
13031 fn legacy_migration_completion_log_has_operator_fields() {
13032 assert_eq!(
13033 legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
13034 "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
13035 );
13036 }
13037
13038 fn write_generation_with_age(
13039 dir: &Path,
13040 project_key: &str,
13041 ordinal: u64,
13042 age: Duration,
13043 ) -> String {
13044 let generation = format!("{project_key}.g{ordinal}.1.sqlite");
13045 let path = dir.join(&generation);
13046 fs::write(&path, b"sqlite placeholder").unwrap();
13047 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
13048 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
13049 generation
13050 }
13051
13052 #[test]
13053 fn gc_old_generations_preserves_live_reader_until_marker_drops() {
13054 let dir = tempfile::tempdir().unwrap();
13055 let project_key = "project";
13056 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
13057 let previous =
13058 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
13059 let pinned =
13060 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
13061 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
13062
13063 gc_old_generations(dir.path(), project_key, ¤t);
13064
13065 assert!(dir.path().join(&previous).is_file());
13066 assert!(dir.path().join(&pinned).is_file());
13067
13068 drop(marker);
13069 gc_old_generations(dir.path(), project_key, ¤t);
13070
13071 assert!(dir.path().join(&previous).is_file());
13072 assert!(!dir.path().join(&pinned).exists());
13073 }
13074
13075 #[test]
13076 fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
13077 let dir = tempfile::tempdir().unwrap();
13078 let project_key = "project";
13079 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
13080 let _previous =
13081 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
13082 let pinned =
13083 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
13084 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
13085 filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
13086
13087 gc_old_generations(dir.path(), project_key, ¤t);
13088
13089 assert!(dir.path().join(&pinned).is_file());
13090 }
13091
13092 #[test]
13093 fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
13094 let dir = tempfile::tempdir().unwrap();
13095 let project_key = "project";
13096 let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
13097 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
13098 let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
13099 let old = write_generation_with_age(
13100 dir.path(),
13101 project_key,
13102 200,
13103 expired + Duration::from_secs(60),
13104 );
13105 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
13106
13107 gc_old_generations(dir.path(), project_key, ¤t);
13108
13109 assert!(dir.path().join(¤t).is_file());
13110 assert!(dir.path().join(&previous).is_file());
13111 assert!(!dir.path().join(&old).exists());
13112 }
13113
13114 fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
13115 let path = dir.join(name);
13116 fs::write(&path, b"temp placeholder").unwrap();
13117 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
13118 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
13119 path
13120 }
13121
13122 #[test]
13123 fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
13124 let dir = tempdir().unwrap();
13125 let aged = "project.g100.1.sqlite.tmp.1.200";
13129 let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
13130 let fresh = "project.g300.1.sqlite.tmp.1.400";
13131 let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
13132 write_build_temp_with_age(dir.path(), aged, aged_age);
13133 write_build_temp_with_age(dir.path(), aged_journal, aged_age);
13134 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
13135
13136 sweep_orphaned_build_temps(dir.path());
13137
13138 assert!(
13139 !dir.path().join(aged).exists(),
13140 "aged orphan must be removed"
13141 );
13142 assert!(
13143 !dir.path().join(aged_journal).exists(),
13144 "aged journal sidecar must be removed"
13145 );
13146 assert!(
13147 dir.path().join(fresh).is_file(),
13148 "fresh temporary must survive"
13149 );
13150 }
13151
13152 #[test]
13153 fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
13154 let storage = tempdir().unwrap();
13155 let storage_root = storage.path();
13156 let legacy_dir = storage_root.join("opencode").join("callgraph");
13162 fs::create_dir_all(&legacy_dir).unwrap();
13163 let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
13164 write_build_temp_with_age(
13165 &legacy_dir,
13166 orphan,
13167 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
13168 );
13169 assert!(
13170 !legacy_dir.join("deadbeef.current").exists(),
13171 "the dead root has no current pointer"
13172 );
13173
13174 let root_keyed_dir = storage_root.join("callgraph").join("livekey");
13175 fs::create_dir_all(&root_keyed_dir).unwrap();
13176
13177 sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
13178
13179 assert!(
13180 !legacy_dir.join(orphan).exists(),
13181 "legacy orphan must be reclaimed by the store-wide sweep"
13182 );
13183 }
13184
13185 #[test]
13186 fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
13187 let dir = tempdir().unwrap();
13193 let fresh = "project.g300.1.sqlite.tmp.1.400";
13194 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
13195
13196 sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
13197
13198 assert!(
13199 !dir.path().join(fresh).exists(),
13200 "with the age predicate forced open, the fresh temporary is removed"
13201 );
13202 }
13203
13204 #[test]
13205 fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
13206 let dir = tempdir().unwrap();
13207 let generation = write_generation_with_age(
13211 dir.path(),
13212 "project",
13213 400,
13214 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
13215 );
13216 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
13217
13218 sweep_orphaned_build_temps(dir.path());
13219
13220 assert!(
13221 dir.path().join(&generation).is_file(),
13222 "completed generation must survive the orphan sweep"
13223 );
13224 assert!(
13225 crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
13226 "read marker must survive the orphan sweep"
13227 );
13228 }
13229
13230 #[test]
13231 fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
13232 let dir = tempfile::tempdir().unwrap();
13233 let project_key = "project".to_string();
13234 let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
13235 let sqlite_path = dir.path().join(&generation);
13236 fs::remove_file(&sqlite_path).unwrap();
13237 let conn = Connection::open(&sqlite_path).unwrap();
13238 let store = CallGraphStore::from_connection(
13239 dir.path().to_path_buf(),
13240 project_key,
13241 sqlite_path,
13242 dir.path().to_path_buf(),
13243 false,
13244 Some(generation.clone()),
13245 None,
13246 None,
13247 conn,
13248 );
13249
13250 let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
13251 assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
13252
13253 drop(marker);
13254 assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
13255 }
13256
13257 #[test]
13258 fn readiness_cache_only_skips_checks_after_a_successful_validation() {
13259 let dir = tempdir().expect("temp dir");
13260 let file = dir.path().join("main.ts");
13261 fs::write(&file, "export function main() {}\n").expect("write fixture");
13262 let store = CallGraphStore::open(
13263 dir.path().join(".store-readiness-cache"),
13264 dir.path().to_path_buf(),
13265 )
13266 .expect("open store");
13267 {
13268 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13269 conn.trace(Some(count_caller_traversal_selects));
13270 }
13271
13272 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13273 assert!(store.indexed_file_count().is_err());
13274 assert!(store.indexed_file_count().is_err());
13275 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
13276
13277 store
13278 .cold_build(std::slice::from_ref(&file))
13279 .expect("cold build");
13280 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13281 assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
13282 assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
13283 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
13284
13285 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13286 conn.trace(None);
13287 }
13288
13289 #[test]
13290 fn direct_caller_frontier_chunks_sqlite_selects() {
13291 let dir = tempdir().expect("temp dir");
13292 let file = dir.path().join("main.ts");
13293 fs::write(
13294 &file,
13295 "export function caller() { target(); }\nexport function target() {}\n",
13296 )
13297 .expect("write fixture");
13298 let store = CallGraphStore::open(
13299 dir.path().join(".store-caller-frontier-query"),
13300 dir.path().to_path_buf(),
13301 )
13302 .expect("open store");
13303 store
13304 .cold_build(std::slice::from_ref(&file))
13305 .expect("cold build");
13306 let mut targets = vec![("main.ts".to_string(), "target".to_string())];
13307 targets.extend((1..1_000).map(|index| ("main.ts".to_string(), format!("missing{index}"))));
13308
13309 CALLER_QUERY_SELECTS.with(|count| count.set(0));
13310 BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
13311 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13312 {
13313 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13314 conn.trace(Some(count_caller_traversal_selects));
13315 }
13316 let callers = store
13317 .direct_callers_for_symbols(&targets)
13318 .expect("batched callers");
13319 {
13320 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13321 conn.trace(None);
13322 }
13323
13324 assert_eq!(callers.len(), 1_000);
13325 assert_eq!(callers.get(&targets[0]).unwrap().len(), 1);
13326 assert_eq!(CALLER_QUERY_SELECTS.with(Cell::get), 3);
13327 assert_eq!(BOUNDARY_COUNT_SELECTS.with(Cell::get), 0);
13328 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
13329 }
13330
13331 #[test]
13332 fn callers_depth_boundary_batches_sqlite_counts() {
13333 const CALLER_COUNT: usize = 1_000;
13334
13335 let dir = tempdir().expect("temp dir");
13336 let file = dir.path().join("main.ts");
13337 let mut source = String::from("export function sharedHotHelper() {}\n");
13338 for index in 0..CALLER_COUNT {
13339 source.push_str(&format!(
13340 "export function caller{index}() {{ sharedHotHelper(); }}\n"
13341 ));
13342 }
13343 fs::write(&file, source).expect("write fixture");
13344
13345 let store = CallGraphStore::open(
13346 dir.path().join(".store-callers-query-fanout"),
13347 dir.path().to_path_buf(),
13348 )
13349 .expect("open store");
13350 store
13351 .cold_build(std::slice::from_ref(&file))
13352 .expect("cold build");
13353
13354 CALLER_QUERY_SELECTS.with(|count| count.set(0));
13355 BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
13356 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13357 {
13358 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13359 conn.trace(Some(count_caller_traversal_selects));
13360 }
13361
13362 let started = Instant::now();
13363 let result = crate::commands::callgraph_store_adapter::callers_result(
13364 &store,
13365 Path::new("main.ts"),
13366 "sharedHotHelper",
13367 1,
13368 true,
13369 )
13370 .expect("callers result");
13371 let elapsed = started.elapsed();
13372
13373 {
13374 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13375 conn.trace(None);
13376 }
13377 let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
13378 let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
13379 let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
13380 eprintln!(
13381 "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
13382 result.total_callers,
13383 caller_queries,
13384 boundary_queries,
13385 total_selects,
13386 elapsed.as_secs_f64() * 1_000.0
13387 );
13388
13389 assert_eq!(result.total_callers, CALLER_COUNT);
13390 assert_eq!(caller_queries, 1);
13391 assert_eq!(boundary_queries, 3);
13392 assert_eq!(total_selects, 9);
13393 }
13394
13395 #[test]
13396 fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
13397 let dir = tempdir().expect("temp dir");
13398 let file = dir.path().join("main.ts");
13399 fs::write(
13400 &file,
13401 r#"export function topA() {
13402 root();
13403}
13404
13405export function topB() {
13406 root();
13407}
13408
13409export function root() {
13410 leaf();
13411 missing();
13412}
13413
13414export function leaf() {}
13415"#,
13416 )
13417 .expect("write fixture");
13418
13419 let store = CallGraphStore::open(
13420 dir.path().join(".store-depth-boundary-counts"),
13421 dir.path().to_path_buf(),
13422 )
13423 .expect("open store");
13424 store
13425 .cold_build(std::slice::from_ref(&file))
13426 .expect("cold build");
13427
13428 let root = store
13429 .node_for(Path::new("main.ts"), "root")
13430 .expect("root node");
13431 let leaf = store
13432 .node_for(Path::new("main.ts"), "leaf")
13433 .expect("leaf node");
13434
13435 let (full_forward_len, full_direct_len) = {
13436 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
13437 conn.execute(
13438 "INSERT INTO edges (
13439 edge_id, ref_id, source_node, target_node, target_file,
13440 target_symbol, kind, line, provenance
13441 ) VALUES (
13442 'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
13443 ?2, ?3, 'call', 98, ?4
13444 )",
13445 rusqlite::params![
13446 &root.node_id,
13447 &leaf.file,
13448 &leaf.symbol,
13449 PROVENANCE_TREESITTER
13450 ],
13451 )
13452 .expect("insert dangling forward edge");
13453 conn.execute(
13454 "INSERT INTO edges (
13455 edge_id, ref_id, source_node, target_node, target_file,
13456 target_symbol, kind, line, provenance
13457 ) VALUES (
13458 'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
13459 ?1, ?2, ?3, 'call', 99, ?4
13460 )",
13461 rusqlite::params![
13462 &root.node_id,
13463 &root.file,
13464 &root.symbol,
13465 PROVENANCE_TREESITTER
13466 ],
13467 )
13468 .expect("insert dangling direct-caller edge");
13469
13470 let full_forward_len = forward_calls_for_node(&conn, &root)
13471 .expect("full forward calls")
13472 .len();
13473 let counted_forward_len =
13474 forward_call_count_for_node(&conn, &root).expect("counted forward calls");
13475 assert_eq!(
13476 counted_forward_len, full_forward_len,
13477 "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
13478 );
13479
13480 let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
13481 .expect("full direct callers");
13482 let full_direct_len = full_direct.len();
13483 let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
13484 .expect("counted direct callers");
13485 assert_eq!(
13486 counted_direct_len, full_direct_len,
13487 "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
13488 );
13489
13490 let distinct_direct_len = full_direct
13491 .iter()
13492 .map(|site| {
13493 (
13494 site.caller.file.clone(),
13495 site.line,
13496 site.target_file.clone(),
13497 site.target_symbol.clone(),
13498 )
13499 })
13500 .collect::<BTreeSet<_>>()
13501 .len();
13502 let batch_counts = direct_caller_counts_for_tuples(
13503 &conn,
13504 &[
13505 (root.file.clone(), root.symbol.clone()),
13506 (root.file.clone(), root.symbol.clone()),
13507 (leaf.file.clone(), leaf.symbol.clone()),
13508 ],
13509 )
13510 .expect("batched direct-caller counts");
13511 assert_eq!(batch_counts.len(), 2);
13512 assert_eq!(
13513 batch_counts.get(&(root.file.clone(), root.symbol.clone())),
13514 Some(&distinct_direct_len)
13515 );
13516
13517 (full_forward_len, full_direct_len)
13518 };
13519
13520 assert_eq!(
13521 full_forward_len, 2,
13522 "fixture root should have one resolved and one unresolved outgoing call"
13523 );
13524 assert_eq!(
13525 full_direct_len, 2,
13526 "fixture root should have two real direct callers"
13527 );
13528
13529 let tree = store
13530 .call_tree(Path::new("main.ts"), "root", 0)
13531 .expect("call tree");
13532 assert!(tree.depth_limited);
13533 assert_eq!(tree.children.len(), 0);
13534 assert_eq!(
13535 tree.truncated, full_forward_len,
13536 "call_tree depth boundary must report the full forward-call list length"
13537 );
13538
13539 let callers = store
13540 .callers_of(Path::new("main.ts"), "leaf", 0)
13541 .expect("callers");
13542 assert!(callers.depth_limited);
13543 assert_eq!(callers.callers.len(), 1);
13544 assert_eq!(callers.callers[0].caller.symbol, "root");
13545 assert_eq!(
13546 callers.truncated, full_direct_len,
13547 "callers depth boundary must report the full direct-caller list length"
13548 );
13549 }
13550
13551 #[test]
13552 fn source_freshness_matches_cache_collect_for_same_bytes() {
13553 let dir = tempdir().expect("temp dir");
13554 let path = dir.path().join("fixture.ts");
13555 let source = "export function main() { return helper(); }\n";
13556 fs::write(&path, source).expect("write fixture");
13557
13558 let expected = cache_freshness::collect(&path).expect("collect freshness from file");
13559 let actual =
13560 collect_source_freshness(&path, source).expect("collect freshness from source");
13561
13562 assert_eq!(actual, expected);
13563 }
13564
13565 #[test]
13566 fn superseded_cold_build_cannot_publish_after_newer_epoch() {
13567 let root = tempfile::tempdir().unwrap();
13568 let callgraph_dir = tempfile::tempdir().unwrap();
13569 let source_dir = root.path().join("src");
13570 std::fs::create_dir_all(&source_dir).unwrap();
13571 let source = source_dir.join("lib.rs");
13572 std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
13573 let files = vec![source.clone()];
13574 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
13575 let old_epoch = epoch.next();
13576 let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
13577 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
13578 let old_epoch_flag = epoch.clone();
13579 let old_dir = callgraph_dir.path().to_path_buf();
13580 let old_root = root.path().to_path_buf();
13581 let old_files = files.clone();
13582 let old = std::thread::spawn(move || {
13583 set_cold_build_before_publish_observer(Some(Arc::new(move || {
13584 reached_tx.send(()).unwrap();
13585 release_rx.recv().unwrap();
13586 })));
13587 let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
13588 CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
13589 });
13590 set_cold_build_before_publish_observer(None);
13591 result
13592 });
13593 reached_rx
13597 .recv_timeout(Duration::from_secs(30))
13598 .expect("older build did not reach its publication barrier");
13599
13600 std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
13601 let new_epoch = epoch.next();
13602 let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
13603 CallGraphStore::cold_build_with_lease(
13604 callgraph_dir.path().to_path_buf(),
13605 root.path().to_path_buf(),
13606 &files,
13607 )
13608 })
13609 .expect("newer build should publish");
13610 drop(new_store);
13611
13612 release_tx.send(()).unwrap();
13613 assert!(matches!(
13614 old.join().unwrap(),
13615 Err(CallGraphStoreError::Superseded)
13616 ));
13617
13618 let current = CallGraphStore::open_readonly(
13619 callgraph_dir.path().to_path_buf(),
13620 root.path().to_path_buf(),
13621 )
13622 .unwrap()
13623 .expect("current callgraph generation");
13624 assert_eq!(
13625 current
13626 .nodes_matching("new_generation_marker")
13627 .unwrap()
13628 .len(),
13629 1
13630 );
13631 assert!(current
13632 .nodes_matching("old_generation_marker")
13633 .unwrap()
13634 .is_empty());
13635 }
13636
13637 #[test]
13638 fn cold_build_prepared_bulk_insert_matches_reference_rows() {
13639 let dir = tempdir().expect("temp dir");
13640 let project_root = dir.path();
13641 let extract = fixture_extract(project_root);
13642 let resolved = fixture_resolved(&extract);
13643
13644 let reference = build_reference_connection(project_root, &extract, &resolved);
13645 let optimized = build_optimized_connection(project_root, &extract, &resolved);
13646
13647 for table in [
13648 "files",
13649 "nodes",
13650 "file_dependencies",
13651 "dispatch_hints",
13652 "refs",
13653 "edges",
13654 ] {
13655 let excluded: &[&str] = if table == "files" {
13662 &["indexed_at"]
13663 } else {
13664 &[]
13665 };
13666 assert_eq!(
13667 table_rows_without(&reference, table, excluded),
13668 table_rows_without(&optimized, table, excluded),
13669 "table `{table}` rows must match apart from wall-clock columns"
13670 );
13671 }
13672 assert_eq!(
13673 backend_state_rows(&reference),
13674 backend_state_rows(&optimized),
13675 "backend freshness rows must match apart from updated_at"
13676 );
13677 assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
13678 }
13679
13680 #[test]
13681 fn cold_build_chunked_matches_unchunked_logical_rows() {
13682 let dir = tempdir().expect("temp dir");
13683 let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
13684 write_chunked_equivalence_fixture(&project_root);
13685 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
13686 assert!(
13687 files.len() > 6,
13688 "fixture should be large enough to split into multiple chunks"
13689 );
13690
13691 let unchunked = CallGraphStore::open(
13692 project_root.join(".store-unchunked"),
13693 project_root.to_path_buf(),
13694 )
13695 .expect("open unchunked store");
13696 let unchunked_stats = unchunked
13697 .cold_build_chunked(&files, 0)
13698 .expect("unchunked cold build");
13699
13700 let chunked = CallGraphStore::open(
13701 project_root.join(".store-chunked"),
13702 project_root.to_path_buf(),
13703 )
13704 .expect("open chunked store");
13705 let chunked_stats = chunked
13706 .cold_build_chunked(&files, 3)
13707 .expect("chunked cold build");
13708
13709 assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
13710 assert_eq!(
13711 unchunked.edge_snapshot().expect("unchunked edge snapshot"),
13712 chunked.edge_snapshot().expect("chunked edge snapshot"),
13713 "public edge snapshots must match"
13714 );
13715
13716 let dispatch_edges = {
13717 let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
13718 conn.query_row(
13719 "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
13720 [],
13721 |row| row.get::<_, i64>(0),
13722 )
13723 .expect("count dispatch edges")
13724 };
13725 assert!(
13726 dispatch_edges > 0,
13727 "fixture must exercise method-dispatch edge insertion"
13728 );
13729
13730 for table in [
13731 "edges",
13732 "refs",
13733 "nodes",
13734 "file_dependencies",
13735 "dispatch_hints",
13736 ] {
13737 assert_eq!(
13738 graph_table_rows(&unchunked, table),
13739 graph_table_rows(&chunked, table),
13740 "chunked cold build must match unchunked rows for {table}"
13741 );
13742 }
13743 assert_eq!(
13744 graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
13745 graph_table_rows_without(&chunked, "files", &["indexed_at"]),
13746 "files rows must match apart from indexed_at"
13747 );
13748 assert_eq!(
13749 graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
13750 graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
13751 "backend freshness rows must match apart from updated_at"
13752 );
13753
13754 let published_dir = project_root.join(".store-published");
13755 let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
13756 published_dir.clone(),
13757 project_root.to_path_buf(),
13758 &files,
13759 0,
13760 )
13761 .expect("published unchunked cold build");
13762 assert!(
13763 !CallGraphStore::needs_cold_build(&published_dir, &project_root)
13764 .expect("needs_cold_build after publish"),
13765 "published store should be ready"
13766 );
13767 drop(_published);
13768 let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
13769 published_dir,
13770 project_root.to_path_buf(),
13771 &files,
13772 3,
13773 )
13774 .expect("ensure with a different chunk size");
13775 assert!(
13776 rebuild_stats.is_none(),
13777 "changing callgraph_chunk_size must not affect store identity or force a rebuild"
13778 );
13779 }
13780
13781 #[test]
13788 #[ignore]
13789 fn bench_cold_build_chunk() {
13790 let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
13791 let chunk: usize = std::env::var("AFT_PERF_CHUNK")
13792 .expect("AFT_PERF_CHUNK")
13793 .parse()
13794 .expect("AFT_PERF_CHUNK must be a non-negative integer");
13795 let project_root = fs::canonicalize(&repo).expect("canonical repo root");
13796 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
13797 let dir = tempdir().expect("temp dir");
13798 let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
13799 .expect("open store");
13800 let started = Instant::now();
13801 let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
13802 let ms = started.elapsed().as_millis();
13803 println!(
13804 "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
13805 stats.files, stats.nodes, stats.refs, stats.edges
13806 );
13807 }
13808
13809 #[test]
13810 fn persisted_workspace_reexport_selects_its_package_dependency() {
13811 let root = tempdir().expect("temp dir");
13812 let dependencies = BTreeSet::from([
13813 "packages/aft-bridge/src/index.ts".to_string(),
13814 "packages/opencode-plugin/src/types.ts".to_string(),
13815 ]);
13816 let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
13817
13818 assert_eq!(
13819 stored_dependencies_for_module(
13820 root.path(),
13821 "packages/opencode-plugin/src/shared/bash-hints.ts",
13822 "@cortexkit/aft-bridge",
13823 &dependencies,
13824 &indexed_files,
13825 ),
13826 BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
13827 );
13828 }
13829
13830 #[test]
13831 fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
13832 let dir = tempdir().expect("temp dir");
13833 let project_root = dir.path();
13834 let files =
13835 write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
13836 let index_path = project_root.join("src/index.ts");
13837
13838 let store = CallGraphStore::open(
13839 project_root.join(".store-incremental-barrel"),
13840 project_root.to_path_buf(),
13841 )
13842 .expect("open incremental store");
13843 store.cold_build(&files).expect("initial cold build");
13844
13845 {
13846 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13847 let tx = conn.transaction().expect("dependency transaction");
13848 let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
13849 .expect("dependent refs for barrel");
13850 let selected_ref_ids = dependent_refs
13851 .iter()
13852 .map(|dependent_ref| dependent_ref.ref_id.clone())
13853 .collect::<BTreeSet<_>>();
13854 let mut threaded_ref_ids = BTreeSet::new();
13855 let mut threaded_by_caller = BTreeMap::new();
13856 record_dependent_refs(
13857 &mut threaded_ref_ids,
13858 &mut threaded_by_caller,
13859 dependent_refs,
13860 );
13861 let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
13862 .expect("old per-ref caller lookup");
13863
13864 assert_eq!(threaded_ref_ids, selected_ref_ids);
13865 assert_eq!(threaded_by_caller, old_by_caller);
13866 for consumer in [
13867 "src/consumer_a.ts",
13868 "src/consumer_b.ts",
13869 "src/consumer_c.ts",
13870 ] {
13871 assert!(
13872 threaded_by_caller.contains_key(consumer),
13873 "barrel edit should select dependent refs from {consumer}"
13874 );
13875 }
13876 }
13877
13878 fs::write(
13879 &index_path,
13880 "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
13881 )
13882 .expect("edit barrel");
13883 let stats = store
13884 .refresh_files(std::slice::from_ref(&index_path))
13885 .expect("incremental refresh");
13886 assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
13887 assert!(
13888 stats.dependency_selected_refs > 0,
13889 "barrel surface edit should select dependent refs"
13890 );
13891
13892 let cold_store = CallGraphStore::open(
13893 project_root.join(".store-cold-barrel"),
13894 project_root.to_path_buf(),
13895 )
13896 .expect("open cold rebuild store");
13897 cold_store
13898 .cold_build(&files)
13899 .expect("comparison cold build");
13900
13901 for table in [
13902 "nodes",
13903 "refs",
13904 "file_dependencies",
13905 "edges",
13906 "dispatch_hints",
13907 ] {
13908 assert_eq!(
13909 graph_table_rows(&store, table),
13910 graph_table_rows(&cold_store, table),
13911 "incremental refresh {table} rows must match cold rebuild"
13912 );
13913 }
13914
13915 let consumer_path = project_root.join("src/consumer_a.ts");
13916 fs::write(
13917 &consumer_path,
13918 "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
13919 )
13920 .expect("edit barrel consumer");
13921 store
13922 .refresh_files(std::slice::from_ref(&consumer_path))
13923 .expect("refresh consumer through unchanged barrel");
13924 cold_store
13925 .cold_build(&files)
13926 .expect("comparison cold rebuild after consumer refresh");
13927 for table in [
13928 "nodes",
13929 "refs",
13930 "file_dependencies",
13931 "edges",
13932 "dispatch_hints",
13933 ] {
13934 assert_eq!(
13935 graph_table_rows(&store, table),
13936 graph_table_rows(&cold_store, table),
13937 "refresh through a persisted barrel must preserve cold-build {table} rows"
13938 );
13939 }
13940 }
13941
13942 fn build_reference_connection(
13943 project_root: &Path,
13944 extract: &FileExtract,
13945 resolved: &ResolvedRef,
13946 ) -> Connection {
13947 let mut conn = Connection::open_in_memory().expect("open reference db");
13948 configure_build_connection(&conn).expect("configure reference db");
13949 initialize_schema(&conn).expect("initialize reference schema");
13950 {
13951 let tx = conn.transaction().expect("reference transaction");
13952 clear_tables(&tx).expect("reference clear");
13953 insert_meta(&tx).expect("reference meta");
13954 insert_file_extract(&tx, project_root, extract).expect("reference file extract");
13955 insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
13956 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
13957 .expect("reference dispatch edges");
13958 assert_eq!(supplemental, 0);
13959 tx.commit().expect("reference commit");
13960 }
13961 conn
13962 }
13963
13964 fn build_optimized_connection(
13965 project_root: &Path,
13966 extract: &FileExtract,
13967 resolved: &ResolvedRef,
13968 ) -> Connection {
13969 let mut conn = Connection::open_in_memory().expect("open optimized db");
13970 configure_build_connection(&conn).expect("configure optimized db");
13971 initialize_schema(&conn).expect("initialize optimized schema");
13972 {
13973 let tx = conn.transaction().expect("optimized transaction");
13974 clear_tables(&tx).expect("optimized clear");
13975 insert_meta(&tx).expect("optimized meta");
13976 drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
13977 {
13978 let workspace_root = project_root.display().to_string();
13979 let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
13980 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
13981 .expect("optimized file extract");
13982 insert_resolved_ref_prepared(&mut inserts, resolved)
13983 .expect("optimized resolved ref");
13984 }
13985 create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
13986 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
13987 .expect("optimized dispatch edges");
13988 assert_eq!(supplemental, 0);
13989 tx.commit().expect("optimized commit");
13990 }
13991 conn
13992 }
13993
13994 fn fixture_extract(_project_root: &Path) -> FileExtract {
13995 let rel_path = "src/main.ts".to_string();
13996 let target_path = "src/helper.ts".to_string();
13997 let node = NodeRecord {
13998 id: "node-main".to_string(),
13999 file_path: rel_path.clone(),
14000 name: "main".to_string(),
14001 scoped_name: "main".to_string(),
14002 kind: "function".to_string(),
14003 range: Range {
14004 start_line: 0,
14005 start_col: 0,
14006 end_line: 0,
14007 end_col: 32,
14008 },
14009 range_ordinal: 0,
14010 signature: Some("export function main()".to_string()),
14011 exported: true,
14012 is_default_export: false,
14013 is_type_like: false,
14014 is_callgraph_entry_point: true,
14015 };
14016 let mut dependencies = BTreeSet::new();
14017 dependencies.insert(target_path.clone());
14018 let raw_ref = RawRef {
14019 ref_id: "ref-main-helper".to_string(),
14020 caller_node: Some(node.id.clone()),
14021 caller_symbol: Some(node.scoped_name.clone()),
14022 caller_file: rel_path.clone(),
14023 kind: "call".to_string(),
14024 short_name: Some("helper".to_string()),
14025 full_ref: Some("helper".to_string()),
14026 module_path: None,
14027 import_kind: None,
14028 local_name: Some("helper".to_string()),
14029 requested_name: Some("helper".to_string()),
14030 namespace_alias: None,
14031 wildcard: false,
14032 line: 1,
14033 byte_start: 24,
14034 byte_end: 32,
14035 dependencies,
14036 };
14037 FileExtract {
14038 rel_path,
14039 freshness: FileFreshness {
14040 mtime: UNIX_EPOCH + Duration::from_secs(123),
14041 size: 40,
14042 content_hash: cache_freshness::hash_bytes(b"fixture source"),
14043 },
14044 lang: LangId::TypeScript,
14045 data: FileCallData {
14046 calls_by_symbol: HashMap::new(),
14047 value_refs_by_symbol: HashMap::new(),
14048 exported_symbols: Vec::new(),
14049 symbol_metadata: HashMap::new(),
14050 default_export_symbol: None,
14051 import_block: ImportBlock::empty(),
14052 lang: LangId::TypeScript,
14053 },
14054 nodes: vec![node.clone()],
14055 raw_refs: vec![raw_ref],
14056 dispatch_hints: vec![DispatchHint {
14057 id: "dispatch-main-helper".to_string(),
14058 method_name: "helper".to_string(),
14059 caller_node: node.id,
14060 file: "src/main.ts".to_string(),
14061 line: 1,
14062 byte_start: 24,
14063 byte_end: 32,
14064 }],
14065 surface_fingerprint: "surface".to_string(),
14066 }
14067 }
14068
14069 fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
14070 let raw = extract.raw_refs[0].clone();
14071 let mut dependencies = raw.dependencies.clone();
14072 dependencies.insert("src/helper.ts".to_string());
14073 ResolvedRef {
14074 edge: Some(EdgeRecord {
14075 edge_id: "edge-main-helper".to_string(),
14076 source_node: raw.caller_node.clone().expect("caller node"),
14077 target_node: Some("node-helper".to_string()),
14078 target_file: "src/helper.ts".to_string(),
14079 target_symbol: "helper".to_string(),
14080 kind: "call".to_string(),
14081 line: raw.line,
14082 }),
14083 raw,
14084 status: "resolved".to_string(),
14085 target_node: Some("node-helper".to_string()),
14086 target_file: Some("src/helper.ts".to_string()),
14087 target_symbol: Some("helper".to_string()),
14088 dependencies,
14089 }
14090 }
14091
14092 fn write_chunked_equivalence_fixture(project_root: &Path) {
14093 let ts_dir = project_root.join("ts");
14094 fs::create_dir_all(&ts_dir).expect("create ts dir");
14095 fs::write(
14096 ts_dir.join("leaf.ts"),
14097 "export function leaf(value: number) {\n return value + 1;\n}\n",
14098 )
14099 .expect("write ts leaf");
14100 fs::write(
14101 ts_dir.join("mid.ts"),
14102 "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n return leaf(value);\n}\n",
14103 )
14104 .expect("write ts mid");
14105 fs::write(
14106 ts_dir.join("entry.ts"),
14107 "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n return mid(worker.run());\n}\n",
14108 )
14109 .expect("write ts entry");
14110 fs::write(
14111 ts_dir.join("worker.ts"),
14112 "export class Worker {\n run() {\n return 41;\n }\n}\n",
14113 )
14114 .expect("write ts worker");
14115 for idx in 0..4 {
14116 fs::write(
14117 ts_dir.join(format!("extra_{idx}.ts")),
14118 format!(
14119 "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n return entry(new Worker());\n}}\n"
14120 ),
14121 )
14122 .expect("write ts extra");
14123 }
14124
14125 let rust_dir = project_root.join("src");
14126 let commands_dir = rust_dir.join("commands");
14127 fs::create_dir_all(&commands_dir).expect("create rust commands dir");
14128 fs::write(
14129 rust_dir.join("context.rs"),
14130 r#"pub struct AppContext;
14131
14132impl AppContext {
14133 pub fn callgraph_store_for_ops(&self) -> usize {
14134 1
14135 }
14136}
14137"#,
14138 )
14139 .expect("write rust context");
14140 fs::write(
14141 rust_dir.join("lib.rs"),
14142 "pub mod context;\npub mod commands;\n",
14143 )
14144 .expect("write rust lib");
14145 fs::write(
14146 commands_dir.join("mod.rs"),
14147 "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
14148 )
14149 .expect("write rust commands mod");
14150 for name in ["callers", "impact", "trace_to"] {
14151 fs::write(
14152 commands_dir.join(format!("{name}.rs")),
14153 format!(
14154 r#"use crate::context::AppContext;
14155
14156pub fn handle_{name}(ctx: &AppContext) -> usize {{
14157 ctx.callgraph_store_for_ops()
14158}}
14159"#
14160 ),
14161 )
14162 .expect("write rust command");
14163 }
14164 }
14165
14166 fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
14167 let src_dir = project_root.join("src");
14168 fs::create_dir_all(&src_dir).expect("create src dir");
14169
14170 let target_path = src_dir.join("target.ts");
14171 fs::write(&target_path, "export function target() {\n return 1;\n}\n")
14172 .expect("write target");
14173
14174 let index_path = src_dir.join("index.ts");
14175 fs::write(&index_path, barrel_source).expect("write barrel");
14176
14177 let mut files = vec![target_path, index_path];
14178 for (file_name, function_name) in [
14179 ("consumer_a.ts", "consumerA"),
14180 ("consumer_b.ts", "consumerB"),
14181 ("consumer_c.ts", "consumerC"),
14182 ] {
14183 let path = src_dir.join(file_name);
14184 fs::write(
14185 &path,
14186 format!(
14187 "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n return target();\n}}\n"
14188 ),
14189 )
14190 .expect("write consumer");
14191 files.push(path);
14192 }
14193 files
14194 }
14195
14196 fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
14197 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
14198 table_rows(&conn, table)
14199 }
14200
14201 fn graph_table_rows_without(
14202 store: &CallGraphStore,
14203 table: &str,
14204 excluded_columns: &[&str],
14205 ) -> Vec<String> {
14206 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
14207 table_rows_without(&conn, table, excluded_columns)
14208 }
14209
14210 fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
14211 table_rows_without(conn, table, &[])
14212 }
14213
14214 fn table_rows_without(
14215 conn: &Connection,
14216 table: &str,
14217 excluded_columns: &[&str],
14218 ) -> Vec<String> {
14219 let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
14220 let columns: Vec<String> = conn
14221 .prepare(&format!("PRAGMA table_info({table})"))
14222 .expect("prepare table_info")
14223 .query_map([], |row| row.get::<_, String>(1))
14224 .expect("query table_info")
14225 .collect::<std::result::Result<Vec<String>, _>>()
14226 .expect("collect columns")
14227 .into_iter()
14228 .filter(|column| !excluded_columns.contains(column.as_str()))
14229 .collect();
14230 let sql = format!(
14231 "SELECT {} FROM {table} ORDER BY {}",
14232 columns.join(", "),
14233 columns.join(", ")
14234 );
14235 conn.prepare(&sql)
14236 .expect("prepare table rows")
14237 .query_map([], |row| row_to_strings(row, columns.len()))
14238 .expect("query table rows")
14239 .collect::<std::result::Result<_, _>>()
14240 .expect("collect table rows")
14241 }
14242
14243 fn assert_cold_build_stats_match_except_elapsed(
14244 expected: &ColdBuildStats,
14245 actual: &ColdBuildStats,
14246 ) {
14247 assert_eq!(actual.files, expected.files, "file counts must match");
14248 assert_eq!(actual.nodes, expected.nodes, "node counts must match");
14249 assert_eq!(actual.refs, expected.refs, "ref counts must match");
14250 assert_eq!(actual.edges, expected.edges, "edge counts must match");
14251 assert_eq!(
14252 actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
14253 expected
14254 .failed_files
14255 .iter()
14256 .cloned()
14257 .collect::<BTreeSet<_>>(),
14258 "failed file sets must match"
14259 );
14260 }
14261
14262 fn backend_state_rows(conn: &Connection) -> Vec<String> {
14263 conn.prepare(
14264 "SELECT backend, workspace_root, file_path, content_hash, status
14265 FROM backend_file_state
14266 ORDER BY backend, workspace_root, file_path, content_hash, status",
14267 )
14268 .expect("prepare backend rows")
14269 .query_map([], |row| row_to_strings(row, 5))
14270 .expect("query backend rows")
14271 .collect::<std::result::Result<_, _>>()
14272 .expect("collect backend rows")
14273 }
14274
14275 fn secondary_indexes(conn: &Connection) -> Vec<String> {
14276 let mut indexes = Vec::new();
14277 for table in [
14278 "files",
14279 "nodes",
14280 "refs",
14281 "file_dependencies",
14282 "edges",
14283 "dispatch_hints",
14284 "type_ref_names",
14285 "backend_file_state",
14286 "meta",
14287 ] {
14288 let sql = format!("PRAGMA index_list({table})");
14289 let mut stmt = conn.prepare(&sql).expect("prepare index list");
14290 let rows = stmt
14291 .query_map([], |row| row.get::<_, String>(1))
14292 .expect("query index list");
14293 for name in rows {
14294 let name = name.expect("index name");
14295 if name.starts_with("idx_") {
14296 indexes.push(format!("{table}:{name}"));
14297 }
14298 }
14299 }
14300 indexes.sort();
14301 indexes
14302 }
14303
14304 fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
14305 let mut values = Vec::with_capacity(len);
14306 for index in 0..len {
14307 let value = row.get_ref(index)?;
14308 values.push(match value {
14309 rusqlite::types::ValueRef::Null => "NULL".to_string(),
14310 rusqlite::types::ValueRef::Integer(value) => value.to_string(),
14311 rusqlite::types::ValueRef::Real(value) => value.to_string(),
14312 rusqlite::types::ValueRef::Text(value) => {
14313 String::from_utf8_lossy(value).into_owned()
14314 }
14315 rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
14316 });
14317 }
14318 Ok(values.join("\u{1f}"))
14319 }
14320}
14321
14322#[cfg(test)]
14323mod rust_resolution_tests {
14324 use super::*;
14325 use crate::inspect::job::CallgraphSnapshot;
14326 use std::fs;
14327 use tempfile::tempdir;
14328
14329 #[test]
14330 fn rust_function_scoped_module_alias_resolves_and_projects_live() {
14331 let dir = tempdir().expect("tempdir");
14332 let root = dir.path();
14333 write_rust_manifest(root, "scoped-alias-fixture");
14334 write_file(
14335 root,
14336 "src/lib.rs",
14337 r#"pub mod finalization_contract;
14338
14339pub fn run_alias() {
14340 use crate::finalization_contract as fc;
14341 fc::check_mason_contract();
14342}
14343"#,
14344 );
14345 write_file(
14346 root,
14347 "src/finalization_contract.rs",
14348 r#"pub fn check_mason_contract() {}
14349fn planted_dead() {}
14350"#,
14351 );
14352
14353 let (store, snapshot) = cold_build_twice(root);
14354 assert_direct_caller(
14355 &store,
14356 "src/finalization_contract.rs",
14357 "check_mason_contract",
14358 "src/lib.rs",
14359 "run_alias",
14360 );
14361 assert_projected_call(
14362 root,
14363 &snapshot,
14364 "src/finalization_contract.rs",
14365 "check_mason_contract",
14366 );
14367 assert_no_projected_call(
14368 root,
14369 &snapshot,
14370 "src/finalization_contract.rs",
14371 "planted_dead",
14372 );
14373 assert!(
14374 store
14375 .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
14376 .expect("planted dead callers")
14377 .is_empty(),
14378 "planted-dead guard should stay without callers"
14379 );
14380 }
14381
14382 #[test]
14383 fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
14384 let dir = tempdir().expect("tempdir");
14385 let root = dir.path();
14386 write_rust_manifest(root, "inline-module-fixture");
14387 write_file(
14388 root,
14389 "src/lib.rs",
14390 r#"mod work_graph { fn operations() {} }
14391mod manifest { fn operations() {} }
14392mod audit { fn operations() {} }
14393mod dispatch { fn operations() {} }
14394mod finalization { fn operations() {} }
14395
14396pub fn run_inline_operations() {
14397 work_graph::operations();
14398 manifest::operations();
14399 audit::operations();
14400 dispatch::operations();
14401 finalization::operations();
14402}
14403
14404fn planted_dead() {}
14405"#,
14406 );
14407
14408 let (store, snapshot) = cold_build_twice(root);
14409 for module in [
14410 "work_graph",
14411 "manifest",
14412 "audit",
14413 "dispatch",
14414 "finalization",
14415 ] {
14416 assert_direct_caller(
14417 &store,
14418 "src/lib.rs",
14419 &format!("{module}::operations"),
14420 "src/lib.rs",
14421 "run_inline_operations",
14422 );
14423 }
14424 assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
14425 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
14426 }
14427
14428 #[test]
14429 fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
14430 let dir = tempdir().expect("tempdir");
14431 let root = dir.path();
14432 fs::write(
14433 root.join("Cargo.toml"),
14434 "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
14435 )
14436 .expect("write workspace manifest");
14437 write_file(
14438 root,
14439 "crates/but-action/Cargo.toml",
14440 r#"[package]
14441name = "but-action"
14442version = "0.1.0"
14443edition = "2021"
14444"#,
14445 );
14446 write_file(
14447 root,
14448 "crates/but-action/src/lib.rs",
14449 "mod action;\npub use action::{list_actions};\n",
14450 );
14451 write_file(
14452 root,
14453 "crates/but-action/src/action.rs",
14454 "pub fn list_actions() {}\nfn planted_dead() {}\n",
14455 );
14456 write_file(
14457 root,
14458 "crates/app/Cargo.toml",
14459 r#"[package]
14460name = "app"
14461version = "0.1.0"
14462edition = "2021"
14463"#,
14464 );
14465 write_file(
14466 root,
14467 "crates/app/src/lib.rs",
14468 "pub fn run_actions() {\n but_action::list_actions();\n}\n",
14469 );
14470
14471 let (store, snapshot) = cold_build_twice(root);
14472 assert_direct_caller(
14473 &store,
14474 "crates/but-action/src/action.rs",
14475 "list_actions",
14476 "crates/app/src/lib.rs",
14477 "run_actions",
14478 );
14479 assert!(
14480 store
14481 .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
14482 .expect("lib reexport callers")
14483 .is_empty(),
14484 "call should target the reexported source function, not lib.rs"
14485 );
14486 assert_projected_call(
14487 root,
14488 &snapshot,
14489 "crates/but-action/src/action.rs",
14490 "list_actions",
14491 );
14492 assert_no_projected_call(
14493 root,
14494 &snapshot,
14495 "crates/but-action/src/action.rs",
14496 "planted_dead",
14497 );
14498 }
14499
14500 #[test]
14501 fn rust_generic_self_turbofish_method_dispatch_resolves() {
14502 let dir = tempdir().expect("tempdir");
14503 let root = dir.path();
14504 write_rust_manifest(root, "generic-self-fixture");
14505 write_file(
14506 root,
14507 "src/lib.rs",
14508 r#"pub struct Matcher;
14509
14510impl Matcher {
14511 pub fn run(&self) -> bool {
14512 self.fuzzy_match_optimal::<usize>("needle")
14513 }
14514
14515 fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
14516 let _ = std::marker::PhantomData::<T>;
14517 true
14518 }
14519
14520 fn planted_dead(&self) {}
14521}
14522
14523pub fn entry() -> bool {
14524 let matcher = Matcher;
14525 matcher.run()
14526}
14527"#,
14528 );
14529
14530 let (store, snapshot) = cold_build_twice(root);
14531 assert_direct_caller(
14532 &store,
14533 "src/lib.rs",
14534 "Matcher::fuzzy_match_optimal",
14535 "src/lib.rs",
14536 "Matcher::run",
14537 );
14538 assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
14539 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
14540 }
14541
14542 #[test]
14543 fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
14544 let dir = tempdir().expect("tempdir");
14545 let root = dir.path();
14546 write_rust_manifest(root, "manifest-operations-fixture");
14547 write_file(
14548 root,
14549 "src/main.rs",
14550 r#"mod dispatch;
14551use dispatch::{manifest_operations};
14552
14553fn main() {
14554 manifest_operations();
14555}
14556"#,
14557 );
14558 write_file(
14559 root,
14560 "src/dispatch.rs",
14561 r#"mod work_graph { fn operations() {} }
14562mod manifest { fn operations() {} }
14563mod audit { fn operations() {} }
14564mod descriptor { fn operations() {} }
14565mod writer { fn operations() {} }
14566
14567pub fn manifest_operations() {
14568 manifest::operations();
14569}
14570
14571pub fn work_graph_operations() {
14572 work_graph::operations();
14573}
14574
14575pub fn audit_operations() {
14576 audit::operations();
14577}
14578
14579pub fn descriptor_operations() {
14580 descriptor::operations();
14581}
14582
14583pub fn writer_operations() {
14584 writer::operations();
14585}
14586
14587fn planted_dead() {}
14588"#,
14589 );
14590
14591 let (store, snapshot) = cold_build_twice(root);
14592 assert_direct_caller(
14593 &store,
14594 "src/dispatch.rs",
14595 "manifest_operations",
14596 "src/main.rs",
14597 "main",
14598 );
14599 assert_direct_caller(
14600 &store,
14601 "src/dispatch.rs",
14602 "manifest::operations",
14603 "src/dispatch.rs",
14604 "manifest_operations",
14605 );
14606 assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
14607 assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
14608 assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
14609 }
14610
14611 fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
14612 let files = rust_files(root);
14613 let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
14614 .expect("open first store");
14615 first.cold_build(&files).expect("first cold build");
14616 let first_snapshot =
14617 project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
14618
14619 let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
14620 .expect("open second store");
14621 second.cold_build(&files).expect("second cold build");
14622 let second_snapshot =
14623 project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
14624
14625 assert_eq!(
14626 projection_rows(&first_snapshot),
14627 projection_rows(&second_snapshot),
14628 "cold-build projection should be deterministic"
14629 );
14630 (first, first_snapshot)
14631 }
14632
14633 fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
14634 let mut rows = Vec::new();
14635 for export in &snapshot.exported_symbols {
14636 rows.push(format!(
14637 "export\t{}\t{}\t{}\t{}",
14638 export.file.display(),
14639 export.symbol,
14640 export.kind,
14641 export.line
14642 ));
14643 }
14644 for call in &snapshot.outbound_calls {
14645 rows.push(format!(
14646 "call\t{}\t{}\t{}\t{}\t{}",
14647 call.caller_file.display(),
14648 call.caller_symbol,
14649 call.target,
14650 call.line,
14651 call.provenance
14652 ));
14653 }
14654 for file in &snapshot.entry_points {
14655 rows.push(format!("entry_file\t{}", file.display()));
14656 }
14657 for (file, symbols) in &snapshot.entry_point_symbols {
14658 for symbol in symbols {
14659 rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
14660 }
14661 }
14662 rows.sort();
14663 rows
14664 }
14665
14666 fn assert_direct_caller(
14667 store: &CallGraphStore,
14668 target_rel: &str,
14669 target_symbol: &str,
14670 caller_rel: &str,
14671 caller_symbol: &str,
14672 ) {
14673 let callers = store
14674 .direct_callers_of(Path::new(target_rel), target_symbol)
14675 .unwrap_or_else(|error| {
14676 panic!("direct callers for {target_rel}::{target_symbol}: {error}")
14677 });
14678 assert!(
14679 callers.iter().any(|site| {
14680 site.caller.file == caller_rel && site.caller.symbol == caller_symbol
14681 }),
14682 "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
14683 );
14684 }
14685
14686 fn assert_projected_call(
14687 root: &Path,
14688 snapshot: &CallgraphSnapshot,
14689 target_rel: &str,
14690 symbol: &str,
14691 ) {
14692 let target = projected_target(root, target_rel, symbol);
14693 assert!(
14694 snapshot.outbound_calls.iter().any(|call| {
14695 call.target == target
14696 || call.target.starts_with(&format!(
14697 "{target}{}",
14698 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
14699 ))
14700 }),
14701 "expected projected call to {target}; calls: {:#?}",
14702 snapshot.outbound_calls
14703 );
14704 }
14705
14706 fn assert_no_projected_call(
14707 root: &Path,
14708 snapshot: &CallgraphSnapshot,
14709 target_rel: &str,
14710 symbol: &str,
14711 ) {
14712 let target = projected_target(root, target_rel, symbol);
14713 assert!(
14714 snapshot.outbound_calls.iter().all(|call| {
14715 call.target != target
14716 && !call.target.starts_with(&format!(
14717 "{target}{}",
14718 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
14719 ))
14720 }),
14721 "did not expect projected call to {target}; calls: {:#?}",
14722 snapshot.outbound_calls
14723 );
14724 }
14725
14726 fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
14727 let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
14730 format!("{}::{symbol}", path.display())
14731 }
14732
14733 fn write_rust_manifest(root: &Path, name: &str) {
14734 write_file(
14735 root,
14736 "Cargo.toml",
14737 &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
14738 );
14739 }
14740
14741 fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
14742 let path = root.join(rel_path);
14743 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
14744 fs::write(&path, source).expect("write fixture file");
14745 path
14746 }
14747
14748 fn rust_files(root: &Path) -> Vec<PathBuf> {
14749 let mut files = Vec::new();
14750 collect_rust_files(root, &mut files);
14751 files.sort();
14752 files
14753 }
14754
14755 fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
14756 for entry in fs::read_dir(dir).expect("read fixture dir") {
14757 let entry = entry.expect("read fixture entry");
14758 let path = entry.path();
14759 if path.is_dir() {
14760 let name = path
14761 .file_name()
14762 .and_then(|name| name.to_str())
14763 .unwrap_or("");
14764 if !name.starts_with(".store") {
14765 collect_rust_files(&path, files);
14766 }
14767 } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
14768 files.push(path);
14769 }
14770 }
14771 }
14772}
14773
14774#[cfg(test)]
14775mod build_pool_tests {
14776 use super::build_pool_size;
14777
14778 #[test]
14779 fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
14780 let size = build_pool_size();
14781 assert!(size >= 1, "pool size must be at least 1");
14784 assert!(size <= 8, "pool size must be capped at 8, got {size}");
14785
14786 let cores = std::thread::available_parallelism()
14787 .map(|p| p.get())
14788 .unwrap_or(1);
14789 let expected = cores.div_ceil(2).clamp(1, 8);
14790 assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
14791 }
14792}
14793
14794#[cfg(test)]
14795mod reexport_resolution_tests {
14796 use super::*;
14797
14798 fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
14799 ProjectIndex {
14800 project_root: PathBuf::from("/fixture"),
14801 files: files.into_iter().collect(),
14802 caller_data: HashMap::new(),
14803 workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
14804 }
14805 }
14806
14807 fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
14808 DbFileIndex {
14809 lang: None,
14810 exports: HashSet::new(),
14811 default_export: None,
14812 export_aliases: HashMap::new(),
14813 node_by_scoped: HashMap::new(),
14814 node_by_bare: HashMap::new(),
14815 node_kind_by_id: HashMap::new(),
14816 module_targets: HashMap::new(),
14817 reexports: reexport_targets
14818 .iter()
14819 .map(|target| ReexportIndex {
14820 target_file: Some((*target).to_string()),
14821 named: HashMap::new(),
14822 wildcard: true,
14823 })
14824 .collect(),
14825 }
14826 }
14827
14828 #[test]
14835 fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
14836 let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
14837 let files = names
14838 .iter()
14839 .map(|name| {
14840 let targets: Vec<&str> = names
14841 .iter()
14842 .filter(|other| *other != name)
14843 .map(String::as_str)
14844 .collect();
14845 (name.clone(), barrel_file(&targets))
14846 })
14847 .collect();
14848 let index = barrel_index(files);
14849
14850 assert_eq!(
14851 resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
14852 None
14853 );
14854 }
14855
14856 #[test]
14862 fn shallow_revisit_after_deep_capped_visit_still_resolves() {
14863 let mut leaf = barrel_file(&[]);
14864 leaf.exports.insert("deep_symbol".to_string());
14865 let mut files: Vec<(String, DbFileIndex)> = Vec::new();
14866 files.push((
14869 "src/entry.ts".to_string(),
14870 barrel_file(&["src/chain0.ts", "src/shared.ts"]),
14871 ));
14872 for i in 0..15 {
14873 let next = if i == 14 {
14874 "src/shared.ts".to_string()
14875 } else {
14876 format!("src/chain{}.ts", i + 1)
14877 };
14878 files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
14879 }
14880 files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
14881 files.push(("src/leaf.ts".to_string(), leaf));
14882 let index = barrel_index(files);
14883
14884 assert_eq!(
14885 resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
14886 Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
14887 "a shallower re-visit must not be pruned by a deeper capped visit"
14888 );
14889 }
14890
14891 #[test]
14892 fn symbol_reachable_through_reexport_cycle_still_resolves() {
14893 let mut leaf = barrel_file(&[]);
14894 leaf.exports.insert("real_symbol".to_string());
14895 let index = barrel_index(vec![
14896 (
14897 "src/a.ts".to_string(),
14898 barrel_file(&["src/b.ts", "src/a.ts"]),
14899 ),
14900 (
14901 "src/b.ts".to_string(),
14902 barrel_file(&["src/a.ts", "src/leaf.ts"]),
14903 ),
14904 ("src/leaf.ts".to_string(), leaf),
14905 ]);
14906
14907 assert_eq!(
14908 resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
14909 Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
14910 );
14911 }
14912}
14913
14914#[cfg(test)]
14915mod method_dispatch_inference_tests {
14916 use super::*;
14917 use std::fs;
14918 use tempfile::tempdir;
14919
14920 #[test]
14921 fn java_field_receiver_type_selects_declared_class_method() {
14922 let source = r#"class EntryPoint {
14923 private UserService userService;
14924
14925 void handle() {
14926 userService.find();
14927 }
14928}
14929
14930class UserService {
14931 void find() {}
14932}
14933
14934class AuditService {
14935 void find() {}
14936}
14937"#;
14938 let dir = tempdir().expect("temp dir");
14939 let root = dir.path();
14940 write_fixture(root, "src/EntryPoint.java", source);
14941 let reference = reference(
14942 "java",
14943 "src/EntryPoint.java",
14944 "EntryPoint::handle",
14945 "userService",
14946 "find",
14947 line_of(source, "userService.find()"),
14948 );
14949 let mut cache = DispatchSourceCache::new();
14950
14951 let receiver_type =
14952 infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
14953 assert_eq!(receiver_type, "UserService");
14954
14955 let candidates = vec![
14956 method_candidate("audit", "AuditService::find"),
14957 method_candidate("user", "UserService::find"),
14958 ];
14959 let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
14960 .expect("type candidate");
14961 assert_eq!(selected.scoped_name, "UserService::find");
14962
14963 let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
14964 assert!(
14965 select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
14966 );
14967 }
14968
14969 #[test]
14970 fn kotlin_property_and_local_value_types_are_inferred() {
14971 let source = r#"class Handler {
14972 private val auditService: AuditService = AuditService()
14973
14974 fun handle() {
14975 auditService.find()
14976 val userService: UserService = UserService()
14977 userService.find()
14978 val billingService = BillingService()
14979 billingService.find()
14980 }
14981}
14982
14983class UserService { fun find() {} }
14984class AuditService { fun find() {} }
14985class BillingService { fun find() {} }
14986"#;
14987 let dir = tempdir().expect("temp dir");
14988 let root = dir.path();
14989 write_fixture(root, "src/Handler.kt", source);
14990 let mut cache = DispatchSourceCache::new();
14991
14992 let audit_ref = reference(
14993 "kotlin",
14994 "src/Handler.kt",
14995 "Handler::handle",
14996 "auditService",
14997 "find",
14998 line_of(source, "auditService.find()"),
14999 );
15000 assert_eq!(
15001 infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
15002 Some("AuditService")
15003 );
15004
15005 let user_ref = reference(
15006 "kotlin",
15007 "src/Handler.kt",
15008 "Handler::handle",
15009 "userService",
15010 "find",
15011 line_of(source, "userService.find()"),
15012 );
15013 assert_eq!(
15014 infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
15015 Some("UserService")
15016 );
15017
15018 let billing_ref = reference(
15019 "kotlin",
15020 "src/Handler.kt",
15021 "Handler::handle",
15022 "billingService",
15023 "find",
15024 line_of(source, "billingService.find()"),
15025 );
15026 assert_eq!(
15027 infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
15028 Some("BillingService")
15029 );
15030 }
15031
15032 #[test]
15033 fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
15034 let source = r#"struct Foo { void run(); };
15035struct PointerFoo { void run(); };
15036struct FactoryFoo { void run(); };
15037FactoryFoo makeFactoryFoo();
15038
15039void handle() {
15040 Foo foo;
15041 foo.run();
15042 PointerFoo* pointerFoo = nullptr;
15043 pointerFoo->run();
15044 auto factoryFoo = makeFactoryFoo();
15045 factoryFoo.run();
15046}
15047"#;
15048 let dir = tempdir().expect("temp dir");
15049 let root = dir.path();
15050 write_fixture(root, "src/fixture.cpp", source);
15051 let mut cache = DispatchSourceCache::new();
15052
15053 let foo_ref = reference(
15054 "cpp",
15055 "src/fixture.cpp",
15056 "handle",
15057 "foo",
15058 "run",
15059 line_of(source, "foo.run()"),
15060 );
15061 assert_eq!(
15062 infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
15063 Some("Foo")
15064 );
15065
15066 let pointer_ref = reference(
15067 "cpp",
15068 "src/fixture.cpp",
15069 "handle",
15070 "pointerFoo",
15071 "run",
15072 line_of(source, "pointerFoo->run()"),
15073 );
15074 assert_eq!(
15075 infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
15076 Some("PointerFoo")
15077 );
15078
15079 let factory_ref = reference(
15080 "cpp",
15081 "src/fixture.cpp",
15082 "handle",
15083 "factoryFoo",
15084 "run",
15085 line_of(source, "factoryFoo.run()"),
15086 );
15087 assert_eq!(
15088 infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
15089 Some("FactoryFoo")
15090 );
15091 }
15092
15093 #[test]
15094 fn rust_direct_self_field_name_trims_separator_whitespace() {
15095 for receiver_expression in ["self .engine", "self. engine", "self . engine"] {
15096 assert_eq!(
15097 rust_direct_self_field_name(receiver_expression),
15098 Some("engine")
15099 );
15100 }
15101 }
15102
15103 #[test]
15104 fn rust_direct_self_field_receiver_type_is_conservative() {
15105 let source = r#"struct Engine;
15106
15107struct Car {
15108 engine: Engine,
15109}
15110
15111impl Car {
15112 fn run(&self) {
15113 self.engine.start();
15114 }
15115}
15116
15117struct NestedCar {
15118 engine: Engine,
15119}
15120
15121impl NestedCar {
15122 fn run(&self) {
15123 self.inner.engine.start();
15124 }
15125}
15126
15127struct WrappedCar {
15128 engine: Option<Engine>,
15129}
15130
15131impl WrappedCar {
15132 fn run(&self) {
15133 self.engine.start(); // wrapped
15134 }
15135}
15136
15137struct GenericCar<T> {
15138 engine: T,
15139}
15140
15141impl<T> GenericCar<T> {
15142 fn run(&self) {
15143 self.engine.start(); // generic
15144 }
15145}
15146
15147type EngineAlias = Engine;
15148
15149struct AliasCar {
15150 engine: EngineAlias,
15151}
15152
15153impl AliasCar {
15154 fn run(&self) {
15155 self.engine.start(); // alias
15156 }
15157}
15158"#;
15159 let dir = tempdir().expect("temp dir");
15160 let root = dir.path();
15161 write_fixture(root, "src/lib.rs", source);
15162 let mut cache = DispatchSourceCache::new();
15163
15164 let mut direct = reference(
15165 "rust",
15166 "src/lib.rs",
15167 "Car::run",
15168 "engine",
15169 "start",
15170 line_of(source, "self.engine.start()"),
15171 );
15172 direct.receiver_expression = "self.engine".to_string();
15173 assert_eq!(
15174 infer_receiver_type(root, &direct, &mut cache).as_deref(),
15175 Some("Engine")
15176 );
15177
15178 let mut mismatched_impl_target = direct.clone();
15179 mismatched_impl_target.caller_symbol = "other::Car::run".to_string();
15180 assert!(infer_receiver_type(root, &mismatched_impl_target, &mut cache).is_none());
15181
15182 let mut nested = reference(
15183 "rust",
15184 "src/lib.rs",
15185 "NestedCar::run",
15186 "engine",
15187 "start",
15188 line_of(source, "self.inner.engine.start()"),
15189 );
15190 nested.receiver_expression = "self.inner.engine".to_string();
15191 assert!(infer_receiver_type(root, &nested, &mut cache).is_none());
15192
15193 let mut wrapped = reference(
15194 "rust",
15195 "src/lib.rs",
15196 "WrappedCar::run",
15197 "engine",
15198 "start",
15199 line_of(source, "self.engine.start(); // wrapped"),
15200 );
15201 wrapped.receiver_expression = "self.engine".to_string();
15202 assert!(infer_receiver_type(root, &wrapped, &mut cache).is_none());
15203
15204 let mut generic = reference(
15205 "rust",
15206 "src/lib.rs",
15207 "GenericCar::run",
15208 "engine",
15209 "start",
15210 line_of(source, "self.engine.start(); // generic"),
15211 );
15212 generic.receiver_expression = "self.engine".to_string();
15213 assert!(infer_receiver_type(root, &generic, &mut cache).is_none());
15214
15215 let mut alias = reference(
15216 "rust",
15217 "src/lib.rs",
15218 "AliasCar::run",
15219 "engine",
15220 "start",
15221 line_of(source, "self.engine.start(); // alias"),
15222 );
15223 alias.receiver_expression = "self.engine".to_string();
15224 assert!(infer_receiver_type(root, &alias, &mut cache).is_none());
15225 }
15226
15227 #[test]
15228 fn rust_direct_self_reference_field_receiver_is_not_inferred() {
15229 let source = r#"struct Engine;
15230
15231struct Car {
15232 engine: &'static Engine,
15233}
15234
15235impl Car {
15236 fn run(&self) {
15237 self.engine.start();
15238 }
15239}
15240"#;
15241 let dir = tempdir().expect("temp dir");
15242 let root = dir.path();
15243 write_fixture(root, "src/lib.rs", source);
15244 let mut cache = DispatchSourceCache::new();
15245 let mut reference = reference(
15246 "rust",
15247 "src/lib.rs",
15248 "Car::run",
15249 "engine",
15250 "start",
15251 line_of(source, "self.engine.start()"),
15252 );
15253 reference.receiver_expression = "self.engine".to_string();
15254
15255 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15256 }
15257
15258 #[test]
15259 fn rust_trait_impl_self_field_receiver_is_not_inferred() {
15260 let source = r#"trait Drive {
15261 fn run(&self);
15262}
15263
15264struct Engine;
15265
15266struct Car {
15267 engine: Engine,
15268}
15269
15270impl Drive for Car {
15271 fn run(&self) {
15272 self.engine.start();
15273 }
15274}
15275"#;
15276 let dir = tempdir().expect("temp dir");
15277 let root = dir.path();
15278 write_fixture(root, "src/lib.rs", source);
15279 let mut cache = DispatchSourceCache::new();
15280 let mut reference = reference(
15281 "rust",
15282 "src/lib.rs",
15283 "Car::run",
15284 "engine",
15285 "start",
15286 line_of(source, "self.engine.start()"),
15287 );
15288 reference.receiver_expression = "self.engine".to_string();
15289
15290 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15291 }
15292
15293 #[test]
15294 fn rust_self_field_does_not_bind_struct_from_another_module() {
15295 let source = r#"struct Engine;
15296
15297mod unrelated {
15298 struct Car {
15299 engine: Engine,
15300 }
15301}
15302
15303impl Car {
15304 fn run(&self) {
15305 self.engine.start();
15306 }
15307}
15308"#;
15309 let dir = tempdir().expect("temp dir");
15310 let root = dir.path();
15311 write_fixture(root, "src/lib.rs", source);
15312 let mut cache = DispatchSourceCache::new();
15313 let mut reference = reference(
15314 "rust",
15315 "src/lib.rs",
15316 "Car::run",
15317 "engine",
15318 "start",
15319 line_of(source, "self.engine.start()"),
15320 );
15321 reference.receiver_expression = "self.engine".to_string();
15322
15323 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15324 }
15325
15326 #[test]
15327 fn unknown_java_receiver_still_uses_name_match_fallback() {
15328 let source = r#"class EntryPoint {
15329 void handle() {
15330 service.runSpecial();
15331 }
15332}
15333
15334class OnlyService {
15335 void runSpecial() {}
15336}
15337"#;
15338 let dir = tempdir().expect("temp dir");
15339 let root = dir.path();
15340 write_fixture(root, "src/EntryPoint.java", source);
15341 let reference = reference(
15342 "java",
15343 "src/EntryPoint.java",
15344 "EntryPoint::handle",
15345 "service",
15346 "runSpecial",
15347 line_of(source, "service.runSpecial()"),
15348 );
15349 let mut cache = DispatchSourceCache::new();
15350
15351 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15352 let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
15353 let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
15354 assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
15355 }
15356
15357 fn reference(
15358 lang: &str,
15359 caller_file: &str,
15360 caller_symbol: &str,
15361 receiver: &str,
15362 method_name: &str,
15363 line: u32,
15364 ) -> NameMatchRef {
15365 NameMatchRef {
15366 ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
15367 caller_node: format!("{caller_symbol}:node"),
15368 caller_file: caller_file.to_string(),
15369 caller_symbol: caller_symbol.to_string(),
15370 caller_signature: None,
15371 receiver_expression: receiver.to_string(),
15372 receiver: receiver.to_string(),
15373 method_name: method_name.to_string(),
15374 colon_dispatch: false,
15375 line,
15376 lang: lang.to_string(),
15377 }
15378 }
15379
15380 fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
15381 NameMatchCandidate {
15382 node_id: node_id.to_string(),
15383 file_path: "src/targets.fixture".to_string(),
15384 scoped_name: scoped_name.to_string(),
15385 kind: "method".to_string(),
15386 start_line: 1,
15387 }
15388 }
15389
15390 fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
15391 let path = root.join(rel_path);
15392 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
15393 fs::write(path, source).expect("write fixture");
15394 }
15395
15396 fn line_of(source: &str, needle: &str) -> u32 {
15397 source
15398 .lines()
15399 .position(|line| line.contains(needle))
15400 .map(|index| index as u32 + 1)
15401 .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
15402 }
15403}