Skip to main content

mir_codebase/
codebase.rs

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