Skip to main content

aft/callgraph_store/
mod.rs

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