Skip to main content

aft/callgraph_store/
mod.rs

1//! Persistent call/reference graph sidecar.
2//!
3//! This SQLite-backed substrate stores raw symbols, references, and resolved
4//! edges, and backs the live call-graph commands (callers, call-tree, impact,
5//! trace) as well as dead-code reachability. It is self-contained: it can be
6//! built and queried directly without going through the in-memory call graph.
7
8use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
9use crate::callgraph::{self, EdgeResolution, FileCallData};
10use crate::error::AftError;
11use crate::imports::{ImportKind, ImportStatement};
12use crate::parser::LangId;
13use crate::symbols::{Range, SymbolKind};
14use rayon::prelude::*;
15use rusqlite::{params, Connection, OpenFlags, OptionalExtension, Statement, Transaction};
16use std::collections::{hash_map::Entry, BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
17use std::fmt;
18use std::path::{Path, PathBuf};
19use std::sync::{Arc, Mutex};
20use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
21
22const SCHEMA_VERSION: i64 = 1;
23const BACKEND_TREESITTER: &str = "treesitter";
24const PROVENANCE_TREESITTER: &str = "treesitter+resolver";
25const PROVENANCE_NAME_MATCH: &str = "name_match";
26const PROVENANCE_TYPE_MATCH: &str = "type_match";
27const NAME_MATCH_SCORE_THRESHOLD: f64 = 2.0;
28const TOP_LEVEL_SYMBOL: &str = "<top-level>";
29const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
30
31type ColdBuildSwapObserver = dyn Fn(&Path, &Path) + Send + Sync + 'static;
32// THREAD-LOCAL, not a process-global: the observer fires synchronously on the
33// thread running the cold build, and the only caller (a test) installs and
34// clears it on its own thread. A process-global `Mutex<Option<...>>` raced
35// across parallel tests — one test's installed observer fired during ANOTHER
36// test's `cold_build_with_lease`, asserting against the wrong build's edges
37// (flaked on Windows CI under parallel scheduling). Production never sets it.
38thread_local! {
39    static COLD_BUILD_SWAP_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSwapObserver>>> =
40        const { std::cell::RefCell::new(None) };
41}
42
43mod dead_code_projection;
44pub use dead_code_projection::project_dead_code_snapshot;
45
46#[doc(hidden)]
47pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
48    COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
49}
50
51fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
52    let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
53    if let Some(observer) = observer {
54        observer(temp_path, target_path);
55    }
56}
57
58#[derive(Debug)]
59pub enum CallGraphStoreError {
60    Io(std::io::Error),
61    Sqlite(rusqlite::Error),
62    Json(serde_json::Error),
63    Aft(AftError),
64    Lock(crate::fs_lock::AcquireError),
65    MissingCallerData { file: String },
66    Unavailable(String),
67    StaleFiles(Vec<String>),
68}
69
70impl fmt::Display for CallGraphStoreError {
71    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
72        match self {
73            Self::Io(error) => write!(formatter, "I/O error: {error}"),
74            Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
75            Self::Json(error) => write!(formatter, "json error: {error}"),
76            Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
77            Self::Lock(error) => write!(formatter, "callgraph build lock error: {error}"),
78            Self::MissingCallerData { file } => {
79                write!(formatter, "missing extracted caller data for {file}")
80            }
81            Self::Unavailable(message) => {
82                write!(formatter, "callgraph store unavailable: {message}")
83            }
84            Self::StaleFiles(files) => {
85                write!(
86                    formatter,
87                    "callgraph store has stale files: {}",
88                    files.join(", ")
89                )
90            }
91        }
92    }
93}
94
95impl std::error::Error for CallGraphStoreError {}
96
97impl From<std::io::Error> for CallGraphStoreError {
98    fn from(error: std::io::Error) -> Self {
99        Self::Io(error)
100    }
101}
102
103impl From<rusqlite::Error> for CallGraphStoreError {
104    fn from(error: rusqlite::Error) -> Self {
105        Self::Sqlite(error)
106    }
107}
108
109impl From<serde_json::Error> for CallGraphStoreError {
110    fn from(error: serde_json::Error) -> Self {
111        Self::Json(error)
112    }
113}
114
115impl From<AftError> for CallGraphStoreError {
116    fn from(error: AftError) -> Self {
117        Self::Aft(error)
118    }
119}
120
121impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
122    fn from(error: crate::fs_lock::AcquireError) -> Self {
123        Self::Lock(error)
124    }
125}
126
127pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
128
129/// Config flag name gating whether the store is opened (default on). Production
130/// commands open it through `open_if_enabled` so the substrate can be disabled
131/// without code changes.
132pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
133
134#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
135pub struct CallGraphStoreOptions {
136    pub enabled: bool,
137}
138
139#[derive(Debug)]
140pub struct CallGraphStore {
141    project_root: PathBuf,
142    project_key: String,
143    /// The concrete on-disk DB file this store opened. With the generation
144    /// scheme this is `<dir>/<key>.g<...>.sqlite` (resolved via the pointer) or,
145    /// for a pre-generation store, the legacy `<dir>/<key>.sqlite`.
146    sqlite_path: PathBuf,
147    /// The generation file NAME this store opened (e.g. `<key>.g<nanos>.<pid>.sqlite`),
148    /// or `None` when it opened the legacy single-file DB. Used to detect when
149    /// another process has published a newer generation so this process can
150    /// drop its connection and reopen (see `current_generation`).
151    generation: Option<String>,
152    conn: Mutex<Connection>,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
156enum OpenRootRepair {
157    None,
158    ReRooted,
159    NeedsRebuild {
160        previous_roots: Vec<String>,
161        current_root: String,
162        reason: String,
163    },
164}
165
166struct OpenedStore {
167    store: CallGraphStore,
168    root_repair: OpenRootRepair,
169}
170
171#[derive(Debug, Clone)]
172pub struct ColdBuildStats {
173    pub files: usize,
174    pub nodes: usize,
175    pub refs: usize,
176    pub edges: usize,
177    pub failed_files: Vec<String>,
178    pub elapsed_ms: u128,
179}
180
181#[derive(Debug, Clone)]
182pub struct IncrementalStats {
183    pub changed_files: Vec<String>,
184    pub surface_changed: Vec<String>,
185    pub deleted_files: Vec<String>,
186    pub dependency_selected_refs: usize,
187    pub refreshed_own_files: usize,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
191pub struct StoredEdge {
192    pub source_file: String,
193    pub source_symbol: String,
194    pub target_file: String,
195    pub target_symbol: String,
196    pub kind: String,
197    pub line: u32,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct StoreNode {
202    node_id: String,
203    pub file: String,
204    pub symbol: String,
205    pub name: String,
206    pub kind: String,
207    pub line: u32,
208    pub end_line: u32,
209    pub signature: Option<String>,
210    pub exported: bool,
211    pub is_entry_point: bool,
212    pub lang: LangId,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct StoreCallSite {
217    pub caller: StoreNode,
218    pub target_file: String,
219    pub target_symbol: String,
220    pub target: Option<StoreNode>,
221    pub line: u32,
222    pub byte_start: usize,
223    pub byte_end: usize,
224    pub resolved: bool,
225    pub provenance: String,
226}
227
228impl StoreCallSite {
229    pub fn approximate(&self) -> bool {
230        self.provenance == PROVENANCE_NAME_MATCH
231    }
232
233    pub fn resolved_by(&self) -> &str {
234        &self.provenance
235    }
236
237    pub fn supplemental_resolution(&self) -> Option<&str> {
238        match self.provenance.as_str() {
239            PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
240            _ => None,
241        }
242    }
243}
244
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct StoreUnresolvedCall {
247    pub caller: StoreNode,
248    pub symbol: String,
249    pub full_ref: Option<String>,
250    pub line: u32,
251    pub byte_start: usize,
252    pub byte_end: usize,
253}
254
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub struct StoreCallersResult {
257    pub target: StoreNode,
258    pub callers: Vec<StoreCallSite>,
259    pub scanned_files: usize,
260    pub depth_limited: bool,
261    pub truncated: usize,
262}
263
264#[derive(Debug, Clone, PartialEq, Eq)]
265pub struct StoreImpactCaller {
266    pub site: StoreCallSite,
267    pub signature: Option<String>,
268    pub is_entry_point: bool,
269    pub call_expression: Option<String>,
270    pub parameters: Vec<String>,
271}
272
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub struct StoreImpactResult {
275    pub target: StoreNode,
276    pub parameters: Vec<String>,
277    pub callers: Vec<StoreImpactCaller>,
278    pub depth_limited: bool,
279    pub truncated: usize,
280}
281
282#[derive(Debug, Clone)]
283struct ExtractFailure {
284    rel_path: String,
285    freshness: Option<FileFreshness>,
286}
287
288#[derive(Debug, Clone)]
289struct BuildExtractsResult {
290    extracts: Vec<FileExtract>,
291    failures: Vec<ExtractFailure>,
292}
293
294#[derive(Debug, Clone)]
295enum StoreForwardCall {
296    Resolved(StoreCallSite),
297    Unresolved(StoreUnresolvedCall),
298}
299
300impl StoreForwardCall {
301    fn byte_start(&self) -> usize {
302        match self {
303            Self::Resolved(site) => site.byte_start,
304            Self::Unresolved(call) => call.byte_start,
305        }
306    }
307
308    fn line(&self) -> u32 {
309        match self {
310            Self::Resolved(site) => site.line,
311            Self::Unresolved(call) => call.line,
312        }
313    }
314}
315
316#[derive(Debug, Clone)]
317struct FileExtract {
318    abs_path: PathBuf,
319    rel_path: String,
320    freshness: FileFreshness,
321    lang: LangId,
322    data: FileCallData,
323    nodes: Vec<NodeRecord>,
324    raw_refs: Vec<RawRef>,
325    dispatch_hints: Vec<DispatchHint>,
326    surface_fingerprint: String,
327}
328
329#[derive(Debug, Clone)]
330struct NodeRecord {
331    id: String,
332    file_path: String,
333    name: String,
334    scoped_name: String,
335    kind: String,
336    range: Range,
337    range_ordinal: u32,
338    signature: Option<String>,
339    exported: bool,
340    is_default_export: bool,
341    is_type_like: bool,
342    is_callgraph_entry_point: bool,
343}
344
345#[derive(Debug, Clone)]
346struct RawRef {
347    ref_id: String,
348    caller_node: Option<String>,
349    caller_symbol: Option<String>,
350    caller_file: String,
351    kind: String,
352    short_name: Option<String>,
353    full_ref: Option<String>,
354    module_path: Option<String>,
355    import_kind: Option<String>,
356    local_name: Option<String>,
357    requested_name: Option<String>,
358    namespace_alias: Option<String>,
359    wildcard: bool,
360    line: u32,
361    byte_start: usize,
362    byte_end: usize,
363    dependencies: BTreeSet<String>,
364}
365
366#[derive(Debug, Clone)]
367struct ResolvedRef {
368    raw: RawRef,
369    status: String,
370    target_node: Option<String>,
371    target_file: Option<String>,
372    target_symbol: Option<String>,
373    dependencies: BTreeSet<String>,
374    edge: Option<EdgeRecord>,
375}
376
377#[derive(Debug, Clone)]
378struct EdgeRecord {
379    edge_id: String,
380    source_node: String,
381    target_node: Option<String>,
382    target_file: String,
383    target_symbol: String,
384    kind: String,
385    line: u32,
386}
387
388#[derive(Debug, Clone)]
389struct DispatchHint {
390    id: String,
391    method_name: String,
392    caller_node: String,
393    file: String,
394    line: u32,
395    byte_start: usize,
396    byte_end: usize,
397}
398
399#[derive(Debug, Clone)]
400struct NameMatchRef {
401    ref_id: String,
402    caller_node: String,
403    caller_file: String,
404    caller_symbol: String,
405    caller_signature: Option<String>,
406    receiver: String,
407    method_name: String,
408    colon_dispatch: bool,
409    line: u32,
410    lang: String,
411}
412
413#[derive(Debug, Clone)]
414struct NameMatchCandidate {
415    node_id: String,
416    file_path: String,
417    scoped_name: String,
418    kind: String,
419}
420
421#[derive(Debug, Clone)]
422struct FileRow {
423    surface_fingerprint: String,
424    freshness: FileFreshness,
425}
426
427#[derive(Debug, Clone)]
428struct DbFileIndex {
429    lang: Option<LangId>,
430    exports: HashSet<String>,
431    default_export: Option<String>,
432    export_aliases: HashMap<String, String>,
433    node_by_scoped: HashMap<String, String>,
434    node_by_bare: HashMap<String, String>,
435    module_targets: HashMap<String, Option<String>>,
436    reexports: Vec<ReexportIndex>,
437}
438
439#[derive(Debug, Clone)]
440struct ReexportIndex {
441    target_file: Option<String>,
442    named: HashMap<String, String>,
443    wildcard: bool,
444}
445
446#[derive(Debug, Clone)]
447struct ProjectIndex<'a> {
448    project_root: PathBuf,
449    files: HashMap<String, DbFileIndex>,
450    caller_data: HashMap<String, &'a FileCallData>,
451    /// Lazily-built `crate_name -> src prefix` map for Rust workspace resolution.
452    /// Built once (whole-tree walk) on first qualified-ref resolution and reused,
453    /// instead of re-walking the project per ref. Skipped entirely when no Rust
454    /// workspace ref is resolved (e.g. warm query path with no Rust changes).
455    workspace_crate_prefixes: std::sync::OnceLock<HashMap<String, String>>,
456}
457
458impl ProjectIndex<'_> {
459    /// Resolve a crate name to its `src` prefix, building the workspace map on
460    /// first use. The map walks the project tree exactly once per index.
461    fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
462        self.workspace_crate_prefixes
463            .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
464            .get(crate_name)
465            .cloned()
466    }
467}
468
469impl CallGraphStore {
470    pub fn open_if_enabled(
471        options: CallGraphStoreOptions,
472        callgraph_dir: PathBuf,
473        project_root: PathBuf,
474    ) -> Result<Option<Self>> {
475        if !options.enabled {
476            return Ok(None);
477        }
478        Self::open(callgraph_dir, project_root).map(Some)
479    }
480
481    pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
482        std::fs::create_dir_all(&callgraph_dir)?;
483        let project_key = crate::search_index::artifact_cache_key(&project_root);
484        // Resolve the current generation via the pointer (falling back to the
485        // legacy single-file DB). If nothing is published yet, open the legacy
486        // path so a brand-new store still gets a writable DB + schema.
487        let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
488            .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
489        let OpenedStore { store, root_repair } = Self::open_at_path(
490            project_root.clone(),
491            project_key,
492            sqlite_path,
493            generation,
494            true,
495        )?;
496        match root_repair {
497            OpenRootRepair::NeedsRebuild { .. } => {
498                log_root_repair_rebuild(&root_repair);
499                drop(store);
500                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
501                let (store, _stats) =
502                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
503                Ok(store)
504            }
505            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
506        }
507    }
508
509    pub fn open_readonly(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
510        let project_key = crate::search_index::artifact_cache_key(&project_root);
511        let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
512        else {
513            return Ok(None);
514        };
515        let conn = Connection::open_with_flags(&sqlite_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
516        conn.busy_timeout(Duration::from_millis(5_000))?;
517        if !database_ready(&conn).unwrap_or(false) {
518            return Ok(None);
519        }
520        Ok(Some(Self::from_connection(
521            project_root,
522            project_key,
523            sqlite_path,
524            generation,
525            conn,
526        )))
527    }
528
529    /// Open the currently-published ready store with write access so moved-root
530    /// metadata can be repaired before projection readers consume it. Unlike
531    /// [`open`], this preserves the read path's cold/mid-build behavior: if no
532    /// ready generation exists, it returns `Ok(None)` instead of creating an
533    /// empty legacy database. Worktree bridges must keep using [`open_readonly`].
534    pub fn open_ready_repairing(
535        callgraph_dir: PathBuf,
536        project_root: PathBuf,
537    ) -> Result<Option<Self>> {
538        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true)
539    }
540
541    pub fn open_ready_no_rebuild(
542        callgraph_dir: PathBuf,
543        project_root: PathBuf,
544    ) -> Result<Option<Self>> {
545        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false)
546    }
547
548    fn open_ready_with_rebuild_policy(
549        callgraph_dir: PathBuf,
550        project_root: PathBuf,
551        allow_cold_build: bool,
552    ) -> Result<Option<Self>> {
553        let project_key = crate::search_index::artifact_cache_key(&project_root);
554        let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
555        else {
556            return Ok(None);
557        };
558        let OpenedStore { store, root_repair } = Self::open_at_path(
559            project_root.clone(),
560            project_key,
561            sqlite_path,
562            generation,
563            true,
564        )?;
565        match root_repair {
566            OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
567                log_root_repair_rebuild(&root_repair);
568                drop(store);
569                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
570                let (store, _stats) =
571                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
572                Ok(Some(store))
573            }
574            OpenRootRepair::NeedsRebuild { .. } => {
575                crate::slog_info!(
576                    "callgraph store root repair requires rebuild; open-only reader reports unavailable"
577                );
578                Ok(None)
579            }
580            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
581        }
582    }
583
584    pub fn cold_build_with_lease(
585        callgraph_dir: PathBuf,
586        project_root: PathBuf,
587        files: &[PathBuf],
588    ) -> Result<(Self, ColdBuildStats)> {
589        Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
590    }
591
592    pub fn cold_build_with_lease_chunked(
593        callgraph_dir: PathBuf,
594        project_root: PathBuf,
595        files: &[PathBuf],
596        chunk_size: usize,
597    ) -> Result<(Self, ColdBuildStats)> {
598        std::fs::create_dir_all(&callgraph_dir)?;
599        let project_key = crate::search_index::artifact_cache_key(&project_root);
600        let lock_path = callgraph_dir.join(format!("{project_key}.build.lock"));
601        let _guard = crate::fs_lock::try_acquire(&lock_path, Duration::from_secs(30))?;
602        let (stats, generation) = Self::cold_build_publish_locked(
603            &callgraph_dir,
604            &project_root,
605            &project_key,
606            files,
607            chunk_size,
608        )?;
609        let store = Self::open_generation(&callgraph_dir, project_root, project_key, generation)?;
610        Ok((store, stats))
611    }
612
613    pub fn ensure_built_with_lease(
614        callgraph_dir: PathBuf,
615        project_root: PathBuf,
616        files: &[PathBuf],
617    ) -> Result<(Self, Option<ColdBuildStats>)> {
618        Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
619    }
620
621    pub fn ensure_built_with_lease_chunked(
622        callgraph_dir: PathBuf,
623        project_root: PathBuf,
624        files: &[PathBuf],
625        chunk_size: usize,
626    ) -> Result<(Self, Option<ColdBuildStats>)> {
627        std::fs::create_dir_all(&callgraph_dir)?;
628        let project_key = crate::search_index::artifact_cache_key(&project_root);
629        let lock_path = callgraph_dir.join(format!("{project_key}.build.lock"));
630        let _guard = crate::fs_lock::try_acquire(&lock_path, Duration::from_secs(30))?;
631        // Another process may have published a ready generation while we waited
632        // for the lock — open it instead of rebuilding. If that generation is
633        // from this same project at an older filesystem root, repair the root
634        // metadata in-place while still holding the build lease. If data rows
635        // contain absolute paths, publish a fresh generation under this lease
636        // rather than recursively reacquiring the same lock.
637        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
638        {
639            let OpenedStore { store, root_repair } = Self::open_at_path(
640                project_root.clone(),
641                project_key.clone(),
642                sqlite_path,
643                generation,
644                true,
645            )?;
646            match root_repair {
647                OpenRootRepair::NeedsRebuild { .. } => {
648                    log_root_repair_rebuild(&root_repair);
649                    drop(store);
650                    let (stats, generation) = Self::cold_build_publish_locked(
651                        &callgraph_dir,
652                        &project_root,
653                        &project_key,
654                        files,
655                        chunk_size,
656                    )?;
657                    let store = Self::open_generation(
658                        &callgraph_dir,
659                        project_root,
660                        project_key,
661                        generation,
662                    )?;
663                    return Ok((store, Some(stats)));
664                }
665                OpenRootRepair::None | OpenRootRepair::ReRooted => {
666                    return Ok((store, None));
667                }
668            }
669        }
670        let (stats, generation) = Self::cold_build_publish_locked(
671            &callgraph_dir,
672            &project_root,
673            &project_key,
674            files,
675            chunk_size,
676        )?;
677        let store = Self::open_generation(&callgraph_dir, project_root, project_key, generation)?;
678        Ok((store, Some(stats)))
679    }
680
681    /// Build a fresh DB and publish it as a new generation, then atomically flip
682    /// the `<key>.current` pointer to it. NEVER replaces an open DB file, so it
683    /// succeeds even when other processes hold an older generation open (the
684    /// multi-TUI Windows case). The builder owns the temp + generation files
685    /// exclusively (unique pid+nanos names), so it can rename/replace them
686    /// freely; only the tiny pointer is shared, and only Rust std touches it.
687    ///
688    /// Returns the published generation file name so callers open exactly the
689    /// generation they built (avoiding a race where a concurrent build's flip
690    /// would otherwise reopen a different generation).
691    fn cold_build_publish_locked(
692        callgraph_dir: &Path,
693        project_root: &Path,
694        project_key: &str,
695        files: &[PathBuf],
696        chunk_size: usize,
697    ) -> Result<(ColdBuildStats, String)> {
698        let generation = generation_file_name(project_key);
699        let gen_path = callgraph_dir.join(&generation);
700        let temp_path = callgraph_dir.join(format!(
701            "{generation}.tmp.{}.{}",
702            std::process::id(),
703            now_nanos()
704        ));
705        remove_sqlite_file_set(&temp_path);
706
707        let stats = {
708            let temp_store = Self::open_at_path(
709                project_root.to_path_buf(),
710                project_key.to_string(),
711                temp_path.clone(),
712                None,
713                false,
714            )?
715            .store;
716            let stats = temp_store.cold_build_chunked(files, chunk_size)?;
717            temp_store.prepare_for_atomic_swap()?;
718            stats
719        };
720
721        // Move the finished build to its final generation path. This target is
722        // brand-new and owned by us, so the rename never hits an open file.
723        remove_sqlite_file_set(&gen_path);
724        std::fs::rename(&temp_path, &gen_path)?;
725        remove_sqlite_sidecars(&gen_path);
726
727        notify_cold_build_swap_observer(&temp_path, &gen_path);
728
729        // Atomically publish the new generation, then best-effort GC old ones.
730        publish_pointer(callgraph_dir, project_key, &generation)?;
731        gc_old_generations(callgraph_dir, project_key, &generation);
732        Ok((stats, generation))
733    }
734
735    /// Open a specific just-published generation (read-write, WAL) so a builder
736    /// returns a store pinned to exactly what it built.
737    fn open_generation(
738        callgraph_dir: &Path,
739        project_root: PathBuf,
740        project_key: String,
741        generation: String,
742    ) -> Result<Self> {
743        let gen_path = callgraph_dir.join(&generation);
744        Ok(Self::open_at_path(project_root, project_key, gen_path, Some(generation), true)?.store)
745    }
746
747    pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
748        let project_key = crate::search_index::artifact_cache_key(project_root);
749        // A cold build is needed unless a ready generation (or ready legacy DB)
750        // is currently published.
751        Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
752    }
753
754    fn open_at_path(
755        project_root: PathBuf,
756        project_key: String,
757        sqlite_path: PathBuf,
758        generation: Option<String>,
759        use_wal: bool,
760    ) -> Result<OpenedStore> {
761        if let Some(parent) = sqlite_path.parent() {
762            std::fs::create_dir_all(parent)?;
763        }
764        let mut conn = Connection::open(&sqlite_path)?;
765        if use_wal {
766            configure_connection(&conn)?;
767        } else {
768            configure_build_connection(&conn)?;
769        }
770        initialize_schema(&conn)?;
771        let root_repair = reconcile_workspace_roots(&mut conn, &project_root)?;
772        let store = Self::from_connection(project_root, project_key, sqlite_path, generation, conn);
773        Ok(OpenedStore { store, root_repair })
774    }
775
776    fn prepare_for_atomic_swap(&self) -> Result<()> {
777        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
778        conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;")?;
779        Ok(())
780    }
781
782    fn from_connection(
783        project_root: PathBuf,
784        project_key: String,
785        sqlite_path: PathBuf,
786        generation: Option<String>,
787        conn: Connection,
788    ) -> Self {
789        Self {
790            project_root,
791            project_key,
792            sqlite_path,
793            generation,
794            conn: Mutex::new(conn),
795        }
796    }
797
798    pub fn project_root(&self) -> &Path {
799        &self.project_root
800    }
801
802    pub fn project_key(&self) -> &str {
803        &self.project_key
804    }
805
806    pub fn sqlite_path(&self) -> &Path {
807        &self.sqlite_path
808    }
809
810    /// True if this store still reflects the currently-published generation.
811    /// Cheap (one small pointer-file read). When false, another process (or a
812    /// local cold rebuild) has published a newer generation and the holder
813    /// should drop this store and reopen via the pointer to converge. A missing
814    /// pointer keeps the current store (legacy DB still valid, or transient).
815    pub fn is_current(&self) -> bool {
816        let Some(dir) = self.sqlite_path.parent() else {
817            return true;
818        };
819        match (read_pointer(dir, &self.project_key), &self.generation) {
820            (Some(published), Some(opened)) => &published == opened,
821            // A generation now supersedes the legacy single-file DB we opened.
822            (Some(_), None) => false,
823            // No pointer: keep serving (legacy DB, or an anomalous pointer
824            // removal where our open generation file is still valid).
825            (None, _) => true,
826        }
827    }
828
829    pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
830        self.cold_build_chunked(files, 0)
831    }
832
833    pub fn cold_build_chunked(
834        &self,
835        files: &[PathBuf],
836        chunk_size: usize,
837    ) -> Result<ColdBuildStats> {
838        let started = Instant::now();
839        let bench = std::env::var("AFT_BENCH_COLD").is_ok();
840        macro_rules! phase {
841            ($label:expr, $t:expr) => {
842                if bench {
843                    eprintln!("  cold_build[{}]: {} ms", $label, $t.elapsed().as_millis());
844                    let _ = std::io::Write::flush(&mut std::io::stderr());
845                }
846            };
847        }
848        let files = normalize_file_list(&self.project_root, files)?;
849
850        if chunk_size == 0 {
851            let t = Instant::now();
852            let build = build_extracts_parallel(&self.project_root, &files);
853            phase!("extract_parallel", t);
854            let extracts = build.extracts;
855            let failures = build.failures;
856            let node_count = extracts.iter().map(|extract| extract.nodes.len()).sum();
857
858            let t = Instant::now();
859            let index = ProjectIndex::from_extracts(&self.project_root, &extracts);
860            phase!("build_index", t);
861            let t = Instant::now();
862            let mut resolved_refs = Vec::new();
863            for extract in &extracts {
864                for raw_ref in &extract.raw_refs {
865                    resolved_refs.push(resolve_ref(raw_ref.clone(), &index)?);
866                }
867            }
868            phase!("resolve_refs", t);
869            let ref_count = resolved_refs.len();
870            let edge_count = resolved_refs
871                .iter()
872                .filter(|item| item.edge.is_some())
873                .count();
874
875            let t = Instant::now();
876            let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
877            let tx = conn.transaction()?;
878            clear_tables(&tx)?;
879            insert_meta(&tx)?;
880            drop_cold_build_secondary_indexes(&tx)?;
881            {
882                let workspace_root = self.project_root.display().to_string();
883                let mut inserts = ColdBuildInsertStatements::new(&tx)?;
884                for extract in &extracts {
885                    insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
886                }
887                for failure in &failures {
888                    insert_backend_state_prepared(
889                        &mut inserts.backend_state,
890                        &workspace_root,
891                        &failure.rel_path,
892                        failure
893                            .freshness
894                            .as_ref()
895                            .map(|freshness| &freshness.content_hash),
896                        "stale",
897                    )?;
898                }
899                for resolved in &resolved_refs {
900                    insert_resolved_ref_prepared(&mut inserts, resolved)?;
901                }
902            }
903            create_cold_build_secondary_indexes(&tx)?;
904            let supplemental_edge_count =
905                insert_method_dispatch_edges(&tx, &self.project_root, None)?;
906            set_meta_ready(&tx, true)?;
907            tx.commit()?;
908            phase!("sqlite_insert", t);
909
910            let elapsed_ms = started.elapsed().as_millis();
911            crate::slog_info!(
912                "perf callgraph_store cold_build: files={} nodes={} refs={} edges={} ms={}",
913                extracts.len(),
914                node_count,
915                ref_count,
916                edge_count + supplemental_edge_count,
917                elapsed_ms
918            );
919            return Ok(ColdBuildStats {
920                files: extracts.len(),
921                nodes: node_count,
922                refs: ref_count,
923                edges: edge_count + supplemental_edge_count,
924                failed_files: failures
925                    .into_iter()
926                    .map(|failure| failure.rel_path)
927                    .collect(),
928                elapsed_ms,
929            });
930        }
931
932        // Chunked implementation: parse and resolve in batches to reduce peak
933        // memory during cold build without changing the persisted graph.
934        let t = Instant::now();
935        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
936        let tx = conn.transaction()?;
937        clear_tables(&tx)?;
938        insert_meta(&tx)?;
939        drop_cold_build_secondary_indexes(&tx)?;
940
941        let mut all_raw_refs = Vec::new();
942        let mut failures = Vec::new();
943        let mut node_count = 0;
944        let mut files_parsed = 0;
945
946        let mut persistent_call_data = Vec::new();
947        let mut file_to_call_data_index = HashMap::new();
948        let mut files_index = HashMap::new();
949
950        let workspace_root = self.project_root.display().to_string();
951
952        {
953            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
954            for chunk in files.chunks(chunk_size) {
955                let build = build_extracts_parallel(&self.project_root, chunk);
956                failures.extend(build.failures.clone());
957
958                for extract in build.extracts {
959                    files_parsed += 1;
960                    node_count += extract.nodes.len();
961                    insert_file_extract_prepared(&mut inserts, &workspace_root, &extract)?;
962
963                    let db_file_index = DbFileIndex::from_extract(&self.project_root, &extract);
964                    files_index.insert(extract.rel_path.clone(), db_file_index);
965
966                    persistent_call_data.push(extract.data);
967                    let idx = persistent_call_data.len() - 1;
968                    file_to_call_data_index.insert(extract.rel_path.clone(), idx);
969
970                    all_raw_refs.push((extract.rel_path, extract.raw_refs));
971                }
972                for failure in &build.failures {
973                    insert_backend_state_prepared(
974                        &mut inserts.backend_state,
975                        &workspace_root,
976                        &failure.rel_path,
977                        failure
978                            .freshness
979                            .as_ref()
980                            .map(|freshness| &freshness.content_hash),
981                        "stale",
982                    )?;
983                }
984            }
985        }
986
987        let mut caller_data = HashMap::new();
988        for (rel_path, idx) in &file_to_call_data_index {
989            caller_data.insert(rel_path.clone(), &persistent_call_data[*idx]);
990        }
991        let indexed_caller_files = files_index.keys().cloned().collect::<BTreeSet<_>>();
992        let index = ProjectIndex::from_parts(&self.project_root, files_index, caller_data);
993
994        let mut resolved_refs = Vec::new();
995        for (_, raw_refs) in all_raw_refs {
996            for raw_ref in raw_refs {
997                resolved_refs.push(resolve_ref(raw_ref, &index)?);
998            }
999        }
1000
1001        let ref_count = resolved_refs.len();
1002        let edge_count = resolved_refs
1003            .iter()
1004            .filter(|item| item.edge.is_some())
1005            .count();
1006
1007        {
1008            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
1009            for resolved in &resolved_refs {
1010                insert_resolved_ref_prepared(&mut inserts, resolved)?;
1011            }
1012        }
1013        create_cold_build_secondary_indexes(&tx)?;
1014        let supplemental_edge_count = insert_method_dispatch_edges_chunked(
1015            &tx,
1016            &self.project_root,
1017            &indexed_caller_files,
1018            chunk_size,
1019        )?;
1020        set_meta_ready(&tx, true)?;
1021        tx.commit()?;
1022        phase!("sqlite_insert", t);
1023
1024        let elapsed_ms = started.elapsed().as_millis();
1025        crate::slog_info!(
1026            "perf callgraph_store cold_build (chunked): files={} nodes={} refs={} edges={} ms={}",
1027            files_parsed,
1028            node_count,
1029            ref_count,
1030            edge_count + supplemental_edge_count,
1031            elapsed_ms
1032        );
1033        Ok(ColdBuildStats {
1034            files: files_parsed,
1035            nodes: node_count,
1036            refs: ref_count,
1037            edges: edge_count + supplemental_edge_count,
1038            failed_files: failures
1039                .into_iter()
1040                .map(|failure| failure.rel_path)
1041                .collect(),
1042            elapsed_ms,
1043        })
1044    }
1045
1046    pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
1047        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
1048        let tx = conn.transaction()?;
1049        ensure_database_ready(&tx)?;
1050        let mut changed = Vec::new();
1051        let mut surface_changed = BTreeSet::new();
1052        let mut deleted = BTreeSet::new();
1053        let mut own_refresh = BTreeSet::new();
1054        let mut selected_ref_ids = BTreeSet::new();
1055        let mut selected_refs_by_caller = BTreeMap::new();
1056        let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
1057
1058        for input in changed_files {
1059            let abs_path = normalize_file_path(&self.project_root, input)?;
1060            let rel_path = relative_path(&self.project_root, &abs_path);
1061            changed.push(rel_path.clone());
1062            let old_row = load_file_row(&tx, &rel_path)?;
1063            if !abs_path.exists() {
1064                if old_row.is_some() {
1065                    surface_changed.insert(rel_path.clone());
1066                    deleted.insert(rel_path.clone());
1067                    record_dependent_refs(
1068                        &mut selected_ref_ids,
1069                        &mut selected_refs_by_caller,
1070                        ref_ids_depending_on(&tx, &self.project_root, &rel_path)?,
1071                    );
1072                    delete_file_rows(&tx, &rel_path)?;
1073                    clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
1074                }
1075                continue;
1076            }
1077
1078            if let Some(row) = &old_row {
1079                match cache_freshness::verify_file(&abs_path, &row.freshness) {
1080                    FreshnessVerdict::HotFresh => continue,
1081                    FreshnessVerdict::ContentFresh {
1082                        new_mtime,
1083                        new_size,
1084                    } => {
1085                        update_file_fresh_metadata(
1086                            &tx,
1087                            &rel_path,
1088                            &row.freshness.content_hash,
1089                            new_mtime,
1090                            new_size,
1091                        )?;
1092                        continue;
1093                    }
1094                    FreshnessVerdict::Deleted => {
1095                        surface_changed.insert(rel_path.clone());
1096                        deleted.insert(rel_path.clone());
1097                        record_dependent_refs(
1098                            &mut selected_ref_ids,
1099                            &mut selected_refs_by_caller,
1100                            ref_ids_depending_on(&tx, &self.project_root, &rel_path)?,
1101                        );
1102                        delete_file_rows(&tx, &rel_path)?;
1103                        clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
1104                        continue;
1105                    }
1106                    FreshnessVerdict::Stale => {}
1107                }
1108            }
1109
1110            let extract = build_file_extract(&self.project_root, &abs_path)?;
1111            let surface_is_changed = old_row
1112                .as_ref()
1113                .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
1114                .unwrap_or(true);
1115            if surface_is_changed {
1116                surface_changed.insert(rel_path.clone());
1117                record_dependent_refs(
1118                    &mut selected_ref_ids,
1119                    &mut selected_refs_by_caller,
1120                    ref_ids_depending_on(&tx, &self.project_root, &rel_path)?,
1121                );
1122            }
1123            own_refresh.insert(rel_path.clone());
1124            delete_file_rows(&tx, &rel_path)?;
1125            insert_file_extract(&tx, &self.project_root, &extract)?;
1126            changed_extracts.insert(rel_path, extract);
1127        }
1128
1129        let dependency_selected_refs = selected_ref_ids.len();
1130        let mut touched_callers: BTreeSet<String> =
1131            selected_refs_by_caller.keys().cloned().collect();
1132        touched_callers.extend(own_refresh.iter().cloned());
1133
1134        let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
1135        for rel_path in &touched_callers {
1136            if deleted.contains(rel_path) {
1137                continue;
1138            }
1139            if let Some(extract) = changed_extracts.get(rel_path) {
1140                caller_extracts.insert(rel_path.clone(), extract.clone());
1141                continue;
1142            }
1143            let abs_path = self.project_root.join(rel_path);
1144            if abs_path.exists() {
1145                let extract = build_file_extract(&self.project_root, &abs_path)?;
1146                caller_extracts.insert(rel_path.clone(), extract);
1147            }
1148        }
1149
1150        let dependency_callers = touched_callers
1151            .iter()
1152            .filter(|rel_path| !deleted.contains(*rel_path) && !own_refresh.contains(*rel_path))
1153            .cloned()
1154            .collect::<Vec<_>>();
1155        for rel_path in dependency_callers {
1156            let Some(extract) = caller_extracts.get(&rel_path) else {
1157                continue;
1158            };
1159            if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
1160                continue;
1161            }
1162
1163            own_refresh.insert(rel_path.clone());
1164            delete_file_rows(&tx, &rel_path)?;
1165            insert_file_extract(&tx, &self.project_root, extract)?;
1166        }
1167
1168        let index = ProjectIndex::from_db_and_callers(&tx, &self.project_root, &caller_extracts)?;
1169        for rel_path in &touched_callers {
1170            if deleted.contains(rel_path) {
1171                continue;
1172            }
1173            let Some(extract) = caller_extracts.get(rel_path) else {
1174                continue;
1175            };
1176            if own_refresh.contains(rel_path) {
1177                delete_refs_for_caller(&tx, rel_path)?;
1178                for raw_ref in &extract.raw_refs {
1179                    let resolved = resolve_ref(raw_ref.clone(), &index)?;
1180                    insert_resolved_ref(&tx, &resolved)?;
1181                }
1182                continue;
1183            }
1184
1185            let selected_for_caller = selected_refs_by_caller
1186                .get(rel_path)
1187                .cloned()
1188                .unwrap_or_default();
1189            delete_ref_ids(&tx, &selected_for_caller)?;
1190            for raw_ref in &extract.raw_refs {
1191                if selected_for_caller.contains(&raw_ref.ref_id) {
1192                    let resolved = resolve_ref(raw_ref.clone(), &index)?;
1193                    insert_resolved_ref(&tx, &resolved)?;
1194                }
1195            }
1196        }
1197
1198        delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
1199        insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
1200
1201        tx.commit()?;
1202        Ok(IncrementalStats {
1203            changed_files: changed,
1204            surface_changed: surface_changed.into_iter().collect(),
1205            deleted_files: deleted.into_iter().collect(),
1206            dependency_selected_refs,
1207            refreshed_own_files: own_refresh.len(),
1208        })
1209    }
1210
1211    pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
1212        self.cold_build(current_files)
1213    }
1214
1215    pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
1216        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
1217        let tx = conn.transaction()?;
1218        let mut marked = Vec::new();
1219        for path in files {
1220            let abs_path = normalize_file_path(&self.project_root, path)?;
1221            let rel_path = relative_path(&self.project_root, &abs_path);
1222            let freshness = cache_freshness::collect(&abs_path).ok();
1223            mark_backend_state(
1224                &tx,
1225                &self.project_root,
1226                &rel_path,
1227                freshness.as_ref().map(|freshness| &freshness.content_hash),
1228                "stale",
1229            )?;
1230            marked.push(rel_path);
1231        }
1232        tx.commit()?;
1233        marked.sort();
1234        marked.dedup();
1235        Ok(marked)
1236    }
1237
1238    pub fn stale_files(&self) -> Result<Vec<String>> {
1239        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1240        let mut stmt = conn.prepare(
1241            "SELECT DISTINCT file_path FROM backend_file_state
1242             WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
1243             ORDER BY file_path",
1244        )?;
1245        let rows = stmt.query_map(
1246            params![BACKEND_TREESITTER, self.project_root.display().to_string()],
1247            |row| row.get::<_, String>(0),
1248        )?;
1249        rows.collect::<std::result::Result<Vec<_>, _>>()
1250            .map_err(Into::into)
1251    }
1252
1253    pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
1254        let rel_path = relative_path(
1255            &self.project_root,
1256            &normalize_file_path(&self.project_root, file)?,
1257        );
1258        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1259        conn.query_row(
1260            "SELECT status FROM backend_file_state
1261             WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
1262             ORDER BY updated_at DESC LIMIT 1",
1263            params![
1264                BACKEND_TREESITTER,
1265                self.project_root.display().to_string(),
1266                rel_path
1267            ],
1268            |row| row.get(0),
1269        )
1270        .optional()
1271        .map_err(Into::into)
1272    }
1273
1274    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
1275        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1276        ensure_database_ready(&conn)?;
1277        edge_snapshot_with_conn(&conn)
1278    }
1279
1280    pub fn indexed_file_count(&self) -> Result<usize> {
1281        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1282        ensure_database_ready(&conn)?;
1283        indexed_file_count(&conn)
1284    }
1285
1286    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
1287        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
1288        let rel_path = relative_path(&self.project_root, &abs_path);
1289        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1290        ensure_database_ready(&conn)?;
1291        resolve_node_for_rel(&conn, &rel_path, symbol)
1292    }
1293
1294    /// Return all positional nodes matching a legacy symbol query in a file.
1295    ///
1296    /// Consumers that need legacy compatibility can collapse these by
1297    /// `StoreNode::symbol` before deciding whether a query is ambiguous.
1298    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
1299        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
1300        let rel_path = relative_path(&self.project_root, &abs_path);
1301        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1302        ensure_database_ready(&conn)?;
1303        nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
1304    }
1305
1306    /// Return all positional nodes matching a symbol query anywhere in the store.
1307    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
1308        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1309        ensure_database_ready(&conn)?;
1310        nodes_matching_symbol(&conn, symbol)
1311    }
1312
1313    /// Return direct callers for an already-resolved `(file, scoped_symbol)` tuple.
1314    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
1315        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
1316        let rel_path = relative_path(&self.project_root, &abs_path);
1317        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1318        ensure_database_ready(&conn)?;
1319        direct_callers_for_tuple(&conn, &rel_path, symbol)
1320    }
1321
1322    pub fn callers_of(
1323        &self,
1324        file_rel: &Path,
1325        symbol: &str,
1326        depth: usize,
1327    ) -> Result<StoreCallersResult> {
1328        let target = self.node_for(file_rel, symbol)?;
1329        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1330        ensure_database_ready(&conn)?;
1331        let effective_depth = depth.max(1);
1332        let mut visited = HashSet::new();
1333        let mut callers = Vec::new();
1334        let mut depth_limited = false;
1335        let mut truncated = 0usize;
1336        collect_callers_recursive(
1337            &conn,
1338            &target.file,
1339            &target.symbol,
1340            effective_depth,
1341            0,
1342            &mut visited,
1343            &mut callers,
1344            &mut depth_limited,
1345            &mut truncated,
1346        )?;
1347        Ok(StoreCallersResult {
1348            target,
1349            callers,
1350            scanned_files: indexed_file_count(&conn)?,
1351            depth_limited,
1352            truncated,
1353        })
1354    }
1355
1356    pub fn impact_of(
1357        &self,
1358        file_rel: &Path,
1359        symbol: &str,
1360        depth: usize,
1361    ) -> Result<StoreImpactResult> {
1362        let callers = self.callers_of(file_rel, symbol, depth)?;
1363        let target_parameters = callers
1364            .target
1365            .signature
1366            .as_deref()
1367            .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
1368            .unwrap_or_default();
1369        let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
1370        for site in &callers.callers {
1371            source_lines_by_file
1372                .entry(site.caller.file.clone())
1373                .or_insert_with(|| {
1374                    read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
1375                });
1376        }
1377        let enriched = callers
1378            .callers
1379            .iter()
1380            .map(|site| StoreImpactCaller {
1381                site: site.clone(),
1382                signature: site.caller.signature.clone(),
1383                is_entry_point: site.caller.is_entry_point,
1384                call_expression: source_lines_by_file
1385                    .get(&site.caller.file)
1386                    .and_then(|lines| lines.as_ref())
1387                    .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
1388                    .cloned(),
1389                parameters: site
1390                    .caller
1391                    .signature
1392                    .as_deref()
1393                    .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
1394                    .unwrap_or_default(),
1395            })
1396            .collect();
1397        Ok(StoreImpactResult {
1398            target: callers.target,
1399            parameters: target_parameters,
1400            callers: enriched,
1401            depth_limited: callers.depth_limited,
1402            truncated: callers.truncated,
1403        })
1404    }
1405
1406    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
1407        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1408        ensure_database_ready(&conn)?;
1409        outgoing_calls_for_node(&conn, node)
1410    }
1411
1412    /// Return resolved direct self-call refs suppressed from the general edge table.
1413    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
1414        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1415        ensure_database_ready(&conn)?;
1416        resolved_self_calls_for_node(&conn, node)
1417    }
1418
1419    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
1420        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1421        ensure_database_ready(&conn)?;
1422        unresolved_calls_for_node(&conn, node)
1423    }
1424
1425    pub fn call_tree(
1426        &self,
1427        file_rel: &Path,
1428        symbol: &str,
1429        max_depth: usize,
1430    ) -> Result<callgraph::CallTreeNode> {
1431        let node = self.node_for(file_rel, symbol)?;
1432        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1433        ensure_database_ready(&conn)?;
1434        let mut visited = HashSet::new();
1435        call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
1436    }
1437
1438    pub fn trace_to(
1439        &self,
1440        file_rel: &Path,
1441        symbol: &str,
1442        max_depth: usize,
1443    ) -> Result<callgraph::TraceToResult> {
1444        let target = self.node_for(file_rel, symbol)?;
1445        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1446        ensure_database_ready(&conn)?;
1447        let effective_max = if max_depth == 0 { 10 } else { max_depth };
1448
1449        #[derive(Clone)]
1450        struct PathElem {
1451            node: StoreNode,
1452        }
1453
1454        let initial = vec![PathElem {
1455            node: target.clone(),
1456        }];
1457        let mut complete_paths = Vec::new();
1458        if target.is_entry_point {
1459            complete_paths.push(initial.clone());
1460        }
1461
1462        let mut queue = vec![(initial, 0usize)];
1463        let mut max_depth_reached = false;
1464        let mut truncated_paths = 0usize;
1465
1466        while let Some((path, depth)) = queue.pop() {
1467            if depth >= effective_max {
1468                max_depth_reached = true;
1469                continue;
1470            }
1471            let Some(current) = path.last() else {
1472                continue;
1473            };
1474            let callers =
1475                direct_callers_for_tuple(&conn, &current.node.file, &current.node.symbol)?;
1476            if callers.is_empty() {
1477                if path.len() > 1 {
1478                    truncated_paths += 1;
1479                }
1480                continue;
1481            }
1482
1483            let mut has_new_path = false;
1484            for site in callers {
1485                if path.iter().any(|elem| {
1486                    elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
1487                }) {
1488                    continue;
1489                }
1490                has_new_path = true;
1491                let mut new_path = path.clone();
1492                new_path.push(PathElem {
1493                    node: site.caller.clone(),
1494                });
1495                if site.caller.is_entry_point {
1496                    complete_paths.push(new_path.clone());
1497                }
1498                queue.push((new_path, depth + 1));
1499            }
1500            if !has_new_path && path.len() > 1 {
1501                truncated_paths += 1;
1502            }
1503        }
1504
1505        let mut paths: Vec<callgraph::TracePath> = complete_paths
1506            .into_iter()
1507            .map(|mut elems| {
1508                elems.reverse();
1509                let hops = elems
1510                    .iter()
1511                    .enumerate()
1512                    .map(|(index, elem)| callgraph::TraceHop {
1513                        symbol: elem.node.symbol.clone(),
1514                        file: elem.node.file.clone(),
1515                        line: elem.node.line,
1516                        signature: elem.node.signature.clone(),
1517                        is_entry_point: index == 0 && elem.node.is_entry_point,
1518                    })
1519                    .collect();
1520                callgraph::TracePath { hops }
1521            })
1522            .collect();
1523        paths.sort_by(|left, right| {
1524            let left_entry = left
1525                .hops
1526                .first()
1527                .map(|hop| hop.symbol.as_str())
1528                .unwrap_or("");
1529            let right_entry = right
1530                .hops
1531                .first()
1532                .map(|hop| hop.symbol.as_str())
1533                .unwrap_or("");
1534            left_entry
1535                .cmp(right_entry)
1536                .then(left.hops.len().cmp(&right.hops.len()))
1537        });
1538        let entry_points_found = paths
1539            .iter()
1540            .filter_map(|path| path.hops.first())
1541            .filter(|hop| hop.is_entry_point)
1542            .map(|hop| (hop.file.clone(), hop.symbol.clone()))
1543            .collect::<HashSet<_>>()
1544            .len();
1545
1546        Ok(callgraph::TraceToResult {
1547            target_symbol: target.symbol,
1548            target_file: target.file,
1549            total_paths: paths.len(),
1550            paths,
1551            entry_points_found,
1552            max_depth_reached,
1553            truncated_paths,
1554        })
1555    }
1556
1557    pub fn trace_to_symbol_candidates(
1558        &self,
1559        to_symbol: &str,
1560    ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
1561        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1562        ensure_database_ready(&conn)?;
1563        let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
1564        for node in nodes_matching_symbol(&conn, to_symbol)? {
1565            candidates_by_file
1566                .entry(node.file)
1567                .and_modify(|line| *line = (*line).min(node.line))
1568                .or_insert(node.line);
1569        }
1570        let mut candidates: Vec<_> = candidates_by_file
1571            .into_iter()
1572            .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
1573            .collect();
1574        candidates
1575            .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
1576        Ok(candidates)
1577    }
1578
1579    pub fn trace_to_symbol(
1580        &self,
1581        file_rel: &Path,
1582        symbol: &str,
1583        to_symbol: &str,
1584        to_file: Option<&Path>,
1585        max_depth: usize,
1586    ) -> Result<callgraph::TraceToSymbolResult> {
1587        let origin = self.node_for(file_rel, symbol)?;
1588        let target_file = to_file
1589            .map(|path| normalize_file_path(&self.project_root, path))
1590            .transpose()?
1591            .map(|path| relative_path(&self.project_root, &path));
1592        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1593        ensure_database_ready(&conn)?;
1594        let effective_max = if max_depth == 0 {
1595            10
1596        } else {
1597            max_depth.min(16)
1598        };
1599
1600        let start_hop = trace_to_symbol_hop(&origin);
1601        if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
1602            return Ok(callgraph::TraceToSymbolResult {
1603                path: Some(vec![start_hop]),
1604                complete: true,
1605                reason: None,
1606            });
1607        }
1608
1609        let mut queue = VecDeque::new();
1610        queue.push_back((origin.clone(), vec![start_hop], 0usize));
1611        let mut visited = HashSet::new();
1612        visited.insert((origin.file.clone(), origin.symbol.clone()));
1613        let mut max_depth_exhausted = false;
1614
1615        while let Some((current, path, depth)) = queue.pop_front() {
1616            let callees = outgoing_calls_for_node(&conn, &current)?
1617                .into_iter()
1618                .filter_map(|site| site.target)
1619                .collect::<Vec<_>>();
1620
1621            if depth >= effective_max {
1622                if callees
1623                    .iter()
1624                    .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
1625                {
1626                    max_depth_exhausted = true;
1627                }
1628                continue;
1629            }
1630
1631            for callee in callees {
1632                if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
1633                    continue;
1634                }
1635                let mut next_path = path.clone();
1636                next_path.push(trace_to_symbol_hop(&callee));
1637                if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
1638                    return Ok(callgraph::TraceToSymbolResult {
1639                        path: Some(next_path),
1640                        complete: true,
1641                        reason: None,
1642                    });
1643                }
1644                queue.push_back((callee, next_path, depth + 1));
1645            }
1646        }
1647
1648        if max_depth_exhausted {
1649            Ok(callgraph::TraceToSymbolResult {
1650                path: None,
1651                complete: false,
1652                reason: Some("max_depth_exhausted".to_string()),
1653            })
1654        } else {
1655            Ok(callgraph::TraceToSymbolResult {
1656                path: None,
1657                complete: true,
1658                reason: Some("no_path_found".to_string()),
1659            })
1660        }
1661    }
1662}
1663
1664fn indexed_file_count(conn: &Connection) -> Result<usize> {
1665    let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
1666    Ok(count.max(0) as usize)
1667}
1668
1669fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
1670    let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
1671    match candidates.as_slice() {
1672        [candidate] => Ok(candidate.clone()),
1673        [] => Err(AftError::SymbolNotFound {
1674            name: symbol.to_string(),
1675            file: rel_path.to_string(),
1676        }
1677        .into()),
1678        _ => Err(AftError::AmbiguousSymbol {
1679            name: symbol.to_string(),
1680            candidates: candidates
1681                .iter()
1682                .map(|candidate| candidate.symbol.clone())
1683                .collect(),
1684        }
1685        .into()),
1686    }
1687}
1688
1689fn nodes_for_file_matching_symbol(
1690    conn: &Connection,
1691    rel_path: &str,
1692    symbol: &str,
1693) -> Result<Vec<StoreNode>> {
1694    let qualified_query = symbol.contains("::");
1695    let sql = if qualified_query {
1696        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
1697                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
1698         FROM nodes n JOIN files f ON f.path = n.file_path
1699         WHERE n.file_path = ?1 AND n.scoped_name = ?2
1700         ORDER BY n.scoped_name, n.start_line, n.start_col"
1701    } else {
1702        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
1703                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
1704         FROM nodes n JOIN files f ON f.path = n.file_path
1705         WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
1706         ORDER BY n.scoped_name, n.start_line, n.start_col"
1707    };
1708    let mut stmt = conn.prepare(sql)?;
1709    let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
1710    rows.collect::<std::result::Result<Vec<_>, _>>()
1711        .map_err(Into::into)
1712}
1713
1714fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
1715    let qualified_query = symbol.contains("::");
1716    let sql = if qualified_query {
1717        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
1718                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
1719         FROM nodes n JOIN files f ON f.path = n.file_path
1720         WHERE n.scoped_name = ?1
1721         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
1722    } else {
1723        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
1724                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
1725         FROM nodes n JOIN files f ON f.path = n.file_path
1726         WHERE n.scoped_name = ?1 OR n.name = ?1
1727         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
1728    };
1729    let mut stmt = conn.prepare(sql)?;
1730    let rows = stmt.query_map(params![symbol], store_node_from_row)?;
1731    rows.collect::<std::result::Result<Vec<_>, _>>()
1732        .map_err(Into::into)
1733}
1734
1735fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
1736    store_node_from_row_at(row, 0)
1737}
1738
1739fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
1740    let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
1741    let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
1742    let lang_label_value: String = row.get(offset + 10)?;
1743    Ok(StoreNode {
1744        node_id: row.get(offset)?,
1745        file: row.get(offset + 1)?,
1746        symbol: row.get(offset + 2)?,
1747        name: row.get(offset + 3)?,
1748        kind: row.get(offset + 4)?,
1749        line: start_line.saturating_add(1),
1750        end_line: end_line.saturating_add(1),
1751        signature: row.get(offset + 7)?,
1752        exported: row.get::<_, i64>(offset + 8)? != 0,
1753        is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
1754        lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
1755    })
1756}
1757
1758fn optional_store_node_from_row_at(
1759    row: &rusqlite::Row<'_>,
1760    offset: usize,
1761) -> rusqlite::Result<Option<StoreNode>> {
1762    if row.get::<_, Option<String>>(offset)?.is_some() {
1763        store_node_from_row_at(row, offset).map(Some)
1764    } else {
1765        Ok(None)
1766    }
1767}
1768
1769#[allow(clippy::too_many_arguments)]
1770fn collect_callers_recursive(
1771    conn: &Connection,
1772    file: &str,
1773    symbol: &str,
1774    max_depth: usize,
1775    current_depth: usize,
1776    visited: &mut HashSet<(String, String)>,
1777    result: &mut Vec<StoreCallSite>,
1778    depth_limited: &mut bool,
1779    truncated: &mut usize,
1780) -> Result<()> {
1781    if current_depth >= max_depth {
1782        let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
1783        if omitted > 0 {
1784            *depth_limited = true;
1785            *truncated += omitted;
1786        }
1787        return Ok(());
1788    }
1789
1790    if !visited.insert((file.to_string(), symbol.to_string())) {
1791        return Ok(());
1792    }
1793
1794    let sites = direct_callers_for_tuple(conn, file, symbol)?;
1795    for site in sites {
1796        result.push(site.clone());
1797        if current_depth + 1 < max_depth {
1798            collect_callers_recursive(
1799                conn,
1800                &site.caller.file,
1801                &site.caller.symbol,
1802                max_depth,
1803                current_depth + 1,
1804                visited,
1805                result,
1806                depth_limited,
1807                truncated,
1808            )?;
1809        } else {
1810            let omitted =
1811                direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
1812            if omitted > 0 {
1813                *depth_limited = true;
1814                *truncated += omitted;
1815            }
1816        }
1817    }
1818    Ok(())
1819}
1820
1821fn direct_caller_count_for_tuple(
1822    conn: &Connection,
1823    target_file: &str,
1824    target_symbol: &str,
1825) -> Result<usize> {
1826    let count: i64 = conn.query_row(
1827        "SELECT COUNT(*)
1828         FROM edges e
1829         JOIN refs r ON r.ref_id = e.ref_id
1830         JOIN nodes src ON src.id = e.source_node
1831         JOIN files src_file ON src_file.path = src.file_path
1832         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
1833        params![target_file, target_symbol],
1834        |row| row.get(0),
1835    )?;
1836    Ok(usize::try_from(count).unwrap_or(usize::MAX))
1837}
1838
1839fn direct_callers_for_tuple(
1840    conn: &Connection,
1841    target_file: &str,
1842    target_symbol: &str,
1843) -> Result<Vec<StoreCallSite>> {
1844    let mut stmt = conn.prepare(
1845        "SELECT e.target_file, e.target_symbol, e.line,
1846                r.byte_start, r.byte_end, r.status, e.provenance,
1847                src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
1848                src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
1849                src_file.lang,
1850                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
1851                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
1852                tgt_file.lang
1853         FROM edges e
1854         JOIN refs r ON r.ref_id = e.ref_id
1855         JOIN nodes src ON src.id = e.source_node
1856         JOIN files src_file ON src_file.path = src.file_path
1857         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
1858             ON tgt.id = e.target_node
1859         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
1860         ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
1861    )?;
1862    let rows = stmt.query_map(params![target_file, target_symbol], |row| {
1863        let caller = store_node_from_row_at(row, 7)?;
1864        let target = optional_store_node_from_row_at(row, 18)?;
1865        Ok(StoreCallSite {
1866            caller,
1867            target_file: row.get(0)?,
1868            target_symbol: row.get(1)?,
1869            target,
1870            line: row.get::<_, i64>(2)?.max(0) as u32,
1871            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
1872            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
1873            resolved: row.get::<_, String>(5)? == "resolved",
1874            provenance: row.get(6)?,
1875        })
1876    })?;
1877    rows.collect::<std::result::Result<Vec<_>, _>>()
1878        .map_err(Into::into)
1879}
1880
1881fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
1882    let mut stmt = conn.prepare(
1883        "SELECT e.target_file, e.target_symbol, e.line,
1884                r.byte_start, r.byte_end, r.status, e.provenance,
1885                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
1886                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
1887                tgt_file.lang
1888         FROM edges e
1889         JOIN refs r ON r.ref_id = e.ref_id
1890         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
1891             ON tgt.id = e.target_node
1892         WHERE e.kind = 'call' AND e.source_node = ?1
1893         ORDER BY r.byte_start, r.line, r.ref_id",
1894    )?;
1895    let rows = stmt.query_map(params![node.node_id], |row| {
1896        let target = optional_store_node_from_row_at(row, 7)?;
1897        Ok(StoreCallSite {
1898            caller: node.clone(),
1899            target_file: row.get(0)?,
1900            target_symbol: row.get(1)?,
1901            target,
1902            line: row.get::<_, i64>(2)?.max(0) as u32,
1903            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
1904            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
1905            resolved: row.get::<_, String>(5)? == "resolved",
1906            provenance: row.get(6)?,
1907        })
1908    })?;
1909    rows.collect::<std::result::Result<Vec<_>, _>>()
1910        .map_err(Into::into)
1911}
1912
1913fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
1914    let mut stmt = conn.prepare(
1915        "SELECT r.target_file, r.target_symbol, r.line,
1916                r.byte_start, r.byte_end, r.status, r.provenance,
1917                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
1918                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
1919                tgt_file.lang
1920         FROM refs r
1921         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
1922             ON tgt.id = r.target_node
1923         WHERE r.caller_node = ?1
1924           AND r.kind = 'call'
1925           AND r.status <> 'unresolved'
1926           AND r.target_file = ?2
1927           AND r.target_symbol = ?3
1928           AND r.provenance = ?4
1929           AND NOT EXISTS (
1930               SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
1931           )
1932         ORDER BY r.byte_start, r.line, r.ref_id",
1933    )?;
1934    let rows = stmt.query_map(
1935        params![
1936            &node.node_id,
1937            &node.file,
1938            &node.symbol,
1939            PROVENANCE_TREESITTER
1940        ],
1941        |row| {
1942            let target = optional_store_node_from_row_at(row, 7)?;
1943            Ok(StoreCallSite {
1944                caller: node.clone(),
1945                target_file: row.get(0)?,
1946                target_symbol: row.get(1)?,
1947                target,
1948                line: row.get::<_, i64>(2)?.max(0) as u32,
1949                byte_start: row.get::<_, i64>(3)?.max(0) as usize,
1950                byte_end: row.get::<_, i64>(4)?.max(0) as usize,
1951                resolved: row.get::<_, String>(5)? == "resolved",
1952                provenance: row.get(6)?,
1953            })
1954        },
1955    )?;
1956    rows.collect::<std::result::Result<Vec<_>, _>>()
1957        .map_err(Into::into)
1958}
1959
1960fn unresolved_calls_for_node(
1961    conn: &Connection,
1962    node: &StoreNode,
1963) -> Result<Vec<StoreUnresolvedCall>> {
1964    let mut stmt = conn.prepare(
1965        "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
1966         FROM refs
1967         WHERE caller_node = ?1
1968           AND kind = 'call'
1969           AND status = 'unresolved'
1970           AND NOT EXISTS (
1971               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
1972           )
1973         ORDER BY byte_start, line, ref_id",
1974    )?;
1975    let rows = stmt.query_map(params![node.node_id], |row| {
1976        Ok(StoreUnresolvedCall {
1977            caller: node.clone(),
1978            symbol: row.get(0)?,
1979            full_ref: row.get(1)?,
1980            line: row.get::<_, i64>(2)?.max(0) as u32,
1981            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
1982            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
1983        })
1984    })?;
1985    rows.collect::<std::result::Result<Vec<_>, _>>()
1986        .map_err(Into::into)
1987}
1988
1989fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
1990    let mut calls = Vec::new();
1991    calls.extend(
1992        outgoing_calls_for_node(conn, node)?
1993            .into_iter()
1994            .map(StoreForwardCall::Resolved),
1995    );
1996    calls.extend(
1997        unresolved_calls_for_node(conn, node)?
1998            .into_iter()
1999            .map(StoreForwardCall::Unresolved),
2000    );
2001    calls.sort_by(|left, right| {
2002        left.byte_start()
2003            .cmp(&right.byte_start())
2004            .then(left.line().cmp(&right.line()))
2005    });
2006    Ok(calls)
2007}
2008
2009fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
2010    let resolved_count: i64 = conn.query_row(
2011        "SELECT COUNT(*)
2012         FROM edges e
2013         JOIN refs r ON r.ref_id = e.ref_id
2014         WHERE e.kind = 'call' AND e.source_node = ?1",
2015        params![&node.node_id],
2016        |row| row.get(0),
2017    )?;
2018    let unresolved_count: i64 = conn.query_row(
2019        "SELECT COUNT(*)
2020         FROM refs
2021         WHERE caller_node = ?1
2022           AND kind = 'call'
2023           AND status = 'unresolved'
2024           AND NOT EXISTS (
2025               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
2026           )",
2027        params![&node.node_id],
2028        |row| row.get(0),
2029    )?;
2030    let total = resolved_count.saturating_add(unresolved_count);
2031    Ok(usize::try_from(total).unwrap_or(usize::MAX))
2032}
2033
2034fn call_tree_inner(
2035    conn: &Connection,
2036    node: &StoreNode,
2037    max_depth: usize,
2038    current_depth: usize,
2039    visited: &mut HashSet<(String, String)>,
2040) -> Result<callgraph::CallTreeNode> {
2041    let visit_key = (node.file.clone(), node.symbol.clone());
2042    if visited.contains(&visit_key) {
2043        return Ok(callgraph::CallTreeNode {
2044            name: node.symbol.clone(),
2045            file: node.file.clone(),
2046            line: node.line,
2047            signature: node.signature.clone(),
2048            resolved: true,
2049            children: Vec::new(),
2050            depth_limited: false,
2051            truncated: 0,
2052        });
2053    }
2054    visited.insert(visit_key.clone());
2055
2056    let mut children = Vec::new();
2057    let mut depth_limited = false;
2058    let mut truncated = 0usize;
2059
2060    if current_depth < max_depth {
2061        let calls = forward_calls_for_node(conn, node)?;
2062        for call in calls {
2063            match call {
2064                StoreForwardCall::Resolved(site) => {
2065                    if let Some(target) = site.target {
2066                        let child =
2067                            call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
2068                        depth_limited |= child.depth_limited;
2069                        truncated += child.truncated;
2070                        children.push(child);
2071                    } else {
2072                        children.push(callgraph::CallTreeNode {
2073                            name: site.target_symbol,
2074                            file: site.target_file,
2075                            line: site.line,
2076                            signature: None,
2077                            resolved: false,
2078                            children: Vec::new(),
2079                            depth_limited: false,
2080                            truncated: 0,
2081                        });
2082                    }
2083                }
2084                StoreForwardCall::Unresolved(call) => {
2085                    children.push(callgraph::CallTreeNode {
2086                        name: call.symbol,
2087                        file: call.caller.file,
2088                        line: call.line,
2089                        signature: None,
2090                        resolved: false,
2091                        children: Vec::new(),
2092                        depth_limited: false,
2093                        truncated: 0,
2094                    });
2095                }
2096            }
2097        }
2098    } else {
2099        truncated = forward_call_count_for_node(conn, node)?;
2100        depth_limited = truncated > 0;
2101    }
2102
2103    visited.remove(&visit_key);
2104    Ok(callgraph::CallTreeNode {
2105        name: node.symbol.clone(),
2106        file: node.file.clone(),
2107        line: node.line,
2108        signature: node.signature.clone(),
2109        resolved: true,
2110        children,
2111        depth_limited,
2112        truncated,
2113    })
2114}
2115
2116fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
2117    callgraph::TraceToSymbolHop {
2118        symbol: node.symbol.clone(),
2119        file: node.file.clone(),
2120        line: node.line,
2121    }
2122}
2123
2124fn trace_to_symbol_matches_target(
2125    node: &StoreNode,
2126    to_symbol: &str,
2127    to_file: Option<&str>,
2128) -> bool {
2129    if !symbol_query_matches(&node.symbol, to_symbol) {
2130        return false;
2131    }
2132    match to_file {
2133        Some(file) => node.file == file,
2134        None => true,
2135    }
2136}
2137
2138fn symbol_query_matches(symbol: &str, query: &str) -> bool {
2139    symbol == query || unqualified_name(symbol) == query
2140}
2141
2142fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
2143    let source = std::fs::read_to_string(path).ok()?;
2144    Some(source.lines().map(|line| line.trim().to_string()).collect())
2145}
2146
2147#[doc(hidden)]
2148pub fn live_callgraph_edge_snapshot(
2149    project_root: &Path,
2150    files: &[PathBuf],
2151) -> Result<BTreeSet<StoredEdge>> {
2152    let files = normalize_file_list(project_root, files)?;
2153    let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
2154    let mut file_data = Vec::new();
2155    for file in &files {
2156        let canon = canonicalize_path(file);
2157        let data = graph.build_file(&canon)?.clone();
2158        file_data.push((canon, data));
2159    }
2160
2161    let mut edges = BTreeSet::new();
2162    for (caller_file, data) in &file_data {
2163        for (caller_symbol, call_sites) in &data.calls_by_symbol {
2164            for call_site in call_sites {
2165                let resolution = graph.resolve_cross_file_edge(
2166                    &call_site.full_callee,
2167                    &call_site.callee_name,
2168                    caller_file,
2169                    &data.import_block,
2170                );
2171                let (target_file, target_symbol) = match resolution {
2172                    EdgeResolution::Resolved { file, symbol } => (file, symbol),
2173                    EdgeResolution::Unresolved { callee_name } => {
2174                        if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
2175                            continue;
2176                        }
2177                        let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
2178                            data,
2179                            caller_file,
2180                            &callee_name,
2181                        ) else {
2182                            continue;
2183                        };
2184                        (caller_file.clone(), target_symbol)
2185                    }
2186                };
2187                if target_file == *caller_file && target_symbol == *caller_symbol {
2188                    continue;
2189                }
2190                edges.insert(StoredEdge {
2191                    source_file: relative_path(project_root, caller_file),
2192                    source_symbol: caller_symbol.clone(),
2193                    target_file: relative_path(project_root, &target_file),
2194                    target_symbol,
2195                    kind: "call".to_string(),
2196                    line: call_site.line,
2197                });
2198            }
2199        }
2200    }
2201    Ok(edges)
2202}
2203
2204fn configure_connection(conn: &Connection) -> Result<()> {
2205    conn.pragma_update(None, "journal_mode", "WAL")?;
2206    conn.pragma_update(None, "busy_timeout", 5_000)?;
2207    Ok(())
2208}
2209
2210fn configure_build_connection(conn: &Connection) -> Result<()> {
2211    conn.pragma_update(None, "journal_mode", "DELETE")?;
2212    conn.pragma_update(None, "busy_timeout", 5_000)?;
2213    Ok(())
2214}
2215
2216fn initialize_schema(conn: &Connection) -> Result<()> {
2217    conn.execute_batch(
2218        "CREATE TABLE IF NOT EXISTS files (
2219            path                TEXT PRIMARY KEY,
2220            content_hash        TEXT NOT NULL,
2221            mtime_ns            INTEGER NOT NULL,
2222            size                INTEGER NOT NULL,
2223            lang                TEXT NOT NULL,
2224            is_dead_code_root   INTEGER NOT NULL DEFAULT 0,
2225            is_public_api       INTEGER NOT NULL DEFAULT 0,
2226            surface_fingerprint TEXT NOT NULL,
2227            indexed_at          INTEGER NOT NULL
2228        );
2229
2230        CREATE TABLE IF NOT EXISTS nodes (
2231            id                         TEXT PRIMARY KEY,
2232            file_path                  TEXT NOT NULL,
2233            name                       TEXT NOT NULL,
2234            scoped_name                TEXT NOT NULL,
2235            kind                       TEXT NOT NULL,
2236            start_line                 INTEGER NOT NULL,
2237            start_col                  INTEGER NOT NULL,
2238            end_line                   INTEGER NOT NULL,
2239            end_col                    INTEGER NOT NULL,
2240            range_ordinal              INTEGER NOT NULL,
2241            signature                  TEXT,
2242            exported                   INTEGER NOT NULL,
2243            is_default_export          INTEGER NOT NULL,
2244            is_type_like               INTEGER NOT NULL,
2245            is_callgraph_entry_point   INTEGER NOT NULL,
2246            provenance                 TEXT NOT NULL,
2247            UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
2248        );
2249        CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
2250        CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
2251        CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
2252
2253        CREATE TABLE IF NOT EXISTS refs (
2254            ref_id          TEXT PRIMARY KEY,
2255            caller_node     TEXT,
2256            caller_file     TEXT NOT NULL,
2257            kind            TEXT NOT NULL,
2258            short_name      TEXT,
2259            full_ref        TEXT,
2260            module_path     TEXT,
2261            import_kind     TEXT,
2262            local_name      TEXT,
2263            requested_name  TEXT,
2264            namespace_alias TEXT,
2265            wildcard        INTEGER NOT NULL DEFAULT 0,
2266            line            INTEGER NOT NULL,
2267            byte_start      INTEGER NOT NULL,
2268            byte_end        INTEGER NOT NULL,
2269            status          TEXT NOT NULL,
2270            target_node     TEXT,
2271            target_file     TEXT,
2272            target_symbol   TEXT,
2273            provenance      TEXT NOT NULL
2274        );
2275        CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
2276        CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
2277        CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
2278        CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
2279
2280        CREATE TABLE IF NOT EXISTS file_dependencies (
2281            file_path   TEXT NOT NULL,
2282            dep_file    TEXT NOT NULL,
2283            PRIMARY KEY(file_path, dep_file)
2284        );
2285        CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
2286
2287        CREATE TABLE IF NOT EXISTS edges (
2288            edge_id       TEXT PRIMARY KEY,
2289            ref_id        TEXT NOT NULL,
2290            source_node   TEXT NOT NULL,
2291            target_node   TEXT,
2292            target_file   TEXT NOT NULL,
2293            target_symbol TEXT NOT NULL,
2294            kind          TEXT NOT NULL,
2295            line          INTEGER NOT NULL,
2296            provenance    TEXT NOT NULL
2297        );
2298        CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
2299        CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
2300        CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
2301        CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
2302
2303        CREATE TABLE IF NOT EXISTS dispatch_hints (
2304            id           TEXT PRIMARY KEY,
2305            method_name  TEXT NOT NULL,
2306            caller_node  TEXT NOT NULL,
2307            file         TEXT NOT NULL,
2308            line         INTEGER NOT NULL,
2309            byte_start   INTEGER NOT NULL,
2310            byte_end     INTEGER NOT NULL,
2311            provenance   TEXT NOT NULL
2312        );
2313        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
2314
2315        CREATE TABLE IF NOT EXISTS type_ref_names (
2316            name TEXT PRIMARY KEY
2317        );
2318
2319        CREATE TABLE IF NOT EXISTS backend_file_state (
2320            backend        TEXT NOT NULL,
2321            workspace_root TEXT NOT NULL,
2322            file_path      TEXT NOT NULL,
2323            content_hash   TEXT NOT NULL,
2324            status         TEXT NOT NULL,
2325            updated_at     INTEGER NOT NULL,
2326            PRIMARY KEY(backend, workspace_root, file_path, content_hash)
2327        );
2328        CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
2329
2330        CREATE TABLE IF NOT EXISTS meta (
2331            k TEXT PRIMARY KEY,
2332            v TEXT NOT NULL
2333        );",
2334    )?;
2335    insert_meta(conn)?;
2336    Ok(())
2337}
2338
2339fn insert_meta(conn: &Connection) -> Result<()> {
2340    conn.execute(
2341        "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
2342        params![SCHEMA_VERSION.to_string()],
2343    )?;
2344    conn.execute(
2345        "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
2346        params![schema_fingerprint()],
2347    )?;
2348    Ok(())
2349}
2350
2351fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
2352    conn.execute(
2353        "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
2354        params![if ready { "1" } else { "0" }],
2355    )?;
2356    Ok(())
2357}
2358
2359fn database_ready(conn: &Connection) -> Result<bool> {
2360    let schema_version: Option<String> = conn
2361        .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
2362            row.get(0)
2363        })
2364        .optional()?;
2365    let fingerprint: Option<String> = conn
2366        .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
2367            row.get(0)
2368        })
2369        .optional()?;
2370    let ready: Option<String> = conn
2371        .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
2372        .optional()?;
2373
2374    let expected_schema = SCHEMA_VERSION.to_string();
2375    let expected_fingerprint = schema_fingerprint();
2376    Ok(schema_version.as_deref() == Some(expected_schema.as_str())
2377        && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
2378        && ready.as_deref() == Some("1"))
2379}
2380
2381fn ensure_database_ready(conn: &Connection) -> Result<()> {
2382    if database_ready(conn)? {
2383        Ok(())
2384    } else {
2385        Err(CallGraphStoreError::Unavailable(
2386            "database is missing, stale, or mid-build".to_string(),
2387        ))
2388    }
2389}
2390
2391fn schema_fingerprint() -> String {
2392    // Bump the trailing content-version whenever the BUILD OUTPUT changes (new
2393    // edge sources, broader call extraction) even if the table SHAPE is
2394    // unchanged, so existing on-disk stores rebuild and pick up the new edges.
2395    // v8-attr-entry: Rust external-entry attributes now set node entry flags.
2396    let input = format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v8-attr-entry");
2397    hash_to_hex(blake3::hash(input.as_bytes()))
2398}
2399
2400fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
2401    tx.execute_batch(
2402        "DELETE FROM edges;
2403         DELETE FROM file_dependencies;
2404         DELETE FROM refs;
2405         DELETE FROM dispatch_hints;
2406         DELETE FROM type_ref_names;
2407         DELETE FROM backend_file_state;
2408         DELETE FROM nodes;
2409         DELETE FROM files;",
2410    )?;
2411    Ok(())
2412}
2413
2414fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
2415    tx.execute_batch(
2416        "DROP INDEX IF EXISTS idx_nodes_file;
2417         DROP INDEX IF EXISTS idx_nodes_name;
2418         DROP INDEX IF EXISTS idx_nodes_scoped;
2419         DROP INDEX IF EXISTS idx_refs_short_name;
2420         DROP INDEX IF EXISTS idx_refs_caller_file;
2421         DROP INDEX IF EXISTS idx_refs_caller_node_kind;
2422         DROP INDEX IF EXISTS idx_refs_target_file;
2423         DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
2424         DROP INDEX IF EXISTS idx_edges_source_kind;
2425         DROP INDEX IF EXISTS idx_edges_target_kind;
2426         DROP INDEX IF EXISTS idx_edges_target_file_symbol;
2427         DROP INDEX IF EXISTS idx_edges_ref_id;
2428         DROP INDEX IF EXISTS idx_dispatch_hints_method;
2429         DROP INDEX IF EXISTS idx_backend_file_state_file;",
2430    )?;
2431    Ok(())
2432}
2433
2434fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
2435    tx.execute_batch(
2436        "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
2437         CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
2438         CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
2439         CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
2440         CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
2441         CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
2442         CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
2443         CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
2444         CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
2445         CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
2446         CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
2447         CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
2448         CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
2449         CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
2450    )?;
2451    Ok(())
2452}
2453
2454const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
2455    ("files", "path"),
2456    ("nodes", "file_path"),
2457    ("refs", "caller_file"),
2458    ("refs", "target_file"),
2459    ("file_dependencies", "file_path"),
2460    ("file_dependencies", "dep_file"),
2461    ("edges", "target_file"),
2462    ("dispatch_hints", "file"),
2463    ("backend_file_state", "file_path"),
2464];
2465
2466/// Reconcile `backend_file_state.workspace_root` when the opener's project root
2467/// differs from what is stored. The store key is the git-root commit hash, so
2468/// multiple live checkouts/clones share one on-disk generation.
2469///
2470/// Cheap in-place re-root is only safe when every previously stored root path is
2471/// gone from disk (true move/rename). If any stale root still exists, another
2472/// clone is still alive and rewriting metadata would ping-pong relative rows
2473/// between trees (possibly on different branches). We then return
2474/// [`OpenRootRepair::NeedsRebuild`] so the caller cold-builds for the current
2475/// opener. That can make each clone rebuild on open when they alternate — bounded
2476/// by open frequency — but each rebuild is correct for its opener, unlike silent
2477/// cross-clone corruption.
2478fn reconcile_workspace_roots(conn: &mut Connection, project_root: &Path) -> Result<OpenRootRepair> {
2479    let roots = stored_workspace_roots(conn)?;
2480    let current_root = project_root.display().to_string();
2481    if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
2482        return Ok(OpenRootRepair::None);
2483    }
2484
2485    if let Some(sample) = sample_absolute_data_path(conn)? {
2486        return Ok(OpenRootRepair::NeedsRebuild {
2487            previous_roots: roots,
2488            current_root,
2489            reason: format!("absolute store data path row {sample}"),
2490        });
2491    }
2492
2493    for stored_root in roots.iter() {
2494        if stored_root == &current_root {
2495            continue;
2496        }
2497        if Path::new(stored_root).exists() {
2498            let reason = format!(
2499                "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
2500            );
2501            return Ok(OpenRootRepair::NeedsRebuild {
2502                previous_roots: roots,
2503                current_root,
2504                reason,
2505            });
2506        }
2507    }
2508
2509    let tx = conn.transaction()?;
2510    tx.execute(
2511        "UPDATE OR IGNORE backend_file_state
2512         SET workspace_root = ?1
2513         WHERE workspace_root <> ?1",
2514        params![&current_root],
2515    )?;
2516    tx.execute(
2517        "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
2518        params![&current_root],
2519    )?;
2520    tx.commit()?;
2521
2522    crate::slog_info!(
2523        "callgraph store re-rooted from {} to {}",
2524        roots.join(", "),
2525        current_root
2526    );
2527    Ok(OpenRootRepair::ReRooted)
2528}
2529
2530fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
2531    let mut stmt = conn.prepare(
2532        "SELECT DISTINCT workspace_root
2533         FROM backend_file_state
2534         ORDER BY workspace_root",
2535    )?;
2536    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
2537    rows.collect::<std::result::Result<Vec<_>, _>>()
2538        .map_err(Into::into)
2539}
2540
2541fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
2542    for (table, column) in STORE_DATA_PATH_COLUMNS {
2543        let sql = format!(
2544            "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
2545        );
2546        let mut stmt = conn.prepare(&sql)?;
2547        let mut rows = stmt.query([])?;
2548        while let Some(row) = rows.next()? {
2549            let value: String = row.get(0)?;
2550            if stored_path_is_absolute(&value) {
2551                return Ok(Some(format!("{table}.{column}={value}")));
2552            }
2553        }
2554    }
2555    Ok(None)
2556}
2557
2558fn stored_path_is_absolute(value: &str) -> bool {
2559    if value.is_empty() {
2560        return false;
2561    }
2562    if Path::new(value).is_absolute() || value.starts_with('/') {
2563        return true;
2564    }
2565    let bytes = value.as_bytes();
2566    if bytes.len() >= 3
2567        && bytes[1] == b':'
2568        && (bytes[2] == b'/' || bytes[2] == b'\\')
2569        && bytes[0].is_ascii_alphabetic()
2570    {
2571        return true;
2572    }
2573    value.starts_with("\\\\") || value.starts_with("//")
2574}
2575
2576fn log_root_repair_rebuild(repair: &OpenRootRepair) {
2577    if let OpenRootRepair::NeedsRebuild {
2578        previous_roots,
2579        current_root,
2580        reason,
2581    } = repair
2582    {
2583        crate::slog_info!(
2584            "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
2585            previous_roots.join(", "),
2586            current_root,
2587            reason
2588        );
2589    }
2590}
2591
2592/// Nanosecond clock used to make temp/generation file names unique.
2593fn now_nanos() -> u128 {
2594    SystemTime::now()
2595        .duration_since(UNIX_EPOCH)
2596        .unwrap_or(Duration::ZERO)
2597        .as_nanos()
2598}
2599
2600/// The pointer file `<dir>/<key>.current`. Its single line names the current
2601/// generation DB file. ONLY Rust std ever opens this file (never SQLite), so it
2602/// can always be atomically replaced via rename even on Windows — Rust opens
2603/// files with `FILE_SHARE_DELETE`, unlike SQLite's Win32 VFS.
2604fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
2605    callgraph_dir.join(format!("{project_key}.current"))
2606}
2607
2608/// The legacy single-file DB path used before the generation scheme. Still read
2609/// as a fallback so pre-upgrade on-disk stores keep working until the next cold
2610/// build publishes a generation.
2611fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
2612    callgraph_dir.join(format!("{project_key}.sqlite"))
2613}
2614
2615/// A fresh, unique generation file NAME: `<key>.g<nanos>.<pid>.sqlite`. Each
2616/// cold build writes a brand-new generation file, so publishing NEVER replaces
2617/// a file another process holds open (the root Windows fix).
2618fn generation_file_name(project_key: &str) -> String {
2619    format!(
2620        "{project_key}.g{}.{}.sqlite",
2621        now_nanos(),
2622        std::process::id()
2623    )
2624}
2625
2626/// Read the pointer; returns the generation file name if present and non-empty.
2627fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
2628    let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
2629    let name = text.trim();
2630    if name.is_empty() {
2631        None
2632    } else {
2633        Some(name.to_string())
2634    }
2635}
2636
2637/// True if the DB at `path` opens and reports ready (schema + fingerprint + the
2638/// `ready` flag). Uses a throwaway read-only connection.
2639fn db_path_ready(path: &Path) -> bool {
2640    (|| -> Result<bool> {
2641        let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
2642        conn.busy_timeout(Duration::from_millis(5_000))?;
2643        database_ready(&conn)
2644    })()
2645    .unwrap_or(false)
2646}
2647
2648/// Resolve the DB file a reader/opener should use, returning `(path, generation)`
2649/// where `generation` is `Some(name)` for a pointer-published generation or
2650/// `None` for the legacy single-file DB. Returns `None` when nothing ready is
2651/// published (caller treats that as "needs cold build").
2652///
2653/// Handles the GC race (the pointer names a generation that was just deleted) by
2654/// re-reading the pointer and retrying a few times.
2655fn resolve_ready_target(
2656    callgraph_dir: &Path,
2657    project_key: &str,
2658) -> Option<(PathBuf, Option<String>)> {
2659    for _ in 0..5 {
2660        if let Some(generation) = read_pointer(callgraph_dir, project_key) {
2661            let gen_path = callgraph_dir.join(&generation);
2662            if gen_path.is_file() {
2663                return db_path_ready(&gen_path).then_some((gen_path, Some(generation)));
2664            }
2665            // Pointer names a missing generation (a GC/publish race): re-read the
2666            // pointer and retry rather than failing the reader.
2667            std::thread::sleep(Duration::from_millis(5));
2668            continue;
2669        }
2670        // No pointer: fall back to the legacy single-file DB if it is ready.
2671        let legacy = legacy_sqlite_path(callgraph_dir, project_key);
2672        return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
2673    }
2674    None
2675}
2676
2677/// Atomically publish `generation` as the current store by flipping the pointer
2678/// file. Writes a temp file, fsyncs, then renames over the pointer — never
2679/// replacing an open DB file, so it succeeds cross-platform.
2680fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
2681    let pointer = pointer_path(callgraph_dir, project_key);
2682    let tmp = callgraph_dir.join(format!(
2683        "{project_key}.current.tmp.{}.{}",
2684        std::process::id(),
2685        now_nanos()
2686    ));
2687    {
2688        use std::io::Write as _;
2689        let mut file = std::fs::File::create(&tmp)?;
2690        file.write_all(generation.as_bytes())?;
2691        file.write_all(b"\n")?;
2692        file.sync_all()?;
2693    }
2694    if let Err(error) = std::fs::rename(&tmp, &pointer) {
2695        let _ = std::fs::remove_file(&tmp);
2696        return Err(error.into());
2697    }
2698    Ok(())
2699}
2700
2701/// Best-effort GC of superseded generation files. Never touches the current
2702/// generation; keeps the most-recent previous generation (so an in-flight
2703/// reader that resolved just before a flip can still open it) and a 60s grace
2704/// window for the rest. Deletion failures (e.g. a still-open generation on
2705/// Windows) are ignored and retried on a later build.
2706fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
2707    let grace = Duration::from_secs(60);
2708    let now = SystemTime::now();
2709    let gen_prefix = format!("{project_key}.g");
2710    let tmp_prefixes = [
2711        format!("{project_key}.g"), // generation build temps (<key>.g...sqlite.tmp.*)
2712        format!("{project_key}.current."), // pointer publish temps (<key>.current.tmp.*)
2713        format!("{project_key}.sqlite.tmp."), // legacy-scheme build temps
2714    ];
2715    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
2716        return;
2717    };
2718    let mut gens: Vec<(PathBuf, SystemTime)> = Vec::new();
2719    for entry in entries.flatten() {
2720        let name = entry.file_name();
2721        let name = name.to_string_lossy();
2722        let mtime = entry
2723            .metadata()
2724            .and_then(|m| m.modified())
2725            .unwrap_or_else(|_| SystemTime::now());
2726        let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= grace;
2727
2728        // Orphaned temp files from a crashed build/publish: remove once aged out.
2729        if name.contains(".tmp.") {
2730            if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
2731                let _ = std::fs::remove_file(entry.path());
2732            }
2733            continue;
2734        }
2735
2736        // Superseded legacy single-file DB: best-effort delete once a generation
2737        // is published (ignored if another process still holds it open).
2738        if *name == *format!("{project_key}.sqlite") {
2739            remove_sqlite_file_set(&entry.path());
2740            continue;
2741        }
2742
2743        if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") && name != current {
2744            gens.push((entry.path(), mtime));
2745        }
2746    }
2747    // Keep the newest superseded generation as a safety net for readers that
2748    // resolved the pointer just before the flip; GC the rest after the grace
2749    // window. Deletion of a still-open generation (Windows) fails silently and
2750    // is retried on a later build.
2751    gens.sort_by(|a, b| b.1.cmp(&a.1));
2752    for (index, (path, mtime)) in gens.into_iter().enumerate() {
2753        if index == 0 {
2754            continue;
2755        }
2756        if now.duration_since(mtime).unwrap_or(Duration::ZERO) < grace {
2757            continue;
2758        }
2759        remove_sqlite_file_set(&path);
2760    }
2761}
2762
2763fn remove_sqlite_file_set(path: &Path) {
2764    let _ = std::fs::remove_file(path);
2765    remove_sqlite_sidecars(path);
2766}
2767
2768fn remove_sqlite_sidecars(path: &Path) {
2769    let path_text = path.to_string_lossy();
2770    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
2771    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
2772    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
2773}
2774
2775/// Bound the cold-build's tree-sitter pass to half the cores (cap 8) instead of
2776/// the global all-cores rayon pool. The store cold-build is the heaviest
2777/// background pass (parse-dominated) and runs on a separate thread off the
2778/// single-threaded request loop; left unbounded it monopolizes every core and
2779/// starves the bridge so interactive tools time out (the same starvation the
2780/// v0.35 embedder and the inspect Tier-2 pool already cap). 8MB worker stacks
2781/// match the main thread, since the extract walks tree-sitter ASTs.
2782fn build_pool_size() -> usize {
2783    std::thread::available_parallelism()
2784        .map(|parallelism| parallelism.get())
2785        .unwrap_or(1)
2786        .div_ceil(2)
2787        .clamp(1, 8)
2788}
2789
2790fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
2791    let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
2792        Ok(extract) => Ok(extract),
2793        Err(error) => {
2794            let abs_path =
2795                normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
2796            let rel_path = relative_path(project_root, &abs_path);
2797            let freshness = cache_freshness::collect(&abs_path).ok();
2798            log::debug!(
2799                "callgraph store: skipping {} during cold build: {}",
2800                abs_path.display(),
2801                error
2802            );
2803            Err(ExtractFailure {
2804                rel_path,
2805                freshness,
2806            })
2807        }
2808    };
2809
2810    let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
2811        files.par_iter().map(extract_one).collect()
2812    };
2813
2814    // Run inside a dedicated bounded pool when one builds; fall back to the
2815    // global pool only if the bounded pool can't be constructed.
2816    let results = match rayon::ThreadPoolBuilder::new()
2817        .num_threads(build_pool_size())
2818        .thread_name(|index| format!("aft-callgraph-build-{index}"))
2819        .stack_size(8 * 1024 * 1024)
2820        .build()
2821    {
2822        Ok(pool) => pool.install(run),
2823        Err(error) => {
2824            log::warn!(
2825                "callgraph store: bounded build pool unavailable ({error}); using global pool"
2826            );
2827            run()
2828        }
2829    };
2830
2831    let mut extracts = Vec::new();
2832    let mut failures = Vec::new();
2833    for result in results {
2834        match result {
2835            Ok(extract) => extracts.push(extract),
2836            Err(failure) => failures.push(failure),
2837        }
2838    }
2839    BuildExtractsResult { extracts, failures }
2840}
2841
2842fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
2843    let metadata = std::fs::metadata(path)?;
2844    let size = metadata.len();
2845    let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
2846        cache_freshness::zero_hash()
2847    } else if source.len() as u64 == size {
2848        cache_freshness::hash_bytes(source.as_bytes())
2849    } else {
2850        cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
2851    };
2852    Ok(FileFreshness {
2853        mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
2854        size,
2855        content_hash,
2856    })
2857}
2858
2859fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
2860    let abs_path = normalize_file_path(project_root, path)?;
2861    let rel_path = relative_path(project_root, &abs_path);
2862    let source = std::fs::read_to_string(&abs_path)?;
2863    let freshness = collect_source_freshness(&abs_path, &source)?;
2864    let data = callgraph::build_file_data_from_source(&abs_path, &source)?;
2865    let lang = data.lang;
2866    let mut nodes = build_node_records(&rel_path, &source, &data)?;
2867    let node_by_scoped: HashMap<String, String> = nodes
2868        .iter()
2869        .map(|node| (node.scoped_name.clone(), node.id.clone()))
2870        .collect();
2871    let import_dependencies =
2872        import_dependencies(project_root, &abs_path, &data.import_block.imports);
2873    let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
2874    let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
2875    let mut raw_refs = Vec::new();
2876    raw_refs.extend(build_call_refs(
2877        &rel_path,
2878        &data,
2879        &node_by_scoped,
2880        &import_dependencies,
2881    ));
2882    let line_index = LineIndex::new(&source);
2883    raw_refs.extend(build_import_refs(
2884        project_root,
2885        &abs_path,
2886        &rel_path,
2887        &data.import_block.imports,
2888        &line_index,
2889    ));
2890    let mut surface_parts = reexports.surface_parts;
2891    surface_parts.extend(source_less_exports.surface_parts);
2892    raw_refs.extend(reexports.raw_refs);
2893    raw_refs.extend(source_less_exports.raw_refs);
2894    let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
2895    let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
2896
2897    Ok(FileExtract {
2898        abs_path,
2899        rel_path,
2900        freshness,
2901        lang,
2902        data,
2903        nodes,
2904        raw_refs,
2905        dispatch_hints,
2906        surface_fingerprint,
2907    })
2908}
2909
2910fn build_node_records(
2911    rel_path: &str,
2912    source: &str,
2913    data: &FileCallData,
2914) -> Result<Vec<NodeRecord>> {
2915    let mut records = Vec::new();
2916    let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
2917    let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
2918    metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
2919
2920    for (scoped_name, meta) in metadata {
2921        let name = unqualified_name(scoped_name).to_string();
2922        let range = selection_range(source, scoped_name, &name, &meta.range);
2923        let range_key = (
2924            range.start_line,
2925            range.start_col,
2926            range.end_line,
2927            range.end_col,
2928        );
2929        let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
2930        let range_ordinal = *ordinal;
2931        *ordinal += 1;
2932        let id = node_id(rel_path, &range, range_ordinal, scoped_name);
2933        let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
2934        let is_default_export = data
2935            .default_export_symbol
2936            .as_deref()
2937            .map(|default| default == scoped_name || default == name)
2938            .unwrap_or(false);
2939        records.push(NodeRecord {
2940            id,
2941            file_path: rel_path.to_string(),
2942            name: name.clone(),
2943            scoped_name: scoped_name.clone(),
2944            kind: symbol_kind_label(&meta.kind).to_string(),
2945            range,
2946            range_ordinal,
2947            signature: meta.signature.clone(),
2948            exported,
2949            is_default_export,
2950            is_type_like: is_type_like(&meta.kind),
2951            is_callgraph_entry_point: meta.entry_point_attribute.is_some()
2952                || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
2953        });
2954    }
2955
2956    Ok(records)
2957}
2958
2959fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
2960    if scoped_name == TOP_LEVEL_SYMBOL {
2961        return Range {
2962            start_line: 0,
2963            start_col: 0,
2964            end_line: 0,
2965            end_col: 0,
2966        };
2967    }
2968    let Some(line) = source.lines().nth(fallback.start_line as usize) else {
2969        return fallback.clone();
2970    };
2971    let start_col = fallback.start_col as usize;
2972    let search_start = start_col.min(line.len());
2973    if let Some(offset) = line[search_start..].find(name) {
2974        let col = search_start + offset;
2975        return Range {
2976            start_line: fallback.start_line,
2977            start_col: col as u32,
2978            end_line: fallback.start_line,
2979            end_col: (col + name.len()) as u32,
2980        };
2981    }
2982    if let Some(offset) = line.find(name) {
2983        return Range {
2984            start_line: fallback.start_line,
2985            start_col: offset as u32,
2986            end_line: fallback.start_line,
2987            end_col: (offset + name.len()) as u32,
2988        };
2989    }
2990    Range {
2991        start_line: fallback.start_line,
2992        start_col: fallback.start_col,
2993        end_line: fallback.start_line,
2994        end_col: fallback.start_col.saturating_add(name.len() as u32),
2995    }
2996}
2997
2998fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
2999    if scoped_name == TOP_LEVEL_SYMBOL {
3000        return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
3001    }
3002    let input = format!(
3003        "{rel_path}:{}:{}:{}:{}:{ordinal}",
3004        range.start_line, range.start_col, range.end_line, range.end_col
3005    );
3006    format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
3007}
3008
3009fn build_call_refs(
3010    rel_path: &str,
3011    data: &FileCallData,
3012    node_by_scoped: &HashMap<String, String>,
3013    import_dependencies: &BTreeSet<String>,
3014) -> Vec<RawRef> {
3015    let mut refs = Vec::new();
3016    let mut ordinal = 0usize;
3017    let mut symbols: Vec<_> = data.calls_by_symbol.iter().collect();
3018    symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
3019    for (caller_symbol, call_sites) in symbols {
3020        let caller_node = node_by_scoped.get(caller_symbol).cloned();
3021        for call_site in call_sites {
3022            ordinal += 1;
3023            let ref_id = ref_id(&[
3024                rel_path,
3025                "call",
3026                caller_symbol,
3027                &call_site.line.to_string(),
3028                &call_site.byte_start.to_string(),
3029                &call_site.byte_end.to_string(),
3030                &call_site.full_callee,
3031                &ordinal.to_string(),
3032            ]);
3033            refs.push(RawRef {
3034                ref_id,
3035                caller_node: caller_node.clone(),
3036                caller_symbol: Some(caller_symbol.clone()),
3037                caller_file: rel_path.to_string(),
3038                kind: "call".to_string(),
3039                short_name: Some(call_site.callee_name.clone()),
3040                full_ref: Some(call_site.full_callee.clone()),
3041                module_path: None,
3042                import_kind: None,
3043                local_name: Some(call_site.callee_name.clone()),
3044                requested_name: Some(call_site.callee_name.clone()),
3045                namespace_alias: namespace_alias(&call_site.full_callee),
3046                wildcard: false,
3047                line: call_site.line,
3048                byte_start: call_site.byte_start,
3049                byte_end: call_site.byte_end,
3050                dependencies: import_dependencies.clone(),
3051            });
3052        }
3053    }
3054    refs
3055}
3056
3057fn build_import_refs(
3058    project_root: &Path,
3059    abs_path: &Path,
3060    rel_path: &str,
3061    imports: &[ImportStatement],
3062    line_index: &LineIndex,
3063) -> Vec<RawRef> {
3064    let mut refs = Vec::new();
3065    for (index, import) in imports.iter().enumerate() {
3066        let import_kind = import_kind_label(import.kind).to_string();
3067        let local_name = import_local_names(import).join(",");
3068        let requested_name = import_requested_names(import).join(",");
3069        let ref_id = ref_id(&[
3070            rel_path,
3071            "import",
3072            &import.byte_range.start.to_string(),
3073            &import.byte_range.end.to_string(),
3074            &import.module_path,
3075            &index.to_string(),
3076        ]);
3077        refs.push(RawRef {
3078            ref_id,
3079            caller_node: None,
3080            caller_symbol: None,
3081            caller_file: rel_path.to_string(),
3082            kind: "import".to_string(),
3083            short_name: None,
3084            full_ref: Some(import.raw_text.clone()),
3085            module_path: Some(import.module_path.clone()),
3086            import_kind: Some(import_kind),
3087            local_name: empty_to_none(local_name),
3088            requested_name: empty_to_none(requested_name),
3089            namespace_alias: import.namespace_import.clone(),
3090            wildcard: import_is_wildcard(import),
3091            line: line_index.byte_to_line(import.byte_range.start),
3092            byte_start: import.byte_range.start,
3093            byte_end: import.byte_range.end,
3094            dependencies: module_dependencies(project_root, abs_path, &import.module_path),
3095        });
3096    }
3097    refs
3098}
3099
3100#[derive(Debug, Clone)]
3101struct ReexportRefs {
3102    raw_refs: Vec<RawRef>,
3103    surface_parts: Vec<String>,
3104}
3105
3106fn collect_reexport_refs(
3107    project_root: &Path,
3108    abs_path: &Path,
3109    rel_path: &str,
3110    source: &str,
3111) -> ReexportRefs {
3112    let mut raw_refs = Vec::new();
3113    let mut surface_parts = Vec::new();
3114    let mut search_start = 0usize;
3115    let mut ordinal = 0usize;
3116    while let Some(export_offset) = source[search_start..].find("export") {
3117        let start = search_start + export_offset;
3118        let Some(statement_end_offset) = source[start..].find(';') else {
3119            break;
3120        };
3121        let end = start + statement_end_offset + 1;
3122        let statement = &source[start..end];
3123        search_start = end;
3124        if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
3125            continue;
3126        }
3127        let Some(module_path) = quoted_module_path(statement) else {
3128            continue;
3129        };
3130        ordinal += 1;
3131        let wildcard = statement.contains('*');
3132        let line = source[..start]
3133            .bytes()
3134            .filter(|byte| *byte == b'\n')
3135            .count() as u32
3136            + 1;
3137        let ref_id = ref_id(&[
3138            rel_path,
3139            "reexport",
3140            &start.to_string(),
3141            &end.to_string(),
3142            &module_path,
3143            &ordinal.to_string(),
3144        ]);
3145        surface_parts.push(format!("reexport\t{statement}"));
3146        raw_refs.push(RawRef {
3147            ref_id,
3148            caller_node: None,
3149            caller_symbol: None,
3150            caller_file: rel_path.to_string(),
3151            kind: "reexport".to_string(),
3152            short_name: None,
3153            full_ref: Some(statement.to_string()),
3154            module_path: Some(module_path.clone()),
3155            import_kind: Some("reexport".to_string()),
3156            local_name: None,
3157            requested_name: None,
3158            namespace_alias: None,
3159            wildcard,
3160            line,
3161            byte_start: start,
3162            byte_end: end,
3163            dependencies: module_dependencies(project_root, abs_path, &module_path),
3164        });
3165    }
3166    ReexportRefs {
3167        raw_refs,
3168        surface_parts,
3169    }
3170}
3171
3172fn quoted_module_path(statement: &str) -> Option<String> {
3173    let quote = match (statement.find('\''), statement.find('"')) {
3174        (Some(single), Some(double)) if single < double => '\'',
3175        (Some(_), Some(_)) => '"',
3176        (Some(_), None) => '\'',
3177        (None, Some(_)) => '"',
3178        (None, None) => return None,
3179    };
3180    let start = statement.find(quote)? + 1;
3181    let end = statement[start..].find(quote)? + start;
3182    Some(statement[start..end].to_string())
3183}
3184
3185#[derive(Debug, Clone)]
3186struct SourceLessExportRefs {
3187    raw_refs: Vec<RawRef>,
3188    surface_parts: Vec<String>,
3189}
3190
3191fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
3192    let mut raw_refs = Vec::new();
3193    let mut surface_parts = Vec::new();
3194    let mut search_start = 0usize;
3195    let mut ordinal = 0usize;
3196    while let Some(export_offset) = source[search_start..].find("export") {
3197        let start = search_start + export_offset;
3198        let Some(statement_end_offset) = source[start..].find(';') else {
3199            break;
3200        };
3201        let end = start + statement_end_offset + 1;
3202        let statement = &source[start..end];
3203        search_start = end;
3204        if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
3205            continue;
3206        }
3207        let aliases = parse_reexport_names(statement);
3208        if aliases.is_empty() {
3209            continue;
3210        }
3211        let line = source[..start]
3212            .bytes()
3213            .filter(|byte| *byte == b'\n')
3214            .count() as u32
3215            + 1;
3216        for (exported, source_symbol) in aliases {
3217            ordinal += 1;
3218            let ref_id = ref_id(&[
3219                rel_path,
3220                "export_alias",
3221                &start.to_string(),
3222                &end.to_string(),
3223                &exported,
3224                &source_symbol,
3225                &ordinal.to_string(),
3226            ]);
3227            surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
3228            raw_refs.push(RawRef {
3229                ref_id,
3230                caller_node: None,
3231                caller_symbol: None,
3232                caller_file: rel_path.to_string(),
3233                kind: "export_alias".to_string(),
3234                short_name: None,
3235                full_ref: Some(statement.to_string()),
3236                module_path: None,
3237                import_kind: Some("export_alias".to_string()),
3238                local_name: Some(exported),
3239                requested_name: Some(source_symbol),
3240                namespace_alias: None,
3241                wildcard: false,
3242                line,
3243                byte_start: start,
3244                byte_end: end,
3245                dependencies: BTreeSet::new(),
3246            });
3247        }
3248    }
3249    SourceLessExportRefs {
3250        raw_refs,
3251        surface_parts,
3252    }
3253}
3254
3255fn build_dispatch_hints(
3256    rel_path: &str,
3257    data: &FileCallData,
3258    node_by_scoped: &HashMap<String, String>,
3259) -> Vec<DispatchHint> {
3260    let mut hints = Vec::new();
3261    let mut ordinal = 0usize;
3262    for (caller_symbol, call_sites) in &data.calls_by_symbol {
3263        let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
3264            continue;
3265        };
3266        for call_site in call_sites {
3267            if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
3268                continue;
3269            }
3270            ordinal += 1;
3271            hints.push(DispatchHint {
3272                id: ref_id(&[
3273                    rel_path,
3274                    "dispatch",
3275                    caller_symbol,
3276                    &call_site.line.to_string(),
3277                    &call_site.byte_start.to_string(),
3278                    &call_site.byte_end.to_string(),
3279                    &ordinal.to_string(),
3280                ]),
3281                method_name: call_site.callee_name.clone(),
3282                caller_node: caller_node.clone(),
3283                file: rel_path.to_string(),
3284                line: call_site.line,
3285                byte_start: call_site.byte_start,
3286                byte_end: call_site.byte_end,
3287            });
3288        }
3289    }
3290    hints
3291}
3292
3293fn surface_fingerprint(
3294    nodes: &mut [NodeRecord],
3295    data: &FileCallData,
3296    reexport_parts: &[String],
3297) -> String {
3298    nodes.sort_by(|left, right| {
3299        (left.file_path.as_str(), left.scoped_name.as_str())
3300            .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
3301    });
3302    let mut parts = Vec::new();
3303    for node in nodes.iter() {
3304        parts.push(format!(
3305            "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
3306            node.scoped_name,
3307            node.name,
3308            node.kind,
3309            node.exported,
3310            node.range.start_line,
3311            node.range.start_col,
3312            node.range.end_line,
3313            node.range.end_col,
3314            node.range_ordinal,
3315            node.signature.as_deref().unwrap_or("")
3316        ));
3317    }
3318    let mut exports = data.exported_symbols.clone();
3319    exports.sort();
3320    for export in exports {
3321        parts.push(format!("export\t{export}"));
3322    }
3323    if let Some(default_export) = &data.default_export_symbol {
3324        parts.push(format!("default\t{default_export}"));
3325    }
3326    let mut imports: Vec<String> = data
3327        .import_block
3328        .imports
3329        .iter()
3330        .map(|import| {
3331            format!(
3332                "import\t{}\t{:?}\t{}",
3333                import.module_path, import.form, import.raw_text
3334            )
3335        })
3336        .collect();
3337    imports.sort();
3338    parts.extend(imports);
3339    parts.extend(reexport_parts.iter().cloned());
3340    hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
3341}
3342
3343fn resolve_ref(raw: RawRef, index: &ProjectIndex<'_>) -> Result<ResolvedRef> {
3344    if raw.kind != "call" {
3345        return Ok(ResolvedRef {
3346            dependencies: raw.dependencies.clone(),
3347            raw,
3348            status: "unresolved".to_string(),
3349            target_node: None,
3350            target_file: None,
3351            target_symbol: None,
3352            edge: None,
3353        });
3354    }
3355
3356    let caller_file = raw.caller_file.clone();
3357    let caller_data = index.caller_data.get(&caller_file).ok_or_else(|| {
3358        CallGraphStoreError::MissingCallerData {
3359            file: caller_file.clone(),
3360        }
3361    })?;
3362    let full_ref = raw.full_ref.as_deref().unwrap_or_default();
3363    let short_name = raw.short_name.as_deref().unwrap_or_default();
3364    let mut dependencies = raw.dependencies.clone();
3365
3366    let resolved = match index.lang_for(&caller_file) {
3367        Some(LangId::Rust) => {
3368            resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data)
3369        }
3370        Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
3371            resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
3372        }
3373        _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
3374    };
3375
3376    let Some((status, target_file, target_symbol)) = resolved else {
3377        return Ok(ResolvedRef {
3378            raw,
3379            status: "unresolved".to_string(),
3380            target_node: None,
3381            target_file: None,
3382            target_symbol: None,
3383            dependencies,
3384            edge: None,
3385        });
3386    };
3387
3388    dependencies.insert(target_file.clone());
3389    let target_node = index.node_for_symbol(&target_file, &target_symbol);
3390    let source_node = raw.caller_node.clone();
3391    let edge = if let Some(source_node) = source_node {
3392        if target_file == caller_file
3393            && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
3394        {
3395            None
3396        } else {
3397            Some(EdgeRecord {
3398                edge_id: ref_id(&[&raw.ref_id, "edge"]),
3399                source_node,
3400                target_node: target_node.clone(),
3401                target_file: target_file.clone(),
3402                target_symbol: target_symbol.clone(),
3403                kind: "call".to_string(),
3404                line: raw.line,
3405            })
3406        }
3407    } else {
3408        None
3409    };
3410
3411    Ok(ResolvedRef {
3412        raw,
3413        status,
3414        target_node,
3415        target_file: Some(target_file),
3416        target_symbol: Some(target_symbol),
3417        dependencies,
3418        edge,
3419    })
3420}
3421
3422fn resolve_js_ts_target(
3423    index: &ProjectIndex<'_>,
3424    caller_file: &str,
3425    full_ref: &str,
3426    short_name: &str,
3427    caller_data: &FileCallData,
3428) -> Option<(String, String, String)> {
3429    if let Some((namespace, member)) = full_ref.split_once('.') {
3430        for import in &caller_data.import_block.imports {
3431            if import.namespace_import.as_deref() == Some(namespace) {
3432                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
3433                    if let Some((file, symbol)) =
3434                        resolve_exported_symbol(index, &target_file, member, 0)
3435                    {
3436                        return Some(("resolved".to_string(), file, symbol));
3437                    }
3438                }
3439            }
3440        }
3441    }
3442
3443    for import in &caller_data.import_block.imports {
3444        for spec in &import.names {
3445            if crate::imports::specifier_local_name(spec) == short_name {
3446                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
3447                    let requested = crate::imports::specifier_imported_name(spec);
3448                    let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
3449                        .unwrap_or_else(|| (target_file, requested.to_string()));
3450                    return Some(("resolved".to_string(), file, symbol));
3451                }
3452            }
3453        }
3454
3455        if import.default_import.as_deref() == Some(short_name) {
3456            if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
3457                let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
3458                    .or_else(|| {
3459                        index
3460                            .files
3461                            .get(&target_file)
3462                            .and_then(|file| file.default_export.clone())
3463                            .map(|symbol| (target_file.clone(), symbol))
3464                    })
3465                    .unwrap_or_else(|| {
3466                        let file_name = Path::new(&target_file)
3467                            .file_name()
3468                            .and_then(|name| name.to_str())
3469                            .unwrap_or("unknown")
3470                            .to_string();
3471                        (target_file, format!("<default:{file_name}>"))
3472                    });
3473                return Some(("resolved".to_string(), file, symbol));
3474            }
3475        }
3476    }
3477
3478    for import in &caller_data.import_block.imports {
3479        if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
3480            if index
3481                .files
3482                .get(&target_file)
3483                .map(|file| file.exports.contains(short_name))
3484                .unwrap_or(false)
3485            {
3486                return Some(("resolved".to_string(), target_file, short_name.to_string()));
3487            }
3488        }
3489    }
3490
3491    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
3492}
3493
3494fn resolve_exported_symbol(
3495    index: &ProjectIndex<'_>,
3496    file: &str,
3497    requested: &str,
3498    depth: usize,
3499) -> Option<(String, String)> {
3500    if depth > 16 {
3501        return None;
3502    }
3503    if requested != "default" {
3504        if let Some(source_symbol) = index
3505            .files
3506            .get(file)
3507            .and_then(|item| item.export_aliases.get(requested))
3508        {
3509            return Some((file.to_string(), source_symbol.clone()));
3510        }
3511        if index
3512            .files
3513            .get(file)
3514            .map(|item| item.exports.contains(requested))
3515            .unwrap_or(false)
3516        {
3517            return Some((file.to_string(), requested.to_string()));
3518        }
3519    } else if let Some(default) = index
3520        .files
3521        .get(file)
3522        .and_then(|item| item.default_export.clone())
3523    {
3524        return Some((file.to_string(), default));
3525    }
3526
3527    for reexport in index.reexports_for(file) {
3528        let mut next_requested = requested.to_string();
3529        let matches = if reexport.wildcard {
3530            true
3531        } else if let Some(source_name) = reexport.named.get(requested) {
3532            next_requested = source_name.clone();
3533            true
3534        } else {
3535            false
3536        };
3537        if !matches {
3538            continue;
3539        }
3540        if let Some(target_file) = &reexport.target_file {
3541            if let Some(target) =
3542                resolve_exported_symbol(index, target_file, &next_requested, depth + 1)
3543            {
3544                return Some(target);
3545            }
3546        }
3547    }
3548    None
3549}
3550
3551fn resolve_rust_target(
3552    index: &ProjectIndex<'_>,
3553    caller_file: &str,
3554    full_ref: &str,
3555    short_name: &str,
3556    caller_data: &FileCallData,
3557) -> Option<(String, String, String)> {
3558    if full_ref.contains("::") {
3559        if let Some(target_file) = rust_target_for_qualified(index, caller_file, full_ref) {
3560            return Some((
3561                "resolved".to_string(),
3562                target_file,
3563                rust_target_symbol(full_ref, short_name),
3564            ));
3565        }
3566    }
3567
3568    for import in &caller_data.import_block.imports {
3569        if let Some((target_file, target_symbol)) =
3570            rust_target_for_use(index, caller_file, import, short_name)
3571        {
3572            return Some(("resolved".to_string(), target_file, target_symbol));
3573        }
3574    }
3575
3576    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
3577}
3578
3579fn rust_target_for_qualified(
3580    index: &ProjectIndex<'_>,
3581    caller_file: &str,
3582    full_ref: &str,
3583) -> Option<String> {
3584    let mut segments: Vec<&str> = full_ref.split("::").collect();
3585    if segments.len() < 2 {
3586        return None;
3587    }
3588    segments.pop();
3589    if !matches!(segments.first().copied(), Some("crate" | "self" | "super")) {
3590        if let Some(target) = rust_workspace_file_for_segments(index, &segments) {
3591            return Some(target);
3592        }
3593    }
3594    let module_segments = rust_resolve_segments(caller_file, &segments)?;
3595    rust_file_for_segments(index, caller_file, &module_segments)
3596}
3597
3598fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
3599    full_ref
3600        .rsplit("::")
3601        .next()
3602        .filter(|name| !name.is_empty())
3603        .unwrap_or(short_name)
3604        .to_string()
3605}
3606
3607fn rust_target_for_use(
3608    index: &ProjectIndex<'_>,
3609    caller_file: &str,
3610    import: &ImportStatement,
3611    short_name: &str,
3612) -> Option<(String, String)> {
3613    let path = import.module_path.trim().trim_end_matches(';');
3614    if let Some(brace_start) = path.find("::{") {
3615        let prefix = &path[..brace_start];
3616        if import.names.iter().any(|name| name == short_name) {
3617            let prefix_segments: Vec<&str> = prefix.split("::").collect();
3618            let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
3619            let file = rust_file_for_segments(index, caller_file, &module_segments)?;
3620            return Some((file, short_name.to_string()));
3621        }
3622        return None;
3623    }
3624
3625    let (path_without_alias, alias) = path
3626        .split_once(" as ")
3627        .map(|(left, right)| (left.trim(), Some(right.trim())))
3628        .unwrap_or((path, None));
3629    let segments: Vec<&str> = path_without_alias.split("::").collect();
3630    let imported = alias.or_else(|| segments.last().copied())?;
3631    if imported != short_name {
3632        return None;
3633    }
3634    if segments.len() < 2 {
3635        return None;
3636    }
3637    let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
3638    let file = rust_file_for_segments(index, caller_file, &module_segments)?;
3639    Some((file, segments.last().unwrap_or(&short_name).to_string()))
3640}
3641
3642fn rust_workspace_file_for_segments(index: &ProjectIndex<'_>, segments: &[&str]) -> Option<String> {
3643    let crate_name = segments.first().copied()?;
3644    let src_prefix = index.crate_src_prefix(crate_name)?;
3645    let module_segments = segments[1..]
3646        .iter()
3647        .map(|segment| segment.to_string())
3648        .collect::<Vec<_>>();
3649    rust_file_for_src_prefix(index, &src_prefix, &module_segments)
3650}
3651
3652/// Walk the project tree once and map every Rust crate name (package name with
3653/// `-` normalized to `_`, plus any explicit `[lib] name`) to its `src` prefix.
3654/// Replaces the previous per-ref tree walk: resolving 600k+ qualified refs no
3655/// longer re-walks the filesystem once per ref.
3656fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
3657    let mut prefixes = HashMap::new();
3658    let mut stack = vec![project_root.to_path_buf()];
3659    while let Some(dir) = stack.pop() {
3660        let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
3661        if matches!(name, "target" | "node_modules" | ".git") {
3662            continue;
3663        }
3664        let manifest = dir.join("Cargo.toml");
3665        if manifest.is_file() {
3666            let crate_names = rust_manifest_crate_names(&manifest);
3667            if !crate_names.is_empty() {
3668                let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
3669                for crate_name in crate_names {
3670                    prefixes
3671                        .entry(crate_name)
3672                        .or_insert_with(|| src_prefix.clone());
3673                }
3674            }
3675        }
3676        let Ok(entries) = std::fs::read_dir(&dir) else {
3677            continue;
3678        };
3679        for entry in entries.flatten() {
3680            let path = entry.path();
3681            if path.is_dir() {
3682                stack.push(path);
3683            }
3684        }
3685    }
3686    prefixes
3687}
3688
3689/// Extract the crate names a manifest defines: the normalized package name
3690/// (`-` -> `_`) and any explicit `[lib] name`. Returns both so a crate is
3691/// reachable by either spelling, matching the previous match semantics.
3692fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
3693    let Ok(source) = std::fs::read_to_string(manifest) else {
3694        return Vec::new();
3695    };
3696    let mut in_lib = false;
3697    let mut package_name = None;
3698    let mut lib_name = None;
3699    for line in source.lines() {
3700        let trimmed = line.trim();
3701        if trimmed.starts_with('[') {
3702            in_lib = trimmed == "[lib]";
3703            continue;
3704        }
3705        let Some((key, value)) = trimmed.split_once('=') else {
3706            continue;
3707        };
3708        let key = key.trim();
3709        let value = value.trim().trim_matches('"');
3710        if in_lib && key == "name" {
3711            lib_name = Some(value.to_string());
3712        } else if !in_lib && key == "name" && package_name.is_none() {
3713            package_name = Some(value.to_string());
3714        }
3715    }
3716    let mut names = Vec::new();
3717    if let Some(lib) = lib_name {
3718        names.push(lib);
3719    }
3720    if let Some(package) = package_name {
3721        let normalized = package.replace('-', "_");
3722        if !names.contains(&normalized) {
3723            names.push(normalized);
3724        }
3725    }
3726    names
3727}
3728
3729fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
3730    if segments.is_empty() {
3731        return Some(Vec::new());
3732    }
3733    let caller_segments = rust_module_segments_for_rel(caller_file);
3734    match segments[0] {
3735        "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
3736        "self" => {
3737            let mut resolved = caller_segments;
3738            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
3739            Some(resolved)
3740        }
3741        "super" => {
3742            let mut resolved = caller_segments;
3743            resolved.pop();
3744            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
3745            Some(resolved)
3746        }
3747        _ => {
3748            let mut resolved = caller_segments;
3749            resolved.pop();
3750            resolved.extend(segments.iter().map(|item| item.to_string()));
3751            Some(resolved)
3752        }
3753    }
3754}
3755
3756fn rust_file_for_segments(
3757    index: &ProjectIndex<'_>,
3758    caller_file: &str,
3759    segments: &[String],
3760) -> Option<String> {
3761    rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
3762}
3763
3764fn rust_file_for_src_prefix(
3765    index: &ProjectIndex<'_>,
3766    src_prefix: &str,
3767    segments: &[String],
3768) -> Option<String> {
3769    let candidate = if segments.is_empty() {
3770        [src_prefix, "lib.rs"].join("/")
3771    } else {
3772        format!("{}/{}.rs", src_prefix, segments.join("/"))
3773    };
3774    if index.files.contains_key(&candidate) {
3775        return Some(candidate);
3776    }
3777    if !segments.is_empty() {
3778        let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
3779        if index.files.contains_key(&mod_candidate) {
3780            return Some(mod_candidate);
3781        }
3782    }
3783    None
3784}
3785
3786fn rust_src_prefix(rel_path: &str) -> String {
3787    rel_path
3788        .split_once("/src/")
3789        .map(|(prefix, _)| format!("{prefix}/src"))
3790        .unwrap_or_else(|| "src".to_string())
3791}
3792
3793fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
3794    let after_src = rel_path
3795        .split_once("/src/")
3796        .map(|(_, rest)| rest)
3797        .or_else(|| rel_path.strip_prefix("src/"))
3798        .unwrap_or(rel_path);
3799    if matches!(after_src, "lib.rs" | "main.rs") {
3800        return Vec::new();
3801    }
3802    if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
3803        return prefix.split('/').map(|item| item.to_string()).collect();
3804    }
3805    after_src
3806        .strip_suffix(".rs")
3807        .unwrap_or(after_src)
3808        .split('/')
3809        .map(|item| item.to_string())
3810        .collect()
3811}
3812
3813fn resolve_local_target(
3814    _index: &ProjectIndex<'_>,
3815    caller_file: &str,
3816    full_ref: &str,
3817    short_name: &str,
3818    caller_data: &FileCallData,
3819) -> Option<(String, String, String)> {
3820    if !callgraph::is_bare_callee(full_ref, short_name) {
3821        return None;
3822    }
3823    callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
3824        .ok()
3825        .map(|symbol| {
3826            (
3827                "resolved_local".to_string(),
3828                caller_file.to_string(),
3829                symbol,
3830            )
3831        })
3832}
3833
3834impl<'a> ProjectIndex<'a> {
3835    fn from_parts(
3836        project_root: &Path,
3837        files: HashMap<String, DbFileIndex>,
3838        caller_data: HashMap<String, &'a FileCallData>,
3839    ) -> Self {
3840        Self {
3841            project_root: project_root.to_path_buf(),
3842            files,
3843            caller_data,
3844            workspace_crate_prefixes: std::sync::OnceLock::new(),
3845        }
3846    }
3847
3848    fn from_extracts(project_root: &Path, extracts: &'a [FileExtract]) -> Self {
3849        let mut files = HashMap::new();
3850        let mut caller_data = HashMap::new();
3851        for extract in extracts {
3852            let index = DbFileIndex::from_extract(project_root, extract);
3853            caller_data.insert(extract.rel_path.clone(), &extract.data);
3854            files.insert(extract.rel_path.clone(), index);
3855        }
3856        Self::from_parts(project_root, files, caller_data)
3857    }
3858
3859    fn from_db_and_callers(
3860        tx: &Transaction<'_>,
3861        project_root: &Path,
3862        caller_extracts: &'a HashMap<String, FileExtract>,
3863    ) -> Result<Self> {
3864        let mut files = load_db_file_indexes(tx, project_root)?;
3865        let mut caller_data = HashMap::new();
3866        for (rel_path, extract) in caller_extracts {
3867            files.insert(
3868                rel_path.clone(),
3869                DbFileIndex::from_extract(project_root, extract),
3870            );
3871            caller_data.insert(rel_path.clone(), &extract.data);
3872        }
3873        Ok(Self::from_parts(project_root, files, caller_data))
3874    }
3875
3876    fn lang_for(&self, rel_path: &str) -> Option<LangId> {
3877        self.files.get(rel_path).and_then(|file| file.lang)
3878    }
3879
3880    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
3881        self.files
3882            .get(caller_file)
3883            .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
3884    }
3885
3886    fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
3887        self.files
3888            .get(rel_path)
3889            .map(|file| file.reexports.as_slice())
3890            .unwrap_or(&[])
3891    }
3892
3893    fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
3894        self.files.get(rel_path).and_then(|file| {
3895            file.node_by_scoped
3896                .get(symbol)
3897                .cloned()
3898                .or_else(|| file.node_by_bare.get(symbol).cloned())
3899        })
3900    }
3901}
3902
3903impl DbFileIndex {
3904    fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
3905        let mut node_by_scoped = HashMap::new();
3906        let mut node_by_bare = HashMap::new();
3907        for node in &extract.nodes {
3908            node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
3909            node_by_bare
3910                .entry(node.name.clone())
3911                .or_insert(node.id.clone());
3912        }
3913        let mut export_aliases = HashMap::new();
3914        for raw_ref in &extract.raw_refs {
3915            if raw_ref.kind == "export_alias" {
3916                if let (Some(exported), Some(source_symbol)) =
3917                    (&raw_ref.local_name, &raw_ref.requested_name)
3918                {
3919                    export_aliases.insert(exported.clone(), source_symbol.clone());
3920                }
3921            }
3922        }
3923        let mut module_targets = HashMap::new();
3924        for import in &extract.data.import_block.imports {
3925            module_targets.insert(
3926                import.module_path.clone(),
3927                module_target_from_dependencies(
3928                    project_root,
3929                    &module_dependencies(project_root, &extract.abs_path, &import.module_path),
3930                ),
3931            );
3932        }
3933        let mut reexports = Vec::new();
3934        for raw_ref in &extract.raw_refs {
3935            if raw_ref.kind == "reexport" {
3936                if let Some(module_path) = &raw_ref.module_path {
3937                    let target_file =
3938                        module_target_from_dependencies(project_root, &raw_ref.dependencies);
3939                    module_targets.insert(module_path.clone(), target_file.clone());
3940                    reexports.push(reexport_index_from_raw(raw_ref, target_file));
3941                }
3942            }
3943        }
3944        Self {
3945            lang: Some(extract.lang),
3946            exports: extract.data.exported_symbols.iter().cloned().collect(),
3947            default_export: extract.data.default_export_symbol.clone(),
3948            export_aliases,
3949            node_by_scoped,
3950            node_by_bare,
3951            module_targets,
3952            reexports,
3953        }
3954    }
3955}
3956
3957fn load_db_file_indexes(
3958    tx: &Transaction<'_>,
3959    project_root: &Path,
3960) -> Result<HashMap<String, DbFileIndex>> {
3961    let mut files = HashMap::new();
3962    let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
3963    let rows = stmt.query_map([], |row| {
3964        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3965    })?;
3966    for row in rows {
3967        let (rel_path, lang) = row?;
3968        files.insert(
3969            rel_path.clone(),
3970            DbFileIndex {
3971                lang: lang_from_label(&lang),
3972                exports: HashSet::new(),
3973                default_export: None,
3974                export_aliases: HashMap::new(),
3975                node_by_scoped: HashMap::new(),
3976                node_by_bare: HashMap::new(),
3977                module_targets: HashMap::new(),
3978                reexports: Vec::new(),
3979            },
3980        );
3981    }
3982
3983    let mut node_stmt = tx.prepare(
3984        "SELECT file_path, id, name, scoped_name, exported, is_default_export FROM nodes",
3985    )?;
3986    let nodes = node_stmt.query_map([], |row| {
3987        Ok((
3988            row.get::<_, String>(0)?,
3989            row.get::<_, String>(1)?,
3990            row.get::<_, String>(2)?,
3991            row.get::<_, String>(3)?,
3992            row.get::<_, i64>(4)? != 0,
3993            row.get::<_, i64>(5)? != 0,
3994        ))
3995    })?;
3996    for row in nodes {
3997        let (file_path, id, name, scoped_name, exported, is_default_export) = row?;
3998        let file = files
3999            .entry(file_path.clone())
4000            .or_insert_with(|| DbFileIndex {
4001                lang: None,
4002                exports: HashSet::new(),
4003                default_export: None,
4004                export_aliases: HashMap::new(),
4005                node_by_scoped: HashMap::new(),
4006                node_by_bare: HashMap::new(),
4007                module_targets: HashMap::new(),
4008                reexports: Vec::new(),
4009            });
4010        if exported {
4011            file.exports.insert(name.clone());
4012            file.exports.insert(scoped_name.clone());
4013        }
4014        if is_default_export {
4015            file.default_export = Some(scoped_name.clone());
4016        }
4017        file.node_by_scoped.insert(scoped_name, id.clone());
4018        file.node_by_bare.entry(name).or_insert(id);
4019    }
4020    let file_keys: HashSet<String> = files.keys().cloned().collect();
4021    let mut ref_stmt = tx.prepare(
4022        "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
4023         FROM refs WHERE kind IN ('import', 'reexport', 'export_alias')",
4024    )?;
4025    let ref_rows = ref_stmt.query_map([], |row| {
4026        Ok((
4027            row.get::<_, String>(0)?,
4028            row.get::<_, String>(1)?,
4029            row.get::<_, String>(2)?,
4030            row.get::<_, Option<String>>(3)?,
4031            row.get::<_, Option<String>>(4)?,
4032            row.get::<_, i64>(5)? != 0,
4033            row.get::<_, Option<String>>(6)?,
4034            row.get::<_, Option<String>>(7)?,
4035        ))
4036    })?;
4037    for row in ref_rows {
4038        let (
4039            ref_id,
4040            caller_file,
4041            kind,
4042            module_path,
4043            full_ref,
4044            wildcard,
4045            local_name,
4046            requested_name,
4047        ) = row?;
4048        if kind == "export_alias" {
4049            if let (Some(exported), Some(source_symbol), Some(file)) =
4050                (local_name, requested_name, files.get_mut(&caller_file))
4051            {
4052                file.export_aliases.insert(exported, source_symbol);
4053            }
4054            continue;
4055        }
4056        let Some(module_path) = module_path else {
4057            continue;
4058        };
4059        let deps = dependencies_for_ref(tx, project_root, &ref_id)?;
4060        let target_file = deps
4061            .iter()
4062            .find(|dep| file_keys.contains(*dep))
4063            .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
4064        if let Some(file) = files.get_mut(&caller_file) {
4065            file.module_targets
4066                .entry(module_path.clone())
4067                .or_insert_with(|| target_file.clone());
4068            if kind == "reexport" {
4069                let raw = RawRef {
4070                    ref_id,
4071                    caller_node: None,
4072                    caller_symbol: None,
4073                    caller_file,
4074                    kind,
4075                    short_name: None,
4076                    full_ref,
4077                    module_path: Some(module_path),
4078                    import_kind: Some("reexport".to_string()),
4079                    local_name: None,
4080                    requested_name: None,
4081                    namespace_alias: None,
4082                    wildcard,
4083                    line: 0,
4084                    byte_start: 0,
4085                    byte_end: 0,
4086                    dependencies: deps,
4087                };
4088                file.reexports
4089                    .push(reexport_index_from_raw(&raw, target_file));
4090            }
4091        }
4092    }
4093
4094    Ok(files)
4095}
4096
4097struct ColdBuildInsertStatements<'stmt> {
4098    file: Statement<'stmt>,
4099    node: Statement<'stmt>,
4100    file_dependency: Statement<'stmt>,
4101    dispatch_hint: Statement<'stmt>,
4102    backend_state: Statement<'stmt>,
4103    reference: Statement<'stmt>,
4104    edge: Statement<'stmt>,
4105}
4106
4107impl<'stmt> ColdBuildInsertStatements<'stmt> {
4108    fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
4109        Ok(Self {
4110            file: tx.prepare(
4111                "INSERT OR REPLACE INTO files(
4112                    path, content_hash, mtime_ns, size, lang, is_dead_code_root,
4113                    is_public_api, surface_fingerprint, indexed_at
4114                ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
4115            )?,
4116            node: tx.prepare(
4117                "INSERT OR REPLACE INTO nodes(
4118                    id, file_path, name, scoped_name, kind, start_line, start_col,
4119                    end_line, end_col, range_ordinal, signature, exported,
4120                    is_default_export, is_type_like, is_callgraph_entry_point, provenance
4121                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
4122            )?,
4123            file_dependency: tx.prepare(
4124                "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
4125            )?,
4126            dispatch_hint: tx.prepare(
4127                "INSERT OR REPLACE INTO dispatch_hints(
4128                    id, method_name, caller_node, file, line, byte_start, byte_end, provenance
4129                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
4130            )?,
4131            backend_state: tx.prepare(
4132                "INSERT OR REPLACE INTO backend_file_state(
4133                    backend, workspace_root, file_path, content_hash, status, updated_at
4134                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
4135            )?,
4136            reference: tx.prepare(
4137                "INSERT OR REPLACE INTO refs(
4138                    ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
4139                    import_kind, local_name, requested_name, namespace_alias, wildcard, line,
4140                    byte_start, byte_end, status, target_node, target_file, target_symbol,
4141                    provenance
4142                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
4143            )?,
4144            edge: tx.prepare(
4145                "INSERT OR REPLACE INTO edges(
4146                    edge_id, ref_id, source_node, target_node, target_file, target_symbol,
4147                    kind, line, provenance
4148                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
4149            )?,
4150        })
4151    }
4152}
4153
4154fn insert_file_extract_prepared(
4155    statements: &mut ColdBuildInsertStatements<'_>,
4156    workspace_root: &str,
4157    extract: &FileExtract,
4158) -> Result<()> {
4159    statements.file.execute(params![
4160        extract.rel_path,
4161        hash_to_hex(extract.freshness.content_hash),
4162        system_time_to_ns(extract.freshness.mtime),
4163        extract.freshness.size as i64,
4164        lang_label(extract.lang),
4165        extract.surface_fingerprint,
4166        unix_seconds_now(),
4167    ])?;
4168    for node in &extract.nodes {
4169        statements.node.execute(params![
4170            node.id,
4171            node.file_path,
4172            node.name,
4173            node.scoped_name,
4174            node.kind,
4175            node.range.start_line as i64,
4176            node.range.start_col as i64,
4177            node.range.end_line as i64,
4178            node.range.end_col as i64,
4179            node.range_ordinal as i64,
4180            node.signature,
4181            bool_int(node.exported),
4182            bool_int(node.is_default_export),
4183            bool_int(node.is_type_like),
4184            bool_int(node.is_callgraph_entry_point),
4185            PROVENANCE_TREESITTER,
4186        ])?;
4187    }
4188
4189    let mut dependencies = BTreeSet::new();
4190    for raw_ref in &extract.raw_refs {
4191        dependencies.extend(raw_ref.dependencies.iter().cloned());
4192    }
4193    for dep_file in &dependencies {
4194        statements
4195            .file_dependency
4196            .execute(params![extract.rel_path, dep_file])?;
4197    }
4198
4199    for hint in &extract.dispatch_hints {
4200        statements.dispatch_hint.execute(params![
4201            hint.id,
4202            hint.method_name,
4203            hint.caller_node,
4204            hint.file,
4205            hint.line as i64,
4206            hint.byte_start as i64,
4207            hint.byte_end as i64,
4208            PROVENANCE_TREESITTER,
4209        ])?;
4210    }
4211    insert_backend_state_prepared(
4212        &mut statements.backend_state,
4213        workspace_root,
4214        &extract.rel_path,
4215        Some(&extract.freshness.content_hash),
4216        "fresh",
4217    )?;
4218    Ok(())
4219}
4220
4221fn insert_backend_state_prepared(
4222    stmt: &mut Statement<'_>,
4223    workspace_root: &str,
4224    rel_path: &str,
4225    content_hash: Option<&blake3::Hash>,
4226    status: &str,
4227) -> Result<()> {
4228    let hash = content_hash
4229        .map(|hash| hash_to_hex(*hash))
4230        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
4231    stmt.execute(params![
4232        BACKEND_TREESITTER,
4233        workspace_root,
4234        rel_path,
4235        hash,
4236        status,
4237        unix_seconds_now(),
4238    ])?;
4239    Ok(())
4240}
4241
4242fn insert_resolved_ref_prepared(
4243    statements: &mut ColdBuildInsertStatements<'_>,
4244    resolved: &ResolvedRef,
4245) -> Result<()> {
4246    let raw = &resolved.raw;
4247    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
4248    statements.reference.execute(params![
4249        raw.ref_id,
4250        raw.caller_node,
4251        raw.caller_file,
4252        raw.kind,
4253        raw.short_name,
4254        raw.full_ref,
4255        raw.module_path,
4256        raw.import_kind,
4257        raw.local_name,
4258        raw.requested_name,
4259        raw.namespace_alias,
4260        bool_int(raw.wildcard),
4261        raw.line as i64,
4262        raw.byte_start as i64,
4263        raw.byte_end as i64,
4264        resolved.status,
4265        resolved.target_node,
4266        resolved.target_file,
4267        resolved.target_symbol,
4268        PROVENANCE_TREESITTER,
4269    ])?;
4270    if let Some(edge) = &resolved.edge {
4271        statements.edge.execute(params![
4272            edge.edge_id,
4273            raw.ref_id,
4274            edge.source_node,
4275            edge.target_node,
4276            edge.target_file,
4277            edge.target_symbol,
4278            edge.kind,
4279            edge.line as i64,
4280            PROVENANCE_TREESITTER,
4281        ])?;
4282    }
4283    Ok(())
4284}
4285
4286fn insert_file_extract(
4287    tx: &Transaction<'_>,
4288    project_root: &Path,
4289    extract: &FileExtract,
4290) -> Result<()> {
4291    tx.execute(
4292        "INSERT OR REPLACE INTO files(
4293            path, content_hash, mtime_ns, size, lang, is_dead_code_root,
4294            is_public_api, surface_fingerprint, indexed_at
4295        ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
4296        params![
4297            extract.rel_path,
4298            hash_to_hex(extract.freshness.content_hash),
4299            system_time_to_ns(extract.freshness.mtime),
4300            extract.freshness.size as i64,
4301            lang_label(extract.lang),
4302            extract.surface_fingerprint,
4303            unix_seconds_now(),
4304        ],
4305    )?;
4306    for node in &extract.nodes {
4307        tx.execute(
4308            "INSERT OR REPLACE INTO nodes(
4309                id, file_path, name, scoped_name, kind, start_line, start_col,
4310                end_line, end_col, range_ordinal, signature, exported,
4311                is_default_export, is_type_like, is_callgraph_entry_point, provenance
4312            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
4313            params![
4314                node.id,
4315                node.file_path,
4316                node.name,
4317                node.scoped_name,
4318                node.kind,
4319                node.range.start_line as i64,
4320                node.range.start_col as i64,
4321                node.range.end_line as i64,
4322                node.range.end_col as i64,
4323                node.range_ordinal as i64,
4324                node.signature,
4325                bool_int(node.exported),
4326                bool_int(node.is_default_export),
4327                bool_int(node.is_type_like),
4328                bool_int(node.is_callgraph_entry_point),
4329                PROVENANCE_TREESITTER,
4330            ],
4331        )?;
4332    }
4333    let mut dependencies = BTreeSet::new();
4334    for raw_ref in &extract.raw_refs {
4335        dependencies.extend(raw_ref.dependencies.iter().cloned());
4336    }
4337    insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
4338
4339    for hint in &extract.dispatch_hints {
4340        tx.execute(
4341            "INSERT OR REPLACE INTO dispatch_hints(
4342                id, method_name, caller_node, file, line, byte_start, byte_end, provenance
4343            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
4344            params![
4345                hint.id,
4346                hint.method_name,
4347                hint.caller_node,
4348                hint.file,
4349                hint.line as i64,
4350                hint.byte_start as i64,
4351                hint.byte_end as i64,
4352                PROVENANCE_TREESITTER,
4353            ],
4354        )?;
4355    }
4356    mark_backend_state(
4357        tx,
4358        project_root,
4359        &extract.rel_path,
4360        Some(&extract.freshness.content_hash),
4361        "fresh",
4362    )?;
4363    Ok(())
4364}
4365
4366fn insert_file_dependencies(
4367    tx: &Transaction<'_>,
4368    file_path: &str,
4369    dependencies: &BTreeSet<String>,
4370) -> Result<()> {
4371    for dep_file in dependencies {
4372        tx.execute(
4373            "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
4374            params![file_path, dep_file],
4375        )?;
4376    }
4377    Ok(())
4378}
4379
4380fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
4381    let raw = &resolved.raw;
4382    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
4383    tx.execute(
4384        "INSERT OR REPLACE INTO refs(
4385            ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
4386            import_kind, local_name, requested_name, namespace_alias, wildcard, line,
4387            byte_start, byte_end, status, target_node, target_file, target_symbol,
4388            provenance
4389        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
4390        params![
4391            raw.ref_id,
4392            raw.caller_node,
4393            raw.caller_file,
4394            raw.kind,
4395            raw.short_name,
4396            raw.full_ref,
4397            raw.module_path,
4398            raw.import_kind,
4399            raw.local_name,
4400            raw.requested_name,
4401            raw.namespace_alias,
4402            bool_int(raw.wildcard),
4403            raw.line as i64,
4404            raw.byte_start as i64,
4405            raw.byte_end as i64,
4406            resolved.status,
4407            resolved.target_node,
4408            resolved.target_file,
4409            resolved.target_symbol,
4410            PROVENANCE_TREESITTER,
4411        ],
4412    )?;
4413    if let Some(edge) = &resolved.edge {
4414        tx.execute(
4415            "INSERT OR REPLACE INTO edges(
4416                edge_id, ref_id, source_node, target_node, target_file, target_symbol,
4417                kind, line, provenance
4418            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
4419            params![
4420                edge.edge_id,
4421                raw.ref_id,
4422                edge.source_node,
4423                edge.target_node,
4424                edge.target_file,
4425                edge.target_symbol,
4426                edge.kind,
4427                edge.line as i64,
4428                PROVENANCE_TREESITTER,
4429            ],
4430        )?;
4431    }
4432    Ok(())
4433}
4434
4435fn insert_method_dispatch_edges(
4436    tx: &Transaction<'_>,
4437    project_root: &Path,
4438    caller_files: Option<&BTreeSet<String>>,
4439) -> Result<usize> {
4440    let references = load_name_match_refs(tx, caller_files)?;
4441    if references.is_empty() {
4442        return Ok(0);
4443    }
4444
4445    let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
4446    let mut source_cache: DispatchSourceCache = HashMap::new();
4447    let mut inserted = 0usize;
4448    for reference in references {
4449        let key = (reference.method_name.clone(), reference.lang.clone());
4450        let candidates = match candidates_by_name.entry(key) {
4451            Entry::Occupied(entry) => entry.into_mut(),
4452            Entry::Vacant(entry) => {
4453                let candidates =
4454                    load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
4455                entry.insert(candidates)
4456            }
4457        };
4458
4459        if let Some(receiver_type) =
4460            infer_receiver_type(project_root, &reference, &mut source_cache)
4461        {
4462            let Some(candidate) =
4463                select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
4464            else {
4465                continue;
4466            };
4467            insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
4468            inserted += 1;
4469            continue;
4470        }
4471
4472        if method_name_match_denylisted(&reference.method_name) {
4473            continue;
4474        }
4475
4476        let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
4477            continue;
4478        };
4479        insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
4480        inserted += 1;
4481    }
4482    Ok(inserted)
4483}
4484
4485fn insert_method_dispatch_edges_chunked(
4486    tx: &Transaction<'_>,
4487    project_root: &Path,
4488    caller_files: &BTreeSet<String>,
4489    chunk_size: usize,
4490) -> Result<usize> {
4491    if caller_files.is_empty() {
4492        return Ok(0);
4493    }
4494    if chunk_size == 0 || caller_files.len() <= chunk_size {
4495        return insert_method_dispatch_edges(tx, project_root, Some(caller_files));
4496    }
4497
4498    let mut inserted = 0usize;
4499    let mut batch = BTreeSet::new();
4500    for caller_file in caller_files {
4501        batch.insert(caller_file.clone());
4502        if batch.len() == chunk_size {
4503            inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
4504            batch.clear();
4505        }
4506    }
4507    if !batch.is_empty() {
4508        inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
4509    }
4510    Ok(inserted)
4511}
4512
4513fn insert_method_dispatch_edge(
4514    tx: &Transaction<'_>,
4515    reference: &NameMatchRef,
4516    candidate: &NameMatchCandidate,
4517    provenance: &str,
4518) -> Result<()> {
4519    tx.execute(
4520        "INSERT OR REPLACE INTO edges(
4521            edge_id, ref_id, source_node, target_node, target_file, target_symbol,
4522            kind, line, provenance
4523        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
4524        params![
4525            ref_id(&[&reference.ref_id, provenance, "edge"]),
4526            &reference.ref_id,
4527            &reference.caller_node,
4528            &candidate.node_id,
4529            &candidate.file_path,
4530            &candidate.scoped_name,
4531            reference.line as i64,
4532            provenance,
4533        ],
4534    )?;
4535    Ok(())
4536}
4537
4538fn delete_method_dispatch_edges_for_callers(
4539    tx: &Transaction<'_>,
4540    caller_files: &BTreeSet<String>,
4541) -> Result<()> {
4542    if caller_files.is_empty() {
4543        return Ok(());
4544    }
4545
4546    let mut stmt = tx.prepare(
4547        "DELETE FROM edges
4548         WHERE provenance IN (?1, ?2)
4549           AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
4550    )?;
4551    for caller_file in caller_files {
4552        stmt.execute(params![
4553            PROVENANCE_NAME_MATCH,
4554            PROVENANCE_TYPE_MATCH,
4555            caller_file
4556        ])?;
4557    }
4558    Ok(())
4559}
4560
4561fn load_name_match_refs(
4562    tx: &Transaction<'_>,
4563    caller_files: Option<&BTreeSet<String>>,
4564) -> Result<Vec<NameMatchRef>> {
4565    let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
4566                           n.signature, r.short_name, r.full_ref, r.line, f.lang
4567                    FROM refs r
4568                    JOIN files f ON f.path = r.caller_file
4569                    JOIN nodes n ON n.id = r.caller_node
4570                    WHERE r.kind = 'call'
4571                      AND r.status = 'unresolved'
4572                      AND r.caller_node IS NOT NULL
4573                      AND r.full_ref IS NOT NULL
4574                      AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
4575                      AND NOT EXISTS (
4576                          SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
4577                      )";
4578    let mut references = Vec::new();
4579
4580    if let Some(caller_files) = caller_files {
4581        if caller_files.is_empty() {
4582            return Ok(references);
4583        }
4584        let sql = format!(
4585            "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
4586        );
4587        let mut stmt = tx.prepare(&sql)?;
4588        for caller_file in caller_files {
4589            let rows = stmt.query_map(params![caller_file], |row| {
4590                Ok((
4591                    row.get::<_, String>(0)?,
4592                    row.get::<_, Option<String>>(1)?,
4593                    row.get::<_, String>(2)?,
4594                    row.get::<_, String>(3)?,
4595                    row.get::<_, Option<String>>(4)?,
4596                    row.get::<_, Option<String>>(5)?,
4597                    row.get::<_, Option<String>>(6)?,
4598                    row.get::<_, i64>(7)?,
4599                    row.get::<_, String>(8)?,
4600                ))
4601            })?;
4602            for row in rows {
4603                let (
4604                    ref_id,
4605                    caller_node,
4606                    caller_file,
4607                    caller_symbol,
4608                    caller_signature,
4609                    short_name,
4610                    full_ref,
4611                    line,
4612                    lang,
4613                ) = row?;
4614                if let Some(reference) = name_match_ref_from_parts(
4615                    ref_id,
4616                    caller_node,
4617                    caller_file,
4618                    caller_symbol,
4619                    caller_signature,
4620                    short_name,
4621                    full_ref,
4622                    line,
4623                    lang,
4624                ) {
4625                    references.push(reference);
4626                }
4627            }
4628        }
4629        return Ok(references);
4630    }
4631
4632    let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
4633    let mut stmt = tx.prepare(&sql)?;
4634    let rows = stmt.query_map([], |row| {
4635        Ok((
4636            row.get::<_, String>(0)?,
4637            row.get::<_, Option<String>>(1)?,
4638            row.get::<_, String>(2)?,
4639            row.get::<_, String>(3)?,
4640            row.get::<_, Option<String>>(4)?,
4641            row.get::<_, Option<String>>(5)?,
4642            row.get::<_, Option<String>>(6)?,
4643            row.get::<_, i64>(7)?,
4644            row.get::<_, String>(8)?,
4645        ))
4646    })?;
4647    for row in rows {
4648        let (
4649            ref_id,
4650            caller_node,
4651            caller_file,
4652            caller_symbol,
4653            caller_signature,
4654            short_name,
4655            full_ref,
4656            line,
4657            lang,
4658        ) = row?;
4659        if let Some(reference) = name_match_ref_from_parts(
4660            ref_id,
4661            caller_node,
4662            caller_file,
4663            caller_symbol,
4664            caller_signature,
4665            short_name,
4666            full_ref,
4667            line,
4668            lang,
4669        ) {
4670            references.push(reference);
4671        }
4672    }
4673    Ok(references)
4674}
4675
4676#[allow(clippy::too_many_arguments)]
4677fn name_match_ref_from_parts(
4678    ref_id: String,
4679    caller_node: Option<String>,
4680    caller_file: String,
4681    caller_symbol: String,
4682    caller_signature: Option<String>,
4683    short_name: Option<String>,
4684    full_ref: Option<String>,
4685    line: i64,
4686    lang: String,
4687) -> Option<NameMatchRef> {
4688    let caller_node = caller_node?;
4689    let full_ref = full_ref?;
4690    let (receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
4691    let method_name = if member.is_empty() {
4692        short_name.as_deref()?.to_string()
4693    } else {
4694        member
4695    };
4696    Some(NameMatchRef {
4697        ref_id,
4698        caller_node,
4699        caller_file,
4700        caller_symbol,
4701        caller_signature,
4702        receiver,
4703        method_name,
4704        colon_dispatch,
4705        line: line.max(0) as u32,
4706        lang,
4707    })
4708}
4709
4710fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, bool)> {
4711    let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
4712    let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
4713    let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
4714    let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
4715        .into_iter()
4716        .flatten()
4717        .max_by_key(|(index, _, _)| *index)?;
4718    if delimiter == 0 {
4719        return None;
4720    }
4721    let member_start = delimiter + delimiter_len;
4722    if member_start >= full_ref.len() {
4723        return None;
4724    }
4725    let receiver = last_name_segment(&full_ref[..delimiter]);
4726    let member = &full_ref[member_start..];
4727    if receiver.is_empty() || member.is_empty() {
4728        return None;
4729    }
4730    Some((receiver.to_string(), member.to_string(), colon_dispatch))
4731}
4732
4733fn last_name_segment(value: &str) -> &str {
4734    value
4735        .rsplit(['.', ':', '/', '\\', '-', '>'])
4736        .find(|segment| !segment.is_empty())
4737        .unwrap_or(value)
4738}
4739
4740fn load_name_match_candidates(
4741    tx: &Transaction<'_>,
4742    method_name: &str,
4743    lang: &str,
4744) -> Result<Vec<NameMatchCandidate>> {
4745    let mut stmt = tx.prepare(
4746        "SELECT n.id, n.file_path, n.scoped_name, n.kind
4747         FROM nodes n JOIN files f ON f.path = n.file_path
4748         WHERE n.name = ?1
4749           AND f.lang = ?2
4750           AND n.kind IN ('method', 'function')
4751         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
4752    )?;
4753    let rows = stmt.query_map(params![method_name, lang], |row| {
4754        Ok(NameMatchCandidate {
4755            node_id: row.get(0)?,
4756            file_path: row.get(1)?,
4757            scoped_name: row.get(2)?,
4758            kind: row.get(3)?,
4759        })
4760    })?;
4761    rows.collect::<std::result::Result<Vec<_>, _>>()
4762        .map_err(Into::into)
4763}
4764
4765struct ParsedDispatchSource {
4766    source: String,
4767    tree: tree_sitter::Tree,
4768}
4769
4770type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
4771
4772fn infer_receiver_type(
4773    project_root: &Path,
4774    reference: &NameMatchRef,
4775    source_cache: &mut DispatchSourceCache,
4776) -> Option<String> {
4777    match reference.lang.as_str() {
4778        "rust" => infer_rust_receiver_type(reference),
4779        "java" => {
4780            infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
4781        }
4782        "kotlin" => {
4783            infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
4784        }
4785        "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache),
4786        _ => None,
4787    }
4788}
4789
4790fn parse_dispatch_source(
4791    project_root: &Path,
4792    caller_file: &str,
4793    lang: LangId,
4794) -> Option<ParsedDispatchSource> {
4795    let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
4796    let grammar = crate::parser::grammar_for(lang);
4797    let mut parser = tree_sitter::Parser::new();
4798    parser.set_language(&grammar).ok()?;
4799    let tree = parser.parse(&source, None)?;
4800    Some(ParsedDispatchSource { source, tree })
4801}
4802
4803fn parsed_dispatch_source<'a>(
4804    project_root: &Path,
4805    reference: &NameMatchRef,
4806    lang: LangId,
4807    source_cache: &'a mut DispatchSourceCache,
4808) -> Option<&'a ParsedDispatchSource> {
4809    let key = (reference.caller_file.clone(), reference.lang.clone());
4810    source_cache
4811        .entry(key)
4812        .or_insert_with(|| parse_dispatch_source(project_root, &reference.caller_file, lang))
4813        .as_ref()
4814}
4815
4816fn infer_java_like_receiver_type(
4817    project_root: &Path,
4818    reference: &NameMatchRef,
4819    lang: LangId,
4820    source_cache: &mut DispatchSourceCache,
4821) -> Option<String> {
4822    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
4823        return None;
4824    }
4825
4826    let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
4827    let root = parsed.tree.root_node();
4828    let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
4829
4830    let callable_scope = type_node
4831        .and_then(|node| {
4832            find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
4833        })
4834        .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
4835
4836    if let Some(callable_scope) = callable_scope {
4837        if let Some(receiver_type) = infer_java_like_local_receiver_type(
4838            callable_scope,
4839            &parsed.source,
4840            &reference.receiver,
4841            reference.line.max(1),
4842            lang,
4843        ) {
4844            return Some(receiver_type);
4845        }
4846    }
4847
4848    type_node.and_then(|node| {
4849        infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
4850    })
4851}
4852
4853fn infer_cpp_receiver_type(
4854    project_root: &Path,
4855    reference: &NameMatchRef,
4856    source_cache: &mut DispatchSourceCache,
4857) -> Option<String> {
4858    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
4859        return None;
4860    }
4861
4862    let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
4863    let root = parsed.tree.root_node();
4864    let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
4865    infer_cpp_receiver_type_from_scope(
4866        scope,
4867        &parsed.source,
4868        &reference.receiver,
4869        reference.line.max(1),
4870    )
4871}
4872
4873fn find_enclosing_java_like_type_node<'tree>(
4874    root: tree_sitter::Node<'tree>,
4875    source: &str,
4876    reference: &NameMatchRef,
4877    lang: LangId,
4878) -> Option<tree_sitter::Node<'tree>> {
4879    let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
4880        .and_then(|name| simple_type_name(&name));
4881    let line = reference.line.max(1);
4882    let mut best = None;
4883    let mut stack = vec![root];
4884    while let Some(node) = stack.pop() {
4885        if !node_contains_line(node, line) {
4886            continue;
4887        }
4888        if is_java_like_type_kind(node.kind(), lang) {
4889            let name = declaration_name(node, source);
4890            if expected_type
4891                .as_deref()
4892                .is_none_or(|expected| name == Some(expected))
4893            {
4894                best = tighter_node(best, node);
4895            }
4896        }
4897        push_named_children(node, &mut stack);
4898    }
4899    best
4900}
4901
4902fn find_enclosing_java_like_callable_node<'tree>(
4903    root: tree_sitter::Node<'tree>,
4904    source: &str,
4905    reference: &NameMatchRef,
4906    lang: LangId,
4907) -> Option<tree_sitter::Node<'tree>> {
4908    let expected_name = reference.caller_symbol.rsplit("::").next();
4909    let line = reference.line.max(1);
4910    let mut best = None;
4911    let mut stack = vec![root];
4912    while let Some(node) = stack.pop() {
4913        if !node_contains_line(node, line) {
4914            continue;
4915        }
4916        if is_java_like_callable_kind(node.kind(), lang) {
4917            let name = declaration_name(node, source);
4918            if expected_name.is_none_or(|expected| name == Some(expected)) {
4919                best = tighter_node(best, node);
4920            }
4921        }
4922        push_named_children(node, &mut stack);
4923    }
4924    best
4925}
4926
4927fn find_enclosing_cpp_callable_node<'tree>(
4928    root: tree_sitter::Node<'tree>,
4929    _source: &str,
4930    reference: &NameMatchRef,
4931) -> Option<tree_sitter::Node<'tree>> {
4932    let line = reference.line.max(1);
4933    let mut best = None;
4934    let mut stack = vec![root];
4935    while let Some(node) = stack.pop() {
4936        if !node_contains_line(node, line) {
4937            continue;
4938        }
4939        if node.kind() == "function_definition" {
4940            best = tighter_node(best, node);
4941        }
4942        push_named_children(node, &mut stack);
4943    }
4944    best
4945}
4946
4947fn tighter_node<'tree>(
4948    current: Option<tree_sitter::Node<'tree>>,
4949    candidate: tree_sitter::Node<'tree>,
4950) -> Option<tree_sitter::Node<'tree>> {
4951    match current {
4952        Some(current)
4953            if current.start_byte() > candidate.start_byte()
4954                || (current.start_byte() == candidate.start_byte()
4955                    && current.end_byte() <= candidate.end_byte()) =>
4956        {
4957            Some(current)
4958        }
4959        _ => Some(candidate),
4960    }
4961}
4962
4963fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
4964    let start = node.start_position().row as u32 + 1;
4965    let end = node.end_position().row as u32 + 1;
4966    start <= line && line <= end
4967}
4968
4969fn push_named_children<'tree>(
4970    node: tree_sitter::Node<'tree>,
4971    stack: &mut Vec<tree_sitter::Node<'tree>>,
4972) {
4973    for index in 0..node.named_child_count() {
4974        if let Some(child) = node.named_child(index as u32) {
4975            stack.push(child);
4976        }
4977    }
4978}
4979
4980fn declaration_name<'source>(
4981    node: tree_sitter::Node<'_>,
4982    source: &'source str,
4983) -> Option<&'source str> {
4984    node.child_by_field_name("name")
4985        .map(|name| node_text(name, source))
4986        .or_else(|| {
4987            first_named_child_text(
4988                node,
4989                source,
4990                &["identifier", "type_identifier", "simple_identifier"],
4991            )
4992        })
4993}
4994
4995fn first_named_child_text<'source>(
4996    node: tree_sitter::Node<'_>,
4997    source: &'source str,
4998    kinds: &[&str],
4999) -> Option<&'source str> {
5000    for index in 0..node.named_child_count() {
5001        let child = node.named_child(index as u32)?;
5002        if kinds.contains(&child.kind()) {
5003            return Some(node_text(child, source));
5004        }
5005    }
5006    None
5007}
5008
5009fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
5010    &source[node.byte_range()]
5011}
5012
5013fn infer_java_like_field_receiver_type(
5014    type_node: tree_sitter::Node<'_>,
5015    source: &str,
5016    receiver: &str,
5017    lang: LangId,
5018) -> Option<String> {
5019    let mut stack = Vec::new();
5020    push_named_children(type_node, &mut stack);
5021    while let Some(node) = stack.pop() {
5022        if is_java_like_field_kind(node.kind(), lang) {
5023            if let Some(receiver_type) =
5024                extract_java_like_declared_type(node_text(node, source), receiver, lang)
5025            {
5026                return Some(receiver_type);
5027            }
5028        }
5029        if is_java_like_type_kind(node.kind(), lang)
5030            || is_java_like_callable_kind(node.kind(), lang)
5031        {
5032            continue;
5033        }
5034        push_named_children(node, &mut stack);
5035    }
5036    None
5037}
5038
5039fn infer_java_like_local_receiver_type(
5040    callable_node: tree_sitter::Node<'_>,
5041    source: &str,
5042    receiver: &str,
5043    call_line: u32,
5044    lang: LangId,
5045) -> Option<String> {
5046    let mut best: Option<(u32, String)> = None;
5047    let mut stack = Vec::new();
5048    push_named_children(callable_node, &mut stack);
5049    while let Some(node) = stack.pop() {
5050        let start_line = node.start_position().row as u32 + 1;
5051        if start_line > call_line {
5052            continue;
5053        }
5054        if is_java_like_local_kind(node.kind(), lang) {
5055            if let Some(receiver_type) =
5056                extract_java_like_declared_type(node_text(node, source), receiver, lang)
5057            {
5058                if best
5059                    .as_ref()
5060                    .is_none_or(|(best_line, _)| start_line >= *best_line)
5061                {
5062                    best = Some((start_line, receiver_type));
5063                }
5064            }
5065        }
5066        if is_java_like_type_kind(node.kind(), lang)
5067            || is_java_like_callable_kind(node.kind(), lang)
5068        {
5069            continue;
5070        }
5071        push_named_children(node, &mut stack);
5072    }
5073    best.map(|(_, receiver_type)| receiver_type)
5074}
5075
5076fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
5077    match lang {
5078        LangId::Java => matches!(
5079            kind,
5080            "class_declaration"
5081                | "interface_declaration"
5082                | "enum_declaration"
5083                | "record_declaration"
5084                | "annotation_type_declaration"
5085        ),
5086        LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
5087        _ => false,
5088    }
5089}
5090
5091fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
5092    match lang {
5093        LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
5094        LangId::Kotlin => kind == "function_declaration",
5095        _ => false,
5096    }
5097}
5098
5099fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
5100    match lang {
5101        LangId::Java => kind == "field_declaration",
5102        LangId::Kotlin => kind == "property_declaration",
5103        _ => false,
5104    }
5105}
5106
5107fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
5108    match lang {
5109        LangId::Java => kind == "local_variable_declaration",
5110        LangId::Kotlin => kind == "property_declaration",
5111        _ => false,
5112    }
5113}
5114
5115fn extract_java_like_declared_type(
5116    declaration: &str,
5117    receiver: &str,
5118    lang: LangId,
5119) -> Option<String> {
5120    match lang {
5121        LangId::Java => extract_java_declared_type(declaration, receiver),
5122        LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
5123        _ => None,
5124    }
5125}
5126
5127fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
5128    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
5129    let after = declaration[receiver_start + receiver.len()..].trim_start();
5130    if after
5131        .chars()
5132        .next()
5133        .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
5134    {
5135        return None;
5136    }
5137
5138    let before = declaration[..receiver_start].trim_end();
5139    if before.contains(',') {
5140        return None;
5141    }
5142    normalize_receiver_type_name(strip_java_declaration_prefixes(before))
5143}
5144
5145fn strip_java_declaration_prefixes(mut value: &str) -> &str {
5146    loop {
5147        value = value.trim_start();
5148        if let Some(stripped) = strip_leading_java_annotation(value) {
5149            value = stripped;
5150            continue;
5151        }
5152        if let Some(stripped) = strip_leading_java_modifier(value) {
5153            value = stripped;
5154            continue;
5155        }
5156        return value.trim();
5157    }
5158}
5159
5160fn strip_leading_java_annotation(value: &str) -> Option<&str> {
5161    let value = value.trim_start();
5162    let mut chars = value.char_indices();
5163    let (_, first) = chars.next()?;
5164    if first != '@' {
5165        return None;
5166    }
5167    let mut end = first.len_utf8();
5168    for (index, ch) in chars {
5169        if !(is_code_ident_char(ch) || ch == '.') {
5170            end = index;
5171            break;
5172        }
5173        end = index + ch.len_utf8();
5174    }
5175    let rest = value[end..].trim_start();
5176    if let Some(stripped) = rest.strip_prefix('(') {
5177        let mut depth = 1usize;
5178        for (index, ch) in stripped.char_indices() {
5179            match ch {
5180                '(' => depth += 1,
5181                ')' => {
5182                    depth = depth.saturating_sub(1);
5183                    if depth == 0 {
5184                        return Some(stripped[index + ch.len_utf8()..].trim_start());
5185                    }
5186                }
5187                _ => {}
5188            }
5189        }
5190        return Some("");
5191    }
5192    Some(rest)
5193}
5194
5195fn strip_leading_java_modifier(value: &str) -> Option<&str> {
5196    const MODIFIERS: &[&str] = &[
5197        "public",
5198        "protected",
5199        "private",
5200        "abstract",
5201        "static",
5202        "final",
5203        "transient",
5204        "volatile",
5205        "synchronized",
5206        "native",
5207        "strictfp",
5208    ];
5209    MODIFIERS
5210        .iter()
5211        .find_map(|modifier| strip_leading_word(value, modifier))
5212}
5213
5214fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
5215    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
5216    let before = &declaration[..receiver_start];
5217    if find_identifier_occurrence(before, "val").is_none()
5218        && find_identifier_occurrence(before, "var").is_none()
5219    {
5220        return None;
5221    }
5222
5223    let after = declaration[receiver_start + receiver.len()..].trim_start();
5224    if let Some(type_text) = after.strip_prefix(':') {
5225        return normalize_receiver_type_name(read_type_prefix(type_text));
5226    }
5227    after
5228        .strip_prefix('=')
5229        .and_then(infer_kotlin_constructor_type)
5230}
5231
5232fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
5233    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
5234    if rest.trim_start().starts_with('(') {
5235        normalize_receiver_type_name(head)
5236    } else {
5237        None
5238    }
5239}
5240
5241fn read_type_prefix(value: &str) -> &str {
5242    let mut angle_depth = 0usize;
5243    for (index, ch) in value.char_indices() {
5244        match ch {
5245            '<' => angle_depth += 1,
5246            '>' => angle_depth = angle_depth.saturating_sub(1),
5247            '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
5248                return value[..index].trim();
5249            }
5250            _ => {}
5251        }
5252    }
5253    value.trim()
5254}
5255
5256fn infer_cpp_receiver_type_from_scope(
5257    scope: tree_sitter::Node<'_>,
5258    source: &str,
5259    receiver: &str,
5260    call_line: u32,
5261) -> Option<String> {
5262    let lines = source.lines().collect::<Vec<_>>();
5263    if lines.is_empty() {
5264        return None;
5265    }
5266    let scope_start = scope.start_position().row as usize;
5267    let call_index = (call_line as usize)
5268        .saturating_sub(1)
5269        .min(lines.len().saturating_sub(1));
5270    for index in (scope_start..=call_index).rev() {
5271        if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
5272            return Some(receiver_type);
5273        }
5274    }
5275    None
5276}
5277
5278fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
5279    for receiver_start in identifier_occurrences(line, receiver) {
5280        let after = line[receiver_start + receiver.len()..].trim_start();
5281        if after
5282            .chars()
5283            .next()
5284            .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
5285        {
5286            continue;
5287        }
5288        let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
5289        let normalized = normalize_cpp_type_name(type_text)?;
5290        if normalized == "auto" {
5291            if let Some(rhs) = after.strip_prefix('=') {
5292                return infer_cpp_auto_receiver_type(rhs);
5293            }
5294            continue;
5295        }
5296        return Some(normalized);
5297    }
5298    None
5299}
5300
5301fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
5302    let candidate = prefix
5303        .rsplit([';', '{', '}', '('])
5304        .next()
5305        .unwrap_or(prefix)
5306        .trim();
5307    if candidate.is_empty() || candidate.ends_with(',') {
5308        None
5309    } else {
5310        Some(candidate)
5311    }
5312}
5313
5314fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
5315    let without_templates = strip_angle_groups(type_text);
5316    let mut cleaned = String::with_capacity(without_templates.len());
5317    for token in without_templates.split_whitespace() {
5318        if matches!(
5319            token,
5320            "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
5321        ) {
5322            continue;
5323        }
5324        if !cleaned.is_empty() {
5325            cleaned.push(' ');
5326        }
5327        cleaned.push_str(token);
5328    }
5329    let token = cleaned
5330        .split_whitespace()
5331        .last()
5332        .unwrap_or(cleaned.trim())
5333        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
5334        .trim_matches(['*', '&']);
5335    let simple = token.rsplit("::").next().unwrap_or(token).trim();
5336    if simple.is_empty() || cpp_non_type_token(simple) {
5337        None
5338    } else {
5339        Some(simple.to_string())
5340    }
5341}
5342
5343fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
5344    let rhs = rhs.trim_start();
5345    if let Some(after_new) = rhs.strip_prefix("new ") {
5346        return infer_cpp_constructor_type(after_new);
5347    }
5348    infer_cpp_make_template_type(rhs)
5349        .or_else(|| infer_cpp_constructor_type(rhs))
5350        .or_else(|| infer_cpp_factory_type(rhs))
5351}
5352
5353fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
5354    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
5355    let normalized = normalize_cpp_type_name(head)?;
5356    if !normalized
5357        .chars()
5358        .next()
5359        .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
5360    {
5361        return None;
5362    }
5363    if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
5364        Some(normalized)
5365    } else {
5366        None
5367    }
5368}
5369
5370fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
5371    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
5372    if !rest.trim_start().starts_with('(') {
5373        return None;
5374    }
5375    let base = head.split('<').next().unwrap_or(head);
5376    let base_simple = base.rsplit("::").next().unwrap_or(base);
5377    if !matches!(base_simple, "make_unique" | "make_shared") {
5378        return None;
5379    }
5380    first_angle_arg(head).and_then(normalize_cpp_type_name)
5381}
5382
5383fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
5384    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
5385    if !rest.trim_start().starts_with('(') {
5386        return None;
5387    }
5388    let simple = head
5389        .split('<')
5390        .next()
5391        .unwrap_or(head)
5392        .rsplit("::")
5393        .next()
5394        .unwrap_or(head);
5395    for prefix in ["make", "create", "build"] {
5396        if let Some(suffix) = simple.strip_prefix(prefix) {
5397            if suffix
5398                .chars()
5399                .next()
5400                .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
5401            {
5402                return normalize_cpp_type_name(suffix);
5403            }
5404        }
5405    }
5406    None
5407}
5408
5409#[derive(Debug, Clone, Copy)]
5410enum JavaLikeInvocation {
5411    Kotlin,
5412    Cpp,
5413}
5414
5415fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
5416    let value = value.trim_start();
5417    let mut end = 0usize;
5418    for (index, ch) in value.char_indices() {
5419        let allowed_separator = match flavor {
5420            JavaLikeInvocation::Kotlin => ch == '.',
5421            JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
5422        };
5423        if is_code_ident_char(ch) || allowed_separator {
5424            end = index + ch.len_utf8();
5425            continue;
5426        }
5427        break;
5428    }
5429    if end == 0 {
5430        return None;
5431    }
5432    let mut rest = &value[end..];
5433    if let Some(stripped) = rest.trim_start().strip_prefix('<') {
5434        let skipped = skip_balanced_angle(stripped)?;
5435        let rest_start = rest.len() - rest.trim_start().len();
5436        let angle_len = 1 + skipped;
5437        end += rest_start + angle_len;
5438        rest = &value[end..];
5439    }
5440    Some((value[..end].trim(), rest))
5441}
5442
5443fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
5444    let mut depth = 1usize;
5445    for (index, ch) in value_after_open.char_indices() {
5446        match ch {
5447            '<' => depth += 1,
5448            '>' => {
5449                depth = depth.saturating_sub(1);
5450                if depth == 0 {
5451                    return Some(index + ch.len_utf8());
5452                }
5453            }
5454            _ => {}
5455        }
5456    }
5457    None
5458}
5459
5460fn first_angle_arg(value: &str) -> Option<&str> {
5461    let open = value.find('<')?;
5462    let inner_len = skip_balanced_angle(&value[open + 1..])?;
5463    let inner = &value[open + 1..open + inner_len];
5464    split_top_level_commas(inner).into_iter().next()
5465}
5466
5467fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
5468    let without_generics = strip_angle_groups(type_text);
5469    let cleaned = without_generics
5470        .replace("[]", " ")
5471        .replace("...", " ")
5472        .replace(['?', '&', '*'], " ");
5473    let token = cleaned
5474        .split_whitespace()
5475        .last()
5476        .unwrap_or(cleaned.trim())
5477        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
5478    let token = token.rsplit("::").next().unwrap_or(token);
5479    let simple = token.rsplit('.').next().unwrap_or(token).trim();
5480    if simple.is_empty()
5481        || java_like_primitive_type(simple)
5482        || !simple
5483            .chars()
5484            .next()
5485            .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
5486    {
5487        None
5488    } else {
5489        Some(simple.to_string())
5490    }
5491}
5492
5493fn simple_type_name(scoped_name: &str) -> Option<String> {
5494    scoped_name
5495        .rsplit("::")
5496        .find(|segment| !segment.is_empty())
5497        .and_then(normalize_receiver_type_name)
5498}
5499
5500fn strip_angle_groups(value: &str) -> String {
5501    let mut output = String::with_capacity(value.len());
5502    let mut depth = 0usize;
5503    for ch in value.chars() {
5504        match ch {
5505            '<' => {
5506                if depth == 0 {
5507                    output.push(' ');
5508                }
5509                depth += 1;
5510            }
5511            '>' => depth = depth.saturating_sub(1),
5512            _ if depth == 0 => output.push(ch),
5513            _ => {}
5514        }
5515    }
5516    output
5517}
5518
5519fn java_like_primitive_type(value: &str) -> bool {
5520    matches!(
5521        value,
5522        "boolean"
5523            | "byte"
5524            | "char"
5525            | "double"
5526            | "float"
5527            | "int"
5528            | "long"
5529            | "short"
5530            | "void"
5531            | "Boolean"
5532            | "Byte"
5533            | "Char"
5534            | "Double"
5535            | "Float"
5536            | "Int"
5537            | "Long"
5538            | "Short"
5539            | "Unit"
5540    )
5541}
5542
5543fn cpp_non_type_token(value: &str) -> bool {
5544    matches!(
5545        value,
5546        "return"
5547            | "if"
5548            | "else"
5549            | "for"
5550            | "while"
5551            | "do"
5552            | "switch"
5553            | "case"
5554            | "default"
5555            | "break"
5556            | "continue"
5557            | "goto"
5558            | "throw"
5559            | "new"
5560            | "delete"
5561            | "co_await"
5562            | "co_yield"
5563            | "co_return"
5564            | "static_cast"
5565            | "const_cast"
5566            | "dynamic_cast"
5567            | "reinterpret_cast"
5568            | "sizeof"
5569            | "alignof"
5570            | "typeid"
5571            | "and"
5572            | "or"
5573            | "not"
5574            | "xor"
5575    )
5576}
5577
5578fn receiver_is_bare_identifier(value: &str) -> bool {
5579    let mut chars = value.chars();
5580    let Some(first) = chars.next() else {
5581        return false;
5582    };
5583    (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
5584}
5585
5586fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
5587    identifier_occurrences(value, needle).into_iter().next()
5588}
5589
5590fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
5591    value
5592        .match_indices(needle)
5593        .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
5594        .collect()
5595}
5596
5597fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
5598    let before = value[..start].chars().next_back();
5599    let after = value[start + len..].chars().next();
5600    !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
5601}
5602
5603fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
5604    let stripped = value.strip_prefix(word)?;
5605    if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
5606        Some(stripped.trim_start())
5607    } else {
5608        None
5609    }
5610}
5611
5612fn is_code_ident_char(ch: char) -> bool {
5613    ch == '_' || ch.is_ascii_alphanumeric()
5614}
5615
5616fn infer_rust_receiver_type(reference: &NameMatchRef) -> Option<String> {
5617    if matches!(reference.receiver.as_str(), "self" | "Self") {
5618        return enclosing_type_from_scoped_name(&reference.caller_symbol);
5619    }
5620
5621    if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
5622        return Some(reference.receiver.clone());
5623    }
5624
5625    reference
5626        .caller_signature
5627        .as_deref()
5628        .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
5629}
5630
5631fn rust_receiver_looks_type_like(receiver: &str) -> bool {
5632    receiver
5633        .chars()
5634        .next()
5635        .is_some_and(|ch| ch == '_' || ch.is_uppercase())
5636}
5637
5638fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
5639    scoped_name
5640        .rsplit_once("::")
5641        .map(|(enclosing, _)| enclosing)
5642        .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
5643        .map(ToString::to_string)
5644}
5645
5646fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
5647    let params = signature_parameter_text(signature)?;
5648    for param in split_top_level_commas(params) {
5649        let Some((pattern, type_text)) = param.split_once(':') else {
5650            continue;
5651        };
5652        let Some(name) = rust_parameter_name(pattern) else {
5653            continue;
5654        };
5655        if name == receiver {
5656            return normalize_rust_receiver_type(type_text);
5657        }
5658    }
5659    None
5660}
5661
5662fn signature_parameter_text(signature: &str) -> Option<&str> {
5663    let open = signature.find('(')?;
5664    let mut depth = 0usize;
5665    for (offset, ch) in signature[open..].char_indices() {
5666        match ch {
5667            '(' => depth += 1,
5668            ')' => {
5669                depth = depth.saturating_sub(1);
5670                if depth == 0 {
5671                    return Some(&signature[open + 1..open + offset]);
5672                }
5673            }
5674            _ => {}
5675        }
5676    }
5677    None
5678}
5679
5680fn split_top_level_commas(value: &str) -> Vec<&str> {
5681    let mut parts = Vec::new();
5682    let mut start = 0usize;
5683    let mut angle_depth = 0usize;
5684    let mut paren_depth = 0usize;
5685    let mut bracket_depth = 0usize;
5686    for (index, ch) in value.char_indices() {
5687        match ch {
5688            '<' => angle_depth += 1,
5689            '>' => angle_depth = angle_depth.saturating_sub(1),
5690            '(' => paren_depth += 1,
5691            ')' => paren_depth = paren_depth.saturating_sub(1),
5692            '[' => bracket_depth += 1,
5693            ']' => bracket_depth = bracket_depth.saturating_sub(1),
5694            ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
5695                let part = value[start..index].trim();
5696                if !part.is_empty() {
5697                    parts.push(part);
5698                }
5699                start = index + ch.len_utf8();
5700            }
5701            _ => {}
5702        }
5703    }
5704    let part = value[start..].trim();
5705    if !part.is_empty() {
5706        parts.push(part);
5707    }
5708    parts
5709}
5710
5711fn rust_parameter_name(pattern: &str) -> Option<&str> {
5712    let mut pattern = pattern.trim();
5713    if let Some(stripped) = pattern.strip_prefix("mut ") {
5714        pattern = stripped.trim_start();
5715    }
5716    pattern
5717        .rsplit(|ch: char| !is_rust_ident_char(ch))
5718        .find(|part| !part.is_empty())
5719}
5720
5721fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
5722    let mut ty = strip_leading_rust_type_modifiers(type_text);
5723    let owned_inner;
5724    if let Some(inner) = single_outer_generic_arg(ty) {
5725        owned_inner = inner.trim().to_string();
5726        ty = strip_leading_rust_type_modifiers(&owned_inner);
5727    }
5728    rust_base_type_ident(ty)
5729}
5730
5731fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
5732    loop {
5733        ty = ty.trim_start();
5734        if let Some(stripped) = ty.strip_prefix('&') {
5735            ty = stripped.trim_start();
5736            if let Some(stripped) = strip_leading_lifetime(ty) {
5737                ty = stripped.trim_start();
5738            }
5739            if let Some(stripped) = ty.strip_prefix("mut ") {
5740                ty = stripped.trim_start();
5741            }
5742            continue;
5743        }
5744        if let Some(stripped) = ty.strip_prefix("mut ") {
5745            ty = stripped.trim_start();
5746            continue;
5747        }
5748        if let Some(stripped) = ty.strip_prefix("dyn ") {
5749            ty = stripped.trim_start();
5750            continue;
5751        }
5752        if let Some(stripped) = ty.strip_prefix("impl ") {
5753            ty = stripped.trim_start();
5754            continue;
5755        }
5756        break ty.trim();
5757    }
5758}
5759
5760fn strip_leading_lifetime(value: &str) -> Option<&str> {
5761    let mut chars = value.char_indices();
5762    let (_, first) = chars.next()?;
5763    if first != '\'' {
5764        return None;
5765    }
5766    for (index, ch) in chars {
5767        if !(ch == '_' || ch.is_ascii_alphanumeric()) {
5768            return Some(&value[index..]);
5769        }
5770    }
5771    Some("")
5772}
5773
5774fn single_outer_generic_arg(ty: &str) -> Option<&str> {
5775    let ty = ty.trim();
5776    let open = ty.find('<')?;
5777    let mut depth = 0usize;
5778    let mut close = None;
5779    for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
5780        match ch {
5781            '<' => depth += 1,
5782            '>' => {
5783                depth = depth.saturating_sub(1);
5784                if depth == 0 {
5785                    close = Some(index);
5786                    break;
5787                }
5788            }
5789            _ => {}
5790        }
5791    }
5792    let close = close?;
5793    if !ty[close + 1..].trim().is_empty() {
5794        return None;
5795    }
5796    let inner = &ty[open + 1..close];
5797    let args = split_top_level_commas(inner);
5798    match args.as_slice() {
5799        [arg] => Some(*arg),
5800        _ => None,
5801    }
5802}
5803
5804fn rust_base_type_ident(ty: &str) -> Option<String> {
5805    let ty = ty.trim();
5806    let head = ty
5807        .split([' ', '+', '='])
5808        .find(|part| !part.is_empty())
5809        .unwrap_or(ty);
5810    let head = head.split('<').next().unwrap_or(head).trim();
5811    let ident = head
5812        .rsplit("::")
5813        .next()
5814        .unwrap_or(head)
5815        .trim_matches(|ch: char| !is_rust_ident_char(ch));
5816    if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
5817        None
5818    } else {
5819        Some(ident.to_string())
5820    }
5821}
5822
5823fn is_rust_ident_char(ch: char) -> bool {
5824    ch == '_' || ch.is_ascii_alphanumeric()
5825}
5826
5827fn select_type_match_candidate(
5828    reference: &NameMatchRef,
5829    candidates: &[NameMatchCandidate],
5830    receiver_type: &str,
5831) -> Option<NameMatchCandidate> {
5832    let candidates = candidates
5833        .iter()
5834        .filter(|candidate| candidate.node_id != reference.caller_node)
5835        .filter(|candidate| {
5836            type_candidate_matches(candidate, receiver_type, &reference.method_name)
5837        })
5838        .collect::<Vec<_>>();
5839    match candidates.as_slice() {
5840        [candidate] => Some((**candidate).clone()),
5841        _ => None,
5842    }
5843}
5844
5845fn type_candidate_matches(
5846    candidate: &NameMatchCandidate,
5847    receiver_type: &str,
5848    method_name: &str,
5849) -> bool {
5850    let normalized_type = receiver_type.replace('.', "::");
5851    let suffix = format!("{normalized_type}::{method_name}");
5852    candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
5853}
5854
5855fn select_name_match_candidate(
5856    reference: &NameMatchRef,
5857    candidates: &[NameMatchCandidate],
5858) -> Option<NameMatchCandidate> {
5859    let candidates = candidates
5860        .iter()
5861        .filter(|candidate| candidate.node_id != reference.caller_node)
5862        .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
5863        .collect::<Vec<_>>();
5864    match candidates.as_slice() {
5865        [] => None,
5866        [candidate] => Some((**candidate).clone()),
5867        _ => select_scored_name_match_candidate(reference, &candidates),
5868    }
5869}
5870
5871fn candidate_allowed_for_reference(
5872    reference: &NameMatchRef,
5873    candidate: &NameMatchCandidate,
5874) -> bool {
5875    if !reference.colon_dispatch {
5876        return true;
5877    }
5878
5879    candidate.kind == "method"
5880        && candidate
5881            .scoped_name
5882            .split("::")
5883            .any(|segment| segment == reference.receiver)
5884}
5885
5886fn select_scored_name_match_candidate(
5887    reference: &NameMatchRef,
5888    candidates: &[&NameMatchCandidate],
5889) -> Option<NameMatchCandidate> {
5890    let receiver_words = split_camel_case(&reference.receiver);
5891    if receiver_words.is_empty() {
5892        return None;
5893    }
5894
5895    let mut best: Option<(&NameMatchCandidate, f64)> = None;
5896    let mut tied_best = false;
5897    for candidate in candidates {
5898        let candidate_words = split_camel_case(&candidate.scoped_name);
5899        let overlap = receiver_words
5900            .iter()
5901            .filter(|receiver_word| {
5902                candidate_words
5903                    .iter()
5904                    .any(|candidate_word| candidate_word == *receiver_word)
5905            })
5906            .count() as f64;
5907        let score =
5908            overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
5909        match best {
5910            None => {
5911                best = Some((*candidate, score));
5912                tied_best = false;
5913            }
5914            Some((_, best_score)) if score > best_score => {
5915                best = Some((*candidate, score));
5916                tied_best = false;
5917            }
5918            Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
5919                tied_best = true;
5920            }
5921            _ => {}
5922        }
5923    }
5924
5925    let (candidate, score) = best?;
5926    if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
5927        Some(candidate.clone())
5928    } else {
5929        None
5930    }
5931}
5932
5933fn method_name_match_denylisted(method_name: &str) -> bool {
5934    matches!(
5935        method_name,
5936        "and_then"
5937            | "as_bytes"
5938            | "as_deref"
5939            | "as_mut"
5940            | "as_ref"
5941            | "as_str"
5942            | "borrow"
5943            | "borrow_mut"
5944            | "clear"
5945            | "clone"
5946            | "collect"
5947            | "contains"
5948            | "contains_key"
5949            | "count"
5950            | "dedup"
5951            | "default"
5952            | "drain"
5953            | "ends_with"
5954            | "entry"
5955            | "err"
5956            | "expect"
5957            | "extend"
5958            | "filter"
5959            | "filter_map"
5960            | "find"
5961            | "from"
5962            | "get"
5963            | "get_mut"
5964            | "insert"
5965            | "into"
5966            | "into_iter"
5967            | "is_empty"
5968            | "is_err"
5969            | "is_none"
5970            | "is_ok"
5971            | "is_some"
5972            | "iter"
5973            | "iter_mut"
5974            | "join"
5975            | "len"
5976            | "lock"
5977            | "map"
5978            | "map_err"
5979            | "max"
5980            | "min"
5981            | "new"
5982            | "next"
5983            | "ok"
5984            | "or_default"
5985            | "or_else"
5986            | "or_insert"
5987            | "or_insert_with"
5988            | "parse"
5989            | "pop"
5990            | "position"
5991            | "push"
5992            | "read"
5993            | "recv"
5994            | "remove"
5995            | "replace"
5996            | "retain"
5997            | "send"
5998            | "sort"
5999            | "sort_by"
6000            | "split"
6001            | "starts_with"
6002            | "sum"
6003            | "take"
6004            | "to_owned"
6005            | "to_string"
6006            | "trim"
6007            | "try_from"
6008            | "try_into"
6009            | "unwrap"
6010            | "unwrap_or"
6011            | "unwrap_or_default"
6012            | "unwrap_or_else"
6013            | "with_capacity"
6014            | "write"
6015    )
6016}
6017
6018fn split_camel_case(value: &str) -> Vec<String> {
6019    let chars = value.chars().collect::<Vec<_>>();
6020    let mut normalized = String::with_capacity(value.len() + 8);
6021    for (index, ch) in chars.iter().enumerate() {
6022        let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
6023        let next = chars.get(index + 1);
6024        let is_separator = ch.is_whitespace()
6025            || matches!(
6026                ch,
6027                '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
6028            );
6029        if is_separator {
6030            normalized.push(' ');
6031            continue;
6032        }
6033        let camel_boundary = previous.is_some_and(|prev| {
6034            (prev.is_lowercase() && ch.is_uppercase())
6035                || (prev.is_ascii_digit() && ch.is_alphabetic())
6036                || (prev.is_uppercase()
6037                    && ch.is_uppercase()
6038                    && next.is_some_and(|next| next.is_lowercase()))
6039        });
6040        if camel_boundary {
6041            normalized.push(' ');
6042        }
6043        normalized.push(*ch);
6044    }
6045
6046    normalized
6047        .split_whitespace()
6048        .filter(|word| word.len() > 1)
6049        .map(|word| word.to_ascii_lowercase())
6050        .collect()
6051}
6052
6053fn compute_path_proximity(left: &str, right: &str) -> f64 {
6054    let left_dirs = left
6055        .rsplit_once('/')
6056        .map(|(dir, _)| dir)
6057        .unwrap_or_default()
6058        .split('/')
6059        .filter(|part| !part.is_empty());
6060    let right_dirs = right
6061        .rsplit_once('/')
6062        .map(|(dir, _)| dir)
6063        .unwrap_or_default()
6064        .split('/')
6065        .filter(|part| !part.is_empty());
6066
6067    let shared = left_dirs
6068        .zip(right_dirs)
6069        .take_while(|(left, right)| left == right)
6070        .count();
6071    ((shared as f64) * 0.05).min(0.5)
6072}
6073
6074fn mark_backend_state(
6075    tx: &Transaction<'_>,
6076    project_root: &Path,
6077    rel_path: &str,
6078    content_hash: Option<&blake3::Hash>,
6079    status: &str,
6080) -> Result<()> {
6081    clear_backend_state_for_file(tx, project_root, rel_path)?;
6082    let hash = content_hash
6083        .map(|hash| hash_to_hex(*hash))
6084        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
6085    tx.execute(
6086        "INSERT OR REPLACE INTO backend_file_state(
6087            backend, workspace_root, file_path, content_hash, status, updated_at
6088        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
6089        params![
6090            BACKEND_TREESITTER,
6091            project_root.display().to_string(),
6092            rel_path,
6093            hash,
6094            status,
6095            unix_seconds_now(),
6096        ],
6097    )?;
6098    Ok(())
6099}
6100
6101fn clear_backend_state_for_file(
6102    tx: &Transaction<'_>,
6103    project_root: &Path,
6104    rel_path: &str,
6105) -> Result<()> {
6106    tx.execute(
6107        "DELETE FROM backend_file_state
6108         WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
6109        params![
6110            BACKEND_TREESITTER,
6111            project_root.display().to_string(),
6112            rel_path
6113        ],
6114    )?;
6115    Ok(())
6116}
6117
6118fn load_file_row(tx: &Transaction<'_>, rel_path: &str) -> Result<Option<FileRow>> {
6119    tx.query_row(
6120        "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
6121        params![rel_path],
6122        |row| {
6123            let hash_text: String = row.get(1)?;
6124            Ok(FileRow {
6125                surface_fingerprint: row.get(0)?,
6126                freshness: FileFreshness {
6127                    content_hash: hash_from_hex(&hash_text)
6128                        .unwrap_or_else(cache_freshness::zero_hash),
6129                    mtime: ns_to_system_time(row.get::<_, i64>(2)?),
6130                    size: row.get::<_, i64>(3)? as u64,
6131                },
6132            })
6133        },
6134    )
6135    .optional()
6136    .map_err(CallGraphStoreError::from)
6137}
6138
6139fn stored_node_ids_match_extract(
6140    tx: &Transaction<'_>,
6141    rel_path: &str,
6142    extract: &FileExtract,
6143) -> Result<bool> {
6144    let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
6145    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
6146    let mut stored = BTreeSet::new();
6147    for row in rows {
6148        stored.insert(row?);
6149    }
6150    let extracted = extract
6151        .nodes
6152        .iter()
6153        .map(|node| node.id.clone())
6154        .collect::<BTreeSet<_>>();
6155    Ok(stored == extracted)
6156}
6157
6158fn update_file_fresh_metadata(
6159    tx: &Transaction<'_>,
6160    rel_path: &str,
6161    hash: &blake3::Hash,
6162    mtime: SystemTime,
6163    size: u64,
6164) -> Result<()> {
6165    tx.execute(
6166        "UPDATE files SET mtime_ns = ?2, size = ?3, indexed_at = ?4 WHERE path = ?1",
6167        params![
6168            rel_path,
6169            system_time_to_ns(mtime),
6170            size as i64,
6171            unix_seconds_now()
6172        ],
6173    )?;
6174    tx.execute(
6175        "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
6176         WHERE backend = ?1 AND file_path = ?2 AND content_hash = ?3",
6177        params![
6178            BACKEND_TREESITTER,
6179            rel_path,
6180            hash_to_hex(*hash),
6181            unix_seconds_now(),
6182        ],
6183    )?;
6184    Ok(())
6185}
6186
6187#[derive(Debug, Clone, PartialEq, Eq)]
6188struct DependentRefSelection {
6189    ref_id: String,
6190    caller_file: String,
6191}
6192
6193fn ref_ids_depending_on(
6194    tx: &Transaction<'_>,
6195    project_root: &Path,
6196    rel_path: &str,
6197) -> Result<Vec<DependentRefSelection>> {
6198    let mut stmt = tx.prepare(
6199        "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
6200         FROM refs r
6201         WHERE r.caller_file IN (
6202             SELECT file_path FROM file_dependencies WHERE dep_file = ?1
6203         )
6204            OR r.target_file = ?1
6205         ORDER BY r.ref_id",
6206    )?;
6207    let rows = stmt.query_map(params![rel_path], |row| {
6208        Ok(RefDependencyRow {
6209            ref_id: row.get(0)?,
6210            kind: row.get(1)?,
6211            caller_file: row.get(2)?,
6212            module_path: row.get(3)?,
6213            target_file: row.get(4)?,
6214        })
6215    })?;
6216    let mut ids = Vec::new();
6217    for row in rows {
6218        let row = row?;
6219        if ref_dependency_row_depends_on(project_root, &row, rel_path) {
6220            ids.push(DependentRefSelection {
6221                ref_id: row.ref_id,
6222                caller_file: row.caller_file,
6223            });
6224        }
6225    }
6226    Ok(ids)
6227}
6228
6229fn record_dependent_refs(
6230    selected_ref_ids: &mut BTreeSet<String>,
6231    selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
6232    dependent_refs: Vec<DependentRefSelection>,
6233) {
6234    for dependent_ref in dependent_refs {
6235        let DependentRefSelection {
6236            ref_id,
6237            caller_file,
6238        } = dependent_ref;
6239        selected_ref_ids.insert(ref_id.clone());
6240        selected_refs_by_caller
6241            .entry(caller_file)
6242            .or_default()
6243            .insert(ref_id);
6244    }
6245}
6246
6247#[cfg(test)]
6248fn refs_by_caller_for_ref_ids(
6249    tx: &Transaction<'_>,
6250    ref_ids: &BTreeSet<String>,
6251) -> Result<BTreeMap<String, BTreeSet<String>>> {
6252    let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
6253    let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
6254    for ref_id in ref_ids {
6255        if let Some(caller) = stmt
6256            .query_row(params![ref_id], |row| row.get::<_, String>(0))
6257            .optional()?
6258        {
6259            by_caller.entry(caller).or_default().insert(ref_id.clone());
6260        }
6261    }
6262    Ok(by_caller)
6263}
6264
6265fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
6266    tx.execute(
6267        "DELETE FROM file_dependencies WHERE file_path = ?1",
6268        params![rel_path],
6269    )?;
6270    delete_refs_for_caller(tx, rel_path)?;
6271    tx.execute(
6272        "DELETE FROM dispatch_hints WHERE file = ?1",
6273        params![rel_path],
6274    )?;
6275    tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
6276    tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
6277    Ok(())
6278}
6279
6280fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
6281    let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
6282    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
6283    let mut ids = BTreeSet::new();
6284    for row in rows {
6285        ids.insert(row?);
6286    }
6287    delete_ref_ids(tx, &ids)
6288}
6289
6290fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
6291    for ref_id in ref_ids {
6292        tx.execute("DELETE FROM edges WHERE ref_id = ?1", params![ref_id])?;
6293        tx.execute("DELETE FROM refs WHERE ref_id = ?1", params![ref_id])?;
6294    }
6295    Ok(())
6296}
6297
6298fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
6299    let mut stmt = conn.prepare(
6300        "SELECT source.file_path, source.scoped_name, edges.target_file,
6301                edges.target_symbol, edges.kind, edges.line
6302         FROM edges
6303         JOIN nodes AS source ON source.id = edges.source_node
6304         ORDER BY source.file_path, source.scoped_name, edges.target_file,
6305                  edges.target_symbol, edges.kind, edges.line",
6306    )?;
6307    let rows = stmt.query_map([], |row| {
6308        Ok(StoredEdge {
6309            source_file: row.get(0)?,
6310            source_symbol: row.get(1)?,
6311            target_file: row.get(2)?,
6312            target_symbol: row.get(3)?,
6313            kind: row.get(4)?,
6314            line: row.get::<_, i64>(5)? as u32,
6315        })
6316    })?;
6317    let mut edges = BTreeSet::new();
6318    for row in rows {
6319        edges.insert(row?);
6320    }
6321    Ok(edges)
6322}
6323
6324fn module_target_from_dependencies(
6325    project_root: &Path,
6326    dependencies: &BTreeSet<String>,
6327) -> Option<String> {
6328    dependencies.iter().find_map(|dep| {
6329        let path = project_root.join(dep);
6330        if path.is_file() {
6331            Some(relative_path(project_root, &canonicalize_path(&path)))
6332        } else {
6333            None
6334        }
6335    })
6336}
6337
6338fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
6339    let mut named = HashMap::new();
6340    if let Some(full_ref) = &raw_ref.full_ref {
6341        named = parse_reexport_names(full_ref);
6342    }
6343    ReexportIndex {
6344        target_file,
6345        named,
6346        wildcard: raw_ref.wildcard,
6347    }
6348}
6349
6350fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
6351    let mut names = HashMap::new();
6352    let Some(open) = statement.find('{') else {
6353        return names;
6354    };
6355    let Some(close) = statement[open + 1..]
6356        .find('}')
6357        .map(|offset| open + 1 + offset)
6358    else {
6359        return names;
6360    };
6361    for spec in statement[open + 1..close].split(',') {
6362        let spec = spec.trim();
6363        if spec.is_empty() {
6364            continue;
6365        }
6366        if let Some((source, local)) = spec.split_once(" as ") {
6367            names.insert(local.trim().to_string(), source.trim().to_string());
6368        } else {
6369            names.insert(spec.to_string(), spec.to_string());
6370        }
6371    }
6372    names
6373}
6374
6375fn dependencies_for_ref(
6376    tx: &Transaction<'_>,
6377    project_root: &Path,
6378    ref_id: &str,
6379) -> Result<BTreeSet<String>> {
6380    let row = tx.query_row(
6381        "SELECT kind, caller_file, module_path, target_file FROM refs WHERE ref_id = ?1",
6382        params![ref_id],
6383        |row| {
6384            Ok(RefDependencyRow {
6385                ref_id: ref_id.to_string(),
6386                kind: row.get(0)?,
6387                caller_file: row.get(1)?,
6388                module_path: row.get(2)?,
6389                target_file: row.get(3)?,
6390            })
6391        },
6392    )?;
6393
6394    match row.kind.as_str() {
6395        "import" | "reexport" => {
6396            let Some(module_path) = row.module_path.as_deref() else {
6397                return Ok(BTreeSet::new());
6398            };
6399            let file_deps = file_dependencies_for_file(tx, &row.caller_file)?;
6400            let module_deps =
6401                module_dependencies_for_ref(project_root, &row.caller_file, module_path);
6402            Ok(file_deps.intersection(&module_deps).cloned().collect())
6403        }
6404        "export_alias" => Ok(BTreeSet::new()),
6405        "call" => {
6406            let mut deps = file_dependencies_for_file(tx, &row.caller_file)?;
6407            if let Some(target_file) = row.target_file {
6408                deps.insert(target_file);
6409            }
6410            Ok(deps)
6411        }
6412        _ => file_dependencies_for_file(tx, &row.caller_file),
6413    }
6414}
6415
6416#[derive(Debug)]
6417struct RefDependencyRow {
6418    ref_id: String,
6419    kind: String,
6420    caller_file: String,
6421    module_path: Option<String>,
6422    target_file: Option<String>,
6423}
6424
6425fn ref_dependency_row_depends_on(
6426    project_root: &Path,
6427    row: &RefDependencyRow,
6428    rel_path: &str,
6429) -> bool {
6430    if row.target_file.as_deref() == Some(rel_path) {
6431        return true;
6432    }
6433
6434    match row.kind.as_str() {
6435        "call" => true,
6436        "import" | "reexport" => row
6437            .module_path
6438            .as_deref()
6439            .map(|module_path| {
6440                module_dependencies_for_ref(project_root, &row.caller_file, module_path)
6441                    .contains(rel_path)
6442            })
6443            .unwrap_or(false),
6444        "export_alias" => false,
6445        _ => false,
6446    }
6447}
6448
6449fn file_dependencies_for_file(tx: &Transaction<'_>, file_path: &str) -> Result<BTreeSet<String>> {
6450    let mut stmt = tx
6451        .prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1 ORDER BY dep_file")?;
6452    let rows = stmt.query_map(params![file_path], |row| row.get::<_, String>(0))?;
6453    let mut deps = BTreeSet::new();
6454    for row in rows {
6455        deps.insert(row?);
6456    }
6457    Ok(deps)
6458}
6459
6460fn module_dependencies_for_ref(
6461    project_root: &Path,
6462    caller_file: &str,
6463    module_path: &str,
6464) -> BTreeSet<String> {
6465    module_dependencies(project_root, &project_root.join(caller_file), module_path)
6466}
6467
6468fn import_dependencies(
6469    project_root: &Path,
6470    abs_path: &Path,
6471    imports: &[ImportStatement],
6472) -> BTreeSet<String> {
6473    let mut deps = BTreeSet::new();
6474    for import in imports {
6475        deps.extend(module_dependencies(
6476            project_root,
6477            abs_path,
6478            &import.module_path,
6479        ));
6480    }
6481    deps
6482}
6483
6484fn module_dependencies(
6485    project_root: &Path,
6486    abs_path: &Path,
6487    module_path: &str,
6488) -> BTreeSet<String> {
6489    let mut deps = BTreeSet::new();
6490    let caller_dir = abs_path.parent().unwrap_or(project_root);
6491    if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
6492        deps.insert(relative_path(project_root, &resolved));
6493    }
6494    if module_path.starts_with('.') {
6495        let base = caller_dir.join(module_path);
6496        for candidate in relative_module_candidates(&base) {
6497            deps.insert(relative_path(project_root, &candidate));
6498        }
6499    }
6500    deps
6501}
6502
6503fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
6504    let mut candidates = Vec::new();
6505    if base.extension().is_some() {
6506        candidates.push(base.to_path_buf());
6507        return candidates;
6508    }
6509    for ext in JS_TS_EXTENSIONS {
6510        candidates.push(base.with_extension(ext));
6511    }
6512    for ext in JS_TS_EXTENSIONS {
6513        candidates.push(base.join(format!("index.{ext}")));
6514    }
6515    candidates
6516}
6517
6518fn import_local_names(import: &ImportStatement) -> Vec<String> {
6519    let mut names = Vec::new();
6520    if let Some(default) = &import.default_import {
6521        names.push(default.clone());
6522    }
6523    if let Some(namespace) = &import.namespace_import {
6524        names.push(namespace.clone());
6525    }
6526    for name in &import.names {
6527        names.push(crate::imports::specifier_local_name(name).to_string());
6528    }
6529    names
6530}
6531
6532fn import_requested_names(import: &ImportStatement) -> Vec<String> {
6533    import
6534        .names
6535        .iter()
6536        .map(|name| crate::imports::specifier_imported_name(name).to_string())
6537        .collect()
6538}
6539
6540fn import_is_wildcard(import: &ImportStatement) -> bool {
6541    import.namespace_import.is_some() || import.raw_text.contains('*')
6542}
6543
6544fn namespace_alias(full_ref: &str) -> Option<String> {
6545    full_ref
6546        .split_once('.')
6547        .map(|(namespace, _)| namespace.to_string())
6548}
6549
6550fn import_kind_label(kind: ImportKind) -> &'static str {
6551    match kind {
6552        ImportKind::Value => "value",
6553        ImportKind::Type => "type",
6554        ImportKind::SideEffect => "side_effect",
6555    }
6556}
6557
6558fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
6559    match kind {
6560        SymbolKind::Function => "function",
6561        SymbolKind::Class => "class",
6562        SymbolKind::Method => "method",
6563        SymbolKind::Struct => "struct",
6564        SymbolKind::Interface => "interface",
6565        SymbolKind::Enum => "enum",
6566        SymbolKind::TypeAlias => "type_alias",
6567        SymbolKind::Variable => "variable",
6568        SymbolKind::Heading => "heading",
6569        SymbolKind::FileSummary => "file_summary",
6570    }
6571}
6572
6573fn is_type_like(kind: &SymbolKind) -> bool {
6574    matches!(
6575        kind,
6576        SymbolKind::Class
6577            | SymbolKind::Struct
6578            | SymbolKind::Interface
6579            | SymbolKind::Enum
6580            | SymbolKind::TypeAlias
6581    )
6582}
6583
6584fn lang_label(lang: LangId) -> &'static str {
6585    match lang {
6586        LangId::TypeScript => "typescript",
6587        LangId::Tsx => "tsx",
6588        LangId::JavaScript => "javascript",
6589        LangId::Python => "python",
6590        LangId::Rust => "rust",
6591        LangId::Go => "go",
6592        LangId::C => "c",
6593        LangId::Cpp => "cpp",
6594        LangId::Zig => "zig",
6595        LangId::CSharp => "csharp",
6596        LangId::Bash => "bash",
6597        LangId::Html => "html",
6598        LangId::Markdown => "markdown",
6599        LangId::Solidity => "solidity",
6600        LangId::Scss => "scss",
6601        LangId::Vue => "vue",
6602        LangId::Json => "json",
6603        LangId::Scala => "scala",
6604        LangId::Java => "java",
6605        LangId::Ruby => "ruby",
6606        LangId::Kotlin => "kotlin",
6607        LangId::Swift => "swift",
6608        LangId::Php => "php",
6609        LangId::Lua => "lua",
6610        LangId::Perl => "perl",
6611        LangId::Yaml => "yaml",
6612        LangId::Pascal => "pascal",
6613        LangId::R => "r",
6614        LangId::ObjC => "objc",
6615    }
6616}
6617
6618fn lang_from_label(label: &str) -> Option<LangId> {
6619    match label {
6620        "typescript" => Some(LangId::TypeScript),
6621        "tsx" => Some(LangId::Tsx),
6622        "javascript" => Some(LangId::JavaScript),
6623        "python" => Some(LangId::Python),
6624        "rust" => Some(LangId::Rust),
6625        "go" => Some(LangId::Go),
6626        "c" => Some(LangId::C),
6627        "cpp" => Some(LangId::Cpp),
6628        "zig" => Some(LangId::Zig),
6629        "csharp" => Some(LangId::CSharp),
6630        "bash" => Some(LangId::Bash),
6631        "html" => Some(LangId::Html),
6632        "markdown" => Some(LangId::Markdown),
6633        "solidity" => Some(LangId::Solidity),
6634        "scss" => Some(LangId::Scss),
6635        "vue" => Some(LangId::Vue),
6636        "json" => Some(LangId::Json),
6637        "scala" => Some(LangId::Scala),
6638        "java" => Some(LangId::Java),
6639        "ruby" => Some(LangId::Ruby),
6640        "kotlin" => Some(LangId::Kotlin),
6641        "swift" => Some(LangId::Swift),
6642        "php" => Some(LangId::Php),
6643        "lua" => Some(LangId::Lua),
6644        "perl" => Some(LangId::Perl),
6645        "yaml" => Some(LangId::Yaml),
6646        "pascal" => Some(LangId::Pascal),
6647        "r" => Some(LangId::R),
6648        "objc" => Some(LangId::ObjC),
6649        _ => None,
6650    }
6651}
6652
6653fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
6654    let mut normalized = if files.is_empty() {
6655        callgraph::walk_project_files(project_root).collect::<Vec<_>>()
6656    } else {
6657        files
6658            .iter()
6659            .map(|path| normalize_file_path(project_root, path))
6660            .collect::<Result<Vec<_>>>()?
6661    };
6662    normalized.sort();
6663    normalized.dedup();
6664    Ok(normalized)
6665}
6666
6667fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
6668    let full_path = if path.is_relative() {
6669        project_root.join(path)
6670    } else {
6671        path.to_path_buf()
6672    };
6673    Ok(canonicalize_path(&full_path))
6674}
6675
6676fn canonicalize_path(path: &Path) -> PathBuf {
6677    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
6678}
6679
6680fn relative_path(project_root: &Path, path: &Path) -> String {
6681    if let Ok(stripped) = path.strip_prefix(project_root) {
6682        return stripped.to_string_lossy().replace('\\', "/");
6683    }
6684    let canon_root = canonicalize_path(project_root);
6685    let canon_path = canonicalize_path(path);
6686    if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
6687        return stripped.to_string_lossy().replace('\\', "/");
6688    }
6689    canon_path.to_string_lossy().replace('\\', "/")
6690}
6691
6692fn unqualified_name(scoped: &str) -> &str {
6693    if scoped == TOP_LEVEL_SYMBOL {
6694        return scoped;
6695    }
6696    scoped
6697        .rsplit("::")
6698        .next()
6699        .unwrap_or(scoped)
6700        .rsplit('.')
6701        .next()
6702        .unwrap_or(scoped)
6703        .rsplit('#')
6704        .next()
6705        .unwrap_or(scoped)
6706}
6707
6708fn ref_id(parts: &[&str]) -> String {
6709    let joined = parts.join("\0");
6710    hash_to_hex(blake3::hash(joined.as_bytes()))
6711}
6712
6713fn hash_to_hex(hash: blake3::Hash) -> String {
6714    hash.to_hex().to_string()
6715}
6716
6717fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
6718    let bytes = hex_to_bytes(value)?;
6719    Some(blake3::Hash::from_bytes(bytes))
6720}
6721
6722fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
6723    if value.len() != 64 {
6724        return None;
6725    }
6726    let mut bytes = [0u8; 32];
6727    for (index, slot) in bytes.iter_mut().enumerate() {
6728        let start = index * 2;
6729        let end = start + 2;
6730        *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
6731    }
6732    Some(bytes)
6733}
6734
6735#[derive(Debug, Clone)]
6736struct LineIndex {
6737    newline_offsets: Vec<usize>,
6738    source_len: usize,
6739}
6740
6741impl LineIndex {
6742    fn new(source: &str) -> Self {
6743        Self {
6744            newline_offsets: source
6745                .bytes()
6746                .enumerate()
6747                .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
6748                .collect(),
6749            source_len: source.len(),
6750        }
6751    }
6752
6753    fn byte_to_line(&self, byte_offset: usize) -> u32 {
6754        let byte_offset = byte_offset.min(self.source_len);
6755        self.newline_offsets
6756            .partition_point(|offset| *offset < byte_offset) as u32
6757            + 1
6758    }
6759}
6760
6761fn empty_to_none(value: String) -> Option<String> {
6762    if value.is_empty() {
6763        None
6764    } else {
6765        Some(value)
6766    }
6767}
6768
6769fn bool_int(value: bool) -> i64 {
6770    if value {
6771        1
6772    } else {
6773        0
6774    }
6775}
6776
6777fn system_time_to_ns(time: SystemTime) -> i64 {
6778    time.duration_since(UNIX_EPOCH)
6779        .unwrap_or_default()
6780        .as_nanos()
6781        .min(i64::MAX as u128) as i64
6782}
6783
6784fn ns_to_system_time(value: i64) -> SystemTime {
6785    UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
6786}
6787
6788fn unix_seconds_now() -> i64 {
6789    SystemTime::now()
6790        .duration_since(UNIX_EPOCH)
6791        .unwrap_or_default()
6792        .as_secs() as i64
6793}
6794
6795#[cfg(test)]
6796mod cold_build_insert_tests {
6797    use super::*;
6798    use crate::imports::ImportBlock;
6799    use std::fs;
6800    use tempfile::tempdir;
6801
6802    #[test]
6803    fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
6804        let dir = tempdir().expect("temp dir");
6805        let file = dir.path().join("main.ts");
6806        fs::write(
6807            &file,
6808            r#"export function topA() {
6809  root();
6810}
6811
6812export function topB() {
6813  root();
6814}
6815
6816export function root() {
6817  leaf();
6818  missing();
6819}
6820
6821export function leaf() {}
6822"#,
6823        )
6824        .expect("write fixture");
6825
6826        let store = CallGraphStore::open(
6827            dir.path().join(".store-depth-boundary-counts"),
6828            dir.path().to_path_buf(),
6829        )
6830        .expect("open store");
6831        store
6832            .cold_build(std::slice::from_ref(&file))
6833            .expect("cold build");
6834
6835        let root = store
6836            .node_for(Path::new("main.ts"), "root")
6837            .expect("root node");
6838        let leaf = store
6839            .node_for(Path::new("main.ts"), "leaf")
6840            .expect("leaf node");
6841
6842        let (full_forward_len, full_direct_len) = {
6843            let conn = store.conn.lock().expect("callgraph store mutex poisoned");
6844            conn.execute(
6845                "INSERT INTO edges (
6846                    edge_id, ref_id, source_node, target_node, target_file,
6847                    target_symbol, kind, line, provenance
6848                 ) VALUES (
6849                    'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
6850                    ?2, ?3, 'call', 98, ?4
6851                 )",
6852                rusqlite::params![
6853                    &root.node_id,
6854                    &leaf.file,
6855                    &leaf.symbol,
6856                    PROVENANCE_TREESITTER
6857                ],
6858            )
6859            .expect("insert dangling forward edge");
6860            conn.execute(
6861                "INSERT INTO edges (
6862                    edge_id, ref_id, source_node, target_node, target_file,
6863                    target_symbol, kind, line, provenance
6864                 ) VALUES (
6865                    'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
6866                    ?1, ?2, ?3, 'call', 99, ?4
6867                 )",
6868                rusqlite::params![
6869                    &root.node_id,
6870                    &root.file,
6871                    &root.symbol,
6872                    PROVENANCE_TREESITTER
6873                ],
6874            )
6875            .expect("insert dangling direct-caller edge");
6876
6877            let full_forward_len = forward_calls_for_node(&conn, &root)
6878                .expect("full forward calls")
6879                .len();
6880            let counted_forward_len =
6881                forward_call_count_for_node(&conn, &root).expect("counted forward calls");
6882            assert_eq!(
6883                counted_forward_len, full_forward_len,
6884                "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
6885            );
6886
6887            let full_direct_len = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
6888                .expect("full direct callers")
6889                .len();
6890            let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
6891                .expect("counted direct callers");
6892            assert_eq!(
6893                counted_direct_len, full_direct_len,
6894                "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
6895            );
6896
6897            (full_forward_len, full_direct_len)
6898        };
6899
6900        assert_eq!(
6901            full_forward_len, 2,
6902            "fixture root should have one resolved and one unresolved outgoing call"
6903        );
6904        assert_eq!(
6905            full_direct_len, 2,
6906            "fixture root should have two real direct callers"
6907        );
6908
6909        let tree = store
6910            .call_tree(Path::new("main.ts"), "root", 0)
6911            .expect("call tree");
6912        assert!(tree.depth_limited);
6913        assert_eq!(tree.children.len(), 0);
6914        assert_eq!(
6915            tree.truncated, full_forward_len,
6916            "call_tree depth boundary must report the full forward-call list length"
6917        );
6918
6919        let callers = store
6920            .callers_of(Path::new("main.ts"), "leaf", 0)
6921            .expect("callers");
6922        assert!(callers.depth_limited);
6923        assert_eq!(callers.callers.len(), 1);
6924        assert_eq!(callers.callers[0].caller.symbol, "root");
6925        assert_eq!(
6926            callers.truncated, full_direct_len,
6927            "callers depth boundary must report the full direct-caller list length"
6928        );
6929    }
6930
6931    #[test]
6932    fn source_freshness_matches_cache_collect_for_same_bytes() {
6933        let dir = tempdir().expect("temp dir");
6934        let path = dir.path().join("fixture.ts");
6935        let source = "export function main() { return helper(); }\n";
6936        fs::write(&path, source).expect("write fixture");
6937
6938        let expected = cache_freshness::collect(&path).expect("collect freshness from file");
6939        let actual =
6940            collect_source_freshness(&path, source).expect("collect freshness from source");
6941
6942        assert_eq!(actual, expected);
6943    }
6944
6945    #[test]
6946    fn cold_build_prepared_bulk_insert_matches_reference_rows() {
6947        let dir = tempdir().expect("temp dir");
6948        let project_root = dir.path();
6949        let extract = fixture_extract(project_root);
6950        let resolved = fixture_resolved(&extract);
6951
6952        let reference = build_reference_connection(project_root, &extract, &resolved);
6953        let optimized = build_optimized_connection(project_root, &extract, &resolved);
6954
6955        for table in [
6956            "files",
6957            "nodes",
6958            "file_dependencies",
6959            "dispatch_hints",
6960            "refs",
6961            "edges",
6962        ] {
6963            // `files.indexed_at` is a wall-clock insert timestamp (unix_seconds_now);
6964            // the reference and optimized builds run sequentially and can straddle a
6965            // one-second tick under load, so it is legitimately allowed to differ.
6966            // This mirrors the existing exclusions of `backend_file_state.updated_at`
6967            // and the chunked-vs-unchunked sibling test. The check is for structural
6968            // row equivalence of the optimized bulk insert, not wall-clock equality.
6969            let excluded: &[&str] = if table == "files" {
6970                &["indexed_at"]
6971            } else {
6972                &[]
6973            };
6974            assert_eq!(
6975                table_rows_without(&reference, table, excluded),
6976                table_rows_without(&optimized, table, excluded),
6977                "table `{table}` rows must match apart from wall-clock columns"
6978            );
6979        }
6980        assert_eq!(
6981            backend_state_rows(&reference),
6982            backend_state_rows(&optimized),
6983            "backend freshness rows must match apart from updated_at"
6984        );
6985        assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
6986    }
6987
6988    #[test]
6989    fn cold_build_chunked_matches_unchunked_logical_rows() {
6990        let dir = tempdir().expect("temp dir");
6991        let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
6992        write_chunked_equivalence_fixture(&project_root);
6993        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
6994        assert!(
6995            files.len() > 6,
6996            "fixture should be large enough to split into multiple chunks"
6997        );
6998
6999        let unchunked = CallGraphStore::open(
7000            project_root.join(".store-unchunked"),
7001            project_root.to_path_buf(),
7002        )
7003        .expect("open unchunked store");
7004        let unchunked_stats = unchunked
7005            .cold_build_chunked(&files, 0)
7006            .expect("unchunked cold build");
7007
7008        let chunked = CallGraphStore::open(
7009            project_root.join(".store-chunked"),
7010            project_root.to_path_buf(),
7011        )
7012        .expect("open chunked store");
7013        let chunked_stats = chunked
7014            .cold_build_chunked(&files, 3)
7015            .expect("chunked cold build");
7016
7017        assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
7018        assert_eq!(
7019            unchunked.edge_snapshot().expect("unchunked edge snapshot"),
7020            chunked.edge_snapshot().expect("chunked edge snapshot"),
7021            "public edge snapshots must match"
7022        );
7023
7024        let dispatch_edges = {
7025            let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
7026            conn.query_row(
7027                "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
7028                [],
7029                |row| row.get::<_, i64>(0),
7030            )
7031            .expect("count dispatch edges")
7032        };
7033        assert!(
7034            dispatch_edges > 0,
7035            "fixture must exercise method-dispatch edge insertion"
7036        );
7037
7038        for table in [
7039            "edges",
7040            "refs",
7041            "nodes",
7042            "file_dependencies",
7043            "dispatch_hints",
7044        ] {
7045            assert_eq!(
7046                graph_table_rows(&unchunked, table),
7047                graph_table_rows(&chunked, table),
7048                "chunked cold build must match unchunked rows for {table}"
7049            );
7050        }
7051        assert_eq!(
7052            graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
7053            graph_table_rows_without(&chunked, "files", &["indexed_at"]),
7054            "files rows must match apart from indexed_at"
7055        );
7056        assert_eq!(
7057            graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
7058            graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
7059            "backend freshness rows must match apart from updated_at"
7060        );
7061
7062        let published_dir = project_root.join(".store-published");
7063        let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
7064            published_dir.clone(),
7065            project_root.to_path_buf(),
7066            &files,
7067            0,
7068        )
7069        .expect("published unchunked cold build");
7070        assert!(
7071            !CallGraphStore::needs_cold_build(&published_dir, &project_root)
7072                .expect("needs_cold_build after publish"),
7073            "published store should be ready"
7074        );
7075        let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
7076            published_dir,
7077            project_root.to_path_buf(),
7078            &files,
7079            3,
7080        )
7081        .expect("ensure with a different chunk size");
7082        assert!(
7083            rebuild_stats.is_none(),
7084            "changing callgraph_chunk_size must not affect store identity or force a rebuild"
7085        );
7086    }
7087
7088    // Perf A/B bench (not a gate): measures cold_build wall time at a given
7089    // chunk size against a real repo. Driven by env so the same binary can A/B
7090    // chunk=0 vs chunk=N in clean isolation. Reusable for the deferred DB-spill
7091    // memory work. Run:
7092    //   AFT_PERF_REPO=/path AFT_PERF_CHUNK=0 cargo test -p agent-file-tools \
7093    //     --release --lib bench_cold_build_chunk -- --ignored --nocapture
7094    #[test]
7095    #[ignore]
7096    fn bench_cold_build_chunk() {
7097        let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
7098        let chunk: usize = std::env::var("AFT_PERF_CHUNK")
7099            .expect("AFT_PERF_CHUNK")
7100            .parse()
7101            .expect("AFT_PERF_CHUNK must be a non-negative integer");
7102        let project_root = fs::canonicalize(&repo).expect("canonical repo root");
7103        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
7104        let dir = tempdir().expect("temp dir");
7105        let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
7106            .expect("open store");
7107        let started = Instant::now();
7108        let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
7109        let ms = started.elapsed().as_millis();
7110        println!(
7111            "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
7112            stats.files, stats.nodes, stats.refs, stats.edges
7113        );
7114    }
7115
7116    #[test]
7117    fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
7118        let dir = tempdir().expect("temp dir");
7119        let project_root = dir.path();
7120        let files =
7121            write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
7122        let index_path = project_root.join("src/index.ts");
7123
7124        let store = CallGraphStore::open(
7125            project_root.join(".store-incremental-barrel"),
7126            project_root.to_path_buf(),
7127        )
7128        .expect("open incremental store");
7129        store.cold_build(&files).expect("initial cold build");
7130
7131        {
7132            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
7133            let tx = conn.transaction().expect("dependency transaction");
7134            let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
7135                .expect("dependent refs for barrel");
7136            let selected_ref_ids = dependent_refs
7137                .iter()
7138                .map(|dependent_ref| dependent_ref.ref_id.clone())
7139                .collect::<BTreeSet<_>>();
7140            let mut threaded_ref_ids = BTreeSet::new();
7141            let mut threaded_by_caller = BTreeMap::new();
7142            record_dependent_refs(
7143                &mut threaded_ref_ids,
7144                &mut threaded_by_caller,
7145                dependent_refs,
7146            );
7147            let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
7148                .expect("old per-ref caller lookup");
7149
7150            assert_eq!(threaded_ref_ids, selected_ref_ids);
7151            assert_eq!(threaded_by_caller, old_by_caller);
7152            for consumer in [
7153                "src/consumer_a.ts",
7154                "src/consumer_b.ts",
7155                "src/consumer_c.ts",
7156            ] {
7157                assert!(
7158                    threaded_by_caller.contains_key(consumer),
7159                    "barrel edit should select dependent refs from {consumer}"
7160                );
7161            }
7162        }
7163
7164        fs::write(
7165            &index_path,
7166            "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
7167        )
7168        .expect("edit barrel");
7169        let stats = store
7170            .refresh_files(std::slice::from_ref(&index_path))
7171            .expect("incremental refresh");
7172        assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
7173        assert!(
7174            stats.dependency_selected_refs > 0,
7175            "barrel surface edit should select dependent refs"
7176        );
7177
7178        let cold_store = CallGraphStore::open(
7179            project_root.join(".store-cold-barrel"),
7180            project_root.to_path_buf(),
7181        )
7182        .expect("open cold rebuild store");
7183        cold_store
7184            .cold_build(&files)
7185            .expect("comparison cold build");
7186
7187        for table in [
7188            "nodes",
7189            "refs",
7190            "file_dependencies",
7191            "edges",
7192            "dispatch_hints",
7193        ] {
7194            assert_eq!(
7195                graph_table_rows(&store, table),
7196                graph_table_rows(&cold_store, table),
7197                "incremental refresh {table} rows must match cold rebuild"
7198            );
7199        }
7200    }
7201
7202    fn build_reference_connection(
7203        project_root: &Path,
7204        extract: &FileExtract,
7205        resolved: &ResolvedRef,
7206    ) -> Connection {
7207        let mut conn = Connection::open_in_memory().expect("open reference db");
7208        configure_build_connection(&conn).expect("configure reference db");
7209        initialize_schema(&conn).expect("initialize reference schema");
7210        {
7211            let tx = conn.transaction().expect("reference transaction");
7212            clear_tables(&tx).expect("reference clear");
7213            insert_meta(&tx).expect("reference meta");
7214            insert_file_extract(&tx, project_root, extract).expect("reference file extract");
7215            insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
7216            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
7217                .expect("reference dispatch edges");
7218            assert_eq!(supplemental, 0);
7219            tx.commit().expect("reference commit");
7220        }
7221        conn
7222    }
7223
7224    fn build_optimized_connection(
7225        project_root: &Path,
7226        extract: &FileExtract,
7227        resolved: &ResolvedRef,
7228    ) -> Connection {
7229        let mut conn = Connection::open_in_memory().expect("open optimized db");
7230        configure_build_connection(&conn).expect("configure optimized db");
7231        initialize_schema(&conn).expect("initialize optimized schema");
7232        {
7233            let tx = conn.transaction().expect("optimized transaction");
7234            clear_tables(&tx).expect("optimized clear");
7235            insert_meta(&tx).expect("optimized meta");
7236            drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
7237            {
7238                let workspace_root = project_root.display().to_string();
7239                let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
7240                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
7241                    .expect("optimized file extract");
7242                insert_resolved_ref_prepared(&mut inserts, resolved)
7243                    .expect("optimized resolved ref");
7244            }
7245            create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
7246            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
7247                .expect("optimized dispatch edges");
7248            assert_eq!(supplemental, 0);
7249            tx.commit().expect("optimized commit");
7250        }
7251        conn
7252    }
7253
7254    fn fixture_extract(project_root: &Path) -> FileExtract {
7255        let rel_path = "src/main.ts".to_string();
7256        let target_path = "src/helper.ts".to_string();
7257        let node = NodeRecord {
7258            id: "node-main".to_string(),
7259            file_path: rel_path.clone(),
7260            name: "main".to_string(),
7261            scoped_name: "main".to_string(),
7262            kind: "function".to_string(),
7263            range: Range {
7264                start_line: 0,
7265                start_col: 0,
7266                end_line: 0,
7267                end_col: 32,
7268            },
7269            range_ordinal: 0,
7270            signature: Some("export function main()".to_string()),
7271            exported: true,
7272            is_default_export: false,
7273            is_type_like: false,
7274            is_callgraph_entry_point: true,
7275        };
7276        let mut dependencies = BTreeSet::new();
7277        dependencies.insert(target_path.clone());
7278        let raw_ref = RawRef {
7279            ref_id: "ref-main-helper".to_string(),
7280            caller_node: Some(node.id.clone()),
7281            caller_symbol: Some(node.scoped_name.clone()),
7282            caller_file: rel_path.clone(),
7283            kind: "call".to_string(),
7284            short_name: Some("helper".to_string()),
7285            full_ref: Some("helper".to_string()),
7286            module_path: None,
7287            import_kind: None,
7288            local_name: Some("helper".to_string()),
7289            requested_name: Some("helper".to_string()),
7290            namespace_alias: None,
7291            wildcard: false,
7292            line: 1,
7293            byte_start: 24,
7294            byte_end: 32,
7295            dependencies,
7296        };
7297        FileExtract {
7298            abs_path: project_root.join(&rel_path),
7299            rel_path,
7300            freshness: FileFreshness {
7301                mtime: UNIX_EPOCH + Duration::from_secs(123),
7302                size: 40,
7303                content_hash: cache_freshness::hash_bytes(b"fixture source"),
7304            },
7305            lang: LangId::TypeScript,
7306            data: FileCallData {
7307                calls_by_symbol: HashMap::new(),
7308                exported_symbols: Vec::new(),
7309                symbol_metadata: HashMap::new(),
7310                default_export_symbol: None,
7311                import_block: ImportBlock::empty(),
7312                lang: LangId::TypeScript,
7313            },
7314            nodes: vec![node.clone()],
7315            raw_refs: vec![raw_ref],
7316            dispatch_hints: vec![DispatchHint {
7317                id: "dispatch-main-helper".to_string(),
7318                method_name: "helper".to_string(),
7319                caller_node: node.id,
7320                file: "src/main.ts".to_string(),
7321                line: 1,
7322                byte_start: 24,
7323                byte_end: 32,
7324            }],
7325            surface_fingerprint: "surface".to_string(),
7326        }
7327    }
7328
7329    fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
7330        let raw = extract.raw_refs[0].clone();
7331        let mut dependencies = raw.dependencies.clone();
7332        dependencies.insert("src/helper.ts".to_string());
7333        ResolvedRef {
7334            edge: Some(EdgeRecord {
7335                edge_id: "edge-main-helper".to_string(),
7336                source_node: raw.caller_node.clone().expect("caller node"),
7337                target_node: Some("node-helper".to_string()),
7338                target_file: "src/helper.ts".to_string(),
7339                target_symbol: "helper".to_string(),
7340                kind: "call".to_string(),
7341                line: raw.line,
7342            }),
7343            raw,
7344            status: "resolved".to_string(),
7345            target_node: Some("node-helper".to_string()),
7346            target_file: Some("src/helper.ts".to_string()),
7347            target_symbol: Some("helper".to_string()),
7348            dependencies,
7349        }
7350    }
7351
7352    fn write_chunked_equivalence_fixture(project_root: &Path) {
7353        let ts_dir = project_root.join("ts");
7354        fs::create_dir_all(&ts_dir).expect("create ts dir");
7355        fs::write(
7356            ts_dir.join("leaf.ts"),
7357            "export function leaf(value: number) {\n  return value + 1;\n}\n",
7358        )
7359        .expect("write ts leaf");
7360        fs::write(
7361            ts_dir.join("mid.ts"),
7362            "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n  return leaf(value);\n}\n",
7363        )
7364        .expect("write ts mid");
7365        fs::write(
7366            ts_dir.join("entry.ts"),
7367            "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n  return mid(worker.run());\n}\n",
7368        )
7369        .expect("write ts entry");
7370        fs::write(
7371            ts_dir.join("worker.ts"),
7372            "export class Worker {\n  run() {\n    return 41;\n  }\n}\n",
7373        )
7374        .expect("write ts worker");
7375        for idx in 0..4 {
7376            fs::write(
7377                ts_dir.join(format!("extra_{idx}.ts")),
7378                format!(
7379                    "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n  return entry(new Worker());\n}}\n"
7380                ),
7381            )
7382            .expect("write ts extra");
7383        }
7384
7385        let rust_dir = project_root.join("src");
7386        let commands_dir = rust_dir.join("commands");
7387        fs::create_dir_all(&commands_dir).expect("create rust commands dir");
7388        fs::write(
7389            rust_dir.join("context.rs"),
7390            r#"pub struct AppContext;
7391
7392impl AppContext {
7393    pub fn callgraph_store_for_ops(&self) -> usize {
7394        1
7395    }
7396}
7397"#,
7398        )
7399        .expect("write rust context");
7400        fs::write(
7401            rust_dir.join("lib.rs"),
7402            "pub mod context;\npub mod commands;\n",
7403        )
7404        .expect("write rust lib");
7405        fs::write(
7406            commands_dir.join("mod.rs"),
7407            "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
7408        )
7409        .expect("write rust commands mod");
7410        for name in ["callers", "impact", "trace_to"] {
7411            fs::write(
7412                commands_dir.join(format!("{name}.rs")),
7413                format!(
7414                    r#"use crate::context::AppContext;
7415
7416pub fn handle_{name}(ctx: &AppContext) -> usize {{
7417    ctx.callgraph_store_for_ops()
7418}}
7419"#
7420                ),
7421            )
7422            .expect("write rust command");
7423        }
7424    }
7425
7426    fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
7427        let src_dir = project_root.join("src");
7428        fs::create_dir_all(&src_dir).expect("create src dir");
7429
7430        let target_path = src_dir.join("target.ts");
7431        fs::write(&target_path, "export function target() {\n  return 1;\n}\n")
7432            .expect("write target");
7433
7434        let index_path = src_dir.join("index.ts");
7435        fs::write(&index_path, barrel_source).expect("write barrel");
7436
7437        let mut files = vec![target_path, index_path];
7438        for (file_name, function_name) in [
7439            ("consumer_a.ts", "consumerA"),
7440            ("consumer_b.ts", "consumerB"),
7441            ("consumer_c.ts", "consumerC"),
7442        ] {
7443            let path = src_dir.join(file_name);
7444            fs::write(
7445                &path,
7446                format!(
7447                    "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n  return target();\n}}\n"
7448                ),
7449            )
7450            .expect("write consumer");
7451            files.push(path);
7452        }
7453        files
7454    }
7455
7456    fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
7457        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
7458        table_rows(&conn, table)
7459    }
7460
7461    fn graph_table_rows_without(
7462        store: &CallGraphStore,
7463        table: &str,
7464        excluded_columns: &[&str],
7465    ) -> Vec<String> {
7466        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
7467        table_rows_without(&conn, table, excluded_columns)
7468    }
7469
7470    fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
7471        table_rows_without(conn, table, &[])
7472    }
7473
7474    fn table_rows_without(
7475        conn: &Connection,
7476        table: &str,
7477        excluded_columns: &[&str],
7478    ) -> Vec<String> {
7479        let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
7480        let columns: Vec<String> = conn
7481            .prepare(&format!("PRAGMA table_info({table})"))
7482            .expect("prepare table_info")
7483            .query_map([], |row| row.get::<_, String>(1))
7484            .expect("query table_info")
7485            .collect::<std::result::Result<Vec<String>, _>>()
7486            .expect("collect columns")
7487            .into_iter()
7488            .filter(|column| !excluded_columns.contains(column.as_str()))
7489            .collect();
7490        let sql = format!(
7491            "SELECT {} FROM {table} ORDER BY {}",
7492            columns.join(", "),
7493            columns.join(", ")
7494        );
7495        conn.prepare(&sql)
7496            .expect("prepare table rows")
7497            .query_map([], |row| row_to_strings(row, columns.len()))
7498            .expect("query table rows")
7499            .collect::<std::result::Result<_, _>>()
7500            .expect("collect table rows")
7501    }
7502
7503    fn assert_cold_build_stats_match_except_elapsed(
7504        expected: &ColdBuildStats,
7505        actual: &ColdBuildStats,
7506    ) {
7507        assert_eq!(actual.files, expected.files, "file counts must match");
7508        assert_eq!(actual.nodes, expected.nodes, "node counts must match");
7509        assert_eq!(actual.refs, expected.refs, "ref counts must match");
7510        assert_eq!(actual.edges, expected.edges, "edge counts must match");
7511        assert_eq!(
7512            actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
7513            expected
7514                .failed_files
7515                .iter()
7516                .cloned()
7517                .collect::<BTreeSet<_>>(),
7518            "failed file sets must match"
7519        );
7520    }
7521
7522    fn backend_state_rows(conn: &Connection) -> Vec<String> {
7523        conn.prepare(
7524            "SELECT backend, workspace_root, file_path, content_hash, status
7525             FROM backend_file_state
7526             ORDER BY backend, workspace_root, file_path, content_hash, status",
7527        )
7528        .expect("prepare backend rows")
7529        .query_map([], |row| row_to_strings(row, 5))
7530        .expect("query backend rows")
7531        .collect::<std::result::Result<_, _>>()
7532        .expect("collect backend rows")
7533    }
7534
7535    fn secondary_indexes(conn: &Connection) -> Vec<String> {
7536        let mut indexes = Vec::new();
7537        for table in [
7538            "files",
7539            "nodes",
7540            "refs",
7541            "file_dependencies",
7542            "edges",
7543            "dispatch_hints",
7544            "type_ref_names",
7545            "backend_file_state",
7546            "meta",
7547        ] {
7548            let sql = format!("PRAGMA index_list({table})");
7549            let mut stmt = conn.prepare(&sql).expect("prepare index list");
7550            let rows = stmt
7551                .query_map([], |row| row.get::<_, String>(1))
7552                .expect("query index list");
7553            for name in rows {
7554                let name = name.expect("index name");
7555                if name.starts_with("idx_") {
7556                    indexes.push(format!("{table}:{name}"));
7557                }
7558            }
7559        }
7560        indexes.sort();
7561        indexes
7562    }
7563
7564    fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
7565        let mut values = Vec::with_capacity(len);
7566        for index in 0..len {
7567            let value = row.get_ref(index)?;
7568            values.push(match value {
7569                rusqlite::types::ValueRef::Null => "NULL".to_string(),
7570                rusqlite::types::ValueRef::Integer(value) => value.to_string(),
7571                rusqlite::types::ValueRef::Real(value) => value.to_string(),
7572                rusqlite::types::ValueRef::Text(value) => {
7573                    String::from_utf8_lossy(value).into_owned()
7574                }
7575                rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
7576            });
7577        }
7578        Ok(values.join("\u{1f}"))
7579    }
7580}
7581
7582#[cfg(test)]
7583mod build_pool_tests {
7584    use super::build_pool_size;
7585
7586    #[test]
7587    fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
7588        let size = build_pool_size();
7589        // Never zero, never the full core count, never above the 8 cap — this is
7590        // the starvation guard for the cold-build's all-cores tree-sitter pass.
7591        assert!(size >= 1, "pool size must be at least 1");
7592        assert!(size <= 8, "pool size must be capped at 8, got {size}");
7593
7594        let cores = std::thread::available_parallelism()
7595            .map(|p| p.get())
7596            .unwrap_or(1);
7597        let expected = cores.div_ceil(2).clamp(1, 8);
7598        assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
7599    }
7600}
7601
7602#[cfg(test)]
7603mod method_dispatch_inference_tests {
7604    use super::*;
7605    use std::fs;
7606    use tempfile::tempdir;
7607
7608    #[test]
7609    fn java_field_receiver_type_selects_declared_class_method() {
7610        let source = r#"class EntryPoint {
7611    private UserService userService;
7612
7613    void handle() {
7614        userService.find();
7615    }
7616}
7617
7618class UserService {
7619    void find() {}
7620}
7621
7622class AuditService {
7623    void find() {}
7624}
7625"#;
7626        let dir = tempdir().expect("temp dir");
7627        let root = dir.path();
7628        write_fixture(root, "src/EntryPoint.java", source);
7629        let reference = reference(
7630            "java",
7631            "src/EntryPoint.java",
7632            "EntryPoint::handle",
7633            "userService",
7634            "find",
7635            line_of(source, "userService.find()"),
7636        );
7637        let mut cache = DispatchSourceCache::new();
7638
7639        let receiver_type =
7640            infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
7641        assert_eq!(receiver_type, "UserService");
7642
7643        let candidates = vec![
7644            method_candidate("audit", "AuditService::find"),
7645            method_candidate("user", "UserService::find"),
7646        ];
7647        let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
7648            .expect("type candidate");
7649        assert_eq!(selected.scoped_name, "UserService::find");
7650
7651        let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
7652        assert!(
7653            select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
7654        );
7655    }
7656
7657    #[test]
7658    fn kotlin_property_and_local_value_types_are_inferred() {
7659        let source = r#"class Handler {
7660    private val auditService: AuditService = AuditService()
7661
7662    fun handle() {
7663        auditService.find()
7664        val userService: UserService = UserService()
7665        userService.find()
7666        val billingService = BillingService()
7667        billingService.find()
7668    }
7669}
7670
7671class UserService { fun find() {} }
7672class AuditService { fun find() {} }
7673class BillingService { fun find() {} }
7674"#;
7675        let dir = tempdir().expect("temp dir");
7676        let root = dir.path();
7677        write_fixture(root, "src/Handler.kt", source);
7678        let mut cache = DispatchSourceCache::new();
7679
7680        let audit_ref = reference(
7681            "kotlin",
7682            "src/Handler.kt",
7683            "Handler::handle",
7684            "auditService",
7685            "find",
7686            line_of(source, "auditService.find()"),
7687        );
7688        assert_eq!(
7689            infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
7690            Some("AuditService")
7691        );
7692
7693        let user_ref = reference(
7694            "kotlin",
7695            "src/Handler.kt",
7696            "Handler::handle",
7697            "userService",
7698            "find",
7699            line_of(source, "userService.find()"),
7700        );
7701        assert_eq!(
7702            infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
7703            Some("UserService")
7704        );
7705
7706        let billing_ref = reference(
7707            "kotlin",
7708            "src/Handler.kt",
7709            "Handler::handle",
7710            "billingService",
7711            "find",
7712            line_of(source, "billingService.find()"),
7713        );
7714        assert_eq!(
7715            infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
7716            Some("BillingService")
7717        );
7718    }
7719
7720    #[test]
7721    fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
7722        let source = r#"struct Foo { void run(); };
7723struct PointerFoo { void run(); };
7724struct FactoryFoo { void run(); };
7725FactoryFoo makeFactoryFoo();
7726
7727void handle() {
7728    Foo foo;
7729    foo.run();
7730    PointerFoo* pointerFoo = nullptr;
7731    pointerFoo->run();
7732    auto factoryFoo = makeFactoryFoo();
7733    factoryFoo.run();
7734}
7735"#;
7736        let dir = tempdir().expect("temp dir");
7737        let root = dir.path();
7738        write_fixture(root, "src/fixture.cpp", source);
7739        let mut cache = DispatchSourceCache::new();
7740
7741        let foo_ref = reference(
7742            "cpp",
7743            "src/fixture.cpp",
7744            "handle",
7745            "foo",
7746            "run",
7747            line_of(source, "foo.run()"),
7748        );
7749        assert_eq!(
7750            infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
7751            Some("Foo")
7752        );
7753
7754        let pointer_ref = reference(
7755            "cpp",
7756            "src/fixture.cpp",
7757            "handle",
7758            "pointerFoo",
7759            "run",
7760            line_of(source, "pointerFoo->run()"),
7761        );
7762        assert_eq!(
7763            infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
7764            Some("PointerFoo")
7765        );
7766
7767        let factory_ref = reference(
7768            "cpp",
7769            "src/fixture.cpp",
7770            "handle",
7771            "factoryFoo",
7772            "run",
7773            line_of(source, "factoryFoo.run()"),
7774        );
7775        assert_eq!(
7776            infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
7777            Some("FactoryFoo")
7778        );
7779    }
7780
7781    #[test]
7782    fn unknown_java_receiver_still_uses_name_match_fallback() {
7783        let source = r#"class EntryPoint {
7784    void handle() {
7785        service.runSpecial();
7786    }
7787}
7788
7789class OnlyService {
7790    void runSpecial() {}
7791}
7792"#;
7793        let dir = tempdir().expect("temp dir");
7794        let root = dir.path();
7795        write_fixture(root, "src/EntryPoint.java", source);
7796        let reference = reference(
7797            "java",
7798            "src/EntryPoint.java",
7799            "EntryPoint::handle",
7800            "service",
7801            "runSpecial",
7802            line_of(source, "service.runSpecial()"),
7803        );
7804        let mut cache = DispatchSourceCache::new();
7805
7806        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
7807        let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
7808        let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
7809        assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
7810    }
7811
7812    fn reference(
7813        lang: &str,
7814        caller_file: &str,
7815        caller_symbol: &str,
7816        receiver: &str,
7817        method_name: &str,
7818        line: u32,
7819    ) -> NameMatchRef {
7820        NameMatchRef {
7821            ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
7822            caller_node: format!("{caller_symbol}:node"),
7823            caller_file: caller_file.to_string(),
7824            caller_symbol: caller_symbol.to_string(),
7825            caller_signature: None,
7826            receiver: receiver.to_string(),
7827            method_name: method_name.to_string(),
7828            colon_dispatch: false,
7829            line,
7830            lang: lang.to_string(),
7831        }
7832    }
7833
7834    fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
7835        NameMatchCandidate {
7836            node_id: node_id.to_string(),
7837            file_path: "src/targets.fixture".to_string(),
7838            scoped_name: scoped_name.to_string(),
7839            kind: "method".to_string(),
7840        }
7841    }
7842
7843    fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
7844        let path = root.join(rel_path);
7845        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
7846        fs::write(path, source).expect("write fixture");
7847    }
7848
7849    fn line_of(source: &str, needle: &str) -> u32 {
7850        source
7851            .lines()
7852            .position(|line| line.contains(needle))
7853            .map(|index| index as u32 + 1)
7854            .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
7855    }
7856}