Skip to main content

aft/callgraph_store/
mod.rs

1//! Persistent call/reference graph sidecar.
2//!
3//! Phase 1 intentionally keeps this substrate self-contained: callers can build
4//! and query the sidecar directly, but no runtime command reads from it yet.
5
6use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
7use crate::callgraph::{self, EdgeResolution, FileCallData};
8use crate::error::AftError;
9use crate::imports::{ImportKind, ImportStatement};
10use crate::parser::LangId;
11use crate::symbols::{Range, SymbolKind};
12use rayon::prelude::*;
13use rusqlite::{params, Connection, OpenFlags, OptionalExtension, Transaction};
14use std::collections::{hash_map::Entry, BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
15use std::fmt;
16use std::path::{Path, PathBuf};
17use std::sync::{Arc, Mutex};
18use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
19
20const SCHEMA_VERSION: i64 = 1;
21const BACKEND_TREESITTER: &str = "treesitter";
22const PROVENANCE_TREESITTER: &str = "treesitter+resolver";
23const PROVENANCE_NAME_MATCH: &str = "name_match";
24const PROVENANCE_TYPE_MATCH: &str = "type_match";
25const NAME_MATCH_SCORE_THRESHOLD: f64 = 2.0;
26const TOP_LEVEL_SYMBOL: &str = "<top-level>";
27const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
28
29type ColdBuildSwapObserver = dyn Fn(&Path, &Path) + Send + Sync + 'static;
30// THREAD-LOCAL, not a process-global: the observer fires synchronously on the
31// thread running the cold build, and the only caller (a test) installs and
32// clears it on its own thread. A process-global `Mutex<Option<...>>` raced
33// across parallel tests — one test's installed observer fired during ANOTHER
34// test's `cold_build_with_lease`, asserting against the wrong build's edges
35// (flaked on Windows CI under parallel scheduling). Production never sets it.
36thread_local! {
37    static COLD_BUILD_SWAP_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSwapObserver>>> =
38        const { std::cell::RefCell::new(None) };
39}
40
41mod dead_code_projection;
42pub use dead_code_projection::project_dead_code_snapshot;
43
44#[doc(hidden)]
45pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
46    COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
47}
48
49fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
50    let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
51    if let Some(observer) = observer {
52        observer(temp_path, target_path);
53    }
54}
55
56#[derive(Debug)]
57pub enum CallGraphStoreError {
58    Io(std::io::Error),
59    Sqlite(rusqlite::Error),
60    Json(serde_json::Error),
61    Aft(AftError),
62    Lock(crate::fs_lock::AcquireError),
63    MissingCallerData { file: String },
64    Unavailable(String),
65    StaleFiles(Vec<String>),
66}
67
68impl fmt::Display for CallGraphStoreError {
69    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
70        match self {
71            Self::Io(error) => write!(formatter, "I/O error: {error}"),
72            Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
73            Self::Json(error) => write!(formatter, "json error: {error}"),
74            Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
75            Self::Lock(error) => write!(formatter, "callgraph build lock error: {error}"),
76            Self::MissingCallerData { file } => {
77                write!(formatter, "missing extracted caller data for {file}")
78            }
79            Self::Unavailable(message) => {
80                write!(formatter, "callgraph store unavailable: {message}")
81            }
82            Self::StaleFiles(files) => {
83                write!(
84                    formatter,
85                    "callgraph store has stale files: {}",
86                    files.join(", ")
87                )
88            }
89        }
90    }
91}
92
93impl std::error::Error for CallGraphStoreError {}
94
95impl From<std::io::Error> for CallGraphStoreError {
96    fn from(error: std::io::Error) -> Self {
97        Self::Io(error)
98    }
99}
100
101impl From<rusqlite::Error> for CallGraphStoreError {
102    fn from(error: rusqlite::Error) -> Self {
103        Self::Sqlite(error)
104    }
105}
106
107impl From<serde_json::Error> for CallGraphStoreError {
108    fn from(error: serde_json::Error) -> Self {
109        Self::Json(error)
110    }
111}
112
113impl From<AftError> for CallGraphStoreError {
114    fn from(error: AftError) -> Self {
115        Self::Aft(error)
116    }
117}
118
119impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
120    fn from(error: crate::fs_lock::AcquireError) -> Self {
121        Self::Lock(error)
122    }
123}
124
125pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
126
127/// Runtime gate name for Phase-1 callers. The substrate is compiled and tested,
128/// but production commands should only open it through `open_if_enabled` until
129/// Phase 2 migrates consumers.
130pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
131
132#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
133pub struct CallGraphStoreOptions {
134    pub enabled: bool,
135}
136
137#[derive(Debug)]
138pub struct CallGraphStore {
139    project_root: PathBuf,
140    project_key: String,
141    /// The concrete on-disk DB file this store opened. With the generation
142    /// scheme this is `<dir>/<key>.g<...>.sqlite` (resolved via the pointer) or,
143    /// for a pre-generation store, the legacy `<dir>/<key>.sqlite`.
144    sqlite_path: PathBuf,
145    /// The generation file NAME this store opened (e.g. `<key>.g<nanos>.<pid>.sqlite`),
146    /// or `None` when it opened the legacy single-file DB. Used to detect when
147    /// another process has published a newer generation so this process can
148    /// drop its connection and reopen (see `current_generation`).
149    generation: Option<String>,
150    conn: Mutex<Connection>,
151}
152
153#[derive(Debug, Clone)]
154pub struct ColdBuildStats {
155    pub files: usize,
156    pub nodes: usize,
157    pub refs: usize,
158    pub edges: usize,
159    pub failed_files: Vec<String>,
160    pub elapsed_ms: u128,
161}
162
163#[derive(Debug, Clone)]
164pub struct IncrementalStats {
165    pub changed_files: Vec<String>,
166    pub surface_changed: Vec<String>,
167    pub deleted_files: Vec<String>,
168    pub dependency_selected_refs: usize,
169    pub refreshed_own_files: usize,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
173pub struct StoredEdge {
174    pub source_file: String,
175    pub source_symbol: String,
176    pub target_file: String,
177    pub target_symbol: String,
178    pub kind: String,
179    pub line: u32,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct StoreNode {
184    node_id: String,
185    pub file: String,
186    pub symbol: String,
187    pub name: String,
188    pub kind: String,
189    pub line: u32,
190    pub end_line: u32,
191    pub signature: Option<String>,
192    pub exported: bool,
193    pub is_entry_point: bool,
194    pub lang: LangId,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct StoreCallSite {
199    pub caller: StoreNode,
200    pub target_file: String,
201    pub target_symbol: String,
202    pub target: Option<StoreNode>,
203    pub line: u32,
204    pub byte_start: usize,
205    pub byte_end: usize,
206    pub resolved: bool,
207    pub provenance: String,
208}
209
210impl StoreCallSite {
211    pub fn approximate(&self) -> bool {
212        self.provenance == PROVENANCE_NAME_MATCH
213    }
214
215    pub fn resolved_by(&self) -> &str {
216        &self.provenance
217    }
218
219    pub fn supplemental_resolution(&self) -> Option<&str> {
220        match self.provenance.as_str() {
221            PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
222            _ => None,
223        }
224    }
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct StoreUnresolvedCall {
229    pub caller: StoreNode,
230    pub symbol: String,
231    pub full_ref: Option<String>,
232    pub line: u32,
233    pub byte_start: usize,
234    pub byte_end: usize,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq)]
238pub struct StoreCallersResult {
239    pub target: StoreNode,
240    pub callers: Vec<StoreCallSite>,
241    pub scanned_files: usize,
242    pub depth_limited: bool,
243    pub truncated: usize,
244}
245
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct StoreImpactCaller {
248    pub site: StoreCallSite,
249    pub signature: Option<String>,
250    pub is_entry_point: bool,
251    pub call_expression: Option<String>,
252    pub parameters: Vec<String>,
253}
254
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub struct StoreImpactResult {
257    pub target: StoreNode,
258    pub parameters: Vec<String>,
259    pub callers: Vec<StoreImpactCaller>,
260    pub depth_limited: bool,
261    pub truncated: usize,
262}
263
264#[derive(Debug, Clone)]
265struct ExtractFailure {
266    rel_path: String,
267    freshness: Option<FileFreshness>,
268}
269
270#[derive(Debug, Clone)]
271struct BuildExtractsResult {
272    extracts: Vec<FileExtract>,
273    failures: Vec<ExtractFailure>,
274}
275
276#[derive(Debug, Clone)]
277enum StoreForwardCall {
278    Resolved(StoreCallSite),
279    Unresolved(StoreUnresolvedCall),
280}
281
282impl StoreForwardCall {
283    fn byte_start(&self) -> usize {
284        match self {
285            Self::Resolved(site) => site.byte_start,
286            Self::Unresolved(call) => call.byte_start,
287        }
288    }
289
290    fn line(&self) -> u32 {
291        match self {
292            Self::Resolved(site) => site.line,
293            Self::Unresolved(call) => call.line,
294        }
295    }
296}
297
298#[derive(Debug, Clone)]
299struct FileExtract {
300    abs_path: PathBuf,
301    rel_path: String,
302    freshness: FileFreshness,
303    lang: LangId,
304    data: FileCallData,
305    nodes: Vec<NodeRecord>,
306    raw_refs: Vec<RawRef>,
307    dispatch_hints: Vec<DispatchHint>,
308    surface_fingerprint: String,
309}
310
311#[derive(Debug, Clone)]
312struct NodeRecord {
313    id: String,
314    file_path: String,
315    name: String,
316    scoped_name: String,
317    kind: String,
318    range: Range,
319    range_ordinal: u32,
320    signature: Option<String>,
321    exported: bool,
322    is_default_export: bool,
323    is_type_like: bool,
324    is_callgraph_entry_point: bool,
325}
326
327#[derive(Debug, Clone)]
328struct RawRef {
329    ref_id: String,
330    caller_node: Option<String>,
331    caller_symbol: Option<String>,
332    caller_file: String,
333    kind: String,
334    short_name: Option<String>,
335    full_ref: Option<String>,
336    module_path: Option<String>,
337    import_kind: Option<String>,
338    local_name: Option<String>,
339    requested_name: Option<String>,
340    namespace_alias: Option<String>,
341    wildcard: bool,
342    line: u32,
343    byte_start: usize,
344    byte_end: usize,
345    dependencies: BTreeSet<String>,
346}
347
348#[derive(Debug, Clone)]
349struct ResolvedRef {
350    raw: RawRef,
351    status: String,
352    target_node: Option<String>,
353    target_file: Option<String>,
354    target_symbol: Option<String>,
355    dependencies: BTreeSet<String>,
356    edge: Option<EdgeRecord>,
357}
358
359#[derive(Debug, Clone)]
360struct EdgeRecord {
361    edge_id: String,
362    source_node: String,
363    target_node: Option<String>,
364    target_file: String,
365    target_symbol: String,
366    kind: String,
367    line: u32,
368}
369
370#[derive(Debug, Clone)]
371struct DispatchHint {
372    id: String,
373    method_name: String,
374    caller_node: String,
375    file: String,
376    line: u32,
377    byte_start: usize,
378    byte_end: usize,
379}
380
381#[derive(Debug, Clone)]
382struct NameMatchRef {
383    ref_id: String,
384    caller_node: String,
385    caller_file: String,
386    caller_symbol: String,
387    caller_signature: Option<String>,
388    receiver: String,
389    method_name: String,
390    colon_dispatch: bool,
391    line: u32,
392    lang: String,
393}
394
395#[derive(Debug, Clone)]
396struct NameMatchCandidate {
397    node_id: String,
398    file_path: String,
399    scoped_name: String,
400    kind: String,
401}
402
403#[derive(Debug, Clone)]
404struct FileRow {
405    surface_fingerprint: String,
406    freshness: FileFreshness,
407}
408
409#[derive(Debug, Clone)]
410struct DbFileIndex {
411    lang: Option<LangId>,
412    exports: HashSet<String>,
413    default_export: Option<String>,
414    export_aliases: HashMap<String, String>,
415    node_by_scoped: HashMap<String, String>,
416    node_by_bare: HashMap<String, String>,
417    module_targets: HashMap<String, Option<String>>,
418    reexports: Vec<ReexportIndex>,
419}
420
421#[derive(Debug, Clone)]
422struct ReexportIndex {
423    target_file: Option<String>,
424    named: HashMap<String, String>,
425    wildcard: bool,
426}
427
428#[derive(Debug, Clone)]
429struct ProjectIndex<'a> {
430    project_root: PathBuf,
431    files: HashMap<String, DbFileIndex>,
432    caller_data: HashMap<String, &'a FileCallData>,
433    /// Lazily-built `crate_name -> src prefix` map for Rust workspace resolution.
434    /// Built once (whole-tree walk) on first qualified-ref resolution and reused,
435    /// instead of re-walking the project per ref. Skipped entirely when no Rust
436    /// workspace ref is resolved (e.g. warm query path with no Rust changes).
437    workspace_crate_prefixes: std::sync::OnceLock<HashMap<String, String>>,
438}
439
440impl ProjectIndex<'_> {
441    /// Resolve a crate name to its `src` prefix, building the workspace map on
442    /// first use. The map walks the project tree exactly once per index.
443    fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
444        self.workspace_crate_prefixes
445            .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
446            .get(crate_name)
447            .cloned()
448    }
449}
450
451impl CallGraphStore {
452    pub fn open_if_enabled(
453        options: CallGraphStoreOptions,
454        callgraph_dir: PathBuf,
455        project_root: PathBuf,
456    ) -> Result<Option<Self>> {
457        if !options.enabled {
458            return Ok(None);
459        }
460        Self::open(callgraph_dir, project_root).map(Some)
461    }
462
463    pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
464        std::fs::create_dir_all(&callgraph_dir)?;
465        let project_key = crate::search_index::project_cache_key(&project_root);
466        // Resolve the current generation via the pointer (falling back to the
467        // legacy single-file DB). If nothing is published yet, open the legacy
468        // path so a brand-new store still gets a writable DB + schema.
469        let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
470            .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
471        Self::open_at_path(project_root, project_key, sqlite_path, generation, true)
472    }
473
474    pub fn open_readonly(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
475        let project_key = crate::search_index::project_cache_key(&project_root);
476        let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
477        else {
478            return Ok(None);
479        };
480        let conn = Connection::open_with_flags(&sqlite_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
481        conn.busy_timeout(Duration::from_millis(5_000))?;
482        if !database_ready(&conn).unwrap_or(false) {
483            return Ok(None);
484        }
485        Ok(Some(Self::from_connection(
486            project_root,
487            project_key,
488            sqlite_path,
489            generation,
490            conn,
491        )))
492    }
493
494    pub fn cold_build_with_lease(
495        callgraph_dir: PathBuf,
496        project_root: PathBuf,
497        files: &[PathBuf],
498    ) -> Result<(Self, ColdBuildStats)> {
499        std::fs::create_dir_all(&callgraph_dir)?;
500        let project_key = crate::search_index::project_cache_key(&project_root);
501        let lock_path = callgraph_dir.join(format!("{project_key}.build.lock"));
502        let _guard = crate::fs_lock::try_acquire(&lock_path, Duration::from_secs(30))?;
503        let (stats, generation) =
504            Self::cold_build_publish_locked(&callgraph_dir, &project_root, &project_key, files)?;
505        let store = Self::open_generation(&callgraph_dir, project_root, project_key, generation)?;
506        Ok((store, stats))
507    }
508
509    pub fn ensure_built_with_lease(
510        callgraph_dir: PathBuf,
511        project_root: PathBuf,
512        files: &[PathBuf],
513    ) -> Result<(Self, Option<ColdBuildStats>)> {
514        std::fs::create_dir_all(&callgraph_dir)?;
515        let project_key = crate::search_index::project_cache_key(&project_root);
516        let lock_path = callgraph_dir.join(format!("{project_key}.build.lock"));
517        let _guard = crate::fs_lock::try_acquire(&lock_path, Duration::from_secs(30))?;
518        // Another process may have published a ready generation while we waited
519        // for the lock — open it instead of rebuilding.
520        if !Self::needs_cold_build(&callgraph_dir, &project_root)? {
521            return Ok((Self::open(callgraph_dir, project_root)?, None));
522        }
523        let (stats, generation) =
524            Self::cold_build_publish_locked(&callgraph_dir, &project_root, &project_key, files)?;
525        let store = Self::open_generation(&callgraph_dir, project_root, project_key, generation)?;
526        Ok((store, Some(stats)))
527    }
528
529    /// Build a fresh DB and publish it as a new generation, then atomically flip
530    /// the `<key>.current` pointer to it. NEVER replaces an open DB file, so it
531    /// succeeds even when other processes hold an older generation open (the
532    /// multi-TUI Windows case). The builder owns the temp + generation files
533    /// exclusively (unique pid+nanos names), so it can rename/replace them
534    /// freely; only the tiny pointer is shared, and only Rust std touches it.
535    ///
536    /// Returns the published generation file name so callers open exactly the
537    /// generation they built (avoiding a race where a concurrent build's flip
538    /// would otherwise reopen a different generation).
539    fn cold_build_publish_locked(
540        callgraph_dir: &Path,
541        project_root: &Path,
542        project_key: &str,
543        files: &[PathBuf],
544    ) -> Result<(ColdBuildStats, String)> {
545        let generation = generation_file_name(project_key);
546        let gen_path = callgraph_dir.join(&generation);
547        let temp_path = callgraph_dir.join(format!(
548            "{generation}.tmp.{}.{}",
549            std::process::id(),
550            now_nanos()
551        ));
552        remove_sqlite_file_set(&temp_path);
553
554        let stats = {
555            let temp_store = Self::open_at_path(
556                project_root.to_path_buf(),
557                project_key.to_string(),
558                temp_path.clone(),
559                None,
560                false,
561            )?;
562            let stats = temp_store.cold_build(files)?;
563            temp_store.prepare_for_atomic_swap()?;
564            stats
565        };
566
567        // Move the finished build to its final generation path. This target is
568        // brand-new and owned by us, so the rename never hits an open file.
569        remove_sqlite_file_set(&gen_path);
570        std::fs::rename(&temp_path, &gen_path)?;
571        remove_sqlite_sidecars(&gen_path);
572
573        notify_cold_build_swap_observer(&temp_path, &gen_path);
574
575        // Atomically publish the new generation, then best-effort GC old ones.
576        publish_pointer(callgraph_dir, project_key, &generation)?;
577        gc_old_generations(callgraph_dir, project_key, &generation);
578        Ok((stats, generation))
579    }
580
581    /// Open a specific just-published generation (read-write, WAL) so a builder
582    /// returns a store pinned to exactly what it built.
583    fn open_generation(
584        callgraph_dir: &Path,
585        project_root: PathBuf,
586        project_key: String,
587        generation: String,
588    ) -> Result<Self> {
589        let gen_path = callgraph_dir.join(&generation);
590        Self::open_at_path(project_root, project_key, gen_path, Some(generation), true)
591    }
592
593    pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
594        let project_key = crate::search_index::project_cache_key(project_root);
595        // A cold build is needed unless a ready generation (or ready legacy DB)
596        // is currently published.
597        Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
598    }
599
600    fn open_at_path(
601        project_root: PathBuf,
602        project_key: String,
603        sqlite_path: PathBuf,
604        generation: Option<String>,
605        use_wal: bool,
606    ) -> Result<Self> {
607        if let Some(parent) = sqlite_path.parent() {
608            std::fs::create_dir_all(parent)?;
609        }
610        let conn = Connection::open(&sqlite_path)?;
611        if use_wal {
612            configure_connection(&conn)?;
613        } else {
614            configure_build_connection(&conn)?;
615        }
616        initialize_schema(&conn)?;
617        Ok(Self::from_connection(
618            project_root,
619            project_key,
620            sqlite_path,
621            generation,
622            conn,
623        ))
624    }
625
626    fn prepare_for_atomic_swap(&self) -> Result<()> {
627        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
628        conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;")?;
629        Ok(())
630    }
631
632    fn from_connection(
633        project_root: PathBuf,
634        project_key: String,
635        sqlite_path: PathBuf,
636        generation: Option<String>,
637        conn: Connection,
638    ) -> Self {
639        Self {
640            project_root,
641            project_key,
642            sqlite_path,
643            generation,
644            conn: Mutex::new(conn),
645        }
646    }
647
648    pub fn project_root(&self) -> &Path {
649        &self.project_root
650    }
651
652    pub fn project_key(&self) -> &str {
653        &self.project_key
654    }
655
656    pub fn sqlite_path(&self) -> &Path {
657        &self.sqlite_path
658    }
659
660    /// True if this store still reflects the currently-published generation.
661    /// Cheap (one small pointer-file read). When false, another process (or a
662    /// local cold rebuild) has published a newer generation and the holder
663    /// should drop this store and reopen via the pointer to converge. A missing
664    /// pointer keeps the current store (legacy DB still valid, or transient).
665    pub fn is_current(&self) -> bool {
666        let Some(dir) = self.sqlite_path.parent() else {
667            return true;
668        };
669        match (read_pointer(dir, &self.project_key), &self.generation) {
670            (Some(published), Some(opened)) => &published == opened,
671            // A generation now supersedes the legacy single-file DB we opened.
672            (Some(_), None) => false,
673            // No pointer: keep serving (legacy DB, or an anomalous pointer
674            // removal where our open generation file is still valid).
675            (None, _) => true,
676        }
677    }
678
679    pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
680        let started = Instant::now();
681        let bench = std::env::var("AFT_BENCH_COLD").is_ok();
682        macro_rules! phase {
683            ($label:expr, $t:expr) => {
684                if bench {
685                    eprintln!("  cold_build[{}]: {} ms", $label, $t.elapsed().as_millis());
686                    let _ = std::io::Write::flush(&mut std::io::stderr());
687                }
688            };
689        }
690        let files = normalize_file_list(&self.project_root, files)?;
691        let t = Instant::now();
692        let build = build_extracts_parallel(&self.project_root, &files);
693        phase!("extract_parallel", t);
694        let extracts = build.extracts;
695        let failures = build.failures;
696        let node_count = extracts.iter().map(|extract| extract.nodes.len()).sum();
697
698        let t = Instant::now();
699        let index = ProjectIndex::from_extracts(&self.project_root, &extracts);
700        phase!("build_index", t);
701        let t = Instant::now();
702        let mut resolved_refs = Vec::new();
703        for extract in &extracts {
704            for raw_ref in &extract.raw_refs {
705                resolved_refs.push(resolve_ref(raw_ref.clone(), &index)?);
706            }
707        }
708        phase!("resolve_refs", t);
709        let ref_count = resolved_refs.len();
710        let edge_count = resolved_refs
711            .iter()
712            .filter(|item| item.edge.is_some())
713            .count();
714
715        let t = Instant::now();
716        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
717        let tx = conn.transaction()?;
718        clear_tables(&tx)?;
719        insert_meta(&tx)?;
720        for extract in &extracts {
721            insert_file_extract(&tx, &self.project_root, extract)?;
722        }
723        for failure in &failures {
724            mark_backend_state(
725                &tx,
726                &self.project_root,
727                &failure.rel_path,
728                failure
729                    .freshness
730                    .as_ref()
731                    .map(|freshness| &freshness.content_hash),
732                "stale",
733            )?;
734        }
735        for resolved in &resolved_refs {
736            insert_resolved_ref(&tx, resolved)?;
737        }
738        let supplemental_edge_count = insert_method_dispatch_edges(&tx, &self.project_root, None)?;
739        set_meta_ready(&tx, true)?;
740        tx.commit()?;
741        phase!("sqlite_insert", t);
742
743        Ok(ColdBuildStats {
744            files: extracts.len(),
745            nodes: node_count,
746            refs: ref_count,
747            edges: edge_count + supplemental_edge_count,
748            failed_files: failures
749                .into_iter()
750                .map(|failure| failure.rel_path)
751                .collect(),
752            elapsed_ms: started.elapsed().as_millis(),
753        })
754    }
755
756    pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
757        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
758        let tx = conn.transaction()?;
759        ensure_database_ready(&tx)?;
760        let mut changed = Vec::new();
761        let mut surface_changed = BTreeSet::new();
762        let mut deleted = BTreeSet::new();
763        let mut own_refresh = BTreeSet::new();
764        let mut selected_ref_ids = BTreeSet::new();
765        let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
766
767        for input in changed_files {
768            let abs_path = normalize_file_path(&self.project_root, input)?;
769            let rel_path = relative_path(&self.project_root, &abs_path);
770            changed.push(rel_path.clone());
771            let old_row = load_file_row(&tx, &rel_path)?;
772            if !abs_path.exists() {
773                if old_row.is_some() {
774                    surface_changed.insert(rel_path.clone());
775                    deleted.insert(rel_path.clone());
776                    selected_ref_ids.extend(ref_ids_depending_on(
777                        &tx,
778                        &self.project_root,
779                        &rel_path,
780                    )?);
781                    delete_file_rows(&tx, &rel_path)?;
782                    mark_backend_state(&tx, &self.project_root, &rel_path, None, "stale")?;
783                }
784                continue;
785            }
786
787            if let Some(row) = &old_row {
788                match cache_freshness::verify_file(&abs_path, &row.freshness) {
789                    FreshnessVerdict::HotFresh => continue,
790                    FreshnessVerdict::ContentFresh {
791                        new_mtime,
792                        new_size,
793                    } => {
794                        update_file_fresh_metadata(
795                            &tx,
796                            &rel_path,
797                            &row.freshness.content_hash,
798                            new_mtime,
799                            new_size,
800                        )?;
801                        continue;
802                    }
803                    FreshnessVerdict::Deleted => {
804                        surface_changed.insert(rel_path.clone());
805                        deleted.insert(rel_path.clone());
806                        selected_ref_ids.extend(ref_ids_depending_on(
807                            &tx,
808                            &self.project_root,
809                            &rel_path,
810                        )?);
811                        delete_file_rows(&tx, &rel_path)?;
812                        mark_backend_state(&tx, &self.project_root, &rel_path, None, "stale")?;
813                        continue;
814                    }
815                    FreshnessVerdict::Stale => {}
816                }
817            }
818
819            let extract = build_file_extract(&self.project_root, &abs_path)?;
820            let surface_is_changed = old_row
821                .as_ref()
822                .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
823                .unwrap_or(true);
824            if surface_is_changed {
825                surface_changed.insert(rel_path.clone());
826                selected_ref_ids.extend(ref_ids_depending_on(&tx, &self.project_root, &rel_path)?);
827            }
828            own_refresh.insert(rel_path.clone());
829            delete_file_rows(&tx, &rel_path)?;
830            insert_file_extract(&tx, &self.project_root, &extract)?;
831            changed_extracts.insert(rel_path, extract);
832        }
833
834        let dependency_selected_refs = selected_ref_ids.len();
835        let selected_refs_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)?;
836        let mut touched_callers: BTreeSet<String> =
837            selected_refs_by_caller.keys().cloned().collect();
838        touched_callers.extend(own_refresh.iter().cloned());
839
840        let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
841        for rel_path in &touched_callers {
842            if deleted.contains(rel_path) {
843                continue;
844            }
845            if let Some(extract) = changed_extracts.get(rel_path) {
846                caller_extracts.insert(rel_path.clone(), extract.clone());
847                continue;
848            }
849            let abs_path = self.project_root.join(rel_path);
850            if abs_path.exists() {
851                let extract = build_file_extract(&self.project_root, &abs_path)?;
852                caller_extracts.insert(rel_path.clone(), extract);
853            }
854        }
855
856        let index = ProjectIndex::from_db_and_callers(&tx, &self.project_root, &caller_extracts)?;
857        for rel_path in &touched_callers {
858            if deleted.contains(rel_path) {
859                continue;
860            }
861            let Some(extract) = caller_extracts.get(rel_path) else {
862                continue;
863            };
864            if own_refresh.contains(rel_path) {
865                delete_refs_for_caller(&tx, rel_path)?;
866                for raw_ref in &extract.raw_refs {
867                    let resolved = resolve_ref(raw_ref.clone(), &index)?;
868                    insert_resolved_ref(&tx, &resolved)?;
869                }
870                continue;
871            }
872
873            let selected_for_caller = selected_refs_by_caller
874                .get(rel_path)
875                .cloned()
876                .unwrap_or_default();
877            delete_ref_ids(&tx, &selected_for_caller)?;
878            for raw_ref in &extract.raw_refs {
879                if selected_for_caller.contains(&raw_ref.ref_id) {
880                    let resolved = resolve_ref(raw_ref.clone(), &index)?;
881                    insert_resolved_ref(&tx, &resolved)?;
882                }
883            }
884        }
885
886        delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
887        insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
888
889        tx.commit()?;
890        Ok(IncrementalStats {
891            changed_files: changed,
892            surface_changed: surface_changed.into_iter().collect(),
893            deleted_files: deleted.into_iter().collect(),
894            dependency_selected_refs,
895            refreshed_own_files: own_refresh.len(),
896        })
897    }
898
899    pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
900        self.cold_build(current_files)
901    }
902
903    pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
904        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
905        let tx = conn.transaction()?;
906        let mut marked = Vec::new();
907        for path in files {
908            let abs_path = normalize_file_path(&self.project_root, path)?;
909            let rel_path = relative_path(&self.project_root, &abs_path);
910            let freshness = cache_freshness::collect(&abs_path).ok();
911            mark_backend_state(
912                &tx,
913                &self.project_root,
914                &rel_path,
915                freshness.as_ref().map(|freshness| &freshness.content_hash),
916                "stale",
917            )?;
918            marked.push(rel_path);
919        }
920        tx.commit()?;
921        marked.sort();
922        marked.dedup();
923        Ok(marked)
924    }
925
926    pub fn stale_files(&self) -> Result<Vec<String>> {
927        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
928        let mut stmt = conn.prepare(
929            "SELECT DISTINCT file_path FROM backend_file_state
930             WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
931             ORDER BY file_path",
932        )?;
933        let rows = stmt.query_map(
934            params![BACKEND_TREESITTER, self.project_root.display().to_string()],
935            |row| row.get::<_, String>(0),
936        )?;
937        rows.collect::<std::result::Result<Vec<_>, _>>()
938            .map_err(Into::into)
939    }
940
941    pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
942        let rel_path = relative_path(
943            &self.project_root,
944            &normalize_file_path(&self.project_root, file)?,
945        );
946        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
947        conn.query_row(
948            "SELECT status FROM backend_file_state
949             WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
950             ORDER BY updated_at DESC LIMIT 1",
951            params![
952                BACKEND_TREESITTER,
953                self.project_root.display().to_string(),
954                rel_path
955            ],
956            |row| row.get(0),
957        )
958        .optional()
959        .map_err(Into::into)
960    }
961
962    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
963        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
964        ensure_database_ready(&conn)?;
965        edge_snapshot_with_conn(&conn)
966    }
967
968    pub fn indexed_file_count(&self) -> Result<usize> {
969        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
970        ensure_database_ready(&conn)?;
971        indexed_file_count(&conn)
972    }
973
974    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
975        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
976        let rel_path = relative_path(&self.project_root, &abs_path);
977        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
978        ensure_database_ready(&conn)?;
979        resolve_node_for_rel(&conn, &rel_path, symbol)
980    }
981
982    /// Return all positional nodes matching a legacy symbol query in a file.
983    ///
984    /// Consumers that need legacy compatibility can collapse these by
985    /// `StoreNode::symbol` before deciding whether a query is ambiguous.
986    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
987        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
988        let rel_path = relative_path(&self.project_root, &abs_path);
989        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
990        ensure_database_ready(&conn)?;
991        nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
992    }
993
994    /// Return all positional nodes matching a symbol query anywhere in the store.
995    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
996        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
997        ensure_database_ready(&conn)?;
998        nodes_matching_symbol(&conn, symbol)
999    }
1000
1001    /// Return direct callers for an already-resolved `(file, scoped_symbol)` tuple.
1002    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
1003        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
1004        let rel_path = relative_path(&self.project_root, &abs_path);
1005        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1006        ensure_database_ready(&conn)?;
1007        direct_callers_for_tuple(&conn, &rel_path, symbol)
1008    }
1009
1010    pub fn callers_of(
1011        &self,
1012        file_rel: &Path,
1013        symbol: &str,
1014        depth: usize,
1015    ) -> Result<StoreCallersResult> {
1016        let target = self.node_for(file_rel, symbol)?;
1017        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1018        ensure_database_ready(&conn)?;
1019        let effective_depth = depth.max(1);
1020        let mut visited = HashSet::new();
1021        let mut callers = Vec::new();
1022        let mut depth_limited = false;
1023        let mut truncated = 0usize;
1024        collect_callers_recursive(
1025            &conn,
1026            &target.file,
1027            &target.symbol,
1028            effective_depth,
1029            0,
1030            &mut visited,
1031            &mut callers,
1032            &mut depth_limited,
1033            &mut truncated,
1034        )?;
1035        Ok(StoreCallersResult {
1036            target,
1037            callers,
1038            scanned_files: indexed_file_count(&conn)?,
1039            depth_limited,
1040            truncated,
1041        })
1042    }
1043
1044    pub fn impact_of(
1045        &self,
1046        file_rel: &Path,
1047        symbol: &str,
1048        depth: usize,
1049    ) -> Result<StoreImpactResult> {
1050        let callers = self.callers_of(file_rel, symbol, depth)?;
1051        let target_parameters = callers
1052            .target
1053            .signature
1054            .as_deref()
1055            .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
1056            .unwrap_or_default();
1057        let enriched = callers
1058            .callers
1059            .iter()
1060            .map(|site| StoreImpactCaller {
1061                site: site.clone(),
1062                signature: site.caller.signature.clone(),
1063                is_entry_point: site.caller.is_entry_point,
1064                call_expression: read_source_line(
1065                    &self.project_root.join(&site.caller.file),
1066                    site.line,
1067                ),
1068                parameters: site
1069                    .caller
1070                    .signature
1071                    .as_deref()
1072                    .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
1073                    .unwrap_or_default(),
1074            })
1075            .collect();
1076        Ok(StoreImpactResult {
1077            target: callers.target,
1078            parameters: target_parameters,
1079            callers: enriched,
1080            depth_limited: callers.depth_limited,
1081            truncated: callers.truncated,
1082        })
1083    }
1084
1085    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
1086        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1087        ensure_database_ready(&conn)?;
1088        outgoing_calls_for_node(&conn, node)
1089    }
1090
1091    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
1092        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1093        ensure_database_ready(&conn)?;
1094        unresolved_calls_for_node(&conn, node)
1095    }
1096
1097    pub fn call_tree(
1098        &self,
1099        file_rel: &Path,
1100        symbol: &str,
1101        max_depth: usize,
1102    ) -> Result<callgraph::CallTreeNode> {
1103        let node = self.node_for(file_rel, symbol)?;
1104        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1105        ensure_database_ready(&conn)?;
1106        let mut visited = HashSet::new();
1107        call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
1108    }
1109
1110    pub fn trace_to(
1111        &self,
1112        file_rel: &Path,
1113        symbol: &str,
1114        max_depth: usize,
1115    ) -> Result<callgraph::TraceToResult> {
1116        let target = self.node_for(file_rel, symbol)?;
1117        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1118        ensure_database_ready(&conn)?;
1119        let effective_max = if max_depth == 0 { 10 } else { max_depth };
1120
1121        #[derive(Clone)]
1122        struct PathElem {
1123            node: StoreNode,
1124        }
1125
1126        let initial = vec![PathElem {
1127            node: target.clone(),
1128        }];
1129        let mut complete_paths = Vec::new();
1130        if target.is_entry_point {
1131            complete_paths.push(initial.clone());
1132        }
1133
1134        let mut queue = vec![(initial, 0usize)];
1135        let mut max_depth_reached = false;
1136        let mut truncated_paths = 0usize;
1137
1138        while let Some((path, depth)) = queue.pop() {
1139            if depth >= effective_max {
1140                max_depth_reached = true;
1141                continue;
1142            }
1143            let Some(current) = path.last() else {
1144                continue;
1145            };
1146            let callers =
1147                direct_callers_for_tuple(&conn, &current.node.file, &current.node.symbol)?;
1148            if callers.is_empty() {
1149                if path.len() > 1 {
1150                    truncated_paths += 1;
1151                }
1152                continue;
1153            }
1154
1155            let mut has_new_path = false;
1156            for site in callers {
1157                if path.iter().any(|elem| {
1158                    elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
1159                }) {
1160                    continue;
1161                }
1162                has_new_path = true;
1163                let mut new_path = path.clone();
1164                new_path.push(PathElem {
1165                    node: site.caller.clone(),
1166                });
1167                if site.caller.is_entry_point {
1168                    complete_paths.push(new_path.clone());
1169                }
1170                queue.push((new_path, depth + 1));
1171            }
1172            if !has_new_path && path.len() > 1 {
1173                truncated_paths += 1;
1174            }
1175        }
1176
1177        let mut paths: Vec<callgraph::TracePath> = complete_paths
1178            .into_iter()
1179            .map(|mut elems| {
1180                elems.reverse();
1181                let hops = elems
1182                    .iter()
1183                    .enumerate()
1184                    .map(|(index, elem)| callgraph::TraceHop {
1185                        symbol: elem.node.symbol.clone(),
1186                        file: elem.node.file.clone(),
1187                        line: elem.node.line,
1188                        signature: elem.node.signature.clone(),
1189                        is_entry_point: index == 0 && elem.node.is_entry_point,
1190                    })
1191                    .collect();
1192                callgraph::TracePath { hops }
1193            })
1194            .collect();
1195        paths.sort_by(|left, right| {
1196            let left_entry = left
1197                .hops
1198                .first()
1199                .map(|hop| hop.symbol.as_str())
1200                .unwrap_or("");
1201            let right_entry = right
1202                .hops
1203                .first()
1204                .map(|hop| hop.symbol.as_str())
1205                .unwrap_or("");
1206            left_entry
1207                .cmp(right_entry)
1208                .then(left.hops.len().cmp(&right.hops.len()))
1209        });
1210        let entry_points_found = paths
1211            .iter()
1212            .filter_map(|path| path.hops.first())
1213            .filter(|hop| hop.is_entry_point)
1214            .map(|hop| (hop.file.clone(), hop.symbol.clone()))
1215            .collect::<HashSet<_>>()
1216            .len();
1217
1218        Ok(callgraph::TraceToResult {
1219            target_symbol: target.symbol,
1220            target_file: target.file,
1221            total_paths: paths.len(),
1222            paths,
1223            entry_points_found,
1224            max_depth_reached,
1225            truncated_paths,
1226        })
1227    }
1228
1229    pub fn trace_to_symbol_candidates(
1230        &self,
1231        to_symbol: &str,
1232    ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
1233        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1234        ensure_database_ready(&conn)?;
1235        let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
1236        for node in nodes_matching_symbol(&conn, to_symbol)? {
1237            candidates_by_file
1238                .entry(node.file)
1239                .and_modify(|line| *line = (*line).min(node.line))
1240                .or_insert(node.line);
1241        }
1242        let mut candidates: Vec<_> = candidates_by_file
1243            .into_iter()
1244            .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
1245            .collect();
1246        candidates
1247            .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
1248        Ok(candidates)
1249    }
1250
1251    pub fn trace_to_symbol(
1252        &self,
1253        file_rel: &Path,
1254        symbol: &str,
1255        to_symbol: &str,
1256        to_file: Option<&Path>,
1257        max_depth: usize,
1258    ) -> Result<callgraph::TraceToSymbolResult> {
1259        let origin = self.node_for(file_rel, symbol)?;
1260        let target_file = to_file
1261            .map(|path| normalize_file_path(&self.project_root, path))
1262            .transpose()?
1263            .map(|path| relative_path(&self.project_root, &path));
1264        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1265        ensure_database_ready(&conn)?;
1266        let effective_max = if max_depth == 0 {
1267            10
1268        } else {
1269            max_depth.min(16)
1270        };
1271
1272        let start_hop = trace_to_symbol_hop(&origin);
1273        if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
1274            return Ok(callgraph::TraceToSymbolResult {
1275                path: Some(vec![start_hop]),
1276                complete: true,
1277                reason: None,
1278            });
1279        }
1280
1281        let mut queue = VecDeque::new();
1282        queue.push_back((origin.clone(), vec![start_hop], 0usize));
1283        let mut visited = HashSet::new();
1284        visited.insert((origin.file.clone(), origin.symbol.clone()));
1285        let mut max_depth_exhausted = false;
1286
1287        while let Some((current, path, depth)) = queue.pop_front() {
1288            let callees = outgoing_calls_for_node(&conn, &current)?
1289                .into_iter()
1290                .filter_map(|site| site.target)
1291                .collect::<Vec<_>>();
1292
1293            if depth >= effective_max {
1294                if callees
1295                    .iter()
1296                    .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
1297                {
1298                    max_depth_exhausted = true;
1299                }
1300                continue;
1301            }
1302
1303            for callee in callees {
1304                if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
1305                    continue;
1306                }
1307                let mut next_path = path.clone();
1308                next_path.push(trace_to_symbol_hop(&callee));
1309                if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
1310                    return Ok(callgraph::TraceToSymbolResult {
1311                        path: Some(next_path),
1312                        complete: true,
1313                        reason: None,
1314                    });
1315                }
1316                queue.push_back((callee, next_path, depth + 1));
1317            }
1318        }
1319
1320        if max_depth_exhausted {
1321            Ok(callgraph::TraceToSymbolResult {
1322                path: None,
1323                complete: false,
1324                reason: Some("max_depth_exhausted".to_string()),
1325            })
1326        } else {
1327            Ok(callgraph::TraceToSymbolResult {
1328                path: None,
1329                complete: true,
1330                reason: Some("no_path_found".to_string()),
1331            })
1332        }
1333    }
1334}
1335
1336fn indexed_file_count(conn: &Connection) -> Result<usize> {
1337    let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
1338    Ok(count.max(0) as usize)
1339}
1340
1341fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
1342    let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
1343    match candidates.as_slice() {
1344        [candidate] => Ok(candidate.clone()),
1345        [] => Err(AftError::SymbolNotFound {
1346            name: symbol.to_string(),
1347            file: rel_path.to_string(),
1348        }
1349        .into()),
1350        _ => Err(AftError::AmbiguousSymbol {
1351            name: symbol.to_string(),
1352            candidates: candidates
1353                .iter()
1354                .map(|candidate| candidate.symbol.clone())
1355                .collect(),
1356        }
1357        .into()),
1358    }
1359}
1360
1361fn nodes_for_file_matching_symbol(
1362    conn: &Connection,
1363    rel_path: &str,
1364    symbol: &str,
1365) -> Result<Vec<StoreNode>> {
1366    let qualified_query = symbol.contains("::");
1367    let sql = if qualified_query {
1368        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
1369                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
1370         FROM nodes n JOIN files f ON f.path = n.file_path
1371         WHERE n.file_path = ?1 AND n.scoped_name = ?2
1372         ORDER BY n.scoped_name, n.start_line, n.start_col"
1373    } else {
1374        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
1375                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
1376         FROM nodes n JOIN files f ON f.path = n.file_path
1377         WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
1378         ORDER BY n.scoped_name, n.start_line, n.start_col"
1379    };
1380    let mut stmt = conn.prepare(sql)?;
1381    let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
1382    rows.collect::<std::result::Result<Vec<_>, _>>()
1383        .map_err(Into::into)
1384}
1385
1386fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
1387    let qualified_query = symbol.contains("::");
1388    let sql = if qualified_query {
1389        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
1390                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
1391         FROM nodes n JOIN files f ON f.path = n.file_path
1392         WHERE n.scoped_name = ?1
1393         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
1394    } else {
1395        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
1396                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
1397         FROM nodes n JOIN files f ON f.path = n.file_path
1398         WHERE n.scoped_name = ?1 OR n.name = ?1
1399         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
1400    };
1401    let mut stmt = conn.prepare(sql)?;
1402    let rows = stmt.query_map(params![symbol], store_node_from_row)?;
1403    rows.collect::<std::result::Result<Vec<_>, _>>()
1404        .map_err(Into::into)
1405}
1406
1407fn load_node_by_id(conn: &Connection, node_id: &str) -> Result<Option<StoreNode>> {
1408    conn.query_row(
1409        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
1410                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
1411         FROM nodes n JOIN files f ON f.path = n.file_path
1412         WHERE n.id = ?1",
1413        params![node_id],
1414        store_node_from_row,
1415    )
1416    .optional()
1417    .map_err(Into::into)
1418}
1419
1420fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
1421    let start_line: u32 = row.get::<_, i64>(5)?.max(0) as u32;
1422    let end_line: u32 = row.get::<_, i64>(6)?.max(0) as u32;
1423    let lang_label_value: String = row.get(10)?;
1424    Ok(StoreNode {
1425        node_id: row.get(0)?,
1426        file: row.get(1)?,
1427        symbol: row.get(2)?,
1428        name: row.get(3)?,
1429        kind: row.get(4)?,
1430        line: start_line.saturating_add(1),
1431        end_line: end_line.saturating_add(1),
1432        signature: row.get(7)?,
1433        exported: row.get::<_, i64>(8)? != 0,
1434        is_entry_point: row.get::<_, i64>(9)? != 0,
1435        lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
1436    })
1437}
1438
1439#[allow(clippy::too_many_arguments)]
1440fn collect_callers_recursive(
1441    conn: &Connection,
1442    file: &str,
1443    symbol: &str,
1444    max_depth: usize,
1445    current_depth: usize,
1446    visited: &mut HashSet<(String, String)>,
1447    result: &mut Vec<StoreCallSite>,
1448    depth_limited: &mut bool,
1449    truncated: &mut usize,
1450) -> Result<()> {
1451    if current_depth >= max_depth {
1452        let omitted = direct_callers_for_tuple(conn, file, symbol)?.len();
1453        if omitted > 0 {
1454            *depth_limited = true;
1455            *truncated += omitted;
1456        }
1457        return Ok(());
1458    }
1459
1460    if !visited.insert((file.to_string(), symbol.to_string())) {
1461        return Ok(());
1462    }
1463
1464    let sites = direct_callers_for_tuple(conn, file, symbol)?;
1465    for site in sites {
1466        result.push(site.clone());
1467        if current_depth + 1 < max_depth {
1468            collect_callers_recursive(
1469                conn,
1470                &site.caller.file,
1471                &site.caller.symbol,
1472                max_depth,
1473                current_depth + 1,
1474                visited,
1475                result,
1476                depth_limited,
1477                truncated,
1478            )?;
1479        } else {
1480            let omitted =
1481                direct_callers_for_tuple(conn, &site.caller.file, &site.caller.symbol)?.len();
1482            if omitted > 0 {
1483                *depth_limited = true;
1484                *truncated += omitted;
1485            }
1486        }
1487    }
1488    Ok(())
1489}
1490
1491fn direct_callers_for_tuple(
1492    conn: &Connection,
1493    target_file: &str,
1494    target_symbol: &str,
1495) -> Result<Vec<StoreCallSite>> {
1496    let mut stmt = conn.prepare(
1497        "SELECT e.source_node, e.target_node, e.target_file, e.target_symbol, e.line,
1498                r.byte_start, r.byte_end, r.status, e.provenance
1499         FROM edges e JOIN refs r ON r.ref_id = e.ref_id
1500         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
1501         ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
1502    )?;
1503    let rows = stmt.query_map(params![target_file, target_symbol], |row| {
1504        Ok((
1505            row.get::<_, String>(0)?,
1506            row.get::<_, Option<String>>(1)?,
1507            row.get::<_, String>(2)?,
1508            row.get::<_, String>(3)?,
1509            row.get::<_, i64>(4)?,
1510            row.get::<_, i64>(5)?,
1511            row.get::<_, i64>(6)?,
1512            row.get::<_, String>(7)?,
1513            row.get::<_, String>(8)?,
1514        ))
1515    })?;
1516
1517    let mut sites = Vec::new();
1518    for row in rows {
1519        let (
1520            source_node,
1521            target_node,
1522            target_file,
1523            target_symbol,
1524            line,
1525            byte_start,
1526            byte_end,
1527            status,
1528            provenance,
1529        ) = row?;
1530        let Some(caller) = load_node_by_id(conn, &source_node)? else {
1531            continue;
1532        };
1533        let target = target_node
1534            .as_deref()
1535            .map(|node_id| load_node_by_id(conn, node_id))
1536            .transpose()?
1537            .flatten();
1538        sites.push(StoreCallSite {
1539            caller,
1540            target_file,
1541            target_symbol,
1542            target,
1543            line: line.max(0) as u32,
1544            byte_start: byte_start.max(0) as usize,
1545            byte_end: byte_end.max(0) as usize,
1546            resolved: status == "resolved",
1547            provenance,
1548        });
1549    }
1550    Ok(sites)
1551}
1552
1553fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
1554    let mut stmt = conn.prepare(
1555        "SELECT e.target_node, e.target_file, e.target_symbol, e.line,
1556                r.byte_start, r.byte_end, r.status, e.provenance
1557         FROM edges e JOIN refs r ON r.ref_id = e.ref_id
1558         WHERE e.kind = 'call' AND e.source_node = ?1
1559         ORDER BY r.byte_start, r.line, r.ref_id",
1560    )?;
1561    let rows = stmt.query_map(params![node.node_id], |row| {
1562        Ok((
1563            row.get::<_, Option<String>>(0)?,
1564            row.get::<_, String>(1)?,
1565            row.get::<_, String>(2)?,
1566            row.get::<_, i64>(3)?,
1567            row.get::<_, i64>(4)?,
1568            row.get::<_, i64>(5)?,
1569            row.get::<_, String>(6)?,
1570            row.get::<_, String>(7)?,
1571        ))
1572    })?;
1573
1574    let mut calls = Vec::new();
1575    for row in rows {
1576        let (
1577            target_node,
1578            target_file,
1579            target_symbol,
1580            line,
1581            byte_start,
1582            byte_end,
1583            status,
1584            provenance,
1585        ) = row?;
1586        let target = target_node
1587            .as_deref()
1588            .map(|node_id| load_node_by_id(conn, node_id))
1589            .transpose()?
1590            .flatten();
1591        calls.push(StoreCallSite {
1592            caller: node.clone(),
1593            target_file,
1594            target_symbol,
1595            target,
1596            line: line.max(0) as u32,
1597            byte_start: byte_start.max(0) as usize,
1598            byte_end: byte_end.max(0) as usize,
1599            resolved: status == "resolved",
1600            provenance,
1601        });
1602    }
1603    Ok(calls)
1604}
1605
1606fn unresolved_calls_for_node(
1607    conn: &Connection,
1608    node: &StoreNode,
1609) -> Result<Vec<StoreUnresolvedCall>> {
1610    let mut stmt = conn.prepare(
1611        "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
1612         FROM refs
1613         WHERE caller_node = ?1
1614           AND kind = 'call'
1615           AND status = 'unresolved'
1616           AND NOT EXISTS (
1617               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
1618           )
1619         ORDER BY byte_start, line, ref_id",
1620    )?;
1621    let rows = stmt.query_map(params![node.node_id], |row| {
1622        Ok(StoreUnresolvedCall {
1623            caller: node.clone(),
1624            symbol: row.get(0)?,
1625            full_ref: row.get(1)?,
1626            line: row.get::<_, i64>(2)?.max(0) as u32,
1627            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
1628            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
1629        })
1630    })?;
1631    rows.collect::<std::result::Result<Vec<_>, _>>()
1632        .map_err(Into::into)
1633}
1634
1635fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
1636    let mut calls = Vec::new();
1637    calls.extend(
1638        outgoing_calls_for_node(conn, node)?
1639            .into_iter()
1640            .map(StoreForwardCall::Resolved),
1641    );
1642    calls.extend(
1643        unresolved_calls_for_node(conn, node)?
1644            .into_iter()
1645            .map(StoreForwardCall::Unresolved),
1646    );
1647    calls.sort_by(|left, right| {
1648        left.byte_start()
1649            .cmp(&right.byte_start())
1650            .then(left.line().cmp(&right.line()))
1651    });
1652    Ok(calls)
1653}
1654
1655fn call_tree_inner(
1656    conn: &Connection,
1657    node: &StoreNode,
1658    max_depth: usize,
1659    current_depth: usize,
1660    visited: &mut HashSet<(String, String)>,
1661) -> Result<callgraph::CallTreeNode> {
1662    let visit_key = (node.file.clone(), node.symbol.clone());
1663    if visited.contains(&visit_key) {
1664        return Ok(callgraph::CallTreeNode {
1665            name: node.symbol.clone(),
1666            file: node.file.clone(),
1667            line: node.line,
1668            signature: node.signature.clone(),
1669            resolved: true,
1670            children: Vec::new(),
1671            depth_limited: false,
1672            truncated: 0,
1673        });
1674    }
1675    visited.insert(visit_key.clone());
1676
1677    let calls = forward_calls_for_node(conn, node)?;
1678    let mut children = Vec::new();
1679    let mut depth_limited = false;
1680    let mut truncated = 0usize;
1681
1682    if current_depth < max_depth {
1683        for call in calls {
1684            match call {
1685                StoreForwardCall::Resolved(site) => {
1686                    if let Some(target) = site.target {
1687                        let child =
1688                            call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
1689                        depth_limited |= child.depth_limited;
1690                        truncated += child.truncated;
1691                        children.push(child);
1692                    } else {
1693                        children.push(callgraph::CallTreeNode {
1694                            name: site.target_symbol,
1695                            file: site.target_file,
1696                            line: site.line,
1697                            signature: None,
1698                            resolved: false,
1699                            children: Vec::new(),
1700                            depth_limited: false,
1701                            truncated: 0,
1702                        });
1703                    }
1704                }
1705                StoreForwardCall::Unresolved(call) => {
1706                    children.push(callgraph::CallTreeNode {
1707                        name: call.symbol,
1708                        file: call.caller.file,
1709                        line: call.line,
1710                        signature: None,
1711                        resolved: false,
1712                        children: Vec::new(),
1713                        depth_limited: false,
1714                        truncated: 0,
1715                    });
1716                }
1717            }
1718        }
1719    } else if !calls.is_empty() {
1720        depth_limited = true;
1721        truncated = calls.len();
1722    }
1723
1724    visited.remove(&visit_key);
1725    Ok(callgraph::CallTreeNode {
1726        name: node.symbol.clone(),
1727        file: node.file.clone(),
1728        line: node.line,
1729        signature: node.signature.clone(),
1730        resolved: true,
1731        children,
1732        depth_limited,
1733        truncated,
1734    })
1735}
1736
1737fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
1738    callgraph::TraceToSymbolHop {
1739        symbol: node.symbol.clone(),
1740        file: node.file.clone(),
1741        line: node.line,
1742    }
1743}
1744
1745fn trace_to_symbol_matches_target(
1746    node: &StoreNode,
1747    to_symbol: &str,
1748    to_file: Option<&str>,
1749) -> bool {
1750    if !symbol_query_matches(&node.symbol, to_symbol) {
1751        return false;
1752    }
1753    match to_file {
1754        Some(file) => node.file == file,
1755        None => true,
1756    }
1757}
1758
1759fn symbol_query_matches(symbol: &str, query: &str) -> bool {
1760    symbol == query || unqualified_name(symbol) == query
1761}
1762
1763fn read_source_line(path: &Path, line: u32) -> Option<String> {
1764    let source = std::fs::read_to_string(path).ok()?;
1765    source
1766        .lines()
1767        .nth(line.saturating_sub(1) as usize)
1768        .map(|line| line.trim().to_string())
1769}
1770
1771#[doc(hidden)]
1772pub fn live_callgraph_edge_snapshot(
1773    project_root: &Path,
1774    files: &[PathBuf],
1775) -> Result<BTreeSet<StoredEdge>> {
1776    let files = normalize_file_list(project_root, files)?;
1777    let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
1778    let mut file_data = Vec::new();
1779    for file in &files {
1780        let canon = canonicalize_path(file);
1781        let data = graph.build_file(&canon)?.clone();
1782        file_data.push((canon, data));
1783    }
1784
1785    let mut edges = BTreeSet::new();
1786    for (caller_file, data) in &file_data {
1787        for (caller_symbol, call_sites) in &data.calls_by_symbol {
1788            for call_site in call_sites {
1789                let resolution = graph.resolve_cross_file_edge(
1790                    &call_site.full_callee,
1791                    &call_site.callee_name,
1792                    caller_file,
1793                    &data.import_block,
1794                );
1795                let (target_file, target_symbol) = match resolution {
1796                    EdgeResolution::Resolved { file, symbol } => (file, symbol),
1797                    EdgeResolution::Unresolved { callee_name } => {
1798                        if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
1799                            continue;
1800                        }
1801                        let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
1802                            data,
1803                            caller_file,
1804                            &callee_name,
1805                        ) else {
1806                            continue;
1807                        };
1808                        (caller_file.clone(), target_symbol)
1809                    }
1810                };
1811                if target_file == *caller_file && target_symbol == *caller_symbol {
1812                    continue;
1813                }
1814                edges.insert(StoredEdge {
1815                    source_file: relative_path(project_root, caller_file),
1816                    source_symbol: caller_symbol.clone(),
1817                    target_file: relative_path(project_root, &target_file),
1818                    target_symbol,
1819                    kind: "call".to_string(),
1820                    line: call_site.line,
1821                });
1822            }
1823        }
1824    }
1825    Ok(edges)
1826}
1827
1828fn configure_connection(conn: &Connection) -> Result<()> {
1829    conn.pragma_update(None, "journal_mode", "WAL")?;
1830    conn.pragma_update(None, "busy_timeout", 5_000)?;
1831    Ok(())
1832}
1833
1834fn configure_build_connection(conn: &Connection) -> Result<()> {
1835    conn.pragma_update(None, "journal_mode", "DELETE")?;
1836    conn.pragma_update(None, "busy_timeout", 5_000)?;
1837    Ok(())
1838}
1839
1840fn initialize_schema(conn: &Connection) -> Result<()> {
1841    conn.execute_batch(
1842        "CREATE TABLE IF NOT EXISTS files (
1843            path                TEXT PRIMARY KEY,
1844            content_hash        TEXT NOT NULL,
1845            mtime_ns            INTEGER NOT NULL,
1846            size                INTEGER NOT NULL,
1847            lang                TEXT NOT NULL,
1848            is_dead_code_root   INTEGER NOT NULL DEFAULT 0,
1849            is_public_api       INTEGER NOT NULL DEFAULT 0,
1850            surface_fingerprint TEXT NOT NULL,
1851            indexed_at          INTEGER NOT NULL
1852        );
1853
1854        CREATE TABLE IF NOT EXISTS nodes (
1855            id                         TEXT PRIMARY KEY,
1856            file_path                  TEXT NOT NULL,
1857            name                       TEXT NOT NULL,
1858            scoped_name                TEXT NOT NULL,
1859            kind                       TEXT NOT NULL,
1860            start_line                 INTEGER NOT NULL,
1861            start_col                  INTEGER NOT NULL,
1862            end_line                   INTEGER NOT NULL,
1863            end_col                    INTEGER NOT NULL,
1864            range_ordinal              INTEGER NOT NULL,
1865            signature                  TEXT,
1866            exported                   INTEGER NOT NULL,
1867            is_default_export          INTEGER NOT NULL,
1868            is_type_like               INTEGER NOT NULL,
1869            is_callgraph_entry_point   INTEGER NOT NULL,
1870            provenance                 TEXT NOT NULL,
1871            UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
1872        );
1873        CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
1874        CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
1875        CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
1876
1877        CREATE TABLE IF NOT EXISTS refs (
1878            ref_id          TEXT PRIMARY KEY,
1879            caller_node     TEXT,
1880            caller_file     TEXT NOT NULL,
1881            kind            TEXT NOT NULL,
1882            short_name      TEXT,
1883            full_ref        TEXT,
1884            module_path     TEXT,
1885            import_kind     TEXT,
1886            local_name      TEXT,
1887            requested_name  TEXT,
1888            namespace_alias TEXT,
1889            wildcard        INTEGER NOT NULL DEFAULT 0,
1890            line            INTEGER NOT NULL,
1891            byte_start      INTEGER NOT NULL,
1892            byte_end        INTEGER NOT NULL,
1893            status          TEXT NOT NULL,
1894            target_node     TEXT,
1895            target_file     TEXT,
1896            target_symbol   TEXT,
1897            provenance      TEXT NOT NULL
1898        );
1899        CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
1900        CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
1901        CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
1902        CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
1903
1904        CREATE TABLE IF NOT EXISTS file_dependencies (
1905            file_path   TEXT NOT NULL,
1906            dep_file    TEXT NOT NULL,
1907            PRIMARY KEY(file_path, dep_file)
1908        );
1909        CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
1910
1911        CREATE TABLE IF NOT EXISTS edges (
1912            edge_id       TEXT PRIMARY KEY,
1913            ref_id        TEXT NOT NULL,
1914            source_node   TEXT NOT NULL,
1915            target_node   TEXT,
1916            target_file   TEXT NOT NULL,
1917            target_symbol TEXT NOT NULL,
1918            kind          TEXT NOT NULL,
1919            line          INTEGER NOT NULL,
1920            provenance    TEXT NOT NULL
1921        );
1922        CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
1923        CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
1924        CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
1925        CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
1926
1927        CREATE TABLE IF NOT EXISTS dispatch_hints (
1928            id           TEXT PRIMARY KEY,
1929            method_name  TEXT NOT NULL,
1930            caller_node  TEXT NOT NULL,
1931            file         TEXT NOT NULL,
1932            line         INTEGER NOT NULL,
1933            byte_start   INTEGER NOT NULL,
1934            byte_end     INTEGER NOT NULL,
1935            provenance   TEXT NOT NULL
1936        );
1937        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
1938
1939        CREATE TABLE IF NOT EXISTS type_ref_names (
1940            name TEXT PRIMARY KEY
1941        );
1942
1943        CREATE TABLE IF NOT EXISTS backend_file_state (
1944            backend        TEXT NOT NULL,
1945            workspace_root TEXT NOT NULL,
1946            file_path      TEXT NOT NULL,
1947            content_hash   TEXT NOT NULL,
1948            status         TEXT NOT NULL,
1949            updated_at     INTEGER NOT NULL,
1950            PRIMARY KEY(backend, workspace_root, file_path, content_hash)
1951        );
1952        CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
1953
1954        CREATE TABLE IF NOT EXISTS meta (
1955            k TEXT PRIMARY KEY,
1956            v TEXT NOT NULL
1957        );",
1958    )?;
1959    insert_meta(conn)?;
1960    Ok(())
1961}
1962
1963fn insert_meta(conn: &Connection) -> Result<()> {
1964    conn.execute(
1965        "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
1966        params![SCHEMA_VERSION.to_string()],
1967    )?;
1968    conn.execute(
1969        "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
1970        params![schema_fingerprint()],
1971    )?;
1972    Ok(())
1973}
1974
1975fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
1976    conn.execute(
1977        "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
1978        params![if ready { "1" } else { "0" }],
1979    )?;
1980    Ok(())
1981}
1982
1983fn database_ready(conn: &Connection) -> Result<bool> {
1984    let schema_version: Option<String> = conn
1985        .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
1986            row.get(0)
1987        })
1988        .optional()?;
1989    let fingerprint: Option<String> = conn
1990        .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
1991            row.get(0)
1992        })
1993        .optional()?;
1994    let ready: Option<String> = conn
1995        .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
1996        .optional()?;
1997
1998    let expected_schema = SCHEMA_VERSION.to_string();
1999    let expected_fingerprint = schema_fingerprint();
2000    Ok(schema_version.as_deref() == Some(expected_schema.as_str())
2001        && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
2002        && ready.as_deref() == Some("1"))
2003}
2004
2005fn ensure_database_ready(conn: &Connection) -> Result<()> {
2006    if database_ready(conn)? {
2007        Ok(())
2008    } else {
2009        Err(CallGraphStoreError::Unavailable(
2010            "database is missing, stale, or mid-build".to_string(),
2011        ))
2012    }
2013}
2014
2015fn schema_fingerprint() -> String {
2016    // Bump the trailing content-version whenever the BUILD OUTPUT changes (new
2017    // edge sources, broader call extraction) even if the table SHAPE is
2018    // unchanged, so existing on-disk stores rebuild and pick up the new edges.
2019    // v6 -> v7-lean: file-level dependency rows and structured refs without raw JSON payloads.
2020    let input = format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v7-lean");
2021    hash_to_hex(blake3::hash(input.as_bytes()))
2022}
2023
2024fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
2025    tx.execute_batch(
2026        "DELETE FROM edges;
2027         DELETE FROM file_dependencies;
2028         DELETE FROM refs;
2029         DELETE FROM dispatch_hints;
2030         DELETE FROM type_ref_names;
2031         DELETE FROM backend_file_state;
2032         DELETE FROM nodes;
2033         DELETE FROM files;",
2034    )?;
2035    Ok(())
2036}
2037
2038/// Nanosecond clock used to make temp/generation file names unique.
2039fn now_nanos() -> u128 {
2040    SystemTime::now()
2041        .duration_since(UNIX_EPOCH)
2042        .unwrap_or(Duration::ZERO)
2043        .as_nanos()
2044}
2045
2046/// The pointer file `<dir>/<key>.current`. Its single line names the current
2047/// generation DB file. ONLY Rust std ever opens this file (never SQLite), so it
2048/// can always be atomically replaced via rename even on Windows — Rust opens
2049/// files with `FILE_SHARE_DELETE`, unlike SQLite's Win32 VFS.
2050fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
2051    callgraph_dir.join(format!("{project_key}.current"))
2052}
2053
2054/// The legacy single-file DB path used before the generation scheme. Still read
2055/// as a fallback so pre-upgrade on-disk stores keep working until the next cold
2056/// build publishes a generation.
2057fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
2058    callgraph_dir.join(format!("{project_key}.sqlite"))
2059}
2060
2061/// A fresh, unique generation file NAME: `<key>.g<nanos>.<pid>.sqlite`. Each
2062/// cold build writes a brand-new generation file, so publishing NEVER replaces
2063/// a file another process holds open (the root Windows fix).
2064fn generation_file_name(project_key: &str) -> String {
2065    format!(
2066        "{project_key}.g{}.{}.sqlite",
2067        now_nanos(),
2068        std::process::id()
2069    )
2070}
2071
2072/// Read the pointer; returns the generation file name if present and non-empty.
2073fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
2074    let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
2075    let name = text.trim();
2076    if name.is_empty() {
2077        None
2078    } else {
2079        Some(name.to_string())
2080    }
2081}
2082
2083/// True if the DB at `path` opens and reports ready (schema + fingerprint + the
2084/// `ready` flag). Uses a throwaway read-only connection.
2085fn db_path_ready(path: &Path) -> bool {
2086    (|| -> Result<bool> {
2087        let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
2088        conn.busy_timeout(Duration::from_millis(5_000))?;
2089        database_ready(&conn)
2090    })()
2091    .unwrap_or(false)
2092}
2093
2094/// Resolve the DB file a reader/opener should use, returning `(path, generation)`
2095/// where `generation` is `Some(name)` for a pointer-published generation or
2096/// `None` for the legacy single-file DB. Returns `None` when nothing ready is
2097/// published (caller treats that as "needs cold build").
2098///
2099/// Handles the GC race (the pointer names a generation that was just deleted) by
2100/// re-reading the pointer and retrying a few times.
2101fn resolve_ready_target(
2102    callgraph_dir: &Path,
2103    project_key: &str,
2104) -> Option<(PathBuf, Option<String>)> {
2105    for _ in 0..5 {
2106        if let Some(generation) = read_pointer(callgraph_dir, project_key) {
2107            let gen_path = callgraph_dir.join(&generation);
2108            if gen_path.is_file() {
2109                return db_path_ready(&gen_path).then_some((gen_path, Some(generation)));
2110            }
2111            // Pointer names a missing generation (a GC/publish race): re-read the
2112            // pointer and retry rather than failing the reader.
2113            std::thread::sleep(Duration::from_millis(5));
2114            continue;
2115        }
2116        // No pointer: fall back to the legacy single-file DB if it is ready.
2117        let legacy = legacy_sqlite_path(callgraph_dir, project_key);
2118        return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
2119    }
2120    None
2121}
2122
2123/// Atomically publish `generation` as the current store by flipping the pointer
2124/// file. Writes a temp file, fsyncs, then renames over the pointer — never
2125/// replacing an open DB file, so it succeeds cross-platform.
2126fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
2127    let pointer = pointer_path(callgraph_dir, project_key);
2128    let tmp = callgraph_dir.join(format!(
2129        "{project_key}.current.tmp.{}.{}",
2130        std::process::id(),
2131        now_nanos()
2132    ));
2133    {
2134        use std::io::Write as _;
2135        let mut file = std::fs::File::create(&tmp)?;
2136        file.write_all(generation.as_bytes())?;
2137        file.write_all(b"\n")?;
2138        file.sync_all()?;
2139    }
2140    if let Err(error) = std::fs::rename(&tmp, &pointer) {
2141        let _ = std::fs::remove_file(&tmp);
2142        return Err(error.into());
2143    }
2144    Ok(())
2145}
2146
2147/// Best-effort GC of superseded generation files. Never touches the current
2148/// generation; keeps the most-recent previous generation (so an in-flight
2149/// reader that resolved just before a flip can still open it) and a 60s grace
2150/// window for the rest. Deletion failures (e.g. a still-open generation on
2151/// Windows) are ignored and retried on a later build.
2152fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
2153    let grace = Duration::from_secs(60);
2154    let now = SystemTime::now();
2155    let gen_prefix = format!("{project_key}.g");
2156    let tmp_prefixes = [
2157        format!("{project_key}.g"), // generation build temps (<key>.g...sqlite.tmp.*)
2158        format!("{project_key}.current."), // pointer publish temps (<key>.current.tmp.*)
2159        format!("{project_key}.sqlite.tmp."), // legacy-scheme build temps
2160    ];
2161    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
2162        return;
2163    };
2164    let mut gens: Vec<(PathBuf, SystemTime)> = Vec::new();
2165    for entry in entries.flatten() {
2166        let name = entry.file_name();
2167        let name = name.to_string_lossy();
2168        let mtime = entry
2169            .metadata()
2170            .and_then(|m| m.modified())
2171            .unwrap_or_else(|_| SystemTime::now());
2172        let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= grace;
2173
2174        // Orphaned temp files from a crashed build/publish: remove once aged out.
2175        if name.contains(".tmp.") {
2176            if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
2177                let _ = std::fs::remove_file(entry.path());
2178            }
2179            continue;
2180        }
2181
2182        // Superseded legacy single-file DB: best-effort delete once a generation
2183        // is published (ignored if another process still holds it open).
2184        if *name == *format!("{project_key}.sqlite") {
2185            remove_sqlite_file_set(&entry.path());
2186            continue;
2187        }
2188
2189        if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") && name != current {
2190            gens.push((entry.path(), mtime));
2191        }
2192    }
2193    // Keep the newest superseded generation as a safety net for readers that
2194    // resolved the pointer just before the flip; GC the rest after the grace
2195    // window. Deletion of a still-open generation (Windows) fails silently and
2196    // is retried on a later build.
2197    gens.sort_by(|a, b| b.1.cmp(&a.1));
2198    for (index, (path, mtime)) in gens.into_iter().enumerate() {
2199        if index == 0 {
2200            continue;
2201        }
2202        if now.duration_since(mtime).unwrap_or(Duration::ZERO) < grace {
2203            continue;
2204        }
2205        remove_sqlite_file_set(&path);
2206    }
2207}
2208
2209fn remove_sqlite_file_set(path: &Path) {
2210    let _ = std::fs::remove_file(path);
2211    remove_sqlite_sidecars(path);
2212}
2213
2214fn remove_sqlite_sidecars(path: &Path) {
2215    let path_text = path.to_string_lossy();
2216    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
2217    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
2218    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
2219}
2220
2221/// Bound the cold-build's tree-sitter pass to half the cores (cap 8) instead of
2222/// the global all-cores rayon pool. The store cold-build is the heaviest
2223/// background pass (parse-dominated) and runs on a separate thread off the
2224/// single-threaded request loop; left unbounded it monopolizes every core and
2225/// starves the bridge so interactive tools time out (the same starvation the
2226/// v0.35 embedder and the inspect Tier-2 pool already cap). 8MB worker stacks
2227/// match the main thread, since the extract walks tree-sitter ASTs.
2228fn build_pool_size() -> usize {
2229    std::thread::available_parallelism()
2230        .map(|parallelism| parallelism.get())
2231        .unwrap_or(1)
2232        .div_ceil(2)
2233        .clamp(1, 8)
2234}
2235
2236fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
2237    let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
2238        Ok(extract) => Ok(extract),
2239        Err(error) => {
2240            let abs_path =
2241                normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
2242            let rel_path = relative_path(project_root, &abs_path);
2243            let freshness = cache_freshness::collect(&abs_path).ok();
2244            log::debug!(
2245                "callgraph store: skipping {} during cold build: {}",
2246                abs_path.display(),
2247                error
2248            );
2249            Err(ExtractFailure {
2250                rel_path,
2251                freshness,
2252            })
2253        }
2254    };
2255
2256    let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
2257        files.par_iter().map(extract_one).collect()
2258    };
2259
2260    // Run inside a dedicated bounded pool when one builds; fall back to the
2261    // global pool only if the bounded pool can't be constructed.
2262    let results = match rayon::ThreadPoolBuilder::new()
2263        .num_threads(build_pool_size())
2264        .thread_name(|index| format!("aft-callgraph-build-{index}"))
2265        .stack_size(8 * 1024 * 1024)
2266        .build()
2267    {
2268        Ok(pool) => pool.install(run),
2269        Err(error) => {
2270            log::warn!(
2271                "callgraph store: bounded build pool unavailable ({error}); using global pool"
2272            );
2273            run()
2274        }
2275    };
2276
2277    let mut extracts = Vec::new();
2278    let mut failures = Vec::new();
2279    for result in results {
2280        match result {
2281            Ok(extract) => extracts.push(extract),
2282            Err(failure) => failures.push(failure),
2283        }
2284    }
2285    BuildExtractsResult { extracts, failures }
2286}
2287
2288fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
2289    let abs_path = normalize_file_path(project_root, path)?;
2290    let rel_path = relative_path(project_root, &abs_path);
2291    let source = std::fs::read_to_string(&abs_path)?;
2292    let freshness = cache_freshness::collect(&abs_path)?;
2293    let data = callgraph::build_file_data(&abs_path)?;
2294    let lang = data.lang;
2295    let mut nodes = build_node_records(&rel_path, &source, &data)?;
2296    let node_by_scoped: HashMap<String, String> = nodes
2297        .iter()
2298        .map(|node| (node.scoped_name.clone(), node.id.clone()))
2299        .collect();
2300    let import_dependencies =
2301        import_dependencies(project_root, &abs_path, &data.import_block.imports);
2302    let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
2303    let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
2304    let mut raw_refs = Vec::new();
2305    raw_refs.extend(build_call_refs(
2306        &rel_path,
2307        &data,
2308        &node_by_scoped,
2309        &import_dependencies,
2310    ));
2311    raw_refs.extend(build_import_refs(
2312        project_root,
2313        &abs_path,
2314        &rel_path,
2315        &data.import_block.imports,
2316    ));
2317    let mut surface_parts = reexports.surface_parts;
2318    surface_parts.extend(source_less_exports.surface_parts);
2319    raw_refs.extend(reexports.raw_refs);
2320    raw_refs.extend(source_less_exports.raw_refs);
2321    let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
2322    let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
2323
2324    Ok(FileExtract {
2325        abs_path,
2326        rel_path,
2327        freshness,
2328        lang,
2329        data,
2330        nodes,
2331        raw_refs,
2332        dispatch_hints,
2333        surface_fingerprint,
2334    })
2335}
2336
2337fn build_node_records(
2338    rel_path: &str,
2339    source: &str,
2340    data: &FileCallData,
2341) -> Result<Vec<NodeRecord>> {
2342    let mut records = Vec::new();
2343    let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
2344    let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
2345    metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
2346
2347    for (scoped_name, meta) in metadata {
2348        let name = unqualified_name(scoped_name).to_string();
2349        let range = selection_range(source, scoped_name, &name, &meta.range);
2350        let range_key = (
2351            range.start_line,
2352            range.start_col,
2353            range.end_line,
2354            range.end_col,
2355        );
2356        let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
2357        let range_ordinal = *ordinal;
2358        *ordinal += 1;
2359        let id = node_id(rel_path, &range, range_ordinal, scoped_name);
2360        let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
2361        let is_default_export = data
2362            .default_export_symbol
2363            .as_deref()
2364            .map(|default| default == scoped_name || default == name)
2365            .unwrap_or(false);
2366        records.push(NodeRecord {
2367            id,
2368            file_path: rel_path.to_string(),
2369            name: name.clone(),
2370            scoped_name: scoped_name.clone(),
2371            kind: symbol_kind_label(&meta.kind).to_string(),
2372            range,
2373            range_ordinal,
2374            signature: meta.signature.clone(),
2375            exported,
2376            is_default_export,
2377            is_type_like: is_type_like(&meta.kind),
2378            is_callgraph_entry_point: callgraph::is_entry_point(
2379                scoped_name,
2380                &meta.kind,
2381                exported,
2382                data.lang,
2383            ),
2384        });
2385    }
2386
2387    Ok(records)
2388}
2389
2390fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
2391    if scoped_name == TOP_LEVEL_SYMBOL {
2392        return Range {
2393            start_line: 0,
2394            start_col: 0,
2395            end_line: 0,
2396            end_col: 0,
2397        };
2398    }
2399    let Some(line) = source.lines().nth(fallback.start_line as usize) else {
2400        return fallback.clone();
2401    };
2402    let start_col = fallback.start_col as usize;
2403    let search_start = start_col.min(line.len());
2404    if let Some(offset) = line[search_start..].find(name) {
2405        let col = search_start + offset;
2406        return Range {
2407            start_line: fallback.start_line,
2408            start_col: col as u32,
2409            end_line: fallback.start_line,
2410            end_col: (col + name.len()) as u32,
2411        };
2412    }
2413    if let Some(offset) = line.find(name) {
2414        return Range {
2415            start_line: fallback.start_line,
2416            start_col: offset as u32,
2417            end_line: fallback.start_line,
2418            end_col: (offset + name.len()) as u32,
2419        };
2420    }
2421    Range {
2422        start_line: fallback.start_line,
2423        start_col: fallback.start_col,
2424        end_line: fallback.start_line,
2425        end_col: fallback.start_col.saturating_add(name.len() as u32),
2426    }
2427}
2428
2429fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
2430    if scoped_name == TOP_LEVEL_SYMBOL {
2431        return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
2432    }
2433    let input = format!(
2434        "{rel_path}:{}:{}:{}:{}:{ordinal}",
2435        range.start_line, range.start_col, range.end_line, range.end_col
2436    );
2437    format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
2438}
2439
2440fn build_call_refs(
2441    rel_path: &str,
2442    data: &FileCallData,
2443    node_by_scoped: &HashMap<String, String>,
2444    import_dependencies: &BTreeSet<String>,
2445) -> Vec<RawRef> {
2446    let mut refs = Vec::new();
2447    let mut ordinal = 0usize;
2448    let mut symbols: Vec<_> = data.calls_by_symbol.iter().collect();
2449    symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
2450    for (caller_symbol, call_sites) in symbols {
2451        let caller_node = node_by_scoped.get(caller_symbol).cloned();
2452        for call_site in call_sites {
2453            ordinal += 1;
2454            let ref_id = ref_id(&[
2455                rel_path,
2456                "call",
2457                caller_symbol,
2458                &call_site.line.to_string(),
2459                &call_site.byte_start.to_string(),
2460                &call_site.byte_end.to_string(),
2461                &call_site.full_callee,
2462                &ordinal.to_string(),
2463            ]);
2464            refs.push(RawRef {
2465                ref_id,
2466                caller_node: caller_node.clone(),
2467                caller_symbol: Some(caller_symbol.clone()),
2468                caller_file: rel_path.to_string(),
2469                kind: "call".to_string(),
2470                short_name: Some(call_site.callee_name.clone()),
2471                full_ref: Some(call_site.full_callee.clone()),
2472                module_path: None,
2473                import_kind: None,
2474                local_name: Some(call_site.callee_name.clone()),
2475                requested_name: Some(call_site.callee_name.clone()),
2476                namespace_alias: namespace_alias(&call_site.full_callee),
2477                wildcard: false,
2478                line: call_site.line,
2479                byte_start: call_site.byte_start,
2480                byte_end: call_site.byte_end,
2481                dependencies: import_dependencies.clone(),
2482            });
2483        }
2484    }
2485    refs
2486}
2487
2488fn build_import_refs(
2489    project_root: &Path,
2490    abs_path: &Path,
2491    rel_path: &str,
2492    imports: &[ImportStatement],
2493) -> Vec<RawRef> {
2494    let mut refs = Vec::new();
2495    for (index, import) in imports.iter().enumerate() {
2496        let import_kind = import_kind_label(import.kind).to_string();
2497        let local_name = import_local_names(import).join(",");
2498        let requested_name = import_requested_names(import).join(",");
2499        let ref_id = ref_id(&[
2500            rel_path,
2501            "import",
2502            &import.byte_range.start.to_string(),
2503            &import.byte_range.end.to_string(),
2504            &import.module_path,
2505            &index.to_string(),
2506        ]);
2507        refs.push(RawRef {
2508            ref_id,
2509            caller_node: None,
2510            caller_symbol: None,
2511            caller_file: rel_path.to_string(),
2512            kind: "import".to_string(),
2513            short_name: None,
2514            full_ref: Some(import.raw_text.clone()),
2515            module_path: Some(import.module_path.clone()),
2516            import_kind: Some(import_kind),
2517            local_name: empty_to_none(local_name),
2518            requested_name: empty_to_none(requested_name),
2519            namespace_alias: import.namespace_import.clone(),
2520            wildcard: import_is_wildcard(import),
2521            line: byte_to_line(abs_path, import.byte_range.start).unwrap_or(1),
2522            byte_start: import.byte_range.start,
2523            byte_end: import.byte_range.end,
2524            dependencies: module_dependencies(project_root, abs_path, &import.module_path),
2525        });
2526    }
2527    refs
2528}
2529
2530#[derive(Debug, Clone)]
2531struct ReexportRefs {
2532    raw_refs: Vec<RawRef>,
2533    surface_parts: Vec<String>,
2534}
2535
2536fn collect_reexport_refs(
2537    project_root: &Path,
2538    abs_path: &Path,
2539    rel_path: &str,
2540    source: &str,
2541) -> ReexportRefs {
2542    let mut raw_refs = Vec::new();
2543    let mut surface_parts = Vec::new();
2544    let mut search_start = 0usize;
2545    let mut ordinal = 0usize;
2546    while let Some(export_offset) = source[search_start..].find("export") {
2547        let start = search_start + export_offset;
2548        let Some(statement_end_offset) = source[start..].find(';') else {
2549            break;
2550        };
2551        let end = start + statement_end_offset + 1;
2552        let statement = &source[start..end];
2553        search_start = end;
2554        if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
2555            continue;
2556        }
2557        let Some(module_path) = quoted_module_path(statement) else {
2558            continue;
2559        };
2560        ordinal += 1;
2561        let wildcard = statement.contains('*');
2562        let line = source[..start]
2563            .bytes()
2564            .filter(|byte| *byte == b'\n')
2565            .count() as u32
2566            + 1;
2567        let ref_id = ref_id(&[
2568            rel_path,
2569            "reexport",
2570            &start.to_string(),
2571            &end.to_string(),
2572            &module_path,
2573            &ordinal.to_string(),
2574        ]);
2575        surface_parts.push(format!("reexport\t{statement}"));
2576        raw_refs.push(RawRef {
2577            ref_id,
2578            caller_node: None,
2579            caller_symbol: None,
2580            caller_file: rel_path.to_string(),
2581            kind: "reexport".to_string(),
2582            short_name: None,
2583            full_ref: Some(statement.to_string()),
2584            module_path: Some(module_path.clone()),
2585            import_kind: Some("reexport".to_string()),
2586            local_name: None,
2587            requested_name: None,
2588            namespace_alias: None,
2589            wildcard,
2590            line,
2591            byte_start: start,
2592            byte_end: end,
2593            dependencies: module_dependencies(project_root, abs_path, &module_path),
2594        });
2595    }
2596    ReexportRefs {
2597        raw_refs,
2598        surface_parts,
2599    }
2600}
2601
2602fn quoted_module_path(statement: &str) -> Option<String> {
2603    let quote = match (statement.find('\''), statement.find('"')) {
2604        (Some(single), Some(double)) if single < double => '\'',
2605        (Some(_), Some(_)) => '"',
2606        (Some(_), None) => '\'',
2607        (None, Some(_)) => '"',
2608        (None, None) => return None,
2609    };
2610    let start = statement.find(quote)? + 1;
2611    let end = statement[start..].find(quote)? + start;
2612    Some(statement[start..end].to_string())
2613}
2614
2615#[derive(Debug, Clone)]
2616struct SourceLessExportRefs {
2617    raw_refs: Vec<RawRef>,
2618    surface_parts: Vec<String>,
2619}
2620
2621fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
2622    let mut raw_refs = Vec::new();
2623    let mut surface_parts = Vec::new();
2624    let mut search_start = 0usize;
2625    let mut ordinal = 0usize;
2626    while let Some(export_offset) = source[search_start..].find("export") {
2627        let start = search_start + export_offset;
2628        let Some(statement_end_offset) = source[start..].find(';') else {
2629            break;
2630        };
2631        let end = start + statement_end_offset + 1;
2632        let statement = &source[start..end];
2633        search_start = end;
2634        if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
2635            continue;
2636        }
2637        let aliases = parse_reexport_names(statement);
2638        if aliases.is_empty() {
2639            continue;
2640        }
2641        let line = source[..start]
2642            .bytes()
2643            .filter(|byte| *byte == b'\n')
2644            .count() as u32
2645            + 1;
2646        for (exported, source_symbol) in aliases {
2647            ordinal += 1;
2648            let ref_id = ref_id(&[
2649                rel_path,
2650                "export_alias",
2651                &start.to_string(),
2652                &end.to_string(),
2653                &exported,
2654                &source_symbol,
2655                &ordinal.to_string(),
2656            ]);
2657            surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
2658            raw_refs.push(RawRef {
2659                ref_id,
2660                caller_node: None,
2661                caller_symbol: None,
2662                caller_file: rel_path.to_string(),
2663                kind: "export_alias".to_string(),
2664                short_name: None,
2665                full_ref: Some(statement.to_string()),
2666                module_path: None,
2667                import_kind: Some("export_alias".to_string()),
2668                local_name: Some(exported),
2669                requested_name: Some(source_symbol),
2670                namespace_alias: None,
2671                wildcard: false,
2672                line,
2673                byte_start: start,
2674                byte_end: end,
2675                dependencies: BTreeSet::new(),
2676            });
2677        }
2678    }
2679    SourceLessExportRefs {
2680        raw_refs,
2681        surface_parts,
2682    }
2683}
2684
2685fn build_dispatch_hints(
2686    rel_path: &str,
2687    data: &FileCallData,
2688    node_by_scoped: &HashMap<String, String>,
2689) -> Vec<DispatchHint> {
2690    let mut hints = Vec::new();
2691    let mut ordinal = 0usize;
2692    for (caller_symbol, call_sites) in &data.calls_by_symbol {
2693        let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
2694            continue;
2695        };
2696        for call_site in call_sites {
2697            if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
2698                continue;
2699            }
2700            ordinal += 1;
2701            hints.push(DispatchHint {
2702                id: ref_id(&[
2703                    rel_path,
2704                    "dispatch",
2705                    caller_symbol,
2706                    &call_site.line.to_string(),
2707                    &call_site.byte_start.to_string(),
2708                    &call_site.byte_end.to_string(),
2709                    &ordinal.to_string(),
2710                ]),
2711                method_name: call_site.callee_name.clone(),
2712                caller_node: caller_node.clone(),
2713                file: rel_path.to_string(),
2714                line: call_site.line,
2715                byte_start: call_site.byte_start,
2716                byte_end: call_site.byte_end,
2717            });
2718        }
2719    }
2720    hints
2721}
2722
2723fn surface_fingerprint(
2724    nodes: &mut [NodeRecord],
2725    data: &FileCallData,
2726    reexport_parts: &[String],
2727) -> String {
2728    nodes.sort_by(|left, right| {
2729        (left.file_path.as_str(), left.scoped_name.as_str())
2730            .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
2731    });
2732    let mut parts = Vec::new();
2733    for node in nodes.iter() {
2734        parts.push(format!(
2735            "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
2736            node.scoped_name,
2737            node.name,
2738            node.kind,
2739            node.exported,
2740            node.range.start_line,
2741            node.range.start_col,
2742            node.range.end_line,
2743            node.range.end_col,
2744            node.range_ordinal,
2745            node.signature.as_deref().unwrap_or("")
2746        ));
2747    }
2748    let mut exports = data.exported_symbols.clone();
2749    exports.sort();
2750    for export in exports {
2751        parts.push(format!("export\t{export}"));
2752    }
2753    if let Some(default_export) = &data.default_export_symbol {
2754        parts.push(format!("default\t{default_export}"));
2755    }
2756    let mut imports: Vec<String> = data
2757        .import_block
2758        .imports
2759        .iter()
2760        .map(|import| {
2761            format!(
2762                "import\t{}\t{:?}\t{}",
2763                import.module_path, import.form, import.raw_text
2764            )
2765        })
2766        .collect();
2767    imports.sort();
2768    parts.extend(imports);
2769    parts.extend(reexport_parts.iter().cloned());
2770    hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
2771}
2772
2773fn resolve_ref(raw: RawRef, index: &ProjectIndex<'_>) -> Result<ResolvedRef> {
2774    if raw.kind != "call" {
2775        return Ok(ResolvedRef {
2776            dependencies: raw.dependencies.clone(),
2777            raw,
2778            status: "unresolved".to_string(),
2779            target_node: None,
2780            target_file: None,
2781            target_symbol: None,
2782            edge: None,
2783        });
2784    }
2785
2786    let caller_file = raw.caller_file.clone();
2787    let caller_data = index.caller_data.get(&caller_file).ok_or_else(|| {
2788        CallGraphStoreError::MissingCallerData {
2789            file: caller_file.clone(),
2790        }
2791    })?;
2792    let full_ref = raw.full_ref.as_deref().unwrap_or_default();
2793    let short_name = raw.short_name.as_deref().unwrap_or_default();
2794    let mut dependencies = raw.dependencies.clone();
2795
2796    let resolved = match index.lang_for(&caller_file) {
2797        Some(LangId::Rust) => {
2798            resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data)
2799        }
2800        Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
2801            resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
2802        }
2803        _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
2804    };
2805
2806    let Some((status, target_file, target_symbol)) = resolved else {
2807        return Ok(ResolvedRef {
2808            raw,
2809            status: "unresolved".to_string(),
2810            target_node: None,
2811            target_file: None,
2812            target_symbol: None,
2813            dependencies,
2814            edge: None,
2815        });
2816    };
2817
2818    dependencies.insert(target_file.clone());
2819    let target_node = index.node_for_symbol(&target_file, &target_symbol);
2820    let source_node = raw.caller_node.clone();
2821    let edge = if let Some(source_node) = source_node {
2822        if target_file == caller_file
2823            && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
2824        {
2825            None
2826        } else {
2827            Some(EdgeRecord {
2828                edge_id: ref_id(&[&raw.ref_id, "edge"]),
2829                source_node,
2830                target_node: target_node.clone(),
2831                target_file: target_file.clone(),
2832                target_symbol: target_symbol.clone(),
2833                kind: "call".to_string(),
2834                line: raw.line,
2835            })
2836        }
2837    } else {
2838        None
2839    };
2840
2841    Ok(ResolvedRef {
2842        raw,
2843        status,
2844        target_node,
2845        target_file: Some(target_file),
2846        target_symbol: Some(target_symbol),
2847        dependencies,
2848        edge,
2849    })
2850}
2851
2852fn resolve_js_ts_target(
2853    index: &ProjectIndex<'_>,
2854    caller_file: &str,
2855    full_ref: &str,
2856    short_name: &str,
2857    caller_data: &FileCallData,
2858) -> Option<(String, String, String)> {
2859    if let Some((namespace, member)) = full_ref.split_once('.') {
2860        for import in &caller_data.import_block.imports {
2861            if import.namespace_import.as_deref() == Some(namespace) {
2862                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
2863                    if let Some((file, symbol)) =
2864                        resolve_exported_symbol(index, &target_file, member, 0)
2865                    {
2866                        return Some(("resolved".to_string(), file, symbol));
2867                    }
2868                }
2869            }
2870        }
2871    }
2872
2873    for import in &caller_data.import_block.imports {
2874        for spec in &import.names {
2875            if crate::imports::specifier_local_name(spec) == short_name {
2876                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
2877                    let requested = crate::imports::specifier_imported_name(spec);
2878                    let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
2879                        .unwrap_or_else(|| (target_file, requested.to_string()));
2880                    return Some(("resolved".to_string(), file, symbol));
2881                }
2882            }
2883        }
2884
2885        if import.default_import.as_deref() == Some(short_name) {
2886            if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
2887                let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
2888                    .or_else(|| {
2889                        index
2890                            .files
2891                            .get(&target_file)
2892                            .and_then(|file| file.default_export.clone())
2893                            .map(|symbol| (target_file.clone(), symbol))
2894                    })
2895                    .unwrap_or_else(|| {
2896                        let file_name = Path::new(&target_file)
2897                            .file_name()
2898                            .and_then(|name| name.to_str())
2899                            .unwrap_or("unknown")
2900                            .to_string();
2901                        (target_file, format!("<default:{file_name}>"))
2902                    });
2903                return Some(("resolved".to_string(), file, symbol));
2904            }
2905        }
2906    }
2907
2908    for import in &caller_data.import_block.imports {
2909        if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
2910            if index
2911                .files
2912                .get(&target_file)
2913                .map(|file| file.exports.contains(short_name))
2914                .unwrap_or(false)
2915            {
2916                return Some(("resolved".to_string(), target_file, short_name.to_string()));
2917            }
2918        }
2919    }
2920
2921    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
2922}
2923
2924fn resolve_exported_symbol(
2925    index: &ProjectIndex<'_>,
2926    file: &str,
2927    requested: &str,
2928    depth: usize,
2929) -> Option<(String, String)> {
2930    if depth > 16 {
2931        return None;
2932    }
2933    if requested != "default" {
2934        if let Some(source_symbol) = index
2935            .files
2936            .get(file)
2937            .and_then(|item| item.export_aliases.get(requested))
2938        {
2939            return Some((file.to_string(), source_symbol.clone()));
2940        }
2941        if index
2942            .files
2943            .get(file)
2944            .map(|item| item.exports.contains(requested))
2945            .unwrap_or(false)
2946        {
2947            return Some((file.to_string(), requested.to_string()));
2948        }
2949    } else if let Some(default) = index
2950        .files
2951        .get(file)
2952        .and_then(|item| item.default_export.clone())
2953    {
2954        return Some((file.to_string(), default));
2955    }
2956
2957    for reexport in index.reexports_for(file) {
2958        let mut next_requested = requested.to_string();
2959        let matches = if reexport.wildcard {
2960            true
2961        } else if let Some(source_name) = reexport.named.get(requested) {
2962            next_requested = source_name.clone();
2963            true
2964        } else {
2965            false
2966        };
2967        if !matches {
2968            continue;
2969        }
2970        if let Some(target_file) = &reexport.target_file {
2971            if let Some(target) =
2972                resolve_exported_symbol(index, target_file, &next_requested, depth + 1)
2973            {
2974                return Some(target);
2975            }
2976        }
2977    }
2978    None
2979}
2980
2981fn resolve_rust_target(
2982    index: &ProjectIndex<'_>,
2983    caller_file: &str,
2984    full_ref: &str,
2985    short_name: &str,
2986    caller_data: &FileCallData,
2987) -> Option<(String, String, String)> {
2988    if full_ref.contains("::") {
2989        if let Some(target_file) = rust_target_for_qualified(index, caller_file, full_ref) {
2990            return Some((
2991                "resolved".to_string(),
2992                target_file,
2993                rust_target_symbol(full_ref, short_name),
2994            ));
2995        }
2996    }
2997
2998    for import in &caller_data.import_block.imports {
2999        if let Some((target_file, target_symbol)) =
3000            rust_target_for_use(index, caller_file, import, short_name)
3001        {
3002            return Some(("resolved".to_string(), target_file, target_symbol));
3003        }
3004    }
3005
3006    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
3007}
3008
3009fn rust_target_for_qualified(
3010    index: &ProjectIndex<'_>,
3011    caller_file: &str,
3012    full_ref: &str,
3013) -> Option<String> {
3014    let mut segments: Vec<&str> = full_ref.split("::").collect();
3015    if segments.len() < 2 {
3016        return None;
3017    }
3018    segments.pop();
3019    if !matches!(segments.first().copied(), Some("crate" | "self" | "super")) {
3020        if let Some(target) = rust_workspace_file_for_segments(index, &segments) {
3021            return Some(target);
3022        }
3023    }
3024    let module_segments = rust_resolve_segments(caller_file, &segments)?;
3025    rust_file_for_segments(index, caller_file, &module_segments)
3026}
3027
3028fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
3029    full_ref
3030        .rsplit("::")
3031        .next()
3032        .filter(|name| !name.is_empty())
3033        .unwrap_or(short_name)
3034        .to_string()
3035}
3036
3037fn rust_target_for_use(
3038    index: &ProjectIndex<'_>,
3039    caller_file: &str,
3040    import: &ImportStatement,
3041    short_name: &str,
3042) -> Option<(String, String)> {
3043    let path = import.module_path.trim().trim_end_matches(';');
3044    if let Some(brace_start) = path.find("::{") {
3045        let prefix = &path[..brace_start];
3046        if import.names.iter().any(|name| name == short_name) {
3047            let prefix_segments: Vec<&str> = prefix.split("::").collect();
3048            let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
3049            let file = rust_file_for_segments(index, caller_file, &module_segments)?;
3050            return Some((file, short_name.to_string()));
3051        }
3052        return None;
3053    }
3054
3055    let (path_without_alias, alias) = path
3056        .split_once(" as ")
3057        .map(|(left, right)| (left.trim(), Some(right.trim())))
3058        .unwrap_or((path, None));
3059    let segments: Vec<&str> = path_without_alias.split("::").collect();
3060    let imported = alias.or_else(|| segments.last().copied())?;
3061    if imported != short_name {
3062        return None;
3063    }
3064    if segments.len() < 2 {
3065        return None;
3066    }
3067    let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
3068    let file = rust_file_for_segments(index, caller_file, &module_segments)?;
3069    Some((file, segments.last().unwrap_or(&short_name).to_string()))
3070}
3071
3072fn rust_workspace_file_for_segments(index: &ProjectIndex<'_>, segments: &[&str]) -> Option<String> {
3073    let crate_name = segments.first().copied()?;
3074    let src_prefix = index.crate_src_prefix(crate_name)?;
3075    let module_segments = segments[1..]
3076        .iter()
3077        .map(|segment| segment.to_string())
3078        .collect::<Vec<_>>();
3079    rust_file_for_src_prefix(index, &src_prefix, &module_segments)
3080}
3081
3082/// Walk the project tree once and map every Rust crate name (package name with
3083/// `-` normalized to `_`, plus any explicit `[lib] name`) to its `src` prefix.
3084/// Replaces the previous per-ref tree walk: resolving 600k+ qualified refs no
3085/// longer re-walks the filesystem once per ref.
3086fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
3087    let mut prefixes = HashMap::new();
3088    let mut stack = vec![project_root.to_path_buf()];
3089    while let Some(dir) = stack.pop() {
3090        let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
3091        if matches!(name, "target" | "node_modules" | ".git") {
3092            continue;
3093        }
3094        let manifest = dir.join("Cargo.toml");
3095        if manifest.is_file() {
3096            let crate_names = rust_manifest_crate_names(&manifest);
3097            if !crate_names.is_empty() {
3098                let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
3099                for crate_name in crate_names {
3100                    prefixes
3101                        .entry(crate_name)
3102                        .or_insert_with(|| src_prefix.clone());
3103                }
3104            }
3105        }
3106        let Ok(entries) = std::fs::read_dir(&dir) else {
3107            continue;
3108        };
3109        for entry in entries.flatten() {
3110            let path = entry.path();
3111            if path.is_dir() {
3112                stack.push(path);
3113            }
3114        }
3115    }
3116    prefixes
3117}
3118
3119/// Extract the crate names a manifest defines: the normalized package name
3120/// (`-` -> `_`) and any explicit `[lib] name`. Returns both so a crate is
3121/// reachable by either spelling, matching the previous match semantics.
3122fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
3123    let Ok(source) = std::fs::read_to_string(manifest) else {
3124        return Vec::new();
3125    };
3126    let mut in_lib = false;
3127    let mut package_name = None;
3128    let mut lib_name = None;
3129    for line in source.lines() {
3130        let trimmed = line.trim();
3131        if trimmed.starts_with('[') {
3132            in_lib = trimmed == "[lib]";
3133            continue;
3134        }
3135        let Some((key, value)) = trimmed.split_once('=') else {
3136            continue;
3137        };
3138        let key = key.trim();
3139        let value = value.trim().trim_matches('"');
3140        if in_lib && key == "name" {
3141            lib_name = Some(value.to_string());
3142        } else if !in_lib && key == "name" && package_name.is_none() {
3143            package_name = Some(value.to_string());
3144        }
3145    }
3146    let mut names = Vec::new();
3147    if let Some(lib) = lib_name {
3148        names.push(lib);
3149    }
3150    if let Some(package) = package_name {
3151        let normalized = package.replace('-', "_");
3152        if !names.contains(&normalized) {
3153            names.push(normalized);
3154        }
3155    }
3156    names
3157}
3158
3159fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
3160    if segments.is_empty() {
3161        return Some(Vec::new());
3162    }
3163    let caller_segments = rust_module_segments_for_rel(caller_file);
3164    match segments[0] {
3165        "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
3166        "self" => {
3167            let mut resolved = caller_segments;
3168            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
3169            Some(resolved)
3170        }
3171        "super" => {
3172            let mut resolved = caller_segments;
3173            resolved.pop();
3174            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
3175            Some(resolved)
3176        }
3177        _ => {
3178            let mut resolved = caller_segments;
3179            resolved.pop();
3180            resolved.extend(segments.iter().map(|item| item.to_string()));
3181            Some(resolved)
3182        }
3183    }
3184}
3185
3186fn rust_file_for_segments(
3187    index: &ProjectIndex<'_>,
3188    caller_file: &str,
3189    segments: &[String],
3190) -> Option<String> {
3191    rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
3192}
3193
3194fn rust_file_for_src_prefix(
3195    index: &ProjectIndex<'_>,
3196    src_prefix: &str,
3197    segments: &[String],
3198) -> Option<String> {
3199    let candidate = if segments.is_empty() {
3200        [src_prefix, "lib.rs"].join("/")
3201    } else {
3202        format!("{}/{}.rs", src_prefix, segments.join("/"))
3203    };
3204    if index.files.contains_key(&candidate) {
3205        return Some(candidate);
3206    }
3207    if !segments.is_empty() {
3208        let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
3209        if index.files.contains_key(&mod_candidate) {
3210            return Some(mod_candidate);
3211        }
3212    }
3213    None
3214}
3215
3216fn rust_src_prefix(rel_path: &str) -> String {
3217    rel_path
3218        .split_once("/src/")
3219        .map(|(prefix, _)| format!("{prefix}/src"))
3220        .unwrap_or_else(|| "src".to_string())
3221}
3222
3223fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
3224    let after_src = rel_path
3225        .split_once("/src/")
3226        .map(|(_, rest)| rest)
3227        .or_else(|| rel_path.strip_prefix("src/"))
3228        .unwrap_or(rel_path);
3229    if matches!(after_src, "lib.rs" | "main.rs") {
3230        return Vec::new();
3231    }
3232    if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
3233        return prefix.split('/').map(|item| item.to_string()).collect();
3234    }
3235    after_src
3236        .strip_suffix(".rs")
3237        .unwrap_or(after_src)
3238        .split('/')
3239        .map(|item| item.to_string())
3240        .collect()
3241}
3242
3243fn resolve_local_target(
3244    _index: &ProjectIndex<'_>,
3245    caller_file: &str,
3246    full_ref: &str,
3247    short_name: &str,
3248    caller_data: &FileCallData,
3249) -> Option<(String, String, String)> {
3250    if !callgraph::is_bare_callee(full_ref, short_name) {
3251        return None;
3252    }
3253    callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
3254        .ok()
3255        .map(|symbol| {
3256            (
3257                "resolved_local".to_string(),
3258                caller_file.to_string(),
3259                symbol,
3260            )
3261        })
3262}
3263
3264impl<'a> ProjectIndex<'a> {
3265    fn from_extracts(project_root: &Path, extracts: &'a [FileExtract]) -> Self {
3266        let mut files = HashMap::new();
3267        let mut caller_data = HashMap::new();
3268        for extract in extracts {
3269            let index = DbFileIndex::from_extract(project_root, extract);
3270            caller_data.insert(extract.rel_path.clone(), &extract.data);
3271            files.insert(extract.rel_path.clone(), index);
3272        }
3273        Self {
3274            project_root: project_root.to_path_buf(),
3275            files,
3276            caller_data,
3277            workspace_crate_prefixes: std::sync::OnceLock::new(),
3278        }
3279    }
3280
3281    fn from_db_and_callers(
3282        tx: &Transaction<'_>,
3283        project_root: &Path,
3284        caller_extracts: &'a HashMap<String, FileExtract>,
3285    ) -> Result<Self> {
3286        let mut files = load_db_file_indexes(tx, project_root)?;
3287        let mut caller_data = HashMap::new();
3288        for (rel_path, extract) in caller_extracts {
3289            files.insert(
3290                rel_path.clone(),
3291                DbFileIndex::from_extract(project_root, extract),
3292            );
3293            caller_data.insert(rel_path.clone(), &extract.data);
3294        }
3295        Ok(Self {
3296            project_root: project_root.to_path_buf(),
3297            files,
3298            caller_data,
3299            workspace_crate_prefixes: std::sync::OnceLock::new(),
3300        })
3301    }
3302
3303    fn lang_for(&self, rel_path: &str) -> Option<LangId> {
3304        self.files.get(rel_path).and_then(|file| file.lang)
3305    }
3306
3307    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
3308        self.files
3309            .get(caller_file)
3310            .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
3311    }
3312
3313    fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
3314        self.files
3315            .get(rel_path)
3316            .map(|file| file.reexports.as_slice())
3317            .unwrap_or(&[])
3318    }
3319
3320    fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
3321        self.files.get(rel_path).and_then(|file| {
3322            file.node_by_scoped
3323                .get(symbol)
3324                .cloned()
3325                .or_else(|| file.node_by_bare.get(symbol).cloned())
3326        })
3327    }
3328}
3329
3330impl DbFileIndex {
3331    fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
3332        let mut node_by_scoped = HashMap::new();
3333        let mut node_by_bare = HashMap::new();
3334        for node in &extract.nodes {
3335            node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
3336            node_by_bare
3337                .entry(node.name.clone())
3338                .or_insert(node.id.clone());
3339        }
3340        let mut export_aliases = HashMap::new();
3341        for raw_ref in &extract.raw_refs {
3342            if raw_ref.kind == "export_alias" {
3343                if let (Some(exported), Some(source_symbol)) =
3344                    (&raw_ref.local_name, &raw_ref.requested_name)
3345                {
3346                    export_aliases.insert(exported.clone(), source_symbol.clone());
3347                }
3348            }
3349        }
3350        let mut module_targets = HashMap::new();
3351        for import in &extract.data.import_block.imports {
3352            module_targets.insert(
3353                import.module_path.clone(),
3354                module_target_from_dependencies(
3355                    project_root,
3356                    &module_dependencies(project_root, &extract.abs_path, &import.module_path),
3357                ),
3358            );
3359        }
3360        let mut reexports = Vec::new();
3361        for raw_ref in &extract.raw_refs {
3362            if raw_ref.kind == "reexport" {
3363                if let Some(module_path) = &raw_ref.module_path {
3364                    let target_file =
3365                        module_target_from_dependencies(project_root, &raw_ref.dependencies);
3366                    module_targets.insert(module_path.clone(), target_file.clone());
3367                    reexports.push(reexport_index_from_raw(raw_ref, target_file));
3368                }
3369            }
3370        }
3371        Self {
3372            lang: Some(extract.lang),
3373            exports: extract.data.exported_symbols.iter().cloned().collect(),
3374            default_export: extract.data.default_export_symbol.clone(),
3375            export_aliases,
3376            node_by_scoped,
3377            node_by_bare,
3378            module_targets,
3379            reexports,
3380        }
3381    }
3382}
3383
3384fn load_db_file_indexes(
3385    tx: &Transaction<'_>,
3386    project_root: &Path,
3387) -> Result<HashMap<String, DbFileIndex>> {
3388    let mut files = HashMap::new();
3389    let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
3390    let rows = stmt.query_map([], |row| {
3391        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3392    })?;
3393    for row in rows {
3394        let (rel_path, lang) = row?;
3395        files.insert(
3396            rel_path.clone(),
3397            DbFileIndex {
3398                lang: lang_from_label(&lang),
3399                exports: HashSet::new(),
3400                default_export: None,
3401                export_aliases: HashMap::new(),
3402                node_by_scoped: HashMap::new(),
3403                node_by_bare: HashMap::new(),
3404                module_targets: HashMap::new(),
3405                reexports: Vec::new(),
3406            },
3407        );
3408    }
3409
3410    let mut node_stmt = tx.prepare(
3411        "SELECT file_path, id, name, scoped_name, exported, is_default_export FROM nodes",
3412    )?;
3413    let nodes = node_stmt.query_map([], |row| {
3414        Ok((
3415            row.get::<_, String>(0)?,
3416            row.get::<_, String>(1)?,
3417            row.get::<_, String>(2)?,
3418            row.get::<_, String>(3)?,
3419            row.get::<_, i64>(4)? != 0,
3420            row.get::<_, i64>(5)? != 0,
3421        ))
3422    })?;
3423    for row in nodes {
3424        let (file_path, id, name, scoped_name, exported, is_default_export) = row?;
3425        let file = files
3426            .entry(file_path.clone())
3427            .or_insert_with(|| DbFileIndex {
3428                lang: None,
3429                exports: HashSet::new(),
3430                default_export: None,
3431                export_aliases: HashMap::new(),
3432                node_by_scoped: HashMap::new(),
3433                node_by_bare: HashMap::new(),
3434                module_targets: HashMap::new(),
3435                reexports: Vec::new(),
3436            });
3437        if exported {
3438            file.exports.insert(name.clone());
3439            file.exports.insert(scoped_name.clone());
3440        }
3441        if is_default_export {
3442            file.default_export = Some(scoped_name.clone());
3443        }
3444        file.node_by_scoped.insert(scoped_name, id.clone());
3445        file.node_by_bare.entry(name).or_insert(id);
3446    }
3447    let file_keys: HashSet<String> = files.keys().cloned().collect();
3448    let mut ref_stmt = tx.prepare(
3449        "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
3450         FROM refs WHERE kind IN ('import', 'reexport', 'export_alias')",
3451    )?;
3452    let ref_rows = ref_stmt.query_map([], |row| {
3453        Ok((
3454            row.get::<_, String>(0)?,
3455            row.get::<_, String>(1)?,
3456            row.get::<_, String>(2)?,
3457            row.get::<_, Option<String>>(3)?,
3458            row.get::<_, Option<String>>(4)?,
3459            row.get::<_, i64>(5)? != 0,
3460            row.get::<_, Option<String>>(6)?,
3461            row.get::<_, Option<String>>(7)?,
3462        ))
3463    })?;
3464    for row in ref_rows {
3465        let (
3466            ref_id,
3467            caller_file,
3468            kind,
3469            module_path,
3470            full_ref,
3471            wildcard,
3472            local_name,
3473            requested_name,
3474        ) = row?;
3475        if kind == "export_alias" {
3476            if let (Some(exported), Some(source_symbol), Some(file)) =
3477                (local_name, requested_name, files.get_mut(&caller_file))
3478            {
3479                file.export_aliases.insert(exported, source_symbol);
3480            }
3481            continue;
3482        }
3483        let Some(module_path) = module_path else {
3484            continue;
3485        };
3486        let deps = dependencies_for_ref(tx, project_root, &ref_id)?;
3487        let target_file = deps
3488            .iter()
3489            .find(|dep| file_keys.contains(*dep))
3490            .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
3491        if let Some(file) = files.get_mut(&caller_file) {
3492            file.module_targets
3493                .entry(module_path.clone())
3494                .or_insert_with(|| target_file.clone());
3495            if kind == "reexport" {
3496                let raw = RawRef {
3497                    ref_id,
3498                    caller_node: None,
3499                    caller_symbol: None,
3500                    caller_file,
3501                    kind,
3502                    short_name: None,
3503                    full_ref,
3504                    module_path: Some(module_path),
3505                    import_kind: Some("reexport".to_string()),
3506                    local_name: None,
3507                    requested_name: None,
3508                    namespace_alias: None,
3509                    wildcard,
3510                    line: 0,
3511                    byte_start: 0,
3512                    byte_end: 0,
3513                    dependencies: deps,
3514                };
3515                file.reexports
3516                    .push(reexport_index_from_raw(&raw, target_file));
3517            }
3518        }
3519    }
3520
3521    Ok(files)
3522}
3523
3524fn insert_file_extract(
3525    tx: &Transaction<'_>,
3526    project_root: &Path,
3527    extract: &FileExtract,
3528) -> Result<()> {
3529    tx.execute(
3530        "INSERT OR REPLACE INTO files(
3531            path, content_hash, mtime_ns, size, lang, is_dead_code_root,
3532            is_public_api, surface_fingerprint, indexed_at
3533        ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
3534        params![
3535            extract.rel_path,
3536            hash_to_hex(extract.freshness.content_hash),
3537            system_time_to_ns(extract.freshness.mtime),
3538            extract.freshness.size as i64,
3539            lang_label(extract.lang),
3540            extract.surface_fingerprint,
3541            unix_seconds_now(),
3542        ],
3543    )?;
3544    for node in &extract.nodes {
3545        tx.execute(
3546            "INSERT OR REPLACE INTO nodes(
3547                id, file_path, name, scoped_name, kind, start_line, start_col,
3548                end_line, end_col, range_ordinal, signature, exported,
3549                is_default_export, is_type_like, is_callgraph_entry_point, provenance
3550            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
3551            params![
3552                node.id,
3553                node.file_path,
3554                node.name,
3555                node.scoped_name,
3556                node.kind,
3557                node.range.start_line as i64,
3558                node.range.start_col as i64,
3559                node.range.end_line as i64,
3560                node.range.end_col as i64,
3561                node.range_ordinal as i64,
3562                node.signature,
3563                bool_int(node.exported),
3564                bool_int(node.is_default_export),
3565                bool_int(node.is_type_like),
3566                bool_int(node.is_callgraph_entry_point),
3567                PROVENANCE_TREESITTER,
3568            ],
3569        )?;
3570    }
3571    let mut dependencies = BTreeSet::new();
3572    for raw_ref in &extract.raw_refs {
3573        dependencies.extend(raw_ref.dependencies.iter().cloned());
3574    }
3575    insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
3576
3577    for hint in &extract.dispatch_hints {
3578        tx.execute(
3579            "INSERT OR REPLACE INTO dispatch_hints(
3580                id, method_name, caller_node, file, line, byte_start, byte_end, provenance
3581            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
3582            params![
3583                hint.id,
3584                hint.method_name,
3585                hint.caller_node,
3586                hint.file,
3587                hint.line as i64,
3588                hint.byte_start as i64,
3589                hint.byte_end as i64,
3590                PROVENANCE_TREESITTER,
3591            ],
3592        )?;
3593    }
3594    mark_backend_state(
3595        tx,
3596        project_root,
3597        &extract.rel_path,
3598        Some(&extract.freshness.content_hash),
3599        "fresh",
3600    )?;
3601    Ok(())
3602}
3603
3604fn insert_file_dependencies(
3605    tx: &Transaction<'_>,
3606    file_path: &str,
3607    dependencies: &BTreeSet<String>,
3608) -> Result<()> {
3609    for dep_file in dependencies {
3610        tx.execute(
3611            "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
3612            params![file_path, dep_file],
3613        )?;
3614    }
3615    Ok(())
3616}
3617
3618fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
3619    let raw = &resolved.raw;
3620    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
3621    tx.execute(
3622        "INSERT OR REPLACE INTO refs(
3623            ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
3624            import_kind, local_name, requested_name, namespace_alias, wildcard, line,
3625            byte_start, byte_end, status, target_node, target_file, target_symbol,
3626            provenance
3627        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
3628        params![
3629            raw.ref_id,
3630            raw.caller_node,
3631            raw.caller_file,
3632            raw.kind,
3633            raw.short_name,
3634            raw.full_ref,
3635            raw.module_path,
3636            raw.import_kind,
3637            raw.local_name,
3638            raw.requested_name,
3639            raw.namespace_alias,
3640            bool_int(raw.wildcard),
3641            raw.line as i64,
3642            raw.byte_start as i64,
3643            raw.byte_end as i64,
3644            resolved.status,
3645            resolved.target_node,
3646            resolved.target_file,
3647            resolved.target_symbol,
3648            PROVENANCE_TREESITTER,
3649        ],
3650    )?;
3651    if let Some(edge) = &resolved.edge {
3652        tx.execute(
3653            "INSERT OR REPLACE INTO edges(
3654                edge_id, ref_id, source_node, target_node, target_file, target_symbol,
3655                kind, line, provenance
3656            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
3657            params![
3658                edge.edge_id,
3659                raw.ref_id,
3660                edge.source_node,
3661                edge.target_node,
3662                edge.target_file,
3663                edge.target_symbol,
3664                edge.kind,
3665                edge.line as i64,
3666                PROVENANCE_TREESITTER,
3667            ],
3668        )?;
3669    }
3670    Ok(())
3671}
3672
3673fn insert_method_dispatch_edges(
3674    tx: &Transaction<'_>,
3675    project_root: &Path,
3676    caller_files: Option<&BTreeSet<String>>,
3677) -> Result<usize> {
3678    let references = load_name_match_refs(tx, caller_files)?;
3679    if references.is_empty() {
3680        return Ok(0);
3681    }
3682
3683    let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
3684    let mut source_cache: DispatchSourceCache = HashMap::new();
3685    let mut inserted = 0usize;
3686    for reference in references {
3687        let key = (reference.method_name.clone(), reference.lang.clone());
3688        let candidates = match candidates_by_name.entry(key) {
3689            Entry::Occupied(entry) => entry.into_mut(),
3690            Entry::Vacant(entry) => {
3691                let candidates =
3692                    load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
3693                entry.insert(candidates)
3694            }
3695        };
3696
3697        if let Some(receiver_type) =
3698            infer_receiver_type(project_root, &reference, &mut source_cache)
3699        {
3700            let Some(candidate) =
3701                select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
3702            else {
3703                continue;
3704            };
3705            insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
3706            inserted += 1;
3707            continue;
3708        }
3709
3710        if method_name_match_denylisted(&reference.method_name) {
3711            continue;
3712        }
3713
3714        let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
3715            continue;
3716        };
3717        insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
3718        inserted += 1;
3719    }
3720    Ok(inserted)
3721}
3722
3723fn insert_method_dispatch_edge(
3724    tx: &Transaction<'_>,
3725    reference: &NameMatchRef,
3726    candidate: &NameMatchCandidate,
3727    provenance: &str,
3728) -> Result<()> {
3729    tx.execute(
3730        "INSERT OR REPLACE INTO edges(
3731            edge_id, ref_id, source_node, target_node, target_file, target_symbol,
3732            kind, line, provenance
3733        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
3734        params![
3735            ref_id(&[&reference.ref_id, provenance, "edge"]),
3736            &reference.ref_id,
3737            &reference.caller_node,
3738            &candidate.node_id,
3739            &candidate.file_path,
3740            &candidate.scoped_name,
3741            reference.line as i64,
3742            provenance,
3743        ],
3744    )?;
3745    Ok(())
3746}
3747
3748fn delete_method_dispatch_edges_for_callers(
3749    tx: &Transaction<'_>,
3750    caller_files: &BTreeSet<String>,
3751) -> Result<()> {
3752    if caller_files.is_empty() {
3753        return Ok(());
3754    }
3755
3756    let mut stmt = tx.prepare(
3757        "DELETE FROM edges
3758         WHERE provenance IN (?1, ?2)
3759           AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
3760    )?;
3761    for caller_file in caller_files {
3762        stmt.execute(params![
3763            PROVENANCE_NAME_MATCH,
3764            PROVENANCE_TYPE_MATCH,
3765            caller_file
3766        ])?;
3767    }
3768    Ok(())
3769}
3770
3771fn load_name_match_refs(
3772    tx: &Transaction<'_>,
3773    caller_files: Option<&BTreeSet<String>>,
3774) -> Result<Vec<NameMatchRef>> {
3775    let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
3776                           n.signature, r.short_name, r.full_ref, r.line, f.lang
3777                    FROM refs r
3778                    JOIN files f ON f.path = r.caller_file
3779                    JOIN nodes n ON n.id = r.caller_node
3780                    WHERE r.kind = 'call'
3781                      AND r.status = 'unresolved'
3782                      AND r.caller_node IS NOT NULL
3783                      AND r.full_ref IS NOT NULL
3784                      AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
3785                      AND NOT EXISTS (
3786                          SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
3787                      )";
3788    let mut references = Vec::new();
3789
3790    if let Some(caller_files) = caller_files {
3791        if caller_files.is_empty() {
3792            return Ok(references);
3793        }
3794        let sql = format!(
3795            "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
3796        );
3797        let mut stmt = tx.prepare(&sql)?;
3798        for caller_file in caller_files {
3799            let rows = stmt.query_map(params![caller_file], |row| {
3800                Ok((
3801                    row.get::<_, String>(0)?,
3802                    row.get::<_, Option<String>>(1)?,
3803                    row.get::<_, String>(2)?,
3804                    row.get::<_, String>(3)?,
3805                    row.get::<_, Option<String>>(4)?,
3806                    row.get::<_, Option<String>>(5)?,
3807                    row.get::<_, Option<String>>(6)?,
3808                    row.get::<_, i64>(7)?,
3809                    row.get::<_, String>(8)?,
3810                ))
3811            })?;
3812            for row in rows {
3813                let (
3814                    ref_id,
3815                    caller_node,
3816                    caller_file,
3817                    caller_symbol,
3818                    caller_signature,
3819                    short_name,
3820                    full_ref,
3821                    line,
3822                    lang,
3823                ) = row?;
3824                if let Some(reference) = name_match_ref_from_parts(
3825                    ref_id,
3826                    caller_node,
3827                    caller_file,
3828                    caller_symbol,
3829                    caller_signature,
3830                    short_name,
3831                    full_ref,
3832                    line,
3833                    lang,
3834                ) {
3835                    references.push(reference);
3836                }
3837            }
3838        }
3839        return Ok(references);
3840    }
3841
3842    let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
3843    let mut stmt = tx.prepare(&sql)?;
3844    let rows = stmt.query_map([], |row| {
3845        Ok((
3846            row.get::<_, String>(0)?,
3847            row.get::<_, Option<String>>(1)?,
3848            row.get::<_, String>(2)?,
3849            row.get::<_, String>(3)?,
3850            row.get::<_, Option<String>>(4)?,
3851            row.get::<_, Option<String>>(5)?,
3852            row.get::<_, Option<String>>(6)?,
3853            row.get::<_, i64>(7)?,
3854            row.get::<_, String>(8)?,
3855        ))
3856    })?;
3857    for row in rows {
3858        let (
3859            ref_id,
3860            caller_node,
3861            caller_file,
3862            caller_symbol,
3863            caller_signature,
3864            short_name,
3865            full_ref,
3866            line,
3867            lang,
3868        ) = row?;
3869        if let Some(reference) = name_match_ref_from_parts(
3870            ref_id,
3871            caller_node,
3872            caller_file,
3873            caller_symbol,
3874            caller_signature,
3875            short_name,
3876            full_ref,
3877            line,
3878            lang,
3879        ) {
3880            references.push(reference);
3881        }
3882    }
3883    Ok(references)
3884}
3885
3886#[allow(clippy::too_many_arguments)]
3887fn name_match_ref_from_parts(
3888    ref_id: String,
3889    caller_node: Option<String>,
3890    caller_file: String,
3891    caller_symbol: String,
3892    caller_signature: Option<String>,
3893    short_name: Option<String>,
3894    full_ref: Option<String>,
3895    line: i64,
3896    lang: String,
3897) -> Option<NameMatchRef> {
3898    let caller_node = caller_node?;
3899    let full_ref = full_ref?;
3900    let (receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
3901    let method_name = if member.is_empty() {
3902        short_name.as_deref()?.to_string()
3903    } else {
3904        member
3905    };
3906    Some(NameMatchRef {
3907        ref_id,
3908        caller_node,
3909        caller_file,
3910        caller_symbol,
3911        caller_signature,
3912        receiver,
3913        method_name,
3914        colon_dispatch,
3915        line: line.max(0) as u32,
3916        lang,
3917    })
3918}
3919
3920fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, bool)> {
3921    let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
3922    let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
3923    let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
3924    let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
3925        .into_iter()
3926        .flatten()
3927        .max_by_key(|(index, _, _)| *index)?;
3928    if delimiter == 0 {
3929        return None;
3930    }
3931    let member_start = delimiter + delimiter_len;
3932    if member_start >= full_ref.len() {
3933        return None;
3934    }
3935    let receiver = last_name_segment(&full_ref[..delimiter]);
3936    let member = &full_ref[member_start..];
3937    if receiver.is_empty() || member.is_empty() {
3938        return None;
3939    }
3940    Some((receiver.to_string(), member.to_string(), colon_dispatch))
3941}
3942
3943fn last_name_segment(value: &str) -> &str {
3944    value
3945        .rsplit(['.', ':', '/', '\\', '-', '>'])
3946        .find(|segment| !segment.is_empty())
3947        .unwrap_or(value)
3948}
3949
3950fn load_name_match_candidates(
3951    tx: &Transaction<'_>,
3952    method_name: &str,
3953    lang: &str,
3954) -> Result<Vec<NameMatchCandidate>> {
3955    let mut stmt = tx.prepare(
3956        "SELECT n.id, n.file_path, n.scoped_name, n.kind
3957         FROM nodes n JOIN files f ON f.path = n.file_path
3958         WHERE n.name = ?1
3959           AND f.lang = ?2
3960           AND n.kind IN ('method', 'function')
3961         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
3962    )?;
3963    let rows = stmt.query_map(params![method_name, lang], |row| {
3964        Ok(NameMatchCandidate {
3965            node_id: row.get(0)?,
3966            file_path: row.get(1)?,
3967            scoped_name: row.get(2)?,
3968            kind: row.get(3)?,
3969        })
3970    })?;
3971    rows.collect::<std::result::Result<Vec<_>, _>>()
3972        .map_err(Into::into)
3973}
3974
3975struct ParsedDispatchSource {
3976    source: String,
3977    tree: tree_sitter::Tree,
3978}
3979
3980type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
3981
3982fn infer_receiver_type(
3983    project_root: &Path,
3984    reference: &NameMatchRef,
3985    source_cache: &mut DispatchSourceCache,
3986) -> Option<String> {
3987    match reference.lang.as_str() {
3988        "rust" => infer_rust_receiver_type(reference),
3989        "java" => {
3990            infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
3991        }
3992        "kotlin" => {
3993            infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
3994        }
3995        "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache),
3996        _ => None,
3997    }
3998}
3999
4000fn parse_dispatch_source(
4001    project_root: &Path,
4002    caller_file: &str,
4003    lang: LangId,
4004) -> Option<ParsedDispatchSource> {
4005    let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
4006    let grammar = crate::parser::grammar_for(lang);
4007    let mut parser = tree_sitter::Parser::new();
4008    parser.set_language(&grammar).ok()?;
4009    let tree = parser.parse(&source, None)?;
4010    Some(ParsedDispatchSource { source, tree })
4011}
4012
4013fn parsed_dispatch_source<'a>(
4014    project_root: &Path,
4015    reference: &NameMatchRef,
4016    lang: LangId,
4017    source_cache: &'a mut DispatchSourceCache,
4018) -> Option<&'a ParsedDispatchSource> {
4019    let key = (reference.caller_file.clone(), reference.lang.clone());
4020    source_cache
4021        .entry(key)
4022        .or_insert_with(|| parse_dispatch_source(project_root, &reference.caller_file, lang))
4023        .as_ref()
4024}
4025
4026fn infer_java_like_receiver_type(
4027    project_root: &Path,
4028    reference: &NameMatchRef,
4029    lang: LangId,
4030    source_cache: &mut DispatchSourceCache,
4031) -> Option<String> {
4032    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
4033        return None;
4034    }
4035
4036    let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
4037    let root = parsed.tree.root_node();
4038    let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
4039
4040    let callable_scope = type_node
4041        .and_then(|node| {
4042            find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
4043        })
4044        .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
4045
4046    if let Some(callable_scope) = callable_scope {
4047        if let Some(receiver_type) = infer_java_like_local_receiver_type(
4048            callable_scope,
4049            &parsed.source,
4050            &reference.receiver,
4051            reference.line.max(1),
4052            lang,
4053        ) {
4054            return Some(receiver_type);
4055        }
4056    }
4057
4058    type_node.and_then(|node| {
4059        infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
4060    })
4061}
4062
4063fn infer_cpp_receiver_type(
4064    project_root: &Path,
4065    reference: &NameMatchRef,
4066    source_cache: &mut DispatchSourceCache,
4067) -> Option<String> {
4068    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
4069        return None;
4070    }
4071
4072    let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
4073    let root = parsed.tree.root_node();
4074    let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
4075    infer_cpp_receiver_type_from_scope(
4076        scope,
4077        &parsed.source,
4078        &reference.receiver,
4079        reference.line.max(1),
4080    )
4081}
4082
4083fn find_enclosing_java_like_type_node<'tree>(
4084    root: tree_sitter::Node<'tree>,
4085    source: &str,
4086    reference: &NameMatchRef,
4087    lang: LangId,
4088) -> Option<tree_sitter::Node<'tree>> {
4089    let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
4090        .and_then(|name| simple_type_name(&name));
4091    let line = reference.line.max(1);
4092    let mut best = None;
4093    let mut stack = vec![root];
4094    while let Some(node) = stack.pop() {
4095        if !node_contains_line(node, line) {
4096            continue;
4097        }
4098        if is_java_like_type_kind(node.kind(), lang) {
4099            let name = declaration_name(node, source);
4100            if expected_type
4101                .as_deref()
4102                .is_none_or(|expected| name == Some(expected))
4103            {
4104                best = tighter_node(best, node);
4105            }
4106        }
4107        push_named_children(node, &mut stack);
4108    }
4109    best
4110}
4111
4112fn find_enclosing_java_like_callable_node<'tree>(
4113    root: tree_sitter::Node<'tree>,
4114    source: &str,
4115    reference: &NameMatchRef,
4116    lang: LangId,
4117) -> Option<tree_sitter::Node<'tree>> {
4118    let expected_name = reference.caller_symbol.rsplit("::").next();
4119    let line = reference.line.max(1);
4120    let mut best = None;
4121    let mut stack = vec![root];
4122    while let Some(node) = stack.pop() {
4123        if !node_contains_line(node, line) {
4124            continue;
4125        }
4126        if is_java_like_callable_kind(node.kind(), lang) {
4127            let name = declaration_name(node, source);
4128            if expected_name.is_none_or(|expected| name == Some(expected)) {
4129                best = tighter_node(best, node);
4130            }
4131        }
4132        push_named_children(node, &mut stack);
4133    }
4134    best
4135}
4136
4137fn find_enclosing_cpp_callable_node<'tree>(
4138    root: tree_sitter::Node<'tree>,
4139    _source: &str,
4140    reference: &NameMatchRef,
4141) -> Option<tree_sitter::Node<'tree>> {
4142    let line = reference.line.max(1);
4143    let mut best = None;
4144    let mut stack = vec![root];
4145    while let Some(node) = stack.pop() {
4146        if !node_contains_line(node, line) {
4147            continue;
4148        }
4149        if node.kind() == "function_definition" {
4150            best = tighter_node(best, node);
4151        }
4152        push_named_children(node, &mut stack);
4153    }
4154    best
4155}
4156
4157fn tighter_node<'tree>(
4158    current: Option<tree_sitter::Node<'tree>>,
4159    candidate: tree_sitter::Node<'tree>,
4160) -> Option<tree_sitter::Node<'tree>> {
4161    match current {
4162        Some(current)
4163            if current.start_byte() > candidate.start_byte()
4164                || (current.start_byte() == candidate.start_byte()
4165                    && current.end_byte() <= candidate.end_byte()) =>
4166        {
4167            Some(current)
4168        }
4169        _ => Some(candidate),
4170    }
4171}
4172
4173fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
4174    let start = node.start_position().row as u32 + 1;
4175    let end = node.end_position().row as u32 + 1;
4176    start <= line && line <= end
4177}
4178
4179fn push_named_children<'tree>(
4180    node: tree_sitter::Node<'tree>,
4181    stack: &mut Vec<tree_sitter::Node<'tree>>,
4182) {
4183    for index in 0..node.named_child_count() {
4184        if let Some(child) = node.named_child(index as u32) {
4185            stack.push(child);
4186        }
4187    }
4188}
4189
4190fn declaration_name<'source>(
4191    node: tree_sitter::Node<'_>,
4192    source: &'source str,
4193) -> Option<&'source str> {
4194    node.child_by_field_name("name")
4195        .map(|name| node_text(name, source))
4196        .or_else(|| {
4197            first_named_child_text(
4198                node,
4199                source,
4200                &["identifier", "type_identifier", "simple_identifier"],
4201            )
4202        })
4203}
4204
4205fn first_named_child_text<'source>(
4206    node: tree_sitter::Node<'_>,
4207    source: &'source str,
4208    kinds: &[&str],
4209) -> Option<&'source str> {
4210    for index in 0..node.named_child_count() {
4211        let child = node.named_child(index as u32)?;
4212        if kinds.contains(&child.kind()) {
4213            return Some(node_text(child, source));
4214        }
4215    }
4216    None
4217}
4218
4219fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
4220    &source[node.byte_range()]
4221}
4222
4223fn infer_java_like_field_receiver_type(
4224    type_node: tree_sitter::Node<'_>,
4225    source: &str,
4226    receiver: &str,
4227    lang: LangId,
4228) -> Option<String> {
4229    let mut stack = Vec::new();
4230    push_named_children(type_node, &mut stack);
4231    while let Some(node) = stack.pop() {
4232        if is_java_like_field_kind(node.kind(), lang) {
4233            if let Some(receiver_type) =
4234                extract_java_like_declared_type(node_text(node, source), receiver, lang)
4235            {
4236                return Some(receiver_type);
4237            }
4238        }
4239        if is_java_like_type_kind(node.kind(), lang)
4240            || is_java_like_callable_kind(node.kind(), lang)
4241        {
4242            continue;
4243        }
4244        push_named_children(node, &mut stack);
4245    }
4246    None
4247}
4248
4249fn infer_java_like_local_receiver_type(
4250    callable_node: tree_sitter::Node<'_>,
4251    source: &str,
4252    receiver: &str,
4253    call_line: u32,
4254    lang: LangId,
4255) -> Option<String> {
4256    let mut best: Option<(u32, String)> = None;
4257    let mut stack = Vec::new();
4258    push_named_children(callable_node, &mut stack);
4259    while let Some(node) = stack.pop() {
4260        let start_line = node.start_position().row as u32 + 1;
4261        if start_line > call_line {
4262            continue;
4263        }
4264        if is_java_like_local_kind(node.kind(), lang) {
4265            if let Some(receiver_type) =
4266                extract_java_like_declared_type(node_text(node, source), receiver, lang)
4267            {
4268                if best
4269                    .as_ref()
4270                    .is_none_or(|(best_line, _)| start_line >= *best_line)
4271                {
4272                    best = Some((start_line, receiver_type));
4273                }
4274            }
4275        }
4276        if is_java_like_type_kind(node.kind(), lang)
4277            || is_java_like_callable_kind(node.kind(), lang)
4278        {
4279            continue;
4280        }
4281        push_named_children(node, &mut stack);
4282    }
4283    best.map(|(_, receiver_type)| receiver_type)
4284}
4285
4286fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
4287    match lang {
4288        LangId::Java => matches!(
4289            kind,
4290            "class_declaration"
4291                | "interface_declaration"
4292                | "enum_declaration"
4293                | "record_declaration"
4294                | "annotation_type_declaration"
4295        ),
4296        LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
4297        _ => false,
4298    }
4299}
4300
4301fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
4302    match lang {
4303        LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
4304        LangId::Kotlin => kind == "function_declaration",
4305        _ => false,
4306    }
4307}
4308
4309fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
4310    match lang {
4311        LangId::Java => kind == "field_declaration",
4312        LangId::Kotlin => kind == "property_declaration",
4313        _ => false,
4314    }
4315}
4316
4317fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
4318    match lang {
4319        LangId::Java => kind == "local_variable_declaration",
4320        LangId::Kotlin => kind == "property_declaration",
4321        _ => false,
4322    }
4323}
4324
4325fn extract_java_like_declared_type(
4326    declaration: &str,
4327    receiver: &str,
4328    lang: LangId,
4329) -> Option<String> {
4330    match lang {
4331        LangId::Java => extract_java_declared_type(declaration, receiver),
4332        LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
4333        _ => None,
4334    }
4335}
4336
4337fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
4338    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
4339    let after = declaration[receiver_start + receiver.len()..].trim_start();
4340    if after
4341        .chars()
4342        .next()
4343        .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
4344    {
4345        return None;
4346    }
4347
4348    let before = declaration[..receiver_start].trim_end();
4349    if before.contains(',') {
4350        return None;
4351    }
4352    normalize_receiver_type_name(strip_java_declaration_prefixes(before))
4353}
4354
4355fn strip_java_declaration_prefixes(mut value: &str) -> &str {
4356    loop {
4357        value = value.trim_start();
4358        if let Some(stripped) = strip_leading_java_annotation(value) {
4359            value = stripped;
4360            continue;
4361        }
4362        if let Some(stripped) = strip_leading_java_modifier(value) {
4363            value = stripped;
4364            continue;
4365        }
4366        return value.trim();
4367    }
4368}
4369
4370fn strip_leading_java_annotation(value: &str) -> Option<&str> {
4371    let value = value.trim_start();
4372    let mut chars = value.char_indices();
4373    let (_, first) = chars.next()?;
4374    if first != '@' {
4375        return None;
4376    }
4377    let mut end = first.len_utf8();
4378    for (index, ch) in chars {
4379        if !(is_code_ident_char(ch) || ch == '.') {
4380            end = index;
4381            break;
4382        }
4383        end = index + ch.len_utf8();
4384    }
4385    let rest = value[end..].trim_start();
4386    if let Some(stripped) = rest.strip_prefix('(') {
4387        let mut depth = 1usize;
4388        for (index, ch) in stripped.char_indices() {
4389            match ch {
4390                '(' => depth += 1,
4391                ')' => {
4392                    depth = depth.saturating_sub(1);
4393                    if depth == 0 {
4394                        return Some(stripped[index + ch.len_utf8()..].trim_start());
4395                    }
4396                }
4397                _ => {}
4398            }
4399        }
4400        return Some("");
4401    }
4402    Some(rest)
4403}
4404
4405fn strip_leading_java_modifier(value: &str) -> Option<&str> {
4406    const MODIFIERS: &[&str] = &[
4407        "public",
4408        "protected",
4409        "private",
4410        "abstract",
4411        "static",
4412        "final",
4413        "transient",
4414        "volatile",
4415        "synchronized",
4416        "native",
4417        "strictfp",
4418    ];
4419    MODIFIERS
4420        .iter()
4421        .find_map(|modifier| strip_leading_word(value, modifier))
4422}
4423
4424fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
4425    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
4426    let before = &declaration[..receiver_start];
4427    if find_identifier_occurrence(before, "val").is_none()
4428        && find_identifier_occurrence(before, "var").is_none()
4429    {
4430        return None;
4431    }
4432
4433    let after = declaration[receiver_start + receiver.len()..].trim_start();
4434    if let Some(type_text) = after.strip_prefix(':') {
4435        return normalize_receiver_type_name(read_type_prefix(type_text));
4436    }
4437    after
4438        .strip_prefix('=')
4439        .and_then(infer_kotlin_constructor_type)
4440}
4441
4442fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
4443    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
4444    if rest.trim_start().starts_with('(') {
4445        normalize_receiver_type_name(head)
4446    } else {
4447        None
4448    }
4449}
4450
4451fn read_type_prefix(value: &str) -> &str {
4452    let mut angle_depth = 0usize;
4453    for (index, ch) in value.char_indices() {
4454        match ch {
4455            '<' => angle_depth += 1,
4456            '>' => angle_depth = angle_depth.saturating_sub(1),
4457            '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
4458                return value[..index].trim();
4459            }
4460            _ => {}
4461        }
4462    }
4463    value.trim()
4464}
4465
4466fn infer_cpp_receiver_type_from_scope(
4467    scope: tree_sitter::Node<'_>,
4468    source: &str,
4469    receiver: &str,
4470    call_line: u32,
4471) -> Option<String> {
4472    let lines = source.lines().collect::<Vec<_>>();
4473    if lines.is_empty() {
4474        return None;
4475    }
4476    let scope_start = scope.start_position().row as usize;
4477    let call_index = (call_line as usize)
4478        .saturating_sub(1)
4479        .min(lines.len().saturating_sub(1));
4480    for index in (scope_start..=call_index).rev() {
4481        if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
4482            return Some(receiver_type);
4483        }
4484    }
4485    None
4486}
4487
4488fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
4489    for receiver_start in identifier_occurrences(line, receiver) {
4490        let after = line[receiver_start + receiver.len()..].trim_start();
4491        if after
4492            .chars()
4493            .next()
4494            .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
4495        {
4496            continue;
4497        }
4498        let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
4499        let normalized = normalize_cpp_type_name(type_text)?;
4500        if normalized == "auto" {
4501            if let Some(rhs) = after.strip_prefix('=') {
4502                return infer_cpp_auto_receiver_type(rhs);
4503            }
4504            continue;
4505        }
4506        return Some(normalized);
4507    }
4508    None
4509}
4510
4511fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
4512    let candidate = prefix
4513        .rsplit([';', '{', '}', '('])
4514        .next()
4515        .unwrap_or(prefix)
4516        .trim();
4517    if candidate.is_empty() || candidate.ends_with(',') {
4518        None
4519    } else {
4520        Some(candidate)
4521    }
4522}
4523
4524fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
4525    let without_templates = strip_angle_groups(type_text);
4526    let mut cleaned = String::with_capacity(without_templates.len());
4527    for token in without_templates.split_whitespace() {
4528        if matches!(
4529            token,
4530            "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
4531        ) {
4532            continue;
4533        }
4534        if !cleaned.is_empty() {
4535            cleaned.push(' ');
4536        }
4537        cleaned.push_str(token);
4538    }
4539    let token = cleaned
4540        .split_whitespace()
4541        .last()
4542        .unwrap_or(cleaned.trim())
4543        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
4544        .trim_matches(['*', '&']);
4545    let simple = token.rsplit("::").next().unwrap_or(token).trim();
4546    if simple.is_empty() || cpp_non_type_token(simple) {
4547        None
4548    } else {
4549        Some(simple.to_string())
4550    }
4551}
4552
4553fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
4554    let rhs = rhs.trim_start();
4555    if let Some(after_new) = rhs.strip_prefix("new ") {
4556        return infer_cpp_constructor_type(after_new);
4557    }
4558    infer_cpp_make_template_type(rhs)
4559        .or_else(|| infer_cpp_constructor_type(rhs))
4560        .or_else(|| infer_cpp_factory_type(rhs))
4561}
4562
4563fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
4564    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
4565    let normalized = normalize_cpp_type_name(head)?;
4566    if !normalized
4567        .chars()
4568        .next()
4569        .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
4570    {
4571        return None;
4572    }
4573    if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
4574        Some(normalized)
4575    } else {
4576        None
4577    }
4578}
4579
4580fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
4581    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
4582    if !rest.trim_start().starts_with('(') {
4583        return None;
4584    }
4585    let base = head.split('<').next().unwrap_or(head);
4586    let base_simple = base.rsplit("::").next().unwrap_or(base);
4587    if !matches!(base_simple, "make_unique" | "make_shared") {
4588        return None;
4589    }
4590    first_angle_arg(head).and_then(normalize_cpp_type_name)
4591}
4592
4593fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
4594    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
4595    if !rest.trim_start().starts_with('(') {
4596        return None;
4597    }
4598    let simple = head
4599        .split('<')
4600        .next()
4601        .unwrap_or(head)
4602        .rsplit("::")
4603        .next()
4604        .unwrap_or(head);
4605    for prefix in ["make", "create", "build"] {
4606        if let Some(suffix) = simple.strip_prefix(prefix) {
4607            if suffix
4608                .chars()
4609                .next()
4610                .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
4611            {
4612                return normalize_cpp_type_name(suffix);
4613            }
4614        }
4615    }
4616    None
4617}
4618
4619#[derive(Debug, Clone, Copy)]
4620enum JavaLikeInvocation {
4621    Kotlin,
4622    Cpp,
4623}
4624
4625fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
4626    let value = value.trim_start();
4627    let mut end = 0usize;
4628    for (index, ch) in value.char_indices() {
4629        let allowed_separator = match flavor {
4630            JavaLikeInvocation::Kotlin => ch == '.',
4631            JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
4632        };
4633        if is_code_ident_char(ch) || allowed_separator {
4634            end = index + ch.len_utf8();
4635            continue;
4636        }
4637        break;
4638    }
4639    if end == 0 {
4640        return None;
4641    }
4642    let mut rest = &value[end..];
4643    if let Some(stripped) = rest.trim_start().strip_prefix('<') {
4644        let skipped = skip_balanced_angle(stripped)?;
4645        let rest_start = rest.len() - rest.trim_start().len();
4646        let angle_len = 1 + skipped;
4647        end += rest_start + angle_len;
4648        rest = &value[end..];
4649    }
4650    Some((value[..end].trim(), rest))
4651}
4652
4653fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
4654    let mut depth = 1usize;
4655    for (index, ch) in value_after_open.char_indices() {
4656        match ch {
4657            '<' => depth += 1,
4658            '>' => {
4659                depth = depth.saturating_sub(1);
4660                if depth == 0 {
4661                    return Some(index + ch.len_utf8());
4662                }
4663            }
4664            _ => {}
4665        }
4666    }
4667    None
4668}
4669
4670fn first_angle_arg(value: &str) -> Option<&str> {
4671    let open = value.find('<')?;
4672    let inner_len = skip_balanced_angle(&value[open + 1..])?;
4673    let inner = &value[open + 1..open + inner_len];
4674    split_top_level_commas(inner).into_iter().next()
4675}
4676
4677fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
4678    let without_generics = strip_angle_groups(type_text);
4679    let cleaned = without_generics
4680        .replace("[]", " ")
4681        .replace("...", " ")
4682        .replace(['?', '&', '*'], " ");
4683    let token = cleaned
4684        .split_whitespace()
4685        .last()
4686        .unwrap_or(cleaned.trim())
4687        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
4688    let token = token.rsplit("::").next().unwrap_or(token);
4689    let simple = token.rsplit('.').next().unwrap_or(token).trim();
4690    if simple.is_empty()
4691        || java_like_primitive_type(simple)
4692        || !simple
4693            .chars()
4694            .next()
4695            .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
4696    {
4697        None
4698    } else {
4699        Some(simple.to_string())
4700    }
4701}
4702
4703fn simple_type_name(scoped_name: &str) -> Option<String> {
4704    scoped_name
4705        .rsplit("::")
4706        .find(|segment| !segment.is_empty())
4707        .and_then(normalize_receiver_type_name)
4708}
4709
4710fn strip_angle_groups(value: &str) -> String {
4711    let mut output = String::with_capacity(value.len());
4712    let mut depth = 0usize;
4713    for ch in value.chars() {
4714        match ch {
4715            '<' => {
4716                if depth == 0 {
4717                    output.push(' ');
4718                }
4719                depth += 1;
4720            }
4721            '>' => depth = depth.saturating_sub(1),
4722            _ if depth == 0 => output.push(ch),
4723            _ => {}
4724        }
4725    }
4726    output
4727}
4728
4729fn java_like_primitive_type(value: &str) -> bool {
4730    matches!(
4731        value,
4732        "boolean"
4733            | "byte"
4734            | "char"
4735            | "double"
4736            | "float"
4737            | "int"
4738            | "long"
4739            | "short"
4740            | "void"
4741            | "Boolean"
4742            | "Byte"
4743            | "Char"
4744            | "Double"
4745            | "Float"
4746            | "Int"
4747            | "Long"
4748            | "Short"
4749            | "Unit"
4750    )
4751}
4752
4753fn cpp_non_type_token(value: &str) -> bool {
4754    matches!(
4755        value,
4756        "return"
4757            | "if"
4758            | "else"
4759            | "for"
4760            | "while"
4761            | "do"
4762            | "switch"
4763            | "case"
4764            | "default"
4765            | "break"
4766            | "continue"
4767            | "goto"
4768            | "throw"
4769            | "new"
4770            | "delete"
4771            | "co_await"
4772            | "co_yield"
4773            | "co_return"
4774            | "static_cast"
4775            | "const_cast"
4776            | "dynamic_cast"
4777            | "reinterpret_cast"
4778            | "sizeof"
4779            | "alignof"
4780            | "typeid"
4781            | "and"
4782            | "or"
4783            | "not"
4784            | "xor"
4785    )
4786}
4787
4788fn receiver_is_bare_identifier(value: &str) -> bool {
4789    let mut chars = value.chars();
4790    let Some(first) = chars.next() else {
4791        return false;
4792    };
4793    (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
4794}
4795
4796fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
4797    identifier_occurrences(value, needle).into_iter().next()
4798}
4799
4800fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
4801    value
4802        .match_indices(needle)
4803        .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
4804        .collect()
4805}
4806
4807fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
4808    let before = value[..start].chars().next_back();
4809    let after = value[start + len..].chars().next();
4810    !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
4811}
4812
4813fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
4814    let stripped = value.strip_prefix(word)?;
4815    if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
4816        Some(stripped.trim_start())
4817    } else {
4818        None
4819    }
4820}
4821
4822fn is_code_ident_char(ch: char) -> bool {
4823    ch == '_' || ch.is_ascii_alphanumeric()
4824}
4825
4826fn infer_rust_receiver_type(reference: &NameMatchRef) -> Option<String> {
4827    if matches!(reference.receiver.as_str(), "self" | "Self") {
4828        return enclosing_type_from_scoped_name(&reference.caller_symbol);
4829    }
4830
4831    if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
4832        return Some(reference.receiver.clone());
4833    }
4834
4835    reference
4836        .caller_signature
4837        .as_deref()
4838        .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
4839}
4840
4841fn rust_receiver_looks_type_like(receiver: &str) -> bool {
4842    receiver
4843        .chars()
4844        .next()
4845        .is_some_and(|ch| ch == '_' || ch.is_uppercase())
4846}
4847
4848fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
4849    scoped_name
4850        .rsplit_once("::")
4851        .map(|(enclosing, _)| enclosing)
4852        .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
4853        .map(ToString::to_string)
4854}
4855
4856fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
4857    let params = signature_parameter_text(signature)?;
4858    for param in split_top_level_commas(params) {
4859        let Some((pattern, type_text)) = param.split_once(':') else {
4860            continue;
4861        };
4862        let Some(name) = rust_parameter_name(pattern) else {
4863            continue;
4864        };
4865        if name == receiver {
4866            return normalize_rust_receiver_type(type_text);
4867        }
4868    }
4869    None
4870}
4871
4872fn signature_parameter_text(signature: &str) -> Option<&str> {
4873    let open = signature.find('(')?;
4874    let mut depth = 0usize;
4875    for (offset, ch) in signature[open..].char_indices() {
4876        match ch {
4877            '(' => depth += 1,
4878            ')' => {
4879                depth = depth.saturating_sub(1);
4880                if depth == 0 {
4881                    return Some(&signature[open + 1..open + offset]);
4882                }
4883            }
4884            _ => {}
4885        }
4886    }
4887    None
4888}
4889
4890fn split_top_level_commas(value: &str) -> Vec<&str> {
4891    let mut parts = Vec::new();
4892    let mut start = 0usize;
4893    let mut angle_depth = 0usize;
4894    let mut paren_depth = 0usize;
4895    let mut bracket_depth = 0usize;
4896    for (index, ch) in value.char_indices() {
4897        match ch {
4898            '<' => angle_depth += 1,
4899            '>' => angle_depth = angle_depth.saturating_sub(1),
4900            '(' => paren_depth += 1,
4901            ')' => paren_depth = paren_depth.saturating_sub(1),
4902            '[' => bracket_depth += 1,
4903            ']' => bracket_depth = bracket_depth.saturating_sub(1),
4904            ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
4905                let part = value[start..index].trim();
4906                if !part.is_empty() {
4907                    parts.push(part);
4908                }
4909                start = index + ch.len_utf8();
4910            }
4911            _ => {}
4912        }
4913    }
4914    let part = value[start..].trim();
4915    if !part.is_empty() {
4916        parts.push(part);
4917    }
4918    parts
4919}
4920
4921fn rust_parameter_name(pattern: &str) -> Option<&str> {
4922    let mut pattern = pattern.trim();
4923    if let Some(stripped) = pattern.strip_prefix("mut ") {
4924        pattern = stripped.trim_start();
4925    }
4926    pattern
4927        .rsplit(|ch: char| !is_rust_ident_char(ch))
4928        .find(|part| !part.is_empty())
4929}
4930
4931fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
4932    let mut ty = strip_leading_rust_type_modifiers(type_text);
4933    let owned_inner;
4934    if let Some(inner) = single_outer_generic_arg(ty) {
4935        owned_inner = inner.trim().to_string();
4936        ty = strip_leading_rust_type_modifiers(&owned_inner);
4937    }
4938    rust_base_type_ident(ty)
4939}
4940
4941fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
4942    loop {
4943        ty = ty.trim_start();
4944        if let Some(stripped) = ty.strip_prefix('&') {
4945            ty = stripped.trim_start();
4946            if let Some(stripped) = strip_leading_lifetime(ty) {
4947                ty = stripped.trim_start();
4948            }
4949            if let Some(stripped) = ty.strip_prefix("mut ") {
4950                ty = stripped.trim_start();
4951            }
4952            continue;
4953        }
4954        if let Some(stripped) = ty.strip_prefix("mut ") {
4955            ty = stripped.trim_start();
4956            continue;
4957        }
4958        if let Some(stripped) = ty.strip_prefix("dyn ") {
4959            ty = stripped.trim_start();
4960            continue;
4961        }
4962        if let Some(stripped) = ty.strip_prefix("impl ") {
4963            ty = stripped.trim_start();
4964            continue;
4965        }
4966        break ty.trim();
4967    }
4968}
4969
4970fn strip_leading_lifetime(value: &str) -> Option<&str> {
4971    let mut chars = value.char_indices();
4972    let (_, first) = chars.next()?;
4973    if first != '\'' {
4974        return None;
4975    }
4976    for (index, ch) in chars {
4977        if !(ch == '_' || ch.is_ascii_alphanumeric()) {
4978            return Some(&value[index..]);
4979        }
4980    }
4981    Some("")
4982}
4983
4984fn single_outer_generic_arg(ty: &str) -> Option<&str> {
4985    let ty = ty.trim();
4986    let open = ty.find('<')?;
4987    let mut depth = 0usize;
4988    let mut close = None;
4989    for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
4990        match ch {
4991            '<' => depth += 1,
4992            '>' => {
4993                depth = depth.saturating_sub(1);
4994                if depth == 0 {
4995                    close = Some(index);
4996                    break;
4997                }
4998            }
4999            _ => {}
5000        }
5001    }
5002    let close = close?;
5003    if !ty[close + 1..].trim().is_empty() {
5004        return None;
5005    }
5006    let inner = &ty[open + 1..close];
5007    let args = split_top_level_commas(inner);
5008    match args.as_slice() {
5009        [arg] => Some(*arg),
5010        _ => None,
5011    }
5012}
5013
5014fn rust_base_type_ident(ty: &str) -> Option<String> {
5015    let ty = ty.trim();
5016    let head = ty
5017        .split([' ', '+', '='])
5018        .find(|part| !part.is_empty())
5019        .unwrap_or(ty);
5020    let head = head.split('<').next().unwrap_or(head).trim();
5021    let ident = head
5022        .rsplit("::")
5023        .next()
5024        .unwrap_or(head)
5025        .trim_matches(|ch: char| !is_rust_ident_char(ch));
5026    if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
5027        None
5028    } else {
5029        Some(ident.to_string())
5030    }
5031}
5032
5033fn is_rust_ident_char(ch: char) -> bool {
5034    ch == '_' || ch.is_ascii_alphanumeric()
5035}
5036
5037fn select_type_match_candidate(
5038    reference: &NameMatchRef,
5039    candidates: &[NameMatchCandidate],
5040    receiver_type: &str,
5041) -> Option<NameMatchCandidate> {
5042    let candidates = candidates
5043        .iter()
5044        .filter(|candidate| candidate.node_id != reference.caller_node)
5045        .filter(|candidate| {
5046            type_candidate_matches(candidate, receiver_type, &reference.method_name)
5047        })
5048        .collect::<Vec<_>>();
5049    match candidates.as_slice() {
5050        [candidate] => Some((**candidate).clone()),
5051        _ => None,
5052    }
5053}
5054
5055fn type_candidate_matches(
5056    candidate: &NameMatchCandidate,
5057    receiver_type: &str,
5058    method_name: &str,
5059) -> bool {
5060    let normalized_type = receiver_type.replace('.', "::");
5061    let suffix = format!("{normalized_type}::{method_name}");
5062    candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
5063}
5064
5065fn select_name_match_candidate(
5066    reference: &NameMatchRef,
5067    candidates: &[NameMatchCandidate],
5068) -> Option<NameMatchCandidate> {
5069    let candidates = candidates
5070        .iter()
5071        .filter(|candidate| candidate.node_id != reference.caller_node)
5072        .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
5073        .collect::<Vec<_>>();
5074    match candidates.as_slice() {
5075        [] => None,
5076        [candidate] => Some((**candidate).clone()),
5077        _ => select_scored_name_match_candidate(reference, &candidates),
5078    }
5079}
5080
5081fn candidate_allowed_for_reference(
5082    reference: &NameMatchRef,
5083    candidate: &NameMatchCandidate,
5084) -> bool {
5085    if !reference.colon_dispatch {
5086        return true;
5087    }
5088
5089    candidate.kind == "method"
5090        && candidate
5091            .scoped_name
5092            .split("::")
5093            .any(|segment| segment == reference.receiver)
5094}
5095
5096fn select_scored_name_match_candidate(
5097    reference: &NameMatchRef,
5098    candidates: &[&NameMatchCandidate],
5099) -> Option<NameMatchCandidate> {
5100    let receiver_words = split_camel_case(&reference.receiver);
5101    if receiver_words.is_empty() {
5102        return None;
5103    }
5104
5105    let mut best: Option<(&NameMatchCandidate, f64)> = None;
5106    let mut tied_best = false;
5107    for candidate in candidates {
5108        let candidate_words = split_camel_case(&candidate.scoped_name);
5109        let overlap = receiver_words
5110            .iter()
5111            .filter(|receiver_word| {
5112                candidate_words
5113                    .iter()
5114                    .any(|candidate_word| candidate_word == *receiver_word)
5115            })
5116            .count() as f64;
5117        let score =
5118            overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
5119        match best {
5120            None => {
5121                best = Some((*candidate, score));
5122                tied_best = false;
5123            }
5124            Some((_, best_score)) if score > best_score => {
5125                best = Some((*candidate, score));
5126                tied_best = false;
5127            }
5128            Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
5129                tied_best = true;
5130            }
5131            _ => {}
5132        }
5133    }
5134
5135    let (candidate, score) = best?;
5136    if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
5137        Some(candidate.clone())
5138    } else {
5139        None
5140    }
5141}
5142
5143fn method_name_match_denylisted(method_name: &str) -> bool {
5144    matches!(
5145        method_name,
5146        "and_then"
5147            | "as_bytes"
5148            | "as_deref"
5149            | "as_mut"
5150            | "as_ref"
5151            | "as_str"
5152            | "borrow"
5153            | "borrow_mut"
5154            | "clear"
5155            | "clone"
5156            | "collect"
5157            | "contains"
5158            | "contains_key"
5159            | "count"
5160            | "dedup"
5161            | "default"
5162            | "drain"
5163            | "ends_with"
5164            | "entry"
5165            | "err"
5166            | "expect"
5167            | "extend"
5168            | "filter"
5169            | "filter_map"
5170            | "find"
5171            | "from"
5172            | "get"
5173            | "get_mut"
5174            | "insert"
5175            | "into"
5176            | "into_iter"
5177            | "is_empty"
5178            | "is_err"
5179            | "is_none"
5180            | "is_ok"
5181            | "is_some"
5182            | "iter"
5183            | "iter_mut"
5184            | "join"
5185            | "len"
5186            | "lock"
5187            | "map"
5188            | "map_err"
5189            | "max"
5190            | "min"
5191            | "new"
5192            | "next"
5193            | "ok"
5194            | "or_default"
5195            | "or_else"
5196            | "or_insert"
5197            | "or_insert_with"
5198            | "parse"
5199            | "pop"
5200            | "position"
5201            | "push"
5202            | "read"
5203            | "recv"
5204            | "remove"
5205            | "replace"
5206            | "retain"
5207            | "send"
5208            | "sort"
5209            | "sort_by"
5210            | "split"
5211            | "starts_with"
5212            | "sum"
5213            | "take"
5214            | "to_owned"
5215            | "to_string"
5216            | "trim"
5217            | "try_from"
5218            | "try_into"
5219            | "unwrap"
5220            | "unwrap_or"
5221            | "unwrap_or_default"
5222            | "unwrap_or_else"
5223            | "with_capacity"
5224            | "write"
5225    )
5226}
5227
5228fn split_camel_case(value: &str) -> Vec<String> {
5229    let chars = value.chars().collect::<Vec<_>>();
5230    let mut normalized = String::with_capacity(value.len() + 8);
5231    for (index, ch) in chars.iter().enumerate() {
5232        let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
5233        let next = chars.get(index + 1);
5234        let is_separator = ch.is_whitespace()
5235            || matches!(
5236                ch,
5237                '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
5238            );
5239        if is_separator {
5240            normalized.push(' ');
5241            continue;
5242        }
5243        let camel_boundary = previous.is_some_and(|prev| {
5244            (prev.is_lowercase() && ch.is_uppercase())
5245                || (prev.is_ascii_digit() && ch.is_alphabetic())
5246                || (prev.is_uppercase()
5247                    && ch.is_uppercase()
5248                    && next.is_some_and(|next| next.is_lowercase()))
5249        });
5250        if camel_boundary {
5251            normalized.push(' ');
5252        }
5253        normalized.push(*ch);
5254    }
5255
5256    normalized
5257        .split_whitespace()
5258        .filter(|word| word.len() > 1)
5259        .map(|word| word.to_ascii_lowercase())
5260        .collect()
5261}
5262
5263fn compute_path_proximity(left: &str, right: &str) -> f64 {
5264    let left_dirs = left
5265        .rsplit_once('/')
5266        .map(|(dir, _)| dir)
5267        .unwrap_or_default()
5268        .split('/')
5269        .filter(|part| !part.is_empty());
5270    let right_dirs = right
5271        .rsplit_once('/')
5272        .map(|(dir, _)| dir)
5273        .unwrap_or_default()
5274        .split('/')
5275        .filter(|part| !part.is_empty());
5276
5277    let shared = left_dirs
5278        .zip(right_dirs)
5279        .take_while(|(left, right)| left == right)
5280        .count();
5281    ((shared as f64) * 0.05).min(0.5)
5282}
5283
5284fn mark_backend_state(
5285    tx: &Transaction<'_>,
5286    project_root: &Path,
5287    rel_path: &str,
5288    content_hash: Option<&blake3::Hash>,
5289    status: &str,
5290) -> Result<()> {
5291    let hash = content_hash
5292        .map(|hash| hash_to_hex(*hash))
5293        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
5294    tx.execute(
5295        "INSERT OR REPLACE INTO backend_file_state(
5296            backend, workspace_root, file_path, content_hash, status, updated_at
5297        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
5298        params![
5299            BACKEND_TREESITTER,
5300            project_root.display().to_string(),
5301            rel_path,
5302            hash,
5303            status,
5304            unix_seconds_now(),
5305        ],
5306    )?;
5307    Ok(())
5308}
5309
5310fn load_file_row(tx: &Transaction<'_>, rel_path: &str) -> Result<Option<FileRow>> {
5311    tx.query_row(
5312        "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
5313        params![rel_path],
5314        |row| {
5315            let hash_text: String = row.get(1)?;
5316            Ok(FileRow {
5317                surface_fingerprint: row.get(0)?,
5318                freshness: FileFreshness {
5319                    content_hash: hash_from_hex(&hash_text)
5320                        .unwrap_or_else(cache_freshness::zero_hash),
5321                    mtime: ns_to_system_time(row.get::<_, i64>(2)?),
5322                    size: row.get::<_, i64>(3)? as u64,
5323                },
5324            })
5325        },
5326    )
5327    .optional()
5328    .map_err(CallGraphStoreError::from)
5329}
5330
5331fn update_file_fresh_metadata(
5332    tx: &Transaction<'_>,
5333    rel_path: &str,
5334    hash: &blake3::Hash,
5335    mtime: SystemTime,
5336    size: u64,
5337) -> Result<()> {
5338    tx.execute(
5339        "UPDATE files SET mtime_ns = ?2, size = ?3, indexed_at = ?4 WHERE path = ?1",
5340        params![
5341            rel_path,
5342            system_time_to_ns(mtime),
5343            size as i64,
5344            unix_seconds_now()
5345        ],
5346    )?;
5347    tx.execute(
5348        "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
5349         WHERE backend = ?1 AND file_path = ?2 AND content_hash = ?3",
5350        params![
5351            BACKEND_TREESITTER,
5352            rel_path,
5353            hash_to_hex(*hash),
5354            unix_seconds_now(),
5355        ],
5356    )?;
5357    Ok(())
5358}
5359
5360fn ref_ids_depending_on(
5361    tx: &Transaction<'_>,
5362    project_root: &Path,
5363    rel_path: &str,
5364) -> Result<Vec<String>> {
5365    let mut stmt = tx.prepare(
5366        "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
5367         FROM refs r
5368         WHERE r.caller_file IN (
5369             SELECT file_path FROM file_dependencies WHERE dep_file = ?1
5370         )
5371            OR r.target_file = ?1
5372         ORDER BY r.ref_id",
5373    )?;
5374    let rows = stmt.query_map(params![rel_path], |row| {
5375        Ok(RefDependencyRow {
5376            ref_id: row.get(0)?,
5377            kind: row.get(1)?,
5378            caller_file: row.get(2)?,
5379            module_path: row.get(3)?,
5380            target_file: row.get(4)?,
5381        })
5382    })?;
5383    let mut ids = Vec::new();
5384    for row in rows {
5385        let row = row?;
5386        if ref_dependency_row_depends_on(project_root, &row, rel_path) {
5387            ids.push(row.ref_id);
5388        }
5389    }
5390    Ok(ids)
5391}
5392
5393fn refs_by_caller_for_ref_ids(
5394    tx: &Transaction<'_>,
5395    ref_ids: &BTreeSet<String>,
5396) -> Result<BTreeMap<String, BTreeSet<String>>> {
5397    let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
5398    let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
5399    for ref_id in ref_ids {
5400        if let Some(caller) = stmt
5401            .query_row(params![ref_id], |row| row.get::<_, String>(0))
5402            .optional()?
5403        {
5404            by_caller.entry(caller).or_default().insert(ref_id.clone());
5405        }
5406    }
5407    Ok(by_caller)
5408}
5409
5410fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
5411    tx.execute(
5412        "DELETE FROM file_dependencies WHERE file_path = ?1",
5413        params![rel_path],
5414    )?;
5415    delete_refs_for_caller(tx, rel_path)?;
5416    tx.execute(
5417        "DELETE FROM dispatch_hints WHERE file = ?1",
5418        params![rel_path],
5419    )?;
5420    tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
5421    tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
5422    Ok(())
5423}
5424
5425fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
5426    let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
5427    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
5428    let mut ids = BTreeSet::new();
5429    for row in rows {
5430        ids.insert(row?);
5431    }
5432    delete_ref_ids(tx, &ids)
5433}
5434
5435fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
5436    for ref_id in ref_ids {
5437        tx.execute("DELETE FROM edges WHERE ref_id = ?1", params![ref_id])?;
5438        tx.execute("DELETE FROM refs WHERE ref_id = ?1", params![ref_id])?;
5439    }
5440    Ok(())
5441}
5442
5443fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
5444    let mut stmt = conn.prepare(
5445        "SELECT source.file_path, source.scoped_name, edges.target_file,
5446                edges.target_symbol, edges.kind, edges.line
5447         FROM edges
5448         JOIN nodes AS source ON source.id = edges.source_node
5449         ORDER BY source.file_path, source.scoped_name, edges.target_file,
5450                  edges.target_symbol, edges.kind, edges.line",
5451    )?;
5452    let rows = stmt.query_map([], |row| {
5453        Ok(StoredEdge {
5454            source_file: row.get(0)?,
5455            source_symbol: row.get(1)?,
5456            target_file: row.get(2)?,
5457            target_symbol: row.get(3)?,
5458            kind: row.get(4)?,
5459            line: row.get::<_, i64>(5)? as u32,
5460        })
5461    })?;
5462    let mut edges = BTreeSet::new();
5463    for row in rows {
5464        edges.insert(row?);
5465    }
5466    Ok(edges)
5467}
5468
5469fn module_target_from_dependencies(
5470    project_root: &Path,
5471    dependencies: &BTreeSet<String>,
5472) -> Option<String> {
5473    dependencies.iter().find_map(|dep| {
5474        let path = project_root.join(dep);
5475        if path.is_file() {
5476            Some(relative_path(project_root, &canonicalize_path(&path)))
5477        } else {
5478            None
5479        }
5480    })
5481}
5482
5483fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
5484    let mut named = HashMap::new();
5485    if let Some(full_ref) = &raw_ref.full_ref {
5486        named = parse_reexport_names(full_ref);
5487    }
5488    ReexportIndex {
5489        target_file,
5490        named,
5491        wildcard: raw_ref.wildcard,
5492    }
5493}
5494
5495fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
5496    let mut names = HashMap::new();
5497    let Some(open) = statement.find('{') else {
5498        return names;
5499    };
5500    let Some(close) = statement[open + 1..]
5501        .find('}')
5502        .map(|offset| open + 1 + offset)
5503    else {
5504        return names;
5505    };
5506    for spec in statement[open + 1..close].split(',') {
5507        let spec = spec.trim();
5508        if spec.is_empty() {
5509            continue;
5510        }
5511        if let Some((source, local)) = spec.split_once(" as ") {
5512            names.insert(local.trim().to_string(), source.trim().to_string());
5513        } else {
5514            names.insert(spec.to_string(), spec.to_string());
5515        }
5516    }
5517    names
5518}
5519
5520fn dependencies_for_ref(
5521    tx: &Transaction<'_>,
5522    project_root: &Path,
5523    ref_id: &str,
5524) -> Result<BTreeSet<String>> {
5525    let row = tx.query_row(
5526        "SELECT kind, caller_file, module_path, target_file FROM refs WHERE ref_id = ?1",
5527        params![ref_id],
5528        |row| {
5529            Ok(RefDependencyRow {
5530                ref_id: ref_id.to_string(),
5531                kind: row.get(0)?,
5532                caller_file: row.get(1)?,
5533                module_path: row.get(2)?,
5534                target_file: row.get(3)?,
5535            })
5536        },
5537    )?;
5538
5539    match row.kind.as_str() {
5540        "import" | "reexport" => {
5541            let Some(module_path) = row.module_path.as_deref() else {
5542                return Ok(BTreeSet::new());
5543            };
5544            let file_deps = file_dependencies_for_file(tx, &row.caller_file)?;
5545            let module_deps =
5546                module_dependencies_for_ref(project_root, &row.caller_file, module_path);
5547            Ok(file_deps.intersection(&module_deps).cloned().collect())
5548        }
5549        "export_alias" => Ok(BTreeSet::new()),
5550        "call" => {
5551            let mut deps = file_dependencies_for_file(tx, &row.caller_file)?;
5552            if let Some(target_file) = row.target_file {
5553                deps.insert(target_file);
5554            }
5555            Ok(deps)
5556        }
5557        _ => file_dependencies_for_file(tx, &row.caller_file),
5558    }
5559}
5560
5561#[derive(Debug)]
5562struct RefDependencyRow {
5563    ref_id: String,
5564    kind: String,
5565    caller_file: String,
5566    module_path: Option<String>,
5567    target_file: Option<String>,
5568}
5569
5570fn ref_dependency_row_depends_on(
5571    project_root: &Path,
5572    row: &RefDependencyRow,
5573    rel_path: &str,
5574) -> bool {
5575    if row.target_file.as_deref() == Some(rel_path) {
5576        return true;
5577    }
5578
5579    match row.kind.as_str() {
5580        "call" => true,
5581        "import" | "reexport" => row
5582            .module_path
5583            .as_deref()
5584            .map(|module_path| {
5585                module_dependencies_for_ref(project_root, &row.caller_file, module_path)
5586                    .contains(rel_path)
5587            })
5588            .unwrap_or(false),
5589        "export_alias" => false,
5590        _ => false,
5591    }
5592}
5593
5594fn file_dependencies_for_file(tx: &Transaction<'_>, file_path: &str) -> Result<BTreeSet<String>> {
5595    let mut stmt = tx
5596        .prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1 ORDER BY dep_file")?;
5597    let rows = stmt.query_map(params![file_path], |row| row.get::<_, String>(0))?;
5598    let mut deps = BTreeSet::new();
5599    for row in rows {
5600        deps.insert(row?);
5601    }
5602    Ok(deps)
5603}
5604
5605fn module_dependencies_for_ref(
5606    project_root: &Path,
5607    caller_file: &str,
5608    module_path: &str,
5609) -> BTreeSet<String> {
5610    module_dependencies(project_root, &project_root.join(caller_file), module_path)
5611}
5612
5613fn import_dependencies(
5614    project_root: &Path,
5615    abs_path: &Path,
5616    imports: &[ImportStatement],
5617) -> BTreeSet<String> {
5618    let mut deps = BTreeSet::new();
5619    for import in imports {
5620        deps.extend(module_dependencies(
5621            project_root,
5622            abs_path,
5623            &import.module_path,
5624        ));
5625    }
5626    deps
5627}
5628
5629fn module_dependencies(
5630    project_root: &Path,
5631    abs_path: &Path,
5632    module_path: &str,
5633) -> BTreeSet<String> {
5634    let mut deps = BTreeSet::new();
5635    let caller_dir = abs_path.parent().unwrap_or(project_root);
5636    if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
5637        deps.insert(relative_path(project_root, &resolved));
5638    }
5639    if module_path.starts_with('.') {
5640        let base = caller_dir.join(module_path);
5641        for candidate in relative_module_candidates(&base) {
5642            deps.insert(relative_path(project_root, &candidate));
5643        }
5644    }
5645    deps
5646}
5647
5648fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
5649    let mut candidates = Vec::new();
5650    if base.extension().is_some() {
5651        candidates.push(base.to_path_buf());
5652        return candidates;
5653    }
5654    for ext in JS_TS_EXTENSIONS {
5655        candidates.push(base.with_extension(ext));
5656    }
5657    for ext in JS_TS_EXTENSIONS {
5658        candidates.push(base.join(format!("index.{ext}")));
5659    }
5660    candidates
5661}
5662
5663fn import_local_names(import: &ImportStatement) -> Vec<String> {
5664    let mut names = Vec::new();
5665    if let Some(default) = &import.default_import {
5666        names.push(default.clone());
5667    }
5668    if let Some(namespace) = &import.namespace_import {
5669        names.push(namespace.clone());
5670    }
5671    for name in &import.names {
5672        names.push(crate::imports::specifier_local_name(name).to_string());
5673    }
5674    names
5675}
5676
5677fn import_requested_names(import: &ImportStatement) -> Vec<String> {
5678    import
5679        .names
5680        .iter()
5681        .map(|name| crate::imports::specifier_imported_name(name).to_string())
5682        .collect()
5683}
5684
5685fn import_is_wildcard(import: &ImportStatement) -> bool {
5686    import.namespace_import.is_some() || import.raw_text.contains('*')
5687}
5688
5689fn namespace_alias(full_ref: &str) -> Option<String> {
5690    full_ref
5691        .split_once('.')
5692        .map(|(namespace, _)| namespace.to_string())
5693}
5694
5695fn import_kind_label(kind: ImportKind) -> &'static str {
5696    match kind {
5697        ImportKind::Value => "value",
5698        ImportKind::Type => "type",
5699        ImportKind::SideEffect => "side_effect",
5700    }
5701}
5702
5703fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
5704    match kind {
5705        SymbolKind::Function => "function",
5706        SymbolKind::Class => "class",
5707        SymbolKind::Method => "method",
5708        SymbolKind::Struct => "struct",
5709        SymbolKind::Interface => "interface",
5710        SymbolKind::Enum => "enum",
5711        SymbolKind::TypeAlias => "type_alias",
5712        SymbolKind::Variable => "variable",
5713        SymbolKind::Heading => "heading",
5714        SymbolKind::FileSummary => "file_summary",
5715    }
5716}
5717
5718fn is_type_like(kind: &SymbolKind) -> bool {
5719    matches!(
5720        kind,
5721        SymbolKind::Class
5722            | SymbolKind::Struct
5723            | SymbolKind::Interface
5724            | SymbolKind::Enum
5725            | SymbolKind::TypeAlias
5726    )
5727}
5728
5729fn lang_label(lang: LangId) -> &'static str {
5730    match lang {
5731        LangId::TypeScript => "typescript",
5732        LangId::Tsx => "tsx",
5733        LangId::JavaScript => "javascript",
5734        LangId::Python => "python",
5735        LangId::Rust => "rust",
5736        LangId::Go => "go",
5737        LangId::C => "c",
5738        LangId::Cpp => "cpp",
5739        LangId::Zig => "zig",
5740        LangId::CSharp => "csharp",
5741        LangId::Bash => "bash",
5742        LangId::Html => "html",
5743        LangId::Markdown => "markdown",
5744        LangId::Solidity => "solidity",
5745        LangId::Scss => "scss",
5746        LangId::Vue => "vue",
5747        LangId::Json => "json",
5748        LangId::Scala => "scala",
5749        LangId::Java => "java",
5750        LangId::Ruby => "ruby",
5751        LangId::Kotlin => "kotlin",
5752        LangId::Swift => "swift",
5753        LangId::Php => "php",
5754        LangId::Lua => "lua",
5755        LangId::Perl => "perl",
5756        LangId::Yaml => "yaml",
5757    }
5758}
5759
5760fn lang_from_label(label: &str) -> Option<LangId> {
5761    match label {
5762        "typescript" => Some(LangId::TypeScript),
5763        "tsx" => Some(LangId::Tsx),
5764        "javascript" => Some(LangId::JavaScript),
5765        "python" => Some(LangId::Python),
5766        "rust" => Some(LangId::Rust),
5767        "go" => Some(LangId::Go),
5768        "c" => Some(LangId::C),
5769        "cpp" => Some(LangId::Cpp),
5770        "zig" => Some(LangId::Zig),
5771        "csharp" => Some(LangId::CSharp),
5772        "bash" => Some(LangId::Bash),
5773        "html" => Some(LangId::Html),
5774        "markdown" => Some(LangId::Markdown),
5775        "solidity" => Some(LangId::Solidity),
5776        "scss" => Some(LangId::Scss),
5777        "vue" => Some(LangId::Vue),
5778        "json" => Some(LangId::Json),
5779        "scala" => Some(LangId::Scala),
5780        "java" => Some(LangId::Java),
5781        "ruby" => Some(LangId::Ruby),
5782        "kotlin" => Some(LangId::Kotlin),
5783        "swift" => Some(LangId::Swift),
5784        "php" => Some(LangId::Php),
5785        "lua" => Some(LangId::Lua),
5786        "perl" => Some(LangId::Perl),
5787        "yaml" => Some(LangId::Yaml),
5788        _ => None,
5789    }
5790}
5791
5792fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
5793    let mut normalized = if files.is_empty() {
5794        callgraph::walk_project_files(project_root).collect::<Vec<_>>()
5795    } else {
5796        files
5797            .iter()
5798            .map(|path| normalize_file_path(project_root, path))
5799            .collect::<Result<Vec<_>>>()?
5800    };
5801    normalized.sort();
5802    normalized.dedup();
5803    Ok(normalized)
5804}
5805
5806fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
5807    let full_path = if path.is_relative() {
5808        project_root.join(path)
5809    } else {
5810        path.to_path_buf()
5811    };
5812    Ok(canonicalize_path(&full_path))
5813}
5814
5815fn canonicalize_path(path: &Path) -> PathBuf {
5816    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
5817}
5818
5819fn relative_path(project_root: &Path, path: &Path) -> String {
5820    if let Ok(stripped) = path.strip_prefix(project_root) {
5821        return stripped.to_string_lossy().replace('\\', "/");
5822    }
5823    let canon_root = canonicalize_path(project_root);
5824    let canon_path = canonicalize_path(path);
5825    if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
5826        return stripped.to_string_lossy().replace('\\', "/");
5827    }
5828    canon_path.to_string_lossy().replace('\\', "/")
5829}
5830
5831fn unqualified_name(scoped: &str) -> &str {
5832    if scoped == TOP_LEVEL_SYMBOL {
5833        return scoped;
5834    }
5835    scoped
5836        .rsplit("::")
5837        .next()
5838        .unwrap_or(scoped)
5839        .rsplit('.')
5840        .next()
5841        .unwrap_or(scoped)
5842        .rsplit('#')
5843        .next()
5844        .unwrap_or(scoped)
5845}
5846
5847fn ref_id(parts: &[&str]) -> String {
5848    let joined = parts.join("\0");
5849    hash_to_hex(blake3::hash(joined.as_bytes()))
5850}
5851
5852fn hash_to_hex(hash: blake3::Hash) -> String {
5853    hash.to_hex().to_string()
5854}
5855
5856fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
5857    let bytes = hex_to_bytes(value)?;
5858    Some(blake3::Hash::from_bytes(bytes))
5859}
5860
5861fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
5862    if value.len() != 64 {
5863        return None;
5864    }
5865    let mut bytes = [0u8; 32];
5866    for (index, slot) in bytes.iter_mut().enumerate() {
5867        let start = index * 2;
5868        let end = start + 2;
5869        *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
5870    }
5871    Some(bytes)
5872}
5873
5874fn byte_to_line(path: &Path, byte_offset: usize) -> Option<u32> {
5875    let source = std::fs::read_to_string(path).ok()?;
5876    Some(
5877        source[..byte_offset.min(source.len())]
5878            .bytes()
5879            .filter(|byte| *byte == b'\n')
5880            .count() as u32
5881            + 1,
5882    )
5883}
5884
5885fn empty_to_none(value: String) -> Option<String> {
5886    if value.is_empty() {
5887        None
5888    } else {
5889        Some(value)
5890    }
5891}
5892
5893fn bool_int(value: bool) -> i64 {
5894    if value {
5895        1
5896    } else {
5897        0
5898    }
5899}
5900
5901fn system_time_to_ns(time: SystemTime) -> i64 {
5902    time.duration_since(UNIX_EPOCH)
5903        .unwrap_or_default()
5904        .as_nanos()
5905        .min(i64::MAX as u128) as i64
5906}
5907
5908fn ns_to_system_time(value: i64) -> SystemTime {
5909    UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
5910}
5911
5912fn unix_seconds_now() -> i64 {
5913    SystemTime::now()
5914        .duration_since(UNIX_EPOCH)
5915        .unwrap_or_default()
5916        .as_secs() as i64
5917}
5918
5919#[cfg(test)]
5920mod build_pool_tests {
5921    use super::build_pool_size;
5922
5923    #[test]
5924    fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
5925        let size = build_pool_size();
5926        // Never zero, never the full core count, never above the 8 cap — this is
5927        // the starvation guard for the cold-build's all-cores tree-sitter pass.
5928        assert!(size >= 1, "pool size must be at least 1");
5929        assert!(size <= 8, "pool size must be capped at 8, got {size}");
5930
5931        let cores = std::thread::available_parallelism()
5932            .map(|p| p.get())
5933            .unwrap_or(1);
5934        let expected = cores.div_ceil(2).clamp(1, 8);
5935        assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
5936    }
5937}
5938
5939#[cfg(test)]
5940mod method_dispatch_inference_tests {
5941    use super::*;
5942    use std::fs;
5943    use tempfile::tempdir;
5944
5945    #[test]
5946    fn java_field_receiver_type_selects_declared_class_method() {
5947        let source = r#"class EntryPoint {
5948    private UserService userService;
5949
5950    void handle() {
5951        userService.find();
5952    }
5953}
5954
5955class UserService {
5956    void find() {}
5957}
5958
5959class AuditService {
5960    void find() {}
5961}
5962"#;
5963        let dir = tempdir().expect("temp dir");
5964        let root = dir.path();
5965        write_fixture(root, "src/EntryPoint.java", source);
5966        let reference = reference(
5967            "java",
5968            "src/EntryPoint.java",
5969            "EntryPoint::handle",
5970            "userService",
5971            "find",
5972            line_of(source, "userService.find()"),
5973        );
5974        let mut cache = DispatchSourceCache::new();
5975
5976        let receiver_type =
5977            infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
5978        assert_eq!(receiver_type, "UserService");
5979
5980        let candidates = vec![
5981            method_candidate("audit", "AuditService::find"),
5982            method_candidate("user", "UserService::find"),
5983        ];
5984        let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
5985            .expect("type candidate");
5986        assert_eq!(selected.scoped_name, "UserService::find");
5987
5988        let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
5989        assert!(
5990            select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
5991        );
5992    }
5993
5994    #[test]
5995    fn kotlin_property_and_local_value_types_are_inferred() {
5996        let source = r#"class Handler {
5997    private val auditService: AuditService = AuditService()
5998
5999    fun handle() {
6000        auditService.find()
6001        val userService: UserService = UserService()
6002        userService.find()
6003        val billingService = BillingService()
6004        billingService.find()
6005    }
6006}
6007
6008class UserService { fun find() {} }
6009class AuditService { fun find() {} }
6010class BillingService { fun find() {} }
6011"#;
6012        let dir = tempdir().expect("temp dir");
6013        let root = dir.path();
6014        write_fixture(root, "src/Handler.kt", source);
6015        let mut cache = DispatchSourceCache::new();
6016
6017        let audit_ref = reference(
6018            "kotlin",
6019            "src/Handler.kt",
6020            "Handler::handle",
6021            "auditService",
6022            "find",
6023            line_of(source, "auditService.find()"),
6024        );
6025        assert_eq!(
6026            infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
6027            Some("AuditService")
6028        );
6029
6030        let user_ref = reference(
6031            "kotlin",
6032            "src/Handler.kt",
6033            "Handler::handle",
6034            "userService",
6035            "find",
6036            line_of(source, "userService.find()"),
6037        );
6038        assert_eq!(
6039            infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
6040            Some("UserService")
6041        );
6042
6043        let billing_ref = reference(
6044            "kotlin",
6045            "src/Handler.kt",
6046            "Handler::handle",
6047            "billingService",
6048            "find",
6049            line_of(source, "billingService.find()"),
6050        );
6051        assert_eq!(
6052            infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
6053            Some("BillingService")
6054        );
6055    }
6056
6057    #[test]
6058    fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
6059        let source = r#"struct Foo { void run(); };
6060struct PointerFoo { void run(); };
6061struct FactoryFoo { void run(); };
6062FactoryFoo makeFactoryFoo();
6063
6064void handle() {
6065    Foo foo;
6066    foo.run();
6067    PointerFoo* pointerFoo = nullptr;
6068    pointerFoo->run();
6069    auto factoryFoo = makeFactoryFoo();
6070    factoryFoo.run();
6071}
6072"#;
6073        let dir = tempdir().expect("temp dir");
6074        let root = dir.path();
6075        write_fixture(root, "src/fixture.cpp", source);
6076        let mut cache = DispatchSourceCache::new();
6077
6078        let foo_ref = reference(
6079            "cpp",
6080            "src/fixture.cpp",
6081            "handle",
6082            "foo",
6083            "run",
6084            line_of(source, "foo.run()"),
6085        );
6086        assert_eq!(
6087            infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
6088            Some("Foo")
6089        );
6090
6091        let pointer_ref = reference(
6092            "cpp",
6093            "src/fixture.cpp",
6094            "handle",
6095            "pointerFoo",
6096            "run",
6097            line_of(source, "pointerFoo->run()"),
6098        );
6099        assert_eq!(
6100            infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
6101            Some("PointerFoo")
6102        );
6103
6104        let factory_ref = reference(
6105            "cpp",
6106            "src/fixture.cpp",
6107            "handle",
6108            "factoryFoo",
6109            "run",
6110            line_of(source, "factoryFoo.run()"),
6111        );
6112        assert_eq!(
6113            infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
6114            Some("FactoryFoo")
6115        );
6116    }
6117
6118    #[test]
6119    fn unknown_java_receiver_still_uses_name_match_fallback() {
6120        let source = r#"class EntryPoint {
6121    void handle() {
6122        service.runSpecial();
6123    }
6124}
6125
6126class OnlyService {
6127    void runSpecial() {}
6128}
6129"#;
6130        let dir = tempdir().expect("temp dir");
6131        let root = dir.path();
6132        write_fixture(root, "src/EntryPoint.java", source);
6133        let reference = reference(
6134            "java",
6135            "src/EntryPoint.java",
6136            "EntryPoint::handle",
6137            "service",
6138            "runSpecial",
6139            line_of(source, "service.runSpecial()"),
6140        );
6141        let mut cache = DispatchSourceCache::new();
6142
6143        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
6144        let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
6145        let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
6146        assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
6147    }
6148
6149    fn reference(
6150        lang: &str,
6151        caller_file: &str,
6152        caller_symbol: &str,
6153        receiver: &str,
6154        method_name: &str,
6155        line: u32,
6156    ) -> NameMatchRef {
6157        NameMatchRef {
6158            ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
6159            caller_node: format!("{caller_symbol}:node"),
6160            caller_file: caller_file.to_string(),
6161            caller_symbol: caller_symbol.to_string(),
6162            caller_signature: None,
6163            receiver: receiver.to_string(),
6164            method_name: method_name.to_string(),
6165            colon_dispatch: false,
6166            line,
6167            lang: lang.to_string(),
6168        }
6169    }
6170
6171    fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
6172        NameMatchCandidate {
6173            node_id: node_id.to_string(),
6174            file_path: "src/targets.fixture".to_string(),
6175            scoped_name: scoped_name.to_string(),
6176            kind: "method".to_string(),
6177        }
6178    }
6179
6180    fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
6181        let path = root.join(rel_path);
6182        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
6183        fs::write(path, source).expect("write fixture");
6184    }
6185
6186    fn line_of(source: &str, needle: &str) -> u32 {
6187        source
6188            .lines()
6189            .position(|line| line.contains(needle))
6190            .map(|index| index as u32 + 1)
6191            .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
6192    }
6193}