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