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        // Check direct class own_properties
618        if let Some(cls) = self.classes.get(fqcn) {
619            if let Some(p) = cls.own_properties.get(prop_name) {
620                return Some(p.clone());
621            }
622            let mixins = cls.mixins.clone();
623            drop(cls);
624            for mixin in &mixins {
625                if let Some(p) = self.get_property(mixin.as_ref(), prop_name) {
626                    return Some(p);
627                }
628            }
629        }
630
631        // Walk all ancestors (collected during finalize)
632        let all_parents = {
633            if let Some(cls) = self.classes.get(fqcn) {
634                cls.all_parents.clone()
635            } else {
636                return None;
637            }
638        };
639
640        for ancestor_fqcn in &all_parents {
641            if let Some(ancestor_cls) = self.classes.get(ancestor_fqcn.as_ref()) {
642                if let Some(p) = ancestor_cls.own_properties.get(prop_name) {
643                    return Some(p.clone());
644                }
645            }
646        }
647
648        // Check traits
649        let trait_list = {
650            if let Some(cls) = self.classes.get(fqcn) {
651                cls.traits.clone()
652            } else {
653                vec![]
654            }
655        };
656        for trait_fqcn in &trait_list {
657            if let Some(tr) = self.traits.get(trait_fqcn.as_ref()) {
658                if let Some(p) = tr.own_properties.get(prop_name) {
659                    return Some(p.clone());
660                }
661            }
662        }
663
664        None
665    }
666
667    /// Resolve a class constant by name, walking up the inheritance chain.
668    pub fn get_class_constant(
669        &self,
670        fqcn: &str,
671        const_name: &str,
672    ) -> Option<crate::storage::ConstantStorage> {
673        // Class: own → traits → ancestors → interfaces
674        if let Some(cls) = self.classes.get(fqcn) {
675            if let Some(c) = cls.own_constants.get(const_name) {
676                return Some(c.clone());
677            }
678            let all_parents = cls.all_parents.clone();
679            let interfaces = cls.interfaces.clone();
680            let traits = cls.traits.clone();
681            drop(cls);
682
683            for tr_fqcn in &traits {
684                if let Some(tr) = self.traits.get(tr_fqcn.as_ref()) {
685                    if let Some(c) = tr.own_constants.get(const_name) {
686                        return Some(c.clone());
687                    }
688                }
689            }
690
691            for ancestor_fqcn in &all_parents {
692                if let Some(ancestor) = self.classes.get(ancestor_fqcn.as_ref()) {
693                    if let Some(c) = ancestor.own_constants.get(const_name) {
694                        return Some(c.clone());
695                    }
696                }
697                if let Some(iface) = self.interfaces.get(ancestor_fqcn.as_ref()) {
698                    if let Some(c) = iface.own_constants.get(const_name) {
699                        return Some(c.clone());
700                    }
701                }
702            }
703
704            for iface_fqcn in &interfaces {
705                if let Some(iface) = self.interfaces.get(iface_fqcn.as_ref()) {
706                    if let Some(c) = iface.own_constants.get(const_name) {
707                        return Some(c.clone());
708                    }
709                }
710            }
711
712            return None;
713        }
714
715        // Interface: own → parent interfaces
716        if let Some(iface) = self.interfaces.get(fqcn) {
717            if let Some(c) = iface.own_constants.get(const_name) {
718                return Some(c.clone());
719            }
720            let parents = iface.all_parents.clone();
721            drop(iface);
722            for p in &parents {
723                if let Some(parent_iface) = self.interfaces.get(p.as_ref()) {
724                    if let Some(c) = parent_iface.own_constants.get(const_name) {
725                        return Some(c.clone());
726                    }
727                }
728            }
729            return None;
730        }
731
732        // Enum: own constants + cases
733        if let Some(en) = self.enums.get(fqcn) {
734            if let Some(c) = en.own_constants.get(const_name) {
735                return Some(c.clone());
736            }
737            if en.cases.contains_key(const_name) {
738                return Some(crate::storage::ConstantStorage {
739                    name: Arc::from(const_name),
740                    ty: mir_types::Union::mixed(),
741                    visibility: None,
742                    is_final: false,
743                    location: None,
744                });
745            }
746            return None;
747        }
748
749        // Trait: own constants only
750        if let Some(tr) = self.traits.get(fqcn) {
751            if let Some(c) = tr.own_constants.get(const_name) {
752                return Some(c.clone());
753            }
754            return None;
755        }
756
757        None
758    }
759
760    /// Resolve a method, walking up the full inheritance chain (own → traits → ancestors).
761    pub fn get_method(&self, fqcn: &str, method_name: &str) -> Option<Arc<MethodStorage>> {
762        // PHP method names are case-insensitive — normalize to lowercase for all lookups.
763        let method_lower = method_name.to_lowercase();
764        let method_name = method_lower.as_str();
765
766        // --- Class: own methods → own traits → ancestor classes/traits/interfaces ---
767        if let Some(cls) = self.classes.get(fqcn) {
768            // 1. Own methods (highest priority)
769            if let Some(m) = lookup_method(&cls.own_methods, method_name) {
770                return Some(Arc::clone(m));
771            }
772            // Collect chain info before dropping the DashMap guard.
773            let own_traits = cls.traits.clone();
774            let ancestors = cls.all_parents.clone();
775            let mixins = cls.mixins.clone();
776            drop(cls);
777
778            // 2. Docblock mixins (delegated magic lookup)
779            for mixin_fqcn in &mixins {
780                if let Some(m) = self.get_method(mixin_fqcn, method_name) {
781                    return Some(m);
782                }
783            }
784
785            // 3. Own trait methods (recursive into trait-of-trait)
786            for tr_fqcn in &own_traits {
787                if let Some(m) = self.get_method_in_trait(tr_fqcn, method_name) {
788                    return Some(m);
789                }
790            }
791
792            // 4. Ancestor chain (all_parents is closest-first: parent, grandparent, …)
793            for ancestor_fqcn in &ancestors {
794                if let Some(anc) = self.classes.get(ancestor_fqcn.as_ref()) {
795                    if let Some(m) = lookup_method(&anc.own_methods, method_name) {
796                        return Some(Arc::clone(m));
797                    }
798                    let anc_traits = anc.traits.clone();
799                    drop(anc);
800                    for tr_fqcn in &anc_traits {
801                        if let Some(m) = self.get_method_in_trait(tr_fqcn, method_name) {
802                            return Some(m);
803                        }
804                    }
805                } else if let Some(iface) = self.interfaces.get(ancestor_fqcn.as_ref()) {
806                    if let Some(m) = lookup_method(&iface.own_methods, method_name) {
807                        let mut ms = (**m).clone();
808                        ms.is_abstract = true;
809                        return Some(Arc::new(ms));
810                    }
811                }
812                // Traits listed in all_parents are already covered via their owning class above.
813            }
814            return None;
815        }
816
817        // --- Interface: own methods + parent interfaces ---
818        if let Some(iface) = self.interfaces.get(fqcn) {
819            if let Some(m) = lookup_method(&iface.own_methods, method_name) {
820                return Some(Arc::clone(m));
821            }
822            let parents = iface.all_parents.clone();
823            drop(iface);
824            for parent_fqcn in &parents {
825                if let Some(parent_iface) = self.interfaces.get(parent_fqcn.as_ref()) {
826                    if let Some(m) = lookup_method(&parent_iface.own_methods, method_name) {
827                        return Some(Arc::clone(m));
828                    }
829                }
830            }
831            return None;
832        }
833
834        // --- Trait (variable annotated with a trait type) ---
835        if let Some(tr) = self.traits.get(fqcn) {
836            if let Some(m) = lookup_method(&tr.own_methods, method_name) {
837                return Some(Arc::clone(m));
838            }
839            return None;
840        }
841
842        // --- Enum ---
843        if let Some(e) = self.enums.get(fqcn) {
844            if let Some(m) = lookup_method(&e.own_methods, method_name) {
845                return Some(Arc::clone(m));
846            }
847            // PHP 8.1 built-in enum methods: cases(), from(), tryFrom()
848            if matches!(method_name, "cases" | "from" | "tryfrom") {
849                return Some(Arc::new(crate::storage::MethodStorage {
850                    fqcn: Arc::from(fqcn),
851                    name: Arc::from(method_name),
852                    params: vec![],
853                    return_type: Some(mir_types::Union::mixed()),
854                    inferred_return_type: None,
855                    visibility: crate::storage::Visibility::Public,
856                    is_static: true,
857                    is_abstract: false,
858                    is_constructor: false,
859                    template_params: vec![],
860                    assertions: vec![],
861                    throws: vec![],
862                    is_final: false,
863                    is_internal: false,
864                    is_pure: false,
865                    deprecated: None,
866                    location: None,
867                }));
868            }
869        }
870
871        None
872    }
873
874    /// Returns true if `child` extends or implements `ancestor` (transitively).
875    pub fn extends_or_implements(&self, child: &str, ancestor: &str) -> bool {
876        if child == ancestor {
877            return true;
878        }
879        if let Some(cls) = self.classes.get(child) {
880            return cls.implements_or_extends(ancestor);
881        }
882        if let Some(iface) = self.interfaces.get(child) {
883            return iface.all_parents.iter().any(|p| p.as_ref() == ancestor);
884        }
885        // Enum: backed enums implicitly implement BackedEnum (and UnitEnum);
886        // pure enums implicitly implement UnitEnum.
887        if let Some(en) = self.enums.get(child) {
888            // Check explicitly declared interfaces (e.g. implements SomeInterface)
889            if en.interfaces.iter().any(|i| i.as_ref() == ancestor) {
890                return true;
891            }
892            // PHP built-in: every enum implements UnitEnum
893            if ancestor == "UnitEnum" || ancestor == "\\UnitEnum" {
894                return true;
895            }
896            // Backed enums implement BackedEnum
897            if (ancestor == "BackedEnum" || ancestor == "\\BackedEnum") && en.scalar_type.is_some()
898            {
899                return true;
900            }
901        }
902        false
903    }
904
905    /// Whether a class/interface/trait/enum with this FQCN exists.
906    pub fn type_exists(&self, fqcn: &str) -> bool {
907        self.classes.contains_key(fqcn)
908            || self.interfaces.contains_key(fqcn)
909            || self.traits.contains_key(fqcn)
910            || self.enums.contains_key(fqcn)
911    }
912
913    pub fn function_exists(&self, fqn: &str) -> bool {
914        self.functions.contains_key(fqn)
915    }
916
917    /// Returns true if the class is declared abstract.
918    /// Used to suppress `UndefinedMethod` on abstract class receivers: the concrete
919    /// subclass is expected to implement the method, matching Psalm errorLevel=3 behaviour.
920    pub fn is_abstract_class(&self, fqcn: &str) -> bool {
921        self.classes.get(fqcn).is_some_and(|c| c.is_abstract)
922    }
923
924    /// Return the declared template params for `fqcn` (class or interface), or
925    /// an empty vec if the type is not found or has no templates.
926    pub fn get_class_template_params(&self, fqcn: &str) -> Vec<crate::storage::TemplateParam> {
927        if let Some(cls) = self.classes.get(fqcn) {
928            return cls.template_params.clone();
929        }
930        if let Some(iface) = self.interfaces.get(fqcn) {
931            return iface.template_params.clone();
932        }
933        if let Some(tr) = self.traits.get(fqcn) {
934            return tr.template_params.clone();
935        }
936        vec![]
937    }
938
939    /// Walk the parent chain collecting template bindings from `@extends` type args.
940    ///
941    /// For `class UserRepo extends BaseRepo` with `@extends BaseRepo<User>`, this returns
942    /// `{ T → User }` where `T` is `BaseRepo`'s declared template parameter.
943    pub fn get_inherited_template_bindings(
944        &self,
945        fqcn: &str,
946    ) -> std::collections::HashMap<Arc<str>, Union> {
947        let mut bindings = std::collections::HashMap::new();
948        let mut current = fqcn.to_string();
949
950        loop {
951            let (parent_fqcn, extends_type_args) = {
952                let cls = match self.classes.get(current.as_str()) {
953                    Some(c) => c,
954                    None => break,
955                };
956                let parent = match &cls.parent {
957                    Some(p) => p.clone(),
958                    None => break,
959                };
960                let args = cls.extends_type_args.clone();
961                (parent, args)
962            };
963
964            if !extends_type_args.is_empty() {
965                let parent_tps = self.get_class_template_params(&parent_fqcn);
966                for (tp, ty) in parent_tps.iter().zip(extends_type_args.iter()) {
967                    bindings
968                        .entry(tp.name.clone())
969                        .or_insert_with(|| ty.clone());
970                }
971            }
972
973            current = parent_fqcn.to_string();
974        }
975
976        bindings
977    }
978
979    /// Returns true if the class (or any ancestor/trait) defines a `__get` magic method.
980    /// Such classes allow arbitrary property access, suppressing UndefinedProperty.
981    pub fn has_magic_get(&self, fqcn: &str) -> bool {
982        self.get_method(fqcn, "__get").is_some()
983    }
984
985    /// Returns true if the class (or any of its ancestors) has a parent/interface/trait
986    /// that is NOT present in the codebase.  Used to suppress `UndefinedMethod` false
987    /// positives: if a method might be inherited from an unscanned external class we
988    /// cannot confirm or deny its existence.
989    ///
990    /// We use the pre-computed `all_parents` list (built during finalization) rather
991    /// than recursive DashMap lookups to avoid potential deadlocks.
992    pub fn has_unknown_ancestor(&self, fqcn: &str) -> bool {
993        // For interfaces: check whether any parent interface is unknown.
994        if let Some(iface) = self.interfaces.get(fqcn) {
995            let parents = iface.all_parents.clone();
996            drop(iface);
997            for p in &parents {
998                if !self.type_exists(p.as_ref()) {
999                    return true;
1000                }
1001            }
1002            return false;
1003        }
1004
1005        // Clone the data we need so the DashMap ref is dropped before any further lookups.
1006        let (parent, interfaces, traits, all_parents) = {
1007            let Some(cls) = self.classes.get(fqcn) else {
1008                return false;
1009            };
1010            (
1011                cls.parent.clone(),
1012                cls.interfaces.clone(),
1013                cls.traits.clone(),
1014                cls.all_parents.clone(),
1015            )
1016        };
1017
1018        // Fast path: check direct parent/interfaces/traits
1019        if let Some(ref p) = parent {
1020            if !self.type_exists(p.as_ref()) {
1021                return true;
1022            }
1023        }
1024        for iface in &interfaces {
1025            if !self.type_exists(iface.as_ref()) {
1026                return true;
1027            }
1028        }
1029        for tr in &traits {
1030            if !self.type_exists(tr.as_ref()) {
1031                return true;
1032            }
1033        }
1034
1035        // Also check the full ancestor chain (pre-computed during finalization)
1036        for ancestor in &all_parents {
1037            if !self.type_exists(ancestor.as_ref()) {
1038                return true;
1039            }
1040        }
1041
1042        false
1043    }
1044
1045    /// Resolve a short class/function name to its FQCN using the import table
1046    /// and namespace recorded for `file` during Pass 1.
1047    ///
1048    /// - Names already containing `\` (after stripping a leading `\`) are
1049    ///   returned as-is (already fully qualified).
1050    /// - `self`, `parent`, `static` are returned unchanged (caller handles them).
1051    pub fn resolve_class_name(&self, file: &str, name: &str) -> String {
1052        let name = name.trim_start_matches('\\');
1053        if name.is_empty() {
1054            return name.to_string();
1055        }
1056        // Fully qualified absolute paths start with '\' (already stripped above).
1057        // Names containing '\' but not starting with it may be:
1058        //   - Already-resolved FQCNs (e.g. Frontify\Util\Foo) — check type_exists
1059        //   - Qualified relative names (e.g. Option\Some from within Frontify\Utility) — need namespace prefix
1060        if name.contains('\\') {
1061            // Check if the leading segment matches a use-import alias
1062            let first_segment = name.split('\\').next().unwrap_or(name);
1063            if let Some(imports) = self.file_imports.get(file) {
1064                if let Some(resolved_prefix) = imports.get(first_segment) {
1065                    let rest = &name[first_segment.len()..]; // includes leading '\'
1066                    return format!("{resolved_prefix}{rest}");
1067                }
1068            }
1069            // If already known in codebase as-is, it's FQCN — trust it
1070            if self.type_exists(name) {
1071                return name.to_string();
1072            }
1073            // Otherwise it's a relative qualified name — prepend the file namespace
1074            if let Some(ns) = self.file_namespaces.get(file) {
1075                let qualified = format!("{}\\{}", *ns, name);
1076                if self.type_exists(&qualified) {
1077                    return qualified;
1078                }
1079            }
1080            return name.to_string();
1081        }
1082        // Built-in pseudo-types / keywords handled by the caller
1083        match name {
1084            "self" | "parent" | "static" | "this" => return name.to_string(),
1085            _ => {}
1086        }
1087        // Check use aliases for this file (PHP class names are case-insensitive)
1088        if let Some(imports) = self.file_imports.get(file) {
1089            if let Some(resolved) = imports.get(name) {
1090                return resolved.clone();
1091            }
1092            // Fall back to case-insensitive alias lookup
1093            let name_lower = name.to_lowercase();
1094            for (alias, resolved) in imports.iter() {
1095                if alias.to_lowercase() == name_lower {
1096                    return resolved.clone();
1097                }
1098            }
1099        }
1100        // Qualify with the file's namespace if one exists
1101        if let Some(ns) = self.file_namespaces.get(file) {
1102            let qualified = format!("{}\\{}", *ns, name);
1103            // If the namespaced version exists in the codebase, use it.
1104            // Otherwise fall back to the global (unqualified) name if that exists.
1105            // This handles `DateTimeInterface`, `Exception`, etc. used without import
1106            // while not overriding user-defined classes in namespaces.
1107            if self.type_exists(&qualified) {
1108                return qualified;
1109            }
1110            if self.type_exists(name) {
1111                return name.to_string();
1112            }
1113            return qualified;
1114        }
1115        name.to_string()
1116    }
1117
1118    // -----------------------------------------------------------------------
1119    // Definition location lookups
1120    // -----------------------------------------------------------------------
1121
1122    /// Look up the definition location of any symbol (class, interface, trait, enum, function).
1123    /// Returns the file path and byte offsets.
1124    pub fn get_symbol_location(&self, fqcn: &str) -> Option<crate::storage::Location> {
1125        if let Some(cls) = self.classes.get(fqcn) {
1126            return cls.location.clone();
1127        }
1128        if let Some(iface) = self.interfaces.get(fqcn) {
1129            return iface.location.clone();
1130        }
1131        if let Some(tr) = self.traits.get(fqcn) {
1132            return tr.location.clone();
1133        }
1134        if let Some(en) = self.enums.get(fqcn) {
1135            return en.location.clone();
1136        }
1137        if let Some(func) = self.functions.get(fqcn) {
1138            return func.location.clone();
1139        }
1140        None
1141    }
1142
1143    /// Look up the definition location of a class member (method, property, constant).
1144    pub fn get_member_location(
1145        &self,
1146        fqcn: &str,
1147        member_name: &str,
1148    ) -> Option<crate::storage::Location> {
1149        // Check methods
1150        if let Some(method) = self.get_method(fqcn, member_name) {
1151            return method.location.clone();
1152        }
1153        // Check properties
1154        if let Some(prop) = self.get_property(fqcn, member_name) {
1155            return prop.location.clone();
1156        }
1157        // Check class constants
1158        if let Some(cls) = self.classes.get(fqcn) {
1159            if let Some(c) = cls.own_constants.get(member_name) {
1160                return c.location.clone();
1161            }
1162        }
1163        // Check interface constants
1164        if let Some(iface) = self.interfaces.get(fqcn) {
1165            if let Some(c) = iface.own_constants.get(member_name) {
1166                return c.location.clone();
1167            }
1168        }
1169        // Check trait constants
1170        if let Some(tr) = self.traits.get(fqcn) {
1171            if let Some(c) = tr.own_constants.get(member_name) {
1172                return c.location.clone();
1173            }
1174        }
1175        // Check enum constants and cases
1176        if let Some(en) = self.enums.get(fqcn) {
1177            if let Some(c) = en.own_constants.get(member_name) {
1178                return c.location.clone();
1179            }
1180            if let Some(case) = en.cases.get(member_name) {
1181                return case.location.clone();
1182            }
1183        }
1184        None
1185    }
1186
1187    // -----------------------------------------------------------------------
1188    // Reference tracking (M18 dead-code detection)
1189    // -----------------------------------------------------------------------
1190
1191    /// Mark a method as referenced from user code.
1192    pub fn mark_method_referenced(&self, fqcn: &str, method_name: &str) {
1193        let key = format!("{}::{}", fqcn, method_name.to_lowercase());
1194        let id = self.symbol_interner.intern_str(&key);
1195        self.referenced_methods.insert(id);
1196    }
1197
1198    /// Mark a property as referenced from user code.
1199    pub fn mark_property_referenced(&self, fqcn: &str, prop_name: &str) {
1200        let key = format!("{fqcn}::{prop_name}");
1201        let id = self.symbol_interner.intern_str(&key);
1202        self.referenced_properties.insert(id);
1203    }
1204
1205    /// Mark a free function as referenced from user code.
1206    pub fn mark_function_referenced(&self, fqn: &str) {
1207        let id = self.symbol_interner.intern_str(fqn);
1208        self.referenced_functions.insert(id);
1209    }
1210
1211    pub fn is_method_referenced(&self, fqcn: &str, method_name: &str) -> bool {
1212        let key = format!("{}::{}", fqcn, method_name.to_lowercase());
1213        match self.symbol_interner.get_id(&key) {
1214            Some(id) => self.referenced_methods.contains(&id),
1215            None => false,
1216        }
1217    }
1218
1219    pub fn is_property_referenced(&self, fqcn: &str, prop_name: &str) -> bool {
1220        let key = format!("{fqcn}::{prop_name}");
1221        match self.symbol_interner.get_id(&key) {
1222            Some(id) => self.referenced_properties.contains(&id),
1223            None => false,
1224        }
1225    }
1226
1227    pub fn is_function_referenced(&self, fqn: &str) -> bool {
1228        match self.symbol_interner.get_id(fqn) {
1229            Some(id) => self.referenced_functions.contains(&id),
1230            None => false,
1231        }
1232    }
1233
1234    /// Record a method reference with its source location.
1235    /// Also updates the referenced_methods DashSet for dead-code detection.
1236    pub fn mark_method_referenced_at(
1237        &self,
1238        fqcn: &str,
1239        method_name: &str,
1240        file: Arc<str>,
1241        start: u32,
1242        end: u32,
1243    ) {
1244        let key = format!("{}::{}", fqcn, method_name.to_lowercase());
1245        self.ensure_expanded();
1246        let sym_id = self.symbol_interner.intern_str(&key);
1247        let file_id = self.file_interner.intern(file);
1248        self.referenced_methods.insert(sym_id);
1249        record_ref(
1250            &self.symbol_reference_locations,
1251            &self.file_symbol_references,
1252            sym_id,
1253            file_id,
1254            start,
1255            end,
1256        );
1257    }
1258
1259    /// Record a property reference with its source location.
1260    /// Also updates the referenced_properties DashSet for dead-code detection.
1261    pub fn mark_property_referenced_at(
1262        &self,
1263        fqcn: &str,
1264        prop_name: &str,
1265        file: Arc<str>,
1266        start: u32,
1267        end: u32,
1268    ) {
1269        let key = format!("{fqcn}::{prop_name}");
1270        self.ensure_expanded();
1271        let sym_id = self.symbol_interner.intern_str(&key);
1272        let file_id = self.file_interner.intern(file);
1273        self.referenced_properties.insert(sym_id);
1274        record_ref(
1275            &self.symbol_reference_locations,
1276            &self.file_symbol_references,
1277            sym_id,
1278            file_id,
1279            start,
1280            end,
1281        );
1282    }
1283
1284    /// Record a function reference with its source location.
1285    /// Also updates the referenced_functions DashSet for dead-code detection.
1286    pub fn mark_function_referenced_at(&self, fqn: &str, file: Arc<str>, start: u32, end: u32) {
1287        self.ensure_expanded();
1288        let sym_id = self.symbol_interner.intern_str(fqn);
1289        let file_id = self.file_interner.intern(file);
1290        self.referenced_functions.insert(sym_id);
1291        record_ref(
1292            &self.symbol_reference_locations,
1293            &self.file_symbol_references,
1294            sym_id,
1295            file_id,
1296            start,
1297            end,
1298        );
1299    }
1300
1301    /// Record a class reference (e.g. `new Foo()`) with its source location.
1302    /// Does not update any dead-code DashSet — class instantiation tracking is
1303    /// separate from method/property/function dead-code detection.
1304    pub fn mark_class_referenced_at(&self, fqcn: &str, file: Arc<str>, start: u32, end: u32) {
1305        self.ensure_expanded();
1306        let sym_id = self.symbol_interner.intern_str(fqcn);
1307        let file_id = self.file_interner.intern(file);
1308        record_ref(
1309            &self.symbol_reference_locations,
1310            &self.file_symbol_references,
1311            sym_id,
1312            file_id,
1313            start,
1314            end,
1315        );
1316    }
1317
1318    /// Replay cached reference locations for a file into the reference index.
1319    /// Called on cache hits to avoid re-running Pass 2 just to rebuild the index.
1320    /// `locs` is a slice of `(symbol_key, start_byte, end_byte)` as stored in the cache.
1321    pub fn replay_reference_locations(&self, file: Arc<str>, locs: &[(String, u32, u32)]) {
1322        if locs.is_empty() {
1323            return;
1324        }
1325        self.ensure_expanded();
1326        let file_id = self.file_interner.intern(file);
1327        for (symbol_key, start, end) in locs {
1328            let sym_id = self.symbol_interner.intern_str(symbol_key);
1329            record_ref(
1330                &self.symbol_reference_locations,
1331                &self.file_symbol_references,
1332                sym_id,
1333                file_id,
1334                *start,
1335                *end,
1336            );
1337        }
1338    }
1339
1340    /// Return all reference locations for `symbol` as a flat `Vec<(file, start, end)>`.
1341    /// Returns an empty Vec if the symbol has no recorded references.
1342    pub fn get_reference_locations(&self, symbol: &str) -> Vec<(Arc<str>, u32, u32)> {
1343        let Some(sym_id) = self.symbol_interner.get_id(symbol) else {
1344            return Vec::new();
1345        };
1346        // Fast path: compact CSR index.
1347        if let Some(ref ci) = *self.compact_ref_index.read().unwrap() {
1348            let id = sym_id as usize;
1349            if id + 1 >= ci.sym_offsets.len() {
1350                return Vec::new();
1351            }
1352            let start = ci.sym_offsets[id] as usize;
1353            let end = ci.sym_offsets[id + 1] as usize;
1354            return ci.entries[start..end]
1355                .iter()
1356                .map(|&(_, file_id, s, e)| (self.file_interner.get(file_id), s, e))
1357                .collect();
1358        }
1359        // Slow path: build-phase DashMap.
1360        let Some(entries) = self.symbol_reference_locations.get(&sym_id) else {
1361            return Vec::new();
1362        };
1363        entries
1364            .iter()
1365            .map(|&(file_id, start, end)| (self.file_interner.get(file_id), start, end))
1366            .collect()
1367    }
1368
1369    /// Extract all reference locations recorded for `file` as `(symbol_key, start, end)` triples.
1370    /// Used by the cache layer to persist per-file reference data between runs.
1371    pub fn extract_file_reference_locations(&self, file: &str) -> Vec<(Arc<str>, u32, u32)> {
1372        let Some(file_id) = self.file_interner.get_id(file) else {
1373            return Vec::new();
1374        };
1375        // Fast path: compact CSR index.
1376        if let Some(ref ci) = *self.compact_ref_index.read().unwrap() {
1377            let id = file_id as usize;
1378            if id + 1 >= ci.file_offsets.len() {
1379                return Vec::new();
1380            }
1381            let start = ci.file_offsets[id] as usize;
1382            let end = ci.file_offsets[id + 1] as usize;
1383            return ci.by_file[start..end]
1384                .iter()
1385                .map(|&entry_idx| {
1386                    let (sym_id, _, s, e) = ci.entries[entry_idx as usize];
1387                    (self.symbol_interner.get(sym_id), s, e)
1388                })
1389                .collect();
1390        }
1391        // Slow path: build-phase DashMaps.
1392        let Some(sym_ids) = self.file_symbol_references.get(&file_id) else {
1393            return Vec::new();
1394        };
1395        let mut out = Vec::new();
1396        for &sym_id in sym_ids.iter() {
1397            let Some(entries) = self.symbol_reference_locations.get(&sym_id) else {
1398                continue;
1399            };
1400            let sym_key = self.symbol_interner.get(sym_id);
1401            for &(entry_file_id, start, end) in entries.iter() {
1402                if entry_file_id == file_id {
1403                    out.push((sym_key.clone(), start, end));
1404                }
1405            }
1406        }
1407        out
1408    }
1409
1410    /// Returns true if the given file has any recorded symbol references.
1411    pub fn file_has_symbol_references(&self, file: &str) -> bool {
1412        let Some(file_id) = self.file_interner.get_id(file) else {
1413            return false;
1414        };
1415        // Check compact index first.
1416        if let Some(ref ci) = *self.compact_ref_index.read().unwrap() {
1417            let id = file_id as usize;
1418            return id + 1 < ci.file_offsets.len() && ci.file_offsets[id] < ci.file_offsets[id + 1];
1419        }
1420        self.file_symbol_references.contains_key(&file_id)
1421    }
1422
1423    // -----------------------------------------------------------------------
1424    // Finalization
1425    // -----------------------------------------------------------------------
1426
1427    /// Must be called after all files have been parsed (pass 1 complete).
1428    /// Resolves inheritance chains and builds method dispatch tables.
1429    pub fn finalize(&self) {
1430        if self.finalized.load(std::sync::atomic::Ordering::SeqCst) {
1431            return;
1432        }
1433
1434        // 1. Resolve all_parents for classes
1435        let class_keys: Vec<Arc<str>> = self.classes.iter().map(|e| e.key().clone()).collect();
1436        for fqcn in &class_keys {
1437            let parents = self.collect_class_ancestors(fqcn);
1438            if let Some(mut cls) = self.classes.get_mut(fqcn.as_ref()) {
1439                cls.all_parents = parents;
1440            }
1441        }
1442
1443        // 2. Resolve all_parents for interfaces
1444        let iface_keys: Vec<Arc<str>> = self.interfaces.iter().map(|e| e.key().clone()).collect();
1445        for fqcn in &iface_keys {
1446            let parents = self.collect_interface_ancestors(fqcn);
1447            if let Some(mut iface) = self.interfaces.get_mut(fqcn.as_ref()) {
1448                iface.all_parents = parents;
1449            }
1450        }
1451
1452        // 3. Resolve @psalm-import-type declarations
1453        // Collect imports first to avoid holding two locks simultaneously.
1454        type PendingImports = Vec<(Arc<str>, Vec<(Arc<str>, Arc<str>, Arc<str>)>)>;
1455        let pending: PendingImports = self
1456            .classes
1457            .iter()
1458            .filter(|e| !e.pending_import_types.is_empty())
1459            .map(|e| (e.key().clone(), e.pending_import_types.clone()))
1460            .collect();
1461        for (dst_fqcn, imports) in pending {
1462            let mut resolved: std::collections::HashMap<Arc<str>, mir_types::Union> =
1463                std::collections::HashMap::new();
1464            for (local, original, from_class) in &imports {
1465                if let Some(src_cls) = self.classes.get(from_class.as_ref()) {
1466                    if let Some(ty) = src_cls.type_aliases.get(original.as_ref()) {
1467                        resolved.insert(local.clone(), ty.clone());
1468                    }
1469                }
1470            }
1471            if !resolved.is_empty() {
1472                if let Some(mut dst_cls) = self.classes.get_mut(dst_fqcn.as_ref()) {
1473                    for (k, v) in resolved {
1474                        dst_cls.type_aliases.insert(k, v);
1475                    }
1476                }
1477            }
1478        }
1479
1480        self.finalized
1481            .store(true, std::sync::atomic::Ordering::SeqCst);
1482    }
1483
1484    // -----------------------------------------------------------------------
1485    // Private helpers
1486    // -----------------------------------------------------------------------
1487
1488    /// Look up `method_name` in a trait's own methods, then recursively in any
1489    /// traits that the trait itself uses (`use OtherTrait;` inside a trait body).
1490    /// A visited set prevents infinite loops on pathological mutual trait use.
1491    fn get_method_in_trait(
1492        &self,
1493        tr_fqcn: &Arc<str>,
1494        method_name: &str,
1495    ) -> Option<Arc<MethodStorage>> {
1496        let mut visited = std::collections::HashSet::new();
1497        self.get_method_in_trait_inner(tr_fqcn, method_name, &mut visited)
1498    }
1499
1500    fn get_method_in_trait_inner(
1501        &self,
1502        tr_fqcn: &Arc<str>,
1503        method_name: &str,
1504        visited: &mut std::collections::HashSet<String>,
1505    ) -> Option<Arc<MethodStorage>> {
1506        if !visited.insert(tr_fqcn.to_string()) {
1507            return None; // cycle guard
1508        }
1509        let tr = self.traits.get(tr_fqcn.as_ref())?;
1510        if let Some(m) = lookup_method(&tr.own_methods, method_name) {
1511            return Some(Arc::clone(m));
1512        }
1513        let used_traits = tr.traits.clone();
1514        drop(tr);
1515        for used_fqcn in &used_traits {
1516            if let Some(m) = self.get_method_in_trait_inner(used_fqcn, method_name, visited) {
1517                return Some(m);
1518            }
1519        }
1520        None
1521    }
1522
1523    fn collect_class_ancestors(&self, fqcn: &str) -> Vec<Arc<str>> {
1524        let mut result = Vec::new();
1525        let mut visited = std::collections::HashSet::new();
1526        self.collect_class_ancestors_inner(fqcn, &mut result, &mut visited);
1527        result
1528    }
1529
1530    fn collect_class_ancestors_inner(
1531        &self,
1532        fqcn: &str,
1533        out: &mut Vec<Arc<str>>,
1534        visited: &mut std::collections::HashSet<String>,
1535    ) {
1536        if !visited.insert(fqcn.to_string()) {
1537            return; // cycle guard
1538        }
1539        let (parent, interfaces, traits) = {
1540            if let Some(cls) = self.classes.get(fqcn) {
1541                (
1542                    cls.parent.clone(),
1543                    cls.interfaces.clone(),
1544                    cls.traits.clone(),
1545                )
1546            } else {
1547                return;
1548            }
1549        };
1550
1551        if let Some(p) = parent {
1552            out.push(p.clone());
1553            self.collect_class_ancestors_inner(&p, out, visited);
1554        }
1555        for iface in interfaces {
1556            out.push(iface.clone());
1557            self.collect_interface_ancestors_inner(&iface, out, visited);
1558        }
1559        for t in traits {
1560            out.push(t);
1561        }
1562    }
1563
1564    fn collect_interface_ancestors(&self, fqcn: &str) -> Vec<Arc<str>> {
1565        let mut result = Vec::new();
1566        let mut visited = std::collections::HashSet::new();
1567        self.collect_interface_ancestors_inner(fqcn, &mut result, &mut visited);
1568        result
1569    }
1570
1571    fn collect_interface_ancestors_inner(
1572        &self,
1573        fqcn: &str,
1574        out: &mut Vec<Arc<str>>,
1575        visited: &mut std::collections::HashSet<String>,
1576    ) {
1577        if !visited.insert(fqcn.to_string()) {
1578            return;
1579        }
1580        let extends = {
1581            if let Some(iface) = self.interfaces.get(fqcn) {
1582                iface.extends.clone()
1583            } else {
1584                return;
1585            }
1586        };
1587        for e in extends {
1588            out.push(e.clone());
1589            self.collect_interface_ancestors_inner(&e, out, visited);
1590        }
1591    }
1592}
1593
1594// ---------------------------------------------------------------------------
1595// CodebaseBuilder — compose a finalized Codebase from per-file StubSlices
1596// ---------------------------------------------------------------------------
1597
1598/// Incremental builder that accumulates [`crate::storage::StubSlice`] values
1599/// into a fresh [`Codebase`] and finalizes it on demand.
1600///
1601/// Designed for callers (e.g. salsa queries in downstream consumers) that want
1602/// to treat Pass-1 definition collection as a pure function from source to
1603/// `StubSlice`, then compose the slices into a full codebase outside the
1604/// collector.
1605pub struct CodebaseBuilder {
1606    cb: Codebase,
1607}
1608
1609impl CodebaseBuilder {
1610    pub fn new() -> Self {
1611        Self {
1612            cb: Codebase::new(),
1613        }
1614    }
1615
1616    /// Inject a single slice. Later injections overwrite earlier definitions
1617    /// with the same FQN, matching [`Codebase::inject_stub_slice`] semantics.
1618    pub fn add(&mut self, slice: crate::storage::StubSlice) {
1619        self.cb.inject_stub_slice(slice);
1620    }
1621
1622    /// Finalize inheritance graphs and return the built `Codebase`.
1623    pub fn finalize(self) -> Codebase {
1624        self.cb.finalize();
1625        self.cb
1626    }
1627
1628    /// Access the in-progress codebase without consuming the builder.
1629    pub fn codebase(&self) -> &Codebase {
1630        &self.cb
1631    }
1632}
1633
1634impl Default for CodebaseBuilder {
1635    fn default() -> Self {
1636        Self::new()
1637    }
1638}
1639
1640/// One-shot: build a finalized [`Codebase`] from a set of per-file slices.
1641pub fn codebase_from_parts(parts: Vec<crate::storage::StubSlice>) -> Codebase {
1642    let mut b = CodebaseBuilder::new();
1643    for p in parts {
1644        b.add(p);
1645    }
1646    b.finalize()
1647}
1648
1649#[cfg(test)]
1650mod tests {
1651    use super::*;
1652
1653    fn arc(s: &str) -> Arc<str> {
1654        Arc::from(s)
1655    }
1656
1657    #[test]
1658    fn method_referenced_at_groups_spans_by_file() {
1659        let cb = Codebase::new();
1660        cb.mark_method_referenced_at("Foo", "bar", arc("a.php"), 0, 5);
1661        cb.mark_method_referenced_at("Foo", "bar", arc("a.php"), 10, 15);
1662        cb.mark_method_referenced_at("Foo", "bar", arc("b.php"), 20, 25);
1663
1664        let locs = cb.get_reference_locations("Foo::bar");
1665        let files: std::collections::HashSet<&str> =
1666            locs.iter().map(|(f, _, _)| f.as_ref()).collect();
1667        assert_eq!(files.len(), 2, "two files, not three spans");
1668        assert!(locs.contains(&(arc("a.php"), 0, 5)));
1669        assert!(locs.contains(&(arc("a.php"), 10, 15)));
1670        assert_eq!(
1671            locs.iter()
1672                .filter(|(f, _, _)| f.as_ref() == "a.php")
1673                .count(),
1674            2
1675        );
1676        assert!(locs.contains(&(arc("b.php"), 20, 25)));
1677        assert!(
1678            cb.is_method_referenced("Foo", "bar"),
1679            "DashSet also updated"
1680        );
1681    }
1682
1683    #[test]
1684    fn duplicate_spans_are_deduplicated() {
1685        let cb = Codebase::new();
1686        // Same call site recorded twice (e.g. union receiver Foo|Foo)
1687        cb.mark_method_referenced_at("Foo", "bar", arc("a.php"), 0, 5);
1688        cb.mark_method_referenced_at("Foo", "bar", arc("a.php"), 0, 5);
1689
1690        let count = cb
1691            .get_reference_locations("Foo::bar")
1692            .iter()
1693            .filter(|(f, _, _)| f.as_ref() == "a.php")
1694            .count();
1695        assert_eq!(count, 1, "duplicate span deduplicated");
1696    }
1697
1698    #[test]
1699    fn method_key_is_lowercased() {
1700        let cb = Codebase::new();
1701        cb.mark_method_referenced_at("Cls", "MyMethod", arc("f.php"), 0, 3);
1702        assert!(!cb.get_reference_locations("Cls::mymethod").is_empty());
1703    }
1704
1705    #[test]
1706    fn property_referenced_at_records_location() {
1707        let cb = Codebase::new();
1708        cb.mark_property_referenced_at("Bar", "count", arc("x.php"), 5, 10);
1709
1710        assert!(cb
1711            .get_reference_locations("Bar::count")
1712            .contains(&(arc("x.php"), 5, 10)));
1713        assert!(cb.is_property_referenced("Bar", "count"));
1714    }
1715
1716    #[test]
1717    fn function_referenced_at_records_location() {
1718        let cb = Codebase::new();
1719        cb.mark_function_referenced_at("my_fn", arc("a.php"), 10, 15);
1720
1721        assert!(cb
1722            .get_reference_locations("my_fn")
1723            .contains(&(arc("a.php"), 10, 15)));
1724        assert!(cb.is_function_referenced("my_fn"));
1725    }
1726
1727    #[test]
1728    fn class_referenced_at_records_location() {
1729        let cb = Codebase::new();
1730        cb.mark_class_referenced_at("Foo", arc("a.php"), 5, 8);
1731
1732        assert!(cb
1733            .get_reference_locations("Foo")
1734            .contains(&(arc("a.php"), 5, 8)));
1735    }
1736
1737    #[test]
1738    fn get_reference_locations_flattens_all_files() {
1739        let cb = Codebase::new();
1740        cb.mark_function_referenced_at("fn1", arc("a.php"), 0, 5);
1741        cb.mark_function_referenced_at("fn1", arc("b.php"), 10, 15);
1742
1743        let mut locs = cb.get_reference_locations("fn1");
1744        locs.sort_by_key(|(_, s, _)| *s);
1745        assert_eq!(locs.len(), 2);
1746        assert_eq!(locs[0], (arc("a.php"), 0, 5));
1747        assert_eq!(locs[1], (arc("b.php"), 10, 15));
1748    }
1749
1750    #[test]
1751    fn replay_reference_locations_restores_index() {
1752        let cb = Codebase::new();
1753        let locs = vec![
1754            ("Foo::bar".to_string(), 0u32, 5u32),
1755            ("Foo::bar".to_string(), 10, 15),
1756            ("greet".to_string(), 20, 25),
1757        ];
1758        cb.replay_reference_locations(arc("a.php"), &locs);
1759
1760        let bar_locs = cb.get_reference_locations("Foo::bar");
1761        assert!(bar_locs.contains(&(arc("a.php"), 0, 5)));
1762        assert!(bar_locs.contains(&(arc("a.php"), 10, 15)));
1763
1764        assert!(cb
1765            .get_reference_locations("greet")
1766            .contains(&(arc("a.php"), 20, 25)));
1767
1768        assert!(cb.file_has_symbol_references("a.php"));
1769    }
1770
1771    #[test]
1772    fn remove_file_clears_its_spans_only() {
1773        let cb = Codebase::new();
1774        cb.mark_function_referenced_at("fn1", arc("a.php"), 0, 5);
1775        cb.mark_function_referenced_at("fn1", arc("b.php"), 10, 15);
1776
1777        cb.remove_file_definitions("a.php");
1778
1779        let locs = cb.get_reference_locations("fn1");
1780        assert!(
1781            !locs.iter().any(|(f, _, _)| f.as_ref() == "a.php"),
1782            "a.php spans removed"
1783        );
1784        assert!(
1785            locs.contains(&(arc("b.php"), 10, 15)),
1786            "b.php spans untouched"
1787        );
1788        assert!(!cb.file_has_symbol_references("a.php"));
1789    }
1790
1791    #[test]
1792    fn remove_file_does_not_affect_other_files() {
1793        let cb = Codebase::new();
1794        cb.mark_property_referenced_at("Cls", "prop", arc("x.php"), 1, 4);
1795        cb.mark_property_referenced_at("Cls", "prop", arc("y.php"), 7, 10);
1796
1797        cb.remove_file_definitions("x.php");
1798
1799        let locs = cb.get_reference_locations("Cls::prop");
1800        assert!(!locs.iter().any(|(f, _, _)| f.as_ref() == "x.php"));
1801        assert!(locs.contains(&(arc("y.php"), 7, 10)));
1802    }
1803
1804    #[test]
1805    fn remove_file_definitions_on_never_analyzed_file_is_noop() {
1806        let cb = Codebase::new();
1807        cb.mark_function_referenced_at("fn1", arc("a.php"), 0, 5);
1808
1809        // "ghost.php" was never analyzed — removing it must not panic or corrupt state.
1810        cb.remove_file_definitions("ghost.php");
1811
1812        // Existing data must be untouched.
1813        assert!(cb
1814            .get_reference_locations("fn1")
1815            .contains(&(arc("a.php"), 0, 5)));
1816        assert!(!cb.file_has_symbol_references("ghost.php"));
1817    }
1818
1819    #[test]
1820    fn replay_reference_locations_with_empty_list_is_noop() {
1821        let cb = Codebase::new();
1822        cb.mark_function_referenced_at("fn1", arc("a.php"), 0, 5);
1823
1824        // Replaying an empty list must not touch existing entries.
1825        cb.replay_reference_locations(arc("b.php"), &[]);
1826
1827        assert!(
1828            !cb.file_has_symbol_references("b.php"),
1829            "empty replay must not create a file entry"
1830        );
1831        assert!(
1832            cb.get_reference_locations("fn1")
1833                .contains(&(arc("a.php"), 0, 5)),
1834            "existing spans untouched"
1835        );
1836    }
1837
1838    #[test]
1839    fn replay_reference_locations_twice_does_not_duplicate_spans() {
1840        let cb = Codebase::new();
1841        let locs = vec![("fn1".to_string(), 0u32, 5u32)];
1842
1843        cb.replay_reference_locations(arc("a.php"), &locs);
1844        cb.replay_reference_locations(arc("a.php"), &locs);
1845
1846        let count = cb
1847            .get_reference_locations("fn1")
1848            .iter()
1849            .filter(|(f, _, _)| f.as_ref() == "a.php")
1850            .count();
1851        assert_eq!(
1852            count, 1,
1853            "replaying the same location twice must not create duplicate spans"
1854        );
1855    }
1856
1857    // -----------------------------------------------------------------------
1858    // inject_stub_slice — correctness-critical tests
1859    // -----------------------------------------------------------------------
1860
1861    fn make_fn(fqn: &str, short_name: &str) -> crate::storage::FunctionStorage {
1862        crate::storage::FunctionStorage {
1863            fqn: Arc::from(fqn),
1864            short_name: Arc::from(short_name),
1865            params: vec![],
1866            return_type: None,
1867            inferred_return_type: None,
1868            template_params: vec![],
1869            assertions: vec![],
1870            throws: vec![],
1871            deprecated: None,
1872            is_pure: false,
1873            location: None,
1874        }
1875    }
1876
1877    #[test]
1878    fn inject_stub_slice_later_injection_overwrites_earlier() {
1879        let cb = Codebase::new();
1880
1881        cb.inject_stub_slice(crate::storage::StubSlice {
1882            functions: vec![make_fn("strlen", "phpstorm_version")],
1883            file: Some(Arc::from("phpstorm/standard.php")),
1884            ..Default::default()
1885        });
1886        assert_eq!(
1887            cb.functions.get("strlen").unwrap().short_name.as_ref(),
1888            "phpstorm_version"
1889        );
1890
1891        cb.inject_stub_slice(crate::storage::StubSlice {
1892            functions: vec![make_fn("strlen", "custom_version")],
1893            file: Some(Arc::from("stubs/standard/basic.php")),
1894            ..Default::default()
1895        });
1896
1897        assert_eq!(
1898            cb.functions.get("strlen").unwrap().short_name.as_ref(),
1899            "custom_version",
1900            "custom stub must overwrite phpstorm stub"
1901        );
1902        assert_eq!(
1903            cb.symbol_to_file.get("strlen").unwrap().as_ref(),
1904            "stubs/standard/basic.php",
1905            "symbol_to_file must point to the overriding file"
1906        );
1907    }
1908
1909    #[test]
1910    fn inject_stub_slice_constants_not_added_to_symbol_to_file() {
1911        let cb = Codebase::new();
1912
1913        cb.inject_stub_slice(crate::storage::StubSlice {
1914            constants: vec![(Arc::from("PHP_EOL"), mir_types::Union::empty())],
1915            file: Some(Arc::from("stubs/core/constants.php")),
1916            ..Default::default()
1917        });
1918
1919        assert!(
1920            cb.constants.contains_key("PHP_EOL"),
1921            "constant must be registered in constants map"
1922        );
1923        assert!(
1924            !cb.symbol_to_file.contains_key("PHP_EOL"),
1925            "constants must not appear in symbol_to_file — go-to-definition is not supported for them"
1926        );
1927    }
1928
1929    #[test]
1930    fn remove_file_definitions_purges_injected_global_vars() {
1931        let cb = Codebase::new();
1932
1933        cb.inject_stub_slice(crate::storage::StubSlice {
1934            global_vars: vec![(Arc::from("db_connection"), mir_types::Union::empty())],
1935            file: Some(Arc::from("src/bootstrap.php")),
1936            ..Default::default()
1937        });
1938        assert!(
1939            cb.global_vars.contains_key("db_connection"),
1940            "global var must be registered after injection"
1941        );
1942
1943        cb.remove_file_definitions("src/bootstrap.php");
1944
1945        assert!(
1946            !cb.global_vars.contains_key("db_connection"),
1947            "global var must be removed when its defining file is removed"
1948        );
1949    }
1950
1951    #[test]
1952    fn inject_stub_slice_without_file_discards_global_vars() {
1953        let cb = Codebase::new();
1954
1955        cb.inject_stub_slice(crate::storage::StubSlice {
1956            global_vars: vec![(Arc::from("orphan_var"), mir_types::Union::empty())],
1957            file: None,
1958            ..Default::default()
1959        });
1960
1961        assert!(
1962            !cb.global_vars.contains_key("orphan_var"),
1963            "global_vars must not be registered when slice.file is None"
1964        );
1965    }
1966}