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