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    // Compact reference index
218    // -----------------------------------------------------------------------
219
220    /// Convert the build-phase `DashMap` reference index into a compact CSR form.
221    ///
222    /// Call this once after Pass 2 completes on all files. The method:
223    /// 1. Drains the two build-phase `DashMap`s into a single flat `Vec`.
224    /// 2. Sorts and deduplicates entries.
225    /// 3. Builds two CSR offset arrays (by symbol and by file).
226    /// 4. Clears the `DashMap`s (freeing their allocations).
227    ///
228    /// After this call all reference queries use the compact index. Incremental
229    /// re-analysis via [`Self::re_analyze_file`] will automatically decompress the
230    /// index back into `DashMap`s on the first write, then recompact can be called
231    /// again at the end of that analysis pass.
232    pub fn compact_reference_index(&self) {
233        // Collect all entries from the build-phase DashMap.
234        let mut entries: Vec<(u32, u32, u32, u32)> = self
235            .symbol_reference_locations
236            .iter()
237            .flat_map(|entry| {
238                let sym_id = *entry.key();
239                entry
240                    .value()
241                    .iter()
242                    .map(move |&(file_id, start, end)| (sym_id, file_id, start, end))
243                    .collect::<Vec<_>>()
244            })
245            .collect();
246
247        if entries.is_empty() {
248            return;
249        }
250
251        // Sort by (sym_id, file_id, start, end) and drop exact duplicates.
252        entries.sort_unstable();
253        entries.dedup();
254
255        let n = entries.len();
256
257        // ---- Build symbol-keyed CSR offsets --------------------------------
258        let max_sym = entries.iter().map(|&(s, ..)| s).max().unwrap_or(0) as usize;
259        let mut sym_offsets = vec![0u32; max_sym + 2];
260        for &(sym_id, ..) in &entries {
261            sym_offsets[sym_id as usize + 1] += 1;
262        }
263        for i in 1..sym_offsets.len() {
264            sym_offsets[i] += sym_offsets[i - 1];
265        }
266
267        // ---- Build file-keyed indirect index --------------------------------
268        // `by_file[i]` is an index into `entries`; the slice is sorted by
269        // `(file_id, sym_id, start, end)` so CSR offsets can be computed cheaply.
270        let max_file = entries.iter().map(|&(_, f, ..)| f).max().unwrap_or(0) as usize;
271        let mut by_file: Vec<u32> = (0..n as u32).collect();
272        by_file.sort_unstable_by_key(|&i| {
273            let (sym_id, file_id, start, end) = entries[i as usize];
274            (file_id, sym_id, start, end)
275        });
276
277        let mut file_offsets = vec![0u32; max_file + 2];
278        for &idx in &by_file {
279            let file_id = entries[idx as usize].1;
280            file_offsets[file_id as usize + 1] += 1;
281        }
282        for i in 1..file_offsets.len() {
283            file_offsets[i] += file_offsets[i - 1];
284        }
285
286        *self.compact_ref_index.write().unwrap() = Some(CompactRefIndex {
287            entries,
288            sym_offsets,
289            by_file,
290            file_offsets,
291        });
292        self.is_compacted
293            .store(true, std::sync::atomic::Ordering::Release);
294
295        // Free build-phase allocations.
296        self.symbol_reference_locations.clear();
297        self.file_symbol_references.clear();
298    }
299
300    /// Decompress the compact index back into the build-phase `DashMap`s.
301    ///
302    /// Called automatically by write methods when the compact index is live.
303    /// This makes incremental re-analysis transparent: callers never need to
304    /// know whether the index is compacted or not.
305    fn ensure_expanded(&self) {
306        // Fast path: not compacted — one atomic load, no lock.
307        if !self.is_compacted.load(std::sync::atomic::Ordering::Acquire) {
308            return;
309        }
310        // Slow path: acquire write lock and decompress.
311        let mut guard = self.compact_ref_index.write().unwrap();
312        if let Some(ci) = guard.take() {
313            for &(sym_id, file_id, start, end) in &ci.entries {
314                record_ref(
315                    &self.symbol_reference_locations,
316                    &self.file_symbol_references,
317                    sym_id,
318                    file_id,
319                    start,
320                    end,
321                );
322            }
323            self.is_compacted
324                .store(false, std::sync::atomic::Ordering::Release);
325        }
326        // If another thread already decompressed (guard is now None), we're done.
327    }
328
329    /// Reset the finalization flag so that `finalize()` will run again.
330    ///
331    /// Use this when new class definitions have been added after an initial
332    /// `finalize()` call (e.g., lazily loaded via PSR-4) and the inheritance
333    /// graph needs to be rebuilt.
334    pub fn invalidate_finalization(&self) {
335        self.finalized
336            .store(false, std::sync::atomic::Ordering::SeqCst);
337    }
338
339    // -----------------------------------------------------------------------
340    // Incremental: remove all definitions from a single file
341    // -----------------------------------------------------------------------
342
343    /// Remove all definitions and outgoing reference locations contributed by the given file.
344    /// This clears classes, interfaces, traits, enums, functions, and constants
345    /// whose defining file matches `file_path`, the file's import and namespace entries,
346    /// and all entries in symbol_reference_locations that originated from this file.
347    /// After calling this, `invalidate_finalization()` is called so the next `finalize()`
348    /// rebuilds inheritance.
349    pub fn remove_file_definitions(&self, file_path: &str) {
350        // Collect all symbols defined in this file
351        let symbols: Vec<Arc<str>> = self
352            .symbol_to_file
353            .iter()
354            .filter(|entry| entry.value().as_ref() == file_path)
355            .map(|entry| entry.key().clone())
356            .collect();
357
358        // Remove each symbol from its respective map and from symbol_to_file
359        for sym in &symbols {
360            self.classes.remove(sym.as_ref());
361            self.interfaces.remove(sym.as_ref());
362            self.traits.remove(sym.as_ref());
363            self.enums.remove(sym.as_ref());
364            self.functions.remove(sym.as_ref());
365            self.constants.remove(sym.as_ref());
366            self.symbol_to_file.remove(sym.as_ref());
367            self.known_symbols.remove(sym.as_ref());
368        }
369
370        // Remove file-level metadata
371        self.file_imports.remove(file_path);
372        self.file_namespaces.remove(file_path);
373
374        // Remove @var-annotated global variables declared in this file
375        if let Some((_, var_names)) = self.file_global_vars.remove(file_path) {
376            for name in var_names {
377                self.global_vars.remove(name.as_ref());
378            }
379        }
380
381        // Ensure the reference index is in DashMap form so the removal below works.
382        self.ensure_expanded();
383
384        // Remove reference locations contributed by this file.
385        // Use the reverse index to avoid a full scan of all symbols.
386        if let Some(file_id) = self.file_interner.get_id(file_path) {
387            if let Some((_, sym_ids)) = self.file_symbol_references.remove(&file_id) {
388                for sym_id in sym_ids {
389                    if let Some(mut entries) = self.symbol_reference_locations.get_mut(&sym_id) {
390                        entries.retain(|&(fid, _, _)| fid != file_id);
391                    }
392                }
393            }
394        }
395
396        self.invalidate_finalization();
397    }
398
399    // -----------------------------------------------------------------------
400    // Structural snapshot — skip finalize() on body-only changes
401    // -----------------------------------------------------------------------
402
403    /// Capture the inheritance structure of all symbols defined in `file_path`.
404    ///
405    /// Call this *before* `remove_file_definitions` to preserve the data that
406    /// `finalize()` would otherwise have to recompute.  The snapshot records, for
407    /// each class/interface in the file, the fields that feed into
408    /// `all_parents` (parent class, implemented interfaces, used traits, extended
409    /// interfaces) as well as the already-computed `all_parents` list itself.
410    pub fn file_structural_snapshot(&self, file_path: &str) -> StructuralSnapshot {
411        let symbols: Vec<Arc<str>> = self
412            .symbol_to_file
413            .iter()
414            .filter(|e| e.value().as_ref() == file_path)
415            .map(|e| e.key().clone())
416            .collect();
417
418        let mut classes = std::collections::HashMap::new();
419        let mut interfaces = std::collections::HashMap::new();
420
421        for sym in symbols {
422            if let Some(cls) = self.classes.get(sym.as_ref()) {
423                let mut ifaces = cls.interfaces.clone();
424                ifaces.sort_unstable_by(|a, b| a.as_ref().cmp(b.as_ref()));
425                let mut traits = cls.traits.clone();
426                traits.sort_unstable_by(|a, b| a.as_ref().cmp(b.as_ref()));
427                classes.insert(
428                    sym,
429                    ClassInheritance {
430                        parent: cls.parent.clone(),
431                        interfaces: ifaces,
432                        traits,
433                        all_parents: cls.all_parents.clone(),
434                    },
435                );
436            } else if let Some(iface) = self.interfaces.get(sym.as_ref()) {
437                let mut extends = iface.extends.clone();
438                extends.sort_unstable_by(|a, b| a.as_ref().cmp(b.as_ref()));
439                interfaces.insert(
440                    sym,
441                    InterfaceInheritance {
442                        extends,
443                        all_parents: iface.all_parents.clone(),
444                    },
445                );
446            }
447        }
448
449        StructuralSnapshot {
450            classes,
451            interfaces,
452        }
453    }
454
455    /// After Pass 1 completes, check whether the inheritance structure in
456    /// `file_path` matches the snapshot taken before `remove_file_definitions`.
457    ///
458    /// Returns `true` if `finalize()` can be skipped — i.e. only method bodies,
459    /// properties, or annotations changed, not any class/interface hierarchy.
460    pub fn structural_unchanged_after_pass1(
461        &self,
462        file_path: &str,
463        old: &StructuralSnapshot,
464    ) -> bool {
465        let symbols: Vec<Arc<str>> = self
466            .symbol_to_file
467            .iter()
468            .filter(|e| e.value().as_ref() == file_path)
469            .map(|e| e.key().clone())
470            .collect();
471
472        let mut seen_classes = 0usize;
473        let mut seen_interfaces = 0usize;
474
475        for sym in &symbols {
476            if let Some(cls) = self.classes.get(sym.as_ref()) {
477                seen_classes += 1;
478                let Some(old_cls) = old.classes.get(sym.as_ref()) else {
479                    return false; // new class added
480                };
481                if old_cls.parent != cls.parent {
482                    return false;
483                }
484                let mut new_ifaces = cls.interfaces.clone();
485                new_ifaces.sort_unstable_by(|a, b| a.as_ref().cmp(b.as_ref()));
486                if old_cls.interfaces != new_ifaces {
487                    return false;
488                }
489                let mut new_traits = cls.traits.clone();
490                new_traits.sort_unstable_by(|a, b| a.as_ref().cmp(b.as_ref()));
491                if old_cls.traits != new_traits {
492                    return false;
493                }
494            } else if let Some(iface) = self.interfaces.get(sym.as_ref()) {
495                seen_interfaces += 1;
496                let Some(old_iface) = old.interfaces.get(sym.as_ref()) else {
497                    return false; // new interface added
498                };
499                let mut new_extends = iface.extends.clone();
500                new_extends.sort_unstable_by(|a, b| a.as_ref().cmp(b.as_ref()));
501                if old_iface.extends != new_extends {
502                    return false;
503                }
504            }
505            // Traits, enums, functions, constants: not finalization-relevant, skip.
506        }
507
508        // Check for removed classes or interfaces.
509        seen_classes == old.classes.len() && seen_interfaces == old.interfaces.len()
510    }
511
512    /// Restore `all_parents` from a snapshot and mark the codebase as finalized.
513    ///
514    /// Call this instead of `finalize()` when `structural_unchanged_after_pass1`
515    /// returns `true`.  The newly re-registered symbols (written by Pass 1) have
516    /// `all_parents = []`; this method repopulates them from the snapshot so that
517    /// all downstream lookups that depend on `all_parents` keep working correctly.
518    pub fn restore_all_parents(&self, file_path: &str, snapshot: &StructuralSnapshot) {
519        let symbols: Vec<Arc<str>> = self
520            .symbol_to_file
521            .iter()
522            .filter(|e| e.value().as_ref() == file_path)
523            .map(|e| e.key().clone())
524            .collect();
525
526        for sym in &symbols {
527            if let Some(old_cls) = snapshot.classes.get(sym.as_ref()) {
528                if let Some(mut cls) = self.classes.get_mut(sym.as_ref()) {
529                    cls.all_parents = old_cls.all_parents.clone();
530                }
531            } else if let Some(old_iface) = snapshot.interfaces.get(sym.as_ref()) {
532                if let Some(mut iface) = self.interfaces.get_mut(sym.as_ref()) {
533                    iface.all_parents = old_iface.all_parents.clone();
534                }
535            }
536        }
537
538        self.finalized
539            .store(true, std::sync::atomic::Ordering::SeqCst);
540    }
541
542    // -----------------------------------------------------------------------
543    // Global variable registry
544    // -----------------------------------------------------------------------
545
546    /// Record an `@var`-annotated global variable type discovered in Pass 1.
547    /// If the same variable is annotated in multiple files, the last write wins.
548    pub fn register_global_var(&self, file: &Arc<str>, name: Arc<str>, ty: Union) {
549        self.file_global_vars
550            .entry(file.clone())
551            .or_default()
552            .push(name.clone());
553        self.global_vars.insert(name, ty);
554    }
555
556    // -----------------------------------------------------------------------
557    // Lookups
558    // -----------------------------------------------------------------------
559
560    /// Resolve a property, walking up the inheritance chain (parent classes and traits).
561    pub fn get_property(
562        &self,
563        fqcn: &str,
564        prop_name: &str,
565    ) -> Option<crate::storage::PropertyStorage> {
566        // Check direct class own_properties
567        if let Some(cls) = self.classes.get(fqcn) {
568            if let Some(p) = cls.own_properties.get(prop_name) {
569                return Some(p.clone());
570            }
571        }
572
573        // Walk all ancestors (collected during finalize)
574        let all_parents = {
575            if let Some(cls) = self.classes.get(fqcn) {
576                cls.all_parents.clone()
577            } else {
578                return None;
579            }
580        };
581
582        for ancestor_fqcn in &all_parents {
583            if let Some(ancestor_cls) = self.classes.get(ancestor_fqcn.as_ref()) {
584                if let Some(p) = ancestor_cls.own_properties.get(prop_name) {
585                    return Some(p.clone());
586                }
587            }
588        }
589
590        // Check traits
591        let trait_list = {
592            if let Some(cls) = self.classes.get(fqcn) {
593                cls.traits.clone()
594            } else {
595                vec![]
596            }
597        };
598        for trait_fqcn in &trait_list {
599            if let Some(tr) = self.traits.get(trait_fqcn.as_ref()) {
600                if let Some(p) = tr.own_properties.get(prop_name) {
601                    return Some(p.clone());
602                }
603            }
604        }
605
606        None
607    }
608
609    /// Resolve a method, walking up the full inheritance chain (own → traits → ancestors).
610    pub fn get_method(&self, fqcn: &str, method_name: &str) -> Option<Arc<MethodStorage>> {
611        // PHP method names are case-insensitive — normalize to lowercase for all lookups.
612        let method_lower = method_name.to_lowercase();
613        let method_name = method_lower.as_str();
614
615        // --- Class: own methods → own traits → ancestor classes/traits/interfaces ---
616        if let Some(cls) = self.classes.get(fqcn) {
617            // 1. Own methods (highest priority)
618            if let Some(m) = lookup_method(&cls.own_methods, method_name) {
619                return Some(Arc::clone(m));
620            }
621            // Collect chain info before dropping the DashMap guard.
622            let own_traits = cls.traits.clone();
623            let ancestors = cls.all_parents.clone();
624            drop(cls);
625
626            // 2. Own trait methods (recursive into trait-of-trait)
627            for tr_fqcn in &own_traits {
628                if let Some(m) = self.get_method_in_trait(tr_fqcn, method_name) {
629                    return Some(m);
630                }
631            }
632
633            // 3. Ancestor chain (all_parents is closest-first: parent, grandparent, …)
634            for ancestor_fqcn in &ancestors {
635                if let Some(anc) = self.classes.get(ancestor_fqcn.as_ref()) {
636                    if let Some(m) = lookup_method(&anc.own_methods, method_name) {
637                        return Some(Arc::clone(m));
638                    }
639                    let anc_traits = anc.traits.clone();
640                    drop(anc);
641                    for tr_fqcn in &anc_traits {
642                        if let Some(m) = self.get_method_in_trait(tr_fqcn, method_name) {
643                            return Some(m);
644                        }
645                    }
646                } else if let Some(iface) = self.interfaces.get(ancestor_fqcn.as_ref()) {
647                    if let Some(m) = lookup_method(&iface.own_methods, method_name) {
648                        let mut ms = (**m).clone();
649                        ms.is_abstract = true;
650                        return Some(Arc::new(ms));
651                    }
652                }
653                // Traits listed in all_parents are already covered via their owning class above.
654            }
655            return None;
656        }
657
658        // --- Interface: own methods + parent interfaces ---
659        if let Some(iface) = self.interfaces.get(fqcn) {
660            if let Some(m) = lookup_method(&iface.own_methods, method_name) {
661                return Some(Arc::clone(m));
662            }
663            let parents = iface.all_parents.clone();
664            drop(iface);
665            for parent_fqcn in &parents {
666                if let Some(parent_iface) = self.interfaces.get(parent_fqcn.as_ref()) {
667                    if let Some(m) = lookup_method(&parent_iface.own_methods, method_name) {
668                        return Some(Arc::clone(m));
669                    }
670                }
671            }
672            return None;
673        }
674
675        // --- Trait (variable annotated with a trait type) ---
676        if let Some(tr) = self.traits.get(fqcn) {
677            if let Some(m) = lookup_method(&tr.own_methods, method_name) {
678                return Some(Arc::clone(m));
679            }
680            return None;
681        }
682
683        // --- Enum ---
684        if let Some(e) = self.enums.get(fqcn) {
685            if let Some(m) = lookup_method(&e.own_methods, method_name) {
686                return Some(Arc::clone(m));
687            }
688            // PHP 8.1 built-in enum methods: cases(), from(), tryFrom()
689            if matches!(method_name, "cases" | "from" | "tryfrom") {
690                return Some(Arc::new(crate::storage::MethodStorage {
691                    fqcn: Arc::from(fqcn),
692                    name: Arc::from(method_name),
693                    params: vec![],
694                    return_type: Some(mir_types::Union::mixed()),
695                    inferred_return_type: None,
696                    visibility: crate::storage::Visibility::Public,
697                    is_static: true,
698                    is_abstract: false,
699                    is_constructor: false,
700                    template_params: vec![],
701                    assertions: vec![],
702                    throws: vec![],
703                    is_final: false,
704                    is_internal: false,
705                    is_pure: false,
706                    is_deprecated: false,
707                    location: None,
708                }));
709            }
710        }
711
712        None
713    }
714
715    /// Returns true if `child` extends or implements `ancestor` (transitively).
716    pub fn extends_or_implements(&self, child: &str, ancestor: &str) -> bool {
717        if child == ancestor {
718            return true;
719        }
720        if let Some(cls) = self.classes.get(child) {
721            return cls.implements_or_extends(ancestor);
722        }
723        if let Some(iface) = self.interfaces.get(child) {
724            return iface.all_parents.iter().any(|p| p.as_ref() == ancestor);
725        }
726        // Enum: backed enums implicitly implement BackedEnum (and UnitEnum);
727        // pure enums implicitly implement UnitEnum.
728        if let Some(en) = self.enums.get(child) {
729            // Check explicitly declared interfaces (e.g. implements SomeInterface)
730            if en.interfaces.iter().any(|i| i.as_ref() == ancestor) {
731                return true;
732            }
733            // PHP built-in: every enum implements UnitEnum
734            if ancestor == "UnitEnum" || ancestor == "\\UnitEnum" {
735                return true;
736            }
737            // Backed enums implement BackedEnum
738            if (ancestor == "BackedEnum" || ancestor == "\\BackedEnum") && en.scalar_type.is_some()
739            {
740                return true;
741            }
742        }
743        false
744    }
745
746    /// Whether a class/interface/trait/enum with this FQCN exists.
747    pub fn type_exists(&self, fqcn: &str) -> bool {
748        self.classes.contains_key(fqcn)
749            || self.interfaces.contains_key(fqcn)
750            || self.traits.contains_key(fqcn)
751            || self.enums.contains_key(fqcn)
752    }
753
754    pub fn function_exists(&self, fqn: &str) -> bool {
755        self.functions.contains_key(fqn)
756    }
757
758    /// Returns true if the class is declared abstract.
759    /// Used to suppress `UndefinedMethod` on abstract class receivers: the concrete
760    /// subclass is expected to implement the method, matching Psalm errorLevel=3 behaviour.
761    pub fn is_abstract_class(&self, fqcn: &str) -> bool {
762        self.classes.get(fqcn).is_some_and(|c| c.is_abstract)
763    }
764
765    /// Return the declared template params for `fqcn` (class or interface), or
766    /// an empty vec if the type is not found or has no templates.
767    pub fn get_class_template_params(&self, fqcn: &str) -> Vec<crate::storage::TemplateParam> {
768        if let Some(cls) = self.classes.get(fqcn) {
769            return cls.template_params.clone();
770        }
771        if let Some(iface) = self.interfaces.get(fqcn) {
772            return iface.template_params.clone();
773        }
774        if let Some(tr) = self.traits.get(fqcn) {
775            return tr.template_params.clone();
776        }
777        vec![]
778    }
779
780    /// Returns true if the class (or any ancestor/trait) defines a `__get` magic method.
781    /// Such classes allow arbitrary property access, suppressing UndefinedProperty.
782    pub fn has_magic_get(&self, fqcn: &str) -> bool {
783        self.get_method(fqcn, "__get").is_some()
784    }
785
786    /// Returns true if the class (or any of its ancestors) has a parent/interface/trait
787    /// that is NOT present in the codebase.  Used to suppress `UndefinedMethod` false
788    /// positives: if a method might be inherited from an unscanned external class we
789    /// cannot confirm or deny its existence.
790    ///
791    /// We use the pre-computed `all_parents` list (built during finalization) rather
792    /// than recursive DashMap lookups to avoid potential deadlocks.
793    pub fn has_unknown_ancestor(&self, fqcn: &str) -> bool {
794        // For interfaces: check whether any parent interface is unknown.
795        if let Some(iface) = self.interfaces.get(fqcn) {
796            let parents = iface.all_parents.clone();
797            drop(iface);
798            for p in &parents {
799                if !self.type_exists(p.as_ref()) {
800                    return true;
801                }
802            }
803            return false;
804        }
805
806        // Clone the data we need so the DashMap ref is dropped before any further lookups.
807        let (parent, interfaces, traits, all_parents) = {
808            let Some(cls) = self.classes.get(fqcn) else {
809                return false;
810            };
811            (
812                cls.parent.clone(),
813                cls.interfaces.clone(),
814                cls.traits.clone(),
815                cls.all_parents.clone(),
816            )
817        };
818
819        // Fast path: check direct parent/interfaces/traits
820        if let Some(ref p) = parent {
821            if !self.type_exists(p.as_ref()) {
822                return true;
823            }
824        }
825        for iface in &interfaces {
826            if !self.type_exists(iface.as_ref()) {
827                return true;
828            }
829        }
830        for tr in &traits {
831            if !self.type_exists(tr.as_ref()) {
832                return true;
833            }
834        }
835
836        // Also check the full ancestor chain (pre-computed during finalization)
837        for ancestor in &all_parents {
838            if !self.type_exists(ancestor.as_ref()) {
839                return true;
840            }
841        }
842
843        false
844    }
845
846    /// Resolve a short class/function name to its FQCN using the import table
847    /// and namespace recorded for `file` during Pass 1.
848    ///
849    /// - Names already containing `\` (after stripping a leading `\`) are
850    ///   returned as-is (already fully qualified).
851    /// - `self`, `parent`, `static` are returned unchanged (caller handles them).
852    pub fn resolve_class_name(&self, file: &str, name: &str) -> String {
853        let name = name.trim_start_matches('\\');
854        if name.is_empty() {
855            return name.to_string();
856        }
857        // Fully qualified absolute paths start with '\' (already stripped above).
858        // Names containing '\' but not starting with it may be:
859        //   - Already-resolved FQCNs (e.g. Frontify\Util\Foo) — check type_exists
860        //   - Qualified relative names (e.g. Option\Some from within Frontify\Utility) — need namespace prefix
861        if name.contains('\\') {
862            // Check if the leading segment matches a use-import alias
863            let first_segment = name.split('\\').next().unwrap_or(name);
864            if let Some(imports) = self.file_imports.get(file) {
865                if let Some(resolved_prefix) = imports.get(first_segment) {
866                    let rest = &name[first_segment.len()..]; // includes leading '\'
867                    return format!("{}{}", resolved_prefix, rest);
868                }
869            }
870            // If already known in codebase as-is, it's FQCN — trust it
871            if self.type_exists(name) {
872                return name.to_string();
873            }
874            // Otherwise it's a relative qualified name — prepend the file namespace
875            if let Some(ns) = self.file_namespaces.get(file) {
876                let qualified = format!("{}\\{}", *ns, name);
877                if self.type_exists(&qualified) {
878                    return qualified;
879                }
880            }
881            return name.to_string();
882        }
883        // Built-in pseudo-types / keywords handled by the caller
884        match name {
885            "self" | "parent" | "static" | "this" => return name.to_string(),
886            _ => {}
887        }
888        // Check use aliases for this file (PHP class names are case-insensitive)
889        if let Some(imports) = self.file_imports.get(file) {
890            if let Some(resolved) = imports.get(name) {
891                return resolved.clone();
892            }
893            // Fall back to case-insensitive alias lookup
894            let name_lower = name.to_lowercase();
895            for (alias, resolved) in imports.iter() {
896                if alias.to_lowercase() == name_lower {
897                    return resolved.clone();
898                }
899            }
900        }
901        // Qualify with the file's namespace if one exists
902        if let Some(ns) = self.file_namespaces.get(file) {
903            let qualified = format!("{}\\{}", *ns, name);
904            // If the namespaced version exists in the codebase, use it.
905            // Otherwise fall back to the global (unqualified) name if that exists.
906            // This handles `DateTimeInterface`, `Exception`, etc. used without import
907            // while not overriding user-defined classes in namespaces.
908            if self.type_exists(&qualified) {
909                return qualified;
910            }
911            if self.type_exists(name) {
912                return name.to_string();
913            }
914            return qualified;
915        }
916        name.to_string()
917    }
918
919    // -----------------------------------------------------------------------
920    // Definition location lookups
921    // -----------------------------------------------------------------------
922
923    /// Look up the definition location of any symbol (class, interface, trait, enum, function).
924    /// Returns the file path and byte offsets.
925    pub fn get_symbol_location(&self, fqcn: &str) -> Option<crate::storage::Location> {
926        if let Some(cls) = self.classes.get(fqcn) {
927            return cls.location.clone();
928        }
929        if let Some(iface) = self.interfaces.get(fqcn) {
930            return iface.location.clone();
931        }
932        if let Some(tr) = self.traits.get(fqcn) {
933            return tr.location.clone();
934        }
935        if let Some(en) = self.enums.get(fqcn) {
936            return en.location.clone();
937        }
938        if let Some(func) = self.functions.get(fqcn) {
939            return func.location.clone();
940        }
941        None
942    }
943
944    /// Look up the definition location of a class member (method, property, constant).
945    pub fn get_member_location(
946        &self,
947        fqcn: &str,
948        member_name: &str,
949    ) -> Option<crate::storage::Location> {
950        // Check methods
951        if let Some(method) = self.get_method(fqcn, member_name) {
952            return method.location.clone();
953        }
954        // Check properties
955        if let Some(prop) = self.get_property(fqcn, member_name) {
956            return prop.location.clone();
957        }
958        // Check class constants
959        if let Some(cls) = self.classes.get(fqcn) {
960            if let Some(c) = cls.own_constants.get(member_name) {
961                return c.location.clone();
962            }
963        }
964        // Check interface constants
965        if let Some(iface) = self.interfaces.get(fqcn) {
966            if let Some(c) = iface.own_constants.get(member_name) {
967                return c.location.clone();
968            }
969        }
970        // Check trait constants
971        if let Some(tr) = self.traits.get(fqcn) {
972            if let Some(c) = tr.own_constants.get(member_name) {
973                return c.location.clone();
974            }
975        }
976        // Check enum constants and cases
977        if let Some(en) = self.enums.get(fqcn) {
978            if let Some(c) = en.own_constants.get(member_name) {
979                return c.location.clone();
980            }
981            if let Some(case) = en.cases.get(member_name) {
982                return case.location.clone();
983            }
984        }
985        None
986    }
987
988    // -----------------------------------------------------------------------
989    // Reference tracking (M18 dead-code detection)
990    // -----------------------------------------------------------------------
991
992    /// Mark a method as referenced from user code.
993    pub fn mark_method_referenced(&self, fqcn: &str, method_name: &str) {
994        let key = format!("{}::{}", fqcn, method_name.to_lowercase());
995        let id = self.symbol_interner.intern_str(&key);
996        self.referenced_methods.insert(id);
997    }
998
999    /// Mark a property as referenced from user code.
1000    pub fn mark_property_referenced(&self, fqcn: &str, prop_name: &str) {
1001        let key = format!("{}::{}", fqcn, prop_name);
1002        let id = self.symbol_interner.intern_str(&key);
1003        self.referenced_properties.insert(id);
1004    }
1005
1006    /// Mark a free function as referenced from user code.
1007    pub fn mark_function_referenced(&self, fqn: &str) {
1008        let id = self.symbol_interner.intern_str(fqn);
1009        self.referenced_functions.insert(id);
1010    }
1011
1012    pub fn is_method_referenced(&self, fqcn: &str, method_name: &str) -> bool {
1013        let key = format!("{}::{}", fqcn, method_name.to_lowercase());
1014        match self.symbol_interner.get_id(&key) {
1015            Some(id) => self.referenced_methods.contains(&id),
1016            None => false,
1017        }
1018    }
1019
1020    pub fn is_property_referenced(&self, fqcn: &str, prop_name: &str) -> bool {
1021        let key = format!("{}::{}", fqcn, prop_name);
1022        match self.symbol_interner.get_id(&key) {
1023            Some(id) => self.referenced_properties.contains(&id),
1024            None => false,
1025        }
1026    }
1027
1028    pub fn is_function_referenced(&self, fqn: &str) -> bool {
1029        match self.symbol_interner.get_id(fqn) {
1030            Some(id) => self.referenced_functions.contains(&id),
1031            None => false,
1032        }
1033    }
1034
1035    /// Record a method reference with its source location.
1036    /// Also updates the referenced_methods DashSet for dead-code detection.
1037    pub fn mark_method_referenced_at(
1038        &self,
1039        fqcn: &str,
1040        method_name: &str,
1041        file: Arc<str>,
1042        start: u32,
1043        end: u32,
1044    ) {
1045        let key = format!("{}::{}", fqcn, method_name.to_lowercase());
1046        self.ensure_expanded();
1047        let sym_id = self.symbol_interner.intern_str(&key);
1048        let file_id = self.file_interner.intern(file);
1049        self.referenced_methods.insert(sym_id);
1050        record_ref(
1051            &self.symbol_reference_locations,
1052            &self.file_symbol_references,
1053            sym_id,
1054            file_id,
1055            start,
1056            end,
1057        );
1058    }
1059
1060    /// Record a property reference with its source location.
1061    /// Also updates the referenced_properties DashSet for dead-code detection.
1062    pub fn mark_property_referenced_at(
1063        &self,
1064        fqcn: &str,
1065        prop_name: &str,
1066        file: Arc<str>,
1067        start: u32,
1068        end: u32,
1069    ) {
1070        let key = format!("{}::{}", fqcn, prop_name);
1071        self.ensure_expanded();
1072        let sym_id = self.symbol_interner.intern_str(&key);
1073        let file_id = self.file_interner.intern(file);
1074        self.referenced_properties.insert(sym_id);
1075        record_ref(
1076            &self.symbol_reference_locations,
1077            &self.file_symbol_references,
1078            sym_id,
1079            file_id,
1080            start,
1081            end,
1082        );
1083    }
1084
1085    /// Record a function reference with its source location.
1086    /// Also updates the referenced_functions DashSet for dead-code detection.
1087    pub fn mark_function_referenced_at(&self, fqn: &str, file: Arc<str>, start: u32, end: u32) {
1088        self.ensure_expanded();
1089        let sym_id = self.symbol_interner.intern_str(fqn);
1090        let file_id = self.file_interner.intern(file);
1091        self.referenced_functions.insert(sym_id);
1092        record_ref(
1093            &self.symbol_reference_locations,
1094            &self.file_symbol_references,
1095            sym_id,
1096            file_id,
1097            start,
1098            end,
1099        );
1100    }
1101
1102    /// Record a class reference (e.g. `new Foo()`) with its source location.
1103    /// Does not update any dead-code DashSet — class instantiation tracking is
1104    /// separate from method/property/function dead-code detection.
1105    pub fn mark_class_referenced_at(&self, fqcn: &str, file: Arc<str>, start: u32, end: u32) {
1106        self.ensure_expanded();
1107        let sym_id = self.symbol_interner.intern_str(fqcn);
1108        let file_id = self.file_interner.intern(file);
1109        record_ref(
1110            &self.symbol_reference_locations,
1111            &self.file_symbol_references,
1112            sym_id,
1113            file_id,
1114            start,
1115            end,
1116        );
1117    }
1118
1119    /// Replay cached reference locations for a file into the reference index.
1120    /// Called on cache hits to avoid re-running Pass 2 just to rebuild the index.
1121    /// `locs` is a slice of `(symbol_key, start_byte, end_byte)` as stored in the cache.
1122    pub fn replay_reference_locations(&self, file: Arc<str>, locs: &[(String, u32, u32)]) {
1123        if locs.is_empty() {
1124            return;
1125        }
1126        self.ensure_expanded();
1127        let file_id = self.file_interner.intern(file);
1128        for (symbol_key, start, end) in locs {
1129            let sym_id = self.symbol_interner.intern_str(symbol_key);
1130            record_ref(
1131                &self.symbol_reference_locations,
1132                &self.file_symbol_references,
1133                sym_id,
1134                file_id,
1135                *start,
1136                *end,
1137            );
1138        }
1139    }
1140
1141    /// Return all reference locations for `symbol` as a flat `Vec<(file, start, end)>`.
1142    /// Returns an empty Vec if the symbol has no recorded references.
1143    pub fn get_reference_locations(&self, symbol: &str) -> Vec<(Arc<str>, u32, u32)> {
1144        let Some(sym_id) = self.symbol_interner.get_id(symbol) else {
1145            return Vec::new();
1146        };
1147        // Fast path: compact CSR index.
1148        if let Some(ref ci) = *self.compact_ref_index.read().unwrap() {
1149            let id = sym_id as usize;
1150            if id + 1 >= ci.sym_offsets.len() {
1151                return Vec::new();
1152            }
1153            let start = ci.sym_offsets[id] as usize;
1154            let end = ci.sym_offsets[id + 1] as usize;
1155            return ci.entries[start..end]
1156                .iter()
1157                .map(|&(_, file_id, s, e)| (self.file_interner.get(file_id), s, e))
1158                .collect();
1159        }
1160        // Slow path: build-phase DashMap.
1161        let Some(entries) = self.symbol_reference_locations.get(&sym_id) else {
1162            return Vec::new();
1163        };
1164        entries
1165            .iter()
1166            .map(|&(file_id, start, end)| (self.file_interner.get(file_id), start, end))
1167            .collect()
1168    }
1169
1170    /// Extract all reference locations recorded for `file` as `(symbol_key, start, end)` triples.
1171    /// Used by the cache layer to persist per-file reference data between runs.
1172    pub fn extract_file_reference_locations(&self, file: &str) -> Vec<(Arc<str>, u32, u32)> {
1173        let Some(file_id) = self.file_interner.get_id(file) else {
1174            return Vec::new();
1175        };
1176        // Fast path: compact CSR index.
1177        if let Some(ref ci) = *self.compact_ref_index.read().unwrap() {
1178            let id = file_id as usize;
1179            if id + 1 >= ci.file_offsets.len() {
1180                return Vec::new();
1181            }
1182            let start = ci.file_offsets[id] as usize;
1183            let end = ci.file_offsets[id + 1] as usize;
1184            return ci.by_file[start..end]
1185                .iter()
1186                .map(|&entry_idx| {
1187                    let (sym_id, _, s, e) = ci.entries[entry_idx as usize];
1188                    (self.symbol_interner.get(sym_id), s, e)
1189                })
1190                .collect();
1191        }
1192        // Slow path: build-phase DashMaps.
1193        let Some(sym_ids) = self.file_symbol_references.get(&file_id) else {
1194            return Vec::new();
1195        };
1196        let mut out = Vec::new();
1197        for &sym_id in sym_ids.iter() {
1198            let Some(entries) = self.symbol_reference_locations.get(&sym_id) else {
1199                continue;
1200            };
1201            let sym_key = self.symbol_interner.get(sym_id);
1202            for &(entry_file_id, start, end) in entries.iter() {
1203                if entry_file_id == file_id {
1204                    out.push((sym_key.clone(), start, end));
1205                }
1206            }
1207        }
1208        out
1209    }
1210
1211    /// Returns true if the given file has any recorded symbol references.
1212    pub fn file_has_symbol_references(&self, file: &str) -> bool {
1213        let Some(file_id) = self.file_interner.get_id(file) else {
1214            return false;
1215        };
1216        // Check compact index first.
1217        if let Some(ref ci) = *self.compact_ref_index.read().unwrap() {
1218            let id = file_id as usize;
1219            return id + 1 < ci.file_offsets.len() && ci.file_offsets[id] < ci.file_offsets[id + 1];
1220        }
1221        self.file_symbol_references.contains_key(&file_id)
1222    }
1223
1224    // -----------------------------------------------------------------------
1225    // Finalization
1226    // -----------------------------------------------------------------------
1227
1228    /// Must be called after all files have been parsed (pass 1 complete).
1229    /// Resolves inheritance chains and builds method dispatch tables.
1230    pub fn finalize(&self) {
1231        if self.finalized.load(std::sync::atomic::Ordering::SeqCst) {
1232            return;
1233        }
1234
1235        // 1. Resolve all_parents for classes
1236        let class_keys: Vec<Arc<str>> = self.classes.iter().map(|e| e.key().clone()).collect();
1237        for fqcn in &class_keys {
1238            let parents = self.collect_class_ancestors(fqcn);
1239            if let Some(mut cls) = self.classes.get_mut(fqcn.as_ref()) {
1240                cls.all_parents = parents;
1241            }
1242        }
1243
1244        // 2. Resolve all_parents for interfaces
1245        let iface_keys: Vec<Arc<str>> = self.interfaces.iter().map(|e| e.key().clone()).collect();
1246        for fqcn in &iface_keys {
1247            let parents = self.collect_interface_ancestors(fqcn);
1248            if let Some(mut iface) = self.interfaces.get_mut(fqcn.as_ref()) {
1249                iface.all_parents = parents;
1250            }
1251        }
1252
1253        self.finalized
1254            .store(true, std::sync::atomic::Ordering::SeqCst);
1255    }
1256
1257    // -----------------------------------------------------------------------
1258    // Private helpers
1259    // -----------------------------------------------------------------------
1260
1261    /// Look up `method_name` in a trait's own methods, then recursively in any
1262    /// traits that the trait itself uses (`use OtherTrait;` inside a trait body).
1263    /// A visited set prevents infinite loops on pathological mutual trait use.
1264    fn get_method_in_trait(
1265        &self,
1266        tr_fqcn: &Arc<str>,
1267        method_name: &str,
1268    ) -> Option<Arc<MethodStorage>> {
1269        let mut visited = std::collections::HashSet::new();
1270        self.get_method_in_trait_inner(tr_fqcn, method_name, &mut visited)
1271    }
1272
1273    fn get_method_in_trait_inner(
1274        &self,
1275        tr_fqcn: &Arc<str>,
1276        method_name: &str,
1277        visited: &mut std::collections::HashSet<String>,
1278    ) -> Option<Arc<MethodStorage>> {
1279        if !visited.insert(tr_fqcn.to_string()) {
1280            return None; // cycle guard
1281        }
1282        let tr = self.traits.get(tr_fqcn.as_ref())?;
1283        if let Some(m) = lookup_method(&tr.own_methods, method_name) {
1284            return Some(Arc::clone(m));
1285        }
1286        let used_traits = tr.traits.clone();
1287        drop(tr);
1288        for used_fqcn in &used_traits {
1289            if let Some(m) = self.get_method_in_trait_inner(used_fqcn, method_name, visited) {
1290                return Some(m);
1291            }
1292        }
1293        None
1294    }
1295
1296    fn collect_class_ancestors(&self, fqcn: &str) -> Vec<Arc<str>> {
1297        let mut result = Vec::new();
1298        let mut visited = std::collections::HashSet::new();
1299        self.collect_class_ancestors_inner(fqcn, &mut result, &mut visited);
1300        result
1301    }
1302
1303    fn collect_class_ancestors_inner(
1304        &self,
1305        fqcn: &str,
1306        out: &mut Vec<Arc<str>>,
1307        visited: &mut std::collections::HashSet<String>,
1308    ) {
1309        if !visited.insert(fqcn.to_string()) {
1310            return; // cycle guard
1311        }
1312        let (parent, interfaces, traits) = {
1313            if let Some(cls) = self.classes.get(fqcn) {
1314                (
1315                    cls.parent.clone(),
1316                    cls.interfaces.clone(),
1317                    cls.traits.clone(),
1318                )
1319            } else {
1320                return;
1321            }
1322        };
1323
1324        if let Some(p) = parent {
1325            out.push(p.clone());
1326            self.collect_class_ancestors_inner(&p, out, visited);
1327        }
1328        for iface in interfaces {
1329            out.push(iface.clone());
1330            self.collect_interface_ancestors_inner(&iface, out, visited);
1331        }
1332        for t in traits {
1333            out.push(t);
1334        }
1335    }
1336
1337    fn collect_interface_ancestors(&self, fqcn: &str) -> Vec<Arc<str>> {
1338        let mut result = Vec::new();
1339        let mut visited = std::collections::HashSet::new();
1340        self.collect_interface_ancestors_inner(fqcn, &mut result, &mut visited);
1341        result
1342    }
1343
1344    fn collect_interface_ancestors_inner(
1345        &self,
1346        fqcn: &str,
1347        out: &mut Vec<Arc<str>>,
1348        visited: &mut std::collections::HashSet<String>,
1349    ) {
1350        if !visited.insert(fqcn.to_string()) {
1351            return;
1352        }
1353        let extends = {
1354            if let Some(iface) = self.interfaces.get(fqcn) {
1355                iface.extends.clone()
1356            } else {
1357                return;
1358            }
1359        };
1360        for e in extends {
1361            out.push(e.clone());
1362            self.collect_interface_ancestors_inner(&e, out, visited);
1363        }
1364    }
1365}
1366
1367#[cfg(test)]
1368mod tests {
1369    use super::*;
1370
1371    fn arc(s: &str) -> Arc<str> {
1372        Arc::from(s)
1373    }
1374
1375    #[test]
1376    fn method_referenced_at_groups_spans_by_file() {
1377        let cb = Codebase::new();
1378        cb.mark_method_referenced_at("Foo", "bar", arc("a.php"), 0, 5);
1379        cb.mark_method_referenced_at("Foo", "bar", arc("a.php"), 10, 15);
1380        cb.mark_method_referenced_at("Foo", "bar", arc("b.php"), 20, 25);
1381
1382        let locs = cb.get_reference_locations("Foo::bar");
1383        let files: std::collections::HashSet<&str> =
1384            locs.iter().map(|(f, _, _)| f.as_ref()).collect();
1385        assert_eq!(files.len(), 2, "two files, not three spans");
1386        assert!(locs.contains(&(arc("a.php"), 0, 5)));
1387        assert!(locs.contains(&(arc("a.php"), 10, 15)));
1388        assert_eq!(
1389            locs.iter()
1390                .filter(|(f, _, _)| f.as_ref() == "a.php")
1391                .count(),
1392            2
1393        );
1394        assert!(locs.contains(&(arc("b.php"), 20, 25)));
1395        assert!(
1396            cb.is_method_referenced("Foo", "bar"),
1397            "DashSet also updated"
1398        );
1399    }
1400
1401    #[test]
1402    fn duplicate_spans_are_deduplicated() {
1403        let cb = Codebase::new();
1404        // Same call site recorded twice (e.g. union receiver Foo|Foo)
1405        cb.mark_method_referenced_at("Foo", "bar", arc("a.php"), 0, 5);
1406        cb.mark_method_referenced_at("Foo", "bar", arc("a.php"), 0, 5);
1407
1408        let count = cb
1409            .get_reference_locations("Foo::bar")
1410            .iter()
1411            .filter(|(f, _, _)| f.as_ref() == "a.php")
1412            .count();
1413        assert_eq!(count, 1, "duplicate span deduplicated");
1414    }
1415
1416    #[test]
1417    fn method_key_is_lowercased() {
1418        let cb = Codebase::new();
1419        cb.mark_method_referenced_at("Cls", "MyMethod", arc("f.php"), 0, 3);
1420        assert!(!cb.get_reference_locations("Cls::mymethod").is_empty());
1421    }
1422
1423    #[test]
1424    fn property_referenced_at_records_location() {
1425        let cb = Codebase::new();
1426        cb.mark_property_referenced_at("Bar", "count", arc("x.php"), 5, 10);
1427
1428        assert!(cb
1429            .get_reference_locations("Bar::count")
1430            .contains(&(arc("x.php"), 5, 10)));
1431        assert!(cb.is_property_referenced("Bar", "count"));
1432    }
1433
1434    #[test]
1435    fn function_referenced_at_records_location() {
1436        let cb = Codebase::new();
1437        cb.mark_function_referenced_at("my_fn", arc("a.php"), 10, 15);
1438
1439        assert!(cb
1440            .get_reference_locations("my_fn")
1441            .contains(&(arc("a.php"), 10, 15)));
1442        assert!(cb.is_function_referenced("my_fn"));
1443    }
1444
1445    #[test]
1446    fn class_referenced_at_records_location() {
1447        let cb = Codebase::new();
1448        cb.mark_class_referenced_at("Foo", arc("a.php"), 5, 8);
1449
1450        assert!(cb
1451            .get_reference_locations("Foo")
1452            .contains(&(arc("a.php"), 5, 8)));
1453    }
1454
1455    #[test]
1456    fn get_reference_locations_flattens_all_files() {
1457        let cb = Codebase::new();
1458        cb.mark_function_referenced_at("fn1", arc("a.php"), 0, 5);
1459        cb.mark_function_referenced_at("fn1", arc("b.php"), 10, 15);
1460
1461        let mut locs = cb.get_reference_locations("fn1");
1462        locs.sort_by_key(|(_, s, _)| *s);
1463        assert_eq!(locs.len(), 2);
1464        assert_eq!(locs[0], (arc("a.php"), 0, 5));
1465        assert_eq!(locs[1], (arc("b.php"), 10, 15));
1466    }
1467
1468    #[test]
1469    fn replay_reference_locations_restores_index() {
1470        let cb = Codebase::new();
1471        let locs = vec![
1472            ("Foo::bar".to_string(), 0u32, 5u32),
1473            ("Foo::bar".to_string(), 10, 15),
1474            ("greet".to_string(), 20, 25),
1475        ];
1476        cb.replay_reference_locations(arc("a.php"), &locs);
1477
1478        let bar_locs = cb.get_reference_locations("Foo::bar");
1479        assert!(bar_locs.contains(&(arc("a.php"), 0, 5)));
1480        assert!(bar_locs.contains(&(arc("a.php"), 10, 15)));
1481
1482        assert!(cb
1483            .get_reference_locations("greet")
1484            .contains(&(arc("a.php"), 20, 25)));
1485
1486        assert!(cb.file_has_symbol_references("a.php"));
1487    }
1488
1489    #[test]
1490    fn remove_file_clears_its_spans_only() {
1491        let cb = Codebase::new();
1492        cb.mark_function_referenced_at("fn1", arc("a.php"), 0, 5);
1493        cb.mark_function_referenced_at("fn1", arc("b.php"), 10, 15);
1494
1495        cb.remove_file_definitions("a.php");
1496
1497        let locs = cb.get_reference_locations("fn1");
1498        assert!(
1499            !locs.iter().any(|(f, _, _)| f.as_ref() == "a.php"),
1500            "a.php spans removed"
1501        );
1502        assert!(
1503            locs.contains(&(arc("b.php"), 10, 15)),
1504            "b.php spans untouched"
1505        );
1506        assert!(!cb.file_has_symbol_references("a.php"));
1507    }
1508
1509    #[test]
1510    fn remove_file_does_not_affect_other_files() {
1511        let cb = Codebase::new();
1512        cb.mark_property_referenced_at("Cls", "prop", arc("x.php"), 1, 4);
1513        cb.mark_property_referenced_at("Cls", "prop", arc("y.php"), 7, 10);
1514
1515        cb.remove_file_definitions("x.php");
1516
1517        let locs = cb.get_reference_locations("Cls::prop");
1518        assert!(!locs.iter().any(|(f, _, _)| f.as_ref() == "x.php"));
1519        assert!(locs.contains(&(arc("y.php"), 7, 10)));
1520    }
1521
1522    #[test]
1523    fn remove_file_definitions_on_never_analyzed_file_is_noop() {
1524        let cb = Codebase::new();
1525        cb.mark_function_referenced_at("fn1", arc("a.php"), 0, 5);
1526
1527        // "ghost.php" was never analyzed — removing it must not panic or corrupt state.
1528        cb.remove_file_definitions("ghost.php");
1529
1530        // Existing data must be untouched.
1531        assert!(cb
1532            .get_reference_locations("fn1")
1533            .contains(&(arc("a.php"), 0, 5)));
1534        assert!(!cb.file_has_symbol_references("ghost.php"));
1535    }
1536
1537    #[test]
1538    fn replay_reference_locations_with_empty_list_is_noop() {
1539        let cb = Codebase::new();
1540        cb.mark_function_referenced_at("fn1", arc("a.php"), 0, 5);
1541
1542        // Replaying an empty list must not touch existing entries.
1543        cb.replay_reference_locations(arc("b.php"), &[]);
1544
1545        assert!(
1546            !cb.file_has_symbol_references("b.php"),
1547            "empty replay must not create a file entry"
1548        );
1549        assert!(
1550            cb.get_reference_locations("fn1")
1551                .contains(&(arc("a.php"), 0, 5)),
1552            "existing spans untouched"
1553        );
1554    }
1555
1556    #[test]
1557    fn replay_reference_locations_twice_does_not_duplicate_spans() {
1558        let cb = Codebase::new();
1559        let locs = vec![("fn1".to_string(), 0u32, 5u32)];
1560
1561        cb.replay_reference_locations(arc("a.php"), &locs);
1562        cb.replay_reference_locations(arc("a.php"), &locs);
1563
1564        let count = cb
1565            .get_reference_locations("fn1")
1566            .iter()
1567            .filter(|(f, _, _)| f.as_ref() == "a.php")
1568            .count();
1569        assert_eq!(
1570            count, 1,
1571            "replaying the same location twice must not create duplicate spans"
1572        );
1573    }
1574}