Skip to main content

aft/callgraph_store/
mod.rs

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