1use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
9use crate::callgraph::{self, EdgeResolution, FileCallData, TraceToSymbolCandidate};
10use crate::context::SubcLifecycleAdmission;
11use crate::error::AftError;
12use crate::imports::{ImportForm, ImportGroup, ImportKind, ImportStatement};
13use crate::parser::{grammar_for, LangId};
14use crate::symbols::{Range, SymbolKind};
15use rayon::prelude::*;
16use rusqlite::{
17 params, params_from_iter, Connection, OpenFlags, OptionalExtension, Statement, Transaction,
18};
19use std::collections::{hash_map::Entry, BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
20use std::fmt;
21use std::io::Read;
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
24use std::sync::{Arc, Condvar, Mutex, OnceLock};
25use std::thread::JoinHandle;
26use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
27use tree_sitter::{Node, Parser};
28
29const SCHEMA_VERSION: i64 = 1;
30const BACKEND_TREESITTER: &str = "treesitter";
31const PROVENANCE_TREESITTER: &str = "treesitter+resolver";
32const PROVENANCE_NAME_MATCH: &str = "name_match";
33const PROVENANCE_TYPE_MATCH: &str = "type_match";
34const NAME_MATCH_SCORE_THRESHOLD: f64 = 2.0;
35const TOP_LEVEL_SYMBOL: &str = "<top-level>";
36const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
37const MIGRATION_MANIFEST_VERSION: u32 = 1;
38const MIGRATION_GENERATION_TAG: &str = ".migrated.";
39const MIGRATION_BACKUP_PAGES_PER_STEP: i32 = 128;
40const MIGRATION_BACKUP_RETRY_BUDGET: usize = 25;
41const MIGRATION_BACKUP_WALL_CLOCK_BUDGET: Duration = Duration::from_secs(10);
42const SQLITE_FILE_SET_SUFFIXES: &[&str] = &["", "-wal", "-shm", "-journal"];
43const MARKED_GENERATION_RETENTION_TTL: Duration = Duration::from_secs(6 * 60 * 60);
48const REFRESH_WORKER_WARN_AFTER: Duration = Duration::from_secs(5);
49const REFRESH_WORKER_FINAL_AFTER: Duration = Duration::from_secs(30);
50pub const REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET: Duration = Duration::from_millis(100);
51const REBUILD_COOLDOWN: Duration = Duration::from_secs(30);
52const ROOT_REPAIR_WARN_INTERVAL: Duration = Duration::from_secs(60);
53const CALLGRAPH_WRITE_METRIC_WINDOW: Duration = Duration::from_secs(60);
54const CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES: i64 = 4_000;
55const REFRESH_IDLE_CHECKPOINT_INTERVAL: Duration = Duration::from_secs(60);
56
57fn write_amplification_baseline_enabled() -> bool {
58 std::env::var_os("AFT_CALLGRAPH_WRITE_AMP_BASELINE").is_some()
59}
60
61type ColdBuildSwapObserver = dyn Fn(&Path, &Path) + Send + Sync + 'static;
62
63#[derive(Clone, Debug, Eq, Hash, PartialEq)]
64struct RebuildCooldownKey {
65 callgraph_dir: PathBuf,
66 project_key: String,
67}
68
69#[derive(Clone, Debug)]
70struct RebuildCooldownRecord {
71 project_root: PathBuf,
72 published_at: Instant,
73 cross_root_cooldown_armed: bool,
74}
75
76static SUCCESSFUL_REBUILDS: OnceLock<Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>> =
81 OnceLock::new();
82
83#[derive(Clone, Debug, Eq, Hash, PartialEq)]
84struct RootRepairWarningKey {
85 project_key: String,
86}
87
88#[derive(Clone, Debug)]
89struct RootRepairWarningRecord {
90 window_start: Instant,
91 last_emitted: Instant,
92 entry_count: u64,
93 suppressed: u64,
94}
95
96static ROOT_REPAIR_WARNINGS: OnceLock<
97 Mutex<HashMap<RootRepairWarningKey, RootRepairWarningRecord>>,
98> = OnceLock::new();
99
100#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
101pub(crate) struct CallgraphWriteMetricsSnapshot {
102 pub commits_60s: u64,
103 pub pages_or_bytes_written_60s: u64,
104}
105
106#[derive(Debug, Default)]
107struct CallgraphWriteMetrics {
108 window_start_ms: AtomicU64,
109 commits_60s: AtomicU64,
110 pages_or_bytes_written_60s: AtomicU64,
111}
112
113static CALLGRAPH_WRITE_METRICS: OnceLock<Mutex<HashMap<String, Arc<CallgraphWriteMetrics>>>> =
114 OnceLock::new();
115
116fn callgraph_write_metrics_for_key(project_key: &str) -> Arc<CallgraphWriteMetrics> {
117 let metrics = CALLGRAPH_WRITE_METRICS.get_or_init(|| Mutex::new(HashMap::new()));
118 let mut metrics = metrics
119 .lock()
120 .expect("callgraph write metrics mutex poisoned");
121 Arc::clone(
122 metrics
123 .entry(project_key.to_string())
124 .or_insert_with(|| Arc::new(CallgraphWriteMetrics::default())),
125 )
126}
127
128fn roll_callgraph_write_metric_window(metrics: &CallgraphWriteMetrics, now_ms: u64) {
129 let current_start = metrics.window_start_ms.load(AtomicOrdering::Acquire);
130 if current_start == 0 {
131 let _ = metrics.window_start_ms.compare_exchange(
132 0,
133 now_ms,
134 AtomicOrdering::AcqRel,
135 AtomicOrdering::Acquire,
136 );
137 return;
138 }
139 if now_ms.saturating_sub(current_start) < CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64 {
140 return;
141 }
142 if metrics
143 .window_start_ms
144 .compare_exchange(
145 current_start,
146 now_ms,
147 AtomicOrdering::AcqRel,
148 AtomicOrdering::Acquire,
149 )
150 .is_ok()
151 {
152 metrics.commits_60s.store(0, AtomicOrdering::Release);
153 metrics
154 .pages_or_bytes_written_60s
155 .store(0, AtomicOrdering::Release);
156 }
157}
158
159impl CallgraphWriteMetrics {
160 fn record_commit(&self, pages_or_bytes_written: u64) {
161 let now_ms = unix_millis_now();
162 roll_callgraph_write_metric_window(self, now_ms);
163 self.commits_60s.fetch_add(1, AtomicOrdering::Relaxed);
164 self.pages_or_bytes_written_60s
165 .fetch_add(pages_or_bytes_written, AtomicOrdering::Relaxed);
166 }
167
168 fn snapshot(&self) -> CallgraphWriteMetricsSnapshot {
169 roll_callgraph_write_metric_window(self, unix_millis_now());
170 CallgraphWriteMetricsSnapshot {
171 commits_60s: self.commits_60s.load(AtomicOrdering::Acquire),
172 pages_or_bytes_written_60s: self
173 .pages_or_bytes_written_60s
174 .load(AtomicOrdering::Acquire),
175 }
176 }
177}
178
179pub(crate) fn callgraph_write_metrics_for_project(
180 project_key: &str,
181) -> CallgraphWriteMetricsSnapshot {
182 callgraph_write_metrics_for_key(project_key).snapshot()
183}
184
185pub(crate) fn callgraph_write_metrics_total() -> CallgraphWriteMetricsSnapshot {
186 let Some(metrics) = CALLGRAPH_WRITE_METRICS.get() else {
187 return CallgraphWriteMetricsSnapshot::default();
188 };
189 let metrics = metrics
190 .lock()
191 .expect("callgraph write metrics mutex poisoned");
192 metrics.values().map(|metrics| metrics.snapshot()).fold(
193 CallgraphWriteMetricsSnapshot::default(),
194 |total, current| CallgraphWriteMetricsSnapshot {
195 commits_60s: total.commits_60s.saturating_add(current.commits_60s),
196 pages_or_bytes_written_60s: total
197 .pages_or_bytes_written_60s
198 .saturating_add(current.pages_or_bytes_written_60s),
199 },
200 )
201}
202
203const ROOT_REPAIR_WARNING_TEXT: &str =
204 "callgraph store root repair requires rebuild; open-only reader reports unavailable";
205
206fn next_root_repair_warning(key: RootRepairWarningKey, now: Instant) -> Option<String> {
207 let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
208 let mut warnings = warnings.lock().ok()?;
209 let entry = warnings.entry(key);
210 let record = match entry {
211 Entry::Vacant(entry) => {
212 entry.insert(RootRepairWarningRecord {
213 window_start: now,
214 last_emitted: now,
215 entry_count: 1,
216 suppressed: 0,
217 });
218 return Some(ROOT_REPAIR_WARNING_TEXT.to_string());
219 }
220 Entry::Occupied(entry) => entry.into_mut(),
221 };
222
223 if now.saturating_duration_since(record.window_start) >= ROOT_REPAIR_WARN_INTERVAL {
224 let suppressed = record.suppressed;
225 record.window_start = now;
226 record.last_emitted = now;
227 record.entry_count = 1;
228 record.suppressed = 0;
229 return Some(if suppressed == 0 {
230 ROOT_REPAIR_WARNING_TEXT.to_string()
231 } else {
232 format!("{ROOT_REPAIR_WARNING_TEXT} (repeated {suppressed}x in 60s)")
233 });
234 }
235
236 record.entry_count = record.entry_count.saturating_add(1);
237 if now.saturating_duration_since(record.last_emitted) < ROOT_REPAIR_WARN_INTERVAL {
238 record.suppressed = record.suppressed.saturating_add(1);
239 None
240 } else {
241 record.last_emitted = now;
242 Some(ROOT_REPAIR_WARNING_TEXT.to_string())
243 }
244}
245
246pub(crate) fn note_repair_entry(project_key: &str) -> Option<String> {
247 next_root_repair_warning(
248 RootRepairWarningKey {
249 project_key: project_key.to_string(),
250 },
251 Instant::now(),
252 )
253}
254
255pub(crate) fn repair_entry_rate(project_key: &str) -> Option<(u64, Instant)> {
260 let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
261 let warnings = warnings.lock().ok()?;
262 let record = warnings.get(&RootRepairWarningKey {
263 project_key: project_key.to_string(),
264 })?;
265 (Instant::now().saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL)
266 .then_some((record.entry_count, record.window_start))
267}
268
269pub(crate) fn repair_entry_rate_total() -> u64 {
270 let Ok(warnings) = ROOT_REPAIR_WARNINGS
271 .get_or_init(|| Mutex::new(HashMap::new()))
272 .lock()
273 else {
274 return 0;
275 };
276 let now = Instant::now();
277 warnings
278 .values()
279 .filter(|record| {
280 now.saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL
281 })
282 .map(|record| record.entry_count)
283 .sum()
284}
285
286#[cfg(test)]
287pub(crate) fn expire_repair_entry_window_for_test(project_key: &str) {
288 let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
289 let mut warnings = warnings.lock().unwrap();
290 if let Some(record) = warnings.get_mut(&RootRepairWarningKey {
291 project_key: project_key.to_string(),
292 }) {
293 record.window_start = Instant::now() - ROOT_REPAIR_WARN_INTERVAL;
294 }
295}
296
297#[cfg(test)]
298mod root_repair_warning_tests {
299 use super::*;
300
301 #[test]
302 fn repair_warning_emits_once_then_reemits_with_suppressed_count() {
303 let key = RootRepairWarningKey {
304 project_key: "test-project".to_string(),
305 };
306 let first_at = Instant::now();
307 let first = next_root_repair_warning(key.clone(), first_at).unwrap();
308 assert_eq!(first, ROOT_REPAIR_WARNING_TEXT);
309 assert!(next_root_repair_warning(key.clone(), first_at + Duration::from_secs(1)).is_none());
310 assert_eq!(
311 repair_entry_rate("test-project").map(|rate| rate.0),
312 Some(2)
313 );
314
315 let repeated = next_root_repair_warning(key, first_at + ROOT_REPAIR_WARN_INTERVAL).unwrap();
316 assert!(repeated.ends_with("(repeated 1x in 60s)"));
317 expire_repair_entry_window_for_test("test-project");
318 assert!(repair_entry_rate("test-project").is_none());
319 }
320}
321
322#[cfg(test)]
323mod write_amplification_tests {
324 use super::*;
325 use std::fs;
326 use tempfile::tempdir;
327
328 #[test]
329 fn callgraph_writer_and_reader_use_bounded_normal_pragmas() {
330 let temp = tempdir().unwrap();
331 let root = temp.path().join("root");
332 fs::create_dir_all(&root).unwrap();
333 let source = root.join("main.ts");
334 fs::write(&source, "export function main() {}\n").unwrap();
335 let store_dir = temp.path().join("store");
336 let store = CallGraphStore::open(store_dir.clone(), root.clone()).unwrap();
337
338 let conn = store.conn.lock().unwrap();
339 let synchronous: i64 = conn
340 .pragma_query_value(None, "synchronous", |row| row.get(0))
341 .unwrap();
342 let autocheckpoint: i64 = conn
343 .pragma_query_value(None, "wal_autocheckpoint", |row| row.get(0))
344 .unwrap();
345 assert_eq!(synchronous, 1, "NORMAL synchronous mode is value 1");
346 assert_eq!(autocheckpoint, CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES);
347 drop(conn);
348 store.cold_build(std::slice::from_ref(&source)).unwrap();
349 drop(store);
350
351 let readonly = CallGraphStore::open_readonly(store_dir, root)
352 .unwrap()
353 .expect("writer-created empty schema should be readable");
354 let conn = readonly.inner.conn.lock().unwrap();
355 let synchronous: i64 = conn
356 .pragma_query_value(None, "synchronous", |row| row.get(0))
357 .unwrap();
358 assert_eq!(synchronous, 1);
359 }
360
361 #[test]
362 fn own_refresh_skips_identical_extract_but_not_position_shift() {
363 let temp = tempdir().unwrap();
364 let root = temp.path().join("root");
365 fs::create_dir_all(&root).unwrap();
366 let source = root.join("main.ts");
367 fs::write(&source, "export function main() { return 1; }\n").unwrap();
368 let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
369 store.cold_build(std::slice::from_ref(&source)).unwrap();
370 let write_metrics = callgraph_write_metrics_for_project(store.project_key());
371 assert!(write_metrics.commits_60s > 0);
372 assert!(write_metrics.pages_or_bytes_written_60s > 0);
373
374 let before = store.conn.lock().unwrap().total_changes();
375 fs::write(&source, "export function main() { return 1; }\n\n").unwrap();
376 let (stats, _) = store
377 .refresh_files_profiled(std::slice::from_ref(&source))
378 .unwrap();
379 let after = store.conn.lock().unwrap().total_changes();
380 assert_eq!(stats.unchanged_extract_files, 1);
381 assert_eq!(stats.refreshed_own_files, 0);
382 assert_eq!(
383 after - before,
384 3,
385 "files, backend freshness, and the durable projection revision update"
386 );
387
388 fs::write(&source, "\nexport function main() { return 1; }\n\n").unwrap();
389 let (shifted_stats, _) = store
390 .refresh_files_profiled(std::slice::from_ref(&source))
391 .unwrap();
392 assert_eq!(shifted_stats.unchanged_extract_files, 0);
393 assert_eq!(shifted_stats.refreshed_own_files, 1);
394 }
395
396 #[test]
397 fn idle_checkpoint_interval_prevents_checkpoint_storms() {
398 let now = Instant::now();
399 assert!(idle_checkpoint_due(None, now));
400 assert!(!idle_checkpoint_due(
401 Some(now),
402 now + Duration::from_secs(REFRESH_IDLE_CHECKPOINT_INTERVAL.as_secs() - 1),
403 ));
404 assert!(idle_checkpoint_due(
405 Some(now),
406 now + REFRESH_IDLE_CHECKPOINT_INTERVAL,
407 ));
408 }
409
410 #[test]
411 fn write_metrics_decay_after_the_sixty_second_window() {
412 let key = format!("metrics-test-{}", now_nanos());
413 let metrics = callgraph_write_metrics_for_key(&key);
414 metrics.record_commit(17);
415 assert_eq!(metrics.snapshot().commits_60s, 1);
416 assert_eq!(metrics.snapshot().pages_or_bytes_written_60s, 17);
417 metrics.window_start_ms.store(
418 unix_millis_now().saturating_sub(CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64),
419 AtomicOrdering::Release,
420 );
421 assert_eq!(metrics.snapshot(), CallgraphWriteMetricsSnapshot::default());
422 }
423}
424
425#[cfg(test)]
426type ColdBuildBeforePublishObserver = dyn Fn() + Send + Sync + 'static;
427thread_local! {
434 static COLD_BUILD_SWAP_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSwapObserver>>> =
435 const { std::cell::RefCell::new(None) };
436 #[cfg(test)]
437 static COLD_BUILD_BEFORE_PUBLISH_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildBeforePublishObserver>>> =
438 const { std::cell::RefCell::new(None) };
439 static MIGRATION_AVAILABLE_DISK_OVERRIDE: std::cell::RefCell<Option<u64>> =
440 const { std::cell::RefCell::new(None) };
441 static MIGRATION_FAIL_AFTER_TEMP_COPY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
442 static MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED: std::cell::Cell<bool> =
443 const { std::cell::Cell::new(false) };
444 static PUBLISH_ADMISSION: std::cell::RefCell<Option<(crate::root_cache::ArtifactPublishEpoch, u64)>> =
445 const { std::cell::RefCell::new(None) };
446 static REFRESH_COMMIT_ADMISSION: std::cell::RefCell<Option<(SubcLifecycleAdmission, Arc<std::sync::atomic::AtomicU64>, u64)>> =
447 const { std::cell::RefCell::new(None) };
448}
449
450mod dead_code_projection;
451pub use dead_code_projection::project_dead_code_snapshot;
452pub(crate) use dead_code_projection::project_dead_code_snapshot_with_revision;
453#[cfg(test)]
454pub(crate) use dead_code_projection::set_projection_before_open_observer;
455
456#[doc(hidden)]
457pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
458 COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
459}
460
461#[cfg(test)]
462fn set_cold_build_before_publish_observer(observer: Option<Arc<ColdBuildBeforePublishObserver>>) {
463 COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
464}
465
466#[cfg(test)]
467fn notify_cold_build_before_publish_observer() {
468 let observer = COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| slot.borrow().clone());
469 if let Some(observer) = observer {
470 observer();
471 }
472}
473
474#[cfg(not(test))]
475fn notify_cold_build_before_publish_observer() {}
476
477#[doc(hidden)]
478pub fn set_legacy_migration_available_disk_for_test(bytes: Option<u64>) {
479 MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow_mut() = bytes);
480}
481
482#[doc(hidden)]
483pub fn set_legacy_migration_fail_after_temp_copy_for_test(enabled: bool) {
484 MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.set(enabled));
485}
486
487#[doc(hidden)]
488pub fn set_legacy_migration_backup_budget_exhausted_for_test(enabled: bool) {
489 MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.set(enabled));
490}
491
492struct PublishAdmissionGuard {
493 previous: Option<(crate::root_cache::ArtifactPublishEpoch, u64)>,
494}
495
496impl Drop for PublishAdmissionGuard {
497 fn drop(&mut self) {
498 PUBLISH_ADMISSION.with(|slot| {
499 *slot.borrow_mut() = self.previous.take();
500 });
501 }
502}
503
504pub(crate) fn with_publish_epoch<R>(
505 epoch: crate::root_cache::ArtifactPublishEpoch,
506 expected: u64,
507 run: impl FnOnce() -> R,
508) -> R {
509 let previous = PUBLISH_ADMISSION.with(|slot| slot.replace(Some((epoch, expected))));
510 let _guard = PublishAdmissionGuard { previous };
511 run()
512}
513
514fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
515 let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
516 match admission {
517 Some((epoch, expected)) => epoch
518 .run_if_current(expected, publish)
519 .unwrap_or(Err(CallGraphStoreError::Superseded)),
520 None => publish(),
521 }
522}
523
524struct RefreshCommitAdmissionGuard {
525 previous: Option<(
526 SubcLifecycleAdmission,
527 Arc<std::sync::atomic::AtomicU64>,
528 u64,
529 )>,
530}
531
532impl Drop for RefreshCommitAdmissionGuard {
533 fn drop(&mut self) {
534 REFRESH_COMMIT_ADMISSION.with(|slot| {
535 *slot.borrow_mut() = self.previous.take();
536 });
537 }
538}
539
540fn with_refresh_commit_admission<R>(
541 lifecycle: SubcLifecycleAdmission,
542 generation_flag: Arc<std::sync::atomic::AtomicU64>,
543 expected_generation: u64,
544 run: impl FnOnce() -> R,
545) -> R {
546 let previous = REFRESH_COMMIT_ADMISSION
547 .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
548 let _guard = RefreshCommitAdmissionGuard { previous };
549 run()
550}
551
552fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
553 let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
554 let commit = || {
555 publish_if_current(|| {
556 tx.commit()?;
557 Ok(())
558 })
559 };
560 match admission {
561 Some((lifecycle, generation_flag, expected_generation)) => lifecycle
562 .run_if_current(generation_flag.as_ref(), expected_generation, commit)
563 .unwrap_or(Err(CallGraphStoreError::Superseded)),
564 None => commit(),
565 }
566}
567
568fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
569 let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
570 if let Some(observer) = observer {
571 observer(temp_path, target_path);
572 }
573}
574
575#[derive(Debug)]
576pub enum CallGraphStoreError {
577 Io(std::io::Error),
578 Sqlite(rusqlite::Error),
579 Json(serde_json::Error),
580 Aft(AftError),
581 Lock(crate::fs_lock::AcquireError),
582 MissingCallerData { file: String },
583 Unavailable(String),
584 Superseded,
585 StaleFiles(Vec<String>),
586}
587
588impl fmt::Display for CallGraphStoreError {
589 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
590 match self {
591 Self::Io(error) => write!(formatter, "I/O error: {error}"),
592 Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
593 Self::Json(error) => write!(formatter, "json error: {error}"),
594 Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
595 Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
596 Self::MissingCallerData { file } => {
597 write!(formatter, "missing extracted caller data for {file}")
598 }
599 Self::Unavailable(message) => {
600 write!(formatter, "callgraph store unavailable: {message}")
601 }
602 Self::Superseded => {
603 write!(formatter, "callgraph store build superseded before publish")
604 }
605 Self::StaleFiles(files) => {
606 write!(
607 formatter,
608 "callgraph store has stale files: {}",
609 files.join(", ")
610 )
611 }
612 }
613 }
614}
615
616impl std::error::Error for CallGraphStoreError {}
617
618impl From<std::io::Error> for CallGraphStoreError {
619 fn from(error: std::io::Error) -> Self {
620 Self::Io(error)
621 }
622}
623
624impl From<rusqlite::Error> for CallGraphStoreError {
625 fn from(error: rusqlite::Error) -> Self {
626 Self::Sqlite(error)
627 }
628}
629
630impl From<serde_json::Error> for CallGraphStoreError {
631 fn from(error: serde_json::Error) -> Self {
632 Self::Json(error)
633 }
634}
635
636impl From<AftError> for CallGraphStoreError {
637 fn from(error: AftError) -> Self {
638 Self::Aft(error)
639 }
640}
641
642impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
643 fn from(error: crate::fs_lock::AcquireError) -> Self {
644 Self::Lock(error)
645 }
646}
647
648pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
649
650pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
654
655#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
656pub struct CallGraphStoreOptions {
657 pub enabled: bool,
658}
659
660pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
661
662#[derive(Clone)]
666pub(crate) struct CallgraphRefreshState {
667 store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
668 heavy_root_work_allowed: Arc<AtomicBool>,
669}
670
671impl CallgraphRefreshState {
672 pub(crate) fn new(
673 store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
674 heavy_root_work_allowed: Arc<AtomicBool>,
675 ) -> Self {
676 Self {
677 store,
678 heavy_root_work_allowed,
679 }
680 }
681
682 fn installed_store_snapshot(&self) -> Option<Arc<ReadonlyCallGraphStore>> {
683 self.store
684 .read()
685 .unwrap_or_else(std::sync::PoisonError::into_inner)
686 .as_ref()
687 .map(Arc::clone)
688 }
689}
690
691type WorkspaceCratePrefixes = HashMap<String, String>;
692
693#[derive(Clone, Debug, Default)]
694struct WorkspaceCratePrefixCache(Arc<OnceLock<WorkspaceCratePrefixes>>);
695
696const REFRESH_WORKSPACE_CACHE_ROOT_CAP: usize = 128;
697
698pub(crate) fn invalidates_workspace_crate_prefix_cache(path: &Path) -> bool {
699 path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")
700}
701
702#[derive(Clone, Debug, Hash, PartialEq, Eq)]
703struct RefreshRoot {
704 callgraph_dir: PathBuf,
705 project_root: PathBuf,
706}
707
708#[derive(Clone)]
709pub(crate) struct CallgraphRefreshTicket {
710 lifecycle: SubcLifecycleAdmission,
711 generation_flag: Arc<std::sync::atomic::AtomicU64>,
712 expected_generation: u64,
713 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
714 expected_publish_epoch: u64,
715}
716
717impl CallgraphRefreshTicket {
718 pub(crate) fn new(
719 lifecycle: SubcLifecycleAdmission,
720 generation_flag: Arc<std::sync::atomic::AtomicU64>,
721 expected_generation: u64,
722 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
723 expected_publish_epoch: u64,
724 ) -> Self {
725 Self {
726 lifecycle,
727 generation_flag,
728 expected_generation,
729 publish_epoch,
730 expected_publish_epoch,
731 }
732 }
733
734 fn is_current(&self) -> bool {
735 self.lifecycle
736 .is_current(self.generation_flag.as_ref(), self.expected_generation)
737 && self.publish_epoch.current() == self.expected_publish_epoch
738 }
739}
740
741#[derive(Clone)]
742struct RefreshBatch {
743 root: RefreshRoot,
744 paths: BTreeSet<PathBuf>,
745 pending_sinks: Vec<PendingCallGraphStorePaths>,
746 refresh_states: Vec<CallgraphRefreshState>,
747 ticket: Option<CallgraphRefreshTicket>,
748}
749
750impl RefreshBatch {
751 fn defer(&self) {
752 for sink in &self.pending_sinks {
753 sink.lock().extend(self.paths.iter().cloned());
754 }
755 }
756
757 fn defer_after_open_failure(&self) {
758 self.defer();
759 if self
760 .ticket
761 .as_ref()
762 .is_some_and(|ticket| !ticket.is_current())
763 || !self
764 .refresh_states
765 .iter()
766 .any(|state| state.heavy_root_work_allowed.load(AtomicOrdering::SeqCst))
767 {
768 return;
769 }
770
771 let ready_store_installed = self.refresh_states.iter().any(|state| {
772 let store = state.installed_store_snapshot();
773 store.is_some_and(|store| {
774 store.project_root() == self.root.project_root
775 && !store.is_legacy_fallback()
776 && store.is_current()
777 })
778 });
779 if !ready_store_installed {
780 return;
781 }
782
783 for sink in &self.pending_sinks {
787 let paths = {
788 let mut pending = sink.lock();
789 self.paths
790 .iter()
791 .filter(|path| pending.remove(*path))
792 .cloned()
793 .collect::<Vec<_>>()
794 };
795 if paths.is_empty() {
796 continue;
797 }
798 let _ = enqueue_callgraph_store_refresh_inner(
799 self.root.callgraph_dir.clone(),
800 self.root.project_root.clone(),
801 paths,
802 Arc::clone(sink),
803 self.refresh_states.clone(),
804 self.ticket.clone(),
805 );
806 }
807 }
808
809 fn merge(
810 &mut self,
811 paths: impl IntoIterator<Item = PathBuf>,
812 sink: PendingCallGraphStorePaths,
813 refresh_states: Vec<CallgraphRefreshState>,
814 ticket: Option<CallgraphRefreshTicket>,
815 ) {
816 self.paths.extend(paths);
817 if ticket.is_some() {
818 self.ticket = ticket;
819 }
820 if !self
821 .pending_sinks
822 .iter()
823 .any(|existing| Arc::ptr_eq(existing, &sink))
824 {
825 self.pending_sinks.push(sink);
826 }
827 for refresh_state in refresh_states {
828 if !self.refresh_states.iter().any(|existing| {
829 Arc::ptr_eq(&existing.store, &refresh_state.store)
830 && Arc::ptr_eq(
831 &existing.heavy_root_work_allowed,
832 &refresh_state.heavy_root_work_allowed,
833 )
834 }) {
835 self.refresh_states.push(refresh_state);
836 }
837 }
838 }
839}
840
841#[derive(Default)]
842struct RefreshQueue {
843 order: VecDeque<RefreshRoot>,
844 queued: HashMap<RefreshRoot, RefreshBatch>,
845 active: Option<RefreshBatch>,
846 shutdown_requested: bool,
847}
848
849struct RefreshWorkerShared {
850 queue: Mutex<RefreshQueue>,
851 wake: Condvar,
852}
853
854struct RefreshWorker {
855 shared: Arc<RefreshWorkerShared>,
856 thread: Mutex<Option<JoinHandle<()>>>,
857}
858
859struct RefreshWorkerWatchdog {
860 first_path: PathBuf,
861 batch_len: usize,
862 started: Instant,
863}
864
865impl RefreshWorkerWatchdog {
866 fn start(paths: &[PathBuf]) -> Self {
867 Self {
868 first_path: paths
869 .first()
870 .expect("non-empty callgraph refresh batch has a first path")
871 .clone(),
872 batch_len: paths.len(),
873 started: Instant::now(),
874 }
875 }
876}
877
878impl Drop for RefreshWorkerWatchdog {
879 fn drop(&mut self) {
880 let elapsed = self.started.elapsed();
881 if elapsed < REFRESH_WORKER_WARN_AFTER {
882 return;
883 }
884 let path = if self.batch_len == 1 {
885 self.first_path.display().to_string()
886 } else {
887 format!(
888 "{} (+{} paths)",
889 self.first_path.display(),
890 self.batch_len - 1
891 )
892 };
893 log::warn!(
894 "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
895 path,
896 elapsed.as_millis()
897 );
898 if elapsed >= REFRESH_WORKER_FINAL_AFTER {
899 log::warn!(
900 "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
901 path,
902 elapsed.as_millis()
903 );
904 }
905 }
906}
907
908impl RefreshWorker {
909 fn spawn() -> Arc<Self> {
910 let shared = Arc::new(RefreshWorkerShared {
911 queue: Mutex::new(RefreshQueue::default()),
912 wake: Condvar::new(),
913 });
914 let thread_shared = Arc::clone(&shared);
915 let thread = std::thread::Builder::new()
916 .name("aft-callgraph-refresh".to_string())
917 .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
918 .expect("failed to spawn callgraph refresh worker");
919 Arc::new(Self {
920 shared,
921 thread: Mutex::new(Some(thread)),
922 })
923 }
924
925 fn enqueue(
926 &self,
927 root: RefreshRoot,
928 paths: Vec<PathBuf>,
929 pending_sink: PendingCallGraphStorePaths,
930 refresh_states: Vec<CallgraphRefreshState>,
931 ticket: Option<CallgraphRefreshTicket>,
932 ) -> bool {
933 let mut queue = self
934 .shared
935 .queue
936 .lock()
937 .expect("callgraph refresh queue mutex poisoned");
938 if queue.shutdown_requested {
939 pending_sink.lock().extend(paths);
940 return false;
941 }
942 if let Some(batch) = queue.queued.get_mut(&root) {
943 batch.merge(paths, pending_sink, refresh_states, ticket);
944 } else {
945 queue.order.push_back(root.clone());
946 queue.queued.insert(
947 root.clone(),
948 RefreshBatch {
949 root,
950 paths: paths.into_iter().collect(),
951 pending_sinks: vec![pending_sink],
952 refresh_states,
953 ticket,
954 },
955 );
956 }
957 self.shared.wake.notify_one();
958 true
959 }
960
961 fn shutdown_with_budget(&self, budget: Duration) -> bool {
962 let deadline = Instant::now() + budget;
963 let mut queue = self
964 .shared
965 .queue
966 .lock()
967 .expect("callgraph refresh queue mutex poisoned");
968 queue.shutdown_requested = true;
969 self.shared.wake.notify_one();
970 while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
971 let remaining = deadline.saturating_duration_since(Instant::now());
972 let (next, _) = self
973 .shared
974 .wake
975 .wait_timeout(queue, remaining)
976 .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
977 queue = next;
978 }
979 let drained = queue.active.is_none() && queue.order.is_empty();
980 if !drained {
981 if let Some(active) = queue.active.as_ref() {
982 active.defer();
983 }
984 for batch in queue.queued.values() {
985 batch.defer();
986 }
987 queue.order.clear();
988 queue.queued.clear();
989 }
990 drop(queue);
991
992 if drained {
993 if let Some(thread) = self
994 .thread
995 .lock()
996 .expect("callgraph refresh worker thread mutex poisoned")
997 .take()
998 {
999 let _ = thread.join();
1000 }
1001 }
1002 drained
1003 }
1004}
1005
1006static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
1007
1008pub fn enqueue_callgraph_store_refresh(
1009 callgraph_dir: PathBuf,
1010 project_root: PathBuf,
1011 paths: Vec<PathBuf>,
1012 pending_sink: PendingCallGraphStorePaths,
1013) -> bool {
1014 enqueue_callgraph_store_refresh_inner(
1015 callgraph_dir,
1016 project_root,
1017 paths,
1018 pending_sink,
1019 Vec::new(),
1020 None,
1021 )
1022}
1023
1024#[cfg(test)]
1025pub(crate) fn enqueue_callgraph_store_refresh_fenced(
1026 callgraph_dir: PathBuf,
1027 project_root: PathBuf,
1028 paths: Vec<PathBuf>,
1029 pending_sink: PendingCallGraphStorePaths,
1030 ticket: CallgraphRefreshTicket,
1031) -> bool {
1032 enqueue_callgraph_store_refresh_inner(
1033 callgraph_dir,
1034 project_root,
1035 paths,
1036 pending_sink,
1037 Vec::new(),
1038 Some(ticket),
1039 )
1040}
1041
1042pub(crate) fn enqueue_callgraph_store_refresh_fenced_with_state(
1043 callgraph_dir: PathBuf,
1044 project_root: PathBuf,
1045 paths: Vec<PathBuf>,
1046 pending_sink: PendingCallGraphStorePaths,
1047 refresh_state: CallgraphRefreshState,
1048 ticket: CallgraphRefreshTicket,
1049) -> bool {
1050 enqueue_callgraph_store_refresh_inner(
1051 callgraph_dir,
1052 project_root,
1053 paths,
1054 pending_sink,
1055 vec![refresh_state],
1056 Some(ticket),
1057 )
1058}
1059
1060fn enqueue_callgraph_store_refresh_inner(
1061 callgraph_dir: PathBuf,
1062 project_root: PathBuf,
1063 paths: Vec<PathBuf>,
1064 pending_sink: PendingCallGraphStorePaths,
1065 refresh_states: Vec<CallgraphRefreshState>,
1066 ticket: Option<CallgraphRefreshTicket>,
1067) -> bool {
1068 if paths.is_empty() {
1069 return true;
1070 }
1071 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1072 let worker = {
1073 let mut worker = slot
1074 .lock()
1075 .expect("callgraph refresh worker mutex poisoned");
1076 Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
1077 };
1078 worker.enqueue(
1079 RefreshRoot {
1080 callgraph_dir,
1081 project_root,
1082 },
1083 paths,
1084 pending_sink,
1085 refresh_states,
1086 ticket,
1087 )
1088}
1089
1090pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
1091 flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
1092}
1093
1094#[doc(hidden)]
1095pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
1096 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1097 let worker = slot
1098 .lock()
1099 .expect("callgraph refresh worker mutex poisoned")
1100 .clone();
1101 let Some(worker) = worker else {
1102 return true;
1103 };
1104 let drained = worker.shutdown_with_budget(budget);
1105 if drained {
1106 let mut current = slot
1107 .lock()
1108 .expect("callgraph refresh worker mutex poisoned");
1109 if current
1110 .as_ref()
1111 .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
1112 {
1113 *current = None;
1114 }
1115 }
1116 drained
1117}
1118
1119fn idle_checkpoint_due(last: Option<Instant>, now: Instant) -> bool {
1120 last.is_none_or(|last| now.saturating_duration_since(last) >= REFRESH_IDLE_CHECKPOINT_INTERVAL)
1121}
1122
1123fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
1124 let mut workspace_crate_prefixes = HashMap::new();
1127 let mut last_idle_checkpoints: HashMap<RefreshRoot, Instant> = HashMap::new();
1128 loop {
1129 let batch = {
1130 let mut queue = shared
1131 .queue
1132 .lock()
1133 .expect("callgraph refresh queue mutex poisoned");
1134 loop {
1135 if let Some(root) = queue.order.pop_front() {
1136 let batch = queue
1137 .queued
1138 .remove(&root)
1139 .expect("queued callgraph refresh root has a batch");
1140 queue.active = Some(batch.clone());
1141 break batch;
1142 }
1143 if queue.shutdown_requested {
1144 return;
1145 }
1146 queue = shared
1147 .wake
1148 .wait(queue)
1149 .expect("callgraph refresh queue mutex poisoned while waiting");
1150 }
1151 };
1152
1153 let store = process_callgraph_refresh_batch(&batch, &mut workspace_crate_prefixes);
1154
1155 let mut queue = shared
1156 .queue
1157 .lock()
1158 .expect("callgraph refresh queue mutex poisoned");
1159 queue.active = None;
1160 let became_idle = queue.order.is_empty();
1161 shared.wake.notify_all();
1162 drop(queue);
1163
1164 if became_idle {
1165 let checkpoint_due = idle_checkpoint_due(
1166 last_idle_checkpoints.get(&batch.root).copied(),
1167 Instant::now(),
1168 );
1169 if checkpoint_due {
1170 if let Some(store) = store {
1171 if store.checkpoint_wal_truncate() {
1172 last_idle_checkpoints.insert(batch.root.clone(), Instant::now());
1173 }
1174 }
1175 }
1176 }
1177 }
1178}
1179
1180fn process_callgraph_refresh_batch(
1181 batch: &RefreshBatch,
1182 workspace_crate_prefixes: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1183) -> Option<CallGraphStore> {
1184 if batch
1188 .paths
1189 .iter()
1190 .any(|path| invalidates_workspace_crate_prefix_cache(path))
1191 {
1192 workspace_crate_prefixes.remove(&batch.root);
1193 }
1194
1195 let paths = batch
1196 .paths
1197 .iter()
1198 .filter(|path| crate::parser::detect_language(path).is_some())
1199 .cloned()
1200 .collect::<Vec<_>>();
1201 if paths.is_empty() {
1202 return None;
1203 }
1204 note_refresh_worker_batch_for_test(&batch.root.project_root);
1205 if batch
1206 .ticket
1207 .as_ref()
1208 .is_some_and(|ticket| !ticket.is_current())
1209 {
1210 batch.defer();
1213 return None;
1214 }
1215 let workspace_crate_prefix_cache =
1216 workspace_crate_prefix_cache_for_root(workspace_crate_prefixes, &batch.root);
1217 let _watchdog = RefreshWorkerWatchdog::start(&paths);
1218 let test_seam = refresh_worker_test_seam(&batch.root.project_root);
1219 note_refresh_worker_call_for_test(&batch.root.project_root);
1220 let opened = if test_seam.fail_open {
1221 Ok(None)
1222 } else {
1223 CallGraphStore::open_ready(
1224 batch.root.callgraph_dir.clone(),
1225 batch.root.project_root.clone(),
1226 )
1227 };
1228 if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
1229 let _ = gate.held_tx.send(());
1232 let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
1233 }
1234 let store = match opened {
1235 Ok(Some(store)) => store,
1236 Ok(None) => {
1237 batch.defer_after_open_failure();
1238 return None;
1239 }
1240 Err(error) => {
1241 batch.defer_after_open_failure();
1242 crate::slog_warn!(
1243 "callgraph store writer open failed during refresh; deferred paths: {}",
1244 error
1245 );
1246 return None;
1247 }
1248 };
1249 if !test_seam.delay.is_zero() {
1250 std::thread::sleep(test_seam.delay);
1251 }
1252 if batch
1253 .ticket
1254 .as_ref()
1255 .is_some_and(|ticket| !ticket.is_current())
1256 {
1257 batch.defer();
1260 return Some(store);
1261 }
1262 let refresh_result = if test_seam.fail_refresh {
1263 Err(CallGraphStoreError::Unavailable(
1264 "injected refresh worker failure".to_string(),
1265 ))
1266 } else if let Some(ticket) = &batch.ticket {
1267 with_publish_epoch(
1268 ticket.publish_epoch.clone(),
1269 ticket.expected_publish_epoch,
1270 || {
1271 with_refresh_commit_admission(
1272 ticket.lifecycle.clone(),
1273 Arc::clone(&ticket.generation_flag),
1274 ticket.expected_generation,
1275 || {
1276 store
1277 .refresh_files_with_workspace_crate_prefix_cache(
1278 &paths,
1279 workspace_crate_prefix_cache.clone(),
1280 )
1281 .map(|_| ())
1282 },
1283 )
1284 },
1285 )
1286 } else {
1287 store
1288 .refresh_files_with_workspace_crate_prefix_cache(
1289 &paths,
1290 workspace_crate_prefix_cache.clone(),
1291 )
1292 .map(|_| ())
1293 };
1294 if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
1295 batch.defer();
1299 return Some(store);
1300 }
1301 if let Err(error) = refresh_result {
1302 crate::slog_warn!("callgraph store refresh failed: {}", error);
1303 match store.mark_files_stale(&paths) {
1304 Ok(marked) => {
1305 note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
1306 crate::slog_warn!(
1307 "marked {} callgraph store file(s) stale after refresh failure",
1308 marked.len()
1309 );
1310 }
1311 Err(mark_error) => crate::slog_warn!(
1312 "failed to mark callgraph store files stale after refresh failure: {}",
1313 mark_error
1314 ),
1315 }
1316 } else {
1317 crate::logging::note_callgraph_invalidations(paths.len());
1318 }
1319 Some(store)
1320}
1321
1322fn workspace_crate_prefix_cache_for_root(
1323 caches: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1324 root: &RefreshRoot,
1325) -> WorkspaceCratePrefixCache {
1326 if !caches.contains_key(root) && caches.len() >= REFRESH_WORKSPACE_CACHE_ROOT_CAP {
1327 if let Some(evicted) = caches.keys().next().cloned() {
1329 caches.remove(&evicted);
1330 }
1331 }
1332 caches.entry(root.clone()).or_default().clone()
1333}
1334
1335#[derive(Clone, Copy, Default)]
1336struct RefreshWorkerTestSeam {
1337 delay: Duration,
1338 fail_refresh: bool,
1339 fail_open: bool,
1340 refresh_calls: usize,
1341 worker_calls: usize,
1342 stale_marks: usize,
1343}
1344
1345static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
1346 OnceLock::new();
1347
1348struct RefreshWorkerTestGate {
1349 held_tx: crossbeam_channel::Sender<()>,
1350 release_rx: crossbeam_channel::Receiver<()>,
1351}
1352
1353static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
1354 OnceLock::new();
1355
1356#[doc(hidden)]
1357pub fn install_callgraph_refresh_worker_test_gate(
1358 project_root: PathBuf,
1359) -> (
1360 crossbeam_channel::Receiver<()>,
1361 crossbeam_channel::Sender<()>,
1362) {
1363 let (held_tx, held_rx) = crossbeam_channel::bounded(1);
1364 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1365 REFRESH_WORKER_TEST_GATES
1366 .get_or_init(|| Mutex::new(HashMap::new()))
1367 .lock()
1368 .expect("callgraph refresh test gate mutex poisoned")
1369 .insert(
1370 project_root,
1371 RefreshWorkerTestGate {
1372 held_tx,
1373 release_rx,
1374 },
1375 );
1376 (held_rx, release_tx)
1377}
1378
1379fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
1380 REFRESH_WORKER_TEST_GATES
1381 .get_or_init(|| Mutex::new(HashMap::new()))
1382 .lock()
1383 .expect("callgraph refresh test gate mutex poisoned")
1384 .remove(project_root)
1385}
1386
1387fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
1388 let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
1389 return RefreshWorkerTestSeam::default();
1390 };
1391 seams
1392 .lock()
1393 .expect("callgraph refresh test seam mutex poisoned")
1394 .get(project_root)
1395 .copied()
1396 .unwrap_or_default()
1397}
1398
1399fn note_refresh_worker_batch_for_test(project_root: &Path) {
1400 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1401 if let Some(seam) = seams
1402 .lock()
1403 .expect("callgraph refresh test seam mutex poisoned")
1404 .get_mut(project_root)
1405 {
1406 seam.worker_calls += 1;
1407 }
1408 }
1409}
1410
1411fn note_refresh_worker_call_for_test(project_root: &Path) {
1412 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1413 if let Some(seam) = seams
1414 .lock()
1415 .expect("callgraph refresh test seam mutex poisoned")
1416 .get_mut(project_root)
1417 {
1418 seam.refresh_calls += 1;
1419 }
1420 }
1421}
1422
1423fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
1424 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1425 if let Some(seam) = seams
1426 .lock()
1427 .expect("callgraph refresh test seam mutex poisoned")
1428 .get_mut(project_root)
1429 {
1430 seam.stale_marks += 1;
1431 }
1432 }
1433}
1434
1435#[doc(hidden)]
1436pub fn set_callgraph_refresh_worker_test_seam(
1437 project_root: PathBuf,
1438 delay: Duration,
1439 fail_refresh: bool,
1440) {
1441 REFRESH_WORKER_TEST_SEAMS
1442 .get_or_init(|| Mutex::new(HashMap::new()))
1443 .lock()
1444 .expect("callgraph refresh test seam mutex poisoned")
1445 .insert(
1446 project_root,
1447 RefreshWorkerTestSeam {
1448 delay,
1449 fail_refresh,
1450 ..RefreshWorkerTestSeam::default()
1451 },
1452 );
1453}
1454
1455#[doc(hidden)]
1456pub fn set_callgraph_refresh_worker_test_open_failure(project_root: PathBuf, enabled: bool) {
1457 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1458 if let Some(seam) = seams
1459 .lock()
1460 .expect("callgraph refresh test seam mutex poisoned")
1461 .get_mut(&project_root)
1462 {
1463 seam.fail_open = enabled;
1464 }
1465 }
1466}
1467
1468#[doc(hidden)]
1469pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
1470 let seam = refresh_worker_test_seam(project_root);
1471 (seam.refresh_calls, seam.stale_marks)
1472}
1473
1474#[doc(hidden)]
1475pub fn callgraph_refresh_worker_test_worker_calls(project_root: &Path) -> usize {
1476 refresh_worker_test_seam(project_root).worker_calls
1477}
1478
1479#[doc(hidden)]
1480pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
1481 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1482 seams
1483 .lock()
1484 .expect("callgraph refresh test seam mutex poisoned")
1485 .remove(project_root);
1486 }
1487}
1488
1489#[derive(Debug)]
1490pub struct CallGraphStore {
1491 project_root: PathBuf,
1492 project_key: String,
1493 sqlite_path: PathBuf,
1497 publication_dir: PathBuf,
1501 legacy_fallback: bool,
1505 generation: Option<String>,
1510 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1511 read_marker: Option<crate::root_cache::ReadMarker>,
1512 database_ready: AtomicBool,
1515 write_metrics: Arc<CallgraphWriteMetrics>,
1516 conn: Mutex<Connection>,
1517}
1518
1519#[derive(Debug)]
1520pub struct ReadonlyCallGraphStore {
1521 inner: CallGraphStore,
1522}
1523
1524pub trait CallGraphRead {
1525 fn project_root(&self) -> &Path;
1526 fn project_key(&self) -> &str;
1527 fn sqlite_path(&self) -> &Path;
1528 fn is_current(&self) -> bool;
1529 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
1530 fn indexed_file_count(&self) -> Result<usize>;
1531 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
1532 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
1533 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
1534 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
1535 fn direct_caller_counts_of(
1536 &self,
1537 targets: &[(String, String)],
1538 ) -> Result<HashMap<(String, String), usize>>;
1539 fn outgoing_calls_for_symbols(
1540 &self,
1541 sources: &[(String, String)],
1542 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
1543 fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
1544 -> Result<StoreCallersResult>;
1545 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
1546 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1547 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1548 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
1549 fn call_tree(
1550 &self,
1551 file_rel: &Path,
1552 symbol: &str,
1553 depth: usize,
1554 ) -> Result<callgraph::CallTreeNode>;
1555 fn trace_to(
1556 &self,
1557 file_rel: &Path,
1558 symbol: &str,
1559 max_depth: usize,
1560 ) -> Result<callgraph::TraceToResult>;
1561 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
1562 fn trace_to_symbol(
1563 &self,
1564 file_rel: &Path,
1565 symbol: &str,
1566 to_symbol: &str,
1567 to_file: Option<&Path>,
1568 max_depth: usize,
1569 ) -> Result<callgraph::TraceToSymbolResult>;
1570}
1571
1572#[derive(Debug, Clone, PartialEq, Eq)]
1573enum OpenRootRepair {
1574 None,
1575 ReRooted,
1576 NeedsRebuild {
1577 previous_roots: Vec<String>,
1578 current_root: String,
1579 reason: String,
1580 },
1581}
1582
1583struct OpenedStore {
1584 store: CallGraphStore,
1585 root_repair: OpenRootRepair,
1586}
1587
1588#[derive(Clone, Debug)]
1589struct LegacyCallgraphPartition {
1590 harness: String,
1591 dir: PathBuf,
1592 key: String,
1593 bytes: u64,
1594 freshness: Option<SystemTime>,
1595}
1596
1597#[derive(Clone, Debug)]
1598struct LegacyCallgraphTarget {
1599 partition: LegacyCallgraphPartition,
1600 sqlite_path: PathBuf,
1601 generation: Option<String>,
1602 source_bytes: u64,
1603 source_blake3: String,
1604}
1605
1606#[derive(Clone, Debug)]
1607struct SourceFingerprint {
1608 bytes: u64,
1609 blake3: String,
1610}
1611
1612#[derive(Clone, Debug)]
1613struct PublishedLegacyMigration {
1614 generation: String,
1615 migrated_bytes: u64,
1616}
1617
1618#[derive(Debug, Clone)]
1619pub struct ColdBuildStats {
1620 pub files: usize,
1621 pub nodes: usize,
1622 pub refs: usize,
1623 pub edges: usize,
1624 pub failed_files: Vec<String>,
1625 pub elapsed_ms: u128,
1626}
1627
1628#[derive(Debug, Clone)]
1629pub struct IncrementalStats {
1630 pub changed_files: Vec<String>,
1631 pub surface_changed: Vec<String>,
1632 pub deleted_files: Vec<String>,
1633 pub dependency_selected_refs: usize,
1634 pub refreshed_own_files: usize,
1635 pub unchanged_extract_files: usize,
1636}
1637
1638#[doc(hidden)]
1640#[derive(Debug, Clone, Default, PartialEq, Eq)]
1641pub struct RefreshFilesProfile {
1642 pub parse: Duration,
1643 pub dependency_selection: Duration,
1644 pub row_deletes: Duration,
1645 pub row_inserts: Duration,
1646 pub dependent_parse: Duration,
1647 pub index_load: Duration,
1648 pub ref_resolution: Duration,
1649 pub method_dispatch: Duration,
1650 pub commit: Duration,
1651 pub total: Duration,
1652}
1653
1654impl RefreshFilesProfile {
1655 pub fn report(&self) -> String {
1656 format!(
1657 "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",
1658 self.parse.as_millis(),
1659 self.dependency_selection.as_millis(),
1660 self.row_deletes.as_millis(),
1661 self.row_inserts.as_millis(),
1662 self.dependent_parse.as_millis(),
1663 self.index_load.as_millis(),
1664 self.ref_resolution.as_millis(),
1665 self.method_dispatch.as_millis(),
1666 self.commit.as_millis(),
1667 self.total.as_millis(),
1668 )
1669 }
1670}
1671
1672#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1673pub struct StoredEdge {
1674 pub source_file: String,
1675 pub source_symbol: String,
1676 pub target_file: String,
1677 pub target_symbol: String,
1678 pub kind: String,
1679 pub line: u32,
1680}
1681
1682#[derive(Debug, Clone, PartialEq, Eq)]
1683pub struct StoreNode {
1684 node_id: String,
1685 pub file: String,
1686 pub symbol: String,
1687 pub name: String,
1688 pub kind: String,
1689 pub line: u32,
1690 pub end_line: u32,
1691 pub signature: Option<String>,
1692 pub exported: bool,
1693 pub is_entry_point: bool,
1694 pub lang: LangId,
1695}
1696
1697#[cfg(test)]
1698impl StoreNode {
1699 pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
1700 Self {
1701 node_id: format!("{file}:{symbol}"),
1702 file: file.to_string(),
1703 symbol: symbol.to_string(),
1704 name: symbol.to_string(),
1705 kind: "function".to_string(),
1706 line: 1,
1707 end_line: 1,
1708 signature: None,
1709 exported: is_entry_point,
1710 is_entry_point,
1711 lang: LangId::TypeScript,
1712 }
1713 }
1714}
1715
1716#[derive(Debug, Clone, PartialEq, Eq)]
1717pub struct StoreCallSite {
1718 pub caller: StoreNode,
1719 pub target_file: String,
1720 pub target_symbol: String,
1721 pub target: Option<StoreNode>,
1722 pub line: u32,
1723 pub byte_start: usize,
1724 pub byte_end: usize,
1725 pub resolved: bool,
1726 pub provenance: String,
1727}
1728
1729impl StoreCallSite {
1730 pub fn approximate(&self) -> bool {
1731 self.provenance == PROVENANCE_NAME_MATCH
1732 }
1733
1734 pub fn resolved_by(&self) -> &str {
1735 &self.provenance
1736 }
1737
1738 pub fn supplemental_resolution(&self) -> Option<&str> {
1739 match self.provenance.as_str() {
1740 PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
1741 _ => None,
1742 }
1743 }
1744}
1745
1746#[derive(Debug, Clone, PartialEq, Eq)]
1747pub struct StoreUnresolvedCall {
1748 pub caller: StoreNode,
1749 pub symbol: String,
1750 pub full_ref: Option<String>,
1751 pub line: u32,
1752 pub byte_start: usize,
1753 pub byte_end: usize,
1754}
1755
1756#[derive(Debug, Clone, PartialEq, Eq)]
1757pub struct StoreCallersResult {
1758 pub target: StoreNode,
1759 pub callers: Vec<StoreCallSite>,
1760 pub scanned_files: usize,
1761 pub depth_limited: bool,
1762 pub truncated: usize,
1763}
1764
1765#[derive(Debug, Clone, PartialEq, Eq)]
1766pub struct StoreImpactCaller {
1767 pub site: StoreCallSite,
1768 pub signature: Option<String>,
1769 pub is_entry_point: bool,
1770 pub call_expression: Option<String>,
1771 pub parameters: Vec<String>,
1772}
1773
1774#[derive(Debug, Clone, PartialEq, Eq)]
1775pub struct StoreImpactResult {
1776 pub target: StoreNode,
1777 pub parameters: Vec<String>,
1778 pub callers: Vec<StoreImpactCaller>,
1779 pub depth_limited: bool,
1780 pub truncated: usize,
1781}
1782
1783#[derive(Debug, Clone)]
1784struct ExtractFailure {
1785 rel_path: String,
1786 freshness: Option<FileFreshness>,
1787}
1788
1789#[derive(Debug, Clone)]
1790struct BuildExtractsResult {
1791 extracts: Vec<FileExtract>,
1792 failures: Vec<ExtractFailure>,
1793}
1794
1795#[derive(Debug, Clone)]
1796enum StoreForwardCall {
1797 Resolved(StoreCallSite),
1798 Unresolved(StoreUnresolvedCall),
1799}
1800
1801impl StoreForwardCall {
1802 fn byte_start(&self) -> usize {
1803 match self {
1804 Self::Resolved(site) => site.byte_start,
1805 Self::Unresolved(call) => call.byte_start,
1806 }
1807 }
1808
1809 fn line(&self) -> u32 {
1810 match self {
1811 Self::Resolved(site) => site.line,
1812 Self::Unresolved(call) => call.line,
1813 }
1814 }
1815}
1816
1817#[derive(Debug, Clone)]
1818struct FileExtract {
1819 rel_path: String,
1820 freshness: FileFreshness,
1821 lang: LangId,
1822 data: FileCallData,
1823 nodes: Vec<NodeRecord>,
1824 raw_refs: Vec<RawRef>,
1825 dispatch_hints: Vec<DispatchHint>,
1826 surface_fingerprint: String,
1827}
1828
1829#[derive(Debug, Clone)]
1830struct NodeRecord {
1831 id: String,
1832 file_path: String,
1833 name: String,
1834 scoped_name: String,
1835 kind: String,
1836 range: Range,
1837 range_ordinal: u32,
1838 signature: Option<String>,
1839 exported: bool,
1840 is_default_export: bool,
1841 is_type_like: bool,
1842 is_callgraph_entry_point: bool,
1843}
1844
1845#[derive(Debug, Clone)]
1846struct RawRef {
1847 ref_id: String,
1848 caller_node: Option<String>,
1849 caller_symbol: Option<String>,
1850 caller_file: String,
1851 kind: String,
1852 short_name: Option<String>,
1853 full_ref: Option<String>,
1854 module_path: Option<String>,
1855 import_kind: Option<String>,
1856 local_name: Option<String>,
1857 requested_name: Option<String>,
1858 namespace_alias: Option<String>,
1859 wildcard: bool,
1860 line: u32,
1861 byte_start: usize,
1862 byte_end: usize,
1863 dependencies: BTreeSet<String>,
1864}
1865
1866#[derive(Debug, Clone)]
1867struct ResolvedRef {
1868 raw: RawRef,
1869 status: String,
1870 target_node: Option<String>,
1871 target_file: Option<String>,
1872 target_symbol: Option<String>,
1873 dependencies: BTreeSet<String>,
1874 edge: Option<EdgeRecord>,
1875}
1876
1877#[derive(Debug, Clone)]
1878struct EdgeRecord {
1879 edge_id: String,
1880 source_node: String,
1881 target_node: Option<String>,
1882 target_file: String,
1883 target_symbol: String,
1884 kind: String,
1885 line: u32,
1886}
1887
1888#[derive(Debug, Clone)]
1889struct DispatchHint {
1890 id: String,
1891 method_name: String,
1892 caller_node: String,
1893 file: String,
1894 line: u32,
1895 byte_start: usize,
1896 byte_end: usize,
1897}
1898
1899#[derive(Debug, Clone)]
1900struct NameMatchRef {
1901 ref_id: String,
1902 caller_node: String,
1903 caller_file: String,
1904 caller_symbol: String,
1905 caller_signature: Option<String>,
1906 receiver_expression: String,
1907 receiver: String,
1908 method_name: String,
1909 colon_dispatch: bool,
1910 line: u32,
1911 lang: String,
1912}
1913
1914#[derive(Debug, Clone)]
1915struct NameMatchCandidate {
1916 node_id: String,
1917 file_path: String,
1918 scoped_name: String,
1919 kind: String,
1920 start_line: u32,
1922}
1923
1924#[derive(Debug, Clone)]
1925struct FileRow {
1926 surface_fingerprint: String,
1927 freshness: FileFreshness,
1928}
1929
1930#[derive(Debug, Clone)]
1931struct DbFileIndex {
1932 lang: Option<LangId>,
1933 exports: HashSet<String>,
1934 default_export: Option<String>,
1935 export_aliases: HashMap<String, String>,
1936 node_by_scoped: HashMap<String, String>,
1937 node_by_bare: HashMap<String, String>,
1938 module_targets: HashMap<String, Option<String>>,
1939 reexports: Vec<ReexportIndex>,
1940}
1941
1942#[derive(Debug, Clone)]
1943struct ReexportIndex {
1944 target_file: Option<String>,
1945 named: HashMap<String, String>,
1946 wildcard: bool,
1947}
1948
1949#[derive(Debug, Clone)]
1950struct ProjectIndex<'a> {
1951 project_root: PathBuf,
1952 files: HashMap<String, DbFileIndex>,
1953 caller_data: HashMap<String, &'a FileCallData>,
1954 workspace_crate_prefixes: WorkspaceCratePrefixCache,
1959}
1960
1961impl ProjectIndex<'_> {
1962 fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
1965 self.workspace_crate_prefixes
1966 .0
1967 .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
1968 .get(crate_name)
1969 .cloned()
1970 }
1971}
1972
1973impl CallGraphStore {
1974 pub fn open_if_enabled(
1975 options: CallGraphStoreOptions,
1976 callgraph_dir: PathBuf,
1977 project_root: PathBuf,
1978 ) -> Result<Option<Self>> {
1979 if !options.enabled {
1980 return Ok(None);
1981 }
1982 Self::open(callgraph_dir, project_root).map(Some)
1983 }
1984
1985 pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
1986 let project_key = crate::search_index::artifact_cache_key(&project_root);
1987 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1988 else {
1989 return Err(CallGraphStoreError::Unavailable(
1990 "writer capability denied; use the read-only callgraph opener".to_string(),
1991 ));
1992 };
1993 std::fs::create_dir_all(&callgraph_dir)?;
1994 let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
1998 .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
1999 let OpenedStore { store, root_repair } = Self::open_at_path(
2000 project_root.clone(),
2001 project_key,
2002 sqlite_path,
2003 generation,
2004 true,
2005 Some(Arc::clone(&writer_lease)),
2006 None,
2007 )?;
2008 match root_repair {
2009 OpenRootRepair::NeedsRebuild { .. } => {
2010 log_root_repair_rebuild(&root_repair);
2011 drop(store);
2012 drop(writer_lease);
2013 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2014 let (store, _stats) =
2015 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2016 Ok(store)
2017 }
2018 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
2019 }
2020 }
2021
2022 pub fn open_readonly(
2023 callgraph_dir: PathBuf,
2024 project_root: PathBuf,
2025 ) -> Result<Option<ReadonlyCallGraphStore>> {
2026 let project_key = crate::search_index::artifact_cache_key(&project_root);
2027 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2028 {
2029 let conn = open_readonly_connection(&sqlite_path)?;
2030 if !database_ready(&conn).unwrap_or(false) {
2031 return Ok(None);
2032 }
2033 let marker_label = generation.as_deref().unwrap_or("legacy");
2034 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
2035 return Ok(Some(ReadonlyCallGraphStore::from_inner(
2036 Self::from_connection(
2037 project_root,
2038 project_key,
2039 sqlite_path,
2040 callgraph_dir,
2041 false,
2042 generation,
2043 None,
2044 Some(read_marker),
2045 conn,
2046 ),
2047 )));
2048 }
2049
2050 let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
2051 return Ok(None);
2052 };
2053 crate::slog_warn!(
2054 "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
2055 target.partition.harness,
2056 target.sqlite_path.display()
2057 );
2058 let conn = open_readonly_connection(&target.sqlite_path)?;
2059 if !database_ready(&conn).unwrap_or(false) {
2060 return Ok(None);
2061 }
2062 let marker_label =
2063 legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
2064 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
2065 Ok(Some(ReadonlyCallGraphStore::from_inner(
2066 Self::from_connection(
2067 project_root,
2068 project_key,
2069 target.sqlite_path,
2070 callgraph_dir,
2071 true,
2072 target.generation,
2073 None,
2074 Some(read_marker),
2075 conn,
2076 ),
2077 )))
2078 }
2079
2080 pub fn open_ready_repairing(
2086 callgraph_dir: PathBuf,
2087 project_root: PathBuf,
2088 ) -> Result<Option<Self>> {
2089 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true)
2090 }
2091
2092 pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
2096 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false)
2097 }
2098
2099 pub fn open_ready_no_rebuild(
2100 callgraph_dir: PathBuf,
2101 project_root: PathBuf,
2102 ) -> Result<Option<Self>> {
2103 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true)
2104 }
2105
2106 fn open_ready_with_rebuild_policy(
2107 callgraph_dir: PathBuf,
2108 project_root: PathBuf,
2109 allow_cold_build: bool,
2110 allow_root_repair: bool,
2111 ) -> Result<Option<Self>> {
2112 let project_key = crate::search_index::artifact_cache_key(&project_root);
2113 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2114 else {
2115 return Ok(None);
2116 };
2117 let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2118 else {
2119 return Ok(None);
2120 };
2121 let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
2122 project_root.clone(),
2123 project_key.clone(),
2124 sqlite_path,
2125 generation,
2126 true,
2127 Some(Arc::clone(&writer_lease)),
2128 None,
2129 allow_root_repair,
2130 )?;
2131 match root_repair {
2132 OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
2133 log_root_repair_rebuild(&root_repair);
2134 drop(store);
2135 drop(writer_lease);
2136 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2137 let (store, _stats) =
2138 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2139 Ok(Some(store))
2140 }
2141 OpenRootRepair::NeedsRebuild { .. } => {
2142 if let Some(message) = note_repair_entry(&project_key) {
2143 crate::slog_warn!("{message}");
2144 }
2145 Ok(None)
2146 }
2147 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2148 }
2149 }
2150
2151 pub fn cold_build_with_lease(
2152 callgraph_dir: PathBuf,
2153 project_root: PathBuf,
2154 files: &[PathBuf],
2155 ) -> Result<(Self, ColdBuildStats)> {
2156 Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
2157 }
2158
2159 pub fn cold_build_with_lease_chunked(
2160 callgraph_dir: PathBuf,
2161 project_root: PathBuf,
2162 files: &[PathBuf],
2163 chunk_size: usize,
2164 ) -> Result<(Self, ColdBuildStats)> {
2165 Self::cold_build_with_lease_chunked_inner(
2166 callgraph_dir,
2167 project_root,
2168 files,
2169 chunk_size,
2170 false,
2171 )
2172 }
2173
2174 pub(crate) fn force_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 true,
2186 )
2187 }
2188
2189 fn cold_build_with_lease_chunked_inner(
2190 callgraph_dir: PathBuf,
2191 project_root: PathBuf,
2192 files: &[PathBuf],
2193 chunk_size: usize,
2194 require_new_publication: bool,
2195 ) -> Result<(Self, ColdBuildStats)> {
2196 let project_key = crate::search_index::artifact_cache_key(&project_root);
2197 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2198 else {
2199 let operation = if require_new_publication {
2200 "forced rebuild"
2201 } else {
2202 "cold build"
2203 };
2204 return Err(CallGraphStoreError::Unavailable(format!(
2205 "{operation} could not acquire writer capability"
2206 )));
2207 };
2208 std::fs::create_dir_all(&callgraph_dir)?;
2209 let (stats, generation) = Self::cold_build_publish_locked(
2210 &callgraph_dir,
2211 &project_root,
2212 &project_key,
2213 files,
2214 chunk_size,
2215 Arc::clone(&writer_lease),
2216 )?;
2217 let store = Self::open_generation(
2218 &callgraph_dir,
2219 project_root,
2220 project_key,
2221 generation,
2222 writer_lease,
2223 )?;
2224 Ok((store, stats))
2225 }
2226
2227 pub fn ensure_built_with_lease(
2228 callgraph_dir: PathBuf,
2229 project_root: PathBuf,
2230 files: &[PathBuf],
2231 ) -> Result<(Self, Option<ColdBuildStats>)> {
2232 Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
2233 }
2234
2235 pub fn ensure_built_with_lease_chunked(
2236 callgraph_dir: PathBuf,
2237 project_root: PathBuf,
2238 files: &[PathBuf],
2239 chunk_size: usize,
2240 ) -> Result<(Self, Option<ColdBuildStats>)> {
2241 let project_key = crate::search_index::artifact_cache_key(&project_root);
2242 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2243 else {
2244 return Err(CallGraphStoreError::Unavailable(
2245 "callgraph ensure could not acquire writer capability".to_string(),
2246 ));
2247 };
2248 std::fs::create_dir_all(&callgraph_dir)?;
2249 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2250 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2257 {
2258 let OpenedStore { store, root_repair } = Self::open_at_path(
2259 project_root.clone(),
2260 project_key.clone(),
2261 sqlite_path,
2262 generation,
2263 true,
2264 Some(Arc::clone(&writer_lease)),
2265 None,
2266 )?;
2267 match root_repair {
2268 OpenRootRepair::NeedsRebuild { .. } => {
2269 log_root_repair_rebuild(&root_repair);
2270 drop(store);
2271 let (stats, generation) = Self::cold_build_publish_locked(
2272 &callgraph_dir,
2273 &project_root,
2274 &project_key,
2275 files,
2276 chunk_size,
2277 Arc::clone(&writer_lease),
2278 )?;
2279 let store = Self::open_generation(
2280 &callgraph_dir,
2281 project_root,
2282 project_key,
2283 generation,
2284 writer_lease,
2285 )?;
2286 return Ok((store, Some(stats)));
2287 }
2288 OpenRootRepair::None | OpenRootRepair::ReRooted => {
2289 return Ok((store, None));
2290 }
2291 }
2292 }
2293 if let Some(store) = try_legacy_migration_or_fallback(
2294 &callgraph_dir,
2295 &project_root,
2296 &project_key,
2297 Arc::clone(&writer_lease),
2298 )? {
2299 return Ok((store, None));
2300 }
2301 let (stats, generation) = Self::cold_build_publish_locked(
2302 &callgraph_dir,
2303 &project_root,
2304 &project_key,
2305 files,
2306 chunk_size,
2307 Arc::clone(&writer_lease),
2308 )?;
2309 let store = Self::open_generation(
2310 &callgraph_dir,
2311 project_root,
2312 project_key,
2313 generation,
2314 writer_lease,
2315 )?;
2316 Ok((store, Some(stats)))
2317 }
2318
2319 pub fn migrate_legacy_with_lease(
2326 callgraph_dir: PathBuf,
2327 project_root: PathBuf,
2328 ) -> Result<Option<Self>> {
2329 let project_key = crate::search_index::artifact_cache_key(&project_root);
2330 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2331 else {
2332 return Ok(None);
2333 };
2334 std::fs::create_dir_all(&callgraph_dir)?;
2335 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2336
2337 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2341 {
2342 let OpenedStore { store, root_repair } = Self::open_at_path(
2343 project_root,
2344 project_key,
2345 sqlite_path,
2346 generation,
2347 true,
2348 Some(writer_lease),
2349 None,
2350 )?;
2351 return match root_repair {
2352 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2353 OpenRootRepair::NeedsRebuild { reason, .. } => {
2354 Err(CallGraphStoreError::Unavailable(format!(
2355 "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
2356 )))
2357 }
2358 };
2359 }
2360
2361 let store = try_legacy_migration_or_fallback(
2362 &callgraph_dir,
2363 &project_root,
2364 &project_key,
2365 writer_lease,
2366 )?;
2367 Ok(store.filter(|store| !store.is_legacy_fallback()))
2371 }
2372
2373 fn cold_build_publish_locked(
2384 callgraph_dir: &Path,
2385 project_root: &Path,
2386 project_key: &str,
2387 files: &[PathBuf],
2388 chunk_size: usize,
2389 writer_lease: Arc<crate::root_cache::WriterLease>,
2390 ) -> Result<(ColdBuildStats, String)> {
2391 if let Some((previous_root, remaining)) =
2392 rebuild_cooldown_denial(callgraph_dir, project_key, project_root, Instant::now())
2393 {
2394 return Err(CallGraphStoreError::Unavailable(format!(
2395 "cache key {project_key} was rebuilt for {} too recently; retry {} ms after the per-key cooldown",
2396 previous_root.display(),
2397 remaining.as_millis()
2398 )));
2399 }
2400 let generation = generation_file_name(project_key);
2401 let gen_path = callgraph_dir.join(&generation);
2402 let temp_path = callgraph_dir.join(format!(
2403 "{generation}.tmp.{}.{}",
2404 std::process::id(),
2405 now_nanos()
2406 ));
2407 remove_sqlite_file_set(&temp_path);
2408
2409 let stats = {
2410 let temp_store = Self::open_at_path(
2411 project_root.to_path_buf(),
2412 project_key.to_string(),
2413 temp_path.clone(),
2414 None,
2415 false,
2416 Some(Arc::clone(&writer_lease)),
2417 None,
2418 )?
2419 .store;
2420 let stats = temp_store.cold_build_chunked(files, chunk_size)?;
2421 let _ = temp_store.checkpoint_wal_truncate();
2422 temp_store.prepare_for_atomic_swap()?;
2423 stats
2424 };
2425
2426 notify_cold_build_before_publish_observer();
2427 let publication = publish_if_current(|| {
2428 verify_writer_lease(&writer_lease)?;
2429 remove_sqlite_file_set(&gen_path);
2432 crate::fs_lock::rename_over(&temp_path, &gen_path)?;
2433 crate::fs_lock::sync_parent(&gen_path);
2434 remove_sqlite_sidecars(&gen_path);
2435
2436 notify_cold_build_swap_observer(&temp_path, &gen_path);
2437
2438 verify_writer_lease(&writer_lease)?;
2440 publish_pointer(callgraph_dir, project_key, &generation)?;
2441 gc_old_generations(callgraph_dir, project_key, &generation);
2442 sweep_orphaned_build_temps_store_wide(callgraph_dir);
2446 if let Some(storage_root) = root_storage_dir(callgraph_dir) {
2447 let inspect_root =
2448 storage_root.join(crate::root_cache::RootCacheDomain::Inspect.as_str());
2449 let live_scope_keys = crate::root_cache::live_scope_keys_for_storage(&storage_root);
2450 crate::inspect::cache::sweep_inspect_scope_dirs(&inspect_root, &live_scope_keys);
2451 }
2452 Ok(())
2453 });
2454 if matches!(publication, Err(CallGraphStoreError::Superseded)) {
2455 remove_sqlite_file_set(&temp_path);
2456 }
2457 publication?;
2458 record_successful_rebuild(callgraph_dir, project_key, project_root, Instant::now());
2459 Ok((stats, generation))
2460 }
2461
2462 fn open_generation(
2465 callgraph_dir: &Path,
2466 project_root: PathBuf,
2467 project_key: String,
2468 generation: String,
2469 writer_lease: Arc<crate::root_cache::WriterLease>,
2470 ) -> Result<Self> {
2471 let gen_path = callgraph_dir.join(&generation);
2472 Ok(Self::open_at_path(
2473 project_root,
2474 project_key,
2475 gen_path,
2476 Some(generation),
2477 true,
2478 Some(writer_lease),
2479 None,
2480 )?
2481 .store)
2482 }
2483
2484 pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
2485 let project_key = crate::search_index::artifact_cache_key(project_root);
2486 Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
2489 }
2490
2491 fn open_at_path(
2492 project_root: PathBuf,
2493 project_key: String,
2494 sqlite_path: PathBuf,
2495 generation: Option<String>,
2496 use_wal: bool,
2497 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2498 read_marker: Option<crate::root_cache::ReadMarker>,
2499 ) -> Result<OpenedStore> {
2500 Self::open_at_path_with_root_repair(
2501 project_root,
2502 project_key,
2503 sqlite_path,
2504 generation,
2505 use_wal,
2506 writer_lease,
2507 read_marker,
2508 true,
2509 )
2510 }
2511
2512 fn open_at_path_with_root_repair(
2513 project_root: PathBuf,
2514 project_key: String,
2515 sqlite_path: PathBuf,
2516 generation: Option<String>,
2517 use_wal: bool,
2518 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2519 read_marker: Option<crate::root_cache::ReadMarker>,
2520 allow_root_repair: bool,
2521 ) -> Result<OpenedStore> {
2522 if let Some(lease) = writer_lease.as_ref() {
2523 verify_writer_lease(lease)?;
2524 }
2525 if let Some(parent) = sqlite_path.parent() {
2526 std::fs::create_dir_all(parent)?;
2527 }
2528 let mut conn = Connection::open(&sqlite_path)?;
2529 if use_wal {
2530 configure_connection(&conn)?;
2531 } else {
2532 configure_build_connection(&conn)?;
2533 }
2534 if let Some(lease) = writer_lease.as_ref() {
2535 verify_writer_lease(lease)?;
2536 }
2537 initialize_schema(&conn)?;
2538 if let Some(lease) = writer_lease.as_ref() {
2539 verify_writer_lease(lease)?;
2540 }
2541 let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
2542 let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
2543 (Some(marker), _, _) => Some(marker),
2544 (None, Some(label), Some(cache_dir)) => {
2545 Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
2546 }
2547 (None, _, _) => None,
2548 };
2549 let publication_dir = sqlite_path
2550 .parent()
2551 .map(Path::to_path_buf)
2552 .unwrap_or_default();
2553 let store = Self::from_connection(
2554 project_root,
2555 project_key,
2556 sqlite_path,
2557 publication_dir,
2558 false,
2559 generation,
2560 writer_lease,
2561 read_marker,
2562 conn,
2563 );
2564 Ok(OpenedStore { store, root_repair })
2565 }
2566
2567 fn prepare_for_atomic_swap(&self) -> Result<()> {
2568 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2569 conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
2570 Ok(())
2571 }
2572
2573 fn atomic_swap_checkpoint_sql(&self) -> &'static str {
2574 let protected_reader = self.generation.as_deref().is_some_and(|generation| {
2575 self.sqlite_path
2576 .parent()
2577 .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
2578 });
2579 if protected_reader {
2580 "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
2581 } else {
2582 "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
2583 }
2584 }
2585
2586 fn from_connection(
2587 project_root: PathBuf,
2588 project_key: String,
2589 sqlite_path: PathBuf,
2590 publication_dir: PathBuf,
2591 legacy_fallback: bool,
2592 generation: Option<String>,
2593 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2594 read_marker: Option<crate::root_cache::ReadMarker>,
2595 conn: Connection,
2596 ) -> Self {
2597 let write_metrics = callgraph_write_metrics_for_key(&project_key);
2598 Self {
2599 project_root,
2600 project_key,
2601 sqlite_path,
2602 publication_dir,
2603 legacy_fallback,
2604 generation,
2605 writer_lease,
2606 read_marker,
2607 database_ready: AtomicBool::new(false),
2608 write_metrics,
2609 conn: Mutex::new(conn),
2610 }
2611 }
2612
2613 fn ensure_ready(&self, conn: &Connection) -> Result<()> {
2614 if self.database_ready.load(AtomicOrdering::Acquire) {
2615 return Ok(());
2616 }
2617 ensure_database_ready(conn)?;
2618 self.database_ready.store(true, AtomicOrdering::Release);
2619 Ok(())
2620 }
2621
2622 pub fn project_root(&self) -> &Path {
2623 &self.project_root
2624 }
2625
2626 pub fn project_key(&self) -> &str {
2627 &self.project_key
2628 }
2629
2630 pub fn sqlite_path(&self) -> &Path {
2631 &self.sqlite_path
2632 }
2633
2634 pub(crate) fn projection_generation(&self) -> Option<&str> {
2636 self.generation.as_deref()
2637 }
2638
2639 pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
2641 self.refresh_read_marker()?;
2642 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2643 self.ensure_ready(&conn)?;
2644 projection_write_revision(&conn)
2645 }
2646
2647 pub fn is_legacy_fallback(&self) -> bool {
2650 self.legacy_fallback
2651 }
2652
2653 pub(crate) fn is_legacy_migration(&self) -> bool {
2654 self.generation.as_deref().is_some_and(|generation| {
2655 migration_generation_requires_manifest(generation)
2656 && migration_manifest_valid(&self.publication_dir, generation)
2657 })
2658 }
2659
2660 pub fn writer_epoch_for_test(&self) -> Option<&str> {
2661 self.writer_lease.as_ref().map(|lease| lease.epoch())
2662 }
2663
2664 fn verify_writer_lease(&self) -> Result<()> {
2665 let Some(lease) = self.writer_lease.as_ref() else {
2666 return Err(CallGraphStoreError::Unavailable(
2667 "callgraph store opened read-only; write API is unavailable".to_string(),
2668 ));
2669 };
2670 verify_writer_lease(lease)
2671 }
2672
2673 fn refresh_read_marker(&self) -> Result<()> {
2674 if let Some(marker) = self.read_marker.as_ref() {
2675 marker.touch_if_due()?;
2676 }
2677 Ok(())
2678 }
2679
2680 fn record_commit(&self, total_changes_before: u64, conn: &Connection) {
2681 self.write_metrics
2682 .record_commit(conn.total_changes().saturating_sub(total_changes_before));
2683 }
2684
2685 fn checkpoint_wal_truncate(&self) -> bool {
2686 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2687 checkpoint_wal_truncate(&conn)
2688 }
2689
2690 pub fn is_current(&self) -> bool {
2696 let _ = self.refresh_read_marker();
2697 match (
2698 read_pointer(&self.publication_dir, &self.project_key),
2699 &self.generation,
2700 ) {
2701 (Some(_), _) if self.legacy_fallback => false,
2704 (Some(published), Some(opened)) => &published == opened,
2705 (Some(_), None) => false,
2707 (None, _) => true,
2710 }
2711 }
2712
2713 pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
2714 self.cold_build_chunked(files, 0)
2715 }
2716
2717 pub fn cold_build_chunked(
2718 &self,
2719 files: &[PathBuf],
2720 chunk_size: usize,
2721 ) -> Result<ColdBuildStats> {
2722 let started = Instant::now();
2723 let bench = std::env::var("AFT_BENCH_COLD").is_ok();
2724 macro_rules! phase {
2725 ($label:expr, $t:expr) => {
2726 if bench {
2727 eprintln!(" cold_build[{}]: {} ms", $label, $t.elapsed().as_millis());
2728 let _ = std::io::Write::flush(&mut std::io::stderr());
2729 }
2730 };
2731 }
2732 let files = normalize_file_list(&self.project_root, files)?;
2733
2734 if chunk_size == 0 {
2735 let t = Instant::now();
2736 let build = build_extracts_parallel(&self.project_root, &files);
2737 phase!("extract_parallel", t);
2738 let extracts = build.extracts;
2739 let failures = build.failures;
2740 let node_count = extracts.iter().map(|extract| extract.nodes.len()).sum();
2741
2742 let t = Instant::now();
2743 let index = ProjectIndex::from_extracts(&self.project_root, &extracts);
2744 phase!("build_index", t);
2745 let t = Instant::now();
2746 let mut resolved_refs = Vec::new();
2747 for extract in &extracts {
2748 for raw_ref in &extract.raw_refs {
2749 resolved_refs.push(resolve_ref(raw_ref.clone(), &index)?);
2750 }
2751 }
2752 phase!("resolve_refs", t);
2753 let ref_count = resolved_refs.len();
2754 let edge_count = resolved_refs
2755 .iter()
2756 .filter(|item| item.edge.is_some())
2757 .count();
2758
2759 let t = Instant::now();
2760 self.verify_writer_lease()?;
2761 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2762 let total_changes_before = conn.total_changes();
2763 let tx = conn.transaction()?;
2764 clear_tables(&tx)?;
2765 insert_meta(&tx)?;
2766 drop_cold_build_secondary_indexes(&tx)?;
2767 {
2768 let workspace_root = self.project_root.display().to_string();
2769 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2770 for extract in &extracts {
2771 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
2772 }
2773 for failure in &failures {
2774 insert_backend_state_prepared(
2775 &mut inserts.backend_state,
2776 &workspace_root,
2777 &failure.rel_path,
2778 failure
2779 .freshness
2780 .as_ref()
2781 .map(|freshness| &freshness.content_hash),
2782 "stale",
2783 )?;
2784 }
2785 for resolved in &resolved_refs {
2786 insert_resolved_ref_prepared(&mut inserts, resolved)?;
2787 }
2788 }
2789 create_cold_build_secondary_indexes(&tx)?;
2790 let supplemental_edge_count =
2791 insert_method_dispatch_edges(&tx, &self.project_root, None)?;
2792 set_meta_ready(&tx, true)?;
2793 tx.commit()?;
2794 self.record_commit(total_changes_before, &conn);
2795 phase!("sqlite_insert", t);
2796
2797 let elapsed_ms = started.elapsed().as_millis();
2798 crate::slog_info!(
2799 "perf callgraph_store cold_build: files={} nodes={} refs={} edges={} ms={}",
2800 extracts.len(),
2801 node_count,
2802 ref_count,
2803 edge_count + supplemental_edge_count,
2804 elapsed_ms
2805 );
2806 return Ok(ColdBuildStats {
2807 files: extracts.len(),
2808 nodes: node_count,
2809 refs: ref_count,
2810 edges: edge_count + supplemental_edge_count,
2811 failed_files: failures
2812 .into_iter()
2813 .map(|failure| failure.rel_path)
2814 .collect(),
2815 elapsed_ms,
2816 });
2817 }
2818
2819 let t = Instant::now();
2822 self.verify_writer_lease()?;
2823 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2824 let total_changes_before = conn.total_changes();
2825 let tx = conn.transaction()?;
2826 clear_tables(&tx)?;
2827 insert_meta(&tx)?;
2828 drop_cold_build_secondary_indexes(&tx)?;
2829
2830 let mut all_raw_refs = Vec::new();
2831 let mut failures = Vec::new();
2832 let mut node_count = 0;
2833 let mut files_parsed = 0;
2834
2835 let mut persistent_call_data = Vec::new();
2836 let mut file_to_call_data_index = HashMap::new();
2837 let mut files_index = HashMap::new();
2838
2839 let workspace_root = self.project_root.display().to_string();
2840
2841 {
2842 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2843 for chunk in files.chunks(chunk_size) {
2844 let build = build_extracts_parallel(&self.project_root, chunk);
2845 failures.extend(build.failures.clone());
2846
2847 for extract in build.extracts {
2848 files_parsed += 1;
2849 node_count += extract.nodes.len();
2850 insert_file_extract_prepared(&mut inserts, &workspace_root, &extract)?;
2851
2852 let db_file_index = DbFileIndex::from_extract(&self.project_root, &extract);
2853 files_index.insert(extract.rel_path.clone(), db_file_index);
2854
2855 persistent_call_data.push(extract.data);
2856 let idx = persistent_call_data.len() - 1;
2857 file_to_call_data_index.insert(extract.rel_path.clone(), idx);
2858
2859 all_raw_refs.push((extract.rel_path, extract.raw_refs));
2860 }
2861 for failure in &build.failures {
2862 insert_backend_state_prepared(
2863 &mut inserts.backend_state,
2864 &workspace_root,
2865 &failure.rel_path,
2866 failure
2867 .freshness
2868 .as_ref()
2869 .map(|freshness| &freshness.content_hash),
2870 "stale",
2871 )?;
2872 }
2873 }
2874 }
2875
2876 let mut caller_data = HashMap::new();
2877 for (rel_path, idx) in &file_to_call_data_index {
2878 caller_data.insert(rel_path.clone(), &persistent_call_data[*idx]);
2879 }
2880 let indexed_caller_files = files_index.keys().cloned().collect::<BTreeSet<_>>();
2881 let index = ProjectIndex::from_parts(
2882 &self.project_root,
2883 files_index,
2884 caller_data,
2885 WorkspaceCratePrefixCache::default(),
2886 );
2887
2888 let mut resolved_refs = Vec::new();
2889 for (_, raw_refs) in all_raw_refs {
2890 for raw_ref in raw_refs {
2891 resolved_refs.push(resolve_ref(raw_ref, &index)?);
2892 }
2893 }
2894
2895 let ref_count = resolved_refs.len();
2896 let edge_count = resolved_refs
2897 .iter()
2898 .filter(|item| item.edge.is_some())
2899 .count();
2900
2901 {
2902 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2903 for resolved in &resolved_refs {
2904 insert_resolved_ref_prepared(&mut inserts, resolved)?;
2905 }
2906 }
2907 create_cold_build_secondary_indexes(&tx)?;
2908 let supplemental_edge_count = insert_method_dispatch_edges_chunked(
2909 &tx,
2910 &self.project_root,
2911 &indexed_caller_files,
2912 chunk_size,
2913 )?;
2914 set_meta_ready(&tx, true)?;
2915 bump_projection_write_revision(&tx)?;
2916 tx.commit()?;
2917 self.record_commit(total_changes_before, &conn);
2918 phase!("sqlite_insert", t);
2919
2920 let elapsed_ms = started.elapsed().as_millis();
2921 crate::slog_info!(
2922 "perf callgraph_store cold_build (chunked): files={} nodes={} refs={} edges={} ms={}",
2923 files_parsed,
2924 node_count,
2925 ref_count,
2926 edge_count + supplemental_edge_count,
2927 elapsed_ms
2928 );
2929 Ok(ColdBuildStats {
2930 files: files_parsed,
2931 nodes: node_count,
2932 refs: ref_count,
2933 edges: edge_count + supplemental_edge_count,
2934 failed_files: failures
2935 .into_iter()
2936 .map(|failure| failure.rel_path)
2937 .collect(),
2938 elapsed_ms,
2939 })
2940 }
2941
2942 pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
2943 self.refresh_files_with_workspace_crate_prefix_cache(
2944 changed_files,
2945 WorkspaceCratePrefixCache::default(),
2946 )
2947 }
2948
2949 fn refresh_files_with_workspace_crate_prefix_cache(
2950 &self,
2951 changed_files: &[PathBuf],
2952 workspace_crate_prefixes: WorkspaceCratePrefixCache,
2953 ) -> Result<IncrementalStats> {
2954 let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
2955 changed_files,
2956 workspace_crate_prefixes,
2957 )?;
2958 if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
2959 eprintln!("refresh_files phases: {}", profile.report());
2960 }
2961 Ok(stats)
2962 }
2963
2964 #[doc(hidden)]
2966 pub fn refresh_files_profiled(
2967 &self,
2968 changed_files: &[PathBuf],
2969 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2970 self.refresh_files_profiled_with_workspace_crate_prefix_cache(
2971 changed_files,
2972 WorkspaceCratePrefixCache::default(),
2973 )
2974 }
2975
2976 fn refresh_files_profiled_with_workspace_crate_prefix_cache(
2977 &self,
2978 changed_files: &[PathBuf],
2979 workspace_crate_prefixes: WorkspaceCratePrefixCache,
2980 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2981 let total_started = Instant::now();
2982 let mut profile = RefreshFilesProfile::default();
2983 self.verify_writer_lease()?;
2984 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2985 let total_changes_before = conn.total_changes();
2986 let tx = conn.transaction()?;
2987 ensure_database_ready(&tx)?;
2988 let mut changed = Vec::new();
2989 let mut surface_changed = BTreeSet::new();
2990 let mut deleted = BTreeSet::new();
2991 let mut own_refresh = BTreeSet::new();
2992 let mut candidate_own_refresh = BTreeSet::new();
2993 let mut unchanged_extracts = 0usize;
2994 let mut selected_ref_ids = BTreeSet::new();
2995 let mut selected_refs_by_caller = BTreeMap::new();
2996 let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
2997
2998 for input in changed_files {
2999 let abs_path = normalize_file_path(&self.project_root, input)?;
3000 let rel_path = relative_path(&self.project_root, &abs_path);
3001 changed.push(rel_path.clone());
3002 let old_row = load_file_row(&tx, &rel_path)?;
3003 if !abs_path.exists() {
3004 if old_row.is_some() {
3005 surface_changed.insert(rel_path.clone());
3006 deleted.insert(rel_path.clone());
3007 let started = Instant::now();
3008 let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
3009 profile.dependency_selection += started.elapsed();
3010 record_dependent_refs(
3011 &mut selected_ref_ids,
3012 &mut selected_refs_by_caller,
3013 dependent_refs,
3014 );
3015 let started = Instant::now();
3016 delete_file_rows(&tx, &rel_path)?;
3017 clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
3018 profile.row_deletes += started.elapsed();
3019 }
3020 continue;
3021 }
3022
3023 if let Some(row) = &old_row {
3024 match cache_freshness::verify_file(&abs_path, &row.freshness) {
3025 FreshnessVerdict::HotFresh => continue,
3026 FreshnessVerdict::ContentFresh {
3027 new_mtime,
3028 new_size,
3029 } => {
3030 update_file_fresh_metadata(
3031 &tx,
3032 &self.project_root,
3033 &rel_path,
3034 &row.freshness.content_hash,
3035 new_mtime,
3036 new_size,
3037 )?;
3038 continue;
3039 }
3040 FreshnessVerdict::Deleted => {
3041 surface_changed.insert(rel_path.clone());
3042 deleted.insert(rel_path.clone());
3043 let started = Instant::now();
3044 let dependent_refs =
3045 ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
3046 profile.dependency_selection += started.elapsed();
3047 record_dependent_refs(
3048 &mut selected_ref_ids,
3049 &mut selected_refs_by_caller,
3050 dependent_refs,
3051 );
3052 let started = Instant::now();
3053 delete_file_rows(&tx, &rel_path)?;
3054 clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
3055 profile.row_deletes += started.elapsed();
3056 continue;
3057 }
3058 FreshnessVerdict::Stale => {}
3059 }
3060 }
3061
3062 let started = Instant::now();
3063 let extract = build_file_extract(&self.project_root, &abs_path)?;
3064 profile.parse += started.elapsed();
3065 let surface_is_changed = old_row
3066 .as_ref()
3067 .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
3068 .unwrap_or(true);
3069 if surface_is_changed {
3070 surface_changed.insert(rel_path.clone());
3071 let started = Instant::now();
3072 let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
3073 profile.dependency_selection += started.elapsed();
3074 record_dependent_refs(
3075 &mut selected_ref_ids,
3076 &mut selected_refs_by_caller,
3077 dependent_refs,
3078 );
3079 }
3080 candidate_own_refresh.insert(rel_path.clone());
3081 changed_extracts.insert(rel_path, extract);
3082 }
3083
3084 let dependency_selected_refs = selected_ref_ids.len();
3085 let mut touched_callers: BTreeSet<String> =
3086 selected_refs_by_caller.keys().cloned().collect();
3087 touched_callers.extend(candidate_own_refresh.iter().cloned());
3088
3089 let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
3090 for rel_path in &touched_callers {
3091 if deleted.contains(rel_path) {
3092 continue;
3093 }
3094 if let Some(extract) = changed_extracts.get(rel_path) {
3095 caller_extracts.insert(rel_path.clone(), extract.clone());
3096 continue;
3097 }
3098 let abs_path = self.project_root.join(rel_path);
3099 if abs_path.exists() {
3100 let started = Instant::now();
3101 let extract = build_file_extract(&self.project_root, &abs_path)?;
3102 profile.dependent_parse += started.elapsed();
3103 caller_extracts.insert(rel_path.clone(), extract);
3104 }
3105 }
3106
3107 let started = Instant::now();
3108 let index = ProjectIndex::from_db_and_callers(
3109 &tx,
3110 &self.project_root,
3111 &caller_extracts,
3112 workspace_crate_prefixes,
3113 )?;
3114 profile.index_load += started.elapsed();
3115
3116 for rel_path in &candidate_own_refresh {
3117 let Some(extract) = changed_extracts.get(rel_path) else {
3118 continue;
3119 };
3120 if !write_amplification_baseline_enabled()
3121 && stored_extract_matches(&tx, rel_path, extract, &index)?
3122 {
3123 unchanged_extracts += 1;
3124 update_file_fresh_metadata(
3125 &tx,
3126 &self.project_root,
3127 rel_path,
3128 &extract.freshness.content_hash,
3129 extract.freshness.mtime,
3130 extract.freshness.size,
3131 )?;
3132 continue;
3133 }
3134
3135 own_refresh.insert(rel_path.clone());
3136 let started = Instant::now();
3137 delete_file_rows(&tx, rel_path)?;
3138 profile.row_deletes += started.elapsed();
3139 let started = Instant::now();
3140 insert_file_extract(&tx, &self.project_root, extract)?;
3141 profile.row_inserts += started.elapsed();
3142 }
3143
3144 let dependency_callers = touched_callers
3145 .iter()
3146 .filter(|rel_path| {
3147 !deleted.contains(*rel_path) && !candidate_own_refresh.contains(*rel_path)
3148 })
3149 .cloned()
3150 .collect::<Vec<_>>();
3151 for rel_path in dependency_callers {
3152 let Some(extract) = caller_extracts.get(&rel_path) else {
3153 continue;
3154 };
3155 if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
3156 continue;
3157 }
3158
3159 own_refresh.insert(rel_path.clone());
3160 let started = Instant::now();
3161 delete_file_rows(&tx, &rel_path)?;
3162 profile.row_deletes += started.elapsed();
3163 let started = Instant::now();
3164 insert_file_extract(&tx, &self.project_root, extract)?;
3165 profile.row_inserts += started.elapsed();
3166 }
3167 let started = Instant::now();
3168 for rel_path in &touched_callers {
3169 if deleted.contains(rel_path) {
3170 continue;
3171 }
3172 let Some(extract) = caller_extracts.get(rel_path) else {
3173 continue;
3174 };
3175 if own_refresh.contains(rel_path) {
3176 delete_refs_for_caller(&tx, rel_path)?;
3177 for raw_ref in &extract.raw_refs {
3178 let resolved = resolve_ref(raw_ref.clone(), &index)?;
3179 insert_resolved_ref(&tx, &resolved)?;
3180 }
3181 continue;
3182 }
3183
3184 let selected_for_caller = selected_refs_by_caller
3185 .get(rel_path)
3186 .cloned()
3187 .unwrap_or_default();
3188 delete_ref_ids(&tx, &selected_for_caller)?;
3189 for raw_ref in &extract.raw_refs {
3190 if selected_for_caller.contains(&raw_ref.ref_id) {
3191 let resolved = resolve_ref(raw_ref.clone(), &index)?;
3192 insert_resolved_ref(&tx, &resolved)?;
3193 }
3194 }
3195 }
3196 profile.ref_resolution += started.elapsed();
3197
3198 let started = Instant::now();
3199 delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
3200 insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
3201 profile.method_dispatch += started.elapsed();
3202
3203 bump_projection_write_revision(&tx)?;
3204 let started = Instant::now();
3205 commit_incremental_if_current(tx)?;
3206 self.record_commit(total_changes_before, &conn);
3207 profile.commit += started.elapsed();
3208 profile.total = total_started.elapsed();
3209 Ok((
3210 IncrementalStats {
3211 changed_files: changed,
3212 surface_changed: surface_changed.into_iter().collect(),
3213 deleted_files: deleted.into_iter().collect(),
3214 dependency_selected_refs,
3215 refreshed_own_files: own_refresh.len(),
3216 unchanged_extract_files: unchanged_extracts,
3217 },
3218 profile,
3219 ))
3220 }
3221
3222 pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
3223 self.cold_build(current_files)
3224 }
3225
3226 pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
3227 self.verify_writer_lease()?;
3228 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3229 let total_changes_before = conn.total_changes();
3230 let tx = conn.transaction()?;
3231 let mut marked = Vec::new();
3232 for path in files {
3233 let abs_path = normalize_file_path(&self.project_root, path)?;
3234 let rel_path = relative_path(&self.project_root, &abs_path);
3235 let freshness = cache_freshness::collect(&abs_path).ok();
3236 mark_backend_state(
3237 &tx,
3238 &self.project_root,
3239 &rel_path,
3240 freshness.as_ref().map(|freshness| &freshness.content_hash),
3241 "stale",
3242 )?;
3243 marked.push(rel_path);
3244 }
3245 bump_projection_write_revision(&tx)?;
3246 tx.commit()?;
3247 self.record_commit(total_changes_before, &conn);
3248 marked.sort();
3249 marked.dedup();
3250 Ok(marked)
3251 }
3252
3253 pub fn stale_files(&self) -> Result<Vec<String>> {
3254 self.refresh_read_marker()?;
3255 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3256 let mut stmt = conn.prepare(
3257 "SELECT DISTINCT file_path FROM backend_file_state
3258 WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
3259 ORDER BY file_path",
3260 )?;
3261 let rows = stmt.query_map(
3262 params![BACKEND_TREESITTER, self.project_root.display().to_string()],
3263 |row| row.get::<_, String>(0),
3264 )?;
3265 rows.collect::<std::result::Result<Vec<_>, _>>()
3266 .map_err(Into::into)
3267 }
3268
3269 pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
3270 self.refresh_read_marker()?;
3271 let rel_path = relative_path(
3272 &self.project_root,
3273 &normalize_file_path(&self.project_root, file)?,
3274 );
3275 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3276 conn.query_row(
3277 "SELECT status FROM backend_file_state
3278 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
3279 ORDER BY updated_at DESC LIMIT 1",
3280 params![
3281 BACKEND_TREESITTER,
3282 self.project_root.display().to_string(),
3283 rel_path
3284 ],
3285 |row| row.get(0),
3286 )
3287 .optional()
3288 .map_err(Into::into)
3289 }
3290
3291 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3292 self.refresh_read_marker()?;
3293 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3294 self.ensure_ready(&conn)?;
3295 edge_snapshot_with_conn(&conn)
3296 }
3297
3298 pub fn indexed_file_count(&self) -> Result<usize> {
3299 self.refresh_read_marker()?;
3300 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3301 self.ensure_ready(&conn)?;
3302 indexed_file_count(&conn)
3303 }
3304
3305 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3306 self.refresh_read_marker()?;
3307 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3308 let rel_path = relative_path(&self.project_root, &abs_path);
3309 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3310 self.ensure_ready(&conn)?;
3311 resolve_node_for_rel(&conn, &rel_path, symbol)
3312 }
3313
3314 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3319 self.refresh_read_marker()?;
3320 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3321 let rel_path = relative_path(&self.project_root, &abs_path);
3322 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3323 self.ensure_ready(&conn)?;
3324 nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
3325 }
3326
3327 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3329 self.refresh_read_marker()?;
3330 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3331 self.ensure_ready(&conn)?;
3332 nodes_matching_symbol(&conn, symbol)
3333 }
3334
3335 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3337 self.refresh_read_marker()?;
3338 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3339 let rel_path = relative_path(&self.project_root, &abs_path);
3340 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3341 self.ensure_ready(&conn)?;
3342 direct_callers_for_tuple(&conn, &rel_path, symbol)
3343 }
3344
3345 pub fn direct_caller_counts_of(
3347 &self,
3348 targets: &[(String, String)],
3349 ) -> Result<HashMap<(String, String), usize>> {
3350 if targets.is_empty() {
3351 return Ok(HashMap::new());
3352 }
3353 self.refresh_read_marker()?;
3354 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3355 self.ensure_ready(&conn)?;
3356 direct_caller_counts_for_tuples(&conn, targets)
3357 }
3358
3359 pub fn callers_of(
3360 &self,
3361 file_rel: &Path,
3362 symbol: &str,
3363 depth: usize,
3364 ) -> Result<StoreCallersResult> {
3365 let target = self.node_for(file_rel, symbol)?;
3366 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3367 self.ensure_ready(&conn)?;
3368 let effective_depth = depth.max(1);
3369 let mut visited = HashSet::new();
3370 let mut callers = Vec::new();
3371 let mut depth_limited = false;
3372 let mut truncated = 0usize;
3373 collect_callers_recursive(
3374 &conn,
3375 &target.file,
3376 &target.symbol,
3377 effective_depth,
3378 0,
3379 &mut visited,
3380 &mut callers,
3381 &mut depth_limited,
3382 &mut truncated,
3383 )?;
3384 Ok(StoreCallersResult {
3385 target,
3386 callers,
3387 scanned_files: indexed_file_count(&conn)?,
3388 depth_limited,
3389 truncated,
3390 })
3391 }
3392
3393 pub fn impact_of(
3394 &self,
3395 file_rel: &Path,
3396 symbol: &str,
3397 depth: usize,
3398 ) -> Result<StoreImpactResult> {
3399 let callers = self.callers_of(file_rel, symbol, depth)?;
3400 let target_parameters = callers
3401 .target
3402 .signature
3403 .as_deref()
3404 .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
3405 .unwrap_or_default();
3406 let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
3407 for site in &callers.callers {
3408 source_lines_by_file
3409 .entry(site.caller.file.clone())
3410 .or_insert_with(|| {
3411 read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
3412 });
3413 }
3414 let enriched = callers
3415 .callers
3416 .iter()
3417 .map(|site| StoreImpactCaller {
3418 site: site.clone(),
3419 signature: site.caller.signature.clone(),
3420 is_entry_point: site.caller.is_entry_point,
3421 call_expression: source_lines_by_file
3422 .get(&site.caller.file)
3423 .and_then(|lines| lines.as_ref())
3424 .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
3425 .cloned(),
3426 parameters: site
3427 .caller
3428 .signature
3429 .as_deref()
3430 .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
3431 .unwrap_or_default(),
3432 })
3433 .collect();
3434 Ok(StoreImpactResult {
3435 target: callers.target,
3436 parameters: target_parameters,
3437 callers: enriched,
3438 depth_limited: callers.depth_limited,
3439 truncated: callers.truncated,
3440 })
3441 }
3442
3443 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3444 self.refresh_read_marker()?;
3445 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3446 self.ensure_ready(&conn)?;
3447 outgoing_calls_for_node(&conn, node)
3448 }
3449
3450 pub fn outgoing_calls_for_symbols(
3452 &self,
3453 sources: &[(String, String)],
3454 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3455 if sources.is_empty() {
3456 return Ok(HashMap::new());
3457 }
3458 self.refresh_read_marker()?;
3459 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3460 self.ensure_ready(&conn)?;
3461 outgoing_calls_for_symbol_tuples(&conn, sources)
3462 }
3463
3464 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3466 self.refresh_read_marker()?;
3467 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3468 self.ensure_ready(&conn)?;
3469 resolved_self_calls_for_node(&conn, node)
3470 }
3471
3472 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3473 self.refresh_read_marker()?;
3474 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3475 self.ensure_ready(&conn)?;
3476 unresolved_calls_for_node(&conn, node)
3477 }
3478
3479 pub fn call_tree(
3480 &self,
3481 file_rel: &Path,
3482 symbol: &str,
3483 max_depth: usize,
3484 ) -> Result<callgraph::CallTreeNode> {
3485 let node = self.node_for(file_rel, symbol)?;
3486 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3487 self.ensure_ready(&conn)?;
3488 let mut visited = HashSet::new();
3489 call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
3490 }
3491
3492 pub fn trace_to(
3493 &self,
3494 file_rel: &Path,
3495 symbol: &str,
3496 max_depth: usize,
3497 ) -> Result<callgraph::TraceToResult> {
3498 let target = self.node_for(file_rel, symbol)?;
3499 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3500 self.ensure_ready(&conn)?;
3501 let effective_max = if max_depth == 0 { 10 } else { max_depth };
3502
3503 #[derive(Clone)]
3504 struct PathElem {
3505 node: StoreNode,
3506 }
3507
3508 let initial = vec![PathElem {
3509 node: target.clone(),
3510 }];
3511 let mut complete_paths = Vec::new();
3512 if target.is_entry_point {
3513 complete_paths.push(initial.clone());
3514 }
3515
3516 let mut queue = vec![(initial, 0usize)];
3517 let mut max_depth_reached = false;
3518 let mut truncated_paths = 0usize;
3519
3520 while let Some((path, depth)) = queue.pop() {
3521 if depth >= effective_max {
3522 max_depth_reached = true;
3523 continue;
3524 }
3525 let Some(current) = path.last() else {
3526 continue;
3527 };
3528 let callers =
3529 direct_callers_for_tuple(&conn, ¤t.node.file, ¤t.node.symbol)?;
3530 if callers.is_empty() {
3531 if path.len() > 1 {
3532 truncated_paths += 1;
3533 }
3534 continue;
3535 }
3536
3537 let mut has_new_path = false;
3538 for site in callers {
3539 if path.iter().any(|elem| {
3540 elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
3541 }) {
3542 continue;
3543 }
3544 has_new_path = true;
3545 let mut new_path = path.clone();
3546 new_path.push(PathElem {
3547 node: site.caller.clone(),
3548 });
3549 if site.caller.is_entry_point {
3550 complete_paths.push(new_path.clone());
3551 }
3552 queue.push((new_path, depth + 1));
3553 }
3554 if !has_new_path && path.len() > 1 {
3555 truncated_paths += 1;
3556 }
3557 }
3558
3559 let mut paths: Vec<callgraph::TracePath> = complete_paths
3560 .into_iter()
3561 .map(|mut elems| {
3562 elems.reverse();
3563 let hops = elems
3564 .iter()
3565 .enumerate()
3566 .map(|(index, elem)| callgraph::TraceHop {
3567 symbol: elem.node.symbol.clone(),
3568 file: elem.node.file.clone(),
3569 line: elem.node.line,
3570 signature: elem.node.signature.clone(),
3571 is_entry_point: index == 0 && elem.node.is_entry_point,
3572 })
3573 .collect();
3574 callgraph::TracePath { hops }
3575 })
3576 .collect();
3577 paths.sort_by(|left, right| {
3578 let left_entry = left
3579 .hops
3580 .first()
3581 .map(|hop| hop.symbol.as_str())
3582 .unwrap_or("");
3583 let right_entry = right
3584 .hops
3585 .first()
3586 .map(|hop| hop.symbol.as_str())
3587 .unwrap_or("");
3588 left_entry
3589 .cmp(right_entry)
3590 .then(left.hops.len().cmp(&right.hops.len()))
3591 });
3592 let entry_points_found = paths
3593 .iter()
3594 .filter_map(|path| path.hops.first())
3595 .filter(|hop| hop.is_entry_point)
3596 .map(|hop| (hop.file.clone(), hop.symbol.clone()))
3597 .collect::<HashSet<_>>()
3598 .len();
3599
3600 Ok(callgraph::TraceToResult {
3601 target_symbol: target.symbol,
3602 target_file: target.file,
3603 total_paths: paths.len(),
3604 paths,
3605 entry_points_found,
3606 max_depth_reached,
3607 truncated_paths,
3608 })
3609 }
3610
3611 pub fn trace_to_symbol_candidates(
3612 &self,
3613 to_symbol: &str,
3614 ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
3615 self.refresh_read_marker()?;
3616 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3617 self.ensure_ready(&conn)?;
3618 let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
3619 for node in nodes_matching_symbol(&conn, to_symbol)? {
3620 candidates_by_file
3621 .entry(node.file)
3622 .and_modify(|line| *line = (*line).min(node.line))
3623 .or_insert(node.line);
3624 }
3625 let mut candidates: Vec<_> = candidates_by_file
3626 .into_iter()
3627 .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
3628 .collect();
3629 candidates
3630 .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
3631 Ok(candidates)
3632 }
3633
3634 pub fn trace_to_symbol(
3635 &self,
3636 file_rel: &Path,
3637 symbol: &str,
3638 to_symbol: &str,
3639 to_file: Option<&Path>,
3640 max_depth: usize,
3641 ) -> Result<callgraph::TraceToSymbolResult> {
3642 let origin = self.node_for(file_rel, symbol)?;
3643 let target_file = to_file
3644 .map(|path| normalize_file_path(&self.project_root, path))
3645 .transpose()?
3646 .map(|path| relative_path(&self.project_root, &path));
3647 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3648 self.ensure_ready(&conn)?;
3649 let effective_max = if max_depth == 0 {
3650 10
3651 } else {
3652 max_depth.min(16)
3653 };
3654
3655 let start_hop = trace_to_symbol_hop(&origin);
3656 if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
3657 return Ok(callgraph::TraceToSymbolResult {
3658 path: Some(vec![start_hop]),
3659 complete: true,
3660 reason: None,
3661 });
3662 }
3663
3664 let mut queue = VecDeque::new();
3665 queue.push_back((origin.clone(), vec![start_hop], 0usize));
3666 let mut visited = HashSet::new();
3667 visited.insert((origin.file.clone(), origin.symbol.clone()));
3668 let mut max_depth_exhausted = false;
3669
3670 while let Some((current, path, depth)) = queue.pop_front() {
3671 let callees = outgoing_calls_for_node(&conn, ¤t)?
3672 .into_iter()
3673 .filter_map(|site| site.target)
3674 .collect::<Vec<_>>();
3675
3676 if depth >= effective_max {
3677 if callees
3678 .iter()
3679 .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
3680 {
3681 max_depth_exhausted = true;
3682 }
3683 continue;
3684 }
3685
3686 for callee in callees {
3687 if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
3688 continue;
3689 }
3690 let mut next_path = path.clone();
3691 next_path.push(trace_to_symbol_hop(&callee));
3692 if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
3693 return Ok(callgraph::TraceToSymbolResult {
3694 path: Some(next_path),
3695 complete: true,
3696 reason: None,
3697 });
3698 }
3699 queue.push_back((callee, next_path, depth + 1));
3700 }
3701 }
3702
3703 if max_depth_exhausted {
3704 Ok(callgraph::TraceToSymbolResult {
3705 path: None,
3706 complete: false,
3707 reason: Some("max_depth_exhausted".to_string()),
3708 })
3709 } else {
3710 Ok(callgraph::TraceToSymbolResult {
3711 path: None,
3712 complete: true,
3713 reason: Some("no_path_found".to_string()),
3714 })
3715 }
3716 }
3717}
3718
3719impl ReadonlyCallGraphStore {
3720 fn from_inner(inner: CallGraphStore) -> Self {
3721 Self { inner }
3722 }
3723
3724 pub fn project_root(&self) -> &Path {
3725 self.inner.project_root()
3726 }
3727
3728 pub fn project_key(&self) -> &str {
3729 self.inner.project_key()
3730 }
3731
3732 pub fn sqlite_path(&self) -> &Path {
3733 self.inner.sqlite_path()
3734 }
3735
3736 pub(crate) fn projection_generation(&self) -> Option<&str> {
3737 self.inner.projection_generation()
3738 }
3739
3740 pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
3741 self.inner.projection_write_revision()
3742 }
3743
3744 pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
3747 crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
3748 }
3749
3750 pub fn is_legacy_fallback(&self) -> bool {
3752 self.inner.is_legacy_fallback()
3753 }
3754
3755 pub fn is_current(&self) -> bool {
3756 self.inner.is_current()
3757 }
3758
3759 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3760 self.inner.edge_snapshot()
3761 }
3762
3763 pub fn indexed_file_count(&self) -> Result<usize> {
3764 self.inner.indexed_file_count()
3765 }
3766
3767 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3768 self.inner.node_for(file_rel, symbol)
3769 }
3770
3771 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3772 self.inner.nodes_for(file_rel, symbol)
3773 }
3774
3775 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3776 self.inner.nodes_matching(symbol)
3777 }
3778
3779 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3780 self.inner.direct_callers_of(file_rel, symbol)
3781 }
3782
3783 pub fn direct_caller_counts_of(
3784 &self,
3785 targets: &[(String, String)],
3786 ) -> Result<HashMap<(String, String), usize>> {
3787 self.inner.direct_caller_counts_of(targets)
3788 }
3789
3790 pub fn callers_of(
3791 &self,
3792 file_rel: &Path,
3793 symbol: &str,
3794 depth: usize,
3795 ) -> Result<StoreCallersResult> {
3796 self.inner.callers_of(file_rel, symbol, depth)
3797 }
3798
3799 pub fn impact_of(
3800 &self,
3801 file_rel: &Path,
3802 symbol: &str,
3803 depth: usize,
3804 ) -> Result<StoreImpactResult> {
3805 self.inner.impact_of(file_rel, symbol, depth)
3806 }
3807
3808 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3809 self.inner.outgoing_calls_of(node)
3810 }
3811
3812 pub fn outgoing_calls_for_symbols(
3813 &self,
3814 sources: &[(String, String)],
3815 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3816 self.inner.outgoing_calls_for_symbols(sources)
3817 }
3818
3819 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3820 self.inner.resolved_self_calls_of(node)
3821 }
3822
3823 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3824 self.inner.unresolved_calls_of(node)
3825 }
3826
3827 pub fn call_tree(
3828 &self,
3829 file_rel: &Path,
3830 symbol: &str,
3831 depth: usize,
3832 ) -> Result<callgraph::CallTreeNode> {
3833 self.inner.call_tree(file_rel, symbol, depth)
3834 }
3835
3836 pub fn trace_to(
3837 &self,
3838 file_rel: &Path,
3839 symbol: &str,
3840 max_depth: usize,
3841 ) -> Result<callgraph::TraceToResult> {
3842 self.inner.trace_to(file_rel, symbol, max_depth)
3843 }
3844
3845 pub fn trace_to_symbol_candidates(
3846 &self,
3847 to_symbol: &str,
3848 ) -> Result<Vec<TraceToSymbolCandidate>> {
3849 self.inner.trace_to_symbol_candidates(to_symbol)
3850 }
3851
3852 pub fn trace_to_symbol(
3853 &self,
3854 file_rel: &Path,
3855 symbol: &str,
3856 to_symbol: &str,
3857 to_file: Option<&Path>,
3858 max_depth: usize,
3859 ) -> Result<callgraph::TraceToSymbolResult> {
3860 self.inner
3861 .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3862 }
3863}
3864
3865impl CallGraphRead for CallGraphStore {
3866 fn project_root(&self) -> &Path {
3867 CallGraphStore::project_root(self)
3868 }
3869 fn project_key(&self) -> &str {
3870 CallGraphStore::project_key(self)
3871 }
3872 fn sqlite_path(&self) -> &Path {
3873 CallGraphStore::sqlite_path(self)
3874 }
3875 fn is_current(&self) -> bool {
3876 CallGraphStore::is_current(self)
3877 }
3878 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3879 CallGraphStore::edge_snapshot(self)
3880 }
3881 fn indexed_file_count(&self) -> Result<usize> {
3882 CallGraphStore::indexed_file_count(self)
3883 }
3884 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3885 CallGraphStore::node_for(self, file_rel, symbol)
3886 }
3887 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3888 CallGraphStore::nodes_for(self, file_rel, symbol)
3889 }
3890 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3891 CallGraphStore::nodes_matching(self, symbol)
3892 }
3893 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3894 CallGraphStore::direct_callers_of(self, file_rel, symbol)
3895 }
3896 fn direct_caller_counts_of(
3897 &self,
3898 targets: &[(String, String)],
3899 ) -> Result<HashMap<(String, String), usize>> {
3900 CallGraphStore::direct_caller_counts_of(self, targets)
3901 }
3902 fn callers_of(
3903 &self,
3904 file_rel: &Path,
3905 symbol: &str,
3906 depth: usize,
3907 ) -> Result<StoreCallersResult> {
3908 CallGraphStore::callers_of(self, file_rel, symbol, depth)
3909 }
3910 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3911 CallGraphStore::impact_of(self, file_rel, symbol, depth)
3912 }
3913 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3914 CallGraphStore::outgoing_calls_of(self, node)
3915 }
3916 fn outgoing_calls_for_symbols(
3917 &self,
3918 sources: &[(String, String)],
3919 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3920 CallGraphStore::outgoing_calls_for_symbols(self, sources)
3921 }
3922 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3923 CallGraphStore::resolved_self_calls_of(self, node)
3924 }
3925 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3926 CallGraphStore::unresolved_calls_of(self, node)
3927 }
3928 fn call_tree(
3929 &self,
3930 file_rel: &Path,
3931 symbol: &str,
3932 depth: usize,
3933 ) -> Result<callgraph::CallTreeNode> {
3934 CallGraphStore::call_tree(self, file_rel, symbol, depth)
3935 }
3936 fn trace_to(
3937 &self,
3938 file_rel: &Path,
3939 symbol: &str,
3940 max_depth: usize,
3941 ) -> Result<callgraph::TraceToResult> {
3942 CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
3943 }
3944 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3945 CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
3946 }
3947 fn trace_to_symbol(
3948 &self,
3949 file_rel: &Path,
3950 symbol: &str,
3951 to_symbol: &str,
3952 to_file: Option<&Path>,
3953 max_depth: usize,
3954 ) -> Result<callgraph::TraceToSymbolResult> {
3955 CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
3956 }
3957}
3958
3959impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
3960 fn project_root(&self) -> &Path {
3961 (**self).project_root()
3962 }
3963 fn project_key(&self) -> &str {
3964 (**self).project_key()
3965 }
3966 fn sqlite_path(&self) -> &Path {
3967 (**self).sqlite_path()
3968 }
3969 fn is_current(&self) -> bool {
3970 (**self).is_current()
3971 }
3972 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3973 (**self).edge_snapshot()
3974 }
3975 fn indexed_file_count(&self) -> Result<usize> {
3976 (**self).indexed_file_count()
3977 }
3978 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3979 (**self).node_for(file_rel, symbol)
3980 }
3981 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3982 (**self).nodes_for(file_rel, symbol)
3983 }
3984 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3985 (**self).nodes_matching(symbol)
3986 }
3987 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3988 (**self).direct_callers_of(file_rel, symbol)
3989 }
3990 fn direct_caller_counts_of(
3991 &self,
3992 targets: &[(String, String)],
3993 ) -> Result<HashMap<(String, String), usize>> {
3994 (**self).direct_caller_counts_of(targets)
3995 }
3996 fn callers_of(
3997 &self,
3998 file_rel: &Path,
3999 symbol: &str,
4000 depth: usize,
4001 ) -> Result<StoreCallersResult> {
4002 (**self).callers_of(file_rel, symbol, depth)
4003 }
4004 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4005 (**self).impact_of(file_rel, symbol, depth)
4006 }
4007 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4008 (**self).outgoing_calls_of(node)
4009 }
4010 fn outgoing_calls_for_symbols(
4011 &self,
4012 sources: &[(String, String)],
4013 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4014 (**self).outgoing_calls_for_symbols(sources)
4015 }
4016 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4017 (**self).resolved_self_calls_of(node)
4018 }
4019 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4020 (**self).unresolved_calls_of(node)
4021 }
4022 fn call_tree(
4023 &self,
4024 file_rel: &Path,
4025 symbol: &str,
4026 depth: usize,
4027 ) -> Result<callgraph::CallTreeNode> {
4028 (**self).call_tree(file_rel, symbol, depth)
4029 }
4030 fn trace_to(
4031 &self,
4032 file_rel: &Path,
4033 symbol: &str,
4034 max_depth: usize,
4035 ) -> Result<callgraph::TraceToResult> {
4036 (**self).trace_to(file_rel, symbol, max_depth)
4037 }
4038 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4039 (**self).trace_to_symbol_candidates(to_symbol)
4040 }
4041 fn trace_to_symbol(
4042 &self,
4043 file_rel: &Path,
4044 symbol: &str,
4045 to_symbol: &str,
4046 to_file: Option<&Path>,
4047 max_depth: usize,
4048 ) -> Result<callgraph::TraceToSymbolResult> {
4049 (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4050 }
4051}
4052
4053impl CallGraphRead for ReadonlyCallGraphStore {
4054 fn project_root(&self) -> &Path {
4055 self.project_root()
4056 }
4057 fn project_key(&self) -> &str {
4058 self.project_key()
4059 }
4060 fn sqlite_path(&self) -> &Path {
4061 self.sqlite_path()
4062 }
4063 fn is_current(&self) -> bool {
4064 self.is_current()
4065 }
4066 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4067 self.edge_snapshot()
4068 }
4069 fn indexed_file_count(&self) -> Result<usize> {
4070 self.indexed_file_count()
4071 }
4072 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4073 self.node_for(file_rel, symbol)
4074 }
4075 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4076 self.nodes_for(file_rel, symbol)
4077 }
4078 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4079 self.nodes_matching(symbol)
4080 }
4081 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4082 self.direct_callers_of(file_rel, symbol)
4083 }
4084 fn direct_caller_counts_of(
4085 &self,
4086 targets: &[(String, String)],
4087 ) -> Result<HashMap<(String, String), usize>> {
4088 self.direct_caller_counts_of(targets)
4089 }
4090 fn callers_of(
4091 &self,
4092 file_rel: &Path,
4093 symbol: &str,
4094 depth: usize,
4095 ) -> Result<StoreCallersResult> {
4096 self.callers_of(file_rel, symbol, depth)
4097 }
4098 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4099 self.impact_of(file_rel, symbol, depth)
4100 }
4101 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4102 self.outgoing_calls_of(node)
4103 }
4104 fn outgoing_calls_for_symbols(
4105 &self,
4106 sources: &[(String, String)],
4107 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4108 self.outgoing_calls_for_symbols(sources)
4109 }
4110 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4111 self.resolved_self_calls_of(node)
4112 }
4113 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4114 self.unresolved_calls_of(node)
4115 }
4116 fn call_tree(
4117 &self,
4118 file_rel: &Path,
4119 symbol: &str,
4120 depth: usize,
4121 ) -> Result<callgraph::CallTreeNode> {
4122 self.call_tree(file_rel, symbol, depth)
4123 }
4124 fn trace_to(
4125 &self,
4126 file_rel: &Path,
4127 symbol: &str,
4128 max_depth: usize,
4129 ) -> Result<callgraph::TraceToResult> {
4130 self.trace_to(file_rel, symbol, max_depth)
4131 }
4132 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4133 self.trace_to_symbol_candidates(to_symbol)
4134 }
4135 fn trace_to_symbol(
4136 &self,
4137 file_rel: &Path,
4138 symbol: &str,
4139 to_symbol: &str,
4140 to_file: Option<&Path>,
4141 max_depth: usize,
4142 ) -> Result<callgraph::TraceToSymbolResult> {
4143 self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4144 }
4145}
4146
4147fn indexed_file_count(conn: &Connection) -> Result<usize> {
4148 let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
4149 Ok(count.max(0) as usize)
4150}
4151
4152fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
4153 let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
4154 match candidates.as_slice() {
4155 [candidate] => Ok(candidate.clone()),
4156 [] => Err(AftError::SymbolNotFound {
4157 name: symbol.to_string(),
4158 file: rel_path.to_string(),
4159 }
4160 .into()),
4161 _ => Err(AftError::AmbiguousSymbol {
4162 name: symbol.to_string(),
4163 candidates: candidates
4164 .iter()
4165 .map(|candidate| candidate.symbol.clone())
4166 .collect(),
4167 }
4168 .into()),
4169 }
4170}
4171
4172fn nodes_for_file_matching_symbol(
4173 conn: &Connection,
4174 rel_path: &str,
4175 symbol: &str,
4176) -> Result<Vec<StoreNode>> {
4177 let qualified_query = symbol.contains("::");
4178 let sql = if qualified_query {
4179 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4180 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4181 FROM nodes n JOIN files f ON f.path = n.file_path
4182 WHERE n.file_path = ?1 AND n.scoped_name = ?2
4183 ORDER BY n.scoped_name, n.start_line, n.start_col"
4184 } else {
4185 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4186 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4187 FROM nodes n JOIN files f ON f.path = n.file_path
4188 WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
4189 ORDER BY n.scoped_name, n.start_line, n.start_col"
4190 };
4191 let mut stmt = conn.prepare(sql)?;
4192 let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
4193 rows.collect::<std::result::Result<Vec<_>, _>>()
4194 .map_err(Into::into)
4195}
4196
4197fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
4198 let qualified_query = symbol.contains("::");
4199 let sql = if qualified_query {
4200 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4201 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4202 FROM nodes n JOIN files f ON f.path = n.file_path
4203 WHERE n.scoped_name = ?1
4204 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4205 } else {
4206 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4207 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4208 FROM nodes n JOIN files f ON f.path = n.file_path
4209 WHERE n.scoped_name = ?1 OR n.name = ?1
4210 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4211 };
4212 let mut stmt = conn.prepare(sql)?;
4213 let rows = stmt.query_map(params![symbol], store_node_from_row)?;
4214 rows.collect::<std::result::Result<Vec<_>, _>>()
4215 .map_err(Into::into)
4216}
4217
4218fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
4219 store_node_from_row_at(row, 0)
4220}
4221
4222fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
4223 let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
4224 let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
4225 let lang_label_value: String = row.get(offset + 10)?;
4226 Ok(StoreNode {
4227 node_id: row.get(offset)?,
4228 file: row.get(offset + 1)?,
4229 symbol: row.get(offset + 2)?,
4230 name: row.get(offset + 3)?,
4231 kind: row.get(offset + 4)?,
4232 line: start_line.saturating_add(1),
4233 end_line: end_line.saturating_add(1),
4234 signature: row.get(offset + 7)?,
4235 exported: row.get::<_, i64>(offset + 8)? != 0,
4236 is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
4237 lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
4238 })
4239}
4240
4241fn optional_store_node_from_row_at(
4242 row: &rusqlite::Row<'_>,
4243 offset: usize,
4244) -> rusqlite::Result<Option<StoreNode>> {
4245 if row.get::<_, Option<String>>(offset)?.is_some() {
4246 store_node_from_row_at(row, offset).map(Some)
4247 } else {
4248 Ok(None)
4249 }
4250}
4251
4252#[allow(clippy::too_many_arguments)]
4253fn collect_callers_recursive(
4254 conn: &Connection,
4255 file: &str,
4256 symbol: &str,
4257 max_depth: usize,
4258 current_depth: usize,
4259 visited: &mut HashSet<(String, String)>,
4260 result: &mut Vec<StoreCallSite>,
4261 depth_limited: &mut bool,
4262 truncated: &mut usize,
4263) -> Result<()> {
4264 if current_depth >= max_depth {
4265 let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
4266 if omitted > 0 {
4267 *depth_limited = true;
4268 *truncated += omitted;
4269 }
4270 return Ok(());
4271 }
4272
4273 if !visited.insert((file.to_string(), symbol.to_string())) {
4274 return Ok(());
4275 }
4276
4277 let sites = direct_callers_for_tuple(conn, file, symbol)?;
4278 for site in sites {
4279 result.push(site.clone());
4280 if current_depth + 1 < max_depth {
4281 collect_callers_recursive(
4282 conn,
4283 &site.caller.file,
4284 &site.caller.symbol,
4285 max_depth,
4286 current_depth + 1,
4287 visited,
4288 result,
4289 depth_limited,
4290 truncated,
4291 )?;
4292 } else {
4293 let omitted =
4294 direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
4295 if omitted > 0 {
4296 *depth_limited = true;
4297 *truncated += omitted;
4298 }
4299 }
4300 }
4301 Ok(())
4302}
4303
4304const DIRECT_CALLER_COUNT_BATCH_SIZE: usize = 499;
4306
4307fn direct_caller_counts_for_tuples(
4308 conn: &Connection,
4309 targets: &[(String, String)],
4310) -> Result<HashMap<(String, String), usize>> {
4311 let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
4312 let mut counts = unique_targets
4313 .iter()
4314 .cloned()
4315 .map(|target| (target, 0usize))
4316 .collect::<HashMap<_, _>>();
4317
4318 let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
4319 for chunk in unique_targets.chunks(DIRECT_CALLER_COUNT_BATCH_SIZE) {
4320 let requested_values = (0..chunk.len())
4321 .map(|_| "(?, ?)")
4322 .collect::<Vec<_>>()
4323 .join(", ");
4324 let sql = format!(
4325 "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
4326 deduped AS (
4327 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
4328 FROM requested requested
4329 JOIN edges e
4330 ON e.target_file = requested.target_file
4331 AND e.target_symbol = requested.target_symbol
4332 AND e.kind = 'call'
4333 JOIN refs r ON r.ref_id = e.ref_id
4334 JOIN nodes src ON src.id = e.source_node
4335 JOIN files src_file ON src_file.path = src.file_path
4336 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
4337 )
4338 SELECT target_file, target_symbol, COUNT(*)
4339 FROM deduped
4340 GROUP BY target_file, target_symbol"
4341 );
4342 let bindings = chunk
4343 .iter()
4344 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4345 let mut stmt = conn.prepare(&sql)?;
4346 let rows = stmt.query_map(params_from_iter(bindings), |row| {
4347 Ok((
4348 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
4349 row.get::<_, i64>(2)?,
4350 ))
4351 })?;
4352 for row in rows {
4353 let (target, count) = row?;
4354 counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
4355 }
4356 }
4357
4358 Ok(counts)
4359}
4360
4361fn direct_caller_count_for_tuple(
4362 conn: &Connection,
4363 target_file: &str,
4364 target_symbol: &str,
4365) -> Result<usize> {
4366 let count: i64 = conn.query_row(
4367 "SELECT COUNT(*)
4368 FROM edges e
4369 JOIN refs r ON r.ref_id = e.ref_id
4370 JOIN nodes src ON src.id = e.source_node
4371 JOIN files src_file ON src_file.path = src.file_path
4372 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
4373 params![target_file, target_symbol],
4374 |row| row.get(0),
4375 )?;
4376 Ok(usize::try_from(count).unwrap_or(usize::MAX))
4377}
4378
4379fn direct_callers_for_tuple(
4380 conn: &Connection,
4381 target_file: &str,
4382 target_symbol: &str,
4383) -> Result<Vec<StoreCallSite>> {
4384 let mut stmt = conn.prepare(
4385 "SELECT e.target_file, e.target_symbol, e.line,
4386 r.byte_start, r.byte_end, r.status, e.provenance,
4387 src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
4388 src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
4389 src_file.lang,
4390 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4391 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4392 tgt_file.lang
4393 FROM edges e
4394 JOIN refs r ON r.ref_id = e.ref_id
4395 JOIN nodes src ON src.id = e.source_node
4396 JOIN files src_file ON src_file.path = src.file_path
4397 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4398 ON tgt.id = e.target_node
4399 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
4400 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
4401 )?;
4402 let rows = stmt.query_map(params![target_file, target_symbol], |row| {
4403 let caller = store_node_from_row_at(row, 7)?;
4404 let target = optional_store_node_from_row_at(row, 18)?;
4405 Ok(StoreCallSite {
4406 caller,
4407 target_file: row.get(0)?,
4408 target_symbol: row.get(1)?,
4409 target,
4410 line: row.get::<_, i64>(2)?.max(0) as u32,
4411 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4412 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4413 resolved: row.get::<_, String>(5)? == "resolved",
4414 provenance: row.get(6)?,
4415 })
4416 })?;
4417 rows.collect::<std::result::Result<Vec<_>, _>>()
4418 .map_err(Into::into)
4419}
4420
4421const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
4423const OUTGOING_NODE_BATCH_SIZE: usize = 999;
4425
4426fn outgoing_calls_for_symbol_tuples(
4427 conn: &Connection,
4428 sources: &[(String, String)],
4429) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4430 let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
4431 let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
4432 let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
4433 let source_nodes = unique_sources
4434 .iter()
4435 .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
4436 .cloned()
4437 .collect::<Vec<_>>();
4438 let source_nodes_by_id = source_nodes
4439 .iter()
4440 .cloned()
4441 .map(|node| (node.node_id.clone(), node))
4442 .collect::<HashMap<_, _>>();
4443 let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
4444
4445 for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
4446 let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
4447 let sql = format!(
4448 "SELECT e.source_node,
4449 e.target_file, e.target_symbol, e.line,
4450 r.byte_start, r.byte_end, r.status, e.provenance,
4451 CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
4452 tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4453 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4454 tgt_file.lang
4455 FROM edges e
4456 JOIN refs r ON r.ref_id = e.ref_id
4457 LEFT JOIN nodes tgt ON tgt.id = e.target_node
4458 LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
4459 WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
4460 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
4461 );
4462 let bindings = chunk.iter().map(|node| node.node_id.as_str());
4463 let mut stmt = conn.prepare(&sql)?;
4464 let rows = stmt.query_map(params_from_iter(bindings), |row| {
4465 let source_node_id = row.get::<_, String>(0)?;
4466 let caller = source_nodes_by_id
4467 .get(&source_node_id)
4468 .expect("batched outgoing row belongs to a requested source node")
4469 .clone();
4470 let target = optional_store_node_from_row_at(row, 8)?;
4471 Ok((
4472 source_node_id,
4473 StoreCallSite {
4474 caller,
4475 target_file: row.get(1)?,
4476 target_symbol: row.get(2)?,
4477 target,
4478 line: row.get::<_, i64>(3)?.max(0) as u32,
4479 byte_start: row.get::<_, i64>(4)?.max(0) as usize,
4480 byte_end: row.get::<_, i64>(5)?.max(0) as usize,
4481 resolved: row.get::<_, String>(6)? == "resolved",
4482 provenance: row.get(7)?,
4483 },
4484 ))
4485 })?;
4486 for row in rows {
4487 let (source_node_id, call) = row?;
4488 calls_by_node.entry(source_node_id).or_default().push(call);
4489 }
4490 }
4491
4492 let mut calls_by_source = HashMap::new();
4493 for source in &unique_sources {
4494 let mut calls = Vec::new();
4495 if let Some(nodes) = source_nodes_by_symbol.get(source) {
4496 for node in nodes {
4497 if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
4498 calls.extend(node_calls);
4499 }
4500 }
4501 }
4502 calls_by_source.insert(source.clone(), calls);
4503 }
4504
4505 let target_tuples = calls_by_source
4508 .values()
4509 .flatten()
4510 .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
4511 .collect::<Vec<_>>();
4512 let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
4513 for calls in calls_by_source.values_mut() {
4514 for call in calls {
4515 if let Some(target) = target_nodes
4516 .get(&(call.target_file.clone(), call.target_symbol.clone()))
4517 .and_then(|nodes| nodes.first())
4518 {
4519 call.target = Some(target.clone());
4520 }
4521 }
4522 }
4523
4524 Ok(calls_by_source)
4525}
4526
4527fn nodes_for_symbol_tuples(
4528 conn: &Connection,
4529 symbols: &[(String, String)],
4530) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
4531 let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
4532 let mut nodes_by_symbol = unique_symbols
4533 .iter()
4534 .cloned()
4535 .map(|symbol| (symbol, Vec::new()))
4536 .collect::<HashMap<_, _>>();
4537 let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
4538
4539 for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
4540 let requested_values = (0..chunk.len())
4541 .map(|_| "(?, ?)")
4542 .collect::<Vec<_>>()
4543 .join(", ");
4544 let sql = format!(
4545 "WITH requested(file, symbol) AS (VALUES {requested_values})
4546 SELECT requested.file, requested.symbol,
4547 node.id, node.file_path, node.scoped_name, node.name, node.kind,
4548 node.start_line, node.end_line, node.signature, node.exported,
4549 node.is_callgraph_entry_point, node_file.lang
4550 FROM requested
4551 JOIN nodes node INDEXED BY idx_nodes_file
4552 ON node.file_path = requested.file
4553 AND node.scoped_name = requested.symbol
4554 JOIN files node_file ON node_file.path = node.file_path
4555 ORDER BY requested.file, requested.symbol,
4556 node.scoped_name, node.start_line, node.end_line,
4557 node.start_col, node.range_ordinal"
4558 );
4559 let bindings = chunk
4560 .iter()
4561 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4562 let mut stmt = conn.prepare(&sql)?;
4563 let rows = stmt.query_map(params_from_iter(bindings), |row| {
4564 Ok((
4565 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
4566 store_node_from_row_at(row, 2)?,
4567 ))
4568 })?;
4569 for row in rows {
4570 let (symbol, node) = row?;
4571 nodes_by_symbol.entry(symbol).or_default().push(node);
4572 }
4573 }
4574
4575 Ok(nodes_by_symbol)
4576}
4577
4578fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4579 let mut stmt = conn.prepare(
4580 "SELECT e.target_file, e.target_symbol, e.line,
4581 r.byte_start, r.byte_end, r.status, e.provenance,
4582 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4583 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4584 tgt_file.lang
4585 FROM edges e
4586 JOIN refs r ON r.ref_id = e.ref_id
4587 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4588 ON tgt.id = e.target_node
4589 WHERE e.kind = 'call' AND e.source_node = ?1
4590 ORDER BY r.byte_start, r.line, r.ref_id",
4591 )?;
4592 let rows = stmt.query_map(params![node.node_id], |row| {
4593 let target = optional_store_node_from_row_at(row, 7)?;
4594 Ok(StoreCallSite {
4595 caller: node.clone(),
4596 target_file: row.get(0)?,
4597 target_symbol: row.get(1)?,
4598 target,
4599 line: row.get::<_, i64>(2)?.max(0) as u32,
4600 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4601 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4602 resolved: row.get::<_, String>(5)? == "resolved",
4603 provenance: row.get(6)?,
4604 })
4605 })?;
4606 rows.collect::<std::result::Result<Vec<_>, _>>()
4607 .map_err(Into::into)
4608}
4609
4610fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4611 let mut stmt = conn.prepare(
4612 "SELECT r.target_file, r.target_symbol, r.line,
4613 r.byte_start, r.byte_end, r.status, r.provenance,
4614 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4615 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4616 tgt_file.lang
4617 FROM refs r
4618 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4619 ON tgt.id = r.target_node
4620 WHERE r.caller_node = ?1
4621 AND r.kind = 'call'
4622 AND r.status <> 'unresolved'
4623 AND r.target_file = ?2
4624 AND r.target_symbol = ?3
4625 AND r.provenance = ?4
4626 AND NOT EXISTS (
4627 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
4628 )
4629 ORDER BY r.byte_start, r.line, r.ref_id",
4630 )?;
4631 let rows = stmt.query_map(
4632 params![
4633 &node.node_id,
4634 &node.file,
4635 &node.symbol,
4636 PROVENANCE_TREESITTER
4637 ],
4638 |row| {
4639 let target = optional_store_node_from_row_at(row, 7)?;
4640 Ok(StoreCallSite {
4641 caller: node.clone(),
4642 target_file: row.get(0)?,
4643 target_symbol: row.get(1)?,
4644 target,
4645 line: row.get::<_, i64>(2)?.max(0) as u32,
4646 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4647 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4648 resolved: row.get::<_, String>(5)? == "resolved",
4649 provenance: row.get(6)?,
4650 })
4651 },
4652 )?;
4653 rows.collect::<std::result::Result<Vec<_>, _>>()
4654 .map_err(Into::into)
4655}
4656
4657fn unresolved_calls_for_node(
4658 conn: &Connection,
4659 node: &StoreNode,
4660) -> Result<Vec<StoreUnresolvedCall>> {
4661 let mut stmt = conn.prepare(
4662 "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
4663 FROM refs
4664 WHERE caller_node = ?1
4665 AND kind = 'call'
4666 AND status = 'unresolved'
4667 AND NOT EXISTS (
4668 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4669 )
4670 ORDER BY byte_start, line, ref_id",
4671 )?;
4672 let rows = stmt.query_map(params![node.node_id], |row| {
4673 Ok(StoreUnresolvedCall {
4674 caller: node.clone(),
4675 symbol: row.get(0)?,
4676 full_ref: row.get(1)?,
4677 line: row.get::<_, i64>(2)?.max(0) as u32,
4678 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4679 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4680 })
4681 })?;
4682 rows.collect::<std::result::Result<Vec<_>, _>>()
4683 .map_err(Into::into)
4684}
4685
4686fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
4687 let mut calls = Vec::new();
4688 calls.extend(
4689 outgoing_calls_for_node(conn, node)?
4690 .into_iter()
4691 .map(StoreForwardCall::Resolved),
4692 );
4693 calls.extend(
4694 unresolved_calls_for_node(conn, node)?
4695 .into_iter()
4696 .map(StoreForwardCall::Unresolved),
4697 );
4698 calls.sort_by(|left, right| {
4699 left.byte_start()
4700 .cmp(&right.byte_start())
4701 .then(left.line().cmp(&right.line()))
4702 });
4703 Ok(calls)
4704}
4705
4706fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
4707 let resolved_count: i64 = conn.query_row(
4708 "SELECT COUNT(*)
4709 FROM edges e
4710 JOIN refs r ON r.ref_id = e.ref_id
4711 WHERE e.kind = 'call' AND e.source_node = ?1",
4712 params![&node.node_id],
4713 |row| row.get(0),
4714 )?;
4715 let unresolved_count: i64 = conn.query_row(
4716 "SELECT COUNT(*)
4717 FROM refs
4718 WHERE caller_node = ?1
4719 AND kind = 'call'
4720 AND status = 'unresolved'
4721 AND NOT EXISTS (
4722 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4723 )",
4724 params![&node.node_id],
4725 |row| row.get(0),
4726 )?;
4727 let total = resolved_count.saturating_add(unresolved_count);
4728 Ok(usize::try_from(total).unwrap_or(usize::MAX))
4729}
4730
4731fn call_tree_inner(
4732 conn: &Connection,
4733 node: &StoreNode,
4734 max_depth: usize,
4735 current_depth: usize,
4736 visited: &mut HashSet<(String, String)>,
4737) -> Result<callgraph::CallTreeNode> {
4738 let visit_key = (node.file.clone(), node.symbol.clone());
4739 if visited.contains(&visit_key) {
4740 return Ok(callgraph::CallTreeNode {
4741 name: node.symbol.clone(),
4742 file: node.file.clone(),
4743 line: node.line,
4744 signature: node.signature.clone(),
4745 resolved: true,
4746 children: Vec::new(),
4747 depth_limited: false,
4748 truncated: 0,
4749 });
4750 }
4751 visited.insert(visit_key.clone());
4752
4753 let mut children = Vec::new();
4754 let mut depth_limited = false;
4755 let mut truncated = 0usize;
4756
4757 if current_depth < max_depth {
4758 let calls = forward_calls_for_node(conn, node)?;
4759 for call in calls {
4760 match call {
4761 StoreForwardCall::Resolved(site) => {
4762 if let Some(target) = site.target {
4763 let child =
4764 call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
4765 depth_limited |= child.depth_limited;
4766 truncated += child.truncated;
4767 children.push(child);
4768 } else {
4769 children.push(callgraph::CallTreeNode {
4770 name: site.target_symbol,
4771 file: site.target_file,
4772 line: site.line,
4773 signature: None,
4774 resolved: false,
4775 children: Vec::new(),
4776 depth_limited: false,
4777 truncated: 0,
4778 });
4779 }
4780 }
4781 StoreForwardCall::Unresolved(call) => {
4782 children.push(callgraph::CallTreeNode {
4783 name: call.symbol,
4784 file: call.caller.file,
4785 line: call.line,
4786 signature: None,
4787 resolved: false,
4788 children: Vec::new(),
4789 depth_limited: false,
4790 truncated: 0,
4791 });
4792 }
4793 }
4794 }
4795 } else {
4796 truncated = forward_call_count_for_node(conn, node)?;
4797 depth_limited = truncated > 0;
4798 }
4799
4800 visited.remove(&visit_key);
4801 Ok(callgraph::CallTreeNode {
4802 name: node.symbol.clone(),
4803 file: node.file.clone(),
4804 line: node.line,
4805 signature: node.signature.clone(),
4806 resolved: true,
4807 children,
4808 depth_limited,
4809 truncated,
4810 })
4811}
4812
4813fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
4814 callgraph::TraceToSymbolHop {
4815 symbol: node.symbol.clone(),
4816 file: node.file.clone(),
4817 line: node.line,
4818 }
4819}
4820
4821fn trace_to_symbol_matches_target(
4822 node: &StoreNode,
4823 to_symbol: &str,
4824 to_file: Option<&str>,
4825) -> bool {
4826 if !symbol_query_matches(&node.symbol, to_symbol) {
4827 return false;
4828 }
4829 match to_file {
4830 Some(file) => node.file == file,
4831 None => true,
4832 }
4833}
4834
4835fn symbol_query_matches(symbol: &str, query: &str) -> bool {
4836 symbol == query || unqualified_name(symbol) == query
4837}
4838
4839fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
4840 let source = std::fs::read_to_string(path).ok()?;
4841 Some(source.lines().map(|line| line.trim().to_string()).collect())
4842}
4843
4844#[doc(hidden)]
4845pub fn live_callgraph_edge_snapshot(
4846 project_root: &Path,
4847 files: &[PathBuf],
4848) -> Result<BTreeSet<StoredEdge>> {
4849 let files = normalize_file_list(project_root, files)?;
4850 let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
4851 let mut file_data = Vec::new();
4852 for file in &files {
4853 let canon = canonicalize_path(file);
4854 let data = graph.build_file(&canon)?.clone();
4855 file_data.push((canon, data));
4856 }
4857
4858 let mut edges = BTreeSet::new();
4859 for (caller_file, data) in &file_data {
4860 for (caller_symbol, call_sites) in &data.calls_by_symbol {
4861 for call_site in call_sites {
4862 let resolution = graph.resolve_cross_file_edge(
4863 &call_site.full_callee,
4864 &call_site.callee_name,
4865 caller_file,
4866 &data.import_block,
4867 );
4868 let (target_file, target_symbol) = match resolution {
4869 EdgeResolution::Resolved { file, symbol } => (file, symbol),
4870 EdgeResolution::Unresolved { callee_name } => {
4871 if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
4872 continue;
4873 }
4874 let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
4875 data,
4876 caller_file,
4877 &callee_name,
4878 ) else {
4879 continue;
4880 };
4881 (caller_file.clone(), target_symbol)
4882 }
4883 };
4884 if target_file == *caller_file && target_symbol == *caller_symbol {
4885 continue;
4886 }
4887 edges.insert(StoredEdge {
4888 source_file: relative_path(project_root, caller_file),
4889 source_symbol: caller_symbol.clone(),
4890 target_file: relative_path(project_root, &target_file),
4891 target_symbol,
4892 kind: "call".to_string(),
4893 line: call_site.line,
4894 });
4895 }
4896 }
4897 }
4898 Ok(edges)
4899}
4900
4901fn rebuild_cooldown_records() -> &'static Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>
4902{
4903 SUCCESSFUL_REBUILDS.get_or_init(|| Mutex::new(HashMap::new()))
4904}
4905
4906fn rebuild_cooldown_key(callgraph_dir: &Path, project_key: &str) -> RebuildCooldownKey {
4907 RebuildCooldownKey {
4908 callgraph_dir: std::fs::canonicalize(callgraph_dir)
4909 .unwrap_or_else(|_| callgraph_dir.to_path_buf()),
4910 project_key: project_key.to_string(),
4911 }
4912}
4913
4914fn rebuild_cooldown_denial(
4915 callgraph_dir: &Path,
4916 project_key: &str,
4917 project_root: &Path,
4918 now: Instant,
4919) -> Option<(PathBuf, Duration)> {
4920 let key = rebuild_cooldown_key(callgraph_dir, project_key);
4921 let records = rebuild_cooldown_records()
4922 .lock()
4923 .unwrap_or_else(std::sync::PoisonError::into_inner);
4924 let record = records.get(&key)?;
4925 if record.project_root == project_root || !record.cross_root_cooldown_armed {
4926 return None;
4927 }
4928 let elapsed = now.saturating_duration_since(record.published_at);
4929 (elapsed < REBUILD_COOLDOWN).then(|| (record.project_root.clone(), REBUILD_COOLDOWN - elapsed))
4930}
4931
4932fn record_successful_rebuild(
4933 callgraph_dir: &Path,
4934 project_key: &str,
4935 project_root: &Path,
4936 published_at: Instant,
4937) {
4938 let key = rebuild_cooldown_key(callgraph_dir, project_key);
4939 let mut records = rebuild_cooldown_records()
4940 .lock()
4941 .unwrap_or_else(std::sync::PoisonError::into_inner);
4942 if records.len() >= 4_096 && !records.contains_key(&key) {
4943 if let Some(evict) = records.keys().next().cloned() {
4944 records.remove(&evict);
4945 }
4946 }
4947 let cross_root_cooldown_armed = records.get(&key).is_some_and(|previous| {
4948 previous.cross_root_cooldown_armed || previous.project_root != project_root
4949 });
4950 records.insert(
4951 key,
4952 RebuildCooldownRecord {
4953 project_root: project_root.to_path_buf(),
4954 published_at,
4955 cross_root_cooldown_armed,
4956 },
4957 );
4958}
4959
4960fn acquire_writer_lease(
4961 callgraph_dir: &Path,
4962 project_key: &str,
4963 project_root: &Path,
4964) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
4965 crate::root_cache::WriterLease::acquire_shared(
4966 crate::root_cache::RootCacheDomain::Callgraph,
4967 callgraph_dir,
4968 project_key,
4969 project_root,
4970 )
4971 .map_err(CallGraphStoreError::from)
4972}
4973
4974fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
4975 if lease.verify()? {
4976 Ok(())
4977 } else {
4978 Err(CallGraphStoreError::Unavailable(format!(
4979 "callgraph writer lease for key {} lost epoch {}; aborting write",
4980 lease.key(),
4981 lease.epoch()
4982 )))
4983 }
4984}
4985
4986fn legacy_migration_completion_line(
4987 project_key: &str,
4988 method: &str,
4989 legacy_bytes: u64,
4990 migrated_bytes: u64,
4991) -> String {
4992 format!(
4993 "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
4994 )
4995}
4996
4997fn log_legacy_migration_completion(
4998 project_key: &str,
4999 method: &str,
5000 legacy_bytes: u64,
5001 migrated_bytes: u64,
5002) {
5003 crate::slog_info!(
5004 "{}",
5005 legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
5006 );
5007}
5008
5009fn try_legacy_migration_or_fallback(
5010 callgraph_dir: &Path,
5011 project_root: &Path,
5012 project_key: &str,
5013 writer_lease: Arc<crate::root_cache::WriterLease>,
5014) -> Result<Option<CallGraphStore>> {
5015 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
5016 if partitions.is_empty() {
5017 return Ok(None);
5018 }
5019
5020 for partition in &partitions {
5021 if let Some(source) = newest_superseded_legacy_generation(partition)? {
5022 if !migration_disk_floor_allows(&source, callgraph_dir)? {
5023 return open_legacy_fallback_store(
5024 callgraph_dir,
5025 project_root,
5026 project_key,
5027 &partitions,
5028 );
5029 }
5030 match publish_generation_copy_migration(
5031 callgraph_dir,
5032 project_key,
5033 &source,
5034 Arc::clone(&writer_lease),
5035 ) {
5036 Ok(published) => {
5037 log_legacy_migration_completion(
5038 project_key,
5039 "generation_copy",
5040 source.source_bytes,
5041 published.migrated_bytes,
5042 );
5043 return CallGraphStore::open_generation(
5044 callgraph_dir,
5045 project_root.to_path_buf(),
5046 project_key.to_string(),
5047 published.generation,
5048 writer_lease,
5049 )
5050 .map(Some);
5051 }
5052 Err(error) => {
5053 crate::slog_warn!(
5054 "root-keyed callgraph generation-copy migration failed from {}: {}",
5055 source.sqlite_path.display(),
5056 error
5057 );
5058 return open_legacy_fallback_store(
5059 callgraph_dir,
5060 project_root,
5061 project_key,
5062 &partitions,
5063 );
5064 }
5065 }
5066 }
5067
5068 if let Some(source) = current_legacy_generation(partition)? {
5069 if !migration_disk_floor_allows(&source, callgraph_dir)? {
5070 return open_legacy_fallback_store(
5071 callgraph_dir,
5072 project_root,
5073 project_key,
5074 &partitions,
5075 );
5076 }
5077 match publish_backup_migration(
5078 callgraph_dir,
5079 project_key,
5080 &source,
5081 Arc::clone(&writer_lease),
5082 ) {
5083 Ok(published) => {
5084 log_legacy_migration_completion(
5085 project_key,
5086 "sqlite_backup",
5087 source.source_bytes,
5088 published.migrated_bytes,
5089 );
5090 return CallGraphStore::open_generation(
5091 callgraph_dir,
5092 project_root.to_path_buf(),
5093 project_key.to_string(),
5094 published.generation,
5095 writer_lease,
5096 )
5097 .map(Some);
5098 }
5099 Err(error) => {
5100 crate::slog_warn!(
5101 "root-keyed callgraph backup migration failed from {}: {}",
5102 source.sqlite_path.display(),
5103 error
5104 );
5105 return open_legacy_fallback_store(
5106 callgraph_dir,
5107 project_root,
5108 project_key,
5109 &partitions,
5110 );
5111 }
5112 }
5113 }
5114 }
5115
5116 open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
5117}
5118
5119fn open_legacy_fallback_store(
5120 callgraph_dir: &Path,
5121 project_root: &Path,
5122 project_key: &str,
5123 partitions: &[LegacyCallgraphPartition],
5124) -> Result<Option<CallGraphStore>> {
5125 let Some(target) = first_ready_legacy_target(partitions)? else {
5126 return Ok(None);
5127 };
5128 crate::slog_warn!(
5129 "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
5130 target.partition.harness,
5131 target.sqlite_path.display()
5132 );
5133 let conn = open_readonly_connection(&target.sqlite_path)?;
5134 if !database_ready(&conn).unwrap_or(false) {
5135 return Ok(None);
5136 }
5137 let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
5138 let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
5139 Ok(Some(CallGraphStore::from_connection(
5140 project_root.to_path_buf(),
5141 project_key.to_string(),
5142 target.sqlite_path,
5143 callgraph_dir.to_path_buf(),
5144 true,
5145 target.generation,
5146 None,
5147 Some(read_marker),
5148 conn,
5149 )))
5150}
5151
5152fn migration_disk_floor_allows(
5153 source: &LegacyCallgraphTarget,
5154 callgraph_dir: &Path,
5155) -> Result<bool> {
5156 let available = migration_available_disk(callgraph_dir)?;
5157 let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
5158 source.source_bytes,
5159 available,
5160 );
5161 if decision.should_skip_copy() {
5162 crate::slog_warn!(
5163 "{}",
5164 decision.warning_message(&source.sqlite_path, callgraph_dir)
5165 );
5166 return Ok(false);
5167 }
5168 Ok(true)
5169}
5170
5171fn migration_available_disk(path: &Path) -> Result<u64> {
5172 if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
5173 return Ok(bytes);
5174 }
5175 crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
5176}
5177
5178fn legacy_callgraph_partitions(
5179 callgraph_dir: &Path,
5180 project_key: &str,
5181) -> Result<Vec<LegacyCallgraphPartition>> {
5182 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5183 return Ok(Vec::new());
5184 };
5185 let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
5186 let mut partitions = inventory
5187 .into_iter()
5188 .filter(|entry| {
5189 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
5190 && entry.key == project_key
5191 })
5192 .map(|entry| {
5193 let dir = if entry.path.is_dir() {
5194 entry.path.clone()
5195 } else {
5196 entry
5197 .path
5198 .parent()
5199 .map(Path::to_path_buf)
5200 .unwrap_or_else(|| entry.path.clone())
5201 };
5202 LegacyCallgraphPartition {
5203 harness: entry.harness,
5204 dir,
5205 key: entry.key,
5206 bytes: entry.bytes,
5207 freshness: entry.callgraph_pointer_mtime,
5208 }
5209 })
5210 .collect::<Vec<_>>();
5211 partitions.sort_by(|left, right| {
5212 right
5213 .freshness
5214 .cmp(&left.freshness)
5215 .then_with(|| right.bytes.cmp(&left.bytes))
5216 .then_with(|| left.harness.cmp(&right.harness))
5217 });
5218 Ok(partitions)
5219}
5220
5221fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
5222 let domain_dir = callgraph_dir.parent()?;
5223 if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
5224 return None;
5225 }
5226 domain_dir.parent().map(Path::to_path_buf)
5227}
5228
5229pub(crate) fn all_legacy_partitions_migrated_for_keys(
5230 callgraph_dir: &Path,
5231 configured_keys: &BTreeSet<String>,
5232) -> Result<bool> {
5233 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5234 return Ok(false);
5235 };
5236 let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
5237 .into_iter()
5238 .filter(|entry| {
5239 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
5240 && configured_keys.contains(&entry.key)
5241 })
5242 .map(|entry| entry.key)
5243 .collect::<BTreeSet<_>>();
5244 if legacy_keys.is_empty() {
5245 return Ok(false);
5246 }
5247
5248 for key in legacy_keys {
5249 let migrated_dir = storage_root.join("callgraph").join(&key);
5250 let Some(generation) = read_pointer(&migrated_dir, &key) else {
5251 return Ok(false);
5252 };
5253 if !migration_generation_requires_manifest(&generation)
5254 || !migration_manifest_valid(&migrated_dir, &generation)
5255 {
5256 return Ok(false);
5257 }
5258 }
5259 Ok(true)
5260}
5261
5262fn newest_superseded_legacy_generation(
5263 partition: &LegacyCallgraphPartition,
5264) -> Result<Option<LegacyCallgraphTarget>> {
5265 let Some(current) = read_pointer(&partition.dir, &partition.key) else {
5266 return Ok(None);
5267 };
5268 let prefix = format!("{}.g", partition.key);
5269 let Ok(entries) = std::fs::read_dir(&partition.dir) else {
5270 return Ok(None);
5271 };
5272 let mut candidates = Vec::new();
5273 for entry in entries.flatten() {
5274 let name = entry.file_name().to_string_lossy().to_string();
5275 if name == current
5276 || name.contains(".tmp.")
5277 || !name.starts_with(&prefix)
5278 || !name.ends_with(".sqlite")
5279 {
5280 continue;
5281 }
5282 let path = entry.path();
5283 if !db_path_ready(&path) {
5284 continue;
5285 }
5286 let modified = entry
5287 .metadata()
5288 .and_then(|metadata| metadata.modified())
5289 .unwrap_or(SystemTime::UNIX_EPOCH);
5290 candidates.push((modified, path, name));
5291 }
5292 candidates.sort_by(|left, right| right.0.cmp(&left.0));
5293 let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
5294 return Ok(None);
5295 };
5296 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5297 Ok(Some(LegacyCallgraphTarget {
5298 partition: partition.clone(),
5299 sqlite_path,
5300 generation: Some(generation),
5301 source_bytes,
5302 source_blake3: String::new(),
5303 }))
5304}
5305
5306fn current_legacy_generation(
5307 partition: &LegacyCallgraphPartition,
5308) -> Result<Option<LegacyCallgraphTarget>> {
5309 let Some(target) = ready_legacy_target(partition)? else {
5310 return Ok(None);
5311 };
5312 let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
5313 if has_superseded {
5314 return Ok(None);
5315 }
5316 Ok(Some(target))
5317}
5318
5319fn freshest_legacy_fallback_target(
5320 callgraph_dir: &Path,
5321 project_key: &str,
5322) -> Result<Option<LegacyCallgraphTarget>> {
5323 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
5324 first_ready_legacy_target(&partitions)
5325}
5326
5327fn first_ready_legacy_target(
5328 partitions: &[LegacyCallgraphPartition],
5329) -> Result<Option<LegacyCallgraphTarget>> {
5330 for partition in partitions {
5331 if let Some(target) = ready_legacy_target(partition)? {
5332 return Ok(Some(target));
5333 }
5334 }
5335 Ok(None)
5336}
5337
5338fn ready_legacy_target(
5339 partition: &LegacyCallgraphPartition,
5340) -> Result<Option<LegacyCallgraphTarget>> {
5341 if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
5342 let sqlite_path = partition.dir.join(&generation);
5343 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
5344 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5345 return Ok(Some(LegacyCallgraphTarget {
5346 partition: partition.clone(),
5347 sqlite_path,
5348 generation: Some(generation),
5349 source_bytes,
5350 source_blake3: String::new(),
5351 }));
5352 }
5353 }
5354
5355 let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
5356 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
5357 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5358 return Ok(Some(LegacyCallgraphTarget {
5359 partition: partition.clone(),
5360 sqlite_path,
5361 generation: None,
5362 source_bytes,
5363 source_blake3: String::new(),
5364 }));
5365 }
5366 Ok(None)
5367}
5368
5369fn publish_generation_copy_migration(
5370 callgraph_dir: &Path,
5371 project_key: &str,
5372 source: &LegacyCallgraphTarget,
5373 writer_lease: Arc<crate::root_cache::WriterLease>,
5374) -> Result<PublishedLegacyMigration> {
5375 let generation = migration_generation_file_name(project_key, "copy");
5376 let temp_path = migration_temp_path(callgraph_dir, &generation);
5377 remove_sqlite_file_set(&temp_path);
5378 copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
5379 fail_after_temp_copy_for_test()?;
5380
5381 let mut source = source.clone();
5382 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
5383 source.source_blake3 = fingerprint.blake3;
5384 let generation = publish_migrated_generation(
5385 callgraph_dir,
5386 project_key,
5387 &generation,
5388 &temp_path,
5389 &source,
5390 fingerprint.bytes,
5391 writer_lease,
5392 "generation_copy",
5393 )?;
5394 Ok(PublishedLegacyMigration {
5395 generation,
5396 migrated_bytes: fingerprint.bytes,
5397 })
5398}
5399
5400fn publish_backup_migration(
5401 callgraph_dir: &Path,
5402 project_key: &str,
5403 source: &LegacyCallgraphTarget,
5404 writer_lease: Arc<crate::root_cache::WriterLease>,
5405) -> Result<PublishedLegacyMigration> {
5406 if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
5407 return Err(CallGraphStoreError::Unavailable(
5408 "legacy callgraph backup migration budget exhausted by test seam".to_string(),
5409 ));
5410 }
5411
5412 let generation = migration_generation_file_name(project_key, "backup");
5413 let temp_path = migration_temp_path(callgraph_dir, &generation);
5414 remove_sqlite_file_set(&temp_path);
5415
5416 let source_conn = open_readonly_connection(&source.sqlite_path)?;
5417 let mut destination = Connection::open(&temp_path)?;
5418 destination.busy_timeout(Duration::from_secs(5))?;
5419 let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
5420 let started = Instant::now();
5421 let mut retries = 0;
5422 loop {
5423 match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
5424 rusqlite::backup::StepResult::Done => break,
5425 rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
5426 rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
5427 retries += 1;
5428 if retries > MIGRATION_BACKUP_RETRY_BUDGET
5429 || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
5430 {
5431 return Err(CallGraphStoreError::Unavailable(format!(
5432 "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
5433 )));
5434 }
5435 std::thread::sleep(Duration::from_millis(20));
5436 }
5437 _ => {
5438 return Err(CallGraphStoreError::Unavailable(
5439 "legacy callgraph backup returned an unknown step result".to_string(),
5440 ));
5441 }
5442 }
5443 }
5444 drop(backup);
5445
5446 let integrity: String =
5447 destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
5448 if integrity != "ok" {
5449 return Err(CallGraphStoreError::Unavailable(format!(
5450 "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
5451 )));
5452 }
5453 if !database_ready(&destination)? {
5454 return Err(CallGraphStoreError::Unavailable(
5455 "legacy callgraph backup produced a database without ready metadata".to_string(),
5456 ));
5457 }
5458 destination.execute_batch("PRAGMA optimize;")?;
5459 drop(destination);
5460 sync_file(&temp_path)?;
5461 fail_after_temp_copy_for_test()?;
5462
5463 let mut source = source.clone();
5464 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
5465 source.source_blake3 = fingerprint.blake3;
5466 let generation = publish_migrated_generation(
5467 callgraph_dir,
5468 project_key,
5469 &generation,
5470 &temp_path,
5471 &source,
5472 fingerprint.bytes,
5473 writer_lease,
5474 "sqlite_backup",
5475 )?;
5476 Ok(PublishedLegacyMigration {
5477 generation,
5478 migrated_bytes: fingerprint.bytes,
5479 })
5480}
5481
5482fn publish_migrated_generation(
5483 callgraph_dir: &Path,
5484 project_key: &str,
5485 generation: &str,
5486 temp_path: &Path,
5487 source: &LegacyCallgraphTarget,
5488 migrated_bytes: u64,
5489 writer_lease: Arc<crate::root_cache::WriterLease>,
5490 method: &str,
5491) -> Result<String> {
5492 let gen_path = callgraph_dir.join(generation);
5493 checkpoint_sqlite_before_publication(temp_path);
5494 let publication = publish_if_current(|| {
5495 verify_writer_lease(&writer_lease)?;
5496 remove_sqlite_file_set(&gen_path);
5497 rename_sqlite_file_set(temp_path, &gen_path)?;
5498 crate::fs_lock::sync_parent(&gen_path);
5499
5500 verify_writer_lease(&writer_lease)?;
5501 publish_pointer(callgraph_dir, project_key, generation)?;
5502 write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
5503 Ok(generation.to_string())
5504 });
5505 if matches!(publication, Err(CallGraphStoreError::Superseded)) {
5506 remove_sqlite_file_set(temp_path);
5507 }
5508 publication
5509}
5510
5511fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
5512 if let Some(parent) = destination.parent() {
5513 std::fs::create_dir_all(parent)?;
5514 }
5515 for suffix in SQLITE_FILE_SET_SUFFIXES {
5516 let source_path = sqlite_file_set_path(source, suffix);
5517 if !source_path.is_file() {
5518 continue;
5519 }
5520 let destination_path = sqlite_file_set_path(destination, suffix);
5521 std::fs::copy(&source_path, &destination_path)?;
5522 sync_file(&destination_path)?;
5523 }
5524 Ok(())
5525}
5526
5527fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
5528 for suffix in SQLITE_FILE_SET_SUFFIXES {
5529 let source_path = sqlite_file_set_path(source, suffix);
5530 if !source_path.exists() {
5531 continue;
5532 }
5533 let destination_path = sqlite_file_set_path(destination, suffix);
5534 if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
5535 let _ = std::fs::remove_file(&source_path);
5536 return Err(error.into());
5537 }
5538 }
5539 Ok(())
5540}
5541
5542fn sqlite_file_set_size(path: &Path) -> Result<u64> {
5543 let mut bytes = 0_u64;
5544 for suffix in SQLITE_FILE_SET_SUFFIXES {
5545 let member = sqlite_file_set_path(path, suffix);
5546 if !member.is_file() {
5547 continue;
5548 }
5549 bytes = bytes.saturating_add(member.metadata()?.len());
5550 }
5551 Ok(bytes)
5552}
5553
5554fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
5555 let mut hasher = blake3::Hasher::new();
5556 let mut bytes = 0_u64;
5557 let mut buffer = [0_u8; 64 * 1024];
5558 for suffix in SQLITE_FILE_SET_SUFFIXES {
5559 let member = sqlite_file_set_path(path, suffix);
5560 if !member.is_file() {
5561 continue;
5562 }
5563 hasher.update(suffix.as_bytes());
5564 let mut file = std::fs::File::open(&member)?;
5565 loop {
5566 let read = file.read(&mut buffer)?;
5567 if read == 0 {
5568 break;
5569 }
5570 bytes = bytes.saturating_add(read as u64);
5571 hasher.update(&buffer[..read]);
5572 }
5573 }
5574 Ok(SourceFingerprint {
5575 bytes,
5576 blake3: hash_to_hex(hasher.finalize()),
5577 })
5578}
5579
5580fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
5581 if suffix.is_empty() {
5582 path.to_path_buf()
5583 } else {
5584 PathBuf::from(format!("{}{suffix}", path.display()))
5585 }
5586}
5587
5588fn sync_file(path: &Path) -> Result<()> {
5589 let file = std::fs::OpenOptions::new()
5590 .read(true)
5591 .write(true)
5592 .open(path)?;
5593 file.sync_all()?;
5594 Ok(())
5595}
5596
5597fn fail_after_temp_copy_for_test() -> Result<()> {
5598 if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
5599 return Err(CallGraphStoreError::Unavailable(
5600 "legacy callgraph migration stopped after temp copy by test seam".to_string(),
5601 ));
5602 }
5603 Ok(())
5604}
5605
5606fn migration_generation_file_name(project_key: &str, method: &str) -> String {
5607 format!(
5608 "{project_key}.g{}.{}{}{}.sqlite",
5609 now_nanos(),
5610 std::process::id(),
5611 MIGRATION_GENERATION_TAG,
5612 method
5613 )
5614}
5615
5616fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
5617 callgraph_dir.join(format!(
5618 "{generation}.tmp.{}.{}",
5619 std::process::id(),
5620 now_nanos()
5621 ))
5622}
5623
5624fn write_migration_manifest(
5625 callgraph_dir: &Path,
5626 generation: &str,
5627 source: &LegacyCallgraphTarget,
5628 migrated_bytes: u64,
5629 method: &str,
5630) -> Result<()> {
5631 let manifest_path = migration_manifest_path(callgraph_dir, generation);
5632 let temp_path = manifest_path.with_extension(format!(
5633 "migration.json.tmp.{}.{}",
5634 std::process::id(),
5635 now_nanos()
5636 ));
5637 let manifest = serde_json::json!({
5638 "version": MIGRATION_MANIFEST_VERSION,
5639 "method": method,
5640 "target_generation": generation,
5641 "source_harness": source.partition.harness,
5642 "source_path": source.sqlite_path.display().to_string(),
5643 "source_generation": source.generation,
5644 "source_bytes": source.source_bytes,
5645 "source_blake3": source.source_blake3,
5646 "migrated_bytes": migrated_bytes,
5647 });
5648 {
5649 use std::io::Write as _;
5650 let mut file = std::fs::File::create(&temp_path)?;
5651 file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
5652 file.write_all(b"\n")?;
5653 file.sync_all()?;
5654 }
5655 if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
5656 let _ = std::fs::remove_file(&temp_path);
5657 return Err(error.into());
5658 }
5659 crate::fs_lock::sync_parent(&manifest_path);
5660 Ok(())
5661}
5662
5663fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
5664 callgraph_dir.join(format!("{generation}.migration.json"))
5665}
5666
5667fn migration_generation_requires_manifest(generation: &str) -> bool {
5668 generation.contains(MIGRATION_GENERATION_TAG)
5669}
5670
5671fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
5672 if !migration_generation_requires_manifest(generation) {
5673 return true;
5674 }
5675 let path = migration_manifest_path(callgraph_dir, generation);
5676 let Ok(bytes) = std::fs::read(path) else {
5677 return false;
5678 };
5679 let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
5680 return false;
5681 };
5682 value.get("version").and_then(serde_json::Value::as_u64)
5683 == Some(MIGRATION_MANIFEST_VERSION as u64)
5684 && value
5685 .get("target_generation")
5686 .and_then(serde_json::Value::as_str)
5687 == Some(generation)
5688 && value
5689 .get("source_bytes")
5690 .and_then(serde_json::Value::as_u64)
5691 .is_some_and(|bytes| bytes > 0)
5692 && value
5693 .get("source_blake3")
5694 .and_then(serde_json::Value::as_str)
5695 .is_some_and(|hash| hash.len() == 64)
5696}
5697
5698fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
5699 let pointer_generation = read_pointer(callgraph_dir, project_key);
5700 if let Some(generation) = pointer_generation.as_deref() {
5701 if migration_generation_requires_manifest(generation)
5702 && !migration_manifest_valid(callgraph_dir, generation)
5703 {
5704 let path = callgraph_dir.join(generation);
5705 remove_sqlite_file_set(&path);
5706 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
5707 let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
5708 }
5709 }
5710
5711 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
5712 return;
5713 };
5714 for entry in entries.flatten() {
5715 let name = entry.file_name().to_string_lossy().to_string();
5716 let path = entry.path();
5717 if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
5718 let _ = std::fs::remove_file(path);
5719 continue;
5720 }
5721 if name.starts_with(&format!("{project_key}.g"))
5722 && name.ends_with(".sqlite")
5723 && name.contains(MIGRATION_GENERATION_TAG)
5724 && pointer_generation.as_deref() != Some(&name)
5725 && !migration_manifest_valid(callgraph_dir, &name)
5726 {
5727 remove_sqlite_file_set(&path);
5728 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
5729 }
5730 }
5731 crate::fs_lock::sync_parent(callgraph_dir);
5732}
5733
5734fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
5735 let mut hasher = blake3::Hasher::new();
5736 hasher.update(path.to_string_lossy().as_bytes());
5737 if let Some(generation) = generation {
5738 hasher.update(generation.as_bytes());
5739 }
5740 let digest = hash_to_hex(hasher.finalize());
5741 format!("legacy-{}", &digest[..16])
5742}
5743
5744fn open_readonly_connection(path: &Path) -> Result<Connection> {
5745 let uri = sqlite_readonly_uri(path);
5746 let conn = Connection::open_with_flags(
5747 &uri,
5748 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
5749 )?;
5750 conn.pragma_update(
5751 None,
5752 "synchronous",
5753 if write_amplification_baseline_enabled() {
5754 "FULL"
5755 } else {
5756 "NORMAL"
5757 },
5758 )?;
5759 conn.busy_timeout(reader_busy_timeout())?;
5760 conn.execute_batch("PRAGMA query_only=ON;")?;
5761 Ok(conn)
5762}
5763
5764fn reader_busy_timeout() -> Duration {
5765 let jitter = (now_nanos() % 500) as u64;
5766 Duration::from_millis(250 + jitter)
5767}
5768
5769fn sqlite_readonly_uri(path: &Path) -> String {
5770 let raw = path.to_string_lossy().replace('\\', "/");
5771 let encoded = percent_encode_sqlite_uri_path(&raw);
5772 if raw.starts_with('/') {
5773 format!("file://{encoded}?mode=ro")
5774 } else if raw.as_bytes().get(1) == Some(&b':') {
5775 format!("file:///{encoded}?mode=ro")
5776 } else {
5777 format!("file:{encoded}?mode=ro")
5778 }
5779}
5780
5781fn percent_encode_sqlite_uri_path(path: &str) -> String {
5782 let mut encoded = String::with_capacity(path.len());
5783 for byte in path.bytes() {
5784 match byte {
5785 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
5786 encoded.push(byte as char)
5787 }
5788 _ => encoded.push_str(&format!("%{byte:02X}")),
5789 }
5790 }
5791 encoded
5792}
5793
5794fn configure_connection(conn: &Connection) -> Result<()> {
5795 conn.pragma_update(None, "journal_mode", "WAL")?;
5796 let baseline = write_amplification_baseline_enabled();
5797 conn.pragma_update(
5798 None,
5799 "synchronous",
5800 if baseline { "FULL" } else { "NORMAL" },
5801 )?;
5802 conn.pragma_update(
5803 None,
5804 "wal_autocheckpoint",
5805 if baseline {
5806 1_000
5807 } else {
5808 CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES
5809 },
5810 )?;
5811 conn.pragma_update(None, "busy_timeout", 5_000)?;
5812 Ok(())
5813}
5814
5815fn configure_build_connection(conn: &Connection) -> Result<()> {
5816 conn.pragma_update(None, "journal_mode", "DELETE")?;
5817 conn.pragma_update(
5818 None,
5819 "synchronous",
5820 if write_amplification_baseline_enabled() {
5821 "FULL"
5822 } else {
5823 "NORMAL"
5824 },
5825 )?;
5826 conn.pragma_update(None, "busy_timeout", 5_000)?;
5827 Ok(())
5828}
5829
5830fn checkpoint_sqlite_before_publication(path: &Path) {
5834 let Ok(conn) = Connection::open(path) else {
5835 return;
5836 };
5837 let _ = conn.pragma_update(None, "synchronous", "NORMAL");
5838 let _ = conn.busy_timeout(Duration::from_secs(5));
5839 let _ = checkpoint_wal_truncate(&conn);
5840}
5841
5842fn checkpoint_wal_truncate(conn: &Connection) -> bool {
5843 match conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
5844 row.get::<_, i64>(0)
5845 }) {
5846 Ok(0) => true,
5847 Ok(_) => false,
5848 Err(rusqlite::Error::SqliteFailure(error, _))
5849 if matches!(
5850 error.code,
5851 rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
5852 ) =>
5853 {
5854 false
5855 }
5856 Err(error) => {
5857 log::debug!("callgraph WAL truncate checkpoint skipped: {error}");
5858 false
5859 }
5860 }
5861}
5862
5863fn initialize_schema(conn: &Connection) -> Result<()> {
5864 conn.execute_batch(
5865 "CREATE TABLE IF NOT EXISTS files (
5866 path TEXT PRIMARY KEY,
5867 content_hash TEXT NOT NULL,
5868 mtime_ns INTEGER NOT NULL,
5869 size INTEGER NOT NULL,
5870 lang TEXT NOT NULL,
5871 is_dead_code_root INTEGER NOT NULL DEFAULT 0,
5872 is_public_api INTEGER NOT NULL DEFAULT 0,
5873 surface_fingerprint TEXT NOT NULL,
5874 indexed_at INTEGER NOT NULL
5875 );
5876
5877 CREATE TABLE IF NOT EXISTS nodes (
5878 id TEXT PRIMARY KEY,
5879 file_path TEXT NOT NULL,
5880 name TEXT NOT NULL,
5881 scoped_name TEXT NOT NULL,
5882 kind TEXT NOT NULL,
5883 start_line INTEGER NOT NULL,
5884 start_col INTEGER NOT NULL,
5885 end_line INTEGER NOT NULL,
5886 end_col INTEGER NOT NULL,
5887 range_ordinal INTEGER NOT NULL,
5888 signature TEXT,
5889 exported INTEGER NOT NULL,
5890 is_default_export INTEGER NOT NULL,
5891 is_type_like INTEGER NOT NULL,
5892 is_callgraph_entry_point INTEGER NOT NULL,
5893 provenance TEXT NOT NULL,
5894 UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
5895 );
5896 CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
5897 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
5898 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
5899
5900 CREATE TABLE IF NOT EXISTS refs (
5901 ref_id TEXT PRIMARY KEY,
5902 caller_node TEXT,
5903 caller_file TEXT NOT NULL,
5904 kind TEXT NOT NULL,
5905 short_name TEXT,
5906 full_ref TEXT,
5907 module_path TEXT,
5908 import_kind TEXT,
5909 local_name TEXT,
5910 requested_name TEXT,
5911 namespace_alias TEXT,
5912 wildcard INTEGER NOT NULL DEFAULT 0,
5913 line INTEGER NOT NULL,
5914 byte_start INTEGER NOT NULL,
5915 byte_end INTEGER NOT NULL,
5916 status TEXT NOT NULL,
5917 target_node TEXT,
5918 target_file TEXT,
5919 target_symbol TEXT,
5920 provenance TEXT NOT NULL
5921 );
5922 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
5923 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
5924 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
5925 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
5926 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
5927
5928 CREATE TABLE IF NOT EXISTS file_dependencies (
5929 file_path TEXT NOT NULL,
5930 dep_file TEXT NOT NULL,
5931 PRIMARY KEY(file_path, dep_file)
5932 );
5933 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
5934
5935 CREATE TABLE IF NOT EXISTS edges (
5936 edge_id TEXT PRIMARY KEY,
5937 ref_id TEXT NOT NULL,
5938 source_node TEXT NOT NULL,
5939 target_node TEXT,
5940 target_file TEXT NOT NULL,
5941 target_symbol TEXT NOT NULL,
5942 kind TEXT NOT NULL,
5943 line INTEGER NOT NULL,
5944 provenance TEXT NOT NULL
5945 );
5946 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
5947 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
5948 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
5949 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
5950
5951 CREATE TABLE IF NOT EXISTS dispatch_hints (
5952 id TEXT PRIMARY KEY,
5953 method_name TEXT NOT NULL,
5954 caller_node TEXT NOT NULL,
5955 file TEXT NOT NULL,
5956 line INTEGER NOT NULL,
5957 byte_start INTEGER NOT NULL,
5958 byte_end INTEGER NOT NULL,
5959 provenance TEXT NOT NULL
5960 );
5961 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
5962
5963 CREATE TABLE IF NOT EXISTS type_ref_names (
5964 name TEXT PRIMARY KEY
5965 );
5966
5967 CREATE TABLE IF NOT EXISTS backend_file_state (
5968 backend TEXT NOT NULL,
5969 workspace_root TEXT NOT NULL,
5970 file_path TEXT NOT NULL,
5971 content_hash TEXT NOT NULL,
5972 status TEXT NOT NULL,
5973 updated_at INTEGER NOT NULL,
5974 PRIMARY KEY(backend, workspace_root, file_path, content_hash)
5975 );
5976 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
5977
5978 CREATE TABLE IF NOT EXISTS meta (
5979 k TEXT PRIMARY KEY,
5980 v TEXT NOT NULL
5981 );",
5982 )?;
5983 insert_meta(conn)?;
5984 Ok(())
5985}
5986
5987fn insert_meta(conn: &Connection) -> Result<()> {
5988 conn.execute(
5989 "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
5990 params![SCHEMA_VERSION.to_string()],
5991 )?;
5992 conn.execute(
5993 "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
5994 params![schema_fingerprint()],
5995 )?;
5996 conn.execute(
5997 "INSERT OR IGNORE INTO meta(k, v) VALUES('projection_write_revision', '0')",
5998 [],
5999 )?;
6000 Ok(())
6001}
6002
6003fn projection_write_revision(conn: &Connection) -> Result<Option<u64>> {
6007 let revision: Option<String> = conn
6008 .query_row(
6009 "SELECT v FROM meta WHERE k = 'projection_write_revision'",
6010 [],
6011 |row| row.get(0),
6012 )
6013 .optional()?;
6014 revision
6015 .map(|revision| {
6016 revision.parse::<u64>().map_err(|error| {
6017 CallGraphStoreError::Unavailable(format!(
6018 "callgraph projection write revision is invalid: {error}"
6019 ))
6020 })
6021 })
6022 .transpose()
6023}
6024
6025fn bump_projection_write_revision(tx: &Transaction<'_>) -> Result<()> {
6028 tx.execute(
6029 "INSERT INTO meta(k, v) VALUES('projection_write_revision', '1')
6030 ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1",
6031 [],
6032 )?;
6033 Ok(())
6034}
6035
6036fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
6037 conn.execute(
6038 "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
6039 params![if ready { "1" } else { "0" }],
6040 )?;
6041 Ok(())
6042}
6043
6044fn database_ready(conn: &Connection) -> Result<bool> {
6045 let schema_version: Option<String> = conn
6046 .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
6047 row.get(0)
6048 })
6049 .optional()?;
6050 let fingerprint: Option<String> = conn
6051 .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
6052 row.get(0)
6053 })
6054 .optional()?;
6055 let ready: Option<String> = conn
6056 .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
6057 .optional()?;
6058
6059 let expected_schema = SCHEMA_VERSION.to_string();
6060 let expected_fingerprint = schema_fingerprint();
6061 Ok(schema_version.as_deref() == Some(expected_schema.as_str())
6062 && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
6063 && ready.as_deref() == Some("1"))
6064}
6065
6066fn ensure_database_ready(conn: &Connection) -> Result<()> {
6067 if database_ready(conn)? {
6068 Ok(())
6069 } else {
6070 Err(CallGraphStoreError::Unavailable(
6071 "database is missing, stale, or mid-build".to_string(),
6072 ))
6073 }
6074}
6075
6076fn schema_fingerprint() -> String {
6077 let input =
6082 format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
6083 hash_to_hex(blake3::hash(input.as_bytes()))
6084}
6085
6086fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
6087 tx.execute_batch(
6088 "DELETE FROM edges;
6089 DELETE FROM file_dependencies;
6090 DELETE FROM refs;
6091 DELETE FROM dispatch_hints;
6092 DELETE FROM type_ref_names;
6093 DELETE FROM backend_file_state;
6094 DELETE FROM nodes;
6095 DELETE FROM files;",
6096 )?;
6097 Ok(())
6098}
6099
6100fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
6101 tx.execute_batch(
6102 "DROP INDEX IF EXISTS idx_nodes_file;
6103 DROP INDEX IF EXISTS idx_nodes_name;
6104 DROP INDEX IF EXISTS idx_nodes_scoped;
6105 DROP INDEX IF EXISTS idx_refs_short_name;
6106 DROP INDEX IF EXISTS idx_refs_kind_caller_file;
6107 DROP INDEX IF EXISTS idx_refs_caller_file;
6108 DROP INDEX IF EXISTS idx_refs_caller_node_kind;
6109 DROP INDEX IF EXISTS idx_refs_target_file;
6110 DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
6111 DROP INDEX IF EXISTS idx_edges_source_kind;
6112 DROP INDEX IF EXISTS idx_edges_target_kind;
6113 DROP INDEX IF EXISTS idx_edges_target_file_symbol;
6114 DROP INDEX IF EXISTS idx_edges_ref_id;
6115 DROP INDEX IF EXISTS idx_dispatch_hints_method;
6116 DROP INDEX IF EXISTS idx_backend_file_state_file;",
6117 )?;
6118 Ok(())
6119}
6120
6121fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
6122 tx.execute_batch(
6123 "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
6124 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
6125 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
6126 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
6127 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
6128 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
6129 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
6130 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
6131 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
6132 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
6133 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
6134 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
6135 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
6136 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
6137 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
6138 )?;
6139 Ok(())
6140}
6141
6142const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
6143 ("files", "path"),
6144 ("nodes", "file_path"),
6145 ("refs", "caller_file"),
6146 ("refs", "target_file"),
6147 ("file_dependencies", "file_path"),
6148 ("file_dependencies", "dep_file"),
6149 ("edges", "target_file"),
6150 ("dispatch_hints", "file"),
6151 ("backend_file_state", "file_path"),
6152];
6153
6154fn reconcile_workspace_roots(
6167 conn: &mut Connection,
6168 project_root: &Path,
6169 allow_repair: bool,
6170) -> Result<OpenRootRepair> {
6171 let roots = stored_workspace_roots(conn)?;
6172 let current_root = project_root.display().to_string();
6173 if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
6174 return Ok(OpenRootRepair::None);
6175 }
6176
6177 if let Some(sample) = sample_absolute_data_path(conn)? {
6178 return Ok(OpenRootRepair::NeedsRebuild {
6179 previous_roots: roots,
6180 current_root,
6181 reason: format!("absolute store data path row {sample}"),
6182 });
6183 }
6184
6185 for stored_root in roots.iter() {
6186 if stored_root == ¤t_root {
6187 continue;
6188 }
6189 if Path::new(stored_root).exists() {
6190 let reason = format!(
6191 "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
6192 );
6193 return Ok(OpenRootRepair::NeedsRebuild {
6194 previous_roots: roots,
6195 current_root,
6196 reason,
6197 });
6198 }
6199 }
6200
6201 if !allow_repair {
6202 return Ok(OpenRootRepair::NeedsRebuild {
6203 previous_roots: roots,
6204 current_root,
6205 reason: "workspace root metadata requires deferred repair".to_string(),
6206 });
6207 }
6208
6209 publish_if_current(|| {
6210 let tx = conn.transaction()?;
6211 tx.execute(
6212 "UPDATE OR IGNORE backend_file_state
6213 SET workspace_root = ?1
6214 WHERE workspace_root <> ?1",
6215 params![¤t_root],
6216 )?;
6217 tx.execute(
6218 "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
6219 params![¤t_root],
6220 )?;
6221 tx.commit()?;
6222 Ok(())
6223 })?;
6224
6225 crate::slog_info!(
6226 "callgraph store re-rooted from {} to {}",
6227 roots.join(", "),
6228 current_root
6229 );
6230 Ok(OpenRootRepair::ReRooted)
6231}
6232
6233fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
6234 let mut stmt = conn.prepare(
6235 "SELECT DISTINCT workspace_root
6236 FROM backend_file_state
6237 ORDER BY workspace_root",
6238 )?;
6239 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
6240 rows.collect::<std::result::Result<Vec<_>, _>>()
6241 .map_err(Into::into)
6242}
6243
6244fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
6245 for (table, column) in STORE_DATA_PATH_COLUMNS {
6246 let sql = format!(
6247 "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
6248 );
6249 let mut stmt = conn.prepare(&sql)?;
6250 let mut rows = stmt.query([])?;
6251 while let Some(row) = rows.next()? {
6252 let value: String = row.get(0)?;
6253 if stored_path_is_absolute(&value) {
6254 return Ok(Some(format!("{table}.{column}={value}")));
6255 }
6256 }
6257 }
6258 Ok(None)
6259}
6260
6261fn stored_path_is_absolute(value: &str) -> bool {
6262 if value.is_empty() {
6263 return false;
6264 }
6265 if Path::new(value).is_absolute() || value.starts_with('/') {
6266 return true;
6267 }
6268 let bytes = value.as_bytes();
6269 if bytes.len() >= 3
6270 && bytes[1] == b':'
6271 && (bytes[2] == b'/' || bytes[2] == b'\\')
6272 && bytes[0].is_ascii_alphabetic()
6273 {
6274 return true;
6275 }
6276 value.starts_with("\\\\") || value.starts_with("//")
6277}
6278
6279fn log_root_repair_rebuild(repair: &OpenRootRepair) {
6280 if let OpenRootRepair::NeedsRebuild {
6281 previous_roots,
6282 current_root,
6283 reason,
6284 } = repair
6285 {
6286 crate::slog_info!(
6287 "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
6288 previous_roots.join(", "),
6289 current_root,
6290 reason
6291 );
6292 }
6293}
6294
6295fn now_nanos() -> u128 {
6297 SystemTime::now()
6298 .duration_since(UNIX_EPOCH)
6299 .unwrap_or(Duration::ZERO)
6300 .as_nanos()
6301}
6302
6303fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
6308 callgraph_dir.join(format!("{project_key}.current"))
6309}
6310
6311fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
6315 callgraph_dir.join(format!("{project_key}.sqlite"))
6316}
6317
6318fn generation_file_name(project_key: &str) -> String {
6322 format!(
6323 "{project_key}.g{}.{}.sqlite",
6324 now_nanos(),
6325 std::process::id()
6326 )
6327}
6328
6329fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
6331 let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
6332 let name = text.trim();
6333 if name.is_empty() {
6334 None
6335 } else {
6336 Some(name.to_string())
6337 }
6338}
6339
6340fn db_path_ready(path: &Path) -> bool {
6343 (|| -> Result<bool> {
6344 let conn = open_readonly_connection(path)?;
6345 database_ready(&conn)
6346 })()
6347 .unwrap_or(false)
6348}
6349
6350fn resolve_ready_target(
6358 callgraph_dir: &Path,
6359 project_key: &str,
6360) -> Option<(PathBuf, Option<String>)> {
6361 for _ in 0..5 {
6362 if let Some(generation) = read_pointer(callgraph_dir, project_key) {
6363 let gen_path = callgraph_dir.join(&generation);
6364 if gen_path.is_file() {
6365 return (migration_manifest_valid(callgraph_dir, &generation)
6366 && db_path_ready(&gen_path))
6367 .then_some((gen_path, Some(generation)));
6368 }
6369 std::thread::sleep(Duration::from_millis(5));
6372 continue;
6373 }
6374 let legacy = legacy_sqlite_path(callgraph_dir, project_key);
6376 return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
6377 }
6378 None
6379}
6380
6381fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
6385 let pointer = pointer_path(callgraph_dir, project_key);
6386 let tmp = callgraph_dir.join(format!(
6387 "{project_key}.current.tmp.{}.{}",
6388 std::process::id(),
6389 now_nanos()
6390 ));
6391 {
6392 use std::io::Write as _;
6393 let mut file = std::fs::File::create(&tmp)?;
6394 file.write_all(generation.as_bytes())?;
6395 file.write_all(b"\n")?;
6396 file.sync_all()?;
6397 }
6398 if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
6399 let _ = std::fs::remove_file(&tmp);
6400 return Err(error.into());
6401 }
6402 crate::fs_lock::sync_parent(&pointer);
6403 Ok(())
6404}
6405
6406#[derive(Clone, Debug)]
6407struct GenerationGcCandidate {
6408 name: String,
6409 path: PathBuf,
6410 modified: SystemTime,
6411}
6412
6413fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
6419 let temp_grace = Duration::from_secs(60);
6420 let now = SystemTime::now();
6421 let pointer_current =
6422 read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
6423 let gen_prefix = format!("{project_key}.g");
6424 let tmp_prefixes = [
6425 format!("{project_key}.g"), format!("{project_key}.current."), format!("{project_key}.sqlite.tmp."), ];
6429 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6430 return;
6431 };
6432 let mut gens: Vec<GenerationGcCandidate> = Vec::new();
6433 for entry in entries.flatten() {
6434 let name = entry.file_name();
6435 let name = name.to_string_lossy().to_string();
6436 let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
6437 let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
6438
6439 if name.contains(".tmp.") {
6441 if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
6442 let _ = std::fs::remove_file(entry.path());
6443 }
6444 continue;
6445 }
6446
6447 if name == format!("{project_key}.sqlite") {
6450 remove_sqlite_file_set(&entry.path());
6451 continue;
6452 }
6453
6454 if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
6455 gens.push(GenerationGcCandidate {
6456 name,
6457 path: entry.path(),
6458 modified: mtime,
6459 });
6460 }
6461 }
6462
6463 let mut superseded = gens
6464 .iter()
6465 .filter(|generation| generation.name != pointer_current)
6466 .collect::<Vec<_>>();
6467 superseded.sort_by(|left, right| {
6468 right
6469 .modified
6470 .cmp(&left.modified)
6471 .then_with(|| right.name.cmp(&left.name))
6472 });
6473 let previous = superseded.first().map(|generation| generation.name.clone());
6474
6475 for generation in gens {
6476 let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
6477 if generation.name == pointer_current
6478 || Some(generation.name.as_str()) == previous.as_deref()
6479 {
6480 continue;
6481 }
6482
6483 let age = now
6484 .duration_since(generation.modified)
6485 .unwrap_or(Duration::ZERO);
6486 if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
6487 continue;
6488 }
6489
6490 remove_sqlite_file_set(&generation.path);
6491 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
6492 let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
6493 callgraph_dir,
6494 &generation.name,
6495 ));
6496 }
6497}
6498
6499fn remove_sqlite_file_set(path: &Path) {
6500 let _ = std::fs::remove_file(path);
6501 remove_sqlite_sidecars(path);
6502}
6503
6504fn remove_sqlite_sidecars(path: &Path) {
6505 let path_text = path.to_string_lossy();
6506 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
6507 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
6508 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
6509}
6510
6511const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
6525
6526fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
6538 sweep_orphaned_build_temps(callgraph_dir);
6539 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6540 return;
6541 };
6542 let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
6543
6544 if let Ok(entries) = std::fs::read_dir(storage_root.join(domain)) {
6546 for entry in entries.flatten() {
6547 if entry.path().is_dir() {
6548 sweep_orphaned_build_temps(&entry.path());
6549 }
6550 }
6551 }
6552
6553 if let Ok(entries) = std::fs::read_dir(&storage_root) {
6555 for entry in entries.flatten() {
6556 let legacy_dir = entry.path().join(domain);
6557 if legacy_dir.is_dir() {
6558 sweep_orphaned_build_temps(&legacy_dir);
6559 }
6560 }
6561 }
6562}
6563
6564fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
6567 sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
6568}
6569
6570fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
6573 let now = SystemTime::now();
6574 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6575 return;
6576 };
6577 let mut removed_any = false;
6578 for entry in entries.flatten() {
6579 let name = entry.file_name().to_string_lossy().to_string();
6580 if !name.contains(".sqlite.tmp.") {
6586 continue;
6587 }
6588 let mtime = entry
6589 .metadata()
6590 .and_then(|meta| meta.modified())
6591 .unwrap_or(now);
6592 if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
6593 continue;
6594 }
6595 match std::fs::remove_file(entry.path()) {
6601 Ok(()) => removed_any = true,
6602 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
6603 Err(_) => {}
6604 }
6605 }
6606 if removed_any {
6607 crate::fs_lock::sync_parent(callgraph_dir);
6608 }
6609}
6610
6611fn build_pool_size() -> usize {
6619 std::thread::available_parallelism()
6620 .map(|parallelism| parallelism.get())
6621 .unwrap_or(1)
6622 .div_ceil(2)
6623 .clamp(1, 8)
6624}
6625
6626fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
6627 let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
6628 Ok(extract) => Ok(extract),
6629 Err(error) => {
6630 let abs_path =
6631 normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
6632 let rel_path = relative_path(project_root, &abs_path);
6633 let freshness = cache_freshness::collect(&abs_path).ok();
6634 log::debug!(
6635 "callgraph store: skipping {} during cold build: {}",
6636 abs_path.display(),
6637 error
6638 );
6639 Err(ExtractFailure {
6640 rel_path,
6641 freshness,
6642 })
6643 }
6644 };
6645
6646 let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
6647 files.par_iter().map(extract_one).collect()
6648 };
6649
6650 let results = match rayon::ThreadPoolBuilder::new()
6653 .num_threads(build_pool_size())
6654 .thread_name(|index| format!("aft-callgraph-build-{index}"))
6655 .stack_size(8 * 1024 * 1024)
6656 .build()
6657 {
6658 Ok(pool) => pool.install(run),
6659 Err(error) => {
6660 log::warn!(
6661 "callgraph store: bounded build pool unavailable ({error}); using global pool"
6662 );
6663 run()
6664 }
6665 };
6666
6667 let mut extracts = Vec::new();
6668 let mut failures = Vec::new();
6669 for result in results {
6670 match result {
6671 Ok(extract) => extracts.push(extract),
6672 Err(failure) => failures.push(failure),
6673 }
6674 }
6675 BuildExtractsResult { extracts, failures }
6676}
6677
6678fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
6679 let metadata = std::fs::metadata(path)?;
6680 let size = metadata.len();
6681 let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
6682 cache_freshness::zero_hash()
6683 } else if source.len() as u64 == size {
6684 cache_freshness::hash_bytes(source.as_bytes())
6685 } else {
6686 cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
6687 };
6688 Ok(FileFreshness {
6689 mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
6690 size,
6691 content_hash,
6692 })
6693}
6694
6695fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
6696 let abs_path = normalize_file_path(project_root, path)?;
6697 let rel_path = relative_path(project_root, &abs_path);
6698 let source = std::fs::read_to_string(&abs_path)?;
6699 let freshness = collect_source_freshness(&abs_path, &source)?;
6700 let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
6701 let lang = data.lang;
6702 if lang == LangId::Rust {
6703 extend_rust_imports_with_nested_uses(&source, &mut data);
6704 }
6705 let mut nodes = build_node_records(&rel_path, &source, &data)?;
6706 let node_by_scoped: HashMap<String, String> = nodes
6707 .iter()
6708 .map(|node| (node.scoped_name.clone(), node.id.clone()))
6709 .collect();
6710 let import_dependencies =
6711 import_dependencies(project_root, &abs_path, &data.import_block.imports);
6712 let line_index = LineIndex::new(&source);
6713 let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
6714 let rust_reexports = if lang == LangId::Rust {
6715 collect_rust_pub_use_reexport_refs(
6716 project_root,
6717 &abs_path,
6718 &rel_path,
6719 &data.import_block.imports,
6720 &line_index,
6721 )
6722 } else {
6723 ReexportRefs {
6724 raw_refs: Vec::new(),
6725 surface_parts: Vec::new(),
6726 }
6727 };
6728 let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
6729 let mut raw_refs = Vec::new();
6730 raw_refs.extend(build_call_refs(
6731 &rel_path,
6732 &data,
6733 &node_by_scoped,
6734 &import_dependencies,
6735 ));
6736 raw_refs.extend(build_import_refs(
6737 project_root,
6738 &abs_path,
6739 &rel_path,
6740 &data.import_block.imports,
6741 &line_index,
6742 ));
6743 let mut surface_parts = reexports.surface_parts;
6744 surface_parts.extend(rust_reexports.surface_parts);
6745 surface_parts.extend(source_less_exports.surface_parts);
6746 raw_refs.extend(reexports.raw_refs);
6747 raw_refs.extend(rust_reexports.raw_refs);
6748 raw_refs.extend(source_less_exports.raw_refs);
6749 let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
6750 let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
6751
6752 Ok(FileExtract {
6753 rel_path,
6754 freshness,
6755 lang,
6756 data,
6757 nodes,
6758 raw_refs,
6759 dispatch_hints,
6760 surface_fingerprint,
6761 })
6762}
6763
6764fn build_node_records(
6765 rel_path: &str,
6766 source: &str,
6767 data: &FileCallData,
6768) -> Result<Vec<NodeRecord>> {
6769 let mut records = Vec::new();
6770 let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
6771 let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
6772 metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
6773
6774 for (scoped_name, meta) in metadata {
6775 let name = unqualified_name(scoped_name).to_string();
6776 let range = selection_range(source, scoped_name, &name, &meta.range);
6777 let range_key = (
6778 range.start_line,
6779 range.start_col,
6780 range.end_line,
6781 range.end_col,
6782 );
6783 let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
6784 let range_ordinal = *ordinal;
6785 *ordinal += 1;
6786 let id = node_id(rel_path, &range, range_ordinal, scoped_name);
6787 let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
6788 let is_default_export = data
6789 .default_export_symbol
6790 .as_deref()
6791 .map(|default| default == scoped_name || default == name)
6792 .unwrap_or(false);
6793 records.push(NodeRecord {
6794 id,
6795 file_path: rel_path.to_string(),
6796 name: name.clone(),
6797 scoped_name: scoped_name.clone(),
6798 kind: symbol_kind_label(&meta.kind).to_string(),
6799 range,
6800 range_ordinal,
6801 signature: meta.signature.clone(),
6802 exported,
6803 is_default_export,
6804 is_type_like: is_type_like(&meta.kind),
6805 is_callgraph_entry_point: meta.entry_point_attribute.is_some()
6806 || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
6807 });
6808 }
6809
6810 Ok(records)
6811}
6812
6813fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
6814 if scoped_name == TOP_LEVEL_SYMBOL {
6815 return Range {
6816 start_line: 0,
6817 start_col: 0,
6818 end_line: 0,
6819 end_col: 0,
6820 };
6821 }
6822 let Some(line) = source.lines().nth(fallback.start_line as usize) else {
6823 return fallback.clone();
6824 };
6825 let start_col = fallback.start_col as usize;
6826 let search_start = start_col.min(line.len());
6827 if let Some(offset) = line[search_start..].find(name) {
6828 let col = search_start + offset;
6829 return Range {
6830 start_line: fallback.start_line,
6831 start_col: col as u32,
6832 end_line: fallback.start_line,
6833 end_col: (col + name.len()) as u32,
6834 };
6835 }
6836 if let Some(offset) = line.find(name) {
6837 return Range {
6838 start_line: fallback.start_line,
6839 start_col: offset as u32,
6840 end_line: fallback.start_line,
6841 end_col: (offset + name.len()) as u32,
6842 };
6843 }
6844 Range {
6845 start_line: fallback.start_line,
6846 start_col: fallback.start_col,
6847 end_line: fallback.start_line,
6848 end_col: fallback.start_col.saturating_add(name.len() as u32),
6849 }
6850}
6851
6852fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
6853 if scoped_name == TOP_LEVEL_SYMBOL {
6854 return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
6855 }
6856 let input = format!(
6857 "{rel_path}:{}:{}:{}:{}:{ordinal}",
6858 range.start_line, range.start_col, range.end_line, range.end_col
6859 );
6860 format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
6861}
6862
6863fn build_call_refs(
6864 rel_path: &str,
6865 data: &FileCallData,
6866 node_by_scoped: &HashMap<String, String>,
6867 import_dependencies: &BTreeSet<String>,
6868) -> Vec<RawRef> {
6869 let mut refs = Vec::new();
6870 let mut ordinal = 0usize;
6871 let mut symbols: Vec<_> = data.calls_by_symbol.iter().collect();
6872 symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
6873 for (caller_symbol, call_sites) in symbols {
6874 let caller_node = node_by_scoped.get(caller_symbol).cloned();
6875 for call_site in call_sites {
6876 ordinal += 1;
6877 let ref_id = ref_id(&[
6878 rel_path,
6879 "call",
6880 caller_symbol,
6881 &call_site.line.to_string(),
6882 &call_site.byte_start.to_string(),
6883 &call_site.byte_end.to_string(),
6884 &call_site.full_callee,
6885 &ordinal.to_string(),
6886 ]);
6887 refs.push(RawRef {
6888 ref_id,
6889 caller_node: caller_node.clone(),
6890 caller_symbol: Some(caller_symbol.clone()),
6891 caller_file: rel_path.to_string(),
6892 kind: "call".to_string(),
6893 short_name: Some(call_site.callee_name.clone()),
6894 full_ref: Some(call_site.full_callee.clone()),
6895 module_path: None,
6896 import_kind: None,
6897 local_name: Some(call_site.callee_name.clone()),
6898 requested_name: Some(call_site.callee_name.clone()),
6899 namespace_alias: namespace_alias(&call_site.full_callee),
6900 wildcard: false,
6901 line: call_site.line,
6902 byte_start: call_site.byte_start,
6903 byte_end: call_site.byte_end,
6904 dependencies: import_dependencies.clone(),
6905 });
6906 }
6907 }
6908 refs
6909}
6910
6911fn build_import_refs(
6912 project_root: &Path,
6913 abs_path: &Path,
6914 rel_path: &str,
6915 imports: &[ImportStatement],
6916 line_index: &LineIndex,
6917) -> Vec<RawRef> {
6918 let mut refs = Vec::new();
6919 for (index, import) in imports.iter().enumerate() {
6920 let import_kind = import_kind_label(import.kind).to_string();
6921 let local_name = import_local_names(import).join(",");
6922 let requested_name = import_requested_names(import).join(",");
6923 let ref_id = ref_id(&[
6924 rel_path,
6925 "import",
6926 &import.byte_range.start.to_string(),
6927 &import.byte_range.end.to_string(),
6928 &import.module_path,
6929 &index.to_string(),
6930 ]);
6931 refs.push(RawRef {
6932 ref_id,
6933 caller_node: None,
6934 caller_symbol: None,
6935 caller_file: rel_path.to_string(),
6936 kind: "import".to_string(),
6937 short_name: None,
6938 full_ref: Some(import.raw_text.clone()),
6939 module_path: Some(import.module_path.clone()),
6940 import_kind: Some(import_kind),
6941 local_name: empty_to_none(local_name),
6942 requested_name: empty_to_none(requested_name),
6943 namespace_alias: import.namespace_import.clone(),
6944 wildcard: import_is_wildcard(import),
6945 line: line_index.byte_to_line(import.byte_range.start),
6946 byte_start: import.byte_range.start,
6947 byte_end: import.byte_range.end,
6948 dependencies: module_dependencies(project_root, abs_path, &import.module_path),
6949 });
6950 }
6951 refs
6952}
6953
6954fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
6955 let grammar = grammar_for(LangId::Rust);
6956 let mut parser = Parser::new();
6957 if parser.set_language(&grammar).is_err() {
6958 return;
6959 }
6960 let Some(tree) = parser.parse(source, None) else {
6961 return;
6962 };
6963
6964 let mut seen = data
6965 .import_block
6966 .imports
6967 .iter()
6968 .map(|import| (import.byte_range.start, import.byte_range.end))
6969 .collect::<HashSet<_>>();
6970 let mut nested_imports = Vec::new();
6971 collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
6972 if nested_imports.is_empty() {
6973 return;
6974 }
6975
6976 data.import_block.imports.extend(nested_imports);
6977 data.import_block
6978 .imports
6979 .sort_by_key(|import| import.byte_range.start);
6980 data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
6981}
6982
6983fn collect_rust_use_imports(
6984 source: &str,
6985 node: Node<'_>,
6986 seen: &mut HashSet<(usize, usize)>,
6987 imports: &mut Vec<ImportStatement>,
6988) {
6989 if node.kind() == "use_declaration" {
6990 let range = node.byte_range();
6991 if seen.insert((range.start, range.end)) {
6992 if let Some(import) = rust_import_from_use_node(source, node) {
6993 imports.push(import);
6994 }
6995 }
6996 }
6997
6998 let mut cursor = node.walk();
6999 if !cursor.goto_first_child() {
7000 return;
7001 }
7002 loop {
7003 collect_rust_use_imports(source, cursor.node(), seen, imports);
7004 if !cursor.goto_next_sibling() {
7005 break;
7006 }
7007 }
7008}
7009
7010fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
7011 let raw_text = source[node.byte_range()].to_string();
7012 let body = rust_use_body(&raw_text)?.to_string();
7013 let visibility = rust_use_visibility(&raw_text);
7014 let names = rust_use_list_names(&body);
7015 let group = classify_rust_import_group(&body);
7016 let byte_range = node.byte_range();
7017
7018 Some(ImportStatement {
7019 module_path: body,
7020 names: names.clone(),
7021 default_import: visibility.clone(),
7022 namespace_import: None,
7023 kind: ImportKind::Value,
7024 group,
7025 byte_range,
7026 raw_text,
7027 form: ImportForm::RustUse {
7028 visibility,
7029 named: names,
7030 },
7031 })
7032}
7033
7034fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
7035 let start = imports.iter().map(|import| import.byte_range.start).min()?;
7036 let end = imports.iter().map(|import| import.byte_range.end).max()?;
7037 Some(start..end)
7038}
7039
7040fn rust_use_visibility(raw_text: &str) -> Option<String> {
7041 let use_pos = raw_text.find("use ")?;
7042 let prefix = raw_text[..use_pos].trim();
7043 if prefix.is_empty() {
7044 None
7045 } else {
7046 Some(prefix.to_string())
7047 }
7048}
7049
7050fn rust_use_body(raw_text: &str) -> Option<&str> {
7051 let use_pos = raw_text.find("use ")?;
7052 Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
7053}
7054
7055fn rust_use_list_names(body: &str) -> Vec<String> {
7056 let Some(open) = body.find("::{") else {
7057 return Vec::new();
7058 };
7059 let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
7060 return Vec::new();
7061 };
7062 body[open + 3..close]
7063 .split(',')
7064 .filter_map(|spec| {
7065 let spec = spec.trim();
7066 if spec.is_empty() {
7067 None
7068 } else {
7069 Some(spec.to_string())
7070 }
7071 })
7072 .collect()
7073}
7074
7075fn classify_rust_import_group(body: &str) -> ImportGroup {
7076 let first = body
7077 .split("::")
7078 .next()
7079 .unwrap_or(body)
7080 .split_whitespace()
7081 .next()
7082 .unwrap_or(body);
7083 match first.trim() {
7084 "std" | "core" | "alloc" => ImportGroup::Stdlib,
7085 "crate" | "self" | "super" => ImportGroup::Internal,
7086 _ => ImportGroup::External,
7087 }
7088}
7089
7090#[derive(Debug, Clone)]
7091struct ReexportRefs {
7092 raw_refs: Vec<RawRef>,
7093 surface_parts: Vec<String>,
7094}
7095
7096fn collect_reexport_refs(
7097 project_root: &Path,
7098 abs_path: &Path,
7099 rel_path: &str,
7100 source: &str,
7101) -> ReexportRefs {
7102 let mut raw_refs = Vec::new();
7103 let mut surface_parts = Vec::new();
7104 let mut search_start = 0usize;
7105 let mut ordinal = 0usize;
7106 while let Some(export_offset) = source[search_start..].find("export") {
7107 let start = search_start + export_offset;
7108 let Some(statement_end_offset) = source[start..].find(';') else {
7109 break;
7110 };
7111 let end = start + statement_end_offset + 1;
7112 let statement = &source[start..end];
7113 search_start = end;
7114 if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
7115 continue;
7116 }
7117 let Some(module_path) = quoted_module_path(statement) else {
7118 continue;
7119 };
7120 ordinal += 1;
7121 let wildcard = statement.contains('*');
7122 let line = source[..start]
7123 .bytes()
7124 .filter(|byte| *byte == b'\n')
7125 .count() as u32
7126 + 1;
7127 let ref_id = ref_id(&[
7128 rel_path,
7129 "reexport",
7130 &start.to_string(),
7131 &end.to_string(),
7132 &module_path,
7133 &ordinal.to_string(),
7134 ]);
7135 surface_parts.push(format!("reexport\t{statement}"));
7136 raw_refs.push(RawRef {
7137 ref_id,
7138 caller_node: None,
7139 caller_symbol: None,
7140 caller_file: rel_path.to_string(),
7141 kind: "reexport".to_string(),
7142 short_name: None,
7143 full_ref: Some(statement.to_string()),
7144 module_path: Some(module_path.clone()),
7145 import_kind: Some("reexport".to_string()),
7146 local_name: None,
7147 requested_name: None,
7148 namespace_alias: None,
7149 wildcard,
7150 line,
7151 byte_start: start,
7152 byte_end: end,
7153 dependencies: module_dependencies(project_root, abs_path, &module_path),
7154 });
7155 }
7156 ReexportRefs {
7157 raw_refs,
7158 surface_parts,
7159 }
7160}
7161
7162fn collect_rust_pub_use_reexport_refs(
7163 project_root: &Path,
7164 abs_path: &Path,
7165 rel_path: &str,
7166 imports: &[ImportStatement],
7167 line_index: &LineIndex,
7168) -> ReexportRefs {
7169 let mut raw_refs = Vec::new();
7170 let mut surface_parts = Vec::new();
7171 let mut ordinal = 0usize;
7172
7173 for import in imports {
7174 let Some(visibility) = &import.default_import else {
7175 continue;
7176 };
7177 if !visibility.starts_with("pub") {
7178 continue;
7179 }
7180 let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
7181 continue;
7182 };
7183 ordinal += 1;
7184 let ref_id = ref_id(&[
7185 rel_path,
7186 "rust_reexport",
7187 &import.byte_range.start.to_string(),
7188 &import.byte_range.end.to_string(),
7189 &module_path,
7190 &ordinal.to_string(),
7191 ]);
7192 surface_parts.push(format!("reexport\t{}", import.raw_text));
7193 raw_refs.push(RawRef {
7194 ref_id,
7195 caller_node: None,
7196 caller_symbol: None,
7197 caller_file: rel_path.to_string(),
7198 kind: "reexport".to_string(),
7199 short_name: None,
7200 full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
7201 module_path: Some(module_path.clone()),
7202 import_kind: Some("reexport".to_string()),
7203 local_name: None,
7204 requested_name: None,
7205 namespace_alias: None,
7206 wildcard,
7207 line: line_index.byte_to_line(import.byte_range.start),
7208 byte_start: import.byte_range.start,
7209 byte_end: import.byte_range.end,
7210 dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
7211 });
7212 }
7213
7214 ReexportRefs {
7215 raw_refs,
7216 surface_parts,
7217 }
7218}
7219
7220fn rust_pub_use_reexport_parts(
7221 import: &ImportStatement,
7222) -> Option<(String, HashMap<String, String>, bool)> {
7223 let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
7224 let body = body.trim();
7225 if let Some(module_path) = body.strip_suffix("::*") {
7226 return Some((module_path.trim().to_string(), HashMap::new(), true));
7227 }
7228
7229 if let Some(brace_start) = body.find("::{") {
7230 let module_path = body[..brace_start].trim().to_string();
7231 let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
7232 if names.is_empty() {
7233 return None;
7234 }
7235 return Some((module_path, names, false));
7236 }
7237
7238 let (module_path, spec) = body.rsplit_once("::")?;
7239 let names = rust_reexport_names_from_specs(spec);
7240 if names.is_empty() {
7241 return None;
7242 }
7243 Some((module_path.trim().to_string(), names, false))
7244}
7245
7246fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
7247 let mut names = HashMap::new();
7248 for spec in specs.split(',') {
7249 let spec = spec.trim();
7250 if spec.is_empty() || spec == "self" {
7251 continue;
7252 }
7253 if let Some((source, local)) = spec.split_once(" as ") {
7254 let source = source.trim();
7255 let local = local.trim();
7256 if !source.is_empty() && !local.is_empty() && source != "self" {
7257 names.insert(local.to_string(), source.to_string());
7258 }
7259 } else {
7260 names.insert(spec.to_string(), spec.to_string());
7261 }
7262 }
7263 names
7264}
7265
7266fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
7267 if named.is_empty() {
7268 return fallback.to_string();
7269 }
7270 let mut specs = named
7271 .iter()
7272 .map(|(local, source)| {
7273 if local == source {
7274 source.clone()
7275 } else {
7276 format!("{source} as {local}")
7277 }
7278 })
7279 .collect::<Vec<_>>();
7280 specs.sort();
7281 format!("pub use {{{}}};", specs.join(", "))
7282}
7283
7284fn quoted_module_path(statement: &str) -> Option<String> {
7285 let quote = match (statement.find('\''), statement.find('"')) {
7286 (Some(single), Some(double)) if single < double => '\'',
7287 (Some(_), Some(_)) => '"',
7288 (Some(_), None) => '\'',
7289 (None, Some(_)) => '"',
7290 (None, None) => return None,
7291 };
7292 let start = statement.find(quote)? + 1;
7293 let end = statement[start..].find(quote)? + start;
7294 Some(statement[start..end].to_string())
7295}
7296
7297#[derive(Debug, Clone)]
7298struct SourceLessExportRefs {
7299 raw_refs: Vec<RawRef>,
7300 surface_parts: Vec<String>,
7301}
7302
7303fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
7304 let mut raw_refs = Vec::new();
7305 let mut surface_parts = Vec::new();
7306 let mut search_start = 0usize;
7307 let mut ordinal = 0usize;
7308 while let Some(export_offset) = source[search_start..].find("export") {
7309 let start = search_start + export_offset;
7310 let Some(statement_end_offset) = source[start..].find(';') else {
7311 break;
7312 };
7313 let end = start + statement_end_offset + 1;
7314 let statement = &source[start..end];
7315 search_start = end;
7316 if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
7317 continue;
7318 }
7319 let aliases = parse_reexport_names(statement);
7320 if aliases.is_empty() {
7321 continue;
7322 }
7323 let line = source[..start]
7324 .bytes()
7325 .filter(|byte| *byte == b'\n')
7326 .count() as u32
7327 + 1;
7328 for (exported, source_symbol) in aliases {
7329 ordinal += 1;
7330 let ref_id = ref_id(&[
7331 rel_path,
7332 "export_alias",
7333 &start.to_string(),
7334 &end.to_string(),
7335 &exported,
7336 &source_symbol,
7337 &ordinal.to_string(),
7338 ]);
7339 surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
7340 raw_refs.push(RawRef {
7341 ref_id,
7342 caller_node: None,
7343 caller_symbol: None,
7344 caller_file: rel_path.to_string(),
7345 kind: "export_alias".to_string(),
7346 short_name: None,
7347 full_ref: Some(statement.to_string()),
7348 module_path: None,
7349 import_kind: Some("export_alias".to_string()),
7350 local_name: Some(exported),
7351 requested_name: Some(source_symbol),
7352 namespace_alias: None,
7353 wildcard: false,
7354 line,
7355 byte_start: start,
7356 byte_end: end,
7357 dependencies: BTreeSet::new(),
7358 });
7359 }
7360 }
7361 SourceLessExportRefs {
7362 raw_refs,
7363 surface_parts,
7364 }
7365}
7366
7367fn build_dispatch_hints(
7368 rel_path: &str,
7369 data: &FileCallData,
7370 node_by_scoped: &HashMap<String, String>,
7371) -> Vec<DispatchHint> {
7372 let mut hints = Vec::new();
7373 let mut ordinal = 0usize;
7374 for (caller_symbol, call_sites) in &data.calls_by_symbol {
7375 let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
7376 continue;
7377 };
7378 for call_site in call_sites {
7379 if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
7380 continue;
7381 }
7382 ordinal += 1;
7383 hints.push(DispatchHint {
7384 id: ref_id(&[
7385 rel_path,
7386 "dispatch",
7387 caller_symbol,
7388 &call_site.line.to_string(),
7389 &call_site.byte_start.to_string(),
7390 &call_site.byte_end.to_string(),
7391 &ordinal.to_string(),
7392 ]),
7393 method_name: call_site.callee_name.clone(),
7394 caller_node: caller_node.clone(),
7395 file: rel_path.to_string(),
7396 line: call_site.line,
7397 byte_start: call_site.byte_start,
7398 byte_end: call_site.byte_end,
7399 });
7400 }
7401 }
7402 hints
7403}
7404
7405fn surface_fingerprint(
7406 nodes: &mut [NodeRecord],
7407 data: &FileCallData,
7408 reexport_parts: &[String],
7409) -> String {
7410 nodes.sort_by(|left, right| {
7411 (left.file_path.as_str(), left.scoped_name.as_str())
7412 .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
7413 });
7414 let mut parts = Vec::new();
7415 for node in nodes.iter() {
7416 parts.push(format!(
7417 "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
7418 node.scoped_name,
7419 node.name,
7420 node.kind,
7421 node.exported,
7422 node.range.start_line,
7423 node.range.start_col,
7424 node.range.end_line,
7425 node.range.end_col,
7426 node.range_ordinal,
7427 node.signature.as_deref().unwrap_or("")
7428 ));
7429 }
7430 let mut exports = data.exported_symbols.clone();
7431 exports.sort();
7432 for export in exports {
7433 parts.push(format!("export\t{export}"));
7434 }
7435 if let Some(default_export) = &data.default_export_symbol {
7436 parts.push(format!("default\t{default_export}"));
7437 }
7438 let mut imports: Vec<String> = data
7439 .import_block
7440 .imports
7441 .iter()
7442 .map(|import| {
7443 format!(
7444 "import\t{}\t{:?}\t{}",
7445 import.module_path, import.form, import.raw_text
7446 )
7447 })
7448 .collect();
7449 imports.sort();
7450 parts.extend(imports);
7451 parts.extend(reexport_parts.iter().cloned());
7452 hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
7453}
7454
7455fn resolve_ref(raw: RawRef, index: &ProjectIndex<'_>) -> Result<ResolvedRef> {
7456 if raw.kind != "call" {
7457 return Ok(ResolvedRef {
7458 dependencies: raw.dependencies.clone(),
7459 raw,
7460 status: "unresolved".to_string(),
7461 target_node: None,
7462 target_file: None,
7463 target_symbol: None,
7464 edge: None,
7465 });
7466 }
7467
7468 let caller_file = raw.caller_file.clone();
7469 let caller_data = index.caller_data.get(&caller_file).ok_or_else(|| {
7470 CallGraphStoreError::MissingCallerData {
7471 file: caller_file.clone(),
7472 }
7473 })?;
7474 let full_ref = raw.full_ref.as_deref().unwrap_or_default();
7475 let short_name = raw.short_name.as_deref().unwrap_or_default();
7476 let mut dependencies = raw.dependencies.clone();
7477
7478 let resolved = match index.lang_for(&caller_file) {
7479 Some(LangId::Rust) => {
7480 resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
7481 }
7482 Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
7483 resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
7484 }
7485 _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
7486 };
7487
7488 let Some((status, target_file, target_symbol)) = resolved else {
7489 return Ok(ResolvedRef {
7490 raw,
7491 status: "unresolved".to_string(),
7492 target_node: None,
7493 target_file: None,
7494 target_symbol: None,
7495 dependencies,
7496 edge: None,
7497 });
7498 };
7499
7500 dependencies.insert(target_file.clone());
7501 let target_node = index.node_for_symbol(&target_file, &target_symbol);
7502 let source_node = raw.caller_node.clone();
7503 let edge = if let Some(source_node) = source_node {
7504 if target_file == caller_file
7505 && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
7506 {
7507 None
7508 } else {
7509 Some(EdgeRecord {
7510 edge_id: ref_id(&[&raw.ref_id, "edge"]),
7511 source_node,
7512 target_node: target_node.clone(),
7513 target_file: target_file.clone(),
7514 target_symbol: target_symbol.clone(),
7515 kind: "call".to_string(),
7516 line: raw.line,
7517 })
7518 }
7519 } else {
7520 None
7521 };
7522
7523 Ok(ResolvedRef {
7524 raw,
7525 status,
7526 target_node,
7527 target_file: Some(target_file),
7528 target_symbol: Some(target_symbol),
7529 dependencies,
7530 edge,
7531 })
7532}
7533
7534fn resolve_js_ts_target(
7535 index: &ProjectIndex<'_>,
7536 caller_file: &str,
7537 full_ref: &str,
7538 short_name: &str,
7539 caller_data: &FileCallData,
7540) -> Option<(String, String, String)> {
7541 if let Some((namespace, member)) = full_ref.split_once('.') {
7542 for import in &caller_data.import_block.imports {
7543 if import.namespace_import.as_deref() == Some(namespace) {
7544 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7545 if let Some((file, symbol)) =
7546 resolve_exported_symbol(index, &target_file, member, 0)
7547 {
7548 return Some(("resolved".to_string(), file, symbol));
7549 }
7550 }
7551 }
7552 }
7553 }
7554
7555 for import in &caller_data.import_block.imports {
7556 for spec in &import.names {
7557 if crate::imports::specifier_local_name(spec) == short_name {
7558 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7559 let requested = crate::imports::specifier_imported_name(spec);
7560 let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
7561 .unwrap_or_else(|| (target_file, requested.to_string()));
7562 return Some(("resolved".to_string(), file, symbol));
7563 }
7564 }
7565 }
7566
7567 if import.default_import.as_deref() == Some(short_name) {
7568 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7569 let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
7570 .or_else(|| {
7571 index
7572 .files
7573 .get(&target_file)
7574 .and_then(|file| file.default_export.clone())
7575 .map(|symbol| (target_file.clone(), symbol))
7576 })
7577 .unwrap_or_else(|| {
7578 let file_name = Path::new(&target_file)
7579 .file_name()
7580 .and_then(|name| name.to_str())
7581 .unwrap_or("unknown")
7582 .to_string();
7583 (target_file, format!("<default:{file_name}>"))
7584 });
7585 return Some(("resolved".to_string(), file, symbol));
7586 }
7587 }
7588 }
7589
7590 for import in &caller_data.import_block.imports {
7591 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7592 if index
7593 .files
7594 .get(&target_file)
7595 .map(|file| file.exports.contains(short_name))
7596 .unwrap_or(false)
7597 {
7598 return Some(("resolved".to_string(), target_file, short_name.to_string()));
7599 }
7600 }
7601 }
7602
7603 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
7604}
7605
7606fn resolve_exported_symbol(
7607 index: &ProjectIndex<'_>,
7608 file: &str,
7609 requested: &str,
7610 depth: usize,
7611) -> Option<(String, String)> {
7612 let mut visited = std::collections::HashMap::new();
7613 resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
7614}
7615
7616fn resolve_exported_symbol_inner(
7625 index: &ProjectIndex<'_>,
7626 file: &str,
7627 requested: &str,
7628 depth: usize,
7629 visited: &mut std::collections::HashMap<(String, String), usize>,
7630) -> Option<(String, String)> {
7631 if depth > 16 {
7632 return None;
7633 }
7634 if requested != "default" {
7635 if let Some(source_symbol) = index
7636 .files
7637 .get(file)
7638 .and_then(|item| item.export_aliases.get(requested))
7639 {
7640 return Some((file.to_string(), source_symbol.clone()));
7641 }
7642 if index
7643 .files
7644 .get(file)
7645 .map(|item| item.exports.contains(requested))
7646 .unwrap_or(false)
7647 {
7648 return Some((file.to_string(), requested.to_string()));
7649 }
7650 } else if let Some(default) = index
7651 .files
7652 .get(file)
7653 .and_then(|item| item.default_export.clone())
7654 {
7655 return Some((file.to_string(), default));
7656 }
7657
7658 match visited.entry((file.to_string(), requested.to_string())) {
7662 std::collections::hash_map::Entry::Occupied(mut seen) => {
7663 if *seen.get() <= depth {
7664 return None;
7665 }
7666 seen.insert(depth);
7667 }
7668 std::collections::hash_map::Entry::Vacant(slot) => {
7669 slot.insert(depth);
7670 }
7671 }
7672
7673 for reexport in index.reexports_for(file) {
7674 let mut next_requested = requested.to_string();
7675 let matches = if reexport.wildcard {
7676 true
7677 } else if let Some(source_name) = reexport.named.get(requested) {
7678 next_requested = source_name.clone();
7679 true
7680 } else {
7681 false
7682 };
7683 if !matches {
7684 continue;
7685 }
7686 if let Some(target_file) = &reexport.target_file {
7687 if let Some(target) = resolve_exported_symbol_inner(
7688 index,
7689 target_file,
7690 &next_requested,
7691 depth + 1,
7692 visited,
7693 ) {
7694 return Some(target);
7695 }
7696 }
7697 }
7698 None
7699}
7700
7701fn resolve_rust_target(
7702 index: &ProjectIndex<'_>,
7703 caller_file: &str,
7704 full_ref: &str,
7705 short_name: &str,
7706 caller_data: &FileCallData,
7707 raw: &RawRef,
7708) -> Option<(String, String, String)> {
7709 if full_ref.contains("::") {
7710 if let Some((target_file, target_symbol)) =
7711 rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
7712 {
7713 return Some(("resolved".to_string(), target_file, target_symbol));
7714 }
7715 }
7716
7717 for import in &caller_data.import_block.imports {
7718 if let Some((target_file, target_symbol)) =
7719 rust_target_for_use(index, caller_file, import, short_name)
7720 {
7721 return Some(("resolved".to_string(), target_file, target_symbol));
7722 }
7723 }
7724
7725 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
7726}
7727
7728fn rust_target_for_qualified(
7729 index: &ProjectIndex<'_>,
7730 caller_file: &str,
7731 full_ref: &str,
7732 short_name: &str,
7733 caller_data: &FileCallData,
7734 raw: &RawRef,
7735) -> Option<(String, String)> {
7736 let mut segments: Vec<&str> = full_ref.split("::").collect();
7737 if segments.len() < 2 {
7738 return None;
7739 }
7740 segments.pop();
7741 let requested_symbol = rust_target_symbol(full_ref, short_name);
7742
7743 for path in rust_module_path_candidates(&segments, caller_data, raw) {
7744 let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
7745 if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
7746 if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
7747 return Some(rust_resolve_reexport_if_symbol_missing(
7748 index,
7749 target_file,
7750 requested_symbol.clone(),
7751 ));
7752 }
7753 }
7754
7755 let module_segments = rust_resolve_segments(caller_file, &path_refs)?;
7756 if let Some(target) =
7757 rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
7758 {
7759 return Some(target);
7760 }
7761 if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
7762 return Some(rust_resolve_reexport_if_symbol_missing(
7763 index,
7764 target_file,
7765 requested_symbol.clone(),
7766 ));
7767 }
7768 }
7769 None
7770}
7771
7772fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
7773 full_ref
7774 .rsplit("::")
7775 .next()
7776 .filter(|name| !name.is_empty())
7777 .unwrap_or(short_name)
7778 .to_string()
7779}
7780
7781fn rust_resolve_reexport_if_symbol_missing(
7782 index: &ProjectIndex<'_>,
7783 target_file: String,
7784 target_symbol: String,
7785) -> (String, String) {
7786 if index
7787 .node_for_symbol(&target_file, &target_symbol)
7788 .is_some()
7789 {
7790 return (target_file, target_symbol);
7791 }
7792 if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
7793 resolved
7794 } else {
7795 (target_file, target_symbol)
7796 }
7797}
7798
7799fn rust_module_path_candidates(
7800 segments: &[&str],
7801 caller_data: &FileCallData,
7802 raw: &RawRef,
7803) -> Vec<Vec<String>> {
7804 let mut candidates = Vec::new();
7805 if let Some(first) = segments.first().copied() {
7806 for import in &caller_data.import_block.imports {
7807 if !rust_import_is_visible_to_call(import, raw) {
7808 continue;
7809 }
7810 let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
7811 continue;
7812 };
7813 if local_name == first {
7814 path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
7815 rust_push_unique_path_candidate(&mut candidates, path_segments);
7816 }
7817 }
7818 }
7819 rust_push_unique_path_candidate(
7820 &mut candidates,
7821 segments
7822 .iter()
7823 .map(|segment| (*segment).to_string())
7824 .collect(),
7825 );
7826 candidates
7827}
7828
7829fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
7830 if !candidates.iter().any(|existing| existing == &candidate) {
7831 candidates.push(candidate);
7832 }
7833}
7834
7835fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
7836 import.byte_range.start <= raw.byte_start
7837}
7838
7839fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
7840 let path = import.module_path.trim().trim_end_matches(';').trim();
7841 if path.contains("::{") || path.contains('{') || path.contains('*') {
7842 return None;
7843 }
7844 let (path_without_alias, alias) = path
7845 .split_once(" as ")
7846 .map(|(left, right)| (left.trim(), Some(right.trim())))
7847 .unwrap_or((path, None));
7848 let segments = path_without_alias
7849 .split("::")
7850 .map(str::trim)
7851 .filter(|segment| !segment.is_empty())
7852 .collect::<Vec<_>>();
7853 let local_name = alias.or_else(|| segments.last().copied())?.to_string();
7854 if local_name.chars().next().is_some_and(char::is_uppercase) {
7855 return None;
7856 }
7857 Some((
7858 local_name,
7859 segments
7860 .into_iter()
7861 .map(|segment| segment.to_string())
7862 .collect(),
7863 ))
7864}
7865
7866fn rust_inline_scoped_target(
7867 index: &ProjectIndex<'_>,
7868 caller_file: &str,
7869 module_segments: &[String],
7870 short_name: &str,
7871) -> Option<(String, String)> {
7872 let src_prefix = rust_src_prefix(caller_file);
7873 let mut file_paths = index.files.keys().cloned().collect::<Vec<_>>();
7874 file_paths.sort();
7875 if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
7876 let caller = file_paths.remove(position);
7877 file_paths.insert(0, caller);
7878 }
7879
7880 for file_path in file_paths {
7881 if index.lang_for(&file_path) != Some(LangId::Rust)
7882 || rust_src_prefix(&file_path) != src_prefix
7883 {
7884 continue;
7885 }
7886 let file_module_segments = rust_module_segments_for_rel(&file_path);
7887 if !module_segments.starts_with(&file_module_segments) {
7888 continue;
7889 }
7890 let scoped_segments = &module_segments[file_module_segments.len()..];
7891 if scoped_segments.is_empty() {
7892 continue;
7893 }
7894 let mut scoped_symbol = scoped_segments.join("::");
7895 scoped_symbol.push_str("::");
7896 scoped_symbol.push_str(short_name);
7897 if index.node_for_symbol(&file_path, &scoped_symbol).is_some() {
7898 return Some((file_path, scoped_symbol));
7899 }
7900 }
7901 None
7902}
7903
7904fn rust_target_for_use(
7905 index: &ProjectIndex<'_>,
7906 caller_file: &str,
7907 import: &ImportStatement,
7908 short_name: &str,
7909) -> Option<(String, String)> {
7910 let path = import.module_path.trim().trim_end_matches(';');
7911 if let Some(brace_start) = path.find("::{") {
7912 let prefix = &path[..brace_start];
7913 if import.names.iter().any(|name| name == short_name) {
7914 let prefix_segments: Vec<&str> = prefix.split("::").collect();
7915 let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
7916 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
7917 return Some((file, short_name.to_string()));
7918 }
7919 return None;
7920 }
7921
7922 let (path_without_alias, alias) = path
7923 .split_once(" as ")
7924 .map(|(left, right)| (left.trim(), Some(right.trim())))
7925 .unwrap_or((path, None));
7926 let segments: Vec<&str> = path_without_alias.split("::").collect();
7927 let imported = alias.or_else(|| segments.last().copied())?;
7928 if imported != short_name {
7929 return None;
7930 }
7931 if segments.len() < 2 {
7932 return None;
7933 }
7934 let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
7935 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
7936 Some((file, segments.last().unwrap_or(&short_name).to_string()))
7937}
7938
7939fn rust_workspace_file_for_segments(index: &ProjectIndex<'_>, segments: &[&str]) -> Option<String> {
7940 let crate_name = segments.first().copied()?;
7941 let src_prefix = index.crate_src_prefix(crate_name)?;
7942 let module_segments = segments[1..]
7943 .iter()
7944 .map(|segment| segment.to_string())
7945 .collect::<Vec<_>>();
7946 rust_file_for_src_prefix(index, &src_prefix, &module_segments)
7947}
7948
7949#[cfg(test)]
7950static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
7951 OnceLock::new();
7952
7953#[cfg(test)]
7954fn note_workspace_crate_prefix_build(project_root: &Path) {
7955 let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
7956 .get_or_init(|| Mutex::new(HashMap::new()))
7957 .lock()
7958 .expect("workspace crate prefix build counts mutex poisoned");
7959 *counts.entry(project_root.to_path_buf()).or_default() += 1;
7960}
7961
7962#[cfg(not(test))]
7963fn note_workspace_crate_prefix_build(_project_root: &Path) {}
7964
7965#[cfg(test)]
7966fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
7967 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
7968 .get_or_init(|| Mutex::new(HashMap::new()))
7969 .lock()
7970 .expect("workspace crate prefix build counts mutex poisoned")
7971 .remove(project_root);
7972}
7973
7974#[cfg(test)]
7975fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
7976 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
7977 .get_or_init(|| Mutex::new(HashMap::new()))
7978 .lock()
7979 .expect("workspace crate prefix build counts mutex poisoned")
7980 .get(project_root)
7981 .copied()
7982 .unwrap_or(0)
7983}
7984
7985fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
7990 note_workspace_crate_prefix_build(project_root);
7991 let mut prefixes = HashMap::new();
7992 let mut stack = vec![project_root.to_path_buf()];
7993 while let Some(dir) = stack.pop() {
7994 let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
7995 if matches!(name, "target" | "node_modules" | ".git") {
7996 continue;
7997 }
7998 let manifest = dir.join("Cargo.toml");
7999 if manifest.is_file() {
8000 let crate_names = rust_manifest_crate_names(&manifest);
8001 if !crate_names.is_empty() {
8002 let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
8003 for crate_name in crate_names {
8004 prefixes
8005 .entry(crate_name)
8006 .or_insert_with(|| src_prefix.clone());
8007 }
8008 }
8009 }
8010 let Ok(entries) = std::fs::read_dir(&dir) else {
8011 continue;
8012 };
8013 for entry in entries.flatten() {
8014 let path = entry.path();
8015 if path.is_dir() {
8016 stack.push(path);
8017 }
8018 }
8019 }
8020 prefixes
8021}
8022
8023fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
8027 let Ok(source) = std::fs::read_to_string(manifest) else {
8028 return Vec::new();
8029 };
8030 let mut in_lib = false;
8031 let mut package_name = None;
8032 let mut lib_name = None;
8033 for line in source.lines() {
8034 let trimmed = line.trim();
8035 if trimmed.starts_with('[') {
8036 in_lib = trimmed == "[lib]";
8037 continue;
8038 }
8039 let Some((key, value)) = trimmed.split_once('=') else {
8040 continue;
8041 };
8042 let key = key.trim();
8043 let value = value.trim().trim_matches('"');
8044 if in_lib && key == "name" {
8045 lib_name = Some(value.to_string());
8046 } else if !in_lib && key == "name" && package_name.is_none() {
8047 package_name = Some(value.to_string());
8048 }
8049 }
8050 let mut names = Vec::new();
8051 if let Some(lib) = lib_name {
8052 names.push(lib);
8053 }
8054 if let Some(package) = package_name {
8055 let normalized = package.replace('-', "_");
8056 if !names.contains(&normalized) {
8057 names.push(normalized);
8058 }
8059 }
8060 names
8061}
8062
8063fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
8064 if segments.is_empty() {
8065 return Some(Vec::new());
8066 }
8067 let caller_segments = rust_module_segments_for_rel(caller_file);
8068 match segments[0] {
8069 "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
8070 "self" => {
8071 let mut resolved = caller_segments;
8072 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
8073 Some(resolved)
8074 }
8075 "super" => {
8076 let mut resolved = caller_segments;
8077 resolved.pop();
8078 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
8079 Some(resolved)
8080 }
8081 _ => {
8082 let mut resolved = caller_segments;
8083 resolved.pop();
8084 resolved.extend(segments.iter().map(|item| item.to_string()));
8085 Some(resolved)
8086 }
8087 }
8088}
8089
8090fn rust_file_for_segments(
8091 index: &ProjectIndex<'_>,
8092 caller_file: &str,
8093 segments: &[String],
8094) -> Option<String> {
8095 rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
8096}
8097
8098fn rust_file_for_src_prefix(
8099 index: &ProjectIndex<'_>,
8100 src_prefix: &str,
8101 segments: &[String],
8102) -> Option<String> {
8103 let candidate = if segments.is_empty() {
8104 [src_prefix, "lib.rs"].join("/")
8105 } else {
8106 format!("{}/{}.rs", src_prefix, segments.join("/"))
8107 };
8108 if index.files.contains_key(&candidate) {
8109 return Some(candidate);
8110 }
8111 if !segments.is_empty() {
8112 let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
8113 if index.files.contains_key(&mod_candidate) {
8114 return Some(mod_candidate);
8115 }
8116 }
8117 None
8118}
8119
8120fn rust_src_prefix(rel_path: &str) -> String {
8121 rel_path
8122 .split_once("/src/")
8123 .map(|(prefix, _)| format!("{prefix}/src"))
8124 .unwrap_or_else(|| "src".to_string())
8125}
8126
8127fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
8128 let after_src = rel_path
8129 .split_once("/src/")
8130 .map(|(_, rest)| rest)
8131 .or_else(|| rel_path.strip_prefix("src/"))
8132 .unwrap_or(rel_path);
8133 if matches!(after_src, "lib.rs" | "main.rs") {
8134 return Vec::new();
8135 }
8136 if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
8137 return prefix.split('/').map(|item| item.to_string()).collect();
8138 }
8139 after_src
8140 .strip_suffix(".rs")
8141 .unwrap_or(after_src)
8142 .split('/')
8143 .map(|item| item.to_string())
8144 .collect()
8145}
8146
8147fn resolve_local_target(
8148 _index: &ProjectIndex<'_>,
8149 caller_file: &str,
8150 full_ref: &str,
8151 short_name: &str,
8152 caller_data: &FileCallData,
8153) -> Option<(String, String, String)> {
8154 if !callgraph::is_bare_callee(full_ref, short_name) {
8155 return None;
8156 }
8157 callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
8158 .ok()
8159 .map(|symbol| {
8160 (
8161 "resolved_local".to_string(),
8162 caller_file.to_string(),
8163 symbol,
8164 )
8165 })
8166}
8167
8168impl<'a> ProjectIndex<'a> {
8169 fn from_parts(
8170 project_root: &Path,
8171 files: HashMap<String, DbFileIndex>,
8172 caller_data: HashMap<String, &'a FileCallData>,
8173 workspace_crate_prefixes: WorkspaceCratePrefixCache,
8174 ) -> Self {
8175 Self {
8176 project_root: project_root.to_path_buf(),
8177 files,
8178 caller_data,
8179 workspace_crate_prefixes,
8180 }
8181 }
8182
8183 fn from_extracts(project_root: &Path, extracts: &'a [FileExtract]) -> Self {
8184 let mut files = HashMap::new();
8185 let mut caller_data = HashMap::new();
8186 for extract in extracts {
8187 let index = DbFileIndex::from_extract(project_root, extract);
8188 caller_data.insert(extract.rel_path.clone(), &extract.data);
8189 files.insert(extract.rel_path.clone(), index);
8190 }
8191 Self::from_parts(
8192 project_root,
8193 files,
8194 caller_data,
8195 WorkspaceCratePrefixCache::default(),
8196 )
8197 }
8198
8199 fn from_db_and_callers(
8200 tx: &Transaction<'_>,
8201 project_root: &Path,
8202 caller_extracts: &'a HashMap<String, FileExtract>,
8203 workspace_crate_prefixes: WorkspaceCratePrefixCache,
8204 ) -> Result<Self> {
8205 let mut files = load_db_file_indexes(tx, project_root)?;
8206 let mut caller_data = HashMap::new();
8207 for (rel_path, extract) in caller_extracts {
8208 files.insert(
8209 rel_path.clone(),
8210 DbFileIndex::from_extract(project_root, extract),
8211 );
8212 caller_data.insert(rel_path.clone(), &extract.data);
8213 }
8214 Ok(Self::from_parts(
8215 project_root,
8216 files,
8217 caller_data,
8218 workspace_crate_prefixes,
8219 ))
8220 }
8221
8222 fn lang_for(&self, rel_path: &str) -> Option<LangId> {
8223 self.files.get(rel_path).and_then(|file| file.lang)
8224 }
8225
8226 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
8227 self.files
8228 .get(caller_file)
8229 .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
8230 }
8231
8232 fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
8233 self.files
8234 .get(rel_path)
8235 .map(|file| file.reexports.as_slice())
8236 .unwrap_or(&[])
8237 }
8238
8239 fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
8240 self.files.get(rel_path).and_then(|file| {
8241 file.node_by_scoped
8242 .get(symbol)
8243 .cloned()
8244 .or_else(|| file.node_by_bare.get(symbol).cloned())
8245 })
8246 }
8247}
8248
8249impl DbFileIndex {
8250 fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
8251 let mut node_by_scoped = HashMap::new();
8252 let mut node_by_bare = HashMap::new();
8253 for node in &extract.nodes {
8254 node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
8255 node_by_bare
8256 .entry(node.name.clone())
8257 .or_insert(node.id.clone());
8258 }
8259 let mut export_aliases = HashMap::new();
8260 for raw_ref in &extract.raw_refs {
8261 if raw_ref.kind == "export_alias" {
8262 if let (Some(exported), Some(source_symbol)) =
8263 (&raw_ref.local_name, &raw_ref.requested_name)
8264 {
8265 export_aliases.insert(exported.clone(), source_symbol.clone());
8266 }
8267 }
8268 }
8269 let mut module_targets = HashMap::new();
8270 let mut reexports = Vec::new();
8271 for raw_ref in &extract.raw_refs {
8272 if !matches!(raw_ref.kind.as_str(), "import" | "reexport") {
8273 continue;
8274 }
8275 let Some(module_path) = &raw_ref.module_path else {
8276 continue;
8277 };
8278 let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
8279 module_targets
8280 .entry(module_path.clone())
8281 .or_insert_with(|| target_file.clone());
8282 if raw_ref.kind == "reexport" {
8283 reexports.push(reexport_index_from_raw(raw_ref, target_file));
8284 }
8285 }
8286 Self {
8287 lang: Some(extract.lang),
8288 exports: extract.data.exported_symbols.iter().cloned().collect(),
8289 default_export: extract.data.default_export_symbol.clone(),
8290 export_aliases,
8291 node_by_scoped,
8292 node_by_bare,
8293 module_targets,
8294 reexports,
8295 }
8296 }
8297}
8298
8299fn load_db_file_indexes(
8300 tx: &Transaction<'_>,
8301 project_root: &Path,
8302) -> Result<HashMap<String, DbFileIndex>> {
8303 let mut files = HashMap::new();
8304 let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
8305 let rows = stmt.query_map([], |row| {
8306 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8307 })?;
8308 for row in rows {
8309 let (rel_path, lang) = row?;
8310 files.insert(
8311 rel_path.clone(),
8312 DbFileIndex {
8313 lang: lang_from_label(&lang),
8314 exports: HashSet::new(),
8315 default_export: None,
8316 export_aliases: HashMap::new(),
8317 node_by_scoped: HashMap::new(),
8318 node_by_bare: HashMap::new(),
8319 module_targets: HashMap::new(),
8320 reexports: Vec::new(),
8321 },
8322 );
8323 }
8324
8325 let mut node_stmt = tx.prepare(
8326 "SELECT file_path, id, name, scoped_name, exported, is_default_export FROM nodes",
8327 )?;
8328 let nodes = node_stmt.query_map([], |row| {
8329 Ok((
8330 row.get::<_, String>(0)?,
8331 row.get::<_, String>(1)?,
8332 row.get::<_, String>(2)?,
8333 row.get::<_, String>(3)?,
8334 row.get::<_, i64>(4)? != 0,
8335 row.get::<_, i64>(5)? != 0,
8336 ))
8337 })?;
8338 for row in nodes {
8339 let (file_path, id, name, scoped_name, exported, is_default_export) = row?;
8340 let file = files
8341 .entry(file_path.clone())
8342 .or_insert_with(|| DbFileIndex {
8343 lang: None,
8344 exports: HashSet::new(),
8345 default_export: None,
8346 export_aliases: HashMap::new(),
8347 node_by_scoped: HashMap::new(),
8348 node_by_bare: HashMap::new(),
8349 module_targets: HashMap::new(),
8350 reexports: Vec::new(),
8351 });
8352 if exported {
8353 file.exports.insert(name.clone());
8354 file.exports.insert(scoped_name.clone());
8355 }
8356 if is_default_export {
8357 file.default_export = Some(scoped_name.clone());
8358 }
8359 file.node_by_scoped.insert(scoped_name, id.clone());
8360 file.node_by_bare.entry(name).or_insert(id);
8361 }
8362 let file_keys: HashSet<String> = files.keys().cloned().collect();
8363 let dependencies_by_file = load_file_dependencies_index(tx)?;
8367 let mut ref_stmt = tx.prepare(
8368 "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
8369 FROM refs WHERE kind IN ('reexport', 'export_alias')",
8370 )?;
8371 let ref_rows = ref_stmt.query_map([], |row| {
8372 Ok((
8373 row.get::<_, String>(0)?,
8374 row.get::<_, String>(1)?,
8375 row.get::<_, String>(2)?,
8376 row.get::<_, Option<String>>(3)?,
8377 row.get::<_, Option<String>>(4)?,
8378 row.get::<_, i64>(5)? != 0,
8379 row.get::<_, Option<String>>(6)?,
8380 row.get::<_, Option<String>>(7)?,
8381 ))
8382 })?;
8383 for row in ref_rows {
8384 let (
8385 ref_id,
8386 caller_file,
8387 kind,
8388 module_path,
8389 full_ref,
8390 wildcard,
8391 local_name,
8392 requested_name,
8393 ) = row?;
8394 if kind == "export_alias" {
8395 if let (Some(exported), Some(source_symbol), Some(file)) =
8396 (local_name, requested_name, files.get_mut(&caller_file))
8397 {
8398 file.export_aliases.insert(exported, source_symbol);
8399 }
8400 continue;
8401 }
8402 let Some(module_path) = module_path else {
8403 continue;
8404 };
8405 let file_deps = dependencies_by_file
8406 .get(&caller_file)
8407 .cloned()
8408 .unwrap_or_default();
8409 let deps = stored_dependencies_for_module(
8410 project_root,
8411 &caller_file,
8412 &module_path,
8413 &file_deps,
8414 &file_keys,
8415 );
8416 let target_file = deps
8417 .iter()
8418 .find(|dep| file_keys.contains(*dep))
8419 .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
8420 if let Some(file) = files.get_mut(&caller_file) {
8421 file.module_targets
8422 .entry(module_path.clone())
8423 .or_insert_with(|| target_file.clone());
8424 if kind == "reexport" {
8425 let raw = RawRef {
8426 ref_id,
8427 caller_node: None,
8428 caller_symbol: None,
8429 caller_file,
8430 kind,
8431 short_name: None,
8432 full_ref,
8433 module_path: Some(module_path),
8434 import_kind: Some("reexport".to_string()),
8435 local_name: None,
8436 requested_name: None,
8437 namespace_alias: None,
8438 wildcard,
8439 line: 0,
8440 byte_start: 0,
8441 byte_end: 0,
8442 dependencies: deps,
8443 };
8444 file.reexports
8445 .push(reexport_index_from_raw(&raw, target_file));
8446 }
8447 }
8448 }
8449
8450 Ok(files)
8451}
8452
8453fn stored_dependencies_for_module(
8454 project_root: &Path,
8455 caller_file: &str,
8456 module_path: &str,
8457 caller_dependencies: &BTreeSet<String>,
8458 indexed_files: &HashSet<String>,
8459) -> BTreeSet<String> {
8460 let caller_path = project_root.join(caller_file);
8461 let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
8462 if module_path.starts_with('.') {
8463 let caller_dir = caller_path.parent().unwrap_or(project_root);
8464 for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
8465 let normalized = if candidate.is_file() {
8466 canonicalize_path(&candidate)
8467 } else {
8468 candidate
8469 };
8470 candidates.insert(relative_path(project_root, &normalized));
8471 }
8472 }
8473 let exact = candidates
8474 .intersection(caller_dependencies)
8475 .filter(|dependency| indexed_files.contains(*dependency))
8476 .cloned()
8477 .collect::<BTreeSet<_>>();
8478 if !exact.is_empty() || module_path.starts_with('.') {
8479 return exact;
8480 }
8481
8482 let module_path = rust_module_path_without_alias_or_use_list(module_path)
8483 .trim_matches(|character| matches!(character, '\'' | '"'));
8484 let package_name = module_path
8485 .split('/')
8486 .next_back()
8487 .unwrap_or(module_path)
8488 .replace('_', "-");
8489 let matched = caller_dependencies
8490 .iter()
8491 .filter(|dependency| indexed_files.contains(*dependency))
8492 .filter(|dependency| {
8493 dependency.as_str() == module_path
8494 || dependency.ends_with(&format!("/{module_path}"))
8495 || Path::new(dependency).components().any(|component| {
8496 component.as_os_str().to_string_lossy().replace('_', "-") == package_name
8497 })
8498 })
8499 .cloned()
8500 .collect::<BTreeSet<_>>();
8501 if matched.len() == 1 {
8502 matched
8503 } else {
8504 BTreeSet::new()
8505 }
8506}
8507
8508fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
8509 let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
8510 let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
8511 let rows = stmt.query_map([], |row| {
8512 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8513 })?;
8514 for row in rows {
8515 let (file_path, dependency) = row?;
8516 by_file.entry(file_path).or_default().insert(dependency);
8517 }
8518 Ok(by_file)
8519}
8520
8521struct ColdBuildInsertStatements<'stmt> {
8522 file: Statement<'stmt>,
8523 node: Statement<'stmt>,
8524 file_dependency: Statement<'stmt>,
8525 dispatch_hint: Statement<'stmt>,
8526 backend_state: Statement<'stmt>,
8527 reference: Statement<'stmt>,
8528 edge: Statement<'stmt>,
8529}
8530
8531impl<'stmt> ColdBuildInsertStatements<'stmt> {
8532 fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
8533 Ok(Self {
8534 file: tx.prepare(
8535 "INSERT OR REPLACE INTO files(
8536 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
8537 is_public_api, surface_fingerprint, indexed_at
8538 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
8539 )?,
8540 node: tx.prepare(
8541 "INSERT OR REPLACE INTO nodes(
8542 id, file_path, name, scoped_name, kind, start_line, start_col,
8543 end_line, end_col, range_ordinal, signature, exported,
8544 is_default_export, is_type_like, is_callgraph_entry_point, provenance
8545 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
8546 )?,
8547 file_dependency: tx.prepare(
8548 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
8549 )?,
8550 dispatch_hint: tx.prepare(
8551 "INSERT OR REPLACE INTO dispatch_hints(
8552 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
8553 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
8554 )?,
8555 backend_state: tx.prepare(
8556 "INSERT OR REPLACE INTO backend_file_state(
8557 backend, workspace_root, file_path, content_hash, status, updated_at
8558 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
8559 )?,
8560 reference: tx.prepare(
8561 "INSERT OR REPLACE INTO refs(
8562 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
8563 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
8564 byte_start, byte_end, status, target_node, target_file, target_symbol,
8565 provenance
8566 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
8567 )?,
8568 edge: tx.prepare(
8569 "INSERT OR REPLACE INTO edges(
8570 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8571 kind, line, provenance
8572 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
8573 )?,
8574 })
8575 }
8576}
8577
8578fn insert_file_extract_prepared(
8579 statements: &mut ColdBuildInsertStatements<'_>,
8580 workspace_root: &str,
8581 extract: &FileExtract,
8582) -> Result<()> {
8583 statements.file.execute(params![
8584 extract.rel_path,
8585 hash_to_hex(extract.freshness.content_hash),
8586 system_time_to_ns(extract.freshness.mtime),
8587 extract.freshness.size as i64,
8588 lang_label(extract.lang),
8589 extract.surface_fingerprint,
8590 unix_seconds_now(),
8591 ])?;
8592 for node in &extract.nodes {
8593 statements.node.execute(params![
8594 node.id,
8595 node.file_path,
8596 node.name,
8597 node.scoped_name,
8598 node.kind,
8599 node.range.start_line as i64,
8600 node.range.start_col as i64,
8601 node.range.end_line as i64,
8602 node.range.end_col as i64,
8603 node.range_ordinal as i64,
8604 node.signature,
8605 bool_int(node.exported),
8606 bool_int(node.is_default_export),
8607 bool_int(node.is_type_like),
8608 bool_int(node.is_callgraph_entry_point),
8609 PROVENANCE_TREESITTER,
8610 ])?;
8611 }
8612
8613 let mut dependencies = BTreeSet::new();
8614 for raw_ref in &extract.raw_refs {
8615 dependencies.extend(raw_ref.dependencies.iter().cloned());
8616 }
8617 for dep_file in &dependencies {
8618 statements
8619 .file_dependency
8620 .execute(params![extract.rel_path, dep_file])?;
8621 }
8622
8623 for hint in &extract.dispatch_hints {
8624 statements.dispatch_hint.execute(params![
8625 hint.id,
8626 hint.method_name,
8627 hint.caller_node,
8628 hint.file,
8629 hint.line as i64,
8630 hint.byte_start as i64,
8631 hint.byte_end as i64,
8632 PROVENANCE_TREESITTER,
8633 ])?;
8634 }
8635 insert_backend_state_prepared(
8636 &mut statements.backend_state,
8637 workspace_root,
8638 &extract.rel_path,
8639 Some(&extract.freshness.content_hash),
8640 "fresh",
8641 )?;
8642 Ok(())
8643}
8644
8645fn insert_backend_state_prepared(
8646 stmt: &mut Statement<'_>,
8647 workspace_root: &str,
8648 rel_path: &str,
8649 content_hash: Option<&blake3::Hash>,
8650 status: &str,
8651) -> Result<()> {
8652 let hash = content_hash
8653 .map(|hash| hash_to_hex(*hash))
8654 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
8655 stmt.execute(params![
8656 BACKEND_TREESITTER,
8657 workspace_root,
8658 rel_path,
8659 hash,
8660 status,
8661 unix_seconds_now(),
8662 ])?;
8663 Ok(())
8664}
8665
8666fn insert_resolved_ref_prepared(
8667 statements: &mut ColdBuildInsertStatements<'_>,
8668 resolved: &ResolvedRef,
8669) -> Result<()> {
8670 let raw = &resolved.raw;
8671 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
8672 statements.reference.execute(params![
8673 raw.ref_id,
8674 raw.caller_node,
8675 raw.caller_file,
8676 raw.kind,
8677 raw.short_name,
8678 raw.full_ref,
8679 raw.module_path,
8680 raw.import_kind,
8681 raw.local_name,
8682 raw.requested_name,
8683 raw.namespace_alias,
8684 bool_int(raw.wildcard),
8685 raw.line as i64,
8686 raw.byte_start as i64,
8687 raw.byte_end as i64,
8688 resolved.status,
8689 resolved.target_node,
8690 resolved.target_file,
8691 resolved.target_symbol,
8692 PROVENANCE_TREESITTER,
8693 ])?;
8694 if let Some(edge) = &resolved.edge {
8695 statements.edge.execute(params![
8696 edge.edge_id,
8697 raw.ref_id,
8698 edge.source_node,
8699 edge.target_node,
8700 edge.target_file,
8701 edge.target_symbol,
8702 edge.kind,
8703 edge.line as i64,
8704 PROVENANCE_TREESITTER,
8705 ])?;
8706 }
8707 Ok(())
8708}
8709
8710fn insert_file_extract(
8711 tx: &Transaction<'_>,
8712 project_root: &Path,
8713 extract: &FileExtract,
8714) -> Result<()> {
8715 tx.execute(
8716 "INSERT OR REPLACE INTO files(
8717 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
8718 is_public_api, surface_fingerprint, indexed_at
8719 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
8720 params![
8721 extract.rel_path,
8722 hash_to_hex(extract.freshness.content_hash),
8723 system_time_to_ns(extract.freshness.mtime),
8724 extract.freshness.size as i64,
8725 lang_label(extract.lang),
8726 extract.surface_fingerprint,
8727 unix_seconds_now(),
8728 ],
8729 )?;
8730 for node in &extract.nodes {
8731 tx.execute(
8732 "INSERT OR REPLACE INTO nodes(
8733 id, file_path, name, scoped_name, kind, start_line, start_col,
8734 end_line, end_col, range_ordinal, signature, exported,
8735 is_default_export, is_type_like, is_callgraph_entry_point, provenance
8736 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
8737 params![
8738 node.id,
8739 node.file_path,
8740 node.name,
8741 node.scoped_name,
8742 node.kind,
8743 node.range.start_line as i64,
8744 node.range.start_col as i64,
8745 node.range.end_line as i64,
8746 node.range.end_col as i64,
8747 node.range_ordinal as i64,
8748 node.signature,
8749 bool_int(node.exported),
8750 bool_int(node.is_default_export),
8751 bool_int(node.is_type_like),
8752 bool_int(node.is_callgraph_entry_point),
8753 PROVENANCE_TREESITTER,
8754 ],
8755 )?;
8756 }
8757 let mut dependencies = BTreeSet::new();
8758 for raw_ref in &extract.raw_refs {
8759 dependencies.extend(raw_ref.dependencies.iter().cloned());
8760 }
8761 insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
8762
8763 for hint in &extract.dispatch_hints {
8764 tx.execute(
8765 "INSERT OR REPLACE INTO dispatch_hints(
8766 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
8767 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
8768 params![
8769 hint.id,
8770 hint.method_name,
8771 hint.caller_node,
8772 hint.file,
8773 hint.line as i64,
8774 hint.byte_start as i64,
8775 hint.byte_end as i64,
8776 PROVENANCE_TREESITTER,
8777 ],
8778 )?;
8779 }
8780 mark_backend_state(
8781 tx,
8782 project_root,
8783 &extract.rel_path,
8784 Some(&extract.freshness.content_hash),
8785 "fresh",
8786 )?;
8787 Ok(())
8788}
8789
8790fn insert_file_dependencies(
8791 tx: &Transaction<'_>,
8792 file_path: &str,
8793 dependencies: &BTreeSet<String>,
8794) -> Result<()> {
8795 for dep_file in dependencies {
8796 tx.execute(
8797 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
8798 params![file_path, dep_file],
8799 )?;
8800 }
8801 Ok(())
8802}
8803
8804fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
8805 let raw = &resolved.raw;
8806 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
8807 tx.execute(
8808 "INSERT OR REPLACE INTO refs(
8809 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
8810 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
8811 byte_start, byte_end, status, target_node, target_file, target_symbol,
8812 provenance
8813 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
8814 params![
8815 raw.ref_id,
8816 raw.caller_node,
8817 raw.caller_file,
8818 raw.kind,
8819 raw.short_name,
8820 raw.full_ref,
8821 raw.module_path,
8822 raw.import_kind,
8823 raw.local_name,
8824 raw.requested_name,
8825 raw.namespace_alias,
8826 bool_int(raw.wildcard),
8827 raw.line as i64,
8828 raw.byte_start as i64,
8829 raw.byte_end as i64,
8830 resolved.status,
8831 resolved.target_node,
8832 resolved.target_file,
8833 resolved.target_symbol,
8834 PROVENANCE_TREESITTER,
8835 ],
8836 )?;
8837 if let Some(edge) = &resolved.edge {
8838 tx.execute(
8839 "INSERT OR REPLACE INTO edges(
8840 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8841 kind, line, provenance
8842 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
8843 params![
8844 edge.edge_id,
8845 raw.ref_id,
8846 edge.source_node,
8847 edge.target_node,
8848 edge.target_file,
8849 edge.target_symbol,
8850 edge.kind,
8851 edge.line as i64,
8852 PROVENANCE_TREESITTER,
8853 ],
8854 )?;
8855 }
8856 Ok(())
8857}
8858
8859fn insert_method_dispatch_edges(
8860 tx: &Transaction<'_>,
8861 project_root: &Path,
8862 caller_files: Option<&BTreeSet<String>>,
8863) -> Result<usize> {
8864 let references = load_name_match_refs(tx, caller_files)?;
8865 if references.is_empty() {
8866 return Ok(0);
8867 }
8868
8869 let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
8870 let mut source_cache: DispatchSourceCache = HashMap::new();
8871 let mut inserted = 0usize;
8872 for reference in references {
8873 let key = (reference.method_name.clone(), reference.lang.clone());
8874 let candidates = match candidates_by_name.entry(key) {
8875 Entry::Occupied(entry) => entry.into_mut(),
8876 Entry::Vacant(entry) => {
8877 let candidates =
8878 load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
8879 entry.insert(candidates)
8880 }
8881 };
8882
8883 match infer_receiver_type_state(project_root, &reference, &mut source_cache) {
8884 ReceiverTypeInference::Known(receiver_type) => {
8885 let Some(candidate) =
8886 select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
8887 else {
8888 continue;
8889 };
8890 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
8891 inserted += 1;
8892 continue;
8893 }
8894 ReceiverTypeInference::RustDirectSelfField {
8895 receiver_type,
8896 declaration_file,
8897 module_scope,
8898 } => {
8899 let Some(candidate) = select_rust_direct_self_field_candidate(
8900 project_root,
8901 &reference,
8902 candidates.as_slice(),
8903 &receiver_type,
8904 &declaration_file,
8905 &module_scope,
8906 &mut source_cache,
8907 ) else {
8908 continue;
8909 };
8910 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
8911 inserted += 1;
8912 continue;
8913 }
8914 ReceiverTypeInference::KnownButUnresolved => continue,
8915 ReceiverTypeInference::Unknown => {}
8916 }
8917
8918 if method_name_match_denylisted(&reference.method_name) {
8919 continue;
8920 }
8921
8922 let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
8923 continue;
8924 };
8925 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
8926 inserted += 1;
8927 }
8928 Ok(inserted)
8929}
8930
8931fn insert_method_dispatch_edges_chunked(
8932 tx: &Transaction<'_>,
8933 project_root: &Path,
8934 caller_files: &BTreeSet<String>,
8935 chunk_size: usize,
8936) -> Result<usize> {
8937 if caller_files.is_empty() {
8938 return Ok(0);
8939 }
8940 if chunk_size == 0 || caller_files.len() <= chunk_size {
8941 return insert_method_dispatch_edges(tx, project_root, Some(caller_files));
8942 }
8943
8944 let mut inserted = 0usize;
8945 let mut batch = BTreeSet::new();
8946 for caller_file in caller_files {
8947 batch.insert(caller_file.clone());
8948 if batch.len() == chunk_size {
8949 inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
8950 batch.clear();
8951 }
8952 }
8953 if !batch.is_empty() {
8954 inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
8955 }
8956 Ok(inserted)
8957}
8958
8959fn insert_method_dispatch_edge(
8960 tx: &Transaction<'_>,
8961 reference: &NameMatchRef,
8962 candidate: &NameMatchCandidate,
8963 provenance: &str,
8964) -> Result<()> {
8965 tx.execute(
8966 "INSERT OR REPLACE INTO edges(
8967 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8968 kind, line, provenance
8969 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
8970 params![
8971 ref_id(&[&reference.ref_id, provenance, "edge"]),
8972 &reference.ref_id,
8973 &reference.caller_node,
8974 &candidate.node_id,
8975 &candidate.file_path,
8976 &candidate.scoped_name,
8977 reference.line as i64,
8978 provenance,
8979 ],
8980 )?;
8981 Ok(())
8982}
8983
8984fn delete_method_dispatch_edges_for_callers(
8985 tx: &Transaction<'_>,
8986 caller_files: &BTreeSet<String>,
8987) -> Result<()> {
8988 if caller_files.is_empty() {
8989 return Ok(());
8990 }
8991
8992 let mut stmt = tx.prepare(
8993 "DELETE FROM edges
8994 WHERE provenance IN (?1, ?2)
8995 AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
8996 )?;
8997 for caller_file in caller_files {
8998 stmt.execute(params![
8999 PROVENANCE_NAME_MATCH,
9000 PROVENANCE_TYPE_MATCH,
9001 caller_file
9002 ])?;
9003 }
9004 Ok(())
9005}
9006
9007fn load_name_match_refs(
9008 tx: &Transaction<'_>,
9009 caller_files: Option<&BTreeSet<String>>,
9010) -> Result<Vec<NameMatchRef>> {
9011 let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
9012 n.signature, r.short_name, r.full_ref, r.line, f.lang
9013 FROM refs r
9014 JOIN files f ON f.path = r.caller_file
9015 JOIN nodes n ON n.id = r.caller_node
9016 WHERE r.kind = 'call'
9017 AND r.status = 'unresolved'
9018 AND r.caller_node IS NOT NULL
9019 AND r.full_ref IS NOT NULL
9020 AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
9021 AND NOT EXISTS (
9022 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
9023 )";
9024 let mut references = Vec::new();
9025
9026 if let Some(caller_files) = caller_files {
9027 if caller_files.is_empty() {
9028 return Ok(references);
9029 }
9030 let sql = format!(
9031 "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
9032 );
9033 let mut stmt = tx.prepare(&sql)?;
9034 for caller_file in caller_files {
9035 let rows = stmt.query_map(params![caller_file], |row| {
9036 Ok((
9037 row.get::<_, String>(0)?,
9038 row.get::<_, Option<String>>(1)?,
9039 row.get::<_, String>(2)?,
9040 row.get::<_, String>(3)?,
9041 row.get::<_, Option<String>>(4)?,
9042 row.get::<_, Option<String>>(5)?,
9043 row.get::<_, Option<String>>(6)?,
9044 row.get::<_, i64>(7)?,
9045 row.get::<_, String>(8)?,
9046 ))
9047 })?;
9048 for row in rows {
9049 let (
9050 ref_id,
9051 caller_node,
9052 caller_file,
9053 caller_symbol,
9054 caller_signature,
9055 short_name,
9056 full_ref,
9057 line,
9058 lang,
9059 ) = row?;
9060 if let Some(reference) = name_match_ref_from_parts(
9061 ref_id,
9062 caller_node,
9063 caller_file,
9064 caller_symbol,
9065 caller_signature,
9066 short_name,
9067 full_ref,
9068 line,
9069 lang,
9070 ) {
9071 references.push(reference);
9072 }
9073 }
9074 }
9075 return Ok(references);
9076 }
9077
9078 let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
9079 let mut stmt = tx.prepare(&sql)?;
9080 let rows = stmt.query_map([], |row| {
9081 Ok((
9082 row.get::<_, String>(0)?,
9083 row.get::<_, Option<String>>(1)?,
9084 row.get::<_, String>(2)?,
9085 row.get::<_, String>(3)?,
9086 row.get::<_, Option<String>>(4)?,
9087 row.get::<_, Option<String>>(5)?,
9088 row.get::<_, Option<String>>(6)?,
9089 row.get::<_, i64>(7)?,
9090 row.get::<_, String>(8)?,
9091 ))
9092 })?;
9093 for row in rows {
9094 let (
9095 ref_id,
9096 caller_node,
9097 caller_file,
9098 caller_symbol,
9099 caller_signature,
9100 short_name,
9101 full_ref,
9102 line,
9103 lang,
9104 ) = row?;
9105 if let Some(reference) = name_match_ref_from_parts(
9106 ref_id,
9107 caller_node,
9108 caller_file,
9109 caller_symbol,
9110 caller_signature,
9111 short_name,
9112 full_ref,
9113 line,
9114 lang,
9115 ) {
9116 references.push(reference);
9117 }
9118 }
9119 Ok(references)
9120}
9121
9122#[allow(clippy::too_many_arguments)]
9123fn name_match_ref_from_parts(
9124 ref_id: String,
9125 caller_node: Option<String>,
9126 caller_file: String,
9127 caller_symbol: String,
9128 caller_signature: Option<String>,
9129 short_name: Option<String>,
9130 full_ref: Option<String>,
9131 line: i64,
9132 lang: String,
9133) -> Option<NameMatchRef> {
9134 let caller_node = caller_node?;
9135 let full_ref = full_ref?;
9136 let (receiver_expression, receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
9137 let method_name = if member.is_empty() {
9138 short_name.as_deref()?.to_string()
9139 } else {
9140 member
9141 };
9142 Some(NameMatchRef {
9143 ref_id,
9144 caller_node,
9145 caller_file,
9146 caller_symbol,
9147 caller_signature,
9148 receiver_expression,
9149 receiver,
9150 method_name,
9151 colon_dispatch,
9152 line: line.max(0) as u32,
9153 lang,
9154 })
9155}
9156
9157fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, String, bool)> {
9158 let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
9159 let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
9160 let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
9161 let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
9162 .into_iter()
9163 .flatten()
9164 .max_by_key(|(index, _, _)| *index)?;
9165 if delimiter == 0 {
9166 return None;
9167 }
9168 let member_start = delimiter + delimiter_len;
9169 if member_start >= full_ref.len() {
9170 return None;
9171 }
9172 let receiver_expression = full_ref[..delimiter].trim();
9173 let receiver = last_name_segment(receiver_expression).trim();
9174 let member = &full_ref[member_start..];
9175 if receiver.is_empty() || member.is_empty() {
9176 return None;
9177 }
9178 Some((
9179 receiver_expression.to_string(),
9180 receiver.to_string(),
9181 member.to_string(),
9182 colon_dispatch,
9183 ))
9184}
9185
9186fn last_name_segment(value: &str) -> &str {
9187 value
9188 .rsplit(['.', ':', '/', '\\', '-', '>'])
9189 .find(|segment| !segment.is_empty())
9190 .unwrap_or(value)
9191}
9192
9193fn load_name_match_candidates(
9194 tx: &Transaction<'_>,
9195 method_name: &str,
9196 lang: &str,
9197) -> Result<Vec<NameMatchCandidate>> {
9198 let mut stmt = tx.prepare(
9199 "SELECT n.id, n.file_path, n.scoped_name, n.kind, n.start_line
9200 FROM nodes n JOIN files f ON f.path = n.file_path
9201 WHERE n.name = ?1
9202 AND f.lang = ?2
9203 AND n.kind IN ('method', 'function')
9204 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
9205 )?;
9206 let rows = stmt.query_map(params![method_name, lang], |row| {
9207 Ok(NameMatchCandidate {
9208 node_id: row.get(0)?,
9209 file_path: row.get(1)?,
9210 scoped_name: row.get(2)?,
9211 kind: row.get(3)?,
9212 start_line: (row.get::<_, i64>(4)?.max(0) as u32).saturating_add(1),
9213 })
9214 })?;
9215 rows.collect::<std::result::Result<Vec<_>, _>>()
9216 .map_err(Into::into)
9217}
9218
9219struct ParsedDispatchSource {
9220 source: String,
9221 tree: tree_sitter::Tree,
9222}
9223
9224type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
9225
9226#[derive(Debug, Clone, PartialEq, Eq)]
9227enum ReceiverTypeInference {
9228 Unknown,
9229 Known(String),
9230 RustDirectSelfField {
9231 receiver_type: String,
9232 declaration_file: String,
9233 module_scope: Vec<(usize, usize)>,
9234 },
9235 KnownButUnresolved,
9236}
9237
9238#[cfg(test)]
9239fn infer_receiver_type(
9240 project_root: &Path,
9241 reference: &NameMatchRef,
9242 source_cache: &mut DispatchSourceCache,
9243) -> Option<String> {
9244 match infer_receiver_type_state(project_root, reference, source_cache) {
9245 ReceiverTypeInference::Known(receiver_type)
9246 | ReceiverTypeInference::RustDirectSelfField { receiver_type, .. } => Some(receiver_type),
9247 ReceiverTypeInference::Unknown | ReceiverTypeInference::KnownButUnresolved => None,
9248 }
9249}
9250
9251fn infer_receiver_type_state(
9252 project_root: &Path,
9253 reference: &NameMatchRef,
9254 source_cache: &mut DispatchSourceCache,
9255) -> ReceiverTypeInference {
9256 let known = |receiver_type| ReceiverTypeInference::Known(receiver_type);
9257 match reference.lang.as_str() {
9258 "rust" => infer_rust_receiver_type(project_root, reference, source_cache),
9259 "java" => {
9260 infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
9261 .map(known)
9262 .unwrap_or(ReceiverTypeInference::Unknown)
9263 }
9264 "kotlin" => {
9265 infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
9266 .map(known)
9267 .unwrap_or(ReceiverTypeInference::Unknown)
9268 }
9269 "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache)
9270 .map(known)
9271 .unwrap_or(ReceiverTypeInference::Unknown),
9272 _ => ReceiverTypeInference::Unknown,
9273 }
9274}
9275
9276fn parse_dispatch_source(
9277 project_root: &Path,
9278 caller_file: &str,
9279 lang: LangId,
9280) -> Option<ParsedDispatchSource> {
9281 let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
9282 let grammar = crate::parser::grammar_for(lang);
9283 let mut parser = tree_sitter::Parser::new();
9284 parser.set_language(&grammar).ok()?;
9285 let tree = parser.parse(&source, None)?;
9286 Some(ParsedDispatchSource { source, tree })
9287}
9288
9289fn parsed_dispatch_source<'a>(
9290 project_root: &Path,
9291 reference: &NameMatchRef,
9292 lang: LangId,
9293 source_cache: &'a mut DispatchSourceCache,
9294) -> Option<&'a ParsedDispatchSource> {
9295 parsed_dispatch_source_for_file(
9296 project_root,
9297 &reference.caller_file,
9298 &reference.lang,
9299 lang,
9300 source_cache,
9301 )
9302}
9303
9304fn parsed_dispatch_source_for_file<'a>(
9305 project_root: &Path,
9306 file_path: &str,
9307 lang_label: &str,
9308 lang: LangId,
9309 source_cache: &'a mut DispatchSourceCache,
9310) -> Option<&'a ParsedDispatchSource> {
9311 let key = (file_path.to_string(), lang_label.to_string());
9312 source_cache
9313 .entry(key)
9314 .or_insert_with(|| parse_dispatch_source(project_root, file_path, lang))
9315 .as_ref()
9316}
9317
9318fn infer_java_like_receiver_type(
9319 project_root: &Path,
9320 reference: &NameMatchRef,
9321 lang: LangId,
9322 source_cache: &mut DispatchSourceCache,
9323) -> Option<String> {
9324 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
9325 return None;
9326 }
9327
9328 let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
9329 let root = parsed.tree.root_node();
9330 let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
9331
9332 let callable_scope = type_node
9333 .and_then(|node| {
9334 find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
9335 })
9336 .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
9337
9338 if let Some(callable_scope) = callable_scope {
9339 if let Some(receiver_type) = infer_java_like_local_receiver_type(
9340 callable_scope,
9341 &parsed.source,
9342 &reference.receiver,
9343 reference.line.max(1),
9344 lang,
9345 ) {
9346 return Some(receiver_type);
9347 }
9348 }
9349
9350 type_node.and_then(|node| {
9351 infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
9352 })
9353}
9354
9355fn infer_cpp_receiver_type(
9356 project_root: &Path,
9357 reference: &NameMatchRef,
9358 source_cache: &mut DispatchSourceCache,
9359) -> Option<String> {
9360 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
9361 return None;
9362 }
9363
9364 let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
9365 let root = parsed.tree.root_node();
9366 let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
9367 infer_cpp_receiver_type_from_scope(
9368 scope,
9369 &parsed.source,
9370 &reference.receiver,
9371 reference.line.max(1),
9372 )
9373}
9374
9375fn find_enclosing_java_like_type_node<'tree>(
9376 root: tree_sitter::Node<'tree>,
9377 source: &str,
9378 reference: &NameMatchRef,
9379 lang: LangId,
9380) -> Option<tree_sitter::Node<'tree>> {
9381 let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
9382 .and_then(|name| simple_type_name(&name));
9383 let line = reference.line.max(1);
9384 let mut best = None;
9385 let mut stack = vec![root];
9386 while let Some(node) = stack.pop() {
9387 if !node_contains_line(node, line) {
9388 continue;
9389 }
9390 if is_java_like_type_kind(node.kind(), lang) {
9391 let name = declaration_name(node, source);
9392 if expected_type
9393 .as_deref()
9394 .is_none_or(|expected| name == Some(expected))
9395 {
9396 best = tighter_node(best, node);
9397 }
9398 }
9399 push_named_children(node, &mut stack);
9400 }
9401 best
9402}
9403
9404fn find_enclosing_java_like_callable_node<'tree>(
9405 root: tree_sitter::Node<'tree>,
9406 source: &str,
9407 reference: &NameMatchRef,
9408 lang: LangId,
9409) -> Option<tree_sitter::Node<'tree>> {
9410 let expected_name = reference.caller_symbol.rsplit("::").next();
9411 let line = reference.line.max(1);
9412 let mut best = None;
9413 let mut stack = vec![root];
9414 while let Some(node) = stack.pop() {
9415 if !node_contains_line(node, line) {
9416 continue;
9417 }
9418 if is_java_like_callable_kind(node.kind(), lang) {
9419 let name = declaration_name(node, source);
9420 if expected_name.is_none_or(|expected| name == Some(expected)) {
9421 best = tighter_node(best, node);
9422 }
9423 }
9424 push_named_children(node, &mut stack);
9425 }
9426 best
9427}
9428
9429fn find_enclosing_cpp_callable_node<'tree>(
9430 root: tree_sitter::Node<'tree>,
9431 _source: &str,
9432 reference: &NameMatchRef,
9433) -> Option<tree_sitter::Node<'tree>> {
9434 let line = reference.line.max(1);
9435 let mut best = None;
9436 let mut stack = vec![root];
9437 while let Some(node) = stack.pop() {
9438 if !node_contains_line(node, line) {
9439 continue;
9440 }
9441 if node.kind() == "function_definition" {
9442 best = tighter_node(best, node);
9443 }
9444 push_named_children(node, &mut stack);
9445 }
9446 best
9447}
9448
9449fn tighter_node<'tree>(
9450 current: Option<tree_sitter::Node<'tree>>,
9451 candidate: tree_sitter::Node<'tree>,
9452) -> Option<tree_sitter::Node<'tree>> {
9453 match current {
9454 Some(current)
9455 if current.start_byte() > candidate.start_byte()
9456 || (current.start_byte() == candidate.start_byte()
9457 && current.end_byte() <= candidate.end_byte()) =>
9458 {
9459 Some(current)
9460 }
9461 _ => Some(candidate),
9462 }
9463}
9464
9465fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
9466 let start = node.start_position().row as u32 + 1;
9467 let end = node.end_position().row as u32 + 1;
9468 start <= line && line <= end
9469}
9470
9471fn push_named_children<'tree>(
9472 node: tree_sitter::Node<'tree>,
9473 stack: &mut Vec<tree_sitter::Node<'tree>>,
9474) {
9475 for index in 0..node.named_child_count() {
9476 if let Some(child) = node.named_child(index as u32) {
9477 stack.push(child);
9478 }
9479 }
9480}
9481
9482fn declaration_name<'source>(
9483 node: tree_sitter::Node<'_>,
9484 source: &'source str,
9485) -> Option<&'source str> {
9486 node.child_by_field_name("name")
9487 .map(|name| node_text(name, source))
9488 .or_else(|| {
9489 first_named_child_text(
9490 node,
9491 source,
9492 &["identifier", "type_identifier", "simple_identifier"],
9493 )
9494 })
9495}
9496
9497fn first_named_child_text<'source>(
9498 node: tree_sitter::Node<'_>,
9499 source: &'source str,
9500 kinds: &[&str],
9501) -> Option<&'source str> {
9502 for index in 0..node.named_child_count() {
9503 let child = node.named_child(index as u32)?;
9504 if kinds.contains(&child.kind()) {
9505 return Some(node_text(child, source));
9506 }
9507 }
9508 None
9509}
9510
9511fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
9512 &source[node.byte_range()]
9513}
9514
9515fn infer_java_like_field_receiver_type(
9516 type_node: tree_sitter::Node<'_>,
9517 source: &str,
9518 receiver: &str,
9519 lang: LangId,
9520) -> Option<String> {
9521 let mut stack = Vec::new();
9522 push_named_children(type_node, &mut stack);
9523 while let Some(node) = stack.pop() {
9524 if is_java_like_field_kind(node.kind(), lang) {
9525 if let Some(receiver_type) =
9526 extract_java_like_declared_type(node_text(node, source), receiver, lang)
9527 {
9528 return Some(receiver_type);
9529 }
9530 }
9531 if is_java_like_type_kind(node.kind(), lang)
9532 || is_java_like_callable_kind(node.kind(), lang)
9533 {
9534 continue;
9535 }
9536 push_named_children(node, &mut stack);
9537 }
9538 None
9539}
9540
9541fn infer_java_like_local_receiver_type(
9542 callable_node: tree_sitter::Node<'_>,
9543 source: &str,
9544 receiver: &str,
9545 call_line: u32,
9546 lang: LangId,
9547) -> Option<String> {
9548 let mut best: Option<(u32, String)> = None;
9549 let mut stack = Vec::new();
9550 push_named_children(callable_node, &mut stack);
9551 while let Some(node) = stack.pop() {
9552 let start_line = node.start_position().row as u32 + 1;
9553 if start_line > call_line {
9554 continue;
9555 }
9556 if is_java_like_local_kind(node.kind(), lang) {
9557 if let Some(receiver_type) =
9558 extract_java_like_declared_type(node_text(node, source), receiver, lang)
9559 {
9560 if best
9561 .as_ref()
9562 .is_none_or(|(best_line, _)| start_line >= *best_line)
9563 {
9564 best = Some((start_line, receiver_type));
9565 }
9566 }
9567 }
9568 if is_java_like_type_kind(node.kind(), lang)
9569 || is_java_like_callable_kind(node.kind(), lang)
9570 {
9571 continue;
9572 }
9573 push_named_children(node, &mut stack);
9574 }
9575 best.map(|(_, receiver_type)| receiver_type)
9576}
9577
9578fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
9579 match lang {
9580 LangId::Java => matches!(
9581 kind,
9582 "class_declaration"
9583 | "interface_declaration"
9584 | "enum_declaration"
9585 | "record_declaration"
9586 | "annotation_type_declaration"
9587 ),
9588 LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
9589 _ => false,
9590 }
9591}
9592
9593fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
9594 match lang {
9595 LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
9596 LangId::Kotlin => kind == "function_declaration",
9597 _ => false,
9598 }
9599}
9600
9601fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
9602 match lang {
9603 LangId::Java => kind == "field_declaration",
9604 LangId::Kotlin => kind == "property_declaration",
9605 _ => false,
9606 }
9607}
9608
9609fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
9610 match lang {
9611 LangId::Java => kind == "local_variable_declaration",
9612 LangId::Kotlin => kind == "property_declaration",
9613 _ => false,
9614 }
9615}
9616
9617fn extract_java_like_declared_type(
9618 declaration: &str,
9619 receiver: &str,
9620 lang: LangId,
9621) -> Option<String> {
9622 match lang {
9623 LangId::Java => extract_java_declared_type(declaration, receiver),
9624 LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
9625 _ => None,
9626 }
9627}
9628
9629fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
9630 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
9631 let after = declaration[receiver_start + receiver.len()..].trim_start();
9632 if after
9633 .chars()
9634 .next()
9635 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
9636 {
9637 return None;
9638 }
9639
9640 let before = declaration[..receiver_start].trim_end();
9641 if before.contains(',') {
9642 return None;
9643 }
9644 normalize_receiver_type_name(strip_java_declaration_prefixes(before))
9645}
9646
9647fn strip_java_declaration_prefixes(mut value: &str) -> &str {
9648 loop {
9649 value = value.trim_start();
9650 if let Some(stripped) = strip_leading_java_annotation(value) {
9651 value = stripped;
9652 continue;
9653 }
9654 if let Some(stripped) = strip_leading_java_modifier(value) {
9655 value = stripped;
9656 continue;
9657 }
9658 return value.trim();
9659 }
9660}
9661
9662fn strip_leading_java_annotation(value: &str) -> Option<&str> {
9663 let value = value.trim_start();
9664 let mut chars = value.char_indices();
9665 let (_, first) = chars.next()?;
9666 if first != '@' {
9667 return None;
9668 }
9669 let mut end = first.len_utf8();
9670 for (index, ch) in chars {
9671 if !(is_code_ident_char(ch) || ch == '.') {
9672 end = index;
9673 break;
9674 }
9675 end = index + ch.len_utf8();
9676 }
9677 let rest = value[end..].trim_start();
9678 if let Some(stripped) = rest.strip_prefix('(') {
9679 let mut depth = 1usize;
9680 for (index, ch) in stripped.char_indices() {
9681 match ch {
9682 '(' => depth += 1,
9683 ')' => {
9684 depth = depth.saturating_sub(1);
9685 if depth == 0 {
9686 return Some(stripped[index + ch.len_utf8()..].trim_start());
9687 }
9688 }
9689 _ => {}
9690 }
9691 }
9692 return Some("");
9693 }
9694 Some(rest)
9695}
9696
9697fn strip_leading_java_modifier(value: &str) -> Option<&str> {
9698 const MODIFIERS: &[&str] = &[
9699 "public",
9700 "protected",
9701 "private",
9702 "abstract",
9703 "static",
9704 "final",
9705 "transient",
9706 "volatile",
9707 "synchronized",
9708 "native",
9709 "strictfp",
9710 ];
9711 MODIFIERS
9712 .iter()
9713 .find_map(|modifier| strip_leading_word(value, modifier))
9714}
9715
9716fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
9717 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
9718 let before = &declaration[..receiver_start];
9719 if find_identifier_occurrence(before, "val").is_none()
9720 && find_identifier_occurrence(before, "var").is_none()
9721 {
9722 return None;
9723 }
9724
9725 let after = declaration[receiver_start + receiver.len()..].trim_start();
9726 if let Some(type_text) = after.strip_prefix(':') {
9727 return normalize_receiver_type_name(read_type_prefix(type_text));
9728 }
9729 after
9730 .strip_prefix('=')
9731 .and_then(infer_kotlin_constructor_type)
9732}
9733
9734fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
9735 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
9736 if rest.trim_start().starts_with('(') {
9737 normalize_receiver_type_name(head)
9738 } else {
9739 None
9740 }
9741}
9742
9743fn read_type_prefix(value: &str) -> &str {
9744 let mut angle_depth = 0usize;
9745 for (index, ch) in value.char_indices() {
9746 match ch {
9747 '<' => angle_depth += 1,
9748 '>' => angle_depth = angle_depth.saturating_sub(1),
9749 '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
9750 return value[..index].trim();
9751 }
9752 _ => {}
9753 }
9754 }
9755 value.trim()
9756}
9757
9758fn infer_cpp_receiver_type_from_scope(
9759 scope: tree_sitter::Node<'_>,
9760 source: &str,
9761 receiver: &str,
9762 call_line: u32,
9763) -> Option<String> {
9764 let lines = source.lines().collect::<Vec<_>>();
9765 if lines.is_empty() {
9766 return None;
9767 }
9768 let scope_start = scope.start_position().row as usize;
9769 let call_index = (call_line as usize)
9770 .saturating_sub(1)
9771 .min(lines.len().saturating_sub(1));
9772 for index in (scope_start..=call_index).rev() {
9773 if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
9774 return Some(receiver_type);
9775 }
9776 }
9777 None
9778}
9779
9780fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
9781 for receiver_start in identifier_occurrences(line, receiver) {
9782 let after = line[receiver_start + receiver.len()..].trim_start();
9783 if after
9784 .chars()
9785 .next()
9786 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
9787 {
9788 continue;
9789 }
9790 let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
9791 let normalized = normalize_cpp_type_name(type_text)?;
9792 if normalized == "auto" {
9793 if let Some(rhs) = after.strip_prefix('=') {
9794 return infer_cpp_auto_receiver_type(rhs);
9795 }
9796 continue;
9797 }
9798 return Some(normalized);
9799 }
9800 None
9801}
9802
9803fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
9804 let candidate = prefix
9805 .rsplit([';', '{', '}', '('])
9806 .next()
9807 .unwrap_or(prefix)
9808 .trim();
9809 if candidate.is_empty() || candidate.ends_with(',') {
9810 None
9811 } else {
9812 Some(candidate)
9813 }
9814}
9815
9816fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
9817 let without_templates = strip_angle_groups(type_text);
9818 let mut cleaned = String::with_capacity(without_templates.len());
9819 for token in without_templates.split_whitespace() {
9820 if matches!(
9821 token,
9822 "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
9823 ) {
9824 continue;
9825 }
9826 if !cleaned.is_empty() {
9827 cleaned.push(' ');
9828 }
9829 cleaned.push_str(token);
9830 }
9831 let token = cleaned
9832 .split_whitespace()
9833 .last()
9834 .unwrap_or(cleaned.trim())
9835 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
9836 .trim_matches(['*', '&']);
9837 let simple = token.rsplit("::").next().unwrap_or(token).trim();
9838 if simple.is_empty() || cpp_non_type_token(simple) {
9839 None
9840 } else {
9841 Some(simple.to_string())
9842 }
9843}
9844
9845fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
9846 let rhs = rhs.trim_start();
9847 if let Some(after_new) = rhs.strip_prefix("new ") {
9848 return infer_cpp_constructor_type(after_new);
9849 }
9850 infer_cpp_make_template_type(rhs)
9851 .or_else(|| infer_cpp_constructor_type(rhs))
9852 .or_else(|| infer_cpp_factory_type(rhs))
9853}
9854
9855fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
9856 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9857 let normalized = normalize_cpp_type_name(head)?;
9858 if !normalized
9859 .chars()
9860 .next()
9861 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
9862 {
9863 return None;
9864 }
9865 if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
9866 Some(normalized)
9867 } else {
9868 None
9869 }
9870}
9871
9872fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
9873 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9874 if !rest.trim_start().starts_with('(') {
9875 return None;
9876 }
9877 let base = head.split('<').next().unwrap_or(head);
9878 let base_simple = base.rsplit("::").next().unwrap_or(base);
9879 if !matches!(base_simple, "make_unique" | "make_shared") {
9880 return None;
9881 }
9882 first_angle_arg(head).and_then(normalize_cpp_type_name)
9883}
9884
9885fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
9886 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9887 if !rest.trim_start().starts_with('(') {
9888 return None;
9889 }
9890 let simple = head
9891 .split('<')
9892 .next()
9893 .unwrap_or(head)
9894 .rsplit("::")
9895 .next()
9896 .unwrap_or(head);
9897 for prefix in ["make", "create", "build"] {
9898 if let Some(suffix) = simple.strip_prefix(prefix) {
9899 if suffix
9900 .chars()
9901 .next()
9902 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
9903 {
9904 return normalize_cpp_type_name(suffix);
9905 }
9906 }
9907 }
9908 None
9909}
9910
9911#[derive(Debug, Clone, Copy)]
9912enum JavaLikeInvocation {
9913 Kotlin,
9914 Cpp,
9915}
9916
9917fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
9918 let value = value.trim_start();
9919 let mut end = 0usize;
9920 for (index, ch) in value.char_indices() {
9921 let allowed_separator = match flavor {
9922 JavaLikeInvocation::Kotlin => ch == '.',
9923 JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
9924 };
9925 if is_code_ident_char(ch) || allowed_separator {
9926 end = index + ch.len_utf8();
9927 continue;
9928 }
9929 break;
9930 }
9931 if end == 0 {
9932 return None;
9933 }
9934 let mut rest = &value[end..];
9935 if let Some(stripped) = rest.trim_start().strip_prefix('<') {
9936 let skipped = skip_balanced_angle(stripped)?;
9937 let rest_start = rest.len() - rest.trim_start().len();
9938 let angle_len = 1 + skipped;
9939 end += rest_start + angle_len;
9940 rest = &value[end..];
9941 }
9942 Some((value[..end].trim(), rest))
9943}
9944
9945fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
9946 let mut depth = 1usize;
9947 for (index, ch) in value_after_open.char_indices() {
9948 match ch {
9949 '<' => depth += 1,
9950 '>' => {
9951 depth = depth.saturating_sub(1);
9952 if depth == 0 {
9953 return Some(index + ch.len_utf8());
9954 }
9955 }
9956 _ => {}
9957 }
9958 }
9959 None
9960}
9961
9962fn first_angle_arg(value: &str) -> Option<&str> {
9963 let open = value.find('<')?;
9964 let inner_len = skip_balanced_angle(&value[open + 1..])?;
9965 let inner = &value[open + 1..open + inner_len];
9966 split_top_level_commas(inner).into_iter().next()
9967}
9968
9969fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
9970 let without_generics = strip_angle_groups(type_text);
9971 let cleaned = without_generics
9972 .replace("[]", " ")
9973 .replace("...", " ")
9974 .replace(['?', '&', '*'], " ");
9975 let token = cleaned
9976 .split_whitespace()
9977 .last()
9978 .unwrap_or(cleaned.trim())
9979 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
9980 let token = token.rsplit("::").next().unwrap_or(token);
9981 let simple = token.rsplit('.').next().unwrap_or(token).trim();
9982 if simple.is_empty()
9983 || java_like_primitive_type(simple)
9984 || !simple
9985 .chars()
9986 .next()
9987 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
9988 {
9989 None
9990 } else {
9991 Some(simple.to_string())
9992 }
9993}
9994
9995fn simple_type_name(scoped_name: &str) -> Option<String> {
9996 scoped_name
9997 .rsplit("::")
9998 .find(|segment| !segment.is_empty())
9999 .and_then(normalize_receiver_type_name)
10000}
10001
10002fn strip_angle_groups(value: &str) -> String {
10003 let mut output = String::with_capacity(value.len());
10004 let mut depth = 0usize;
10005 for ch in value.chars() {
10006 match ch {
10007 '<' => {
10008 if depth == 0 {
10009 output.push(' ');
10010 }
10011 depth += 1;
10012 }
10013 '>' => depth = depth.saturating_sub(1),
10014 _ if depth == 0 => output.push(ch),
10015 _ => {}
10016 }
10017 }
10018 output
10019}
10020
10021fn java_like_primitive_type(value: &str) -> bool {
10022 matches!(
10023 value,
10024 "boolean"
10025 | "byte"
10026 | "char"
10027 | "double"
10028 | "float"
10029 | "int"
10030 | "long"
10031 | "short"
10032 | "void"
10033 | "Boolean"
10034 | "Byte"
10035 | "Char"
10036 | "Double"
10037 | "Float"
10038 | "Int"
10039 | "Long"
10040 | "Short"
10041 | "Unit"
10042 )
10043}
10044
10045fn cpp_non_type_token(value: &str) -> bool {
10046 matches!(
10047 value,
10048 "return"
10049 | "if"
10050 | "else"
10051 | "for"
10052 | "while"
10053 | "do"
10054 | "switch"
10055 | "case"
10056 | "default"
10057 | "break"
10058 | "continue"
10059 | "goto"
10060 | "throw"
10061 | "new"
10062 | "delete"
10063 | "co_await"
10064 | "co_yield"
10065 | "co_return"
10066 | "static_cast"
10067 | "const_cast"
10068 | "dynamic_cast"
10069 | "reinterpret_cast"
10070 | "sizeof"
10071 | "alignof"
10072 | "typeid"
10073 | "and"
10074 | "or"
10075 | "not"
10076 | "xor"
10077 )
10078}
10079
10080fn receiver_is_bare_identifier(value: &str) -> bool {
10081 let mut chars = value.chars();
10082 let Some(first) = chars.next() else {
10083 return false;
10084 };
10085 (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
10086}
10087
10088fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
10089 identifier_occurrences(value, needle).into_iter().next()
10090}
10091
10092fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
10093 value
10094 .match_indices(needle)
10095 .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
10096 .collect()
10097}
10098
10099fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
10100 let before = value[..start].chars().next_back();
10101 let after = value[start + len..].chars().next();
10102 !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
10103}
10104
10105fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
10106 let stripped = value.strip_prefix(word)?;
10107 if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
10108 Some(stripped.trim_start())
10109 } else {
10110 None
10111 }
10112}
10113
10114fn is_code_ident_char(ch: char) -> bool {
10115 ch == '_' || ch.is_ascii_alphanumeric()
10116}
10117
10118fn infer_rust_receiver_type(
10119 project_root: &Path,
10120 reference: &NameMatchRef,
10121 source_cache: &mut DispatchSourceCache,
10122) -> ReceiverTypeInference {
10123 if matches!(reference.receiver.as_str(), "self" | "Self") {
10124 return enclosing_type_from_scoped_name(&reference.caller_symbol)
10125 .map(ReceiverTypeInference::Known)
10126 .unwrap_or(ReceiverTypeInference::Unknown);
10127 }
10128
10129 if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
10130 return ReceiverTypeInference::Known(reference.receiver.clone());
10131 }
10132
10133 if let Some(receiver_type) = reference
10134 .caller_signature
10135 .as_deref()
10136 .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
10137 {
10138 return ReceiverTypeInference::Known(receiver_type);
10139 }
10140
10141 infer_rust_direct_self_field_receiver_type(project_root, reference, source_cache)
10142}
10143
10144fn infer_rust_direct_self_field_receiver_type(
10145 project_root: &Path,
10146 reference: &NameMatchRef,
10147 source_cache: &mut DispatchSourceCache,
10148) -> ReceiverTypeInference {
10149 if reference.colon_dispatch {
10150 return ReceiverTypeInference::Unknown;
10151 }
10152 let Some(field_name) = rust_direct_self_field_name(&reference.receiver_expression) else {
10153 return ReceiverTypeInference::Unknown;
10154 };
10155 if field_name != reference.receiver {
10156 return ReceiverTypeInference::Unknown;
10157 }
10158
10159 let Some(impl_type) = enclosing_type_from_scoped_name(&reference.caller_symbol) else {
10160 return ReceiverTypeInference::Unknown;
10161 };
10162 let Some(struct_name) = rust_direct_nominal_type_name(&impl_type) else {
10163 return ReceiverTypeInference::KnownButUnresolved;
10164 };
10165 let Some(parsed) = parsed_dispatch_source(project_root, reference, LangId::Rust, source_cache)
10166 else {
10167 return ReceiverTypeInference::Unknown;
10168 };
10169 let Some(impl_node) =
10170 find_enclosing_rust_impl_node(parsed.tree.root_node(), reference.line.max(1))
10171 else {
10172 return ReceiverTypeInference::Unknown;
10173 };
10174 if impl_node.child_by_field_name("trait").is_some()
10175 || impl_node.child_by_field_name("type_parameters").is_some()
10176 {
10177 return ReceiverTypeInference::KnownButUnresolved;
10178 }
10179 let Some(impl_target) = impl_node.child_by_field_name("type") else {
10180 return ReceiverTypeInference::KnownButUnresolved;
10181 };
10182 if impl_target.kind() != "type_identifier"
10183 || node_text(impl_target, &parsed.source) != impl_type
10184 {
10185 return ReceiverTypeInference::KnownButUnresolved;
10186 }
10187
10188 let module_scope = rust_module_scope(impl_node);
10189 let Some(struct_node) = find_unique_rust_struct(
10190 parsed.tree.root_node(),
10191 &parsed.source,
10192 struct_name,
10193 &module_scope,
10194 ) else {
10195 return ReceiverTypeInference::KnownButUnresolved;
10196 };
10197 let Some(field_type) = rust_struct_field_type_node(struct_node, &parsed.source, field_name)
10198 else {
10199 return ReceiverTypeInference::KnownButUnresolved;
10200 };
10201 if field_type.kind() != "type_identifier" {
10202 return ReceiverTypeInference::KnownButUnresolved;
10203 }
10204 let field_type_name = node_text(field_type, &parsed.source);
10205 if find_unique_rust_struct(
10206 parsed.tree.root_node(),
10207 &parsed.source,
10208 field_type_name,
10209 &module_scope,
10210 )
10211 .is_none()
10212 {
10213 return ReceiverTypeInference::KnownButUnresolved;
10214 }
10215
10216 ReceiverTypeInference::RustDirectSelfField {
10217 receiver_type: field_type_name.to_string(),
10218 declaration_file: reference.caller_file.clone(),
10219 module_scope,
10220 }
10221}
10222
10223fn rust_direct_self_field_name(receiver_expression: &str) -> Option<&str> {
10224 let (base, field) = receiver_expression.split_once('.')?;
10225 let base = base.trim();
10226 let field = field.trim();
10227 (base == "self" && rust_direct_nominal_type_name(field).is_some()).then_some(field)
10228}
10229
10230fn rust_direct_nominal_type_name(value: &str) -> Option<&str> {
10231 let name = value.rsplit("::").next()?.trim();
10232 (!name.is_empty()
10233 && !name.chars().next().is_some_and(|ch| ch.is_ascii_digit())
10234 && name.chars().all(is_rust_ident_char))
10235 .then_some(name)
10236}
10237
10238fn find_enclosing_rust_impl_node<'tree>(
10239 root: tree_sitter::Node<'tree>,
10240 line: u32,
10241) -> Option<tree_sitter::Node<'tree>> {
10242 let mut best = None;
10243 let mut stack = vec![root];
10244 while let Some(node) = stack.pop() {
10245 if !node_contains_line(node, line) {
10246 continue;
10247 }
10248 if node.kind() == "impl_item" {
10249 best = tighter_node(best, node);
10250 }
10251 push_named_children(node, &mut stack);
10252 }
10253 best
10254}
10255
10256fn rust_module_scope(node: tree_sitter::Node<'_>) -> Vec<(usize, usize)> {
10257 let mut scope = Vec::new();
10258 let mut current = node.parent();
10259 while let Some(parent) = current {
10260 if parent.kind() == "mod_item" {
10261 scope.push((parent.start_byte(), parent.end_byte()));
10262 }
10263 current = parent.parent();
10264 }
10265 scope.reverse();
10266 scope
10267}
10268
10269fn find_unique_rust_struct<'tree>(
10270 root: tree_sitter::Node<'tree>,
10271 source: &str,
10272 expected_name: &str,
10273 module_scope: &[(usize, usize)],
10274) -> Option<tree_sitter::Node<'tree>> {
10275 let mut found = None;
10276 let mut stack = vec![root];
10277 while let Some(node) = stack.pop() {
10278 if node.kind() == "struct_item"
10279 && rust_module_scope(node) == module_scope
10280 && node.child_by_field_name("type_parameters").is_none()
10281 && declaration_name(node, source) == Some(expected_name)
10282 {
10283 if found.is_some() {
10284 return None;
10285 }
10286 found = Some(node);
10287 }
10288 push_named_children(node, &mut stack);
10289 }
10290 found
10291}
10292
10293fn rust_struct_field_type_node<'tree>(
10294 struct_node: tree_sitter::Node<'tree>,
10295 source: &str,
10296 field_name: &str,
10297) -> Option<tree_sitter::Node<'tree>> {
10298 let fields = struct_node.child_by_field_name("body")?;
10299 if fields.kind() != "field_declaration_list" {
10300 return None;
10301 }
10302 for index in 0..fields.named_child_count() {
10303 let field = fields.named_child(index as u32)?;
10304 if field.kind() != "field_declaration"
10305 || declaration_name(field, source) != Some(field_name)
10306 {
10307 continue;
10308 }
10309 return field.child_by_field_name("type");
10310 }
10311 None
10312}
10313
10314fn rust_receiver_looks_type_like(receiver: &str) -> bool {
10315 receiver
10316 .chars()
10317 .next()
10318 .is_some_and(|ch| ch == '_' || ch.is_uppercase())
10319}
10320
10321fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
10322 scoped_name
10323 .rsplit_once("::")
10324 .map(|(enclosing, _)| enclosing)
10325 .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
10326 .map(ToString::to_string)
10327}
10328
10329fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
10330 let params = signature_parameter_text(signature)?;
10331 for param in split_top_level_commas(params) {
10332 let Some((pattern, type_text)) = param.split_once(':') else {
10333 continue;
10334 };
10335 let Some(name) = rust_parameter_name(pattern) else {
10336 continue;
10337 };
10338 if name == receiver {
10339 return normalize_rust_receiver_type(type_text);
10340 }
10341 }
10342 None
10343}
10344
10345fn signature_parameter_text(signature: &str) -> Option<&str> {
10346 let open = signature.find('(')?;
10347 let mut depth = 0usize;
10348 for (offset, ch) in signature[open..].char_indices() {
10349 match ch {
10350 '(' => depth += 1,
10351 ')' => {
10352 depth = depth.saturating_sub(1);
10353 if depth == 0 {
10354 return Some(&signature[open + 1..open + offset]);
10355 }
10356 }
10357 _ => {}
10358 }
10359 }
10360 None
10361}
10362
10363fn split_top_level_commas(value: &str) -> Vec<&str> {
10364 let mut parts = Vec::new();
10365 let mut start = 0usize;
10366 let mut angle_depth = 0usize;
10367 let mut paren_depth = 0usize;
10368 let mut bracket_depth = 0usize;
10369 for (index, ch) in value.char_indices() {
10370 match ch {
10371 '<' => angle_depth += 1,
10372 '>' => angle_depth = angle_depth.saturating_sub(1),
10373 '(' => paren_depth += 1,
10374 ')' => paren_depth = paren_depth.saturating_sub(1),
10375 '[' => bracket_depth += 1,
10376 ']' => bracket_depth = bracket_depth.saturating_sub(1),
10377 ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
10378 let part = value[start..index].trim();
10379 if !part.is_empty() {
10380 parts.push(part);
10381 }
10382 start = index + ch.len_utf8();
10383 }
10384 _ => {}
10385 }
10386 }
10387 let part = value[start..].trim();
10388 if !part.is_empty() {
10389 parts.push(part);
10390 }
10391 parts
10392}
10393
10394fn rust_parameter_name(pattern: &str) -> Option<&str> {
10395 let mut pattern = pattern.trim();
10396 if let Some(stripped) = pattern.strip_prefix("mut ") {
10397 pattern = stripped.trim_start();
10398 }
10399 pattern
10400 .rsplit(|ch: char| !is_rust_ident_char(ch))
10401 .find(|part| !part.is_empty())
10402}
10403
10404fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
10405 let mut ty = strip_leading_rust_type_modifiers(type_text);
10406 let owned_inner;
10407 if let Some(inner) = single_outer_generic_arg(ty) {
10408 owned_inner = inner.trim().to_string();
10409 ty = strip_leading_rust_type_modifiers(&owned_inner);
10410 }
10411 rust_base_type_ident(ty)
10412}
10413
10414fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
10415 loop {
10416 ty = ty.trim_start();
10417 if let Some(stripped) = ty.strip_prefix('&') {
10418 ty = stripped.trim_start();
10419 if let Some(stripped) = strip_leading_lifetime(ty) {
10420 ty = stripped.trim_start();
10421 }
10422 if let Some(stripped) = ty.strip_prefix("mut ") {
10423 ty = stripped.trim_start();
10424 }
10425 continue;
10426 }
10427 if let Some(stripped) = ty.strip_prefix("mut ") {
10428 ty = stripped.trim_start();
10429 continue;
10430 }
10431 if let Some(stripped) = ty.strip_prefix("dyn ") {
10432 ty = stripped.trim_start();
10433 continue;
10434 }
10435 if let Some(stripped) = ty.strip_prefix("impl ") {
10436 ty = stripped.trim_start();
10437 continue;
10438 }
10439 break ty.trim();
10440 }
10441}
10442
10443fn strip_leading_lifetime(value: &str) -> Option<&str> {
10444 let mut chars = value.char_indices();
10445 let (_, first) = chars.next()?;
10446 if first != '\'' {
10447 return None;
10448 }
10449 for (index, ch) in chars {
10450 if !(ch == '_' || ch.is_ascii_alphanumeric()) {
10451 return Some(&value[index..]);
10452 }
10453 }
10454 Some("")
10455}
10456
10457fn single_outer_generic_arg(ty: &str) -> Option<&str> {
10458 let ty = ty.trim();
10459 let open = ty.find('<')?;
10460 let mut depth = 0usize;
10461 let mut close = None;
10462 for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
10463 match ch {
10464 '<' => depth += 1,
10465 '>' => {
10466 depth = depth.saturating_sub(1);
10467 if depth == 0 {
10468 close = Some(index);
10469 break;
10470 }
10471 }
10472 _ => {}
10473 }
10474 }
10475 let close = close?;
10476 if !ty[close + 1..].trim().is_empty() {
10477 return None;
10478 }
10479 let inner = &ty[open + 1..close];
10480 let args = split_top_level_commas(inner);
10481 match args.as_slice() {
10482 [arg] => Some(*arg),
10483 _ => None,
10484 }
10485}
10486
10487fn rust_base_type_ident(ty: &str) -> Option<String> {
10488 let ty = ty.trim();
10489 let head = ty
10490 .split([' ', '+', '='])
10491 .find(|part| !part.is_empty())
10492 .unwrap_or(ty);
10493 let head = head.split('<').next().unwrap_or(head).trim();
10494 let ident = head
10495 .rsplit("::")
10496 .next()
10497 .unwrap_or(head)
10498 .trim_matches(|ch: char| !is_rust_ident_char(ch));
10499 if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
10500 None
10501 } else {
10502 Some(ident.to_string())
10503 }
10504}
10505
10506fn is_rust_ident_char(ch: char) -> bool {
10507 ch == '_' || ch.is_ascii_alphanumeric()
10508}
10509
10510fn select_rust_direct_self_field_candidate(
10511 project_root: &Path,
10512 reference: &NameMatchRef,
10513 candidates: &[NameMatchCandidate],
10514 receiver_type: &str,
10515 declaration_file: &str,
10516 declaration_scope: &[(usize, usize)],
10517 source_cache: &mut DispatchSourceCache,
10518) -> Option<NameMatchCandidate> {
10519 let eligible = candidates
10520 .iter()
10521 .filter(|candidate| candidate.node_id != reference.caller_node)
10522 .filter(|candidate| {
10523 type_candidate_matches(candidate, receiver_type, &reference.method_name)
10524 })
10525 .filter(|candidate| {
10526 rust_direct_self_field_candidate_matches_scope(
10527 project_root,
10528 candidate,
10529 receiver_type,
10530 declaration_file,
10531 declaration_scope,
10532 source_cache,
10533 )
10534 })
10535 .collect::<Vec<_>>();
10536 match eligible.as_slice() {
10537 [candidate] => Some((**candidate).clone()),
10538 _ => None,
10539 }
10540}
10541
10542fn rust_direct_self_field_candidate_matches_scope(
10543 project_root: &Path,
10544 candidate: &NameMatchCandidate,
10545 receiver_type: &str,
10546 declaration_file: &str,
10547 declaration_scope: &[(usize, usize)],
10548 source_cache: &mut DispatchSourceCache,
10549) -> bool {
10550 if candidate.file_path != declaration_file {
10551 return false;
10552 }
10553 let Some(parsed) = parsed_dispatch_source_for_file(
10554 project_root,
10555 &candidate.file_path,
10556 "rust",
10557 LangId::Rust,
10558 source_cache,
10559 ) else {
10560 return false;
10561 };
10562 let Some(impl_node) =
10563 find_enclosing_rust_impl_node(parsed.tree.root_node(), candidate.start_line)
10564 else {
10565 return false;
10566 };
10567 if impl_node.child_by_field_name("trait").is_some()
10568 || impl_node.child_by_field_name("type_parameters").is_some()
10569 {
10570 return false;
10571 }
10572 let Some(impl_target) = impl_node.child_by_field_name("type") else {
10573 return false;
10574 };
10575 impl_target.kind() == "type_identifier"
10576 && node_text(impl_target, &parsed.source) == receiver_type
10577 && rust_module_scope(impl_node) == declaration_scope
10578}
10579
10580fn select_type_match_candidate(
10581 reference: &NameMatchRef,
10582 candidates: &[NameMatchCandidate],
10583 receiver_type: &str,
10584) -> Option<NameMatchCandidate> {
10585 let candidates = candidates
10586 .iter()
10587 .filter(|candidate| candidate.node_id != reference.caller_node)
10588 .filter(|candidate| {
10589 type_candidate_matches(candidate, receiver_type, &reference.method_name)
10590 })
10591 .collect::<Vec<_>>();
10592 match candidates.as_slice() {
10593 [candidate] => Some((**candidate).clone()),
10594 _ => None,
10595 }
10596}
10597
10598fn type_candidate_matches(
10599 candidate: &NameMatchCandidate,
10600 receiver_type: &str,
10601 method_name: &str,
10602) -> bool {
10603 let normalized_type = receiver_type.replace('.', "::");
10604 let suffix = format!("{normalized_type}::{method_name}");
10605 candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
10606}
10607
10608fn select_name_match_candidate(
10609 reference: &NameMatchRef,
10610 candidates: &[NameMatchCandidate],
10611) -> Option<NameMatchCandidate> {
10612 let candidates = candidates
10613 .iter()
10614 .filter(|candidate| candidate.node_id != reference.caller_node)
10615 .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
10616 .collect::<Vec<_>>();
10617 match candidates.as_slice() {
10618 [] => None,
10619 [candidate] => Some((**candidate).clone()),
10620 _ => select_scored_name_match_candidate(reference, &candidates),
10621 }
10622}
10623
10624fn candidate_allowed_for_reference(
10625 reference: &NameMatchRef,
10626 candidate: &NameMatchCandidate,
10627) -> bool {
10628 if !reference.colon_dispatch {
10629 return true;
10630 }
10631
10632 candidate.kind == "method"
10633 && candidate
10634 .scoped_name
10635 .split("::")
10636 .any(|segment| segment == reference.receiver)
10637}
10638
10639fn select_scored_name_match_candidate(
10640 reference: &NameMatchRef,
10641 candidates: &[&NameMatchCandidate],
10642) -> Option<NameMatchCandidate> {
10643 let receiver_words = split_camel_case(&reference.receiver);
10644 if receiver_words.is_empty() {
10645 return None;
10646 }
10647
10648 let mut best: Option<(&NameMatchCandidate, f64)> = None;
10649 let mut tied_best = false;
10650 for candidate in candidates {
10651 let candidate_words = split_camel_case(&candidate.scoped_name);
10652 let overlap = receiver_words
10653 .iter()
10654 .filter(|receiver_word| {
10655 candidate_words
10656 .iter()
10657 .any(|candidate_word| candidate_word == *receiver_word)
10658 })
10659 .count() as f64;
10660 let score =
10661 overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
10662 match best {
10663 None => {
10664 best = Some((*candidate, score));
10665 tied_best = false;
10666 }
10667 Some((_, best_score)) if score > best_score => {
10668 best = Some((*candidate, score));
10669 tied_best = false;
10670 }
10671 Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
10672 tied_best = true;
10673 }
10674 _ => {}
10675 }
10676 }
10677
10678 let (candidate, score) = best?;
10679 if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
10680 Some(candidate.clone())
10681 } else {
10682 None
10683 }
10684}
10685
10686fn method_name_match_denylisted(method_name: &str) -> bool {
10687 matches!(
10688 method_name,
10689 "and_then"
10690 | "as_bytes"
10691 | "as_deref"
10692 | "as_mut"
10693 | "as_ref"
10694 | "as_str"
10695 | "borrow"
10696 | "borrow_mut"
10697 | "clear"
10698 | "clone"
10699 | "collect"
10700 | "contains"
10701 | "contains_key"
10702 | "count"
10703 | "dedup"
10704 | "default"
10705 | "drain"
10706 | "ends_with"
10707 | "entry"
10708 | "err"
10709 | "expect"
10710 | "extend"
10711 | "filter"
10712 | "filter_map"
10713 | "find"
10714 | "from"
10715 | "get"
10716 | "get_mut"
10717 | "insert"
10718 | "into"
10719 | "into_iter"
10720 | "is_empty"
10721 | "is_err"
10722 | "is_none"
10723 | "is_ok"
10724 | "is_some"
10725 | "iter"
10726 | "iter_mut"
10727 | "join"
10728 | "len"
10729 | "lock"
10730 | "map"
10731 | "map_err"
10732 | "max"
10733 | "min"
10734 | "new"
10735 | "next"
10736 | "ok"
10737 | "or_default"
10738 | "or_else"
10739 | "or_insert"
10740 | "or_insert_with"
10741 | "parse"
10742 | "pop"
10743 | "position"
10744 | "push"
10745 | "read"
10746 | "recv"
10747 | "remove"
10748 | "replace"
10749 | "retain"
10750 | "send"
10751 | "sort"
10752 | "sort_by"
10753 | "split"
10754 | "starts_with"
10755 | "sum"
10756 | "take"
10757 | "to_owned"
10758 | "to_string"
10759 | "trim"
10760 | "try_from"
10761 | "try_into"
10762 | "unwrap"
10763 | "unwrap_or"
10764 | "unwrap_or_default"
10765 | "unwrap_or_else"
10766 | "with_capacity"
10767 | "write"
10768 )
10769}
10770
10771fn split_camel_case(value: &str) -> Vec<String> {
10772 let chars = value.chars().collect::<Vec<_>>();
10773 let mut normalized = String::with_capacity(value.len() + 8);
10774 for (index, ch) in chars.iter().enumerate() {
10775 let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
10776 let next = chars.get(index + 1);
10777 let is_separator = ch.is_whitespace()
10778 || matches!(
10779 ch,
10780 '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
10781 );
10782 if is_separator {
10783 normalized.push(' ');
10784 continue;
10785 }
10786 let camel_boundary = previous.is_some_and(|prev| {
10787 (prev.is_lowercase() && ch.is_uppercase())
10788 || (prev.is_ascii_digit() && ch.is_alphabetic())
10789 || (prev.is_uppercase()
10790 && ch.is_uppercase()
10791 && next.is_some_and(|next| next.is_lowercase()))
10792 });
10793 if camel_boundary {
10794 normalized.push(' ');
10795 }
10796 normalized.push(*ch);
10797 }
10798
10799 normalized
10800 .split_whitespace()
10801 .filter(|word| word.len() > 1)
10802 .map(|word| word.to_ascii_lowercase())
10803 .collect()
10804}
10805
10806fn compute_path_proximity(left: &str, right: &str) -> f64 {
10807 let left_dirs = left
10808 .rsplit_once('/')
10809 .map(|(dir, _)| dir)
10810 .unwrap_or_default()
10811 .split('/')
10812 .filter(|part| !part.is_empty());
10813 let right_dirs = right
10814 .rsplit_once('/')
10815 .map(|(dir, _)| dir)
10816 .unwrap_or_default()
10817 .split('/')
10818 .filter(|part| !part.is_empty());
10819
10820 let shared = left_dirs
10821 .zip(right_dirs)
10822 .take_while(|(left, right)| left == right)
10823 .count();
10824 ((shared as f64) * 0.05).min(0.5)
10825}
10826
10827fn mark_backend_state(
10828 tx: &Transaction<'_>,
10829 project_root: &Path,
10830 rel_path: &str,
10831 content_hash: Option<&blake3::Hash>,
10832 status: &str,
10833) -> Result<()> {
10834 clear_backend_state_for_file(tx, project_root, rel_path)?;
10835 let hash = content_hash
10836 .map(|hash| hash_to_hex(*hash))
10837 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
10838 tx.execute(
10839 "INSERT OR REPLACE INTO backend_file_state(
10840 backend, workspace_root, file_path, content_hash, status, updated_at
10841 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
10842 params![
10843 BACKEND_TREESITTER,
10844 project_root.display().to_string(),
10845 rel_path,
10846 hash,
10847 status,
10848 unix_seconds_now(),
10849 ],
10850 )?;
10851 Ok(())
10852}
10853
10854fn clear_backend_state_for_file(
10855 tx: &Transaction<'_>,
10856 project_root: &Path,
10857 rel_path: &str,
10858) -> Result<()> {
10859 tx.execute(
10860 "DELETE FROM backend_file_state
10861 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
10862 params![
10863 BACKEND_TREESITTER,
10864 project_root.display().to_string(),
10865 rel_path
10866 ],
10867 )?;
10868 Ok(())
10869}
10870
10871fn load_file_row(tx: &Transaction<'_>, rel_path: &str) -> Result<Option<FileRow>> {
10872 tx.query_row(
10873 "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
10874 params![rel_path],
10875 |row| {
10876 let hash_text: String = row.get(1)?;
10877 Ok(FileRow {
10878 surface_fingerprint: row.get(0)?,
10879 freshness: FileFreshness {
10880 content_hash: hash_from_hex(&hash_text)
10881 .unwrap_or_else(cache_freshness::zero_hash),
10882 mtime: ns_to_system_time(row.get::<_, i64>(2)?),
10883 size: row.get::<_, i64>(3)? as u64,
10884 },
10885 })
10886 },
10887 )
10888 .optional()
10889 .map_err(CallGraphStoreError::from)
10890}
10891
10892fn stored_node_ids_match_extract(
10893 tx: &Transaction<'_>,
10894 rel_path: &str,
10895 extract: &FileExtract,
10896) -> Result<bool> {
10897 let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
10898 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
10899 let mut stored = BTreeSet::new();
10900 for row in rows {
10901 stored.insert(row?);
10902 }
10903 let extracted = extract
10904 .nodes
10905 .iter()
10906 .map(|node| node.id.clone())
10907 .collect::<BTreeSet<_>>();
10908 Ok(stored == extracted)
10909}
10910
10911fn stored_extract_matches(
10915 tx: &Transaction<'_>,
10916 rel_path: &str,
10917 extract: &FileExtract,
10918 index: &ProjectIndex<'_>,
10919) -> Result<bool> {
10920 let stored_file = tx
10921 .query_row(
10922 "SELECT lang, surface_fingerprint FROM files WHERE path = ?1",
10923 params![rel_path],
10924 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
10925 )
10926 .optional()?;
10927 if stored_file
10928 != Some((
10929 lang_label(extract.lang).to_string(),
10930 extract.surface_fingerprint.clone(),
10931 ))
10932 {
10933 return Ok(false);
10934 }
10935
10936 let mut stored_nodes_stmt = tx.prepare(
10937 "SELECT id, file_path, name, scoped_name, kind, start_line, start_col,
10938 end_line, end_col, range_ordinal, signature, exported,
10939 is_default_export, is_type_like, is_callgraph_entry_point, provenance
10940 FROM nodes WHERE file_path = ?1",
10941 )?;
10942 let stored_nodes = stored_nodes_stmt
10943 .query_map(params![rel_path], |row| {
10944 Ok(serde_json::json!([
10945 row.get::<_, String>(0)?,
10946 row.get::<_, String>(1)?,
10947 row.get::<_, String>(2)?,
10948 row.get::<_, String>(3)?,
10949 row.get::<_, String>(4)?,
10950 row.get::<_, i64>(5)?,
10951 row.get::<_, i64>(6)?,
10952 row.get::<_, i64>(7)?,
10953 row.get::<_, i64>(8)?,
10954 row.get::<_, i64>(9)?,
10955 row.get::<_, Option<String>>(10)?,
10956 row.get::<_, i64>(11)?,
10957 row.get::<_, i64>(12)?,
10958 row.get::<_, i64>(13)?,
10959 row.get::<_, i64>(14)?,
10960 row.get::<_, String>(15)?,
10961 ])
10962 .to_string())
10963 })?
10964 .collect::<rusqlite::Result<Vec<_>>>()?;
10965 let expected_nodes = extract
10966 .nodes
10967 .iter()
10968 .map(|node| {
10969 serde_json::json!([
10970 node.id,
10971 node.file_path,
10972 node.name,
10973 node.scoped_name,
10974 node.kind,
10975 node.range.start_line,
10976 node.range.start_col,
10977 node.range.end_line,
10978 node.range.end_col,
10979 node.range_ordinal,
10980 node.signature,
10981 bool_int(node.exported),
10982 bool_int(node.is_default_export),
10983 bool_int(node.is_type_like),
10984 bool_int(node.is_callgraph_entry_point),
10985 PROVENANCE_TREESITTER,
10986 ])
10987 .to_string()
10988 })
10989 .collect::<Vec<_>>();
10990 let mut stored_nodes = stored_nodes;
10991 let mut expected_nodes = expected_nodes;
10992 stored_nodes.sort();
10993 expected_nodes.sort();
10994 if stored_nodes != expected_nodes {
10995 return Ok(false);
10996 }
10997
10998 let resolved_refs = extract
10999 .raw_refs
11000 .iter()
11001 .cloned()
11002 .map(|raw| resolve_ref(raw, index))
11003 .collect::<Result<Vec<_>>>()?;
11004 let mut stored_refs_stmt = tx.prepare(
11005 "SELECT ref_id, caller_node, caller_file, kind, short_name, full_ref,
11006 module_path, import_kind, local_name, requested_name, namespace_alias,
11007 wildcard, line, byte_start, byte_end, status, target_node,
11008 target_file, target_symbol, provenance
11009 FROM refs WHERE caller_file = ?1",
11010 )?;
11011 let stored_refs = stored_refs_stmt
11012 .query_map(params![rel_path], |row| {
11013 Ok(serde_json::json!([
11014 row.get::<_, String>(0)?,
11015 row.get::<_, Option<String>>(1)?,
11016 row.get::<_, String>(2)?,
11017 row.get::<_, String>(3)?,
11018 row.get::<_, Option<String>>(4)?,
11019 row.get::<_, Option<String>>(5)?,
11020 row.get::<_, Option<String>>(6)?,
11021 row.get::<_, Option<String>>(7)?,
11022 row.get::<_, Option<String>>(8)?,
11023 row.get::<_, Option<String>>(9)?,
11024 row.get::<_, Option<String>>(10)?,
11025 row.get::<_, i64>(11)?,
11026 row.get::<_, i64>(12)?,
11027 row.get::<_, i64>(13)?,
11028 row.get::<_, i64>(14)?,
11029 row.get::<_, String>(15)?,
11030 row.get::<_, Option<String>>(16)?,
11031 row.get::<_, Option<String>>(17)?,
11032 row.get::<_, Option<String>>(18)?,
11033 row.get::<_, String>(19)?,
11034 ])
11035 .to_string())
11036 })?
11037 .collect::<rusqlite::Result<Vec<_>>>()?;
11038 let expected_refs = resolved_refs
11039 .iter()
11040 .map(|resolved| {
11041 let raw = &resolved.raw;
11042 serde_json::json!([
11043 raw.ref_id,
11044 raw.caller_node,
11045 raw.caller_file,
11046 raw.kind,
11047 raw.short_name,
11048 raw.full_ref,
11049 raw.module_path,
11050 raw.import_kind,
11051 raw.local_name,
11052 raw.requested_name,
11053 raw.namespace_alias,
11054 bool_int(raw.wildcard),
11055 raw.line,
11056 raw.byte_start,
11057 raw.byte_end,
11058 resolved.status,
11059 resolved.target_node,
11060 resolved.target_file,
11061 resolved.target_symbol,
11062 PROVENANCE_TREESITTER,
11063 ])
11064 .to_string()
11065 })
11066 .collect::<Vec<_>>();
11067 let mut stored_refs = stored_refs;
11068 let mut expected_refs = expected_refs;
11069 stored_refs.sort();
11070 expected_refs.sort();
11071 if stored_refs != expected_refs {
11072 return Ok(false);
11073 }
11074
11075 let mut stored_edges_stmt = tx.prepare(
11076 "SELECT e.edge_id, e.ref_id, e.source_node, e.target_node,
11077 e.target_file, e.target_symbol, e.kind, e.line, e.provenance
11078 FROM edges e JOIN refs r ON r.ref_id = e.ref_id
11079 WHERE r.caller_file = ?1 AND e.provenance = ?2",
11080 )?;
11081 let stored_edges = stored_edges_stmt
11082 .query_map(params![rel_path, PROVENANCE_TREESITTER], |row| {
11083 Ok(serde_json::json!([
11084 row.get::<_, String>(0)?,
11085 row.get::<_, String>(1)?,
11086 row.get::<_, String>(2)?,
11087 row.get::<_, Option<String>>(3)?,
11088 row.get::<_, String>(4)?,
11089 row.get::<_, String>(5)?,
11090 row.get::<_, String>(6)?,
11091 row.get::<_, i64>(7)?,
11092 row.get::<_, String>(8)?,
11093 ])
11094 .to_string())
11095 })?
11096 .collect::<rusqlite::Result<Vec<_>>>()?;
11097 let expected_edges = resolved_refs
11098 .iter()
11099 .filter_map(|resolved| {
11100 resolved.edge.as_ref().map(|edge| {
11101 serde_json::json!([
11102 edge.edge_id,
11103 resolved.raw.ref_id,
11104 edge.source_node,
11105 edge.target_node,
11106 edge.target_file,
11107 edge.target_symbol,
11108 edge.kind,
11109 edge.line,
11110 PROVENANCE_TREESITTER,
11111 ])
11112 .to_string()
11113 })
11114 })
11115 .collect::<Vec<_>>();
11116 let mut stored_edges = stored_edges;
11117 let mut expected_edges = expected_edges;
11118 stored_edges.sort();
11119 expected_edges.sort();
11120 if stored_edges != expected_edges {
11121 return Ok(false);
11122 }
11123
11124 let mut stored_dependencies_stmt =
11125 tx.prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1")?;
11126 let stored_dependencies = stored_dependencies_stmt
11127 .query_map(params![rel_path], |row| row.get::<_, String>(0))?
11128 .collect::<rusqlite::Result<BTreeSet<_>>>()?;
11129 let expected_dependencies = extract
11130 .raw_refs
11131 .iter()
11132 .flat_map(|raw| raw.dependencies.iter().cloned())
11133 .collect::<BTreeSet<_>>();
11134 if stored_dependencies != expected_dependencies {
11135 return Ok(false);
11136 }
11137
11138 let mut stored_hints_stmt = tx.prepare(
11139 "SELECT id, method_name, caller_node, file, line, byte_start, byte_end, provenance
11140 FROM dispatch_hints WHERE file = ?1",
11141 )?;
11142 let stored_hints = stored_hints_stmt
11143 .query_map(params![rel_path], |row| {
11144 Ok(serde_json::json!([
11145 row.get::<_, String>(0)?,
11146 row.get::<_, String>(1)?,
11147 row.get::<_, String>(2)?,
11148 row.get::<_, String>(3)?,
11149 row.get::<_, i64>(4)?,
11150 row.get::<_, i64>(5)?,
11151 row.get::<_, i64>(6)?,
11152 row.get::<_, String>(7)?,
11153 ])
11154 .to_string())
11155 })?
11156 .collect::<rusqlite::Result<Vec<_>>>()?;
11157 let expected_hints = extract
11158 .dispatch_hints
11159 .iter()
11160 .map(|hint| {
11161 serde_json::json!([
11162 hint.id,
11163 hint.method_name,
11164 hint.caller_node,
11165 hint.file,
11166 hint.line,
11167 hint.byte_start,
11168 hint.byte_end,
11169 PROVENANCE_TREESITTER,
11170 ])
11171 .to_string()
11172 })
11173 .collect::<Vec<_>>();
11174 let mut stored_hints = stored_hints;
11175 let mut expected_hints = expected_hints;
11176 stored_hints.sort();
11177 expected_hints.sort();
11178 Ok(stored_hints == expected_hints)
11179}
11180
11181fn update_file_fresh_metadata(
11182 tx: &Transaction<'_>,
11183 project_root: &Path,
11184 rel_path: &str,
11185 hash: &blake3::Hash,
11186 mtime: SystemTime,
11187 size: u64,
11188) -> Result<()> {
11189 tx.execute(
11190 "UPDATE files SET content_hash = ?2, mtime_ns = ?3, size = ?4, indexed_at = ?5
11191 WHERE path = ?1",
11192 params![
11193 rel_path,
11194 hash_to_hex(*hash),
11195 system_time_to_ns(mtime),
11196 size as i64,
11197 unix_seconds_now()
11198 ],
11199 )?;
11200 tx.execute(
11201 "UPDATE backend_file_state SET content_hash = ?3, status = 'fresh', updated_at = ?5
11202 WHERE backend = ?1 AND file_path = ?2 AND workspace_root = ?4",
11203 params![
11204 BACKEND_TREESITTER,
11205 rel_path,
11206 hash_to_hex(*hash),
11207 project_root.display().to_string(),
11208 unix_seconds_now(),
11209 ],
11210 )?;
11211 Ok(())
11212}
11213
11214#[derive(Debug, Clone, PartialEq, Eq)]
11215struct DependentRefSelection {
11216 ref_id: String,
11217 caller_file: String,
11218}
11219
11220fn ref_ids_depending_on(
11221 tx: &Transaction<'_>,
11222 project_root: &Path,
11223 rel_path: &str,
11224) -> Result<Vec<DependentRefSelection>> {
11225 let mut stmt = tx.prepare(
11226 "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
11227 FROM refs r
11228 WHERE r.caller_file IN (
11229 SELECT file_path FROM file_dependencies WHERE dep_file = ?1
11230 )
11231 OR r.target_file = ?1
11232 ORDER BY r.ref_id",
11233 )?;
11234 let rows = stmt.query_map(params![rel_path], |row| {
11235 Ok(RefDependencyRow {
11236 ref_id: row.get(0)?,
11237 kind: row.get(1)?,
11238 caller_file: row.get(2)?,
11239 module_path: row.get(3)?,
11240 target_file: row.get(4)?,
11241 })
11242 })?;
11243 let mut ids = Vec::new();
11244 for row in rows {
11245 let row = row?;
11246 if ref_dependency_row_depends_on(project_root, &row, rel_path) {
11247 ids.push(DependentRefSelection {
11248 ref_id: row.ref_id,
11249 caller_file: row.caller_file,
11250 });
11251 }
11252 }
11253 Ok(ids)
11254}
11255
11256fn record_dependent_refs(
11257 selected_ref_ids: &mut BTreeSet<String>,
11258 selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
11259 dependent_refs: Vec<DependentRefSelection>,
11260) {
11261 for dependent_ref in dependent_refs {
11262 let DependentRefSelection {
11263 ref_id,
11264 caller_file,
11265 } = dependent_ref;
11266 selected_ref_ids.insert(ref_id.clone());
11267 selected_refs_by_caller
11268 .entry(caller_file)
11269 .or_default()
11270 .insert(ref_id);
11271 }
11272}
11273
11274#[cfg(test)]
11275fn refs_by_caller_for_ref_ids(
11276 tx: &Transaction<'_>,
11277 ref_ids: &BTreeSet<String>,
11278) -> Result<BTreeMap<String, BTreeSet<String>>> {
11279 let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11280 let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
11281 for ref_id in ref_ids {
11282 if let Some(caller) = stmt
11283 .query_row(params![ref_id], |row| row.get::<_, String>(0))
11284 .optional()?
11285 {
11286 by_caller.entry(caller).or_default().insert(ref_id.clone());
11287 }
11288 }
11289 Ok(by_caller)
11290}
11291
11292fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
11293 tx.execute(
11294 "DELETE FROM file_dependencies WHERE file_path = ?1",
11295 params![rel_path],
11296 )?;
11297 delete_refs_for_caller(tx, rel_path)?;
11298 tx.execute(
11299 "DELETE FROM dispatch_hints WHERE file = ?1",
11300 params![rel_path],
11301 )?;
11302 tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
11303 tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
11304 Ok(())
11305}
11306
11307fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
11308 let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
11309 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
11310 let mut ids = BTreeSet::new();
11311 for row in rows {
11312 ids.insert(row?);
11313 }
11314 delete_ref_ids(tx, &ids)
11315}
11316
11317fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
11318 for ref_id in ref_ids {
11319 tx.execute("DELETE FROM edges WHERE ref_id = ?1", params![ref_id])?;
11320 tx.execute("DELETE FROM refs WHERE ref_id = ?1", params![ref_id])?;
11321 }
11322 Ok(())
11323}
11324
11325fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
11326 let mut stmt = conn.prepare(
11327 "SELECT source.file_path, source.scoped_name, edges.target_file,
11328 edges.target_symbol, edges.kind, edges.line
11329 FROM edges
11330 JOIN nodes AS source ON source.id = edges.source_node
11331 ORDER BY source.file_path, source.scoped_name, edges.target_file,
11332 edges.target_symbol, edges.kind, edges.line",
11333 )?;
11334 let rows = stmt.query_map([], |row| {
11335 Ok(StoredEdge {
11336 source_file: row.get(0)?,
11337 source_symbol: row.get(1)?,
11338 target_file: row.get(2)?,
11339 target_symbol: row.get(3)?,
11340 kind: row.get(4)?,
11341 line: row.get::<_, i64>(5)? as u32,
11342 })
11343 })?;
11344 let mut edges = BTreeSet::new();
11345 for row in rows {
11346 edges.insert(row?);
11347 }
11348 Ok(edges)
11349}
11350
11351fn module_target_from_dependencies(
11352 project_root: &Path,
11353 dependencies: &BTreeSet<String>,
11354) -> Option<String> {
11355 dependencies.iter().find_map(|dep| {
11356 let path = project_root.join(dep);
11357 if path.is_file() {
11358 Some(relative_path(project_root, &canonicalize_path(&path)))
11359 } else {
11360 None
11361 }
11362 })
11363}
11364
11365fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
11366 let mut named = HashMap::new();
11367 if let Some(full_ref) = &raw_ref.full_ref {
11368 named = parse_reexport_names(full_ref);
11369 }
11370 ReexportIndex {
11371 target_file,
11372 named,
11373 wildcard: raw_ref.wildcard,
11374 }
11375}
11376
11377fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
11378 let mut names = HashMap::new();
11379 let Some(open) = statement.find('{') else {
11380 return names;
11381 };
11382 let Some(close) = statement[open + 1..]
11383 .find('}')
11384 .map(|offset| open + 1 + offset)
11385 else {
11386 return names;
11387 };
11388 for spec in statement[open + 1..close].split(',') {
11389 let spec = spec.trim();
11390 if spec.is_empty() {
11391 continue;
11392 }
11393 if let Some((source, local)) = spec.split_once(" as ") {
11394 names.insert(local.trim().to_string(), source.trim().to_string());
11395 } else {
11396 names.insert(spec.to_string(), spec.to_string());
11397 }
11398 }
11399 names
11400}
11401
11402#[derive(Debug)]
11403struct RefDependencyRow {
11404 ref_id: String,
11405 kind: String,
11406 caller_file: String,
11407 module_path: Option<String>,
11408 target_file: Option<String>,
11409}
11410
11411fn ref_dependency_row_depends_on(
11412 project_root: &Path,
11413 row: &RefDependencyRow,
11414 rel_path: &str,
11415) -> bool {
11416 if row.target_file.as_deref() == Some(rel_path) {
11417 return true;
11418 }
11419
11420 match row.kind.as_str() {
11421 "call" => true,
11422 "import" | "reexport" => row
11423 .module_path
11424 .as_deref()
11425 .map(|module_path| {
11426 module_dependencies_for_ref(project_root, &row.caller_file, module_path)
11427 .contains(rel_path)
11428 })
11429 .unwrap_or(false),
11430 "export_alias" => false,
11431 _ => false,
11432 }
11433}
11434
11435fn module_dependencies_for_ref(
11436 project_root: &Path,
11437 caller_file: &str,
11438 module_path: &str,
11439) -> BTreeSet<String> {
11440 module_dependencies(project_root, &project_root.join(caller_file), module_path)
11441}
11442
11443fn import_dependencies(
11444 project_root: &Path,
11445 abs_path: &Path,
11446 imports: &[ImportStatement],
11447) -> BTreeSet<String> {
11448 let mut deps = BTreeSet::new();
11449 for import in imports {
11450 deps.extend(module_dependencies(
11451 project_root,
11452 abs_path,
11453 &import.module_path,
11454 ));
11455 }
11456 deps
11457}
11458
11459fn module_dependencies(
11460 project_root: &Path,
11461 abs_path: &Path,
11462 module_path: &str,
11463) -> BTreeSet<String> {
11464 let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
11465 let caller_dir = abs_path.parent().unwrap_or(project_root);
11466 if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
11467 deps.insert(relative_path(project_root, &resolved));
11468 }
11469 if module_path.starts_with('.') {
11470 let base = caller_dir.join(module_path);
11471 for candidate in relative_module_candidates(&base) {
11472 deps.insert(relative_path(project_root, &candidate));
11473 }
11474 }
11475 deps
11476}
11477
11478fn rust_module_dependencies(
11479 project_root: &Path,
11480 abs_path: &Path,
11481 module_path: &str,
11482) -> BTreeSet<String> {
11483 let mut deps = BTreeSet::new();
11484 let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
11485 let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
11486 return deps;
11487 };
11488 let src_prefix = rust_src_prefix(&rel_path);
11489 rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
11490 if !path_segments.is_empty() {
11491 rust_push_module_dependency_candidate(
11492 project_root,
11493 &mut deps,
11494 &src_prefix,
11495 &path_segments[..path_segments.len() - 1],
11496 );
11497 }
11498 deps
11499}
11500
11501fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
11502 let path = rust_module_path_without_alias_or_use_list(module_path);
11503 let segments = path
11504 .split("::")
11505 .map(str::trim)
11506 .filter(|segment| !segment.is_empty())
11507 .collect::<Vec<_>>();
11508 if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
11509 return None;
11510 }
11511 rust_resolve_segments(rel_path, &segments)
11512}
11513
11514fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
11515 let path = module_path
11516 .trim()
11517 .trim_end_matches(';')
11518 .split_once(" as ")
11519 .map(|(left, _)| left.trim())
11520 .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
11521 path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
11522}
11523
11524fn rust_push_module_dependency_candidate(
11525 project_root: &Path,
11526 deps: &mut BTreeSet<String>,
11527 src_prefix: &str,
11528 segments: &[String],
11529) {
11530 let candidates = if segments.is_empty() {
11531 vec![
11532 format!("{src_prefix}/lib.rs"),
11533 format!("{src_prefix}/main.rs"),
11534 ]
11535 } else {
11536 vec![
11537 format!("{}/{}.rs", src_prefix, segments.join("/")),
11538 format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
11539 ]
11540 };
11541 for candidate in candidates {
11542 if project_root.join(&candidate).is_file() {
11543 deps.insert(candidate);
11544 }
11545 }
11546}
11547
11548fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
11549 let mut candidates = Vec::new();
11550 if base.extension().is_some() {
11551 candidates.push(base.to_path_buf());
11552 return candidates;
11553 }
11554 for ext in JS_TS_EXTENSIONS {
11555 candidates.push(base.with_extension(ext));
11556 }
11557 for ext in JS_TS_EXTENSIONS {
11558 candidates.push(base.join(format!("index.{ext}")));
11559 }
11560 candidates
11561}
11562
11563fn import_local_names(import: &ImportStatement) -> Vec<String> {
11564 let mut names = Vec::new();
11565 if let Some(default) = &import.default_import {
11566 names.push(default.clone());
11567 }
11568 if let Some(namespace) = &import.namespace_import {
11569 names.push(namespace.clone());
11570 }
11571 for name in &import.names {
11572 names.push(crate::imports::specifier_local_name(name).to_string());
11573 }
11574 names
11575}
11576
11577fn import_requested_names(import: &ImportStatement) -> Vec<String> {
11578 import
11579 .names
11580 .iter()
11581 .map(|name| crate::imports::specifier_imported_name(name).to_string())
11582 .collect()
11583}
11584
11585fn import_is_wildcard(import: &ImportStatement) -> bool {
11586 import.namespace_import.is_some() || import.raw_text.contains('*')
11587}
11588
11589fn namespace_alias(full_ref: &str) -> Option<String> {
11590 full_ref
11591 .split_once('.')
11592 .map(|(namespace, _)| namespace.to_string())
11593}
11594
11595fn import_kind_label(kind: ImportKind) -> &'static str {
11596 match kind {
11597 ImportKind::Value => "value",
11598 ImportKind::Type => "type",
11599 ImportKind::SideEffect => "side_effect",
11600 }
11601}
11602
11603fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
11604 match kind {
11605 SymbolKind::Function => "function",
11606 SymbolKind::Class => "class",
11607 SymbolKind::Method => "method",
11608 SymbolKind::Struct => "struct",
11609 SymbolKind::Interface => "interface",
11610 SymbolKind::Enum => "enum",
11611 SymbolKind::TypeAlias => "type_alias",
11612 SymbolKind::Variable => "variable",
11613 SymbolKind::Heading => "heading",
11614 SymbolKind::FileSummary => "file_summary",
11615 }
11616}
11617
11618fn is_type_like(kind: &SymbolKind) -> bool {
11619 matches!(
11620 kind,
11621 SymbolKind::Class
11622 | SymbolKind::Struct
11623 | SymbolKind::Interface
11624 | SymbolKind::Enum
11625 | SymbolKind::TypeAlias
11626 )
11627}
11628
11629fn lang_label(lang: LangId) -> &'static str {
11630 match lang {
11631 LangId::TypeScript => "typescript",
11632 LangId::Tsx => "tsx",
11633 LangId::JavaScript => "javascript",
11634 LangId::Python => "python",
11635 LangId::Rust => "rust",
11636 LangId::Go => "go",
11637 LangId::C => "c",
11638 LangId::Cpp => "cpp",
11639 LangId::Zig => "zig",
11640 LangId::CSharp => "csharp",
11641 LangId::Bash => "bash",
11642 LangId::Html => "html",
11643 LangId::Markdown => "markdown",
11644 LangId::Solidity => "solidity",
11645 LangId::Scss => "scss",
11646 LangId::Vue => "vue",
11647 LangId::Json => "json",
11648 LangId::Scala => "scala",
11649 LangId::Java => "java",
11650 LangId::Ruby => "ruby",
11651 LangId::Kotlin => "kotlin",
11652 LangId::Swift => "swift",
11653 LangId::Php => "php",
11654 LangId::Lua => "lua",
11655 LangId::Perl => "perl",
11656 LangId::Yaml => "yaml",
11657 LangId::Pascal => "pascal",
11658 LangId::R => "r",
11659 LangId::Groovy => "groovy",
11660 LangId::ObjC => "objc",
11661 }
11662}
11663
11664fn lang_from_label(label: &str) -> Option<LangId> {
11665 match label {
11666 "typescript" => Some(LangId::TypeScript),
11667 "tsx" => Some(LangId::Tsx),
11668 "javascript" => Some(LangId::JavaScript),
11669 "python" => Some(LangId::Python),
11670 "rust" => Some(LangId::Rust),
11671 "go" => Some(LangId::Go),
11672 "c" => Some(LangId::C),
11673 "cpp" => Some(LangId::Cpp),
11674 "zig" => Some(LangId::Zig),
11675 "csharp" => Some(LangId::CSharp),
11676 "bash" => Some(LangId::Bash),
11677 "html" => Some(LangId::Html),
11678 "markdown" => Some(LangId::Markdown),
11679 "solidity" => Some(LangId::Solidity),
11680 "scss" => Some(LangId::Scss),
11681 "vue" => Some(LangId::Vue),
11682 "json" => Some(LangId::Json),
11683 "scala" => Some(LangId::Scala),
11684 "java" => Some(LangId::Java),
11685 "ruby" => Some(LangId::Ruby),
11686 "kotlin" => Some(LangId::Kotlin),
11687 "swift" => Some(LangId::Swift),
11688 "php" => Some(LangId::Php),
11689 "lua" => Some(LangId::Lua),
11690 "perl" => Some(LangId::Perl),
11691 "yaml" => Some(LangId::Yaml),
11692 "pascal" => Some(LangId::Pascal),
11693 "r" => Some(LangId::R),
11694 "groovy" => Some(LangId::Groovy),
11695 "objc" => Some(LangId::ObjC),
11696 _ => None,
11697 }
11698}
11699
11700fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
11701 let mut normalized = if files.is_empty() {
11702 callgraph::walk_project_files(project_root).collect::<Vec<_>>()
11703 } else {
11704 files
11705 .iter()
11706 .map(|path| normalize_file_path(project_root, path))
11707 .collect::<Result<Vec<_>>>()?
11708 };
11709 normalized.sort();
11710 normalized.dedup();
11711 Ok(normalized)
11712}
11713
11714fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
11715 let full_path = if path.is_relative() {
11716 project_root.join(path)
11717 } else {
11718 path.to_path_buf()
11719 };
11720 Ok(canonicalize_path(&full_path))
11721}
11722
11723fn canonicalize_path(path: &Path) -> PathBuf {
11724 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
11725}
11726
11727fn relative_path(project_root: &Path, path: &Path) -> String {
11728 if let Ok(stripped) = path.strip_prefix(project_root) {
11729 return stripped.to_string_lossy().replace('\\', "/");
11730 }
11731 let canon_root = canonicalize_path(project_root);
11732 let canon_path = canonicalize_path(path);
11733 if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
11734 return stripped.to_string_lossy().replace('\\', "/");
11735 }
11736 canon_path.to_string_lossy().replace('\\', "/")
11737}
11738
11739fn unqualified_name(scoped: &str) -> &str {
11740 if scoped == TOP_LEVEL_SYMBOL {
11741 return scoped;
11742 }
11743 scoped
11744 .rsplit("::")
11745 .next()
11746 .unwrap_or(scoped)
11747 .rsplit('.')
11748 .next()
11749 .unwrap_or(scoped)
11750 .rsplit('#')
11751 .next()
11752 .unwrap_or(scoped)
11753}
11754
11755fn ref_id(parts: &[&str]) -> String {
11756 let joined = parts.join("\0");
11757 hash_to_hex(blake3::hash(joined.as_bytes()))
11758}
11759
11760fn hash_to_hex(hash: blake3::Hash) -> String {
11761 hash.to_hex().to_string()
11762}
11763
11764fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
11765 let bytes = hex_to_bytes(value)?;
11766 Some(blake3::Hash::from_bytes(bytes))
11767}
11768
11769fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
11770 if value.len() != 64 {
11771 return None;
11772 }
11773 let mut bytes = [0u8; 32];
11774 for (index, slot) in bytes.iter_mut().enumerate() {
11775 let start = index * 2;
11776 let end = start + 2;
11777 *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
11778 }
11779 Some(bytes)
11780}
11781
11782#[derive(Debug, Clone)]
11783struct LineIndex {
11784 newline_offsets: Vec<usize>,
11785 source_len: usize,
11786}
11787
11788impl LineIndex {
11789 fn new(source: &str) -> Self {
11790 Self {
11791 newline_offsets: source
11792 .bytes()
11793 .enumerate()
11794 .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
11795 .collect(),
11796 source_len: source.len(),
11797 }
11798 }
11799
11800 fn byte_to_line(&self, byte_offset: usize) -> u32 {
11801 let byte_offset = byte_offset.min(self.source_len);
11802 self.newline_offsets
11803 .partition_point(|offset| *offset < byte_offset) as u32
11804 + 1
11805 }
11806}
11807
11808fn empty_to_none(value: String) -> Option<String> {
11809 if value.is_empty() {
11810 None
11811 } else {
11812 Some(value)
11813 }
11814}
11815
11816fn bool_int(value: bool) -> i64 {
11817 if value {
11818 1
11819 } else {
11820 0
11821 }
11822}
11823
11824fn system_time_to_ns(time: SystemTime) -> i64 {
11825 time.duration_since(UNIX_EPOCH)
11826 .unwrap_or_default()
11827 .as_nanos()
11828 .min(i64::MAX as u128) as i64
11829}
11830
11831fn ns_to_system_time(value: i64) -> SystemTime {
11832 UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
11833}
11834
11835fn unix_millis_now() -> u64 {
11836 SystemTime::now()
11837 .duration_since(UNIX_EPOCH)
11838 .unwrap_or_default()
11839 .as_millis()
11840 .min(u128::from(u64::MAX)) as u64
11841}
11842
11843fn unix_seconds_now() -> i64 {
11844 SystemTime::now()
11845 .duration_since(UNIX_EPOCH)
11846 .unwrap_or_default()
11847 .as_secs() as i64
11848}
11849
11850#[cfg(test)]
11855pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
11856
11857#[cfg(test)]
11858mod refresh_worker_tests {
11859 use super::*;
11860 use std::fs;
11861 use tempfile::tempdir;
11862
11863 fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
11864 let temp = tempdir().unwrap();
11865 let root = temp.path().join("root");
11866 fs::create_dir_all(&root).unwrap();
11867 let artifact_key = crate::search_index::artifact_cache_key(&root);
11868 crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
11869 let callgraph_dir = temp
11870 .path()
11871 .join("storage")
11872 .join("callgraph")
11873 .join(artifact_key);
11874 let source = root.join("main.rs");
11875 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
11876 let (store, _) = CallGraphStore::cold_build_with_lease(
11877 callgraph_dir.clone(),
11878 root.clone(),
11879 std::slice::from_ref(&source),
11880 )
11881 .unwrap();
11882 drop(store);
11883 (temp, root, callgraph_dir, source)
11884 }
11885
11886 fn pending_paths() -> PendingCallGraphStorePaths {
11887 Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
11888 }
11889
11890 fn wait_for_refresh_calls(root: &Path, expected: usize) {
11891 let deadline = Instant::now() + Duration::from_secs(12);
11892 while callgraph_refresh_worker_test_counts(root).0 < expected {
11893 assert!(
11894 Instant::now() < deadline,
11895 "timed out waiting for {expected} callgraph refresh worker call(s)"
11896 );
11897 std::thread::sleep(Duration::from_millis(5));
11898 }
11899 }
11900
11901 fn wait_for_refresh_worker_idle() {
11902 let deadline = Instant::now() + Duration::from_secs(12);
11903 loop {
11904 let worker = CALLGRAPH_REFRESH_WORKER
11905 .get_or_init(|| Mutex::new(None))
11906 .lock()
11907 .expect("callgraph refresh worker mutex poisoned")
11908 .clone();
11909 let idle = worker.is_none_or(|worker| {
11910 let queue = worker
11911 .shared
11912 .queue
11913 .lock()
11914 .expect("callgraph refresh queue mutex poisoned");
11915 queue.active.is_none() && queue.order.is_empty()
11916 });
11917 if idle {
11918 return;
11919 }
11920 assert!(
11921 Instant::now() < deadline,
11922 "timed out waiting for callgraph refresh worker to become idle"
11923 );
11924 std::thread::sleep(Duration::from_millis(5));
11925 }
11926 }
11927
11928 fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
11929 let temp = tempdir().unwrap();
11930 let root = temp.path().join("workspace");
11931 fs::create_dir_all(root.join("app/src")).unwrap();
11932 let artifact_key = crate::search_index::artifact_cache_key(&root);
11933 crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
11934 let callgraph_dir = temp
11935 .path()
11936 .join("storage")
11937 .join("callgraph")
11938 .join(artifact_key);
11939 fs::write(
11940 root.join("Cargo.toml"),
11941 "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
11942 )
11943 .unwrap();
11944 fs::write(
11945 root.join("app/Cargo.toml"),
11946 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
11947 )
11948 .unwrap();
11949 let caller = root.join("app/src/lib.rs");
11950 fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
11951 let (store, _) = CallGraphStore::cold_build_with_lease(
11952 callgraph_dir.clone(),
11953 root.clone(),
11954 std::slice::from_ref(&caller),
11955 )
11956 .unwrap();
11957 drop(store);
11958 (temp, root, callgraph_dir, caller)
11959 }
11960
11961 #[test]
11962 fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
11963 let _guard = REFRESH_WORKER_TEST_LOCK
11964 .lock()
11965 .unwrap_or_else(std::sync::PoisonError::into_inner);
11966 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
11967 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
11968 reset_workspace_crate_prefix_build_count(&root);
11969 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
11970
11971 for revision in ["first", "second"] {
11972 fs::write(
11973 &caller,
11974 format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
11975 )
11976 .unwrap();
11977 enqueue_callgraph_store_refresh(
11978 callgraph_dir.clone(),
11979 root.clone(),
11980 vec![caller.clone()],
11981 pending_paths(),
11982 );
11983 wait_for_refresh_worker_idle();
11984 }
11985
11986 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
11987 assert!(flush_callgraph_store_refreshes_with_budget(
11988 Duration::from_secs(5)
11989 ));
11990 clear_callgraph_refresh_worker_test_seam(&root);
11991 }
11992
11993 #[test]
11994 fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
11995 let _guard = REFRESH_WORKER_TEST_LOCK
11996 .lock()
11997 .unwrap_or_else(std::sync::PoisonError::into_inner);
11998 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
11999 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
12000 reset_workspace_crate_prefix_build_count(&root);
12001 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12002
12003 fs::write(
12004 &caller,
12005 "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
12006 )
12007 .unwrap();
12008 enqueue_callgraph_store_refresh(
12009 callgraph_dir.clone(),
12010 root.clone(),
12011 vec![caller.clone()],
12012 pending_paths(),
12013 );
12014 wait_for_refresh_worker_idle();
12015 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
12016
12017 let added_manifest = root.join("added/Cargo.toml");
12018 let added_source = root.join("added/src/lib.rs");
12019 fs::create_dir_all(added_source.parent().unwrap()).unwrap();
12020 fs::write(
12021 root.join("Cargo.toml"),
12022 "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
12023 )
12024 .unwrap();
12025 fs::write(
12026 &added_manifest,
12027 "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
12028 )
12029 .unwrap();
12030 fs::write(&added_source, "pub fn target() {}\n").unwrap();
12031 fs::write(
12032 &caller,
12033 "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
12034 )
12035 .unwrap();
12036
12037 enqueue_callgraph_store_refresh(
12038 callgraph_dir.clone(),
12039 root.clone(),
12040 vec![
12041 root.join("Cargo.toml"),
12042 added_manifest,
12043 added_source,
12044 caller,
12045 ],
12046 pending_paths(),
12047 );
12048 assert!(flush_callgraph_store_refreshes_with_budget(
12049 Duration::from_secs(12)
12050 ));
12051
12052 assert_eq!(workspace_crate_prefix_build_count(&root), 2);
12056 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
12057 .unwrap()
12058 .expect("refreshed workspace store");
12059 let tree = store
12060 .call_tree(Path::new("app/src/lib.rs"), "run", 1)
12061 .unwrap();
12062 assert_eq!(tree.children.len(), 1);
12063 assert_eq!(tree.children[0].file, "added/src/lib.rs");
12064 assert_eq!(tree.children[0].name, "target");
12065 assert!(tree.children[0].resolved);
12066 clear_callgraph_refresh_worker_test_seam(&root);
12067 }
12068
12069 fn linked_worktree_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, PathBuf) {
12070 let temp = tempdir().unwrap();
12071 let main = temp.path().join("main");
12072 let worktree = temp.path().join("worktree");
12073 fs::create_dir_all(&main).unwrap();
12074 let mut git = std::process::Command::new("git");
12075 assert!(
12076 crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
12077 .status()
12078 .unwrap()
12079 .success()
12080 );
12081 fs::write(main.join("lib.rs"), "pub fn marker() {}\n").unwrap();
12082 for args in [
12083 vec![
12084 "-C",
12085 main.to_str().unwrap(),
12086 "config",
12087 "user.email",
12088 "test@example.com",
12089 ],
12090 vec![
12091 "-C",
12092 main.to_str().unwrap(),
12093 "config",
12094 "user.name",
12095 "AFT Test",
12096 ],
12097 vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
12098 vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
12099 ] {
12100 let mut command = std::process::Command::new("git");
12101 assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
12102 .status()
12103 .unwrap()
12104 .success());
12105 }
12106 let mut add_worktree = std::process::Command::new("git");
12107 assert!(crate::test_env::apply_hermetic_git_env(
12108 add_worktree
12109 .arg("-C")
12110 .arg(&main)
12111 .args(["worktree", "add", "--detach"])
12112 .arg(&worktree),
12113 )
12114 .status()
12115 .unwrap()
12116 .success());
12117 let main = fs::canonicalize(main).unwrap();
12118 let worktree = fs::canonicalize(worktree).unwrap();
12119 let project_key = crate::search_index::artifact_cache_key(&main);
12120 assert_eq!(
12121 crate::search_index::artifact_cache_key(&worktree),
12122 project_key
12123 );
12124 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
12125 (temp, main, worktree, project_key, callgraph_dir)
12126 }
12127
12128 #[test]
12129 fn linked_worktree_never_acquires_writer_or_publishes_any_build_path() {
12130 let _git_env = crate::test_env::hermetic_git_env_guard();
12131 let (_temp, _main, root, project_key, callgraph_dir) = linked_worktree_fixture();
12132 crate::root_cache::configure_artifact_access(&root, &project_key, true);
12133 crate::root_cache::reset_writer_lease_acquisition_counts_for_test();
12134 let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
12135 let publications_for_observer = Arc::clone(&publications);
12136 set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
12137 publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
12138 })));
12139 let source = root.join("lib.rs");
12140
12141 let open_error = CallGraphStore::open(callgraph_dir.clone(), root.clone())
12142 .expect_err("borrow-only writable open must remain unavailable");
12143 assert!(matches!(open_error, CallGraphStoreError::Unavailable(_)));
12144 assert!(
12145 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), root.clone())
12146 .unwrap()
12147 .is_none()
12148 );
12149 assert!(
12150 CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), root.clone())
12151 .unwrap()
12152 .is_none()
12153 );
12154 assert!(matches!(
12155 CallGraphStore::cold_build_with_lease(
12156 callgraph_dir.clone(),
12157 root.clone(),
12158 std::slice::from_ref(&source),
12159 ),
12160 Err(CallGraphStoreError::Unavailable(_))
12161 ));
12162 assert!(matches!(
12163 CallGraphStore::ensure_built_with_lease(
12164 callgraph_dir.clone(),
12165 root.clone(),
12166 std::slice::from_ref(&source),
12167 ),
12168 Err(CallGraphStoreError::Unavailable(_))
12169 ));
12170 let force_error = CallGraphStore::force_cold_build_with_lease_chunked(
12171 callgraph_dir.clone(),
12172 root.clone(),
12173 &[source],
12174 1,
12175 )
12176 .expect_err("borrow-only forced rebuild must remain unsatisfied");
12177 set_cold_build_swap_observer(None);
12178
12179 assert!(matches!(force_error, CallGraphStoreError::Unavailable(_)));
12180 assert_eq!(
12181 crate::root_cache::writer_lease_acquisition_count_for_test(
12182 crate::root_cache::RootCacheDomain::Callgraph,
12183 &project_key,
12184 &root,
12185 ),
12186 0
12187 );
12188 assert_eq!(publications.load(AtomicOrdering::SeqCst), 0);
12189 assert!(!pointer_path(&callgraph_dir, &project_key).exists());
12190 }
12191
12192 #[test]
12193 fn owner_and_linked_worktree_alternation_rebuilds_storm_generation_once() {
12194 let _git_env = crate::test_env::hermetic_git_env_guard();
12195 let (_temp, owner, worktree, project_key, callgraph_dir) = linked_worktree_fixture();
12196 crate::root_cache::configure_artifact_access(&owner, &project_key, false);
12197 crate::root_cache::configure_artifact_access(&worktree, &project_key, true);
12198 let source = owner.join("lib.rs");
12199 let (store, _) = CallGraphStore::cold_build_with_lease(
12200 callgraph_dir.clone(),
12201 owner.clone(),
12202 std::slice::from_ref(&source),
12203 )
12204 .unwrap();
12205 let sqlite_path = store.sqlite_path().to_path_buf();
12206 drop(store);
12207
12208 let conn = Connection::open(&sqlite_path).unwrap();
12209 conn.execute(
12210 "UPDATE backend_file_state SET workspace_root = ?1",
12211 [worktree.display().to_string()],
12212 )
12213 .unwrap();
12214 drop(conn);
12215
12216 let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
12217 let publications_for_observer = Arc::clone(&publications);
12218 set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
12219 publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
12220 })));
12221 crate::root_cache::reset_writer_lease_acquisition_counts_for_test();
12222
12223 let repaired = CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
12224 .unwrap()
12225 .expect("owner should purge the storm-era worktree root");
12226 drop(repaired);
12227 for _ in 0..3 {
12228 let borrower = CallGraphStore::open_readonly(callgraph_dir.clone(), worktree.clone())
12229 .unwrap()
12230 .expect("linked worktree should borrow the owner generation");
12231 drop(borrower);
12232 assert!(
12233 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), worktree.clone())
12234 .unwrap()
12235 .is_none()
12236 );
12237 let owner_store =
12238 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
12239 .unwrap()
12240 .expect("owner generation should remain ready");
12241 drop(owner_store);
12242 }
12243 set_cold_build_swap_observer(None);
12244
12245 assert_eq!(
12246 publications.load(AtomicOrdering::SeqCst),
12247 1,
12248 "the owner performs one expected post-storm purge and alternation stays read-only"
12249 );
12250 assert_eq!(
12251 crate::root_cache::writer_lease_acquisition_count_for_test(
12252 crate::root_cache::RootCacheDomain::Callgraph,
12253 &project_key,
12254 &worktree,
12255 ),
12256 0
12257 );
12258 }
12259
12260 #[test]
12261 fn rebuild_cooldown_records_only_successful_publication_per_cache_key() {
12262 let temp = tempdir().unwrap();
12263 let root = temp.path().join("owner");
12264 let other_root = temp.path().join("other");
12265 fs::create_dir_all(&root).unwrap();
12266 fs::create_dir_all(&other_root).unwrap();
12267 let source = root.join("lib.rs");
12268 fs::write(&source, "pub fn marker() {}\n").unwrap();
12269 let project_key = crate::search_index::artifact_cache_key(&root);
12270 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
12271 crate::root_cache::configure_artifact_access(&root, &project_key, false);
12272 let cooldown_key = rebuild_cooldown_key(&callgraph_dir, &project_key);
12273 rebuild_cooldown_records()
12274 .lock()
12275 .unwrap_or_else(std::sync::PoisonError::into_inner)
12276 .remove(&cooldown_key);
12277 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
12278 let stale_epoch = epoch.current();
12279 epoch.next();
12280
12281 let failed = with_publish_epoch(epoch, stale_epoch, || {
12282 CallGraphStore::cold_build_with_lease(
12283 callgraph_dir.clone(),
12284 root.clone(),
12285 std::slice::from_ref(&source),
12286 )
12287 });
12288 assert!(matches!(failed, Err(CallGraphStoreError::Superseded)));
12289 assert!(
12290 rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
12291 .is_none()
12292 );
12293
12294 let (store, _) = CallGraphStore::cold_build_with_lease(
12295 callgraph_dir.clone(),
12296 root.clone(),
12297 std::slice::from_ref(&source),
12298 )
12299 .unwrap();
12300 drop(store);
12301 assert!(
12302 rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
12303 .is_none()
12304 );
12305
12306 record_successful_rebuild(&callgraph_dir, &project_key, &other_root, Instant::now());
12307 assert!(
12308 rebuild_cooldown_denial(&callgraph_dir, &project_key, &root, Instant::now(),).is_some()
12309 );
12310 }
12311
12312 #[test]
12313 fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
12314 let _guard = REFRESH_WORKER_TEST_LOCK
12315 .lock()
12316 .unwrap_or_else(std::sync::PoisonError::into_inner);
12317 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12318 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12319 let pending = pending_paths();
12320 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12321
12322 let lifecycle = SubcLifecycleAdmission::default();
12323 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
12324 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12325 let ticket = CallgraphRefreshTicket::new(
12326 lifecycle,
12327 Arc::clone(&generation),
12328 7,
12329 publish_epoch.clone(),
12330 publish_epoch.current(),
12331 );
12332 generation.store(8, std::sync::atomic::Ordering::SeqCst);
12334 let installed = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
12335 .unwrap()
12336 .expect("ready store snapshot");
12337 let refresh_state = CallgraphRefreshState::new(
12338 Arc::new(std::sync::RwLock::new(Some(Arc::new(installed)))),
12339 Arc::new(AtomicBool::new(true)),
12340 );
12341
12342 enqueue_callgraph_store_refresh_fenced_with_state(
12343 callgraph_dir,
12344 root.clone(),
12345 vec![source.clone()],
12346 Arc::clone(&pending),
12347 refresh_state,
12348 ticket,
12349 );
12350 assert!(flush_callgraph_store_refreshes_with_budget(
12351 Duration::from_secs(5)
12352 ));
12353 assert_eq!(
12354 callgraph_refresh_worker_test_counts(&root).0,
12355 0,
12356 "superseded batch must not reach refresh_files or self-replay"
12357 );
12358 assert!(
12359 pending.lock().contains(&source),
12360 "superseded batch must defer its paths to the pending sink"
12361 );
12362 clear_callgraph_refresh_worker_test_seam(&root);
12363 }
12364
12365 #[test]
12366 fn superseded_open_failure_defers_without_self_replay() {
12367 let _guard = REFRESH_WORKER_TEST_LOCK
12368 .lock()
12369 .unwrap_or_else(std::sync::PoisonError::into_inner);
12370 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12371 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12372 let pending = pending_paths();
12373 let installed = Arc::new(
12374 CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
12375 .unwrap()
12376 .expect("ready store snapshot"),
12377 );
12378 let refresh_state = CallgraphRefreshState::new(
12379 Arc::new(std::sync::RwLock::new(Some(Arc::clone(&installed)))),
12380 Arc::new(AtomicBool::new(true)),
12381 );
12382 assert!(!installed.is_legacy_fallback());
12383 assert!(installed.is_current());
12384 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
12385 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12386 set_callgraph_refresh_worker_test_open_failure(root.clone(), true);
12387 let (held_rx, release_tx) = install_callgraph_refresh_worker_test_gate(root.clone());
12388
12389 let lifecycle = SubcLifecycleAdmission::default();
12390 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
12391 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12392 let ticket = CallgraphRefreshTicket::new(
12393 lifecycle,
12394 Arc::clone(&generation),
12395 7,
12396 publish_epoch.clone(),
12397 publish_epoch.current(),
12398 );
12399 enqueue_callgraph_store_refresh_fenced_with_state(
12400 callgraph_dir,
12401 root.clone(),
12402 vec![source.clone()],
12403 Arc::clone(&pending),
12404 refresh_state,
12405 ticket,
12406 );
12407 held_rx
12408 .recv_timeout(Duration::from_secs(12))
12409 .expect("refresh worker must hold after injected open failure");
12410
12411 generation.store(8, std::sync::atomic::Ordering::SeqCst);
12414 set_callgraph_refresh_worker_test_open_failure(root.clone(), false);
12415 release_tx
12416 .send(())
12417 .expect("release superseded refresh worker");
12418 wait_for_refresh_worker_idle();
12419
12420 assert_eq!(
12421 callgraph_refresh_worker_test_counts(&root).0,
12422 1,
12423 "superseded open-failure batch must not self-replay"
12424 );
12425 assert_eq!(
12426 callgraph_refresh_worker_test_worker_calls(&root),
12427 1,
12428 "superseded open-failure batch must not create another worker call"
12429 );
12430 assert!(
12431 pending.lock().contains(&source),
12432 "superseded open-failure paths must remain in the pending sink"
12433 );
12434 let tree = installed
12435 .call_tree(Path::new("main.rs"), "entry", 1)
12436 .unwrap();
12437 assert_eq!(
12438 tree.children[0].name, "old_leaf",
12439 "superseded open-failure batch must not converge the store"
12440 );
12441 clear_callgraph_refresh_worker_test_seam(&root);
12442 }
12443
12444 #[test]
12445 fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
12446 let _guard = REFRESH_WORKER_TEST_LOCK
12447 .lock()
12448 .unwrap_or_else(std::sync::PoisonError::into_inner);
12449 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12450 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12451 let pending = pending_paths();
12452 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12453
12454 let lifecycle = SubcLifecycleAdmission::default();
12455 let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
12456 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12457 let expected_epoch = publish_epoch.current();
12458 let ticket = CallgraphRefreshTicket::new(
12459 lifecycle,
12460 generation,
12461 3,
12462 publish_epoch.clone(),
12463 expected_epoch,
12464 );
12465 publish_epoch.next();
12467
12468 enqueue_callgraph_store_refresh_fenced(
12469 callgraph_dir,
12470 root.clone(),
12471 vec![source.clone()],
12472 Arc::clone(&pending),
12473 ticket,
12474 );
12475 assert!(flush_callgraph_store_refreshes_with_budget(
12476 Duration::from_secs(5)
12477 ));
12478 assert_eq!(
12479 callgraph_refresh_worker_test_counts(&root).0,
12480 0,
12481 "epoch-superseded batch must not reach refresh_files"
12482 );
12483 assert!(
12484 pending.lock().contains(&source),
12485 "epoch-superseded batch must defer its paths to the pending sink"
12486 );
12487 clear_callgraph_refresh_worker_test_seam(&root);
12488 }
12489
12490 #[test]
12491 fn fenced_refresh_with_current_ticket_commits_normally() {
12492 let _guard = REFRESH_WORKER_TEST_LOCK
12493 .lock()
12494 .unwrap_or_else(std::sync::PoisonError::into_inner);
12495 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12496 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12497 let pending = pending_paths();
12498 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12499
12500 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
12501
12502 let lifecycle = SubcLifecycleAdmission::default();
12503 let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
12504 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12505 let ticket = CallgraphRefreshTicket::new(
12506 lifecycle,
12507 generation,
12508 5,
12509 publish_epoch.clone(),
12510 publish_epoch.current(),
12511 );
12512
12513 enqueue_callgraph_store_refresh_fenced(
12514 callgraph_dir.clone(),
12515 root.clone(),
12516 vec![source.clone()],
12517 Arc::clone(&pending),
12518 ticket,
12519 );
12520 assert!(flush_callgraph_store_refreshes_with_budget(
12521 Duration::from_secs(5)
12522 ));
12523 assert_eq!(
12524 callgraph_refresh_worker_test_counts(&root).0,
12525 1,
12526 "current ticket must run the refresh"
12527 );
12528 assert!(
12529 pending.lock().is_empty(),
12530 "committed batch must not defer paths"
12531 );
12532
12533 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
12534 .unwrap()
12535 .expect("published generation must remain readable");
12536 let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
12537 assert_eq!(
12538 tree.children[0].name, "new_leaf",
12539 "fenced commit must actually persist the refreshed content"
12540 );
12541 clear_callgraph_refresh_worker_test_seam(&root);
12542 }
12543
12544 #[test]
12545 fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
12546 let _guard = REFRESH_WORKER_TEST_LOCK
12547 .lock()
12548 .unwrap_or_else(std::sync::PoisonError::into_inner);
12549 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12554 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12555 let pending = pending_paths();
12556 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
12557
12558 enqueue_callgraph_store_refresh(
12559 callgraph_dir.clone(),
12560 root.clone(),
12561 vec![source.clone()],
12562 Arc::clone(&pending),
12563 );
12564 wait_for_refresh_calls(&root, 1);
12565 for _ in 0..3 {
12566 enqueue_callgraph_store_refresh(
12567 callgraph_dir.clone(),
12568 root.clone(),
12569 vec![source.clone()],
12570 Arc::clone(&pending),
12571 );
12572 }
12573
12574 assert!(flush_callgraph_store_refreshes_with_budget(
12575 Duration::from_secs(2)
12576 ));
12577 assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
12578 assert!(pending.lock().is_empty());
12579 clear_callgraph_refresh_worker_test_seam(&root);
12580 }
12581
12582 #[test]
12583 fn queued_refresh_opens_generation_published_after_enqueue() {
12584 let _guard = REFRESH_WORKER_TEST_LOCK
12585 .lock()
12586 .unwrap_or_else(std::sync::PoisonError::into_inner);
12587 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12592 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
12593 let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
12594 set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
12595 let (active_held_rx, active_release_tx) =
12596 install_callgraph_refresh_worker_test_gate(active_root.clone());
12597 set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
12598 enqueue_callgraph_store_refresh(
12599 active_dir,
12600 active_root.clone(),
12601 vec![active_source],
12602 pending_paths(),
12603 );
12604 active_held_rx
12605 .recv_timeout(Duration::from_secs(12))
12606 .expect("active refresh worker holds the queue");
12607
12608 fs::write(
12609 &target_source,
12610 "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
12611 )
12612 .unwrap();
12613 enqueue_callgraph_store_refresh(
12614 target_dir.clone(),
12615 target_root.clone(),
12616 vec![target_source.clone()],
12617 pending_paths(),
12618 );
12619 let (new_generation, _) = CallGraphStore::cold_build_with_lease(
12620 target_dir.clone(),
12621 target_root.clone(),
12622 std::slice::from_ref(&target_source),
12623 )
12624 .unwrap();
12625 fs::write(
12626 &target_source,
12627 "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
12628 )
12629 .unwrap();
12630 drop(new_generation);
12631
12632 active_release_tx
12633 .send(())
12634 .expect("release active refresh worker");
12635 wait_for_refresh_calls(&target_root, 1);
12636 assert!(flush_callgraph_store_refreshes_with_budget(
12637 Duration::from_secs(12)
12638 ));
12639 let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
12640 .unwrap()
12641 .expect("current callgraph generation");
12642 let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
12643 assert_eq!(tree.children[0].name, "worker_leaf");
12644 assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
12645 clear_callgraph_refresh_worker_test_seam(&active_root);
12646 clear_callgraph_refresh_worker_test_seam(&target_root);
12647 }
12648
12649 #[test]
12650 fn refresh_failure_marks_files_stale() {
12651 let _guard = REFRESH_WORKER_TEST_LOCK
12652 .lock()
12653 .unwrap_or_else(std::sync::PoisonError::into_inner);
12654 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12659 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12660 let pending = pending_paths();
12661 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
12662
12663 enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
12664 assert!(flush_callgraph_store_refreshes_with_budget(
12665 Duration::from_secs(2)
12666 ));
12667
12668 assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
12669 let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
12670 .unwrap()
12671 .expect("ready callgraph store");
12672 assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
12673 clear_callgraph_refresh_worker_test_seam(&root);
12674 }
12675
12676 #[test]
12677 fn idle_refresh_truncates_wal() {
12678 let _guard = REFRESH_WORKER_TEST_LOCK
12679 .lock()
12680 .unwrap_or_else(std::sync::PoisonError::into_inner);
12681 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12682 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12683 let generation = read_pointer(
12684 &callgraph_dir,
12685 &crate::search_index::artifact_cache_key(&root),
12686 )
12687 .expect("fixture publishes a generation");
12688 let wal_path = callgraph_dir.join(format!("{generation}-wal"));
12689 let pending = pending_paths();
12690 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12691
12692 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n\n").unwrap();
12693 enqueue_callgraph_store_refresh(
12694 callgraph_dir.clone(),
12695 root.clone(),
12696 vec![source.clone()],
12697 Arc::clone(&pending),
12698 );
12699 wait_for_refresh_calls(&root, 1);
12700 wait_for_refresh_worker_idle();
12701 let checkpoint_deadline = Instant::now() + Duration::from_secs(2);
12702 while fs::metadata(&wal_path)
12703 .map(|metadata| metadata.len())
12704 .unwrap_or(0)
12705 != 0
12706 {
12707 assert!(
12708 Instant::now() < checkpoint_deadline,
12709 "idle checkpoint did not truncate WAL"
12710 );
12711 std::thread::sleep(Duration::from_millis(5));
12712 }
12713 assert_eq!(
12714 fs::metadata(&wal_path)
12715 .map(|metadata| metadata.len())
12716 .unwrap_or(0),
12717 0,
12718 "idle transition truncates the refresh WAL"
12719 );
12720
12721 clear_callgraph_refresh_worker_test_seam(&root);
12722 }
12723
12724 #[test]
12725 fn bounded_shutdown_defers_unprocessed_batches() {
12726 let _guard = REFRESH_WORKER_TEST_LOCK
12727 .lock()
12728 .unwrap_or_else(std::sync::PoisonError::into_inner);
12729 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12734 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
12735 let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
12736 let active_pending = pending_paths();
12737 let queued_pending = pending_paths();
12738 set_callgraph_refresh_worker_test_seam(
12739 active_root.clone(),
12740 Duration::from_millis(300),
12741 false,
12742 );
12743
12744 enqueue_callgraph_store_refresh(
12745 active_dir,
12746 active_root.clone(),
12747 vec![active_source.clone()],
12748 Arc::clone(&active_pending),
12749 );
12750 wait_for_refresh_calls(&active_root, 1);
12751 enqueue_callgraph_store_refresh(
12752 queued_dir,
12753 queued_root.clone(),
12754 vec![queued_source.clone()],
12755 Arc::clone(&queued_pending),
12756 );
12757
12758 assert!(!flush_callgraph_store_refreshes_with_budget(
12759 Duration::from_millis(20)
12760 ));
12761 assert!(active_pending.lock().contains(&active_source));
12762 assert!(queued_pending.lock().contains(&queued_source));
12763 assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
12764 clear_callgraph_refresh_worker_test_seam(&active_root);
12765 }
12766}
12767
12768#[cfg(test)]
12769mod cold_build_insert_tests {
12770 use super::*;
12771 use crate::imports::ImportBlock;
12772 use std::cell::Cell;
12773 use std::fs;
12774 use std::path::{Path, PathBuf};
12775 use tempfile::tempdir;
12776
12777 thread_local! {
12778 static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
12779 static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
12780 static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
12781 }
12782
12783 fn count_caller_traversal_selects(sql: &str) {
12784 let sql = sql.trim_start();
12785 if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
12786 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
12787 }
12788 if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
12789 && sql.contains("e.target_file =")
12790 {
12791 CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
12792 }
12793 if sql.starts_with("WITH requested") {
12794 BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
12795 }
12796 }
12797
12798 #[test]
12799 fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
12800 let dir = tempdir().unwrap();
12801 let previous_root = dir.path().join("previous-root");
12802 let current_root = dir.path().join("current-root");
12803 fs::create_dir_all(&previous_root).unwrap();
12804 fs::create_dir_all(¤t_root).unwrap();
12805 fs::remove_dir(&previous_root).unwrap();
12806 let mut conn = Connection::open_in_memory().unwrap();
12807 initialize_schema(&conn).unwrap();
12808 conn.execute(
12809 "INSERT INTO backend_file_state(
12810 backend, workspace_root, file_path, content_hash, status, updated_at
12811 ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
12812 params![previous_root.display().to_string()],
12813 )
12814 .unwrap();
12815
12816 let repair = reconcile_workspace_roots(&mut conn, ¤t_root, false).unwrap();
12817
12818 assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
12819 assert_eq!(
12820 stored_workspace_roots(&conn).unwrap(),
12821 vec![previous_root.display().to_string()]
12822 );
12823 }
12824
12825 #[test]
12826 fn sqlite_readonly_uri_percent_encodes_windows_paths() {
12827 assert_eq!(
12828 sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
12829 "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
12830 );
12831 }
12832
12833 #[test]
12834 fn legacy_migration_completion_log_has_operator_fields() {
12835 assert_eq!(
12836 legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
12837 "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
12838 );
12839 }
12840
12841 fn write_generation_with_age(
12842 dir: &Path,
12843 project_key: &str,
12844 ordinal: u64,
12845 age: Duration,
12846 ) -> String {
12847 let generation = format!("{project_key}.g{ordinal}.1.sqlite");
12848 let path = dir.join(&generation);
12849 fs::write(&path, b"sqlite placeholder").unwrap();
12850 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
12851 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
12852 generation
12853 }
12854
12855 #[test]
12856 fn gc_old_generations_preserves_live_reader_until_marker_drops() {
12857 let dir = tempfile::tempdir().unwrap();
12858 let project_key = "project";
12859 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
12860 let previous =
12861 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
12862 let pinned =
12863 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
12864 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
12865
12866 gc_old_generations(dir.path(), project_key, ¤t);
12867
12868 assert!(dir.path().join(&previous).is_file());
12869 assert!(dir.path().join(&pinned).is_file());
12870
12871 drop(marker);
12872 gc_old_generations(dir.path(), project_key, ¤t);
12873
12874 assert!(dir.path().join(&previous).is_file());
12875 assert!(!dir.path().join(&pinned).exists());
12876 }
12877
12878 #[test]
12879 fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
12880 let dir = tempfile::tempdir().unwrap();
12881 let project_key = "project";
12882 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
12883 let _previous =
12884 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
12885 let pinned =
12886 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
12887 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
12888 filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
12889
12890 gc_old_generations(dir.path(), project_key, ¤t);
12891
12892 assert!(dir.path().join(&pinned).is_file());
12893 }
12894
12895 #[test]
12896 fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
12897 let dir = tempfile::tempdir().unwrap();
12898 let project_key = "project";
12899 let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
12900 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
12901 let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
12902 let old = write_generation_with_age(
12903 dir.path(),
12904 project_key,
12905 200,
12906 expired + Duration::from_secs(60),
12907 );
12908 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
12909
12910 gc_old_generations(dir.path(), project_key, ¤t);
12911
12912 assert!(dir.path().join(¤t).is_file());
12913 assert!(dir.path().join(&previous).is_file());
12914 assert!(!dir.path().join(&old).exists());
12915 }
12916
12917 fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
12918 let path = dir.join(name);
12919 fs::write(&path, b"temp placeholder").unwrap();
12920 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
12921 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
12922 path
12923 }
12924
12925 #[test]
12926 fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
12927 let dir = tempdir().unwrap();
12928 let aged = "project.g100.1.sqlite.tmp.1.200";
12932 let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
12933 let fresh = "project.g300.1.sqlite.tmp.1.400";
12934 let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
12935 write_build_temp_with_age(dir.path(), aged, aged_age);
12936 write_build_temp_with_age(dir.path(), aged_journal, aged_age);
12937 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
12938
12939 sweep_orphaned_build_temps(dir.path());
12940
12941 assert!(
12942 !dir.path().join(aged).exists(),
12943 "aged orphan must be removed"
12944 );
12945 assert!(
12946 !dir.path().join(aged_journal).exists(),
12947 "aged journal sidecar must be removed"
12948 );
12949 assert!(
12950 dir.path().join(fresh).is_file(),
12951 "fresh temporary must survive"
12952 );
12953 }
12954
12955 #[test]
12956 fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
12957 let storage = tempdir().unwrap();
12958 let storage_root = storage.path();
12959 let legacy_dir = storage_root.join("opencode").join("callgraph");
12965 fs::create_dir_all(&legacy_dir).unwrap();
12966 let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
12967 write_build_temp_with_age(
12968 &legacy_dir,
12969 orphan,
12970 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
12971 );
12972 assert!(
12973 !legacy_dir.join("deadbeef.current").exists(),
12974 "the dead root has no current pointer"
12975 );
12976
12977 let root_keyed_dir = storage_root.join("callgraph").join("livekey");
12978 fs::create_dir_all(&root_keyed_dir).unwrap();
12979
12980 sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
12981
12982 assert!(
12983 !legacy_dir.join(orphan).exists(),
12984 "legacy orphan must be reclaimed by the store-wide sweep"
12985 );
12986 }
12987
12988 #[test]
12989 fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
12990 let dir = tempdir().unwrap();
12996 let fresh = "project.g300.1.sqlite.tmp.1.400";
12997 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
12998
12999 sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
13000
13001 assert!(
13002 !dir.path().join(fresh).exists(),
13003 "with the age predicate forced open, the fresh temporary is removed"
13004 );
13005 }
13006
13007 #[test]
13008 fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
13009 let dir = tempdir().unwrap();
13010 let generation = write_generation_with_age(
13014 dir.path(),
13015 "project",
13016 400,
13017 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
13018 );
13019 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
13020
13021 sweep_orphaned_build_temps(dir.path());
13022
13023 assert!(
13024 dir.path().join(&generation).is_file(),
13025 "completed generation must survive the orphan sweep"
13026 );
13027 assert!(
13028 crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
13029 "read marker must survive the orphan sweep"
13030 );
13031 }
13032
13033 #[test]
13034 fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
13035 let dir = tempfile::tempdir().unwrap();
13036 let project_key = "project".to_string();
13037 let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
13038 let sqlite_path = dir.path().join(&generation);
13039 fs::remove_file(&sqlite_path).unwrap();
13040 let conn = Connection::open(&sqlite_path).unwrap();
13041 let store = CallGraphStore::from_connection(
13042 dir.path().to_path_buf(),
13043 project_key,
13044 sqlite_path,
13045 dir.path().to_path_buf(),
13046 false,
13047 Some(generation.clone()),
13048 None,
13049 None,
13050 conn,
13051 );
13052
13053 let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
13054 assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
13055
13056 drop(marker);
13057 assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
13058 }
13059
13060 #[test]
13061 fn readiness_cache_only_skips_checks_after_a_successful_validation() {
13062 let dir = tempdir().expect("temp dir");
13063 let file = dir.path().join("main.ts");
13064 fs::write(&file, "export function main() {}\n").expect("write fixture");
13065 let store = CallGraphStore::open(
13066 dir.path().join(".store-readiness-cache"),
13067 dir.path().to_path_buf(),
13068 )
13069 .expect("open store");
13070 {
13071 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13072 conn.trace(Some(count_caller_traversal_selects));
13073 }
13074
13075 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13076 assert!(store.indexed_file_count().is_err());
13077 assert!(store.indexed_file_count().is_err());
13078 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
13079
13080 store
13081 .cold_build(std::slice::from_ref(&file))
13082 .expect("cold build");
13083 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13084 assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
13085 assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
13086 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
13087
13088 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13089 conn.trace(None);
13090 }
13091
13092 #[test]
13093 fn callers_depth_boundary_batches_sqlite_counts() {
13094 const CALLER_COUNT: usize = 1_000;
13095
13096 let dir = tempdir().expect("temp dir");
13097 let file = dir.path().join("main.ts");
13098 let mut source = String::from("export function sharedHotHelper() {}\n");
13099 for index in 0..CALLER_COUNT {
13100 source.push_str(&format!(
13101 "export function caller{index}() {{ sharedHotHelper(); }}\n"
13102 ));
13103 }
13104 fs::write(&file, source).expect("write fixture");
13105
13106 let store = CallGraphStore::open(
13107 dir.path().join(".store-callers-query-fanout"),
13108 dir.path().to_path_buf(),
13109 )
13110 .expect("open store");
13111 store
13112 .cold_build(std::slice::from_ref(&file))
13113 .expect("cold build");
13114
13115 CALLER_QUERY_SELECTS.with(|count| count.set(0));
13116 BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
13117 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13118 {
13119 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13120 conn.trace(Some(count_caller_traversal_selects));
13121 }
13122
13123 let started = Instant::now();
13124 let result = crate::commands::callgraph_store_adapter::callers_result(
13125 &store,
13126 Path::new("main.ts"),
13127 "sharedHotHelper",
13128 1,
13129 true,
13130 )
13131 .expect("callers result");
13132 let elapsed = started.elapsed();
13133
13134 {
13135 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13136 conn.trace(None);
13137 }
13138 let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
13139 let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
13140 let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
13141 eprintln!(
13142 "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
13143 result.total_callers,
13144 caller_queries,
13145 boundary_queries,
13146 total_selects,
13147 elapsed.as_secs_f64() * 1_000.0
13148 );
13149
13150 assert_eq!(result.total_callers, CALLER_COUNT);
13151 assert_eq!(caller_queries, 1);
13152 assert_eq!(boundary_queries, 3);
13153 assert_eq!(total_selects, 9);
13154 }
13155
13156 #[test]
13157 fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
13158 let dir = tempdir().expect("temp dir");
13159 let file = dir.path().join("main.ts");
13160 fs::write(
13161 &file,
13162 r#"export function topA() {
13163 root();
13164}
13165
13166export function topB() {
13167 root();
13168}
13169
13170export function root() {
13171 leaf();
13172 missing();
13173}
13174
13175export function leaf() {}
13176"#,
13177 )
13178 .expect("write fixture");
13179
13180 let store = CallGraphStore::open(
13181 dir.path().join(".store-depth-boundary-counts"),
13182 dir.path().to_path_buf(),
13183 )
13184 .expect("open store");
13185 store
13186 .cold_build(std::slice::from_ref(&file))
13187 .expect("cold build");
13188
13189 let root = store
13190 .node_for(Path::new("main.ts"), "root")
13191 .expect("root node");
13192 let leaf = store
13193 .node_for(Path::new("main.ts"), "leaf")
13194 .expect("leaf node");
13195
13196 let (full_forward_len, full_direct_len) = {
13197 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
13198 conn.execute(
13199 "INSERT INTO edges (
13200 edge_id, ref_id, source_node, target_node, target_file,
13201 target_symbol, kind, line, provenance
13202 ) VALUES (
13203 'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
13204 ?2, ?3, 'call', 98, ?4
13205 )",
13206 rusqlite::params![
13207 &root.node_id,
13208 &leaf.file,
13209 &leaf.symbol,
13210 PROVENANCE_TREESITTER
13211 ],
13212 )
13213 .expect("insert dangling forward edge");
13214 conn.execute(
13215 "INSERT INTO edges (
13216 edge_id, ref_id, source_node, target_node, target_file,
13217 target_symbol, kind, line, provenance
13218 ) VALUES (
13219 'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
13220 ?1, ?2, ?3, 'call', 99, ?4
13221 )",
13222 rusqlite::params![
13223 &root.node_id,
13224 &root.file,
13225 &root.symbol,
13226 PROVENANCE_TREESITTER
13227 ],
13228 )
13229 .expect("insert dangling direct-caller edge");
13230
13231 let full_forward_len = forward_calls_for_node(&conn, &root)
13232 .expect("full forward calls")
13233 .len();
13234 let counted_forward_len =
13235 forward_call_count_for_node(&conn, &root).expect("counted forward calls");
13236 assert_eq!(
13237 counted_forward_len, full_forward_len,
13238 "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
13239 );
13240
13241 let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
13242 .expect("full direct callers");
13243 let full_direct_len = full_direct.len();
13244 let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
13245 .expect("counted direct callers");
13246 assert_eq!(
13247 counted_direct_len, full_direct_len,
13248 "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
13249 );
13250
13251 let distinct_direct_len = full_direct
13252 .iter()
13253 .map(|site| {
13254 (
13255 site.caller.file.clone(),
13256 site.line,
13257 site.target_file.clone(),
13258 site.target_symbol.clone(),
13259 )
13260 })
13261 .collect::<BTreeSet<_>>()
13262 .len();
13263 let batch_counts = direct_caller_counts_for_tuples(
13264 &conn,
13265 &[
13266 (root.file.clone(), root.symbol.clone()),
13267 (root.file.clone(), root.symbol.clone()),
13268 (leaf.file.clone(), leaf.symbol.clone()),
13269 ],
13270 )
13271 .expect("batched direct-caller counts");
13272 assert_eq!(batch_counts.len(), 2);
13273 assert_eq!(
13274 batch_counts.get(&(root.file.clone(), root.symbol.clone())),
13275 Some(&distinct_direct_len)
13276 );
13277
13278 (full_forward_len, full_direct_len)
13279 };
13280
13281 assert_eq!(
13282 full_forward_len, 2,
13283 "fixture root should have one resolved and one unresolved outgoing call"
13284 );
13285 assert_eq!(
13286 full_direct_len, 2,
13287 "fixture root should have two real direct callers"
13288 );
13289
13290 let tree = store
13291 .call_tree(Path::new("main.ts"), "root", 0)
13292 .expect("call tree");
13293 assert!(tree.depth_limited);
13294 assert_eq!(tree.children.len(), 0);
13295 assert_eq!(
13296 tree.truncated, full_forward_len,
13297 "call_tree depth boundary must report the full forward-call list length"
13298 );
13299
13300 let callers = store
13301 .callers_of(Path::new("main.ts"), "leaf", 0)
13302 .expect("callers");
13303 assert!(callers.depth_limited);
13304 assert_eq!(callers.callers.len(), 1);
13305 assert_eq!(callers.callers[0].caller.symbol, "root");
13306 assert_eq!(
13307 callers.truncated, full_direct_len,
13308 "callers depth boundary must report the full direct-caller list length"
13309 );
13310 }
13311
13312 #[test]
13313 fn source_freshness_matches_cache_collect_for_same_bytes() {
13314 let dir = tempdir().expect("temp dir");
13315 let path = dir.path().join("fixture.ts");
13316 let source = "export function main() { return helper(); }\n";
13317 fs::write(&path, source).expect("write fixture");
13318
13319 let expected = cache_freshness::collect(&path).expect("collect freshness from file");
13320 let actual =
13321 collect_source_freshness(&path, source).expect("collect freshness from source");
13322
13323 assert_eq!(actual, expected);
13324 }
13325
13326 #[test]
13327 fn superseded_cold_build_cannot_publish_after_newer_epoch() {
13328 let root = tempfile::tempdir().unwrap();
13329 let callgraph_dir = tempfile::tempdir().unwrap();
13330 let source_dir = root.path().join("src");
13331 std::fs::create_dir_all(&source_dir).unwrap();
13332 let source = source_dir.join("lib.rs");
13333 std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
13334 let files = vec![source.clone()];
13335 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
13336 let old_epoch = epoch.next();
13337 let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
13338 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
13339 let old_epoch_flag = epoch.clone();
13340 let old_dir = callgraph_dir.path().to_path_buf();
13341 let old_root = root.path().to_path_buf();
13342 let old_files = files.clone();
13343 let old = std::thread::spawn(move || {
13344 set_cold_build_before_publish_observer(Some(Arc::new(move || {
13345 reached_tx.send(()).unwrap();
13346 release_rx.recv().unwrap();
13347 })));
13348 let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
13349 CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
13350 });
13351 set_cold_build_before_publish_observer(None);
13352 result
13353 });
13354 reached_rx
13358 .recv_timeout(Duration::from_secs(30))
13359 .expect("older build did not reach its publication barrier");
13360
13361 std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
13362 let new_epoch = epoch.next();
13363 let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
13364 CallGraphStore::cold_build_with_lease(
13365 callgraph_dir.path().to_path_buf(),
13366 root.path().to_path_buf(),
13367 &files,
13368 )
13369 })
13370 .expect("newer build should publish");
13371 drop(new_store);
13372
13373 release_tx.send(()).unwrap();
13374 assert!(matches!(
13375 old.join().unwrap(),
13376 Err(CallGraphStoreError::Superseded)
13377 ));
13378
13379 let current = CallGraphStore::open_readonly(
13380 callgraph_dir.path().to_path_buf(),
13381 root.path().to_path_buf(),
13382 )
13383 .unwrap()
13384 .expect("current callgraph generation");
13385 assert_eq!(
13386 current
13387 .nodes_matching("new_generation_marker")
13388 .unwrap()
13389 .len(),
13390 1
13391 );
13392 assert!(current
13393 .nodes_matching("old_generation_marker")
13394 .unwrap()
13395 .is_empty());
13396 }
13397
13398 #[test]
13399 fn cold_build_prepared_bulk_insert_matches_reference_rows() {
13400 let dir = tempdir().expect("temp dir");
13401 let project_root = dir.path();
13402 let extract = fixture_extract(project_root);
13403 let resolved = fixture_resolved(&extract);
13404
13405 let reference = build_reference_connection(project_root, &extract, &resolved);
13406 let optimized = build_optimized_connection(project_root, &extract, &resolved);
13407
13408 for table in [
13409 "files",
13410 "nodes",
13411 "file_dependencies",
13412 "dispatch_hints",
13413 "refs",
13414 "edges",
13415 ] {
13416 let excluded: &[&str] = if table == "files" {
13423 &["indexed_at"]
13424 } else {
13425 &[]
13426 };
13427 assert_eq!(
13428 table_rows_without(&reference, table, excluded),
13429 table_rows_without(&optimized, table, excluded),
13430 "table `{table}` rows must match apart from wall-clock columns"
13431 );
13432 }
13433 assert_eq!(
13434 backend_state_rows(&reference),
13435 backend_state_rows(&optimized),
13436 "backend freshness rows must match apart from updated_at"
13437 );
13438 assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
13439 }
13440
13441 #[test]
13442 fn cold_build_chunked_matches_unchunked_logical_rows() {
13443 let dir = tempdir().expect("temp dir");
13444 let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
13445 write_chunked_equivalence_fixture(&project_root);
13446 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
13447 assert!(
13448 files.len() > 6,
13449 "fixture should be large enough to split into multiple chunks"
13450 );
13451
13452 let unchunked = CallGraphStore::open(
13453 project_root.join(".store-unchunked"),
13454 project_root.to_path_buf(),
13455 )
13456 .expect("open unchunked store");
13457 let unchunked_stats = unchunked
13458 .cold_build_chunked(&files, 0)
13459 .expect("unchunked cold build");
13460
13461 let chunked = CallGraphStore::open(
13462 project_root.join(".store-chunked"),
13463 project_root.to_path_buf(),
13464 )
13465 .expect("open chunked store");
13466 let chunked_stats = chunked
13467 .cold_build_chunked(&files, 3)
13468 .expect("chunked cold build");
13469
13470 assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
13471 assert_eq!(
13472 unchunked.edge_snapshot().expect("unchunked edge snapshot"),
13473 chunked.edge_snapshot().expect("chunked edge snapshot"),
13474 "public edge snapshots must match"
13475 );
13476
13477 let dispatch_edges = {
13478 let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
13479 conn.query_row(
13480 "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
13481 [],
13482 |row| row.get::<_, i64>(0),
13483 )
13484 .expect("count dispatch edges")
13485 };
13486 assert!(
13487 dispatch_edges > 0,
13488 "fixture must exercise method-dispatch edge insertion"
13489 );
13490
13491 for table in [
13492 "edges",
13493 "refs",
13494 "nodes",
13495 "file_dependencies",
13496 "dispatch_hints",
13497 ] {
13498 assert_eq!(
13499 graph_table_rows(&unchunked, table),
13500 graph_table_rows(&chunked, table),
13501 "chunked cold build must match unchunked rows for {table}"
13502 );
13503 }
13504 assert_eq!(
13505 graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
13506 graph_table_rows_without(&chunked, "files", &["indexed_at"]),
13507 "files rows must match apart from indexed_at"
13508 );
13509 assert_eq!(
13510 graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
13511 graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
13512 "backend freshness rows must match apart from updated_at"
13513 );
13514
13515 let published_dir = project_root.join(".store-published");
13516 let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
13517 published_dir.clone(),
13518 project_root.to_path_buf(),
13519 &files,
13520 0,
13521 )
13522 .expect("published unchunked cold build");
13523 assert!(
13524 !CallGraphStore::needs_cold_build(&published_dir, &project_root)
13525 .expect("needs_cold_build after publish"),
13526 "published store should be ready"
13527 );
13528 drop(_published);
13529 let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
13530 published_dir,
13531 project_root.to_path_buf(),
13532 &files,
13533 3,
13534 )
13535 .expect("ensure with a different chunk size");
13536 assert!(
13537 rebuild_stats.is_none(),
13538 "changing callgraph_chunk_size must not affect store identity or force a rebuild"
13539 );
13540 }
13541
13542 #[test]
13549 #[ignore]
13550 fn bench_cold_build_chunk() {
13551 let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
13552 let chunk: usize = std::env::var("AFT_PERF_CHUNK")
13553 .expect("AFT_PERF_CHUNK")
13554 .parse()
13555 .expect("AFT_PERF_CHUNK must be a non-negative integer");
13556 let project_root = fs::canonicalize(&repo).expect("canonical repo root");
13557 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
13558 let dir = tempdir().expect("temp dir");
13559 let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
13560 .expect("open store");
13561 let started = Instant::now();
13562 let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
13563 let ms = started.elapsed().as_millis();
13564 println!(
13565 "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
13566 stats.files, stats.nodes, stats.refs, stats.edges
13567 );
13568 }
13569
13570 #[test]
13571 fn persisted_workspace_reexport_selects_its_package_dependency() {
13572 let root = tempdir().expect("temp dir");
13573 let dependencies = BTreeSet::from([
13574 "packages/aft-bridge/src/index.ts".to_string(),
13575 "packages/opencode-plugin/src/types.ts".to_string(),
13576 ]);
13577 let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
13578
13579 assert_eq!(
13580 stored_dependencies_for_module(
13581 root.path(),
13582 "packages/opencode-plugin/src/shared/bash-hints.ts",
13583 "@cortexkit/aft-bridge",
13584 &dependencies,
13585 &indexed_files,
13586 ),
13587 BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
13588 );
13589 }
13590
13591 #[test]
13592 fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
13593 let dir = tempdir().expect("temp dir");
13594 let project_root = dir.path();
13595 let files =
13596 write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
13597 let index_path = project_root.join("src/index.ts");
13598
13599 let store = CallGraphStore::open(
13600 project_root.join(".store-incremental-barrel"),
13601 project_root.to_path_buf(),
13602 )
13603 .expect("open incremental store");
13604 store.cold_build(&files).expect("initial cold build");
13605
13606 {
13607 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13608 let tx = conn.transaction().expect("dependency transaction");
13609 let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
13610 .expect("dependent refs for barrel");
13611 let selected_ref_ids = dependent_refs
13612 .iter()
13613 .map(|dependent_ref| dependent_ref.ref_id.clone())
13614 .collect::<BTreeSet<_>>();
13615 let mut threaded_ref_ids = BTreeSet::new();
13616 let mut threaded_by_caller = BTreeMap::new();
13617 record_dependent_refs(
13618 &mut threaded_ref_ids,
13619 &mut threaded_by_caller,
13620 dependent_refs,
13621 );
13622 let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
13623 .expect("old per-ref caller lookup");
13624
13625 assert_eq!(threaded_ref_ids, selected_ref_ids);
13626 assert_eq!(threaded_by_caller, old_by_caller);
13627 for consumer in [
13628 "src/consumer_a.ts",
13629 "src/consumer_b.ts",
13630 "src/consumer_c.ts",
13631 ] {
13632 assert!(
13633 threaded_by_caller.contains_key(consumer),
13634 "barrel edit should select dependent refs from {consumer}"
13635 );
13636 }
13637 }
13638
13639 fs::write(
13640 &index_path,
13641 "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
13642 )
13643 .expect("edit barrel");
13644 let stats = store
13645 .refresh_files(std::slice::from_ref(&index_path))
13646 .expect("incremental refresh");
13647 assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
13648 assert!(
13649 stats.dependency_selected_refs > 0,
13650 "barrel surface edit should select dependent refs"
13651 );
13652
13653 let cold_store = CallGraphStore::open(
13654 project_root.join(".store-cold-barrel"),
13655 project_root.to_path_buf(),
13656 )
13657 .expect("open cold rebuild store");
13658 cold_store
13659 .cold_build(&files)
13660 .expect("comparison cold build");
13661
13662 for table in [
13663 "nodes",
13664 "refs",
13665 "file_dependencies",
13666 "edges",
13667 "dispatch_hints",
13668 ] {
13669 assert_eq!(
13670 graph_table_rows(&store, table),
13671 graph_table_rows(&cold_store, table),
13672 "incremental refresh {table} rows must match cold rebuild"
13673 );
13674 }
13675
13676 let consumer_path = project_root.join("src/consumer_a.ts");
13677 fs::write(
13678 &consumer_path,
13679 "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
13680 )
13681 .expect("edit barrel consumer");
13682 store
13683 .refresh_files(std::slice::from_ref(&consumer_path))
13684 .expect("refresh consumer through unchanged barrel");
13685 cold_store
13686 .cold_build(&files)
13687 .expect("comparison cold rebuild after consumer refresh");
13688 for table in [
13689 "nodes",
13690 "refs",
13691 "file_dependencies",
13692 "edges",
13693 "dispatch_hints",
13694 ] {
13695 assert_eq!(
13696 graph_table_rows(&store, table),
13697 graph_table_rows(&cold_store, table),
13698 "refresh through a persisted barrel must preserve cold-build {table} rows"
13699 );
13700 }
13701 }
13702
13703 fn build_reference_connection(
13704 project_root: &Path,
13705 extract: &FileExtract,
13706 resolved: &ResolvedRef,
13707 ) -> Connection {
13708 let mut conn = Connection::open_in_memory().expect("open reference db");
13709 configure_build_connection(&conn).expect("configure reference db");
13710 initialize_schema(&conn).expect("initialize reference schema");
13711 {
13712 let tx = conn.transaction().expect("reference transaction");
13713 clear_tables(&tx).expect("reference clear");
13714 insert_meta(&tx).expect("reference meta");
13715 insert_file_extract(&tx, project_root, extract).expect("reference file extract");
13716 insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
13717 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
13718 .expect("reference dispatch edges");
13719 assert_eq!(supplemental, 0);
13720 tx.commit().expect("reference commit");
13721 }
13722 conn
13723 }
13724
13725 fn build_optimized_connection(
13726 project_root: &Path,
13727 extract: &FileExtract,
13728 resolved: &ResolvedRef,
13729 ) -> Connection {
13730 let mut conn = Connection::open_in_memory().expect("open optimized db");
13731 configure_build_connection(&conn).expect("configure optimized db");
13732 initialize_schema(&conn).expect("initialize optimized schema");
13733 {
13734 let tx = conn.transaction().expect("optimized transaction");
13735 clear_tables(&tx).expect("optimized clear");
13736 insert_meta(&tx).expect("optimized meta");
13737 drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
13738 {
13739 let workspace_root = project_root.display().to_string();
13740 let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
13741 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
13742 .expect("optimized file extract");
13743 insert_resolved_ref_prepared(&mut inserts, resolved)
13744 .expect("optimized resolved ref");
13745 }
13746 create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
13747 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
13748 .expect("optimized dispatch edges");
13749 assert_eq!(supplemental, 0);
13750 tx.commit().expect("optimized commit");
13751 }
13752 conn
13753 }
13754
13755 fn fixture_extract(_project_root: &Path) -> FileExtract {
13756 let rel_path = "src/main.ts".to_string();
13757 let target_path = "src/helper.ts".to_string();
13758 let node = NodeRecord {
13759 id: "node-main".to_string(),
13760 file_path: rel_path.clone(),
13761 name: "main".to_string(),
13762 scoped_name: "main".to_string(),
13763 kind: "function".to_string(),
13764 range: Range {
13765 start_line: 0,
13766 start_col: 0,
13767 end_line: 0,
13768 end_col: 32,
13769 },
13770 range_ordinal: 0,
13771 signature: Some("export function main()".to_string()),
13772 exported: true,
13773 is_default_export: false,
13774 is_type_like: false,
13775 is_callgraph_entry_point: true,
13776 };
13777 let mut dependencies = BTreeSet::new();
13778 dependencies.insert(target_path.clone());
13779 let raw_ref = RawRef {
13780 ref_id: "ref-main-helper".to_string(),
13781 caller_node: Some(node.id.clone()),
13782 caller_symbol: Some(node.scoped_name.clone()),
13783 caller_file: rel_path.clone(),
13784 kind: "call".to_string(),
13785 short_name: Some("helper".to_string()),
13786 full_ref: Some("helper".to_string()),
13787 module_path: None,
13788 import_kind: None,
13789 local_name: Some("helper".to_string()),
13790 requested_name: Some("helper".to_string()),
13791 namespace_alias: None,
13792 wildcard: false,
13793 line: 1,
13794 byte_start: 24,
13795 byte_end: 32,
13796 dependencies,
13797 };
13798 FileExtract {
13799 rel_path,
13800 freshness: FileFreshness {
13801 mtime: UNIX_EPOCH + Duration::from_secs(123),
13802 size: 40,
13803 content_hash: cache_freshness::hash_bytes(b"fixture source"),
13804 },
13805 lang: LangId::TypeScript,
13806 data: FileCallData {
13807 calls_by_symbol: HashMap::new(),
13808 exported_symbols: Vec::new(),
13809 symbol_metadata: HashMap::new(),
13810 default_export_symbol: None,
13811 import_block: ImportBlock::empty(),
13812 lang: LangId::TypeScript,
13813 },
13814 nodes: vec![node.clone()],
13815 raw_refs: vec![raw_ref],
13816 dispatch_hints: vec![DispatchHint {
13817 id: "dispatch-main-helper".to_string(),
13818 method_name: "helper".to_string(),
13819 caller_node: node.id,
13820 file: "src/main.ts".to_string(),
13821 line: 1,
13822 byte_start: 24,
13823 byte_end: 32,
13824 }],
13825 surface_fingerprint: "surface".to_string(),
13826 }
13827 }
13828
13829 fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
13830 let raw = extract.raw_refs[0].clone();
13831 let mut dependencies = raw.dependencies.clone();
13832 dependencies.insert("src/helper.ts".to_string());
13833 ResolvedRef {
13834 edge: Some(EdgeRecord {
13835 edge_id: "edge-main-helper".to_string(),
13836 source_node: raw.caller_node.clone().expect("caller node"),
13837 target_node: Some("node-helper".to_string()),
13838 target_file: "src/helper.ts".to_string(),
13839 target_symbol: "helper".to_string(),
13840 kind: "call".to_string(),
13841 line: raw.line,
13842 }),
13843 raw,
13844 status: "resolved".to_string(),
13845 target_node: Some("node-helper".to_string()),
13846 target_file: Some("src/helper.ts".to_string()),
13847 target_symbol: Some("helper".to_string()),
13848 dependencies,
13849 }
13850 }
13851
13852 fn write_chunked_equivalence_fixture(project_root: &Path) {
13853 let ts_dir = project_root.join("ts");
13854 fs::create_dir_all(&ts_dir).expect("create ts dir");
13855 fs::write(
13856 ts_dir.join("leaf.ts"),
13857 "export function leaf(value: number) {\n return value + 1;\n}\n",
13858 )
13859 .expect("write ts leaf");
13860 fs::write(
13861 ts_dir.join("mid.ts"),
13862 "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n return leaf(value);\n}\n",
13863 )
13864 .expect("write ts mid");
13865 fs::write(
13866 ts_dir.join("entry.ts"),
13867 "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n return mid(worker.run());\n}\n",
13868 )
13869 .expect("write ts entry");
13870 fs::write(
13871 ts_dir.join("worker.ts"),
13872 "export class Worker {\n run() {\n return 41;\n }\n}\n",
13873 )
13874 .expect("write ts worker");
13875 for idx in 0..4 {
13876 fs::write(
13877 ts_dir.join(format!("extra_{idx}.ts")),
13878 format!(
13879 "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n return entry(new Worker());\n}}\n"
13880 ),
13881 )
13882 .expect("write ts extra");
13883 }
13884
13885 let rust_dir = project_root.join("src");
13886 let commands_dir = rust_dir.join("commands");
13887 fs::create_dir_all(&commands_dir).expect("create rust commands dir");
13888 fs::write(
13889 rust_dir.join("context.rs"),
13890 r#"pub struct AppContext;
13891
13892impl AppContext {
13893 pub fn callgraph_store_for_ops(&self) -> usize {
13894 1
13895 }
13896}
13897"#,
13898 )
13899 .expect("write rust context");
13900 fs::write(
13901 rust_dir.join("lib.rs"),
13902 "pub mod context;\npub mod commands;\n",
13903 )
13904 .expect("write rust lib");
13905 fs::write(
13906 commands_dir.join("mod.rs"),
13907 "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
13908 )
13909 .expect("write rust commands mod");
13910 for name in ["callers", "impact", "trace_to"] {
13911 fs::write(
13912 commands_dir.join(format!("{name}.rs")),
13913 format!(
13914 r#"use crate::context::AppContext;
13915
13916pub fn handle_{name}(ctx: &AppContext) -> usize {{
13917 ctx.callgraph_store_for_ops()
13918}}
13919"#
13920 ),
13921 )
13922 .expect("write rust command");
13923 }
13924 }
13925
13926 fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
13927 let src_dir = project_root.join("src");
13928 fs::create_dir_all(&src_dir).expect("create src dir");
13929
13930 let target_path = src_dir.join("target.ts");
13931 fs::write(&target_path, "export function target() {\n return 1;\n}\n")
13932 .expect("write target");
13933
13934 let index_path = src_dir.join("index.ts");
13935 fs::write(&index_path, barrel_source).expect("write barrel");
13936
13937 let mut files = vec![target_path, index_path];
13938 for (file_name, function_name) in [
13939 ("consumer_a.ts", "consumerA"),
13940 ("consumer_b.ts", "consumerB"),
13941 ("consumer_c.ts", "consumerC"),
13942 ] {
13943 let path = src_dir.join(file_name);
13944 fs::write(
13945 &path,
13946 format!(
13947 "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n return target();\n}}\n"
13948 ),
13949 )
13950 .expect("write consumer");
13951 files.push(path);
13952 }
13953 files
13954 }
13955
13956 fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
13957 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
13958 table_rows(&conn, table)
13959 }
13960
13961 fn graph_table_rows_without(
13962 store: &CallGraphStore,
13963 table: &str,
13964 excluded_columns: &[&str],
13965 ) -> Vec<String> {
13966 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
13967 table_rows_without(&conn, table, excluded_columns)
13968 }
13969
13970 fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
13971 table_rows_without(conn, table, &[])
13972 }
13973
13974 fn table_rows_without(
13975 conn: &Connection,
13976 table: &str,
13977 excluded_columns: &[&str],
13978 ) -> Vec<String> {
13979 let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
13980 let columns: Vec<String> = conn
13981 .prepare(&format!("PRAGMA table_info({table})"))
13982 .expect("prepare table_info")
13983 .query_map([], |row| row.get::<_, String>(1))
13984 .expect("query table_info")
13985 .collect::<std::result::Result<Vec<String>, _>>()
13986 .expect("collect columns")
13987 .into_iter()
13988 .filter(|column| !excluded_columns.contains(column.as_str()))
13989 .collect();
13990 let sql = format!(
13991 "SELECT {} FROM {table} ORDER BY {}",
13992 columns.join(", "),
13993 columns.join(", ")
13994 );
13995 conn.prepare(&sql)
13996 .expect("prepare table rows")
13997 .query_map([], |row| row_to_strings(row, columns.len()))
13998 .expect("query table rows")
13999 .collect::<std::result::Result<_, _>>()
14000 .expect("collect table rows")
14001 }
14002
14003 fn assert_cold_build_stats_match_except_elapsed(
14004 expected: &ColdBuildStats,
14005 actual: &ColdBuildStats,
14006 ) {
14007 assert_eq!(actual.files, expected.files, "file counts must match");
14008 assert_eq!(actual.nodes, expected.nodes, "node counts must match");
14009 assert_eq!(actual.refs, expected.refs, "ref counts must match");
14010 assert_eq!(actual.edges, expected.edges, "edge counts must match");
14011 assert_eq!(
14012 actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
14013 expected
14014 .failed_files
14015 .iter()
14016 .cloned()
14017 .collect::<BTreeSet<_>>(),
14018 "failed file sets must match"
14019 );
14020 }
14021
14022 fn backend_state_rows(conn: &Connection) -> Vec<String> {
14023 conn.prepare(
14024 "SELECT backend, workspace_root, file_path, content_hash, status
14025 FROM backend_file_state
14026 ORDER BY backend, workspace_root, file_path, content_hash, status",
14027 )
14028 .expect("prepare backend rows")
14029 .query_map([], |row| row_to_strings(row, 5))
14030 .expect("query backend rows")
14031 .collect::<std::result::Result<_, _>>()
14032 .expect("collect backend rows")
14033 }
14034
14035 fn secondary_indexes(conn: &Connection) -> Vec<String> {
14036 let mut indexes = Vec::new();
14037 for table in [
14038 "files",
14039 "nodes",
14040 "refs",
14041 "file_dependencies",
14042 "edges",
14043 "dispatch_hints",
14044 "type_ref_names",
14045 "backend_file_state",
14046 "meta",
14047 ] {
14048 let sql = format!("PRAGMA index_list({table})");
14049 let mut stmt = conn.prepare(&sql).expect("prepare index list");
14050 let rows = stmt
14051 .query_map([], |row| row.get::<_, String>(1))
14052 .expect("query index list");
14053 for name in rows {
14054 let name = name.expect("index name");
14055 if name.starts_with("idx_") {
14056 indexes.push(format!("{table}:{name}"));
14057 }
14058 }
14059 }
14060 indexes.sort();
14061 indexes
14062 }
14063
14064 fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
14065 let mut values = Vec::with_capacity(len);
14066 for index in 0..len {
14067 let value = row.get_ref(index)?;
14068 values.push(match value {
14069 rusqlite::types::ValueRef::Null => "NULL".to_string(),
14070 rusqlite::types::ValueRef::Integer(value) => value.to_string(),
14071 rusqlite::types::ValueRef::Real(value) => value.to_string(),
14072 rusqlite::types::ValueRef::Text(value) => {
14073 String::from_utf8_lossy(value).into_owned()
14074 }
14075 rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
14076 });
14077 }
14078 Ok(values.join("\u{1f}"))
14079 }
14080}
14081
14082#[cfg(test)]
14083mod rust_resolution_tests {
14084 use super::*;
14085 use crate::inspect::job::CallgraphSnapshot;
14086 use std::fs;
14087 use tempfile::tempdir;
14088
14089 #[test]
14090 fn rust_function_scoped_module_alias_resolves_and_projects_live() {
14091 let dir = tempdir().expect("tempdir");
14092 let root = dir.path();
14093 write_rust_manifest(root, "scoped-alias-fixture");
14094 write_file(
14095 root,
14096 "src/lib.rs",
14097 r#"pub mod finalization_contract;
14098
14099pub fn run_alias() {
14100 use crate::finalization_contract as fc;
14101 fc::check_mason_contract();
14102}
14103"#,
14104 );
14105 write_file(
14106 root,
14107 "src/finalization_contract.rs",
14108 r#"pub fn check_mason_contract() {}
14109fn planted_dead() {}
14110"#,
14111 );
14112
14113 let (store, snapshot) = cold_build_twice(root);
14114 assert_direct_caller(
14115 &store,
14116 "src/finalization_contract.rs",
14117 "check_mason_contract",
14118 "src/lib.rs",
14119 "run_alias",
14120 );
14121 assert_projected_call(
14122 root,
14123 &snapshot,
14124 "src/finalization_contract.rs",
14125 "check_mason_contract",
14126 );
14127 assert_no_projected_call(
14128 root,
14129 &snapshot,
14130 "src/finalization_contract.rs",
14131 "planted_dead",
14132 );
14133 assert!(
14134 store
14135 .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
14136 .expect("planted dead callers")
14137 .is_empty(),
14138 "planted-dead guard should stay without callers"
14139 );
14140 }
14141
14142 #[test]
14143 fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
14144 let dir = tempdir().expect("tempdir");
14145 let root = dir.path();
14146 write_rust_manifest(root, "inline-module-fixture");
14147 write_file(
14148 root,
14149 "src/lib.rs",
14150 r#"mod work_graph { fn operations() {} }
14151mod manifest { fn operations() {} }
14152mod audit { fn operations() {} }
14153mod dispatch { fn operations() {} }
14154mod finalization { fn operations() {} }
14155
14156pub fn run_inline_operations() {
14157 work_graph::operations();
14158 manifest::operations();
14159 audit::operations();
14160 dispatch::operations();
14161 finalization::operations();
14162}
14163
14164fn planted_dead() {}
14165"#,
14166 );
14167
14168 let (store, snapshot) = cold_build_twice(root);
14169 for module in [
14170 "work_graph",
14171 "manifest",
14172 "audit",
14173 "dispatch",
14174 "finalization",
14175 ] {
14176 assert_direct_caller(
14177 &store,
14178 "src/lib.rs",
14179 &format!("{module}::operations"),
14180 "src/lib.rs",
14181 "run_inline_operations",
14182 );
14183 }
14184 assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
14185 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
14186 }
14187
14188 #[test]
14189 fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
14190 let dir = tempdir().expect("tempdir");
14191 let root = dir.path();
14192 fs::write(
14193 root.join("Cargo.toml"),
14194 "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
14195 )
14196 .expect("write workspace manifest");
14197 write_file(
14198 root,
14199 "crates/but-action/Cargo.toml",
14200 r#"[package]
14201name = "but-action"
14202version = "0.1.0"
14203edition = "2021"
14204"#,
14205 );
14206 write_file(
14207 root,
14208 "crates/but-action/src/lib.rs",
14209 "mod action;\npub use action::{list_actions};\n",
14210 );
14211 write_file(
14212 root,
14213 "crates/but-action/src/action.rs",
14214 "pub fn list_actions() {}\nfn planted_dead() {}\n",
14215 );
14216 write_file(
14217 root,
14218 "crates/app/Cargo.toml",
14219 r#"[package]
14220name = "app"
14221version = "0.1.0"
14222edition = "2021"
14223"#,
14224 );
14225 write_file(
14226 root,
14227 "crates/app/src/lib.rs",
14228 "pub fn run_actions() {\n but_action::list_actions();\n}\n",
14229 );
14230
14231 let (store, snapshot) = cold_build_twice(root);
14232 assert_direct_caller(
14233 &store,
14234 "crates/but-action/src/action.rs",
14235 "list_actions",
14236 "crates/app/src/lib.rs",
14237 "run_actions",
14238 );
14239 assert!(
14240 store
14241 .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
14242 .expect("lib reexport callers")
14243 .is_empty(),
14244 "call should target the reexported source function, not lib.rs"
14245 );
14246 assert_projected_call(
14247 root,
14248 &snapshot,
14249 "crates/but-action/src/action.rs",
14250 "list_actions",
14251 );
14252 assert_no_projected_call(
14253 root,
14254 &snapshot,
14255 "crates/but-action/src/action.rs",
14256 "planted_dead",
14257 );
14258 }
14259
14260 #[test]
14261 fn rust_generic_self_turbofish_method_dispatch_resolves() {
14262 let dir = tempdir().expect("tempdir");
14263 let root = dir.path();
14264 write_rust_manifest(root, "generic-self-fixture");
14265 write_file(
14266 root,
14267 "src/lib.rs",
14268 r#"pub struct Matcher;
14269
14270impl Matcher {
14271 pub fn run(&self) -> bool {
14272 self.fuzzy_match_optimal::<usize>("needle")
14273 }
14274
14275 fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
14276 let _ = std::marker::PhantomData::<T>;
14277 true
14278 }
14279
14280 fn planted_dead(&self) {}
14281}
14282
14283pub fn entry() -> bool {
14284 let matcher = Matcher;
14285 matcher.run()
14286}
14287"#,
14288 );
14289
14290 let (store, snapshot) = cold_build_twice(root);
14291 assert_direct_caller(
14292 &store,
14293 "src/lib.rs",
14294 "Matcher::fuzzy_match_optimal",
14295 "src/lib.rs",
14296 "Matcher::run",
14297 );
14298 assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
14299 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
14300 }
14301
14302 #[test]
14303 fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
14304 let dir = tempdir().expect("tempdir");
14305 let root = dir.path();
14306 write_rust_manifest(root, "manifest-operations-fixture");
14307 write_file(
14308 root,
14309 "src/main.rs",
14310 r#"mod dispatch;
14311use dispatch::{manifest_operations};
14312
14313fn main() {
14314 manifest_operations();
14315}
14316"#,
14317 );
14318 write_file(
14319 root,
14320 "src/dispatch.rs",
14321 r#"mod work_graph { fn operations() {} }
14322mod manifest { fn operations() {} }
14323mod audit { fn operations() {} }
14324mod descriptor { fn operations() {} }
14325mod writer { fn operations() {} }
14326
14327pub fn manifest_operations() {
14328 manifest::operations();
14329}
14330
14331pub fn work_graph_operations() {
14332 work_graph::operations();
14333}
14334
14335pub fn audit_operations() {
14336 audit::operations();
14337}
14338
14339pub fn descriptor_operations() {
14340 descriptor::operations();
14341}
14342
14343pub fn writer_operations() {
14344 writer::operations();
14345}
14346
14347fn planted_dead() {}
14348"#,
14349 );
14350
14351 let (store, snapshot) = cold_build_twice(root);
14352 assert_direct_caller(
14353 &store,
14354 "src/dispatch.rs",
14355 "manifest_operations",
14356 "src/main.rs",
14357 "main",
14358 );
14359 assert_direct_caller(
14360 &store,
14361 "src/dispatch.rs",
14362 "manifest::operations",
14363 "src/dispatch.rs",
14364 "manifest_operations",
14365 );
14366 assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
14367 assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
14368 assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
14369 }
14370
14371 fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
14372 let files = rust_files(root);
14373 let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
14374 .expect("open first store");
14375 first.cold_build(&files).expect("first cold build");
14376 let first_snapshot =
14377 project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
14378
14379 let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
14380 .expect("open second store");
14381 second.cold_build(&files).expect("second cold build");
14382 let second_snapshot =
14383 project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
14384
14385 assert_eq!(
14386 projection_rows(&first_snapshot),
14387 projection_rows(&second_snapshot),
14388 "cold-build projection should be deterministic"
14389 );
14390 (first, first_snapshot)
14391 }
14392
14393 fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
14394 let mut rows = Vec::new();
14395 for export in &snapshot.exported_symbols {
14396 rows.push(format!(
14397 "export\t{}\t{}\t{}\t{}",
14398 export.file.display(),
14399 export.symbol,
14400 export.kind,
14401 export.line
14402 ));
14403 }
14404 for call in &snapshot.outbound_calls {
14405 rows.push(format!(
14406 "call\t{}\t{}\t{}\t{}\t{}",
14407 call.caller_file.display(),
14408 call.caller_symbol,
14409 call.target,
14410 call.line,
14411 call.provenance
14412 ));
14413 }
14414 for file in &snapshot.entry_points {
14415 rows.push(format!("entry_file\t{}", file.display()));
14416 }
14417 for (file, symbols) in &snapshot.entry_point_symbols {
14418 for symbol in symbols {
14419 rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
14420 }
14421 }
14422 rows.sort();
14423 rows
14424 }
14425
14426 fn assert_direct_caller(
14427 store: &CallGraphStore,
14428 target_rel: &str,
14429 target_symbol: &str,
14430 caller_rel: &str,
14431 caller_symbol: &str,
14432 ) {
14433 let callers = store
14434 .direct_callers_of(Path::new(target_rel), target_symbol)
14435 .unwrap_or_else(|error| {
14436 panic!("direct callers for {target_rel}::{target_symbol}: {error}")
14437 });
14438 assert!(
14439 callers.iter().any(|site| {
14440 site.caller.file == caller_rel && site.caller.symbol == caller_symbol
14441 }),
14442 "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
14443 );
14444 }
14445
14446 fn assert_projected_call(
14447 root: &Path,
14448 snapshot: &CallgraphSnapshot,
14449 target_rel: &str,
14450 symbol: &str,
14451 ) {
14452 let target = projected_target(root, target_rel, symbol);
14453 assert!(
14454 snapshot.outbound_calls.iter().any(|call| {
14455 call.target == target
14456 || call.target.starts_with(&format!(
14457 "{target}{}",
14458 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
14459 ))
14460 }),
14461 "expected projected call to {target}; calls: {:#?}",
14462 snapshot.outbound_calls
14463 );
14464 }
14465
14466 fn assert_no_projected_call(
14467 root: &Path,
14468 snapshot: &CallgraphSnapshot,
14469 target_rel: &str,
14470 symbol: &str,
14471 ) {
14472 let target = projected_target(root, target_rel, symbol);
14473 assert!(
14474 snapshot.outbound_calls.iter().all(|call| {
14475 call.target != target
14476 && !call.target.starts_with(&format!(
14477 "{target}{}",
14478 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
14479 ))
14480 }),
14481 "did not expect projected call to {target}; calls: {:#?}",
14482 snapshot.outbound_calls
14483 );
14484 }
14485
14486 fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
14487 let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
14490 format!("{}::{symbol}", path.display())
14491 }
14492
14493 fn write_rust_manifest(root: &Path, name: &str) {
14494 write_file(
14495 root,
14496 "Cargo.toml",
14497 &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
14498 );
14499 }
14500
14501 fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
14502 let path = root.join(rel_path);
14503 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
14504 fs::write(&path, source).expect("write fixture file");
14505 path
14506 }
14507
14508 fn rust_files(root: &Path) -> Vec<PathBuf> {
14509 let mut files = Vec::new();
14510 collect_rust_files(root, &mut files);
14511 files.sort();
14512 files
14513 }
14514
14515 fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
14516 for entry in fs::read_dir(dir).expect("read fixture dir") {
14517 let entry = entry.expect("read fixture entry");
14518 let path = entry.path();
14519 if path.is_dir() {
14520 let name = path
14521 .file_name()
14522 .and_then(|name| name.to_str())
14523 .unwrap_or("");
14524 if !name.starts_with(".store") {
14525 collect_rust_files(&path, files);
14526 }
14527 } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
14528 files.push(path);
14529 }
14530 }
14531 }
14532}
14533
14534#[cfg(test)]
14535mod build_pool_tests {
14536 use super::build_pool_size;
14537
14538 #[test]
14539 fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
14540 let size = build_pool_size();
14541 assert!(size >= 1, "pool size must be at least 1");
14544 assert!(size <= 8, "pool size must be capped at 8, got {size}");
14545
14546 let cores = std::thread::available_parallelism()
14547 .map(|p| p.get())
14548 .unwrap_or(1);
14549 let expected = cores.div_ceil(2).clamp(1, 8);
14550 assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
14551 }
14552}
14553
14554#[cfg(test)]
14555mod reexport_resolution_tests {
14556 use super::*;
14557
14558 fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
14559 ProjectIndex {
14560 project_root: PathBuf::from("/fixture"),
14561 files: files.into_iter().collect(),
14562 caller_data: HashMap::new(),
14563 workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
14564 }
14565 }
14566
14567 fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
14568 DbFileIndex {
14569 lang: None,
14570 exports: HashSet::new(),
14571 default_export: None,
14572 export_aliases: HashMap::new(),
14573 node_by_scoped: HashMap::new(),
14574 node_by_bare: HashMap::new(),
14575 module_targets: HashMap::new(),
14576 reexports: reexport_targets
14577 .iter()
14578 .map(|target| ReexportIndex {
14579 target_file: Some((*target).to_string()),
14580 named: HashMap::new(),
14581 wildcard: true,
14582 })
14583 .collect(),
14584 }
14585 }
14586
14587 #[test]
14594 fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
14595 let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
14596 let files = names
14597 .iter()
14598 .map(|name| {
14599 let targets: Vec<&str> = names
14600 .iter()
14601 .filter(|other| *other != name)
14602 .map(String::as_str)
14603 .collect();
14604 (name.clone(), barrel_file(&targets))
14605 })
14606 .collect();
14607 let index = barrel_index(files);
14608
14609 assert_eq!(
14610 resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
14611 None
14612 );
14613 }
14614
14615 #[test]
14621 fn shallow_revisit_after_deep_capped_visit_still_resolves() {
14622 let mut leaf = barrel_file(&[]);
14623 leaf.exports.insert("deep_symbol".to_string());
14624 let mut files: Vec<(String, DbFileIndex)> = Vec::new();
14625 files.push((
14628 "src/entry.ts".to_string(),
14629 barrel_file(&["src/chain0.ts", "src/shared.ts"]),
14630 ));
14631 for i in 0..15 {
14632 let next = if i == 14 {
14633 "src/shared.ts".to_string()
14634 } else {
14635 format!("src/chain{}.ts", i + 1)
14636 };
14637 files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
14638 }
14639 files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
14640 files.push(("src/leaf.ts".to_string(), leaf));
14641 let index = barrel_index(files);
14642
14643 assert_eq!(
14644 resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
14645 Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
14646 "a shallower re-visit must not be pruned by a deeper capped visit"
14647 );
14648 }
14649
14650 #[test]
14651 fn symbol_reachable_through_reexport_cycle_still_resolves() {
14652 let mut leaf = barrel_file(&[]);
14653 leaf.exports.insert("real_symbol".to_string());
14654 let index = barrel_index(vec![
14655 (
14656 "src/a.ts".to_string(),
14657 barrel_file(&["src/b.ts", "src/a.ts"]),
14658 ),
14659 (
14660 "src/b.ts".to_string(),
14661 barrel_file(&["src/a.ts", "src/leaf.ts"]),
14662 ),
14663 ("src/leaf.ts".to_string(), leaf),
14664 ]);
14665
14666 assert_eq!(
14667 resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
14668 Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
14669 );
14670 }
14671}
14672
14673#[cfg(test)]
14674mod method_dispatch_inference_tests {
14675 use super::*;
14676 use std::fs;
14677 use tempfile::tempdir;
14678
14679 #[test]
14680 fn java_field_receiver_type_selects_declared_class_method() {
14681 let source = r#"class EntryPoint {
14682 private UserService userService;
14683
14684 void handle() {
14685 userService.find();
14686 }
14687}
14688
14689class UserService {
14690 void find() {}
14691}
14692
14693class AuditService {
14694 void find() {}
14695}
14696"#;
14697 let dir = tempdir().expect("temp dir");
14698 let root = dir.path();
14699 write_fixture(root, "src/EntryPoint.java", source);
14700 let reference = reference(
14701 "java",
14702 "src/EntryPoint.java",
14703 "EntryPoint::handle",
14704 "userService",
14705 "find",
14706 line_of(source, "userService.find()"),
14707 );
14708 let mut cache = DispatchSourceCache::new();
14709
14710 let receiver_type =
14711 infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
14712 assert_eq!(receiver_type, "UserService");
14713
14714 let candidates = vec![
14715 method_candidate("audit", "AuditService::find"),
14716 method_candidate("user", "UserService::find"),
14717 ];
14718 let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
14719 .expect("type candidate");
14720 assert_eq!(selected.scoped_name, "UserService::find");
14721
14722 let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
14723 assert!(
14724 select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
14725 );
14726 }
14727
14728 #[test]
14729 fn kotlin_property_and_local_value_types_are_inferred() {
14730 let source = r#"class Handler {
14731 private val auditService: AuditService = AuditService()
14732
14733 fun handle() {
14734 auditService.find()
14735 val userService: UserService = UserService()
14736 userService.find()
14737 val billingService = BillingService()
14738 billingService.find()
14739 }
14740}
14741
14742class UserService { fun find() {} }
14743class AuditService { fun find() {} }
14744class BillingService { fun find() {} }
14745"#;
14746 let dir = tempdir().expect("temp dir");
14747 let root = dir.path();
14748 write_fixture(root, "src/Handler.kt", source);
14749 let mut cache = DispatchSourceCache::new();
14750
14751 let audit_ref = reference(
14752 "kotlin",
14753 "src/Handler.kt",
14754 "Handler::handle",
14755 "auditService",
14756 "find",
14757 line_of(source, "auditService.find()"),
14758 );
14759 assert_eq!(
14760 infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
14761 Some("AuditService")
14762 );
14763
14764 let user_ref = reference(
14765 "kotlin",
14766 "src/Handler.kt",
14767 "Handler::handle",
14768 "userService",
14769 "find",
14770 line_of(source, "userService.find()"),
14771 );
14772 assert_eq!(
14773 infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
14774 Some("UserService")
14775 );
14776
14777 let billing_ref = reference(
14778 "kotlin",
14779 "src/Handler.kt",
14780 "Handler::handle",
14781 "billingService",
14782 "find",
14783 line_of(source, "billingService.find()"),
14784 );
14785 assert_eq!(
14786 infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
14787 Some("BillingService")
14788 );
14789 }
14790
14791 #[test]
14792 fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
14793 let source = r#"struct Foo { void run(); };
14794struct PointerFoo { void run(); };
14795struct FactoryFoo { void run(); };
14796FactoryFoo makeFactoryFoo();
14797
14798void handle() {
14799 Foo foo;
14800 foo.run();
14801 PointerFoo* pointerFoo = nullptr;
14802 pointerFoo->run();
14803 auto factoryFoo = makeFactoryFoo();
14804 factoryFoo.run();
14805}
14806"#;
14807 let dir = tempdir().expect("temp dir");
14808 let root = dir.path();
14809 write_fixture(root, "src/fixture.cpp", source);
14810 let mut cache = DispatchSourceCache::new();
14811
14812 let foo_ref = reference(
14813 "cpp",
14814 "src/fixture.cpp",
14815 "handle",
14816 "foo",
14817 "run",
14818 line_of(source, "foo.run()"),
14819 );
14820 assert_eq!(
14821 infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
14822 Some("Foo")
14823 );
14824
14825 let pointer_ref = reference(
14826 "cpp",
14827 "src/fixture.cpp",
14828 "handle",
14829 "pointerFoo",
14830 "run",
14831 line_of(source, "pointerFoo->run()"),
14832 );
14833 assert_eq!(
14834 infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
14835 Some("PointerFoo")
14836 );
14837
14838 let factory_ref = reference(
14839 "cpp",
14840 "src/fixture.cpp",
14841 "handle",
14842 "factoryFoo",
14843 "run",
14844 line_of(source, "factoryFoo.run()"),
14845 );
14846 assert_eq!(
14847 infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
14848 Some("FactoryFoo")
14849 );
14850 }
14851
14852 #[test]
14853 fn rust_direct_self_field_name_trims_separator_whitespace() {
14854 for receiver_expression in ["self .engine", "self. engine", "self . engine"] {
14855 assert_eq!(
14856 rust_direct_self_field_name(receiver_expression),
14857 Some("engine")
14858 );
14859 }
14860 }
14861
14862 #[test]
14863 fn rust_direct_self_field_receiver_type_is_conservative() {
14864 let source = r#"struct Engine;
14865
14866struct Car {
14867 engine: Engine,
14868}
14869
14870impl Car {
14871 fn run(&self) {
14872 self.engine.start();
14873 }
14874}
14875
14876struct NestedCar {
14877 engine: Engine,
14878}
14879
14880impl NestedCar {
14881 fn run(&self) {
14882 self.inner.engine.start();
14883 }
14884}
14885
14886struct WrappedCar {
14887 engine: Option<Engine>,
14888}
14889
14890impl WrappedCar {
14891 fn run(&self) {
14892 self.engine.start(); // wrapped
14893 }
14894}
14895
14896struct GenericCar<T> {
14897 engine: T,
14898}
14899
14900impl<T> GenericCar<T> {
14901 fn run(&self) {
14902 self.engine.start(); // generic
14903 }
14904}
14905
14906type EngineAlias = Engine;
14907
14908struct AliasCar {
14909 engine: EngineAlias,
14910}
14911
14912impl AliasCar {
14913 fn run(&self) {
14914 self.engine.start(); // alias
14915 }
14916}
14917"#;
14918 let dir = tempdir().expect("temp dir");
14919 let root = dir.path();
14920 write_fixture(root, "src/lib.rs", source);
14921 let mut cache = DispatchSourceCache::new();
14922
14923 let mut direct = reference(
14924 "rust",
14925 "src/lib.rs",
14926 "Car::run",
14927 "engine",
14928 "start",
14929 line_of(source, "self.engine.start()"),
14930 );
14931 direct.receiver_expression = "self.engine".to_string();
14932 assert_eq!(
14933 infer_receiver_type(root, &direct, &mut cache).as_deref(),
14934 Some("Engine")
14935 );
14936
14937 let mut mismatched_impl_target = direct.clone();
14938 mismatched_impl_target.caller_symbol = "other::Car::run".to_string();
14939 assert!(infer_receiver_type(root, &mismatched_impl_target, &mut cache).is_none());
14940
14941 let mut nested = reference(
14942 "rust",
14943 "src/lib.rs",
14944 "NestedCar::run",
14945 "engine",
14946 "start",
14947 line_of(source, "self.inner.engine.start()"),
14948 );
14949 nested.receiver_expression = "self.inner.engine".to_string();
14950 assert!(infer_receiver_type(root, &nested, &mut cache).is_none());
14951
14952 let mut wrapped = reference(
14953 "rust",
14954 "src/lib.rs",
14955 "WrappedCar::run",
14956 "engine",
14957 "start",
14958 line_of(source, "self.engine.start(); // wrapped"),
14959 );
14960 wrapped.receiver_expression = "self.engine".to_string();
14961 assert!(infer_receiver_type(root, &wrapped, &mut cache).is_none());
14962
14963 let mut generic = reference(
14964 "rust",
14965 "src/lib.rs",
14966 "GenericCar::run",
14967 "engine",
14968 "start",
14969 line_of(source, "self.engine.start(); // generic"),
14970 );
14971 generic.receiver_expression = "self.engine".to_string();
14972 assert!(infer_receiver_type(root, &generic, &mut cache).is_none());
14973
14974 let mut alias = reference(
14975 "rust",
14976 "src/lib.rs",
14977 "AliasCar::run",
14978 "engine",
14979 "start",
14980 line_of(source, "self.engine.start(); // alias"),
14981 );
14982 alias.receiver_expression = "self.engine".to_string();
14983 assert!(infer_receiver_type(root, &alias, &mut cache).is_none());
14984 }
14985
14986 #[test]
14987 fn rust_direct_self_reference_field_receiver_is_not_inferred() {
14988 let source = r#"struct Engine;
14989
14990struct Car {
14991 engine: &'static Engine,
14992}
14993
14994impl Car {
14995 fn run(&self) {
14996 self.engine.start();
14997 }
14998}
14999"#;
15000 let dir = tempdir().expect("temp dir");
15001 let root = dir.path();
15002 write_fixture(root, "src/lib.rs", source);
15003 let mut cache = DispatchSourceCache::new();
15004 let mut reference = reference(
15005 "rust",
15006 "src/lib.rs",
15007 "Car::run",
15008 "engine",
15009 "start",
15010 line_of(source, "self.engine.start()"),
15011 );
15012 reference.receiver_expression = "self.engine".to_string();
15013
15014 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15015 }
15016
15017 #[test]
15018 fn rust_trait_impl_self_field_receiver_is_not_inferred() {
15019 let source = r#"trait Drive {
15020 fn run(&self);
15021}
15022
15023struct Engine;
15024
15025struct Car {
15026 engine: Engine,
15027}
15028
15029impl Drive for Car {
15030 fn run(&self) {
15031 self.engine.start();
15032 }
15033}
15034"#;
15035 let dir = tempdir().expect("temp dir");
15036 let root = dir.path();
15037 write_fixture(root, "src/lib.rs", source);
15038 let mut cache = DispatchSourceCache::new();
15039 let mut reference = reference(
15040 "rust",
15041 "src/lib.rs",
15042 "Car::run",
15043 "engine",
15044 "start",
15045 line_of(source, "self.engine.start()"),
15046 );
15047 reference.receiver_expression = "self.engine".to_string();
15048
15049 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15050 }
15051
15052 #[test]
15053 fn rust_self_field_does_not_bind_struct_from_another_module() {
15054 let source = r#"struct Engine;
15055
15056mod unrelated {
15057 struct Car {
15058 engine: Engine,
15059 }
15060}
15061
15062impl Car {
15063 fn run(&self) {
15064 self.engine.start();
15065 }
15066}
15067"#;
15068 let dir = tempdir().expect("temp dir");
15069 let root = dir.path();
15070 write_fixture(root, "src/lib.rs", source);
15071 let mut cache = DispatchSourceCache::new();
15072 let mut reference = reference(
15073 "rust",
15074 "src/lib.rs",
15075 "Car::run",
15076 "engine",
15077 "start",
15078 line_of(source, "self.engine.start()"),
15079 );
15080 reference.receiver_expression = "self.engine".to_string();
15081
15082 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15083 }
15084
15085 #[test]
15086 fn unknown_java_receiver_still_uses_name_match_fallback() {
15087 let source = r#"class EntryPoint {
15088 void handle() {
15089 service.runSpecial();
15090 }
15091}
15092
15093class OnlyService {
15094 void runSpecial() {}
15095}
15096"#;
15097 let dir = tempdir().expect("temp dir");
15098 let root = dir.path();
15099 write_fixture(root, "src/EntryPoint.java", source);
15100 let reference = reference(
15101 "java",
15102 "src/EntryPoint.java",
15103 "EntryPoint::handle",
15104 "service",
15105 "runSpecial",
15106 line_of(source, "service.runSpecial()"),
15107 );
15108 let mut cache = DispatchSourceCache::new();
15109
15110 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15111 let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
15112 let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
15113 assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
15114 }
15115
15116 fn reference(
15117 lang: &str,
15118 caller_file: &str,
15119 caller_symbol: &str,
15120 receiver: &str,
15121 method_name: &str,
15122 line: u32,
15123 ) -> NameMatchRef {
15124 NameMatchRef {
15125 ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
15126 caller_node: format!("{caller_symbol}:node"),
15127 caller_file: caller_file.to_string(),
15128 caller_symbol: caller_symbol.to_string(),
15129 caller_signature: None,
15130 receiver_expression: receiver.to_string(),
15131 receiver: receiver.to_string(),
15132 method_name: method_name.to_string(),
15133 colon_dispatch: false,
15134 line,
15135 lang: lang.to_string(),
15136 }
15137 }
15138
15139 fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
15140 NameMatchCandidate {
15141 node_id: node_id.to_string(),
15142 file_path: "src/targets.fixture".to_string(),
15143 scoped_name: scoped_name.to_string(),
15144 kind: "method".to_string(),
15145 start_line: 1,
15146 }
15147 }
15148
15149 fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
15150 let path = root.join(rel_path);
15151 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
15152 fs::write(path, source).expect("write fixture");
15153 }
15154
15155 fn line_of(source: &str, needle: &str) -> u32 {
15156 source
15157 .lines()
15158 .position(|line| line.contains(needle))
15159 .map(|index| index as u32 + 1)
15160 .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
15161 }
15162}