Skip to main content

mir_codebase/
codebase.rs

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