Skip to main content

mir_codebase/
codebase.rs

1use std::sync::Arc;
2
3use dashmap::{DashMap, DashSet};
4
5use crate::interner::Interner;
6
7/// Maps symbol ID → flat list of `(file_id, line, col_start, col_end)`.
8///
9/// Entries are appended during Pass 2. Duplicates (e.g. from union receivers like
10/// `Foo|Foo->method()`) are filtered at insert time. IDs come from
11/// `Codebase::symbol_interner` / `Codebase::file_interner`.
12///
13/// Each entry is 12 bytes (`u32` + `u32` + `u16` + `u16`) with no per-entry
14/// allocator overhead beyond the `Vec` backing store.
15type ReferenceLocations = DashMap<u32, Vec<(u32, u32, u16, u16)>>;
16
17use crate::storage::{
18    ClassStorage, EnumStorage, FunctionStorage, InterfaceStorage, MethodStorage, TraitStorage,
19};
20use mir_types::Union;
21
22// ---------------------------------------------------------------------------
23// Private helper — shared insert logic for reference tracking
24// ---------------------------------------------------------------------------
25
26/// Case-insensitive method lookup within a single `own_methods` map.
27///
28/// Tries an exact key match first (O(1)), then falls back to a linear
29/// case-insensitive scan for stubs that store keys in original case.
30#[inline]
31fn lookup_method<'a>(
32    map: &'a indexmap::IndexMap<Arc<str>, Arc<MethodStorage>>,
33    name: &str,
34) -> Option<&'a Arc<MethodStorage>> {
35    map.get(name).or_else(|| {
36        map.iter()
37            .find(|(k, _)| k.as_ref().eq_ignore_ascii_case(name))
38            .map(|(_, v)| v)
39    })
40}
41
42/// Append `(sym_id, file_id, line, col_start, col_end)` to the reference index,
43/// skipping exact duplicates so union receivers like `Foo|Foo->method()` don't
44/// inflate the span list.
45///
46/// Both maps are updated atomically under their respective DashMap shard locks.
47#[inline]
48fn record_ref(
49    sym_locs: &ReferenceLocations,
50    file_refs: &DashMap<u32, Vec<u32>>,
51    sym_id: u32,
52    file_id: u32,
53    line: u32,
54    col_start: u16,
55    col_end: u16,
56) {
57    {
58        let mut entries = sym_locs.entry(sym_id).or_default();
59        let span = (file_id, line, col_start, col_end);
60        if !entries.contains(&span) {
61            entries.push(span);
62        }
63    }
64    {
65        let mut refs = file_refs.entry(file_id).or_default();
66        if !refs.contains(&sym_id) {
67            refs.push(sym_id);
68        }
69    }
70}
71
72// ---------------------------------------------------------------------------
73// Compact CSR reference index (post-Pass-2 read-optimised form)
74// ---------------------------------------------------------------------------
75
76/// Read-optimised Compressed Sparse Row representation of the reference index.
77///
78/// Built once by [`Codebase::compact_reference_index`] after Pass 2 finishes.
79/// After compaction the build-phase [`DashMap`]s are cleared, freeing the
80/// per-entry allocator overhead (~72 bytes per (symbol, file) pair).
81///
82/// Two CSR views are maintained over the same flat `entries` array:
83/// - by symbol: `entries[sym_offsets[id]..sym_offsets[id+1]]`
84/// - by file: `by_file[file_offsets[id]..file_offsets[id+1]]` (indirect indices)
85#[derive(Debug, Default)]
86struct CompactRefIndex {
87    /// All spans sorted by `(sym_id, file_id, line, col_start, col_end)`, deduplicated.
88    /// Each entry is 16 bytes; total size = `n_refs × 16` with no hash overhead.
89    entries: Vec<(u32, u32, u32, u16, u16)>,
90    /// CSR offsets keyed by sym_id (length = max_sym_id + 2).
91    sym_offsets: Vec<u32>,
92    /// Indices into `entries` sorted by `(file_id, sym_id, line, col_start, col_end)`.
93    /// Allows O(log n) file-keyed lookups without duplicating the payload.
94    by_file: Vec<u32>,
95    /// CSR offsets keyed by file_id into `by_file` (length = max_file_id + 2).
96    file_offsets: Vec<u32>,
97}
98
99// ---------------------------------------------------------------------------
100// StructuralSnapshot — inheritance data captured before file removal
101// ---------------------------------------------------------------------------
102
103struct ClassInheritance {
104    parent: Option<Arc<str>>,
105    interfaces: Vec<Arc<str>>, // sorted for order-insensitive comparison
106    traits: Vec<Arc<str>>,     // sorted
107    all_parents: Vec<Arc<str>>,
108}
109
110struct InterfaceInheritance {
111    extends: Vec<Arc<str>>, // sorted
112    all_parents: Vec<Arc<str>>,
113}
114
115/// Snapshot of the inheritance structure of all symbols defined in a file.
116///
117/// Produced by [`Codebase::file_structural_snapshot`] before
118/// [`Codebase::remove_file_definitions`], and consumed by
119/// [`Codebase::structural_unchanged_after_pass1`] /
120/// [`Codebase::restore_all_parents`] to skip an expensive `finalize()` call
121/// when only method bodies (not class hierarchies) changed.
122pub struct StructuralSnapshot {
123    classes: std::collections::HashMap<Arc<str>, ClassInheritance>,
124    interfaces: std::collections::HashMap<Arc<str>, InterfaceInheritance>,
125}
126
127// ---------------------------------------------------------------------------
128// Codebase — thread-safe global symbol registry
129// ---------------------------------------------------------------------------
130
131#[derive(Debug, Default)]
132pub struct Codebase {
133    pub classes: DashMap<Arc<str>, ClassStorage>,
134    pub interfaces: DashMap<Arc<str>, InterfaceStorage>,
135    pub traits: DashMap<Arc<str>, TraitStorage>,
136    pub enums: DashMap<Arc<str>, EnumStorage>,
137    pub functions: DashMap<Arc<str>, FunctionStorage>,
138    pub constants: DashMap<Arc<str>, Union>,
139
140    /// Types of `@var`-annotated global variables, collected in Pass 1.
141    /// Key: variable name without the `$` prefix.
142    pub global_vars: DashMap<Arc<str>, Union>,
143    /// Maps file path → variable names declared with `@var` in that file.
144    /// Used by `remove_file_definitions` to purge stale entries on re-analysis.
145    file_global_vars: DashMap<Arc<str>, Vec<Arc<str>>>,
146
147    /// Methods referenced during Pass 2 — stored as interned symbol IDs.
148    /// Used by the dead-code detector (M18).
149    referenced_methods: DashSet<u32>,
150    /// Properties referenced during Pass 2 — stored as interned symbol IDs.
151    referenced_properties: DashSet<u32>,
152    /// Free functions referenced during Pass 2 — stored as interned symbol IDs.
153    referenced_functions: DashSet<u32>,
154
155    /// Interner for symbol keys (`"ClassName::method"`, `"ClassName::prop"`, FQN).
156    /// Replaces repeated `Arc<str>` copies (16 bytes) with compact `u32` IDs (4 bytes).
157    pub symbol_interner: Interner,
158    /// Interner for file paths. Same memory rationale as `symbol_interner`.
159    pub file_interner: Interner,
160
161    /// Maps symbol ID → flat list of `(file_id, line, col_start, col_end)`.
162    /// IDs come from `symbol_interner` / `file_interner`.
163    symbol_reference_locations: ReferenceLocations,
164    /// Reverse index: file ID → symbol IDs referenced in that file.
165    /// Used by `remove_file_definitions` to avoid a full scan of all symbols.
166    /// A `Vec` rather than `HashSet`: duplicate sym_ids are guarded at insert time
167    /// (same as `symbol_reference_locations`) for the same structural simplicity.
168    file_symbol_references: DashMap<u32, Vec<u32>>,
169
170    /// Compact CSR view of the reference index, built by `compact_reference_index()`.
171    /// When `Some`, the build-phase DashMaps above are empty and this is the
172    /// authoritative source for all reference queries.
173    compact_ref_index: std::sync::RwLock<Option<CompactRefIndex>>,
174    /// `true` iff `compact_ref_index` is `Some`. Checked atomically before
175    /// acquiring any lock, so the fast path during Pass 2 is a single load.
176    is_compacted: std::sync::atomic::AtomicBool,
177
178    /// Maps every FQCN (class, interface, trait, enum, function) to the absolute
179    /// path of the file that defines it. Populated during Pass 1.
180    pub symbol_to_file: DashMap<Arc<str>, Arc<str>>,
181
182    /// Lightweight FQCN index populated by `SymbolTable` before Pass 1.
183    /// Enables O(1) "does this symbol exist?" checks before full definitions
184    /// are available.
185    pub known_symbols: DashSet<Arc<str>>,
186
187    /// Per-file `use` alias maps: alias → FQCN.  Populated during Pass 1.
188    ///
189    /// Key: absolute file path (as `Arc<str>`).
190    /// Value: map of `alias → fully-qualified class name`.
191    ///
192    /// Exposed as `pub` so that external consumers (e.g. `php-lsp`) can read
193    /// import data that mir already collects, instead of reimplementing it.
194    pub file_imports: DashMap<Arc<str>, std::collections::HashMap<String, String>>,
195    /// Per-file current namespace (if any).  Populated during Pass 1.
196    ///
197    /// Key: absolute file path (as `Arc<str>`).
198    /// Value: the declared namespace string (e.g. `"App\\Controller"`).
199    ///
200    /// Exposed as `pub` so that external consumers (e.g. `php-lsp`) can read
201    /// namespace data that mir already collects, instead of reimplementing it.
202    pub file_namespaces: DashMap<Arc<str>, String>,
203
204    /// Whether finalize() has been called.
205    finalized: std::sync::atomic::AtomicBool,
206}
207
208impl Codebase {
209    pub fn new() -> Self {
210        Self::default()
211    }
212
213    // -----------------------------------------------------------------------
214    // Stub injection
215    // -----------------------------------------------------------------------
216
217    /// Insert all definitions from `slice` into this codebase.
218    ///
219    /// Called by generated stub modules (`src/generated/stubs_*.rs`) to register
220    /// their pre-compiled definitions. Later insertions overwrite earlier ones,
221    /// so custom stubs loaded after PHPStorm stubs act as overrides.
222    /// Merge a [`StubSlice`] into the codebase.
223    ///
224    /// When `slice.file` is `Some`, this method also writes file-keyed metadata:
225    /// `symbol_to_file`, `global_vars`, `file_namespaces`, and `file_imports`.
226    /// This includes slices produced from PHPStorm stub files — so after this
227    /// call, `file_namespaces` and `file_imports` will contain entries keyed by
228    /// stub file paths as well as user-code file paths.  That is intentional:
229    /// the lazy-load scan iterates `file_imports` but is gated by `type_exists`,
230    /// so stub-sourced entries are harmlessly short-circuited there.
231    pub fn inject_stub_slice(&self, slice: crate::storage::StubSlice) {
232        let file = slice.file.clone();
233        for cls in slice.classes {
234            if let Some(f) = &file {
235                self.symbol_to_file.insert(cls.fqcn.clone(), f.clone());
236            }
237            self.classes.insert(cls.fqcn.clone(), cls);
238        }
239        for iface in slice.interfaces {
240            if let Some(f) = &file {
241                self.symbol_to_file.insert(iface.fqcn.clone(), f.clone());
242            }
243            self.interfaces.insert(iface.fqcn.clone(), iface);
244        }
245        for tr in slice.traits {
246            if let Some(f) = &file {
247                self.symbol_to_file.insert(tr.fqcn.clone(), f.clone());
248            }
249            self.traits.insert(tr.fqcn.clone(), tr);
250        }
251        for en in slice.enums {
252            if let Some(f) = &file {
253                self.symbol_to_file.insert(en.fqcn.clone(), f.clone());
254            }
255            self.enums.insert(en.fqcn.clone(), en);
256        }
257        for func in slice.functions {
258            if let Some(f) = &file {
259                self.symbol_to_file.insert(func.fqn.clone(), f.clone());
260            }
261            self.functions.insert(func.fqn.clone(), func);
262        }
263        for (name, ty) in slice.constants {
264            self.constants.insert(name, ty);
265        }
266        if let Some(f) = &file {
267            for (name, ty) in slice.global_vars {
268                self.register_global_var(f, name, ty);
269            }
270            if let Some(ns) = slice.namespace {
271                self.file_namespaces.insert(f.clone(), ns.to_string());
272            }
273            if !slice.imports.is_empty() {
274                self.file_imports.insert(f.clone(), slice.imports);
275            }
276        }
277    }
278
279    // -----------------------------------------------------------------------
280    // Compact reference index
281    // -----------------------------------------------------------------------
282
283    /// Convert the build-phase `DashMap` reference index into a compact CSR form.
284    ///
285    /// Call this once after Pass 2 completes on all files. The method:
286    /// 1. Drains the two build-phase `DashMap`s into a single flat `Vec`.
287    /// 2. Sorts and deduplicates entries.
288    /// 3. Builds two CSR offset arrays (by symbol and by file).
289    /// 4. Clears the `DashMap`s (freeing their allocations).
290    ///
291    /// After this call all reference queries use the compact index. Incremental
292    /// re-analysis via [`Self::re_analyze_file`] will automatically decompress the
293    /// index back into `DashMap`s on the first write, then recompact can be called
294    /// again at the end of that analysis pass.
295    pub fn compact_reference_index(&self) {
296        // Collect all entries from the build-phase DashMap.
297        let mut entries: Vec<(u32, u32, u32, u16, u16)> = self
298            .symbol_reference_locations
299            .iter()
300            .flat_map(|entry| {
301                let sym_id = *entry.key();
302                entry
303                    .value()
304                    .iter()
305                    .map(move |&(file_id, line, col_start, col_end)| {
306                        (sym_id, file_id, line, col_start, col_end)
307                    })
308                    .collect::<Vec<_>>()
309            })
310            .collect();
311
312        if entries.is_empty() {
313            return;
314        }
315
316        // Sort by (sym_id, file_id, line, col_start, col_end) and drop exact duplicates.
317        entries.sort_unstable();
318        entries.dedup();
319
320        let n = entries.len();
321
322        // ---- Build symbol-keyed CSR offsets --------------------------------
323        let max_sym = entries.iter().map(|&(s, ..)| s).max().unwrap_or(0) as usize;
324        let mut sym_offsets = vec![0u32; max_sym + 2];
325        for &(sym_id, ..) in &entries {
326            sym_offsets[sym_id as usize + 1] += 1;
327        }
328        for i in 1..sym_offsets.len() {
329            sym_offsets[i] += sym_offsets[i - 1];
330        }
331
332        // ---- Build file-keyed indirect index --------------------------------
333        // `by_file[i]` is an index into `entries`; the slice is sorted by
334        // `(file_id, sym_id, line, col_start, col_end)` so CSR offsets can be computed cheaply.
335        let max_file = entries.iter().map(|&(_, f, ..)| f).max().unwrap_or(0) as usize;
336        let mut by_file: Vec<u32> = (0..n as u32).collect();
337        by_file.sort_unstable_by_key(|&i| {
338            let (sym_id, file_id, line, col_start, col_end) = entries[i as usize];
339            (file_id, sym_id, line, col_start, col_end)
340        });
341
342        let mut file_offsets = vec![0u32; max_file + 2];
343        for &idx in &by_file {
344            let file_id = entries[idx as usize].1;
345            file_offsets[file_id as usize + 1] += 1;
346        }
347        for i in 1..file_offsets.len() {
348            file_offsets[i] += file_offsets[i - 1];
349        }
350
351        *self.compact_ref_index.write().unwrap() = Some(CompactRefIndex {
352            entries,
353            sym_offsets,
354            by_file,
355            file_offsets,
356        });
357        self.is_compacted
358            .store(true, std::sync::atomic::Ordering::Release);
359
360        // Free build-phase allocations.
361        self.symbol_reference_locations.clear();
362        self.file_symbol_references.clear();
363    }
364
365    /// Decompress the compact index back into the build-phase `DashMap`s.
366    ///
367    /// Called automatically by write methods when the compact index is live.
368    /// This makes incremental re-analysis transparent: callers never need to
369    /// know whether the index is compacted or not.
370    fn ensure_expanded(&self) {
371        // Fast path: not compacted — one atomic load, no lock.
372        if !self.is_compacted.load(std::sync::atomic::Ordering::Acquire) {
373            return;
374        }
375        // Slow path: acquire write lock and decompress.
376        let mut guard = self.compact_ref_index.write().unwrap();
377        if let Some(ci) = guard.take() {
378            for &(sym_id, file_id, line, col_start, col_end) in &ci.entries {
379                record_ref(
380                    &self.symbol_reference_locations,
381                    &self.file_symbol_references,
382                    sym_id,
383                    file_id,
384                    line,
385                    col_start,
386                    col_end,
387                );
388            }
389            self.is_compacted
390                .store(false, std::sync::atomic::Ordering::Release);
391        }
392        // If another thread already decompressed (guard is now None), we're done.
393    }
394
395    /// Reset the finalization flag so that `finalize()` will run again.
396    ///
397    /// Use this when new class definitions have been added after an initial
398    /// `finalize()` call (e.g., lazily loaded via PSR-4) and the inheritance
399    /// graph needs to be rebuilt.
400    pub fn invalidate_finalization(&self) {
401        self.finalized
402            .store(false, std::sync::atomic::Ordering::SeqCst);
403    }
404
405    // -----------------------------------------------------------------------
406    // Incremental: remove all definitions from a single file
407    // -----------------------------------------------------------------------
408
409    /// Remove all definitions and outgoing reference locations contributed by the given file.
410    /// This clears classes, interfaces, traits, enums, functions, and constants
411    /// whose defining file matches `file_path`, the file's import and namespace entries,
412    /// and all entries in symbol_reference_locations that originated from this file.
413    /// After calling this, `invalidate_finalization()` is called so the next `finalize()`
414    /// rebuilds inheritance.
415    pub fn remove_file_definitions(&self, file_path: &str) {
416        // Collect all symbols defined in this file
417        let symbols: Vec<Arc<str>> = self
418            .symbol_to_file
419            .iter()
420            .filter(|entry| entry.value().as_ref() == file_path)
421            .map(|entry| entry.key().clone())
422            .collect();
423
424        // Remove each symbol from its respective map and from symbol_to_file
425        for sym in &symbols {
426            self.classes.remove(sym.as_ref());
427            self.interfaces.remove(sym.as_ref());
428            self.traits.remove(sym.as_ref());
429            self.enums.remove(sym.as_ref());
430            self.functions.remove(sym.as_ref());
431            self.constants.remove(sym.as_ref());
432            self.symbol_to_file.remove(sym.as_ref());
433            self.known_symbols.remove(sym.as_ref());
434        }
435
436        // Remove file-level metadata
437        self.file_imports.remove(file_path);
438        self.file_namespaces.remove(file_path);
439
440        // Remove @var-annotated global variables declared in this file
441        if let Some((_, var_names)) = self.file_global_vars.remove(file_path) {
442            for name in var_names {
443                self.global_vars.remove(name.as_ref());
444            }
445        }
446
447        // Ensure the reference index is in DashMap form so the removal below works.
448        self.ensure_expanded();
449
450        // Remove reference locations contributed by this file.
451        // Use the reverse index to avoid a full scan of all symbols.
452        if let Some(file_id) = self.file_interner.get_id(file_path) {
453            if let Some((_, sym_ids)) = self.file_symbol_references.remove(&file_id) {
454                for sym_id in sym_ids {
455                    if let Some(mut entries) = self.symbol_reference_locations.get_mut(&sym_id) {
456                        entries.retain(|&(fid, ..)| fid != file_id);
457                    }
458                }
459            }
460        }
461
462        self.invalidate_finalization();
463    }
464
465    // -----------------------------------------------------------------------
466    // Structural snapshot — skip finalize() on body-only changes
467    // -----------------------------------------------------------------------
468
469    /// Capture the inheritance structure of all symbols defined in `file_path`.
470    ///
471    /// Call this *before* `remove_file_definitions` to preserve the data that
472    /// `finalize()` would otherwise have to recompute.  The snapshot records, for
473    /// each class/interface in the file, the fields that feed into
474    /// `all_parents` (parent class, implemented interfaces, used traits, extended
475    /// interfaces) as well as the already-computed `all_parents` list itself.
476    pub fn file_structural_snapshot(&self, file_path: &str) -> StructuralSnapshot {
477        let symbols: Vec<Arc<str>> = self
478            .symbol_to_file
479            .iter()
480            .filter(|e| e.value().as_ref() == file_path)
481            .map(|e| e.key().clone())
482            .collect();
483
484        let mut classes = std::collections::HashMap::new();
485        let mut interfaces = std::collections::HashMap::new();
486
487        for sym in symbols {
488            if let Some(cls) = self.classes.get(sym.as_ref()) {
489                let mut ifaces = cls.interfaces.clone();
490                ifaces.sort_unstable_by(|a, b| a.as_ref().cmp(b.as_ref()));
491                let mut traits = cls.traits.clone();
492                traits.sort_unstable_by(|a, b| a.as_ref().cmp(b.as_ref()));
493                classes.insert(
494                    sym,
495                    ClassInheritance {
496                        parent: cls.parent.clone(),
497                        interfaces: ifaces,
498                        traits,
499                        all_parents: cls.all_parents.clone(),
500                    },
501                );
502            } else if let Some(iface) = self.interfaces.get(sym.as_ref()) {
503                let mut extends = iface.extends.clone();
504                extends.sort_unstable_by(|a, b| a.as_ref().cmp(b.as_ref()));
505                interfaces.insert(
506                    sym,
507                    InterfaceInheritance {
508                        extends,
509                        all_parents: iface.all_parents.clone(),
510                    },
511                );
512            }
513        }
514
515        StructuralSnapshot {
516            classes,
517            interfaces,
518        }
519    }
520
521    /// After Pass 1 completes, check whether the inheritance structure in
522    /// `file_path` matches the snapshot taken before `remove_file_definitions`.
523    ///
524    /// Returns `true` if `finalize()` can be skipped — i.e. only method bodies,
525    /// properties, or annotations changed, not any class/interface hierarchy.
526    pub fn structural_unchanged_after_pass1(
527        &self,
528        file_path: &str,
529        old: &StructuralSnapshot,
530    ) -> bool {
531        let symbols: Vec<Arc<str>> = self
532            .symbol_to_file
533            .iter()
534            .filter(|e| e.value().as_ref() == file_path)
535            .map(|e| e.key().clone())
536            .collect();
537
538        let mut seen_classes = 0usize;
539        let mut seen_interfaces = 0usize;
540
541        for sym in &symbols {
542            if let Some(cls) = self.classes.get(sym.as_ref()) {
543                seen_classes += 1;
544                let Some(old_cls) = old.classes.get(sym.as_ref()) else {
545                    return false; // new class added
546                };
547                if old_cls.parent != cls.parent {
548                    return false;
549                }
550                let mut new_ifaces = cls.interfaces.clone();
551                new_ifaces.sort_unstable_by(|a, b| a.as_ref().cmp(b.as_ref()));
552                if old_cls.interfaces != new_ifaces {
553                    return false;
554                }
555                let mut new_traits = cls.traits.clone();
556                new_traits.sort_unstable_by(|a, b| a.as_ref().cmp(b.as_ref()));
557                if old_cls.traits != new_traits {
558                    return false;
559                }
560            } else if let Some(iface) = self.interfaces.get(sym.as_ref()) {
561                seen_interfaces += 1;
562                let Some(old_iface) = old.interfaces.get(sym.as_ref()) else {
563                    return false; // new interface added
564                };
565                let mut new_extends = iface.extends.clone();
566                new_extends.sort_unstable_by(|a, b| a.as_ref().cmp(b.as_ref()));
567                if old_iface.extends != new_extends {
568                    return false;
569                }
570            }
571            // Traits, enums, functions, constants: not finalization-relevant, skip.
572        }
573
574        // Check for removed classes or interfaces.
575        seen_classes == old.classes.len() && seen_interfaces == old.interfaces.len()
576    }
577
578    /// Restore `all_parents` from a snapshot and mark the codebase as finalized.
579    ///
580    /// Call this instead of `finalize()` when `structural_unchanged_after_pass1`
581    /// returns `true`.  The newly re-registered symbols (written by Pass 1) have
582    /// `all_parents = []`; this method repopulates them from the snapshot so that
583    /// all downstream lookups that depend on `all_parents` keep working correctly.
584    pub fn restore_all_parents(&self, file_path: &str, snapshot: &StructuralSnapshot) {
585        let symbols: Vec<Arc<str>> = self
586            .symbol_to_file
587            .iter()
588            .filter(|e| e.value().as_ref() == file_path)
589            .map(|e| e.key().clone())
590            .collect();
591
592        for sym in &symbols {
593            if let Some(old_cls) = snapshot.classes.get(sym.as_ref()) {
594                if let Some(mut cls) = self.classes.get_mut(sym.as_ref()) {
595                    cls.all_parents = old_cls.all_parents.clone();
596                }
597            } else if let Some(old_iface) = snapshot.interfaces.get(sym.as_ref()) {
598                if let Some(mut iface) = self.interfaces.get_mut(sym.as_ref()) {
599                    iface.all_parents = old_iface.all_parents.clone();
600                }
601            }
602        }
603
604        self.finalized
605            .store(true, std::sync::atomic::Ordering::SeqCst);
606    }
607
608    // -----------------------------------------------------------------------
609    // Global variable registry
610    // -----------------------------------------------------------------------
611
612    /// Record an `@var`-annotated global variable type discovered in Pass 1.
613    /// If the same variable is annotated in multiple files, the last write wins.
614    pub fn register_global_var(&self, file: &Arc<str>, name: Arc<str>, ty: Union) {
615        self.file_global_vars
616            .entry(file.clone())
617            .or_default()
618            .push(name.clone());
619        self.global_vars.insert(name, ty);
620    }
621
622    // -----------------------------------------------------------------------
623    // Lookups
624    // -----------------------------------------------------------------------
625
626    /// Resolve a property, walking up the inheritance chain (parent classes and traits).
627    pub fn get_property(
628        &self,
629        fqcn: &str,
630        prop_name: &str,
631    ) -> Option<crate::storage::PropertyStorage> {
632        self.get_property_inner(fqcn, prop_name, &mut std::collections::HashSet::new())
633    }
634
635    fn get_property_inner(
636        &self,
637        fqcn: &str,
638        prop_name: &str,
639        visited: &mut std::collections::HashSet<String>,
640    ) -> Option<crate::storage::PropertyStorage> {
641        if !visited.insert(fqcn.to_string()) {
642            return None;
643        }
644        // Check direct class own_properties
645        if let Some(cls) = self.classes.get(fqcn) {
646            if let Some(p) = cls.own_properties.get(prop_name) {
647                return Some(p.clone());
648            }
649            let mixins = cls.mixins.clone();
650            drop(cls);
651            for mixin in &mixins {
652                if let Some(p) = self.get_property_inner(mixin.as_ref(), prop_name, visited) {
653                    return Some(p);
654                }
655            }
656        }
657
658        // Walk all ancestors (collected during finalize)
659        let all_parents = {
660            if let Some(cls) = self.classes.get(fqcn) {
661                cls.all_parents.clone()
662            } else {
663                return None;
664            }
665        };
666
667        for ancestor_fqcn in &all_parents {
668            if let Some(ancestor_cls) = self.classes.get(ancestor_fqcn.as_ref()) {
669                if let Some(p) = ancestor_cls.own_properties.get(prop_name) {
670                    return Some(p.clone());
671                }
672                let anc_mixins = ancestor_cls.mixins.clone();
673                drop(ancestor_cls);
674                for mixin_fqcn in &anc_mixins {
675                    if let Some(p) = self.get_property_inner(mixin_fqcn, prop_name, visited) {
676                        return Some(p);
677                    }
678                }
679            }
680        }
681
682        // Check traits
683        let trait_list = {
684            if let Some(cls) = self.classes.get(fqcn) {
685                cls.traits.clone()
686            } else {
687                vec![]
688            }
689        };
690        for trait_fqcn in &trait_list {
691            if let Some(tr) = self.traits.get(trait_fqcn.as_ref()) {
692                if let Some(p) = tr.own_properties.get(prop_name) {
693                    return Some(p.clone());
694                }
695            }
696        }
697
698        None
699    }
700
701    /// Resolve a class constant by name, walking up the inheritance chain.
702    pub fn get_class_constant(
703        &self,
704        fqcn: &str,
705        const_name: &str,
706    ) -> Option<crate::storage::ConstantStorage> {
707        // Class: own → traits → ancestors → interfaces
708        if let Some(cls) = self.classes.get(fqcn) {
709            if let Some(c) = cls.own_constants.get(const_name) {
710                return Some(c.clone());
711            }
712            let all_parents = cls.all_parents.clone();
713            let interfaces = cls.interfaces.clone();
714            let traits = cls.traits.clone();
715            drop(cls);
716
717            for tr_fqcn in &traits {
718                if let Some(tr) = self.traits.get(tr_fqcn.as_ref()) {
719                    if let Some(c) = tr.own_constants.get(const_name) {
720                        return Some(c.clone());
721                    }
722                }
723            }
724
725            for ancestor_fqcn in &all_parents {
726                if let Some(ancestor) = self.classes.get(ancestor_fqcn.as_ref()) {
727                    if let Some(c) = ancestor.own_constants.get(const_name) {
728                        return Some(c.clone());
729                    }
730                }
731                if let Some(iface) = self.interfaces.get(ancestor_fqcn.as_ref()) {
732                    if let Some(c) = iface.own_constants.get(const_name) {
733                        return Some(c.clone());
734                    }
735                }
736            }
737
738            for iface_fqcn in &interfaces {
739                if let Some(iface) = self.interfaces.get(iface_fqcn.as_ref()) {
740                    if let Some(c) = iface.own_constants.get(const_name) {
741                        return Some(c.clone());
742                    }
743                }
744            }
745
746            return None;
747        }
748
749        // Interface: own → parent interfaces
750        if let Some(iface) = self.interfaces.get(fqcn) {
751            if let Some(c) = iface.own_constants.get(const_name) {
752                return Some(c.clone());
753            }
754            let parents = iface.all_parents.clone();
755            drop(iface);
756            for p in &parents {
757                if let Some(parent_iface) = self.interfaces.get(p.as_ref()) {
758                    if let Some(c) = parent_iface.own_constants.get(const_name) {
759                        return Some(c.clone());
760                    }
761                }
762            }
763            return None;
764        }
765
766        // Enum: own constants + cases
767        if let Some(en) = self.enums.get(fqcn) {
768            if let Some(c) = en.own_constants.get(const_name) {
769                return Some(c.clone());
770            }
771            if en.cases.contains_key(const_name) {
772                return Some(crate::storage::ConstantStorage {
773                    name: Arc::from(const_name),
774                    ty: mir_types::Union::mixed(),
775                    visibility: None,
776                    is_final: false,
777                    location: None,
778                });
779            }
780            return None;
781        }
782
783        // Trait: own constants only
784        if let Some(tr) = self.traits.get(fqcn) {
785            if let Some(c) = tr.own_constants.get(const_name) {
786                return Some(c.clone());
787            }
788            return None;
789        }
790
791        None
792    }
793
794    /// Resolve a method, walking up the full inheritance chain (own → traits → ancestors).
795    pub fn get_method(&self, fqcn: &str, method_name: &str) -> Option<Arc<MethodStorage>> {
796        self.get_method_inner(fqcn, method_name, &mut std::collections::HashSet::new())
797    }
798
799    fn get_method_inner(
800        &self,
801        fqcn: &str,
802        method_name: &str,
803        visited: &mut std::collections::HashSet<String>,
804    ) -> Option<Arc<MethodStorage>> {
805        if !visited.insert(fqcn.to_string()) {
806            return None;
807        }
808        // PHP method names are case-insensitive — normalize to lowercase for all lookups.
809        let method_lower = method_name.to_lowercase();
810        let method_name = method_lower.as_str();
811
812        // --- Class: own methods → own traits → ancestor classes/traits/interfaces ---
813        if let Some(cls) = self.classes.get(fqcn) {
814            // 1. Own methods (highest priority)
815            if let Some(m) = lookup_method(&cls.own_methods, method_name) {
816                return Some(Arc::clone(m));
817            }
818            // Collect chain info before dropping the DashMap guard.
819            let own_traits = cls.traits.clone();
820            let ancestors = cls.all_parents.clone();
821            let mixins = cls.mixins.clone();
822            drop(cls);
823
824            // 2. Docblock mixins (delegated magic lookup)
825            for mixin_fqcn in &mixins {
826                if let Some(m) = self.get_method_inner(mixin_fqcn, method_name, visited) {
827                    return Some(m);
828                }
829            }
830
831            // 3. Own trait methods (recursive into trait-of-trait)
832            for tr_fqcn in &own_traits {
833                if let Some(m) = self.get_method_in_trait(tr_fqcn, method_name) {
834                    return Some(m);
835                }
836            }
837
838            // 4. Ancestor chain (all_parents is closest-first: parent, grandparent, …)
839            for ancestor_fqcn in &ancestors {
840                if let Some(anc) = self.classes.get(ancestor_fqcn.as_ref()) {
841                    if let Some(m) = lookup_method(&anc.own_methods, method_name) {
842                        return Some(Arc::clone(m));
843                    }
844                    let anc_traits = anc.traits.clone();
845                    let anc_mixins = anc.mixins.clone();
846                    drop(anc);
847                    for tr_fqcn in &anc_traits {
848                        if let Some(m) = self.get_method_in_trait(tr_fqcn, method_name) {
849                            return Some(m);
850                        }
851                    }
852                    for mixin_fqcn in &anc_mixins {
853                        if let Some(m) = self.get_method_inner(mixin_fqcn, method_name, visited) {
854                            return Some(m);
855                        }
856                    }
857                } else if let Some(iface) = self.interfaces.get(ancestor_fqcn.as_ref()) {
858                    if let Some(m) = lookup_method(&iface.own_methods, method_name) {
859                        let mut ms = (**m).clone();
860                        ms.is_abstract = true;
861                        return Some(Arc::new(ms));
862                    }
863                }
864                // Traits listed in all_parents are already covered via their owning class above.
865            }
866            return None;
867        }
868
869        // --- Interface: own methods + parent interfaces ---
870        if let Some(iface) = self.interfaces.get(fqcn) {
871            if let Some(m) = lookup_method(&iface.own_methods, method_name) {
872                return Some(Arc::clone(m));
873            }
874            let parents = iface.all_parents.clone();
875            drop(iface);
876            for parent_fqcn in &parents {
877                if let Some(parent_iface) = self.interfaces.get(parent_fqcn.as_ref()) {
878                    if let Some(m) = lookup_method(&parent_iface.own_methods, method_name) {
879                        return Some(Arc::clone(m));
880                    }
881                }
882            }
883            return None;
884        }
885
886        // --- Trait (variable annotated with a trait type) ---
887        if let Some(tr) = self.traits.get(fqcn) {
888            if let Some(m) = lookup_method(&tr.own_methods, method_name) {
889                return Some(Arc::clone(m));
890            }
891            return None;
892        }
893
894        // --- Enum ---
895        if let Some(e) = self.enums.get(fqcn) {
896            if let Some(m) = lookup_method(&e.own_methods, method_name) {
897                return Some(Arc::clone(m));
898            }
899            // PHP 8.1 built-in enum methods: cases(), from(), tryFrom()
900            if matches!(method_name, "cases" | "from" | "tryfrom") {
901                return Some(Arc::new(crate::storage::MethodStorage {
902                    fqcn: Arc::from(fqcn),
903                    name: Arc::from(method_name),
904                    params: vec![],
905                    return_type: Some(mir_types::Union::mixed()),
906                    inferred_return_type: None,
907                    visibility: crate::storage::Visibility::Public,
908                    is_static: true,
909                    is_abstract: false,
910                    is_constructor: false,
911                    template_params: vec![],
912                    assertions: vec![],
913                    throws: vec![],
914                    is_final: false,
915                    is_internal: false,
916                    is_pure: false,
917                    deprecated: None,
918                    location: None,
919                }));
920            }
921        }
922
923        None
924    }
925
926    /// Returns true if `child` extends or implements `ancestor` (transitively).
927    pub fn extends_or_implements(&self, child: &str, ancestor: &str) -> bool {
928        if child == ancestor {
929            return true;
930        }
931        if let Some(cls) = self.classes.get(child) {
932            return cls.implements_or_extends(ancestor);
933        }
934        if let Some(iface) = self.interfaces.get(child) {
935            return iface.all_parents.iter().any(|p| p.as_ref() == ancestor);
936        }
937        // Enum: backed enums implicitly implement BackedEnum (and UnitEnum);
938        // pure enums implicitly implement UnitEnum.
939        if let Some(en) = self.enums.get(child) {
940            // Check explicitly declared interfaces (e.g. implements SomeInterface)
941            if en.interfaces.iter().any(|i| i.as_ref() == ancestor) {
942                return true;
943            }
944            // PHP built-in: every enum implements UnitEnum
945            if ancestor == "UnitEnum" || ancestor == "\\UnitEnum" {
946                return true;
947            }
948            // Backed enums implement BackedEnum
949            if (ancestor == "BackedEnum" || ancestor == "\\BackedEnum") && en.scalar_type.is_some()
950            {
951                return true;
952            }
953        }
954        false
955    }
956
957    /// Whether a class/interface/trait/enum with this FQCN exists.
958    pub fn type_exists(&self, fqcn: &str) -> bool {
959        self.classes.contains_key(fqcn)
960            || self.interfaces.contains_key(fqcn)
961            || self.traits.contains_key(fqcn)
962            || self.enums.contains_key(fqcn)
963    }
964
965    pub fn function_exists(&self, fqn: &str) -> bool {
966        self.functions.contains_key(fqn)
967    }
968
969    /// Returns true if the class is declared abstract.
970    /// Used to suppress `UndefinedMethod` on abstract class receivers: the concrete
971    /// subclass is expected to implement the method, matching Psalm errorLevel=3 behaviour.
972    pub fn is_abstract_class(&self, fqcn: &str) -> bool {
973        self.classes.get(fqcn).is_some_and(|c| c.is_abstract)
974    }
975
976    /// Return the declared template params for `fqcn` (class or interface), or
977    /// an empty vec if the type is not found or has no templates.
978    pub fn get_class_template_params(&self, fqcn: &str) -> Vec<crate::storage::TemplateParam> {
979        if let Some(cls) = self.classes.get(fqcn) {
980            return cls.template_params.clone();
981        }
982        if let Some(iface) = self.interfaces.get(fqcn) {
983            return iface.template_params.clone();
984        }
985        if let Some(tr) = self.traits.get(fqcn) {
986            return tr.template_params.clone();
987        }
988        vec![]
989    }
990
991    /// Walk the parent chain collecting template bindings from `@extends` type args.
992    ///
993    /// For `class UserRepo extends BaseRepo` with `@extends BaseRepo<User>`, this returns
994    /// `{ T → User }` where `T` is `BaseRepo`'s declared template parameter.
995    pub fn get_inherited_template_bindings(
996        &self,
997        fqcn: &str,
998    ) -> std::collections::HashMap<Arc<str>, Union> {
999        let mut bindings = std::collections::HashMap::new();
1000        let mut current = fqcn.to_string();
1001
1002        loop {
1003            let (parent_fqcn, extends_type_args) = {
1004                let cls = match self.classes.get(current.as_str()) {
1005                    Some(c) => c,
1006                    None => break,
1007                };
1008                let parent = match &cls.parent {
1009                    Some(p) => p.clone(),
1010                    None => break,
1011                };
1012                let args = cls.extends_type_args.clone();
1013                (parent, args)
1014            };
1015
1016            if !extends_type_args.is_empty() {
1017                let parent_tps = self.get_class_template_params(&parent_fqcn);
1018                for (tp, ty) in parent_tps.iter().zip(extends_type_args.iter()) {
1019                    bindings
1020                        .entry(tp.name.clone())
1021                        .or_insert_with(|| ty.clone());
1022                }
1023            }
1024
1025            current = parent_fqcn.to_string();
1026        }
1027
1028        bindings
1029    }
1030
1031    /// Returns true if the class (or any ancestor/trait) defines a `__get` magic method.
1032    /// Such classes allow arbitrary property access, suppressing UndefinedProperty.
1033    pub fn has_magic_get(&self, fqcn: &str) -> bool {
1034        self.get_method(fqcn, "__get").is_some()
1035    }
1036
1037    /// Returns true if the class (or any of its ancestors) has a parent/interface/trait
1038    /// that is NOT present in the codebase.  Used to suppress `UndefinedMethod` false
1039    /// positives: if a method might be inherited from an unscanned external class we
1040    /// cannot confirm or deny its existence.
1041    ///
1042    /// We use the pre-computed `all_parents` list (built during finalization) rather
1043    /// than recursive DashMap lookups to avoid potential deadlocks.
1044    pub fn has_unknown_ancestor(&self, fqcn: &str) -> bool {
1045        // For interfaces: check whether any parent interface is unknown.
1046        if let Some(iface) = self.interfaces.get(fqcn) {
1047            let parents = iface.all_parents.clone();
1048            drop(iface);
1049            for p in &parents {
1050                if !self.type_exists(p.as_ref()) {
1051                    return true;
1052                }
1053            }
1054            return false;
1055        }
1056
1057        // Clone the data we need so the DashMap ref is dropped before any further lookups.
1058        let (parent, interfaces, traits, all_parents) = {
1059            let Some(cls) = self.classes.get(fqcn) else {
1060                return false;
1061            };
1062            (
1063                cls.parent.clone(),
1064                cls.interfaces.clone(),
1065                cls.traits.clone(),
1066                cls.all_parents.clone(),
1067            )
1068        };
1069
1070        // Fast path: check direct parent/interfaces/traits
1071        if let Some(ref p) = parent {
1072            if !self.type_exists(p.as_ref()) {
1073                return true;
1074            }
1075        }
1076        for iface in &interfaces {
1077            if !self.type_exists(iface.as_ref()) {
1078                return true;
1079            }
1080        }
1081        for tr in &traits {
1082            if !self.type_exists(tr.as_ref()) {
1083                return true;
1084            }
1085        }
1086
1087        // Also check the full ancestor chain (pre-computed during finalization)
1088        for ancestor in &all_parents {
1089            if !self.type_exists(ancestor.as_ref()) {
1090                return true;
1091            }
1092        }
1093
1094        false
1095    }
1096
1097    /// Resolve a short class/function name to its FQCN using the import table
1098    /// and namespace recorded for `file` during Pass 1.
1099    ///
1100    /// - Names already containing `\` (after stripping a leading `\`) are
1101    ///   returned as-is (already fully qualified).
1102    /// - `self`, `parent`, `static` are returned unchanged (caller handles them).
1103    pub fn resolve_class_name(&self, file: &str, name: &str) -> String {
1104        let name = name.trim_start_matches('\\');
1105        if name.is_empty() {
1106            return name.to_string();
1107        }
1108        // Fully qualified absolute paths start with '\' (already stripped above).
1109        // Names containing '\' but not starting with it may be:
1110        //   - Already-resolved FQCNs (e.g. Frontify\Util\Foo) — check type_exists
1111        //   - Qualified relative names (e.g. Option\Some from within Frontify\Utility) — need namespace prefix
1112        if name.contains('\\') {
1113            // Check if the leading segment matches a use-import alias
1114            let first_segment = name.split('\\').next().unwrap_or(name);
1115            if let Some(imports) = self.file_imports.get(file) {
1116                if let Some(resolved_prefix) = imports.get(first_segment) {
1117                    let rest = &name[first_segment.len()..]; // includes leading '\'
1118                    return format!("{resolved_prefix}{rest}");
1119                }
1120            }
1121            // If already known in codebase as-is, it's FQCN — trust it
1122            if self.type_exists(name) {
1123                return name.to_string();
1124            }
1125            // Otherwise it's a relative qualified name — prepend the file namespace
1126            if let Some(ns) = self.file_namespaces.get(file) {
1127                let qualified = format!("{}\\{}", *ns, name);
1128                if self.type_exists(&qualified) {
1129                    return qualified;
1130                }
1131            }
1132            return name.to_string();
1133        }
1134        // Built-in pseudo-types / keywords handled by the caller
1135        match name {
1136            "self" | "parent" | "static" | "this" => return name.to_string(),
1137            _ => {}
1138        }
1139        // Check use aliases for this file (PHP class names are case-insensitive)
1140        if let Some(imports) = self.file_imports.get(file) {
1141            if let Some(resolved) = imports.get(name) {
1142                return resolved.clone();
1143            }
1144            // Fall back to case-insensitive alias lookup
1145            let name_lower = name.to_lowercase();
1146            for (alias, resolved) in imports.iter() {
1147                if alias.to_lowercase() == name_lower {
1148                    return resolved.clone();
1149                }
1150            }
1151        }
1152        // Qualify with the file's namespace if one exists
1153        if let Some(ns) = self.file_namespaces.get(file) {
1154            let qualified = format!("{}\\{}", *ns, name);
1155            // If the namespaced version exists in the codebase, use it.
1156            // Otherwise fall back to the global (unqualified) name if that exists.
1157            // This handles `DateTimeInterface`, `Exception`, etc. used without import
1158            // while not overriding user-defined classes in namespaces.
1159            if self.type_exists(&qualified) {
1160                return qualified;
1161            }
1162            if self.type_exists(name) {
1163                return name.to_string();
1164            }
1165            return qualified;
1166        }
1167        name.to_string()
1168    }
1169
1170    // -----------------------------------------------------------------------
1171    // Definition location lookups
1172    // -----------------------------------------------------------------------
1173
1174    /// Look up the definition location of any symbol (class, interface, trait, enum, function).
1175    /// Returns the file path and byte offsets.
1176    pub fn get_symbol_location(&self, fqcn: &str) -> Option<crate::storage::Location> {
1177        if let Some(cls) = self.classes.get(fqcn) {
1178            return cls.location.clone();
1179        }
1180        if let Some(iface) = self.interfaces.get(fqcn) {
1181            return iface.location.clone();
1182        }
1183        if let Some(tr) = self.traits.get(fqcn) {
1184            return tr.location.clone();
1185        }
1186        if let Some(en) = self.enums.get(fqcn) {
1187            return en.location.clone();
1188        }
1189        if let Some(func) = self.functions.get(fqcn) {
1190            return func.location.clone();
1191        }
1192        None
1193    }
1194
1195    /// Look up the definition location of a class member (method, property, constant).
1196    pub fn get_member_location(
1197        &self,
1198        fqcn: &str,
1199        member_name: &str,
1200    ) -> Option<crate::storage::Location> {
1201        // Check methods
1202        if let Some(method) = self.get_method(fqcn, member_name) {
1203            return method.location.clone();
1204        }
1205        // Check properties
1206        if let Some(prop) = self.get_property(fqcn, member_name) {
1207            return prop.location.clone();
1208        }
1209        // Check class constants
1210        if let Some(cls) = self.classes.get(fqcn) {
1211            if let Some(c) = cls.own_constants.get(member_name) {
1212                return c.location.clone();
1213            }
1214        }
1215        // Check interface constants
1216        if let Some(iface) = self.interfaces.get(fqcn) {
1217            if let Some(c) = iface.own_constants.get(member_name) {
1218                return c.location.clone();
1219            }
1220        }
1221        // Check trait constants
1222        if let Some(tr) = self.traits.get(fqcn) {
1223            if let Some(c) = tr.own_constants.get(member_name) {
1224                return c.location.clone();
1225            }
1226        }
1227        // Check enum constants and cases
1228        if let Some(en) = self.enums.get(fqcn) {
1229            if let Some(c) = en.own_constants.get(member_name) {
1230                return c.location.clone();
1231            }
1232            if let Some(case) = en.cases.get(member_name) {
1233                return case.location.clone();
1234            }
1235        }
1236        None
1237    }
1238
1239    // -----------------------------------------------------------------------
1240    // Reference tracking (M18 dead-code detection)
1241    // -----------------------------------------------------------------------
1242
1243    /// Mark a method as referenced from user code.
1244    pub fn mark_method_referenced(&self, fqcn: &str, method_name: &str) {
1245        let key = format!("{}::{}", fqcn, method_name.to_lowercase());
1246        let id = self.symbol_interner.intern_str(&key);
1247        self.referenced_methods.insert(id);
1248    }
1249
1250    /// Mark a property as referenced from user code.
1251    pub fn mark_property_referenced(&self, fqcn: &str, prop_name: &str) {
1252        let key = format!("{fqcn}::{prop_name}");
1253        let id = self.symbol_interner.intern_str(&key);
1254        self.referenced_properties.insert(id);
1255    }
1256
1257    /// Mark a free function as referenced from user code.
1258    pub fn mark_function_referenced(&self, fqn: &str) {
1259        let id = self.symbol_interner.intern_str(fqn);
1260        self.referenced_functions.insert(id);
1261    }
1262
1263    pub fn is_method_referenced(&self, fqcn: &str, method_name: &str) -> bool {
1264        let key = format!("{}::{}", fqcn, method_name.to_lowercase());
1265        match self.symbol_interner.get_id(&key) {
1266            Some(id) => self.referenced_methods.contains(&id),
1267            None => false,
1268        }
1269    }
1270
1271    pub fn is_property_referenced(&self, fqcn: &str, prop_name: &str) -> bool {
1272        let key = format!("{fqcn}::{prop_name}");
1273        match self.symbol_interner.get_id(&key) {
1274            Some(id) => self.referenced_properties.contains(&id),
1275            None => false,
1276        }
1277    }
1278
1279    pub fn is_function_referenced(&self, fqn: &str) -> bool {
1280        match self.symbol_interner.get_id(fqn) {
1281            Some(id) => self.referenced_functions.contains(&id),
1282            None => false,
1283        }
1284    }
1285
1286    /// Record a method reference with its source location.
1287    /// Also updates the referenced_methods DashSet for dead-code detection.
1288    pub fn mark_method_referenced_at(
1289        &self,
1290        fqcn: &str,
1291        method_name: &str,
1292        file: Arc<str>,
1293        line: u32,
1294        col_start: u16,
1295        col_end: u16,
1296    ) {
1297        let key = format!("{}::{}", fqcn, method_name.to_lowercase());
1298        self.ensure_expanded();
1299        let sym_id = self.symbol_interner.intern_str(&key);
1300        let file_id = self.file_interner.intern(file);
1301        self.referenced_methods.insert(sym_id);
1302        record_ref(
1303            &self.symbol_reference_locations,
1304            &self.file_symbol_references,
1305            sym_id,
1306            file_id,
1307            line,
1308            col_start,
1309            col_end,
1310        );
1311    }
1312
1313    /// Record a property reference with its source location.
1314    /// Also updates the referenced_properties DashSet for dead-code detection.
1315    pub fn mark_property_referenced_at(
1316        &self,
1317        fqcn: &str,
1318        prop_name: &str,
1319        file: Arc<str>,
1320        line: u32,
1321        col_start: u16,
1322        col_end: u16,
1323    ) {
1324        let key = format!("{fqcn}::{prop_name}");
1325        self.ensure_expanded();
1326        let sym_id = self.symbol_interner.intern_str(&key);
1327        let file_id = self.file_interner.intern(file);
1328        self.referenced_properties.insert(sym_id);
1329        record_ref(
1330            &self.symbol_reference_locations,
1331            &self.file_symbol_references,
1332            sym_id,
1333            file_id,
1334            line,
1335            col_start,
1336            col_end,
1337        );
1338    }
1339
1340    /// Record a function reference with its source location.
1341    /// Also updates the referenced_functions DashSet for dead-code detection.
1342    pub fn mark_function_referenced_at(
1343        &self,
1344        fqn: &str,
1345        file: Arc<str>,
1346        line: u32,
1347        col_start: u16,
1348        col_end: u16,
1349    ) {
1350        self.ensure_expanded();
1351        let sym_id = self.symbol_interner.intern_str(fqn);
1352        let file_id = self.file_interner.intern(file);
1353        self.referenced_functions.insert(sym_id);
1354        record_ref(
1355            &self.symbol_reference_locations,
1356            &self.file_symbol_references,
1357            sym_id,
1358            file_id,
1359            line,
1360            col_start,
1361            col_end,
1362        );
1363    }
1364
1365    /// Record a class reference (e.g. `new Foo()`) with its source location.
1366    /// Does not update any dead-code DashSet — class instantiation tracking is
1367    /// separate from method/property/function dead-code detection.
1368    pub fn mark_class_referenced_at(
1369        &self,
1370        fqcn: &str,
1371        file: Arc<str>,
1372        line: u32,
1373        col_start: u16,
1374        col_end: u16,
1375    ) {
1376        self.ensure_expanded();
1377        let sym_id = self.symbol_interner.intern_str(fqcn);
1378        let file_id = self.file_interner.intern(file);
1379        record_ref(
1380            &self.symbol_reference_locations,
1381            &self.file_symbol_references,
1382            sym_id,
1383            file_id,
1384            line,
1385            col_start,
1386            col_end,
1387        );
1388    }
1389
1390    /// Replay cached reference locations for a file into the reference index.
1391    /// Called on cache hits to avoid re-running Pass 2 just to rebuild the index.
1392    /// `locs` is a slice of `(symbol_key, line, col_start, col_end)` as stored in the cache.
1393    pub fn replay_reference_locations(&self, file: Arc<str>, locs: &[(String, u32, u16, u16)]) {
1394        if locs.is_empty() {
1395            return;
1396        }
1397        self.ensure_expanded();
1398        let file_id = self.file_interner.intern(file);
1399        for (symbol_key, line, col_start, col_end) in locs {
1400            let sym_id = self.symbol_interner.intern_str(symbol_key);
1401            record_ref(
1402                &self.symbol_reference_locations,
1403                &self.file_symbol_references,
1404                sym_id,
1405                file_id,
1406                *line,
1407                *col_start,
1408                *col_end,
1409            );
1410        }
1411    }
1412
1413    /// Return all reference locations for `symbol` as `Vec<(file, line, col_start, col_end)>`.
1414    /// Returns an empty Vec if the symbol has no recorded references.
1415    pub fn get_reference_locations(&self, symbol: &str) -> Vec<(Arc<str>, u32, u16, u16)> {
1416        let Some(sym_id) = self.symbol_interner.get_id(symbol) else {
1417            return Vec::new();
1418        };
1419        // Fast path: compact CSR index.
1420        if let Some(ref ci) = *self.compact_ref_index.read().unwrap() {
1421            let id = sym_id as usize;
1422            if id + 1 >= ci.sym_offsets.len() {
1423                return Vec::new();
1424            }
1425            let start = ci.sym_offsets[id] as usize;
1426            let end = ci.sym_offsets[id + 1] as usize;
1427            return ci.entries[start..end]
1428                .iter()
1429                .map(|&(_, file_id, line, col_start, col_end)| {
1430                    (self.file_interner.get(file_id), line, col_start, col_end)
1431                })
1432                .collect();
1433        }
1434        // Slow path: build-phase DashMap.
1435        let Some(entries) = self.symbol_reference_locations.get(&sym_id) else {
1436            return Vec::new();
1437        };
1438        entries
1439            .iter()
1440            .map(|&(file_id, line, col_start, col_end)| {
1441                (self.file_interner.get(file_id), line, col_start, col_end)
1442            })
1443            .collect()
1444    }
1445
1446    /// Extract all reference locations recorded for `file` as
1447    /// `(symbol_key, line, col_start, col_end)` tuples.
1448    /// Used by the cache layer to persist per-file reference data between runs.
1449    pub fn extract_file_reference_locations(&self, file: &str) -> Vec<(Arc<str>, u32, u16, u16)> {
1450        let Some(file_id) = self.file_interner.get_id(file) else {
1451            return Vec::new();
1452        };
1453        // Fast path: compact CSR index.
1454        if let Some(ref ci) = *self.compact_ref_index.read().unwrap() {
1455            let id = file_id as usize;
1456            if id + 1 >= ci.file_offsets.len() {
1457                return Vec::new();
1458            }
1459            let start = ci.file_offsets[id] as usize;
1460            let end = ci.file_offsets[id + 1] as usize;
1461            return ci.by_file[start..end]
1462                .iter()
1463                .map(|&entry_idx| {
1464                    let (sym_id, _, line, col_start, col_end) = ci.entries[entry_idx as usize];
1465                    (self.symbol_interner.get(sym_id), line, col_start, col_end)
1466                })
1467                .collect();
1468        }
1469        // Slow path: build-phase DashMaps.
1470        let Some(sym_ids) = self.file_symbol_references.get(&file_id) else {
1471            return Vec::new();
1472        };
1473        let mut out = Vec::new();
1474        for &sym_id in sym_ids.iter() {
1475            let Some(entries) = self.symbol_reference_locations.get(&sym_id) else {
1476                continue;
1477            };
1478            let sym_key = self.symbol_interner.get(sym_id);
1479            for &(entry_file_id, line, col_start, col_end) in entries.iter() {
1480                if entry_file_id == file_id {
1481                    out.push((sym_key.clone(), line, col_start, col_end));
1482                }
1483            }
1484        }
1485        out
1486    }
1487
1488    /// Returns true if the given file has any recorded symbol references.
1489    pub fn file_has_symbol_references(&self, file: &str) -> bool {
1490        let Some(file_id) = self.file_interner.get_id(file) else {
1491            return false;
1492        };
1493        // Check compact index first.
1494        if let Some(ref ci) = *self.compact_ref_index.read().unwrap() {
1495            let id = file_id as usize;
1496            return id + 1 < ci.file_offsets.len() && ci.file_offsets[id] < ci.file_offsets[id + 1];
1497        }
1498        self.file_symbol_references.contains_key(&file_id)
1499    }
1500
1501    // -----------------------------------------------------------------------
1502    // Finalization
1503    // -----------------------------------------------------------------------
1504
1505    /// Must be called after all files have been parsed (pass 1 complete).
1506    /// Resolves inheritance chains and builds method dispatch tables.
1507    pub fn finalize(&self) {
1508        if self.finalized.load(std::sync::atomic::Ordering::SeqCst) {
1509            return;
1510        }
1511
1512        // 1. Resolve all_parents for classes
1513        let class_keys: Vec<Arc<str>> = self.classes.iter().map(|e| e.key().clone()).collect();
1514        for fqcn in &class_keys {
1515            let parents = self.collect_class_ancestors(fqcn);
1516            if let Some(mut cls) = self.classes.get_mut(fqcn.as_ref()) {
1517                cls.all_parents = parents;
1518            }
1519        }
1520
1521        // 2. Resolve all_parents for interfaces
1522        let iface_keys: Vec<Arc<str>> = self.interfaces.iter().map(|e| e.key().clone()).collect();
1523        for fqcn in &iface_keys {
1524            let parents = self.collect_interface_ancestors(fqcn);
1525            if let Some(mut iface) = self.interfaces.get_mut(fqcn.as_ref()) {
1526                iface.all_parents = parents;
1527            }
1528        }
1529
1530        // 3. Resolve @psalm-import-type declarations
1531        // Collect imports first to avoid holding two locks simultaneously.
1532        type PendingImports = Vec<(Arc<str>, Vec<(Arc<str>, Arc<str>, Arc<str>)>)>;
1533        let pending: PendingImports = self
1534            .classes
1535            .iter()
1536            .filter(|e| !e.pending_import_types.is_empty())
1537            .map(|e| (e.key().clone(), e.pending_import_types.clone()))
1538            .collect();
1539        for (dst_fqcn, imports) in pending {
1540            let mut resolved: std::collections::HashMap<Arc<str>, mir_types::Union> =
1541                std::collections::HashMap::new();
1542            for (local, original, from_class) in &imports {
1543                if let Some(src_cls) = self.classes.get(from_class.as_ref()) {
1544                    if let Some(ty) = src_cls.type_aliases.get(original.as_ref()) {
1545                        resolved.insert(local.clone(), ty.clone());
1546                    }
1547                }
1548            }
1549            if !resolved.is_empty() {
1550                if let Some(mut dst_cls) = self.classes.get_mut(dst_fqcn.as_ref()) {
1551                    for (k, v) in resolved {
1552                        dst_cls.type_aliases.insert(k, v);
1553                    }
1554                }
1555            }
1556        }
1557
1558        self.finalized
1559            .store(true, std::sync::atomic::Ordering::SeqCst);
1560    }
1561
1562    // -----------------------------------------------------------------------
1563    // Private helpers
1564    // -----------------------------------------------------------------------
1565
1566    /// Look up `method_name` in a trait's own methods, then recursively in any
1567    /// traits that the trait itself uses (`use OtherTrait;` inside a trait body).
1568    /// A visited set prevents infinite loops on pathological mutual trait use.
1569    fn get_method_in_trait(
1570        &self,
1571        tr_fqcn: &Arc<str>,
1572        method_name: &str,
1573    ) -> Option<Arc<MethodStorage>> {
1574        let mut visited = std::collections::HashSet::new();
1575        self.get_method_in_trait_inner(tr_fqcn, method_name, &mut visited)
1576    }
1577
1578    fn get_method_in_trait_inner(
1579        &self,
1580        tr_fqcn: &Arc<str>,
1581        method_name: &str,
1582        visited: &mut std::collections::HashSet<String>,
1583    ) -> Option<Arc<MethodStorage>> {
1584        if !visited.insert(tr_fqcn.to_string()) {
1585            return None; // cycle guard
1586        }
1587        let tr = self.traits.get(tr_fqcn.as_ref())?;
1588        if let Some(m) = lookup_method(&tr.own_methods, method_name) {
1589            return Some(Arc::clone(m));
1590        }
1591        let used_traits = tr.traits.clone();
1592        drop(tr);
1593        for used_fqcn in &used_traits {
1594            if let Some(m) = self.get_method_in_trait_inner(used_fqcn, method_name, visited) {
1595                return Some(m);
1596            }
1597        }
1598        None
1599    }
1600
1601    fn collect_class_ancestors(&self, fqcn: &str) -> Vec<Arc<str>> {
1602        let mut result = Vec::new();
1603        let mut visited = std::collections::HashSet::new();
1604        self.collect_class_ancestors_inner(fqcn, &mut result, &mut visited);
1605        result
1606    }
1607
1608    fn collect_class_ancestors_inner(
1609        &self,
1610        fqcn: &str,
1611        out: &mut Vec<Arc<str>>,
1612        visited: &mut std::collections::HashSet<String>,
1613    ) {
1614        if !visited.insert(fqcn.to_string()) {
1615            return; // cycle guard
1616        }
1617        let (parent, interfaces, traits) = {
1618            if let Some(cls) = self.classes.get(fqcn) {
1619                (
1620                    cls.parent.clone(),
1621                    cls.interfaces.clone(),
1622                    cls.traits.clone(),
1623                )
1624            } else {
1625                return;
1626            }
1627        };
1628
1629        if let Some(p) = parent {
1630            out.push(p.clone());
1631            self.collect_class_ancestors_inner(&p, out, visited);
1632        }
1633        for iface in interfaces {
1634            out.push(iface.clone());
1635            self.collect_interface_ancestors_inner(&iface, out, visited);
1636        }
1637        for t in traits {
1638            out.push(t);
1639        }
1640    }
1641
1642    fn collect_interface_ancestors(&self, fqcn: &str) -> Vec<Arc<str>> {
1643        let mut result = Vec::new();
1644        let mut visited = std::collections::HashSet::new();
1645        self.collect_interface_ancestors_inner(fqcn, &mut result, &mut visited);
1646        result
1647    }
1648
1649    fn collect_interface_ancestors_inner(
1650        &self,
1651        fqcn: &str,
1652        out: &mut Vec<Arc<str>>,
1653        visited: &mut std::collections::HashSet<String>,
1654    ) {
1655        if !visited.insert(fqcn.to_string()) {
1656            return;
1657        }
1658        let extends = {
1659            if let Some(iface) = self.interfaces.get(fqcn) {
1660                iface.extends.clone()
1661            } else {
1662                return;
1663            }
1664        };
1665        for e in extends {
1666            out.push(e.clone());
1667            self.collect_interface_ancestors_inner(&e, out, visited);
1668        }
1669    }
1670}
1671
1672// ---------------------------------------------------------------------------
1673// CodebaseBuilder — compose a finalized Codebase from per-file StubSlices
1674// ---------------------------------------------------------------------------
1675
1676/// Incremental builder that accumulates [`crate::storage::StubSlice`] values
1677/// into a fresh [`Codebase`] and finalizes it on demand.
1678///
1679/// Designed for callers (e.g. salsa queries in downstream consumers) that want
1680/// to treat Pass-1 definition collection as a pure function from source to
1681/// `StubSlice`, then compose the slices into a full codebase outside the
1682/// collector.
1683pub struct CodebaseBuilder {
1684    cb: Codebase,
1685}
1686
1687impl CodebaseBuilder {
1688    pub fn new() -> Self {
1689        Self {
1690            cb: Codebase::new(),
1691        }
1692    }
1693
1694    /// Inject a single slice. Later injections overwrite earlier definitions
1695    /// with the same FQN, matching [`Codebase::inject_stub_slice`] semantics.
1696    pub fn add(&mut self, slice: crate::storage::StubSlice) {
1697        self.cb.inject_stub_slice(slice);
1698    }
1699
1700    /// Finalize inheritance graphs and return the built `Codebase`.
1701    pub fn finalize(self) -> Codebase {
1702        self.cb.finalize();
1703        self.cb
1704    }
1705
1706    /// Access the in-progress codebase without consuming the builder.
1707    pub fn codebase(&self) -> &Codebase {
1708        &self.cb
1709    }
1710}
1711
1712impl Default for CodebaseBuilder {
1713    fn default() -> Self {
1714        Self::new()
1715    }
1716}
1717
1718/// One-shot: build a finalized [`Codebase`] from a set of per-file slices.
1719pub fn codebase_from_parts(parts: Vec<crate::storage::StubSlice>) -> Codebase {
1720    let mut b = CodebaseBuilder::new();
1721    for p in parts {
1722        b.add(p);
1723    }
1724    b.finalize()
1725}
1726
1727#[cfg(test)]
1728mod tests {
1729    use super::*;
1730
1731    fn arc(s: &str) -> Arc<str> {
1732        Arc::from(s)
1733    }
1734
1735    #[test]
1736    fn method_referenced_at_groups_spans_by_file() {
1737        let cb = Codebase::new();
1738        cb.mark_method_referenced_at("Foo", "bar", arc("a.php"), 1, 0, 5);
1739        cb.mark_method_referenced_at("Foo", "bar", arc("a.php"), 1, 10, 15);
1740        cb.mark_method_referenced_at("Foo", "bar", arc("b.php"), 2, 0, 5);
1741
1742        let locs = cb.get_reference_locations("Foo::bar");
1743        let files: std::collections::HashSet<&str> =
1744            locs.iter().map(|(f, ..)| f.as_ref()).collect();
1745        assert_eq!(files.len(), 2, "two files, not three spans");
1746        assert!(locs.contains(&(arc("a.php"), 1, 0, 5)));
1747        assert!(locs.contains(&(arc("a.php"), 1, 10, 15)));
1748        assert_eq!(
1749            locs.iter().filter(|(f, ..)| f.as_ref() == "a.php").count(),
1750            2
1751        );
1752        assert!(locs.contains(&(arc("b.php"), 2, 0, 5)));
1753        assert!(
1754            cb.is_method_referenced("Foo", "bar"),
1755            "DashSet also updated"
1756        );
1757    }
1758
1759    #[test]
1760    fn duplicate_spans_are_deduplicated() {
1761        let cb = Codebase::new();
1762        // Same call site recorded twice (e.g. union receiver Foo|Foo)
1763        cb.mark_method_referenced_at("Foo", "bar", arc("a.php"), 1, 0, 5);
1764        cb.mark_method_referenced_at("Foo", "bar", arc("a.php"), 1, 0, 5);
1765
1766        let count = cb
1767            .get_reference_locations("Foo::bar")
1768            .iter()
1769            .filter(|(f, ..)| f.as_ref() == "a.php")
1770            .count();
1771        assert_eq!(count, 1, "duplicate span deduplicated");
1772    }
1773
1774    #[test]
1775    fn method_key_is_lowercased() {
1776        let cb = Codebase::new();
1777        cb.mark_method_referenced_at("Cls", "MyMethod", arc("f.php"), 1, 0, 3);
1778        assert!(!cb.get_reference_locations("Cls::mymethod").is_empty());
1779    }
1780
1781    #[test]
1782    fn property_referenced_at_records_location() {
1783        let cb = Codebase::new();
1784        cb.mark_property_referenced_at("Bar", "count", arc("x.php"), 1, 5, 10);
1785
1786        assert!(cb
1787            .get_reference_locations("Bar::count")
1788            .contains(&(arc("x.php"), 1, 5, 10)));
1789        assert!(cb.is_property_referenced("Bar", "count"));
1790    }
1791
1792    #[test]
1793    fn function_referenced_at_records_location() {
1794        let cb = Codebase::new();
1795        cb.mark_function_referenced_at("my_fn", arc("a.php"), 1, 10, 15);
1796
1797        assert!(cb
1798            .get_reference_locations("my_fn")
1799            .contains(&(arc("a.php"), 1, 10, 15)));
1800        assert!(cb.is_function_referenced("my_fn"));
1801    }
1802
1803    #[test]
1804    fn class_referenced_at_records_location() {
1805        let cb = Codebase::new();
1806        cb.mark_class_referenced_at("Foo", arc("a.php"), 1, 5, 8);
1807
1808        assert!(cb
1809            .get_reference_locations("Foo")
1810            .contains(&(arc("a.php"), 1, 5, 8)));
1811    }
1812
1813    #[test]
1814    fn get_reference_locations_flattens_all_files() {
1815        let cb = Codebase::new();
1816        cb.mark_function_referenced_at("fn1", arc("a.php"), 1, 0, 5);
1817        cb.mark_function_referenced_at("fn1", arc("b.php"), 2, 0, 5);
1818
1819        let mut locs = cb.get_reference_locations("fn1");
1820        locs.sort_by_key(|&(_, line, col, _)| (line, col));
1821        assert_eq!(locs.len(), 2);
1822        assert_eq!(locs[0], (arc("a.php"), 1, 0, 5));
1823        assert_eq!(locs[1], (arc("b.php"), 2, 0, 5));
1824    }
1825
1826    #[test]
1827    fn replay_reference_locations_restores_index() {
1828        let cb = Codebase::new();
1829        let locs = vec![
1830            ("Foo::bar".to_string(), 1u32, 0u16, 5u16),
1831            ("Foo::bar".to_string(), 1, 10, 15),
1832            ("greet".to_string(), 2, 0, 5),
1833        ];
1834        cb.replay_reference_locations(arc("a.php"), &locs);
1835
1836        let bar_locs = cb.get_reference_locations("Foo::bar");
1837        assert!(bar_locs.contains(&(arc("a.php"), 1, 0, 5)));
1838        assert!(bar_locs.contains(&(arc("a.php"), 1, 10, 15)));
1839
1840        assert!(cb
1841            .get_reference_locations("greet")
1842            .contains(&(arc("a.php"), 2, 0, 5)));
1843
1844        assert!(cb.file_has_symbol_references("a.php"));
1845    }
1846
1847    #[test]
1848    fn remove_file_clears_its_spans_only() {
1849        let cb = Codebase::new();
1850        cb.mark_function_referenced_at("fn1", arc("a.php"), 1, 0, 5);
1851        cb.mark_function_referenced_at("fn1", arc("b.php"), 1, 10, 15);
1852
1853        cb.remove_file_definitions("a.php");
1854
1855        let locs = cb.get_reference_locations("fn1");
1856        assert!(
1857            !locs.iter().any(|(f, ..)| f.as_ref() == "a.php"),
1858            "a.php spans removed"
1859        );
1860        assert!(
1861            locs.contains(&(arc("b.php"), 1, 10, 15)),
1862            "b.php spans untouched"
1863        );
1864        assert!(!cb.file_has_symbol_references("a.php"));
1865    }
1866
1867    #[test]
1868    fn remove_file_does_not_affect_other_files() {
1869        let cb = Codebase::new();
1870        cb.mark_property_referenced_at("Cls", "prop", arc("x.php"), 1, 1, 4);
1871        cb.mark_property_referenced_at("Cls", "prop", arc("y.php"), 1, 7, 10);
1872
1873        cb.remove_file_definitions("x.php");
1874
1875        let locs = cb.get_reference_locations("Cls::prop");
1876        assert!(!locs.iter().any(|(f, ..)| f.as_ref() == "x.php"));
1877        assert!(locs.contains(&(arc("y.php"), 1, 7, 10)));
1878    }
1879
1880    #[test]
1881    fn remove_file_definitions_on_never_analyzed_file_is_noop() {
1882        let cb = Codebase::new();
1883        cb.mark_function_referenced_at("fn1", arc("a.php"), 1, 0, 5);
1884
1885        // "ghost.php" was never analyzed — removing it must not panic or corrupt state.
1886        cb.remove_file_definitions("ghost.php");
1887
1888        // Existing data must be untouched.
1889        assert!(cb
1890            .get_reference_locations("fn1")
1891            .contains(&(arc("a.php"), 1, 0, 5)));
1892        assert!(!cb.file_has_symbol_references("ghost.php"));
1893    }
1894
1895    #[test]
1896    fn replay_reference_locations_with_empty_list_is_noop() {
1897        let cb = Codebase::new();
1898        cb.mark_function_referenced_at("fn1", arc("a.php"), 1, 0, 5);
1899
1900        // Replaying an empty list must not touch existing entries.
1901        cb.replay_reference_locations(arc("b.php"), &[]);
1902
1903        assert!(
1904            !cb.file_has_symbol_references("b.php"),
1905            "empty replay must not create a file entry"
1906        );
1907        assert!(
1908            cb.get_reference_locations("fn1")
1909                .contains(&(arc("a.php"), 1, 0, 5)),
1910            "existing spans untouched"
1911        );
1912    }
1913
1914    #[test]
1915    fn replay_reference_locations_twice_does_not_duplicate_spans() {
1916        let cb = Codebase::new();
1917        let locs = vec![("fn1".to_string(), 1u32, 0u16, 5u16)];
1918
1919        cb.replay_reference_locations(arc("a.php"), &locs);
1920        cb.replay_reference_locations(arc("a.php"), &locs);
1921
1922        let count = cb
1923            .get_reference_locations("fn1")
1924            .iter()
1925            .filter(|(f, ..)| f.as_ref() == "a.php")
1926            .count();
1927        assert_eq!(
1928            count, 1,
1929            "replaying the same location twice must not create duplicate spans"
1930        );
1931    }
1932
1933    // -----------------------------------------------------------------------
1934    // inject_stub_slice — correctness-critical tests
1935    // -----------------------------------------------------------------------
1936
1937    fn make_fn(fqn: &str, short_name: &str) -> crate::storage::FunctionStorage {
1938        crate::storage::FunctionStorage {
1939            fqn: Arc::from(fqn),
1940            short_name: Arc::from(short_name),
1941            params: vec![],
1942            return_type: None,
1943            inferred_return_type: None,
1944            template_params: vec![],
1945            assertions: vec![],
1946            throws: vec![],
1947            deprecated: None,
1948            is_pure: false,
1949            location: None,
1950        }
1951    }
1952
1953    #[test]
1954    fn inject_stub_slice_later_injection_overwrites_earlier() {
1955        let cb = Codebase::new();
1956
1957        cb.inject_stub_slice(crate::storage::StubSlice {
1958            functions: vec![make_fn("strlen", "phpstorm_version")],
1959            file: Some(Arc::from("phpstorm/standard.php")),
1960            ..Default::default()
1961        });
1962        assert_eq!(
1963            cb.functions.get("strlen").unwrap().short_name.as_ref(),
1964            "phpstorm_version"
1965        );
1966
1967        cb.inject_stub_slice(crate::storage::StubSlice {
1968            functions: vec![make_fn("strlen", "custom_version")],
1969            file: Some(Arc::from("stubs/standard/basic.php")),
1970            ..Default::default()
1971        });
1972
1973        assert_eq!(
1974            cb.functions.get("strlen").unwrap().short_name.as_ref(),
1975            "custom_version",
1976            "custom stub must overwrite phpstorm stub"
1977        );
1978        assert_eq!(
1979            cb.symbol_to_file.get("strlen").unwrap().as_ref(),
1980            "stubs/standard/basic.php",
1981            "symbol_to_file must point to the overriding file"
1982        );
1983    }
1984
1985    #[test]
1986    fn inject_stub_slice_constants_not_added_to_symbol_to_file() {
1987        let cb = Codebase::new();
1988
1989        cb.inject_stub_slice(crate::storage::StubSlice {
1990            constants: vec![(Arc::from("PHP_EOL"), mir_types::Union::empty())],
1991            file: Some(Arc::from("stubs/core/constants.php")),
1992            ..Default::default()
1993        });
1994
1995        assert!(
1996            cb.constants.contains_key("PHP_EOL"),
1997            "constant must be registered in constants map"
1998        );
1999        assert!(
2000            !cb.symbol_to_file.contains_key("PHP_EOL"),
2001            "constants must not appear in symbol_to_file — go-to-definition is not supported for them"
2002        );
2003    }
2004
2005    #[test]
2006    fn remove_file_definitions_purges_injected_global_vars() {
2007        let cb = Codebase::new();
2008
2009        cb.inject_stub_slice(crate::storage::StubSlice {
2010            global_vars: vec![(Arc::from("db_connection"), mir_types::Union::empty())],
2011            file: Some(Arc::from("src/bootstrap.php")),
2012            ..Default::default()
2013        });
2014        assert!(
2015            cb.global_vars.contains_key("db_connection"),
2016            "global var must be registered after injection"
2017        );
2018
2019        cb.remove_file_definitions("src/bootstrap.php");
2020
2021        assert!(
2022            !cb.global_vars.contains_key("db_connection"),
2023            "global var must be removed when its defining file is removed"
2024        );
2025    }
2026
2027    #[test]
2028    fn inject_stub_slice_without_file_discards_global_vars() {
2029        let cb = Codebase::new();
2030
2031        cb.inject_stub_slice(crate::storage::StubSlice {
2032            global_vars: vec![(Arc::from("orphan_var"), mir_types::Union::empty())],
2033            file: None,
2034            ..Default::default()
2035        });
2036
2037        assert!(
2038            !cb.global_vars.contains_key("orphan_var"),
2039            "global_vars must not be registered when slice.file is None"
2040        );
2041    }
2042
2043    // These three tests guard the StubSlice → file_namespaces / file_imports contract.
2044    //
2045    // Background: inject_stub_slice is the only write path used by both
2046    // collect() (the normal project-analysis path) and collect_slice +
2047    // inject_stub_slice (the salsa/LSP incremental path and re_analyze_file).
2048    // Prior to the fix, inject_stub_slice never wrote file_namespaces or
2049    // file_imports, so any consumer that skipped the separate project.rs AST
2050    // walk ended up with empty maps and produced false UndefinedClass
2051    // diagnostics for use-aliased classes.
2052
2053    #[test]
2054    fn inject_stub_slice_populates_file_namespace() {
2055        // A slice with a namespace must cause file_namespaces to be populated
2056        // for that file so that StatementsAnalyzer can resolve unqualified names
2057        // against the correct namespace during Pass 2.
2058        let cb = Codebase::new();
2059        cb.inject_stub_slice(crate::storage::StubSlice {
2060            file: Some(Arc::from("src/Service.php")),
2061            namespace: Some(Arc::from("App\\Service")),
2062            ..Default::default()
2063        });
2064        assert_eq!(
2065            cb.file_namespaces
2066                .get("src/Service.php")
2067                .as_deref()
2068                .map(|s| s.as_str()),
2069            Some("App\\Service"),
2070            "file_namespaces must be populated when slice carries a namespace"
2071        );
2072
2073        // file=Some but namespace=None must not create a spurious entry.
2074        let cb2 = Codebase::new();
2075        cb2.inject_stub_slice(crate::storage::StubSlice {
2076            file: Some(Arc::from("src/global.php")),
2077            namespace: None,
2078            ..Default::default()
2079        });
2080        assert!(
2081            cb2.file_namespaces.is_empty(),
2082            "file_namespaces must not be written when slice.namespace is None"
2083        );
2084    }
2085
2086    #[test]
2087    fn inject_stub_slice_populates_file_imports() {
2088        // A slice with use-alias imports must cause file_imports to be
2089        // populated so that StatementsAnalyzer can resolve aliased short names
2090        // (e.g. `new Entity()` where `use App\Model\Entity` is in scope).
2091        let cb = Codebase::new();
2092        let mut imports = std::collections::HashMap::new();
2093        imports.insert("Entity".to_string(), "App\\Model\\Entity".to_string());
2094        imports.insert(
2095            "Repo".to_string(),
2096            "App\\Repository\\EntityRepo".to_string(),
2097        );
2098        cb.inject_stub_slice(crate::storage::StubSlice {
2099            file: Some(Arc::from("src/Handler.php")),
2100            imports,
2101            ..Default::default()
2102        });
2103        let stored = cb.file_imports.get("src/Handler.php").unwrap();
2104        assert_eq!(
2105            stored.get("Entity").map(|s| s.as_str()),
2106            Some("App\\Model\\Entity")
2107        );
2108        assert_eq!(
2109            stored.get("Repo").map(|s| s.as_str()),
2110            Some("App\\Repository\\EntityRepo")
2111        );
2112
2113        // file=Some but empty imports must not create a spurious entry.
2114        let cb2 = Codebase::new();
2115        cb2.inject_stub_slice(crate::storage::StubSlice {
2116            file: Some(Arc::from("src/no_imports.php")),
2117            imports: std::collections::HashMap::new(),
2118            ..Default::default()
2119        });
2120        assert!(
2121            cb2.file_imports.is_empty(),
2122            "file_imports must not be written when slice.imports is empty"
2123        );
2124    }
2125
2126    #[test]
2127    fn inject_stub_slice_skips_namespace_and_imports_when_no_file() {
2128        // Bundled stub slices (file = None) must never pollute file_namespaces
2129        // or file_imports — those maps are keyed by on-disk path and only make
2130        // sense for slices that represent a specific source file.
2131        let cb = Codebase::new();
2132        let mut imports = std::collections::HashMap::new();
2133        imports.insert("Foo".to_string(), "Bar\\Foo".to_string());
2134        cb.inject_stub_slice(crate::storage::StubSlice {
2135            file: None,
2136            namespace: Some(Arc::from("Bar")),
2137            imports,
2138            ..Default::default()
2139        });
2140        assert!(
2141            cb.file_namespaces.is_empty(),
2142            "file_namespaces must not be written when slice.file is None"
2143        );
2144        assert!(
2145            cb.file_imports.is_empty(),
2146            "file_imports must not be written when slice.file is None"
2147        );
2148    }
2149
2150    #[test]
2151    fn remove_file_definitions_purges_file_namespaces_and_imports() {
2152        // remove_file_definitions and inject_stub_slice form a round-trip:
2153        // remove clears, inject refills. This test guards the remove half for
2154        // file_namespaces and file_imports — symmetric to
2155        // remove_file_definitions_purges_injected_global_vars which guards
2156        // the same round-trip for global_vars.
2157        let cb = Codebase::new();
2158        let mut imports = std::collections::HashMap::new();
2159        imports.insert("Entity".to_string(), "App\\Model\\Entity".to_string());
2160        cb.inject_stub_slice(crate::storage::StubSlice {
2161            file: Some(Arc::from("src/Handler.php")),
2162            namespace: Some(Arc::from("App\\Service")),
2163            imports,
2164            ..Default::default()
2165        });
2166        assert!(
2167            cb.file_namespaces.contains_key("src/Handler.php"),
2168            "setup: namespace must be present"
2169        );
2170        assert!(
2171            cb.file_imports.contains_key("src/Handler.php"),
2172            "setup: imports must be present"
2173        );
2174
2175        cb.remove_file_definitions("src/Handler.php");
2176
2177        assert!(
2178            !cb.file_namespaces.contains_key("src/Handler.php"),
2179            "file_namespaces entry must be removed when its defining file is removed"
2180        );
2181        assert!(
2182            !cb.file_imports.contains_key("src/Handler.php"),
2183            "file_imports entry must be removed when its defining file is removed"
2184        );
2185    }
2186
2187    // -----------------------------------------------------------------------
2188    // get_method / get_property — mixin cycle guards
2189    // -----------------------------------------------------------------------
2190
2191    fn bare_class(fqcn: &str, mixins: Vec<Arc<str>>) -> ClassStorage {
2192        use indexmap::IndexMap;
2193        ClassStorage {
2194            fqcn: arc(fqcn),
2195            short_name: arc(fqcn),
2196            parent: None,
2197            interfaces: vec![],
2198            traits: vec![],
2199            own_methods: IndexMap::new(),
2200            own_properties: IndexMap::new(),
2201            own_constants: IndexMap::new(),
2202            mixins,
2203            template_params: vec![],
2204            extends_type_args: vec![],
2205            implements_type_args: vec![],
2206            is_abstract: false,
2207            is_final: false,
2208            is_readonly: false,
2209            all_parents: vec![],
2210            deprecated: None,
2211            is_internal: false,
2212            location: None,
2213            type_aliases: std::collections::HashMap::new(),
2214            pending_import_types: vec![],
2215        }
2216    }
2217
2218    fn class_with_method(fqcn: &str, method_name: &str, mixins: Vec<Arc<str>>) -> ClassStorage {
2219        use crate::storage::{MethodStorage, Visibility};
2220        use indexmap::IndexMap;
2221        let mut methods = IndexMap::new();
2222        methods.insert(
2223            arc(method_name),
2224            Arc::new(MethodStorage {
2225                name: arc(method_name),
2226                fqcn: arc(fqcn),
2227                params: vec![],
2228                return_type: None,
2229                inferred_return_type: None,
2230                visibility: Visibility::Public,
2231                is_static: false,
2232                is_abstract: false,
2233                is_final: false,
2234                is_constructor: false,
2235                template_params: vec![],
2236                assertions: vec![],
2237                throws: vec![],
2238                deprecated: None,
2239                is_internal: false,
2240                is_pure: false,
2241                location: None,
2242            }),
2243        );
2244        let mut cls = bare_class(fqcn, mixins);
2245        cls.own_methods = methods;
2246        cls
2247    }
2248
2249    fn class_with_property(fqcn: &str, prop_name: &str, mixins: Vec<Arc<str>>) -> ClassStorage {
2250        use crate::storage::{PropertyStorage, Visibility};
2251        use indexmap::IndexMap;
2252        let mut props = IndexMap::new();
2253        props.insert(
2254            arc(prop_name),
2255            PropertyStorage {
2256                name: arc(prop_name),
2257                ty: None,
2258                inferred_ty: None,
2259                visibility: Visibility::Public,
2260                is_static: false,
2261                is_readonly: false,
2262                default: None,
2263                location: None,
2264            },
2265        );
2266        let mut cls = bare_class(fqcn, mixins);
2267        cls.own_properties = props;
2268        cls
2269    }
2270
2271    #[test]
2272    fn get_method_two_way_mixin_cycle_returns_none() {
2273        let cb = Codebase::new();
2274        cb.classes.insert(arc("A"), bare_class("A", vec![arc("B")]));
2275        cb.classes.insert(arc("B"), bare_class("B", vec![arc("A")]));
2276        assert!(cb.get_method("A", "missing").is_none());
2277    }
2278
2279    #[test]
2280    fn get_method_self_mixin_returns_none() {
2281        let cb = Codebase::new();
2282        cb.classes.insert(arc("A"), bare_class("A", vec![arc("A")]));
2283        assert!(cb.get_method("A", "missing").is_none());
2284    }
2285
2286    #[test]
2287    fn get_method_three_way_cycle_returns_none() {
2288        let cb = Codebase::new();
2289        cb.classes.insert(arc("A"), bare_class("A", vec![arc("B")]));
2290        cb.classes.insert(arc("B"), bare_class("B", vec![arc("C")]));
2291        cb.classes.insert(arc("C"), bare_class("C", vec![arc("A")]));
2292        assert!(cb.get_method("A", "missing").is_none());
2293    }
2294
2295    #[test]
2296    fn get_method_resolves_through_mixin_when_no_cycle() {
2297        let cb = Codebase::new();
2298        cb.classes.insert(arc("A"), bare_class("A", vec![arc("B")]));
2299        cb.classes
2300            .insert(arc("B"), class_with_method("B", "fromB", vec![]));
2301        assert!(cb.get_method("A", "fromB").is_some());
2302    }
2303
2304    #[test]
2305    fn get_method_own_method_shadows_mixin() {
2306        let cb = Codebase::new();
2307        cb.classes
2308            .insert(arc("A"), class_with_method("A", "foo", vec![arc("B")]));
2309        cb.classes
2310            .insert(arc("B"), class_with_method("B", "foo", vec![]));
2311        let m = cb.get_method("A", "foo").unwrap();
2312        assert_eq!(m.fqcn.as_ref(), "A");
2313    }
2314
2315    #[test]
2316    fn get_method_mixin_nonexistent_class_returns_none() {
2317        let cb = Codebase::new();
2318        cb.classes
2319            .insert(arc("A"), bare_class("A", vec![arc("Ghost")]));
2320        assert!(cb.get_method("A", "foo").is_none());
2321    }
2322
2323    #[test]
2324    fn get_property_two_way_mixin_cycle_returns_none() {
2325        let cb = Codebase::new();
2326        cb.classes.insert(arc("A"), bare_class("A", vec![arc("B")]));
2327        cb.classes.insert(arc("B"), bare_class("B", vec![arc("A")]));
2328        assert!(cb.get_property("A", "missing").is_none());
2329    }
2330
2331    #[test]
2332    fn get_method_diamond_mixin_finds_method_via_first_path() {
2333        // A @mixin [B, C]; B @mixin D; C @mixin D; D has "foo".
2334        // D is visited once via B and the method is found — C's path to D is
2335        // blocked by the visited set, but the result is still correct.
2336        let cb = Codebase::new();
2337        cb.classes
2338            .insert(arc("A"), bare_class("A", vec![arc("B"), arc("C")]));
2339        cb.classes.insert(arc("B"), bare_class("B", vec![arc("D")]));
2340        cb.classes.insert(arc("C"), bare_class("C", vec![arc("D")]));
2341        cb.classes
2342            .insert(arc("D"), class_with_method("D", "foo", vec![]));
2343        assert!(cb.get_method("A", "foo").is_some());
2344    }
2345
2346    #[test]
2347    fn get_method_mixin_on_ancestor_is_followed() {
2348        let cb = Codebase::new();
2349        cb.classes.insert(
2350            arc("Child"),
2351            ClassStorage {
2352                all_parents: vec![arc("Parent")],
2353                ..bare_class("Child", vec![])
2354            },
2355        );
2356        cb.classes
2357            .insert(arc("Parent"), bare_class("Parent", vec![arc("Mixin")]));
2358        cb.classes.insert(
2359            arc("Mixin"),
2360            class_with_method("Mixin", "fromMixin", vec![]),
2361        );
2362        assert!(cb.get_method("Child", "fromMixin").is_some());
2363        assert!(cb.get_method("Parent", "fromMixin").is_some());
2364    }
2365
2366    #[test]
2367    fn get_method_mixin_on_transitive_ancestor_is_followed() {
2368        let cb = Codebase::new();
2369        // A extends B extends C; C has @mixin D; D has "foo"
2370        cb.classes.insert(
2371            arc("A"),
2372            ClassStorage {
2373                all_parents: vec![arc("B"), arc("C")],
2374                ..bare_class("A", vec![])
2375            },
2376        );
2377        cb.classes.insert(
2378            arc("B"),
2379            ClassStorage {
2380                all_parents: vec![arc("C")],
2381                ..bare_class("B", vec![])
2382            },
2383        );
2384        cb.classes.insert(arc("C"), bare_class("C", vec![arc("D")]));
2385        cb.classes
2386            .insert(arc("D"), class_with_method("D", "foo", vec![]));
2387        assert!(cb.get_method("A", "foo").is_some());
2388    }
2389
2390    #[test]
2391    fn get_property_resolves_through_mixin_when_no_cycle() {
2392        let cb = Codebase::new();
2393        cb.classes.insert(arc("A"), bare_class("A", vec![arc("B")]));
2394        cb.classes
2395            .insert(arc("B"), class_with_property("B", "title", vec![]));
2396        assert!(cb.get_property("A", "title").is_some());
2397    }
2398}