Skip to main content

mir_codebase/
codebase.rs

1use std::sync::Arc;
2
3use dashmap::{DashMap, DashSet};
4
5use crate::interner::Interner;
6
7/// Maps symbol ID → flat list of `(file_id, line, col_start, col_end)`.
8///
9/// Entries are appended during Pass 2. Duplicates (e.g. from union receivers like
10/// `Foo|Foo->method()`) are filtered at insert time. IDs come from
11/// `Codebase::symbol_interner` / `Codebase::file_interner`.
12///
13/// Each entry is 12 bytes (`u32` + `u32` + `u16` + `u16`) with no per-entry
14/// allocator overhead beyond the `Vec` backing store.
15type ReferenceLocations = DashMap<u32, Vec<(u32, u32, u16, u16)>>;
16
17use crate::storage::{ClassStorage, EnumStorage, FunctionStorage, InterfaceStorage, TraitStorage};
18use mir_types::Union;
19
20// ---------------------------------------------------------------------------
21// Private helper — shared insert logic for reference tracking
22// ---------------------------------------------------------------------------
23
24/// Append `(sym_id, file_id, line, col_start, col_end)` to the reference index,
25/// skipping exact duplicates so union receivers like `Foo|Foo->method()` don't
26/// inflate the span list.
27///
28/// Both maps are updated atomically under their respective DashMap shard locks.
29#[inline]
30fn record_ref(
31    sym_locs: &ReferenceLocations,
32    file_refs: &DashMap<u32, Vec<u32>>,
33    sym_id: u32,
34    file_id: u32,
35    line: u32,
36    col_start: u16,
37    col_end: u16,
38) {
39    {
40        let mut entries = sym_locs.entry(sym_id).or_default();
41        let span = (file_id, line, col_start, col_end);
42        if !entries.contains(&span) {
43            entries.push(span);
44        }
45    }
46    {
47        let mut refs = file_refs.entry(file_id).or_default();
48        if !refs.contains(&sym_id) {
49            refs.push(sym_id);
50        }
51    }
52}
53
54// ---------------------------------------------------------------------------
55// Compact CSR reference index (post-Pass-2 read-optimised form)
56// ---------------------------------------------------------------------------
57
58/// Read-optimised Compressed Sparse Row representation of the reference index.
59///
60/// Built once by [`Codebase::compact_reference_index`] after Pass 2 finishes.
61/// After compaction the build-phase [`DashMap`]s are cleared, freeing the
62/// per-entry allocator overhead (~72 bytes per (symbol, file) pair).
63///
64/// Two CSR views are maintained over the same flat `entries` array:
65/// - by symbol: `entries[sym_offsets[id]..sym_offsets[id+1]]`
66/// - by file: `by_file[file_offsets[id]..file_offsets[id+1]]` (indirect indices)
67#[derive(Debug, Default)]
68struct CompactRefIndex {
69    /// All spans sorted by `(sym_id, file_id, line, col_start, col_end)`, deduplicated.
70    /// Each entry is 16 bytes; total size = `n_refs × 16` with no hash overhead.
71    entries: Vec<(u32, u32, u32, u16, u16)>,
72    /// CSR offsets keyed by sym_id (length = max_sym_id + 2).
73    sym_offsets: Vec<u32>,
74    /// Indices into `entries` sorted by `(file_id, sym_id, line, col_start, col_end)`.
75    /// Allows O(log n) file-keyed lookups without duplicating the payload.
76    by_file: Vec<u32>,
77    /// CSR offsets keyed by file_id into `by_file` (length = max_file_id + 2).
78    file_offsets: Vec<u32>,
79}
80
81// ---------------------------------------------------------------------------
82// Codebase — thread-safe global symbol registry
83// ---------------------------------------------------------------------------
84
85#[derive(Debug, Default)]
86pub struct Codebase {
87    pub classes: DashMap<Arc<str>, ClassStorage>,
88    pub interfaces: DashMap<Arc<str>, InterfaceStorage>,
89    pub traits: DashMap<Arc<str>, TraitStorage>,
90    pub enums: DashMap<Arc<str>, EnumStorage>,
91    pub functions: DashMap<Arc<str>, FunctionStorage>,
92    pub constants: DashMap<Arc<str>, Union>,
93
94    /// Types of `@var`-annotated global variables, collected in Pass 1.
95    /// Key: variable name without the `$` prefix.
96    pub global_vars: DashMap<Arc<str>, Union>,
97    /// Maps file path → variable names declared with `@var` in that file.
98    /// Used by `remove_file_definitions` to purge stale entries on re-analysis.
99    file_global_vars: DashMap<Arc<str>, Vec<Arc<str>>>,
100
101    /// Methods referenced during Pass 2 — stored as interned symbol IDs.
102    /// Used by the dead-code detector (M18).
103    referenced_methods: DashSet<u32>,
104    /// Properties referenced during Pass 2 — stored as interned symbol IDs.
105    referenced_properties: DashSet<u32>,
106    /// Free functions referenced during Pass 2 — stored as interned symbol IDs.
107    referenced_functions: DashSet<u32>,
108
109    /// Interner for symbol keys (`"ClassName::method"`, `"ClassName::prop"`, FQN).
110    /// Replaces repeated `Arc<str>` copies (16 bytes) with compact `u32` IDs (4 bytes).
111    pub symbol_interner: Interner,
112    /// Interner for file paths. Same memory rationale as `symbol_interner`.
113    pub file_interner: Interner,
114
115    /// Maps symbol ID → flat list of `(file_id, line, col_start, col_end)`.
116    /// IDs come from `symbol_interner` / `file_interner`.
117    symbol_reference_locations: ReferenceLocations,
118    /// Reverse index: file ID → symbol IDs referenced in that file.
119    /// Used by `remove_file_definitions` to avoid a full scan of all symbols.
120    /// A `Vec` rather than `HashSet`: duplicate sym_ids are guarded at insert time
121    /// (same as `symbol_reference_locations`) for the same structural simplicity.
122    file_symbol_references: DashMap<u32, Vec<u32>>,
123
124    /// Compact CSR view of the reference index, built by `compact_reference_index()`.
125    /// When `Some`, the build-phase DashMaps above are empty and this is the
126    /// authoritative source for all reference queries.
127    compact_ref_index: std::sync::RwLock<Option<CompactRefIndex>>,
128    /// `true` iff `compact_ref_index` is `Some`. Checked atomically before
129    /// acquiring any lock, so the fast path during Pass 2 is a single load.
130    is_compacted: std::sync::atomic::AtomicBool,
131
132    /// Maps every FQCN (class, interface, trait, enum, function) to the absolute
133    /// path of the file that defines it. Populated during Pass 1.
134    pub symbol_to_file: DashMap<Arc<str>, Arc<str>>,
135
136    /// Lightweight FQCN index populated by `SymbolTable` before Pass 1.
137    /// Enables O(1) "does this symbol exist?" checks before full definitions
138    /// are available.
139    pub known_symbols: DashSet<Arc<str>>,
140
141    /// Per-file `use` alias maps: alias → FQCN.  Populated during Pass 1.
142    ///
143    /// Key: absolute file path (as `Arc<str>`).
144    /// Value: map of `alias → fully-qualified class name`.
145    ///
146    /// Exposed as `pub` so that external consumers (e.g. `php-lsp`) can read
147    /// import data that mir already collects, instead of reimplementing it.
148    pub file_imports: DashMap<Arc<str>, std::collections::HashMap<String, String>>,
149    /// Per-file current namespace (if any).  Populated during Pass 1.
150    ///
151    /// Key: absolute file path (as `Arc<str>`).
152    /// Value: the declared namespace string (e.g. `"App\\Controller"`).
153    ///
154    /// Exposed as `pub` so that external consumers (e.g. `php-lsp`) can read
155    /// namespace data that mir already collects, instead of reimplementing it.
156    pub file_namespaces: DashMap<Arc<str>, String>,
157}
158
159impl Codebase {
160    pub fn new() -> Self {
161        Self::default()
162    }
163
164    // -----------------------------------------------------------------------
165    // Stub injection
166    // -----------------------------------------------------------------------
167
168    /// Insert all definitions from `slice` into this codebase.
169    ///
170    /// Called by generated stub modules (`src/generated/stubs_*.rs`) to register
171    /// their pre-compiled definitions. Later insertions overwrite earlier ones,
172    /// so custom stubs loaded after PHPStorm stubs act as overrides.
173    /// Merge a [`StubSlice`] into the codebase.
174    ///
175    /// When `slice.file` is `Some`, this method also writes file-keyed metadata:
176    /// `symbol_to_file`, `global_vars`, `file_namespaces`, and `file_imports`.
177    /// This includes slices produced from PHPStorm stub files — so after this
178    /// call, `file_namespaces` and `file_imports` will contain entries keyed by
179    /// stub file paths as well as user-code file paths.  That is intentional:
180    /// the lazy-load scan iterates `file_imports` but is gated by `type_exists`,
181    /// so stub-sourced entries are harmlessly short-circuited there.
182    pub fn inject_stub_slice(&self, slice: crate::storage::StubSlice) {
183        let file = slice.file.clone();
184        for cls in slice.classes {
185            if let Some(f) = &file {
186                self.symbol_to_file.insert(cls.fqcn.clone(), f.clone());
187            }
188            self.classes.insert(cls.fqcn.clone(), cls);
189        }
190        for iface in slice.interfaces {
191            if let Some(f) = &file {
192                self.symbol_to_file.insert(iface.fqcn.clone(), f.clone());
193            }
194            self.interfaces.insert(iface.fqcn.clone(), iface);
195        }
196        for tr in slice.traits {
197            if let Some(f) = &file {
198                self.symbol_to_file.insert(tr.fqcn.clone(), f.clone());
199            }
200            self.traits.insert(tr.fqcn.clone(), tr);
201        }
202        for en in slice.enums {
203            if let Some(f) = &file {
204                self.symbol_to_file.insert(en.fqcn.clone(), f.clone());
205            }
206            self.enums.insert(en.fqcn.clone(), en);
207        }
208        for func in slice.functions {
209            if let Some(f) = &file {
210                self.symbol_to_file.insert(func.fqn.clone(), f.clone());
211            }
212            self.functions.insert(func.fqn.clone(), func);
213        }
214        for (name, ty) in slice.constants {
215            self.constants.insert(name, ty);
216        }
217        if let Some(f) = &file {
218            for (name, ty) in slice.global_vars {
219                self.register_global_var(f, name, ty);
220            }
221            if let Some(ns) = slice.namespace {
222                self.file_namespaces.insert(f.clone(), ns.to_string());
223            }
224            if !slice.imports.is_empty() {
225                self.file_imports.insert(f.clone(), slice.imports);
226            }
227        }
228    }
229
230    // -----------------------------------------------------------------------
231    // Compact reference index
232    // -----------------------------------------------------------------------
233
234    /// Convert the build-phase `DashMap` reference index into a compact CSR form.
235    ///
236    /// Call this once after Pass 2 completes on all files. The method:
237    /// 1. Drains the two build-phase `DashMap`s into a single flat `Vec`.
238    /// 2. Sorts and deduplicates entries.
239    /// 3. Builds two CSR offset arrays (by symbol and by file).
240    /// 4. Clears the `DashMap`s (freeing their allocations).
241    ///
242    /// After this call all reference queries use the compact index. Incremental
243    /// re-analysis via [`Self::re_analyze_file`] will automatically decompress the
244    /// index back into `DashMap`s on the first write, then recompact can be called
245    /// again at the end of that analysis pass.
246    pub fn compact_reference_index(&self) {
247        // Collect all entries from the build-phase DashMap.
248        let mut entries: Vec<(u32, u32, u32, u16, u16)> = self
249            .symbol_reference_locations
250            .iter()
251            .flat_map(|entry| {
252                let sym_id = *entry.key();
253                entry
254                    .value()
255                    .iter()
256                    .map(move |&(file_id, line, col_start, col_end)| {
257                        (sym_id, file_id, line, col_start, col_end)
258                    })
259                    .collect::<Vec<_>>()
260            })
261            .collect();
262
263        if entries.is_empty() {
264            return;
265        }
266
267        // Sort by (sym_id, file_id, line, col_start, col_end) and drop exact duplicates.
268        entries.sort_unstable();
269        entries.dedup();
270
271        let n = entries.len();
272
273        // ---- Build symbol-keyed CSR offsets --------------------------------
274        let max_sym = entries.iter().map(|&(s, ..)| s).max().unwrap_or(0) as usize;
275        let mut sym_offsets = vec![0u32; max_sym + 2];
276        for &(sym_id, ..) in &entries {
277            sym_offsets[sym_id as usize + 1] += 1;
278        }
279        for i in 1..sym_offsets.len() {
280            sym_offsets[i] += sym_offsets[i - 1];
281        }
282
283        // ---- Build file-keyed indirect index --------------------------------
284        // `by_file[i]` is an index into `entries`; the slice is sorted by
285        // `(file_id, sym_id, line, col_start, col_end)` so CSR offsets can be computed cheaply.
286        let max_file = entries.iter().map(|&(_, f, ..)| f).max().unwrap_or(0) as usize;
287        let mut by_file: Vec<u32> = (0..n as u32).collect();
288        by_file.sort_unstable_by_key(|&i| {
289            let (sym_id, file_id, line, col_start, col_end) = entries[i as usize];
290            (file_id, sym_id, line, col_start, col_end)
291        });
292
293        let mut file_offsets = vec![0u32; max_file + 2];
294        for &idx in &by_file {
295            let file_id = entries[idx as usize].1;
296            file_offsets[file_id as usize + 1] += 1;
297        }
298        for i in 1..file_offsets.len() {
299            file_offsets[i] += file_offsets[i - 1];
300        }
301
302        *self.compact_ref_index.write().unwrap() = Some(CompactRefIndex {
303            entries,
304            sym_offsets,
305            by_file,
306            file_offsets,
307        });
308        self.is_compacted
309            .store(true, std::sync::atomic::Ordering::Release);
310
311        // Free build-phase allocations.
312        self.symbol_reference_locations.clear();
313        self.file_symbol_references.clear();
314    }
315
316    /// Decompress the compact index back into the build-phase `DashMap`s.
317    ///
318    /// Called automatically by write methods when the compact index is live.
319    /// This makes incremental re-analysis transparent: callers never need to
320    /// know whether the index is compacted or not.
321    fn ensure_expanded(&self) {
322        // Fast path: not compacted — one atomic load, no lock.
323        if !self.is_compacted.load(std::sync::atomic::Ordering::Acquire) {
324            return;
325        }
326        // Slow path: acquire write lock and decompress.
327        let mut guard = self.compact_ref_index.write().unwrap();
328        if let Some(ci) = guard.take() {
329            for &(sym_id, file_id, line, col_start, col_end) in &ci.entries {
330                record_ref(
331                    &self.symbol_reference_locations,
332                    &self.file_symbol_references,
333                    sym_id,
334                    file_id,
335                    line,
336                    col_start,
337                    col_end,
338                );
339            }
340            self.is_compacted
341                .store(false, std::sync::atomic::Ordering::Release);
342        }
343        // If another thread already decompressed (guard is now None), we're done.
344    }
345
346    // -----------------------------------------------------------------------
347    // Incremental: remove all definitions from a single file
348    // -----------------------------------------------------------------------
349
350    /// Remove all definitions and outgoing reference locations contributed by the given file.
351    /// This clears classes, interfaces, traits, enums, functions, and constants
352    /// whose defining file matches `file_path`, the file's import and namespace entries,
353    /// and all entries in symbol_reference_locations that originated from this file.
354    pub fn remove_file_definitions(&self, file_path: &str) {
355        // Collect all symbols defined in this file
356        let symbols: Vec<Arc<str>> = self
357            .symbol_to_file
358            .iter()
359            .filter(|entry| entry.value().as_ref() == file_path)
360            .map(|entry| entry.key().clone())
361            .collect();
362
363        // Remove each symbol from its respective map and from symbol_to_file.
364        for sym in &symbols {
365            self.classes.remove(sym.as_ref());
366            self.interfaces.remove(sym.as_ref());
367            self.traits.remove(sym.as_ref());
368            self.enums.remove(sym.as_ref());
369            self.functions.remove(sym.as_ref());
370            self.constants.remove(sym.as_ref());
371            self.symbol_to_file.remove(sym.as_ref());
372            self.known_symbols.remove(sym.as_ref());
373        }
374
375        // Remove file-level metadata
376        self.file_imports.remove(file_path);
377        self.file_namespaces.remove(file_path);
378
379        // Remove @var-annotated global variables declared in this file
380        if let Some((_, var_names)) = self.file_global_vars.remove(file_path) {
381            for name in var_names {
382                self.global_vars.remove(name.as_ref());
383            }
384        }
385
386        // Ensure the reference index is in DashMap form so the removal below works.
387        self.ensure_expanded();
388
389        // Remove reference locations contributed by this file.
390        // Use the reverse index to avoid a full scan of all symbols.
391        if let Some(file_id) = self.file_interner.get_id(file_path) {
392            if let Some((_, sym_ids)) = self.file_symbol_references.remove(&file_id) {
393                for sym_id in sym_ids {
394                    if let Some(mut entries) = self.symbol_reference_locations.get_mut(&sym_id) {
395                        entries.retain(|&(fid, ..)| fid != file_id);
396                    }
397                }
398            }
399        }
400    }
401
402    // -----------------------------------------------------------------------
403    // Global variable registry
404    // -----------------------------------------------------------------------
405
406    /// Record an `@var`-annotated global variable type discovered in Pass 1.
407    /// If the same variable is annotated in multiple files, the last write wins.
408    fn register_global_var(&self, file: &Arc<str>, name: Arc<str>, ty: Union) {
409        self.file_global_vars
410            .entry(file.clone())
411            .or_default()
412            .push(name.clone());
413        self.global_vars.insert(name, ty);
414    }
415
416    // -----------------------------------------------------------------------
417    // Lookups
418    // -----------------------------------------------------------------------
419
420    /// Whether a class/interface/trait/enum with this FQCN exists.
421    pub fn type_exists(&self, fqcn: &str) -> bool {
422        self.classes.contains_key(fqcn)
423            || self.interfaces.contains_key(fqcn)
424            || self.traits.contains_key(fqcn)
425            || self.enums.contains_key(fqcn)
426    }
427
428    /// Resolve a short class/function name to its FQCN using the import table
429    /// and namespace recorded for `file` during Pass 1.
430    ///
431    /// - Names already containing `\` (after stripping a leading `\`) are
432    ///   returned as-is (already fully qualified).
433    /// - `self`, `parent`, `static` are returned unchanged (caller handles them).
434    pub fn resolve_class_name(&self, file: &str, name: &str) -> String {
435        let name = name.trim_start_matches('\\');
436        if name.is_empty() {
437            return name.to_string();
438        }
439        // Fully qualified absolute paths start with '\' (already stripped above).
440        // Names containing '\' but not starting with it may be:
441        //   - Already-resolved FQCNs (e.g. Frontify\Util\Foo) — check type_exists
442        //   - Qualified relative names (e.g. Option\Some from within Frontify\Utility) — need namespace prefix
443        if name.contains('\\') {
444            // Check if the leading segment matches a use-import alias
445            let first_segment = name.split('\\').next().unwrap_or(name);
446            if let Some(imports) = self.file_imports.get(file) {
447                if let Some(resolved_prefix) = imports.get(first_segment) {
448                    let rest = &name[first_segment.len()..]; // includes leading '\'
449                    return format!("{resolved_prefix}{rest}");
450                }
451            }
452            // If already known in codebase as-is, it's FQCN — trust it
453            if self.type_exists(name) {
454                return name.to_string();
455            }
456            // Otherwise it's a relative qualified name — prepend the file namespace
457            if let Some(ns) = self.file_namespaces.get(file) {
458                let qualified = format!("{}\\{}", *ns, name);
459                if self.type_exists(&qualified) {
460                    return qualified;
461                }
462            }
463            return name.to_string();
464        }
465        // Built-in pseudo-types / keywords handled by the caller
466        match name {
467            "self" | "parent" | "static" | "this" => return name.to_string(),
468            _ => {}
469        }
470        // Check use aliases for this file (PHP class names are case-insensitive)
471        if let Some(imports) = self.file_imports.get(file) {
472            if let Some(resolved) = imports.get(name) {
473                return resolved.clone();
474            }
475            // Fall back to case-insensitive alias lookup
476            let name_lower = name.to_lowercase();
477            for (alias, resolved) in imports.iter() {
478                if alias.to_lowercase() == name_lower {
479                    return resolved.clone();
480                }
481            }
482        }
483        // Qualify with the file's namespace if one exists
484        if let Some(ns) = self.file_namespaces.get(file) {
485            let qualified = format!("{}\\{}", *ns, name);
486            // If the namespaced version exists in the codebase, use it.
487            // Otherwise fall back to the global (unqualified) name if that exists.
488            // This handles `DateTimeInterface`, `Exception`, etc. used without import
489            // while not overriding user-defined classes in namespaces.
490            if self.type_exists(&qualified) {
491                return qualified;
492            }
493            if self.type_exists(name) {
494                return name.to_string();
495            }
496            return qualified;
497        }
498        name.to_string()
499    }
500
501    // -----------------------------------------------------------------------
502    // Definition location lookups
503    // -----------------------------------------------------------------------
504
505    /// Look up the definition location of any symbol (class, interface, trait, enum, function).
506    /// Returns the file path and byte offsets.
507    pub fn get_symbol_location(&self, fqcn: &str) -> Option<crate::storage::Location> {
508        if let Some(cls) = self.classes.get(fqcn) {
509            return cls.location.clone();
510        }
511        if let Some(iface) = self.interfaces.get(fqcn) {
512            return iface.location.clone();
513        }
514        if let Some(tr) = self.traits.get(fqcn) {
515            return tr.location.clone();
516        }
517        if let Some(en) = self.enums.get(fqcn) {
518            return en.location.clone();
519        }
520        if let Some(func) = self.functions.get(fqcn) {
521            return func.location.clone();
522        }
523        None
524    }
525
526    // -----------------------------------------------------------------------
527    // Reference tracking (M18 dead-code detection)
528    // -----------------------------------------------------------------------
529
530    pub fn is_method_referenced(&self, fqcn: &str, method_name: &str) -> bool {
531        let key = format!("{}::{}", fqcn, method_name.to_lowercase());
532        match self.symbol_interner.get_id(&key) {
533            Some(id) => self.referenced_methods.contains(&id),
534            None => false,
535        }
536    }
537
538    pub fn is_property_referenced(&self, fqcn: &str, prop_name: &str) -> bool {
539        let key = format!("{fqcn}::{prop_name}");
540        match self.symbol_interner.get_id(&key) {
541            Some(id) => self.referenced_properties.contains(&id),
542            None => false,
543        }
544    }
545
546    pub fn is_function_referenced(&self, fqn: &str) -> bool {
547        match self.symbol_interner.get_id(fqn) {
548            Some(id) => self.referenced_functions.contains(&id),
549            None => false,
550        }
551    }
552
553    /// Record a method reference with its source location.
554    /// Also updates the referenced_methods DashSet for dead-code detection.
555    pub fn mark_method_referenced_at(
556        &self,
557        fqcn: &str,
558        method_name: &str,
559        file: Arc<str>,
560        line: u32,
561        col_start: u16,
562        col_end: u16,
563    ) {
564        let key = format!("{}::{}", fqcn, method_name.to_lowercase());
565        self.ensure_expanded();
566        let sym_id = self.symbol_interner.intern_str(&key);
567        let file_id = self.file_interner.intern(file);
568        self.referenced_methods.insert(sym_id);
569        record_ref(
570            &self.symbol_reference_locations,
571            &self.file_symbol_references,
572            sym_id,
573            file_id,
574            line,
575            col_start,
576            col_end,
577        );
578    }
579
580    /// Record a property reference with its source location.
581    /// Also updates the referenced_properties DashSet for dead-code detection.
582    pub fn mark_property_referenced_at(
583        &self,
584        fqcn: &str,
585        prop_name: &str,
586        file: Arc<str>,
587        line: u32,
588        col_start: u16,
589        col_end: u16,
590    ) {
591        let key = format!("{fqcn}::{prop_name}");
592        self.ensure_expanded();
593        let sym_id = self.symbol_interner.intern_str(&key);
594        let file_id = self.file_interner.intern(file);
595        self.referenced_properties.insert(sym_id);
596        record_ref(
597            &self.symbol_reference_locations,
598            &self.file_symbol_references,
599            sym_id,
600            file_id,
601            line,
602            col_start,
603            col_end,
604        );
605    }
606
607    /// Record a function reference with its source location.
608    /// Also updates the referenced_functions DashSet for dead-code detection.
609    pub fn mark_function_referenced_at(
610        &self,
611        fqn: &str,
612        file: Arc<str>,
613        line: u32,
614        col_start: u16,
615        col_end: u16,
616    ) {
617        self.ensure_expanded();
618        let sym_id = self.symbol_interner.intern_str(fqn);
619        let file_id = self.file_interner.intern(file);
620        self.referenced_functions.insert(sym_id);
621        record_ref(
622            &self.symbol_reference_locations,
623            &self.file_symbol_references,
624            sym_id,
625            file_id,
626            line,
627            col_start,
628            col_end,
629        );
630    }
631
632    /// Record a class reference (e.g. `new Foo()`) with its source location.
633    /// Does not update any dead-code DashSet — class instantiation tracking is
634    /// separate from method/property/function dead-code detection.
635    pub fn mark_class_referenced_at(
636        &self,
637        fqcn: &str,
638        file: Arc<str>,
639        line: u32,
640        col_start: u16,
641        col_end: u16,
642    ) {
643        self.ensure_expanded();
644        let sym_id = self.symbol_interner.intern_str(fqcn);
645        let file_id = self.file_interner.intern(file);
646        record_ref(
647            &self.symbol_reference_locations,
648            &self.file_symbol_references,
649            sym_id,
650            file_id,
651            line,
652            col_start,
653            col_end,
654        );
655    }
656
657    /// Replay cached reference locations for a file into the reference index.
658    /// Called on cache hits to avoid re-running Pass 2 just to rebuild the index.
659    /// `locs` is a slice of `(symbol_key, line, col_start, col_end)` as stored in the cache.
660    pub fn replay_reference_locations(&self, file: Arc<str>, locs: &[(String, u32, u16, u16)]) {
661        if locs.is_empty() {
662            return;
663        }
664        self.ensure_expanded();
665        let file_id = self.file_interner.intern(file);
666        for (symbol_key, line, col_start, col_end) in locs {
667            let sym_id = self.symbol_interner.intern_str(symbol_key);
668            record_ref(
669                &self.symbol_reference_locations,
670                &self.file_symbol_references,
671                sym_id,
672                file_id,
673                *line,
674                *col_start,
675                *col_end,
676            );
677        }
678    }
679
680    /// Return all reference locations for `symbol` as `Vec<(file, line, col_start, col_end)>`.
681    /// Returns an empty Vec if the symbol has no recorded references.
682    pub fn get_reference_locations(&self, symbol: &str) -> Vec<(Arc<str>, u32, u16, u16)> {
683        let Some(sym_id) = self.symbol_interner.get_id(symbol) else {
684            return Vec::new();
685        };
686        // Fast path: compact CSR index.
687        if let Some(ref ci) = *self.compact_ref_index.read().unwrap() {
688            let id = sym_id as usize;
689            if id + 1 >= ci.sym_offsets.len() {
690                return Vec::new();
691            }
692            let start = ci.sym_offsets[id] as usize;
693            let end = ci.sym_offsets[id + 1] as usize;
694            return ci.entries[start..end]
695                .iter()
696                .map(|&(_, file_id, line, col_start, col_end)| {
697                    (self.file_interner.get(file_id), line, col_start, col_end)
698                })
699                .collect();
700        }
701        // Slow path: build-phase DashMap.
702        let Some(entries) = self.symbol_reference_locations.get(&sym_id) else {
703            return Vec::new();
704        };
705        entries
706            .iter()
707            .map(|&(file_id, line, col_start, col_end)| {
708                (self.file_interner.get(file_id), line, col_start, col_end)
709            })
710            .collect()
711    }
712
713    /// Extract all reference locations recorded for `file` as
714    /// `(symbol_key, line, col_start, col_end)` tuples.
715    /// Used by the cache layer to persist per-file reference data between runs.
716    pub fn extract_file_reference_locations(&self, file: &str) -> Vec<(Arc<str>, u32, u16, u16)> {
717        let Some(file_id) = self.file_interner.get_id(file) else {
718            return Vec::new();
719        };
720        // Fast path: compact CSR index.
721        if let Some(ref ci) = *self.compact_ref_index.read().unwrap() {
722            let id = file_id as usize;
723            if id + 1 >= ci.file_offsets.len() {
724                return Vec::new();
725            }
726            let start = ci.file_offsets[id] as usize;
727            let end = ci.file_offsets[id + 1] as usize;
728            return ci.by_file[start..end]
729                .iter()
730                .map(|&entry_idx| {
731                    let (sym_id, _, line, col_start, col_end) = ci.entries[entry_idx as usize];
732                    (self.symbol_interner.get(sym_id), line, col_start, col_end)
733                })
734                .collect();
735        }
736        // Slow path: build-phase DashMaps.
737        let Some(sym_ids) = self.file_symbol_references.get(&file_id) else {
738            return Vec::new();
739        };
740        let mut out = Vec::new();
741        for &sym_id in sym_ids.iter() {
742            let Some(entries) = self.symbol_reference_locations.get(&sym_id) else {
743                continue;
744            };
745            let sym_key = self.symbol_interner.get(sym_id);
746            for &(entry_file_id, line, col_start, col_end) in entries.iter() {
747                if entry_file_id == file_id {
748                    out.push((sym_key.clone(), line, col_start, col_end));
749                }
750            }
751        }
752        out
753    }
754
755    /// Returns true if the given file has any recorded symbol references.
756    pub fn file_has_symbol_references(&self, file: &str) -> bool {
757        let Some(file_id) = self.file_interner.get_id(file) else {
758            return false;
759        };
760        // Check compact index first.
761        if let Some(ref ci) = *self.compact_ref_index.read().unwrap() {
762            let id = file_id as usize;
763            return id + 1 < ci.file_offsets.len() && ci.file_offsets[id] < ci.file_offsets[id + 1];
764        }
765        self.file_symbol_references.contains_key(&file_id)
766    }
767
768    // -----------------------------------------------------------------------
769    // @psalm-import-type resolution
770    // -----------------------------------------------------------------------
771
772    /// Resolve `@psalm-import-type` declarations collected in Pass 1 by copying
773    /// each referenced source class's matching `type_aliases` entry into the
774    /// importing class.  Idempotent — running it after every Pass 1 batch (or
775    /// after a lazy load) just re-imports the same aliases.
776    ///
777    /// Must be called after all classes referenced by import-type declarations
778    /// have been collected; otherwise the source `type_aliases` map is empty
779    /// and the import resolves to nothing.
780    pub fn resolve_pending_import_types(&self) {
781        // Collect imports first to avoid holding two locks simultaneously.
782        type PendingImports = Vec<(Arc<str>, Vec<(Arc<str>, Arc<str>, Arc<str>)>)>;
783        let pending: PendingImports = self
784            .classes
785            .iter()
786            .filter(|e| !e.pending_import_types.is_empty())
787            .map(|e| (e.key().clone(), e.pending_import_types.clone()))
788            .collect();
789        for (dst_fqcn, imports) in pending {
790            let mut resolved: std::collections::HashMap<Arc<str>, mir_types::Union> =
791                std::collections::HashMap::new();
792            for (local, original, from_class) in &imports {
793                if let Some(src_cls) = self.classes.get(from_class.as_ref()) {
794                    if let Some(ty) = src_cls.type_aliases.get(original.as_ref()) {
795                        resolved.insert(local.clone(), ty.clone());
796                    }
797                }
798            }
799            if !resolved.is_empty() {
800                if let Some(mut dst_cls) = self.classes.get_mut(dst_fqcn.as_ref()) {
801                    for (k, v) in resolved {
802                        dst_cls.type_aliases.insert(k, v);
803                    }
804                }
805            }
806        }
807    }
808}
809
810// ---------------------------------------------------------------------------
811// CodebaseBuilder — compose a Codebase from per-file StubSlices
812// ---------------------------------------------------------------------------
813
814/// Incremental builder that accumulates [`crate::storage::StubSlice`] values
815/// into a fresh [`Codebase`].
816///
817/// Designed for callers (e.g. salsa queries in downstream consumers) that want
818/// to treat Pass-1 definition collection as a pure function from source to
819/// `StubSlice`, then compose the slices into a full codebase outside the
820/// collector.
821pub struct CodebaseBuilder {
822    cb: Codebase,
823}
824
825impl CodebaseBuilder {
826    pub fn new() -> Self {
827        Self {
828            cb: Codebase::new(),
829        }
830    }
831
832    /// Inject a single slice. Later injections overwrite earlier definitions
833    /// with the same FQN, matching [`Codebase::inject_stub_slice`] semantics.
834    pub fn add(&mut self, slice: crate::storage::StubSlice) {
835        self.cb.inject_stub_slice(slice);
836    }
837
838    /// Resolve `@psalm-import-type` declarations and return the built `Codebase`.
839    pub fn finalize(self) -> Codebase {
840        self.cb.resolve_pending_import_types();
841        self.cb
842    }
843
844    /// Access the in-progress codebase without consuming the builder.
845    pub fn codebase(&self) -> &Codebase {
846        &self.cb
847    }
848}
849
850impl Default for CodebaseBuilder {
851    fn default() -> Self {
852        Self::new()
853    }
854}
855
856/// One-shot: build a finalized [`Codebase`] from a set of per-file slices.
857pub fn codebase_from_parts(parts: Vec<crate::storage::StubSlice>) -> Codebase {
858    let mut b = CodebaseBuilder::new();
859    for p in parts {
860        b.add(p);
861    }
862    b.finalize()
863}
864
865#[cfg(test)]
866mod tests {
867    use super::*;
868
869    fn arc(s: &str) -> Arc<str> {
870        Arc::from(s)
871    }
872
873    #[test]
874    fn method_referenced_at_groups_spans_by_file() {
875        let cb = Codebase::new();
876        cb.mark_method_referenced_at("Foo", "bar", arc("a.php"), 1, 0, 5);
877        cb.mark_method_referenced_at("Foo", "bar", arc("a.php"), 1, 10, 15);
878        cb.mark_method_referenced_at("Foo", "bar", arc("b.php"), 2, 0, 5);
879
880        let locs = cb.get_reference_locations("Foo::bar");
881        let files: std::collections::HashSet<&str> =
882            locs.iter().map(|(f, ..)| f.as_ref()).collect();
883        assert_eq!(files.len(), 2, "two files, not three spans");
884        assert!(locs.contains(&(arc("a.php"), 1, 0, 5)));
885        assert!(locs.contains(&(arc("a.php"), 1, 10, 15)));
886        assert_eq!(
887            locs.iter().filter(|(f, ..)| f.as_ref() == "a.php").count(),
888            2
889        );
890        assert!(locs.contains(&(arc("b.php"), 2, 0, 5)));
891        assert!(
892            cb.is_method_referenced("Foo", "bar"),
893            "DashSet also updated"
894        );
895    }
896
897    #[test]
898    fn duplicate_spans_are_deduplicated() {
899        let cb = Codebase::new();
900        // Same call site recorded twice (e.g. union receiver Foo|Foo)
901        cb.mark_method_referenced_at("Foo", "bar", arc("a.php"), 1, 0, 5);
902        cb.mark_method_referenced_at("Foo", "bar", arc("a.php"), 1, 0, 5);
903
904        let count = cb
905            .get_reference_locations("Foo::bar")
906            .iter()
907            .filter(|(f, ..)| f.as_ref() == "a.php")
908            .count();
909        assert_eq!(count, 1, "duplicate span deduplicated");
910    }
911
912    #[test]
913    fn method_key_is_lowercased() {
914        let cb = Codebase::new();
915        cb.mark_method_referenced_at("Cls", "MyMethod", arc("f.php"), 1, 0, 3);
916        assert!(!cb.get_reference_locations("Cls::mymethod").is_empty());
917    }
918
919    #[test]
920    fn property_referenced_at_records_location() {
921        let cb = Codebase::new();
922        cb.mark_property_referenced_at("Bar", "count", arc("x.php"), 1, 5, 10);
923
924        assert!(cb
925            .get_reference_locations("Bar::count")
926            .contains(&(arc("x.php"), 1, 5, 10)));
927        assert!(cb.is_property_referenced("Bar", "count"));
928    }
929
930    #[test]
931    fn function_referenced_at_records_location() {
932        let cb = Codebase::new();
933        cb.mark_function_referenced_at("my_fn", arc("a.php"), 1, 10, 15);
934
935        assert!(cb
936            .get_reference_locations("my_fn")
937            .contains(&(arc("a.php"), 1, 10, 15)));
938        assert!(cb.is_function_referenced("my_fn"));
939    }
940
941    #[test]
942    fn class_referenced_at_records_location() {
943        let cb = Codebase::new();
944        cb.mark_class_referenced_at("Foo", arc("a.php"), 1, 5, 8);
945
946        assert!(cb
947            .get_reference_locations("Foo")
948            .contains(&(arc("a.php"), 1, 5, 8)));
949    }
950
951    #[test]
952    fn get_reference_locations_flattens_all_files() {
953        let cb = Codebase::new();
954        cb.mark_function_referenced_at("fn1", arc("a.php"), 1, 0, 5);
955        cb.mark_function_referenced_at("fn1", arc("b.php"), 2, 0, 5);
956
957        let mut locs = cb.get_reference_locations("fn1");
958        locs.sort_by_key(|&(_, line, col, _)| (line, col));
959        assert_eq!(locs.len(), 2);
960        assert_eq!(locs[0], (arc("a.php"), 1, 0, 5));
961        assert_eq!(locs[1], (arc("b.php"), 2, 0, 5));
962    }
963
964    #[test]
965    fn replay_reference_locations_restores_index() {
966        let cb = Codebase::new();
967        let locs = vec![
968            ("Foo::bar".to_string(), 1u32, 0u16, 5u16),
969            ("Foo::bar".to_string(), 1, 10, 15),
970            ("greet".to_string(), 2, 0, 5),
971        ];
972        cb.replay_reference_locations(arc("a.php"), &locs);
973
974        let bar_locs = cb.get_reference_locations("Foo::bar");
975        assert!(bar_locs.contains(&(arc("a.php"), 1, 0, 5)));
976        assert!(bar_locs.contains(&(arc("a.php"), 1, 10, 15)));
977
978        assert!(cb
979            .get_reference_locations("greet")
980            .contains(&(arc("a.php"), 2, 0, 5)));
981
982        assert!(cb.file_has_symbol_references("a.php"));
983    }
984
985    #[test]
986    fn remove_file_clears_its_spans_only() {
987        let cb = Codebase::new();
988        cb.mark_function_referenced_at("fn1", arc("a.php"), 1, 0, 5);
989        cb.mark_function_referenced_at("fn1", arc("b.php"), 1, 10, 15);
990
991        cb.remove_file_definitions("a.php");
992
993        let locs = cb.get_reference_locations("fn1");
994        assert!(
995            !locs.iter().any(|(f, ..)| f.as_ref() == "a.php"),
996            "a.php spans removed"
997        );
998        assert!(
999            locs.contains(&(arc("b.php"), 1, 10, 15)),
1000            "b.php spans untouched"
1001        );
1002        assert!(!cb.file_has_symbol_references("a.php"));
1003    }
1004
1005    #[test]
1006    fn remove_file_does_not_affect_other_files() {
1007        let cb = Codebase::new();
1008        cb.mark_property_referenced_at("Cls", "prop", arc("x.php"), 1, 1, 4);
1009        cb.mark_property_referenced_at("Cls", "prop", arc("y.php"), 1, 7, 10);
1010
1011        cb.remove_file_definitions("x.php");
1012
1013        let locs = cb.get_reference_locations("Cls::prop");
1014        assert!(!locs.iter().any(|(f, ..)| f.as_ref() == "x.php"));
1015        assert!(locs.contains(&(arc("y.php"), 1, 7, 10)));
1016    }
1017
1018    #[test]
1019    fn remove_file_definitions_on_never_analyzed_file_is_noop() {
1020        let cb = Codebase::new();
1021        cb.mark_function_referenced_at("fn1", arc("a.php"), 1, 0, 5);
1022
1023        // "ghost.php" was never analyzed — removing it must not panic or corrupt state.
1024        cb.remove_file_definitions("ghost.php");
1025
1026        // Existing data must be untouched.
1027        assert!(cb
1028            .get_reference_locations("fn1")
1029            .contains(&(arc("a.php"), 1, 0, 5)));
1030        assert!(!cb.file_has_symbol_references("ghost.php"));
1031    }
1032
1033    #[test]
1034    fn replay_reference_locations_with_empty_list_is_noop() {
1035        let cb = Codebase::new();
1036        cb.mark_function_referenced_at("fn1", arc("a.php"), 1, 0, 5);
1037
1038        // Replaying an empty list must not touch existing entries.
1039        cb.replay_reference_locations(arc("b.php"), &[]);
1040
1041        assert!(
1042            !cb.file_has_symbol_references("b.php"),
1043            "empty replay must not create a file entry"
1044        );
1045        assert!(
1046            cb.get_reference_locations("fn1")
1047                .contains(&(arc("a.php"), 1, 0, 5)),
1048            "existing spans untouched"
1049        );
1050    }
1051
1052    #[test]
1053    fn replay_reference_locations_twice_does_not_duplicate_spans() {
1054        let cb = Codebase::new();
1055        let locs = vec![("fn1".to_string(), 1u32, 0u16, 5u16)];
1056
1057        cb.replay_reference_locations(arc("a.php"), &locs);
1058        cb.replay_reference_locations(arc("a.php"), &locs);
1059
1060        let count = cb
1061            .get_reference_locations("fn1")
1062            .iter()
1063            .filter(|(f, ..)| f.as_ref() == "a.php")
1064            .count();
1065        assert_eq!(
1066            count, 1,
1067            "replaying the same location twice must not create duplicate spans"
1068        );
1069    }
1070
1071    // -----------------------------------------------------------------------
1072    // inject_stub_slice — correctness-critical tests
1073    // -----------------------------------------------------------------------
1074
1075    fn make_fn(fqn: &str, short_name: &str) -> crate::storage::FunctionStorage {
1076        crate::storage::FunctionStorage {
1077            fqn: Arc::from(fqn),
1078            short_name: Arc::from(short_name),
1079            params: vec![],
1080            return_type: None,
1081            inferred_return_type: None,
1082            template_params: vec![],
1083            assertions: vec![],
1084            throws: vec![],
1085            deprecated: None,
1086            is_pure: false,
1087            location: None,
1088        }
1089    }
1090
1091    #[test]
1092    fn inject_stub_slice_later_injection_overwrites_earlier() {
1093        let cb = Codebase::new();
1094
1095        cb.inject_stub_slice(crate::storage::StubSlice {
1096            functions: vec![make_fn("strlen", "phpstorm_version")],
1097            file: Some(Arc::from("phpstorm/standard.php")),
1098            ..Default::default()
1099        });
1100        assert_eq!(
1101            cb.functions.get("strlen").unwrap().short_name.as_ref(),
1102            "phpstorm_version"
1103        );
1104
1105        cb.inject_stub_slice(crate::storage::StubSlice {
1106            functions: vec![make_fn("strlen", "custom_version")],
1107            file: Some(Arc::from("stubs/standard/basic.php")),
1108            ..Default::default()
1109        });
1110
1111        assert_eq!(
1112            cb.functions.get("strlen").unwrap().short_name.as_ref(),
1113            "custom_version",
1114            "custom stub must overwrite phpstorm stub"
1115        );
1116        assert_eq!(
1117            cb.symbol_to_file.get("strlen").unwrap().as_ref(),
1118            "stubs/standard/basic.php",
1119            "symbol_to_file must point to the overriding file"
1120        );
1121    }
1122
1123    #[test]
1124    fn inject_stub_slice_constants_not_added_to_symbol_to_file() {
1125        let cb = Codebase::new();
1126
1127        cb.inject_stub_slice(crate::storage::StubSlice {
1128            constants: vec![(Arc::from("PHP_EOL"), mir_types::Union::empty())],
1129            file: Some(Arc::from("stubs/core/constants.php")),
1130            ..Default::default()
1131        });
1132
1133        assert!(
1134            cb.constants.contains_key("PHP_EOL"),
1135            "constant must be registered in constants map"
1136        );
1137        assert!(
1138            !cb.symbol_to_file.contains_key("PHP_EOL"),
1139            "constants must not appear in symbol_to_file — go-to-definition is not supported for them"
1140        );
1141    }
1142
1143    #[test]
1144    fn remove_file_definitions_purges_injected_global_vars() {
1145        let cb = Codebase::new();
1146
1147        cb.inject_stub_slice(crate::storage::StubSlice {
1148            global_vars: vec![(Arc::from("db_connection"), mir_types::Union::empty())],
1149            file: Some(Arc::from("src/bootstrap.php")),
1150            ..Default::default()
1151        });
1152        assert!(
1153            cb.global_vars.contains_key("db_connection"),
1154            "global var must be registered after injection"
1155        );
1156
1157        cb.remove_file_definitions("src/bootstrap.php");
1158
1159        assert!(
1160            !cb.global_vars.contains_key("db_connection"),
1161            "global var must be removed when its defining file is removed"
1162        );
1163    }
1164
1165    #[test]
1166    fn inject_stub_slice_without_file_discards_global_vars() {
1167        let cb = Codebase::new();
1168
1169        cb.inject_stub_slice(crate::storage::StubSlice {
1170            global_vars: vec![(Arc::from("orphan_var"), mir_types::Union::empty())],
1171            file: None,
1172            ..Default::default()
1173        });
1174
1175        assert!(
1176            !cb.global_vars.contains_key("orphan_var"),
1177            "global_vars must not be registered when slice.file is None"
1178        );
1179    }
1180
1181    // These three tests guard the StubSlice → file_namespaces / file_imports contract.
1182    //
1183    // Background: inject_stub_slice is the only write path used by both
1184    // collect() (the normal project-analysis path) and collect_slice +
1185    // inject_stub_slice (the salsa/LSP incremental path and re_analyze_file).
1186    // Prior to the fix, inject_stub_slice never wrote file_namespaces or
1187    // file_imports, so any consumer that skipped the separate project.rs AST
1188    // walk ended up with empty maps and produced false UndefinedClass
1189    // diagnostics for use-aliased classes.
1190
1191    #[test]
1192    fn inject_stub_slice_populates_file_namespace() {
1193        // A slice with a namespace must cause file_namespaces to be populated
1194        // for that file so that StatementsAnalyzer can resolve unqualified names
1195        // against the correct namespace during Pass 2.
1196        let cb = Codebase::new();
1197        cb.inject_stub_slice(crate::storage::StubSlice {
1198            file: Some(Arc::from("src/Service.php")),
1199            namespace: Some(Arc::from("App\\Service")),
1200            ..Default::default()
1201        });
1202        assert_eq!(
1203            cb.file_namespaces
1204                .get("src/Service.php")
1205                .as_deref()
1206                .map(|s| s.as_str()),
1207            Some("App\\Service"),
1208            "file_namespaces must be populated when slice carries a namespace"
1209        );
1210
1211        // file=Some but namespace=None must not create a spurious entry.
1212        let cb2 = Codebase::new();
1213        cb2.inject_stub_slice(crate::storage::StubSlice {
1214            file: Some(Arc::from("src/global.php")),
1215            namespace: None,
1216            ..Default::default()
1217        });
1218        assert!(
1219            cb2.file_namespaces.is_empty(),
1220            "file_namespaces must not be written when slice.namespace is None"
1221        );
1222    }
1223
1224    #[test]
1225    fn inject_stub_slice_populates_file_imports() {
1226        // A slice with use-alias imports must cause file_imports to be
1227        // populated so that StatementsAnalyzer can resolve aliased short names
1228        // (e.g. `new Entity()` where `use App\Model\Entity` is in scope).
1229        let cb = Codebase::new();
1230        let mut imports = std::collections::HashMap::new();
1231        imports.insert("Entity".to_string(), "App\\Model\\Entity".to_string());
1232        imports.insert(
1233            "Repo".to_string(),
1234            "App\\Repository\\EntityRepo".to_string(),
1235        );
1236        cb.inject_stub_slice(crate::storage::StubSlice {
1237            file: Some(Arc::from("src/Handler.php")),
1238            imports,
1239            ..Default::default()
1240        });
1241        let stored = cb.file_imports.get("src/Handler.php").unwrap();
1242        assert_eq!(
1243            stored.get("Entity").map(|s| s.as_str()),
1244            Some("App\\Model\\Entity")
1245        );
1246        assert_eq!(
1247            stored.get("Repo").map(|s| s.as_str()),
1248            Some("App\\Repository\\EntityRepo")
1249        );
1250
1251        // file=Some but empty imports must not create a spurious entry.
1252        let cb2 = Codebase::new();
1253        cb2.inject_stub_slice(crate::storage::StubSlice {
1254            file: Some(Arc::from("src/no_imports.php")),
1255            imports: std::collections::HashMap::new(),
1256            ..Default::default()
1257        });
1258        assert!(
1259            cb2.file_imports.is_empty(),
1260            "file_imports must not be written when slice.imports is empty"
1261        );
1262    }
1263
1264    #[test]
1265    fn inject_stub_slice_skips_namespace_and_imports_when_no_file() {
1266        // Bundled stub slices (file = None) must never pollute file_namespaces
1267        // or file_imports — those maps are keyed by on-disk path and only make
1268        // sense for slices that represent a specific source file.
1269        let cb = Codebase::new();
1270        let mut imports = std::collections::HashMap::new();
1271        imports.insert("Foo".to_string(), "Bar\\Foo".to_string());
1272        cb.inject_stub_slice(crate::storage::StubSlice {
1273            file: None,
1274            namespace: Some(Arc::from("Bar")),
1275            imports,
1276            ..Default::default()
1277        });
1278        assert!(
1279            cb.file_namespaces.is_empty(),
1280            "file_namespaces must not be written when slice.file is None"
1281        );
1282        assert!(
1283            cb.file_imports.is_empty(),
1284            "file_imports must not be written when slice.file is None"
1285        );
1286    }
1287
1288    #[test]
1289    fn remove_file_definitions_purges_file_namespaces_and_imports() {
1290        // remove_file_definitions and inject_stub_slice form a round-trip:
1291        // remove clears, inject refills. This test guards the remove half for
1292        // file_namespaces and file_imports — symmetric to
1293        // remove_file_definitions_purges_injected_global_vars which guards
1294        // the same round-trip for global_vars.
1295        let cb = Codebase::new();
1296        let mut imports = std::collections::HashMap::new();
1297        imports.insert("Entity".to_string(), "App\\Model\\Entity".to_string());
1298        cb.inject_stub_slice(crate::storage::StubSlice {
1299            file: Some(Arc::from("src/Handler.php")),
1300            namespace: Some(Arc::from("App\\Service")),
1301            imports,
1302            ..Default::default()
1303        });
1304        assert!(
1305            cb.file_namespaces.contains_key("src/Handler.php"),
1306            "setup: namespace must be present"
1307        );
1308        assert!(
1309            cb.file_imports.contains_key("src/Handler.php"),
1310            "setup: imports must be present"
1311        );
1312
1313        cb.remove_file_definitions("src/Handler.php");
1314
1315        assert!(
1316            !cb.file_namespaces.contains_key("src/Handler.php"),
1317            "file_namespaces entry must be removed when its defining file is removed"
1318        );
1319        assert!(
1320            !cb.file_imports.contains_key("src/Handler.php"),
1321            "file_imports entry must be removed when its defining file is removed"
1322        );
1323    }
1324}