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    // v6 -> v7-lean: file-level dependency rows and structured refs without raw JSON payloads.
2396    let input = format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v7-lean");
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: callgraph::is_entry_point(
2952                scoped_name,
2953                &meta.kind,
2954                exported,
2955                data.lang,
2956            ),
2957        });
2958    }
2959
2960    Ok(records)
2961}
2962
2963fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
2964    if scoped_name == TOP_LEVEL_SYMBOL {
2965        return Range {
2966            start_line: 0,
2967            start_col: 0,
2968            end_line: 0,
2969            end_col: 0,
2970        };
2971    }
2972    let Some(line) = source.lines().nth(fallback.start_line as usize) else {
2973        return fallback.clone();
2974    };
2975    let start_col = fallback.start_col as usize;
2976    let search_start = start_col.min(line.len());
2977    if let Some(offset) = line[search_start..].find(name) {
2978        let col = search_start + offset;
2979        return Range {
2980            start_line: fallback.start_line,
2981            start_col: col as u32,
2982            end_line: fallback.start_line,
2983            end_col: (col + name.len()) as u32,
2984        };
2985    }
2986    if let Some(offset) = line.find(name) {
2987        return Range {
2988            start_line: fallback.start_line,
2989            start_col: offset as u32,
2990            end_line: fallback.start_line,
2991            end_col: (offset + name.len()) as u32,
2992        };
2993    }
2994    Range {
2995        start_line: fallback.start_line,
2996        start_col: fallback.start_col,
2997        end_line: fallback.start_line,
2998        end_col: fallback.start_col.saturating_add(name.len() as u32),
2999    }
3000}
3001
3002fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
3003    if scoped_name == TOP_LEVEL_SYMBOL {
3004        return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
3005    }
3006    let input = format!(
3007        "{rel_path}:{}:{}:{}:{}:{ordinal}",
3008        range.start_line, range.start_col, range.end_line, range.end_col
3009    );
3010    format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
3011}
3012
3013fn build_call_refs(
3014    rel_path: &str,
3015    data: &FileCallData,
3016    node_by_scoped: &HashMap<String, String>,
3017    import_dependencies: &BTreeSet<String>,
3018) -> Vec<RawRef> {
3019    let mut refs = Vec::new();
3020    let mut ordinal = 0usize;
3021    let mut symbols: Vec<_> = data.calls_by_symbol.iter().collect();
3022    symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
3023    for (caller_symbol, call_sites) in symbols {
3024        let caller_node = node_by_scoped.get(caller_symbol).cloned();
3025        for call_site in call_sites {
3026            ordinal += 1;
3027            let ref_id = ref_id(&[
3028                rel_path,
3029                "call",
3030                caller_symbol,
3031                &call_site.line.to_string(),
3032                &call_site.byte_start.to_string(),
3033                &call_site.byte_end.to_string(),
3034                &call_site.full_callee,
3035                &ordinal.to_string(),
3036            ]);
3037            refs.push(RawRef {
3038                ref_id,
3039                caller_node: caller_node.clone(),
3040                caller_symbol: Some(caller_symbol.clone()),
3041                caller_file: rel_path.to_string(),
3042                kind: "call".to_string(),
3043                short_name: Some(call_site.callee_name.clone()),
3044                full_ref: Some(call_site.full_callee.clone()),
3045                module_path: None,
3046                import_kind: None,
3047                local_name: Some(call_site.callee_name.clone()),
3048                requested_name: Some(call_site.callee_name.clone()),
3049                namespace_alias: namespace_alias(&call_site.full_callee),
3050                wildcard: false,
3051                line: call_site.line,
3052                byte_start: call_site.byte_start,
3053                byte_end: call_site.byte_end,
3054                dependencies: import_dependencies.clone(),
3055            });
3056        }
3057    }
3058    refs
3059}
3060
3061fn build_import_refs(
3062    project_root: &Path,
3063    abs_path: &Path,
3064    rel_path: &str,
3065    imports: &[ImportStatement],
3066    line_index: &LineIndex,
3067) -> Vec<RawRef> {
3068    let mut refs = Vec::new();
3069    for (index, import) in imports.iter().enumerate() {
3070        let import_kind = import_kind_label(import.kind).to_string();
3071        let local_name = import_local_names(import).join(",");
3072        let requested_name = import_requested_names(import).join(",");
3073        let ref_id = ref_id(&[
3074            rel_path,
3075            "import",
3076            &import.byte_range.start.to_string(),
3077            &import.byte_range.end.to_string(),
3078            &import.module_path,
3079            &index.to_string(),
3080        ]);
3081        refs.push(RawRef {
3082            ref_id,
3083            caller_node: None,
3084            caller_symbol: None,
3085            caller_file: rel_path.to_string(),
3086            kind: "import".to_string(),
3087            short_name: None,
3088            full_ref: Some(import.raw_text.clone()),
3089            module_path: Some(import.module_path.clone()),
3090            import_kind: Some(import_kind),
3091            local_name: empty_to_none(local_name),
3092            requested_name: empty_to_none(requested_name),
3093            namespace_alias: import.namespace_import.clone(),
3094            wildcard: import_is_wildcard(import),
3095            line: line_index.byte_to_line(import.byte_range.start),
3096            byte_start: import.byte_range.start,
3097            byte_end: import.byte_range.end,
3098            dependencies: module_dependencies(project_root, abs_path, &import.module_path),
3099        });
3100    }
3101    refs
3102}
3103
3104#[derive(Debug, Clone)]
3105struct ReexportRefs {
3106    raw_refs: Vec<RawRef>,
3107    surface_parts: Vec<String>,
3108}
3109
3110fn collect_reexport_refs(
3111    project_root: &Path,
3112    abs_path: &Path,
3113    rel_path: &str,
3114    source: &str,
3115) -> ReexportRefs {
3116    let mut raw_refs = Vec::new();
3117    let mut surface_parts = Vec::new();
3118    let mut search_start = 0usize;
3119    let mut ordinal = 0usize;
3120    while let Some(export_offset) = source[search_start..].find("export") {
3121        let start = search_start + export_offset;
3122        let Some(statement_end_offset) = source[start..].find(';') else {
3123            break;
3124        };
3125        let end = start + statement_end_offset + 1;
3126        let statement = &source[start..end];
3127        search_start = end;
3128        if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
3129            continue;
3130        }
3131        let Some(module_path) = quoted_module_path(statement) else {
3132            continue;
3133        };
3134        ordinal += 1;
3135        let wildcard = statement.contains('*');
3136        let line = source[..start]
3137            .bytes()
3138            .filter(|byte| *byte == b'\n')
3139            .count() as u32
3140            + 1;
3141        let ref_id = ref_id(&[
3142            rel_path,
3143            "reexport",
3144            &start.to_string(),
3145            &end.to_string(),
3146            &module_path,
3147            &ordinal.to_string(),
3148        ]);
3149        surface_parts.push(format!("reexport\t{statement}"));
3150        raw_refs.push(RawRef {
3151            ref_id,
3152            caller_node: None,
3153            caller_symbol: None,
3154            caller_file: rel_path.to_string(),
3155            kind: "reexport".to_string(),
3156            short_name: None,
3157            full_ref: Some(statement.to_string()),
3158            module_path: Some(module_path.clone()),
3159            import_kind: Some("reexport".to_string()),
3160            local_name: None,
3161            requested_name: None,
3162            namespace_alias: None,
3163            wildcard,
3164            line,
3165            byte_start: start,
3166            byte_end: end,
3167            dependencies: module_dependencies(project_root, abs_path, &module_path),
3168        });
3169    }
3170    ReexportRefs {
3171        raw_refs,
3172        surface_parts,
3173    }
3174}
3175
3176fn quoted_module_path(statement: &str) -> Option<String> {
3177    let quote = match (statement.find('\''), statement.find('"')) {
3178        (Some(single), Some(double)) if single < double => '\'',
3179        (Some(_), Some(_)) => '"',
3180        (Some(_), None) => '\'',
3181        (None, Some(_)) => '"',
3182        (None, None) => return None,
3183    };
3184    let start = statement.find(quote)? + 1;
3185    let end = statement[start..].find(quote)? + start;
3186    Some(statement[start..end].to_string())
3187}
3188
3189#[derive(Debug, Clone)]
3190struct SourceLessExportRefs {
3191    raw_refs: Vec<RawRef>,
3192    surface_parts: Vec<String>,
3193}
3194
3195fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
3196    let mut raw_refs = Vec::new();
3197    let mut surface_parts = Vec::new();
3198    let mut search_start = 0usize;
3199    let mut ordinal = 0usize;
3200    while let Some(export_offset) = source[search_start..].find("export") {
3201        let start = search_start + export_offset;
3202        let Some(statement_end_offset) = source[start..].find(';') else {
3203            break;
3204        };
3205        let end = start + statement_end_offset + 1;
3206        let statement = &source[start..end];
3207        search_start = end;
3208        if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
3209            continue;
3210        }
3211        let aliases = parse_reexport_names(statement);
3212        if aliases.is_empty() {
3213            continue;
3214        }
3215        let line = source[..start]
3216            .bytes()
3217            .filter(|byte| *byte == b'\n')
3218            .count() as u32
3219            + 1;
3220        for (exported, source_symbol) in aliases {
3221            ordinal += 1;
3222            let ref_id = ref_id(&[
3223                rel_path,
3224                "export_alias",
3225                &start.to_string(),
3226                &end.to_string(),
3227                &exported,
3228                &source_symbol,
3229                &ordinal.to_string(),
3230            ]);
3231            surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
3232            raw_refs.push(RawRef {
3233                ref_id,
3234                caller_node: None,
3235                caller_symbol: None,
3236                caller_file: rel_path.to_string(),
3237                kind: "export_alias".to_string(),
3238                short_name: None,
3239                full_ref: Some(statement.to_string()),
3240                module_path: None,
3241                import_kind: Some("export_alias".to_string()),
3242                local_name: Some(exported),
3243                requested_name: Some(source_symbol),
3244                namespace_alias: None,
3245                wildcard: false,
3246                line,
3247                byte_start: start,
3248                byte_end: end,
3249                dependencies: BTreeSet::new(),
3250            });
3251        }
3252    }
3253    SourceLessExportRefs {
3254        raw_refs,
3255        surface_parts,
3256    }
3257}
3258
3259fn build_dispatch_hints(
3260    rel_path: &str,
3261    data: &FileCallData,
3262    node_by_scoped: &HashMap<String, String>,
3263) -> Vec<DispatchHint> {
3264    let mut hints = Vec::new();
3265    let mut ordinal = 0usize;
3266    for (caller_symbol, call_sites) in &data.calls_by_symbol {
3267        let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
3268            continue;
3269        };
3270        for call_site in call_sites {
3271            if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
3272                continue;
3273            }
3274            ordinal += 1;
3275            hints.push(DispatchHint {
3276                id: ref_id(&[
3277                    rel_path,
3278                    "dispatch",
3279                    caller_symbol,
3280                    &call_site.line.to_string(),
3281                    &call_site.byte_start.to_string(),
3282                    &call_site.byte_end.to_string(),
3283                    &ordinal.to_string(),
3284                ]),
3285                method_name: call_site.callee_name.clone(),
3286                caller_node: caller_node.clone(),
3287                file: rel_path.to_string(),
3288                line: call_site.line,
3289                byte_start: call_site.byte_start,
3290                byte_end: call_site.byte_end,
3291            });
3292        }
3293    }
3294    hints
3295}
3296
3297fn surface_fingerprint(
3298    nodes: &mut [NodeRecord],
3299    data: &FileCallData,
3300    reexport_parts: &[String],
3301) -> String {
3302    nodes.sort_by(|left, right| {
3303        (left.file_path.as_str(), left.scoped_name.as_str())
3304            .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
3305    });
3306    let mut parts = Vec::new();
3307    for node in nodes.iter() {
3308        parts.push(format!(
3309            "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
3310            node.scoped_name,
3311            node.name,
3312            node.kind,
3313            node.exported,
3314            node.range.start_line,
3315            node.range.start_col,
3316            node.range.end_line,
3317            node.range.end_col,
3318            node.range_ordinal,
3319            node.signature.as_deref().unwrap_or("")
3320        ));
3321    }
3322    let mut exports = data.exported_symbols.clone();
3323    exports.sort();
3324    for export in exports {
3325        parts.push(format!("export\t{export}"));
3326    }
3327    if let Some(default_export) = &data.default_export_symbol {
3328        parts.push(format!("default\t{default_export}"));
3329    }
3330    let mut imports: Vec<String> = data
3331        .import_block
3332        .imports
3333        .iter()
3334        .map(|import| {
3335            format!(
3336                "import\t{}\t{:?}\t{}",
3337                import.module_path, import.form, import.raw_text
3338            )
3339        })
3340        .collect();
3341    imports.sort();
3342    parts.extend(imports);
3343    parts.extend(reexport_parts.iter().cloned());
3344    hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
3345}
3346
3347fn resolve_ref(raw: RawRef, index: &ProjectIndex<'_>) -> Result<ResolvedRef> {
3348    if raw.kind != "call" {
3349        return Ok(ResolvedRef {
3350            dependencies: raw.dependencies.clone(),
3351            raw,
3352            status: "unresolved".to_string(),
3353            target_node: None,
3354            target_file: None,
3355            target_symbol: None,
3356            edge: None,
3357        });
3358    }
3359
3360    let caller_file = raw.caller_file.clone();
3361    let caller_data = index.caller_data.get(&caller_file).ok_or_else(|| {
3362        CallGraphStoreError::MissingCallerData {
3363            file: caller_file.clone(),
3364        }
3365    })?;
3366    let full_ref = raw.full_ref.as_deref().unwrap_or_default();
3367    let short_name = raw.short_name.as_deref().unwrap_or_default();
3368    let mut dependencies = raw.dependencies.clone();
3369
3370    let resolved = match index.lang_for(&caller_file) {
3371        Some(LangId::Rust) => {
3372            resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data)
3373        }
3374        Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
3375            resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
3376        }
3377        _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
3378    };
3379
3380    let Some((status, target_file, target_symbol)) = resolved else {
3381        return Ok(ResolvedRef {
3382            raw,
3383            status: "unresolved".to_string(),
3384            target_node: None,
3385            target_file: None,
3386            target_symbol: None,
3387            dependencies,
3388            edge: None,
3389        });
3390    };
3391
3392    dependencies.insert(target_file.clone());
3393    let target_node = index.node_for_symbol(&target_file, &target_symbol);
3394    let source_node = raw.caller_node.clone();
3395    let edge = if let Some(source_node) = source_node {
3396        if target_file == caller_file
3397            && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
3398        {
3399            None
3400        } else {
3401            Some(EdgeRecord {
3402                edge_id: ref_id(&[&raw.ref_id, "edge"]),
3403                source_node,
3404                target_node: target_node.clone(),
3405                target_file: target_file.clone(),
3406                target_symbol: target_symbol.clone(),
3407                kind: "call".to_string(),
3408                line: raw.line,
3409            })
3410        }
3411    } else {
3412        None
3413    };
3414
3415    Ok(ResolvedRef {
3416        raw,
3417        status,
3418        target_node,
3419        target_file: Some(target_file),
3420        target_symbol: Some(target_symbol),
3421        dependencies,
3422        edge,
3423    })
3424}
3425
3426fn resolve_js_ts_target(
3427    index: &ProjectIndex<'_>,
3428    caller_file: &str,
3429    full_ref: &str,
3430    short_name: &str,
3431    caller_data: &FileCallData,
3432) -> Option<(String, String, String)> {
3433    if let Some((namespace, member)) = full_ref.split_once('.') {
3434        for import in &caller_data.import_block.imports {
3435            if import.namespace_import.as_deref() == Some(namespace) {
3436                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
3437                    if let Some((file, symbol)) =
3438                        resolve_exported_symbol(index, &target_file, member, 0)
3439                    {
3440                        return Some(("resolved".to_string(), file, symbol));
3441                    }
3442                }
3443            }
3444        }
3445    }
3446
3447    for import in &caller_data.import_block.imports {
3448        for spec in &import.names {
3449            if crate::imports::specifier_local_name(spec) == short_name {
3450                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
3451                    let requested = crate::imports::specifier_imported_name(spec);
3452                    let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
3453                        .unwrap_or_else(|| (target_file, requested.to_string()));
3454                    return Some(("resolved".to_string(), file, symbol));
3455                }
3456            }
3457        }
3458
3459        if import.default_import.as_deref() == Some(short_name) {
3460            if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
3461                let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
3462                    .or_else(|| {
3463                        index
3464                            .files
3465                            .get(&target_file)
3466                            .and_then(|file| file.default_export.clone())
3467                            .map(|symbol| (target_file.clone(), symbol))
3468                    })
3469                    .unwrap_or_else(|| {
3470                        let file_name = Path::new(&target_file)
3471                            .file_name()
3472                            .and_then(|name| name.to_str())
3473                            .unwrap_or("unknown")
3474                            .to_string();
3475                        (target_file, format!("<default:{file_name}>"))
3476                    });
3477                return Some(("resolved".to_string(), file, symbol));
3478            }
3479        }
3480    }
3481
3482    for import in &caller_data.import_block.imports {
3483        if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
3484            if index
3485                .files
3486                .get(&target_file)
3487                .map(|file| file.exports.contains(short_name))
3488                .unwrap_or(false)
3489            {
3490                return Some(("resolved".to_string(), target_file, short_name.to_string()));
3491            }
3492        }
3493    }
3494
3495    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
3496}
3497
3498fn resolve_exported_symbol(
3499    index: &ProjectIndex<'_>,
3500    file: &str,
3501    requested: &str,
3502    depth: usize,
3503) -> Option<(String, String)> {
3504    if depth > 16 {
3505        return None;
3506    }
3507    if requested != "default" {
3508        if let Some(source_symbol) = index
3509            .files
3510            .get(file)
3511            .and_then(|item| item.export_aliases.get(requested))
3512        {
3513            return Some((file.to_string(), source_symbol.clone()));
3514        }
3515        if index
3516            .files
3517            .get(file)
3518            .map(|item| item.exports.contains(requested))
3519            .unwrap_or(false)
3520        {
3521            return Some((file.to_string(), requested.to_string()));
3522        }
3523    } else if let Some(default) = index
3524        .files
3525        .get(file)
3526        .and_then(|item| item.default_export.clone())
3527    {
3528        return Some((file.to_string(), default));
3529    }
3530
3531    for reexport in index.reexports_for(file) {
3532        let mut next_requested = requested.to_string();
3533        let matches = if reexport.wildcard {
3534            true
3535        } else if let Some(source_name) = reexport.named.get(requested) {
3536            next_requested = source_name.clone();
3537            true
3538        } else {
3539            false
3540        };
3541        if !matches {
3542            continue;
3543        }
3544        if let Some(target_file) = &reexport.target_file {
3545            if let Some(target) =
3546                resolve_exported_symbol(index, target_file, &next_requested, depth + 1)
3547            {
3548                return Some(target);
3549            }
3550        }
3551    }
3552    None
3553}
3554
3555fn resolve_rust_target(
3556    index: &ProjectIndex<'_>,
3557    caller_file: &str,
3558    full_ref: &str,
3559    short_name: &str,
3560    caller_data: &FileCallData,
3561) -> Option<(String, String, String)> {
3562    if full_ref.contains("::") {
3563        if let Some(target_file) = rust_target_for_qualified(index, caller_file, full_ref) {
3564            return Some((
3565                "resolved".to_string(),
3566                target_file,
3567                rust_target_symbol(full_ref, short_name),
3568            ));
3569        }
3570    }
3571
3572    for import in &caller_data.import_block.imports {
3573        if let Some((target_file, target_symbol)) =
3574            rust_target_for_use(index, caller_file, import, short_name)
3575        {
3576            return Some(("resolved".to_string(), target_file, target_symbol));
3577        }
3578    }
3579
3580    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
3581}
3582
3583fn rust_target_for_qualified(
3584    index: &ProjectIndex<'_>,
3585    caller_file: &str,
3586    full_ref: &str,
3587) -> Option<String> {
3588    let mut segments: Vec<&str> = full_ref.split("::").collect();
3589    if segments.len() < 2 {
3590        return None;
3591    }
3592    segments.pop();
3593    if !matches!(segments.first().copied(), Some("crate" | "self" | "super")) {
3594        if let Some(target) = rust_workspace_file_for_segments(index, &segments) {
3595            return Some(target);
3596        }
3597    }
3598    let module_segments = rust_resolve_segments(caller_file, &segments)?;
3599    rust_file_for_segments(index, caller_file, &module_segments)
3600}
3601
3602fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
3603    full_ref
3604        .rsplit("::")
3605        .next()
3606        .filter(|name| !name.is_empty())
3607        .unwrap_or(short_name)
3608        .to_string()
3609}
3610
3611fn rust_target_for_use(
3612    index: &ProjectIndex<'_>,
3613    caller_file: &str,
3614    import: &ImportStatement,
3615    short_name: &str,
3616) -> Option<(String, String)> {
3617    let path = import.module_path.trim().trim_end_matches(';');
3618    if let Some(brace_start) = path.find("::{") {
3619        let prefix = &path[..brace_start];
3620        if import.names.iter().any(|name| name == short_name) {
3621            let prefix_segments: Vec<&str> = prefix.split("::").collect();
3622            let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
3623            let file = rust_file_for_segments(index, caller_file, &module_segments)?;
3624            return Some((file, short_name.to_string()));
3625        }
3626        return None;
3627    }
3628
3629    let (path_without_alias, alias) = path
3630        .split_once(" as ")
3631        .map(|(left, right)| (left.trim(), Some(right.trim())))
3632        .unwrap_or((path, None));
3633    let segments: Vec<&str> = path_without_alias.split("::").collect();
3634    let imported = alias.or_else(|| segments.last().copied())?;
3635    if imported != short_name {
3636        return None;
3637    }
3638    if segments.len() < 2 {
3639        return None;
3640    }
3641    let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
3642    let file = rust_file_for_segments(index, caller_file, &module_segments)?;
3643    Some((file, segments.last().unwrap_or(&short_name).to_string()))
3644}
3645
3646fn rust_workspace_file_for_segments(index: &ProjectIndex<'_>, segments: &[&str]) -> Option<String> {
3647    let crate_name = segments.first().copied()?;
3648    let src_prefix = index.crate_src_prefix(crate_name)?;
3649    let module_segments = segments[1..]
3650        .iter()
3651        .map(|segment| segment.to_string())
3652        .collect::<Vec<_>>();
3653    rust_file_for_src_prefix(index, &src_prefix, &module_segments)
3654}
3655
3656/// Walk the project tree once and map every Rust crate name (package name with
3657/// `-` normalized to `_`, plus any explicit `[lib] name`) to its `src` prefix.
3658/// Replaces the previous per-ref tree walk: resolving 600k+ qualified refs no
3659/// longer re-walks the filesystem once per ref.
3660fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
3661    let mut prefixes = HashMap::new();
3662    let mut stack = vec![project_root.to_path_buf()];
3663    while let Some(dir) = stack.pop() {
3664        let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
3665        if matches!(name, "target" | "node_modules" | ".git") {
3666            continue;
3667        }
3668        let manifest = dir.join("Cargo.toml");
3669        if manifest.is_file() {
3670            let crate_names = rust_manifest_crate_names(&manifest);
3671            if !crate_names.is_empty() {
3672                let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
3673                for crate_name in crate_names {
3674                    prefixes
3675                        .entry(crate_name)
3676                        .or_insert_with(|| src_prefix.clone());
3677                }
3678            }
3679        }
3680        let Ok(entries) = std::fs::read_dir(&dir) else {
3681            continue;
3682        };
3683        for entry in entries.flatten() {
3684            let path = entry.path();
3685            if path.is_dir() {
3686                stack.push(path);
3687            }
3688        }
3689    }
3690    prefixes
3691}
3692
3693/// Extract the crate names a manifest defines: the normalized package name
3694/// (`-` -> `_`) and any explicit `[lib] name`. Returns both so a crate is
3695/// reachable by either spelling, matching the previous match semantics.
3696fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
3697    let Ok(source) = std::fs::read_to_string(manifest) else {
3698        return Vec::new();
3699    };
3700    let mut in_lib = false;
3701    let mut package_name = None;
3702    let mut lib_name = None;
3703    for line in source.lines() {
3704        let trimmed = line.trim();
3705        if trimmed.starts_with('[') {
3706            in_lib = trimmed == "[lib]";
3707            continue;
3708        }
3709        let Some((key, value)) = trimmed.split_once('=') else {
3710            continue;
3711        };
3712        let key = key.trim();
3713        let value = value.trim().trim_matches('"');
3714        if in_lib && key == "name" {
3715            lib_name = Some(value.to_string());
3716        } else if !in_lib && key == "name" && package_name.is_none() {
3717            package_name = Some(value.to_string());
3718        }
3719    }
3720    let mut names = Vec::new();
3721    if let Some(lib) = lib_name {
3722        names.push(lib);
3723    }
3724    if let Some(package) = package_name {
3725        let normalized = package.replace('-', "_");
3726        if !names.contains(&normalized) {
3727            names.push(normalized);
3728        }
3729    }
3730    names
3731}
3732
3733fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
3734    if segments.is_empty() {
3735        return Some(Vec::new());
3736    }
3737    let caller_segments = rust_module_segments_for_rel(caller_file);
3738    match segments[0] {
3739        "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
3740        "self" => {
3741            let mut resolved = caller_segments;
3742            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
3743            Some(resolved)
3744        }
3745        "super" => {
3746            let mut resolved = caller_segments;
3747            resolved.pop();
3748            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
3749            Some(resolved)
3750        }
3751        _ => {
3752            let mut resolved = caller_segments;
3753            resolved.pop();
3754            resolved.extend(segments.iter().map(|item| item.to_string()));
3755            Some(resolved)
3756        }
3757    }
3758}
3759
3760fn rust_file_for_segments(
3761    index: &ProjectIndex<'_>,
3762    caller_file: &str,
3763    segments: &[String],
3764) -> Option<String> {
3765    rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
3766}
3767
3768fn rust_file_for_src_prefix(
3769    index: &ProjectIndex<'_>,
3770    src_prefix: &str,
3771    segments: &[String],
3772) -> Option<String> {
3773    let candidate = if segments.is_empty() {
3774        [src_prefix, "lib.rs"].join("/")
3775    } else {
3776        format!("{}/{}.rs", src_prefix, segments.join("/"))
3777    };
3778    if index.files.contains_key(&candidate) {
3779        return Some(candidate);
3780    }
3781    if !segments.is_empty() {
3782        let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
3783        if index.files.contains_key(&mod_candidate) {
3784            return Some(mod_candidate);
3785        }
3786    }
3787    None
3788}
3789
3790fn rust_src_prefix(rel_path: &str) -> String {
3791    rel_path
3792        .split_once("/src/")
3793        .map(|(prefix, _)| format!("{prefix}/src"))
3794        .unwrap_or_else(|| "src".to_string())
3795}
3796
3797fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
3798    let after_src = rel_path
3799        .split_once("/src/")
3800        .map(|(_, rest)| rest)
3801        .or_else(|| rel_path.strip_prefix("src/"))
3802        .unwrap_or(rel_path);
3803    if matches!(after_src, "lib.rs" | "main.rs") {
3804        return Vec::new();
3805    }
3806    if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
3807        return prefix.split('/').map(|item| item.to_string()).collect();
3808    }
3809    after_src
3810        .strip_suffix(".rs")
3811        .unwrap_or(after_src)
3812        .split('/')
3813        .map(|item| item.to_string())
3814        .collect()
3815}
3816
3817fn resolve_local_target(
3818    _index: &ProjectIndex<'_>,
3819    caller_file: &str,
3820    full_ref: &str,
3821    short_name: &str,
3822    caller_data: &FileCallData,
3823) -> Option<(String, String, String)> {
3824    if !callgraph::is_bare_callee(full_ref, short_name) {
3825        return None;
3826    }
3827    callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
3828        .ok()
3829        .map(|symbol| {
3830            (
3831                "resolved_local".to_string(),
3832                caller_file.to_string(),
3833                symbol,
3834            )
3835        })
3836}
3837
3838impl<'a> ProjectIndex<'a> {
3839    fn from_parts(
3840        project_root: &Path,
3841        files: HashMap<String, DbFileIndex>,
3842        caller_data: HashMap<String, &'a FileCallData>,
3843    ) -> Self {
3844        Self {
3845            project_root: project_root.to_path_buf(),
3846            files,
3847            caller_data,
3848            workspace_crate_prefixes: std::sync::OnceLock::new(),
3849        }
3850    }
3851
3852    fn from_extracts(project_root: &Path, extracts: &'a [FileExtract]) -> Self {
3853        let mut files = HashMap::new();
3854        let mut caller_data = HashMap::new();
3855        for extract in extracts {
3856            let index = DbFileIndex::from_extract(project_root, extract);
3857            caller_data.insert(extract.rel_path.clone(), &extract.data);
3858            files.insert(extract.rel_path.clone(), index);
3859        }
3860        Self::from_parts(project_root, files, caller_data)
3861    }
3862
3863    fn from_db_and_callers(
3864        tx: &Transaction<'_>,
3865        project_root: &Path,
3866        caller_extracts: &'a HashMap<String, FileExtract>,
3867    ) -> Result<Self> {
3868        let mut files = load_db_file_indexes(tx, project_root)?;
3869        let mut caller_data = HashMap::new();
3870        for (rel_path, extract) in caller_extracts {
3871            files.insert(
3872                rel_path.clone(),
3873                DbFileIndex::from_extract(project_root, extract),
3874            );
3875            caller_data.insert(rel_path.clone(), &extract.data);
3876        }
3877        Ok(Self::from_parts(project_root, files, caller_data))
3878    }
3879
3880    fn lang_for(&self, rel_path: &str) -> Option<LangId> {
3881        self.files.get(rel_path).and_then(|file| file.lang)
3882    }
3883
3884    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
3885        self.files
3886            .get(caller_file)
3887            .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
3888    }
3889
3890    fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
3891        self.files
3892            .get(rel_path)
3893            .map(|file| file.reexports.as_slice())
3894            .unwrap_or(&[])
3895    }
3896
3897    fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
3898        self.files.get(rel_path).and_then(|file| {
3899            file.node_by_scoped
3900                .get(symbol)
3901                .cloned()
3902                .or_else(|| file.node_by_bare.get(symbol).cloned())
3903        })
3904    }
3905}
3906
3907impl DbFileIndex {
3908    fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
3909        let mut node_by_scoped = HashMap::new();
3910        let mut node_by_bare = HashMap::new();
3911        for node in &extract.nodes {
3912            node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
3913            node_by_bare
3914                .entry(node.name.clone())
3915                .or_insert(node.id.clone());
3916        }
3917        let mut export_aliases = HashMap::new();
3918        for raw_ref in &extract.raw_refs {
3919            if raw_ref.kind == "export_alias" {
3920                if let (Some(exported), Some(source_symbol)) =
3921                    (&raw_ref.local_name, &raw_ref.requested_name)
3922                {
3923                    export_aliases.insert(exported.clone(), source_symbol.clone());
3924                }
3925            }
3926        }
3927        let mut module_targets = HashMap::new();
3928        for import in &extract.data.import_block.imports {
3929            module_targets.insert(
3930                import.module_path.clone(),
3931                module_target_from_dependencies(
3932                    project_root,
3933                    &module_dependencies(project_root, &extract.abs_path, &import.module_path),
3934                ),
3935            );
3936        }
3937        let mut reexports = Vec::new();
3938        for raw_ref in &extract.raw_refs {
3939            if raw_ref.kind == "reexport" {
3940                if let Some(module_path) = &raw_ref.module_path {
3941                    let target_file =
3942                        module_target_from_dependencies(project_root, &raw_ref.dependencies);
3943                    module_targets.insert(module_path.clone(), target_file.clone());
3944                    reexports.push(reexport_index_from_raw(raw_ref, target_file));
3945                }
3946            }
3947        }
3948        Self {
3949            lang: Some(extract.lang),
3950            exports: extract.data.exported_symbols.iter().cloned().collect(),
3951            default_export: extract.data.default_export_symbol.clone(),
3952            export_aliases,
3953            node_by_scoped,
3954            node_by_bare,
3955            module_targets,
3956            reexports,
3957        }
3958    }
3959}
3960
3961fn load_db_file_indexes(
3962    tx: &Transaction<'_>,
3963    project_root: &Path,
3964) -> Result<HashMap<String, DbFileIndex>> {
3965    let mut files = HashMap::new();
3966    let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
3967    let rows = stmt.query_map([], |row| {
3968        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3969    })?;
3970    for row in rows {
3971        let (rel_path, lang) = row?;
3972        files.insert(
3973            rel_path.clone(),
3974            DbFileIndex {
3975                lang: lang_from_label(&lang),
3976                exports: HashSet::new(),
3977                default_export: None,
3978                export_aliases: HashMap::new(),
3979                node_by_scoped: HashMap::new(),
3980                node_by_bare: HashMap::new(),
3981                module_targets: HashMap::new(),
3982                reexports: Vec::new(),
3983            },
3984        );
3985    }
3986
3987    let mut node_stmt = tx.prepare(
3988        "SELECT file_path, id, name, scoped_name, exported, is_default_export FROM nodes",
3989    )?;
3990    let nodes = node_stmt.query_map([], |row| {
3991        Ok((
3992            row.get::<_, String>(0)?,
3993            row.get::<_, String>(1)?,
3994            row.get::<_, String>(2)?,
3995            row.get::<_, String>(3)?,
3996            row.get::<_, i64>(4)? != 0,
3997            row.get::<_, i64>(5)? != 0,
3998        ))
3999    })?;
4000    for row in nodes {
4001        let (file_path, id, name, scoped_name, exported, is_default_export) = row?;
4002        let file = files
4003            .entry(file_path.clone())
4004            .or_insert_with(|| DbFileIndex {
4005                lang: None,
4006                exports: HashSet::new(),
4007                default_export: None,
4008                export_aliases: HashMap::new(),
4009                node_by_scoped: HashMap::new(),
4010                node_by_bare: HashMap::new(),
4011                module_targets: HashMap::new(),
4012                reexports: Vec::new(),
4013            });
4014        if exported {
4015            file.exports.insert(name.clone());
4016            file.exports.insert(scoped_name.clone());
4017        }
4018        if is_default_export {
4019            file.default_export = Some(scoped_name.clone());
4020        }
4021        file.node_by_scoped.insert(scoped_name, id.clone());
4022        file.node_by_bare.entry(name).or_insert(id);
4023    }
4024    let file_keys: HashSet<String> = files.keys().cloned().collect();
4025    let mut ref_stmt = tx.prepare(
4026        "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
4027         FROM refs WHERE kind IN ('import', 'reexport', 'export_alias')",
4028    )?;
4029    let ref_rows = ref_stmt.query_map([], |row| {
4030        Ok((
4031            row.get::<_, String>(0)?,
4032            row.get::<_, String>(1)?,
4033            row.get::<_, String>(2)?,
4034            row.get::<_, Option<String>>(3)?,
4035            row.get::<_, Option<String>>(4)?,
4036            row.get::<_, i64>(5)? != 0,
4037            row.get::<_, Option<String>>(6)?,
4038            row.get::<_, Option<String>>(7)?,
4039        ))
4040    })?;
4041    for row in ref_rows {
4042        let (
4043            ref_id,
4044            caller_file,
4045            kind,
4046            module_path,
4047            full_ref,
4048            wildcard,
4049            local_name,
4050            requested_name,
4051        ) = row?;
4052        if kind == "export_alias" {
4053            if let (Some(exported), Some(source_symbol), Some(file)) =
4054                (local_name, requested_name, files.get_mut(&caller_file))
4055            {
4056                file.export_aliases.insert(exported, source_symbol);
4057            }
4058            continue;
4059        }
4060        let Some(module_path) = module_path else {
4061            continue;
4062        };
4063        let deps = dependencies_for_ref(tx, project_root, &ref_id)?;
4064        let target_file = deps
4065            .iter()
4066            .find(|dep| file_keys.contains(*dep))
4067            .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
4068        if let Some(file) = files.get_mut(&caller_file) {
4069            file.module_targets
4070                .entry(module_path.clone())
4071                .or_insert_with(|| target_file.clone());
4072            if kind == "reexport" {
4073                let raw = RawRef {
4074                    ref_id,
4075                    caller_node: None,
4076                    caller_symbol: None,
4077                    caller_file,
4078                    kind,
4079                    short_name: None,
4080                    full_ref,
4081                    module_path: Some(module_path),
4082                    import_kind: Some("reexport".to_string()),
4083                    local_name: None,
4084                    requested_name: None,
4085                    namespace_alias: None,
4086                    wildcard,
4087                    line: 0,
4088                    byte_start: 0,
4089                    byte_end: 0,
4090                    dependencies: deps,
4091                };
4092                file.reexports
4093                    .push(reexport_index_from_raw(&raw, target_file));
4094            }
4095        }
4096    }
4097
4098    Ok(files)
4099}
4100
4101struct ColdBuildInsertStatements<'stmt> {
4102    file: Statement<'stmt>,
4103    node: Statement<'stmt>,
4104    file_dependency: Statement<'stmt>,
4105    dispatch_hint: Statement<'stmt>,
4106    backend_state: Statement<'stmt>,
4107    reference: Statement<'stmt>,
4108    edge: Statement<'stmt>,
4109}
4110
4111impl<'stmt> ColdBuildInsertStatements<'stmt> {
4112    fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
4113        Ok(Self {
4114            file: tx.prepare(
4115                "INSERT OR REPLACE INTO files(
4116                    path, content_hash, mtime_ns, size, lang, is_dead_code_root,
4117                    is_public_api, surface_fingerprint, indexed_at
4118                ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
4119            )?,
4120            node: tx.prepare(
4121                "INSERT OR REPLACE INTO nodes(
4122                    id, file_path, name, scoped_name, kind, start_line, start_col,
4123                    end_line, end_col, range_ordinal, signature, exported,
4124                    is_default_export, is_type_like, is_callgraph_entry_point, provenance
4125                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
4126            )?,
4127            file_dependency: tx.prepare(
4128                "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
4129            )?,
4130            dispatch_hint: tx.prepare(
4131                "INSERT OR REPLACE INTO dispatch_hints(
4132                    id, method_name, caller_node, file, line, byte_start, byte_end, provenance
4133                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
4134            )?,
4135            backend_state: tx.prepare(
4136                "INSERT OR REPLACE INTO backend_file_state(
4137                    backend, workspace_root, file_path, content_hash, status, updated_at
4138                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
4139            )?,
4140            reference: tx.prepare(
4141                "INSERT OR REPLACE INTO refs(
4142                    ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
4143                    import_kind, local_name, requested_name, namespace_alias, wildcard, line,
4144                    byte_start, byte_end, status, target_node, target_file, target_symbol,
4145                    provenance
4146                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
4147            )?,
4148            edge: tx.prepare(
4149                "INSERT OR REPLACE INTO edges(
4150                    edge_id, ref_id, source_node, target_node, target_file, target_symbol,
4151                    kind, line, provenance
4152                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
4153            )?,
4154        })
4155    }
4156}
4157
4158fn insert_file_extract_prepared(
4159    statements: &mut ColdBuildInsertStatements<'_>,
4160    workspace_root: &str,
4161    extract: &FileExtract,
4162) -> Result<()> {
4163    statements.file.execute(params![
4164        extract.rel_path,
4165        hash_to_hex(extract.freshness.content_hash),
4166        system_time_to_ns(extract.freshness.mtime),
4167        extract.freshness.size as i64,
4168        lang_label(extract.lang),
4169        extract.surface_fingerprint,
4170        unix_seconds_now(),
4171    ])?;
4172    for node in &extract.nodes {
4173        statements.node.execute(params![
4174            node.id,
4175            node.file_path,
4176            node.name,
4177            node.scoped_name,
4178            node.kind,
4179            node.range.start_line as i64,
4180            node.range.start_col as i64,
4181            node.range.end_line as i64,
4182            node.range.end_col as i64,
4183            node.range_ordinal as i64,
4184            node.signature,
4185            bool_int(node.exported),
4186            bool_int(node.is_default_export),
4187            bool_int(node.is_type_like),
4188            bool_int(node.is_callgraph_entry_point),
4189            PROVENANCE_TREESITTER,
4190        ])?;
4191    }
4192
4193    let mut dependencies = BTreeSet::new();
4194    for raw_ref in &extract.raw_refs {
4195        dependencies.extend(raw_ref.dependencies.iter().cloned());
4196    }
4197    for dep_file in &dependencies {
4198        statements
4199            .file_dependency
4200            .execute(params![extract.rel_path, dep_file])?;
4201    }
4202
4203    for hint in &extract.dispatch_hints {
4204        statements.dispatch_hint.execute(params![
4205            hint.id,
4206            hint.method_name,
4207            hint.caller_node,
4208            hint.file,
4209            hint.line as i64,
4210            hint.byte_start as i64,
4211            hint.byte_end as i64,
4212            PROVENANCE_TREESITTER,
4213        ])?;
4214    }
4215    insert_backend_state_prepared(
4216        &mut statements.backend_state,
4217        workspace_root,
4218        &extract.rel_path,
4219        Some(&extract.freshness.content_hash),
4220        "fresh",
4221    )?;
4222    Ok(())
4223}
4224
4225fn insert_backend_state_prepared(
4226    stmt: &mut Statement<'_>,
4227    workspace_root: &str,
4228    rel_path: &str,
4229    content_hash: Option<&blake3::Hash>,
4230    status: &str,
4231) -> Result<()> {
4232    let hash = content_hash
4233        .map(|hash| hash_to_hex(*hash))
4234        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
4235    stmt.execute(params![
4236        BACKEND_TREESITTER,
4237        workspace_root,
4238        rel_path,
4239        hash,
4240        status,
4241        unix_seconds_now(),
4242    ])?;
4243    Ok(())
4244}
4245
4246fn insert_resolved_ref_prepared(
4247    statements: &mut ColdBuildInsertStatements<'_>,
4248    resolved: &ResolvedRef,
4249) -> Result<()> {
4250    let raw = &resolved.raw;
4251    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
4252    statements.reference.execute(params![
4253        raw.ref_id,
4254        raw.caller_node,
4255        raw.caller_file,
4256        raw.kind,
4257        raw.short_name,
4258        raw.full_ref,
4259        raw.module_path,
4260        raw.import_kind,
4261        raw.local_name,
4262        raw.requested_name,
4263        raw.namespace_alias,
4264        bool_int(raw.wildcard),
4265        raw.line as i64,
4266        raw.byte_start as i64,
4267        raw.byte_end as i64,
4268        resolved.status,
4269        resolved.target_node,
4270        resolved.target_file,
4271        resolved.target_symbol,
4272        PROVENANCE_TREESITTER,
4273    ])?;
4274    if let Some(edge) = &resolved.edge {
4275        statements.edge.execute(params![
4276            edge.edge_id,
4277            raw.ref_id,
4278            edge.source_node,
4279            edge.target_node,
4280            edge.target_file,
4281            edge.target_symbol,
4282            edge.kind,
4283            edge.line as i64,
4284            PROVENANCE_TREESITTER,
4285        ])?;
4286    }
4287    Ok(())
4288}
4289
4290fn insert_file_extract(
4291    tx: &Transaction<'_>,
4292    project_root: &Path,
4293    extract: &FileExtract,
4294) -> Result<()> {
4295    tx.execute(
4296        "INSERT OR REPLACE INTO files(
4297            path, content_hash, mtime_ns, size, lang, is_dead_code_root,
4298            is_public_api, surface_fingerprint, indexed_at
4299        ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
4300        params![
4301            extract.rel_path,
4302            hash_to_hex(extract.freshness.content_hash),
4303            system_time_to_ns(extract.freshness.mtime),
4304            extract.freshness.size as i64,
4305            lang_label(extract.lang),
4306            extract.surface_fingerprint,
4307            unix_seconds_now(),
4308        ],
4309    )?;
4310    for node in &extract.nodes {
4311        tx.execute(
4312            "INSERT OR REPLACE INTO nodes(
4313                id, file_path, name, scoped_name, kind, start_line, start_col,
4314                end_line, end_col, range_ordinal, signature, exported,
4315                is_default_export, is_type_like, is_callgraph_entry_point, provenance
4316            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
4317            params![
4318                node.id,
4319                node.file_path,
4320                node.name,
4321                node.scoped_name,
4322                node.kind,
4323                node.range.start_line as i64,
4324                node.range.start_col as i64,
4325                node.range.end_line as i64,
4326                node.range.end_col as i64,
4327                node.range_ordinal as i64,
4328                node.signature,
4329                bool_int(node.exported),
4330                bool_int(node.is_default_export),
4331                bool_int(node.is_type_like),
4332                bool_int(node.is_callgraph_entry_point),
4333                PROVENANCE_TREESITTER,
4334            ],
4335        )?;
4336    }
4337    let mut dependencies = BTreeSet::new();
4338    for raw_ref in &extract.raw_refs {
4339        dependencies.extend(raw_ref.dependencies.iter().cloned());
4340    }
4341    insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
4342
4343    for hint in &extract.dispatch_hints {
4344        tx.execute(
4345            "INSERT OR REPLACE INTO dispatch_hints(
4346                id, method_name, caller_node, file, line, byte_start, byte_end, provenance
4347            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
4348            params![
4349                hint.id,
4350                hint.method_name,
4351                hint.caller_node,
4352                hint.file,
4353                hint.line as i64,
4354                hint.byte_start as i64,
4355                hint.byte_end as i64,
4356                PROVENANCE_TREESITTER,
4357            ],
4358        )?;
4359    }
4360    mark_backend_state(
4361        tx,
4362        project_root,
4363        &extract.rel_path,
4364        Some(&extract.freshness.content_hash),
4365        "fresh",
4366    )?;
4367    Ok(())
4368}
4369
4370fn insert_file_dependencies(
4371    tx: &Transaction<'_>,
4372    file_path: &str,
4373    dependencies: &BTreeSet<String>,
4374) -> Result<()> {
4375    for dep_file in dependencies {
4376        tx.execute(
4377            "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
4378            params![file_path, dep_file],
4379        )?;
4380    }
4381    Ok(())
4382}
4383
4384fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
4385    let raw = &resolved.raw;
4386    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
4387    tx.execute(
4388        "INSERT OR REPLACE INTO refs(
4389            ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
4390            import_kind, local_name, requested_name, namespace_alias, wildcard, line,
4391            byte_start, byte_end, status, target_node, target_file, target_symbol,
4392            provenance
4393        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
4394        params![
4395            raw.ref_id,
4396            raw.caller_node,
4397            raw.caller_file,
4398            raw.kind,
4399            raw.short_name,
4400            raw.full_ref,
4401            raw.module_path,
4402            raw.import_kind,
4403            raw.local_name,
4404            raw.requested_name,
4405            raw.namespace_alias,
4406            bool_int(raw.wildcard),
4407            raw.line as i64,
4408            raw.byte_start as i64,
4409            raw.byte_end as i64,
4410            resolved.status,
4411            resolved.target_node,
4412            resolved.target_file,
4413            resolved.target_symbol,
4414            PROVENANCE_TREESITTER,
4415        ],
4416    )?;
4417    if let Some(edge) = &resolved.edge {
4418        tx.execute(
4419            "INSERT OR REPLACE INTO edges(
4420                edge_id, ref_id, source_node, target_node, target_file, target_symbol,
4421                kind, line, provenance
4422            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
4423            params![
4424                edge.edge_id,
4425                raw.ref_id,
4426                edge.source_node,
4427                edge.target_node,
4428                edge.target_file,
4429                edge.target_symbol,
4430                edge.kind,
4431                edge.line as i64,
4432                PROVENANCE_TREESITTER,
4433            ],
4434        )?;
4435    }
4436    Ok(())
4437}
4438
4439fn insert_method_dispatch_edges(
4440    tx: &Transaction<'_>,
4441    project_root: &Path,
4442    caller_files: Option<&BTreeSet<String>>,
4443) -> Result<usize> {
4444    let references = load_name_match_refs(tx, caller_files)?;
4445    if references.is_empty() {
4446        return Ok(0);
4447    }
4448
4449    let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
4450    let mut source_cache: DispatchSourceCache = HashMap::new();
4451    let mut inserted = 0usize;
4452    for reference in references {
4453        let key = (reference.method_name.clone(), reference.lang.clone());
4454        let candidates = match candidates_by_name.entry(key) {
4455            Entry::Occupied(entry) => entry.into_mut(),
4456            Entry::Vacant(entry) => {
4457                let candidates =
4458                    load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
4459                entry.insert(candidates)
4460            }
4461        };
4462
4463        if let Some(receiver_type) =
4464            infer_receiver_type(project_root, &reference, &mut source_cache)
4465        {
4466            let Some(candidate) =
4467                select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
4468            else {
4469                continue;
4470            };
4471            insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
4472            inserted += 1;
4473            continue;
4474        }
4475
4476        if method_name_match_denylisted(&reference.method_name) {
4477            continue;
4478        }
4479
4480        let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
4481            continue;
4482        };
4483        insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
4484        inserted += 1;
4485    }
4486    Ok(inserted)
4487}
4488
4489fn insert_method_dispatch_edges_chunked(
4490    tx: &Transaction<'_>,
4491    project_root: &Path,
4492    caller_files: &BTreeSet<String>,
4493    chunk_size: usize,
4494) -> Result<usize> {
4495    if caller_files.is_empty() {
4496        return Ok(0);
4497    }
4498    if chunk_size == 0 || caller_files.len() <= chunk_size {
4499        return insert_method_dispatch_edges(tx, project_root, Some(caller_files));
4500    }
4501
4502    let mut inserted = 0usize;
4503    let mut batch = BTreeSet::new();
4504    for caller_file in caller_files {
4505        batch.insert(caller_file.clone());
4506        if batch.len() == chunk_size {
4507            inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
4508            batch.clear();
4509        }
4510    }
4511    if !batch.is_empty() {
4512        inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
4513    }
4514    Ok(inserted)
4515}
4516
4517fn insert_method_dispatch_edge(
4518    tx: &Transaction<'_>,
4519    reference: &NameMatchRef,
4520    candidate: &NameMatchCandidate,
4521    provenance: &str,
4522) -> Result<()> {
4523    tx.execute(
4524        "INSERT OR REPLACE INTO edges(
4525            edge_id, ref_id, source_node, target_node, target_file, target_symbol,
4526            kind, line, provenance
4527        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
4528        params![
4529            ref_id(&[&reference.ref_id, provenance, "edge"]),
4530            &reference.ref_id,
4531            &reference.caller_node,
4532            &candidate.node_id,
4533            &candidate.file_path,
4534            &candidate.scoped_name,
4535            reference.line as i64,
4536            provenance,
4537        ],
4538    )?;
4539    Ok(())
4540}
4541
4542fn delete_method_dispatch_edges_for_callers(
4543    tx: &Transaction<'_>,
4544    caller_files: &BTreeSet<String>,
4545) -> Result<()> {
4546    if caller_files.is_empty() {
4547        return Ok(());
4548    }
4549
4550    let mut stmt = tx.prepare(
4551        "DELETE FROM edges
4552         WHERE provenance IN (?1, ?2)
4553           AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
4554    )?;
4555    for caller_file in caller_files {
4556        stmt.execute(params![
4557            PROVENANCE_NAME_MATCH,
4558            PROVENANCE_TYPE_MATCH,
4559            caller_file
4560        ])?;
4561    }
4562    Ok(())
4563}
4564
4565fn load_name_match_refs(
4566    tx: &Transaction<'_>,
4567    caller_files: Option<&BTreeSet<String>>,
4568) -> Result<Vec<NameMatchRef>> {
4569    let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
4570                           n.signature, r.short_name, r.full_ref, r.line, f.lang
4571                    FROM refs r
4572                    JOIN files f ON f.path = r.caller_file
4573                    JOIN nodes n ON n.id = r.caller_node
4574                    WHERE r.kind = 'call'
4575                      AND r.status = 'unresolved'
4576                      AND r.caller_node IS NOT NULL
4577                      AND r.full_ref IS NOT NULL
4578                      AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
4579                      AND NOT EXISTS (
4580                          SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
4581                      )";
4582    let mut references = Vec::new();
4583
4584    if let Some(caller_files) = caller_files {
4585        if caller_files.is_empty() {
4586            return Ok(references);
4587        }
4588        let sql = format!(
4589            "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
4590        );
4591        let mut stmt = tx.prepare(&sql)?;
4592        for caller_file in caller_files {
4593            let rows = stmt.query_map(params![caller_file], |row| {
4594                Ok((
4595                    row.get::<_, String>(0)?,
4596                    row.get::<_, Option<String>>(1)?,
4597                    row.get::<_, String>(2)?,
4598                    row.get::<_, String>(3)?,
4599                    row.get::<_, Option<String>>(4)?,
4600                    row.get::<_, Option<String>>(5)?,
4601                    row.get::<_, Option<String>>(6)?,
4602                    row.get::<_, i64>(7)?,
4603                    row.get::<_, String>(8)?,
4604                ))
4605            })?;
4606            for row in rows {
4607                let (
4608                    ref_id,
4609                    caller_node,
4610                    caller_file,
4611                    caller_symbol,
4612                    caller_signature,
4613                    short_name,
4614                    full_ref,
4615                    line,
4616                    lang,
4617                ) = row?;
4618                if let Some(reference) = name_match_ref_from_parts(
4619                    ref_id,
4620                    caller_node,
4621                    caller_file,
4622                    caller_symbol,
4623                    caller_signature,
4624                    short_name,
4625                    full_ref,
4626                    line,
4627                    lang,
4628                ) {
4629                    references.push(reference);
4630                }
4631            }
4632        }
4633        return Ok(references);
4634    }
4635
4636    let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
4637    let mut stmt = tx.prepare(&sql)?;
4638    let rows = stmt.query_map([], |row| {
4639        Ok((
4640            row.get::<_, String>(0)?,
4641            row.get::<_, Option<String>>(1)?,
4642            row.get::<_, String>(2)?,
4643            row.get::<_, String>(3)?,
4644            row.get::<_, Option<String>>(4)?,
4645            row.get::<_, Option<String>>(5)?,
4646            row.get::<_, Option<String>>(6)?,
4647            row.get::<_, i64>(7)?,
4648            row.get::<_, String>(8)?,
4649        ))
4650    })?;
4651    for row in rows {
4652        let (
4653            ref_id,
4654            caller_node,
4655            caller_file,
4656            caller_symbol,
4657            caller_signature,
4658            short_name,
4659            full_ref,
4660            line,
4661            lang,
4662        ) = row?;
4663        if let Some(reference) = name_match_ref_from_parts(
4664            ref_id,
4665            caller_node,
4666            caller_file,
4667            caller_symbol,
4668            caller_signature,
4669            short_name,
4670            full_ref,
4671            line,
4672            lang,
4673        ) {
4674            references.push(reference);
4675        }
4676    }
4677    Ok(references)
4678}
4679
4680#[allow(clippy::too_many_arguments)]
4681fn name_match_ref_from_parts(
4682    ref_id: String,
4683    caller_node: Option<String>,
4684    caller_file: String,
4685    caller_symbol: String,
4686    caller_signature: Option<String>,
4687    short_name: Option<String>,
4688    full_ref: Option<String>,
4689    line: i64,
4690    lang: String,
4691) -> Option<NameMatchRef> {
4692    let caller_node = caller_node?;
4693    let full_ref = full_ref?;
4694    let (receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
4695    let method_name = if member.is_empty() {
4696        short_name.as_deref()?.to_string()
4697    } else {
4698        member
4699    };
4700    Some(NameMatchRef {
4701        ref_id,
4702        caller_node,
4703        caller_file,
4704        caller_symbol,
4705        caller_signature,
4706        receiver,
4707        method_name,
4708        colon_dispatch,
4709        line: line.max(0) as u32,
4710        lang,
4711    })
4712}
4713
4714fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, bool)> {
4715    let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
4716    let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
4717    let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
4718    let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
4719        .into_iter()
4720        .flatten()
4721        .max_by_key(|(index, _, _)| *index)?;
4722    if delimiter == 0 {
4723        return None;
4724    }
4725    let member_start = delimiter + delimiter_len;
4726    if member_start >= full_ref.len() {
4727        return None;
4728    }
4729    let receiver = last_name_segment(&full_ref[..delimiter]);
4730    let member = &full_ref[member_start..];
4731    if receiver.is_empty() || member.is_empty() {
4732        return None;
4733    }
4734    Some((receiver.to_string(), member.to_string(), colon_dispatch))
4735}
4736
4737fn last_name_segment(value: &str) -> &str {
4738    value
4739        .rsplit(['.', ':', '/', '\\', '-', '>'])
4740        .find(|segment| !segment.is_empty())
4741        .unwrap_or(value)
4742}
4743
4744fn load_name_match_candidates(
4745    tx: &Transaction<'_>,
4746    method_name: &str,
4747    lang: &str,
4748) -> Result<Vec<NameMatchCandidate>> {
4749    let mut stmt = tx.prepare(
4750        "SELECT n.id, n.file_path, n.scoped_name, n.kind
4751         FROM nodes n JOIN files f ON f.path = n.file_path
4752         WHERE n.name = ?1
4753           AND f.lang = ?2
4754           AND n.kind IN ('method', 'function')
4755         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
4756    )?;
4757    let rows = stmt.query_map(params![method_name, lang], |row| {
4758        Ok(NameMatchCandidate {
4759            node_id: row.get(0)?,
4760            file_path: row.get(1)?,
4761            scoped_name: row.get(2)?,
4762            kind: row.get(3)?,
4763        })
4764    })?;
4765    rows.collect::<std::result::Result<Vec<_>, _>>()
4766        .map_err(Into::into)
4767}
4768
4769struct ParsedDispatchSource {
4770    source: String,
4771    tree: tree_sitter::Tree,
4772}
4773
4774type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
4775
4776fn infer_receiver_type(
4777    project_root: &Path,
4778    reference: &NameMatchRef,
4779    source_cache: &mut DispatchSourceCache,
4780) -> Option<String> {
4781    match reference.lang.as_str() {
4782        "rust" => infer_rust_receiver_type(reference),
4783        "java" => {
4784            infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
4785        }
4786        "kotlin" => {
4787            infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
4788        }
4789        "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache),
4790        _ => None,
4791    }
4792}
4793
4794fn parse_dispatch_source(
4795    project_root: &Path,
4796    caller_file: &str,
4797    lang: LangId,
4798) -> Option<ParsedDispatchSource> {
4799    let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
4800    let grammar = crate::parser::grammar_for(lang);
4801    let mut parser = tree_sitter::Parser::new();
4802    parser.set_language(&grammar).ok()?;
4803    let tree = parser.parse(&source, None)?;
4804    Some(ParsedDispatchSource { source, tree })
4805}
4806
4807fn parsed_dispatch_source<'a>(
4808    project_root: &Path,
4809    reference: &NameMatchRef,
4810    lang: LangId,
4811    source_cache: &'a mut DispatchSourceCache,
4812) -> Option<&'a ParsedDispatchSource> {
4813    let key = (reference.caller_file.clone(), reference.lang.clone());
4814    source_cache
4815        .entry(key)
4816        .or_insert_with(|| parse_dispatch_source(project_root, &reference.caller_file, lang))
4817        .as_ref()
4818}
4819
4820fn infer_java_like_receiver_type(
4821    project_root: &Path,
4822    reference: &NameMatchRef,
4823    lang: LangId,
4824    source_cache: &mut DispatchSourceCache,
4825) -> Option<String> {
4826    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
4827        return None;
4828    }
4829
4830    let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
4831    let root = parsed.tree.root_node();
4832    let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
4833
4834    let callable_scope = type_node
4835        .and_then(|node| {
4836            find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
4837        })
4838        .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
4839
4840    if let Some(callable_scope) = callable_scope {
4841        if let Some(receiver_type) = infer_java_like_local_receiver_type(
4842            callable_scope,
4843            &parsed.source,
4844            &reference.receiver,
4845            reference.line.max(1),
4846            lang,
4847        ) {
4848            return Some(receiver_type);
4849        }
4850    }
4851
4852    type_node.and_then(|node| {
4853        infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
4854    })
4855}
4856
4857fn infer_cpp_receiver_type(
4858    project_root: &Path,
4859    reference: &NameMatchRef,
4860    source_cache: &mut DispatchSourceCache,
4861) -> Option<String> {
4862    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
4863        return None;
4864    }
4865
4866    let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
4867    let root = parsed.tree.root_node();
4868    let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
4869    infer_cpp_receiver_type_from_scope(
4870        scope,
4871        &parsed.source,
4872        &reference.receiver,
4873        reference.line.max(1),
4874    )
4875}
4876
4877fn find_enclosing_java_like_type_node<'tree>(
4878    root: tree_sitter::Node<'tree>,
4879    source: &str,
4880    reference: &NameMatchRef,
4881    lang: LangId,
4882) -> Option<tree_sitter::Node<'tree>> {
4883    let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
4884        .and_then(|name| simple_type_name(&name));
4885    let line = reference.line.max(1);
4886    let mut best = None;
4887    let mut stack = vec![root];
4888    while let Some(node) = stack.pop() {
4889        if !node_contains_line(node, line) {
4890            continue;
4891        }
4892        if is_java_like_type_kind(node.kind(), lang) {
4893            let name = declaration_name(node, source);
4894            if expected_type
4895                .as_deref()
4896                .is_none_or(|expected| name == Some(expected))
4897            {
4898                best = tighter_node(best, node);
4899            }
4900        }
4901        push_named_children(node, &mut stack);
4902    }
4903    best
4904}
4905
4906fn find_enclosing_java_like_callable_node<'tree>(
4907    root: tree_sitter::Node<'tree>,
4908    source: &str,
4909    reference: &NameMatchRef,
4910    lang: LangId,
4911) -> Option<tree_sitter::Node<'tree>> {
4912    let expected_name = reference.caller_symbol.rsplit("::").next();
4913    let line = reference.line.max(1);
4914    let mut best = None;
4915    let mut stack = vec![root];
4916    while let Some(node) = stack.pop() {
4917        if !node_contains_line(node, line) {
4918            continue;
4919        }
4920        if is_java_like_callable_kind(node.kind(), lang) {
4921            let name = declaration_name(node, source);
4922            if expected_name.is_none_or(|expected| name == Some(expected)) {
4923                best = tighter_node(best, node);
4924            }
4925        }
4926        push_named_children(node, &mut stack);
4927    }
4928    best
4929}
4930
4931fn find_enclosing_cpp_callable_node<'tree>(
4932    root: tree_sitter::Node<'tree>,
4933    _source: &str,
4934    reference: &NameMatchRef,
4935) -> Option<tree_sitter::Node<'tree>> {
4936    let line = reference.line.max(1);
4937    let mut best = None;
4938    let mut stack = vec![root];
4939    while let Some(node) = stack.pop() {
4940        if !node_contains_line(node, line) {
4941            continue;
4942        }
4943        if node.kind() == "function_definition" {
4944            best = tighter_node(best, node);
4945        }
4946        push_named_children(node, &mut stack);
4947    }
4948    best
4949}
4950
4951fn tighter_node<'tree>(
4952    current: Option<tree_sitter::Node<'tree>>,
4953    candidate: tree_sitter::Node<'tree>,
4954) -> Option<tree_sitter::Node<'tree>> {
4955    match current {
4956        Some(current)
4957            if current.start_byte() > candidate.start_byte()
4958                || (current.start_byte() == candidate.start_byte()
4959                    && current.end_byte() <= candidate.end_byte()) =>
4960        {
4961            Some(current)
4962        }
4963        _ => Some(candidate),
4964    }
4965}
4966
4967fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
4968    let start = node.start_position().row as u32 + 1;
4969    let end = node.end_position().row as u32 + 1;
4970    start <= line && line <= end
4971}
4972
4973fn push_named_children<'tree>(
4974    node: tree_sitter::Node<'tree>,
4975    stack: &mut Vec<tree_sitter::Node<'tree>>,
4976) {
4977    for index in 0..node.named_child_count() {
4978        if let Some(child) = node.named_child(index as u32) {
4979            stack.push(child);
4980        }
4981    }
4982}
4983
4984fn declaration_name<'source>(
4985    node: tree_sitter::Node<'_>,
4986    source: &'source str,
4987) -> Option<&'source str> {
4988    node.child_by_field_name("name")
4989        .map(|name| node_text(name, source))
4990        .or_else(|| {
4991            first_named_child_text(
4992                node,
4993                source,
4994                &["identifier", "type_identifier", "simple_identifier"],
4995            )
4996        })
4997}
4998
4999fn first_named_child_text<'source>(
5000    node: tree_sitter::Node<'_>,
5001    source: &'source str,
5002    kinds: &[&str],
5003) -> Option<&'source str> {
5004    for index in 0..node.named_child_count() {
5005        let child = node.named_child(index as u32)?;
5006        if kinds.contains(&child.kind()) {
5007            return Some(node_text(child, source));
5008        }
5009    }
5010    None
5011}
5012
5013fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
5014    &source[node.byte_range()]
5015}
5016
5017fn infer_java_like_field_receiver_type(
5018    type_node: tree_sitter::Node<'_>,
5019    source: &str,
5020    receiver: &str,
5021    lang: LangId,
5022) -> Option<String> {
5023    let mut stack = Vec::new();
5024    push_named_children(type_node, &mut stack);
5025    while let Some(node) = stack.pop() {
5026        if is_java_like_field_kind(node.kind(), lang) {
5027            if let Some(receiver_type) =
5028                extract_java_like_declared_type(node_text(node, source), receiver, lang)
5029            {
5030                return Some(receiver_type);
5031            }
5032        }
5033        if is_java_like_type_kind(node.kind(), lang)
5034            || is_java_like_callable_kind(node.kind(), lang)
5035        {
5036            continue;
5037        }
5038        push_named_children(node, &mut stack);
5039    }
5040    None
5041}
5042
5043fn infer_java_like_local_receiver_type(
5044    callable_node: tree_sitter::Node<'_>,
5045    source: &str,
5046    receiver: &str,
5047    call_line: u32,
5048    lang: LangId,
5049) -> Option<String> {
5050    let mut best: Option<(u32, String)> = None;
5051    let mut stack = Vec::new();
5052    push_named_children(callable_node, &mut stack);
5053    while let Some(node) = stack.pop() {
5054        let start_line = node.start_position().row as u32 + 1;
5055        if start_line > call_line {
5056            continue;
5057        }
5058        if is_java_like_local_kind(node.kind(), lang) {
5059            if let Some(receiver_type) =
5060                extract_java_like_declared_type(node_text(node, source), receiver, lang)
5061            {
5062                if best
5063                    .as_ref()
5064                    .is_none_or(|(best_line, _)| start_line >= *best_line)
5065                {
5066                    best = Some((start_line, receiver_type));
5067                }
5068            }
5069        }
5070        if is_java_like_type_kind(node.kind(), lang)
5071            || is_java_like_callable_kind(node.kind(), lang)
5072        {
5073            continue;
5074        }
5075        push_named_children(node, &mut stack);
5076    }
5077    best.map(|(_, receiver_type)| receiver_type)
5078}
5079
5080fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
5081    match lang {
5082        LangId::Java => matches!(
5083            kind,
5084            "class_declaration"
5085                | "interface_declaration"
5086                | "enum_declaration"
5087                | "record_declaration"
5088                | "annotation_type_declaration"
5089        ),
5090        LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
5091        _ => false,
5092    }
5093}
5094
5095fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
5096    match lang {
5097        LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
5098        LangId::Kotlin => kind == "function_declaration",
5099        _ => false,
5100    }
5101}
5102
5103fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
5104    match lang {
5105        LangId::Java => kind == "field_declaration",
5106        LangId::Kotlin => kind == "property_declaration",
5107        _ => false,
5108    }
5109}
5110
5111fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
5112    match lang {
5113        LangId::Java => kind == "local_variable_declaration",
5114        LangId::Kotlin => kind == "property_declaration",
5115        _ => false,
5116    }
5117}
5118
5119fn extract_java_like_declared_type(
5120    declaration: &str,
5121    receiver: &str,
5122    lang: LangId,
5123) -> Option<String> {
5124    match lang {
5125        LangId::Java => extract_java_declared_type(declaration, receiver),
5126        LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
5127        _ => None,
5128    }
5129}
5130
5131fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
5132    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
5133    let after = declaration[receiver_start + receiver.len()..].trim_start();
5134    if after
5135        .chars()
5136        .next()
5137        .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
5138    {
5139        return None;
5140    }
5141
5142    let before = declaration[..receiver_start].trim_end();
5143    if before.contains(',') {
5144        return None;
5145    }
5146    normalize_receiver_type_name(strip_java_declaration_prefixes(before))
5147}
5148
5149fn strip_java_declaration_prefixes(mut value: &str) -> &str {
5150    loop {
5151        value = value.trim_start();
5152        if let Some(stripped) = strip_leading_java_annotation(value) {
5153            value = stripped;
5154            continue;
5155        }
5156        if let Some(stripped) = strip_leading_java_modifier(value) {
5157            value = stripped;
5158            continue;
5159        }
5160        return value.trim();
5161    }
5162}
5163
5164fn strip_leading_java_annotation(value: &str) -> Option<&str> {
5165    let value = value.trim_start();
5166    let mut chars = value.char_indices();
5167    let (_, first) = chars.next()?;
5168    if first != '@' {
5169        return None;
5170    }
5171    let mut end = first.len_utf8();
5172    for (index, ch) in chars {
5173        if !(is_code_ident_char(ch) || ch == '.') {
5174            end = index;
5175            break;
5176        }
5177        end = index + ch.len_utf8();
5178    }
5179    let rest = value[end..].trim_start();
5180    if let Some(stripped) = rest.strip_prefix('(') {
5181        let mut depth = 1usize;
5182        for (index, ch) in stripped.char_indices() {
5183            match ch {
5184                '(' => depth += 1,
5185                ')' => {
5186                    depth = depth.saturating_sub(1);
5187                    if depth == 0 {
5188                        return Some(stripped[index + ch.len_utf8()..].trim_start());
5189                    }
5190                }
5191                _ => {}
5192            }
5193        }
5194        return Some("");
5195    }
5196    Some(rest)
5197}
5198
5199fn strip_leading_java_modifier(value: &str) -> Option<&str> {
5200    const MODIFIERS: &[&str] = &[
5201        "public",
5202        "protected",
5203        "private",
5204        "abstract",
5205        "static",
5206        "final",
5207        "transient",
5208        "volatile",
5209        "synchronized",
5210        "native",
5211        "strictfp",
5212    ];
5213    MODIFIERS
5214        .iter()
5215        .find_map(|modifier| strip_leading_word(value, modifier))
5216}
5217
5218fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
5219    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
5220    let before = &declaration[..receiver_start];
5221    if find_identifier_occurrence(before, "val").is_none()
5222        && find_identifier_occurrence(before, "var").is_none()
5223    {
5224        return None;
5225    }
5226
5227    let after = declaration[receiver_start + receiver.len()..].trim_start();
5228    if let Some(type_text) = after.strip_prefix(':') {
5229        return normalize_receiver_type_name(read_type_prefix(type_text));
5230    }
5231    after
5232        .strip_prefix('=')
5233        .and_then(infer_kotlin_constructor_type)
5234}
5235
5236fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
5237    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
5238    if rest.trim_start().starts_with('(') {
5239        normalize_receiver_type_name(head)
5240    } else {
5241        None
5242    }
5243}
5244
5245fn read_type_prefix(value: &str) -> &str {
5246    let mut angle_depth = 0usize;
5247    for (index, ch) in value.char_indices() {
5248        match ch {
5249            '<' => angle_depth += 1,
5250            '>' => angle_depth = angle_depth.saturating_sub(1),
5251            '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
5252                return value[..index].trim();
5253            }
5254            _ => {}
5255        }
5256    }
5257    value.trim()
5258}
5259
5260fn infer_cpp_receiver_type_from_scope(
5261    scope: tree_sitter::Node<'_>,
5262    source: &str,
5263    receiver: &str,
5264    call_line: u32,
5265) -> Option<String> {
5266    let lines = source.lines().collect::<Vec<_>>();
5267    if lines.is_empty() {
5268        return None;
5269    }
5270    let scope_start = scope.start_position().row as usize;
5271    let call_index = (call_line as usize)
5272        .saturating_sub(1)
5273        .min(lines.len().saturating_sub(1));
5274    for index in (scope_start..=call_index).rev() {
5275        if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
5276            return Some(receiver_type);
5277        }
5278    }
5279    None
5280}
5281
5282fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
5283    for receiver_start in identifier_occurrences(line, receiver) {
5284        let after = line[receiver_start + receiver.len()..].trim_start();
5285        if after
5286            .chars()
5287            .next()
5288            .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
5289        {
5290            continue;
5291        }
5292        let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
5293        let normalized = normalize_cpp_type_name(type_text)?;
5294        if normalized == "auto" {
5295            if let Some(rhs) = after.strip_prefix('=') {
5296                return infer_cpp_auto_receiver_type(rhs);
5297            }
5298            continue;
5299        }
5300        return Some(normalized);
5301    }
5302    None
5303}
5304
5305fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
5306    let candidate = prefix
5307        .rsplit([';', '{', '}', '('])
5308        .next()
5309        .unwrap_or(prefix)
5310        .trim();
5311    if candidate.is_empty() || candidate.ends_with(',') {
5312        None
5313    } else {
5314        Some(candidate)
5315    }
5316}
5317
5318fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
5319    let without_templates = strip_angle_groups(type_text);
5320    let mut cleaned = String::with_capacity(without_templates.len());
5321    for token in without_templates.split_whitespace() {
5322        if matches!(
5323            token,
5324            "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
5325        ) {
5326            continue;
5327        }
5328        if !cleaned.is_empty() {
5329            cleaned.push(' ');
5330        }
5331        cleaned.push_str(token);
5332    }
5333    let token = cleaned
5334        .split_whitespace()
5335        .last()
5336        .unwrap_or(cleaned.trim())
5337        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
5338        .trim_matches(['*', '&']);
5339    let simple = token.rsplit("::").next().unwrap_or(token).trim();
5340    if simple.is_empty() || cpp_non_type_token(simple) {
5341        None
5342    } else {
5343        Some(simple.to_string())
5344    }
5345}
5346
5347fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
5348    let rhs = rhs.trim_start();
5349    if let Some(after_new) = rhs.strip_prefix("new ") {
5350        return infer_cpp_constructor_type(after_new);
5351    }
5352    infer_cpp_make_template_type(rhs)
5353        .or_else(|| infer_cpp_constructor_type(rhs))
5354        .or_else(|| infer_cpp_factory_type(rhs))
5355}
5356
5357fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
5358    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
5359    let normalized = normalize_cpp_type_name(head)?;
5360    if !normalized
5361        .chars()
5362        .next()
5363        .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
5364    {
5365        return None;
5366    }
5367    if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
5368        Some(normalized)
5369    } else {
5370        None
5371    }
5372}
5373
5374fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
5375    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
5376    if !rest.trim_start().starts_with('(') {
5377        return None;
5378    }
5379    let base = head.split('<').next().unwrap_or(head);
5380    let base_simple = base.rsplit("::").next().unwrap_or(base);
5381    if !matches!(base_simple, "make_unique" | "make_shared") {
5382        return None;
5383    }
5384    first_angle_arg(head).and_then(normalize_cpp_type_name)
5385}
5386
5387fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
5388    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
5389    if !rest.trim_start().starts_with('(') {
5390        return None;
5391    }
5392    let simple = head
5393        .split('<')
5394        .next()
5395        .unwrap_or(head)
5396        .rsplit("::")
5397        .next()
5398        .unwrap_or(head);
5399    for prefix in ["make", "create", "build"] {
5400        if let Some(suffix) = simple.strip_prefix(prefix) {
5401            if suffix
5402                .chars()
5403                .next()
5404                .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
5405            {
5406                return normalize_cpp_type_name(suffix);
5407            }
5408        }
5409    }
5410    None
5411}
5412
5413#[derive(Debug, Clone, Copy)]
5414enum JavaLikeInvocation {
5415    Kotlin,
5416    Cpp,
5417}
5418
5419fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
5420    let value = value.trim_start();
5421    let mut end = 0usize;
5422    for (index, ch) in value.char_indices() {
5423        let allowed_separator = match flavor {
5424            JavaLikeInvocation::Kotlin => ch == '.',
5425            JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
5426        };
5427        if is_code_ident_char(ch) || allowed_separator {
5428            end = index + ch.len_utf8();
5429            continue;
5430        }
5431        break;
5432    }
5433    if end == 0 {
5434        return None;
5435    }
5436    let mut rest = &value[end..];
5437    if let Some(stripped) = rest.trim_start().strip_prefix('<') {
5438        let skipped = skip_balanced_angle(stripped)?;
5439        let rest_start = rest.len() - rest.trim_start().len();
5440        let angle_len = 1 + skipped;
5441        end += rest_start + angle_len;
5442        rest = &value[end..];
5443    }
5444    Some((value[..end].trim(), rest))
5445}
5446
5447fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
5448    let mut depth = 1usize;
5449    for (index, ch) in value_after_open.char_indices() {
5450        match ch {
5451            '<' => depth += 1,
5452            '>' => {
5453                depth = depth.saturating_sub(1);
5454                if depth == 0 {
5455                    return Some(index + ch.len_utf8());
5456                }
5457            }
5458            _ => {}
5459        }
5460    }
5461    None
5462}
5463
5464fn first_angle_arg(value: &str) -> Option<&str> {
5465    let open = value.find('<')?;
5466    let inner_len = skip_balanced_angle(&value[open + 1..])?;
5467    let inner = &value[open + 1..open + inner_len];
5468    split_top_level_commas(inner).into_iter().next()
5469}
5470
5471fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
5472    let without_generics = strip_angle_groups(type_text);
5473    let cleaned = without_generics
5474        .replace("[]", " ")
5475        .replace("...", " ")
5476        .replace(['?', '&', '*'], " ");
5477    let token = cleaned
5478        .split_whitespace()
5479        .last()
5480        .unwrap_or(cleaned.trim())
5481        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
5482    let token = token.rsplit("::").next().unwrap_or(token);
5483    let simple = token.rsplit('.').next().unwrap_or(token).trim();
5484    if simple.is_empty()
5485        || java_like_primitive_type(simple)
5486        || !simple
5487            .chars()
5488            .next()
5489            .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
5490    {
5491        None
5492    } else {
5493        Some(simple.to_string())
5494    }
5495}
5496
5497fn simple_type_name(scoped_name: &str) -> Option<String> {
5498    scoped_name
5499        .rsplit("::")
5500        .find(|segment| !segment.is_empty())
5501        .and_then(normalize_receiver_type_name)
5502}
5503
5504fn strip_angle_groups(value: &str) -> String {
5505    let mut output = String::with_capacity(value.len());
5506    let mut depth = 0usize;
5507    for ch in value.chars() {
5508        match ch {
5509            '<' => {
5510                if depth == 0 {
5511                    output.push(' ');
5512                }
5513                depth += 1;
5514            }
5515            '>' => depth = depth.saturating_sub(1),
5516            _ if depth == 0 => output.push(ch),
5517            _ => {}
5518        }
5519    }
5520    output
5521}
5522
5523fn java_like_primitive_type(value: &str) -> bool {
5524    matches!(
5525        value,
5526        "boolean"
5527            | "byte"
5528            | "char"
5529            | "double"
5530            | "float"
5531            | "int"
5532            | "long"
5533            | "short"
5534            | "void"
5535            | "Boolean"
5536            | "Byte"
5537            | "Char"
5538            | "Double"
5539            | "Float"
5540            | "Int"
5541            | "Long"
5542            | "Short"
5543            | "Unit"
5544    )
5545}
5546
5547fn cpp_non_type_token(value: &str) -> bool {
5548    matches!(
5549        value,
5550        "return"
5551            | "if"
5552            | "else"
5553            | "for"
5554            | "while"
5555            | "do"
5556            | "switch"
5557            | "case"
5558            | "default"
5559            | "break"
5560            | "continue"
5561            | "goto"
5562            | "throw"
5563            | "new"
5564            | "delete"
5565            | "co_await"
5566            | "co_yield"
5567            | "co_return"
5568            | "static_cast"
5569            | "const_cast"
5570            | "dynamic_cast"
5571            | "reinterpret_cast"
5572            | "sizeof"
5573            | "alignof"
5574            | "typeid"
5575            | "and"
5576            | "or"
5577            | "not"
5578            | "xor"
5579    )
5580}
5581
5582fn receiver_is_bare_identifier(value: &str) -> bool {
5583    let mut chars = value.chars();
5584    let Some(first) = chars.next() else {
5585        return false;
5586    };
5587    (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
5588}
5589
5590fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
5591    identifier_occurrences(value, needle).into_iter().next()
5592}
5593
5594fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
5595    value
5596        .match_indices(needle)
5597        .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
5598        .collect()
5599}
5600
5601fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
5602    let before = value[..start].chars().next_back();
5603    let after = value[start + len..].chars().next();
5604    !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
5605}
5606
5607fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
5608    let stripped = value.strip_prefix(word)?;
5609    if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
5610        Some(stripped.trim_start())
5611    } else {
5612        None
5613    }
5614}
5615
5616fn is_code_ident_char(ch: char) -> bool {
5617    ch == '_' || ch.is_ascii_alphanumeric()
5618}
5619
5620fn infer_rust_receiver_type(reference: &NameMatchRef) -> Option<String> {
5621    if matches!(reference.receiver.as_str(), "self" | "Self") {
5622        return enclosing_type_from_scoped_name(&reference.caller_symbol);
5623    }
5624
5625    if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
5626        return Some(reference.receiver.clone());
5627    }
5628
5629    reference
5630        .caller_signature
5631        .as_deref()
5632        .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
5633}
5634
5635fn rust_receiver_looks_type_like(receiver: &str) -> bool {
5636    receiver
5637        .chars()
5638        .next()
5639        .is_some_and(|ch| ch == '_' || ch.is_uppercase())
5640}
5641
5642fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
5643    scoped_name
5644        .rsplit_once("::")
5645        .map(|(enclosing, _)| enclosing)
5646        .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
5647        .map(ToString::to_string)
5648}
5649
5650fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
5651    let params = signature_parameter_text(signature)?;
5652    for param in split_top_level_commas(params) {
5653        let Some((pattern, type_text)) = param.split_once(':') else {
5654            continue;
5655        };
5656        let Some(name) = rust_parameter_name(pattern) else {
5657            continue;
5658        };
5659        if name == receiver {
5660            return normalize_rust_receiver_type(type_text);
5661        }
5662    }
5663    None
5664}
5665
5666fn signature_parameter_text(signature: &str) -> Option<&str> {
5667    let open = signature.find('(')?;
5668    let mut depth = 0usize;
5669    for (offset, ch) in signature[open..].char_indices() {
5670        match ch {
5671            '(' => depth += 1,
5672            ')' => {
5673                depth = depth.saturating_sub(1);
5674                if depth == 0 {
5675                    return Some(&signature[open + 1..open + offset]);
5676                }
5677            }
5678            _ => {}
5679        }
5680    }
5681    None
5682}
5683
5684fn split_top_level_commas(value: &str) -> Vec<&str> {
5685    let mut parts = Vec::new();
5686    let mut start = 0usize;
5687    let mut angle_depth = 0usize;
5688    let mut paren_depth = 0usize;
5689    let mut bracket_depth = 0usize;
5690    for (index, ch) in value.char_indices() {
5691        match ch {
5692            '<' => angle_depth += 1,
5693            '>' => angle_depth = angle_depth.saturating_sub(1),
5694            '(' => paren_depth += 1,
5695            ')' => paren_depth = paren_depth.saturating_sub(1),
5696            '[' => bracket_depth += 1,
5697            ']' => bracket_depth = bracket_depth.saturating_sub(1),
5698            ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
5699                let part = value[start..index].trim();
5700                if !part.is_empty() {
5701                    parts.push(part);
5702                }
5703                start = index + ch.len_utf8();
5704            }
5705            _ => {}
5706        }
5707    }
5708    let part = value[start..].trim();
5709    if !part.is_empty() {
5710        parts.push(part);
5711    }
5712    parts
5713}
5714
5715fn rust_parameter_name(pattern: &str) -> Option<&str> {
5716    let mut pattern = pattern.trim();
5717    if let Some(stripped) = pattern.strip_prefix("mut ") {
5718        pattern = stripped.trim_start();
5719    }
5720    pattern
5721        .rsplit(|ch: char| !is_rust_ident_char(ch))
5722        .find(|part| !part.is_empty())
5723}
5724
5725fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
5726    let mut ty = strip_leading_rust_type_modifiers(type_text);
5727    let owned_inner;
5728    if let Some(inner) = single_outer_generic_arg(ty) {
5729        owned_inner = inner.trim().to_string();
5730        ty = strip_leading_rust_type_modifiers(&owned_inner);
5731    }
5732    rust_base_type_ident(ty)
5733}
5734
5735fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
5736    loop {
5737        ty = ty.trim_start();
5738        if let Some(stripped) = ty.strip_prefix('&') {
5739            ty = stripped.trim_start();
5740            if let Some(stripped) = strip_leading_lifetime(ty) {
5741                ty = stripped.trim_start();
5742            }
5743            if let Some(stripped) = ty.strip_prefix("mut ") {
5744                ty = stripped.trim_start();
5745            }
5746            continue;
5747        }
5748        if let Some(stripped) = ty.strip_prefix("mut ") {
5749            ty = stripped.trim_start();
5750            continue;
5751        }
5752        if let Some(stripped) = ty.strip_prefix("dyn ") {
5753            ty = stripped.trim_start();
5754            continue;
5755        }
5756        if let Some(stripped) = ty.strip_prefix("impl ") {
5757            ty = stripped.trim_start();
5758            continue;
5759        }
5760        break ty.trim();
5761    }
5762}
5763
5764fn strip_leading_lifetime(value: &str) -> Option<&str> {
5765    let mut chars = value.char_indices();
5766    let (_, first) = chars.next()?;
5767    if first != '\'' {
5768        return None;
5769    }
5770    for (index, ch) in chars {
5771        if !(ch == '_' || ch.is_ascii_alphanumeric()) {
5772            return Some(&value[index..]);
5773        }
5774    }
5775    Some("")
5776}
5777
5778fn single_outer_generic_arg(ty: &str) -> Option<&str> {
5779    let ty = ty.trim();
5780    let open = ty.find('<')?;
5781    let mut depth = 0usize;
5782    let mut close = None;
5783    for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
5784        match ch {
5785            '<' => depth += 1,
5786            '>' => {
5787                depth = depth.saturating_sub(1);
5788                if depth == 0 {
5789                    close = Some(index);
5790                    break;
5791                }
5792            }
5793            _ => {}
5794        }
5795    }
5796    let close = close?;
5797    if !ty[close + 1..].trim().is_empty() {
5798        return None;
5799    }
5800    let inner = &ty[open + 1..close];
5801    let args = split_top_level_commas(inner);
5802    match args.as_slice() {
5803        [arg] => Some(*arg),
5804        _ => None,
5805    }
5806}
5807
5808fn rust_base_type_ident(ty: &str) -> Option<String> {
5809    let ty = ty.trim();
5810    let head = ty
5811        .split([' ', '+', '='])
5812        .find(|part| !part.is_empty())
5813        .unwrap_or(ty);
5814    let head = head.split('<').next().unwrap_or(head).trim();
5815    let ident = head
5816        .rsplit("::")
5817        .next()
5818        .unwrap_or(head)
5819        .trim_matches(|ch: char| !is_rust_ident_char(ch));
5820    if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
5821        None
5822    } else {
5823        Some(ident.to_string())
5824    }
5825}
5826
5827fn is_rust_ident_char(ch: char) -> bool {
5828    ch == '_' || ch.is_ascii_alphanumeric()
5829}
5830
5831fn select_type_match_candidate(
5832    reference: &NameMatchRef,
5833    candidates: &[NameMatchCandidate],
5834    receiver_type: &str,
5835) -> Option<NameMatchCandidate> {
5836    let candidates = candidates
5837        .iter()
5838        .filter(|candidate| candidate.node_id != reference.caller_node)
5839        .filter(|candidate| {
5840            type_candidate_matches(candidate, receiver_type, &reference.method_name)
5841        })
5842        .collect::<Vec<_>>();
5843    match candidates.as_slice() {
5844        [candidate] => Some((**candidate).clone()),
5845        _ => None,
5846    }
5847}
5848
5849fn type_candidate_matches(
5850    candidate: &NameMatchCandidate,
5851    receiver_type: &str,
5852    method_name: &str,
5853) -> bool {
5854    let normalized_type = receiver_type.replace('.', "::");
5855    let suffix = format!("{normalized_type}::{method_name}");
5856    candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
5857}
5858
5859fn select_name_match_candidate(
5860    reference: &NameMatchRef,
5861    candidates: &[NameMatchCandidate],
5862) -> Option<NameMatchCandidate> {
5863    let candidates = candidates
5864        .iter()
5865        .filter(|candidate| candidate.node_id != reference.caller_node)
5866        .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
5867        .collect::<Vec<_>>();
5868    match candidates.as_slice() {
5869        [] => None,
5870        [candidate] => Some((**candidate).clone()),
5871        _ => select_scored_name_match_candidate(reference, &candidates),
5872    }
5873}
5874
5875fn candidate_allowed_for_reference(
5876    reference: &NameMatchRef,
5877    candidate: &NameMatchCandidate,
5878) -> bool {
5879    if !reference.colon_dispatch {
5880        return true;
5881    }
5882
5883    candidate.kind == "method"
5884        && candidate
5885            .scoped_name
5886            .split("::")
5887            .any(|segment| segment == reference.receiver)
5888}
5889
5890fn select_scored_name_match_candidate(
5891    reference: &NameMatchRef,
5892    candidates: &[&NameMatchCandidate],
5893) -> Option<NameMatchCandidate> {
5894    let receiver_words = split_camel_case(&reference.receiver);
5895    if receiver_words.is_empty() {
5896        return None;
5897    }
5898
5899    let mut best: Option<(&NameMatchCandidate, f64)> = None;
5900    let mut tied_best = false;
5901    for candidate in candidates {
5902        let candidate_words = split_camel_case(&candidate.scoped_name);
5903        let overlap = receiver_words
5904            .iter()
5905            .filter(|receiver_word| {
5906                candidate_words
5907                    .iter()
5908                    .any(|candidate_word| candidate_word == *receiver_word)
5909            })
5910            .count() as f64;
5911        let score =
5912            overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
5913        match best {
5914            None => {
5915                best = Some((*candidate, score));
5916                tied_best = false;
5917            }
5918            Some((_, best_score)) if score > best_score => {
5919                best = Some((*candidate, score));
5920                tied_best = false;
5921            }
5922            Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
5923                tied_best = true;
5924            }
5925            _ => {}
5926        }
5927    }
5928
5929    let (candidate, score) = best?;
5930    if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
5931        Some(candidate.clone())
5932    } else {
5933        None
5934    }
5935}
5936
5937fn method_name_match_denylisted(method_name: &str) -> bool {
5938    matches!(
5939        method_name,
5940        "and_then"
5941            | "as_bytes"
5942            | "as_deref"
5943            | "as_mut"
5944            | "as_ref"
5945            | "as_str"
5946            | "borrow"
5947            | "borrow_mut"
5948            | "clear"
5949            | "clone"
5950            | "collect"
5951            | "contains"
5952            | "contains_key"
5953            | "count"
5954            | "dedup"
5955            | "default"
5956            | "drain"
5957            | "ends_with"
5958            | "entry"
5959            | "err"
5960            | "expect"
5961            | "extend"
5962            | "filter"
5963            | "filter_map"
5964            | "find"
5965            | "from"
5966            | "get"
5967            | "get_mut"
5968            | "insert"
5969            | "into"
5970            | "into_iter"
5971            | "is_empty"
5972            | "is_err"
5973            | "is_none"
5974            | "is_ok"
5975            | "is_some"
5976            | "iter"
5977            | "iter_mut"
5978            | "join"
5979            | "len"
5980            | "lock"
5981            | "map"
5982            | "map_err"
5983            | "max"
5984            | "min"
5985            | "new"
5986            | "next"
5987            | "ok"
5988            | "or_default"
5989            | "or_else"
5990            | "or_insert"
5991            | "or_insert_with"
5992            | "parse"
5993            | "pop"
5994            | "position"
5995            | "push"
5996            | "read"
5997            | "recv"
5998            | "remove"
5999            | "replace"
6000            | "retain"
6001            | "send"
6002            | "sort"
6003            | "sort_by"
6004            | "split"
6005            | "starts_with"
6006            | "sum"
6007            | "take"
6008            | "to_owned"
6009            | "to_string"
6010            | "trim"
6011            | "try_from"
6012            | "try_into"
6013            | "unwrap"
6014            | "unwrap_or"
6015            | "unwrap_or_default"
6016            | "unwrap_or_else"
6017            | "with_capacity"
6018            | "write"
6019    )
6020}
6021
6022fn split_camel_case(value: &str) -> Vec<String> {
6023    let chars = value.chars().collect::<Vec<_>>();
6024    let mut normalized = String::with_capacity(value.len() + 8);
6025    for (index, ch) in chars.iter().enumerate() {
6026        let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
6027        let next = chars.get(index + 1);
6028        let is_separator = ch.is_whitespace()
6029            || matches!(
6030                ch,
6031                '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
6032            );
6033        if is_separator {
6034            normalized.push(' ');
6035            continue;
6036        }
6037        let camel_boundary = previous.is_some_and(|prev| {
6038            (prev.is_lowercase() && ch.is_uppercase())
6039                || (prev.is_ascii_digit() && ch.is_alphabetic())
6040                || (prev.is_uppercase()
6041                    && ch.is_uppercase()
6042                    && next.is_some_and(|next| next.is_lowercase()))
6043        });
6044        if camel_boundary {
6045            normalized.push(' ');
6046        }
6047        normalized.push(*ch);
6048    }
6049
6050    normalized
6051        .split_whitespace()
6052        .filter(|word| word.len() > 1)
6053        .map(|word| word.to_ascii_lowercase())
6054        .collect()
6055}
6056
6057fn compute_path_proximity(left: &str, right: &str) -> f64 {
6058    let left_dirs = left
6059        .rsplit_once('/')
6060        .map(|(dir, _)| dir)
6061        .unwrap_or_default()
6062        .split('/')
6063        .filter(|part| !part.is_empty());
6064    let right_dirs = right
6065        .rsplit_once('/')
6066        .map(|(dir, _)| dir)
6067        .unwrap_or_default()
6068        .split('/')
6069        .filter(|part| !part.is_empty());
6070
6071    let shared = left_dirs
6072        .zip(right_dirs)
6073        .take_while(|(left, right)| left == right)
6074        .count();
6075    ((shared as f64) * 0.05).min(0.5)
6076}
6077
6078fn mark_backend_state(
6079    tx: &Transaction<'_>,
6080    project_root: &Path,
6081    rel_path: &str,
6082    content_hash: Option<&blake3::Hash>,
6083    status: &str,
6084) -> Result<()> {
6085    clear_backend_state_for_file(tx, project_root, rel_path)?;
6086    let hash = content_hash
6087        .map(|hash| hash_to_hex(*hash))
6088        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
6089    tx.execute(
6090        "INSERT OR REPLACE INTO backend_file_state(
6091            backend, workspace_root, file_path, content_hash, status, updated_at
6092        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
6093        params![
6094            BACKEND_TREESITTER,
6095            project_root.display().to_string(),
6096            rel_path,
6097            hash,
6098            status,
6099            unix_seconds_now(),
6100        ],
6101    )?;
6102    Ok(())
6103}
6104
6105fn clear_backend_state_for_file(
6106    tx: &Transaction<'_>,
6107    project_root: &Path,
6108    rel_path: &str,
6109) -> Result<()> {
6110    tx.execute(
6111        "DELETE FROM backend_file_state
6112         WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
6113        params![
6114            BACKEND_TREESITTER,
6115            project_root.display().to_string(),
6116            rel_path
6117        ],
6118    )?;
6119    Ok(())
6120}
6121
6122fn load_file_row(tx: &Transaction<'_>, rel_path: &str) -> Result<Option<FileRow>> {
6123    tx.query_row(
6124        "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
6125        params![rel_path],
6126        |row| {
6127            let hash_text: String = row.get(1)?;
6128            Ok(FileRow {
6129                surface_fingerprint: row.get(0)?,
6130                freshness: FileFreshness {
6131                    content_hash: hash_from_hex(&hash_text)
6132                        .unwrap_or_else(cache_freshness::zero_hash),
6133                    mtime: ns_to_system_time(row.get::<_, i64>(2)?),
6134                    size: row.get::<_, i64>(3)? as u64,
6135                },
6136            })
6137        },
6138    )
6139    .optional()
6140    .map_err(CallGraphStoreError::from)
6141}
6142
6143fn stored_node_ids_match_extract(
6144    tx: &Transaction<'_>,
6145    rel_path: &str,
6146    extract: &FileExtract,
6147) -> Result<bool> {
6148    let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
6149    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
6150    let mut stored = BTreeSet::new();
6151    for row in rows {
6152        stored.insert(row?);
6153    }
6154    let extracted = extract
6155        .nodes
6156        .iter()
6157        .map(|node| node.id.clone())
6158        .collect::<BTreeSet<_>>();
6159    Ok(stored == extracted)
6160}
6161
6162fn update_file_fresh_metadata(
6163    tx: &Transaction<'_>,
6164    rel_path: &str,
6165    hash: &blake3::Hash,
6166    mtime: SystemTime,
6167    size: u64,
6168) -> Result<()> {
6169    tx.execute(
6170        "UPDATE files SET mtime_ns = ?2, size = ?3, indexed_at = ?4 WHERE path = ?1",
6171        params![
6172            rel_path,
6173            system_time_to_ns(mtime),
6174            size as i64,
6175            unix_seconds_now()
6176        ],
6177    )?;
6178    tx.execute(
6179        "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
6180         WHERE backend = ?1 AND file_path = ?2 AND content_hash = ?3",
6181        params![
6182            BACKEND_TREESITTER,
6183            rel_path,
6184            hash_to_hex(*hash),
6185            unix_seconds_now(),
6186        ],
6187    )?;
6188    Ok(())
6189}
6190
6191#[derive(Debug, Clone, PartialEq, Eq)]
6192struct DependentRefSelection {
6193    ref_id: String,
6194    caller_file: String,
6195}
6196
6197fn ref_ids_depending_on(
6198    tx: &Transaction<'_>,
6199    project_root: &Path,
6200    rel_path: &str,
6201) -> Result<Vec<DependentRefSelection>> {
6202    let mut stmt = tx.prepare(
6203        "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
6204         FROM refs r
6205         WHERE r.caller_file IN (
6206             SELECT file_path FROM file_dependencies WHERE dep_file = ?1
6207         )
6208            OR r.target_file = ?1
6209         ORDER BY r.ref_id",
6210    )?;
6211    let rows = stmt.query_map(params![rel_path], |row| {
6212        Ok(RefDependencyRow {
6213            ref_id: row.get(0)?,
6214            kind: row.get(1)?,
6215            caller_file: row.get(2)?,
6216            module_path: row.get(3)?,
6217            target_file: row.get(4)?,
6218        })
6219    })?;
6220    let mut ids = Vec::new();
6221    for row in rows {
6222        let row = row?;
6223        if ref_dependency_row_depends_on(project_root, &row, rel_path) {
6224            ids.push(DependentRefSelection {
6225                ref_id: row.ref_id,
6226                caller_file: row.caller_file,
6227            });
6228        }
6229    }
6230    Ok(ids)
6231}
6232
6233fn record_dependent_refs(
6234    selected_ref_ids: &mut BTreeSet<String>,
6235    selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
6236    dependent_refs: Vec<DependentRefSelection>,
6237) {
6238    for dependent_ref in dependent_refs {
6239        let DependentRefSelection {
6240            ref_id,
6241            caller_file,
6242        } = dependent_ref;
6243        selected_ref_ids.insert(ref_id.clone());
6244        selected_refs_by_caller
6245            .entry(caller_file)
6246            .or_default()
6247            .insert(ref_id);
6248    }
6249}
6250
6251#[cfg(test)]
6252fn refs_by_caller_for_ref_ids(
6253    tx: &Transaction<'_>,
6254    ref_ids: &BTreeSet<String>,
6255) -> Result<BTreeMap<String, BTreeSet<String>>> {
6256    let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
6257    let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
6258    for ref_id in ref_ids {
6259        if let Some(caller) = stmt
6260            .query_row(params![ref_id], |row| row.get::<_, String>(0))
6261            .optional()?
6262        {
6263            by_caller.entry(caller).or_default().insert(ref_id.clone());
6264        }
6265    }
6266    Ok(by_caller)
6267}
6268
6269fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
6270    tx.execute(
6271        "DELETE FROM file_dependencies WHERE file_path = ?1",
6272        params![rel_path],
6273    )?;
6274    delete_refs_for_caller(tx, rel_path)?;
6275    tx.execute(
6276        "DELETE FROM dispatch_hints WHERE file = ?1",
6277        params![rel_path],
6278    )?;
6279    tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
6280    tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
6281    Ok(())
6282}
6283
6284fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
6285    let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
6286    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
6287    let mut ids = BTreeSet::new();
6288    for row in rows {
6289        ids.insert(row?);
6290    }
6291    delete_ref_ids(tx, &ids)
6292}
6293
6294fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
6295    for ref_id in ref_ids {
6296        tx.execute("DELETE FROM edges WHERE ref_id = ?1", params![ref_id])?;
6297        tx.execute("DELETE FROM refs WHERE ref_id = ?1", params![ref_id])?;
6298    }
6299    Ok(())
6300}
6301
6302fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
6303    let mut stmt = conn.prepare(
6304        "SELECT source.file_path, source.scoped_name, edges.target_file,
6305                edges.target_symbol, edges.kind, edges.line
6306         FROM edges
6307         JOIN nodes AS source ON source.id = edges.source_node
6308         ORDER BY source.file_path, source.scoped_name, edges.target_file,
6309                  edges.target_symbol, edges.kind, edges.line",
6310    )?;
6311    let rows = stmt.query_map([], |row| {
6312        Ok(StoredEdge {
6313            source_file: row.get(0)?,
6314            source_symbol: row.get(1)?,
6315            target_file: row.get(2)?,
6316            target_symbol: row.get(3)?,
6317            kind: row.get(4)?,
6318            line: row.get::<_, i64>(5)? as u32,
6319        })
6320    })?;
6321    let mut edges = BTreeSet::new();
6322    for row in rows {
6323        edges.insert(row?);
6324    }
6325    Ok(edges)
6326}
6327
6328fn module_target_from_dependencies(
6329    project_root: &Path,
6330    dependencies: &BTreeSet<String>,
6331) -> Option<String> {
6332    dependencies.iter().find_map(|dep| {
6333        let path = project_root.join(dep);
6334        if path.is_file() {
6335            Some(relative_path(project_root, &canonicalize_path(&path)))
6336        } else {
6337            None
6338        }
6339    })
6340}
6341
6342fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
6343    let mut named = HashMap::new();
6344    if let Some(full_ref) = &raw_ref.full_ref {
6345        named = parse_reexport_names(full_ref);
6346    }
6347    ReexportIndex {
6348        target_file,
6349        named,
6350        wildcard: raw_ref.wildcard,
6351    }
6352}
6353
6354fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
6355    let mut names = HashMap::new();
6356    let Some(open) = statement.find('{') else {
6357        return names;
6358    };
6359    let Some(close) = statement[open + 1..]
6360        .find('}')
6361        .map(|offset| open + 1 + offset)
6362    else {
6363        return names;
6364    };
6365    for spec in statement[open + 1..close].split(',') {
6366        let spec = spec.trim();
6367        if spec.is_empty() {
6368            continue;
6369        }
6370        if let Some((source, local)) = spec.split_once(" as ") {
6371            names.insert(local.trim().to_string(), source.trim().to_string());
6372        } else {
6373            names.insert(spec.to_string(), spec.to_string());
6374        }
6375    }
6376    names
6377}
6378
6379fn dependencies_for_ref(
6380    tx: &Transaction<'_>,
6381    project_root: &Path,
6382    ref_id: &str,
6383) -> Result<BTreeSet<String>> {
6384    let row = tx.query_row(
6385        "SELECT kind, caller_file, module_path, target_file FROM refs WHERE ref_id = ?1",
6386        params![ref_id],
6387        |row| {
6388            Ok(RefDependencyRow {
6389                ref_id: ref_id.to_string(),
6390                kind: row.get(0)?,
6391                caller_file: row.get(1)?,
6392                module_path: row.get(2)?,
6393                target_file: row.get(3)?,
6394            })
6395        },
6396    )?;
6397
6398    match row.kind.as_str() {
6399        "import" | "reexport" => {
6400            let Some(module_path) = row.module_path.as_deref() else {
6401                return Ok(BTreeSet::new());
6402            };
6403            let file_deps = file_dependencies_for_file(tx, &row.caller_file)?;
6404            let module_deps =
6405                module_dependencies_for_ref(project_root, &row.caller_file, module_path);
6406            Ok(file_deps.intersection(&module_deps).cloned().collect())
6407        }
6408        "export_alias" => Ok(BTreeSet::new()),
6409        "call" => {
6410            let mut deps = file_dependencies_for_file(tx, &row.caller_file)?;
6411            if let Some(target_file) = row.target_file {
6412                deps.insert(target_file);
6413            }
6414            Ok(deps)
6415        }
6416        _ => file_dependencies_for_file(tx, &row.caller_file),
6417    }
6418}
6419
6420#[derive(Debug)]
6421struct RefDependencyRow {
6422    ref_id: String,
6423    kind: String,
6424    caller_file: String,
6425    module_path: Option<String>,
6426    target_file: Option<String>,
6427}
6428
6429fn ref_dependency_row_depends_on(
6430    project_root: &Path,
6431    row: &RefDependencyRow,
6432    rel_path: &str,
6433) -> bool {
6434    if row.target_file.as_deref() == Some(rel_path) {
6435        return true;
6436    }
6437
6438    match row.kind.as_str() {
6439        "call" => true,
6440        "import" | "reexport" => row
6441            .module_path
6442            .as_deref()
6443            .map(|module_path| {
6444                module_dependencies_for_ref(project_root, &row.caller_file, module_path)
6445                    .contains(rel_path)
6446            })
6447            .unwrap_or(false),
6448        "export_alias" => false,
6449        _ => false,
6450    }
6451}
6452
6453fn file_dependencies_for_file(tx: &Transaction<'_>, file_path: &str) -> Result<BTreeSet<String>> {
6454    let mut stmt = tx
6455        .prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1 ORDER BY dep_file")?;
6456    let rows = stmt.query_map(params![file_path], |row| row.get::<_, String>(0))?;
6457    let mut deps = BTreeSet::new();
6458    for row in rows {
6459        deps.insert(row?);
6460    }
6461    Ok(deps)
6462}
6463
6464fn module_dependencies_for_ref(
6465    project_root: &Path,
6466    caller_file: &str,
6467    module_path: &str,
6468) -> BTreeSet<String> {
6469    module_dependencies(project_root, &project_root.join(caller_file), module_path)
6470}
6471
6472fn import_dependencies(
6473    project_root: &Path,
6474    abs_path: &Path,
6475    imports: &[ImportStatement],
6476) -> BTreeSet<String> {
6477    let mut deps = BTreeSet::new();
6478    for import in imports {
6479        deps.extend(module_dependencies(
6480            project_root,
6481            abs_path,
6482            &import.module_path,
6483        ));
6484    }
6485    deps
6486}
6487
6488fn module_dependencies(
6489    project_root: &Path,
6490    abs_path: &Path,
6491    module_path: &str,
6492) -> BTreeSet<String> {
6493    let mut deps = BTreeSet::new();
6494    let caller_dir = abs_path.parent().unwrap_or(project_root);
6495    if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
6496        deps.insert(relative_path(project_root, &resolved));
6497    }
6498    if module_path.starts_with('.') {
6499        let base = caller_dir.join(module_path);
6500        for candidate in relative_module_candidates(&base) {
6501            deps.insert(relative_path(project_root, &candidate));
6502        }
6503    }
6504    deps
6505}
6506
6507fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
6508    let mut candidates = Vec::new();
6509    if base.extension().is_some() {
6510        candidates.push(base.to_path_buf());
6511        return candidates;
6512    }
6513    for ext in JS_TS_EXTENSIONS {
6514        candidates.push(base.with_extension(ext));
6515    }
6516    for ext in JS_TS_EXTENSIONS {
6517        candidates.push(base.join(format!("index.{ext}")));
6518    }
6519    candidates
6520}
6521
6522fn import_local_names(import: &ImportStatement) -> Vec<String> {
6523    let mut names = Vec::new();
6524    if let Some(default) = &import.default_import {
6525        names.push(default.clone());
6526    }
6527    if let Some(namespace) = &import.namespace_import {
6528        names.push(namespace.clone());
6529    }
6530    for name in &import.names {
6531        names.push(crate::imports::specifier_local_name(name).to_string());
6532    }
6533    names
6534}
6535
6536fn import_requested_names(import: &ImportStatement) -> Vec<String> {
6537    import
6538        .names
6539        .iter()
6540        .map(|name| crate::imports::specifier_imported_name(name).to_string())
6541        .collect()
6542}
6543
6544fn import_is_wildcard(import: &ImportStatement) -> bool {
6545    import.namespace_import.is_some() || import.raw_text.contains('*')
6546}
6547
6548fn namespace_alias(full_ref: &str) -> Option<String> {
6549    full_ref
6550        .split_once('.')
6551        .map(|(namespace, _)| namespace.to_string())
6552}
6553
6554fn import_kind_label(kind: ImportKind) -> &'static str {
6555    match kind {
6556        ImportKind::Value => "value",
6557        ImportKind::Type => "type",
6558        ImportKind::SideEffect => "side_effect",
6559    }
6560}
6561
6562fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
6563    match kind {
6564        SymbolKind::Function => "function",
6565        SymbolKind::Class => "class",
6566        SymbolKind::Method => "method",
6567        SymbolKind::Struct => "struct",
6568        SymbolKind::Interface => "interface",
6569        SymbolKind::Enum => "enum",
6570        SymbolKind::TypeAlias => "type_alias",
6571        SymbolKind::Variable => "variable",
6572        SymbolKind::Heading => "heading",
6573        SymbolKind::FileSummary => "file_summary",
6574    }
6575}
6576
6577fn is_type_like(kind: &SymbolKind) -> bool {
6578    matches!(
6579        kind,
6580        SymbolKind::Class
6581            | SymbolKind::Struct
6582            | SymbolKind::Interface
6583            | SymbolKind::Enum
6584            | SymbolKind::TypeAlias
6585    )
6586}
6587
6588fn lang_label(lang: LangId) -> &'static str {
6589    match lang {
6590        LangId::TypeScript => "typescript",
6591        LangId::Tsx => "tsx",
6592        LangId::JavaScript => "javascript",
6593        LangId::Python => "python",
6594        LangId::Rust => "rust",
6595        LangId::Go => "go",
6596        LangId::C => "c",
6597        LangId::Cpp => "cpp",
6598        LangId::Zig => "zig",
6599        LangId::CSharp => "csharp",
6600        LangId::Bash => "bash",
6601        LangId::Html => "html",
6602        LangId::Markdown => "markdown",
6603        LangId::Solidity => "solidity",
6604        LangId::Scss => "scss",
6605        LangId::Vue => "vue",
6606        LangId::Json => "json",
6607        LangId::Scala => "scala",
6608        LangId::Java => "java",
6609        LangId::Ruby => "ruby",
6610        LangId::Kotlin => "kotlin",
6611        LangId::Swift => "swift",
6612        LangId::Php => "php",
6613        LangId::Lua => "lua",
6614        LangId::Perl => "perl",
6615        LangId::Yaml => "yaml",
6616        LangId::Pascal => "pascal",
6617        LangId::R => "r",
6618    }
6619}
6620
6621fn lang_from_label(label: &str) -> Option<LangId> {
6622    match label {
6623        "typescript" => Some(LangId::TypeScript),
6624        "tsx" => Some(LangId::Tsx),
6625        "javascript" => Some(LangId::JavaScript),
6626        "python" => Some(LangId::Python),
6627        "rust" => Some(LangId::Rust),
6628        "go" => Some(LangId::Go),
6629        "c" => Some(LangId::C),
6630        "cpp" => Some(LangId::Cpp),
6631        "zig" => Some(LangId::Zig),
6632        "csharp" => Some(LangId::CSharp),
6633        "bash" => Some(LangId::Bash),
6634        "html" => Some(LangId::Html),
6635        "markdown" => Some(LangId::Markdown),
6636        "solidity" => Some(LangId::Solidity),
6637        "scss" => Some(LangId::Scss),
6638        "vue" => Some(LangId::Vue),
6639        "json" => Some(LangId::Json),
6640        "scala" => Some(LangId::Scala),
6641        "java" => Some(LangId::Java),
6642        "ruby" => Some(LangId::Ruby),
6643        "kotlin" => Some(LangId::Kotlin),
6644        "swift" => Some(LangId::Swift),
6645        "php" => Some(LangId::Php),
6646        "lua" => Some(LangId::Lua),
6647        "perl" => Some(LangId::Perl),
6648        "yaml" => Some(LangId::Yaml),
6649        "pascal" => Some(LangId::Pascal),
6650        "r" => Some(LangId::R),
6651        _ => None,
6652    }
6653}
6654
6655fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
6656    let mut normalized = if files.is_empty() {
6657        callgraph::walk_project_files(project_root).collect::<Vec<_>>()
6658    } else {
6659        files
6660            .iter()
6661            .map(|path| normalize_file_path(project_root, path))
6662            .collect::<Result<Vec<_>>>()?
6663    };
6664    normalized.sort();
6665    normalized.dedup();
6666    Ok(normalized)
6667}
6668
6669fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
6670    let full_path = if path.is_relative() {
6671        project_root.join(path)
6672    } else {
6673        path.to_path_buf()
6674    };
6675    Ok(canonicalize_path(&full_path))
6676}
6677
6678fn canonicalize_path(path: &Path) -> PathBuf {
6679    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
6680}
6681
6682fn relative_path(project_root: &Path, path: &Path) -> String {
6683    if let Ok(stripped) = path.strip_prefix(project_root) {
6684        return stripped.to_string_lossy().replace('\\', "/");
6685    }
6686    let canon_root = canonicalize_path(project_root);
6687    let canon_path = canonicalize_path(path);
6688    if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
6689        return stripped.to_string_lossy().replace('\\', "/");
6690    }
6691    canon_path.to_string_lossy().replace('\\', "/")
6692}
6693
6694fn unqualified_name(scoped: &str) -> &str {
6695    if scoped == TOP_LEVEL_SYMBOL {
6696        return scoped;
6697    }
6698    scoped
6699        .rsplit("::")
6700        .next()
6701        .unwrap_or(scoped)
6702        .rsplit('.')
6703        .next()
6704        .unwrap_or(scoped)
6705        .rsplit('#')
6706        .next()
6707        .unwrap_or(scoped)
6708}
6709
6710fn ref_id(parts: &[&str]) -> String {
6711    let joined = parts.join("\0");
6712    hash_to_hex(blake3::hash(joined.as_bytes()))
6713}
6714
6715fn hash_to_hex(hash: blake3::Hash) -> String {
6716    hash.to_hex().to_string()
6717}
6718
6719fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
6720    let bytes = hex_to_bytes(value)?;
6721    Some(blake3::Hash::from_bytes(bytes))
6722}
6723
6724fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
6725    if value.len() != 64 {
6726        return None;
6727    }
6728    let mut bytes = [0u8; 32];
6729    for (index, slot) in bytes.iter_mut().enumerate() {
6730        let start = index * 2;
6731        let end = start + 2;
6732        *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
6733    }
6734    Some(bytes)
6735}
6736
6737#[derive(Debug, Clone)]
6738struct LineIndex {
6739    newline_offsets: Vec<usize>,
6740    source_len: usize,
6741}
6742
6743impl LineIndex {
6744    fn new(source: &str) -> Self {
6745        Self {
6746            newline_offsets: source
6747                .bytes()
6748                .enumerate()
6749                .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
6750                .collect(),
6751            source_len: source.len(),
6752        }
6753    }
6754
6755    fn byte_to_line(&self, byte_offset: usize) -> u32 {
6756        let byte_offset = byte_offset.min(self.source_len);
6757        self.newline_offsets
6758            .partition_point(|offset| *offset < byte_offset) as u32
6759            + 1
6760    }
6761}
6762
6763fn empty_to_none(value: String) -> Option<String> {
6764    if value.is_empty() {
6765        None
6766    } else {
6767        Some(value)
6768    }
6769}
6770
6771fn bool_int(value: bool) -> i64 {
6772    if value {
6773        1
6774    } else {
6775        0
6776    }
6777}
6778
6779fn system_time_to_ns(time: SystemTime) -> i64 {
6780    time.duration_since(UNIX_EPOCH)
6781        .unwrap_or_default()
6782        .as_nanos()
6783        .min(i64::MAX as u128) as i64
6784}
6785
6786fn ns_to_system_time(value: i64) -> SystemTime {
6787    UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
6788}
6789
6790fn unix_seconds_now() -> i64 {
6791    SystemTime::now()
6792        .duration_since(UNIX_EPOCH)
6793        .unwrap_or_default()
6794        .as_secs() as i64
6795}
6796
6797#[cfg(test)]
6798mod cold_build_insert_tests {
6799    use super::*;
6800    use crate::imports::ImportBlock;
6801    use std::fs;
6802    use tempfile::tempdir;
6803
6804    #[test]
6805    fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
6806        let dir = tempdir().expect("temp dir");
6807        let file = dir.path().join("main.ts");
6808        fs::write(
6809            &file,
6810            r#"export function topA() {
6811  root();
6812}
6813
6814export function topB() {
6815  root();
6816}
6817
6818export function root() {
6819  leaf();
6820  missing();
6821}
6822
6823export function leaf() {}
6824"#,
6825        )
6826        .expect("write fixture");
6827
6828        let store = CallGraphStore::open(
6829            dir.path().join(".store-depth-boundary-counts"),
6830            dir.path().to_path_buf(),
6831        )
6832        .expect("open store");
6833        store
6834            .cold_build(std::slice::from_ref(&file))
6835            .expect("cold build");
6836
6837        let root = store
6838            .node_for(Path::new("main.ts"), "root")
6839            .expect("root node");
6840        let leaf = store
6841            .node_for(Path::new("main.ts"), "leaf")
6842            .expect("leaf node");
6843
6844        let (full_forward_len, full_direct_len) = {
6845            let conn = store.conn.lock().expect("callgraph store mutex poisoned");
6846            conn.execute(
6847                "INSERT INTO edges (
6848                    edge_id, ref_id, source_node, target_node, target_file,
6849                    target_symbol, kind, line, provenance
6850                 ) VALUES (
6851                    'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
6852                    ?2, ?3, 'call', 98, ?4
6853                 )",
6854                rusqlite::params![
6855                    &root.node_id,
6856                    &leaf.file,
6857                    &leaf.symbol,
6858                    PROVENANCE_TREESITTER
6859                ],
6860            )
6861            .expect("insert dangling forward edge");
6862            conn.execute(
6863                "INSERT INTO edges (
6864                    edge_id, ref_id, source_node, target_node, target_file,
6865                    target_symbol, kind, line, provenance
6866                 ) VALUES (
6867                    'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
6868                    ?1, ?2, ?3, 'call', 99, ?4
6869                 )",
6870                rusqlite::params![
6871                    &root.node_id,
6872                    &root.file,
6873                    &root.symbol,
6874                    PROVENANCE_TREESITTER
6875                ],
6876            )
6877            .expect("insert dangling direct-caller edge");
6878
6879            let full_forward_len = forward_calls_for_node(&conn, &root)
6880                .expect("full forward calls")
6881                .len();
6882            let counted_forward_len =
6883                forward_call_count_for_node(&conn, &root).expect("counted forward calls");
6884            assert_eq!(
6885                counted_forward_len, full_forward_len,
6886                "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
6887            );
6888
6889            let full_direct_len = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
6890                .expect("full direct callers")
6891                .len();
6892            let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
6893                .expect("counted direct callers");
6894            assert_eq!(
6895                counted_direct_len, full_direct_len,
6896                "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
6897            );
6898
6899            (full_forward_len, full_direct_len)
6900        };
6901
6902        assert_eq!(
6903            full_forward_len, 2,
6904            "fixture root should have one resolved and one unresolved outgoing call"
6905        );
6906        assert_eq!(
6907            full_direct_len, 2,
6908            "fixture root should have two real direct callers"
6909        );
6910
6911        let tree = store
6912            .call_tree(Path::new("main.ts"), "root", 0)
6913            .expect("call tree");
6914        assert!(tree.depth_limited);
6915        assert_eq!(tree.children.len(), 0);
6916        assert_eq!(
6917            tree.truncated, full_forward_len,
6918            "call_tree depth boundary must report the full forward-call list length"
6919        );
6920
6921        let callers = store
6922            .callers_of(Path::new("main.ts"), "leaf", 0)
6923            .expect("callers");
6924        assert!(callers.depth_limited);
6925        assert_eq!(callers.callers.len(), 1);
6926        assert_eq!(callers.callers[0].caller.symbol, "root");
6927        assert_eq!(
6928            callers.truncated, full_direct_len,
6929            "callers depth boundary must report the full direct-caller list length"
6930        );
6931    }
6932
6933    #[test]
6934    fn source_freshness_matches_cache_collect_for_same_bytes() {
6935        let dir = tempdir().expect("temp dir");
6936        let path = dir.path().join("fixture.ts");
6937        let source = "export function main() { return helper(); }\n";
6938        fs::write(&path, source).expect("write fixture");
6939
6940        let expected = cache_freshness::collect(&path).expect("collect freshness from file");
6941        let actual =
6942            collect_source_freshness(&path, source).expect("collect freshness from source");
6943
6944        assert_eq!(actual, expected);
6945    }
6946
6947    #[test]
6948    fn cold_build_prepared_bulk_insert_matches_reference_rows() {
6949        let dir = tempdir().expect("temp dir");
6950        let project_root = dir.path();
6951        let extract = fixture_extract(project_root);
6952        let resolved = fixture_resolved(&extract);
6953
6954        let reference = build_reference_connection(project_root, &extract, &resolved);
6955        let optimized = build_optimized_connection(project_root, &extract, &resolved);
6956
6957        for table in [
6958            "files",
6959            "nodes",
6960            "file_dependencies",
6961            "dispatch_hints",
6962            "refs",
6963            "edges",
6964        ] {
6965            // `files.indexed_at` is a wall-clock insert timestamp (unix_seconds_now);
6966            // the reference and optimized builds run sequentially and can straddle a
6967            // one-second tick under load, so it is legitimately allowed to differ.
6968            // This mirrors the existing exclusions of `backend_file_state.updated_at`
6969            // and the chunked-vs-unchunked sibling test. The check is for structural
6970            // row equivalence of the optimized bulk insert, not wall-clock equality.
6971            let excluded: &[&str] = if table == "files" {
6972                &["indexed_at"]
6973            } else {
6974                &[]
6975            };
6976            assert_eq!(
6977                table_rows_without(&reference, table, excluded),
6978                table_rows_without(&optimized, table, excluded),
6979                "table `{table}` rows must match apart from wall-clock columns"
6980            );
6981        }
6982        assert_eq!(
6983            backend_state_rows(&reference),
6984            backend_state_rows(&optimized),
6985            "backend freshness rows must match apart from updated_at"
6986        );
6987        assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
6988    }
6989
6990    #[test]
6991    fn cold_build_chunked_matches_unchunked_logical_rows() {
6992        let dir = tempdir().expect("temp dir");
6993        let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
6994        write_chunked_equivalence_fixture(&project_root);
6995        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
6996        assert!(
6997            files.len() > 6,
6998            "fixture should be large enough to split into multiple chunks"
6999        );
7000
7001        let unchunked = CallGraphStore::open(
7002            project_root.join(".store-unchunked"),
7003            project_root.to_path_buf(),
7004        )
7005        .expect("open unchunked store");
7006        let unchunked_stats = unchunked
7007            .cold_build_chunked(&files, 0)
7008            .expect("unchunked cold build");
7009
7010        let chunked = CallGraphStore::open(
7011            project_root.join(".store-chunked"),
7012            project_root.to_path_buf(),
7013        )
7014        .expect("open chunked store");
7015        let chunked_stats = chunked
7016            .cold_build_chunked(&files, 3)
7017            .expect("chunked cold build");
7018
7019        assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
7020        assert_eq!(
7021            unchunked.edge_snapshot().expect("unchunked edge snapshot"),
7022            chunked.edge_snapshot().expect("chunked edge snapshot"),
7023            "public edge snapshots must match"
7024        );
7025
7026        let dispatch_edges = {
7027            let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
7028            conn.query_row(
7029                "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
7030                [],
7031                |row| row.get::<_, i64>(0),
7032            )
7033            .expect("count dispatch edges")
7034        };
7035        assert!(
7036            dispatch_edges > 0,
7037            "fixture must exercise method-dispatch edge insertion"
7038        );
7039
7040        for table in [
7041            "edges",
7042            "refs",
7043            "nodes",
7044            "file_dependencies",
7045            "dispatch_hints",
7046        ] {
7047            assert_eq!(
7048                graph_table_rows(&unchunked, table),
7049                graph_table_rows(&chunked, table),
7050                "chunked cold build must match unchunked rows for {table}"
7051            );
7052        }
7053        assert_eq!(
7054            graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
7055            graph_table_rows_without(&chunked, "files", &["indexed_at"]),
7056            "files rows must match apart from indexed_at"
7057        );
7058        assert_eq!(
7059            graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
7060            graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
7061            "backend freshness rows must match apart from updated_at"
7062        );
7063
7064        let published_dir = project_root.join(".store-published");
7065        let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
7066            published_dir.clone(),
7067            project_root.to_path_buf(),
7068            &files,
7069            0,
7070        )
7071        .expect("published unchunked cold build");
7072        assert!(
7073            !CallGraphStore::needs_cold_build(&published_dir, &project_root)
7074                .expect("needs_cold_build after publish"),
7075            "published store should be ready"
7076        );
7077        let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
7078            published_dir,
7079            project_root.to_path_buf(),
7080            &files,
7081            3,
7082        )
7083        .expect("ensure with a different chunk size");
7084        assert!(
7085            rebuild_stats.is_none(),
7086            "changing callgraph_chunk_size must not affect store identity or force a rebuild"
7087        );
7088    }
7089
7090    // Perf A/B bench (not a gate): measures cold_build wall time at a given
7091    // chunk size against a real repo. Driven by env so the same binary can A/B
7092    // chunk=0 vs chunk=N in clean isolation. Reusable for the deferred DB-spill
7093    // memory work. Run:
7094    //   AFT_PERF_REPO=/path AFT_PERF_CHUNK=0 cargo test -p agent-file-tools \
7095    //     --release --lib bench_cold_build_chunk -- --ignored --nocapture
7096    #[test]
7097    #[ignore]
7098    fn bench_cold_build_chunk() {
7099        let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
7100        let chunk: usize = std::env::var("AFT_PERF_CHUNK")
7101            .expect("AFT_PERF_CHUNK")
7102            .parse()
7103            .expect("AFT_PERF_CHUNK must be a non-negative integer");
7104        let project_root = fs::canonicalize(&repo).expect("canonical repo root");
7105        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
7106        let dir = tempdir().expect("temp dir");
7107        let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
7108            .expect("open store");
7109        let started = Instant::now();
7110        let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
7111        let ms = started.elapsed().as_millis();
7112        println!(
7113            "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
7114            stats.files, stats.nodes, stats.refs, stats.edges
7115        );
7116    }
7117
7118    #[test]
7119    fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
7120        let dir = tempdir().expect("temp dir");
7121        let project_root = dir.path();
7122        let files =
7123            write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
7124        let index_path = project_root.join("src/index.ts");
7125
7126        let store = CallGraphStore::open(
7127            project_root.join(".store-incremental-barrel"),
7128            project_root.to_path_buf(),
7129        )
7130        .expect("open incremental store");
7131        store.cold_build(&files).expect("initial cold build");
7132
7133        {
7134            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
7135            let tx = conn.transaction().expect("dependency transaction");
7136            let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
7137                .expect("dependent refs for barrel");
7138            let selected_ref_ids = dependent_refs
7139                .iter()
7140                .map(|dependent_ref| dependent_ref.ref_id.clone())
7141                .collect::<BTreeSet<_>>();
7142            let mut threaded_ref_ids = BTreeSet::new();
7143            let mut threaded_by_caller = BTreeMap::new();
7144            record_dependent_refs(
7145                &mut threaded_ref_ids,
7146                &mut threaded_by_caller,
7147                dependent_refs,
7148            );
7149            let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
7150                .expect("old per-ref caller lookup");
7151
7152            assert_eq!(threaded_ref_ids, selected_ref_ids);
7153            assert_eq!(threaded_by_caller, old_by_caller);
7154            for consumer in [
7155                "src/consumer_a.ts",
7156                "src/consumer_b.ts",
7157                "src/consumer_c.ts",
7158            ] {
7159                assert!(
7160                    threaded_by_caller.contains_key(consumer),
7161                    "barrel edit should select dependent refs from {consumer}"
7162                );
7163            }
7164        }
7165
7166        fs::write(
7167            &index_path,
7168            "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
7169        )
7170        .expect("edit barrel");
7171        let stats = store
7172            .refresh_files(std::slice::from_ref(&index_path))
7173            .expect("incremental refresh");
7174        assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
7175        assert!(
7176            stats.dependency_selected_refs > 0,
7177            "barrel surface edit should select dependent refs"
7178        );
7179
7180        let cold_store = CallGraphStore::open(
7181            project_root.join(".store-cold-barrel"),
7182            project_root.to_path_buf(),
7183        )
7184        .expect("open cold rebuild store");
7185        cold_store
7186            .cold_build(&files)
7187            .expect("comparison cold build");
7188
7189        for table in [
7190            "nodes",
7191            "refs",
7192            "file_dependencies",
7193            "edges",
7194            "dispatch_hints",
7195        ] {
7196            assert_eq!(
7197                graph_table_rows(&store, table),
7198                graph_table_rows(&cold_store, table),
7199                "incremental refresh {table} rows must match cold rebuild"
7200            );
7201        }
7202    }
7203
7204    fn build_reference_connection(
7205        project_root: &Path,
7206        extract: &FileExtract,
7207        resolved: &ResolvedRef,
7208    ) -> Connection {
7209        let mut conn = Connection::open_in_memory().expect("open reference db");
7210        configure_build_connection(&conn).expect("configure reference db");
7211        initialize_schema(&conn).expect("initialize reference schema");
7212        {
7213            let tx = conn.transaction().expect("reference transaction");
7214            clear_tables(&tx).expect("reference clear");
7215            insert_meta(&tx).expect("reference meta");
7216            insert_file_extract(&tx, project_root, extract).expect("reference file extract");
7217            insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
7218            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
7219                .expect("reference dispatch edges");
7220            assert_eq!(supplemental, 0);
7221            tx.commit().expect("reference commit");
7222        }
7223        conn
7224    }
7225
7226    fn build_optimized_connection(
7227        project_root: &Path,
7228        extract: &FileExtract,
7229        resolved: &ResolvedRef,
7230    ) -> Connection {
7231        let mut conn = Connection::open_in_memory().expect("open optimized db");
7232        configure_build_connection(&conn).expect("configure optimized db");
7233        initialize_schema(&conn).expect("initialize optimized schema");
7234        {
7235            let tx = conn.transaction().expect("optimized transaction");
7236            clear_tables(&tx).expect("optimized clear");
7237            insert_meta(&tx).expect("optimized meta");
7238            drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
7239            {
7240                let workspace_root = project_root.display().to_string();
7241                let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
7242                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
7243                    .expect("optimized file extract");
7244                insert_resolved_ref_prepared(&mut inserts, resolved)
7245                    .expect("optimized resolved ref");
7246            }
7247            create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
7248            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
7249                .expect("optimized dispatch edges");
7250            assert_eq!(supplemental, 0);
7251            tx.commit().expect("optimized commit");
7252        }
7253        conn
7254    }
7255
7256    fn fixture_extract(project_root: &Path) -> FileExtract {
7257        let rel_path = "src/main.ts".to_string();
7258        let target_path = "src/helper.ts".to_string();
7259        let node = NodeRecord {
7260            id: "node-main".to_string(),
7261            file_path: rel_path.clone(),
7262            name: "main".to_string(),
7263            scoped_name: "main".to_string(),
7264            kind: "function".to_string(),
7265            range: Range {
7266                start_line: 0,
7267                start_col: 0,
7268                end_line: 0,
7269                end_col: 32,
7270            },
7271            range_ordinal: 0,
7272            signature: Some("export function main()".to_string()),
7273            exported: true,
7274            is_default_export: false,
7275            is_type_like: false,
7276            is_callgraph_entry_point: true,
7277        };
7278        let mut dependencies = BTreeSet::new();
7279        dependencies.insert(target_path.clone());
7280        let raw_ref = RawRef {
7281            ref_id: "ref-main-helper".to_string(),
7282            caller_node: Some(node.id.clone()),
7283            caller_symbol: Some(node.scoped_name.clone()),
7284            caller_file: rel_path.clone(),
7285            kind: "call".to_string(),
7286            short_name: Some("helper".to_string()),
7287            full_ref: Some("helper".to_string()),
7288            module_path: None,
7289            import_kind: None,
7290            local_name: Some("helper".to_string()),
7291            requested_name: Some("helper".to_string()),
7292            namespace_alias: None,
7293            wildcard: false,
7294            line: 1,
7295            byte_start: 24,
7296            byte_end: 32,
7297            dependencies,
7298        };
7299        FileExtract {
7300            abs_path: project_root.join(&rel_path),
7301            rel_path,
7302            freshness: FileFreshness {
7303                mtime: UNIX_EPOCH + Duration::from_secs(123),
7304                size: 40,
7305                content_hash: cache_freshness::hash_bytes(b"fixture source"),
7306            },
7307            lang: LangId::TypeScript,
7308            data: FileCallData {
7309                calls_by_symbol: HashMap::new(),
7310                exported_symbols: Vec::new(),
7311                symbol_metadata: HashMap::new(),
7312                default_export_symbol: None,
7313                import_block: ImportBlock::empty(),
7314                lang: LangId::TypeScript,
7315            },
7316            nodes: vec![node.clone()],
7317            raw_refs: vec![raw_ref],
7318            dispatch_hints: vec![DispatchHint {
7319                id: "dispatch-main-helper".to_string(),
7320                method_name: "helper".to_string(),
7321                caller_node: node.id,
7322                file: "src/main.ts".to_string(),
7323                line: 1,
7324                byte_start: 24,
7325                byte_end: 32,
7326            }],
7327            surface_fingerprint: "surface".to_string(),
7328        }
7329    }
7330
7331    fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
7332        let raw = extract.raw_refs[0].clone();
7333        let mut dependencies = raw.dependencies.clone();
7334        dependencies.insert("src/helper.ts".to_string());
7335        ResolvedRef {
7336            edge: Some(EdgeRecord {
7337                edge_id: "edge-main-helper".to_string(),
7338                source_node: raw.caller_node.clone().expect("caller node"),
7339                target_node: Some("node-helper".to_string()),
7340                target_file: "src/helper.ts".to_string(),
7341                target_symbol: "helper".to_string(),
7342                kind: "call".to_string(),
7343                line: raw.line,
7344            }),
7345            raw,
7346            status: "resolved".to_string(),
7347            target_node: Some("node-helper".to_string()),
7348            target_file: Some("src/helper.ts".to_string()),
7349            target_symbol: Some("helper".to_string()),
7350            dependencies,
7351        }
7352    }
7353
7354    fn write_chunked_equivalence_fixture(project_root: &Path) {
7355        let ts_dir = project_root.join("ts");
7356        fs::create_dir_all(&ts_dir).expect("create ts dir");
7357        fs::write(
7358            ts_dir.join("leaf.ts"),
7359            "export function leaf(value: number) {\n  return value + 1;\n}\n",
7360        )
7361        .expect("write ts leaf");
7362        fs::write(
7363            ts_dir.join("mid.ts"),
7364            "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n  return leaf(value);\n}\n",
7365        )
7366        .expect("write ts mid");
7367        fs::write(
7368            ts_dir.join("entry.ts"),
7369            "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n  return mid(worker.run());\n}\n",
7370        )
7371        .expect("write ts entry");
7372        fs::write(
7373            ts_dir.join("worker.ts"),
7374            "export class Worker {\n  run() {\n    return 41;\n  }\n}\n",
7375        )
7376        .expect("write ts worker");
7377        for idx in 0..4 {
7378            fs::write(
7379                ts_dir.join(format!("extra_{idx}.ts")),
7380                format!(
7381                    "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n  return entry(new Worker());\n}}\n"
7382                ),
7383            )
7384            .expect("write ts extra");
7385        }
7386
7387        let rust_dir = project_root.join("src");
7388        let commands_dir = rust_dir.join("commands");
7389        fs::create_dir_all(&commands_dir).expect("create rust commands dir");
7390        fs::write(
7391            rust_dir.join("context.rs"),
7392            r#"pub struct AppContext;
7393
7394impl AppContext {
7395    pub fn callgraph_store_for_ops(&self) -> usize {
7396        1
7397    }
7398}
7399"#,
7400        )
7401        .expect("write rust context");
7402        fs::write(
7403            rust_dir.join("lib.rs"),
7404            "pub mod context;\npub mod commands;\n",
7405        )
7406        .expect("write rust lib");
7407        fs::write(
7408            commands_dir.join("mod.rs"),
7409            "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
7410        )
7411        .expect("write rust commands mod");
7412        for name in ["callers", "impact", "trace_to"] {
7413            fs::write(
7414                commands_dir.join(format!("{name}.rs")),
7415                format!(
7416                    r#"use crate::context::AppContext;
7417
7418pub fn handle_{name}(ctx: &AppContext) -> usize {{
7419    ctx.callgraph_store_for_ops()
7420}}
7421"#
7422                ),
7423            )
7424            .expect("write rust command");
7425        }
7426    }
7427
7428    fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
7429        let src_dir = project_root.join("src");
7430        fs::create_dir_all(&src_dir).expect("create src dir");
7431
7432        let target_path = src_dir.join("target.ts");
7433        fs::write(&target_path, "export function target() {\n  return 1;\n}\n")
7434            .expect("write target");
7435
7436        let index_path = src_dir.join("index.ts");
7437        fs::write(&index_path, barrel_source).expect("write barrel");
7438
7439        let mut files = vec![target_path, index_path];
7440        for (file_name, function_name) in [
7441            ("consumer_a.ts", "consumerA"),
7442            ("consumer_b.ts", "consumerB"),
7443            ("consumer_c.ts", "consumerC"),
7444        ] {
7445            let path = src_dir.join(file_name);
7446            fs::write(
7447                &path,
7448                format!(
7449                    "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n  return target();\n}}\n"
7450                ),
7451            )
7452            .expect("write consumer");
7453            files.push(path);
7454        }
7455        files
7456    }
7457
7458    fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
7459        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
7460        table_rows(&conn, table)
7461    }
7462
7463    fn graph_table_rows_without(
7464        store: &CallGraphStore,
7465        table: &str,
7466        excluded_columns: &[&str],
7467    ) -> Vec<String> {
7468        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
7469        table_rows_without(&conn, table, excluded_columns)
7470    }
7471
7472    fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
7473        table_rows_without(conn, table, &[])
7474    }
7475
7476    fn table_rows_without(
7477        conn: &Connection,
7478        table: &str,
7479        excluded_columns: &[&str],
7480    ) -> Vec<String> {
7481        let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
7482        let columns: Vec<String> = conn
7483            .prepare(&format!("PRAGMA table_info({table})"))
7484            .expect("prepare table_info")
7485            .query_map([], |row| row.get::<_, String>(1))
7486            .expect("query table_info")
7487            .collect::<std::result::Result<Vec<String>, _>>()
7488            .expect("collect columns")
7489            .into_iter()
7490            .filter(|column| !excluded_columns.contains(column.as_str()))
7491            .collect();
7492        let sql = format!(
7493            "SELECT {} FROM {table} ORDER BY {}",
7494            columns.join(", "),
7495            columns.join(", ")
7496        );
7497        conn.prepare(&sql)
7498            .expect("prepare table rows")
7499            .query_map([], |row| row_to_strings(row, columns.len()))
7500            .expect("query table rows")
7501            .collect::<std::result::Result<_, _>>()
7502            .expect("collect table rows")
7503    }
7504
7505    fn assert_cold_build_stats_match_except_elapsed(
7506        expected: &ColdBuildStats,
7507        actual: &ColdBuildStats,
7508    ) {
7509        assert_eq!(actual.files, expected.files, "file counts must match");
7510        assert_eq!(actual.nodes, expected.nodes, "node counts must match");
7511        assert_eq!(actual.refs, expected.refs, "ref counts must match");
7512        assert_eq!(actual.edges, expected.edges, "edge counts must match");
7513        assert_eq!(
7514            actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
7515            expected
7516                .failed_files
7517                .iter()
7518                .cloned()
7519                .collect::<BTreeSet<_>>(),
7520            "failed file sets must match"
7521        );
7522    }
7523
7524    fn backend_state_rows(conn: &Connection) -> Vec<String> {
7525        conn.prepare(
7526            "SELECT backend, workspace_root, file_path, content_hash, status
7527             FROM backend_file_state
7528             ORDER BY backend, workspace_root, file_path, content_hash, status",
7529        )
7530        .expect("prepare backend rows")
7531        .query_map([], |row| row_to_strings(row, 5))
7532        .expect("query backend rows")
7533        .collect::<std::result::Result<_, _>>()
7534        .expect("collect backend rows")
7535    }
7536
7537    fn secondary_indexes(conn: &Connection) -> Vec<String> {
7538        let mut indexes = Vec::new();
7539        for table in [
7540            "files",
7541            "nodes",
7542            "refs",
7543            "file_dependencies",
7544            "edges",
7545            "dispatch_hints",
7546            "type_ref_names",
7547            "backend_file_state",
7548            "meta",
7549        ] {
7550            let sql = format!("PRAGMA index_list({table})");
7551            let mut stmt = conn.prepare(&sql).expect("prepare index list");
7552            let rows = stmt
7553                .query_map([], |row| row.get::<_, String>(1))
7554                .expect("query index list");
7555            for name in rows {
7556                let name = name.expect("index name");
7557                if name.starts_with("idx_") {
7558                    indexes.push(format!("{table}:{name}"));
7559                }
7560            }
7561        }
7562        indexes.sort();
7563        indexes
7564    }
7565
7566    fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
7567        let mut values = Vec::with_capacity(len);
7568        for index in 0..len {
7569            let value = row.get_ref(index)?;
7570            values.push(match value {
7571                rusqlite::types::ValueRef::Null => "NULL".to_string(),
7572                rusqlite::types::ValueRef::Integer(value) => value.to_string(),
7573                rusqlite::types::ValueRef::Real(value) => value.to_string(),
7574                rusqlite::types::ValueRef::Text(value) => {
7575                    String::from_utf8_lossy(value).into_owned()
7576                }
7577                rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
7578            });
7579        }
7580        Ok(values.join("\u{1f}"))
7581    }
7582}
7583
7584#[cfg(test)]
7585mod build_pool_tests {
7586    use super::build_pool_size;
7587
7588    #[test]
7589    fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
7590        let size = build_pool_size();
7591        // Never zero, never the full core count, never above the 8 cap — this is
7592        // the starvation guard for the cold-build's all-cores tree-sitter pass.
7593        assert!(size >= 1, "pool size must be at least 1");
7594        assert!(size <= 8, "pool size must be capped at 8, got {size}");
7595
7596        let cores = std::thread::available_parallelism()
7597            .map(|p| p.get())
7598            .unwrap_or(1);
7599        let expected = cores.div_ceil(2).clamp(1, 8);
7600        assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
7601    }
7602}
7603
7604#[cfg(test)]
7605mod method_dispatch_inference_tests {
7606    use super::*;
7607    use std::fs;
7608    use tempfile::tempdir;
7609
7610    #[test]
7611    fn java_field_receiver_type_selects_declared_class_method() {
7612        let source = r#"class EntryPoint {
7613    private UserService userService;
7614
7615    void handle() {
7616        userService.find();
7617    }
7618}
7619
7620class UserService {
7621    void find() {}
7622}
7623
7624class AuditService {
7625    void find() {}
7626}
7627"#;
7628        let dir = tempdir().expect("temp dir");
7629        let root = dir.path();
7630        write_fixture(root, "src/EntryPoint.java", source);
7631        let reference = reference(
7632            "java",
7633            "src/EntryPoint.java",
7634            "EntryPoint::handle",
7635            "userService",
7636            "find",
7637            line_of(source, "userService.find()"),
7638        );
7639        let mut cache = DispatchSourceCache::new();
7640
7641        let receiver_type =
7642            infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
7643        assert_eq!(receiver_type, "UserService");
7644
7645        let candidates = vec![
7646            method_candidate("audit", "AuditService::find"),
7647            method_candidate("user", "UserService::find"),
7648        ];
7649        let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
7650            .expect("type candidate");
7651        assert_eq!(selected.scoped_name, "UserService::find");
7652
7653        let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
7654        assert!(
7655            select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
7656        );
7657    }
7658
7659    #[test]
7660    fn kotlin_property_and_local_value_types_are_inferred() {
7661        let source = r#"class Handler {
7662    private val auditService: AuditService = AuditService()
7663
7664    fun handle() {
7665        auditService.find()
7666        val userService: UserService = UserService()
7667        userService.find()
7668        val billingService = BillingService()
7669        billingService.find()
7670    }
7671}
7672
7673class UserService { fun find() {} }
7674class AuditService { fun find() {} }
7675class BillingService { fun find() {} }
7676"#;
7677        let dir = tempdir().expect("temp dir");
7678        let root = dir.path();
7679        write_fixture(root, "src/Handler.kt", source);
7680        let mut cache = DispatchSourceCache::new();
7681
7682        let audit_ref = reference(
7683            "kotlin",
7684            "src/Handler.kt",
7685            "Handler::handle",
7686            "auditService",
7687            "find",
7688            line_of(source, "auditService.find()"),
7689        );
7690        assert_eq!(
7691            infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
7692            Some("AuditService")
7693        );
7694
7695        let user_ref = reference(
7696            "kotlin",
7697            "src/Handler.kt",
7698            "Handler::handle",
7699            "userService",
7700            "find",
7701            line_of(source, "userService.find()"),
7702        );
7703        assert_eq!(
7704            infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
7705            Some("UserService")
7706        );
7707
7708        let billing_ref = reference(
7709            "kotlin",
7710            "src/Handler.kt",
7711            "Handler::handle",
7712            "billingService",
7713            "find",
7714            line_of(source, "billingService.find()"),
7715        );
7716        assert_eq!(
7717            infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
7718            Some("BillingService")
7719        );
7720    }
7721
7722    #[test]
7723    fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
7724        let source = r#"struct Foo { void run(); };
7725struct PointerFoo { void run(); };
7726struct FactoryFoo { void run(); };
7727FactoryFoo makeFactoryFoo();
7728
7729void handle() {
7730    Foo foo;
7731    foo.run();
7732    PointerFoo* pointerFoo = nullptr;
7733    pointerFoo->run();
7734    auto factoryFoo = makeFactoryFoo();
7735    factoryFoo.run();
7736}
7737"#;
7738        let dir = tempdir().expect("temp dir");
7739        let root = dir.path();
7740        write_fixture(root, "src/fixture.cpp", source);
7741        let mut cache = DispatchSourceCache::new();
7742
7743        let foo_ref = reference(
7744            "cpp",
7745            "src/fixture.cpp",
7746            "handle",
7747            "foo",
7748            "run",
7749            line_of(source, "foo.run()"),
7750        );
7751        assert_eq!(
7752            infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
7753            Some("Foo")
7754        );
7755
7756        let pointer_ref = reference(
7757            "cpp",
7758            "src/fixture.cpp",
7759            "handle",
7760            "pointerFoo",
7761            "run",
7762            line_of(source, "pointerFoo->run()"),
7763        );
7764        assert_eq!(
7765            infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
7766            Some("PointerFoo")
7767        );
7768
7769        let factory_ref = reference(
7770            "cpp",
7771            "src/fixture.cpp",
7772            "handle",
7773            "factoryFoo",
7774            "run",
7775            line_of(source, "factoryFoo.run()"),
7776        );
7777        assert_eq!(
7778            infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
7779            Some("FactoryFoo")
7780        );
7781    }
7782
7783    #[test]
7784    fn unknown_java_receiver_still_uses_name_match_fallback() {
7785        let source = r#"class EntryPoint {
7786    void handle() {
7787        service.runSpecial();
7788    }
7789}
7790
7791class OnlyService {
7792    void runSpecial() {}
7793}
7794"#;
7795        let dir = tempdir().expect("temp dir");
7796        let root = dir.path();
7797        write_fixture(root, "src/EntryPoint.java", source);
7798        let reference = reference(
7799            "java",
7800            "src/EntryPoint.java",
7801            "EntryPoint::handle",
7802            "service",
7803            "runSpecial",
7804            line_of(source, "service.runSpecial()"),
7805        );
7806        let mut cache = DispatchSourceCache::new();
7807
7808        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
7809        let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
7810        let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
7811        assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
7812    }
7813
7814    fn reference(
7815        lang: &str,
7816        caller_file: &str,
7817        caller_symbol: &str,
7818        receiver: &str,
7819        method_name: &str,
7820        line: u32,
7821    ) -> NameMatchRef {
7822        NameMatchRef {
7823            ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
7824            caller_node: format!("{caller_symbol}:node"),
7825            caller_file: caller_file.to_string(),
7826            caller_symbol: caller_symbol.to_string(),
7827            caller_signature: None,
7828            receiver: receiver.to_string(),
7829            method_name: method_name.to_string(),
7830            colon_dispatch: false,
7831            line,
7832            lang: lang.to_string(),
7833        }
7834    }
7835
7836    fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
7837        NameMatchCandidate {
7838            node_id: node_id.to_string(),
7839            file_path: "src/targets.fixture".to_string(),
7840            scoped_name: scoped_name.to_string(),
7841            kind: "method".to_string(),
7842        }
7843    }
7844
7845    fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
7846        let path = root.join(rel_path);
7847        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
7848        fs::write(path, source).expect("write fixture");
7849    }
7850
7851    fn line_of(source: &str, needle: &str) -> u32 {
7852        source
7853            .lines()
7854            .position(|line| line.contains(needle))
7855            .map(|index| index as u32 + 1)
7856            .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
7857    }
7858}