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