Skip to main content

aft/callgraph_store/
mod.rs

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