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