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 2,
385 "only files and backend freshness rows 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;
452#[cfg(test)]
453pub(crate) use dead_code_projection::set_projection_before_open_observer;
454
455#[doc(hidden)]
456pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
457 COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
458}
459
460#[cfg(test)]
461fn set_cold_build_before_publish_observer(observer: Option<Arc<ColdBuildBeforePublishObserver>>) {
462 COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
463}
464
465#[cfg(test)]
466fn notify_cold_build_before_publish_observer() {
467 let observer = COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| slot.borrow().clone());
468 if let Some(observer) = observer {
469 observer();
470 }
471}
472
473#[cfg(not(test))]
474fn notify_cold_build_before_publish_observer() {}
475
476#[doc(hidden)]
477pub fn set_legacy_migration_available_disk_for_test(bytes: Option<u64>) {
478 MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow_mut() = bytes);
479}
480
481#[doc(hidden)]
482pub fn set_legacy_migration_fail_after_temp_copy_for_test(enabled: bool) {
483 MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.set(enabled));
484}
485
486#[doc(hidden)]
487pub fn set_legacy_migration_backup_budget_exhausted_for_test(enabled: bool) {
488 MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.set(enabled));
489}
490
491struct PublishAdmissionGuard {
492 previous: Option<(crate::root_cache::ArtifactPublishEpoch, u64)>,
493}
494
495impl Drop for PublishAdmissionGuard {
496 fn drop(&mut self) {
497 PUBLISH_ADMISSION.with(|slot| {
498 *slot.borrow_mut() = self.previous.take();
499 });
500 }
501}
502
503pub(crate) fn with_publish_epoch<R>(
504 epoch: crate::root_cache::ArtifactPublishEpoch,
505 expected: u64,
506 run: impl FnOnce() -> R,
507) -> R {
508 let previous = PUBLISH_ADMISSION.with(|slot| slot.replace(Some((epoch, expected))));
509 let _guard = PublishAdmissionGuard { previous };
510 run()
511}
512
513fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
514 let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
515 match admission {
516 Some((epoch, expected)) => epoch
517 .run_if_current(expected, publish)
518 .unwrap_or(Err(CallGraphStoreError::Superseded)),
519 None => publish(),
520 }
521}
522
523struct RefreshCommitAdmissionGuard {
524 previous: Option<(
525 SubcLifecycleAdmission,
526 Arc<std::sync::atomic::AtomicU64>,
527 u64,
528 )>,
529}
530
531impl Drop for RefreshCommitAdmissionGuard {
532 fn drop(&mut self) {
533 REFRESH_COMMIT_ADMISSION.with(|slot| {
534 *slot.borrow_mut() = self.previous.take();
535 });
536 }
537}
538
539fn with_refresh_commit_admission<R>(
540 lifecycle: SubcLifecycleAdmission,
541 generation_flag: Arc<std::sync::atomic::AtomicU64>,
542 expected_generation: u64,
543 run: impl FnOnce() -> R,
544) -> R {
545 let previous = REFRESH_COMMIT_ADMISSION
546 .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
547 let _guard = RefreshCommitAdmissionGuard { previous };
548 run()
549}
550
551fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
552 let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
553 let commit = || {
554 publish_if_current(|| {
555 tx.commit()?;
556 Ok(())
557 })
558 };
559 match admission {
560 Some((lifecycle, generation_flag, expected_generation)) => lifecycle
561 .run_if_current(generation_flag.as_ref(), expected_generation, commit)
562 .unwrap_or(Err(CallGraphStoreError::Superseded)),
563 None => commit(),
564 }
565}
566
567fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
568 let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
569 if let Some(observer) = observer {
570 observer(temp_path, target_path);
571 }
572}
573
574#[derive(Debug)]
575pub enum CallGraphStoreError {
576 Io(std::io::Error),
577 Sqlite(rusqlite::Error),
578 Json(serde_json::Error),
579 Aft(AftError),
580 Lock(crate::fs_lock::AcquireError),
581 MissingCallerData { file: String },
582 Unavailable(String),
583 Superseded,
584 StaleFiles(Vec<String>),
585}
586
587impl fmt::Display for CallGraphStoreError {
588 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
589 match self {
590 Self::Io(error) => write!(formatter, "I/O error: {error}"),
591 Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
592 Self::Json(error) => write!(formatter, "json error: {error}"),
593 Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
594 Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
595 Self::MissingCallerData { file } => {
596 write!(formatter, "missing extracted caller data for {file}")
597 }
598 Self::Unavailable(message) => {
599 write!(formatter, "callgraph store unavailable: {message}")
600 }
601 Self::Superseded => {
602 write!(formatter, "callgraph store build superseded before publish")
603 }
604 Self::StaleFiles(files) => {
605 write!(
606 formatter,
607 "callgraph store has stale files: {}",
608 files.join(", ")
609 )
610 }
611 }
612 }
613}
614
615impl std::error::Error for CallGraphStoreError {}
616
617impl From<std::io::Error> for CallGraphStoreError {
618 fn from(error: std::io::Error) -> Self {
619 Self::Io(error)
620 }
621}
622
623impl From<rusqlite::Error> for CallGraphStoreError {
624 fn from(error: rusqlite::Error) -> Self {
625 Self::Sqlite(error)
626 }
627}
628
629impl From<serde_json::Error> for CallGraphStoreError {
630 fn from(error: serde_json::Error) -> Self {
631 Self::Json(error)
632 }
633}
634
635impl From<AftError> for CallGraphStoreError {
636 fn from(error: AftError) -> Self {
637 Self::Aft(error)
638 }
639}
640
641impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
642 fn from(error: crate::fs_lock::AcquireError) -> Self {
643 Self::Lock(error)
644 }
645}
646
647pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
648
649pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
653
654#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
655pub struct CallGraphStoreOptions {
656 pub enabled: bool,
657}
658
659pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
660
661#[derive(Clone)]
665pub(crate) struct CallgraphRefreshState {
666 store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
667 heavy_root_work_allowed: Arc<AtomicBool>,
668}
669
670impl CallgraphRefreshState {
671 pub(crate) fn new(
672 store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
673 heavy_root_work_allowed: Arc<AtomicBool>,
674 ) -> Self {
675 Self {
676 store,
677 heavy_root_work_allowed,
678 }
679 }
680
681 fn installed_store_snapshot(&self) -> Option<Arc<ReadonlyCallGraphStore>> {
682 self.store
683 .read()
684 .unwrap_or_else(std::sync::PoisonError::into_inner)
685 .as_ref()
686 .map(Arc::clone)
687 }
688}
689
690type WorkspaceCratePrefixes = HashMap<String, String>;
691
692#[derive(Clone, Debug, Default)]
693struct WorkspaceCratePrefixCache(Arc<OnceLock<WorkspaceCratePrefixes>>);
694
695const REFRESH_WORKSPACE_CACHE_ROOT_CAP: usize = 128;
696
697pub(crate) fn invalidates_workspace_crate_prefix_cache(path: &Path) -> bool {
698 path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")
699}
700
701#[derive(Clone, Debug, Hash, PartialEq, Eq)]
702struct RefreshRoot {
703 callgraph_dir: PathBuf,
704 project_root: PathBuf,
705}
706
707#[derive(Clone)]
708pub(crate) struct CallgraphRefreshTicket {
709 lifecycle: SubcLifecycleAdmission,
710 generation_flag: Arc<std::sync::atomic::AtomicU64>,
711 expected_generation: u64,
712 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
713 expected_publish_epoch: u64,
714}
715
716impl CallgraphRefreshTicket {
717 pub(crate) fn new(
718 lifecycle: SubcLifecycleAdmission,
719 generation_flag: Arc<std::sync::atomic::AtomicU64>,
720 expected_generation: u64,
721 publish_epoch: crate::root_cache::ArtifactPublishEpoch,
722 expected_publish_epoch: u64,
723 ) -> Self {
724 Self {
725 lifecycle,
726 generation_flag,
727 expected_generation,
728 publish_epoch,
729 expected_publish_epoch,
730 }
731 }
732
733 fn is_current(&self) -> bool {
734 self.lifecycle
735 .is_current(self.generation_flag.as_ref(), self.expected_generation)
736 && self.publish_epoch.current() == self.expected_publish_epoch
737 }
738}
739
740#[derive(Clone)]
741struct RefreshBatch {
742 root: RefreshRoot,
743 paths: BTreeSet<PathBuf>,
744 pending_sinks: Vec<PendingCallGraphStorePaths>,
745 refresh_states: Vec<CallgraphRefreshState>,
746 ticket: Option<CallgraphRefreshTicket>,
747}
748
749impl RefreshBatch {
750 fn defer(&self) {
751 for sink in &self.pending_sinks {
752 sink.lock().extend(self.paths.iter().cloned());
753 }
754 }
755
756 fn defer_after_open_failure(&self) {
757 self.defer();
758 if self
759 .ticket
760 .as_ref()
761 .is_some_and(|ticket| !ticket.is_current())
762 || !self
763 .refresh_states
764 .iter()
765 .any(|state| state.heavy_root_work_allowed.load(AtomicOrdering::SeqCst))
766 {
767 return;
768 }
769
770 let ready_store_installed = self.refresh_states.iter().any(|state| {
771 let store = state.installed_store_snapshot();
772 store.is_some_and(|store| {
773 store.project_root() == self.root.project_root
774 && !store.is_legacy_fallback()
775 && store.is_current()
776 })
777 });
778 if !ready_store_installed {
779 return;
780 }
781
782 for sink in &self.pending_sinks {
786 let paths = {
787 let mut pending = sink.lock();
788 self.paths
789 .iter()
790 .filter(|path| pending.remove(*path))
791 .cloned()
792 .collect::<Vec<_>>()
793 };
794 if paths.is_empty() {
795 continue;
796 }
797 let _ = enqueue_callgraph_store_refresh_inner(
798 self.root.callgraph_dir.clone(),
799 self.root.project_root.clone(),
800 paths,
801 Arc::clone(sink),
802 self.refresh_states.clone(),
803 self.ticket.clone(),
804 );
805 }
806 }
807
808 fn merge(
809 &mut self,
810 paths: impl IntoIterator<Item = PathBuf>,
811 sink: PendingCallGraphStorePaths,
812 refresh_states: Vec<CallgraphRefreshState>,
813 ticket: Option<CallgraphRefreshTicket>,
814 ) {
815 self.paths.extend(paths);
816 if ticket.is_some() {
817 self.ticket = ticket;
818 }
819 if !self
820 .pending_sinks
821 .iter()
822 .any(|existing| Arc::ptr_eq(existing, &sink))
823 {
824 self.pending_sinks.push(sink);
825 }
826 for refresh_state in refresh_states {
827 if !self.refresh_states.iter().any(|existing| {
828 Arc::ptr_eq(&existing.store, &refresh_state.store)
829 && Arc::ptr_eq(
830 &existing.heavy_root_work_allowed,
831 &refresh_state.heavy_root_work_allowed,
832 )
833 }) {
834 self.refresh_states.push(refresh_state);
835 }
836 }
837 }
838}
839
840#[derive(Default)]
841struct RefreshQueue {
842 order: VecDeque<RefreshRoot>,
843 queued: HashMap<RefreshRoot, RefreshBatch>,
844 active: Option<RefreshBatch>,
845 shutdown_requested: bool,
846}
847
848struct RefreshWorkerShared {
849 queue: Mutex<RefreshQueue>,
850 wake: Condvar,
851}
852
853struct RefreshWorker {
854 shared: Arc<RefreshWorkerShared>,
855 thread: Mutex<Option<JoinHandle<()>>>,
856}
857
858struct RefreshWorkerWatchdog {
859 first_path: PathBuf,
860 batch_len: usize,
861 started: Instant,
862}
863
864impl RefreshWorkerWatchdog {
865 fn start(paths: &[PathBuf]) -> Self {
866 Self {
867 first_path: paths
868 .first()
869 .expect("non-empty callgraph refresh batch has a first path")
870 .clone(),
871 batch_len: paths.len(),
872 started: Instant::now(),
873 }
874 }
875}
876
877impl Drop for RefreshWorkerWatchdog {
878 fn drop(&mut self) {
879 let elapsed = self.started.elapsed();
880 if elapsed < REFRESH_WORKER_WARN_AFTER {
881 return;
882 }
883 let path = if self.batch_len == 1 {
884 self.first_path.display().to_string()
885 } else {
886 format!(
887 "{} (+{} paths)",
888 self.first_path.display(),
889 self.batch_len - 1
890 )
891 };
892 log::warn!(
893 "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
894 path,
895 elapsed.as_millis()
896 );
897 if elapsed >= REFRESH_WORKER_FINAL_AFTER {
898 log::warn!(
899 "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
900 path,
901 elapsed.as_millis()
902 );
903 }
904 }
905}
906
907impl RefreshWorker {
908 fn spawn() -> Arc<Self> {
909 let shared = Arc::new(RefreshWorkerShared {
910 queue: Mutex::new(RefreshQueue::default()),
911 wake: Condvar::new(),
912 });
913 let thread_shared = Arc::clone(&shared);
914 let thread = std::thread::Builder::new()
915 .name("aft-callgraph-refresh".to_string())
916 .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
917 .expect("failed to spawn callgraph refresh worker");
918 Arc::new(Self {
919 shared,
920 thread: Mutex::new(Some(thread)),
921 })
922 }
923
924 fn enqueue(
925 &self,
926 root: RefreshRoot,
927 paths: Vec<PathBuf>,
928 pending_sink: PendingCallGraphStorePaths,
929 refresh_states: Vec<CallgraphRefreshState>,
930 ticket: Option<CallgraphRefreshTicket>,
931 ) -> bool {
932 let mut queue = self
933 .shared
934 .queue
935 .lock()
936 .expect("callgraph refresh queue mutex poisoned");
937 if queue.shutdown_requested {
938 pending_sink.lock().extend(paths);
939 return false;
940 }
941 if let Some(batch) = queue.queued.get_mut(&root) {
942 batch.merge(paths, pending_sink, refresh_states, ticket);
943 } else {
944 queue.order.push_back(root.clone());
945 queue.queued.insert(
946 root.clone(),
947 RefreshBatch {
948 root,
949 paths: paths.into_iter().collect(),
950 pending_sinks: vec![pending_sink],
951 refresh_states,
952 ticket,
953 },
954 );
955 }
956 self.shared.wake.notify_one();
957 true
958 }
959
960 fn shutdown_with_budget(&self, budget: Duration) -> bool {
961 let deadline = Instant::now() + budget;
962 let mut queue = self
963 .shared
964 .queue
965 .lock()
966 .expect("callgraph refresh queue mutex poisoned");
967 queue.shutdown_requested = true;
968 self.shared.wake.notify_one();
969 while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
970 let remaining = deadline.saturating_duration_since(Instant::now());
971 let (next, _) = self
972 .shared
973 .wake
974 .wait_timeout(queue, remaining)
975 .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
976 queue = next;
977 }
978 let drained = queue.active.is_none() && queue.order.is_empty();
979 if !drained {
980 if let Some(active) = queue.active.as_ref() {
981 active.defer();
982 }
983 for batch in queue.queued.values() {
984 batch.defer();
985 }
986 queue.order.clear();
987 queue.queued.clear();
988 }
989 drop(queue);
990
991 if drained {
992 if let Some(thread) = self
993 .thread
994 .lock()
995 .expect("callgraph refresh worker thread mutex poisoned")
996 .take()
997 {
998 let _ = thread.join();
999 }
1000 }
1001 drained
1002 }
1003}
1004
1005static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
1006
1007pub fn enqueue_callgraph_store_refresh(
1008 callgraph_dir: PathBuf,
1009 project_root: PathBuf,
1010 paths: Vec<PathBuf>,
1011 pending_sink: PendingCallGraphStorePaths,
1012) -> bool {
1013 enqueue_callgraph_store_refresh_inner(
1014 callgraph_dir,
1015 project_root,
1016 paths,
1017 pending_sink,
1018 Vec::new(),
1019 None,
1020 )
1021}
1022
1023#[cfg(test)]
1024pub(crate) fn enqueue_callgraph_store_refresh_fenced(
1025 callgraph_dir: PathBuf,
1026 project_root: PathBuf,
1027 paths: Vec<PathBuf>,
1028 pending_sink: PendingCallGraphStorePaths,
1029 ticket: CallgraphRefreshTicket,
1030) -> bool {
1031 enqueue_callgraph_store_refresh_inner(
1032 callgraph_dir,
1033 project_root,
1034 paths,
1035 pending_sink,
1036 Vec::new(),
1037 Some(ticket),
1038 )
1039}
1040
1041pub(crate) fn enqueue_callgraph_store_refresh_fenced_with_state(
1042 callgraph_dir: PathBuf,
1043 project_root: PathBuf,
1044 paths: Vec<PathBuf>,
1045 pending_sink: PendingCallGraphStorePaths,
1046 refresh_state: CallgraphRefreshState,
1047 ticket: CallgraphRefreshTicket,
1048) -> bool {
1049 enqueue_callgraph_store_refresh_inner(
1050 callgraph_dir,
1051 project_root,
1052 paths,
1053 pending_sink,
1054 vec![refresh_state],
1055 Some(ticket),
1056 )
1057}
1058
1059fn enqueue_callgraph_store_refresh_inner(
1060 callgraph_dir: PathBuf,
1061 project_root: PathBuf,
1062 paths: Vec<PathBuf>,
1063 pending_sink: PendingCallGraphStorePaths,
1064 refresh_states: Vec<CallgraphRefreshState>,
1065 ticket: Option<CallgraphRefreshTicket>,
1066) -> bool {
1067 if paths.is_empty() {
1068 return true;
1069 }
1070 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1071 let worker = {
1072 let mut worker = slot
1073 .lock()
1074 .expect("callgraph refresh worker mutex poisoned");
1075 Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
1076 };
1077 worker.enqueue(
1078 RefreshRoot {
1079 callgraph_dir,
1080 project_root,
1081 },
1082 paths,
1083 pending_sink,
1084 refresh_states,
1085 ticket,
1086 )
1087}
1088
1089pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
1090 flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
1091}
1092
1093#[doc(hidden)]
1094pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
1095 let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1096 let worker = slot
1097 .lock()
1098 .expect("callgraph refresh worker mutex poisoned")
1099 .clone();
1100 let Some(worker) = worker else {
1101 return true;
1102 };
1103 let drained = worker.shutdown_with_budget(budget);
1104 if drained {
1105 let mut current = slot
1106 .lock()
1107 .expect("callgraph refresh worker mutex poisoned");
1108 if current
1109 .as_ref()
1110 .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
1111 {
1112 *current = None;
1113 }
1114 }
1115 drained
1116}
1117
1118fn idle_checkpoint_due(last: Option<Instant>, now: Instant) -> bool {
1119 last.is_none_or(|last| now.saturating_duration_since(last) >= REFRESH_IDLE_CHECKPOINT_INTERVAL)
1120}
1121
1122fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
1123 let mut workspace_crate_prefixes = HashMap::new();
1126 let mut last_idle_checkpoints: HashMap<RefreshRoot, Instant> = HashMap::new();
1127 loop {
1128 let batch = {
1129 let mut queue = shared
1130 .queue
1131 .lock()
1132 .expect("callgraph refresh queue mutex poisoned");
1133 loop {
1134 if let Some(root) = queue.order.pop_front() {
1135 let batch = queue
1136 .queued
1137 .remove(&root)
1138 .expect("queued callgraph refresh root has a batch");
1139 queue.active = Some(batch.clone());
1140 break batch;
1141 }
1142 if queue.shutdown_requested {
1143 return;
1144 }
1145 queue = shared
1146 .wake
1147 .wait(queue)
1148 .expect("callgraph refresh queue mutex poisoned while waiting");
1149 }
1150 };
1151
1152 let store = process_callgraph_refresh_batch(&batch, &mut workspace_crate_prefixes);
1153
1154 let mut queue = shared
1155 .queue
1156 .lock()
1157 .expect("callgraph refresh queue mutex poisoned");
1158 queue.active = None;
1159 let became_idle = queue.order.is_empty();
1160 shared.wake.notify_all();
1161 drop(queue);
1162
1163 if became_idle {
1164 let checkpoint_due = idle_checkpoint_due(
1165 last_idle_checkpoints.get(&batch.root).copied(),
1166 Instant::now(),
1167 );
1168 if checkpoint_due {
1169 if let Some(store) = store {
1170 if store.checkpoint_wal_truncate() {
1171 last_idle_checkpoints.insert(batch.root.clone(), Instant::now());
1172 }
1173 }
1174 }
1175 }
1176 }
1177}
1178
1179fn process_callgraph_refresh_batch(
1180 batch: &RefreshBatch,
1181 workspace_crate_prefixes: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1182) -> Option<CallGraphStore> {
1183 if batch
1187 .paths
1188 .iter()
1189 .any(|path| invalidates_workspace_crate_prefix_cache(path))
1190 {
1191 workspace_crate_prefixes.remove(&batch.root);
1192 }
1193
1194 let paths = batch
1195 .paths
1196 .iter()
1197 .filter(|path| crate::parser::detect_language(path).is_some())
1198 .cloned()
1199 .collect::<Vec<_>>();
1200 if paths.is_empty() {
1201 return None;
1202 }
1203 note_refresh_worker_batch_for_test(&batch.root.project_root);
1204 if batch
1205 .ticket
1206 .as_ref()
1207 .is_some_and(|ticket| !ticket.is_current())
1208 {
1209 batch.defer();
1212 return None;
1213 }
1214 let workspace_crate_prefix_cache =
1215 workspace_crate_prefix_cache_for_root(workspace_crate_prefixes, &batch.root);
1216 let _watchdog = RefreshWorkerWatchdog::start(&paths);
1217 let test_seam = refresh_worker_test_seam(&batch.root.project_root);
1218 note_refresh_worker_call_for_test(&batch.root.project_root);
1219 let opened = if test_seam.fail_open {
1220 Ok(None)
1221 } else {
1222 CallGraphStore::open_ready(
1223 batch.root.callgraph_dir.clone(),
1224 batch.root.project_root.clone(),
1225 )
1226 };
1227 if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
1228 let _ = gate.held_tx.send(());
1231 let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
1232 }
1233 let store = match opened {
1234 Ok(Some(store)) => store,
1235 Ok(None) => {
1236 batch.defer_after_open_failure();
1237 return None;
1238 }
1239 Err(error) => {
1240 batch.defer_after_open_failure();
1241 crate::slog_warn!(
1242 "callgraph store writer open failed during refresh; deferred paths: {}",
1243 error
1244 );
1245 return None;
1246 }
1247 };
1248 if !test_seam.delay.is_zero() {
1249 std::thread::sleep(test_seam.delay);
1250 }
1251 if batch
1252 .ticket
1253 .as_ref()
1254 .is_some_and(|ticket| !ticket.is_current())
1255 {
1256 batch.defer();
1259 return Some(store);
1260 }
1261 let refresh_result = if test_seam.fail_refresh {
1262 Err(CallGraphStoreError::Unavailable(
1263 "injected refresh worker failure".to_string(),
1264 ))
1265 } else if let Some(ticket) = &batch.ticket {
1266 with_publish_epoch(
1267 ticket.publish_epoch.clone(),
1268 ticket.expected_publish_epoch,
1269 || {
1270 with_refresh_commit_admission(
1271 ticket.lifecycle.clone(),
1272 Arc::clone(&ticket.generation_flag),
1273 ticket.expected_generation,
1274 || {
1275 store
1276 .refresh_files_with_workspace_crate_prefix_cache(
1277 &paths,
1278 workspace_crate_prefix_cache.clone(),
1279 )
1280 .map(|_| ())
1281 },
1282 )
1283 },
1284 )
1285 } else {
1286 store
1287 .refresh_files_with_workspace_crate_prefix_cache(
1288 &paths,
1289 workspace_crate_prefix_cache.clone(),
1290 )
1291 .map(|_| ())
1292 };
1293 if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
1294 batch.defer();
1298 return Some(store);
1299 }
1300 if let Err(error) = refresh_result {
1301 crate::slog_warn!("callgraph store refresh failed: {}", error);
1302 match store.mark_files_stale(&paths) {
1303 Ok(marked) => {
1304 note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
1305 crate::slog_warn!(
1306 "marked {} callgraph store file(s) stale after refresh failure",
1307 marked.len()
1308 );
1309 }
1310 Err(mark_error) => crate::slog_warn!(
1311 "failed to mark callgraph store files stale after refresh failure: {}",
1312 mark_error
1313 ),
1314 }
1315 } else {
1316 crate::logging::note_callgraph_invalidations(paths.len());
1317 }
1318 Some(store)
1319}
1320
1321fn workspace_crate_prefix_cache_for_root(
1322 caches: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1323 root: &RefreshRoot,
1324) -> WorkspaceCratePrefixCache {
1325 if !caches.contains_key(root) && caches.len() >= REFRESH_WORKSPACE_CACHE_ROOT_CAP {
1326 if let Some(evicted) = caches.keys().next().cloned() {
1328 caches.remove(&evicted);
1329 }
1330 }
1331 caches.entry(root.clone()).or_default().clone()
1332}
1333
1334#[derive(Clone, Copy, Default)]
1335struct RefreshWorkerTestSeam {
1336 delay: Duration,
1337 fail_refresh: bool,
1338 fail_open: bool,
1339 refresh_calls: usize,
1340 worker_calls: usize,
1341 stale_marks: usize,
1342}
1343
1344static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
1345 OnceLock::new();
1346
1347struct RefreshWorkerTestGate {
1348 held_tx: crossbeam_channel::Sender<()>,
1349 release_rx: crossbeam_channel::Receiver<()>,
1350}
1351
1352static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
1353 OnceLock::new();
1354
1355#[doc(hidden)]
1356pub fn install_callgraph_refresh_worker_test_gate(
1357 project_root: PathBuf,
1358) -> (
1359 crossbeam_channel::Receiver<()>,
1360 crossbeam_channel::Sender<()>,
1361) {
1362 let (held_tx, held_rx) = crossbeam_channel::bounded(1);
1363 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1364 REFRESH_WORKER_TEST_GATES
1365 .get_or_init(|| Mutex::new(HashMap::new()))
1366 .lock()
1367 .expect("callgraph refresh test gate mutex poisoned")
1368 .insert(
1369 project_root,
1370 RefreshWorkerTestGate {
1371 held_tx,
1372 release_rx,
1373 },
1374 );
1375 (held_rx, release_tx)
1376}
1377
1378fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
1379 REFRESH_WORKER_TEST_GATES
1380 .get_or_init(|| Mutex::new(HashMap::new()))
1381 .lock()
1382 .expect("callgraph refresh test gate mutex poisoned")
1383 .remove(project_root)
1384}
1385
1386fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
1387 let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
1388 return RefreshWorkerTestSeam::default();
1389 };
1390 seams
1391 .lock()
1392 .expect("callgraph refresh test seam mutex poisoned")
1393 .get(project_root)
1394 .copied()
1395 .unwrap_or_default()
1396}
1397
1398fn note_refresh_worker_batch_for_test(project_root: &Path) {
1399 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1400 if let Some(seam) = seams
1401 .lock()
1402 .expect("callgraph refresh test seam mutex poisoned")
1403 .get_mut(project_root)
1404 {
1405 seam.worker_calls += 1;
1406 }
1407 }
1408}
1409
1410fn note_refresh_worker_call_for_test(project_root: &Path) {
1411 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1412 if let Some(seam) = seams
1413 .lock()
1414 .expect("callgraph refresh test seam mutex poisoned")
1415 .get_mut(project_root)
1416 {
1417 seam.refresh_calls += 1;
1418 }
1419 }
1420}
1421
1422fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
1423 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1424 if let Some(seam) = seams
1425 .lock()
1426 .expect("callgraph refresh test seam mutex poisoned")
1427 .get_mut(project_root)
1428 {
1429 seam.stale_marks += 1;
1430 }
1431 }
1432}
1433
1434#[doc(hidden)]
1435pub fn set_callgraph_refresh_worker_test_seam(
1436 project_root: PathBuf,
1437 delay: Duration,
1438 fail_refresh: bool,
1439) {
1440 REFRESH_WORKER_TEST_SEAMS
1441 .get_or_init(|| Mutex::new(HashMap::new()))
1442 .lock()
1443 .expect("callgraph refresh test seam mutex poisoned")
1444 .insert(
1445 project_root,
1446 RefreshWorkerTestSeam {
1447 delay,
1448 fail_refresh,
1449 ..RefreshWorkerTestSeam::default()
1450 },
1451 );
1452}
1453
1454#[doc(hidden)]
1455pub fn set_callgraph_refresh_worker_test_open_failure(project_root: PathBuf, enabled: bool) {
1456 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1457 if let Some(seam) = seams
1458 .lock()
1459 .expect("callgraph refresh test seam mutex poisoned")
1460 .get_mut(&project_root)
1461 {
1462 seam.fail_open = enabled;
1463 }
1464 }
1465}
1466
1467#[doc(hidden)]
1468pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
1469 let seam = refresh_worker_test_seam(project_root);
1470 (seam.refresh_calls, seam.stale_marks)
1471}
1472
1473#[doc(hidden)]
1474pub fn callgraph_refresh_worker_test_worker_calls(project_root: &Path) -> usize {
1475 refresh_worker_test_seam(project_root).worker_calls
1476}
1477
1478#[doc(hidden)]
1479pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
1480 if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1481 seams
1482 .lock()
1483 .expect("callgraph refresh test seam mutex poisoned")
1484 .remove(project_root);
1485 }
1486}
1487
1488#[derive(Debug)]
1489pub struct CallGraphStore {
1490 project_root: PathBuf,
1491 project_key: String,
1492 sqlite_path: PathBuf,
1496 publication_dir: PathBuf,
1500 legacy_fallback: bool,
1504 generation: Option<String>,
1509 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1510 read_marker: Option<crate::root_cache::ReadMarker>,
1511 database_ready: AtomicBool,
1514 write_metrics: Arc<CallgraphWriteMetrics>,
1515 conn: Mutex<Connection>,
1516}
1517
1518#[derive(Debug)]
1519pub struct ReadonlyCallGraphStore {
1520 inner: CallGraphStore,
1521}
1522
1523pub trait CallGraphRead {
1524 fn project_root(&self) -> &Path;
1525 fn project_key(&self) -> &str;
1526 fn sqlite_path(&self) -> &Path;
1527 fn is_current(&self) -> bool;
1528 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
1529 fn indexed_file_count(&self) -> Result<usize>;
1530 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
1531 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
1532 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
1533 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
1534 fn direct_caller_counts_of(
1535 &self,
1536 targets: &[(String, String)],
1537 ) -> Result<HashMap<(String, String), usize>>;
1538 fn outgoing_calls_for_symbols(
1539 &self,
1540 sources: &[(String, String)],
1541 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
1542 fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
1543 -> Result<StoreCallersResult>;
1544 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
1545 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1546 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1547 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
1548 fn call_tree(
1549 &self,
1550 file_rel: &Path,
1551 symbol: &str,
1552 depth: usize,
1553 ) -> Result<callgraph::CallTreeNode>;
1554 fn trace_to(
1555 &self,
1556 file_rel: &Path,
1557 symbol: &str,
1558 max_depth: usize,
1559 ) -> Result<callgraph::TraceToResult>;
1560 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
1561 fn trace_to_symbol(
1562 &self,
1563 file_rel: &Path,
1564 symbol: &str,
1565 to_symbol: &str,
1566 to_file: Option<&Path>,
1567 max_depth: usize,
1568 ) -> Result<callgraph::TraceToSymbolResult>;
1569}
1570
1571#[derive(Debug, Clone, PartialEq, Eq)]
1572enum OpenRootRepair {
1573 None,
1574 ReRooted,
1575 NeedsRebuild {
1576 previous_roots: Vec<String>,
1577 current_root: String,
1578 reason: String,
1579 },
1580}
1581
1582struct OpenedStore {
1583 store: CallGraphStore,
1584 root_repair: OpenRootRepair,
1585}
1586
1587#[derive(Clone, Debug)]
1588struct LegacyCallgraphPartition {
1589 harness: String,
1590 dir: PathBuf,
1591 key: String,
1592 bytes: u64,
1593 freshness: Option<SystemTime>,
1594}
1595
1596#[derive(Clone, Debug)]
1597struct LegacyCallgraphTarget {
1598 partition: LegacyCallgraphPartition,
1599 sqlite_path: PathBuf,
1600 generation: Option<String>,
1601 source_bytes: u64,
1602 source_blake3: String,
1603}
1604
1605#[derive(Clone, Debug)]
1606struct SourceFingerprint {
1607 bytes: u64,
1608 blake3: String,
1609}
1610
1611#[derive(Clone, Debug)]
1612struct PublishedLegacyMigration {
1613 generation: String,
1614 migrated_bytes: u64,
1615}
1616
1617#[derive(Debug, Clone)]
1618pub struct ColdBuildStats {
1619 pub files: usize,
1620 pub nodes: usize,
1621 pub refs: usize,
1622 pub edges: usize,
1623 pub failed_files: Vec<String>,
1624 pub elapsed_ms: u128,
1625}
1626
1627#[derive(Debug, Clone)]
1628pub struct IncrementalStats {
1629 pub changed_files: Vec<String>,
1630 pub surface_changed: Vec<String>,
1631 pub deleted_files: Vec<String>,
1632 pub dependency_selected_refs: usize,
1633 pub refreshed_own_files: usize,
1634 pub unchanged_extract_files: usize,
1635}
1636
1637#[doc(hidden)]
1639#[derive(Debug, Clone, Default, PartialEq, Eq)]
1640pub struct RefreshFilesProfile {
1641 pub parse: Duration,
1642 pub dependency_selection: Duration,
1643 pub row_deletes: Duration,
1644 pub row_inserts: Duration,
1645 pub dependent_parse: Duration,
1646 pub index_load: Duration,
1647 pub ref_resolution: Duration,
1648 pub method_dispatch: Duration,
1649 pub commit: Duration,
1650 pub total: Duration,
1651}
1652
1653impl RefreshFilesProfile {
1654 pub fn report(&self) -> String {
1655 format!(
1656 "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",
1657 self.parse.as_millis(),
1658 self.dependency_selection.as_millis(),
1659 self.row_deletes.as_millis(),
1660 self.row_inserts.as_millis(),
1661 self.dependent_parse.as_millis(),
1662 self.index_load.as_millis(),
1663 self.ref_resolution.as_millis(),
1664 self.method_dispatch.as_millis(),
1665 self.commit.as_millis(),
1666 self.total.as_millis(),
1667 )
1668 }
1669}
1670
1671#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1672pub struct StoredEdge {
1673 pub source_file: String,
1674 pub source_symbol: String,
1675 pub target_file: String,
1676 pub target_symbol: String,
1677 pub kind: String,
1678 pub line: u32,
1679}
1680
1681#[derive(Debug, Clone, PartialEq, Eq)]
1682pub struct StoreNode {
1683 node_id: String,
1684 pub file: String,
1685 pub symbol: String,
1686 pub name: String,
1687 pub kind: String,
1688 pub line: u32,
1689 pub end_line: u32,
1690 pub signature: Option<String>,
1691 pub exported: bool,
1692 pub is_entry_point: bool,
1693 pub lang: LangId,
1694}
1695
1696#[cfg(test)]
1697impl StoreNode {
1698 pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
1699 Self {
1700 node_id: format!("{file}:{symbol}"),
1701 file: file.to_string(),
1702 symbol: symbol.to_string(),
1703 name: symbol.to_string(),
1704 kind: "function".to_string(),
1705 line: 1,
1706 end_line: 1,
1707 signature: None,
1708 exported: is_entry_point,
1709 is_entry_point,
1710 lang: LangId::TypeScript,
1711 }
1712 }
1713}
1714
1715#[derive(Debug, Clone, PartialEq, Eq)]
1716pub struct StoreCallSite {
1717 pub caller: StoreNode,
1718 pub target_file: String,
1719 pub target_symbol: String,
1720 pub target: Option<StoreNode>,
1721 pub line: u32,
1722 pub byte_start: usize,
1723 pub byte_end: usize,
1724 pub resolved: bool,
1725 pub provenance: String,
1726}
1727
1728impl StoreCallSite {
1729 pub fn approximate(&self) -> bool {
1730 self.provenance == PROVENANCE_NAME_MATCH
1731 }
1732
1733 pub fn resolved_by(&self) -> &str {
1734 &self.provenance
1735 }
1736
1737 pub fn supplemental_resolution(&self) -> Option<&str> {
1738 match self.provenance.as_str() {
1739 PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
1740 _ => None,
1741 }
1742 }
1743}
1744
1745#[derive(Debug, Clone, PartialEq, Eq)]
1746pub struct StoreUnresolvedCall {
1747 pub caller: StoreNode,
1748 pub symbol: String,
1749 pub full_ref: Option<String>,
1750 pub line: u32,
1751 pub byte_start: usize,
1752 pub byte_end: usize,
1753}
1754
1755#[derive(Debug, Clone, PartialEq, Eq)]
1756pub struct StoreCallersResult {
1757 pub target: StoreNode,
1758 pub callers: Vec<StoreCallSite>,
1759 pub scanned_files: usize,
1760 pub depth_limited: bool,
1761 pub truncated: usize,
1762}
1763
1764#[derive(Debug, Clone, PartialEq, Eq)]
1765pub struct StoreImpactCaller {
1766 pub site: StoreCallSite,
1767 pub signature: Option<String>,
1768 pub is_entry_point: bool,
1769 pub call_expression: Option<String>,
1770 pub parameters: Vec<String>,
1771}
1772
1773#[derive(Debug, Clone, PartialEq, Eq)]
1774pub struct StoreImpactResult {
1775 pub target: StoreNode,
1776 pub parameters: Vec<String>,
1777 pub callers: Vec<StoreImpactCaller>,
1778 pub depth_limited: bool,
1779 pub truncated: usize,
1780}
1781
1782#[derive(Debug, Clone)]
1783struct ExtractFailure {
1784 rel_path: String,
1785 freshness: Option<FileFreshness>,
1786}
1787
1788#[derive(Debug, Clone)]
1789struct BuildExtractsResult {
1790 extracts: Vec<FileExtract>,
1791 failures: Vec<ExtractFailure>,
1792}
1793
1794#[derive(Debug, Clone)]
1795enum StoreForwardCall {
1796 Resolved(StoreCallSite),
1797 Unresolved(StoreUnresolvedCall),
1798}
1799
1800impl StoreForwardCall {
1801 fn byte_start(&self) -> usize {
1802 match self {
1803 Self::Resolved(site) => site.byte_start,
1804 Self::Unresolved(call) => call.byte_start,
1805 }
1806 }
1807
1808 fn line(&self) -> u32 {
1809 match self {
1810 Self::Resolved(site) => site.line,
1811 Self::Unresolved(call) => call.line,
1812 }
1813 }
1814}
1815
1816#[derive(Debug, Clone)]
1817struct FileExtract {
1818 rel_path: String,
1819 freshness: FileFreshness,
1820 lang: LangId,
1821 data: FileCallData,
1822 nodes: Vec<NodeRecord>,
1823 raw_refs: Vec<RawRef>,
1824 dispatch_hints: Vec<DispatchHint>,
1825 surface_fingerprint: String,
1826}
1827
1828#[derive(Debug, Clone)]
1829struct NodeRecord {
1830 id: String,
1831 file_path: String,
1832 name: String,
1833 scoped_name: String,
1834 kind: String,
1835 range: Range,
1836 range_ordinal: u32,
1837 signature: Option<String>,
1838 exported: bool,
1839 is_default_export: bool,
1840 is_type_like: bool,
1841 is_callgraph_entry_point: bool,
1842}
1843
1844#[derive(Debug, Clone)]
1845struct RawRef {
1846 ref_id: String,
1847 caller_node: Option<String>,
1848 caller_symbol: Option<String>,
1849 caller_file: String,
1850 kind: String,
1851 short_name: Option<String>,
1852 full_ref: Option<String>,
1853 module_path: Option<String>,
1854 import_kind: Option<String>,
1855 local_name: Option<String>,
1856 requested_name: Option<String>,
1857 namespace_alias: Option<String>,
1858 wildcard: bool,
1859 line: u32,
1860 byte_start: usize,
1861 byte_end: usize,
1862 dependencies: BTreeSet<String>,
1863}
1864
1865#[derive(Debug, Clone)]
1866struct ResolvedRef {
1867 raw: RawRef,
1868 status: String,
1869 target_node: Option<String>,
1870 target_file: Option<String>,
1871 target_symbol: Option<String>,
1872 dependencies: BTreeSet<String>,
1873 edge: Option<EdgeRecord>,
1874}
1875
1876#[derive(Debug, Clone)]
1877struct EdgeRecord {
1878 edge_id: String,
1879 source_node: String,
1880 target_node: Option<String>,
1881 target_file: String,
1882 target_symbol: String,
1883 kind: String,
1884 line: u32,
1885}
1886
1887#[derive(Debug, Clone)]
1888struct DispatchHint {
1889 id: String,
1890 method_name: String,
1891 caller_node: String,
1892 file: String,
1893 line: u32,
1894 byte_start: usize,
1895 byte_end: usize,
1896}
1897
1898#[derive(Debug, Clone)]
1899struct NameMatchRef {
1900 ref_id: String,
1901 caller_node: String,
1902 caller_file: String,
1903 caller_symbol: String,
1904 caller_signature: Option<String>,
1905 receiver_expression: String,
1906 receiver: String,
1907 method_name: String,
1908 colon_dispatch: bool,
1909 line: u32,
1910 lang: String,
1911}
1912
1913#[derive(Debug, Clone)]
1914struct NameMatchCandidate {
1915 node_id: String,
1916 file_path: String,
1917 scoped_name: String,
1918 kind: String,
1919 start_line: u32,
1921}
1922
1923#[derive(Debug, Clone)]
1924struct FileRow {
1925 surface_fingerprint: String,
1926 freshness: FileFreshness,
1927}
1928
1929#[derive(Debug, Clone)]
1930struct DbFileIndex {
1931 lang: Option<LangId>,
1932 exports: HashSet<String>,
1933 default_export: Option<String>,
1934 export_aliases: HashMap<String, String>,
1935 node_by_scoped: HashMap<String, String>,
1936 node_by_bare: HashMap<String, String>,
1937 module_targets: HashMap<String, Option<String>>,
1938 reexports: Vec<ReexportIndex>,
1939}
1940
1941#[derive(Debug, Clone)]
1942struct ReexportIndex {
1943 target_file: Option<String>,
1944 named: HashMap<String, String>,
1945 wildcard: bool,
1946}
1947
1948#[derive(Debug, Clone)]
1949struct ProjectIndex<'a> {
1950 project_root: PathBuf,
1951 files: HashMap<String, DbFileIndex>,
1952 caller_data: HashMap<String, &'a FileCallData>,
1953 workspace_crate_prefixes: WorkspaceCratePrefixCache,
1958}
1959
1960impl ProjectIndex<'_> {
1961 fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
1964 self.workspace_crate_prefixes
1965 .0
1966 .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
1967 .get(crate_name)
1968 .cloned()
1969 }
1970}
1971
1972impl CallGraphStore {
1973 pub fn open_if_enabled(
1974 options: CallGraphStoreOptions,
1975 callgraph_dir: PathBuf,
1976 project_root: PathBuf,
1977 ) -> Result<Option<Self>> {
1978 if !options.enabled {
1979 return Ok(None);
1980 }
1981 Self::open(callgraph_dir, project_root).map(Some)
1982 }
1983
1984 pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
1985 let project_key = crate::search_index::artifact_cache_key(&project_root);
1986 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1987 else {
1988 return Err(CallGraphStoreError::Unavailable(
1989 "writer capability denied; use the read-only callgraph opener".to_string(),
1990 ));
1991 };
1992 std::fs::create_dir_all(&callgraph_dir)?;
1993 let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
1997 .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
1998 let OpenedStore { store, root_repair } = Self::open_at_path(
1999 project_root.clone(),
2000 project_key,
2001 sqlite_path,
2002 generation,
2003 true,
2004 Some(Arc::clone(&writer_lease)),
2005 None,
2006 )?;
2007 match root_repair {
2008 OpenRootRepair::NeedsRebuild { .. } => {
2009 log_root_repair_rebuild(&root_repair);
2010 drop(store);
2011 drop(writer_lease);
2012 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2013 let (store, _stats) =
2014 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2015 Ok(store)
2016 }
2017 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
2018 }
2019 }
2020
2021 pub fn open_readonly(
2022 callgraph_dir: PathBuf,
2023 project_root: PathBuf,
2024 ) -> Result<Option<ReadonlyCallGraphStore>> {
2025 let project_key = crate::search_index::artifact_cache_key(&project_root);
2026 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2027 {
2028 let conn = open_readonly_connection(&sqlite_path)?;
2029 if !database_ready(&conn).unwrap_or(false) {
2030 return Ok(None);
2031 }
2032 let marker_label = generation.as_deref().unwrap_or("legacy");
2033 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
2034 return Ok(Some(ReadonlyCallGraphStore::from_inner(
2035 Self::from_connection(
2036 project_root,
2037 project_key,
2038 sqlite_path,
2039 callgraph_dir,
2040 false,
2041 generation,
2042 None,
2043 Some(read_marker),
2044 conn,
2045 ),
2046 )));
2047 }
2048
2049 let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
2050 return Ok(None);
2051 };
2052 crate::slog_warn!(
2053 "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
2054 target.partition.harness,
2055 target.sqlite_path.display()
2056 );
2057 let conn = open_readonly_connection(&target.sqlite_path)?;
2058 if !database_ready(&conn).unwrap_or(false) {
2059 return Ok(None);
2060 }
2061 let marker_label =
2062 legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
2063 let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
2064 Ok(Some(ReadonlyCallGraphStore::from_inner(
2065 Self::from_connection(
2066 project_root,
2067 project_key,
2068 target.sqlite_path,
2069 callgraph_dir,
2070 true,
2071 target.generation,
2072 None,
2073 Some(read_marker),
2074 conn,
2075 ),
2076 )))
2077 }
2078
2079 pub fn open_ready_repairing(
2085 callgraph_dir: PathBuf,
2086 project_root: PathBuf,
2087 ) -> Result<Option<Self>> {
2088 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true)
2089 }
2090
2091 pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
2095 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false)
2096 }
2097
2098 pub fn open_ready_no_rebuild(
2099 callgraph_dir: PathBuf,
2100 project_root: PathBuf,
2101 ) -> Result<Option<Self>> {
2102 Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true)
2103 }
2104
2105 fn open_ready_with_rebuild_policy(
2106 callgraph_dir: PathBuf,
2107 project_root: PathBuf,
2108 allow_cold_build: bool,
2109 allow_root_repair: bool,
2110 ) -> Result<Option<Self>> {
2111 let project_key = crate::search_index::artifact_cache_key(&project_root);
2112 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2113 else {
2114 return Ok(None);
2115 };
2116 let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2117 else {
2118 return Ok(None);
2119 };
2120 let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
2121 project_root.clone(),
2122 project_key.clone(),
2123 sqlite_path,
2124 generation,
2125 true,
2126 Some(Arc::clone(&writer_lease)),
2127 None,
2128 allow_root_repair,
2129 )?;
2130 match root_repair {
2131 OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
2132 log_root_repair_rebuild(&root_repair);
2133 drop(store);
2134 drop(writer_lease);
2135 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2136 let (store, _stats) =
2137 Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2138 Ok(Some(store))
2139 }
2140 OpenRootRepair::NeedsRebuild { .. } => {
2141 if let Some(message) = note_repair_entry(&project_key) {
2142 crate::slog_warn!("{message}");
2143 }
2144 Ok(None)
2145 }
2146 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2147 }
2148 }
2149
2150 pub fn cold_build_with_lease(
2151 callgraph_dir: PathBuf,
2152 project_root: PathBuf,
2153 files: &[PathBuf],
2154 ) -> Result<(Self, ColdBuildStats)> {
2155 Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
2156 }
2157
2158 pub fn cold_build_with_lease_chunked(
2159 callgraph_dir: PathBuf,
2160 project_root: PathBuf,
2161 files: &[PathBuf],
2162 chunk_size: usize,
2163 ) -> Result<(Self, ColdBuildStats)> {
2164 Self::cold_build_with_lease_chunked_inner(
2165 callgraph_dir,
2166 project_root,
2167 files,
2168 chunk_size,
2169 false,
2170 )
2171 }
2172
2173 pub(crate) fn force_cold_build_with_lease_chunked(
2174 callgraph_dir: PathBuf,
2175 project_root: PathBuf,
2176 files: &[PathBuf],
2177 chunk_size: usize,
2178 ) -> Result<(Self, ColdBuildStats)> {
2179 Self::cold_build_with_lease_chunked_inner(
2180 callgraph_dir,
2181 project_root,
2182 files,
2183 chunk_size,
2184 true,
2185 )
2186 }
2187
2188 fn cold_build_with_lease_chunked_inner(
2189 callgraph_dir: PathBuf,
2190 project_root: PathBuf,
2191 files: &[PathBuf],
2192 chunk_size: usize,
2193 require_new_publication: bool,
2194 ) -> Result<(Self, ColdBuildStats)> {
2195 let project_key = crate::search_index::artifact_cache_key(&project_root);
2196 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2197 else {
2198 let operation = if require_new_publication {
2199 "forced rebuild"
2200 } else {
2201 "cold build"
2202 };
2203 return Err(CallGraphStoreError::Unavailable(format!(
2204 "{operation} could not acquire writer capability"
2205 )));
2206 };
2207 std::fs::create_dir_all(&callgraph_dir)?;
2208 let (stats, generation) = Self::cold_build_publish_locked(
2209 &callgraph_dir,
2210 &project_root,
2211 &project_key,
2212 files,
2213 chunk_size,
2214 Arc::clone(&writer_lease),
2215 )?;
2216 let store = Self::open_generation(
2217 &callgraph_dir,
2218 project_root,
2219 project_key,
2220 generation,
2221 writer_lease,
2222 )?;
2223 Ok((store, stats))
2224 }
2225
2226 pub fn ensure_built_with_lease(
2227 callgraph_dir: PathBuf,
2228 project_root: PathBuf,
2229 files: &[PathBuf],
2230 ) -> Result<(Self, Option<ColdBuildStats>)> {
2231 Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
2232 }
2233
2234 pub fn ensure_built_with_lease_chunked(
2235 callgraph_dir: PathBuf,
2236 project_root: PathBuf,
2237 files: &[PathBuf],
2238 chunk_size: usize,
2239 ) -> Result<(Self, Option<ColdBuildStats>)> {
2240 let project_key = crate::search_index::artifact_cache_key(&project_root);
2241 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2242 else {
2243 return Err(CallGraphStoreError::Unavailable(
2244 "callgraph ensure could not acquire writer capability".to_string(),
2245 ));
2246 };
2247 std::fs::create_dir_all(&callgraph_dir)?;
2248 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2249 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2256 {
2257 let OpenedStore { store, root_repair } = Self::open_at_path(
2258 project_root.clone(),
2259 project_key.clone(),
2260 sqlite_path,
2261 generation,
2262 true,
2263 Some(Arc::clone(&writer_lease)),
2264 None,
2265 )?;
2266 match root_repair {
2267 OpenRootRepair::NeedsRebuild { .. } => {
2268 log_root_repair_rebuild(&root_repair);
2269 drop(store);
2270 let (stats, generation) = Self::cold_build_publish_locked(
2271 &callgraph_dir,
2272 &project_root,
2273 &project_key,
2274 files,
2275 chunk_size,
2276 Arc::clone(&writer_lease),
2277 )?;
2278 let store = Self::open_generation(
2279 &callgraph_dir,
2280 project_root,
2281 project_key,
2282 generation,
2283 writer_lease,
2284 )?;
2285 return Ok((store, Some(stats)));
2286 }
2287 OpenRootRepair::None | OpenRootRepair::ReRooted => {
2288 return Ok((store, None));
2289 }
2290 }
2291 }
2292 if let Some(store) = try_legacy_migration_or_fallback(
2293 &callgraph_dir,
2294 &project_root,
2295 &project_key,
2296 Arc::clone(&writer_lease),
2297 )? {
2298 return Ok((store, None));
2299 }
2300 let (stats, generation) = Self::cold_build_publish_locked(
2301 &callgraph_dir,
2302 &project_root,
2303 &project_key,
2304 files,
2305 chunk_size,
2306 Arc::clone(&writer_lease),
2307 )?;
2308 let store = Self::open_generation(
2309 &callgraph_dir,
2310 project_root,
2311 project_key,
2312 generation,
2313 writer_lease,
2314 )?;
2315 Ok((store, Some(stats)))
2316 }
2317
2318 pub fn migrate_legacy_with_lease(
2325 callgraph_dir: PathBuf,
2326 project_root: PathBuf,
2327 ) -> Result<Option<Self>> {
2328 let project_key = crate::search_index::artifact_cache_key(&project_root);
2329 let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2330 else {
2331 return Ok(None);
2332 };
2333 std::fs::create_dir_all(&callgraph_dir)?;
2334 cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2335
2336 if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2340 {
2341 let OpenedStore { store, root_repair } = Self::open_at_path(
2342 project_root,
2343 project_key,
2344 sqlite_path,
2345 generation,
2346 true,
2347 Some(writer_lease),
2348 None,
2349 )?;
2350 return match root_repair {
2351 OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2352 OpenRootRepair::NeedsRebuild { reason, .. } => {
2353 Err(CallGraphStoreError::Unavailable(format!(
2354 "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
2355 )))
2356 }
2357 };
2358 }
2359
2360 let store = try_legacy_migration_or_fallback(
2361 &callgraph_dir,
2362 &project_root,
2363 &project_key,
2364 writer_lease,
2365 )?;
2366 Ok(store.filter(|store| !store.is_legacy_fallback()))
2370 }
2371
2372 fn cold_build_publish_locked(
2383 callgraph_dir: &Path,
2384 project_root: &Path,
2385 project_key: &str,
2386 files: &[PathBuf],
2387 chunk_size: usize,
2388 writer_lease: Arc<crate::root_cache::WriterLease>,
2389 ) -> Result<(ColdBuildStats, String)> {
2390 if let Some((previous_root, remaining)) =
2391 rebuild_cooldown_denial(callgraph_dir, project_key, project_root, Instant::now())
2392 {
2393 return Err(CallGraphStoreError::Unavailable(format!(
2394 "cache key {project_key} was rebuilt for {} too recently; retry {} ms after the per-key cooldown",
2395 previous_root.display(),
2396 remaining.as_millis()
2397 )));
2398 }
2399 let generation = generation_file_name(project_key);
2400 let gen_path = callgraph_dir.join(&generation);
2401 let temp_path = callgraph_dir.join(format!(
2402 "{generation}.tmp.{}.{}",
2403 std::process::id(),
2404 now_nanos()
2405 ));
2406 remove_sqlite_file_set(&temp_path);
2407
2408 let stats = {
2409 let temp_store = Self::open_at_path(
2410 project_root.to_path_buf(),
2411 project_key.to_string(),
2412 temp_path.clone(),
2413 None,
2414 false,
2415 Some(Arc::clone(&writer_lease)),
2416 None,
2417 )?
2418 .store;
2419 let stats = temp_store.cold_build_chunked(files, chunk_size)?;
2420 let _ = temp_store.checkpoint_wal_truncate();
2421 temp_store.prepare_for_atomic_swap()?;
2422 stats
2423 };
2424
2425 notify_cold_build_before_publish_observer();
2426 let publication = publish_if_current(|| {
2427 verify_writer_lease(&writer_lease)?;
2428 remove_sqlite_file_set(&gen_path);
2431 crate::fs_lock::rename_over(&temp_path, &gen_path)?;
2432 crate::fs_lock::sync_parent(&gen_path);
2433 remove_sqlite_sidecars(&gen_path);
2434
2435 notify_cold_build_swap_observer(&temp_path, &gen_path);
2436
2437 verify_writer_lease(&writer_lease)?;
2439 publish_pointer(callgraph_dir, project_key, &generation)?;
2440 gc_old_generations(callgraph_dir, project_key, &generation);
2441 sweep_orphaned_build_temps_store_wide(callgraph_dir);
2445 if let Some(storage_root) = root_storage_dir(callgraph_dir) {
2446 let inspect_root =
2447 storage_root.join(crate::root_cache::RootCacheDomain::Inspect.as_str());
2448 let live_scope_keys = crate::root_cache::live_scope_keys_for_storage(&storage_root);
2449 crate::inspect::cache::sweep_inspect_scope_dirs(&inspect_root, &live_scope_keys);
2450 }
2451 Ok(())
2452 });
2453 if matches!(publication, Err(CallGraphStoreError::Superseded)) {
2454 remove_sqlite_file_set(&temp_path);
2455 }
2456 publication?;
2457 record_successful_rebuild(callgraph_dir, project_key, project_root, Instant::now());
2458 Ok((stats, generation))
2459 }
2460
2461 fn open_generation(
2464 callgraph_dir: &Path,
2465 project_root: PathBuf,
2466 project_key: String,
2467 generation: String,
2468 writer_lease: Arc<crate::root_cache::WriterLease>,
2469 ) -> Result<Self> {
2470 let gen_path = callgraph_dir.join(&generation);
2471 Ok(Self::open_at_path(
2472 project_root,
2473 project_key,
2474 gen_path,
2475 Some(generation),
2476 true,
2477 Some(writer_lease),
2478 None,
2479 )?
2480 .store)
2481 }
2482
2483 pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
2484 let project_key = crate::search_index::artifact_cache_key(project_root);
2485 Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
2488 }
2489
2490 fn open_at_path(
2491 project_root: PathBuf,
2492 project_key: String,
2493 sqlite_path: PathBuf,
2494 generation: Option<String>,
2495 use_wal: bool,
2496 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2497 read_marker: Option<crate::root_cache::ReadMarker>,
2498 ) -> Result<OpenedStore> {
2499 Self::open_at_path_with_root_repair(
2500 project_root,
2501 project_key,
2502 sqlite_path,
2503 generation,
2504 use_wal,
2505 writer_lease,
2506 read_marker,
2507 true,
2508 )
2509 }
2510
2511 fn open_at_path_with_root_repair(
2512 project_root: PathBuf,
2513 project_key: String,
2514 sqlite_path: PathBuf,
2515 generation: Option<String>,
2516 use_wal: bool,
2517 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2518 read_marker: Option<crate::root_cache::ReadMarker>,
2519 allow_root_repair: bool,
2520 ) -> Result<OpenedStore> {
2521 if let Some(lease) = writer_lease.as_ref() {
2522 verify_writer_lease(lease)?;
2523 }
2524 if let Some(parent) = sqlite_path.parent() {
2525 std::fs::create_dir_all(parent)?;
2526 }
2527 let mut conn = Connection::open(&sqlite_path)?;
2528 if use_wal {
2529 configure_connection(&conn)?;
2530 } else {
2531 configure_build_connection(&conn)?;
2532 }
2533 if let Some(lease) = writer_lease.as_ref() {
2534 verify_writer_lease(lease)?;
2535 }
2536 initialize_schema(&conn)?;
2537 if let Some(lease) = writer_lease.as_ref() {
2538 verify_writer_lease(lease)?;
2539 }
2540 let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
2541 let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
2542 (Some(marker), _, _) => Some(marker),
2543 (None, Some(label), Some(cache_dir)) => {
2544 Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
2545 }
2546 (None, _, _) => None,
2547 };
2548 let publication_dir = sqlite_path
2549 .parent()
2550 .map(Path::to_path_buf)
2551 .unwrap_or_default();
2552 let store = Self::from_connection(
2553 project_root,
2554 project_key,
2555 sqlite_path,
2556 publication_dir,
2557 false,
2558 generation,
2559 writer_lease,
2560 read_marker,
2561 conn,
2562 );
2563 Ok(OpenedStore { store, root_repair })
2564 }
2565
2566 fn prepare_for_atomic_swap(&self) -> Result<()> {
2567 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2568 conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
2569 Ok(())
2570 }
2571
2572 fn atomic_swap_checkpoint_sql(&self) -> &'static str {
2573 let protected_reader = self.generation.as_deref().is_some_and(|generation| {
2574 self.sqlite_path
2575 .parent()
2576 .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
2577 });
2578 if protected_reader {
2579 "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
2580 } else {
2581 "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
2582 }
2583 }
2584
2585 fn from_connection(
2586 project_root: PathBuf,
2587 project_key: String,
2588 sqlite_path: PathBuf,
2589 publication_dir: PathBuf,
2590 legacy_fallback: bool,
2591 generation: Option<String>,
2592 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2593 read_marker: Option<crate::root_cache::ReadMarker>,
2594 conn: Connection,
2595 ) -> Self {
2596 let write_metrics = callgraph_write_metrics_for_key(&project_key);
2597 Self {
2598 project_root,
2599 project_key,
2600 sqlite_path,
2601 publication_dir,
2602 legacy_fallback,
2603 generation,
2604 writer_lease,
2605 read_marker,
2606 database_ready: AtomicBool::new(false),
2607 write_metrics,
2608 conn: Mutex::new(conn),
2609 }
2610 }
2611
2612 fn ensure_ready(&self, conn: &Connection) -> Result<()> {
2613 if self.database_ready.load(AtomicOrdering::Acquire) {
2614 return Ok(());
2615 }
2616 ensure_database_ready(conn)?;
2617 self.database_ready.store(true, AtomicOrdering::Release);
2618 Ok(())
2619 }
2620
2621 pub fn project_root(&self) -> &Path {
2622 &self.project_root
2623 }
2624
2625 pub fn project_key(&self) -> &str {
2626 &self.project_key
2627 }
2628
2629 pub fn sqlite_path(&self) -> &Path {
2630 &self.sqlite_path
2631 }
2632
2633 pub fn is_legacy_fallback(&self) -> bool {
2636 self.legacy_fallback
2637 }
2638
2639 pub(crate) fn is_legacy_migration(&self) -> bool {
2640 self.generation.as_deref().is_some_and(|generation| {
2641 migration_generation_requires_manifest(generation)
2642 && migration_manifest_valid(&self.publication_dir, generation)
2643 })
2644 }
2645
2646 pub fn writer_epoch_for_test(&self) -> Option<&str> {
2647 self.writer_lease.as_ref().map(|lease| lease.epoch())
2648 }
2649
2650 fn verify_writer_lease(&self) -> Result<()> {
2651 let Some(lease) = self.writer_lease.as_ref() else {
2652 return Err(CallGraphStoreError::Unavailable(
2653 "callgraph store opened read-only; write API is unavailable".to_string(),
2654 ));
2655 };
2656 verify_writer_lease(lease)
2657 }
2658
2659 fn refresh_read_marker(&self) -> Result<()> {
2660 if let Some(marker) = self.read_marker.as_ref() {
2661 marker.touch_if_due()?;
2662 }
2663 Ok(())
2664 }
2665
2666 fn record_commit(&self, total_changes_before: u64, conn: &Connection) {
2667 self.write_metrics
2668 .record_commit(conn.total_changes().saturating_sub(total_changes_before));
2669 }
2670
2671 fn checkpoint_wal_truncate(&self) -> bool {
2672 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2673 checkpoint_wal_truncate(&conn)
2674 }
2675
2676 pub fn is_current(&self) -> bool {
2682 let _ = self.refresh_read_marker();
2683 match (
2684 read_pointer(&self.publication_dir, &self.project_key),
2685 &self.generation,
2686 ) {
2687 (Some(_), _) if self.legacy_fallback => false,
2690 (Some(published), Some(opened)) => &published == opened,
2691 (Some(_), None) => false,
2693 (None, _) => true,
2696 }
2697 }
2698
2699 pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
2700 self.cold_build_chunked(files, 0)
2701 }
2702
2703 pub fn cold_build_chunked(
2704 &self,
2705 files: &[PathBuf],
2706 chunk_size: usize,
2707 ) -> Result<ColdBuildStats> {
2708 let started = Instant::now();
2709 let bench = std::env::var("AFT_BENCH_COLD").is_ok();
2710 macro_rules! phase {
2711 ($label:expr, $t:expr) => {
2712 if bench {
2713 eprintln!(" cold_build[{}]: {} ms", $label, $t.elapsed().as_millis());
2714 let _ = std::io::Write::flush(&mut std::io::stderr());
2715 }
2716 };
2717 }
2718 let files = normalize_file_list(&self.project_root, files)?;
2719
2720 if chunk_size == 0 {
2721 let t = Instant::now();
2722 let build = build_extracts_parallel(&self.project_root, &files);
2723 phase!("extract_parallel", t);
2724 let extracts = build.extracts;
2725 let failures = build.failures;
2726 let node_count = extracts.iter().map(|extract| extract.nodes.len()).sum();
2727
2728 let t = Instant::now();
2729 let index = ProjectIndex::from_extracts(&self.project_root, &extracts);
2730 phase!("build_index", t);
2731 let t = Instant::now();
2732 let mut resolved_refs = Vec::new();
2733 for extract in &extracts {
2734 for raw_ref in &extract.raw_refs {
2735 resolved_refs.push(resolve_ref(raw_ref.clone(), &index)?);
2736 }
2737 }
2738 phase!("resolve_refs", t);
2739 let ref_count = resolved_refs.len();
2740 let edge_count = resolved_refs
2741 .iter()
2742 .filter(|item| item.edge.is_some())
2743 .count();
2744
2745 let t = Instant::now();
2746 self.verify_writer_lease()?;
2747 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2748 let total_changes_before = conn.total_changes();
2749 let tx = conn.transaction()?;
2750 clear_tables(&tx)?;
2751 insert_meta(&tx)?;
2752 drop_cold_build_secondary_indexes(&tx)?;
2753 {
2754 let workspace_root = self.project_root.display().to_string();
2755 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2756 for extract in &extracts {
2757 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
2758 }
2759 for failure in &failures {
2760 insert_backend_state_prepared(
2761 &mut inserts.backend_state,
2762 &workspace_root,
2763 &failure.rel_path,
2764 failure
2765 .freshness
2766 .as_ref()
2767 .map(|freshness| &freshness.content_hash),
2768 "stale",
2769 )?;
2770 }
2771 for resolved in &resolved_refs {
2772 insert_resolved_ref_prepared(&mut inserts, resolved)?;
2773 }
2774 }
2775 create_cold_build_secondary_indexes(&tx)?;
2776 let supplemental_edge_count =
2777 insert_method_dispatch_edges(&tx, &self.project_root, None)?;
2778 set_meta_ready(&tx, true)?;
2779 tx.commit()?;
2780 self.record_commit(total_changes_before, &conn);
2781 phase!("sqlite_insert", t);
2782
2783 let elapsed_ms = started.elapsed().as_millis();
2784 crate::slog_info!(
2785 "perf callgraph_store cold_build: files={} nodes={} refs={} edges={} ms={}",
2786 extracts.len(),
2787 node_count,
2788 ref_count,
2789 edge_count + supplemental_edge_count,
2790 elapsed_ms
2791 );
2792 return Ok(ColdBuildStats {
2793 files: extracts.len(),
2794 nodes: node_count,
2795 refs: ref_count,
2796 edges: edge_count + supplemental_edge_count,
2797 failed_files: failures
2798 .into_iter()
2799 .map(|failure| failure.rel_path)
2800 .collect(),
2801 elapsed_ms,
2802 });
2803 }
2804
2805 let t = Instant::now();
2808 self.verify_writer_lease()?;
2809 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2810 let total_changes_before = conn.total_changes();
2811 let tx = conn.transaction()?;
2812 clear_tables(&tx)?;
2813 insert_meta(&tx)?;
2814 drop_cold_build_secondary_indexes(&tx)?;
2815
2816 let mut all_raw_refs = Vec::new();
2817 let mut failures = Vec::new();
2818 let mut node_count = 0;
2819 let mut files_parsed = 0;
2820
2821 let mut persistent_call_data = Vec::new();
2822 let mut file_to_call_data_index = HashMap::new();
2823 let mut files_index = HashMap::new();
2824
2825 let workspace_root = self.project_root.display().to_string();
2826
2827 {
2828 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2829 for chunk in files.chunks(chunk_size) {
2830 let build = build_extracts_parallel(&self.project_root, chunk);
2831 failures.extend(build.failures.clone());
2832
2833 for extract in build.extracts {
2834 files_parsed += 1;
2835 node_count += extract.nodes.len();
2836 insert_file_extract_prepared(&mut inserts, &workspace_root, &extract)?;
2837
2838 let db_file_index = DbFileIndex::from_extract(&self.project_root, &extract);
2839 files_index.insert(extract.rel_path.clone(), db_file_index);
2840
2841 persistent_call_data.push(extract.data);
2842 let idx = persistent_call_data.len() - 1;
2843 file_to_call_data_index.insert(extract.rel_path.clone(), idx);
2844
2845 all_raw_refs.push((extract.rel_path, extract.raw_refs));
2846 }
2847 for failure in &build.failures {
2848 insert_backend_state_prepared(
2849 &mut inserts.backend_state,
2850 &workspace_root,
2851 &failure.rel_path,
2852 failure
2853 .freshness
2854 .as_ref()
2855 .map(|freshness| &freshness.content_hash),
2856 "stale",
2857 )?;
2858 }
2859 }
2860 }
2861
2862 let mut caller_data = HashMap::new();
2863 for (rel_path, idx) in &file_to_call_data_index {
2864 caller_data.insert(rel_path.clone(), &persistent_call_data[*idx]);
2865 }
2866 let indexed_caller_files = files_index.keys().cloned().collect::<BTreeSet<_>>();
2867 let index = ProjectIndex::from_parts(
2868 &self.project_root,
2869 files_index,
2870 caller_data,
2871 WorkspaceCratePrefixCache::default(),
2872 );
2873
2874 let mut resolved_refs = Vec::new();
2875 for (_, raw_refs) in all_raw_refs {
2876 for raw_ref in raw_refs {
2877 resolved_refs.push(resolve_ref(raw_ref, &index)?);
2878 }
2879 }
2880
2881 let ref_count = resolved_refs.len();
2882 let edge_count = resolved_refs
2883 .iter()
2884 .filter(|item| item.edge.is_some())
2885 .count();
2886
2887 {
2888 let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2889 for resolved in &resolved_refs {
2890 insert_resolved_ref_prepared(&mut inserts, resolved)?;
2891 }
2892 }
2893 create_cold_build_secondary_indexes(&tx)?;
2894 let supplemental_edge_count = insert_method_dispatch_edges_chunked(
2895 &tx,
2896 &self.project_root,
2897 &indexed_caller_files,
2898 chunk_size,
2899 )?;
2900 set_meta_ready(&tx, true)?;
2901 tx.commit()?;
2902 self.record_commit(total_changes_before, &conn);
2903 phase!("sqlite_insert", t);
2904
2905 let elapsed_ms = started.elapsed().as_millis();
2906 crate::slog_info!(
2907 "perf callgraph_store cold_build (chunked): files={} nodes={} refs={} edges={} ms={}",
2908 files_parsed,
2909 node_count,
2910 ref_count,
2911 edge_count + supplemental_edge_count,
2912 elapsed_ms
2913 );
2914 Ok(ColdBuildStats {
2915 files: files_parsed,
2916 nodes: node_count,
2917 refs: ref_count,
2918 edges: edge_count + supplemental_edge_count,
2919 failed_files: failures
2920 .into_iter()
2921 .map(|failure| failure.rel_path)
2922 .collect(),
2923 elapsed_ms,
2924 })
2925 }
2926
2927 pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
2928 self.refresh_files_with_workspace_crate_prefix_cache(
2929 changed_files,
2930 WorkspaceCratePrefixCache::default(),
2931 )
2932 }
2933
2934 fn refresh_files_with_workspace_crate_prefix_cache(
2935 &self,
2936 changed_files: &[PathBuf],
2937 workspace_crate_prefixes: WorkspaceCratePrefixCache,
2938 ) -> Result<IncrementalStats> {
2939 let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
2940 changed_files,
2941 workspace_crate_prefixes,
2942 )?;
2943 if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
2944 eprintln!("refresh_files phases: {}", profile.report());
2945 }
2946 Ok(stats)
2947 }
2948
2949 #[doc(hidden)]
2951 pub fn refresh_files_profiled(
2952 &self,
2953 changed_files: &[PathBuf],
2954 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2955 self.refresh_files_profiled_with_workspace_crate_prefix_cache(
2956 changed_files,
2957 WorkspaceCratePrefixCache::default(),
2958 )
2959 }
2960
2961 fn refresh_files_profiled_with_workspace_crate_prefix_cache(
2962 &self,
2963 changed_files: &[PathBuf],
2964 workspace_crate_prefixes: WorkspaceCratePrefixCache,
2965 ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2966 let total_started = Instant::now();
2967 let mut profile = RefreshFilesProfile::default();
2968 self.verify_writer_lease()?;
2969 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2970 let total_changes_before = conn.total_changes();
2971 let tx = conn.transaction()?;
2972 ensure_database_ready(&tx)?;
2973 let mut changed = Vec::new();
2974 let mut surface_changed = BTreeSet::new();
2975 let mut deleted = BTreeSet::new();
2976 let mut own_refresh = BTreeSet::new();
2977 let mut candidate_own_refresh = BTreeSet::new();
2978 let mut unchanged_extracts = 0usize;
2979 let mut selected_ref_ids = BTreeSet::new();
2980 let mut selected_refs_by_caller = BTreeMap::new();
2981 let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
2982
2983 for input in changed_files {
2984 let abs_path = normalize_file_path(&self.project_root, input)?;
2985 let rel_path = relative_path(&self.project_root, &abs_path);
2986 changed.push(rel_path.clone());
2987 let old_row = load_file_row(&tx, &rel_path)?;
2988 if !abs_path.exists() {
2989 if old_row.is_some() {
2990 surface_changed.insert(rel_path.clone());
2991 deleted.insert(rel_path.clone());
2992 let started = Instant::now();
2993 let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
2994 profile.dependency_selection += started.elapsed();
2995 record_dependent_refs(
2996 &mut selected_ref_ids,
2997 &mut selected_refs_by_caller,
2998 dependent_refs,
2999 );
3000 let started = Instant::now();
3001 delete_file_rows(&tx, &rel_path)?;
3002 clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
3003 profile.row_deletes += started.elapsed();
3004 }
3005 continue;
3006 }
3007
3008 if let Some(row) = &old_row {
3009 match cache_freshness::verify_file(&abs_path, &row.freshness) {
3010 FreshnessVerdict::HotFresh => continue,
3011 FreshnessVerdict::ContentFresh {
3012 new_mtime,
3013 new_size,
3014 } => {
3015 update_file_fresh_metadata(
3016 &tx,
3017 &self.project_root,
3018 &rel_path,
3019 &row.freshness.content_hash,
3020 new_mtime,
3021 new_size,
3022 )?;
3023 continue;
3024 }
3025 FreshnessVerdict::Deleted => {
3026 surface_changed.insert(rel_path.clone());
3027 deleted.insert(rel_path.clone());
3028 let started = Instant::now();
3029 let dependent_refs =
3030 ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
3031 profile.dependency_selection += started.elapsed();
3032 record_dependent_refs(
3033 &mut selected_ref_ids,
3034 &mut selected_refs_by_caller,
3035 dependent_refs,
3036 );
3037 let started = Instant::now();
3038 delete_file_rows(&tx, &rel_path)?;
3039 clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
3040 profile.row_deletes += started.elapsed();
3041 continue;
3042 }
3043 FreshnessVerdict::Stale => {}
3044 }
3045 }
3046
3047 let started = Instant::now();
3048 let extract = build_file_extract(&self.project_root, &abs_path)?;
3049 profile.parse += started.elapsed();
3050 let surface_is_changed = old_row
3051 .as_ref()
3052 .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
3053 .unwrap_or(true);
3054 if surface_is_changed {
3055 surface_changed.insert(rel_path.clone());
3056 let started = Instant::now();
3057 let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
3058 profile.dependency_selection += started.elapsed();
3059 record_dependent_refs(
3060 &mut selected_ref_ids,
3061 &mut selected_refs_by_caller,
3062 dependent_refs,
3063 );
3064 }
3065 candidate_own_refresh.insert(rel_path.clone());
3066 changed_extracts.insert(rel_path, extract);
3067 }
3068
3069 let dependency_selected_refs = selected_ref_ids.len();
3070 let mut touched_callers: BTreeSet<String> =
3071 selected_refs_by_caller.keys().cloned().collect();
3072 touched_callers.extend(candidate_own_refresh.iter().cloned());
3073
3074 let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
3075 for rel_path in &touched_callers {
3076 if deleted.contains(rel_path) {
3077 continue;
3078 }
3079 if let Some(extract) = changed_extracts.get(rel_path) {
3080 caller_extracts.insert(rel_path.clone(), extract.clone());
3081 continue;
3082 }
3083 let abs_path = self.project_root.join(rel_path);
3084 if abs_path.exists() {
3085 let started = Instant::now();
3086 let extract = build_file_extract(&self.project_root, &abs_path)?;
3087 profile.dependent_parse += started.elapsed();
3088 caller_extracts.insert(rel_path.clone(), extract);
3089 }
3090 }
3091
3092 let started = Instant::now();
3093 let index = ProjectIndex::from_db_and_callers(
3094 &tx,
3095 &self.project_root,
3096 &caller_extracts,
3097 workspace_crate_prefixes,
3098 )?;
3099 profile.index_load += started.elapsed();
3100
3101 for rel_path in &candidate_own_refresh {
3102 let Some(extract) = changed_extracts.get(rel_path) else {
3103 continue;
3104 };
3105 if !write_amplification_baseline_enabled()
3106 && stored_extract_matches(&tx, rel_path, extract, &index)?
3107 {
3108 unchanged_extracts += 1;
3109 update_file_fresh_metadata(
3110 &tx,
3111 &self.project_root,
3112 rel_path,
3113 &extract.freshness.content_hash,
3114 extract.freshness.mtime,
3115 extract.freshness.size,
3116 )?;
3117 continue;
3118 }
3119
3120 own_refresh.insert(rel_path.clone());
3121 let started = Instant::now();
3122 delete_file_rows(&tx, rel_path)?;
3123 profile.row_deletes += started.elapsed();
3124 let started = Instant::now();
3125 insert_file_extract(&tx, &self.project_root, extract)?;
3126 profile.row_inserts += started.elapsed();
3127 }
3128
3129 let dependency_callers = touched_callers
3130 .iter()
3131 .filter(|rel_path| {
3132 !deleted.contains(*rel_path) && !candidate_own_refresh.contains(*rel_path)
3133 })
3134 .cloned()
3135 .collect::<Vec<_>>();
3136 for rel_path in dependency_callers {
3137 let Some(extract) = caller_extracts.get(&rel_path) else {
3138 continue;
3139 };
3140 if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
3141 continue;
3142 }
3143
3144 own_refresh.insert(rel_path.clone());
3145 let started = Instant::now();
3146 delete_file_rows(&tx, &rel_path)?;
3147 profile.row_deletes += started.elapsed();
3148 let started = Instant::now();
3149 insert_file_extract(&tx, &self.project_root, extract)?;
3150 profile.row_inserts += started.elapsed();
3151 }
3152 let started = Instant::now();
3153 for rel_path in &touched_callers {
3154 if deleted.contains(rel_path) {
3155 continue;
3156 }
3157 let Some(extract) = caller_extracts.get(rel_path) else {
3158 continue;
3159 };
3160 if own_refresh.contains(rel_path) {
3161 delete_refs_for_caller(&tx, rel_path)?;
3162 for raw_ref in &extract.raw_refs {
3163 let resolved = resolve_ref(raw_ref.clone(), &index)?;
3164 insert_resolved_ref(&tx, &resolved)?;
3165 }
3166 continue;
3167 }
3168
3169 let selected_for_caller = selected_refs_by_caller
3170 .get(rel_path)
3171 .cloned()
3172 .unwrap_or_default();
3173 delete_ref_ids(&tx, &selected_for_caller)?;
3174 for raw_ref in &extract.raw_refs {
3175 if selected_for_caller.contains(&raw_ref.ref_id) {
3176 let resolved = resolve_ref(raw_ref.clone(), &index)?;
3177 insert_resolved_ref(&tx, &resolved)?;
3178 }
3179 }
3180 }
3181 profile.ref_resolution += started.elapsed();
3182
3183 let started = Instant::now();
3184 delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
3185 insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
3186 profile.method_dispatch += started.elapsed();
3187
3188 let started = Instant::now();
3189 commit_incremental_if_current(tx)?;
3190 self.record_commit(total_changes_before, &conn);
3191 profile.commit += started.elapsed();
3192 profile.total = total_started.elapsed();
3193 Ok((
3194 IncrementalStats {
3195 changed_files: changed,
3196 surface_changed: surface_changed.into_iter().collect(),
3197 deleted_files: deleted.into_iter().collect(),
3198 dependency_selected_refs,
3199 refreshed_own_files: own_refresh.len(),
3200 unchanged_extract_files: unchanged_extracts,
3201 },
3202 profile,
3203 ))
3204 }
3205
3206 pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
3207 self.cold_build(current_files)
3208 }
3209
3210 pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
3211 self.verify_writer_lease()?;
3212 let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3213 let total_changes_before = conn.total_changes();
3214 let tx = conn.transaction()?;
3215 let mut marked = Vec::new();
3216 for path in files {
3217 let abs_path = normalize_file_path(&self.project_root, path)?;
3218 let rel_path = relative_path(&self.project_root, &abs_path);
3219 let freshness = cache_freshness::collect(&abs_path).ok();
3220 mark_backend_state(
3221 &tx,
3222 &self.project_root,
3223 &rel_path,
3224 freshness.as_ref().map(|freshness| &freshness.content_hash),
3225 "stale",
3226 )?;
3227 marked.push(rel_path);
3228 }
3229 tx.commit()?;
3230 self.record_commit(total_changes_before, &conn);
3231 marked.sort();
3232 marked.dedup();
3233 Ok(marked)
3234 }
3235
3236 pub fn stale_files(&self) -> Result<Vec<String>> {
3237 self.refresh_read_marker()?;
3238 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3239 let mut stmt = conn.prepare(
3240 "SELECT DISTINCT file_path FROM backend_file_state
3241 WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
3242 ORDER BY file_path",
3243 )?;
3244 let rows = stmt.query_map(
3245 params![BACKEND_TREESITTER, self.project_root.display().to_string()],
3246 |row| row.get::<_, String>(0),
3247 )?;
3248 rows.collect::<std::result::Result<Vec<_>, _>>()
3249 .map_err(Into::into)
3250 }
3251
3252 pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
3253 self.refresh_read_marker()?;
3254 let rel_path = relative_path(
3255 &self.project_root,
3256 &normalize_file_path(&self.project_root, file)?,
3257 );
3258 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3259 conn.query_row(
3260 "SELECT status FROM backend_file_state
3261 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
3262 ORDER BY updated_at DESC LIMIT 1",
3263 params![
3264 BACKEND_TREESITTER,
3265 self.project_root.display().to_string(),
3266 rel_path
3267 ],
3268 |row| row.get(0),
3269 )
3270 .optional()
3271 .map_err(Into::into)
3272 }
3273
3274 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3275 self.refresh_read_marker()?;
3276 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3277 self.ensure_ready(&conn)?;
3278 edge_snapshot_with_conn(&conn)
3279 }
3280
3281 pub fn indexed_file_count(&self) -> Result<usize> {
3282 self.refresh_read_marker()?;
3283 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3284 self.ensure_ready(&conn)?;
3285 indexed_file_count(&conn)
3286 }
3287
3288 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3289 self.refresh_read_marker()?;
3290 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3291 let rel_path = relative_path(&self.project_root, &abs_path);
3292 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3293 self.ensure_ready(&conn)?;
3294 resolve_node_for_rel(&conn, &rel_path, symbol)
3295 }
3296
3297 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3302 self.refresh_read_marker()?;
3303 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3304 let rel_path = relative_path(&self.project_root, &abs_path);
3305 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3306 self.ensure_ready(&conn)?;
3307 nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
3308 }
3309
3310 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3312 self.refresh_read_marker()?;
3313 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3314 self.ensure_ready(&conn)?;
3315 nodes_matching_symbol(&conn, symbol)
3316 }
3317
3318 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3320 self.refresh_read_marker()?;
3321 let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3322 let rel_path = relative_path(&self.project_root, &abs_path);
3323 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3324 self.ensure_ready(&conn)?;
3325 direct_callers_for_tuple(&conn, &rel_path, symbol)
3326 }
3327
3328 pub fn direct_caller_counts_of(
3330 &self,
3331 targets: &[(String, String)],
3332 ) -> Result<HashMap<(String, String), usize>> {
3333 if targets.is_empty() {
3334 return Ok(HashMap::new());
3335 }
3336 self.refresh_read_marker()?;
3337 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3338 self.ensure_ready(&conn)?;
3339 direct_caller_counts_for_tuples(&conn, targets)
3340 }
3341
3342 pub fn callers_of(
3343 &self,
3344 file_rel: &Path,
3345 symbol: &str,
3346 depth: usize,
3347 ) -> Result<StoreCallersResult> {
3348 let target = self.node_for(file_rel, symbol)?;
3349 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3350 self.ensure_ready(&conn)?;
3351 let effective_depth = depth.max(1);
3352 let mut visited = HashSet::new();
3353 let mut callers = Vec::new();
3354 let mut depth_limited = false;
3355 let mut truncated = 0usize;
3356 collect_callers_recursive(
3357 &conn,
3358 &target.file,
3359 &target.symbol,
3360 effective_depth,
3361 0,
3362 &mut visited,
3363 &mut callers,
3364 &mut depth_limited,
3365 &mut truncated,
3366 )?;
3367 Ok(StoreCallersResult {
3368 target,
3369 callers,
3370 scanned_files: indexed_file_count(&conn)?,
3371 depth_limited,
3372 truncated,
3373 })
3374 }
3375
3376 pub fn impact_of(
3377 &self,
3378 file_rel: &Path,
3379 symbol: &str,
3380 depth: usize,
3381 ) -> Result<StoreImpactResult> {
3382 let callers = self.callers_of(file_rel, symbol, depth)?;
3383 let target_parameters = callers
3384 .target
3385 .signature
3386 .as_deref()
3387 .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
3388 .unwrap_or_default();
3389 let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
3390 for site in &callers.callers {
3391 source_lines_by_file
3392 .entry(site.caller.file.clone())
3393 .or_insert_with(|| {
3394 read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
3395 });
3396 }
3397 let enriched = callers
3398 .callers
3399 .iter()
3400 .map(|site| StoreImpactCaller {
3401 site: site.clone(),
3402 signature: site.caller.signature.clone(),
3403 is_entry_point: site.caller.is_entry_point,
3404 call_expression: source_lines_by_file
3405 .get(&site.caller.file)
3406 .and_then(|lines| lines.as_ref())
3407 .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
3408 .cloned(),
3409 parameters: site
3410 .caller
3411 .signature
3412 .as_deref()
3413 .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
3414 .unwrap_or_default(),
3415 })
3416 .collect();
3417 Ok(StoreImpactResult {
3418 target: callers.target,
3419 parameters: target_parameters,
3420 callers: enriched,
3421 depth_limited: callers.depth_limited,
3422 truncated: callers.truncated,
3423 })
3424 }
3425
3426 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3427 self.refresh_read_marker()?;
3428 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3429 self.ensure_ready(&conn)?;
3430 outgoing_calls_for_node(&conn, node)
3431 }
3432
3433 pub fn outgoing_calls_for_symbols(
3435 &self,
3436 sources: &[(String, String)],
3437 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3438 if sources.is_empty() {
3439 return Ok(HashMap::new());
3440 }
3441 self.refresh_read_marker()?;
3442 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3443 self.ensure_ready(&conn)?;
3444 outgoing_calls_for_symbol_tuples(&conn, sources)
3445 }
3446
3447 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3449 self.refresh_read_marker()?;
3450 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3451 self.ensure_ready(&conn)?;
3452 resolved_self_calls_for_node(&conn, node)
3453 }
3454
3455 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3456 self.refresh_read_marker()?;
3457 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3458 self.ensure_ready(&conn)?;
3459 unresolved_calls_for_node(&conn, node)
3460 }
3461
3462 pub fn call_tree(
3463 &self,
3464 file_rel: &Path,
3465 symbol: &str,
3466 max_depth: usize,
3467 ) -> Result<callgraph::CallTreeNode> {
3468 let node = self.node_for(file_rel, symbol)?;
3469 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3470 self.ensure_ready(&conn)?;
3471 let mut visited = HashSet::new();
3472 call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
3473 }
3474
3475 pub fn trace_to(
3476 &self,
3477 file_rel: &Path,
3478 symbol: &str,
3479 max_depth: usize,
3480 ) -> Result<callgraph::TraceToResult> {
3481 let target = self.node_for(file_rel, symbol)?;
3482 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3483 self.ensure_ready(&conn)?;
3484 let effective_max = if max_depth == 0 { 10 } else { max_depth };
3485
3486 #[derive(Clone)]
3487 struct PathElem {
3488 node: StoreNode,
3489 }
3490
3491 let initial = vec![PathElem {
3492 node: target.clone(),
3493 }];
3494 let mut complete_paths = Vec::new();
3495 if target.is_entry_point {
3496 complete_paths.push(initial.clone());
3497 }
3498
3499 let mut queue = vec![(initial, 0usize)];
3500 let mut max_depth_reached = false;
3501 let mut truncated_paths = 0usize;
3502
3503 while let Some((path, depth)) = queue.pop() {
3504 if depth >= effective_max {
3505 max_depth_reached = true;
3506 continue;
3507 }
3508 let Some(current) = path.last() else {
3509 continue;
3510 };
3511 let callers =
3512 direct_callers_for_tuple(&conn, ¤t.node.file, ¤t.node.symbol)?;
3513 if callers.is_empty() {
3514 if path.len() > 1 {
3515 truncated_paths += 1;
3516 }
3517 continue;
3518 }
3519
3520 let mut has_new_path = false;
3521 for site in callers {
3522 if path.iter().any(|elem| {
3523 elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
3524 }) {
3525 continue;
3526 }
3527 has_new_path = true;
3528 let mut new_path = path.clone();
3529 new_path.push(PathElem {
3530 node: site.caller.clone(),
3531 });
3532 if site.caller.is_entry_point {
3533 complete_paths.push(new_path.clone());
3534 }
3535 queue.push((new_path, depth + 1));
3536 }
3537 if !has_new_path && path.len() > 1 {
3538 truncated_paths += 1;
3539 }
3540 }
3541
3542 let mut paths: Vec<callgraph::TracePath> = complete_paths
3543 .into_iter()
3544 .map(|mut elems| {
3545 elems.reverse();
3546 let hops = elems
3547 .iter()
3548 .enumerate()
3549 .map(|(index, elem)| callgraph::TraceHop {
3550 symbol: elem.node.symbol.clone(),
3551 file: elem.node.file.clone(),
3552 line: elem.node.line,
3553 signature: elem.node.signature.clone(),
3554 is_entry_point: index == 0 && elem.node.is_entry_point,
3555 })
3556 .collect();
3557 callgraph::TracePath { hops }
3558 })
3559 .collect();
3560 paths.sort_by(|left, right| {
3561 let left_entry = left
3562 .hops
3563 .first()
3564 .map(|hop| hop.symbol.as_str())
3565 .unwrap_or("");
3566 let right_entry = right
3567 .hops
3568 .first()
3569 .map(|hop| hop.symbol.as_str())
3570 .unwrap_or("");
3571 left_entry
3572 .cmp(right_entry)
3573 .then(left.hops.len().cmp(&right.hops.len()))
3574 });
3575 let entry_points_found = paths
3576 .iter()
3577 .filter_map(|path| path.hops.first())
3578 .filter(|hop| hop.is_entry_point)
3579 .map(|hop| (hop.file.clone(), hop.symbol.clone()))
3580 .collect::<HashSet<_>>()
3581 .len();
3582
3583 Ok(callgraph::TraceToResult {
3584 target_symbol: target.symbol,
3585 target_file: target.file,
3586 total_paths: paths.len(),
3587 paths,
3588 entry_points_found,
3589 max_depth_reached,
3590 truncated_paths,
3591 })
3592 }
3593
3594 pub fn trace_to_symbol_candidates(
3595 &self,
3596 to_symbol: &str,
3597 ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
3598 self.refresh_read_marker()?;
3599 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3600 self.ensure_ready(&conn)?;
3601 let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
3602 for node in nodes_matching_symbol(&conn, to_symbol)? {
3603 candidates_by_file
3604 .entry(node.file)
3605 .and_modify(|line| *line = (*line).min(node.line))
3606 .or_insert(node.line);
3607 }
3608 let mut candidates: Vec<_> = candidates_by_file
3609 .into_iter()
3610 .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
3611 .collect();
3612 candidates
3613 .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
3614 Ok(candidates)
3615 }
3616
3617 pub fn trace_to_symbol(
3618 &self,
3619 file_rel: &Path,
3620 symbol: &str,
3621 to_symbol: &str,
3622 to_file: Option<&Path>,
3623 max_depth: usize,
3624 ) -> Result<callgraph::TraceToSymbolResult> {
3625 let origin = self.node_for(file_rel, symbol)?;
3626 let target_file = to_file
3627 .map(|path| normalize_file_path(&self.project_root, path))
3628 .transpose()?
3629 .map(|path| relative_path(&self.project_root, &path));
3630 let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3631 self.ensure_ready(&conn)?;
3632 let effective_max = if max_depth == 0 {
3633 10
3634 } else {
3635 max_depth.min(16)
3636 };
3637
3638 let start_hop = trace_to_symbol_hop(&origin);
3639 if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
3640 return Ok(callgraph::TraceToSymbolResult {
3641 path: Some(vec![start_hop]),
3642 complete: true,
3643 reason: None,
3644 });
3645 }
3646
3647 let mut queue = VecDeque::new();
3648 queue.push_back((origin.clone(), vec![start_hop], 0usize));
3649 let mut visited = HashSet::new();
3650 visited.insert((origin.file.clone(), origin.symbol.clone()));
3651 let mut max_depth_exhausted = false;
3652
3653 while let Some((current, path, depth)) = queue.pop_front() {
3654 let callees = outgoing_calls_for_node(&conn, ¤t)?
3655 .into_iter()
3656 .filter_map(|site| site.target)
3657 .collect::<Vec<_>>();
3658
3659 if depth >= effective_max {
3660 if callees
3661 .iter()
3662 .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
3663 {
3664 max_depth_exhausted = true;
3665 }
3666 continue;
3667 }
3668
3669 for callee in callees {
3670 if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
3671 continue;
3672 }
3673 let mut next_path = path.clone();
3674 next_path.push(trace_to_symbol_hop(&callee));
3675 if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
3676 return Ok(callgraph::TraceToSymbolResult {
3677 path: Some(next_path),
3678 complete: true,
3679 reason: None,
3680 });
3681 }
3682 queue.push_back((callee, next_path, depth + 1));
3683 }
3684 }
3685
3686 if max_depth_exhausted {
3687 Ok(callgraph::TraceToSymbolResult {
3688 path: None,
3689 complete: false,
3690 reason: Some("max_depth_exhausted".to_string()),
3691 })
3692 } else {
3693 Ok(callgraph::TraceToSymbolResult {
3694 path: None,
3695 complete: true,
3696 reason: Some("no_path_found".to_string()),
3697 })
3698 }
3699 }
3700}
3701
3702impl ReadonlyCallGraphStore {
3703 fn from_inner(inner: CallGraphStore) -> Self {
3704 Self { inner }
3705 }
3706
3707 pub fn project_root(&self) -> &Path {
3708 self.inner.project_root()
3709 }
3710
3711 pub fn project_key(&self) -> &str {
3712 self.inner.project_key()
3713 }
3714
3715 pub fn sqlite_path(&self) -> &Path {
3716 self.inner.sqlite_path()
3717 }
3718
3719 pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
3722 crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
3723 }
3724
3725 pub fn is_legacy_fallback(&self) -> bool {
3727 self.inner.is_legacy_fallback()
3728 }
3729
3730 pub fn is_current(&self) -> bool {
3731 self.inner.is_current()
3732 }
3733
3734 pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3735 self.inner.edge_snapshot()
3736 }
3737
3738 pub fn indexed_file_count(&self) -> Result<usize> {
3739 self.inner.indexed_file_count()
3740 }
3741
3742 pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3743 self.inner.node_for(file_rel, symbol)
3744 }
3745
3746 pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3747 self.inner.nodes_for(file_rel, symbol)
3748 }
3749
3750 pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3751 self.inner.nodes_matching(symbol)
3752 }
3753
3754 pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3755 self.inner.direct_callers_of(file_rel, symbol)
3756 }
3757
3758 pub fn direct_caller_counts_of(
3759 &self,
3760 targets: &[(String, String)],
3761 ) -> Result<HashMap<(String, String), usize>> {
3762 self.inner.direct_caller_counts_of(targets)
3763 }
3764
3765 pub fn callers_of(
3766 &self,
3767 file_rel: &Path,
3768 symbol: &str,
3769 depth: usize,
3770 ) -> Result<StoreCallersResult> {
3771 self.inner.callers_of(file_rel, symbol, depth)
3772 }
3773
3774 pub fn impact_of(
3775 &self,
3776 file_rel: &Path,
3777 symbol: &str,
3778 depth: usize,
3779 ) -> Result<StoreImpactResult> {
3780 self.inner.impact_of(file_rel, symbol, depth)
3781 }
3782
3783 pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3784 self.inner.outgoing_calls_of(node)
3785 }
3786
3787 pub fn outgoing_calls_for_symbols(
3788 &self,
3789 sources: &[(String, String)],
3790 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3791 self.inner.outgoing_calls_for_symbols(sources)
3792 }
3793
3794 pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3795 self.inner.resolved_self_calls_of(node)
3796 }
3797
3798 pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3799 self.inner.unresolved_calls_of(node)
3800 }
3801
3802 pub fn call_tree(
3803 &self,
3804 file_rel: &Path,
3805 symbol: &str,
3806 depth: usize,
3807 ) -> Result<callgraph::CallTreeNode> {
3808 self.inner.call_tree(file_rel, symbol, depth)
3809 }
3810
3811 pub fn trace_to(
3812 &self,
3813 file_rel: &Path,
3814 symbol: &str,
3815 max_depth: usize,
3816 ) -> Result<callgraph::TraceToResult> {
3817 self.inner.trace_to(file_rel, symbol, max_depth)
3818 }
3819
3820 pub fn trace_to_symbol_candidates(
3821 &self,
3822 to_symbol: &str,
3823 ) -> Result<Vec<TraceToSymbolCandidate>> {
3824 self.inner.trace_to_symbol_candidates(to_symbol)
3825 }
3826
3827 pub fn trace_to_symbol(
3828 &self,
3829 file_rel: &Path,
3830 symbol: &str,
3831 to_symbol: &str,
3832 to_file: Option<&Path>,
3833 max_depth: usize,
3834 ) -> Result<callgraph::TraceToSymbolResult> {
3835 self.inner
3836 .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3837 }
3838}
3839
3840impl CallGraphRead for CallGraphStore {
3841 fn project_root(&self) -> &Path {
3842 CallGraphStore::project_root(self)
3843 }
3844 fn project_key(&self) -> &str {
3845 CallGraphStore::project_key(self)
3846 }
3847 fn sqlite_path(&self) -> &Path {
3848 CallGraphStore::sqlite_path(self)
3849 }
3850 fn is_current(&self) -> bool {
3851 CallGraphStore::is_current(self)
3852 }
3853 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3854 CallGraphStore::edge_snapshot(self)
3855 }
3856 fn indexed_file_count(&self) -> Result<usize> {
3857 CallGraphStore::indexed_file_count(self)
3858 }
3859 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3860 CallGraphStore::node_for(self, file_rel, symbol)
3861 }
3862 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3863 CallGraphStore::nodes_for(self, file_rel, symbol)
3864 }
3865 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3866 CallGraphStore::nodes_matching(self, symbol)
3867 }
3868 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3869 CallGraphStore::direct_callers_of(self, file_rel, symbol)
3870 }
3871 fn direct_caller_counts_of(
3872 &self,
3873 targets: &[(String, String)],
3874 ) -> Result<HashMap<(String, String), usize>> {
3875 CallGraphStore::direct_caller_counts_of(self, targets)
3876 }
3877 fn callers_of(
3878 &self,
3879 file_rel: &Path,
3880 symbol: &str,
3881 depth: usize,
3882 ) -> Result<StoreCallersResult> {
3883 CallGraphStore::callers_of(self, file_rel, symbol, depth)
3884 }
3885 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3886 CallGraphStore::impact_of(self, file_rel, symbol, depth)
3887 }
3888 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3889 CallGraphStore::outgoing_calls_of(self, node)
3890 }
3891 fn outgoing_calls_for_symbols(
3892 &self,
3893 sources: &[(String, String)],
3894 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3895 CallGraphStore::outgoing_calls_for_symbols(self, sources)
3896 }
3897 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3898 CallGraphStore::resolved_self_calls_of(self, node)
3899 }
3900 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3901 CallGraphStore::unresolved_calls_of(self, node)
3902 }
3903 fn call_tree(
3904 &self,
3905 file_rel: &Path,
3906 symbol: &str,
3907 depth: usize,
3908 ) -> Result<callgraph::CallTreeNode> {
3909 CallGraphStore::call_tree(self, file_rel, symbol, depth)
3910 }
3911 fn trace_to(
3912 &self,
3913 file_rel: &Path,
3914 symbol: &str,
3915 max_depth: usize,
3916 ) -> Result<callgraph::TraceToResult> {
3917 CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
3918 }
3919 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3920 CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
3921 }
3922 fn trace_to_symbol(
3923 &self,
3924 file_rel: &Path,
3925 symbol: &str,
3926 to_symbol: &str,
3927 to_file: Option<&Path>,
3928 max_depth: usize,
3929 ) -> Result<callgraph::TraceToSymbolResult> {
3930 CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
3931 }
3932}
3933
3934impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
3935 fn project_root(&self) -> &Path {
3936 (**self).project_root()
3937 }
3938 fn project_key(&self) -> &str {
3939 (**self).project_key()
3940 }
3941 fn sqlite_path(&self) -> &Path {
3942 (**self).sqlite_path()
3943 }
3944 fn is_current(&self) -> bool {
3945 (**self).is_current()
3946 }
3947 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3948 (**self).edge_snapshot()
3949 }
3950 fn indexed_file_count(&self) -> Result<usize> {
3951 (**self).indexed_file_count()
3952 }
3953 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3954 (**self).node_for(file_rel, symbol)
3955 }
3956 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3957 (**self).nodes_for(file_rel, symbol)
3958 }
3959 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3960 (**self).nodes_matching(symbol)
3961 }
3962 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3963 (**self).direct_callers_of(file_rel, symbol)
3964 }
3965 fn direct_caller_counts_of(
3966 &self,
3967 targets: &[(String, String)],
3968 ) -> Result<HashMap<(String, String), usize>> {
3969 (**self).direct_caller_counts_of(targets)
3970 }
3971 fn callers_of(
3972 &self,
3973 file_rel: &Path,
3974 symbol: &str,
3975 depth: usize,
3976 ) -> Result<StoreCallersResult> {
3977 (**self).callers_of(file_rel, symbol, depth)
3978 }
3979 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3980 (**self).impact_of(file_rel, symbol, depth)
3981 }
3982 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3983 (**self).outgoing_calls_of(node)
3984 }
3985 fn outgoing_calls_for_symbols(
3986 &self,
3987 sources: &[(String, String)],
3988 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3989 (**self).outgoing_calls_for_symbols(sources)
3990 }
3991 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3992 (**self).resolved_self_calls_of(node)
3993 }
3994 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3995 (**self).unresolved_calls_of(node)
3996 }
3997 fn call_tree(
3998 &self,
3999 file_rel: &Path,
4000 symbol: &str,
4001 depth: usize,
4002 ) -> Result<callgraph::CallTreeNode> {
4003 (**self).call_tree(file_rel, symbol, depth)
4004 }
4005 fn trace_to(
4006 &self,
4007 file_rel: &Path,
4008 symbol: &str,
4009 max_depth: usize,
4010 ) -> Result<callgraph::TraceToResult> {
4011 (**self).trace_to(file_rel, symbol, max_depth)
4012 }
4013 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4014 (**self).trace_to_symbol_candidates(to_symbol)
4015 }
4016 fn trace_to_symbol(
4017 &self,
4018 file_rel: &Path,
4019 symbol: &str,
4020 to_symbol: &str,
4021 to_file: Option<&Path>,
4022 max_depth: usize,
4023 ) -> Result<callgraph::TraceToSymbolResult> {
4024 (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4025 }
4026}
4027
4028impl CallGraphRead for ReadonlyCallGraphStore {
4029 fn project_root(&self) -> &Path {
4030 self.project_root()
4031 }
4032 fn project_key(&self) -> &str {
4033 self.project_key()
4034 }
4035 fn sqlite_path(&self) -> &Path {
4036 self.sqlite_path()
4037 }
4038 fn is_current(&self) -> bool {
4039 self.is_current()
4040 }
4041 fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4042 self.edge_snapshot()
4043 }
4044 fn indexed_file_count(&self) -> Result<usize> {
4045 self.indexed_file_count()
4046 }
4047 fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4048 self.node_for(file_rel, symbol)
4049 }
4050 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4051 self.nodes_for(file_rel, symbol)
4052 }
4053 fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4054 self.nodes_matching(symbol)
4055 }
4056 fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4057 self.direct_callers_of(file_rel, symbol)
4058 }
4059 fn direct_caller_counts_of(
4060 &self,
4061 targets: &[(String, String)],
4062 ) -> Result<HashMap<(String, String), usize>> {
4063 self.direct_caller_counts_of(targets)
4064 }
4065 fn callers_of(
4066 &self,
4067 file_rel: &Path,
4068 symbol: &str,
4069 depth: usize,
4070 ) -> Result<StoreCallersResult> {
4071 self.callers_of(file_rel, symbol, depth)
4072 }
4073 fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4074 self.impact_of(file_rel, symbol, depth)
4075 }
4076 fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4077 self.outgoing_calls_of(node)
4078 }
4079 fn outgoing_calls_for_symbols(
4080 &self,
4081 sources: &[(String, String)],
4082 ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4083 self.outgoing_calls_for_symbols(sources)
4084 }
4085 fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4086 self.resolved_self_calls_of(node)
4087 }
4088 fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4089 self.unresolved_calls_of(node)
4090 }
4091 fn call_tree(
4092 &self,
4093 file_rel: &Path,
4094 symbol: &str,
4095 depth: usize,
4096 ) -> Result<callgraph::CallTreeNode> {
4097 self.call_tree(file_rel, symbol, depth)
4098 }
4099 fn trace_to(
4100 &self,
4101 file_rel: &Path,
4102 symbol: &str,
4103 max_depth: usize,
4104 ) -> Result<callgraph::TraceToResult> {
4105 self.trace_to(file_rel, symbol, max_depth)
4106 }
4107 fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4108 self.trace_to_symbol_candidates(to_symbol)
4109 }
4110 fn trace_to_symbol(
4111 &self,
4112 file_rel: &Path,
4113 symbol: &str,
4114 to_symbol: &str,
4115 to_file: Option<&Path>,
4116 max_depth: usize,
4117 ) -> Result<callgraph::TraceToSymbolResult> {
4118 self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4119 }
4120}
4121
4122fn indexed_file_count(conn: &Connection) -> Result<usize> {
4123 let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
4124 Ok(count.max(0) as usize)
4125}
4126
4127fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
4128 let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
4129 match candidates.as_slice() {
4130 [candidate] => Ok(candidate.clone()),
4131 [] => Err(AftError::SymbolNotFound {
4132 name: symbol.to_string(),
4133 file: rel_path.to_string(),
4134 }
4135 .into()),
4136 _ => Err(AftError::AmbiguousSymbol {
4137 name: symbol.to_string(),
4138 candidates: candidates
4139 .iter()
4140 .map(|candidate| candidate.symbol.clone())
4141 .collect(),
4142 }
4143 .into()),
4144 }
4145}
4146
4147fn nodes_for_file_matching_symbol(
4148 conn: &Connection,
4149 rel_path: &str,
4150 symbol: &str,
4151) -> Result<Vec<StoreNode>> {
4152 let qualified_query = symbol.contains("::");
4153 let sql = if qualified_query {
4154 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4155 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4156 FROM nodes n JOIN files f ON f.path = n.file_path
4157 WHERE n.file_path = ?1 AND n.scoped_name = ?2
4158 ORDER BY n.scoped_name, n.start_line, n.start_col"
4159 } else {
4160 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4161 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4162 FROM nodes n JOIN files f ON f.path = n.file_path
4163 WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
4164 ORDER BY n.scoped_name, n.start_line, n.start_col"
4165 };
4166 let mut stmt = conn.prepare(sql)?;
4167 let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
4168 rows.collect::<std::result::Result<Vec<_>, _>>()
4169 .map_err(Into::into)
4170}
4171
4172fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
4173 let qualified_query = symbol.contains("::");
4174 let sql = if qualified_query {
4175 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4176 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4177 FROM nodes n JOIN files f ON f.path = n.file_path
4178 WHERE n.scoped_name = ?1
4179 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4180 } else {
4181 "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4182 n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4183 FROM nodes n JOIN files f ON f.path = n.file_path
4184 WHERE n.scoped_name = ?1 OR n.name = ?1
4185 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4186 };
4187 let mut stmt = conn.prepare(sql)?;
4188 let rows = stmt.query_map(params![symbol], store_node_from_row)?;
4189 rows.collect::<std::result::Result<Vec<_>, _>>()
4190 .map_err(Into::into)
4191}
4192
4193fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
4194 store_node_from_row_at(row, 0)
4195}
4196
4197fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
4198 let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
4199 let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
4200 let lang_label_value: String = row.get(offset + 10)?;
4201 Ok(StoreNode {
4202 node_id: row.get(offset)?,
4203 file: row.get(offset + 1)?,
4204 symbol: row.get(offset + 2)?,
4205 name: row.get(offset + 3)?,
4206 kind: row.get(offset + 4)?,
4207 line: start_line.saturating_add(1),
4208 end_line: end_line.saturating_add(1),
4209 signature: row.get(offset + 7)?,
4210 exported: row.get::<_, i64>(offset + 8)? != 0,
4211 is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
4212 lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
4213 })
4214}
4215
4216fn optional_store_node_from_row_at(
4217 row: &rusqlite::Row<'_>,
4218 offset: usize,
4219) -> rusqlite::Result<Option<StoreNode>> {
4220 if row.get::<_, Option<String>>(offset)?.is_some() {
4221 store_node_from_row_at(row, offset).map(Some)
4222 } else {
4223 Ok(None)
4224 }
4225}
4226
4227#[allow(clippy::too_many_arguments)]
4228fn collect_callers_recursive(
4229 conn: &Connection,
4230 file: &str,
4231 symbol: &str,
4232 max_depth: usize,
4233 current_depth: usize,
4234 visited: &mut HashSet<(String, String)>,
4235 result: &mut Vec<StoreCallSite>,
4236 depth_limited: &mut bool,
4237 truncated: &mut usize,
4238) -> Result<()> {
4239 if current_depth >= max_depth {
4240 let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
4241 if omitted > 0 {
4242 *depth_limited = true;
4243 *truncated += omitted;
4244 }
4245 return Ok(());
4246 }
4247
4248 if !visited.insert((file.to_string(), symbol.to_string())) {
4249 return Ok(());
4250 }
4251
4252 let sites = direct_callers_for_tuple(conn, file, symbol)?;
4253 for site in sites {
4254 result.push(site.clone());
4255 if current_depth + 1 < max_depth {
4256 collect_callers_recursive(
4257 conn,
4258 &site.caller.file,
4259 &site.caller.symbol,
4260 max_depth,
4261 current_depth + 1,
4262 visited,
4263 result,
4264 depth_limited,
4265 truncated,
4266 )?;
4267 } else {
4268 let omitted =
4269 direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
4270 if omitted > 0 {
4271 *depth_limited = true;
4272 *truncated += omitted;
4273 }
4274 }
4275 }
4276 Ok(())
4277}
4278
4279const DIRECT_CALLER_COUNT_BATCH_SIZE: usize = 499;
4281
4282fn direct_caller_counts_for_tuples(
4283 conn: &Connection,
4284 targets: &[(String, String)],
4285) -> Result<HashMap<(String, String), usize>> {
4286 let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
4287 let mut counts = unique_targets
4288 .iter()
4289 .cloned()
4290 .map(|target| (target, 0usize))
4291 .collect::<HashMap<_, _>>();
4292
4293 let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
4294 for chunk in unique_targets.chunks(DIRECT_CALLER_COUNT_BATCH_SIZE) {
4295 let requested_values = (0..chunk.len())
4296 .map(|_| "(?, ?)")
4297 .collect::<Vec<_>>()
4298 .join(", ");
4299 let sql = format!(
4300 "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
4301 deduped AS (
4302 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
4303 FROM requested requested
4304 JOIN edges e
4305 ON e.target_file = requested.target_file
4306 AND e.target_symbol = requested.target_symbol
4307 AND e.kind = 'call'
4308 JOIN refs r ON r.ref_id = e.ref_id
4309 JOIN nodes src ON src.id = e.source_node
4310 JOIN files src_file ON src_file.path = src.file_path
4311 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
4312 )
4313 SELECT target_file, target_symbol, COUNT(*)
4314 FROM deduped
4315 GROUP BY target_file, target_symbol"
4316 );
4317 let bindings = chunk
4318 .iter()
4319 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4320 let mut stmt = conn.prepare(&sql)?;
4321 let rows = stmt.query_map(params_from_iter(bindings), |row| {
4322 Ok((
4323 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
4324 row.get::<_, i64>(2)?,
4325 ))
4326 })?;
4327 for row in rows {
4328 let (target, count) = row?;
4329 counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
4330 }
4331 }
4332
4333 Ok(counts)
4334}
4335
4336fn direct_caller_count_for_tuple(
4337 conn: &Connection,
4338 target_file: &str,
4339 target_symbol: &str,
4340) -> Result<usize> {
4341 let count: i64 = conn.query_row(
4342 "SELECT COUNT(*)
4343 FROM edges e
4344 JOIN refs r ON r.ref_id = e.ref_id
4345 JOIN nodes src ON src.id = e.source_node
4346 JOIN files src_file ON src_file.path = src.file_path
4347 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
4348 params![target_file, target_symbol],
4349 |row| row.get(0),
4350 )?;
4351 Ok(usize::try_from(count).unwrap_or(usize::MAX))
4352}
4353
4354fn direct_callers_for_tuple(
4355 conn: &Connection,
4356 target_file: &str,
4357 target_symbol: &str,
4358) -> Result<Vec<StoreCallSite>> {
4359 let mut stmt = conn.prepare(
4360 "SELECT e.target_file, e.target_symbol, e.line,
4361 r.byte_start, r.byte_end, r.status, e.provenance,
4362 src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
4363 src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
4364 src_file.lang,
4365 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4366 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4367 tgt_file.lang
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 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4373 ON tgt.id = e.target_node
4374 WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
4375 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
4376 )?;
4377 let rows = stmt.query_map(params![target_file, target_symbol], |row| {
4378 let caller = store_node_from_row_at(row, 7)?;
4379 let target = optional_store_node_from_row_at(row, 18)?;
4380 Ok(StoreCallSite {
4381 caller,
4382 target_file: row.get(0)?,
4383 target_symbol: row.get(1)?,
4384 target,
4385 line: row.get::<_, i64>(2)?.max(0) as u32,
4386 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4387 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4388 resolved: row.get::<_, String>(5)? == "resolved",
4389 provenance: row.get(6)?,
4390 })
4391 })?;
4392 rows.collect::<std::result::Result<Vec<_>, _>>()
4393 .map_err(Into::into)
4394}
4395
4396const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
4398const OUTGOING_NODE_BATCH_SIZE: usize = 999;
4400
4401fn outgoing_calls_for_symbol_tuples(
4402 conn: &Connection,
4403 sources: &[(String, String)],
4404) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4405 let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
4406 let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
4407 let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
4408 let source_nodes = unique_sources
4409 .iter()
4410 .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
4411 .cloned()
4412 .collect::<Vec<_>>();
4413 let source_nodes_by_id = source_nodes
4414 .iter()
4415 .cloned()
4416 .map(|node| (node.node_id.clone(), node))
4417 .collect::<HashMap<_, _>>();
4418 let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
4419
4420 for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
4421 let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
4422 let sql = format!(
4423 "SELECT e.source_node,
4424 e.target_file, e.target_symbol, e.line,
4425 r.byte_start, r.byte_end, r.status, e.provenance,
4426 CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
4427 tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4428 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4429 tgt_file.lang
4430 FROM edges e
4431 JOIN refs r ON r.ref_id = e.ref_id
4432 LEFT JOIN nodes tgt ON tgt.id = e.target_node
4433 LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
4434 WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
4435 ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
4436 );
4437 let bindings = chunk.iter().map(|node| node.node_id.as_str());
4438 let mut stmt = conn.prepare(&sql)?;
4439 let rows = stmt.query_map(params_from_iter(bindings), |row| {
4440 let source_node_id = row.get::<_, String>(0)?;
4441 let caller = source_nodes_by_id
4442 .get(&source_node_id)
4443 .expect("batched outgoing row belongs to a requested source node")
4444 .clone();
4445 let target = optional_store_node_from_row_at(row, 8)?;
4446 Ok((
4447 source_node_id,
4448 StoreCallSite {
4449 caller,
4450 target_file: row.get(1)?,
4451 target_symbol: row.get(2)?,
4452 target,
4453 line: row.get::<_, i64>(3)?.max(0) as u32,
4454 byte_start: row.get::<_, i64>(4)?.max(0) as usize,
4455 byte_end: row.get::<_, i64>(5)?.max(0) as usize,
4456 resolved: row.get::<_, String>(6)? == "resolved",
4457 provenance: row.get(7)?,
4458 },
4459 ))
4460 })?;
4461 for row in rows {
4462 let (source_node_id, call) = row?;
4463 calls_by_node.entry(source_node_id).or_default().push(call);
4464 }
4465 }
4466
4467 let mut calls_by_source = HashMap::new();
4468 for source in &unique_sources {
4469 let mut calls = Vec::new();
4470 if let Some(nodes) = source_nodes_by_symbol.get(source) {
4471 for node in nodes {
4472 if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
4473 calls.extend(node_calls);
4474 }
4475 }
4476 }
4477 calls_by_source.insert(source.clone(), calls);
4478 }
4479
4480 let target_tuples = calls_by_source
4483 .values()
4484 .flatten()
4485 .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
4486 .collect::<Vec<_>>();
4487 let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
4488 for calls in calls_by_source.values_mut() {
4489 for call in calls {
4490 if let Some(target) = target_nodes
4491 .get(&(call.target_file.clone(), call.target_symbol.clone()))
4492 .and_then(|nodes| nodes.first())
4493 {
4494 call.target = Some(target.clone());
4495 }
4496 }
4497 }
4498
4499 Ok(calls_by_source)
4500}
4501
4502fn nodes_for_symbol_tuples(
4503 conn: &Connection,
4504 symbols: &[(String, String)],
4505) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
4506 let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
4507 let mut nodes_by_symbol = unique_symbols
4508 .iter()
4509 .cloned()
4510 .map(|symbol| (symbol, Vec::new()))
4511 .collect::<HashMap<_, _>>();
4512 let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
4513
4514 for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
4515 let requested_values = (0..chunk.len())
4516 .map(|_| "(?, ?)")
4517 .collect::<Vec<_>>()
4518 .join(", ");
4519 let sql = format!(
4520 "WITH requested(file, symbol) AS (VALUES {requested_values})
4521 SELECT requested.file, requested.symbol,
4522 node.id, node.file_path, node.scoped_name, node.name, node.kind,
4523 node.start_line, node.end_line, node.signature, node.exported,
4524 node.is_callgraph_entry_point, node_file.lang
4525 FROM requested
4526 JOIN nodes node INDEXED BY idx_nodes_file
4527 ON node.file_path = requested.file
4528 AND node.scoped_name = requested.symbol
4529 JOIN files node_file ON node_file.path = node.file_path
4530 ORDER BY requested.file, requested.symbol,
4531 node.scoped_name, node.start_line, node.end_line,
4532 node.start_col, node.range_ordinal"
4533 );
4534 let bindings = chunk
4535 .iter()
4536 .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4537 let mut stmt = conn.prepare(&sql)?;
4538 let rows = stmt.query_map(params_from_iter(bindings), |row| {
4539 Ok((
4540 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
4541 store_node_from_row_at(row, 2)?,
4542 ))
4543 })?;
4544 for row in rows {
4545 let (symbol, node) = row?;
4546 nodes_by_symbol.entry(symbol).or_default().push(node);
4547 }
4548 }
4549
4550 Ok(nodes_by_symbol)
4551}
4552
4553fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4554 let mut stmt = conn.prepare(
4555 "SELECT e.target_file, e.target_symbol, e.line,
4556 r.byte_start, r.byte_end, r.status, e.provenance,
4557 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4558 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4559 tgt_file.lang
4560 FROM edges e
4561 JOIN refs r ON r.ref_id = e.ref_id
4562 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4563 ON tgt.id = e.target_node
4564 WHERE e.kind = 'call' AND e.source_node = ?1
4565 ORDER BY r.byte_start, r.line, r.ref_id",
4566 )?;
4567 let rows = stmt.query_map(params![node.node_id], |row| {
4568 let target = optional_store_node_from_row_at(row, 7)?;
4569 Ok(StoreCallSite {
4570 caller: node.clone(),
4571 target_file: row.get(0)?,
4572 target_symbol: row.get(1)?,
4573 target,
4574 line: row.get::<_, i64>(2)?.max(0) as u32,
4575 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4576 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4577 resolved: row.get::<_, String>(5)? == "resolved",
4578 provenance: row.get(6)?,
4579 })
4580 })?;
4581 rows.collect::<std::result::Result<Vec<_>, _>>()
4582 .map_err(Into::into)
4583}
4584
4585fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4586 let mut stmt = conn.prepare(
4587 "SELECT r.target_file, r.target_symbol, r.line,
4588 r.byte_start, r.byte_end, r.status, r.provenance,
4589 tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4590 tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4591 tgt_file.lang
4592 FROM refs r
4593 LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4594 ON tgt.id = r.target_node
4595 WHERE r.caller_node = ?1
4596 AND r.kind = 'call'
4597 AND r.status <> 'unresolved'
4598 AND r.target_file = ?2
4599 AND r.target_symbol = ?3
4600 AND r.provenance = ?4
4601 AND NOT EXISTS (
4602 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
4603 )
4604 ORDER BY r.byte_start, r.line, r.ref_id",
4605 )?;
4606 let rows = stmt.query_map(
4607 params![
4608 &node.node_id,
4609 &node.file,
4610 &node.symbol,
4611 PROVENANCE_TREESITTER
4612 ],
4613 |row| {
4614 let target = optional_store_node_from_row_at(row, 7)?;
4615 Ok(StoreCallSite {
4616 caller: node.clone(),
4617 target_file: row.get(0)?,
4618 target_symbol: row.get(1)?,
4619 target,
4620 line: row.get::<_, i64>(2)?.max(0) as u32,
4621 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4622 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4623 resolved: row.get::<_, String>(5)? == "resolved",
4624 provenance: row.get(6)?,
4625 })
4626 },
4627 )?;
4628 rows.collect::<std::result::Result<Vec<_>, _>>()
4629 .map_err(Into::into)
4630}
4631
4632fn unresolved_calls_for_node(
4633 conn: &Connection,
4634 node: &StoreNode,
4635) -> Result<Vec<StoreUnresolvedCall>> {
4636 let mut stmt = conn.prepare(
4637 "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
4638 FROM refs
4639 WHERE caller_node = ?1
4640 AND kind = 'call'
4641 AND status = 'unresolved'
4642 AND NOT EXISTS (
4643 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4644 )
4645 ORDER BY byte_start, line, ref_id",
4646 )?;
4647 let rows = stmt.query_map(params![node.node_id], |row| {
4648 Ok(StoreUnresolvedCall {
4649 caller: node.clone(),
4650 symbol: row.get(0)?,
4651 full_ref: row.get(1)?,
4652 line: row.get::<_, i64>(2)?.max(0) as u32,
4653 byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4654 byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4655 })
4656 })?;
4657 rows.collect::<std::result::Result<Vec<_>, _>>()
4658 .map_err(Into::into)
4659}
4660
4661fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
4662 let mut calls = Vec::new();
4663 calls.extend(
4664 outgoing_calls_for_node(conn, node)?
4665 .into_iter()
4666 .map(StoreForwardCall::Resolved),
4667 );
4668 calls.extend(
4669 unresolved_calls_for_node(conn, node)?
4670 .into_iter()
4671 .map(StoreForwardCall::Unresolved),
4672 );
4673 calls.sort_by(|left, right| {
4674 left.byte_start()
4675 .cmp(&right.byte_start())
4676 .then(left.line().cmp(&right.line()))
4677 });
4678 Ok(calls)
4679}
4680
4681fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
4682 let resolved_count: i64 = conn.query_row(
4683 "SELECT COUNT(*)
4684 FROM edges e
4685 JOIN refs r ON r.ref_id = e.ref_id
4686 WHERE e.kind = 'call' AND e.source_node = ?1",
4687 params![&node.node_id],
4688 |row| row.get(0),
4689 )?;
4690 let unresolved_count: i64 = conn.query_row(
4691 "SELECT COUNT(*)
4692 FROM refs
4693 WHERE caller_node = ?1
4694 AND kind = 'call'
4695 AND status = 'unresolved'
4696 AND NOT EXISTS (
4697 SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4698 )",
4699 params![&node.node_id],
4700 |row| row.get(0),
4701 )?;
4702 let total = resolved_count.saturating_add(unresolved_count);
4703 Ok(usize::try_from(total).unwrap_or(usize::MAX))
4704}
4705
4706fn call_tree_inner(
4707 conn: &Connection,
4708 node: &StoreNode,
4709 max_depth: usize,
4710 current_depth: usize,
4711 visited: &mut HashSet<(String, String)>,
4712) -> Result<callgraph::CallTreeNode> {
4713 let visit_key = (node.file.clone(), node.symbol.clone());
4714 if visited.contains(&visit_key) {
4715 return Ok(callgraph::CallTreeNode {
4716 name: node.symbol.clone(),
4717 file: node.file.clone(),
4718 line: node.line,
4719 signature: node.signature.clone(),
4720 resolved: true,
4721 children: Vec::new(),
4722 depth_limited: false,
4723 truncated: 0,
4724 });
4725 }
4726 visited.insert(visit_key.clone());
4727
4728 let mut children = Vec::new();
4729 let mut depth_limited = false;
4730 let mut truncated = 0usize;
4731
4732 if current_depth < max_depth {
4733 let calls = forward_calls_for_node(conn, node)?;
4734 for call in calls {
4735 match call {
4736 StoreForwardCall::Resolved(site) => {
4737 if let Some(target) = site.target {
4738 let child =
4739 call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
4740 depth_limited |= child.depth_limited;
4741 truncated += child.truncated;
4742 children.push(child);
4743 } else {
4744 children.push(callgraph::CallTreeNode {
4745 name: site.target_symbol,
4746 file: site.target_file,
4747 line: site.line,
4748 signature: None,
4749 resolved: false,
4750 children: Vec::new(),
4751 depth_limited: false,
4752 truncated: 0,
4753 });
4754 }
4755 }
4756 StoreForwardCall::Unresolved(call) => {
4757 children.push(callgraph::CallTreeNode {
4758 name: call.symbol,
4759 file: call.caller.file,
4760 line: call.line,
4761 signature: None,
4762 resolved: false,
4763 children: Vec::new(),
4764 depth_limited: false,
4765 truncated: 0,
4766 });
4767 }
4768 }
4769 }
4770 } else {
4771 truncated = forward_call_count_for_node(conn, node)?;
4772 depth_limited = truncated > 0;
4773 }
4774
4775 visited.remove(&visit_key);
4776 Ok(callgraph::CallTreeNode {
4777 name: node.symbol.clone(),
4778 file: node.file.clone(),
4779 line: node.line,
4780 signature: node.signature.clone(),
4781 resolved: true,
4782 children,
4783 depth_limited,
4784 truncated,
4785 })
4786}
4787
4788fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
4789 callgraph::TraceToSymbolHop {
4790 symbol: node.symbol.clone(),
4791 file: node.file.clone(),
4792 line: node.line,
4793 }
4794}
4795
4796fn trace_to_symbol_matches_target(
4797 node: &StoreNode,
4798 to_symbol: &str,
4799 to_file: Option<&str>,
4800) -> bool {
4801 if !symbol_query_matches(&node.symbol, to_symbol) {
4802 return false;
4803 }
4804 match to_file {
4805 Some(file) => node.file == file,
4806 None => true,
4807 }
4808}
4809
4810fn symbol_query_matches(symbol: &str, query: &str) -> bool {
4811 symbol == query || unqualified_name(symbol) == query
4812}
4813
4814fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
4815 let source = std::fs::read_to_string(path).ok()?;
4816 Some(source.lines().map(|line| line.trim().to_string()).collect())
4817}
4818
4819#[doc(hidden)]
4820pub fn live_callgraph_edge_snapshot(
4821 project_root: &Path,
4822 files: &[PathBuf],
4823) -> Result<BTreeSet<StoredEdge>> {
4824 let files = normalize_file_list(project_root, files)?;
4825 let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
4826 let mut file_data = Vec::new();
4827 for file in &files {
4828 let canon = canonicalize_path(file);
4829 let data = graph.build_file(&canon)?.clone();
4830 file_data.push((canon, data));
4831 }
4832
4833 let mut edges = BTreeSet::new();
4834 for (caller_file, data) in &file_data {
4835 for (caller_symbol, call_sites) in &data.calls_by_symbol {
4836 for call_site in call_sites {
4837 let resolution = graph.resolve_cross_file_edge(
4838 &call_site.full_callee,
4839 &call_site.callee_name,
4840 caller_file,
4841 &data.import_block,
4842 );
4843 let (target_file, target_symbol) = match resolution {
4844 EdgeResolution::Resolved { file, symbol } => (file, symbol),
4845 EdgeResolution::Unresolved { callee_name } => {
4846 if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
4847 continue;
4848 }
4849 let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
4850 data,
4851 caller_file,
4852 &callee_name,
4853 ) else {
4854 continue;
4855 };
4856 (caller_file.clone(), target_symbol)
4857 }
4858 };
4859 if target_file == *caller_file && target_symbol == *caller_symbol {
4860 continue;
4861 }
4862 edges.insert(StoredEdge {
4863 source_file: relative_path(project_root, caller_file),
4864 source_symbol: caller_symbol.clone(),
4865 target_file: relative_path(project_root, &target_file),
4866 target_symbol,
4867 kind: "call".to_string(),
4868 line: call_site.line,
4869 });
4870 }
4871 }
4872 }
4873 Ok(edges)
4874}
4875
4876fn rebuild_cooldown_records() -> &'static Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>
4877{
4878 SUCCESSFUL_REBUILDS.get_or_init(|| Mutex::new(HashMap::new()))
4879}
4880
4881fn rebuild_cooldown_key(callgraph_dir: &Path, project_key: &str) -> RebuildCooldownKey {
4882 RebuildCooldownKey {
4883 callgraph_dir: std::fs::canonicalize(callgraph_dir)
4884 .unwrap_or_else(|_| callgraph_dir.to_path_buf()),
4885 project_key: project_key.to_string(),
4886 }
4887}
4888
4889fn rebuild_cooldown_denial(
4890 callgraph_dir: &Path,
4891 project_key: &str,
4892 project_root: &Path,
4893 now: Instant,
4894) -> Option<(PathBuf, Duration)> {
4895 let key = rebuild_cooldown_key(callgraph_dir, project_key);
4896 let records = rebuild_cooldown_records()
4897 .lock()
4898 .unwrap_or_else(std::sync::PoisonError::into_inner);
4899 let record = records.get(&key)?;
4900 if record.project_root == project_root || !record.cross_root_cooldown_armed {
4901 return None;
4902 }
4903 let elapsed = now.saturating_duration_since(record.published_at);
4904 (elapsed < REBUILD_COOLDOWN).then(|| (record.project_root.clone(), REBUILD_COOLDOWN - elapsed))
4905}
4906
4907fn record_successful_rebuild(
4908 callgraph_dir: &Path,
4909 project_key: &str,
4910 project_root: &Path,
4911 published_at: Instant,
4912) {
4913 let key = rebuild_cooldown_key(callgraph_dir, project_key);
4914 let mut records = rebuild_cooldown_records()
4915 .lock()
4916 .unwrap_or_else(std::sync::PoisonError::into_inner);
4917 if records.len() >= 4_096 && !records.contains_key(&key) {
4918 if let Some(evict) = records.keys().next().cloned() {
4919 records.remove(&evict);
4920 }
4921 }
4922 let cross_root_cooldown_armed = records.get(&key).is_some_and(|previous| {
4923 previous.cross_root_cooldown_armed || previous.project_root != project_root
4924 });
4925 records.insert(
4926 key,
4927 RebuildCooldownRecord {
4928 project_root: project_root.to_path_buf(),
4929 published_at,
4930 cross_root_cooldown_armed,
4931 },
4932 );
4933}
4934
4935fn acquire_writer_lease(
4936 callgraph_dir: &Path,
4937 project_key: &str,
4938 project_root: &Path,
4939) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
4940 crate::root_cache::WriterLease::acquire_shared(
4941 crate::root_cache::RootCacheDomain::Callgraph,
4942 callgraph_dir,
4943 project_key,
4944 project_root,
4945 )
4946 .map_err(CallGraphStoreError::from)
4947}
4948
4949fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
4950 if lease.verify()? {
4951 Ok(())
4952 } else {
4953 Err(CallGraphStoreError::Unavailable(format!(
4954 "callgraph writer lease for key {} lost epoch {}; aborting write",
4955 lease.key(),
4956 lease.epoch()
4957 )))
4958 }
4959}
4960
4961fn legacy_migration_completion_line(
4962 project_key: &str,
4963 method: &str,
4964 legacy_bytes: u64,
4965 migrated_bytes: u64,
4966) -> String {
4967 format!(
4968 "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
4969 )
4970}
4971
4972fn log_legacy_migration_completion(
4973 project_key: &str,
4974 method: &str,
4975 legacy_bytes: u64,
4976 migrated_bytes: u64,
4977) {
4978 crate::slog_info!(
4979 "{}",
4980 legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
4981 );
4982}
4983
4984fn try_legacy_migration_or_fallback(
4985 callgraph_dir: &Path,
4986 project_root: &Path,
4987 project_key: &str,
4988 writer_lease: Arc<crate::root_cache::WriterLease>,
4989) -> Result<Option<CallGraphStore>> {
4990 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
4991 if partitions.is_empty() {
4992 return Ok(None);
4993 }
4994
4995 for partition in &partitions {
4996 if let Some(source) = newest_superseded_legacy_generation(partition)? {
4997 if !migration_disk_floor_allows(&source, callgraph_dir)? {
4998 return open_legacy_fallback_store(
4999 callgraph_dir,
5000 project_root,
5001 project_key,
5002 &partitions,
5003 );
5004 }
5005 match publish_generation_copy_migration(
5006 callgraph_dir,
5007 project_key,
5008 &source,
5009 Arc::clone(&writer_lease),
5010 ) {
5011 Ok(published) => {
5012 log_legacy_migration_completion(
5013 project_key,
5014 "generation_copy",
5015 source.source_bytes,
5016 published.migrated_bytes,
5017 );
5018 return CallGraphStore::open_generation(
5019 callgraph_dir,
5020 project_root.to_path_buf(),
5021 project_key.to_string(),
5022 published.generation,
5023 writer_lease,
5024 )
5025 .map(Some);
5026 }
5027 Err(error) => {
5028 crate::slog_warn!(
5029 "root-keyed callgraph generation-copy migration failed from {}: {}",
5030 source.sqlite_path.display(),
5031 error
5032 );
5033 return open_legacy_fallback_store(
5034 callgraph_dir,
5035 project_root,
5036 project_key,
5037 &partitions,
5038 );
5039 }
5040 }
5041 }
5042
5043 if let Some(source) = current_legacy_generation(partition)? {
5044 if !migration_disk_floor_allows(&source, callgraph_dir)? {
5045 return open_legacy_fallback_store(
5046 callgraph_dir,
5047 project_root,
5048 project_key,
5049 &partitions,
5050 );
5051 }
5052 match publish_backup_migration(
5053 callgraph_dir,
5054 project_key,
5055 &source,
5056 Arc::clone(&writer_lease),
5057 ) {
5058 Ok(published) => {
5059 log_legacy_migration_completion(
5060 project_key,
5061 "sqlite_backup",
5062 source.source_bytes,
5063 published.migrated_bytes,
5064 );
5065 return CallGraphStore::open_generation(
5066 callgraph_dir,
5067 project_root.to_path_buf(),
5068 project_key.to_string(),
5069 published.generation,
5070 writer_lease,
5071 )
5072 .map(Some);
5073 }
5074 Err(error) => {
5075 crate::slog_warn!(
5076 "root-keyed callgraph backup migration failed from {}: {}",
5077 source.sqlite_path.display(),
5078 error
5079 );
5080 return open_legacy_fallback_store(
5081 callgraph_dir,
5082 project_root,
5083 project_key,
5084 &partitions,
5085 );
5086 }
5087 }
5088 }
5089 }
5090
5091 open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
5092}
5093
5094fn open_legacy_fallback_store(
5095 callgraph_dir: &Path,
5096 project_root: &Path,
5097 project_key: &str,
5098 partitions: &[LegacyCallgraphPartition],
5099) -> Result<Option<CallGraphStore>> {
5100 let Some(target) = first_ready_legacy_target(partitions)? else {
5101 return Ok(None);
5102 };
5103 crate::slog_warn!(
5104 "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
5105 target.partition.harness,
5106 target.sqlite_path.display()
5107 );
5108 let conn = open_readonly_connection(&target.sqlite_path)?;
5109 if !database_ready(&conn).unwrap_or(false) {
5110 return Ok(None);
5111 }
5112 let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
5113 let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
5114 Ok(Some(CallGraphStore::from_connection(
5115 project_root.to_path_buf(),
5116 project_key.to_string(),
5117 target.sqlite_path,
5118 callgraph_dir.to_path_buf(),
5119 true,
5120 target.generation,
5121 None,
5122 Some(read_marker),
5123 conn,
5124 )))
5125}
5126
5127fn migration_disk_floor_allows(
5128 source: &LegacyCallgraphTarget,
5129 callgraph_dir: &Path,
5130) -> Result<bool> {
5131 let available = migration_available_disk(callgraph_dir)?;
5132 let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
5133 source.source_bytes,
5134 available,
5135 );
5136 if decision.should_skip_copy() {
5137 crate::slog_warn!(
5138 "{}",
5139 decision.warning_message(&source.sqlite_path, callgraph_dir)
5140 );
5141 return Ok(false);
5142 }
5143 Ok(true)
5144}
5145
5146fn migration_available_disk(path: &Path) -> Result<u64> {
5147 if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
5148 return Ok(bytes);
5149 }
5150 crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
5151}
5152
5153fn legacy_callgraph_partitions(
5154 callgraph_dir: &Path,
5155 project_key: &str,
5156) -> Result<Vec<LegacyCallgraphPartition>> {
5157 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5158 return Ok(Vec::new());
5159 };
5160 let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
5161 let mut partitions = inventory
5162 .into_iter()
5163 .filter(|entry| {
5164 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
5165 && entry.key == project_key
5166 })
5167 .map(|entry| {
5168 let dir = if entry.path.is_dir() {
5169 entry.path.clone()
5170 } else {
5171 entry
5172 .path
5173 .parent()
5174 .map(Path::to_path_buf)
5175 .unwrap_or_else(|| entry.path.clone())
5176 };
5177 LegacyCallgraphPartition {
5178 harness: entry.harness,
5179 dir,
5180 key: entry.key,
5181 bytes: entry.bytes,
5182 freshness: entry.callgraph_pointer_mtime,
5183 }
5184 })
5185 .collect::<Vec<_>>();
5186 partitions.sort_by(|left, right| {
5187 right
5188 .freshness
5189 .cmp(&left.freshness)
5190 .then_with(|| right.bytes.cmp(&left.bytes))
5191 .then_with(|| left.harness.cmp(&right.harness))
5192 });
5193 Ok(partitions)
5194}
5195
5196fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
5197 let domain_dir = callgraph_dir.parent()?;
5198 if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
5199 return None;
5200 }
5201 domain_dir.parent().map(Path::to_path_buf)
5202}
5203
5204pub(crate) fn all_legacy_partitions_migrated_for_keys(
5205 callgraph_dir: &Path,
5206 configured_keys: &BTreeSet<String>,
5207) -> Result<bool> {
5208 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5209 return Ok(false);
5210 };
5211 let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
5212 .into_iter()
5213 .filter(|entry| {
5214 entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
5215 && configured_keys.contains(&entry.key)
5216 })
5217 .map(|entry| entry.key)
5218 .collect::<BTreeSet<_>>();
5219 if legacy_keys.is_empty() {
5220 return Ok(false);
5221 }
5222
5223 for key in legacy_keys {
5224 let migrated_dir = storage_root.join("callgraph").join(&key);
5225 let Some(generation) = read_pointer(&migrated_dir, &key) else {
5226 return Ok(false);
5227 };
5228 if !migration_generation_requires_manifest(&generation)
5229 || !migration_manifest_valid(&migrated_dir, &generation)
5230 {
5231 return Ok(false);
5232 }
5233 }
5234 Ok(true)
5235}
5236
5237fn newest_superseded_legacy_generation(
5238 partition: &LegacyCallgraphPartition,
5239) -> Result<Option<LegacyCallgraphTarget>> {
5240 let Some(current) = read_pointer(&partition.dir, &partition.key) else {
5241 return Ok(None);
5242 };
5243 let prefix = format!("{}.g", partition.key);
5244 let Ok(entries) = std::fs::read_dir(&partition.dir) else {
5245 return Ok(None);
5246 };
5247 let mut candidates = Vec::new();
5248 for entry in entries.flatten() {
5249 let name = entry.file_name().to_string_lossy().to_string();
5250 if name == current
5251 || name.contains(".tmp.")
5252 || !name.starts_with(&prefix)
5253 || !name.ends_with(".sqlite")
5254 {
5255 continue;
5256 }
5257 let path = entry.path();
5258 if !db_path_ready(&path) {
5259 continue;
5260 }
5261 let modified = entry
5262 .metadata()
5263 .and_then(|metadata| metadata.modified())
5264 .unwrap_or(SystemTime::UNIX_EPOCH);
5265 candidates.push((modified, path, name));
5266 }
5267 candidates.sort_by(|left, right| right.0.cmp(&left.0));
5268 let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
5269 return Ok(None);
5270 };
5271 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5272 Ok(Some(LegacyCallgraphTarget {
5273 partition: partition.clone(),
5274 sqlite_path,
5275 generation: Some(generation),
5276 source_bytes,
5277 source_blake3: String::new(),
5278 }))
5279}
5280
5281fn current_legacy_generation(
5282 partition: &LegacyCallgraphPartition,
5283) -> Result<Option<LegacyCallgraphTarget>> {
5284 let Some(target) = ready_legacy_target(partition)? else {
5285 return Ok(None);
5286 };
5287 let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
5288 if has_superseded {
5289 return Ok(None);
5290 }
5291 Ok(Some(target))
5292}
5293
5294fn freshest_legacy_fallback_target(
5295 callgraph_dir: &Path,
5296 project_key: &str,
5297) -> Result<Option<LegacyCallgraphTarget>> {
5298 let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
5299 first_ready_legacy_target(&partitions)
5300}
5301
5302fn first_ready_legacy_target(
5303 partitions: &[LegacyCallgraphPartition],
5304) -> Result<Option<LegacyCallgraphTarget>> {
5305 for partition in partitions {
5306 if let Some(target) = ready_legacy_target(partition)? {
5307 return Ok(Some(target));
5308 }
5309 }
5310 Ok(None)
5311}
5312
5313fn ready_legacy_target(
5314 partition: &LegacyCallgraphPartition,
5315) -> Result<Option<LegacyCallgraphTarget>> {
5316 if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
5317 let sqlite_path = partition.dir.join(&generation);
5318 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
5319 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5320 return Ok(Some(LegacyCallgraphTarget {
5321 partition: partition.clone(),
5322 sqlite_path,
5323 generation: Some(generation),
5324 source_bytes,
5325 source_blake3: String::new(),
5326 }));
5327 }
5328 }
5329
5330 let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
5331 if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
5332 let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5333 return Ok(Some(LegacyCallgraphTarget {
5334 partition: partition.clone(),
5335 sqlite_path,
5336 generation: None,
5337 source_bytes,
5338 source_blake3: String::new(),
5339 }));
5340 }
5341 Ok(None)
5342}
5343
5344fn publish_generation_copy_migration(
5345 callgraph_dir: &Path,
5346 project_key: &str,
5347 source: &LegacyCallgraphTarget,
5348 writer_lease: Arc<crate::root_cache::WriterLease>,
5349) -> Result<PublishedLegacyMigration> {
5350 let generation = migration_generation_file_name(project_key, "copy");
5351 let temp_path = migration_temp_path(callgraph_dir, &generation);
5352 remove_sqlite_file_set(&temp_path);
5353 copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
5354 fail_after_temp_copy_for_test()?;
5355
5356 let mut source = source.clone();
5357 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
5358 source.source_blake3 = fingerprint.blake3;
5359 let generation = publish_migrated_generation(
5360 callgraph_dir,
5361 project_key,
5362 &generation,
5363 &temp_path,
5364 &source,
5365 fingerprint.bytes,
5366 writer_lease,
5367 "generation_copy",
5368 )?;
5369 Ok(PublishedLegacyMigration {
5370 generation,
5371 migrated_bytes: fingerprint.bytes,
5372 })
5373}
5374
5375fn publish_backup_migration(
5376 callgraph_dir: &Path,
5377 project_key: &str,
5378 source: &LegacyCallgraphTarget,
5379 writer_lease: Arc<crate::root_cache::WriterLease>,
5380) -> Result<PublishedLegacyMigration> {
5381 if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
5382 return Err(CallGraphStoreError::Unavailable(
5383 "legacy callgraph backup migration budget exhausted by test seam".to_string(),
5384 ));
5385 }
5386
5387 let generation = migration_generation_file_name(project_key, "backup");
5388 let temp_path = migration_temp_path(callgraph_dir, &generation);
5389 remove_sqlite_file_set(&temp_path);
5390
5391 let source_conn = open_readonly_connection(&source.sqlite_path)?;
5392 let mut destination = Connection::open(&temp_path)?;
5393 destination.busy_timeout(Duration::from_secs(5))?;
5394 let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
5395 let started = Instant::now();
5396 let mut retries = 0;
5397 loop {
5398 match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
5399 rusqlite::backup::StepResult::Done => break,
5400 rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
5401 rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
5402 retries += 1;
5403 if retries > MIGRATION_BACKUP_RETRY_BUDGET
5404 || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
5405 {
5406 return Err(CallGraphStoreError::Unavailable(format!(
5407 "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
5408 )));
5409 }
5410 std::thread::sleep(Duration::from_millis(20));
5411 }
5412 _ => {
5413 return Err(CallGraphStoreError::Unavailable(
5414 "legacy callgraph backup returned an unknown step result".to_string(),
5415 ));
5416 }
5417 }
5418 }
5419 drop(backup);
5420
5421 let integrity: String =
5422 destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
5423 if integrity != "ok" {
5424 return Err(CallGraphStoreError::Unavailable(format!(
5425 "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
5426 )));
5427 }
5428 if !database_ready(&destination)? {
5429 return Err(CallGraphStoreError::Unavailable(
5430 "legacy callgraph backup produced a database without ready metadata".to_string(),
5431 ));
5432 }
5433 destination.execute_batch("PRAGMA optimize;")?;
5434 drop(destination);
5435 sync_file(&temp_path)?;
5436 fail_after_temp_copy_for_test()?;
5437
5438 let mut source = source.clone();
5439 let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
5440 source.source_blake3 = fingerprint.blake3;
5441 let generation = publish_migrated_generation(
5442 callgraph_dir,
5443 project_key,
5444 &generation,
5445 &temp_path,
5446 &source,
5447 fingerprint.bytes,
5448 writer_lease,
5449 "sqlite_backup",
5450 )?;
5451 Ok(PublishedLegacyMigration {
5452 generation,
5453 migrated_bytes: fingerprint.bytes,
5454 })
5455}
5456
5457fn publish_migrated_generation(
5458 callgraph_dir: &Path,
5459 project_key: &str,
5460 generation: &str,
5461 temp_path: &Path,
5462 source: &LegacyCallgraphTarget,
5463 migrated_bytes: u64,
5464 writer_lease: Arc<crate::root_cache::WriterLease>,
5465 method: &str,
5466) -> Result<String> {
5467 let gen_path = callgraph_dir.join(generation);
5468 checkpoint_sqlite_before_publication(temp_path);
5469 let publication = publish_if_current(|| {
5470 verify_writer_lease(&writer_lease)?;
5471 remove_sqlite_file_set(&gen_path);
5472 rename_sqlite_file_set(temp_path, &gen_path)?;
5473 crate::fs_lock::sync_parent(&gen_path);
5474
5475 verify_writer_lease(&writer_lease)?;
5476 publish_pointer(callgraph_dir, project_key, generation)?;
5477 write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
5478 Ok(generation.to_string())
5479 });
5480 if matches!(publication, Err(CallGraphStoreError::Superseded)) {
5481 remove_sqlite_file_set(temp_path);
5482 }
5483 publication
5484}
5485
5486fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
5487 if let Some(parent) = destination.parent() {
5488 std::fs::create_dir_all(parent)?;
5489 }
5490 for suffix in SQLITE_FILE_SET_SUFFIXES {
5491 let source_path = sqlite_file_set_path(source, suffix);
5492 if !source_path.is_file() {
5493 continue;
5494 }
5495 let destination_path = sqlite_file_set_path(destination, suffix);
5496 std::fs::copy(&source_path, &destination_path)?;
5497 sync_file(&destination_path)?;
5498 }
5499 Ok(())
5500}
5501
5502fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
5503 for suffix in SQLITE_FILE_SET_SUFFIXES {
5504 let source_path = sqlite_file_set_path(source, suffix);
5505 if !source_path.exists() {
5506 continue;
5507 }
5508 let destination_path = sqlite_file_set_path(destination, suffix);
5509 if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
5510 let _ = std::fs::remove_file(&source_path);
5511 return Err(error.into());
5512 }
5513 }
5514 Ok(())
5515}
5516
5517fn sqlite_file_set_size(path: &Path) -> Result<u64> {
5518 let mut bytes = 0_u64;
5519 for suffix in SQLITE_FILE_SET_SUFFIXES {
5520 let member = sqlite_file_set_path(path, suffix);
5521 if !member.is_file() {
5522 continue;
5523 }
5524 bytes = bytes.saturating_add(member.metadata()?.len());
5525 }
5526 Ok(bytes)
5527}
5528
5529fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
5530 let mut hasher = blake3::Hasher::new();
5531 let mut bytes = 0_u64;
5532 let mut buffer = [0_u8; 64 * 1024];
5533 for suffix in SQLITE_FILE_SET_SUFFIXES {
5534 let member = sqlite_file_set_path(path, suffix);
5535 if !member.is_file() {
5536 continue;
5537 }
5538 hasher.update(suffix.as_bytes());
5539 let mut file = std::fs::File::open(&member)?;
5540 loop {
5541 let read = file.read(&mut buffer)?;
5542 if read == 0 {
5543 break;
5544 }
5545 bytes = bytes.saturating_add(read as u64);
5546 hasher.update(&buffer[..read]);
5547 }
5548 }
5549 Ok(SourceFingerprint {
5550 bytes,
5551 blake3: hash_to_hex(hasher.finalize()),
5552 })
5553}
5554
5555fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
5556 if suffix.is_empty() {
5557 path.to_path_buf()
5558 } else {
5559 PathBuf::from(format!("{}{suffix}", path.display()))
5560 }
5561}
5562
5563fn sync_file(path: &Path) -> Result<()> {
5564 let file = std::fs::OpenOptions::new()
5565 .read(true)
5566 .write(true)
5567 .open(path)?;
5568 file.sync_all()?;
5569 Ok(())
5570}
5571
5572fn fail_after_temp_copy_for_test() -> Result<()> {
5573 if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
5574 return Err(CallGraphStoreError::Unavailable(
5575 "legacy callgraph migration stopped after temp copy by test seam".to_string(),
5576 ));
5577 }
5578 Ok(())
5579}
5580
5581fn migration_generation_file_name(project_key: &str, method: &str) -> String {
5582 format!(
5583 "{project_key}.g{}.{}{}{}.sqlite",
5584 now_nanos(),
5585 std::process::id(),
5586 MIGRATION_GENERATION_TAG,
5587 method
5588 )
5589}
5590
5591fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
5592 callgraph_dir.join(format!(
5593 "{generation}.tmp.{}.{}",
5594 std::process::id(),
5595 now_nanos()
5596 ))
5597}
5598
5599fn write_migration_manifest(
5600 callgraph_dir: &Path,
5601 generation: &str,
5602 source: &LegacyCallgraphTarget,
5603 migrated_bytes: u64,
5604 method: &str,
5605) -> Result<()> {
5606 let manifest_path = migration_manifest_path(callgraph_dir, generation);
5607 let temp_path = manifest_path.with_extension(format!(
5608 "migration.json.tmp.{}.{}",
5609 std::process::id(),
5610 now_nanos()
5611 ));
5612 let manifest = serde_json::json!({
5613 "version": MIGRATION_MANIFEST_VERSION,
5614 "method": method,
5615 "target_generation": generation,
5616 "source_harness": source.partition.harness,
5617 "source_path": source.sqlite_path.display().to_string(),
5618 "source_generation": source.generation,
5619 "source_bytes": source.source_bytes,
5620 "source_blake3": source.source_blake3,
5621 "migrated_bytes": migrated_bytes,
5622 });
5623 {
5624 use std::io::Write as _;
5625 let mut file = std::fs::File::create(&temp_path)?;
5626 file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
5627 file.write_all(b"\n")?;
5628 file.sync_all()?;
5629 }
5630 if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
5631 let _ = std::fs::remove_file(&temp_path);
5632 return Err(error.into());
5633 }
5634 crate::fs_lock::sync_parent(&manifest_path);
5635 Ok(())
5636}
5637
5638fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
5639 callgraph_dir.join(format!("{generation}.migration.json"))
5640}
5641
5642fn migration_generation_requires_manifest(generation: &str) -> bool {
5643 generation.contains(MIGRATION_GENERATION_TAG)
5644}
5645
5646fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
5647 if !migration_generation_requires_manifest(generation) {
5648 return true;
5649 }
5650 let path = migration_manifest_path(callgraph_dir, generation);
5651 let Ok(bytes) = std::fs::read(path) else {
5652 return false;
5653 };
5654 let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
5655 return false;
5656 };
5657 value.get("version").and_then(serde_json::Value::as_u64)
5658 == Some(MIGRATION_MANIFEST_VERSION as u64)
5659 && value
5660 .get("target_generation")
5661 .and_then(serde_json::Value::as_str)
5662 == Some(generation)
5663 && value
5664 .get("source_bytes")
5665 .and_then(serde_json::Value::as_u64)
5666 .is_some_and(|bytes| bytes > 0)
5667 && value
5668 .get("source_blake3")
5669 .and_then(serde_json::Value::as_str)
5670 .is_some_and(|hash| hash.len() == 64)
5671}
5672
5673fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
5674 let pointer_generation = read_pointer(callgraph_dir, project_key);
5675 if let Some(generation) = pointer_generation.as_deref() {
5676 if migration_generation_requires_manifest(generation)
5677 && !migration_manifest_valid(callgraph_dir, generation)
5678 {
5679 let path = callgraph_dir.join(generation);
5680 remove_sqlite_file_set(&path);
5681 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
5682 let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
5683 }
5684 }
5685
5686 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
5687 return;
5688 };
5689 for entry in entries.flatten() {
5690 let name = entry.file_name().to_string_lossy().to_string();
5691 let path = entry.path();
5692 if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
5693 let _ = std::fs::remove_file(path);
5694 continue;
5695 }
5696 if name.starts_with(&format!("{project_key}.g"))
5697 && name.ends_with(".sqlite")
5698 && name.contains(MIGRATION_GENERATION_TAG)
5699 && pointer_generation.as_deref() != Some(&name)
5700 && !migration_manifest_valid(callgraph_dir, &name)
5701 {
5702 remove_sqlite_file_set(&path);
5703 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
5704 }
5705 }
5706 crate::fs_lock::sync_parent(callgraph_dir);
5707}
5708
5709fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
5710 let mut hasher = blake3::Hasher::new();
5711 hasher.update(path.to_string_lossy().as_bytes());
5712 if let Some(generation) = generation {
5713 hasher.update(generation.as_bytes());
5714 }
5715 let digest = hash_to_hex(hasher.finalize());
5716 format!("legacy-{}", &digest[..16])
5717}
5718
5719fn open_readonly_connection(path: &Path) -> Result<Connection> {
5720 let uri = sqlite_readonly_uri(path);
5721 let conn = Connection::open_with_flags(
5722 &uri,
5723 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
5724 )?;
5725 conn.pragma_update(
5726 None,
5727 "synchronous",
5728 if write_amplification_baseline_enabled() {
5729 "FULL"
5730 } else {
5731 "NORMAL"
5732 },
5733 )?;
5734 conn.busy_timeout(reader_busy_timeout())?;
5735 conn.execute_batch("PRAGMA query_only=ON;")?;
5736 Ok(conn)
5737}
5738
5739fn reader_busy_timeout() -> Duration {
5740 let jitter = (now_nanos() % 500) as u64;
5741 Duration::from_millis(250 + jitter)
5742}
5743
5744fn sqlite_readonly_uri(path: &Path) -> String {
5745 let raw = path.to_string_lossy().replace('\\', "/");
5746 let encoded = percent_encode_sqlite_uri_path(&raw);
5747 if raw.starts_with('/') {
5748 format!("file://{encoded}?mode=ro")
5749 } else if raw.as_bytes().get(1) == Some(&b':') {
5750 format!("file:///{encoded}?mode=ro")
5751 } else {
5752 format!("file:{encoded}?mode=ro")
5753 }
5754}
5755
5756fn percent_encode_sqlite_uri_path(path: &str) -> String {
5757 let mut encoded = String::with_capacity(path.len());
5758 for byte in path.bytes() {
5759 match byte {
5760 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
5761 encoded.push(byte as char)
5762 }
5763 _ => encoded.push_str(&format!("%{byte:02X}")),
5764 }
5765 }
5766 encoded
5767}
5768
5769fn configure_connection(conn: &Connection) -> Result<()> {
5770 conn.pragma_update(None, "journal_mode", "WAL")?;
5771 let baseline = write_amplification_baseline_enabled();
5772 conn.pragma_update(
5773 None,
5774 "synchronous",
5775 if baseline { "FULL" } else { "NORMAL" },
5776 )?;
5777 conn.pragma_update(
5778 None,
5779 "wal_autocheckpoint",
5780 if baseline {
5781 1_000
5782 } else {
5783 CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES
5784 },
5785 )?;
5786 conn.pragma_update(None, "busy_timeout", 5_000)?;
5787 Ok(())
5788}
5789
5790fn configure_build_connection(conn: &Connection) -> Result<()> {
5791 conn.pragma_update(None, "journal_mode", "DELETE")?;
5792 conn.pragma_update(
5793 None,
5794 "synchronous",
5795 if write_amplification_baseline_enabled() {
5796 "FULL"
5797 } else {
5798 "NORMAL"
5799 },
5800 )?;
5801 conn.pragma_update(None, "busy_timeout", 5_000)?;
5802 Ok(())
5803}
5804
5805fn checkpoint_sqlite_before_publication(path: &Path) {
5809 let Ok(conn) = Connection::open(path) else {
5810 return;
5811 };
5812 let _ = conn.pragma_update(None, "synchronous", "NORMAL");
5813 let _ = conn.busy_timeout(Duration::from_secs(5));
5814 let _ = checkpoint_wal_truncate(&conn);
5815}
5816
5817fn checkpoint_wal_truncate(conn: &Connection) -> bool {
5818 match conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
5819 row.get::<_, i64>(0)
5820 }) {
5821 Ok(0) => true,
5822 Ok(_) => false,
5823 Err(rusqlite::Error::SqliteFailure(error, _))
5824 if matches!(
5825 error.code,
5826 rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
5827 ) =>
5828 {
5829 false
5830 }
5831 Err(error) => {
5832 log::debug!("callgraph WAL truncate checkpoint skipped: {error}");
5833 false
5834 }
5835 }
5836}
5837
5838fn initialize_schema(conn: &Connection) -> Result<()> {
5839 conn.execute_batch(
5840 "CREATE TABLE IF NOT EXISTS files (
5841 path TEXT PRIMARY KEY,
5842 content_hash TEXT NOT NULL,
5843 mtime_ns INTEGER NOT NULL,
5844 size INTEGER NOT NULL,
5845 lang TEXT NOT NULL,
5846 is_dead_code_root INTEGER NOT NULL DEFAULT 0,
5847 is_public_api INTEGER NOT NULL DEFAULT 0,
5848 surface_fingerprint TEXT NOT NULL,
5849 indexed_at INTEGER NOT NULL
5850 );
5851
5852 CREATE TABLE IF NOT EXISTS nodes (
5853 id TEXT PRIMARY KEY,
5854 file_path TEXT NOT NULL,
5855 name TEXT NOT NULL,
5856 scoped_name TEXT NOT NULL,
5857 kind TEXT NOT NULL,
5858 start_line INTEGER NOT NULL,
5859 start_col INTEGER NOT NULL,
5860 end_line INTEGER NOT NULL,
5861 end_col INTEGER NOT NULL,
5862 range_ordinal INTEGER NOT NULL,
5863 signature TEXT,
5864 exported INTEGER NOT NULL,
5865 is_default_export INTEGER NOT NULL,
5866 is_type_like INTEGER NOT NULL,
5867 is_callgraph_entry_point INTEGER NOT NULL,
5868 provenance TEXT NOT NULL,
5869 UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
5870 );
5871 CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
5872 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
5873 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
5874
5875 CREATE TABLE IF NOT EXISTS refs (
5876 ref_id TEXT PRIMARY KEY,
5877 caller_node TEXT,
5878 caller_file TEXT NOT NULL,
5879 kind TEXT NOT NULL,
5880 short_name TEXT,
5881 full_ref TEXT,
5882 module_path TEXT,
5883 import_kind TEXT,
5884 local_name TEXT,
5885 requested_name TEXT,
5886 namespace_alias TEXT,
5887 wildcard INTEGER NOT NULL DEFAULT 0,
5888 line INTEGER NOT NULL,
5889 byte_start INTEGER NOT NULL,
5890 byte_end INTEGER NOT NULL,
5891 status TEXT NOT NULL,
5892 target_node TEXT,
5893 target_file TEXT,
5894 target_symbol TEXT,
5895 provenance TEXT NOT NULL
5896 );
5897 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
5898 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
5899 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
5900 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
5901 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
5902
5903 CREATE TABLE IF NOT EXISTS file_dependencies (
5904 file_path TEXT NOT NULL,
5905 dep_file TEXT NOT NULL,
5906 PRIMARY KEY(file_path, dep_file)
5907 );
5908 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
5909
5910 CREATE TABLE IF NOT EXISTS edges (
5911 edge_id TEXT PRIMARY KEY,
5912 ref_id TEXT NOT NULL,
5913 source_node TEXT NOT NULL,
5914 target_node TEXT,
5915 target_file TEXT NOT NULL,
5916 target_symbol TEXT NOT NULL,
5917 kind TEXT NOT NULL,
5918 line INTEGER NOT NULL,
5919 provenance TEXT NOT NULL
5920 );
5921 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
5922 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
5923 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
5924 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
5925
5926 CREATE TABLE IF NOT EXISTS dispatch_hints (
5927 id TEXT PRIMARY KEY,
5928 method_name TEXT NOT NULL,
5929 caller_node TEXT NOT NULL,
5930 file TEXT NOT NULL,
5931 line INTEGER NOT NULL,
5932 byte_start INTEGER NOT NULL,
5933 byte_end INTEGER NOT NULL,
5934 provenance TEXT NOT NULL
5935 );
5936 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
5937
5938 CREATE TABLE IF NOT EXISTS type_ref_names (
5939 name TEXT PRIMARY KEY
5940 );
5941
5942 CREATE TABLE IF NOT EXISTS backend_file_state (
5943 backend TEXT NOT NULL,
5944 workspace_root TEXT NOT NULL,
5945 file_path TEXT NOT NULL,
5946 content_hash TEXT NOT NULL,
5947 status TEXT NOT NULL,
5948 updated_at INTEGER NOT NULL,
5949 PRIMARY KEY(backend, workspace_root, file_path, content_hash)
5950 );
5951 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
5952
5953 CREATE TABLE IF NOT EXISTS meta (
5954 k TEXT PRIMARY KEY,
5955 v TEXT NOT NULL
5956 );",
5957 )?;
5958 insert_meta(conn)?;
5959 Ok(())
5960}
5961
5962fn insert_meta(conn: &Connection) -> Result<()> {
5963 conn.execute(
5964 "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
5965 params![SCHEMA_VERSION.to_string()],
5966 )?;
5967 conn.execute(
5968 "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
5969 params![schema_fingerprint()],
5970 )?;
5971 Ok(())
5972}
5973
5974fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
5975 conn.execute(
5976 "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
5977 params![if ready { "1" } else { "0" }],
5978 )?;
5979 Ok(())
5980}
5981
5982fn database_ready(conn: &Connection) -> Result<bool> {
5983 let schema_version: Option<String> = conn
5984 .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
5985 row.get(0)
5986 })
5987 .optional()?;
5988 let fingerprint: Option<String> = conn
5989 .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
5990 row.get(0)
5991 })
5992 .optional()?;
5993 let ready: Option<String> = conn
5994 .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
5995 .optional()?;
5996
5997 let expected_schema = SCHEMA_VERSION.to_string();
5998 let expected_fingerprint = schema_fingerprint();
5999 Ok(schema_version.as_deref() == Some(expected_schema.as_str())
6000 && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
6001 && ready.as_deref() == Some("1"))
6002}
6003
6004fn ensure_database_ready(conn: &Connection) -> Result<()> {
6005 if database_ready(conn)? {
6006 Ok(())
6007 } else {
6008 Err(CallGraphStoreError::Unavailable(
6009 "database is missing, stale, or mid-build".to_string(),
6010 ))
6011 }
6012}
6013
6014fn schema_fingerprint() -> String {
6015 let input =
6020 format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
6021 hash_to_hex(blake3::hash(input.as_bytes()))
6022}
6023
6024fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
6025 tx.execute_batch(
6026 "DELETE FROM edges;
6027 DELETE FROM file_dependencies;
6028 DELETE FROM refs;
6029 DELETE FROM dispatch_hints;
6030 DELETE FROM type_ref_names;
6031 DELETE FROM backend_file_state;
6032 DELETE FROM nodes;
6033 DELETE FROM files;",
6034 )?;
6035 Ok(())
6036}
6037
6038fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
6039 tx.execute_batch(
6040 "DROP INDEX IF EXISTS idx_nodes_file;
6041 DROP INDEX IF EXISTS idx_nodes_name;
6042 DROP INDEX IF EXISTS idx_nodes_scoped;
6043 DROP INDEX IF EXISTS idx_refs_short_name;
6044 DROP INDEX IF EXISTS idx_refs_kind_caller_file;
6045 DROP INDEX IF EXISTS idx_refs_caller_file;
6046 DROP INDEX IF EXISTS idx_refs_caller_node_kind;
6047 DROP INDEX IF EXISTS idx_refs_target_file;
6048 DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
6049 DROP INDEX IF EXISTS idx_edges_source_kind;
6050 DROP INDEX IF EXISTS idx_edges_target_kind;
6051 DROP INDEX IF EXISTS idx_edges_target_file_symbol;
6052 DROP INDEX IF EXISTS idx_edges_ref_id;
6053 DROP INDEX IF EXISTS idx_dispatch_hints_method;
6054 DROP INDEX IF EXISTS idx_backend_file_state_file;",
6055 )?;
6056 Ok(())
6057}
6058
6059fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
6060 tx.execute_batch(
6061 "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
6062 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
6063 CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
6064 CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
6065 CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
6066 CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
6067 CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
6068 CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
6069 CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
6070 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
6071 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
6072 CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
6073 CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
6074 CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
6075 CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
6076 )?;
6077 Ok(())
6078}
6079
6080const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
6081 ("files", "path"),
6082 ("nodes", "file_path"),
6083 ("refs", "caller_file"),
6084 ("refs", "target_file"),
6085 ("file_dependencies", "file_path"),
6086 ("file_dependencies", "dep_file"),
6087 ("edges", "target_file"),
6088 ("dispatch_hints", "file"),
6089 ("backend_file_state", "file_path"),
6090];
6091
6092fn reconcile_workspace_roots(
6105 conn: &mut Connection,
6106 project_root: &Path,
6107 allow_repair: bool,
6108) -> Result<OpenRootRepair> {
6109 let roots = stored_workspace_roots(conn)?;
6110 let current_root = project_root.display().to_string();
6111 if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
6112 return Ok(OpenRootRepair::None);
6113 }
6114
6115 if let Some(sample) = sample_absolute_data_path(conn)? {
6116 return Ok(OpenRootRepair::NeedsRebuild {
6117 previous_roots: roots,
6118 current_root,
6119 reason: format!("absolute store data path row {sample}"),
6120 });
6121 }
6122
6123 for stored_root in roots.iter() {
6124 if stored_root == ¤t_root {
6125 continue;
6126 }
6127 if Path::new(stored_root).exists() {
6128 let reason = format!(
6129 "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
6130 );
6131 return Ok(OpenRootRepair::NeedsRebuild {
6132 previous_roots: roots,
6133 current_root,
6134 reason,
6135 });
6136 }
6137 }
6138
6139 if !allow_repair {
6140 return Ok(OpenRootRepair::NeedsRebuild {
6141 previous_roots: roots,
6142 current_root,
6143 reason: "workspace root metadata requires deferred repair".to_string(),
6144 });
6145 }
6146
6147 publish_if_current(|| {
6148 let tx = conn.transaction()?;
6149 tx.execute(
6150 "UPDATE OR IGNORE backend_file_state
6151 SET workspace_root = ?1
6152 WHERE workspace_root <> ?1",
6153 params![¤t_root],
6154 )?;
6155 tx.execute(
6156 "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
6157 params![¤t_root],
6158 )?;
6159 tx.commit()?;
6160 Ok(())
6161 })?;
6162
6163 crate::slog_info!(
6164 "callgraph store re-rooted from {} to {}",
6165 roots.join(", "),
6166 current_root
6167 );
6168 Ok(OpenRootRepair::ReRooted)
6169}
6170
6171fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
6172 let mut stmt = conn.prepare(
6173 "SELECT DISTINCT workspace_root
6174 FROM backend_file_state
6175 ORDER BY workspace_root",
6176 )?;
6177 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
6178 rows.collect::<std::result::Result<Vec<_>, _>>()
6179 .map_err(Into::into)
6180}
6181
6182fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
6183 for (table, column) in STORE_DATA_PATH_COLUMNS {
6184 let sql = format!(
6185 "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
6186 );
6187 let mut stmt = conn.prepare(&sql)?;
6188 let mut rows = stmt.query([])?;
6189 while let Some(row) = rows.next()? {
6190 let value: String = row.get(0)?;
6191 if stored_path_is_absolute(&value) {
6192 return Ok(Some(format!("{table}.{column}={value}")));
6193 }
6194 }
6195 }
6196 Ok(None)
6197}
6198
6199fn stored_path_is_absolute(value: &str) -> bool {
6200 if value.is_empty() {
6201 return false;
6202 }
6203 if Path::new(value).is_absolute() || value.starts_with('/') {
6204 return true;
6205 }
6206 let bytes = value.as_bytes();
6207 if bytes.len() >= 3
6208 && bytes[1] == b':'
6209 && (bytes[2] == b'/' || bytes[2] == b'\\')
6210 && bytes[0].is_ascii_alphabetic()
6211 {
6212 return true;
6213 }
6214 value.starts_with("\\\\") || value.starts_with("//")
6215}
6216
6217fn log_root_repair_rebuild(repair: &OpenRootRepair) {
6218 if let OpenRootRepair::NeedsRebuild {
6219 previous_roots,
6220 current_root,
6221 reason,
6222 } = repair
6223 {
6224 crate::slog_info!(
6225 "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
6226 previous_roots.join(", "),
6227 current_root,
6228 reason
6229 );
6230 }
6231}
6232
6233fn now_nanos() -> u128 {
6235 SystemTime::now()
6236 .duration_since(UNIX_EPOCH)
6237 .unwrap_or(Duration::ZERO)
6238 .as_nanos()
6239}
6240
6241fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
6246 callgraph_dir.join(format!("{project_key}.current"))
6247}
6248
6249fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
6253 callgraph_dir.join(format!("{project_key}.sqlite"))
6254}
6255
6256fn generation_file_name(project_key: &str) -> String {
6260 format!(
6261 "{project_key}.g{}.{}.sqlite",
6262 now_nanos(),
6263 std::process::id()
6264 )
6265}
6266
6267fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
6269 let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
6270 let name = text.trim();
6271 if name.is_empty() {
6272 None
6273 } else {
6274 Some(name.to_string())
6275 }
6276}
6277
6278fn db_path_ready(path: &Path) -> bool {
6281 (|| -> Result<bool> {
6282 let conn = open_readonly_connection(path)?;
6283 database_ready(&conn)
6284 })()
6285 .unwrap_or(false)
6286}
6287
6288fn resolve_ready_target(
6296 callgraph_dir: &Path,
6297 project_key: &str,
6298) -> Option<(PathBuf, Option<String>)> {
6299 for _ in 0..5 {
6300 if let Some(generation) = read_pointer(callgraph_dir, project_key) {
6301 let gen_path = callgraph_dir.join(&generation);
6302 if gen_path.is_file() {
6303 return (migration_manifest_valid(callgraph_dir, &generation)
6304 && db_path_ready(&gen_path))
6305 .then_some((gen_path, Some(generation)));
6306 }
6307 std::thread::sleep(Duration::from_millis(5));
6310 continue;
6311 }
6312 let legacy = legacy_sqlite_path(callgraph_dir, project_key);
6314 return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
6315 }
6316 None
6317}
6318
6319fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
6323 let pointer = pointer_path(callgraph_dir, project_key);
6324 let tmp = callgraph_dir.join(format!(
6325 "{project_key}.current.tmp.{}.{}",
6326 std::process::id(),
6327 now_nanos()
6328 ));
6329 {
6330 use std::io::Write as _;
6331 let mut file = std::fs::File::create(&tmp)?;
6332 file.write_all(generation.as_bytes())?;
6333 file.write_all(b"\n")?;
6334 file.sync_all()?;
6335 }
6336 if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
6337 let _ = std::fs::remove_file(&tmp);
6338 return Err(error.into());
6339 }
6340 crate::fs_lock::sync_parent(&pointer);
6341 Ok(())
6342}
6343
6344#[derive(Clone, Debug)]
6345struct GenerationGcCandidate {
6346 name: String,
6347 path: PathBuf,
6348 modified: SystemTime,
6349}
6350
6351fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
6357 let temp_grace = Duration::from_secs(60);
6358 let now = SystemTime::now();
6359 let pointer_current =
6360 read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
6361 let gen_prefix = format!("{project_key}.g");
6362 let tmp_prefixes = [
6363 format!("{project_key}.g"), format!("{project_key}.current."), format!("{project_key}.sqlite.tmp."), ];
6367 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6368 return;
6369 };
6370 let mut gens: Vec<GenerationGcCandidate> = Vec::new();
6371 for entry in entries.flatten() {
6372 let name = entry.file_name();
6373 let name = name.to_string_lossy().to_string();
6374 let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
6375 let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
6376
6377 if name.contains(".tmp.") {
6379 if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
6380 let _ = std::fs::remove_file(entry.path());
6381 }
6382 continue;
6383 }
6384
6385 if name == format!("{project_key}.sqlite") {
6388 remove_sqlite_file_set(&entry.path());
6389 continue;
6390 }
6391
6392 if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
6393 gens.push(GenerationGcCandidate {
6394 name,
6395 path: entry.path(),
6396 modified: mtime,
6397 });
6398 }
6399 }
6400
6401 let mut superseded = gens
6402 .iter()
6403 .filter(|generation| generation.name != pointer_current)
6404 .collect::<Vec<_>>();
6405 superseded.sort_by(|left, right| {
6406 right
6407 .modified
6408 .cmp(&left.modified)
6409 .then_with(|| right.name.cmp(&left.name))
6410 });
6411 let previous = superseded.first().map(|generation| generation.name.clone());
6412
6413 for generation in gens {
6414 let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
6415 if generation.name == pointer_current
6416 || Some(generation.name.as_str()) == previous.as_deref()
6417 {
6418 continue;
6419 }
6420
6421 let age = now
6422 .duration_since(generation.modified)
6423 .unwrap_or(Duration::ZERO);
6424 if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
6425 continue;
6426 }
6427
6428 remove_sqlite_file_set(&generation.path);
6429 let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
6430 let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
6431 callgraph_dir,
6432 &generation.name,
6433 ));
6434 }
6435}
6436
6437fn remove_sqlite_file_set(path: &Path) {
6438 let _ = std::fs::remove_file(path);
6439 remove_sqlite_sidecars(path);
6440}
6441
6442fn remove_sqlite_sidecars(path: &Path) {
6443 let path_text = path.to_string_lossy();
6444 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
6445 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
6446 let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
6447}
6448
6449const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
6463
6464fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
6476 sweep_orphaned_build_temps(callgraph_dir);
6477 let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6478 return;
6479 };
6480 let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
6481
6482 if let Ok(entries) = std::fs::read_dir(storage_root.join(domain)) {
6484 for entry in entries.flatten() {
6485 if entry.path().is_dir() {
6486 sweep_orphaned_build_temps(&entry.path());
6487 }
6488 }
6489 }
6490
6491 if let Ok(entries) = std::fs::read_dir(&storage_root) {
6493 for entry in entries.flatten() {
6494 let legacy_dir = entry.path().join(domain);
6495 if legacy_dir.is_dir() {
6496 sweep_orphaned_build_temps(&legacy_dir);
6497 }
6498 }
6499 }
6500}
6501
6502fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
6505 sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
6506}
6507
6508fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
6511 let now = SystemTime::now();
6512 let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6513 return;
6514 };
6515 let mut removed_any = false;
6516 for entry in entries.flatten() {
6517 let name = entry.file_name().to_string_lossy().to_string();
6518 if !name.contains(".sqlite.tmp.") {
6524 continue;
6525 }
6526 let mtime = entry
6527 .metadata()
6528 .and_then(|meta| meta.modified())
6529 .unwrap_or(now);
6530 if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
6531 continue;
6532 }
6533 match std::fs::remove_file(entry.path()) {
6539 Ok(()) => removed_any = true,
6540 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
6541 Err(_) => {}
6542 }
6543 }
6544 if removed_any {
6545 crate::fs_lock::sync_parent(callgraph_dir);
6546 }
6547}
6548
6549fn build_pool_size() -> usize {
6557 std::thread::available_parallelism()
6558 .map(|parallelism| parallelism.get())
6559 .unwrap_or(1)
6560 .div_ceil(2)
6561 .clamp(1, 8)
6562}
6563
6564fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
6565 let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
6566 Ok(extract) => Ok(extract),
6567 Err(error) => {
6568 let abs_path =
6569 normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
6570 let rel_path = relative_path(project_root, &abs_path);
6571 let freshness = cache_freshness::collect(&abs_path).ok();
6572 log::debug!(
6573 "callgraph store: skipping {} during cold build: {}",
6574 abs_path.display(),
6575 error
6576 );
6577 Err(ExtractFailure {
6578 rel_path,
6579 freshness,
6580 })
6581 }
6582 };
6583
6584 let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
6585 files.par_iter().map(extract_one).collect()
6586 };
6587
6588 let results = match rayon::ThreadPoolBuilder::new()
6591 .num_threads(build_pool_size())
6592 .thread_name(|index| format!("aft-callgraph-build-{index}"))
6593 .stack_size(8 * 1024 * 1024)
6594 .build()
6595 {
6596 Ok(pool) => pool.install(run),
6597 Err(error) => {
6598 log::warn!(
6599 "callgraph store: bounded build pool unavailable ({error}); using global pool"
6600 );
6601 run()
6602 }
6603 };
6604
6605 let mut extracts = Vec::new();
6606 let mut failures = Vec::new();
6607 for result in results {
6608 match result {
6609 Ok(extract) => extracts.push(extract),
6610 Err(failure) => failures.push(failure),
6611 }
6612 }
6613 BuildExtractsResult { extracts, failures }
6614}
6615
6616fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
6617 let metadata = std::fs::metadata(path)?;
6618 let size = metadata.len();
6619 let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
6620 cache_freshness::zero_hash()
6621 } else if source.len() as u64 == size {
6622 cache_freshness::hash_bytes(source.as_bytes())
6623 } else {
6624 cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
6625 };
6626 Ok(FileFreshness {
6627 mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
6628 size,
6629 content_hash,
6630 })
6631}
6632
6633fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
6634 let abs_path = normalize_file_path(project_root, path)?;
6635 let rel_path = relative_path(project_root, &abs_path);
6636 let source = std::fs::read_to_string(&abs_path)?;
6637 let freshness = collect_source_freshness(&abs_path, &source)?;
6638 let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
6639 let lang = data.lang;
6640 if lang == LangId::Rust {
6641 extend_rust_imports_with_nested_uses(&source, &mut data);
6642 }
6643 let mut nodes = build_node_records(&rel_path, &source, &data)?;
6644 let node_by_scoped: HashMap<String, String> = nodes
6645 .iter()
6646 .map(|node| (node.scoped_name.clone(), node.id.clone()))
6647 .collect();
6648 let import_dependencies =
6649 import_dependencies(project_root, &abs_path, &data.import_block.imports);
6650 let line_index = LineIndex::new(&source);
6651 let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
6652 let rust_reexports = if lang == LangId::Rust {
6653 collect_rust_pub_use_reexport_refs(
6654 project_root,
6655 &abs_path,
6656 &rel_path,
6657 &data.import_block.imports,
6658 &line_index,
6659 )
6660 } else {
6661 ReexportRefs {
6662 raw_refs: Vec::new(),
6663 surface_parts: Vec::new(),
6664 }
6665 };
6666 let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
6667 let mut raw_refs = Vec::new();
6668 raw_refs.extend(build_call_refs(
6669 &rel_path,
6670 &data,
6671 &node_by_scoped,
6672 &import_dependencies,
6673 ));
6674 raw_refs.extend(build_import_refs(
6675 project_root,
6676 &abs_path,
6677 &rel_path,
6678 &data.import_block.imports,
6679 &line_index,
6680 ));
6681 let mut surface_parts = reexports.surface_parts;
6682 surface_parts.extend(rust_reexports.surface_parts);
6683 surface_parts.extend(source_less_exports.surface_parts);
6684 raw_refs.extend(reexports.raw_refs);
6685 raw_refs.extend(rust_reexports.raw_refs);
6686 raw_refs.extend(source_less_exports.raw_refs);
6687 let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
6688 let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
6689
6690 Ok(FileExtract {
6691 rel_path,
6692 freshness,
6693 lang,
6694 data,
6695 nodes,
6696 raw_refs,
6697 dispatch_hints,
6698 surface_fingerprint,
6699 })
6700}
6701
6702fn build_node_records(
6703 rel_path: &str,
6704 source: &str,
6705 data: &FileCallData,
6706) -> Result<Vec<NodeRecord>> {
6707 let mut records = Vec::new();
6708 let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
6709 let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
6710 metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
6711
6712 for (scoped_name, meta) in metadata {
6713 let name = unqualified_name(scoped_name).to_string();
6714 let range = selection_range(source, scoped_name, &name, &meta.range);
6715 let range_key = (
6716 range.start_line,
6717 range.start_col,
6718 range.end_line,
6719 range.end_col,
6720 );
6721 let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
6722 let range_ordinal = *ordinal;
6723 *ordinal += 1;
6724 let id = node_id(rel_path, &range, range_ordinal, scoped_name);
6725 let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
6726 let is_default_export = data
6727 .default_export_symbol
6728 .as_deref()
6729 .map(|default| default == scoped_name || default == name)
6730 .unwrap_or(false);
6731 records.push(NodeRecord {
6732 id,
6733 file_path: rel_path.to_string(),
6734 name: name.clone(),
6735 scoped_name: scoped_name.clone(),
6736 kind: symbol_kind_label(&meta.kind).to_string(),
6737 range,
6738 range_ordinal,
6739 signature: meta.signature.clone(),
6740 exported,
6741 is_default_export,
6742 is_type_like: is_type_like(&meta.kind),
6743 is_callgraph_entry_point: meta.entry_point_attribute.is_some()
6744 || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
6745 });
6746 }
6747
6748 Ok(records)
6749}
6750
6751fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
6752 if scoped_name == TOP_LEVEL_SYMBOL {
6753 return Range {
6754 start_line: 0,
6755 start_col: 0,
6756 end_line: 0,
6757 end_col: 0,
6758 };
6759 }
6760 let Some(line) = source.lines().nth(fallback.start_line as usize) else {
6761 return fallback.clone();
6762 };
6763 let start_col = fallback.start_col as usize;
6764 let search_start = start_col.min(line.len());
6765 if let Some(offset) = line[search_start..].find(name) {
6766 let col = search_start + offset;
6767 return Range {
6768 start_line: fallback.start_line,
6769 start_col: col as u32,
6770 end_line: fallback.start_line,
6771 end_col: (col + name.len()) as u32,
6772 };
6773 }
6774 if let Some(offset) = line.find(name) {
6775 return Range {
6776 start_line: fallback.start_line,
6777 start_col: offset as u32,
6778 end_line: fallback.start_line,
6779 end_col: (offset + name.len()) as u32,
6780 };
6781 }
6782 Range {
6783 start_line: fallback.start_line,
6784 start_col: fallback.start_col,
6785 end_line: fallback.start_line,
6786 end_col: fallback.start_col.saturating_add(name.len() as u32),
6787 }
6788}
6789
6790fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
6791 if scoped_name == TOP_LEVEL_SYMBOL {
6792 return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
6793 }
6794 let input = format!(
6795 "{rel_path}:{}:{}:{}:{}:{ordinal}",
6796 range.start_line, range.start_col, range.end_line, range.end_col
6797 );
6798 format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
6799}
6800
6801fn build_call_refs(
6802 rel_path: &str,
6803 data: &FileCallData,
6804 node_by_scoped: &HashMap<String, String>,
6805 import_dependencies: &BTreeSet<String>,
6806) -> Vec<RawRef> {
6807 let mut refs = Vec::new();
6808 let mut ordinal = 0usize;
6809 let mut symbols: Vec<_> = data.calls_by_symbol.iter().collect();
6810 symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
6811 for (caller_symbol, call_sites) in symbols {
6812 let caller_node = node_by_scoped.get(caller_symbol).cloned();
6813 for call_site in call_sites {
6814 ordinal += 1;
6815 let ref_id = ref_id(&[
6816 rel_path,
6817 "call",
6818 caller_symbol,
6819 &call_site.line.to_string(),
6820 &call_site.byte_start.to_string(),
6821 &call_site.byte_end.to_string(),
6822 &call_site.full_callee,
6823 &ordinal.to_string(),
6824 ]);
6825 refs.push(RawRef {
6826 ref_id,
6827 caller_node: caller_node.clone(),
6828 caller_symbol: Some(caller_symbol.clone()),
6829 caller_file: rel_path.to_string(),
6830 kind: "call".to_string(),
6831 short_name: Some(call_site.callee_name.clone()),
6832 full_ref: Some(call_site.full_callee.clone()),
6833 module_path: None,
6834 import_kind: None,
6835 local_name: Some(call_site.callee_name.clone()),
6836 requested_name: Some(call_site.callee_name.clone()),
6837 namespace_alias: namespace_alias(&call_site.full_callee),
6838 wildcard: false,
6839 line: call_site.line,
6840 byte_start: call_site.byte_start,
6841 byte_end: call_site.byte_end,
6842 dependencies: import_dependencies.clone(),
6843 });
6844 }
6845 }
6846 refs
6847}
6848
6849fn build_import_refs(
6850 project_root: &Path,
6851 abs_path: &Path,
6852 rel_path: &str,
6853 imports: &[ImportStatement],
6854 line_index: &LineIndex,
6855) -> Vec<RawRef> {
6856 let mut refs = Vec::new();
6857 for (index, import) in imports.iter().enumerate() {
6858 let import_kind = import_kind_label(import.kind).to_string();
6859 let local_name = import_local_names(import).join(",");
6860 let requested_name = import_requested_names(import).join(",");
6861 let ref_id = ref_id(&[
6862 rel_path,
6863 "import",
6864 &import.byte_range.start.to_string(),
6865 &import.byte_range.end.to_string(),
6866 &import.module_path,
6867 &index.to_string(),
6868 ]);
6869 refs.push(RawRef {
6870 ref_id,
6871 caller_node: None,
6872 caller_symbol: None,
6873 caller_file: rel_path.to_string(),
6874 kind: "import".to_string(),
6875 short_name: None,
6876 full_ref: Some(import.raw_text.clone()),
6877 module_path: Some(import.module_path.clone()),
6878 import_kind: Some(import_kind),
6879 local_name: empty_to_none(local_name),
6880 requested_name: empty_to_none(requested_name),
6881 namespace_alias: import.namespace_import.clone(),
6882 wildcard: import_is_wildcard(import),
6883 line: line_index.byte_to_line(import.byte_range.start),
6884 byte_start: import.byte_range.start,
6885 byte_end: import.byte_range.end,
6886 dependencies: module_dependencies(project_root, abs_path, &import.module_path),
6887 });
6888 }
6889 refs
6890}
6891
6892fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
6893 let grammar = grammar_for(LangId::Rust);
6894 let mut parser = Parser::new();
6895 if parser.set_language(&grammar).is_err() {
6896 return;
6897 }
6898 let Some(tree) = parser.parse(source, None) else {
6899 return;
6900 };
6901
6902 let mut seen = data
6903 .import_block
6904 .imports
6905 .iter()
6906 .map(|import| (import.byte_range.start, import.byte_range.end))
6907 .collect::<HashSet<_>>();
6908 let mut nested_imports = Vec::new();
6909 collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
6910 if nested_imports.is_empty() {
6911 return;
6912 }
6913
6914 data.import_block.imports.extend(nested_imports);
6915 data.import_block
6916 .imports
6917 .sort_by_key(|import| import.byte_range.start);
6918 data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
6919}
6920
6921fn collect_rust_use_imports(
6922 source: &str,
6923 node: Node<'_>,
6924 seen: &mut HashSet<(usize, usize)>,
6925 imports: &mut Vec<ImportStatement>,
6926) {
6927 if node.kind() == "use_declaration" {
6928 let range = node.byte_range();
6929 if seen.insert((range.start, range.end)) {
6930 if let Some(import) = rust_import_from_use_node(source, node) {
6931 imports.push(import);
6932 }
6933 }
6934 }
6935
6936 let mut cursor = node.walk();
6937 if !cursor.goto_first_child() {
6938 return;
6939 }
6940 loop {
6941 collect_rust_use_imports(source, cursor.node(), seen, imports);
6942 if !cursor.goto_next_sibling() {
6943 break;
6944 }
6945 }
6946}
6947
6948fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
6949 let raw_text = source[node.byte_range()].to_string();
6950 let body = rust_use_body(&raw_text)?.to_string();
6951 let visibility = rust_use_visibility(&raw_text);
6952 let names = rust_use_list_names(&body);
6953 let group = classify_rust_import_group(&body);
6954 let byte_range = node.byte_range();
6955
6956 Some(ImportStatement {
6957 module_path: body,
6958 names: names.clone(),
6959 default_import: visibility.clone(),
6960 namespace_import: None,
6961 kind: ImportKind::Value,
6962 group,
6963 byte_range,
6964 raw_text,
6965 form: ImportForm::RustUse {
6966 visibility,
6967 named: names,
6968 },
6969 })
6970}
6971
6972fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
6973 let start = imports.iter().map(|import| import.byte_range.start).min()?;
6974 let end = imports.iter().map(|import| import.byte_range.end).max()?;
6975 Some(start..end)
6976}
6977
6978fn rust_use_visibility(raw_text: &str) -> Option<String> {
6979 let use_pos = raw_text.find("use ")?;
6980 let prefix = raw_text[..use_pos].trim();
6981 if prefix.is_empty() {
6982 None
6983 } else {
6984 Some(prefix.to_string())
6985 }
6986}
6987
6988fn rust_use_body(raw_text: &str) -> Option<&str> {
6989 let use_pos = raw_text.find("use ")?;
6990 Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
6991}
6992
6993fn rust_use_list_names(body: &str) -> Vec<String> {
6994 let Some(open) = body.find("::{") else {
6995 return Vec::new();
6996 };
6997 let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
6998 return Vec::new();
6999 };
7000 body[open + 3..close]
7001 .split(',')
7002 .filter_map(|spec| {
7003 let spec = spec.trim();
7004 if spec.is_empty() {
7005 None
7006 } else {
7007 Some(spec.to_string())
7008 }
7009 })
7010 .collect()
7011}
7012
7013fn classify_rust_import_group(body: &str) -> ImportGroup {
7014 let first = body
7015 .split("::")
7016 .next()
7017 .unwrap_or(body)
7018 .split_whitespace()
7019 .next()
7020 .unwrap_or(body);
7021 match first.trim() {
7022 "std" | "core" | "alloc" => ImportGroup::Stdlib,
7023 "crate" | "self" | "super" => ImportGroup::Internal,
7024 _ => ImportGroup::External,
7025 }
7026}
7027
7028#[derive(Debug, Clone)]
7029struct ReexportRefs {
7030 raw_refs: Vec<RawRef>,
7031 surface_parts: Vec<String>,
7032}
7033
7034fn collect_reexport_refs(
7035 project_root: &Path,
7036 abs_path: &Path,
7037 rel_path: &str,
7038 source: &str,
7039) -> ReexportRefs {
7040 let mut raw_refs = Vec::new();
7041 let mut surface_parts = Vec::new();
7042 let mut search_start = 0usize;
7043 let mut ordinal = 0usize;
7044 while let Some(export_offset) = source[search_start..].find("export") {
7045 let start = search_start + export_offset;
7046 let Some(statement_end_offset) = source[start..].find(';') else {
7047 break;
7048 };
7049 let end = start + statement_end_offset + 1;
7050 let statement = &source[start..end];
7051 search_start = end;
7052 if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
7053 continue;
7054 }
7055 let Some(module_path) = quoted_module_path(statement) else {
7056 continue;
7057 };
7058 ordinal += 1;
7059 let wildcard = statement.contains('*');
7060 let line = source[..start]
7061 .bytes()
7062 .filter(|byte| *byte == b'\n')
7063 .count() as u32
7064 + 1;
7065 let ref_id = ref_id(&[
7066 rel_path,
7067 "reexport",
7068 &start.to_string(),
7069 &end.to_string(),
7070 &module_path,
7071 &ordinal.to_string(),
7072 ]);
7073 surface_parts.push(format!("reexport\t{statement}"));
7074 raw_refs.push(RawRef {
7075 ref_id,
7076 caller_node: None,
7077 caller_symbol: None,
7078 caller_file: rel_path.to_string(),
7079 kind: "reexport".to_string(),
7080 short_name: None,
7081 full_ref: Some(statement.to_string()),
7082 module_path: Some(module_path.clone()),
7083 import_kind: Some("reexport".to_string()),
7084 local_name: None,
7085 requested_name: None,
7086 namespace_alias: None,
7087 wildcard,
7088 line,
7089 byte_start: start,
7090 byte_end: end,
7091 dependencies: module_dependencies(project_root, abs_path, &module_path),
7092 });
7093 }
7094 ReexportRefs {
7095 raw_refs,
7096 surface_parts,
7097 }
7098}
7099
7100fn collect_rust_pub_use_reexport_refs(
7101 project_root: &Path,
7102 abs_path: &Path,
7103 rel_path: &str,
7104 imports: &[ImportStatement],
7105 line_index: &LineIndex,
7106) -> ReexportRefs {
7107 let mut raw_refs = Vec::new();
7108 let mut surface_parts = Vec::new();
7109 let mut ordinal = 0usize;
7110
7111 for import in imports {
7112 let Some(visibility) = &import.default_import else {
7113 continue;
7114 };
7115 if !visibility.starts_with("pub") {
7116 continue;
7117 }
7118 let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
7119 continue;
7120 };
7121 ordinal += 1;
7122 let ref_id = ref_id(&[
7123 rel_path,
7124 "rust_reexport",
7125 &import.byte_range.start.to_string(),
7126 &import.byte_range.end.to_string(),
7127 &module_path,
7128 &ordinal.to_string(),
7129 ]);
7130 surface_parts.push(format!("reexport\t{}", import.raw_text));
7131 raw_refs.push(RawRef {
7132 ref_id,
7133 caller_node: None,
7134 caller_symbol: None,
7135 caller_file: rel_path.to_string(),
7136 kind: "reexport".to_string(),
7137 short_name: None,
7138 full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
7139 module_path: Some(module_path.clone()),
7140 import_kind: Some("reexport".to_string()),
7141 local_name: None,
7142 requested_name: None,
7143 namespace_alias: None,
7144 wildcard,
7145 line: line_index.byte_to_line(import.byte_range.start),
7146 byte_start: import.byte_range.start,
7147 byte_end: import.byte_range.end,
7148 dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
7149 });
7150 }
7151
7152 ReexportRefs {
7153 raw_refs,
7154 surface_parts,
7155 }
7156}
7157
7158fn rust_pub_use_reexport_parts(
7159 import: &ImportStatement,
7160) -> Option<(String, HashMap<String, String>, bool)> {
7161 let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
7162 let body = body.trim();
7163 if let Some(module_path) = body.strip_suffix("::*") {
7164 return Some((module_path.trim().to_string(), HashMap::new(), true));
7165 }
7166
7167 if let Some(brace_start) = body.find("::{") {
7168 let module_path = body[..brace_start].trim().to_string();
7169 let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
7170 if names.is_empty() {
7171 return None;
7172 }
7173 return Some((module_path, names, false));
7174 }
7175
7176 let (module_path, spec) = body.rsplit_once("::")?;
7177 let names = rust_reexport_names_from_specs(spec);
7178 if names.is_empty() {
7179 return None;
7180 }
7181 Some((module_path.trim().to_string(), names, false))
7182}
7183
7184fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
7185 let mut names = HashMap::new();
7186 for spec in specs.split(',') {
7187 let spec = spec.trim();
7188 if spec.is_empty() || spec == "self" {
7189 continue;
7190 }
7191 if let Some((source, local)) = spec.split_once(" as ") {
7192 let source = source.trim();
7193 let local = local.trim();
7194 if !source.is_empty() && !local.is_empty() && source != "self" {
7195 names.insert(local.to_string(), source.to_string());
7196 }
7197 } else {
7198 names.insert(spec.to_string(), spec.to_string());
7199 }
7200 }
7201 names
7202}
7203
7204fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
7205 if named.is_empty() {
7206 return fallback.to_string();
7207 }
7208 let mut specs = named
7209 .iter()
7210 .map(|(local, source)| {
7211 if local == source {
7212 source.clone()
7213 } else {
7214 format!("{source} as {local}")
7215 }
7216 })
7217 .collect::<Vec<_>>();
7218 specs.sort();
7219 format!("pub use {{{}}};", specs.join(", "))
7220}
7221
7222fn quoted_module_path(statement: &str) -> Option<String> {
7223 let quote = match (statement.find('\''), statement.find('"')) {
7224 (Some(single), Some(double)) if single < double => '\'',
7225 (Some(_), Some(_)) => '"',
7226 (Some(_), None) => '\'',
7227 (None, Some(_)) => '"',
7228 (None, None) => return None,
7229 };
7230 let start = statement.find(quote)? + 1;
7231 let end = statement[start..].find(quote)? + start;
7232 Some(statement[start..end].to_string())
7233}
7234
7235#[derive(Debug, Clone)]
7236struct SourceLessExportRefs {
7237 raw_refs: Vec<RawRef>,
7238 surface_parts: Vec<String>,
7239}
7240
7241fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
7242 let mut raw_refs = Vec::new();
7243 let mut surface_parts = Vec::new();
7244 let mut search_start = 0usize;
7245 let mut ordinal = 0usize;
7246 while let Some(export_offset) = source[search_start..].find("export") {
7247 let start = search_start + export_offset;
7248 let Some(statement_end_offset) = source[start..].find(';') else {
7249 break;
7250 };
7251 let end = start + statement_end_offset + 1;
7252 let statement = &source[start..end];
7253 search_start = end;
7254 if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
7255 continue;
7256 }
7257 let aliases = parse_reexport_names(statement);
7258 if aliases.is_empty() {
7259 continue;
7260 }
7261 let line = source[..start]
7262 .bytes()
7263 .filter(|byte| *byte == b'\n')
7264 .count() as u32
7265 + 1;
7266 for (exported, source_symbol) in aliases {
7267 ordinal += 1;
7268 let ref_id = ref_id(&[
7269 rel_path,
7270 "export_alias",
7271 &start.to_string(),
7272 &end.to_string(),
7273 &exported,
7274 &source_symbol,
7275 &ordinal.to_string(),
7276 ]);
7277 surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
7278 raw_refs.push(RawRef {
7279 ref_id,
7280 caller_node: None,
7281 caller_symbol: None,
7282 caller_file: rel_path.to_string(),
7283 kind: "export_alias".to_string(),
7284 short_name: None,
7285 full_ref: Some(statement.to_string()),
7286 module_path: None,
7287 import_kind: Some("export_alias".to_string()),
7288 local_name: Some(exported),
7289 requested_name: Some(source_symbol),
7290 namespace_alias: None,
7291 wildcard: false,
7292 line,
7293 byte_start: start,
7294 byte_end: end,
7295 dependencies: BTreeSet::new(),
7296 });
7297 }
7298 }
7299 SourceLessExportRefs {
7300 raw_refs,
7301 surface_parts,
7302 }
7303}
7304
7305fn build_dispatch_hints(
7306 rel_path: &str,
7307 data: &FileCallData,
7308 node_by_scoped: &HashMap<String, String>,
7309) -> Vec<DispatchHint> {
7310 let mut hints = Vec::new();
7311 let mut ordinal = 0usize;
7312 for (caller_symbol, call_sites) in &data.calls_by_symbol {
7313 let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
7314 continue;
7315 };
7316 for call_site in call_sites {
7317 if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
7318 continue;
7319 }
7320 ordinal += 1;
7321 hints.push(DispatchHint {
7322 id: ref_id(&[
7323 rel_path,
7324 "dispatch",
7325 caller_symbol,
7326 &call_site.line.to_string(),
7327 &call_site.byte_start.to_string(),
7328 &call_site.byte_end.to_string(),
7329 &ordinal.to_string(),
7330 ]),
7331 method_name: call_site.callee_name.clone(),
7332 caller_node: caller_node.clone(),
7333 file: rel_path.to_string(),
7334 line: call_site.line,
7335 byte_start: call_site.byte_start,
7336 byte_end: call_site.byte_end,
7337 });
7338 }
7339 }
7340 hints
7341}
7342
7343fn surface_fingerprint(
7344 nodes: &mut [NodeRecord],
7345 data: &FileCallData,
7346 reexport_parts: &[String],
7347) -> String {
7348 nodes.sort_by(|left, right| {
7349 (left.file_path.as_str(), left.scoped_name.as_str())
7350 .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
7351 });
7352 let mut parts = Vec::new();
7353 for node in nodes.iter() {
7354 parts.push(format!(
7355 "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
7356 node.scoped_name,
7357 node.name,
7358 node.kind,
7359 node.exported,
7360 node.range.start_line,
7361 node.range.start_col,
7362 node.range.end_line,
7363 node.range.end_col,
7364 node.range_ordinal,
7365 node.signature.as_deref().unwrap_or("")
7366 ));
7367 }
7368 let mut exports = data.exported_symbols.clone();
7369 exports.sort();
7370 for export in exports {
7371 parts.push(format!("export\t{export}"));
7372 }
7373 if let Some(default_export) = &data.default_export_symbol {
7374 parts.push(format!("default\t{default_export}"));
7375 }
7376 let mut imports: Vec<String> = data
7377 .import_block
7378 .imports
7379 .iter()
7380 .map(|import| {
7381 format!(
7382 "import\t{}\t{:?}\t{}",
7383 import.module_path, import.form, import.raw_text
7384 )
7385 })
7386 .collect();
7387 imports.sort();
7388 parts.extend(imports);
7389 parts.extend(reexport_parts.iter().cloned());
7390 hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
7391}
7392
7393fn resolve_ref(raw: RawRef, index: &ProjectIndex<'_>) -> Result<ResolvedRef> {
7394 if raw.kind != "call" {
7395 return Ok(ResolvedRef {
7396 dependencies: raw.dependencies.clone(),
7397 raw,
7398 status: "unresolved".to_string(),
7399 target_node: None,
7400 target_file: None,
7401 target_symbol: None,
7402 edge: None,
7403 });
7404 }
7405
7406 let caller_file = raw.caller_file.clone();
7407 let caller_data = index.caller_data.get(&caller_file).ok_or_else(|| {
7408 CallGraphStoreError::MissingCallerData {
7409 file: caller_file.clone(),
7410 }
7411 })?;
7412 let full_ref = raw.full_ref.as_deref().unwrap_or_default();
7413 let short_name = raw.short_name.as_deref().unwrap_or_default();
7414 let mut dependencies = raw.dependencies.clone();
7415
7416 let resolved = match index.lang_for(&caller_file) {
7417 Some(LangId::Rust) => {
7418 resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
7419 }
7420 Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
7421 resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
7422 }
7423 _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
7424 };
7425
7426 let Some((status, target_file, target_symbol)) = resolved else {
7427 return Ok(ResolvedRef {
7428 raw,
7429 status: "unresolved".to_string(),
7430 target_node: None,
7431 target_file: None,
7432 target_symbol: None,
7433 dependencies,
7434 edge: None,
7435 });
7436 };
7437
7438 dependencies.insert(target_file.clone());
7439 let target_node = index.node_for_symbol(&target_file, &target_symbol);
7440 let source_node = raw.caller_node.clone();
7441 let edge = if let Some(source_node) = source_node {
7442 if target_file == caller_file
7443 && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
7444 {
7445 None
7446 } else {
7447 Some(EdgeRecord {
7448 edge_id: ref_id(&[&raw.ref_id, "edge"]),
7449 source_node,
7450 target_node: target_node.clone(),
7451 target_file: target_file.clone(),
7452 target_symbol: target_symbol.clone(),
7453 kind: "call".to_string(),
7454 line: raw.line,
7455 })
7456 }
7457 } else {
7458 None
7459 };
7460
7461 Ok(ResolvedRef {
7462 raw,
7463 status,
7464 target_node,
7465 target_file: Some(target_file),
7466 target_symbol: Some(target_symbol),
7467 dependencies,
7468 edge,
7469 })
7470}
7471
7472fn resolve_js_ts_target(
7473 index: &ProjectIndex<'_>,
7474 caller_file: &str,
7475 full_ref: &str,
7476 short_name: &str,
7477 caller_data: &FileCallData,
7478) -> Option<(String, String, String)> {
7479 if let Some((namespace, member)) = full_ref.split_once('.') {
7480 for import in &caller_data.import_block.imports {
7481 if import.namespace_import.as_deref() == Some(namespace) {
7482 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7483 if let Some((file, symbol)) =
7484 resolve_exported_symbol(index, &target_file, member, 0)
7485 {
7486 return Some(("resolved".to_string(), file, symbol));
7487 }
7488 }
7489 }
7490 }
7491 }
7492
7493 for import in &caller_data.import_block.imports {
7494 for spec in &import.names {
7495 if crate::imports::specifier_local_name(spec) == short_name {
7496 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7497 let requested = crate::imports::specifier_imported_name(spec);
7498 let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
7499 .unwrap_or_else(|| (target_file, requested.to_string()));
7500 return Some(("resolved".to_string(), file, symbol));
7501 }
7502 }
7503 }
7504
7505 if import.default_import.as_deref() == Some(short_name) {
7506 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7507 let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
7508 .or_else(|| {
7509 index
7510 .files
7511 .get(&target_file)
7512 .and_then(|file| file.default_export.clone())
7513 .map(|symbol| (target_file.clone(), symbol))
7514 })
7515 .unwrap_or_else(|| {
7516 let file_name = Path::new(&target_file)
7517 .file_name()
7518 .and_then(|name| name.to_str())
7519 .unwrap_or("unknown")
7520 .to_string();
7521 (target_file, format!("<default:{file_name}>"))
7522 });
7523 return Some(("resolved".to_string(), file, symbol));
7524 }
7525 }
7526 }
7527
7528 for import in &caller_data.import_block.imports {
7529 if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7530 if index
7531 .files
7532 .get(&target_file)
7533 .map(|file| file.exports.contains(short_name))
7534 .unwrap_or(false)
7535 {
7536 return Some(("resolved".to_string(), target_file, short_name.to_string()));
7537 }
7538 }
7539 }
7540
7541 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
7542}
7543
7544fn resolve_exported_symbol(
7545 index: &ProjectIndex<'_>,
7546 file: &str,
7547 requested: &str,
7548 depth: usize,
7549) -> Option<(String, String)> {
7550 let mut visited = std::collections::HashMap::new();
7551 resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
7552}
7553
7554fn resolve_exported_symbol_inner(
7563 index: &ProjectIndex<'_>,
7564 file: &str,
7565 requested: &str,
7566 depth: usize,
7567 visited: &mut std::collections::HashMap<(String, String), usize>,
7568) -> Option<(String, String)> {
7569 if depth > 16 {
7570 return None;
7571 }
7572 if requested != "default" {
7573 if let Some(source_symbol) = index
7574 .files
7575 .get(file)
7576 .and_then(|item| item.export_aliases.get(requested))
7577 {
7578 return Some((file.to_string(), source_symbol.clone()));
7579 }
7580 if index
7581 .files
7582 .get(file)
7583 .map(|item| item.exports.contains(requested))
7584 .unwrap_or(false)
7585 {
7586 return Some((file.to_string(), requested.to_string()));
7587 }
7588 } else if let Some(default) = index
7589 .files
7590 .get(file)
7591 .and_then(|item| item.default_export.clone())
7592 {
7593 return Some((file.to_string(), default));
7594 }
7595
7596 match visited.entry((file.to_string(), requested.to_string())) {
7600 std::collections::hash_map::Entry::Occupied(mut seen) => {
7601 if *seen.get() <= depth {
7602 return None;
7603 }
7604 seen.insert(depth);
7605 }
7606 std::collections::hash_map::Entry::Vacant(slot) => {
7607 slot.insert(depth);
7608 }
7609 }
7610
7611 for reexport in index.reexports_for(file) {
7612 let mut next_requested = requested.to_string();
7613 let matches = if reexport.wildcard {
7614 true
7615 } else if let Some(source_name) = reexport.named.get(requested) {
7616 next_requested = source_name.clone();
7617 true
7618 } else {
7619 false
7620 };
7621 if !matches {
7622 continue;
7623 }
7624 if let Some(target_file) = &reexport.target_file {
7625 if let Some(target) = resolve_exported_symbol_inner(
7626 index,
7627 target_file,
7628 &next_requested,
7629 depth + 1,
7630 visited,
7631 ) {
7632 return Some(target);
7633 }
7634 }
7635 }
7636 None
7637}
7638
7639fn resolve_rust_target(
7640 index: &ProjectIndex<'_>,
7641 caller_file: &str,
7642 full_ref: &str,
7643 short_name: &str,
7644 caller_data: &FileCallData,
7645 raw: &RawRef,
7646) -> Option<(String, String, String)> {
7647 if full_ref.contains("::") {
7648 if let Some((target_file, target_symbol)) =
7649 rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
7650 {
7651 return Some(("resolved".to_string(), target_file, target_symbol));
7652 }
7653 }
7654
7655 for import in &caller_data.import_block.imports {
7656 if let Some((target_file, target_symbol)) =
7657 rust_target_for_use(index, caller_file, import, short_name)
7658 {
7659 return Some(("resolved".to_string(), target_file, target_symbol));
7660 }
7661 }
7662
7663 resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
7664}
7665
7666fn rust_target_for_qualified(
7667 index: &ProjectIndex<'_>,
7668 caller_file: &str,
7669 full_ref: &str,
7670 short_name: &str,
7671 caller_data: &FileCallData,
7672 raw: &RawRef,
7673) -> Option<(String, String)> {
7674 let mut segments: Vec<&str> = full_ref.split("::").collect();
7675 if segments.len() < 2 {
7676 return None;
7677 }
7678 segments.pop();
7679 let requested_symbol = rust_target_symbol(full_ref, short_name);
7680
7681 for path in rust_module_path_candidates(&segments, caller_data, raw) {
7682 let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
7683 if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
7684 if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
7685 return Some(rust_resolve_reexport_if_symbol_missing(
7686 index,
7687 target_file,
7688 requested_symbol.clone(),
7689 ));
7690 }
7691 }
7692
7693 let module_segments = rust_resolve_segments(caller_file, &path_refs)?;
7694 if let Some(target) =
7695 rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
7696 {
7697 return Some(target);
7698 }
7699 if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
7700 return Some(rust_resolve_reexport_if_symbol_missing(
7701 index,
7702 target_file,
7703 requested_symbol.clone(),
7704 ));
7705 }
7706 }
7707 None
7708}
7709
7710fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
7711 full_ref
7712 .rsplit("::")
7713 .next()
7714 .filter(|name| !name.is_empty())
7715 .unwrap_or(short_name)
7716 .to_string()
7717}
7718
7719fn rust_resolve_reexport_if_symbol_missing(
7720 index: &ProjectIndex<'_>,
7721 target_file: String,
7722 target_symbol: String,
7723) -> (String, String) {
7724 if index
7725 .node_for_symbol(&target_file, &target_symbol)
7726 .is_some()
7727 {
7728 return (target_file, target_symbol);
7729 }
7730 if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
7731 resolved
7732 } else {
7733 (target_file, target_symbol)
7734 }
7735}
7736
7737fn rust_module_path_candidates(
7738 segments: &[&str],
7739 caller_data: &FileCallData,
7740 raw: &RawRef,
7741) -> Vec<Vec<String>> {
7742 let mut candidates = Vec::new();
7743 if let Some(first) = segments.first().copied() {
7744 for import in &caller_data.import_block.imports {
7745 if !rust_import_is_visible_to_call(import, raw) {
7746 continue;
7747 }
7748 let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
7749 continue;
7750 };
7751 if local_name == first {
7752 path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
7753 rust_push_unique_path_candidate(&mut candidates, path_segments);
7754 }
7755 }
7756 }
7757 rust_push_unique_path_candidate(
7758 &mut candidates,
7759 segments
7760 .iter()
7761 .map(|segment| (*segment).to_string())
7762 .collect(),
7763 );
7764 candidates
7765}
7766
7767fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
7768 if !candidates.iter().any(|existing| existing == &candidate) {
7769 candidates.push(candidate);
7770 }
7771}
7772
7773fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
7774 import.byte_range.start <= raw.byte_start
7775}
7776
7777fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
7778 let path = import.module_path.trim().trim_end_matches(';').trim();
7779 if path.contains("::{") || path.contains('{') || path.contains('*') {
7780 return None;
7781 }
7782 let (path_without_alias, alias) = path
7783 .split_once(" as ")
7784 .map(|(left, right)| (left.trim(), Some(right.trim())))
7785 .unwrap_or((path, None));
7786 let segments = path_without_alias
7787 .split("::")
7788 .map(str::trim)
7789 .filter(|segment| !segment.is_empty())
7790 .collect::<Vec<_>>();
7791 let local_name = alias.or_else(|| segments.last().copied())?.to_string();
7792 if local_name.chars().next().is_some_and(char::is_uppercase) {
7793 return None;
7794 }
7795 Some((
7796 local_name,
7797 segments
7798 .into_iter()
7799 .map(|segment| segment.to_string())
7800 .collect(),
7801 ))
7802}
7803
7804fn rust_inline_scoped_target(
7805 index: &ProjectIndex<'_>,
7806 caller_file: &str,
7807 module_segments: &[String],
7808 short_name: &str,
7809) -> Option<(String, String)> {
7810 let src_prefix = rust_src_prefix(caller_file);
7811 let mut file_paths = index.files.keys().cloned().collect::<Vec<_>>();
7812 file_paths.sort();
7813 if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
7814 let caller = file_paths.remove(position);
7815 file_paths.insert(0, caller);
7816 }
7817
7818 for file_path in file_paths {
7819 if index.lang_for(&file_path) != Some(LangId::Rust)
7820 || rust_src_prefix(&file_path) != src_prefix
7821 {
7822 continue;
7823 }
7824 let file_module_segments = rust_module_segments_for_rel(&file_path);
7825 if !module_segments.starts_with(&file_module_segments) {
7826 continue;
7827 }
7828 let scoped_segments = &module_segments[file_module_segments.len()..];
7829 if scoped_segments.is_empty() {
7830 continue;
7831 }
7832 let mut scoped_symbol = scoped_segments.join("::");
7833 scoped_symbol.push_str("::");
7834 scoped_symbol.push_str(short_name);
7835 if index.node_for_symbol(&file_path, &scoped_symbol).is_some() {
7836 return Some((file_path, scoped_symbol));
7837 }
7838 }
7839 None
7840}
7841
7842fn rust_target_for_use(
7843 index: &ProjectIndex<'_>,
7844 caller_file: &str,
7845 import: &ImportStatement,
7846 short_name: &str,
7847) -> Option<(String, String)> {
7848 let path = import.module_path.trim().trim_end_matches(';');
7849 if let Some(brace_start) = path.find("::{") {
7850 let prefix = &path[..brace_start];
7851 if import.names.iter().any(|name| name == short_name) {
7852 let prefix_segments: Vec<&str> = prefix.split("::").collect();
7853 let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
7854 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
7855 return Some((file, short_name.to_string()));
7856 }
7857 return None;
7858 }
7859
7860 let (path_without_alias, alias) = path
7861 .split_once(" as ")
7862 .map(|(left, right)| (left.trim(), Some(right.trim())))
7863 .unwrap_or((path, None));
7864 let segments: Vec<&str> = path_without_alias.split("::").collect();
7865 let imported = alias.or_else(|| segments.last().copied())?;
7866 if imported != short_name {
7867 return None;
7868 }
7869 if segments.len() < 2 {
7870 return None;
7871 }
7872 let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
7873 let file = rust_file_for_segments(index, caller_file, &module_segments)?;
7874 Some((file, segments.last().unwrap_or(&short_name).to_string()))
7875}
7876
7877fn rust_workspace_file_for_segments(index: &ProjectIndex<'_>, segments: &[&str]) -> Option<String> {
7878 let crate_name = segments.first().copied()?;
7879 let src_prefix = index.crate_src_prefix(crate_name)?;
7880 let module_segments = segments[1..]
7881 .iter()
7882 .map(|segment| segment.to_string())
7883 .collect::<Vec<_>>();
7884 rust_file_for_src_prefix(index, &src_prefix, &module_segments)
7885}
7886
7887#[cfg(test)]
7888static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
7889 OnceLock::new();
7890
7891#[cfg(test)]
7892fn note_workspace_crate_prefix_build(project_root: &Path) {
7893 let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
7894 .get_or_init(|| Mutex::new(HashMap::new()))
7895 .lock()
7896 .expect("workspace crate prefix build counts mutex poisoned");
7897 *counts.entry(project_root.to_path_buf()).or_default() += 1;
7898}
7899
7900#[cfg(not(test))]
7901fn note_workspace_crate_prefix_build(_project_root: &Path) {}
7902
7903#[cfg(test)]
7904fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
7905 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
7906 .get_or_init(|| Mutex::new(HashMap::new()))
7907 .lock()
7908 .expect("workspace crate prefix build counts mutex poisoned")
7909 .remove(project_root);
7910}
7911
7912#[cfg(test)]
7913fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
7914 WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
7915 .get_or_init(|| Mutex::new(HashMap::new()))
7916 .lock()
7917 .expect("workspace crate prefix build counts mutex poisoned")
7918 .get(project_root)
7919 .copied()
7920 .unwrap_or(0)
7921}
7922
7923fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
7928 note_workspace_crate_prefix_build(project_root);
7929 let mut prefixes = HashMap::new();
7930 let mut stack = vec![project_root.to_path_buf()];
7931 while let Some(dir) = stack.pop() {
7932 let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
7933 if matches!(name, "target" | "node_modules" | ".git") {
7934 continue;
7935 }
7936 let manifest = dir.join("Cargo.toml");
7937 if manifest.is_file() {
7938 let crate_names = rust_manifest_crate_names(&manifest);
7939 if !crate_names.is_empty() {
7940 let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
7941 for crate_name in crate_names {
7942 prefixes
7943 .entry(crate_name)
7944 .or_insert_with(|| src_prefix.clone());
7945 }
7946 }
7947 }
7948 let Ok(entries) = std::fs::read_dir(&dir) else {
7949 continue;
7950 };
7951 for entry in entries.flatten() {
7952 let path = entry.path();
7953 if path.is_dir() {
7954 stack.push(path);
7955 }
7956 }
7957 }
7958 prefixes
7959}
7960
7961fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
7965 let Ok(source) = std::fs::read_to_string(manifest) else {
7966 return Vec::new();
7967 };
7968 let mut in_lib = false;
7969 let mut package_name = None;
7970 let mut lib_name = None;
7971 for line in source.lines() {
7972 let trimmed = line.trim();
7973 if trimmed.starts_with('[') {
7974 in_lib = trimmed == "[lib]";
7975 continue;
7976 }
7977 let Some((key, value)) = trimmed.split_once('=') else {
7978 continue;
7979 };
7980 let key = key.trim();
7981 let value = value.trim().trim_matches('"');
7982 if in_lib && key == "name" {
7983 lib_name = Some(value.to_string());
7984 } else if !in_lib && key == "name" && package_name.is_none() {
7985 package_name = Some(value.to_string());
7986 }
7987 }
7988 let mut names = Vec::new();
7989 if let Some(lib) = lib_name {
7990 names.push(lib);
7991 }
7992 if let Some(package) = package_name {
7993 let normalized = package.replace('-', "_");
7994 if !names.contains(&normalized) {
7995 names.push(normalized);
7996 }
7997 }
7998 names
7999}
8000
8001fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
8002 if segments.is_empty() {
8003 return Some(Vec::new());
8004 }
8005 let caller_segments = rust_module_segments_for_rel(caller_file);
8006 match segments[0] {
8007 "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
8008 "self" => {
8009 let mut resolved = caller_segments;
8010 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
8011 Some(resolved)
8012 }
8013 "super" => {
8014 let mut resolved = caller_segments;
8015 resolved.pop();
8016 resolved.extend(segments[1..].iter().map(|item| item.to_string()));
8017 Some(resolved)
8018 }
8019 _ => {
8020 let mut resolved = caller_segments;
8021 resolved.pop();
8022 resolved.extend(segments.iter().map(|item| item.to_string()));
8023 Some(resolved)
8024 }
8025 }
8026}
8027
8028fn rust_file_for_segments(
8029 index: &ProjectIndex<'_>,
8030 caller_file: &str,
8031 segments: &[String],
8032) -> Option<String> {
8033 rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
8034}
8035
8036fn rust_file_for_src_prefix(
8037 index: &ProjectIndex<'_>,
8038 src_prefix: &str,
8039 segments: &[String],
8040) -> Option<String> {
8041 let candidate = if segments.is_empty() {
8042 [src_prefix, "lib.rs"].join("/")
8043 } else {
8044 format!("{}/{}.rs", src_prefix, segments.join("/"))
8045 };
8046 if index.files.contains_key(&candidate) {
8047 return Some(candidate);
8048 }
8049 if !segments.is_empty() {
8050 let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
8051 if index.files.contains_key(&mod_candidate) {
8052 return Some(mod_candidate);
8053 }
8054 }
8055 None
8056}
8057
8058fn rust_src_prefix(rel_path: &str) -> String {
8059 rel_path
8060 .split_once("/src/")
8061 .map(|(prefix, _)| format!("{prefix}/src"))
8062 .unwrap_or_else(|| "src".to_string())
8063}
8064
8065fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
8066 let after_src = rel_path
8067 .split_once("/src/")
8068 .map(|(_, rest)| rest)
8069 .or_else(|| rel_path.strip_prefix("src/"))
8070 .unwrap_or(rel_path);
8071 if matches!(after_src, "lib.rs" | "main.rs") {
8072 return Vec::new();
8073 }
8074 if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
8075 return prefix.split('/').map(|item| item.to_string()).collect();
8076 }
8077 after_src
8078 .strip_suffix(".rs")
8079 .unwrap_or(after_src)
8080 .split('/')
8081 .map(|item| item.to_string())
8082 .collect()
8083}
8084
8085fn resolve_local_target(
8086 _index: &ProjectIndex<'_>,
8087 caller_file: &str,
8088 full_ref: &str,
8089 short_name: &str,
8090 caller_data: &FileCallData,
8091) -> Option<(String, String, String)> {
8092 if !callgraph::is_bare_callee(full_ref, short_name) {
8093 return None;
8094 }
8095 callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
8096 .ok()
8097 .map(|symbol| {
8098 (
8099 "resolved_local".to_string(),
8100 caller_file.to_string(),
8101 symbol,
8102 )
8103 })
8104}
8105
8106impl<'a> ProjectIndex<'a> {
8107 fn from_parts(
8108 project_root: &Path,
8109 files: HashMap<String, DbFileIndex>,
8110 caller_data: HashMap<String, &'a FileCallData>,
8111 workspace_crate_prefixes: WorkspaceCratePrefixCache,
8112 ) -> Self {
8113 Self {
8114 project_root: project_root.to_path_buf(),
8115 files,
8116 caller_data,
8117 workspace_crate_prefixes,
8118 }
8119 }
8120
8121 fn from_extracts(project_root: &Path, extracts: &'a [FileExtract]) -> Self {
8122 let mut files = HashMap::new();
8123 let mut caller_data = HashMap::new();
8124 for extract in extracts {
8125 let index = DbFileIndex::from_extract(project_root, extract);
8126 caller_data.insert(extract.rel_path.clone(), &extract.data);
8127 files.insert(extract.rel_path.clone(), index);
8128 }
8129 Self::from_parts(
8130 project_root,
8131 files,
8132 caller_data,
8133 WorkspaceCratePrefixCache::default(),
8134 )
8135 }
8136
8137 fn from_db_and_callers(
8138 tx: &Transaction<'_>,
8139 project_root: &Path,
8140 caller_extracts: &'a HashMap<String, FileExtract>,
8141 workspace_crate_prefixes: WorkspaceCratePrefixCache,
8142 ) -> Result<Self> {
8143 let mut files = load_db_file_indexes(tx, project_root)?;
8144 let mut caller_data = HashMap::new();
8145 for (rel_path, extract) in caller_extracts {
8146 files.insert(
8147 rel_path.clone(),
8148 DbFileIndex::from_extract(project_root, extract),
8149 );
8150 caller_data.insert(rel_path.clone(), &extract.data);
8151 }
8152 Ok(Self::from_parts(
8153 project_root,
8154 files,
8155 caller_data,
8156 workspace_crate_prefixes,
8157 ))
8158 }
8159
8160 fn lang_for(&self, rel_path: &str) -> Option<LangId> {
8161 self.files.get(rel_path).and_then(|file| file.lang)
8162 }
8163
8164 fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
8165 self.files
8166 .get(caller_file)
8167 .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
8168 }
8169
8170 fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
8171 self.files
8172 .get(rel_path)
8173 .map(|file| file.reexports.as_slice())
8174 .unwrap_or(&[])
8175 }
8176
8177 fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
8178 self.files.get(rel_path).and_then(|file| {
8179 file.node_by_scoped
8180 .get(symbol)
8181 .cloned()
8182 .or_else(|| file.node_by_bare.get(symbol).cloned())
8183 })
8184 }
8185}
8186
8187impl DbFileIndex {
8188 fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
8189 let mut node_by_scoped = HashMap::new();
8190 let mut node_by_bare = HashMap::new();
8191 for node in &extract.nodes {
8192 node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
8193 node_by_bare
8194 .entry(node.name.clone())
8195 .or_insert(node.id.clone());
8196 }
8197 let mut export_aliases = HashMap::new();
8198 for raw_ref in &extract.raw_refs {
8199 if raw_ref.kind == "export_alias" {
8200 if let (Some(exported), Some(source_symbol)) =
8201 (&raw_ref.local_name, &raw_ref.requested_name)
8202 {
8203 export_aliases.insert(exported.clone(), source_symbol.clone());
8204 }
8205 }
8206 }
8207 let mut module_targets = HashMap::new();
8208 let mut reexports = Vec::new();
8209 for raw_ref in &extract.raw_refs {
8210 if !matches!(raw_ref.kind.as_str(), "import" | "reexport") {
8211 continue;
8212 }
8213 let Some(module_path) = &raw_ref.module_path else {
8214 continue;
8215 };
8216 let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
8217 module_targets
8218 .entry(module_path.clone())
8219 .or_insert_with(|| target_file.clone());
8220 if raw_ref.kind == "reexport" {
8221 reexports.push(reexport_index_from_raw(raw_ref, target_file));
8222 }
8223 }
8224 Self {
8225 lang: Some(extract.lang),
8226 exports: extract.data.exported_symbols.iter().cloned().collect(),
8227 default_export: extract.data.default_export_symbol.clone(),
8228 export_aliases,
8229 node_by_scoped,
8230 node_by_bare,
8231 module_targets,
8232 reexports,
8233 }
8234 }
8235}
8236
8237fn load_db_file_indexes(
8238 tx: &Transaction<'_>,
8239 project_root: &Path,
8240) -> Result<HashMap<String, DbFileIndex>> {
8241 let mut files = HashMap::new();
8242 let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
8243 let rows = stmt.query_map([], |row| {
8244 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8245 })?;
8246 for row in rows {
8247 let (rel_path, lang) = row?;
8248 files.insert(
8249 rel_path.clone(),
8250 DbFileIndex {
8251 lang: lang_from_label(&lang),
8252 exports: HashSet::new(),
8253 default_export: None,
8254 export_aliases: HashMap::new(),
8255 node_by_scoped: HashMap::new(),
8256 node_by_bare: HashMap::new(),
8257 module_targets: HashMap::new(),
8258 reexports: Vec::new(),
8259 },
8260 );
8261 }
8262
8263 let mut node_stmt = tx.prepare(
8264 "SELECT file_path, id, name, scoped_name, exported, is_default_export FROM nodes",
8265 )?;
8266 let nodes = node_stmt.query_map([], |row| {
8267 Ok((
8268 row.get::<_, String>(0)?,
8269 row.get::<_, String>(1)?,
8270 row.get::<_, String>(2)?,
8271 row.get::<_, String>(3)?,
8272 row.get::<_, i64>(4)? != 0,
8273 row.get::<_, i64>(5)? != 0,
8274 ))
8275 })?;
8276 for row in nodes {
8277 let (file_path, id, name, scoped_name, exported, is_default_export) = row?;
8278 let file = files
8279 .entry(file_path.clone())
8280 .or_insert_with(|| DbFileIndex {
8281 lang: None,
8282 exports: HashSet::new(),
8283 default_export: None,
8284 export_aliases: HashMap::new(),
8285 node_by_scoped: HashMap::new(),
8286 node_by_bare: HashMap::new(),
8287 module_targets: HashMap::new(),
8288 reexports: Vec::new(),
8289 });
8290 if exported {
8291 file.exports.insert(name.clone());
8292 file.exports.insert(scoped_name.clone());
8293 }
8294 if is_default_export {
8295 file.default_export = Some(scoped_name.clone());
8296 }
8297 file.node_by_scoped.insert(scoped_name, id.clone());
8298 file.node_by_bare.entry(name).or_insert(id);
8299 }
8300 let file_keys: HashSet<String> = files.keys().cloned().collect();
8301 let dependencies_by_file = load_file_dependencies_index(tx)?;
8305 let mut ref_stmt = tx.prepare(
8306 "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
8307 FROM refs WHERE kind IN ('reexport', 'export_alias')",
8308 )?;
8309 let ref_rows = ref_stmt.query_map([], |row| {
8310 Ok((
8311 row.get::<_, String>(0)?,
8312 row.get::<_, String>(1)?,
8313 row.get::<_, String>(2)?,
8314 row.get::<_, Option<String>>(3)?,
8315 row.get::<_, Option<String>>(4)?,
8316 row.get::<_, i64>(5)? != 0,
8317 row.get::<_, Option<String>>(6)?,
8318 row.get::<_, Option<String>>(7)?,
8319 ))
8320 })?;
8321 for row in ref_rows {
8322 let (
8323 ref_id,
8324 caller_file,
8325 kind,
8326 module_path,
8327 full_ref,
8328 wildcard,
8329 local_name,
8330 requested_name,
8331 ) = row?;
8332 if kind == "export_alias" {
8333 if let (Some(exported), Some(source_symbol), Some(file)) =
8334 (local_name, requested_name, files.get_mut(&caller_file))
8335 {
8336 file.export_aliases.insert(exported, source_symbol);
8337 }
8338 continue;
8339 }
8340 let Some(module_path) = module_path else {
8341 continue;
8342 };
8343 let file_deps = dependencies_by_file
8344 .get(&caller_file)
8345 .cloned()
8346 .unwrap_or_default();
8347 let deps = stored_dependencies_for_module(
8348 project_root,
8349 &caller_file,
8350 &module_path,
8351 &file_deps,
8352 &file_keys,
8353 );
8354 let target_file = deps
8355 .iter()
8356 .find(|dep| file_keys.contains(*dep))
8357 .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
8358 if let Some(file) = files.get_mut(&caller_file) {
8359 file.module_targets
8360 .entry(module_path.clone())
8361 .or_insert_with(|| target_file.clone());
8362 if kind == "reexport" {
8363 let raw = RawRef {
8364 ref_id,
8365 caller_node: None,
8366 caller_symbol: None,
8367 caller_file,
8368 kind,
8369 short_name: None,
8370 full_ref,
8371 module_path: Some(module_path),
8372 import_kind: Some("reexport".to_string()),
8373 local_name: None,
8374 requested_name: None,
8375 namespace_alias: None,
8376 wildcard,
8377 line: 0,
8378 byte_start: 0,
8379 byte_end: 0,
8380 dependencies: deps,
8381 };
8382 file.reexports
8383 .push(reexport_index_from_raw(&raw, target_file));
8384 }
8385 }
8386 }
8387
8388 Ok(files)
8389}
8390
8391fn stored_dependencies_for_module(
8392 project_root: &Path,
8393 caller_file: &str,
8394 module_path: &str,
8395 caller_dependencies: &BTreeSet<String>,
8396 indexed_files: &HashSet<String>,
8397) -> BTreeSet<String> {
8398 let caller_path = project_root.join(caller_file);
8399 let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
8400 if module_path.starts_with('.') {
8401 let caller_dir = caller_path.parent().unwrap_or(project_root);
8402 for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
8403 let normalized = if candidate.is_file() {
8404 canonicalize_path(&candidate)
8405 } else {
8406 candidate
8407 };
8408 candidates.insert(relative_path(project_root, &normalized));
8409 }
8410 }
8411 let exact = candidates
8412 .intersection(caller_dependencies)
8413 .filter(|dependency| indexed_files.contains(*dependency))
8414 .cloned()
8415 .collect::<BTreeSet<_>>();
8416 if !exact.is_empty() || module_path.starts_with('.') {
8417 return exact;
8418 }
8419
8420 let module_path = rust_module_path_without_alias_or_use_list(module_path)
8421 .trim_matches(|character| matches!(character, '\'' | '"'));
8422 let package_name = module_path
8423 .split('/')
8424 .next_back()
8425 .unwrap_or(module_path)
8426 .replace('_', "-");
8427 let matched = caller_dependencies
8428 .iter()
8429 .filter(|dependency| indexed_files.contains(*dependency))
8430 .filter(|dependency| {
8431 dependency.as_str() == module_path
8432 || dependency.ends_with(&format!("/{module_path}"))
8433 || Path::new(dependency).components().any(|component| {
8434 component.as_os_str().to_string_lossy().replace('_', "-") == package_name
8435 })
8436 })
8437 .cloned()
8438 .collect::<BTreeSet<_>>();
8439 if matched.len() == 1 {
8440 matched
8441 } else {
8442 BTreeSet::new()
8443 }
8444}
8445
8446fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
8447 let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
8448 let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
8449 let rows = stmt.query_map([], |row| {
8450 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8451 })?;
8452 for row in rows {
8453 let (file_path, dependency) = row?;
8454 by_file.entry(file_path).or_default().insert(dependency);
8455 }
8456 Ok(by_file)
8457}
8458
8459struct ColdBuildInsertStatements<'stmt> {
8460 file: Statement<'stmt>,
8461 node: Statement<'stmt>,
8462 file_dependency: Statement<'stmt>,
8463 dispatch_hint: Statement<'stmt>,
8464 backend_state: Statement<'stmt>,
8465 reference: Statement<'stmt>,
8466 edge: Statement<'stmt>,
8467}
8468
8469impl<'stmt> ColdBuildInsertStatements<'stmt> {
8470 fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
8471 Ok(Self {
8472 file: tx.prepare(
8473 "INSERT OR REPLACE INTO files(
8474 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
8475 is_public_api, surface_fingerprint, indexed_at
8476 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
8477 )?,
8478 node: tx.prepare(
8479 "INSERT OR REPLACE INTO nodes(
8480 id, file_path, name, scoped_name, kind, start_line, start_col,
8481 end_line, end_col, range_ordinal, signature, exported,
8482 is_default_export, is_type_like, is_callgraph_entry_point, provenance
8483 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
8484 )?,
8485 file_dependency: tx.prepare(
8486 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
8487 )?,
8488 dispatch_hint: tx.prepare(
8489 "INSERT OR REPLACE INTO dispatch_hints(
8490 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
8491 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
8492 )?,
8493 backend_state: tx.prepare(
8494 "INSERT OR REPLACE INTO backend_file_state(
8495 backend, workspace_root, file_path, content_hash, status, updated_at
8496 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
8497 )?,
8498 reference: tx.prepare(
8499 "INSERT OR REPLACE INTO refs(
8500 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
8501 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
8502 byte_start, byte_end, status, target_node, target_file, target_symbol,
8503 provenance
8504 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
8505 )?,
8506 edge: tx.prepare(
8507 "INSERT OR REPLACE INTO edges(
8508 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8509 kind, line, provenance
8510 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
8511 )?,
8512 })
8513 }
8514}
8515
8516fn insert_file_extract_prepared(
8517 statements: &mut ColdBuildInsertStatements<'_>,
8518 workspace_root: &str,
8519 extract: &FileExtract,
8520) -> Result<()> {
8521 statements.file.execute(params![
8522 extract.rel_path,
8523 hash_to_hex(extract.freshness.content_hash),
8524 system_time_to_ns(extract.freshness.mtime),
8525 extract.freshness.size as i64,
8526 lang_label(extract.lang),
8527 extract.surface_fingerprint,
8528 unix_seconds_now(),
8529 ])?;
8530 for node in &extract.nodes {
8531 statements.node.execute(params![
8532 node.id,
8533 node.file_path,
8534 node.name,
8535 node.scoped_name,
8536 node.kind,
8537 node.range.start_line as i64,
8538 node.range.start_col as i64,
8539 node.range.end_line as i64,
8540 node.range.end_col as i64,
8541 node.range_ordinal as i64,
8542 node.signature,
8543 bool_int(node.exported),
8544 bool_int(node.is_default_export),
8545 bool_int(node.is_type_like),
8546 bool_int(node.is_callgraph_entry_point),
8547 PROVENANCE_TREESITTER,
8548 ])?;
8549 }
8550
8551 let mut dependencies = BTreeSet::new();
8552 for raw_ref in &extract.raw_refs {
8553 dependencies.extend(raw_ref.dependencies.iter().cloned());
8554 }
8555 for dep_file in &dependencies {
8556 statements
8557 .file_dependency
8558 .execute(params![extract.rel_path, dep_file])?;
8559 }
8560
8561 for hint in &extract.dispatch_hints {
8562 statements.dispatch_hint.execute(params![
8563 hint.id,
8564 hint.method_name,
8565 hint.caller_node,
8566 hint.file,
8567 hint.line as i64,
8568 hint.byte_start as i64,
8569 hint.byte_end as i64,
8570 PROVENANCE_TREESITTER,
8571 ])?;
8572 }
8573 insert_backend_state_prepared(
8574 &mut statements.backend_state,
8575 workspace_root,
8576 &extract.rel_path,
8577 Some(&extract.freshness.content_hash),
8578 "fresh",
8579 )?;
8580 Ok(())
8581}
8582
8583fn insert_backend_state_prepared(
8584 stmt: &mut Statement<'_>,
8585 workspace_root: &str,
8586 rel_path: &str,
8587 content_hash: Option<&blake3::Hash>,
8588 status: &str,
8589) -> Result<()> {
8590 let hash = content_hash
8591 .map(|hash| hash_to_hex(*hash))
8592 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
8593 stmt.execute(params![
8594 BACKEND_TREESITTER,
8595 workspace_root,
8596 rel_path,
8597 hash,
8598 status,
8599 unix_seconds_now(),
8600 ])?;
8601 Ok(())
8602}
8603
8604fn insert_resolved_ref_prepared(
8605 statements: &mut ColdBuildInsertStatements<'_>,
8606 resolved: &ResolvedRef,
8607) -> Result<()> {
8608 let raw = &resolved.raw;
8609 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
8610 statements.reference.execute(params![
8611 raw.ref_id,
8612 raw.caller_node,
8613 raw.caller_file,
8614 raw.kind,
8615 raw.short_name,
8616 raw.full_ref,
8617 raw.module_path,
8618 raw.import_kind,
8619 raw.local_name,
8620 raw.requested_name,
8621 raw.namespace_alias,
8622 bool_int(raw.wildcard),
8623 raw.line as i64,
8624 raw.byte_start as i64,
8625 raw.byte_end as i64,
8626 resolved.status,
8627 resolved.target_node,
8628 resolved.target_file,
8629 resolved.target_symbol,
8630 PROVENANCE_TREESITTER,
8631 ])?;
8632 if let Some(edge) = &resolved.edge {
8633 statements.edge.execute(params![
8634 edge.edge_id,
8635 raw.ref_id,
8636 edge.source_node,
8637 edge.target_node,
8638 edge.target_file,
8639 edge.target_symbol,
8640 edge.kind,
8641 edge.line as i64,
8642 PROVENANCE_TREESITTER,
8643 ])?;
8644 }
8645 Ok(())
8646}
8647
8648fn insert_file_extract(
8649 tx: &Transaction<'_>,
8650 project_root: &Path,
8651 extract: &FileExtract,
8652) -> Result<()> {
8653 tx.execute(
8654 "INSERT OR REPLACE INTO files(
8655 path, content_hash, mtime_ns, size, lang, is_dead_code_root,
8656 is_public_api, surface_fingerprint, indexed_at
8657 ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
8658 params![
8659 extract.rel_path,
8660 hash_to_hex(extract.freshness.content_hash),
8661 system_time_to_ns(extract.freshness.mtime),
8662 extract.freshness.size as i64,
8663 lang_label(extract.lang),
8664 extract.surface_fingerprint,
8665 unix_seconds_now(),
8666 ],
8667 )?;
8668 for node in &extract.nodes {
8669 tx.execute(
8670 "INSERT OR REPLACE INTO nodes(
8671 id, file_path, name, scoped_name, kind, start_line, start_col,
8672 end_line, end_col, range_ordinal, signature, exported,
8673 is_default_export, is_type_like, is_callgraph_entry_point, provenance
8674 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
8675 params![
8676 node.id,
8677 node.file_path,
8678 node.name,
8679 node.scoped_name,
8680 node.kind,
8681 node.range.start_line as i64,
8682 node.range.start_col as i64,
8683 node.range.end_line as i64,
8684 node.range.end_col as i64,
8685 node.range_ordinal as i64,
8686 node.signature,
8687 bool_int(node.exported),
8688 bool_int(node.is_default_export),
8689 bool_int(node.is_type_like),
8690 bool_int(node.is_callgraph_entry_point),
8691 PROVENANCE_TREESITTER,
8692 ],
8693 )?;
8694 }
8695 let mut dependencies = BTreeSet::new();
8696 for raw_ref in &extract.raw_refs {
8697 dependencies.extend(raw_ref.dependencies.iter().cloned());
8698 }
8699 insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
8700
8701 for hint in &extract.dispatch_hints {
8702 tx.execute(
8703 "INSERT OR REPLACE INTO dispatch_hints(
8704 id, method_name, caller_node, file, line, byte_start, byte_end, provenance
8705 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
8706 params![
8707 hint.id,
8708 hint.method_name,
8709 hint.caller_node,
8710 hint.file,
8711 hint.line as i64,
8712 hint.byte_start as i64,
8713 hint.byte_end as i64,
8714 PROVENANCE_TREESITTER,
8715 ],
8716 )?;
8717 }
8718 mark_backend_state(
8719 tx,
8720 project_root,
8721 &extract.rel_path,
8722 Some(&extract.freshness.content_hash),
8723 "fresh",
8724 )?;
8725 Ok(())
8726}
8727
8728fn insert_file_dependencies(
8729 tx: &Transaction<'_>,
8730 file_path: &str,
8731 dependencies: &BTreeSet<String>,
8732) -> Result<()> {
8733 for dep_file in dependencies {
8734 tx.execute(
8735 "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
8736 params![file_path, dep_file],
8737 )?;
8738 }
8739 Ok(())
8740}
8741
8742fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
8743 let raw = &resolved.raw;
8744 debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
8745 tx.execute(
8746 "INSERT OR REPLACE INTO refs(
8747 ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
8748 import_kind, local_name, requested_name, namespace_alias, wildcard, line,
8749 byte_start, byte_end, status, target_node, target_file, target_symbol,
8750 provenance
8751 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
8752 params![
8753 raw.ref_id,
8754 raw.caller_node,
8755 raw.caller_file,
8756 raw.kind,
8757 raw.short_name,
8758 raw.full_ref,
8759 raw.module_path,
8760 raw.import_kind,
8761 raw.local_name,
8762 raw.requested_name,
8763 raw.namespace_alias,
8764 bool_int(raw.wildcard),
8765 raw.line as i64,
8766 raw.byte_start as i64,
8767 raw.byte_end as i64,
8768 resolved.status,
8769 resolved.target_node,
8770 resolved.target_file,
8771 resolved.target_symbol,
8772 PROVENANCE_TREESITTER,
8773 ],
8774 )?;
8775 if let Some(edge) = &resolved.edge {
8776 tx.execute(
8777 "INSERT OR REPLACE INTO edges(
8778 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8779 kind, line, provenance
8780 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
8781 params![
8782 edge.edge_id,
8783 raw.ref_id,
8784 edge.source_node,
8785 edge.target_node,
8786 edge.target_file,
8787 edge.target_symbol,
8788 edge.kind,
8789 edge.line as i64,
8790 PROVENANCE_TREESITTER,
8791 ],
8792 )?;
8793 }
8794 Ok(())
8795}
8796
8797fn insert_method_dispatch_edges(
8798 tx: &Transaction<'_>,
8799 project_root: &Path,
8800 caller_files: Option<&BTreeSet<String>>,
8801) -> Result<usize> {
8802 let references = load_name_match_refs(tx, caller_files)?;
8803 if references.is_empty() {
8804 return Ok(0);
8805 }
8806
8807 let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
8808 let mut source_cache: DispatchSourceCache = HashMap::new();
8809 let mut inserted = 0usize;
8810 for reference in references {
8811 let key = (reference.method_name.clone(), reference.lang.clone());
8812 let candidates = match candidates_by_name.entry(key) {
8813 Entry::Occupied(entry) => entry.into_mut(),
8814 Entry::Vacant(entry) => {
8815 let candidates =
8816 load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
8817 entry.insert(candidates)
8818 }
8819 };
8820
8821 match infer_receiver_type_state(project_root, &reference, &mut source_cache) {
8822 ReceiverTypeInference::Known(receiver_type) => {
8823 let Some(candidate) =
8824 select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
8825 else {
8826 continue;
8827 };
8828 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
8829 inserted += 1;
8830 continue;
8831 }
8832 ReceiverTypeInference::RustDirectSelfField {
8833 receiver_type,
8834 declaration_file,
8835 module_scope,
8836 } => {
8837 let Some(candidate) = select_rust_direct_self_field_candidate(
8838 project_root,
8839 &reference,
8840 candidates.as_slice(),
8841 &receiver_type,
8842 &declaration_file,
8843 &module_scope,
8844 &mut source_cache,
8845 ) else {
8846 continue;
8847 };
8848 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
8849 inserted += 1;
8850 continue;
8851 }
8852 ReceiverTypeInference::KnownButUnresolved => continue,
8853 ReceiverTypeInference::Unknown => {}
8854 }
8855
8856 if method_name_match_denylisted(&reference.method_name) {
8857 continue;
8858 }
8859
8860 let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
8861 continue;
8862 };
8863 insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
8864 inserted += 1;
8865 }
8866 Ok(inserted)
8867}
8868
8869fn insert_method_dispatch_edges_chunked(
8870 tx: &Transaction<'_>,
8871 project_root: &Path,
8872 caller_files: &BTreeSet<String>,
8873 chunk_size: usize,
8874) -> Result<usize> {
8875 if caller_files.is_empty() {
8876 return Ok(0);
8877 }
8878 if chunk_size == 0 || caller_files.len() <= chunk_size {
8879 return insert_method_dispatch_edges(tx, project_root, Some(caller_files));
8880 }
8881
8882 let mut inserted = 0usize;
8883 let mut batch = BTreeSet::new();
8884 for caller_file in caller_files {
8885 batch.insert(caller_file.clone());
8886 if batch.len() == chunk_size {
8887 inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
8888 batch.clear();
8889 }
8890 }
8891 if !batch.is_empty() {
8892 inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
8893 }
8894 Ok(inserted)
8895}
8896
8897fn insert_method_dispatch_edge(
8898 tx: &Transaction<'_>,
8899 reference: &NameMatchRef,
8900 candidate: &NameMatchCandidate,
8901 provenance: &str,
8902) -> Result<()> {
8903 tx.execute(
8904 "INSERT OR REPLACE INTO edges(
8905 edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8906 kind, line, provenance
8907 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
8908 params![
8909 ref_id(&[&reference.ref_id, provenance, "edge"]),
8910 &reference.ref_id,
8911 &reference.caller_node,
8912 &candidate.node_id,
8913 &candidate.file_path,
8914 &candidate.scoped_name,
8915 reference.line as i64,
8916 provenance,
8917 ],
8918 )?;
8919 Ok(())
8920}
8921
8922fn delete_method_dispatch_edges_for_callers(
8923 tx: &Transaction<'_>,
8924 caller_files: &BTreeSet<String>,
8925) -> Result<()> {
8926 if caller_files.is_empty() {
8927 return Ok(());
8928 }
8929
8930 let mut stmt = tx.prepare(
8931 "DELETE FROM edges
8932 WHERE provenance IN (?1, ?2)
8933 AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
8934 )?;
8935 for caller_file in caller_files {
8936 stmt.execute(params![
8937 PROVENANCE_NAME_MATCH,
8938 PROVENANCE_TYPE_MATCH,
8939 caller_file
8940 ])?;
8941 }
8942 Ok(())
8943}
8944
8945fn load_name_match_refs(
8946 tx: &Transaction<'_>,
8947 caller_files: Option<&BTreeSet<String>>,
8948) -> Result<Vec<NameMatchRef>> {
8949 let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
8950 n.signature, r.short_name, r.full_ref, r.line, f.lang
8951 FROM refs r
8952 JOIN files f ON f.path = r.caller_file
8953 JOIN nodes n ON n.id = r.caller_node
8954 WHERE r.kind = 'call'
8955 AND r.status = 'unresolved'
8956 AND r.caller_node IS NOT NULL
8957 AND r.full_ref IS NOT NULL
8958 AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
8959 AND NOT EXISTS (
8960 SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
8961 )";
8962 let mut references = Vec::new();
8963
8964 if let Some(caller_files) = caller_files {
8965 if caller_files.is_empty() {
8966 return Ok(references);
8967 }
8968 let sql = format!(
8969 "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
8970 );
8971 let mut stmt = tx.prepare(&sql)?;
8972 for caller_file in caller_files {
8973 let rows = stmt.query_map(params![caller_file], |row| {
8974 Ok((
8975 row.get::<_, String>(0)?,
8976 row.get::<_, Option<String>>(1)?,
8977 row.get::<_, String>(2)?,
8978 row.get::<_, String>(3)?,
8979 row.get::<_, Option<String>>(4)?,
8980 row.get::<_, Option<String>>(5)?,
8981 row.get::<_, Option<String>>(6)?,
8982 row.get::<_, i64>(7)?,
8983 row.get::<_, String>(8)?,
8984 ))
8985 })?;
8986 for row in rows {
8987 let (
8988 ref_id,
8989 caller_node,
8990 caller_file,
8991 caller_symbol,
8992 caller_signature,
8993 short_name,
8994 full_ref,
8995 line,
8996 lang,
8997 ) = row?;
8998 if let Some(reference) = name_match_ref_from_parts(
8999 ref_id,
9000 caller_node,
9001 caller_file,
9002 caller_symbol,
9003 caller_signature,
9004 short_name,
9005 full_ref,
9006 line,
9007 lang,
9008 ) {
9009 references.push(reference);
9010 }
9011 }
9012 }
9013 return Ok(references);
9014 }
9015
9016 let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
9017 let mut stmt = tx.prepare(&sql)?;
9018 let rows = stmt.query_map([], |row| {
9019 Ok((
9020 row.get::<_, String>(0)?,
9021 row.get::<_, Option<String>>(1)?,
9022 row.get::<_, String>(2)?,
9023 row.get::<_, String>(3)?,
9024 row.get::<_, Option<String>>(4)?,
9025 row.get::<_, Option<String>>(5)?,
9026 row.get::<_, Option<String>>(6)?,
9027 row.get::<_, i64>(7)?,
9028 row.get::<_, String>(8)?,
9029 ))
9030 })?;
9031 for row in rows {
9032 let (
9033 ref_id,
9034 caller_node,
9035 caller_file,
9036 caller_symbol,
9037 caller_signature,
9038 short_name,
9039 full_ref,
9040 line,
9041 lang,
9042 ) = row?;
9043 if let Some(reference) = name_match_ref_from_parts(
9044 ref_id,
9045 caller_node,
9046 caller_file,
9047 caller_symbol,
9048 caller_signature,
9049 short_name,
9050 full_ref,
9051 line,
9052 lang,
9053 ) {
9054 references.push(reference);
9055 }
9056 }
9057 Ok(references)
9058}
9059
9060#[allow(clippy::too_many_arguments)]
9061fn name_match_ref_from_parts(
9062 ref_id: String,
9063 caller_node: Option<String>,
9064 caller_file: String,
9065 caller_symbol: String,
9066 caller_signature: Option<String>,
9067 short_name: Option<String>,
9068 full_ref: Option<String>,
9069 line: i64,
9070 lang: String,
9071) -> Option<NameMatchRef> {
9072 let caller_node = caller_node?;
9073 let full_ref = full_ref?;
9074 let (receiver_expression, receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
9075 let method_name = if member.is_empty() {
9076 short_name.as_deref()?.to_string()
9077 } else {
9078 member
9079 };
9080 Some(NameMatchRef {
9081 ref_id,
9082 caller_node,
9083 caller_file,
9084 caller_symbol,
9085 caller_signature,
9086 receiver_expression,
9087 receiver,
9088 method_name,
9089 colon_dispatch,
9090 line: line.max(0) as u32,
9091 lang,
9092 })
9093}
9094
9095fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, String, bool)> {
9096 let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
9097 let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
9098 let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
9099 let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
9100 .into_iter()
9101 .flatten()
9102 .max_by_key(|(index, _, _)| *index)?;
9103 if delimiter == 0 {
9104 return None;
9105 }
9106 let member_start = delimiter + delimiter_len;
9107 if member_start >= full_ref.len() {
9108 return None;
9109 }
9110 let receiver_expression = full_ref[..delimiter].trim();
9111 let receiver = last_name_segment(receiver_expression).trim();
9112 let member = &full_ref[member_start..];
9113 if receiver.is_empty() || member.is_empty() {
9114 return None;
9115 }
9116 Some((
9117 receiver_expression.to_string(),
9118 receiver.to_string(),
9119 member.to_string(),
9120 colon_dispatch,
9121 ))
9122}
9123
9124fn last_name_segment(value: &str) -> &str {
9125 value
9126 .rsplit(['.', ':', '/', '\\', '-', '>'])
9127 .find(|segment| !segment.is_empty())
9128 .unwrap_or(value)
9129}
9130
9131fn load_name_match_candidates(
9132 tx: &Transaction<'_>,
9133 method_name: &str,
9134 lang: &str,
9135) -> Result<Vec<NameMatchCandidate>> {
9136 let mut stmt = tx.prepare(
9137 "SELECT n.id, n.file_path, n.scoped_name, n.kind, n.start_line
9138 FROM nodes n JOIN files f ON f.path = n.file_path
9139 WHERE n.name = ?1
9140 AND f.lang = ?2
9141 AND n.kind IN ('method', 'function')
9142 ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
9143 )?;
9144 let rows = stmt.query_map(params![method_name, lang], |row| {
9145 Ok(NameMatchCandidate {
9146 node_id: row.get(0)?,
9147 file_path: row.get(1)?,
9148 scoped_name: row.get(2)?,
9149 kind: row.get(3)?,
9150 start_line: (row.get::<_, i64>(4)?.max(0) as u32).saturating_add(1),
9151 })
9152 })?;
9153 rows.collect::<std::result::Result<Vec<_>, _>>()
9154 .map_err(Into::into)
9155}
9156
9157struct ParsedDispatchSource {
9158 source: String,
9159 tree: tree_sitter::Tree,
9160}
9161
9162type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
9163
9164#[derive(Debug, Clone, PartialEq, Eq)]
9165enum ReceiverTypeInference {
9166 Unknown,
9167 Known(String),
9168 RustDirectSelfField {
9169 receiver_type: String,
9170 declaration_file: String,
9171 module_scope: Vec<(usize, usize)>,
9172 },
9173 KnownButUnresolved,
9174}
9175
9176#[cfg(test)]
9177fn infer_receiver_type(
9178 project_root: &Path,
9179 reference: &NameMatchRef,
9180 source_cache: &mut DispatchSourceCache,
9181) -> Option<String> {
9182 match infer_receiver_type_state(project_root, reference, source_cache) {
9183 ReceiverTypeInference::Known(receiver_type)
9184 | ReceiverTypeInference::RustDirectSelfField { receiver_type, .. } => Some(receiver_type),
9185 ReceiverTypeInference::Unknown | ReceiverTypeInference::KnownButUnresolved => None,
9186 }
9187}
9188
9189fn infer_receiver_type_state(
9190 project_root: &Path,
9191 reference: &NameMatchRef,
9192 source_cache: &mut DispatchSourceCache,
9193) -> ReceiverTypeInference {
9194 let known = |receiver_type| ReceiverTypeInference::Known(receiver_type);
9195 match reference.lang.as_str() {
9196 "rust" => infer_rust_receiver_type(project_root, reference, source_cache),
9197 "java" => {
9198 infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
9199 .map(known)
9200 .unwrap_or(ReceiverTypeInference::Unknown)
9201 }
9202 "kotlin" => {
9203 infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
9204 .map(known)
9205 .unwrap_or(ReceiverTypeInference::Unknown)
9206 }
9207 "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache)
9208 .map(known)
9209 .unwrap_or(ReceiverTypeInference::Unknown),
9210 _ => ReceiverTypeInference::Unknown,
9211 }
9212}
9213
9214fn parse_dispatch_source(
9215 project_root: &Path,
9216 caller_file: &str,
9217 lang: LangId,
9218) -> Option<ParsedDispatchSource> {
9219 let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
9220 let grammar = crate::parser::grammar_for(lang);
9221 let mut parser = tree_sitter::Parser::new();
9222 parser.set_language(&grammar).ok()?;
9223 let tree = parser.parse(&source, None)?;
9224 Some(ParsedDispatchSource { source, tree })
9225}
9226
9227fn parsed_dispatch_source<'a>(
9228 project_root: &Path,
9229 reference: &NameMatchRef,
9230 lang: LangId,
9231 source_cache: &'a mut DispatchSourceCache,
9232) -> Option<&'a ParsedDispatchSource> {
9233 parsed_dispatch_source_for_file(
9234 project_root,
9235 &reference.caller_file,
9236 &reference.lang,
9237 lang,
9238 source_cache,
9239 )
9240}
9241
9242fn parsed_dispatch_source_for_file<'a>(
9243 project_root: &Path,
9244 file_path: &str,
9245 lang_label: &str,
9246 lang: LangId,
9247 source_cache: &'a mut DispatchSourceCache,
9248) -> Option<&'a ParsedDispatchSource> {
9249 let key = (file_path.to_string(), lang_label.to_string());
9250 source_cache
9251 .entry(key)
9252 .or_insert_with(|| parse_dispatch_source(project_root, file_path, lang))
9253 .as_ref()
9254}
9255
9256fn infer_java_like_receiver_type(
9257 project_root: &Path,
9258 reference: &NameMatchRef,
9259 lang: LangId,
9260 source_cache: &mut DispatchSourceCache,
9261) -> Option<String> {
9262 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
9263 return None;
9264 }
9265
9266 let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
9267 let root = parsed.tree.root_node();
9268 let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
9269
9270 let callable_scope = type_node
9271 .and_then(|node| {
9272 find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
9273 })
9274 .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
9275
9276 if let Some(callable_scope) = callable_scope {
9277 if let Some(receiver_type) = infer_java_like_local_receiver_type(
9278 callable_scope,
9279 &parsed.source,
9280 &reference.receiver,
9281 reference.line.max(1),
9282 lang,
9283 ) {
9284 return Some(receiver_type);
9285 }
9286 }
9287
9288 type_node.and_then(|node| {
9289 infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
9290 })
9291}
9292
9293fn infer_cpp_receiver_type(
9294 project_root: &Path,
9295 reference: &NameMatchRef,
9296 source_cache: &mut DispatchSourceCache,
9297) -> Option<String> {
9298 if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
9299 return None;
9300 }
9301
9302 let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
9303 let root = parsed.tree.root_node();
9304 let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
9305 infer_cpp_receiver_type_from_scope(
9306 scope,
9307 &parsed.source,
9308 &reference.receiver,
9309 reference.line.max(1),
9310 )
9311}
9312
9313fn find_enclosing_java_like_type_node<'tree>(
9314 root: tree_sitter::Node<'tree>,
9315 source: &str,
9316 reference: &NameMatchRef,
9317 lang: LangId,
9318) -> Option<tree_sitter::Node<'tree>> {
9319 let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
9320 .and_then(|name| simple_type_name(&name));
9321 let line = reference.line.max(1);
9322 let mut best = None;
9323 let mut stack = vec![root];
9324 while let Some(node) = stack.pop() {
9325 if !node_contains_line(node, line) {
9326 continue;
9327 }
9328 if is_java_like_type_kind(node.kind(), lang) {
9329 let name = declaration_name(node, source);
9330 if expected_type
9331 .as_deref()
9332 .is_none_or(|expected| name == Some(expected))
9333 {
9334 best = tighter_node(best, node);
9335 }
9336 }
9337 push_named_children(node, &mut stack);
9338 }
9339 best
9340}
9341
9342fn find_enclosing_java_like_callable_node<'tree>(
9343 root: tree_sitter::Node<'tree>,
9344 source: &str,
9345 reference: &NameMatchRef,
9346 lang: LangId,
9347) -> Option<tree_sitter::Node<'tree>> {
9348 let expected_name = reference.caller_symbol.rsplit("::").next();
9349 let line = reference.line.max(1);
9350 let mut best = None;
9351 let mut stack = vec![root];
9352 while let Some(node) = stack.pop() {
9353 if !node_contains_line(node, line) {
9354 continue;
9355 }
9356 if is_java_like_callable_kind(node.kind(), lang) {
9357 let name = declaration_name(node, source);
9358 if expected_name.is_none_or(|expected| name == Some(expected)) {
9359 best = tighter_node(best, node);
9360 }
9361 }
9362 push_named_children(node, &mut stack);
9363 }
9364 best
9365}
9366
9367fn find_enclosing_cpp_callable_node<'tree>(
9368 root: tree_sitter::Node<'tree>,
9369 _source: &str,
9370 reference: &NameMatchRef,
9371) -> Option<tree_sitter::Node<'tree>> {
9372 let line = reference.line.max(1);
9373 let mut best = None;
9374 let mut stack = vec![root];
9375 while let Some(node) = stack.pop() {
9376 if !node_contains_line(node, line) {
9377 continue;
9378 }
9379 if node.kind() == "function_definition" {
9380 best = tighter_node(best, node);
9381 }
9382 push_named_children(node, &mut stack);
9383 }
9384 best
9385}
9386
9387fn tighter_node<'tree>(
9388 current: Option<tree_sitter::Node<'tree>>,
9389 candidate: tree_sitter::Node<'tree>,
9390) -> Option<tree_sitter::Node<'tree>> {
9391 match current {
9392 Some(current)
9393 if current.start_byte() > candidate.start_byte()
9394 || (current.start_byte() == candidate.start_byte()
9395 && current.end_byte() <= candidate.end_byte()) =>
9396 {
9397 Some(current)
9398 }
9399 _ => Some(candidate),
9400 }
9401}
9402
9403fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
9404 let start = node.start_position().row as u32 + 1;
9405 let end = node.end_position().row as u32 + 1;
9406 start <= line && line <= end
9407}
9408
9409fn push_named_children<'tree>(
9410 node: tree_sitter::Node<'tree>,
9411 stack: &mut Vec<tree_sitter::Node<'tree>>,
9412) {
9413 for index in 0..node.named_child_count() {
9414 if let Some(child) = node.named_child(index as u32) {
9415 stack.push(child);
9416 }
9417 }
9418}
9419
9420fn declaration_name<'source>(
9421 node: tree_sitter::Node<'_>,
9422 source: &'source str,
9423) -> Option<&'source str> {
9424 node.child_by_field_name("name")
9425 .map(|name| node_text(name, source))
9426 .or_else(|| {
9427 first_named_child_text(
9428 node,
9429 source,
9430 &["identifier", "type_identifier", "simple_identifier"],
9431 )
9432 })
9433}
9434
9435fn first_named_child_text<'source>(
9436 node: tree_sitter::Node<'_>,
9437 source: &'source str,
9438 kinds: &[&str],
9439) -> Option<&'source str> {
9440 for index in 0..node.named_child_count() {
9441 let child = node.named_child(index as u32)?;
9442 if kinds.contains(&child.kind()) {
9443 return Some(node_text(child, source));
9444 }
9445 }
9446 None
9447}
9448
9449fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
9450 &source[node.byte_range()]
9451}
9452
9453fn infer_java_like_field_receiver_type(
9454 type_node: tree_sitter::Node<'_>,
9455 source: &str,
9456 receiver: &str,
9457 lang: LangId,
9458) -> Option<String> {
9459 let mut stack = Vec::new();
9460 push_named_children(type_node, &mut stack);
9461 while let Some(node) = stack.pop() {
9462 if is_java_like_field_kind(node.kind(), lang) {
9463 if let Some(receiver_type) =
9464 extract_java_like_declared_type(node_text(node, source), receiver, lang)
9465 {
9466 return Some(receiver_type);
9467 }
9468 }
9469 if is_java_like_type_kind(node.kind(), lang)
9470 || is_java_like_callable_kind(node.kind(), lang)
9471 {
9472 continue;
9473 }
9474 push_named_children(node, &mut stack);
9475 }
9476 None
9477}
9478
9479fn infer_java_like_local_receiver_type(
9480 callable_node: tree_sitter::Node<'_>,
9481 source: &str,
9482 receiver: &str,
9483 call_line: u32,
9484 lang: LangId,
9485) -> Option<String> {
9486 let mut best: Option<(u32, String)> = None;
9487 let mut stack = Vec::new();
9488 push_named_children(callable_node, &mut stack);
9489 while let Some(node) = stack.pop() {
9490 let start_line = node.start_position().row as u32 + 1;
9491 if start_line > call_line {
9492 continue;
9493 }
9494 if is_java_like_local_kind(node.kind(), lang) {
9495 if let Some(receiver_type) =
9496 extract_java_like_declared_type(node_text(node, source), receiver, lang)
9497 {
9498 if best
9499 .as_ref()
9500 .is_none_or(|(best_line, _)| start_line >= *best_line)
9501 {
9502 best = Some((start_line, receiver_type));
9503 }
9504 }
9505 }
9506 if is_java_like_type_kind(node.kind(), lang)
9507 || is_java_like_callable_kind(node.kind(), lang)
9508 {
9509 continue;
9510 }
9511 push_named_children(node, &mut stack);
9512 }
9513 best.map(|(_, receiver_type)| receiver_type)
9514}
9515
9516fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
9517 match lang {
9518 LangId::Java => matches!(
9519 kind,
9520 "class_declaration"
9521 | "interface_declaration"
9522 | "enum_declaration"
9523 | "record_declaration"
9524 | "annotation_type_declaration"
9525 ),
9526 LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
9527 _ => false,
9528 }
9529}
9530
9531fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
9532 match lang {
9533 LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
9534 LangId::Kotlin => kind == "function_declaration",
9535 _ => false,
9536 }
9537}
9538
9539fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
9540 match lang {
9541 LangId::Java => kind == "field_declaration",
9542 LangId::Kotlin => kind == "property_declaration",
9543 _ => false,
9544 }
9545}
9546
9547fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
9548 match lang {
9549 LangId::Java => kind == "local_variable_declaration",
9550 LangId::Kotlin => kind == "property_declaration",
9551 _ => false,
9552 }
9553}
9554
9555fn extract_java_like_declared_type(
9556 declaration: &str,
9557 receiver: &str,
9558 lang: LangId,
9559) -> Option<String> {
9560 match lang {
9561 LangId::Java => extract_java_declared_type(declaration, receiver),
9562 LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
9563 _ => None,
9564 }
9565}
9566
9567fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
9568 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
9569 let after = declaration[receiver_start + receiver.len()..].trim_start();
9570 if after
9571 .chars()
9572 .next()
9573 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
9574 {
9575 return None;
9576 }
9577
9578 let before = declaration[..receiver_start].trim_end();
9579 if before.contains(',') {
9580 return None;
9581 }
9582 normalize_receiver_type_name(strip_java_declaration_prefixes(before))
9583}
9584
9585fn strip_java_declaration_prefixes(mut value: &str) -> &str {
9586 loop {
9587 value = value.trim_start();
9588 if let Some(stripped) = strip_leading_java_annotation(value) {
9589 value = stripped;
9590 continue;
9591 }
9592 if let Some(stripped) = strip_leading_java_modifier(value) {
9593 value = stripped;
9594 continue;
9595 }
9596 return value.trim();
9597 }
9598}
9599
9600fn strip_leading_java_annotation(value: &str) -> Option<&str> {
9601 let value = value.trim_start();
9602 let mut chars = value.char_indices();
9603 let (_, first) = chars.next()?;
9604 if first != '@' {
9605 return None;
9606 }
9607 let mut end = first.len_utf8();
9608 for (index, ch) in chars {
9609 if !(is_code_ident_char(ch) || ch == '.') {
9610 end = index;
9611 break;
9612 }
9613 end = index + ch.len_utf8();
9614 }
9615 let rest = value[end..].trim_start();
9616 if let Some(stripped) = rest.strip_prefix('(') {
9617 let mut depth = 1usize;
9618 for (index, ch) in stripped.char_indices() {
9619 match ch {
9620 '(' => depth += 1,
9621 ')' => {
9622 depth = depth.saturating_sub(1);
9623 if depth == 0 {
9624 return Some(stripped[index + ch.len_utf8()..].trim_start());
9625 }
9626 }
9627 _ => {}
9628 }
9629 }
9630 return Some("");
9631 }
9632 Some(rest)
9633}
9634
9635fn strip_leading_java_modifier(value: &str) -> Option<&str> {
9636 const MODIFIERS: &[&str] = &[
9637 "public",
9638 "protected",
9639 "private",
9640 "abstract",
9641 "static",
9642 "final",
9643 "transient",
9644 "volatile",
9645 "synchronized",
9646 "native",
9647 "strictfp",
9648 ];
9649 MODIFIERS
9650 .iter()
9651 .find_map(|modifier| strip_leading_word(value, modifier))
9652}
9653
9654fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
9655 let receiver_start = find_identifier_occurrence(declaration, receiver)?;
9656 let before = &declaration[..receiver_start];
9657 if find_identifier_occurrence(before, "val").is_none()
9658 && find_identifier_occurrence(before, "var").is_none()
9659 {
9660 return None;
9661 }
9662
9663 let after = declaration[receiver_start + receiver.len()..].trim_start();
9664 if let Some(type_text) = after.strip_prefix(':') {
9665 return normalize_receiver_type_name(read_type_prefix(type_text));
9666 }
9667 after
9668 .strip_prefix('=')
9669 .and_then(infer_kotlin_constructor_type)
9670}
9671
9672fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
9673 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
9674 if rest.trim_start().starts_with('(') {
9675 normalize_receiver_type_name(head)
9676 } else {
9677 None
9678 }
9679}
9680
9681fn read_type_prefix(value: &str) -> &str {
9682 let mut angle_depth = 0usize;
9683 for (index, ch) in value.char_indices() {
9684 match ch {
9685 '<' => angle_depth += 1,
9686 '>' => angle_depth = angle_depth.saturating_sub(1),
9687 '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
9688 return value[..index].trim();
9689 }
9690 _ => {}
9691 }
9692 }
9693 value.trim()
9694}
9695
9696fn infer_cpp_receiver_type_from_scope(
9697 scope: tree_sitter::Node<'_>,
9698 source: &str,
9699 receiver: &str,
9700 call_line: u32,
9701) -> Option<String> {
9702 let lines = source.lines().collect::<Vec<_>>();
9703 if lines.is_empty() {
9704 return None;
9705 }
9706 let scope_start = scope.start_position().row as usize;
9707 let call_index = (call_line as usize)
9708 .saturating_sub(1)
9709 .min(lines.len().saturating_sub(1));
9710 for index in (scope_start..=call_index).rev() {
9711 if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
9712 return Some(receiver_type);
9713 }
9714 }
9715 None
9716}
9717
9718fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
9719 for receiver_start in identifier_occurrences(line, receiver) {
9720 let after = line[receiver_start + receiver.len()..].trim_start();
9721 if after
9722 .chars()
9723 .next()
9724 .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
9725 {
9726 continue;
9727 }
9728 let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
9729 let normalized = normalize_cpp_type_name(type_text)?;
9730 if normalized == "auto" {
9731 if let Some(rhs) = after.strip_prefix('=') {
9732 return infer_cpp_auto_receiver_type(rhs);
9733 }
9734 continue;
9735 }
9736 return Some(normalized);
9737 }
9738 None
9739}
9740
9741fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
9742 let candidate = prefix
9743 .rsplit([';', '{', '}', '('])
9744 .next()
9745 .unwrap_or(prefix)
9746 .trim();
9747 if candidate.is_empty() || candidate.ends_with(',') {
9748 None
9749 } else {
9750 Some(candidate)
9751 }
9752}
9753
9754fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
9755 let without_templates = strip_angle_groups(type_text);
9756 let mut cleaned = String::with_capacity(without_templates.len());
9757 for token in without_templates.split_whitespace() {
9758 if matches!(
9759 token,
9760 "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
9761 ) {
9762 continue;
9763 }
9764 if !cleaned.is_empty() {
9765 cleaned.push(' ');
9766 }
9767 cleaned.push_str(token);
9768 }
9769 let token = cleaned
9770 .split_whitespace()
9771 .last()
9772 .unwrap_or(cleaned.trim())
9773 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
9774 .trim_matches(['*', '&']);
9775 let simple = token.rsplit("::").next().unwrap_or(token).trim();
9776 if simple.is_empty() || cpp_non_type_token(simple) {
9777 None
9778 } else {
9779 Some(simple.to_string())
9780 }
9781}
9782
9783fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
9784 let rhs = rhs.trim_start();
9785 if let Some(after_new) = rhs.strip_prefix("new ") {
9786 return infer_cpp_constructor_type(after_new);
9787 }
9788 infer_cpp_make_template_type(rhs)
9789 .or_else(|| infer_cpp_constructor_type(rhs))
9790 .or_else(|| infer_cpp_factory_type(rhs))
9791}
9792
9793fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
9794 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9795 let normalized = normalize_cpp_type_name(head)?;
9796 if !normalized
9797 .chars()
9798 .next()
9799 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
9800 {
9801 return None;
9802 }
9803 if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
9804 Some(normalized)
9805 } else {
9806 None
9807 }
9808}
9809
9810fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
9811 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9812 if !rest.trim_start().starts_with('(') {
9813 return None;
9814 }
9815 let base = head.split('<').next().unwrap_or(head);
9816 let base_simple = base.rsplit("::").next().unwrap_or(base);
9817 if !matches!(base_simple, "make_unique" | "make_shared") {
9818 return None;
9819 }
9820 first_angle_arg(head).and_then(normalize_cpp_type_name)
9821}
9822
9823fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
9824 let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9825 if !rest.trim_start().starts_with('(') {
9826 return None;
9827 }
9828 let simple = head
9829 .split('<')
9830 .next()
9831 .unwrap_or(head)
9832 .rsplit("::")
9833 .next()
9834 .unwrap_or(head);
9835 for prefix in ["make", "create", "build"] {
9836 if let Some(suffix) = simple.strip_prefix(prefix) {
9837 if suffix
9838 .chars()
9839 .next()
9840 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
9841 {
9842 return normalize_cpp_type_name(suffix);
9843 }
9844 }
9845 }
9846 None
9847}
9848
9849#[derive(Debug, Clone, Copy)]
9850enum JavaLikeInvocation {
9851 Kotlin,
9852 Cpp,
9853}
9854
9855fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
9856 let value = value.trim_start();
9857 let mut end = 0usize;
9858 for (index, ch) in value.char_indices() {
9859 let allowed_separator = match flavor {
9860 JavaLikeInvocation::Kotlin => ch == '.',
9861 JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
9862 };
9863 if is_code_ident_char(ch) || allowed_separator {
9864 end = index + ch.len_utf8();
9865 continue;
9866 }
9867 break;
9868 }
9869 if end == 0 {
9870 return None;
9871 }
9872 let mut rest = &value[end..];
9873 if let Some(stripped) = rest.trim_start().strip_prefix('<') {
9874 let skipped = skip_balanced_angle(stripped)?;
9875 let rest_start = rest.len() - rest.trim_start().len();
9876 let angle_len = 1 + skipped;
9877 end += rest_start + angle_len;
9878 rest = &value[end..];
9879 }
9880 Some((value[..end].trim(), rest))
9881}
9882
9883fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
9884 let mut depth = 1usize;
9885 for (index, ch) in value_after_open.char_indices() {
9886 match ch {
9887 '<' => depth += 1,
9888 '>' => {
9889 depth = depth.saturating_sub(1);
9890 if depth == 0 {
9891 return Some(index + ch.len_utf8());
9892 }
9893 }
9894 _ => {}
9895 }
9896 }
9897 None
9898}
9899
9900fn first_angle_arg(value: &str) -> Option<&str> {
9901 let open = value.find('<')?;
9902 let inner_len = skip_balanced_angle(&value[open + 1..])?;
9903 let inner = &value[open + 1..open + inner_len];
9904 split_top_level_commas(inner).into_iter().next()
9905}
9906
9907fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
9908 let without_generics = strip_angle_groups(type_text);
9909 let cleaned = without_generics
9910 .replace("[]", " ")
9911 .replace("...", " ")
9912 .replace(['?', '&', '*'], " ");
9913 let token = cleaned
9914 .split_whitespace()
9915 .last()
9916 .unwrap_or(cleaned.trim())
9917 .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
9918 let token = token.rsplit("::").next().unwrap_or(token);
9919 let simple = token.rsplit('.').next().unwrap_or(token).trim();
9920 if simple.is_empty()
9921 || java_like_primitive_type(simple)
9922 || !simple
9923 .chars()
9924 .next()
9925 .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
9926 {
9927 None
9928 } else {
9929 Some(simple.to_string())
9930 }
9931}
9932
9933fn simple_type_name(scoped_name: &str) -> Option<String> {
9934 scoped_name
9935 .rsplit("::")
9936 .find(|segment| !segment.is_empty())
9937 .and_then(normalize_receiver_type_name)
9938}
9939
9940fn strip_angle_groups(value: &str) -> String {
9941 let mut output = String::with_capacity(value.len());
9942 let mut depth = 0usize;
9943 for ch in value.chars() {
9944 match ch {
9945 '<' => {
9946 if depth == 0 {
9947 output.push(' ');
9948 }
9949 depth += 1;
9950 }
9951 '>' => depth = depth.saturating_sub(1),
9952 _ if depth == 0 => output.push(ch),
9953 _ => {}
9954 }
9955 }
9956 output
9957}
9958
9959fn java_like_primitive_type(value: &str) -> bool {
9960 matches!(
9961 value,
9962 "boolean"
9963 | "byte"
9964 | "char"
9965 | "double"
9966 | "float"
9967 | "int"
9968 | "long"
9969 | "short"
9970 | "void"
9971 | "Boolean"
9972 | "Byte"
9973 | "Char"
9974 | "Double"
9975 | "Float"
9976 | "Int"
9977 | "Long"
9978 | "Short"
9979 | "Unit"
9980 )
9981}
9982
9983fn cpp_non_type_token(value: &str) -> bool {
9984 matches!(
9985 value,
9986 "return"
9987 | "if"
9988 | "else"
9989 | "for"
9990 | "while"
9991 | "do"
9992 | "switch"
9993 | "case"
9994 | "default"
9995 | "break"
9996 | "continue"
9997 | "goto"
9998 | "throw"
9999 | "new"
10000 | "delete"
10001 | "co_await"
10002 | "co_yield"
10003 | "co_return"
10004 | "static_cast"
10005 | "const_cast"
10006 | "dynamic_cast"
10007 | "reinterpret_cast"
10008 | "sizeof"
10009 | "alignof"
10010 | "typeid"
10011 | "and"
10012 | "or"
10013 | "not"
10014 | "xor"
10015 )
10016}
10017
10018fn receiver_is_bare_identifier(value: &str) -> bool {
10019 let mut chars = value.chars();
10020 let Some(first) = chars.next() else {
10021 return false;
10022 };
10023 (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
10024}
10025
10026fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
10027 identifier_occurrences(value, needle).into_iter().next()
10028}
10029
10030fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
10031 value
10032 .match_indices(needle)
10033 .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
10034 .collect()
10035}
10036
10037fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
10038 let before = value[..start].chars().next_back();
10039 let after = value[start + len..].chars().next();
10040 !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
10041}
10042
10043fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
10044 let stripped = value.strip_prefix(word)?;
10045 if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
10046 Some(stripped.trim_start())
10047 } else {
10048 None
10049 }
10050}
10051
10052fn is_code_ident_char(ch: char) -> bool {
10053 ch == '_' || ch.is_ascii_alphanumeric()
10054}
10055
10056fn infer_rust_receiver_type(
10057 project_root: &Path,
10058 reference: &NameMatchRef,
10059 source_cache: &mut DispatchSourceCache,
10060) -> ReceiverTypeInference {
10061 if matches!(reference.receiver.as_str(), "self" | "Self") {
10062 return enclosing_type_from_scoped_name(&reference.caller_symbol)
10063 .map(ReceiverTypeInference::Known)
10064 .unwrap_or(ReceiverTypeInference::Unknown);
10065 }
10066
10067 if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
10068 return ReceiverTypeInference::Known(reference.receiver.clone());
10069 }
10070
10071 if let Some(receiver_type) = reference
10072 .caller_signature
10073 .as_deref()
10074 .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
10075 {
10076 return ReceiverTypeInference::Known(receiver_type);
10077 }
10078
10079 infer_rust_direct_self_field_receiver_type(project_root, reference, source_cache)
10080}
10081
10082fn infer_rust_direct_self_field_receiver_type(
10083 project_root: &Path,
10084 reference: &NameMatchRef,
10085 source_cache: &mut DispatchSourceCache,
10086) -> ReceiverTypeInference {
10087 if reference.colon_dispatch {
10088 return ReceiverTypeInference::Unknown;
10089 }
10090 let Some(field_name) = rust_direct_self_field_name(&reference.receiver_expression) else {
10091 return ReceiverTypeInference::Unknown;
10092 };
10093 if field_name != reference.receiver {
10094 return ReceiverTypeInference::Unknown;
10095 }
10096
10097 let Some(impl_type) = enclosing_type_from_scoped_name(&reference.caller_symbol) else {
10098 return ReceiverTypeInference::Unknown;
10099 };
10100 let Some(struct_name) = rust_direct_nominal_type_name(&impl_type) else {
10101 return ReceiverTypeInference::KnownButUnresolved;
10102 };
10103 let Some(parsed) = parsed_dispatch_source(project_root, reference, LangId::Rust, source_cache)
10104 else {
10105 return ReceiverTypeInference::Unknown;
10106 };
10107 let Some(impl_node) =
10108 find_enclosing_rust_impl_node(parsed.tree.root_node(), reference.line.max(1))
10109 else {
10110 return ReceiverTypeInference::Unknown;
10111 };
10112 if impl_node.child_by_field_name("trait").is_some()
10113 || impl_node.child_by_field_name("type_parameters").is_some()
10114 {
10115 return ReceiverTypeInference::KnownButUnresolved;
10116 }
10117 let Some(impl_target) = impl_node.child_by_field_name("type") else {
10118 return ReceiverTypeInference::KnownButUnresolved;
10119 };
10120 if impl_target.kind() != "type_identifier"
10121 || node_text(impl_target, &parsed.source) != impl_type
10122 {
10123 return ReceiverTypeInference::KnownButUnresolved;
10124 }
10125
10126 let module_scope = rust_module_scope(impl_node);
10127 let Some(struct_node) = find_unique_rust_struct(
10128 parsed.tree.root_node(),
10129 &parsed.source,
10130 struct_name,
10131 &module_scope,
10132 ) else {
10133 return ReceiverTypeInference::KnownButUnresolved;
10134 };
10135 let Some(field_type) = rust_struct_field_type_node(struct_node, &parsed.source, field_name)
10136 else {
10137 return ReceiverTypeInference::KnownButUnresolved;
10138 };
10139 if field_type.kind() != "type_identifier" {
10140 return ReceiverTypeInference::KnownButUnresolved;
10141 }
10142 let field_type_name = node_text(field_type, &parsed.source);
10143 if find_unique_rust_struct(
10144 parsed.tree.root_node(),
10145 &parsed.source,
10146 field_type_name,
10147 &module_scope,
10148 )
10149 .is_none()
10150 {
10151 return ReceiverTypeInference::KnownButUnresolved;
10152 }
10153
10154 ReceiverTypeInference::RustDirectSelfField {
10155 receiver_type: field_type_name.to_string(),
10156 declaration_file: reference.caller_file.clone(),
10157 module_scope,
10158 }
10159}
10160
10161fn rust_direct_self_field_name(receiver_expression: &str) -> Option<&str> {
10162 let (base, field) = receiver_expression.split_once('.')?;
10163 let base = base.trim();
10164 let field = field.trim();
10165 (base == "self" && rust_direct_nominal_type_name(field).is_some()).then_some(field)
10166}
10167
10168fn rust_direct_nominal_type_name(value: &str) -> Option<&str> {
10169 let name = value.rsplit("::").next()?.trim();
10170 (!name.is_empty()
10171 && !name.chars().next().is_some_and(|ch| ch.is_ascii_digit())
10172 && name.chars().all(is_rust_ident_char))
10173 .then_some(name)
10174}
10175
10176fn find_enclosing_rust_impl_node<'tree>(
10177 root: tree_sitter::Node<'tree>,
10178 line: u32,
10179) -> Option<tree_sitter::Node<'tree>> {
10180 let mut best = None;
10181 let mut stack = vec![root];
10182 while let Some(node) = stack.pop() {
10183 if !node_contains_line(node, line) {
10184 continue;
10185 }
10186 if node.kind() == "impl_item" {
10187 best = tighter_node(best, node);
10188 }
10189 push_named_children(node, &mut stack);
10190 }
10191 best
10192}
10193
10194fn rust_module_scope(node: tree_sitter::Node<'_>) -> Vec<(usize, usize)> {
10195 let mut scope = Vec::new();
10196 let mut current = node.parent();
10197 while let Some(parent) = current {
10198 if parent.kind() == "mod_item" {
10199 scope.push((parent.start_byte(), parent.end_byte()));
10200 }
10201 current = parent.parent();
10202 }
10203 scope.reverse();
10204 scope
10205}
10206
10207fn find_unique_rust_struct<'tree>(
10208 root: tree_sitter::Node<'tree>,
10209 source: &str,
10210 expected_name: &str,
10211 module_scope: &[(usize, usize)],
10212) -> Option<tree_sitter::Node<'tree>> {
10213 let mut found = None;
10214 let mut stack = vec![root];
10215 while let Some(node) = stack.pop() {
10216 if node.kind() == "struct_item"
10217 && rust_module_scope(node) == module_scope
10218 && node.child_by_field_name("type_parameters").is_none()
10219 && declaration_name(node, source) == Some(expected_name)
10220 {
10221 if found.is_some() {
10222 return None;
10223 }
10224 found = Some(node);
10225 }
10226 push_named_children(node, &mut stack);
10227 }
10228 found
10229}
10230
10231fn rust_struct_field_type_node<'tree>(
10232 struct_node: tree_sitter::Node<'tree>,
10233 source: &str,
10234 field_name: &str,
10235) -> Option<tree_sitter::Node<'tree>> {
10236 let fields = struct_node.child_by_field_name("body")?;
10237 if fields.kind() != "field_declaration_list" {
10238 return None;
10239 }
10240 for index in 0..fields.named_child_count() {
10241 let field = fields.named_child(index as u32)?;
10242 if field.kind() != "field_declaration"
10243 || declaration_name(field, source) != Some(field_name)
10244 {
10245 continue;
10246 }
10247 return field.child_by_field_name("type");
10248 }
10249 None
10250}
10251
10252fn rust_receiver_looks_type_like(receiver: &str) -> bool {
10253 receiver
10254 .chars()
10255 .next()
10256 .is_some_and(|ch| ch == '_' || ch.is_uppercase())
10257}
10258
10259fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
10260 scoped_name
10261 .rsplit_once("::")
10262 .map(|(enclosing, _)| enclosing)
10263 .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
10264 .map(ToString::to_string)
10265}
10266
10267fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
10268 let params = signature_parameter_text(signature)?;
10269 for param in split_top_level_commas(params) {
10270 let Some((pattern, type_text)) = param.split_once(':') else {
10271 continue;
10272 };
10273 let Some(name) = rust_parameter_name(pattern) else {
10274 continue;
10275 };
10276 if name == receiver {
10277 return normalize_rust_receiver_type(type_text);
10278 }
10279 }
10280 None
10281}
10282
10283fn signature_parameter_text(signature: &str) -> Option<&str> {
10284 let open = signature.find('(')?;
10285 let mut depth = 0usize;
10286 for (offset, ch) in signature[open..].char_indices() {
10287 match ch {
10288 '(' => depth += 1,
10289 ')' => {
10290 depth = depth.saturating_sub(1);
10291 if depth == 0 {
10292 return Some(&signature[open + 1..open + offset]);
10293 }
10294 }
10295 _ => {}
10296 }
10297 }
10298 None
10299}
10300
10301fn split_top_level_commas(value: &str) -> Vec<&str> {
10302 let mut parts = Vec::new();
10303 let mut start = 0usize;
10304 let mut angle_depth = 0usize;
10305 let mut paren_depth = 0usize;
10306 let mut bracket_depth = 0usize;
10307 for (index, ch) in value.char_indices() {
10308 match ch {
10309 '<' => angle_depth += 1,
10310 '>' => angle_depth = angle_depth.saturating_sub(1),
10311 '(' => paren_depth += 1,
10312 ')' => paren_depth = paren_depth.saturating_sub(1),
10313 '[' => bracket_depth += 1,
10314 ']' => bracket_depth = bracket_depth.saturating_sub(1),
10315 ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
10316 let part = value[start..index].trim();
10317 if !part.is_empty() {
10318 parts.push(part);
10319 }
10320 start = index + ch.len_utf8();
10321 }
10322 _ => {}
10323 }
10324 }
10325 let part = value[start..].trim();
10326 if !part.is_empty() {
10327 parts.push(part);
10328 }
10329 parts
10330}
10331
10332fn rust_parameter_name(pattern: &str) -> Option<&str> {
10333 let mut pattern = pattern.trim();
10334 if let Some(stripped) = pattern.strip_prefix("mut ") {
10335 pattern = stripped.trim_start();
10336 }
10337 pattern
10338 .rsplit(|ch: char| !is_rust_ident_char(ch))
10339 .find(|part| !part.is_empty())
10340}
10341
10342fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
10343 let mut ty = strip_leading_rust_type_modifiers(type_text);
10344 let owned_inner;
10345 if let Some(inner) = single_outer_generic_arg(ty) {
10346 owned_inner = inner.trim().to_string();
10347 ty = strip_leading_rust_type_modifiers(&owned_inner);
10348 }
10349 rust_base_type_ident(ty)
10350}
10351
10352fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
10353 loop {
10354 ty = ty.trim_start();
10355 if let Some(stripped) = ty.strip_prefix('&') {
10356 ty = stripped.trim_start();
10357 if let Some(stripped) = strip_leading_lifetime(ty) {
10358 ty = stripped.trim_start();
10359 }
10360 if let Some(stripped) = ty.strip_prefix("mut ") {
10361 ty = stripped.trim_start();
10362 }
10363 continue;
10364 }
10365 if let Some(stripped) = ty.strip_prefix("mut ") {
10366 ty = stripped.trim_start();
10367 continue;
10368 }
10369 if let Some(stripped) = ty.strip_prefix("dyn ") {
10370 ty = stripped.trim_start();
10371 continue;
10372 }
10373 if let Some(stripped) = ty.strip_prefix("impl ") {
10374 ty = stripped.trim_start();
10375 continue;
10376 }
10377 break ty.trim();
10378 }
10379}
10380
10381fn strip_leading_lifetime(value: &str) -> Option<&str> {
10382 let mut chars = value.char_indices();
10383 let (_, first) = chars.next()?;
10384 if first != '\'' {
10385 return None;
10386 }
10387 for (index, ch) in chars {
10388 if !(ch == '_' || ch.is_ascii_alphanumeric()) {
10389 return Some(&value[index..]);
10390 }
10391 }
10392 Some("")
10393}
10394
10395fn single_outer_generic_arg(ty: &str) -> Option<&str> {
10396 let ty = ty.trim();
10397 let open = ty.find('<')?;
10398 let mut depth = 0usize;
10399 let mut close = None;
10400 for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
10401 match ch {
10402 '<' => depth += 1,
10403 '>' => {
10404 depth = depth.saturating_sub(1);
10405 if depth == 0 {
10406 close = Some(index);
10407 break;
10408 }
10409 }
10410 _ => {}
10411 }
10412 }
10413 let close = close?;
10414 if !ty[close + 1..].trim().is_empty() {
10415 return None;
10416 }
10417 let inner = &ty[open + 1..close];
10418 let args = split_top_level_commas(inner);
10419 match args.as_slice() {
10420 [arg] => Some(*arg),
10421 _ => None,
10422 }
10423}
10424
10425fn rust_base_type_ident(ty: &str) -> Option<String> {
10426 let ty = ty.trim();
10427 let head = ty
10428 .split([' ', '+', '='])
10429 .find(|part| !part.is_empty())
10430 .unwrap_or(ty);
10431 let head = head.split('<').next().unwrap_or(head).trim();
10432 let ident = head
10433 .rsplit("::")
10434 .next()
10435 .unwrap_or(head)
10436 .trim_matches(|ch: char| !is_rust_ident_char(ch));
10437 if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
10438 None
10439 } else {
10440 Some(ident.to_string())
10441 }
10442}
10443
10444fn is_rust_ident_char(ch: char) -> bool {
10445 ch == '_' || ch.is_ascii_alphanumeric()
10446}
10447
10448fn select_rust_direct_self_field_candidate(
10449 project_root: &Path,
10450 reference: &NameMatchRef,
10451 candidates: &[NameMatchCandidate],
10452 receiver_type: &str,
10453 declaration_file: &str,
10454 declaration_scope: &[(usize, usize)],
10455 source_cache: &mut DispatchSourceCache,
10456) -> Option<NameMatchCandidate> {
10457 let eligible = candidates
10458 .iter()
10459 .filter(|candidate| candidate.node_id != reference.caller_node)
10460 .filter(|candidate| {
10461 type_candidate_matches(candidate, receiver_type, &reference.method_name)
10462 })
10463 .filter(|candidate| {
10464 rust_direct_self_field_candidate_matches_scope(
10465 project_root,
10466 candidate,
10467 receiver_type,
10468 declaration_file,
10469 declaration_scope,
10470 source_cache,
10471 )
10472 })
10473 .collect::<Vec<_>>();
10474 match eligible.as_slice() {
10475 [candidate] => Some((**candidate).clone()),
10476 _ => None,
10477 }
10478}
10479
10480fn rust_direct_self_field_candidate_matches_scope(
10481 project_root: &Path,
10482 candidate: &NameMatchCandidate,
10483 receiver_type: &str,
10484 declaration_file: &str,
10485 declaration_scope: &[(usize, usize)],
10486 source_cache: &mut DispatchSourceCache,
10487) -> bool {
10488 if candidate.file_path != declaration_file {
10489 return false;
10490 }
10491 let Some(parsed) = parsed_dispatch_source_for_file(
10492 project_root,
10493 &candidate.file_path,
10494 "rust",
10495 LangId::Rust,
10496 source_cache,
10497 ) else {
10498 return false;
10499 };
10500 let Some(impl_node) =
10501 find_enclosing_rust_impl_node(parsed.tree.root_node(), candidate.start_line)
10502 else {
10503 return false;
10504 };
10505 if impl_node.child_by_field_name("trait").is_some()
10506 || impl_node.child_by_field_name("type_parameters").is_some()
10507 {
10508 return false;
10509 }
10510 let Some(impl_target) = impl_node.child_by_field_name("type") else {
10511 return false;
10512 };
10513 impl_target.kind() == "type_identifier"
10514 && node_text(impl_target, &parsed.source) == receiver_type
10515 && rust_module_scope(impl_node) == declaration_scope
10516}
10517
10518fn select_type_match_candidate(
10519 reference: &NameMatchRef,
10520 candidates: &[NameMatchCandidate],
10521 receiver_type: &str,
10522) -> Option<NameMatchCandidate> {
10523 let candidates = candidates
10524 .iter()
10525 .filter(|candidate| candidate.node_id != reference.caller_node)
10526 .filter(|candidate| {
10527 type_candidate_matches(candidate, receiver_type, &reference.method_name)
10528 })
10529 .collect::<Vec<_>>();
10530 match candidates.as_slice() {
10531 [candidate] => Some((**candidate).clone()),
10532 _ => None,
10533 }
10534}
10535
10536fn type_candidate_matches(
10537 candidate: &NameMatchCandidate,
10538 receiver_type: &str,
10539 method_name: &str,
10540) -> bool {
10541 let normalized_type = receiver_type.replace('.', "::");
10542 let suffix = format!("{normalized_type}::{method_name}");
10543 candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
10544}
10545
10546fn select_name_match_candidate(
10547 reference: &NameMatchRef,
10548 candidates: &[NameMatchCandidate],
10549) -> Option<NameMatchCandidate> {
10550 let candidates = candidates
10551 .iter()
10552 .filter(|candidate| candidate.node_id != reference.caller_node)
10553 .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
10554 .collect::<Vec<_>>();
10555 match candidates.as_slice() {
10556 [] => None,
10557 [candidate] => Some((**candidate).clone()),
10558 _ => select_scored_name_match_candidate(reference, &candidates),
10559 }
10560}
10561
10562fn candidate_allowed_for_reference(
10563 reference: &NameMatchRef,
10564 candidate: &NameMatchCandidate,
10565) -> bool {
10566 if !reference.colon_dispatch {
10567 return true;
10568 }
10569
10570 candidate.kind == "method"
10571 && candidate
10572 .scoped_name
10573 .split("::")
10574 .any(|segment| segment == reference.receiver)
10575}
10576
10577fn select_scored_name_match_candidate(
10578 reference: &NameMatchRef,
10579 candidates: &[&NameMatchCandidate],
10580) -> Option<NameMatchCandidate> {
10581 let receiver_words = split_camel_case(&reference.receiver);
10582 if receiver_words.is_empty() {
10583 return None;
10584 }
10585
10586 let mut best: Option<(&NameMatchCandidate, f64)> = None;
10587 let mut tied_best = false;
10588 for candidate in candidates {
10589 let candidate_words = split_camel_case(&candidate.scoped_name);
10590 let overlap = receiver_words
10591 .iter()
10592 .filter(|receiver_word| {
10593 candidate_words
10594 .iter()
10595 .any(|candidate_word| candidate_word == *receiver_word)
10596 })
10597 .count() as f64;
10598 let score =
10599 overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
10600 match best {
10601 None => {
10602 best = Some((*candidate, score));
10603 tied_best = false;
10604 }
10605 Some((_, best_score)) if score > best_score => {
10606 best = Some((*candidate, score));
10607 tied_best = false;
10608 }
10609 Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
10610 tied_best = true;
10611 }
10612 _ => {}
10613 }
10614 }
10615
10616 let (candidate, score) = best?;
10617 if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
10618 Some(candidate.clone())
10619 } else {
10620 None
10621 }
10622}
10623
10624fn method_name_match_denylisted(method_name: &str) -> bool {
10625 matches!(
10626 method_name,
10627 "and_then"
10628 | "as_bytes"
10629 | "as_deref"
10630 | "as_mut"
10631 | "as_ref"
10632 | "as_str"
10633 | "borrow"
10634 | "borrow_mut"
10635 | "clear"
10636 | "clone"
10637 | "collect"
10638 | "contains"
10639 | "contains_key"
10640 | "count"
10641 | "dedup"
10642 | "default"
10643 | "drain"
10644 | "ends_with"
10645 | "entry"
10646 | "err"
10647 | "expect"
10648 | "extend"
10649 | "filter"
10650 | "filter_map"
10651 | "find"
10652 | "from"
10653 | "get"
10654 | "get_mut"
10655 | "insert"
10656 | "into"
10657 | "into_iter"
10658 | "is_empty"
10659 | "is_err"
10660 | "is_none"
10661 | "is_ok"
10662 | "is_some"
10663 | "iter"
10664 | "iter_mut"
10665 | "join"
10666 | "len"
10667 | "lock"
10668 | "map"
10669 | "map_err"
10670 | "max"
10671 | "min"
10672 | "new"
10673 | "next"
10674 | "ok"
10675 | "or_default"
10676 | "or_else"
10677 | "or_insert"
10678 | "or_insert_with"
10679 | "parse"
10680 | "pop"
10681 | "position"
10682 | "push"
10683 | "read"
10684 | "recv"
10685 | "remove"
10686 | "replace"
10687 | "retain"
10688 | "send"
10689 | "sort"
10690 | "sort_by"
10691 | "split"
10692 | "starts_with"
10693 | "sum"
10694 | "take"
10695 | "to_owned"
10696 | "to_string"
10697 | "trim"
10698 | "try_from"
10699 | "try_into"
10700 | "unwrap"
10701 | "unwrap_or"
10702 | "unwrap_or_default"
10703 | "unwrap_or_else"
10704 | "with_capacity"
10705 | "write"
10706 )
10707}
10708
10709fn split_camel_case(value: &str) -> Vec<String> {
10710 let chars = value.chars().collect::<Vec<_>>();
10711 let mut normalized = String::with_capacity(value.len() + 8);
10712 for (index, ch) in chars.iter().enumerate() {
10713 let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
10714 let next = chars.get(index + 1);
10715 let is_separator = ch.is_whitespace()
10716 || matches!(
10717 ch,
10718 '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
10719 );
10720 if is_separator {
10721 normalized.push(' ');
10722 continue;
10723 }
10724 let camel_boundary = previous.is_some_and(|prev| {
10725 (prev.is_lowercase() && ch.is_uppercase())
10726 || (prev.is_ascii_digit() && ch.is_alphabetic())
10727 || (prev.is_uppercase()
10728 && ch.is_uppercase()
10729 && next.is_some_and(|next| next.is_lowercase()))
10730 });
10731 if camel_boundary {
10732 normalized.push(' ');
10733 }
10734 normalized.push(*ch);
10735 }
10736
10737 normalized
10738 .split_whitespace()
10739 .filter(|word| word.len() > 1)
10740 .map(|word| word.to_ascii_lowercase())
10741 .collect()
10742}
10743
10744fn compute_path_proximity(left: &str, right: &str) -> f64 {
10745 let left_dirs = left
10746 .rsplit_once('/')
10747 .map(|(dir, _)| dir)
10748 .unwrap_or_default()
10749 .split('/')
10750 .filter(|part| !part.is_empty());
10751 let right_dirs = right
10752 .rsplit_once('/')
10753 .map(|(dir, _)| dir)
10754 .unwrap_or_default()
10755 .split('/')
10756 .filter(|part| !part.is_empty());
10757
10758 let shared = left_dirs
10759 .zip(right_dirs)
10760 .take_while(|(left, right)| left == right)
10761 .count();
10762 ((shared as f64) * 0.05).min(0.5)
10763}
10764
10765fn mark_backend_state(
10766 tx: &Transaction<'_>,
10767 project_root: &Path,
10768 rel_path: &str,
10769 content_hash: Option<&blake3::Hash>,
10770 status: &str,
10771) -> Result<()> {
10772 clear_backend_state_for_file(tx, project_root, rel_path)?;
10773 let hash = content_hash
10774 .map(|hash| hash_to_hex(*hash))
10775 .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
10776 tx.execute(
10777 "INSERT OR REPLACE INTO backend_file_state(
10778 backend, workspace_root, file_path, content_hash, status, updated_at
10779 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
10780 params![
10781 BACKEND_TREESITTER,
10782 project_root.display().to_string(),
10783 rel_path,
10784 hash,
10785 status,
10786 unix_seconds_now(),
10787 ],
10788 )?;
10789 Ok(())
10790}
10791
10792fn clear_backend_state_for_file(
10793 tx: &Transaction<'_>,
10794 project_root: &Path,
10795 rel_path: &str,
10796) -> Result<()> {
10797 tx.execute(
10798 "DELETE FROM backend_file_state
10799 WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
10800 params![
10801 BACKEND_TREESITTER,
10802 project_root.display().to_string(),
10803 rel_path
10804 ],
10805 )?;
10806 Ok(())
10807}
10808
10809fn load_file_row(tx: &Transaction<'_>, rel_path: &str) -> Result<Option<FileRow>> {
10810 tx.query_row(
10811 "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
10812 params![rel_path],
10813 |row| {
10814 let hash_text: String = row.get(1)?;
10815 Ok(FileRow {
10816 surface_fingerprint: row.get(0)?,
10817 freshness: FileFreshness {
10818 content_hash: hash_from_hex(&hash_text)
10819 .unwrap_or_else(cache_freshness::zero_hash),
10820 mtime: ns_to_system_time(row.get::<_, i64>(2)?),
10821 size: row.get::<_, i64>(3)? as u64,
10822 },
10823 })
10824 },
10825 )
10826 .optional()
10827 .map_err(CallGraphStoreError::from)
10828}
10829
10830fn stored_node_ids_match_extract(
10831 tx: &Transaction<'_>,
10832 rel_path: &str,
10833 extract: &FileExtract,
10834) -> Result<bool> {
10835 let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
10836 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
10837 let mut stored = BTreeSet::new();
10838 for row in rows {
10839 stored.insert(row?);
10840 }
10841 let extracted = extract
10842 .nodes
10843 .iter()
10844 .map(|node| node.id.clone())
10845 .collect::<BTreeSet<_>>();
10846 Ok(stored == extracted)
10847}
10848
10849fn stored_extract_matches(
10853 tx: &Transaction<'_>,
10854 rel_path: &str,
10855 extract: &FileExtract,
10856 index: &ProjectIndex<'_>,
10857) -> Result<bool> {
10858 let stored_file = tx
10859 .query_row(
10860 "SELECT lang, surface_fingerprint FROM files WHERE path = ?1",
10861 params![rel_path],
10862 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
10863 )
10864 .optional()?;
10865 if stored_file
10866 != Some((
10867 lang_label(extract.lang).to_string(),
10868 extract.surface_fingerprint.clone(),
10869 ))
10870 {
10871 return Ok(false);
10872 }
10873
10874 let mut stored_nodes_stmt = tx.prepare(
10875 "SELECT id, file_path, name, scoped_name, kind, start_line, start_col,
10876 end_line, end_col, range_ordinal, signature, exported,
10877 is_default_export, is_type_like, is_callgraph_entry_point, provenance
10878 FROM nodes WHERE file_path = ?1",
10879 )?;
10880 let stored_nodes = stored_nodes_stmt
10881 .query_map(params![rel_path], |row| {
10882 Ok(serde_json::json!([
10883 row.get::<_, String>(0)?,
10884 row.get::<_, String>(1)?,
10885 row.get::<_, String>(2)?,
10886 row.get::<_, String>(3)?,
10887 row.get::<_, String>(4)?,
10888 row.get::<_, i64>(5)?,
10889 row.get::<_, i64>(6)?,
10890 row.get::<_, i64>(7)?,
10891 row.get::<_, i64>(8)?,
10892 row.get::<_, i64>(9)?,
10893 row.get::<_, Option<String>>(10)?,
10894 row.get::<_, i64>(11)?,
10895 row.get::<_, i64>(12)?,
10896 row.get::<_, i64>(13)?,
10897 row.get::<_, i64>(14)?,
10898 row.get::<_, String>(15)?,
10899 ])
10900 .to_string())
10901 })?
10902 .collect::<rusqlite::Result<Vec<_>>>()?;
10903 let expected_nodes = extract
10904 .nodes
10905 .iter()
10906 .map(|node| {
10907 serde_json::json!([
10908 node.id,
10909 node.file_path,
10910 node.name,
10911 node.scoped_name,
10912 node.kind,
10913 node.range.start_line,
10914 node.range.start_col,
10915 node.range.end_line,
10916 node.range.end_col,
10917 node.range_ordinal,
10918 node.signature,
10919 bool_int(node.exported),
10920 bool_int(node.is_default_export),
10921 bool_int(node.is_type_like),
10922 bool_int(node.is_callgraph_entry_point),
10923 PROVENANCE_TREESITTER,
10924 ])
10925 .to_string()
10926 })
10927 .collect::<Vec<_>>();
10928 let mut stored_nodes = stored_nodes;
10929 let mut expected_nodes = expected_nodes;
10930 stored_nodes.sort();
10931 expected_nodes.sort();
10932 if stored_nodes != expected_nodes {
10933 return Ok(false);
10934 }
10935
10936 let resolved_refs = extract
10937 .raw_refs
10938 .iter()
10939 .cloned()
10940 .map(|raw| resolve_ref(raw, index))
10941 .collect::<Result<Vec<_>>>()?;
10942 let mut stored_refs_stmt = tx.prepare(
10943 "SELECT ref_id, caller_node, caller_file, kind, short_name, full_ref,
10944 module_path, import_kind, local_name, requested_name, namespace_alias,
10945 wildcard, line, byte_start, byte_end, status, target_node,
10946 target_file, target_symbol, provenance
10947 FROM refs WHERE caller_file = ?1",
10948 )?;
10949 let stored_refs = stored_refs_stmt
10950 .query_map(params![rel_path], |row| {
10951 Ok(serde_json::json!([
10952 row.get::<_, String>(0)?,
10953 row.get::<_, Option<String>>(1)?,
10954 row.get::<_, String>(2)?,
10955 row.get::<_, String>(3)?,
10956 row.get::<_, Option<String>>(4)?,
10957 row.get::<_, Option<String>>(5)?,
10958 row.get::<_, Option<String>>(6)?,
10959 row.get::<_, Option<String>>(7)?,
10960 row.get::<_, Option<String>>(8)?,
10961 row.get::<_, Option<String>>(9)?,
10962 row.get::<_, Option<String>>(10)?,
10963 row.get::<_, i64>(11)?,
10964 row.get::<_, i64>(12)?,
10965 row.get::<_, i64>(13)?,
10966 row.get::<_, i64>(14)?,
10967 row.get::<_, String>(15)?,
10968 row.get::<_, Option<String>>(16)?,
10969 row.get::<_, Option<String>>(17)?,
10970 row.get::<_, Option<String>>(18)?,
10971 row.get::<_, String>(19)?,
10972 ])
10973 .to_string())
10974 })?
10975 .collect::<rusqlite::Result<Vec<_>>>()?;
10976 let expected_refs = resolved_refs
10977 .iter()
10978 .map(|resolved| {
10979 let raw = &resolved.raw;
10980 serde_json::json!([
10981 raw.ref_id,
10982 raw.caller_node,
10983 raw.caller_file,
10984 raw.kind,
10985 raw.short_name,
10986 raw.full_ref,
10987 raw.module_path,
10988 raw.import_kind,
10989 raw.local_name,
10990 raw.requested_name,
10991 raw.namespace_alias,
10992 bool_int(raw.wildcard),
10993 raw.line,
10994 raw.byte_start,
10995 raw.byte_end,
10996 resolved.status,
10997 resolved.target_node,
10998 resolved.target_file,
10999 resolved.target_symbol,
11000 PROVENANCE_TREESITTER,
11001 ])
11002 .to_string()
11003 })
11004 .collect::<Vec<_>>();
11005 let mut stored_refs = stored_refs;
11006 let mut expected_refs = expected_refs;
11007 stored_refs.sort();
11008 expected_refs.sort();
11009 if stored_refs != expected_refs {
11010 return Ok(false);
11011 }
11012
11013 let mut stored_edges_stmt = tx.prepare(
11014 "SELECT e.edge_id, e.ref_id, e.source_node, e.target_node,
11015 e.target_file, e.target_symbol, e.kind, e.line, e.provenance
11016 FROM edges e JOIN refs r ON r.ref_id = e.ref_id
11017 WHERE r.caller_file = ?1 AND e.provenance = ?2",
11018 )?;
11019 let stored_edges = stored_edges_stmt
11020 .query_map(params![rel_path, PROVENANCE_TREESITTER], |row| {
11021 Ok(serde_json::json!([
11022 row.get::<_, String>(0)?,
11023 row.get::<_, String>(1)?,
11024 row.get::<_, String>(2)?,
11025 row.get::<_, Option<String>>(3)?,
11026 row.get::<_, String>(4)?,
11027 row.get::<_, String>(5)?,
11028 row.get::<_, String>(6)?,
11029 row.get::<_, i64>(7)?,
11030 row.get::<_, String>(8)?,
11031 ])
11032 .to_string())
11033 })?
11034 .collect::<rusqlite::Result<Vec<_>>>()?;
11035 let expected_edges = resolved_refs
11036 .iter()
11037 .filter_map(|resolved| {
11038 resolved.edge.as_ref().map(|edge| {
11039 serde_json::json!([
11040 edge.edge_id,
11041 resolved.raw.ref_id,
11042 edge.source_node,
11043 edge.target_node,
11044 edge.target_file,
11045 edge.target_symbol,
11046 edge.kind,
11047 edge.line,
11048 PROVENANCE_TREESITTER,
11049 ])
11050 .to_string()
11051 })
11052 })
11053 .collect::<Vec<_>>();
11054 let mut stored_edges = stored_edges;
11055 let mut expected_edges = expected_edges;
11056 stored_edges.sort();
11057 expected_edges.sort();
11058 if stored_edges != expected_edges {
11059 return Ok(false);
11060 }
11061
11062 let mut stored_dependencies_stmt =
11063 tx.prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1")?;
11064 let stored_dependencies = stored_dependencies_stmt
11065 .query_map(params![rel_path], |row| row.get::<_, String>(0))?
11066 .collect::<rusqlite::Result<BTreeSet<_>>>()?;
11067 let expected_dependencies = extract
11068 .raw_refs
11069 .iter()
11070 .flat_map(|raw| raw.dependencies.iter().cloned())
11071 .collect::<BTreeSet<_>>();
11072 if stored_dependencies != expected_dependencies {
11073 return Ok(false);
11074 }
11075
11076 let mut stored_hints_stmt = tx.prepare(
11077 "SELECT id, method_name, caller_node, file, line, byte_start, byte_end, provenance
11078 FROM dispatch_hints WHERE file = ?1",
11079 )?;
11080 let stored_hints = stored_hints_stmt
11081 .query_map(params![rel_path], |row| {
11082 Ok(serde_json::json!([
11083 row.get::<_, String>(0)?,
11084 row.get::<_, String>(1)?,
11085 row.get::<_, String>(2)?,
11086 row.get::<_, String>(3)?,
11087 row.get::<_, i64>(4)?,
11088 row.get::<_, i64>(5)?,
11089 row.get::<_, i64>(6)?,
11090 row.get::<_, String>(7)?,
11091 ])
11092 .to_string())
11093 })?
11094 .collect::<rusqlite::Result<Vec<_>>>()?;
11095 let expected_hints = extract
11096 .dispatch_hints
11097 .iter()
11098 .map(|hint| {
11099 serde_json::json!([
11100 hint.id,
11101 hint.method_name,
11102 hint.caller_node,
11103 hint.file,
11104 hint.line,
11105 hint.byte_start,
11106 hint.byte_end,
11107 PROVENANCE_TREESITTER,
11108 ])
11109 .to_string()
11110 })
11111 .collect::<Vec<_>>();
11112 let mut stored_hints = stored_hints;
11113 let mut expected_hints = expected_hints;
11114 stored_hints.sort();
11115 expected_hints.sort();
11116 Ok(stored_hints == expected_hints)
11117}
11118
11119fn update_file_fresh_metadata(
11120 tx: &Transaction<'_>,
11121 project_root: &Path,
11122 rel_path: &str,
11123 hash: &blake3::Hash,
11124 mtime: SystemTime,
11125 size: u64,
11126) -> Result<()> {
11127 tx.execute(
11128 "UPDATE files SET content_hash = ?2, mtime_ns = ?3, size = ?4, indexed_at = ?5
11129 WHERE path = ?1",
11130 params![
11131 rel_path,
11132 hash_to_hex(*hash),
11133 system_time_to_ns(mtime),
11134 size as i64,
11135 unix_seconds_now()
11136 ],
11137 )?;
11138 tx.execute(
11139 "UPDATE backend_file_state SET content_hash = ?3, status = 'fresh', updated_at = ?5
11140 WHERE backend = ?1 AND file_path = ?2 AND workspace_root = ?4",
11141 params![
11142 BACKEND_TREESITTER,
11143 rel_path,
11144 hash_to_hex(*hash),
11145 project_root.display().to_string(),
11146 unix_seconds_now(),
11147 ],
11148 )?;
11149 Ok(())
11150}
11151
11152#[derive(Debug, Clone, PartialEq, Eq)]
11153struct DependentRefSelection {
11154 ref_id: String,
11155 caller_file: String,
11156}
11157
11158fn ref_ids_depending_on(
11159 tx: &Transaction<'_>,
11160 project_root: &Path,
11161 rel_path: &str,
11162) -> Result<Vec<DependentRefSelection>> {
11163 let mut stmt = tx.prepare(
11164 "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
11165 FROM refs r
11166 WHERE r.caller_file IN (
11167 SELECT file_path FROM file_dependencies WHERE dep_file = ?1
11168 )
11169 OR r.target_file = ?1
11170 ORDER BY r.ref_id",
11171 )?;
11172 let rows = stmt.query_map(params![rel_path], |row| {
11173 Ok(RefDependencyRow {
11174 ref_id: row.get(0)?,
11175 kind: row.get(1)?,
11176 caller_file: row.get(2)?,
11177 module_path: row.get(3)?,
11178 target_file: row.get(4)?,
11179 })
11180 })?;
11181 let mut ids = Vec::new();
11182 for row in rows {
11183 let row = row?;
11184 if ref_dependency_row_depends_on(project_root, &row, rel_path) {
11185 ids.push(DependentRefSelection {
11186 ref_id: row.ref_id,
11187 caller_file: row.caller_file,
11188 });
11189 }
11190 }
11191 Ok(ids)
11192}
11193
11194fn record_dependent_refs(
11195 selected_ref_ids: &mut BTreeSet<String>,
11196 selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
11197 dependent_refs: Vec<DependentRefSelection>,
11198) {
11199 for dependent_ref in dependent_refs {
11200 let DependentRefSelection {
11201 ref_id,
11202 caller_file,
11203 } = dependent_ref;
11204 selected_ref_ids.insert(ref_id.clone());
11205 selected_refs_by_caller
11206 .entry(caller_file)
11207 .or_default()
11208 .insert(ref_id);
11209 }
11210}
11211
11212#[cfg(test)]
11213fn refs_by_caller_for_ref_ids(
11214 tx: &Transaction<'_>,
11215 ref_ids: &BTreeSet<String>,
11216) -> Result<BTreeMap<String, BTreeSet<String>>> {
11217 let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11218 let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
11219 for ref_id in ref_ids {
11220 if let Some(caller) = stmt
11221 .query_row(params![ref_id], |row| row.get::<_, String>(0))
11222 .optional()?
11223 {
11224 by_caller.entry(caller).or_default().insert(ref_id.clone());
11225 }
11226 }
11227 Ok(by_caller)
11228}
11229
11230fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
11231 tx.execute(
11232 "DELETE FROM file_dependencies WHERE file_path = ?1",
11233 params![rel_path],
11234 )?;
11235 delete_refs_for_caller(tx, rel_path)?;
11236 tx.execute(
11237 "DELETE FROM dispatch_hints WHERE file = ?1",
11238 params![rel_path],
11239 )?;
11240 tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
11241 tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
11242 Ok(())
11243}
11244
11245fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
11246 let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
11247 let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
11248 let mut ids = BTreeSet::new();
11249 for row in rows {
11250 ids.insert(row?);
11251 }
11252 delete_ref_ids(tx, &ids)
11253}
11254
11255fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
11256 for ref_id in ref_ids {
11257 tx.execute("DELETE FROM edges WHERE ref_id = ?1", params![ref_id])?;
11258 tx.execute("DELETE FROM refs WHERE ref_id = ?1", params![ref_id])?;
11259 }
11260 Ok(())
11261}
11262
11263fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
11264 let mut stmt = conn.prepare(
11265 "SELECT source.file_path, source.scoped_name, edges.target_file,
11266 edges.target_symbol, edges.kind, edges.line
11267 FROM edges
11268 JOIN nodes AS source ON source.id = edges.source_node
11269 ORDER BY source.file_path, source.scoped_name, edges.target_file,
11270 edges.target_symbol, edges.kind, edges.line",
11271 )?;
11272 let rows = stmt.query_map([], |row| {
11273 Ok(StoredEdge {
11274 source_file: row.get(0)?,
11275 source_symbol: row.get(1)?,
11276 target_file: row.get(2)?,
11277 target_symbol: row.get(3)?,
11278 kind: row.get(4)?,
11279 line: row.get::<_, i64>(5)? as u32,
11280 })
11281 })?;
11282 let mut edges = BTreeSet::new();
11283 for row in rows {
11284 edges.insert(row?);
11285 }
11286 Ok(edges)
11287}
11288
11289fn module_target_from_dependencies(
11290 project_root: &Path,
11291 dependencies: &BTreeSet<String>,
11292) -> Option<String> {
11293 dependencies.iter().find_map(|dep| {
11294 let path = project_root.join(dep);
11295 if path.is_file() {
11296 Some(relative_path(project_root, &canonicalize_path(&path)))
11297 } else {
11298 None
11299 }
11300 })
11301}
11302
11303fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
11304 let mut named = HashMap::new();
11305 if let Some(full_ref) = &raw_ref.full_ref {
11306 named = parse_reexport_names(full_ref);
11307 }
11308 ReexportIndex {
11309 target_file,
11310 named,
11311 wildcard: raw_ref.wildcard,
11312 }
11313}
11314
11315fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
11316 let mut names = HashMap::new();
11317 let Some(open) = statement.find('{') else {
11318 return names;
11319 };
11320 let Some(close) = statement[open + 1..]
11321 .find('}')
11322 .map(|offset| open + 1 + offset)
11323 else {
11324 return names;
11325 };
11326 for spec in statement[open + 1..close].split(',') {
11327 let spec = spec.trim();
11328 if spec.is_empty() {
11329 continue;
11330 }
11331 if let Some((source, local)) = spec.split_once(" as ") {
11332 names.insert(local.trim().to_string(), source.trim().to_string());
11333 } else {
11334 names.insert(spec.to_string(), spec.to_string());
11335 }
11336 }
11337 names
11338}
11339
11340#[derive(Debug)]
11341struct RefDependencyRow {
11342 ref_id: String,
11343 kind: String,
11344 caller_file: String,
11345 module_path: Option<String>,
11346 target_file: Option<String>,
11347}
11348
11349fn ref_dependency_row_depends_on(
11350 project_root: &Path,
11351 row: &RefDependencyRow,
11352 rel_path: &str,
11353) -> bool {
11354 if row.target_file.as_deref() == Some(rel_path) {
11355 return true;
11356 }
11357
11358 match row.kind.as_str() {
11359 "call" => true,
11360 "import" | "reexport" => row
11361 .module_path
11362 .as_deref()
11363 .map(|module_path| {
11364 module_dependencies_for_ref(project_root, &row.caller_file, module_path)
11365 .contains(rel_path)
11366 })
11367 .unwrap_or(false),
11368 "export_alias" => false,
11369 _ => false,
11370 }
11371}
11372
11373fn module_dependencies_for_ref(
11374 project_root: &Path,
11375 caller_file: &str,
11376 module_path: &str,
11377) -> BTreeSet<String> {
11378 module_dependencies(project_root, &project_root.join(caller_file), module_path)
11379}
11380
11381fn import_dependencies(
11382 project_root: &Path,
11383 abs_path: &Path,
11384 imports: &[ImportStatement],
11385) -> BTreeSet<String> {
11386 let mut deps = BTreeSet::new();
11387 for import in imports {
11388 deps.extend(module_dependencies(
11389 project_root,
11390 abs_path,
11391 &import.module_path,
11392 ));
11393 }
11394 deps
11395}
11396
11397fn module_dependencies(
11398 project_root: &Path,
11399 abs_path: &Path,
11400 module_path: &str,
11401) -> BTreeSet<String> {
11402 let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
11403 let caller_dir = abs_path.parent().unwrap_or(project_root);
11404 if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
11405 deps.insert(relative_path(project_root, &resolved));
11406 }
11407 if module_path.starts_with('.') {
11408 let base = caller_dir.join(module_path);
11409 for candidate in relative_module_candidates(&base) {
11410 deps.insert(relative_path(project_root, &candidate));
11411 }
11412 }
11413 deps
11414}
11415
11416fn rust_module_dependencies(
11417 project_root: &Path,
11418 abs_path: &Path,
11419 module_path: &str,
11420) -> BTreeSet<String> {
11421 let mut deps = BTreeSet::new();
11422 let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
11423 let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
11424 return deps;
11425 };
11426 let src_prefix = rust_src_prefix(&rel_path);
11427 rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
11428 if !path_segments.is_empty() {
11429 rust_push_module_dependency_candidate(
11430 project_root,
11431 &mut deps,
11432 &src_prefix,
11433 &path_segments[..path_segments.len() - 1],
11434 );
11435 }
11436 deps
11437}
11438
11439fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
11440 let path = rust_module_path_without_alias_or_use_list(module_path);
11441 let segments = path
11442 .split("::")
11443 .map(str::trim)
11444 .filter(|segment| !segment.is_empty())
11445 .collect::<Vec<_>>();
11446 if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
11447 return None;
11448 }
11449 rust_resolve_segments(rel_path, &segments)
11450}
11451
11452fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
11453 let path = module_path
11454 .trim()
11455 .trim_end_matches(';')
11456 .split_once(" as ")
11457 .map(|(left, _)| left.trim())
11458 .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
11459 path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
11460}
11461
11462fn rust_push_module_dependency_candidate(
11463 project_root: &Path,
11464 deps: &mut BTreeSet<String>,
11465 src_prefix: &str,
11466 segments: &[String],
11467) {
11468 let candidates = if segments.is_empty() {
11469 vec![
11470 format!("{src_prefix}/lib.rs"),
11471 format!("{src_prefix}/main.rs"),
11472 ]
11473 } else {
11474 vec![
11475 format!("{}/{}.rs", src_prefix, segments.join("/")),
11476 format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
11477 ]
11478 };
11479 for candidate in candidates {
11480 if project_root.join(&candidate).is_file() {
11481 deps.insert(candidate);
11482 }
11483 }
11484}
11485
11486fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
11487 let mut candidates = Vec::new();
11488 if base.extension().is_some() {
11489 candidates.push(base.to_path_buf());
11490 return candidates;
11491 }
11492 for ext in JS_TS_EXTENSIONS {
11493 candidates.push(base.with_extension(ext));
11494 }
11495 for ext in JS_TS_EXTENSIONS {
11496 candidates.push(base.join(format!("index.{ext}")));
11497 }
11498 candidates
11499}
11500
11501fn import_local_names(import: &ImportStatement) -> Vec<String> {
11502 let mut names = Vec::new();
11503 if let Some(default) = &import.default_import {
11504 names.push(default.clone());
11505 }
11506 if let Some(namespace) = &import.namespace_import {
11507 names.push(namespace.clone());
11508 }
11509 for name in &import.names {
11510 names.push(crate::imports::specifier_local_name(name).to_string());
11511 }
11512 names
11513}
11514
11515fn import_requested_names(import: &ImportStatement) -> Vec<String> {
11516 import
11517 .names
11518 .iter()
11519 .map(|name| crate::imports::specifier_imported_name(name).to_string())
11520 .collect()
11521}
11522
11523fn import_is_wildcard(import: &ImportStatement) -> bool {
11524 import.namespace_import.is_some() || import.raw_text.contains('*')
11525}
11526
11527fn namespace_alias(full_ref: &str) -> Option<String> {
11528 full_ref
11529 .split_once('.')
11530 .map(|(namespace, _)| namespace.to_string())
11531}
11532
11533fn import_kind_label(kind: ImportKind) -> &'static str {
11534 match kind {
11535 ImportKind::Value => "value",
11536 ImportKind::Type => "type",
11537 ImportKind::SideEffect => "side_effect",
11538 }
11539}
11540
11541fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
11542 match kind {
11543 SymbolKind::Function => "function",
11544 SymbolKind::Class => "class",
11545 SymbolKind::Method => "method",
11546 SymbolKind::Struct => "struct",
11547 SymbolKind::Interface => "interface",
11548 SymbolKind::Enum => "enum",
11549 SymbolKind::TypeAlias => "type_alias",
11550 SymbolKind::Variable => "variable",
11551 SymbolKind::Heading => "heading",
11552 SymbolKind::FileSummary => "file_summary",
11553 }
11554}
11555
11556fn is_type_like(kind: &SymbolKind) -> bool {
11557 matches!(
11558 kind,
11559 SymbolKind::Class
11560 | SymbolKind::Struct
11561 | SymbolKind::Interface
11562 | SymbolKind::Enum
11563 | SymbolKind::TypeAlias
11564 )
11565}
11566
11567fn lang_label(lang: LangId) -> &'static str {
11568 match lang {
11569 LangId::TypeScript => "typescript",
11570 LangId::Tsx => "tsx",
11571 LangId::JavaScript => "javascript",
11572 LangId::Python => "python",
11573 LangId::Rust => "rust",
11574 LangId::Go => "go",
11575 LangId::C => "c",
11576 LangId::Cpp => "cpp",
11577 LangId::Zig => "zig",
11578 LangId::CSharp => "csharp",
11579 LangId::Bash => "bash",
11580 LangId::Html => "html",
11581 LangId::Markdown => "markdown",
11582 LangId::Solidity => "solidity",
11583 LangId::Scss => "scss",
11584 LangId::Vue => "vue",
11585 LangId::Json => "json",
11586 LangId::Scala => "scala",
11587 LangId::Java => "java",
11588 LangId::Ruby => "ruby",
11589 LangId::Kotlin => "kotlin",
11590 LangId::Swift => "swift",
11591 LangId::Php => "php",
11592 LangId::Lua => "lua",
11593 LangId::Perl => "perl",
11594 LangId::Yaml => "yaml",
11595 LangId::Pascal => "pascal",
11596 LangId::R => "r",
11597 LangId::Groovy => "groovy",
11598 LangId::ObjC => "objc",
11599 }
11600}
11601
11602fn lang_from_label(label: &str) -> Option<LangId> {
11603 match label {
11604 "typescript" => Some(LangId::TypeScript),
11605 "tsx" => Some(LangId::Tsx),
11606 "javascript" => Some(LangId::JavaScript),
11607 "python" => Some(LangId::Python),
11608 "rust" => Some(LangId::Rust),
11609 "go" => Some(LangId::Go),
11610 "c" => Some(LangId::C),
11611 "cpp" => Some(LangId::Cpp),
11612 "zig" => Some(LangId::Zig),
11613 "csharp" => Some(LangId::CSharp),
11614 "bash" => Some(LangId::Bash),
11615 "html" => Some(LangId::Html),
11616 "markdown" => Some(LangId::Markdown),
11617 "solidity" => Some(LangId::Solidity),
11618 "scss" => Some(LangId::Scss),
11619 "vue" => Some(LangId::Vue),
11620 "json" => Some(LangId::Json),
11621 "scala" => Some(LangId::Scala),
11622 "java" => Some(LangId::Java),
11623 "ruby" => Some(LangId::Ruby),
11624 "kotlin" => Some(LangId::Kotlin),
11625 "swift" => Some(LangId::Swift),
11626 "php" => Some(LangId::Php),
11627 "lua" => Some(LangId::Lua),
11628 "perl" => Some(LangId::Perl),
11629 "yaml" => Some(LangId::Yaml),
11630 "pascal" => Some(LangId::Pascal),
11631 "r" => Some(LangId::R),
11632 "groovy" => Some(LangId::Groovy),
11633 "objc" => Some(LangId::ObjC),
11634 _ => None,
11635 }
11636}
11637
11638fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
11639 let mut normalized = if files.is_empty() {
11640 callgraph::walk_project_files(project_root).collect::<Vec<_>>()
11641 } else {
11642 files
11643 .iter()
11644 .map(|path| normalize_file_path(project_root, path))
11645 .collect::<Result<Vec<_>>>()?
11646 };
11647 normalized.sort();
11648 normalized.dedup();
11649 Ok(normalized)
11650}
11651
11652fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
11653 let full_path = if path.is_relative() {
11654 project_root.join(path)
11655 } else {
11656 path.to_path_buf()
11657 };
11658 Ok(canonicalize_path(&full_path))
11659}
11660
11661fn canonicalize_path(path: &Path) -> PathBuf {
11662 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
11663}
11664
11665fn relative_path(project_root: &Path, path: &Path) -> String {
11666 if let Ok(stripped) = path.strip_prefix(project_root) {
11667 return stripped.to_string_lossy().replace('\\', "/");
11668 }
11669 let canon_root = canonicalize_path(project_root);
11670 let canon_path = canonicalize_path(path);
11671 if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
11672 return stripped.to_string_lossy().replace('\\', "/");
11673 }
11674 canon_path.to_string_lossy().replace('\\', "/")
11675}
11676
11677fn unqualified_name(scoped: &str) -> &str {
11678 if scoped == TOP_LEVEL_SYMBOL {
11679 return scoped;
11680 }
11681 scoped
11682 .rsplit("::")
11683 .next()
11684 .unwrap_or(scoped)
11685 .rsplit('.')
11686 .next()
11687 .unwrap_or(scoped)
11688 .rsplit('#')
11689 .next()
11690 .unwrap_or(scoped)
11691}
11692
11693fn ref_id(parts: &[&str]) -> String {
11694 let joined = parts.join("\0");
11695 hash_to_hex(blake3::hash(joined.as_bytes()))
11696}
11697
11698fn hash_to_hex(hash: blake3::Hash) -> String {
11699 hash.to_hex().to_string()
11700}
11701
11702fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
11703 let bytes = hex_to_bytes(value)?;
11704 Some(blake3::Hash::from_bytes(bytes))
11705}
11706
11707fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
11708 if value.len() != 64 {
11709 return None;
11710 }
11711 let mut bytes = [0u8; 32];
11712 for (index, slot) in bytes.iter_mut().enumerate() {
11713 let start = index * 2;
11714 let end = start + 2;
11715 *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
11716 }
11717 Some(bytes)
11718}
11719
11720#[derive(Debug, Clone)]
11721struct LineIndex {
11722 newline_offsets: Vec<usize>,
11723 source_len: usize,
11724}
11725
11726impl LineIndex {
11727 fn new(source: &str) -> Self {
11728 Self {
11729 newline_offsets: source
11730 .bytes()
11731 .enumerate()
11732 .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
11733 .collect(),
11734 source_len: source.len(),
11735 }
11736 }
11737
11738 fn byte_to_line(&self, byte_offset: usize) -> u32 {
11739 let byte_offset = byte_offset.min(self.source_len);
11740 self.newline_offsets
11741 .partition_point(|offset| *offset < byte_offset) as u32
11742 + 1
11743 }
11744}
11745
11746fn empty_to_none(value: String) -> Option<String> {
11747 if value.is_empty() {
11748 None
11749 } else {
11750 Some(value)
11751 }
11752}
11753
11754fn bool_int(value: bool) -> i64 {
11755 if value {
11756 1
11757 } else {
11758 0
11759 }
11760}
11761
11762fn system_time_to_ns(time: SystemTime) -> i64 {
11763 time.duration_since(UNIX_EPOCH)
11764 .unwrap_or_default()
11765 .as_nanos()
11766 .min(i64::MAX as u128) as i64
11767}
11768
11769fn ns_to_system_time(value: i64) -> SystemTime {
11770 UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
11771}
11772
11773fn unix_millis_now() -> u64 {
11774 SystemTime::now()
11775 .duration_since(UNIX_EPOCH)
11776 .unwrap_or_default()
11777 .as_millis()
11778 .min(u128::from(u64::MAX)) as u64
11779}
11780
11781fn unix_seconds_now() -> i64 {
11782 SystemTime::now()
11783 .duration_since(UNIX_EPOCH)
11784 .unwrap_or_default()
11785 .as_secs() as i64
11786}
11787
11788#[cfg(test)]
11793pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
11794
11795#[cfg(test)]
11796mod refresh_worker_tests {
11797 use super::*;
11798 use std::fs;
11799 use tempfile::tempdir;
11800
11801 fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
11802 let temp = tempdir().unwrap();
11803 let root = temp.path().join("root");
11804 fs::create_dir_all(&root).unwrap();
11805 let artifact_key = crate::search_index::artifact_cache_key(&root);
11806 crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
11807 let callgraph_dir = temp
11808 .path()
11809 .join("storage")
11810 .join("callgraph")
11811 .join(artifact_key);
11812 let source = root.join("main.rs");
11813 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
11814 let (store, _) = CallGraphStore::cold_build_with_lease(
11815 callgraph_dir.clone(),
11816 root.clone(),
11817 std::slice::from_ref(&source),
11818 )
11819 .unwrap();
11820 drop(store);
11821 (temp, root, callgraph_dir, source)
11822 }
11823
11824 fn pending_paths() -> PendingCallGraphStorePaths {
11825 Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
11826 }
11827
11828 fn wait_for_refresh_calls(root: &Path, expected: usize) {
11829 let deadline = Instant::now() + Duration::from_secs(12);
11830 while callgraph_refresh_worker_test_counts(root).0 < expected {
11831 assert!(
11832 Instant::now() < deadline,
11833 "timed out waiting for {expected} callgraph refresh worker call(s)"
11834 );
11835 std::thread::sleep(Duration::from_millis(5));
11836 }
11837 }
11838
11839 fn wait_for_refresh_worker_idle() {
11840 let deadline = Instant::now() + Duration::from_secs(12);
11841 loop {
11842 let worker = CALLGRAPH_REFRESH_WORKER
11843 .get_or_init(|| Mutex::new(None))
11844 .lock()
11845 .expect("callgraph refresh worker mutex poisoned")
11846 .clone();
11847 let idle = worker.is_none_or(|worker| {
11848 let queue = worker
11849 .shared
11850 .queue
11851 .lock()
11852 .expect("callgraph refresh queue mutex poisoned");
11853 queue.active.is_none() && queue.order.is_empty()
11854 });
11855 if idle {
11856 return;
11857 }
11858 assert!(
11859 Instant::now() < deadline,
11860 "timed out waiting for callgraph refresh worker to become idle"
11861 );
11862 std::thread::sleep(Duration::from_millis(5));
11863 }
11864 }
11865
11866 fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
11867 let temp = tempdir().unwrap();
11868 let root = temp.path().join("workspace");
11869 fs::create_dir_all(root.join("app/src")).unwrap();
11870 let artifact_key = crate::search_index::artifact_cache_key(&root);
11871 crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
11872 let callgraph_dir = temp
11873 .path()
11874 .join("storage")
11875 .join("callgraph")
11876 .join(artifact_key);
11877 fs::write(
11878 root.join("Cargo.toml"),
11879 "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
11880 )
11881 .unwrap();
11882 fs::write(
11883 root.join("app/Cargo.toml"),
11884 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
11885 )
11886 .unwrap();
11887 let caller = root.join("app/src/lib.rs");
11888 fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
11889 let (store, _) = CallGraphStore::cold_build_with_lease(
11890 callgraph_dir.clone(),
11891 root.clone(),
11892 std::slice::from_ref(&caller),
11893 )
11894 .unwrap();
11895 drop(store);
11896 (temp, root, callgraph_dir, caller)
11897 }
11898
11899 #[test]
11900 fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
11901 let _guard = REFRESH_WORKER_TEST_LOCK
11902 .lock()
11903 .unwrap_or_else(std::sync::PoisonError::into_inner);
11904 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
11905 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
11906 reset_workspace_crate_prefix_build_count(&root);
11907 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
11908
11909 for revision in ["first", "second"] {
11910 fs::write(
11911 &caller,
11912 format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
11913 )
11914 .unwrap();
11915 enqueue_callgraph_store_refresh(
11916 callgraph_dir.clone(),
11917 root.clone(),
11918 vec![caller.clone()],
11919 pending_paths(),
11920 );
11921 wait_for_refresh_worker_idle();
11922 }
11923
11924 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
11925 assert!(flush_callgraph_store_refreshes_with_budget(
11926 Duration::from_secs(5)
11927 ));
11928 clear_callgraph_refresh_worker_test_seam(&root);
11929 }
11930
11931 #[test]
11932 fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
11933 let _guard = REFRESH_WORKER_TEST_LOCK
11934 .lock()
11935 .unwrap_or_else(std::sync::PoisonError::into_inner);
11936 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
11937 let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
11938 reset_workspace_crate_prefix_build_count(&root);
11939 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
11940
11941 fs::write(
11942 &caller,
11943 "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
11944 )
11945 .unwrap();
11946 enqueue_callgraph_store_refresh(
11947 callgraph_dir.clone(),
11948 root.clone(),
11949 vec![caller.clone()],
11950 pending_paths(),
11951 );
11952 wait_for_refresh_worker_idle();
11953 assert_eq!(workspace_crate_prefix_build_count(&root), 1);
11954
11955 let added_manifest = root.join("added/Cargo.toml");
11956 let added_source = root.join("added/src/lib.rs");
11957 fs::create_dir_all(added_source.parent().unwrap()).unwrap();
11958 fs::write(
11959 root.join("Cargo.toml"),
11960 "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
11961 )
11962 .unwrap();
11963 fs::write(
11964 &added_manifest,
11965 "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
11966 )
11967 .unwrap();
11968 fs::write(&added_source, "pub fn target() {}\n").unwrap();
11969 fs::write(
11970 &caller,
11971 "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
11972 )
11973 .unwrap();
11974
11975 enqueue_callgraph_store_refresh(
11976 callgraph_dir.clone(),
11977 root.clone(),
11978 vec![
11979 root.join("Cargo.toml"),
11980 added_manifest,
11981 added_source,
11982 caller,
11983 ],
11984 pending_paths(),
11985 );
11986 assert!(flush_callgraph_store_refreshes_with_budget(
11987 Duration::from_secs(12)
11988 ));
11989
11990 assert_eq!(workspace_crate_prefix_build_count(&root), 2);
11994 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
11995 .unwrap()
11996 .expect("refreshed workspace store");
11997 let tree = store
11998 .call_tree(Path::new("app/src/lib.rs"), "run", 1)
11999 .unwrap();
12000 assert_eq!(tree.children.len(), 1);
12001 assert_eq!(tree.children[0].file, "added/src/lib.rs");
12002 assert_eq!(tree.children[0].name, "target");
12003 assert!(tree.children[0].resolved);
12004 clear_callgraph_refresh_worker_test_seam(&root);
12005 }
12006
12007 fn linked_worktree_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, PathBuf) {
12008 let temp = tempdir().unwrap();
12009 let main = temp.path().join("main");
12010 let worktree = temp.path().join("worktree");
12011 fs::create_dir_all(&main).unwrap();
12012 let mut git = std::process::Command::new("git");
12013 assert!(
12014 crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
12015 .status()
12016 .unwrap()
12017 .success()
12018 );
12019 fs::write(main.join("lib.rs"), "pub fn marker() {}\n").unwrap();
12020 for args in [
12021 vec![
12022 "-C",
12023 main.to_str().unwrap(),
12024 "config",
12025 "user.email",
12026 "test@example.com",
12027 ],
12028 vec![
12029 "-C",
12030 main.to_str().unwrap(),
12031 "config",
12032 "user.name",
12033 "AFT Test",
12034 ],
12035 vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
12036 vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
12037 ] {
12038 let mut command = std::process::Command::new("git");
12039 assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
12040 .status()
12041 .unwrap()
12042 .success());
12043 }
12044 let mut add_worktree = std::process::Command::new("git");
12045 assert!(crate::test_env::apply_hermetic_git_env(
12046 add_worktree
12047 .arg("-C")
12048 .arg(&main)
12049 .args(["worktree", "add", "--detach"])
12050 .arg(&worktree),
12051 )
12052 .status()
12053 .unwrap()
12054 .success());
12055 let main = fs::canonicalize(main).unwrap();
12056 let worktree = fs::canonicalize(worktree).unwrap();
12057 let project_key = crate::search_index::artifact_cache_key(&main);
12058 assert_eq!(
12059 crate::search_index::artifact_cache_key(&worktree),
12060 project_key
12061 );
12062 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
12063 (temp, main, worktree, project_key, callgraph_dir)
12064 }
12065
12066 #[test]
12067 fn linked_worktree_never_acquires_writer_or_publishes_any_build_path() {
12068 let _git_env = crate::test_env::hermetic_git_env_guard();
12069 let (_temp, _main, root, project_key, callgraph_dir) = linked_worktree_fixture();
12070 crate::root_cache::configure_artifact_access(&root, &project_key, true);
12071 crate::root_cache::reset_writer_lease_acquisition_counts_for_test();
12072 let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
12073 let publications_for_observer = Arc::clone(&publications);
12074 set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
12075 publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
12076 })));
12077 let source = root.join("lib.rs");
12078
12079 let open_error = CallGraphStore::open(callgraph_dir.clone(), root.clone())
12080 .expect_err("borrow-only writable open must remain unavailable");
12081 assert!(matches!(open_error, CallGraphStoreError::Unavailable(_)));
12082 assert!(
12083 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), root.clone())
12084 .unwrap()
12085 .is_none()
12086 );
12087 assert!(
12088 CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), root.clone())
12089 .unwrap()
12090 .is_none()
12091 );
12092 assert!(matches!(
12093 CallGraphStore::cold_build_with_lease(
12094 callgraph_dir.clone(),
12095 root.clone(),
12096 std::slice::from_ref(&source),
12097 ),
12098 Err(CallGraphStoreError::Unavailable(_))
12099 ));
12100 assert!(matches!(
12101 CallGraphStore::ensure_built_with_lease(
12102 callgraph_dir.clone(),
12103 root.clone(),
12104 std::slice::from_ref(&source),
12105 ),
12106 Err(CallGraphStoreError::Unavailable(_))
12107 ));
12108 let force_error = CallGraphStore::force_cold_build_with_lease_chunked(
12109 callgraph_dir.clone(),
12110 root.clone(),
12111 &[source],
12112 1,
12113 )
12114 .expect_err("borrow-only forced rebuild must remain unsatisfied");
12115 set_cold_build_swap_observer(None);
12116
12117 assert!(matches!(force_error, CallGraphStoreError::Unavailable(_)));
12118 assert_eq!(
12119 crate::root_cache::writer_lease_acquisition_count_for_test(
12120 crate::root_cache::RootCacheDomain::Callgraph,
12121 &project_key,
12122 &root,
12123 ),
12124 0
12125 );
12126 assert_eq!(publications.load(AtomicOrdering::SeqCst), 0);
12127 assert!(!pointer_path(&callgraph_dir, &project_key).exists());
12128 }
12129
12130 #[test]
12131 fn owner_and_linked_worktree_alternation_rebuilds_storm_generation_once() {
12132 let _git_env = crate::test_env::hermetic_git_env_guard();
12133 let (_temp, owner, worktree, project_key, callgraph_dir) = linked_worktree_fixture();
12134 crate::root_cache::configure_artifact_access(&owner, &project_key, false);
12135 crate::root_cache::configure_artifact_access(&worktree, &project_key, true);
12136 let source = owner.join("lib.rs");
12137 let (store, _) = CallGraphStore::cold_build_with_lease(
12138 callgraph_dir.clone(),
12139 owner.clone(),
12140 std::slice::from_ref(&source),
12141 )
12142 .unwrap();
12143 let sqlite_path = store.sqlite_path().to_path_buf();
12144 drop(store);
12145
12146 let conn = Connection::open(&sqlite_path).unwrap();
12147 conn.execute(
12148 "UPDATE backend_file_state SET workspace_root = ?1",
12149 [worktree.display().to_string()],
12150 )
12151 .unwrap();
12152 drop(conn);
12153
12154 let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
12155 let publications_for_observer = Arc::clone(&publications);
12156 set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
12157 publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
12158 })));
12159 crate::root_cache::reset_writer_lease_acquisition_counts_for_test();
12160
12161 let repaired = CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
12162 .unwrap()
12163 .expect("owner should purge the storm-era worktree root");
12164 drop(repaired);
12165 for _ in 0..3 {
12166 let borrower = CallGraphStore::open_readonly(callgraph_dir.clone(), worktree.clone())
12167 .unwrap()
12168 .expect("linked worktree should borrow the owner generation");
12169 drop(borrower);
12170 assert!(
12171 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), worktree.clone())
12172 .unwrap()
12173 .is_none()
12174 );
12175 let owner_store =
12176 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
12177 .unwrap()
12178 .expect("owner generation should remain ready");
12179 drop(owner_store);
12180 }
12181 set_cold_build_swap_observer(None);
12182
12183 assert_eq!(
12184 publications.load(AtomicOrdering::SeqCst),
12185 1,
12186 "the owner performs one expected post-storm purge and alternation stays read-only"
12187 );
12188 assert_eq!(
12189 crate::root_cache::writer_lease_acquisition_count_for_test(
12190 crate::root_cache::RootCacheDomain::Callgraph,
12191 &project_key,
12192 &worktree,
12193 ),
12194 0
12195 );
12196 }
12197
12198 #[test]
12199 fn rebuild_cooldown_records_only_successful_publication_per_cache_key() {
12200 let temp = tempdir().unwrap();
12201 let root = temp.path().join("owner");
12202 let other_root = temp.path().join("other");
12203 fs::create_dir_all(&root).unwrap();
12204 fs::create_dir_all(&other_root).unwrap();
12205 let source = root.join("lib.rs");
12206 fs::write(&source, "pub fn marker() {}\n").unwrap();
12207 let project_key = crate::search_index::artifact_cache_key(&root);
12208 let callgraph_dir = temp.path().join("callgraph").join(&project_key);
12209 crate::root_cache::configure_artifact_access(&root, &project_key, false);
12210 let cooldown_key = rebuild_cooldown_key(&callgraph_dir, &project_key);
12211 rebuild_cooldown_records()
12212 .lock()
12213 .unwrap_or_else(std::sync::PoisonError::into_inner)
12214 .remove(&cooldown_key);
12215 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
12216 let stale_epoch = epoch.current();
12217 epoch.next();
12218
12219 let failed = with_publish_epoch(epoch, stale_epoch, || {
12220 CallGraphStore::cold_build_with_lease(
12221 callgraph_dir.clone(),
12222 root.clone(),
12223 std::slice::from_ref(&source),
12224 )
12225 });
12226 assert!(matches!(failed, Err(CallGraphStoreError::Superseded)));
12227 assert!(
12228 rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
12229 .is_none()
12230 );
12231
12232 let (store, _) = CallGraphStore::cold_build_with_lease(
12233 callgraph_dir.clone(),
12234 root.clone(),
12235 std::slice::from_ref(&source),
12236 )
12237 .unwrap();
12238 drop(store);
12239 assert!(
12240 rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
12241 .is_none()
12242 );
12243
12244 record_successful_rebuild(&callgraph_dir, &project_key, &other_root, Instant::now());
12245 assert!(
12246 rebuild_cooldown_denial(&callgraph_dir, &project_key, &root, Instant::now(),).is_some()
12247 );
12248 }
12249
12250 #[test]
12251 fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
12252 let _guard = REFRESH_WORKER_TEST_LOCK
12253 .lock()
12254 .unwrap_or_else(std::sync::PoisonError::into_inner);
12255 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12256 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12257 let pending = pending_paths();
12258 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12259
12260 let lifecycle = SubcLifecycleAdmission::default();
12261 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
12262 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12263 let ticket = CallgraphRefreshTicket::new(
12264 lifecycle,
12265 Arc::clone(&generation),
12266 7,
12267 publish_epoch.clone(),
12268 publish_epoch.current(),
12269 );
12270 generation.store(8, std::sync::atomic::Ordering::SeqCst);
12272 let installed = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
12273 .unwrap()
12274 .expect("ready store snapshot");
12275 let refresh_state = CallgraphRefreshState::new(
12276 Arc::new(std::sync::RwLock::new(Some(Arc::new(installed)))),
12277 Arc::new(AtomicBool::new(true)),
12278 );
12279
12280 enqueue_callgraph_store_refresh_fenced_with_state(
12281 callgraph_dir,
12282 root.clone(),
12283 vec![source.clone()],
12284 Arc::clone(&pending),
12285 refresh_state,
12286 ticket,
12287 );
12288 assert!(flush_callgraph_store_refreshes_with_budget(
12289 Duration::from_secs(5)
12290 ));
12291 assert_eq!(
12292 callgraph_refresh_worker_test_counts(&root).0,
12293 0,
12294 "superseded batch must not reach refresh_files or self-replay"
12295 );
12296 assert!(
12297 pending.lock().contains(&source),
12298 "superseded batch must defer its paths to the pending sink"
12299 );
12300 clear_callgraph_refresh_worker_test_seam(&root);
12301 }
12302
12303 #[test]
12304 fn superseded_open_failure_defers_without_self_replay() {
12305 let _guard = REFRESH_WORKER_TEST_LOCK
12306 .lock()
12307 .unwrap_or_else(std::sync::PoisonError::into_inner);
12308 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12309 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12310 let pending = pending_paths();
12311 let installed = Arc::new(
12312 CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
12313 .unwrap()
12314 .expect("ready store snapshot"),
12315 );
12316 let refresh_state = CallgraphRefreshState::new(
12317 Arc::new(std::sync::RwLock::new(Some(Arc::clone(&installed)))),
12318 Arc::new(AtomicBool::new(true)),
12319 );
12320 assert!(!installed.is_legacy_fallback());
12321 assert!(installed.is_current());
12322 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
12323 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12324 set_callgraph_refresh_worker_test_open_failure(root.clone(), true);
12325 let (held_rx, release_tx) = install_callgraph_refresh_worker_test_gate(root.clone());
12326
12327 let lifecycle = SubcLifecycleAdmission::default();
12328 let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
12329 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12330 let ticket = CallgraphRefreshTicket::new(
12331 lifecycle,
12332 Arc::clone(&generation),
12333 7,
12334 publish_epoch.clone(),
12335 publish_epoch.current(),
12336 );
12337 enqueue_callgraph_store_refresh_fenced_with_state(
12338 callgraph_dir,
12339 root.clone(),
12340 vec![source.clone()],
12341 Arc::clone(&pending),
12342 refresh_state,
12343 ticket,
12344 );
12345 held_rx
12346 .recv_timeout(Duration::from_secs(12))
12347 .expect("refresh worker must hold after injected open failure");
12348
12349 generation.store(8, std::sync::atomic::Ordering::SeqCst);
12352 set_callgraph_refresh_worker_test_open_failure(root.clone(), false);
12353 release_tx
12354 .send(())
12355 .expect("release superseded refresh worker");
12356 wait_for_refresh_worker_idle();
12357
12358 assert_eq!(
12359 callgraph_refresh_worker_test_counts(&root).0,
12360 1,
12361 "superseded open-failure batch must not self-replay"
12362 );
12363 assert_eq!(
12364 callgraph_refresh_worker_test_worker_calls(&root),
12365 1,
12366 "superseded open-failure batch must not create another worker call"
12367 );
12368 assert!(
12369 pending.lock().contains(&source),
12370 "superseded open-failure paths must remain in the pending sink"
12371 );
12372 let tree = installed
12373 .call_tree(Path::new("main.rs"), "entry", 1)
12374 .unwrap();
12375 assert_eq!(
12376 tree.children[0].name, "old_leaf",
12377 "superseded open-failure batch must not converge the store"
12378 );
12379 clear_callgraph_refresh_worker_test_seam(&root);
12380 }
12381
12382 #[test]
12383 fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
12384 let _guard = REFRESH_WORKER_TEST_LOCK
12385 .lock()
12386 .unwrap_or_else(std::sync::PoisonError::into_inner);
12387 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12388 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12389 let pending = pending_paths();
12390 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12391
12392 let lifecycle = SubcLifecycleAdmission::default();
12393 let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
12394 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12395 let expected_epoch = publish_epoch.current();
12396 let ticket = CallgraphRefreshTicket::new(
12397 lifecycle,
12398 generation,
12399 3,
12400 publish_epoch.clone(),
12401 expected_epoch,
12402 );
12403 publish_epoch.next();
12405
12406 enqueue_callgraph_store_refresh_fenced(
12407 callgraph_dir,
12408 root.clone(),
12409 vec![source.clone()],
12410 Arc::clone(&pending),
12411 ticket,
12412 );
12413 assert!(flush_callgraph_store_refreshes_with_budget(
12414 Duration::from_secs(5)
12415 ));
12416 assert_eq!(
12417 callgraph_refresh_worker_test_counts(&root).0,
12418 0,
12419 "epoch-superseded batch must not reach refresh_files"
12420 );
12421 assert!(
12422 pending.lock().contains(&source),
12423 "epoch-superseded batch must defer its paths to the pending sink"
12424 );
12425 clear_callgraph_refresh_worker_test_seam(&root);
12426 }
12427
12428 #[test]
12429 fn fenced_refresh_with_current_ticket_commits_normally() {
12430 let _guard = REFRESH_WORKER_TEST_LOCK
12431 .lock()
12432 .unwrap_or_else(std::sync::PoisonError::into_inner);
12433 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12434 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12435 let pending = pending_paths();
12436 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12437
12438 fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
12439
12440 let lifecycle = SubcLifecycleAdmission::default();
12441 let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
12442 let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12443 let ticket = CallgraphRefreshTicket::new(
12444 lifecycle,
12445 generation,
12446 5,
12447 publish_epoch.clone(),
12448 publish_epoch.current(),
12449 );
12450
12451 enqueue_callgraph_store_refresh_fenced(
12452 callgraph_dir.clone(),
12453 root.clone(),
12454 vec![source.clone()],
12455 Arc::clone(&pending),
12456 ticket,
12457 );
12458 assert!(flush_callgraph_store_refreshes_with_budget(
12459 Duration::from_secs(5)
12460 ));
12461 assert_eq!(
12462 callgraph_refresh_worker_test_counts(&root).0,
12463 1,
12464 "current ticket must run the refresh"
12465 );
12466 assert!(
12467 pending.lock().is_empty(),
12468 "committed batch must not defer paths"
12469 );
12470
12471 let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
12472 .unwrap()
12473 .expect("published generation must remain readable");
12474 let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
12475 assert_eq!(
12476 tree.children[0].name, "new_leaf",
12477 "fenced commit must actually persist the refreshed content"
12478 );
12479 clear_callgraph_refresh_worker_test_seam(&root);
12480 }
12481
12482 #[test]
12483 fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
12484 let _guard = REFRESH_WORKER_TEST_LOCK
12485 .lock()
12486 .unwrap_or_else(std::sync::PoisonError::into_inner);
12487 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12492 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12493 let pending = pending_paths();
12494 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
12495
12496 enqueue_callgraph_store_refresh(
12497 callgraph_dir.clone(),
12498 root.clone(),
12499 vec![source.clone()],
12500 Arc::clone(&pending),
12501 );
12502 wait_for_refresh_calls(&root, 1);
12503 for _ in 0..3 {
12504 enqueue_callgraph_store_refresh(
12505 callgraph_dir.clone(),
12506 root.clone(),
12507 vec![source.clone()],
12508 Arc::clone(&pending),
12509 );
12510 }
12511
12512 assert!(flush_callgraph_store_refreshes_with_budget(
12513 Duration::from_secs(2)
12514 ));
12515 assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
12516 assert!(pending.lock().is_empty());
12517 clear_callgraph_refresh_worker_test_seam(&root);
12518 }
12519
12520 #[test]
12521 fn queued_refresh_opens_generation_published_after_enqueue() {
12522 let _guard = REFRESH_WORKER_TEST_LOCK
12523 .lock()
12524 .unwrap_or_else(std::sync::PoisonError::into_inner);
12525 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12530 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
12531 let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
12532 set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
12533 let (active_held_rx, active_release_tx) =
12534 install_callgraph_refresh_worker_test_gate(active_root.clone());
12535 set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
12536 enqueue_callgraph_store_refresh(
12537 active_dir,
12538 active_root.clone(),
12539 vec![active_source],
12540 pending_paths(),
12541 );
12542 active_held_rx
12543 .recv_timeout(Duration::from_secs(12))
12544 .expect("active refresh worker holds the queue");
12545
12546 fs::write(
12547 &target_source,
12548 "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
12549 )
12550 .unwrap();
12551 enqueue_callgraph_store_refresh(
12552 target_dir.clone(),
12553 target_root.clone(),
12554 vec![target_source.clone()],
12555 pending_paths(),
12556 );
12557 let (new_generation, _) = CallGraphStore::cold_build_with_lease(
12558 target_dir.clone(),
12559 target_root.clone(),
12560 std::slice::from_ref(&target_source),
12561 )
12562 .unwrap();
12563 fs::write(
12564 &target_source,
12565 "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
12566 )
12567 .unwrap();
12568 drop(new_generation);
12569
12570 active_release_tx
12571 .send(())
12572 .expect("release active refresh worker");
12573 wait_for_refresh_calls(&target_root, 1);
12574 assert!(flush_callgraph_store_refreshes_with_budget(
12575 Duration::from_secs(12)
12576 ));
12577 let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
12578 .unwrap()
12579 .expect("current callgraph generation");
12580 let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
12581 assert_eq!(tree.children[0].name, "worker_leaf");
12582 assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
12583 clear_callgraph_refresh_worker_test_seam(&active_root);
12584 clear_callgraph_refresh_worker_test_seam(&target_root);
12585 }
12586
12587 #[test]
12588 fn refresh_failure_marks_files_stale() {
12589 let _guard = REFRESH_WORKER_TEST_LOCK
12590 .lock()
12591 .unwrap_or_else(std::sync::PoisonError::into_inner);
12592 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12597 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12598 let pending = pending_paths();
12599 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
12600
12601 enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
12602 assert!(flush_callgraph_store_refreshes_with_budget(
12603 Duration::from_secs(2)
12604 ));
12605
12606 assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
12607 let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
12608 .unwrap()
12609 .expect("ready callgraph store");
12610 assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
12611 clear_callgraph_refresh_worker_test_seam(&root);
12612 }
12613
12614 #[test]
12615 fn idle_refresh_truncates_wal() {
12616 let _guard = REFRESH_WORKER_TEST_LOCK
12617 .lock()
12618 .unwrap_or_else(std::sync::PoisonError::into_inner);
12619 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12620 let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12621 let generation = read_pointer(
12622 &callgraph_dir,
12623 &crate::search_index::artifact_cache_key(&root),
12624 )
12625 .expect("fixture publishes a generation");
12626 let wal_path = callgraph_dir.join(format!("{generation}-wal"));
12627 let pending = pending_paths();
12628 set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12629
12630 fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n\n").unwrap();
12631 enqueue_callgraph_store_refresh(
12632 callgraph_dir.clone(),
12633 root.clone(),
12634 vec![source.clone()],
12635 Arc::clone(&pending),
12636 );
12637 wait_for_refresh_calls(&root, 1);
12638 wait_for_refresh_worker_idle();
12639 let checkpoint_deadline = Instant::now() + Duration::from_secs(2);
12640 while fs::metadata(&wal_path)
12641 .map(|metadata| metadata.len())
12642 .unwrap_or(0)
12643 != 0
12644 {
12645 assert!(
12646 Instant::now() < checkpoint_deadline,
12647 "idle checkpoint did not truncate WAL"
12648 );
12649 std::thread::sleep(Duration::from_millis(5));
12650 }
12651 assert_eq!(
12652 fs::metadata(&wal_path)
12653 .map(|metadata| metadata.len())
12654 .unwrap_or(0),
12655 0,
12656 "idle transition truncates the refresh WAL"
12657 );
12658
12659 clear_callgraph_refresh_worker_test_seam(&root);
12660 }
12661
12662 #[test]
12663 fn bounded_shutdown_defers_unprocessed_batches() {
12664 let _guard = REFRESH_WORKER_TEST_LOCK
12665 .lock()
12666 .unwrap_or_else(std::sync::PoisonError::into_inner);
12667 let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12672 let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
12673 let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
12674 let active_pending = pending_paths();
12675 let queued_pending = pending_paths();
12676 set_callgraph_refresh_worker_test_seam(
12677 active_root.clone(),
12678 Duration::from_millis(300),
12679 false,
12680 );
12681
12682 enqueue_callgraph_store_refresh(
12683 active_dir,
12684 active_root.clone(),
12685 vec![active_source.clone()],
12686 Arc::clone(&active_pending),
12687 );
12688 wait_for_refresh_calls(&active_root, 1);
12689 enqueue_callgraph_store_refresh(
12690 queued_dir,
12691 queued_root.clone(),
12692 vec![queued_source.clone()],
12693 Arc::clone(&queued_pending),
12694 );
12695
12696 assert!(!flush_callgraph_store_refreshes_with_budget(
12697 Duration::from_millis(20)
12698 ));
12699 assert!(active_pending.lock().contains(&active_source));
12700 assert!(queued_pending.lock().contains(&queued_source));
12701 assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
12702 clear_callgraph_refresh_worker_test_seam(&active_root);
12703 }
12704}
12705
12706#[cfg(test)]
12707mod cold_build_insert_tests {
12708 use super::*;
12709 use crate::imports::ImportBlock;
12710 use std::cell::Cell;
12711 use std::fs;
12712 use std::path::{Path, PathBuf};
12713 use tempfile::tempdir;
12714
12715 thread_local! {
12716 static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
12717 static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
12718 static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
12719 }
12720
12721 fn count_caller_traversal_selects(sql: &str) {
12722 let sql = sql.trim_start();
12723 if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
12724 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
12725 }
12726 if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
12727 && sql.contains("e.target_file =")
12728 {
12729 CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
12730 }
12731 if sql.starts_with("WITH requested") {
12732 BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
12733 }
12734 }
12735
12736 #[test]
12737 fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
12738 let dir = tempdir().unwrap();
12739 let previous_root = dir.path().join("previous-root");
12740 let current_root = dir.path().join("current-root");
12741 fs::create_dir_all(&previous_root).unwrap();
12742 fs::create_dir_all(¤t_root).unwrap();
12743 fs::remove_dir(&previous_root).unwrap();
12744 let mut conn = Connection::open_in_memory().unwrap();
12745 initialize_schema(&conn).unwrap();
12746 conn.execute(
12747 "INSERT INTO backend_file_state(
12748 backend, workspace_root, file_path, content_hash, status, updated_at
12749 ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
12750 params![previous_root.display().to_string()],
12751 )
12752 .unwrap();
12753
12754 let repair = reconcile_workspace_roots(&mut conn, ¤t_root, false).unwrap();
12755
12756 assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
12757 assert_eq!(
12758 stored_workspace_roots(&conn).unwrap(),
12759 vec![previous_root.display().to_string()]
12760 );
12761 }
12762
12763 #[test]
12764 fn sqlite_readonly_uri_percent_encodes_windows_paths() {
12765 assert_eq!(
12766 sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
12767 "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
12768 );
12769 }
12770
12771 #[test]
12772 fn legacy_migration_completion_log_has_operator_fields() {
12773 assert_eq!(
12774 legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
12775 "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
12776 );
12777 }
12778
12779 fn write_generation_with_age(
12780 dir: &Path,
12781 project_key: &str,
12782 ordinal: u64,
12783 age: Duration,
12784 ) -> String {
12785 let generation = format!("{project_key}.g{ordinal}.1.sqlite");
12786 let path = dir.join(&generation);
12787 fs::write(&path, b"sqlite placeholder").unwrap();
12788 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
12789 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
12790 generation
12791 }
12792
12793 #[test]
12794 fn gc_old_generations_preserves_live_reader_until_marker_drops() {
12795 let dir = tempfile::tempdir().unwrap();
12796 let project_key = "project";
12797 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
12798 let previous =
12799 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
12800 let pinned =
12801 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
12802 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
12803
12804 gc_old_generations(dir.path(), project_key, ¤t);
12805
12806 assert!(dir.path().join(&previous).is_file());
12807 assert!(dir.path().join(&pinned).is_file());
12808
12809 drop(marker);
12810 gc_old_generations(dir.path(), project_key, ¤t);
12811
12812 assert!(dir.path().join(&previous).is_file());
12813 assert!(!dir.path().join(&pinned).exists());
12814 }
12815
12816 #[test]
12817 fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
12818 let dir = tempfile::tempdir().unwrap();
12819 let project_key = "project";
12820 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
12821 let _previous =
12822 write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
12823 let pinned =
12824 write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
12825 let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
12826 filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
12827
12828 gc_old_generations(dir.path(), project_key, ¤t);
12829
12830 assert!(dir.path().join(&pinned).is_file());
12831 }
12832
12833 #[test]
12834 fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
12835 let dir = tempfile::tempdir().unwrap();
12836 let project_key = "project";
12837 let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
12838 let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
12839 let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
12840 let old = write_generation_with_age(
12841 dir.path(),
12842 project_key,
12843 200,
12844 expired + Duration::from_secs(60),
12845 );
12846 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
12847
12848 gc_old_generations(dir.path(), project_key, ¤t);
12849
12850 assert!(dir.path().join(¤t).is_file());
12851 assert!(dir.path().join(&previous).is_file());
12852 assert!(!dir.path().join(&old).exists());
12853 }
12854
12855 fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
12856 let path = dir.join(name);
12857 fs::write(&path, b"temp placeholder").unwrap();
12858 let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
12859 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
12860 path
12861 }
12862
12863 #[test]
12864 fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
12865 let dir = tempdir().unwrap();
12866 let aged = "project.g100.1.sqlite.tmp.1.200";
12870 let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
12871 let fresh = "project.g300.1.sqlite.tmp.1.400";
12872 let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
12873 write_build_temp_with_age(dir.path(), aged, aged_age);
12874 write_build_temp_with_age(dir.path(), aged_journal, aged_age);
12875 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
12876
12877 sweep_orphaned_build_temps(dir.path());
12878
12879 assert!(
12880 !dir.path().join(aged).exists(),
12881 "aged orphan must be removed"
12882 );
12883 assert!(
12884 !dir.path().join(aged_journal).exists(),
12885 "aged journal sidecar must be removed"
12886 );
12887 assert!(
12888 dir.path().join(fresh).is_file(),
12889 "fresh temporary must survive"
12890 );
12891 }
12892
12893 #[test]
12894 fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
12895 let storage = tempdir().unwrap();
12896 let storage_root = storage.path();
12897 let legacy_dir = storage_root.join("opencode").join("callgraph");
12903 fs::create_dir_all(&legacy_dir).unwrap();
12904 let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
12905 write_build_temp_with_age(
12906 &legacy_dir,
12907 orphan,
12908 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
12909 );
12910 assert!(
12911 !legacy_dir.join("deadbeef.current").exists(),
12912 "the dead root has no current pointer"
12913 );
12914
12915 let root_keyed_dir = storage_root.join("callgraph").join("livekey");
12916 fs::create_dir_all(&root_keyed_dir).unwrap();
12917
12918 sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
12919
12920 assert!(
12921 !legacy_dir.join(orphan).exists(),
12922 "legacy orphan must be reclaimed by the store-wide sweep"
12923 );
12924 }
12925
12926 #[test]
12927 fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
12928 let dir = tempdir().unwrap();
12934 let fresh = "project.g300.1.sqlite.tmp.1.400";
12935 write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
12936
12937 sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
12938
12939 assert!(
12940 !dir.path().join(fresh).exists(),
12941 "with the age predicate forced open, the fresh temporary is removed"
12942 );
12943 }
12944
12945 #[test]
12946 fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
12947 let dir = tempdir().unwrap();
12948 let generation = write_generation_with_age(
12952 dir.path(),
12953 "project",
12954 400,
12955 ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
12956 );
12957 let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
12958
12959 sweep_orphaned_build_temps(dir.path());
12960
12961 assert!(
12962 dir.path().join(&generation).is_file(),
12963 "completed generation must survive the orphan sweep"
12964 );
12965 assert!(
12966 crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
12967 "read marker must survive the orphan sweep"
12968 );
12969 }
12970
12971 #[test]
12972 fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
12973 let dir = tempfile::tempdir().unwrap();
12974 let project_key = "project".to_string();
12975 let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
12976 let sqlite_path = dir.path().join(&generation);
12977 fs::remove_file(&sqlite_path).unwrap();
12978 let conn = Connection::open(&sqlite_path).unwrap();
12979 let store = CallGraphStore::from_connection(
12980 dir.path().to_path_buf(),
12981 project_key,
12982 sqlite_path,
12983 dir.path().to_path_buf(),
12984 false,
12985 Some(generation.clone()),
12986 None,
12987 None,
12988 conn,
12989 );
12990
12991 let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
12992 assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
12993
12994 drop(marker);
12995 assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
12996 }
12997
12998 #[test]
12999 fn readiness_cache_only_skips_checks_after_a_successful_validation() {
13000 let dir = tempdir().expect("temp dir");
13001 let file = dir.path().join("main.ts");
13002 fs::write(&file, "export function main() {}\n").expect("write fixture");
13003 let store = CallGraphStore::open(
13004 dir.path().join(".store-readiness-cache"),
13005 dir.path().to_path_buf(),
13006 )
13007 .expect("open store");
13008 {
13009 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13010 conn.trace(Some(count_caller_traversal_selects));
13011 }
13012
13013 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13014 assert!(store.indexed_file_count().is_err());
13015 assert!(store.indexed_file_count().is_err());
13016 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
13017
13018 store
13019 .cold_build(std::slice::from_ref(&file))
13020 .expect("cold build");
13021 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13022 assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
13023 assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
13024 assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
13025
13026 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13027 conn.trace(None);
13028 }
13029
13030 #[test]
13031 fn callers_depth_boundary_batches_sqlite_counts() {
13032 const CALLER_COUNT: usize = 1_000;
13033
13034 let dir = tempdir().expect("temp dir");
13035 let file = dir.path().join("main.ts");
13036 let mut source = String::from("export function sharedHotHelper() {}\n");
13037 for index in 0..CALLER_COUNT {
13038 source.push_str(&format!(
13039 "export function caller{index}() {{ sharedHotHelper(); }}\n"
13040 ));
13041 }
13042 fs::write(&file, source).expect("write fixture");
13043
13044 let store = CallGraphStore::open(
13045 dir.path().join(".store-callers-query-fanout"),
13046 dir.path().to_path_buf(),
13047 )
13048 .expect("open store");
13049 store
13050 .cold_build(std::slice::from_ref(&file))
13051 .expect("cold build");
13052
13053 CALLER_QUERY_SELECTS.with(|count| count.set(0));
13054 BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
13055 TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13056 {
13057 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13058 conn.trace(Some(count_caller_traversal_selects));
13059 }
13060
13061 let started = Instant::now();
13062 let result = crate::commands::callgraph_store_adapter::callers_result(
13063 &store,
13064 Path::new("main.ts"),
13065 "sharedHotHelper",
13066 1,
13067 true,
13068 )
13069 .expect("callers result");
13070 let elapsed = started.elapsed();
13071
13072 {
13073 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13074 conn.trace(None);
13075 }
13076 let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
13077 let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
13078 let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
13079 eprintln!(
13080 "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
13081 result.total_callers,
13082 caller_queries,
13083 boundary_queries,
13084 total_selects,
13085 elapsed.as_secs_f64() * 1_000.0
13086 );
13087
13088 assert_eq!(result.total_callers, CALLER_COUNT);
13089 assert_eq!(caller_queries, 1);
13090 assert_eq!(boundary_queries, 3);
13091 assert_eq!(total_selects, 9);
13092 }
13093
13094 #[test]
13095 fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
13096 let dir = tempdir().expect("temp dir");
13097 let file = dir.path().join("main.ts");
13098 fs::write(
13099 &file,
13100 r#"export function topA() {
13101 root();
13102}
13103
13104export function topB() {
13105 root();
13106}
13107
13108export function root() {
13109 leaf();
13110 missing();
13111}
13112
13113export function leaf() {}
13114"#,
13115 )
13116 .expect("write fixture");
13117
13118 let store = CallGraphStore::open(
13119 dir.path().join(".store-depth-boundary-counts"),
13120 dir.path().to_path_buf(),
13121 )
13122 .expect("open store");
13123 store
13124 .cold_build(std::slice::from_ref(&file))
13125 .expect("cold build");
13126
13127 let root = store
13128 .node_for(Path::new("main.ts"), "root")
13129 .expect("root node");
13130 let leaf = store
13131 .node_for(Path::new("main.ts"), "leaf")
13132 .expect("leaf node");
13133
13134 let (full_forward_len, full_direct_len) = {
13135 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
13136 conn.execute(
13137 "INSERT INTO edges (
13138 edge_id, ref_id, source_node, target_node, target_file,
13139 target_symbol, kind, line, provenance
13140 ) VALUES (
13141 'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
13142 ?2, ?3, 'call', 98, ?4
13143 )",
13144 rusqlite::params![
13145 &root.node_id,
13146 &leaf.file,
13147 &leaf.symbol,
13148 PROVENANCE_TREESITTER
13149 ],
13150 )
13151 .expect("insert dangling forward edge");
13152 conn.execute(
13153 "INSERT INTO edges (
13154 edge_id, ref_id, source_node, target_node, target_file,
13155 target_symbol, kind, line, provenance
13156 ) VALUES (
13157 'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
13158 ?1, ?2, ?3, 'call', 99, ?4
13159 )",
13160 rusqlite::params![
13161 &root.node_id,
13162 &root.file,
13163 &root.symbol,
13164 PROVENANCE_TREESITTER
13165 ],
13166 )
13167 .expect("insert dangling direct-caller edge");
13168
13169 let full_forward_len = forward_calls_for_node(&conn, &root)
13170 .expect("full forward calls")
13171 .len();
13172 let counted_forward_len =
13173 forward_call_count_for_node(&conn, &root).expect("counted forward calls");
13174 assert_eq!(
13175 counted_forward_len, full_forward_len,
13176 "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
13177 );
13178
13179 let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
13180 .expect("full direct callers");
13181 let full_direct_len = full_direct.len();
13182 let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
13183 .expect("counted direct callers");
13184 assert_eq!(
13185 counted_direct_len, full_direct_len,
13186 "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
13187 );
13188
13189 let distinct_direct_len = full_direct
13190 .iter()
13191 .map(|site| {
13192 (
13193 site.caller.file.clone(),
13194 site.line,
13195 site.target_file.clone(),
13196 site.target_symbol.clone(),
13197 )
13198 })
13199 .collect::<BTreeSet<_>>()
13200 .len();
13201 let batch_counts = direct_caller_counts_for_tuples(
13202 &conn,
13203 &[
13204 (root.file.clone(), root.symbol.clone()),
13205 (root.file.clone(), root.symbol.clone()),
13206 (leaf.file.clone(), leaf.symbol.clone()),
13207 ],
13208 )
13209 .expect("batched direct-caller counts");
13210 assert_eq!(batch_counts.len(), 2);
13211 assert_eq!(
13212 batch_counts.get(&(root.file.clone(), root.symbol.clone())),
13213 Some(&distinct_direct_len)
13214 );
13215
13216 (full_forward_len, full_direct_len)
13217 };
13218
13219 assert_eq!(
13220 full_forward_len, 2,
13221 "fixture root should have one resolved and one unresolved outgoing call"
13222 );
13223 assert_eq!(
13224 full_direct_len, 2,
13225 "fixture root should have two real direct callers"
13226 );
13227
13228 let tree = store
13229 .call_tree(Path::new("main.ts"), "root", 0)
13230 .expect("call tree");
13231 assert!(tree.depth_limited);
13232 assert_eq!(tree.children.len(), 0);
13233 assert_eq!(
13234 tree.truncated, full_forward_len,
13235 "call_tree depth boundary must report the full forward-call list length"
13236 );
13237
13238 let callers = store
13239 .callers_of(Path::new("main.ts"), "leaf", 0)
13240 .expect("callers");
13241 assert!(callers.depth_limited);
13242 assert_eq!(callers.callers.len(), 1);
13243 assert_eq!(callers.callers[0].caller.symbol, "root");
13244 assert_eq!(
13245 callers.truncated, full_direct_len,
13246 "callers depth boundary must report the full direct-caller list length"
13247 );
13248 }
13249
13250 #[test]
13251 fn source_freshness_matches_cache_collect_for_same_bytes() {
13252 let dir = tempdir().expect("temp dir");
13253 let path = dir.path().join("fixture.ts");
13254 let source = "export function main() { return helper(); }\n";
13255 fs::write(&path, source).expect("write fixture");
13256
13257 let expected = cache_freshness::collect(&path).expect("collect freshness from file");
13258 let actual =
13259 collect_source_freshness(&path, source).expect("collect freshness from source");
13260
13261 assert_eq!(actual, expected);
13262 }
13263
13264 #[test]
13265 fn superseded_cold_build_cannot_publish_after_newer_epoch() {
13266 let root = tempfile::tempdir().unwrap();
13267 let callgraph_dir = tempfile::tempdir().unwrap();
13268 let source_dir = root.path().join("src");
13269 std::fs::create_dir_all(&source_dir).unwrap();
13270 let source = source_dir.join("lib.rs");
13271 std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
13272 let files = vec![source.clone()];
13273 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
13274 let old_epoch = epoch.next();
13275 let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
13276 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
13277 let old_epoch_flag = epoch.clone();
13278 let old_dir = callgraph_dir.path().to_path_buf();
13279 let old_root = root.path().to_path_buf();
13280 let old_files = files.clone();
13281 let old = std::thread::spawn(move || {
13282 set_cold_build_before_publish_observer(Some(Arc::new(move || {
13283 reached_tx.send(()).unwrap();
13284 release_rx.recv().unwrap();
13285 })));
13286 let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
13287 CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
13288 });
13289 set_cold_build_before_publish_observer(None);
13290 result
13291 });
13292 reached_rx
13296 .recv_timeout(Duration::from_secs(30))
13297 .expect("older build did not reach its publication barrier");
13298
13299 std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
13300 let new_epoch = epoch.next();
13301 let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
13302 CallGraphStore::cold_build_with_lease(
13303 callgraph_dir.path().to_path_buf(),
13304 root.path().to_path_buf(),
13305 &files,
13306 )
13307 })
13308 .expect("newer build should publish");
13309 drop(new_store);
13310
13311 release_tx.send(()).unwrap();
13312 assert!(matches!(
13313 old.join().unwrap(),
13314 Err(CallGraphStoreError::Superseded)
13315 ));
13316
13317 let current = CallGraphStore::open_readonly(
13318 callgraph_dir.path().to_path_buf(),
13319 root.path().to_path_buf(),
13320 )
13321 .unwrap()
13322 .expect("current callgraph generation");
13323 assert_eq!(
13324 current
13325 .nodes_matching("new_generation_marker")
13326 .unwrap()
13327 .len(),
13328 1
13329 );
13330 assert!(current
13331 .nodes_matching("old_generation_marker")
13332 .unwrap()
13333 .is_empty());
13334 }
13335
13336 #[test]
13337 fn cold_build_prepared_bulk_insert_matches_reference_rows() {
13338 let dir = tempdir().expect("temp dir");
13339 let project_root = dir.path();
13340 let extract = fixture_extract(project_root);
13341 let resolved = fixture_resolved(&extract);
13342
13343 let reference = build_reference_connection(project_root, &extract, &resolved);
13344 let optimized = build_optimized_connection(project_root, &extract, &resolved);
13345
13346 for table in [
13347 "files",
13348 "nodes",
13349 "file_dependencies",
13350 "dispatch_hints",
13351 "refs",
13352 "edges",
13353 ] {
13354 let excluded: &[&str] = if table == "files" {
13361 &["indexed_at"]
13362 } else {
13363 &[]
13364 };
13365 assert_eq!(
13366 table_rows_without(&reference, table, excluded),
13367 table_rows_without(&optimized, table, excluded),
13368 "table `{table}` rows must match apart from wall-clock columns"
13369 );
13370 }
13371 assert_eq!(
13372 backend_state_rows(&reference),
13373 backend_state_rows(&optimized),
13374 "backend freshness rows must match apart from updated_at"
13375 );
13376 assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
13377 }
13378
13379 #[test]
13380 fn cold_build_chunked_matches_unchunked_logical_rows() {
13381 let dir = tempdir().expect("temp dir");
13382 let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
13383 write_chunked_equivalence_fixture(&project_root);
13384 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
13385 assert!(
13386 files.len() > 6,
13387 "fixture should be large enough to split into multiple chunks"
13388 );
13389
13390 let unchunked = CallGraphStore::open(
13391 project_root.join(".store-unchunked"),
13392 project_root.to_path_buf(),
13393 )
13394 .expect("open unchunked store");
13395 let unchunked_stats = unchunked
13396 .cold_build_chunked(&files, 0)
13397 .expect("unchunked cold build");
13398
13399 let chunked = CallGraphStore::open(
13400 project_root.join(".store-chunked"),
13401 project_root.to_path_buf(),
13402 )
13403 .expect("open chunked store");
13404 let chunked_stats = chunked
13405 .cold_build_chunked(&files, 3)
13406 .expect("chunked cold build");
13407
13408 assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
13409 assert_eq!(
13410 unchunked.edge_snapshot().expect("unchunked edge snapshot"),
13411 chunked.edge_snapshot().expect("chunked edge snapshot"),
13412 "public edge snapshots must match"
13413 );
13414
13415 let dispatch_edges = {
13416 let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
13417 conn.query_row(
13418 "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
13419 [],
13420 |row| row.get::<_, i64>(0),
13421 )
13422 .expect("count dispatch edges")
13423 };
13424 assert!(
13425 dispatch_edges > 0,
13426 "fixture must exercise method-dispatch edge insertion"
13427 );
13428
13429 for table in [
13430 "edges",
13431 "refs",
13432 "nodes",
13433 "file_dependencies",
13434 "dispatch_hints",
13435 ] {
13436 assert_eq!(
13437 graph_table_rows(&unchunked, table),
13438 graph_table_rows(&chunked, table),
13439 "chunked cold build must match unchunked rows for {table}"
13440 );
13441 }
13442 assert_eq!(
13443 graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
13444 graph_table_rows_without(&chunked, "files", &["indexed_at"]),
13445 "files rows must match apart from indexed_at"
13446 );
13447 assert_eq!(
13448 graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
13449 graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
13450 "backend freshness rows must match apart from updated_at"
13451 );
13452
13453 let published_dir = project_root.join(".store-published");
13454 let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
13455 published_dir.clone(),
13456 project_root.to_path_buf(),
13457 &files,
13458 0,
13459 )
13460 .expect("published unchunked cold build");
13461 assert!(
13462 !CallGraphStore::needs_cold_build(&published_dir, &project_root)
13463 .expect("needs_cold_build after publish"),
13464 "published store should be ready"
13465 );
13466 drop(_published);
13467 let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
13468 published_dir,
13469 project_root.to_path_buf(),
13470 &files,
13471 3,
13472 )
13473 .expect("ensure with a different chunk size");
13474 assert!(
13475 rebuild_stats.is_none(),
13476 "changing callgraph_chunk_size must not affect store identity or force a rebuild"
13477 );
13478 }
13479
13480 #[test]
13487 #[ignore]
13488 fn bench_cold_build_chunk() {
13489 let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
13490 let chunk: usize = std::env::var("AFT_PERF_CHUNK")
13491 .expect("AFT_PERF_CHUNK")
13492 .parse()
13493 .expect("AFT_PERF_CHUNK must be a non-negative integer");
13494 let project_root = fs::canonicalize(&repo).expect("canonical repo root");
13495 let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
13496 let dir = tempdir().expect("temp dir");
13497 let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
13498 .expect("open store");
13499 let started = Instant::now();
13500 let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
13501 let ms = started.elapsed().as_millis();
13502 println!(
13503 "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
13504 stats.files, stats.nodes, stats.refs, stats.edges
13505 );
13506 }
13507
13508 #[test]
13509 fn persisted_workspace_reexport_selects_its_package_dependency() {
13510 let root = tempdir().expect("temp dir");
13511 let dependencies = BTreeSet::from([
13512 "packages/aft-bridge/src/index.ts".to_string(),
13513 "packages/opencode-plugin/src/types.ts".to_string(),
13514 ]);
13515 let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
13516
13517 assert_eq!(
13518 stored_dependencies_for_module(
13519 root.path(),
13520 "packages/opencode-plugin/src/shared/bash-hints.ts",
13521 "@cortexkit/aft-bridge",
13522 &dependencies,
13523 &indexed_files,
13524 ),
13525 BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
13526 );
13527 }
13528
13529 #[test]
13530 fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
13531 let dir = tempdir().expect("temp dir");
13532 let project_root = dir.path();
13533 let files =
13534 write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
13535 let index_path = project_root.join("src/index.ts");
13536
13537 let store = CallGraphStore::open(
13538 project_root.join(".store-incremental-barrel"),
13539 project_root.to_path_buf(),
13540 )
13541 .expect("open incremental store");
13542 store.cold_build(&files).expect("initial cold build");
13543
13544 {
13545 let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13546 let tx = conn.transaction().expect("dependency transaction");
13547 let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
13548 .expect("dependent refs for barrel");
13549 let selected_ref_ids = dependent_refs
13550 .iter()
13551 .map(|dependent_ref| dependent_ref.ref_id.clone())
13552 .collect::<BTreeSet<_>>();
13553 let mut threaded_ref_ids = BTreeSet::new();
13554 let mut threaded_by_caller = BTreeMap::new();
13555 record_dependent_refs(
13556 &mut threaded_ref_ids,
13557 &mut threaded_by_caller,
13558 dependent_refs,
13559 );
13560 let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
13561 .expect("old per-ref caller lookup");
13562
13563 assert_eq!(threaded_ref_ids, selected_ref_ids);
13564 assert_eq!(threaded_by_caller, old_by_caller);
13565 for consumer in [
13566 "src/consumer_a.ts",
13567 "src/consumer_b.ts",
13568 "src/consumer_c.ts",
13569 ] {
13570 assert!(
13571 threaded_by_caller.contains_key(consumer),
13572 "barrel edit should select dependent refs from {consumer}"
13573 );
13574 }
13575 }
13576
13577 fs::write(
13578 &index_path,
13579 "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
13580 )
13581 .expect("edit barrel");
13582 let stats = store
13583 .refresh_files(std::slice::from_ref(&index_path))
13584 .expect("incremental refresh");
13585 assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
13586 assert!(
13587 stats.dependency_selected_refs > 0,
13588 "barrel surface edit should select dependent refs"
13589 );
13590
13591 let cold_store = CallGraphStore::open(
13592 project_root.join(".store-cold-barrel"),
13593 project_root.to_path_buf(),
13594 )
13595 .expect("open cold rebuild store");
13596 cold_store
13597 .cold_build(&files)
13598 .expect("comparison cold build");
13599
13600 for table in [
13601 "nodes",
13602 "refs",
13603 "file_dependencies",
13604 "edges",
13605 "dispatch_hints",
13606 ] {
13607 assert_eq!(
13608 graph_table_rows(&store, table),
13609 graph_table_rows(&cold_store, table),
13610 "incremental refresh {table} rows must match cold rebuild"
13611 );
13612 }
13613
13614 let consumer_path = project_root.join("src/consumer_a.ts");
13615 fs::write(
13616 &consumer_path,
13617 "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
13618 )
13619 .expect("edit barrel consumer");
13620 store
13621 .refresh_files(std::slice::from_ref(&consumer_path))
13622 .expect("refresh consumer through unchanged barrel");
13623 cold_store
13624 .cold_build(&files)
13625 .expect("comparison cold rebuild after consumer refresh");
13626 for table in [
13627 "nodes",
13628 "refs",
13629 "file_dependencies",
13630 "edges",
13631 "dispatch_hints",
13632 ] {
13633 assert_eq!(
13634 graph_table_rows(&store, table),
13635 graph_table_rows(&cold_store, table),
13636 "refresh through a persisted barrel must preserve cold-build {table} rows"
13637 );
13638 }
13639 }
13640
13641 fn build_reference_connection(
13642 project_root: &Path,
13643 extract: &FileExtract,
13644 resolved: &ResolvedRef,
13645 ) -> Connection {
13646 let mut conn = Connection::open_in_memory().expect("open reference db");
13647 configure_build_connection(&conn).expect("configure reference db");
13648 initialize_schema(&conn).expect("initialize reference schema");
13649 {
13650 let tx = conn.transaction().expect("reference transaction");
13651 clear_tables(&tx).expect("reference clear");
13652 insert_meta(&tx).expect("reference meta");
13653 insert_file_extract(&tx, project_root, extract).expect("reference file extract");
13654 insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
13655 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
13656 .expect("reference dispatch edges");
13657 assert_eq!(supplemental, 0);
13658 tx.commit().expect("reference commit");
13659 }
13660 conn
13661 }
13662
13663 fn build_optimized_connection(
13664 project_root: &Path,
13665 extract: &FileExtract,
13666 resolved: &ResolvedRef,
13667 ) -> Connection {
13668 let mut conn = Connection::open_in_memory().expect("open optimized db");
13669 configure_build_connection(&conn).expect("configure optimized db");
13670 initialize_schema(&conn).expect("initialize optimized schema");
13671 {
13672 let tx = conn.transaction().expect("optimized transaction");
13673 clear_tables(&tx).expect("optimized clear");
13674 insert_meta(&tx).expect("optimized meta");
13675 drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
13676 {
13677 let workspace_root = project_root.display().to_string();
13678 let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
13679 insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
13680 .expect("optimized file extract");
13681 insert_resolved_ref_prepared(&mut inserts, resolved)
13682 .expect("optimized resolved ref");
13683 }
13684 create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
13685 let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
13686 .expect("optimized dispatch edges");
13687 assert_eq!(supplemental, 0);
13688 tx.commit().expect("optimized commit");
13689 }
13690 conn
13691 }
13692
13693 fn fixture_extract(_project_root: &Path) -> FileExtract {
13694 let rel_path = "src/main.ts".to_string();
13695 let target_path = "src/helper.ts".to_string();
13696 let node = NodeRecord {
13697 id: "node-main".to_string(),
13698 file_path: rel_path.clone(),
13699 name: "main".to_string(),
13700 scoped_name: "main".to_string(),
13701 kind: "function".to_string(),
13702 range: Range {
13703 start_line: 0,
13704 start_col: 0,
13705 end_line: 0,
13706 end_col: 32,
13707 },
13708 range_ordinal: 0,
13709 signature: Some("export function main()".to_string()),
13710 exported: true,
13711 is_default_export: false,
13712 is_type_like: false,
13713 is_callgraph_entry_point: true,
13714 };
13715 let mut dependencies = BTreeSet::new();
13716 dependencies.insert(target_path.clone());
13717 let raw_ref = RawRef {
13718 ref_id: "ref-main-helper".to_string(),
13719 caller_node: Some(node.id.clone()),
13720 caller_symbol: Some(node.scoped_name.clone()),
13721 caller_file: rel_path.clone(),
13722 kind: "call".to_string(),
13723 short_name: Some("helper".to_string()),
13724 full_ref: Some("helper".to_string()),
13725 module_path: None,
13726 import_kind: None,
13727 local_name: Some("helper".to_string()),
13728 requested_name: Some("helper".to_string()),
13729 namespace_alias: None,
13730 wildcard: false,
13731 line: 1,
13732 byte_start: 24,
13733 byte_end: 32,
13734 dependencies,
13735 };
13736 FileExtract {
13737 rel_path,
13738 freshness: FileFreshness {
13739 mtime: UNIX_EPOCH + Duration::from_secs(123),
13740 size: 40,
13741 content_hash: cache_freshness::hash_bytes(b"fixture source"),
13742 },
13743 lang: LangId::TypeScript,
13744 data: FileCallData {
13745 calls_by_symbol: HashMap::new(),
13746 exported_symbols: Vec::new(),
13747 symbol_metadata: HashMap::new(),
13748 default_export_symbol: None,
13749 import_block: ImportBlock::empty(),
13750 lang: LangId::TypeScript,
13751 },
13752 nodes: vec![node.clone()],
13753 raw_refs: vec![raw_ref],
13754 dispatch_hints: vec![DispatchHint {
13755 id: "dispatch-main-helper".to_string(),
13756 method_name: "helper".to_string(),
13757 caller_node: node.id,
13758 file: "src/main.ts".to_string(),
13759 line: 1,
13760 byte_start: 24,
13761 byte_end: 32,
13762 }],
13763 surface_fingerprint: "surface".to_string(),
13764 }
13765 }
13766
13767 fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
13768 let raw = extract.raw_refs[0].clone();
13769 let mut dependencies = raw.dependencies.clone();
13770 dependencies.insert("src/helper.ts".to_string());
13771 ResolvedRef {
13772 edge: Some(EdgeRecord {
13773 edge_id: "edge-main-helper".to_string(),
13774 source_node: raw.caller_node.clone().expect("caller node"),
13775 target_node: Some("node-helper".to_string()),
13776 target_file: "src/helper.ts".to_string(),
13777 target_symbol: "helper".to_string(),
13778 kind: "call".to_string(),
13779 line: raw.line,
13780 }),
13781 raw,
13782 status: "resolved".to_string(),
13783 target_node: Some("node-helper".to_string()),
13784 target_file: Some("src/helper.ts".to_string()),
13785 target_symbol: Some("helper".to_string()),
13786 dependencies,
13787 }
13788 }
13789
13790 fn write_chunked_equivalence_fixture(project_root: &Path) {
13791 let ts_dir = project_root.join("ts");
13792 fs::create_dir_all(&ts_dir).expect("create ts dir");
13793 fs::write(
13794 ts_dir.join("leaf.ts"),
13795 "export function leaf(value: number) {\n return value + 1;\n}\n",
13796 )
13797 .expect("write ts leaf");
13798 fs::write(
13799 ts_dir.join("mid.ts"),
13800 "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n return leaf(value);\n}\n",
13801 )
13802 .expect("write ts mid");
13803 fs::write(
13804 ts_dir.join("entry.ts"),
13805 "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n return mid(worker.run());\n}\n",
13806 )
13807 .expect("write ts entry");
13808 fs::write(
13809 ts_dir.join("worker.ts"),
13810 "export class Worker {\n run() {\n return 41;\n }\n}\n",
13811 )
13812 .expect("write ts worker");
13813 for idx in 0..4 {
13814 fs::write(
13815 ts_dir.join(format!("extra_{idx}.ts")),
13816 format!(
13817 "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n return entry(new Worker());\n}}\n"
13818 ),
13819 )
13820 .expect("write ts extra");
13821 }
13822
13823 let rust_dir = project_root.join("src");
13824 let commands_dir = rust_dir.join("commands");
13825 fs::create_dir_all(&commands_dir).expect("create rust commands dir");
13826 fs::write(
13827 rust_dir.join("context.rs"),
13828 r#"pub struct AppContext;
13829
13830impl AppContext {
13831 pub fn callgraph_store_for_ops(&self) -> usize {
13832 1
13833 }
13834}
13835"#,
13836 )
13837 .expect("write rust context");
13838 fs::write(
13839 rust_dir.join("lib.rs"),
13840 "pub mod context;\npub mod commands;\n",
13841 )
13842 .expect("write rust lib");
13843 fs::write(
13844 commands_dir.join("mod.rs"),
13845 "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
13846 )
13847 .expect("write rust commands mod");
13848 for name in ["callers", "impact", "trace_to"] {
13849 fs::write(
13850 commands_dir.join(format!("{name}.rs")),
13851 format!(
13852 r#"use crate::context::AppContext;
13853
13854pub fn handle_{name}(ctx: &AppContext) -> usize {{
13855 ctx.callgraph_store_for_ops()
13856}}
13857"#
13858 ),
13859 )
13860 .expect("write rust command");
13861 }
13862 }
13863
13864 fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
13865 let src_dir = project_root.join("src");
13866 fs::create_dir_all(&src_dir).expect("create src dir");
13867
13868 let target_path = src_dir.join("target.ts");
13869 fs::write(&target_path, "export function target() {\n return 1;\n}\n")
13870 .expect("write target");
13871
13872 let index_path = src_dir.join("index.ts");
13873 fs::write(&index_path, barrel_source).expect("write barrel");
13874
13875 let mut files = vec![target_path, index_path];
13876 for (file_name, function_name) in [
13877 ("consumer_a.ts", "consumerA"),
13878 ("consumer_b.ts", "consumerB"),
13879 ("consumer_c.ts", "consumerC"),
13880 ] {
13881 let path = src_dir.join(file_name);
13882 fs::write(
13883 &path,
13884 format!(
13885 "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n return target();\n}}\n"
13886 ),
13887 )
13888 .expect("write consumer");
13889 files.push(path);
13890 }
13891 files
13892 }
13893
13894 fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
13895 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
13896 table_rows(&conn, table)
13897 }
13898
13899 fn graph_table_rows_without(
13900 store: &CallGraphStore,
13901 table: &str,
13902 excluded_columns: &[&str],
13903 ) -> Vec<String> {
13904 let conn = store.conn.lock().expect("callgraph store mutex poisoned");
13905 table_rows_without(&conn, table, excluded_columns)
13906 }
13907
13908 fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
13909 table_rows_without(conn, table, &[])
13910 }
13911
13912 fn table_rows_without(
13913 conn: &Connection,
13914 table: &str,
13915 excluded_columns: &[&str],
13916 ) -> Vec<String> {
13917 let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
13918 let columns: Vec<String> = conn
13919 .prepare(&format!("PRAGMA table_info({table})"))
13920 .expect("prepare table_info")
13921 .query_map([], |row| row.get::<_, String>(1))
13922 .expect("query table_info")
13923 .collect::<std::result::Result<Vec<String>, _>>()
13924 .expect("collect columns")
13925 .into_iter()
13926 .filter(|column| !excluded_columns.contains(column.as_str()))
13927 .collect();
13928 let sql = format!(
13929 "SELECT {} FROM {table} ORDER BY {}",
13930 columns.join(", "),
13931 columns.join(", ")
13932 );
13933 conn.prepare(&sql)
13934 .expect("prepare table rows")
13935 .query_map([], |row| row_to_strings(row, columns.len()))
13936 .expect("query table rows")
13937 .collect::<std::result::Result<_, _>>()
13938 .expect("collect table rows")
13939 }
13940
13941 fn assert_cold_build_stats_match_except_elapsed(
13942 expected: &ColdBuildStats,
13943 actual: &ColdBuildStats,
13944 ) {
13945 assert_eq!(actual.files, expected.files, "file counts must match");
13946 assert_eq!(actual.nodes, expected.nodes, "node counts must match");
13947 assert_eq!(actual.refs, expected.refs, "ref counts must match");
13948 assert_eq!(actual.edges, expected.edges, "edge counts must match");
13949 assert_eq!(
13950 actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
13951 expected
13952 .failed_files
13953 .iter()
13954 .cloned()
13955 .collect::<BTreeSet<_>>(),
13956 "failed file sets must match"
13957 );
13958 }
13959
13960 fn backend_state_rows(conn: &Connection) -> Vec<String> {
13961 conn.prepare(
13962 "SELECT backend, workspace_root, file_path, content_hash, status
13963 FROM backend_file_state
13964 ORDER BY backend, workspace_root, file_path, content_hash, status",
13965 )
13966 .expect("prepare backend rows")
13967 .query_map([], |row| row_to_strings(row, 5))
13968 .expect("query backend rows")
13969 .collect::<std::result::Result<_, _>>()
13970 .expect("collect backend rows")
13971 }
13972
13973 fn secondary_indexes(conn: &Connection) -> Vec<String> {
13974 let mut indexes = Vec::new();
13975 for table in [
13976 "files",
13977 "nodes",
13978 "refs",
13979 "file_dependencies",
13980 "edges",
13981 "dispatch_hints",
13982 "type_ref_names",
13983 "backend_file_state",
13984 "meta",
13985 ] {
13986 let sql = format!("PRAGMA index_list({table})");
13987 let mut stmt = conn.prepare(&sql).expect("prepare index list");
13988 let rows = stmt
13989 .query_map([], |row| row.get::<_, String>(1))
13990 .expect("query index list");
13991 for name in rows {
13992 let name = name.expect("index name");
13993 if name.starts_with("idx_") {
13994 indexes.push(format!("{table}:{name}"));
13995 }
13996 }
13997 }
13998 indexes.sort();
13999 indexes
14000 }
14001
14002 fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
14003 let mut values = Vec::with_capacity(len);
14004 for index in 0..len {
14005 let value = row.get_ref(index)?;
14006 values.push(match value {
14007 rusqlite::types::ValueRef::Null => "NULL".to_string(),
14008 rusqlite::types::ValueRef::Integer(value) => value.to_string(),
14009 rusqlite::types::ValueRef::Real(value) => value.to_string(),
14010 rusqlite::types::ValueRef::Text(value) => {
14011 String::from_utf8_lossy(value).into_owned()
14012 }
14013 rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
14014 });
14015 }
14016 Ok(values.join("\u{1f}"))
14017 }
14018}
14019
14020#[cfg(test)]
14021mod rust_resolution_tests {
14022 use super::*;
14023 use crate::inspect::job::CallgraphSnapshot;
14024 use std::fs;
14025 use tempfile::tempdir;
14026
14027 #[test]
14028 fn rust_function_scoped_module_alias_resolves_and_projects_live() {
14029 let dir = tempdir().expect("tempdir");
14030 let root = dir.path();
14031 write_rust_manifest(root, "scoped-alias-fixture");
14032 write_file(
14033 root,
14034 "src/lib.rs",
14035 r#"pub mod finalization_contract;
14036
14037pub fn run_alias() {
14038 use crate::finalization_contract as fc;
14039 fc::check_mason_contract();
14040}
14041"#,
14042 );
14043 write_file(
14044 root,
14045 "src/finalization_contract.rs",
14046 r#"pub fn check_mason_contract() {}
14047fn planted_dead() {}
14048"#,
14049 );
14050
14051 let (store, snapshot) = cold_build_twice(root);
14052 assert_direct_caller(
14053 &store,
14054 "src/finalization_contract.rs",
14055 "check_mason_contract",
14056 "src/lib.rs",
14057 "run_alias",
14058 );
14059 assert_projected_call(
14060 root,
14061 &snapshot,
14062 "src/finalization_contract.rs",
14063 "check_mason_contract",
14064 );
14065 assert_no_projected_call(
14066 root,
14067 &snapshot,
14068 "src/finalization_contract.rs",
14069 "planted_dead",
14070 );
14071 assert!(
14072 store
14073 .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
14074 .expect("planted dead callers")
14075 .is_empty(),
14076 "planted-dead guard should stay without callers"
14077 );
14078 }
14079
14080 #[test]
14081 fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
14082 let dir = tempdir().expect("tempdir");
14083 let root = dir.path();
14084 write_rust_manifest(root, "inline-module-fixture");
14085 write_file(
14086 root,
14087 "src/lib.rs",
14088 r#"mod work_graph { fn operations() {} }
14089mod manifest { fn operations() {} }
14090mod audit { fn operations() {} }
14091mod dispatch { fn operations() {} }
14092mod finalization { fn operations() {} }
14093
14094pub fn run_inline_operations() {
14095 work_graph::operations();
14096 manifest::operations();
14097 audit::operations();
14098 dispatch::operations();
14099 finalization::operations();
14100}
14101
14102fn planted_dead() {}
14103"#,
14104 );
14105
14106 let (store, snapshot) = cold_build_twice(root);
14107 for module in [
14108 "work_graph",
14109 "manifest",
14110 "audit",
14111 "dispatch",
14112 "finalization",
14113 ] {
14114 assert_direct_caller(
14115 &store,
14116 "src/lib.rs",
14117 &format!("{module}::operations"),
14118 "src/lib.rs",
14119 "run_inline_operations",
14120 );
14121 }
14122 assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
14123 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
14124 }
14125
14126 #[test]
14127 fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
14128 let dir = tempdir().expect("tempdir");
14129 let root = dir.path();
14130 fs::write(
14131 root.join("Cargo.toml"),
14132 "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
14133 )
14134 .expect("write workspace manifest");
14135 write_file(
14136 root,
14137 "crates/but-action/Cargo.toml",
14138 r#"[package]
14139name = "but-action"
14140version = "0.1.0"
14141edition = "2021"
14142"#,
14143 );
14144 write_file(
14145 root,
14146 "crates/but-action/src/lib.rs",
14147 "mod action;\npub use action::{list_actions};\n",
14148 );
14149 write_file(
14150 root,
14151 "crates/but-action/src/action.rs",
14152 "pub fn list_actions() {}\nfn planted_dead() {}\n",
14153 );
14154 write_file(
14155 root,
14156 "crates/app/Cargo.toml",
14157 r#"[package]
14158name = "app"
14159version = "0.1.0"
14160edition = "2021"
14161"#,
14162 );
14163 write_file(
14164 root,
14165 "crates/app/src/lib.rs",
14166 "pub fn run_actions() {\n but_action::list_actions();\n}\n",
14167 );
14168
14169 let (store, snapshot) = cold_build_twice(root);
14170 assert_direct_caller(
14171 &store,
14172 "crates/but-action/src/action.rs",
14173 "list_actions",
14174 "crates/app/src/lib.rs",
14175 "run_actions",
14176 );
14177 assert!(
14178 store
14179 .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
14180 .expect("lib reexport callers")
14181 .is_empty(),
14182 "call should target the reexported source function, not lib.rs"
14183 );
14184 assert_projected_call(
14185 root,
14186 &snapshot,
14187 "crates/but-action/src/action.rs",
14188 "list_actions",
14189 );
14190 assert_no_projected_call(
14191 root,
14192 &snapshot,
14193 "crates/but-action/src/action.rs",
14194 "planted_dead",
14195 );
14196 }
14197
14198 #[test]
14199 fn rust_generic_self_turbofish_method_dispatch_resolves() {
14200 let dir = tempdir().expect("tempdir");
14201 let root = dir.path();
14202 write_rust_manifest(root, "generic-self-fixture");
14203 write_file(
14204 root,
14205 "src/lib.rs",
14206 r#"pub struct Matcher;
14207
14208impl Matcher {
14209 pub fn run(&self) -> bool {
14210 self.fuzzy_match_optimal::<usize>("needle")
14211 }
14212
14213 fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
14214 let _ = std::marker::PhantomData::<T>;
14215 true
14216 }
14217
14218 fn planted_dead(&self) {}
14219}
14220
14221pub fn entry() -> bool {
14222 let matcher = Matcher;
14223 matcher.run()
14224}
14225"#,
14226 );
14227
14228 let (store, snapshot) = cold_build_twice(root);
14229 assert_direct_caller(
14230 &store,
14231 "src/lib.rs",
14232 "Matcher::fuzzy_match_optimal",
14233 "src/lib.rs",
14234 "Matcher::run",
14235 );
14236 assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
14237 assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
14238 }
14239
14240 #[test]
14241 fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
14242 let dir = tempdir().expect("tempdir");
14243 let root = dir.path();
14244 write_rust_manifest(root, "manifest-operations-fixture");
14245 write_file(
14246 root,
14247 "src/main.rs",
14248 r#"mod dispatch;
14249use dispatch::{manifest_operations};
14250
14251fn main() {
14252 manifest_operations();
14253}
14254"#,
14255 );
14256 write_file(
14257 root,
14258 "src/dispatch.rs",
14259 r#"mod work_graph { fn operations() {} }
14260mod manifest { fn operations() {} }
14261mod audit { fn operations() {} }
14262mod descriptor { fn operations() {} }
14263mod writer { fn operations() {} }
14264
14265pub fn manifest_operations() {
14266 manifest::operations();
14267}
14268
14269pub fn work_graph_operations() {
14270 work_graph::operations();
14271}
14272
14273pub fn audit_operations() {
14274 audit::operations();
14275}
14276
14277pub fn descriptor_operations() {
14278 descriptor::operations();
14279}
14280
14281pub fn writer_operations() {
14282 writer::operations();
14283}
14284
14285fn planted_dead() {}
14286"#,
14287 );
14288
14289 let (store, snapshot) = cold_build_twice(root);
14290 assert_direct_caller(
14291 &store,
14292 "src/dispatch.rs",
14293 "manifest_operations",
14294 "src/main.rs",
14295 "main",
14296 );
14297 assert_direct_caller(
14298 &store,
14299 "src/dispatch.rs",
14300 "manifest::operations",
14301 "src/dispatch.rs",
14302 "manifest_operations",
14303 );
14304 assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
14305 assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
14306 assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
14307 }
14308
14309 fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
14310 let files = rust_files(root);
14311 let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
14312 .expect("open first store");
14313 first.cold_build(&files).expect("first cold build");
14314 let first_snapshot =
14315 project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
14316
14317 let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
14318 .expect("open second store");
14319 second.cold_build(&files).expect("second cold build");
14320 let second_snapshot =
14321 project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
14322
14323 assert_eq!(
14324 projection_rows(&first_snapshot),
14325 projection_rows(&second_snapshot),
14326 "cold-build projection should be deterministic"
14327 );
14328 (first, first_snapshot)
14329 }
14330
14331 fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
14332 let mut rows = Vec::new();
14333 for export in &snapshot.exported_symbols {
14334 rows.push(format!(
14335 "export\t{}\t{}\t{}\t{}",
14336 export.file.display(),
14337 export.symbol,
14338 export.kind,
14339 export.line
14340 ));
14341 }
14342 for call in &snapshot.outbound_calls {
14343 rows.push(format!(
14344 "call\t{}\t{}\t{}\t{}\t{}",
14345 call.caller_file.display(),
14346 call.caller_symbol,
14347 call.target,
14348 call.line,
14349 call.provenance
14350 ));
14351 }
14352 for file in &snapshot.entry_points {
14353 rows.push(format!("entry_file\t{}", file.display()));
14354 }
14355 for (file, symbols) in &snapshot.entry_point_symbols {
14356 for symbol in symbols {
14357 rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
14358 }
14359 }
14360 rows.sort();
14361 rows
14362 }
14363
14364 fn assert_direct_caller(
14365 store: &CallGraphStore,
14366 target_rel: &str,
14367 target_symbol: &str,
14368 caller_rel: &str,
14369 caller_symbol: &str,
14370 ) {
14371 let callers = store
14372 .direct_callers_of(Path::new(target_rel), target_symbol)
14373 .unwrap_or_else(|error| {
14374 panic!("direct callers for {target_rel}::{target_symbol}: {error}")
14375 });
14376 assert!(
14377 callers.iter().any(|site| {
14378 site.caller.file == caller_rel && site.caller.symbol == caller_symbol
14379 }),
14380 "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
14381 );
14382 }
14383
14384 fn assert_projected_call(
14385 root: &Path,
14386 snapshot: &CallgraphSnapshot,
14387 target_rel: &str,
14388 symbol: &str,
14389 ) {
14390 let target = projected_target(root, target_rel, symbol);
14391 assert!(
14392 snapshot.outbound_calls.iter().any(|call| {
14393 call.target == target
14394 || call.target.starts_with(&format!(
14395 "{target}{}",
14396 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
14397 ))
14398 }),
14399 "expected projected call to {target}; calls: {:#?}",
14400 snapshot.outbound_calls
14401 );
14402 }
14403
14404 fn assert_no_projected_call(
14405 root: &Path,
14406 snapshot: &CallgraphSnapshot,
14407 target_rel: &str,
14408 symbol: &str,
14409 ) {
14410 let target = projected_target(root, target_rel, symbol);
14411 assert!(
14412 snapshot.outbound_calls.iter().all(|call| {
14413 call.target != target
14414 && !call.target.starts_with(&format!(
14415 "{target}{}",
14416 crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
14417 ))
14418 }),
14419 "did not expect projected call to {target}; calls: {:#?}",
14420 snapshot.outbound_calls
14421 );
14422 }
14423
14424 fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
14425 let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
14428 format!("{}::{symbol}", path.display())
14429 }
14430
14431 fn write_rust_manifest(root: &Path, name: &str) {
14432 write_file(
14433 root,
14434 "Cargo.toml",
14435 &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
14436 );
14437 }
14438
14439 fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
14440 let path = root.join(rel_path);
14441 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
14442 fs::write(&path, source).expect("write fixture file");
14443 path
14444 }
14445
14446 fn rust_files(root: &Path) -> Vec<PathBuf> {
14447 let mut files = Vec::new();
14448 collect_rust_files(root, &mut files);
14449 files.sort();
14450 files
14451 }
14452
14453 fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
14454 for entry in fs::read_dir(dir).expect("read fixture dir") {
14455 let entry = entry.expect("read fixture entry");
14456 let path = entry.path();
14457 if path.is_dir() {
14458 let name = path
14459 .file_name()
14460 .and_then(|name| name.to_str())
14461 .unwrap_or("");
14462 if !name.starts_with(".store") {
14463 collect_rust_files(&path, files);
14464 }
14465 } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
14466 files.push(path);
14467 }
14468 }
14469 }
14470}
14471
14472#[cfg(test)]
14473mod build_pool_tests {
14474 use super::build_pool_size;
14475
14476 #[test]
14477 fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
14478 let size = build_pool_size();
14479 assert!(size >= 1, "pool size must be at least 1");
14482 assert!(size <= 8, "pool size must be capped at 8, got {size}");
14483
14484 let cores = std::thread::available_parallelism()
14485 .map(|p| p.get())
14486 .unwrap_or(1);
14487 let expected = cores.div_ceil(2).clamp(1, 8);
14488 assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
14489 }
14490}
14491
14492#[cfg(test)]
14493mod reexport_resolution_tests {
14494 use super::*;
14495
14496 fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
14497 ProjectIndex {
14498 project_root: PathBuf::from("/fixture"),
14499 files: files.into_iter().collect(),
14500 caller_data: HashMap::new(),
14501 workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
14502 }
14503 }
14504
14505 fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
14506 DbFileIndex {
14507 lang: None,
14508 exports: HashSet::new(),
14509 default_export: None,
14510 export_aliases: HashMap::new(),
14511 node_by_scoped: HashMap::new(),
14512 node_by_bare: HashMap::new(),
14513 module_targets: HashMap::new(),
14514 reexports: reexport_targets
14515 .iter()
14516 .map(|target| ReexportIndex {
14517 target_file: Some((*target).to_string()),
14518 named: HashMap::new(),
14519 wildcard: true,
14520 })
14521 .collect(),
14522 }
14523 }
14524
14525 #[test]
14532 fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
14533 let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
14534 let files = names
14535 .iter()
14536 .map(|name| {
14537 let targets: Vec<&str> = names
14538 .iter()
14539 .filter(|other| *other != name)
14540 .map(String::as_str)
14541 .collect();
14542 (name.clone(), barrel_file(&targets))
14543 })
14544 .collect();
14545 let index = barrel_index(files);
14546
14547 assert_eq!(
14548 resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
14549 None
14550 );
14551 }
14552
14553 #[test]
14559 fn shallow_revisit_after_deep_capped_visit_still_resolves() {
14560 let mut leaf = barrel_file(&[]);
14561 leaf.exports.insert("deep_symbol".to_string());
14562 let mut files: Vec<(String, DbFileIndex)> = Vec::new();
14563 files.push((
14566 "src/entry.ts".to_string(),
14567 barrel_file(&["src/chain0.ts", "src/shared.ts"]),
14568 ));
14569 for i in 0..15 {
14570 let next = if i == 14 {
14571 "src/shared.ts".to_string()
14572 } else {
14573 format!("src/chain{}.ts", i + 1)
14574 };
14575 files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
14576 }
14577 files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
14578 files.push(("src/leaf.ts".to_string(), leaf));
14579 let index = barrel_index(files);
14580
14581 assert_eq!(
14582 resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
14583 Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
14584 "a shallower re-visit must not be pruned by a deeper capped visit"
14585 );
14586 }
14587
14588 #[test]
14589 fn symbol_reachable_through_reexport_cycle_still_resolves() {
14590 let mut leaf = barrel_file(&[]);
14591 leaf.exports.insert("real_symbol".to_string());
14592 let index = barrel_index(vec![
14593 (
14594 "src/a.ts".to_string(),
14595 barrel_file(&["src/b.ts", "src/a.ts"]),
14596 ),
14597 (
14598 "src/b.ts".to_string(),
14599 barrel_file(&["src/a.ts", "src/leaf.ts"]),
14600 ),
14601 ("src/leaf.ts".to_string(), leaf),
14602 ]);
14603
14604 assert_eq!(
14605 resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
14606 Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
14607 );
14608 }
14609}
14610
14611#[cfg(test)]
14612mod method_dispatch_inference_tests {
14613 use super::*;
14614 use std::fs;
14615 use tempfile::tempdir;
14616
14617 #[test]
14618 fn java_field_receiver_type_selects_declared_class_method() {
14619 let source = r#"class EntryPoint {
14620 private UserService userService;
14621
14622 void handle() {
14623 userService.find();
14624 }
14625}
14626
14627class UserService {
14628 void find() {}
14629}
14630
14631class AuditService {
14632 void find() {}
14633}
14634"#;
14635 let dir = tempdir().expect("temp dir");
14636 let root = dir.path();
14637 write_fixture(root, "src/EntryPoint.java", source);
14638 let reference = reference(
14639 "java",
14640 "src/EntryPoint.java",
14641 "EntryPoint::handle",
14642 "userService",
14643 "find",
14644 line_of(source, "userService.find()"),
14645 );
14646 let mut cache = DispatchSourceCache::new();
14647
14648 let receiver_type =
14649 infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
14650 assert_eq!(receiver_type, "UserService");
14651
14652 let candidates = vec![
14653 method_candidate("audit", "AuditService::find"),
14654 method_candidate("user", "UserService::find"),
14655 ];
14656 let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
14657 .expect("type candidate");
14658 assert_eq!(selected.scoped_name, "UserService::find");
14659
14660 let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
14661 assert!(
14662 select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
14663 );
14664 }
14665
14666 #[test]
14667 fn kotlin_property_and_local_value_types_are_inferred() {
14668 let source = r#"class Handler {
14669 private val auditService: AuditService = AuditService()
14670
14671 fun handle() {
14672 auditService.find()
14673 val userService: UserService = UserService()
14674 userService.find()
14675 val billingService = BillingService()
14676 billingService.find()
14677 }
14678}
14679
14680class UserService { fun find() {} }
14681class AuditService { fun find() {} }
14682class BillingService { fun find() {} }
14683"#;
14684 let dir = tempdir().expect("temp dir");
14685 let root = dir.path();
14686 write_fixture(root, "src/Handler.kt", source);
14687 let mut cache = DispatchSourceCache::new();
14688
14689 let audit_ref = reference(
14690 "kotlin",
14691 "src/Handler.kt",
14692 "Handler::handle",
14693 "auditService",
14694 "find",
14695 line_of(source, "auditService.find()"),
14696 );
14697 assert_eq!(
14698 infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
14699 Some("AuditService")
14700 );
14701
14702 let user_ref = reference(
14703 "kotlin",
14704 "src/Handler.kt",
14705 "Handler::handle",
14706 "userService",
14707 "find",
14708 line_of(source, "userService.find()"),
14709 );
14710 assert_eq!(
14711 infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
14712 Some("UserService")
14713 );
14714
14715 let billing_ref = reference(
14716 "kotlin",
14717 "src/Handler.kt",
14718 "Handler::handle",
14719 "billingService",
14720 "find",
14721 line_of(source, "billingService.find()"),
14722 );
14723 assert_eq!(
14724 infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
14725 Some("BillingService")
14726 );
14727 }
14728
14729 #[test]
14730 fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
14731 let source = r#"struct Foo { void run(); };
14732struct PointerFoo { void run(); };
14733struct FactoryFoo { void run(); };
14734FactoryFoo makeFactoryFoo();
14735
14736void handle() {
14737 Foo foo;
14738 foo.run();
14739 PointerFoo* pointerFoo = nullptr;
14740 pointerFoo->run();
14741 auto factoryFoo = makeFactoryFoo();
14742 factoryFoo.run();
14743}
14744"#;
14745 let dir = tempdir().expect("temp dir");
14746 let root = dir.path();
14747 write_fixture(root, "src/fixture.cpp", source);
14748 let mut cache = DispatchSourceCache::new();
14749
14750 let foo_ref = reference(
14751 "cpp",
14752 "src/fixture.cpp",
14753 "handle",
14754 "foo",
14755 "run",
14756 line_of(source, "foo.run()"),
14757 );
14758 assert_eq!(
14759 infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
14760 Some("Foo")
14761 );
14762
14763 let pointer_ref = reference(
14764 "cpp",
14765 "src/fixture.cpp",
14766 "handle",
14767 "pointerFoo",
14768 "run",
14769 line_of(source, "pointerFoo->run()"),
14770 );
14771 assert_eq!(
14772 infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
14773 Some("PointerFoo")
14774 );
14775
14776 let factory_ref = reference(
14777 "cpp",
14778 "src/fixture.cpp",
14779 "handle",
14780 "factoryFoo",
14781 "run",
14782 line_of(source, "factoryFoo.run()"),
14783 );
14784 assert_eq!(
14785 infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
14786 Some("FactoryFoo")
14787 );
14788 }
14789
14790 #[test]
14791 fn rust_direct_self_field_name_trims_separator_whitespace() {
14792 for receiver_expression in ["self .engine", "self. engine", "self . engine"] {
14793 assert_eq!(
14794 rust_direct_self_field_name(receiver_expression),
14795 Some("engine")
14796 );
14797 }
14798 }
14799
14800 #[test]
14801 fn rust_direct_self_field_receiver_type_is_conservative() {
14802 let source = r#"struct Engine;
14803
14804struct Car {
14805 engine: Engine,
14806}
14807
14808impl Car {
14809 fn run(&self) {
14810 self.engine.start();
14811 }
14812}
14813
14814struct NestedCar {
14815 engine: Engine,
14816}
14817
14818impl NestedCar {
14819 fn run(&self) {
14820 self.inner.engine.start();
14821 }
14822}
14823
14824struct WrappedCar {
14825 engine: Option<Engine>,
14826}
14827
14828impl WrappedCar {
14829 fn run(&self) {
14830 self.engine.start(); // wrapped
14831 }
14832}
14833
14834struct GenericCar<T> {
14835 engine: T,
14836}
14837
14838impl<T> GenericCar<T> {
14839 fn run(&self) {
14840 self.engine.start(); // generic
14841 }
14842}
14843
14844type EngineAlias = Engine;
14845
14846struct AliasCar {
14847 engine: EngineAlias,
14848}
14849
14850impl AliasCar {
14851 fn run(&self) {
14852 self.engine.start(); // alias
14853 }
14854}
14855"#;
14856 let dir = tempdir().expect("temp dir");
14857 let root = dir.path();
14858 write_fixture(root, "src/lib.rs", source);
14859 let mut cache = DispatchSourceCache::new();
14860
14861 let mut direct = reference(
14862 "rust",
14863 "src/lib.rs",
14864 "Car::run",
14865 "engine",
14866 "start",
14867 line_of(source, "self.engine.start()"),
14868 );
14869 direct.receiver_expression = "self.engine".to_string();
14870 assert_eq!(
14871 infer_receiver_type(root, &direct, &mut cache).as_deref(),
14872 Some("Engine")
14873 );
14874
14875 let mut mismatched_impl_target = direct.clone();
14876 mismatched_impl_target.caller_symbol = "other::Car::run".to_string();
14877 assert!(infer_receiver_type(root, &mismatched_impl_target, &mut cache).is_none());
14878
14879 let mut nested = reference(
14880 "rust",
14881 "src/lib.rs",
14882 "NestedCar::run",
14883 "engine",
14884 "start",
14885 line_of(source, "self.inner.engine.start()"),
14886 );
14887 nested.receiver_expression = "self.inner.engine".to_string();
14888 assert!(infer_receiver_type(root, &nested, &mut cache).is_none());
14889
14890 let mut wrapped = reference(
14891 "rust",
14892 "src/lib.rs",
14893 "WrappedCar::run",
14894 "engine",
14895 "start",
14896 line_of(source, "self.engine.start(); // wrapped"),
14897 );
14898 wrapped.receiver_expression = "self.engine".to_string();
14899 assert!(infer_receiver_type(root, &wrapped, &mut cache).is_none());
14900
14901 let mut generic = reference(
14902 "rust",
14903 "src/lib.rs",
14904 "GenericCar::run",
14905 "engine",
14906 "start",
14907 line_of(source, "self.engine.start(); // generic"),
14908 );
14909 generic.receiver_expression = "self.engine".to_string();
14910 assert!(infer_receiver_type(root, &generic, &mut cache).is_none());
14911
14912 let mut alias = reference(
14913 "rust",
14914 "src/lib.rs",
14915 "AliasCar::run",
14916 "engine",
14917 "start",
14918 line_of(source, "self.engine.start(); // alias"),
14919 );
14920 alias.receiver_expression = "self.engine".to_string();
14921 assert!(infer_receiver_type(root, &alias, &mut cache).is_none());
14922 }
14923
14924 #[test]
14925 fn rust_direct_self_reference_field_receiver_is_not_inferred() {
14926 let source = r#"struct Engine;
14927
14928struct Car {
14929 engine: &'static Engine,
14930}
14931
14932impl Car {
14933 fn run(&self) {
14934 self.engine.start();
14935 }
14936}
14937"#;
14938 let dir = tempdir().expect("temp dir");
14939 let root = dir.path();
14940 write_fixture(root, "src/lib.rs", source);
14941 let mut cache = DispatchSourceCache::new();
14942 let mut reference = reference(
14943 "rust",
14944 "src/lib.rs",
14945 "Car::run",
14946 "engine",
14947 "start",
14948 line_of(source, "self.engine.start()"),
14949 );
14950 reference.receiver_expression = "self.engine".to_string();
14951
14952 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
14953 }
14954
14955 #[test]
14956 fn rust_trait_impl_self_field_receiver_is_not_inferred() {
14957 let source = r#"trait Drive {
14958 fn run(&self);
14959}
14960
14961struct Engine;
14962
14963struct Car {
14964 engine: Engine,
14965}
14966
14967impl Drive for Car {
14968 fn run(&self) {
14969 self.engine.start();
14970 }
14971}
14972"#;
14973 let dir = tempdir().expect("temp dir");
14974 let root = dir.path();
14975 write_fixture(root, "src/lib.rs", source);
14976 let mut cache = DispatchSourceCache::new();
14977 let mut reference = reference(
14978 "rust",
14979 "src/lib.rs",
14980 "Car::run",
14981 "engine",
14982 "start",
14983 line_of(source, "self.engine.start()"),
14984 );
14985 reference.receiver_expression = "self.engine".to_string();
14986
14987 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
14988 }
14989
14990 #[test]
14991 fn rust_self_field_does_not_bind_struct_from_another_module() {
14992 let source = r#"struct Engine;
14993
14994mod unrelated {
14995 struct Car {
14996 engine: Engine,
14997 }
14998}
14999
15000impl Car {
15001 fn run(&self) {
15002 self.engine.start();
15003 }
15004}
15005"#;
15006 let dir = tempdir().expect("temp dir");
15007 let root = dir.path();
15008 write_fixture(root, "src/lib.rs", source);
15009 let mut cache = DispatchSourceCache::new();
15010 let mut reference = reference(
15011 "rust",
15012 "src/lib.rs",
15013 "Car::run",
15014 "engine",
15015 "start",
15016 line_of(source, "self.engine.start()"),
15017 );
15018 reference.receiver_expression = "self.engine".to_string();
15019
15020 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15021 }
15022
15023 #[test]
15024 fn unknown_java_receiver_still_uses_name_match_fallback() {
15025 let source = r#"class EntryPoint {
15026 void handle() {
15027 service.runSpecial();
15028 }
15029}
15030
15031class OnlyService {
15032 void runSpecial() {}
15033}
15034"#;
15035 let dir = tempdir().expect("temp dir");
15036 let root = dir.path();
15037 write_fixture(root, "src/EntryPoint.java", source);
15038 let reference = reference(
15039 "java",
15040 "src/EntryPoint.java",
15041 "EntryPoint::handle",
15042 "service",
15043 "runSpecial",
15044 line_of(source, "service.runSpecial()"),
15045 );
15046 let mut cache = DispatchSourceCache::new();
15047
15048 assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15049 let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
15050 let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
15051 assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
15052 }
15053
15054 fn reference(
15055 lang: &str,
15056 caller_file: &str,
15057 caller_symbol: &str,
15058 receiver: &str,
15059 method_name: &str,
15060 line: u32,
15061 ) -> NameMatchRef {
15062 NameMatchRef {
15063 ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
15064 caller_node: format!("{caller_symbol}:node"),
15065 caller_file: caller_file.to_string(),
15066 caller_symbol: caller_symbol.to_string(),
15067 caller_signature: None,
15068 receiver_expression: receiver.to_string(),
15069 receiver: receiver.to_string(),
15070 method_name: method_name.to_string(),
15071 colon_dispatch: false,
15072 line,
15073 lang: lang.to_string(),
15074 }
15075 }
15076
15077 fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
15078 NameMatchCandidate {
15079 node_id: node_id.to_string(),
15080 file_path: "src/targets.fixture".to_string(),
15081 scoped_name: scoped_name.to_string(),
15082 kind: "method".to_string(),
15083 start_line: 1,
15084 }
15085 }
15086
15087 fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
15088 let path = root.join(rel_path);
15089 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
15090 fs::write(path, source).expect("write fixture");
15091 }
15092
15093 fn line_of(source: &str, needle: &str) -> u32 {
15094 source
15095 .lines()
15096 .position(|line| line.contains(needle))
15097 .map(|index| index as u32 + 1)
15098 .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
15099 }
15100}