Skip to main content

mir_analyzer/
db.rs

1use std::collections::{HashMap, HashSet};
2
3use rustc_hash::FxHashMap;
4use std::sync::Arc;
5
6use mir_codebase::storage::{
7    Assertion, ConstantStorage, FnParam, FunctionStorage, Location, MethodStorage, PropertyStorage,
8    TemplateParam, Visibility,
9};
10use mir_codebase::StubSlice;
11use mir_issues::Issue;
12use mir_types::Union;
13
14use crate::pass2::Pass2Driver;
15use crate::PhpVersion;
16
17// MirDatabase trait
18
19/// Salsa database trait for mir incremental analysis.
20#[salsa::db]
21pub trait MirDatabase: salsa::Database {
22    /// The PHP version configured for this analysis run.
23    fn php_version_str(&self) -> Arc<str>;
24
25    /// Look up the [`ClassNode`] handle registered for `fqcn`, if any.
26    ///
27    /// This is an untracked read — the DashMap holds Salsa input *handles*
28    /// (cheap IDs), not data.  Changes to a class's *fields* (parent,
29    /// interfaces, active state) are tracked through the `ClassNode` input
30    /// itself, so downstream queries are still correctly invalidated.
31    fn lookup_class_node(&self, fqcn: &str) -> Option<ClassNode>;
32
33    /// Look up the [`FunctionNode`] handle registered for `fqn`, if any.
34    fn lookup_function_node(&self, fqn: &str) -> Option<FunctionNode>;
35
36    /// Look up the [`MethodNode`] for `(fqcn, method_name_lower)`, if any.
37    ///
38    /// `method_name_lower` must already be lowercased.  This is an untracked
39    /// read — changes to a method's fields are tracked through the `MethodNode`
40    /// input itself.
41    fn lookup_method_node(&self, fqcn: &str, method_name_lower: &str) -> Option<MethodNode>;
42
43    /// Look up the [`PropertyNode`] for `(fqcn, prop_name)`, if any.
44    fn lookup_property_node(&self, fqcn: &str, prop_name: &str) -> Option<PropertyNode>;
45
46    /// Look up the [`ClassConstantNode`] for `(fqcn, const_name)`, if any.
47    fn lookup_class_constant_node(&self, fqcn: &str, const_name: &str)
48        -> Option<ClassConstantNode>;
49
50    /// Look up the [`GlobalConstantNode`] for `fqn`, if any.
51    fn lookup_global_constant_node(&self, fqn: &str) -> Option<GlobalConstantNode>;
52
53    /// Return all own-method nodes for `fqcn`.  Empty if no class is
54    /// registered.  Untracked iteration of a per-class HashMap.
55    fn class_own_methods(&self, fqcn: &str) -> Vec<MethodNode>;
56
57    /// Return all own-property nodes for `fqcn`.  Empty if no class is
58    /// registered.  Untracked iteration of a per-class HashMap.
59    fn class_own_properties(&self, fqcn: &str) -> Vec<PropertyNode>;
60
61    /// Return all class-FQCNs currently registered as active `ClassNode`s,
62    /// optionally filtered by kind.  Untracked snapshot — callers should
63    /// treat the returned `Vec` as a one-shot view.
64    fn active_class_node_fqcns(&self) -> Vec<Arc<str>>;
65
66    /// Return all function-FQNs currently registered as active
67    /// `FunctionNode`s.  Untracked snapshot.
68    fn active_function_node_fqns(&self) -> Vec<Arc<str>>;
69
70    /// Return this file's first declared namespace, if any.
71    fn file_namespace(&self, file: &str) -> Option<Arc<str>>;
72
73    /// Return this file's `use` alias map.
74    fn file_imports(&self, file: &str) -> HashMap<String, String>;
75
76    /// Return the known type for a PHP global variable.
77    fn global_var_type(&self, name: &str) -> Option<Union>;
78
79    /// Return `(file, imports)` snapshots for every known file.
80    fn file_import_snapshots(&self) -> Vec<(Arc<str>, HashMap<String, String>)>;
81
82    /// Return the defining file for a symbol, if known.
83    fn symbol_defining_file(&self, symbol: &str) -> Option<Arc<str>>;
84
85    /// Return all symbols whose defining file is `file`.
86    fn symbols_defined_in_file(&self, file: &str) -> Vec<Arc<str>>;
87
88    /// Record a reference-location entry.
89    fn record_reference_location(&self, loc: RefLoc);
90
91    /// Replay reference locations for one file from cache.
92    fn replay_reference_locations(&self, file: Arc<str>, locs: &[(String, u32, u16, u16)]);
93
94    /// Extract reference locations for one file in cache-storage shape.
95    fn extract_file_reference_locations(&self, file: &str) -> Vec<(Arc<str>, u32, u16, u16)>;
96
97    /// Return all reference locations for one public symbol key.
98    fn reference_locations(&self, symbol: &str) -> Vec<(Arc<str>, u32, u16, u16)>;
99
100    /// Whether the public symbol key has at least one recorded reference.
101    fn has_reference(&self, symbol: &str) -> bool;
102
103    /// Clear reference locations for a file before re-analysis.
104    fn clear_file_references(&self, file: &str);
105}
106
107// SourceFile input (S1)
108
109/// Source file registered as a Salsa input.
110/// Setting `text` on an existing `SourceFile` is the single write that drives
111/// all downstream query invalidation.
112#[salsa::input]
113pub struct SourceFile {
114    pub path: Arc<str>,
115    pub text: Arc<str>,
116}
117
118// FileDefinitions (S1)
119
120/// Result of the `collect_file_definitions` tracked query.
121#[derive(Clone, Debug)]
122pub struct FileDefinitions {
123    pub slice: Arc<StubSlice>,
124    pub issues: Arc<Vec<Issue>>,
125}
126
127impl PartialEq for FileDefinitions {
128    fn eq(&self, other: &Self) -> bool {
129        Arc::ptr_eq(&self.slice, &other.slice) && Arc::ptr_eq(&self.issues, &other.issues)
130    }
131}
132
133unsafe impl salsa::Update for FileDefinitions {
134    unsafe fn maybe_update(old_pointer: *mut Self, new_value: Self) -> bool {
135        unsafe { *old_pointer = new_value };
136        true
137    }
138}
139
140// ClassNode input (S2)
141
142/// `(interface_fqcn, type_args)` pairs from `@implements Iface<T1, T2>`
143/// docblocks.  Stored on `ClassNode` for classes only.
144pub type ImplementsTypeArgs = Arc<[(Arc<str>, Arc<[Union]>)]>;
145
146/// Salsa input representing a single class or interface in the inheritance
147/// graph.  Fields are kept minimal — only what `class_ancestors` needs.
148///
149/// Invariant: every FQCN in the codebase that is known to the Salsa DB has
150/// exactly one `ClassNode` handle, stored in `MirDb::class_nodes`.  When a
151/// class is removed (file deleted or re-indexed), its node is marked
152/// `active = false` rather than dropped, so dependent `class_ancestors` queries
153/// can still observe the change and re-run.
154#[salsa::input]
155pub struct ClassNode {
156    pub fqcn: Arc<str>,
157    /// `false` when the class has been removed from the codebase.  Dependent
158    /// queries observe this change and re-run, returning empty ancestors.
159    pub active: bool,
160    pub is_interface: bool,
161    /// `true` for trait nodes.  Traits don't currently participate in the
162    /// `class_ancestors` query (it returns empty for traits), but registering
163    /// them as `ClassNode`s lets callers answer `type_exists`-style questions
164    /// through the db.
165    pub is_trait: bool,
166    /// `true` for enum nodes.  See note on `is_trait`.
167    pub is_enum: bool,
168    /// `true` if the class is declared `abstract`.  Always `false` for
169    /// interfaces, traits, and enums.
170    pub is_abstract: bool,
171    /// Direct parent class (classes only; `None` for interfaces).
172    pub parent: Option<Arc<str>>,
173    /// Directly implemented interfaces (classes only).
174    pub interfaces: Arc<[Arc<str>]>,
175    /// Used traits (classes only).  Traits are added to the ancestor list but
176    /// their own ancestors are not recursed into, matching PHP semantics.
177    pub traits: Arc<[Arc<str>]>,
178    /// Directly extended interfaces (interfaces only).
179    pub extends: Arc<[Arc<str>]>,
180    /// Declared `@template` parameters from the class/interface/trait
181    /// docblock.  Empty for classes without templates.
182    pub template_params: Arc<[TemplateParam]>,
183    /// `@psalm-require-extends` / `@phpstan-require-extends` — FQCNs that
184    /// using classes must extend.  Populated for trait nodes only; empty for
185    /// classes/interfaces/enums.
186    pub require_extends: Arc<[Arc<str>]>,
187    /// `@psalm-require-implements` / `@phpstan-require-implements` — FQCNs
188    /// that using classes must implement.  Populated for trait nodes only;
189    /// empty for classes/interfaces/enums.
190    pub require_implements: Arc<[Arc<str>]>,
191    /// `true` if this is a *backed* enum (declared with a scalar type).
192    /// Always `false` for non-enum nodes and pure (unbacked) enums.  Used by
193    /// `extends_or_implements_via_db` to answer the implicit `BackedEnum`
194    /// interface check.
195    pub is_backed_enum: bool,
196    /// `@mixin` / `@psalm-mixin` FQCNs declared on the class docblock.
197    /// Used by `lookup_method_in_chain` for delegated magic-method lookup.
198    /// Empty for interfaces, traits, and enums (mixin is a class-only
199    /// docblock concept).
200    pub mixins: Arc<[Arc<str>]>,
201    /// `@deprecated` message from the class docblock, if any.  Mirrors
202    /// `ClassStorage::deprecated`.  Empty / `None` for interfaces, traits,
203    /// and enums (S5-PR42 only mirrors the class-level field — those storages
204    /// don't carry a deprecated message).
205    pub deprecated: Option<Arc<str>>,
206    /// For backed-enum nodes: the declared scalar type (`int`/`string`).
207    /// Mirrors `EnumStorage::scalar_type`.  `None` for non-enum nodes and
208    /// for unbacked (pure) enums.  Used by the `Enum->value` property read
209    /// in `expr.rs` to return the backed scalar type instead of `mixed`.
210    pub enum_scalar_type: Option<Union>,
211    /// `true` if the class is declared `final`.  Always `false` for
212    /// interfaces, traits, and enums (PHP enums are implicitly final but the
213    /// codebase doesn't currently track that on `EnumStorage`).
214    pub is_final: bool,
215    /// `true` if the class is declared `readonly`.  Always `false` for
216    /// non-class kinds.
217    pub is_readonly: bool,
218    /// Source location of the class declaration.  Mirrors
219    /// `ClassStorage::location` (and `InterfaceStorage::location`,
220    /// `TraitStorage::location`, `EnumStorage::location`).  Used by
221    /// `ClassAnalyzer` to attribute issues to the right span.
222    pub location: Option<Location>,
223    /// Type arguments from `@extends Parent<T1, T2>` — populated for
224    /// classes only.  Mirrors `ClassStorage::extends_type_args`.
225    pub extends_type_args: Arc<[Union]>,
226    /// Type arguments from `@implements Iface<T1, T2>` — populated for
227    /// classes only.  Mirrors `ClassStorage::implements_type_args`.
228    pub implements_type_args: ImplementsTypeArgs,
229}
230
231/// Snapshot of a class's discriminator + abstractness, read from a
232/// registered active `ClassNode`.
233///
234/// Returned by [`class_kind_via_db`] when an active node exists for the
235/// given FQCN — call sites can use this in place of the corresponding
236/// `Codebase` lookups.
237#[derive(Debug, Clone, Copy)]
238pub struct ClassKind {
239    pub is_interface: bool,
240    pub is_trait: bool,
241    pub is_enum: bool,
242    pub is_abstract: bool,
243}
244
245/// Read class kind/abstractness from an active `ClassNode`, if one is
246/// registered for `fqcn`.  Returns `None` for unregistered or inactive
247/// nodes.  All bundled and user types are mirrored into `ClassNode` by
248/// `MirDb::ingest_stub_slice`, so a `None` here means the type genuinely
249/// doesn't exist (or is inactive after a `deactivate_class_node` pass).
250pub fn class_kind_via_db(db: &dyn MirDatabase, fqcn: &str) -> Option<ClassKind> {
251    let node = db.lookup_class_node(fqcn).filter(|n| n.active(db))?;
252    Some(ClassKind {
253        is_interface: node.is_interface(db),
254        is_trait: node.is_trait(db),
255        is_enum: node.is_enum(db),
256        is_abstract: node.is_abstract(db),
257    })
258}
259
260/// Whether a class/interface/trait/enum is registered as an active
261/// `ClassNode` in the db.  Returns `false` for unregistered or inactive
262/// nodes.  After `MirDb::ingest_stub_slice` has been called for all
263/// collected slices, this is the authoritative answer — bundled and user
264/// types are both mirrored.
265pub fn type_exists_via_db(db: &dyn MirDatabase, fqcn: &str) -> bool {
266    db.lookup_class_node(fqcn).is_some_and(|n| n.active(db))
267}
268
269pub fn function_exists_via_db(db: &dyn MirDatabase, fqn: &str) -> bool {
270    db.lookup_function_node(fqn).is_some_and(|n| n.active(db))
271}
272
273pub fn constant_exists_via_db(db: &dyn MirDatabase, fqn: &str) -> bool {
274    db.lookup_global_constant_node(fqn)
275        .is_some_and(|n| n.active(db))
276}
277
278pub fn resolve_name_via_db(db: &dyn MirDatabase, file: &str, name: &str) -> String {
279    if name.starts_with('\\') {
280        return name.trim_start_matches('\\').to_string();
281    }
282
283    let lower = name.to_ascii_lowercase();
284    if matches!(lower.as_str(), "self" | "static" | "parent") {
285        return name.to_string();
286    }
287
288    if name.contains('\\') {
289        if let Some(imports) = (!name.starts_with('\\')).then(|| db.file_imports(file)) {
290            if let Some((first, rest)) = name.split_once('\\') {
291                if let Some(base) = imports.get(first) {
292                    return format!("{base}\\{rest}");
293                }
294            }
295        }
296        if type_exists_via_db(db, name) {
297            return name.to_string();
298        }
299        if let Some(ns) = db.file_namespace(file) {
300            let qualified = format!("{}\\{}", ns, name);
301            if type_exists_via_db(db, &qualified) {
302                return qualified;
303            }
304        }
305        return name.to_string();
306    }
307
308    let imports = db.file_imports(file);
309    if let Some(fqcn) = imports.get(name) {
310        return fqcn.clone();
311    }
312    if let Some((_, fqcn)) = imports
313        .iter()
314        .find(|(alias, _)| alias.eq_ignore_ascii_case(name))
315    {
316        return fqcn.clone();
317    }
318    if let Some(ns) = db.file_namespace(file) {
319        return format!("{}\\{}", ns, name);
320    }
321    name.to_string()
322}
323
324/// Return the declared `@template` parameters for `fqcn` from an active
325/// `ClassNode`, if one is registered.  Returns `None` for unregistered
326/// or inactive nodes.  Authoritative after all collected slices have been
327/// fed through `ingest_stub_slice`.
328pub fn class_template_params_via_db(
329    db: &dyn MirDatabase,
330    fqcn: &str,
331) -> Option<Arc<[TemplateParam]>> {
332    let node = db.lookup_class_node(fqcn).filter(|n| n.active(db))?;
333    Some(node.template_params(db))
334}
335
336/// Walk the parent chain collecting template bindings from `@extends` type
337/// args.  Mirrors `Codebase::get_inherited_template_bindings`.
338///
339/// For `class UserRepo extends BaseRepo` with `@extends BaseRepo<User>`, this
340/// returns `{ T → User }` where `T` is `BaseRepo`'s declared template
341/// parameter.  Cycle-safe via a visited set.
342pub fn inherited_template_bindings_via_db(
343    db: &dyn MirDatabase,
344    fqcn: &str,
345) -> std::collections::HashMap<Arc<str>, Union> {
346    let mut bindings: std::collections::HashMap<Arc<str>, Union> = std::collections::HashMap::new();
347    let mut visited: rustc_hash::FxHashSet<Arc<str>> = rustc_hash::FxHashSet::default();
348    let mut current: Arc<str> = Arc::from(fqcn);
349    loop {
350        if !visited.insert(current.clone()) {
351            break;
352        }
353        let node = match db
354            .lookup_class_node(current.as_ref())
355            .filter(|n| n.active(db))
356        {
357            Some(n) => n,
358            None => break,
359        };
360        let parent = match node.parent(db) {
361            Some(p) => p,
362            None => break,
363        };
364        let extends_type_args = node.extends_type_args(db);
365        if !extends_type_args.is_empty() {
366            if let Some(parent_tps) = class_template_params_via_db(db, parent.as_ref()) {
367                for (tp, ty) in parent_tps.iter().zip(extends_type_args.iter()) {
368                    bindings
369                        .entry(tp.name.clone())
370                        .or_insert_with(|| ty.clone());
371                }
372            }
373        }
374        current = parent;
375    }
376    bindings
377}
378
379// FunctionNode input (S5-PR2)
380
381/// Salsa input representing a single global function.
382///
383/// `inferred_return_type` is the Pass-2-derived return type, populated
384/// per-function by the priming sweep.  It is committed to Salsa serially
385/// after the parallel sweep returns (so worker db clones have dropped
386/// and `Storage::cancel_others` sees strong-count==1).  The buffer-and-
387/// commit pattern lives in [`InferredReturnTypes`] and
388/// [`MirDb::commit_inferred_return_types`].
389///
390/// Invariant: every FQN known to the Salsa DB has exactly one `FunctionNode`
391/// handle in `MirDb::function_nodes`.  Removed functions are marked
392/// `active = false` rather than dropped.
393#[salsa::input]
394pub struct FunctionNode {
395    pub fqn: Arc<str>,
396    pub short_name: Arc<str>,
397    pub active: bool,
398    pub params: Arc<[FnParam]>,
399    pub return_type: Option<Arc<Union>>,
400    pub inferred_return_type: Option<Arc<Union>>,
401    pub template_params: Arc<[TemplateParam]>,
402    pub assertions: Arc<[Assertion]>,
403    pub throws: Arc<[Arc<str>]>,
404    pub deprecated: Option<Arc<str>>,
405    pub is_pure: bool,
406    /// Source location of the declaration.  `None` for functions registered
407    /// without a known origin (e.g. some legacy test fixtures).
408    pub location: Option<Location>,
409}
410
411// MethodNode input (S5-PR3)
412
413/// Salsa input representing a single method or interface/trait method.
414///
415/// `inferred_return_type` is the Pass-2-derived return type, populated per
416/// method by the priming sweep.  Committed to Salsa serially after the
417/// parallel sweep returns; see [`FunctionNode`] for the buffer-and-commit
418/// pattern that resolves the historical "S3 deadlock".
419///
420/// The node is keyed by `(fqcn, method_name_lower)` where `fqcn` is the
421/// FQCN of the **owning** class/interface/trait and `method_name_lower` is
422/// the PHP-normalised (lowercased) method name.  Nodes for classes that are
423/// removed from the codebase are marked `active = false` via
424/// `deactivate_class_methods` rather than being dropped.
425#[salsa::input]
426pub struct MethodNode {
427    pub fqcn: Arc<str>,
428    pub name: Arc<str>,
429    pub active: bool,
430    pub params: Arc<[FnParam]>,
431    pub return_type: Option<Arc<Union>>,
432    pub inferred_return_type: Option<Arc<Union>>,
433    pub template_params: Arc<[TemplateParam]>,
434    pub assertions: Arc<[Assertion]>,
435    pub throws: Arc<[Arc<str>]>,
436    pub deprecated: Option<Arc<str>>,
437    pub is_internal: bool,
438    pub visibility: Visibility,
439    pub is_static: bool,
440    pub is_abstract: bool,
441    pub is_final: bool,
442    pub is_constructor: bool,
443    pub is_pure: bool,
444    /// Source location of the declaration.  `None` for synthesized methods
445    /// (e.g. enum implicit `cases`/`from`/`tryFrom`).
446    pub location: Option<Location>,
447}
448
449// PropertyNode input (S5-PR4)
450
451/// Salsa input representing a single class/trait property.
452///
453/// `inferred_ty` is intentionally absent — it stays in `PropertyStorage` until
454/// a future S3-style tracked query promotes it.
455///
456/// Keyed by `(owner fqcn, prop_name)` — property names are case-sensitive.
457#[salsa::input]
458pub struct PropertyNode {
459    pub fqcn: Arc<str>,
460    pub name: Arc<str>,
461    pub active: bool,
462    pub ty: Option<Union>,
463    pub visibility: Visibility,
464    pub is_static: bool,
465    pub is_readonly: bool,
466    pub location: Option<Location>,
467}
468
469// ClassConstantNode input (S5-PR4)
470
471/// Salsa input representing a single class/interface/enum constant.
472///
473/// Keyed by `(owner fqcn, const_name)` — constant names are case-sensitive.
474#[salsa::input]
475pub struct ClassConstantNode {
476    pub fqcn: Arc<str>,
477    pub name: Arc<str>,
478    pub active: bool,
479    pub ty: Union,
480    pub visibility: Option<Visibility>,
481    pub is_final: bool,
482    /// Source location of the declaration.  Mirrors `ConstantStorage::location`
483    /// for class/interface/trait constants, and `EnumCaseStorage::location` for
484    /// enum cases.  `None` for nodes registered without a source span.
485    pub location: Option<Location>,
486}
487
488// GlobalConstantNode input (S5-PR47)
489
490/// Salsa input representing a global PHP constant (e.g. `PHP_EOL`).
491/// Mirrors `Codebase::constants`.
492#[salsa::input]
493pub struct GlobalConstantNode {
494    pub fqn: Arc<str>,
495    pub active: bool,
496    pub ty: Union,
497}
498
499// Ancestors return type (S2)
500
501/// The computed ancestor list for a class or interface.
502///
503/// Uses content equality so Salsa's cycle-convergence check can detect
504/// fixpoints correctly (two empty lists from different iterations are equal).
505#[derive(Clone, Debug, Default)]
506pub struct Ancestors(pub Vec<Arc<str>>);
507
508impl PartialEq for Ancestors {
509    fn eq(&self, other: &Self) -> bool {
510        self.0.len() == other.0.len()
511            && self
512                .0
513                .iter()
514                .zip(&other.0)
515                .all(|(a, b)| a.as_ref() == b.as_ref())
516    }
517}
518
519unsafe impl salsa::Update for Ancestors {
520    unsafe fn maybe_update(old_ptr: *mut Self, new_val: Self) -> bool {
521        let old = unsafe { &mut *old_ptr };
522        if *old == new_val {
523            return false;
524        }
525        *old = new_val;
526        true
527    }
528}
529
530// class_ancestors tracked query (S2)
531
532fn ancestors_initial(_db: &dyn MirDatabase, _id: salsa::Id, _node: ClassNode) -> Ancestors {
533    Ancestors(vec![])
534}
535
536fn ancestors_cycle(
537    _db: &dyn MirDatabase,
538    _cycle: &salsa::Cycle,
539    _last: &Ancestors,
540    _value: Ancestors,
541    _node: ClassNode,
542) -> Ancestors {
543    // PHP class cycles are a compile-time error.  Break immediately with an
544    // empty list so the fixpoint converges on the first iteration.
545    Ancestors(vec![])
546}
547
548/// Salsa tracked query: compute the transitive ancestor list for a class or
549/// interface.
550///
551/// Ancestors are accumulated in the same order as `Codebase::ensure_finalized`:
552/// parent → parent's ancestors → implemented interfaces + their ancestors →
553/// used traits (class); or: extended interfaces + their ancestors (interface).
554///
555/// Cycle recovery returns an empty list on the first iteration, which is
556/// correct because PHP forbids circular inheritance.
557#[salsa::tracked(cycle_fn = ancestors_cycle, cycle_initial = ancestors_initial)]
558pub fn class_ancestors(db: &dyn MirDatabase, node: ClassNode) -> Ancestors {
559    if !node.active(db) {
560        return Ancestors(vec![]);
561    }
562    // Invariant: enums and traits always return empty here.
563    // - Enums: enum membership questions go through
564    //   `extends_or_implements_via_db`, which reads `interfaces` /
565    //   `is_backed_enum` directly.
566    // - Traits: trait-of-trait walking is handled by
567    //   `method_is_concretely_implemented` / `trait_provides_method`
568    //   directly via the `traits` field.
569    // Do not lift either short-circuit without also auditing every caller
570    // of `class_ancestors`.
571    if node.is_enum(db) || node.is_trait(db) {
572        return Ancestors(vec![]);
573    }
574
575    let mut all: Vec<Arc<str>> = Vec::new();
576    let mut seen: rustc_hash::FxHashSet<Arc<str>> = rustc_hash::FxHashSet::default();
577
578    let add =
579        |fqcn: &Arc<str>, all: &mut Vec<Arc<str>>, seen: &mut rustc_hash::FxHashSet<Arc<str>>| {
580            if seen.insert(fqcn.clone()) {
581                all.push(fqcn.clone());
582            }
583        };
584
585    if node.is_interface(db) {
586        for e in node.extends(db).iter() {
587            add(e, &mut all, &mut seen);
588            if let Some(parent_node) = db.lookup_class_node(e) {
589                for a in class_ancestors(db, parent_node).0 {
590                    add(&a, &mut all, &mut seen);
591                }
592            }
593        }
594    } else {
595        if let Some(ref p) = node.parent(db) {
596            add(p, &mut all, &mut seen);
597            if let Some(parent_node) = db.lookup_class_node(p) {
598                for a in class_ancestors(db, parent_node).0 {
599                    add(&a, &mut all, &mut seen);
600                }
601            }
602        }
603        for iface in node.interfaces(db).iter() {
604            add(iface, &mut all, &mut seen);
605            if let Some(iface_node) = db.lookup_class_node(iface) {
606                for a in class_ancestors(db, iface_node).0 {
607                    add(&a, &mut all, &mut seen);
608                }
609            }
610        }
611        for t in node.traits(db).iter() {
612            add(t, &mut all, &mut seen);
613        }
614    }
615
616    Ancestors(all)
617}
618
619/// Predicate: does `fqcn` have any registered ancestor that lacks a
620/// `ClassNode` in the db?
621///
622/// `ingest_stub_slice` mirrors bundled stubs, user stubs, and PSR-4
623/// lazy-loaded definitions into the db before any Pass 2 driver runs, so
624/// a class with no active `ClassNode` is one that genuinely doesn't
625/// exist — and an unknown class trivially has no known ancestors.
626pub fn has_unknown_ancestor_via_db(db: &dyn MirDatabase, fqcn: &str) -> bool {
627    let Some(node) = db.lookup_class_node(fqcn).filter(|n| n.active(db)) else {
628        return false;
629    };
630    class_ancestors(db, node)
631        .0
632        .iter()
633        .any(|ancestor| !type_exists_via_db(db, ancestor))
634}
635
636/// Returns `true` iff `fqcn` (or any non-interface ancestor) declares a
637/// *concrete* (non-abstract) implementation of `method_name`.  Methods
638/// declared on interface ancestors are treated as abstract — interfaces don't
639/// supply implementations even though their `MethodStorage` is collected with
640/// `is_abstract = false`.  Mirrors the implemented-method semantics that
641/// [`Codebase::get_method`] hand-rolls via its `ms.is_abstract = true`
642/// rewrite for interface ancestors.
643///
644/// Method names are PHP-case-insensitive; the lookup lower-cases internally.
645/// Cycle-safe: relies on `class_ancestors` cycle recovery.
646pub fn method_is_concretely_implemented(
647    db: &dyn MirDatabase,
648    fqcn: &str,
649    method_name: &str,
650) -> bool {
651    let lower = method_name.to_lowercase();
652    let Some(self_node) = db.lookup_class_node(fqcn).filter(|n| n.active(db)) else {
653        return false;
654    };
655    // Interfaces don't supply implementations, regardless of how their methods
656    // are stored.
657    if self_node.is_interface(db) {
658        return false;
659    }
660    // 1. Direct own method.
661    if let Some(m) = db.lookup_method_node(fqcn, &lower).filter(|m| m.active(db)) {
662        if !m.is_abstract(db) {
663            return true;
664        }
665    }
666    // 2. Traits used directly by this class — walk transitively.
667    let mut visited_traits: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
668    for t in self_node.traits(db).iter() {
669        if trait_provides_method(db, t.as_ref(), &lower, &mut visited_traits) {
670            return true;
671        }
672    }
673    // 3. Ancestor chain (classes only — interfaces skipped, trait nodes here
674    //    are owning-class trait references already handled by their own walk).
675    for ancestor in class_ancestors(db, self_node).0.iter() {
676        let Some(anc_node) = db
677            .lookup_class_node(ancestor.as_ref())
678            .filter(|n| n.active(db))
679        else {
680            continue;
681        };
682        if anc_node.is_interface(db) {
683            continue;
684        }
685        // Ancestor's own method.
686        if !anc_node.is_trait(db) {
687            if let Some(m) = db
688                .lookup_method_node(ancestor.as_ref(), &lower)
689                .filter(|m| m.active(db))
690            {
691                if !m.is_abstract(db) {
692                    return true;
693                }
694            }
695        }
696        // Ancestor's used traits — walk transitively.  (For trait nodes in
697        // the ancestor list, this re-checks their own_methods + sub-traits.)
698        if anc_node.is_trait(db) {
699            if trait_provides_method(db, ancestor.as_ref(), &lower, &mut visited_traits) {
700                return true;
701            }
702        } else {
703            for t in anc_node.traits(db).iter() {
704                if trait_provides_method(db, t.as_ref(), &lower, &mut visited_traits) {
705                    return true;
706                }
707            }
708        }
709    }
710    false
711}
712
713/// Helper for [`method_is_concretely_implemented`]: walk a trait's own methods
714/// and recursively its used traits.  Returns true iff any provides a
715/// non-abstract method named `method_lower`.  Cycle-safe via `visited`.
716fn trait_provides_method(
717    db: &dyn MirDatabase,
718    trait_fqcn: &str,
719    method_lower: &str,
720    visited: &mut rustc_hash::FxHashSet<String>,
721) -> bool {
722    if !visited.insert(trait_fqcn.to_string()) {
723        return false;
724    }
725    if let Some(m) = db
726        .lookup_method_node(trait_fqcn, method_lower)
727        .filter(|m| m.active(db))
728    {
729        if !m.is_abstract(db) {
730            return true;
731        }
732    }
733    let Some(node) = db.lookup_class_node(trait_fqcn).filter(|n| n.active(db)) else {
734        return false;
735    };
736    if !node.is_trait(db) {
737        return false;
738    }
739    for t in node.traits(db).iter() {
740        if trait_provides_method(db, t.as_ref(), method_lower, visited) {
741            return true;
742        }
743    }
744    false
745}
746
747/// Returns `true` iff `fqcn` (or any ancestor / used trait, transitively)
748/// declares a method named `method_name` (abstract or concrete).  Used by
749/// magic-method existence checks (`__call`, `__callStatic`, `__invoke`,
750/// `__construct`) and intersection-type method lookups.
751///
752/// Method names are PHP-case-insensitive; the lookup lower-cases internally.
753/// Cycle-safe: relies on `class_ancestors` cycle recovery and a per-call
754/// `visited` set across trait-of-trait walks.
755/// Walk `fqcn`'s own MethodNode then the class-ancestor chain, returning the
756/// first active [`MethodNode`] whose name matches `method_name` (case-
757/// insensitive).  Mirrors [`Codebase::get_method`]'s ancestor walk.
758///
759/// Used when a caller needs the full method node (params, return type,
760/// visibility, etc.), not just an existence check.
761pub fn lookup_method_in_chain(
762    db: &dyn MirDatabase,
763    fqcn: &str,
764    method_name: &str,
765) -> Option<MethodNode> {
766    let mut visited_mixins: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
767    lookup_method_in_chain_inner(db, fqcn, &method_name.to_lowercase(), &mut visited_mixins)
768}
769
770fn lookup_method_in_chain_inner(
771    db: &dyn MirDatabase,
772    fqcn: &str,
773    lower: &str,
774    visited_mixins: &mut rustc_hash::FxHashSet<String>,
775) -> Option<MethodNode> {
776    let self_node = db.lookup_class_node(fqcn).filter(|n| n.active(db))?;
777
778    // 1. Direct own method.
779    if let Some(node) = db.lookup_method_node(fqcn, lower).filter(|n| n.active(db)) {
780        return Some(node);
781    }
782    // 2. Docblock @mixin chains (delegated magic-method lookup) — recurse so
783    //    each mixin's own walk includes its own mixins, traits, ancestors.
784    //    Cycle-safe via `visited_mixins`.
785    for m in self_node.mixins(db).iter() {
786        if visited_mixins.insert(m.to_string()) {
787            if let Some(node) = lookup_method_in_chain_inner(db, m.as_ref(), lower, visited_mixins)
788            {
789                return Some(node);
790            }
791        }
792    }
793    // 3. Traits used directly — walk transitively (trait-of-traits is *not*
794    //    included in `class_ancestors`, by design — see that fn's comments).
795    let mut visited_traits: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
796    for t in self_node.traits(db).iter() {
797        if let Some(node) = trait_provides_method_node(db, t.as_ref(), lower, &mut visited_traits) {
798            return Some(node);
799        }
800    }
801    // 4. Ancestor chain (parents, interfaces, traits — empty for enums).
802    for ancestor in class_ancestors(db, self_node).0.iter() {
803        if let Some(node) = db
804            .lookup_method_node(ancestor.as_ref(), lower)
805            .filter(|n| n.active(db))
806        {
807            return Some(node);
808        }
809        if let Some(anc_node) = db
810            .lookup_class_node(ancestor.as_ref())
811            .filter(|n| n.active(db))
812        {
813            if anc_node.is_trait(db) {
814                if let Some(node) =
815                    trait_provides_method_node(db, ancestor.as_ref(), lower, &mut visited_traits)
816                {
817                    return Some(node);
818                }
819            } else {
820                for t in anc_node.traits(db).iter() {
821                    if let Some(node) =
822                        trait_provides_method_node(db, t.as_ref(), lower, &mut visited_traits)
823                    {
824                        return Some(node);
825                    }
826                }
827                for m in anc_node.mixins(db).iter() {
828                    if visited_mixins.insert(m.to_string()) {
829                        if let Some(node) =
830                            lookup_method_in_chain_inner(db, m.as_ref(), lower, visited_mixins)
831                        {
832                            return Some(node);
833                        }
834                    }
835                }
836            }
837        }
838    }
839    None
840}
841
842/// Node-returning sibling of [`trait_declares_method`] used by
843/// [`lookup_method_in_chain`].  Walks `trait_fqcn`'s own MethodNode then its
844/// used traits transitively.  Cycle-safe via `visited`.
845fn trait_provides_method_node(
846    db: &dyn MirDatabase,
847    trait_fqcn: &str,
848    method_lower: &str,
849    visited: &mut rustc_hash::FxHashSet<String>,
850) -> Option<MethodNode> {
851    if !visited.insert(trait_fqcn.to_string()) {
852        return None;
853    }
854    if let Some(node) = db
855        .lookup_method_node(trait_fqcn, method_lower)
856        .filter(|n| n.active(db))
857    {
858        return Some(node);
859    }
860    let node = db.lookup_class_node(trait_fqcn).filter(|n| n.active(db))?;
861    if !node.is_trait(db) {
862        return None;
863    }
864    for t in node.traits(db).iter() {
865        if let Some(found) = trait_provides_method_node(db, t.as_ref(), method_lower, visited) {
866            return Some(found);
867        }
868    }
869    None
870}
871
872pub fn method_exists_via_db(db: &dyn MirDatabase, fqcn: &str, method_name: &str) -> bool {
873    let lower = method_name.to_lowercase();
874    let Some(self_node) = db.lookup_class_node(fqcn).filter(|n| n.active(db)) else {
875        return false;
876    };
877    // Direct own method.
878    if db
879        .lookup_method_node(fqcn, &lower)
880        .is_some_and(|m| m.active(db))
881    {
882        return true;
883    }
884    // Traits used directly — walk transitively.
885    let mut visited_traits: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
886    for t in self_node.traits(db).iter() {
887        if trait_declares_method(db, t.as_ref(), &lower, &mut visited_traits) {
888            return true;
889        }
890    }
891    // Ancestor chain (parents, interfaces, traits).
892    for ancestor in class_ancestors(db, self_node).0.iter() {
893        if db
894            .lookup_method_node(ancestor.as_ref(), &lower)
895            .is_some_and(|m| m.active(db))
896        {
897            return true;
898        }
899        if let Some(anc_node) = db
900            .lookup_class_node(ancestor.as_ref())
901            .filter(|n| n.active(db))
902        {
903            if anc_node.is_trait(db) {
904                if trait_declares_method(db, ancestor.as_ref(), &lower, &mut visited_traits) {
905                    return true;
906                }
907            } else {
908                for t in anc_node.traits(db).iter() {
909                    if trait_declares_method(db, t.as_ref(), &lower, &mut visited_traits) {
910                        return true;
911                    }
912                }
913            }
914        }
915    }
916    false
917}
918
919/// Existence-only sibling of [`trait_provides_method`].  Returns true iff the
920/// trait or any sub-trait declares a method named `method_lower` (abstract
921/// counts).  Cycle-safe via `visited`.
922fn trait_declares_method(
923    db: &dyn MirDatabase,
924    trait_fqcn: &str,
925    method_lower: &str,
926    visited: &mut rustc_hash::FxHashSet<String>,
927) -> bool {
928    if !visited.insert(trait_fqcn.to_string()) {
929        return false;
930    }
931    if db
932        .lookup_method_node(trait_fqcn, method_lower)
933        .is_some_and(|m| m.active(db))
934    {
935        return true;
936    }
937    let Some(node) = db.lookup_class_node(trait_fqcn).filter(|n| n.active(db)) else {
938        return false;
939    };
940    if !node.is_trait(db) {
941        return false;
942    }
943    for t in node.traits(db).iter() {
944        if trait_declares_method(db, t.as_ref(), method_lower, visited) {
945            return true;
946        }
947    }
948    false
949}
950
951/// Walk `fqcn`'s own [`PropertyNode`] then mixins, traits, and ancestors,
952/// returning the first active node whose name matches `prop_name`.
953/// Mirrors [`Codebase::get_property`]'s walk: own → mixins (recursive) →
954/// each ancestor's own + mixins → direct traits' own.  `class_ancestors`
955/// already includes parents, interfaces, and direct traits in its returned
956/// list, so the ancestor loop covers traits' `own_properties`.
957///
958/// Property names are case-sensitive in PHP.  Cycle-safe via a per-call
959/// `visited_mixins` set; `class_ancestors` itself is cycle-safe.
960pub fn lookup_property_in_chain(
961    db: &dyn MirDatabase,
962    fqcn: &str,
963    prop_name: &str,
964) -> Option<PropertyNode> {
965    let mut visited_mixins: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
966    lookup_property_in_chain_inner(db, fqcn, prop_name, &mut visited_mixins)
967}
968
969fn lookup_property_in_chain_inner(
970    db: &dyn MirDatabase,
971    fqcn: &str,
972    prop_name: &str,
973    visited_mixins: &mut rustc_hash::FxHashSet<String>,
974) -> Option<PropertyNode> {
975    let self_node = db.lookup_class_node(fqcn).filter(|n| n.active(db))?;
976
977    // 1. Own property.
978    if let Some(node) = db
979        .lookup_property_node(fqcn, prop_name)
980        .filter(|n| n.active(db))
981    {
982        return Some(node);
983    }
984    // 2. Docblock @mixin chains — recurse so each mixin's own walk includes
985    //    its own mixins, traits, ancestors.  Cycle-safe via `visited_mixins`.
986    for m in self_node.mixins(db).iter() {
987        if visited_mixins.insert(m.to_string()) {
988            if let Some(node) =
989                lookup_property_in_chain_inner(db, m.as_ref(), prop_name, visited_mixins)
990            {
991                return Some(node);
992            }
993        }
994    }
995    // 3. Ancestor chain (parents + interfaces + direct traits).  Each
996    //    ancestor may itself have `@mixin` declarations that forward
997    //    property access — recurse into those too.
998    for ancestor in class_ancestors(db, self_node).0.iter() {
999        if let Some(node) = db
1000            .lookup_property_node(ancestor.as_ref(), prop_name)
1001            .filter(|n| n.active(db))
1002        {
1003            return Some(node);
1004        }
1005        if let Some(anc_node) = db
1006            .lookup_class_node(ancestor.as_ref())
1007            .filter(|n| n.active(db))
1008        {
1009            for m in anc_node.mixins(db).iter() {
1010                if visited_mixins.insert(m.to_string()) {
1011                    if let Some(node) =
1012                        lookup_property_in_chain_inner(db, m.as_ref(), prop_name, visited_mixins)
1013                    {
1014                        return Some(node);
1015                    }
1016                }
1017            }
1018        }
1019    }
1020    None
1021}
1022
1023/// Returns `true` iff `fqcn` (or any class/interface in its ancestor chain)
1024/// declares a class constant named `const_name`.  Mirrors
1025/// [`Codebase::get_class_constant`]'s walk for existence purposes:
1026/// own → traits → ancestors (incl. interfaces).  `class_ancestors` already
1027/// includes direct traits and interfaces in its returned list, so a single
1028/// walk is sufficient.
1029///
1030/// Constant names are case-sensitive in PHP.  Cycle-safe via
1031/// `class_ancestors`'s own cycle recovery.
1032pub fn class_constant_exists_in_chain(db: &dyn MirDatabase, fqcn: &str, const_name: &str) -> bool {
1033    if db
1034        .lookup_class_constant_node(fqcn, const_name)
1035        .is_some_and(|n| n.active(db))
1036    {
1037        return true;
1038    }
1039    let Some(class_node) = db.lookup_class_node(fqcn).filter(|n| n.active(db)) else {
1040        return false;
1041    };
1042    for ancestor in class_ancestors(db, class_node).0.iter() {
1043        if db
1044            .lookup_class_constant_node(ancestor.as_ref(), const_name)
1045            .is_some_and(|n| n.active(db))
1046        {
1047            return true;
1048        }
1049    }
1050    false
1051}
1052
1053/// Look up the source location of a class member (method, property, or
1054/// class/interface/trait/enum constant including enum cases).  Walks the
1055/// inheritance chain via the same helpers used by analyzer call sites
1056/// (`lookup_method_in_chain`, `lookup_property_in_chain`,
1057/// `class_ancestors` for constants), so members defined on an ancestor
1058/// are still found.  Returns `None` if no member with that name exists,
1059/// or if the member exists but has no recorded location (e.g. a
1060/// synthesized enum implicit method).
1061pub fn member_location_via_db(
1062    db: &dyn MirDatabase,
1063    fqcn: &str,
1064    member_name: &str,
1065) -> Option<Location> {
1066    if let Some(node) = lookup_method_in_chain(db, fqcn, member_name) {
1067        if let Some(loc) = node.location(db) {
1068            return Some(loc);
1069        }
1070    }
1071    if let Some(node) = lookup_property_in_chain(db, fqcn, member_name) {
1072        if let Some(loc) = node.location(db) {
1073            return Some(loc);
1074        }
1075    }
1076    // Class/interface/trait/enum constants and enum cases.
1077    if let Some(node) = db
1078        .lookup_class_constant_node(fqcn, member_name)
1079        .filter(|n| n.active(db))
1080    {
1081        if let Some(loc) = node.location(db) {
1082            return Some(loc);
1083        }
1084    }
1085    let class_node = db.lookup_class_node(fqcn).filter(|n| n.active(db))?;
1086    for ancestor in class_ancestors(db, class_node).0.iter() {
1087        if let Some(node) = db
1088            .lookup_class_constant_node(ancestor.as_ref(), member_name)
1089            .filter(|n| n.active(db))
1090        {
1091            if let Some(loc) = node.location(db) {
1092                return Some(loc);
1093            }
1094        }
1095    }
1096    None
1097}
1098
1099/// Predicate variant of [`Codebase::extends_or_implements`] backed by the
1100/// Salsa db.
1101///
1102/// Returns `true` iff `child` is `ancestor`, or `child`'s transitive
1103/// ancestor list (via [`class_ancestors`]) contains `ancestor`.  For enums
1104/// the ancestor list is empty by construction; membership is answered
1105/// directly from the enum's directly-declared interfaces and the implicit
1106/// `UnitEnum` / `BackedEnum` interfaces.
1107///
1108/// Unregistered classes return `false` — `ingest_stub_slice` populates
1109/// the db before any Pass 2 driver runs, so a class with no active
1110/// `ClassNode` genuinely doesn't exist.
1111pub fn extends_or_implements_via_db(db: &dyn MirDatabase, child: &str, ancestor: &str) -> bool {
1112    if child == ancestor {
1113        return true;
1114    }
1115    let Some(node) = db.lookup_class_node(child).filter(|n| n.active(db)) else {
1116        return false;
1117    };
1118    if node.is_enum(db) {
1119        // Enum semantics: only directly-declared interfaces participate
1120        // (no transitive walk), plus the implicit UnitEnum / BackedEnum
1121        // interfaces.
1122        if node.interfaces(db).iter().any(|i| i.as_ref() == ancestor) {
1123            return true;
1124        }
1125        if ancestor == "UnitEnum" || ancestor == "\\UnitEnum" {
1126            return true;
1127        }
1128        if (ancestor == "BackedEnum" || ancestor == "\\BackedEnum") && node.is_backed_enum(db) {
1129            return true;
1130        }
1131        return false;
1132    }
1133    class_ancestors(db, node)
1134        .0
1135        .iter()
1136        .any(|p| p.as_ref() == ancestor)
1137}
1138
1139// collect_file_definitions tracked query (S1)
1140
1141/// Uncached version of collect_file_definitions for bulk operations like vendor
1142/// collection, where we don't need Salsa to cache the intermediate StubSlice
1143/// results. This avoids holding Arc<StubSlice> in Salsa's query cache after
1144/// ingestion.
1145pub fn collect_file_definitions_uncached(
1146    db: &dyn MirDatabase,
1147    file: SourceFile,
1148) -> FileDefinitions {
1149    let path = file.path(db);
1150    let text = file.text(db);
1151
1152    let arena = bumpalo::Bump::new();
1153    let parsed = php_rs_parser::parse(&arena, &text);
1154
1155    let mut all_issues: Vec<Issue> = parsed
1156        .errors
1157        .iter()
1158        .map(|err| {
1159            Issue::new(
1160                mir_issues::IssueKind::ParseError {
1161                    message: err.to_string(),
1162                },
1163                mir_issues::Location {
1164                    file: path.clone(),
1165                    line: 1,
1166                    line_end: 1,
1167                    col_start: 0,
1168                    col_end: 0,
1169                },
1170            )
1171        })
1172        .collect();
1173
1174    let collector =
1175        crate::collector::DefinitionCollector::new_for_slice(path, &text, &parsed.source_map);
1176    let (slice, collector_issues) = collector.collect_slice(&parsed.program);
1177    all_issues.extend(collector_issues);
1178
1179    FileDefinitions {
1180        slice: Arc::new(slice),
1181        issues: Arc::new(all_issues),
1182    }
1183}
1184
1185#[salsa::tracked]
1186pub fn collect_file_definitions(db: &dyn MirDatabase, file: SourceFile) -> FileDefinitions {
1187    collect_file_definitions_uncached(db, file)
1188}
1189
1190// MirDb concrete database
1191
1192/// Concrete in-process Salsa database.
1193///
1194/// `Clone` is required for parallel batch analysis: salsa's supported
1195/// pattern for sharing a db across threads is to give each worker its
1196/// own clone (each clone gets a fresh `ZalsaLocal`, sharing the
1197/// underlying memoization storage).  Sharing `&MirDb` across threads is
1198/// **not** supported because `salsa::Database: Send` (not `Sync`).
1199type MemberRegistry<V> = Arc<FxHashMap<Arc<str>, FxHashMap<Arc<str>, V>>>;
1200type ReferenceLocations =
1201    Arc<std::sync::Mutex<FxHashMap<Arc<str>, Vec<(Arc<str>, u32, u16, u16)>>>>;
1202
1203#[salsa::db]
1204#[derive(Default, Clone)]
1205pub struct MirDb {
1206    storage: salsa::Storage<Self>,
1207    // Keep registries behind `Arc`s so `MirDb::clone()` stays cheap for
1208    // parallel analysis workers. The salsa storage is already shared by clone;
1209    // these maps only hold stable input handles, so copy-on-write insertion is
1210    // enough for the canonical mutable db paths.
1211    /// FQCN → ClassNode handle registry (not tracked by Salsa; see
1212    /// `lookup_class_node` for the rationale). Keys are canonical FQCNs;
1213    /// case-insensitive lookups go through `class_node_keys_lower`.
1214    class_nodes: Arc<FxHashMap<Arc<str>, ClassNode>>,
1215    /// Lowercased FQCN → canonical FQCN. Maintained in lockstep with
1216    /// `class_nodes` so callers can resolve PHP's case-insensitive class
1217    /// names (`new arrayobject()` → `ArrayObject`).
1218    class_node_keys_lower: Arc<FxHashMap<String, Arc<str>>>,
1219    /// FQN → FunctionNode handle registry. Keys are canonical FQNs;
1220    /// case-insensitive lookups go through `function_node_keys_lower`.
1221    function_nodes: Arc<FxHashMap<Arc<str>, FunctionNode>>,
1222    /// Lowercased FQN → canonical FQN. Maintained in lockstep with
1223    /// `function_nodes` so callers can resolve PHP's case-insensitive
1224    /// function names (`STRLEN($x)` → `strlen`).
1225    function_node_keys_lower: Arc<FxHashMap<String, Arc<str>>>,
1226    /// (owner FQCN) → (method_name_lower → MethodNode) handle registry.
1227    method_nodes: MemberRegistry<MethodNode>,
1228    /// (owner FQCN) → (prop_name → PropertyNode) handle registry.
1229    property_nodes: MemberRegistry<PropertyNode>,
1230    /// (owner FQCN) → (const_name → ClassConstantNode) handle registry.
1231    class_constant_nodes: MemberRegistry<ClassConstantNode>,
1232    /// FQN → GlobalConstantNode handle registry.
1233    global_constant_nodes: Arc<FxHashMap<Arc<str>, GlobalConstantNode>>,
1234    /// File path → first declared namespace.
1235    file_namespaces: Arc<FxHashMap<Arc<str>, Arc<str>>>,
1236    /// File path → use-alias imports.
1237    file_imports: Arc<FxHashMap<Arc<str>, HashMap<String, String>>>,
1238    /// Global variable name (without `$`) → collected type.
1239    global_vars: Arc<FxHashMap<Arc<str>, Union>>,
1240    /// Symbol FQN → defining file.
1241    symbol_to_file: Arc<FxHashMap<Arc<str>, Arc<str>>>,
1242    /// Public symbol key → reference locations.
1243    reference_locations: ReferenceLocations,
1244}
1245
1246#[salsa::db]
1247impl salsa::Database for MirDb {}
1248
1249#[salsa::db]
1250impl MirDatabase for MirDb {
1251    fn php_version_str(&self) -> Arc<str> {
1252        Arc::from("8.2")
1253    }
1254
1255    fn lookup_class_node(&self, fqcn: &str) -> Option<ClassNode> {
1256        if let Some(&node) = self.class_nodes.get(fqcn) {
1257            return Some(node);
1258        }
1259        let lower = fqcn.to_ascii_lowercase();
1260        let canonical = self.class_node_keys_lower.get(&lower)?;
1261        self.class_nodes.get(canonical.as_ref()).copied()
1262    }
1263
1264    fn lookup_function_node(&self, fqn: &str) -> Option<FunctionNode> {
1265        if let Some(&node) = self.function_nodes.get(fqn) {
1266            return Some(node);
1267        }
1268        let lower = fqn.to_ascii_lowercase();
1269        let canonical = self.function_node_keys_lower.get(&lower)?;
1270        self.function_nodes.get(canonical.as_ref()).copied()
1271    }
1272
1273    fn lookup_method_node(&self, fqcn: &str, method_name_lower: &str) -> Option<MethodNode> {
1274        self.method_nodes
1275            .get(fqcn)
1276            .and_then(|m| m.get(method_name_lower).copied())
1277    }
1278
1279    fn lookup_property_node(&self, fqcn: &str, prop_name: &str) -> Option<PropertyNode> {
1280        self.property_nodes
1281            .get(fqcn)
1282            .and_then(|m| m.get(prop_name).copied())
1283    }
1284
1285    fn lookup_class_constant_node(
1286        &self,
1287        fqcn: &str,
1288        const_name: &str,
1289    ) -> Option<ClassConstantNode> {
1290        self.class_constant_nodes
1291            .get(fqcn)
1292            .and_then(|m| m.get(const_name).copied())
1293    }
1294
1295    fn lookup_global_constant_node(&self, fqn: &str) -> Option<GlobalConstantNode> {
1296        self.global_constant_nodes.get(fqn).copied()
1297    }
1298
1299    fn class_own_methods(&self, fqcn: &str) -> Vec<MethodNode> {
1300        self.method_nodes
1301            .get(fqcn)
1302            .map(|m| m.values().copied().collect())
1303            .unwrap_or_default()
1304    }
1305
1306    fn class_own_properties(&self, fqcn: &str) -> Vec<PropertyNode> {
1307        self.property_nodes
1308            .get(fqcn)
1309            .map(|m| m.values().copied().collect())
1310            .unwrap_or_default()
1311    }
1312
1313    fn active_class_node_fqcns(&self) -> Vec<Arc<str>> {
1314        self.class_nodes
1315            .iter()
1316            .filter_map(|(fqcn, node)| {
1317                if node.active(self) {
1318                    Some(fqcn.clone())
1319                } else {
1320                    None
1321                }
1322            })
1323            .collect()
1324    }
1325
1326    fn active_function_node_fqns(&self) -> Vec<Arc<str>> {
1327        self.function_nodes
1328            .iter()
1329            .filter_map(|(fqn, node)| {
1330                if node.active(self) {
1331                    Some(fqn.clone())
1332                } else {
1333                    None
1334                }
1335            })
1336            .collect()
1337    }
1338
1339    fn file_namespace(&self, file: &str) -> Option<Arc<str>> {
1340        self.file_namespaces.get(file).cloned()
1341    }
1342
1343    fn file_imports(&self, file: &str) -> HashMap<String, String> {
1344        self.file_imports.get(file).cloned().unwrap_or_default()
1345    }
1346
1347    fn global_var_type(&self, name: &str) -> Option<Union> {
1348        self.global_vars.get(name).cloned()
1349    }
1350
1351    fn file_import_snapshots(&self) -> Vec<(Arc<str>, HashMap<String, String>)> {
1352        self.file_imports
1353            .iter()
1354            .map(|(file, imports)| (file.clone(), imports.clone()))
1355            .collect()
1356    }
1357
1358    fn symbol_defining_file(&self, symbol: &str) -> Option<Arc<str>> {
1359        self.symbol_to_file.get(symbol).cloned()
1360    }
1361
1362    fn symbols_defined_in_file(&self, file: &str) -> Vec<Arc<str>> {
1363        self.symbol_to_file
1364            .iter()
1365            .filter_map(|(sym, defining_file)| {
1366                if defining_file.as_ref() == file {
1367                    Some(sym.clone())
1368                } else {
1369                    None
1370                }
1371            })
1372            .collect()
1373    }
1374
1375    fn record_reference_location(&self, loc: RefLoc) {
1376        let mut refs = self
1377            .reference_locations
1378            .lock()
1379            .expect("reference lock poisoned");
1380        let entry = refs.entry(loc.symbol_key).or_default();
1381        let tuple = (loc.file, loc.line, loc.col_start, loc.col_end);
1382        if !entry.iter().any(|existing| existing == &tuple) {
1383            entry.push(tuple);
1384        }
1385    }
1386
1387    fn replay_reference_locations(&self, file: Arc<str>, locs: &[(String, u32, u16, u16)]) {
1388        for (symbol, line, col_start, col_end) in locs {
1389            self.record_reference_location(RefLoc {
1390                symbol_key: Arc::from(symbol.as_str()),
1391                file: file.clone(),
1392                line: *line,
1393                col_start: *col_start,
1394                col_end: *col_end,
1395            });
1396        }
1397    }
1398
1399    fn extract_file_reference_locations(&self, file: &str) -> Vec<(Arc<str>, u32, u16, u16)> {
1400        let refs = self
1401            .reference_locations
1402            .lock()
1403            .expect("reference lock poisoned");
1404        let mut out = Vec::new();
1405        for (symbol, locs) in refs.iter() {
1406            for (loc_file, line, col_start, col_end) in locs {
1407                if loc_file.as_ref() == file {
1408                    out.push((symbol.clone(), *line, *col_start, *col_end));
1409                }
1410            }
1411        }
1412        out
1413    }
1414
1415    fn reference_locations(&self, symbol: &str) -> Vec<(Arc<str>, u32, u16, u16)> {
1416        let refs = self
1417            .reference_locations
1418            .lock()
1419            .expect("reference lock poisoned");
1420        refs.get(symbol).cloned().unwrap_or_default()
1421    }
1422
1423    fn has_reference(&self, symbol: &str) -> bool {
1424        let refs = self
1425            .reference_locations
1426            .lock()
1427            .expect("reference lock poisoned");
1428        refs.get(symbol).is_some_and(|locs| !locs.is_empty())
1429    }
1430
1431    fn clear_file_references(&self, file: &str) {
1432        let mut refs = self
1433            .reference_locations
1434            .lock()
1435            .expect("reference lock poisoned");
1436        for locs in refs.values_mut() {
1437            locs.retain(|(loc_file, _, _, _)| loc_file.as_ref() != file);
1438        }
1439    }
1440}
1441
1442/// Field bag for [`MirDb::upsert_class_node`].  Construct with `..Default::default()`
1443/// to fill in the fields that don't apply to your kind (e.g. interfaces leave
1444/// `parent`, `traits`, `mixins`, `is_abstract`, etc. at their defaults).
1445///
1446/// Per-kind constructors (`for_class` / `for_interface` / `for_trait` /
1447/// `for_enum`) seed the kind discriminators so the caller only has to populate
1448/// kind-specific fields.
1449#[derive(Debug, Clone, Default)]
1450pub struct ClassNodeFields {
1451    pub fqcn: Arc<str>,
1452    pub is_interface: bool,
1453    pub is_trait: bool,
1454    pub is_enum: bool,
1455    pub is_abstract: bool,
1456    pub parent: Option<Arc<str>>,
1457    pub interfaces: Arc<[Arc<str>]>,
1458    pub traits: Arc<[Arc<str>]>,
1459    pub extends: Arc<[Arc<str>]>,
1460    pub template_params: Arc<[TemplateParam]>,
1461    pub require_extends: Arc<[Arc<str>]>,
1462    pub require_implements: Arc<[Arc<str>]>,
1463    pub is_backed_enum: bool,
1464    pub mixins: Arc<[Arc<str>]>,
1465    pub deprecated: Option<Arc<str>>,
1466    pub enum_scalar_type: Option<Union>,
1467    pub is_final: bool,
1468    pub is_readonly: bool,
1469    pub location: Option<Location>,
1470    pub extends_type_args: Arc<[Union]>,
1471    pub implements_type_args: ImplementsTypeArgs,
1472}
1473
1474impl ClassNodeFields {
1475    pub fn for_class(fqcn: Arc<str>) -> Self {
1476        Self {
1477            fqcn,
1478            ..Self::default()
1479        }
1480    }
1481
1482    pub fn for_interface(fqcn: Arc<str>) -> Self {
1483        Self {
1484            fqcn,
1485            is_interface: true,
1486            ..Self::default()
1487        }
1488    }
1489
1490    pub fn for_trait(fqcn: Arc<str>) -> Self {
1491        Self {
1492            fqcn,
1493            is_trait: true,
1494            ..Self::default()
1495        }
1496    }
1497
1498    pub fn for_enum(fqcn: Arc<str>) -> Self {
1499        Self {
1500            fqcn,
1501            is_enum: true,
1502            ..Self::default()
1503        }
1504    }
1505}
1506
1507impl MirDb {
1508    pub fn remove_file_definitions(&mut self, file: &str) {
1509        let symbols = self.symbols_defined_in_file(file);
1510        for symbol in &symbols {
1511            self.deactivate_class_node(symbol);
1512            self.deactivate_function_node(symbol);
1513            self.deactivate_class_methods(symbol);
1514            self.deactivate_class_properties(symbol);
1515            self.deactivate_class_constants(symbol);
1516            self.deactivate_global_constant_node(symbol);
1517        }
1518        let symbol_set: HashSet<Arc<str>> = symbols.into_iter().collect();
1519        Arc::make_mut(&mut self.symbol_to_file).retain(|sym, defining_file| {
1520            defining_file.as_ref() != file && !symbol_set.contains(sym)
1521        });
1522        Arc::make_mut(&mut self.file_namespaces).retain(|path, _| path.as_ref() != file);
1523        Arc::make_mut(&mut self.file_imports).retain(|path, _| path.as_ref() != file);
1524        Arc::make_mut(&mut self.global_vars).retain(|name, _| !symbol_set.contains(name));
1525        self.clear_file_references(file);
1526    }
1527
1528    pub fn type_count(&self) -> usize {
1529        self.class_nodes
1530            .values()
1531            .filter(|node| node.active(self))
1532            .count()
1533    }
1534
1535    pub fn function_count(&self) -> usize {
1536        self.function_nodes
1537            .values()
1538            .filter(|node| node.active(self))
1539            .count()
1540    }
1541
1542    pub fn constant_count(&self) -> usize {
1543        self.global_constant_nodes
1544            .values()
1545            .filter(|node| node.active(self))
1546            .count()
1547    }
1548
1549    /// Walk one collected [`StubSlice`] and upsert the corresponding db nodes.
1550    ///
1551    /// This is the canonical post-Pass-1 ingestion path: each file's slice is
1552    /// fed in directly, so batch analysis does not need any intermediate
1553    /// mutable codebase store between Pass 1 and Pass 2.
1554    pub fn ingest_stub_slice(&mut self, slice: &StubSlice) {
1555        use std::collections::HashSet;
1556
1557        // Deduplicate param lists to save memory (many methods share identical signatures).
1558        // This reduces cold-start memory usage by ~100-150 MiB when analyzing vendor code.
1559        let mut slice = slice.clone();
1560        mir_codebase::storage::deduplicate_params_in_slice(&mut slice);
1561
1562        if let Some(file) = &slice.file {
1563            if let Some(namespace) = &slice.namespace {
1564                Arc::make_mut(&mut self.file_namespaces).insert(file.clone(), namespace.clone());
1565            }
1566            if !slice.imports.is_empty() {
1567                Arc::make_mut(&mut self.file_imports).insert(file.clone(), slice.imports.clone());
1568            }
1569            for (name, _) in &slice.global_vars {
1570                let global_name = name.strip_prefix('$').unwrap_or(name.as_ref());
1571                Arc::make_mut(&mut self.symbol_to_file)
1572                    .insert(Arc::from(global_name), file.clone());
1573            }
1574        }
1575        for (name, ty) in &slice.global_vars {
1576            let global_name = name.strip_prefix('$').unwrap_or(name.as_ref());
1577            Arc::make_mut(&mut self.global_vars).insert(Arc::from(global_name), ty.clone());
1578        }
1579
1580        for cls in &slice.classes {
1581            if let Some(file) = &slice.file {
1582                Arc::make_mut(&mut self.symbol_to_file).insert(cls.fqcn.clone(), file.clone());
1583            }
1584            self.upsert_class_node(ClassNodeFields {
1585                is_abstract: cls.is_abstract,
1586                parent: cls.parent.clone(),
1587                interfaces: Arc::from(cls.interfaces.as_ref()),
1588                traits: Arc::from(cls.traits.as_ref()),
1589                template_params: Arc::from(cls.template_params.as_ref()),
1590                mixins: Arc::from(cls.mixins.as_ref()),
1591                deprecated: cls.deprecated.clone(),
1592                is_final: cls.is_final,
1593                is_readonly: cls.is_readonly,
1594                location: cls.location.clone(),
1595                extends_type_args: Arc::from(cls.extends_type_args.as_ref()),
1596                implements_type_args: Arc::from(
1597                    cls.implements_type_args
1598                        .iter()
1599                        .map(|(iface, args)| (iface.clone(), Arc::from(args.as_ref())))
1600                        .collect::<Vec<_>>(),
1601                ),
1602                ..ClassNodeFields::for_class(cls.fqcn.clone())
1603            });
1604            if self.method_nodes.contains_key(cls.fqcn.as_ref()) {
1605                let method_keep: HashSet<&str> =
1606                    cls.own_methods.keys().map(|m| m.as_ref()).collect();
1607                self.prune_class_methods(&cls.fqcn, &method_keep);
1608            }
1609            for method in cls.own_methods.values() {
1610                // Avoid cloning complex return type Unions during vendor ingestion
1611                // by wrapping in Arc upfront. This is a per-method operation during
1612                // vendor type collection (rare after initialization), so the Arc
1613                // allocation is amortized.
1614                self.upsert_method_node(method.as_ref());
1615            }
1616            if self.property_nodes.contains_key(cls.fqcn.as_ref()) {
1617                let prop_keep: HashSet<&str> =
1618                    cls.own_properties.keys().map(|p| p.as_ref()).collect();
1619                self.prune_class_properties(&cls.fqcn, &prop_keep);
1620            }
1621            for prop in cls.own_properties.values() {
1622                self.upsert_property_node(&cls.fqcn, prop);
1623            }
1624            if self.class_constant_nodes.contains_key(cls.fqcn.as_ref()) {
1625                let const_keep: HashSet<&str> =
1626                    cls.own_constants.keys().map(|c| c.as_ref()).collect();
1627                self.prune_class_constants(&cls.fqcn, &const_keep);
1628            }
1629            for constant in cls.own_constants.values() {
1630                self.upsert_class_constant_node(&cls.fqcn, constant);
1631            }
1632        }
1633
1634        for iface in &slice.interfaces {
1635            if let Some(file) = &slice.file {
1636                Arc::make_mut(&mut self.symbol_to_file).insert(iface.fqcn.clone(), file.clone());
1637            }
1638            self.upsert_class_node(ClassNodeFields {
1639                extends: Arc::from(iface.extends.as_ref()),
1640                template_params: Arc::from(iface.template_params.as_ref()),
1641                location: iface.location.clone(),
1642                ..ClassNodeFields::for_interface(iface.fqcn.clone())
1643            });
1644            if self.method_nodes.contains_key(iface.fqcn.as_ref()) {
1645                let method_keep: HashSet<&str> =
1646                    iface.own_methods.keys().map(|m| m.as_ref()).collect();
1647                self.prune_class_methods(&iface.fqcn, &method_keep);
1648            }
1649            for method in iface.own_methods.values() {
1650                self.upsert_method_node(method.as_ref());
1651            }
1652            if self.class_constant_nodes.contains_key(iface.fqcn.as_ref()) {
1653                let const_keep: HashSet<&str> =
1654                    iface.own_constants.keys().map(|c| c.as_ref()).collect();
1655                self.prune_class_constants(&iface.fqcn, &const_keep);
1656            }
1657            for constant in iface.own_constants.values() {
1658                self.upsert_class_constant_node(&iface.fqcn, constant);
1659            }
1660        }
1661
1662        for tr in &slice.traits {
1663            if let Some(file) = &slice.file {
1664                Arc::make_mut(&mut self.symbol_to_file).insert(tr.fqcn.clone(), file.clone());
1665            }
1666            self.upsert_class_node(ClassNodeFields {
1667                traits: Arc::from(tr.traits.as_ref()),
1668                template_params: Arc::from(tr.template_params.as_ref()),
1669                require_extends: Arc::from(tr.require_extends.as_ref()),
1670                require_implements: Arc::from(tr.require_implements.as_ref()),
1671                location: tr.location.clone(),
1672                ..ClassNodeFields::for_trait(tr.fqcn.clone())
1673            });
1674            if self.method_nodes.contains_key(tr.fqcn.as_ref()) {
1675                let method_keep: HashSet<&str> =
1676                    tr.own_methods.keys().map(|m| m.as_ref()).collect();
1677                self.prune_class_methods(&tr.fqcn, &method_keep);
1678            }
1679            for method in tr.own_methods.values() {
1680                self.upsert_method_node(method.as_ref());
1681            }
1682            if self.property_nodes.contains_key(tr.fqcn.as_ref()) {
1683                let prop_keep: HashSet<&str> =
1684                    tr.own_properties.keys().map(|p| p.as_ref()).collect();
1685                self.prune_class_properties(&tr.fqcn, &prop_keep);
1686            }
1687            for prop in tr.own_properties.values() {
1688                self.upsert_property_node(&tr.fqcn, prop);
1689            }
1690            if self.class_constant_nodes.contains_key(tr.fqcn.as_ref()) {
1691                let const_keep: HashSet<&str> =
1692                    tr.own_constants.keys().map(|c| c.as_ref()).collect();
1693                self.prune_class_constants(&tr.fqcn, &const_keep);
1694            }
1695            for constant in tr.own_constants.values() {
1696                self.upsert_class_constant_node(&tr.fqcn, constant);
1697            }
1698        }
1699
1700        for en in &slice.enums {
1701            if let Some(file) = &slice.file {
1702                Arc::make_mut(&mut self.symbol_to_file).insert(en.fqcn.clone(), file.clone());
1703            }
1704            self.upsert_class_node(ClassNodeFields {
1705                interfaces: Arc::from(en.interfaces.as_ref()),
1706                is_backed_enum: en.scalar_type.is_some(),
1707                enum_scalar_type: en.scalar_type.clone(),
1708                location: en.location.clone(),
1709                ..ClassNodeFields::for_enum(en.fqcn.clone())
1710            });
1711            if self.method_nodes.contains_key(en.fqcn.as_ref()) {
1712                let mut method_keep: HashSet<&str> =
1713                    en.own_methods.keys().map(|m| m.as_ref()).collect();
1714                method_keep.insert("cases");
1715                if en.scalar_type.is_some() {
1716                    method_keep.insert("from");
1717                    method_keep.insert("tryfrom");
1718                }
1719                self.prune_class_methods(&en.fqcn, &method_keep);
1720            }
1721            for method in en.own_methods.values() {
1722                self.upsert_method_node(method.as_ref());
1723            }
1724            let synth_method = |name: &str| mir_codebase::storage::MethodStorage {
1725                fqcn: en.fqcn.clone(),
1726                name: Arc::from(name),
1727                params: Arc::from([].as_ref()),
1728                return_type: Some(Arc::new(Union::mixed())),
1729                inferred_return_type: None,
1730                visibility: Visibility::Public,
1731                is_static: true,
1732                is_abstract: false,
1733                is_constructor: false,
1734                template_params: vec![],
1735                assertions: vec![],
1736                throws: vec![],
1737                is_final: false,
1738                is_internal: false,
1739                is_pure: false,
1740                deprecated: None,
1741                location: None,
1742            };
1743            let already = |name: &str| {
1744                en.own_methods
1745                    .keys()
1746                    .any(|k| k.as_ref().eq_ignore_ascii_case(name))
1747            };
1748            if !already("cases") {
1749                self.upsert_method_node(&synth_method("cases"));
1750            }
1751            if en.scalar_type.is_some() {
1752                if !already("from") {
1753                    self.upsert_method_node(&synth_method("from"));
1754                }
1755                if !already("tryFrom") {
1756                    self.upsert_method_node(&synth_method("tryFrom"));
1757                }
1758            }
1759            if self.class_constant_nodes.contains_key(en.fqcn.as_ref()) {
1760                let mut const_keep: HashSet<&str> =
1761                    en.own_constants.keys().map(|c| c.as_ref()).collect();
1762                for case in en.cases.values() {
1763                    const_keep.insert(case.name.as_ref());
1764                }
1765                self.prune_class_constants(&en.fqcn, &const_keep);
1766            }
1767            for constant in en.own_constants.values() {
1768                self.upsert_class_constant_node(&en.fqcn, constant);
1769            }
1770            for case in en.cases.values() {
1771                let case_const = ConstantStorage {
1772                    name: case.name.clone(),
1773                    ty: mir_types::Union::mixed(),
1774                    visibility: None,
1775                    is_final: false,
1776                    location: case.location.clone(),
1777                };
1778                self.upsert_class_constant_node(&en.fqcn, &case_const);
1779            }
1780        }
1781
1782        for func in &slice.functions {
1783            if let Some(file) = &slice.file {
1784                Arc::make_mut(&mut self.symbol_to_file).insert(func.fqn.clone(), file.clone());
1785            }
1786            self.upsert_function_node(func);
1787        }
1788        for (fqn, ty) in &slice.constants {
1789            self.upsert_global_constant_node(fqn.clone(), ty.clone());
1790        }
1791    }
1792
1793    /// Create or update the `ClassNode` for `fqcn`.
1794    ///
1795    /// If a handle already exists, its fields are updated in-place so Salsa
1796    /// can track the change.  A new handle is created only on first registration.
1797    #[allow(clippy::too_many_arguments)]
1798    pub fn upsert_class_node(&mut self, fields: ClassNodeFields) -> ClassNode {
1799        use salsa::Setter as _;
1800        let ClassNodeFields {
1801            fqcn,
1802            is_interface,
1803            is_trait,
1804            is_enum,
1805            is_abstract,
1806            parent,
1807            interfaces,
1808            traits,
1809            extends,
1810            template_params,
1811            require_extends,
1812            require_implements,
1813            is_backed_enum,
1814            mixins,
1815            deprecated,
1816            enum_scalar_type,
1817            is_final,
1818            is_readonly,
1819            location,
1820            extends_type_args,
1821            implements_type_args,
1822        } = fields;
1823        if let Some(&node) = self.class_nodes.get(&fqcn) {
1824            // Fast-skip: an already-active node whose Salsa-tracked fields
1825            // match the upsert input.  Bulk re-ingest paths
1826            // (`ingest_stub_slice` / `lazy_load_*`) call this for every class
1827            // on every iteration; without the skip each call fires 13
1828            // setters, each acquiring the Salsa write lock.  Schema doesn't
1829            // mutate after Pass 1 (Pass 2 only writes `inferred_return_type`),
1830            // so an active node with matching fields is by construction up
1831            // to date.
1832            //
1833            // Mutation paths (LSP re-analyze) call `deactivate_class_node`
1834            // first; that flips `active=false`, defeating this guard so the
1835            // setters run as before.
1836            if node.active(self)
1837                && node.is_interface(self) == is_interface
1838                && node.is_trait(self) == is_trait
1839                && node.is_enum(self) == is_enum
1840                && node.is_abstract(self) == is_abstract
1841                && node.is_backed_enum(self) == is_backed_enum
1842                && node.parent(self) == parent
1843                && *node.interfaces(self) == *interfaces
1844                && *node.traits(self) == *traits
1845                && *node.extends(self) == *extends
1846                && *node.template_params(self) == *template_params
1847                && *node.require_extends(self) == *require_extends
1848                && *node.require_implements(self) == *require_implements
1849                && *node.mixins(self) == *mixins
1850                && node.deprecated(self) == deprecated
1851                && node.enum_scalar_type(self) == enum_scalar_type
1852                && node.is_final(self) == is_final
1853                && node.is_readonly(self) == is_readonly
1854                && node.location(self) == location
1855                && *node.extends_type_args(self) == *extends_type_args
1856                && *node.implements_type_args(self) == *implements_type_args
1857            {
1858                return node;
1859            }
1860            node.set_active(self).to(true);
1861            node.set_is_interface(self).to(is_interface);
1862            node.set_is_trait(self).to(is_trait);
1863            node.set_is_enum(self).to(is_enum);
1864            node.set_is_abstract(self).to(is_abstract);
1865            node.set_parent(self).to(parent);
1866            node.set_interfaces(self).to(interfaces);
1867            node.set_traits(self).to(traits);
1868            node.set_extends(self).to(extends);
1869            node.set_template_params(self).to(template_params);
1870            node.set_require_extends(self).to(require_extends);
1871            node.set_require_implements(self).to(require_implements);
1872            node.set_is_backed_enum(self).to(is_backed_enum);
1873            node.set_mixins(self).to(mixins);
1874            node.set_deprecated(self).to(deprecated);
1875            node.set_enum_scalar_type(self).to(enum_scalar_type);
1876            node.set_is_final(self).to(is_final);
1877            node.set_is_readonly(self).to(is_readonly);
1878            node.set_location(self).to(location);
1879            node.set_extends_type_args(self).to(extends_type_args);
1880            node.set_implements_type_args(self).to(implements_type_args);
1881            node
1882        } else {
1883            let node = ClassNode::new(
1884                self,
1885                fqcn.clone(),
1886                true,
1887                is_interface,
1888                is_trait,
1889                is_enum,
1890                is_abstract,
1891                parent,
1892                interfaces,
1893                traits,
1894                extends,
1895                template_params,
1896                require_extends,
1897                require_implements,
1898                is_backed_enum,
1899                mixins,
1900                deprecated,
1901                enum_scalar_type,
1902                is_final,
1903                is_readonly,
1904                location,
1905                extends_type_args,
1906                implements_type_args,
1907            );
1908            Arc::make_mut(&mut self.class_node_keys_lower)
1909                .insert(fqcn.to_ascii_lowercase(), fqcn.clone());
1910            Arc::make_mut(&mut self.class_nodes).insert(fqcn, node);
1911            node
1912        }
1913    }
1914
1915    /// Mark the `ClassNode` for `fqcn` as inactive.
1916    ///
1917    /// Dependent `class_ancestors` queries will observe the change and re-run,
1918    /// returning an empty list.
1919    pub fn deactivate_class_node(&mut self, fqcn: &str) {
1920        use salsa::Setter as _;
1921        if let Some(&node) = self.class_nodes.get(fqcn) {
1922            node.set_active(self).to(false);
1923        }
1924    }
1925
1926    /// Create or update the `FunctionNode` for the given `FunctionStorage`.
1927    pub fn upsert_function_node(&mut self, storage: &FunctionStorage) -> FunctionNode {
1928        use salsa::Setter as _;
1929        let fqn = &storage.fqn;
1930        if let Some(&node) = self.function_nodes.get(fqn.as_ref()) {
1931            // Fast-skip identical re-ingest — see `upsert_class_node` for rationale.
1932            // `inferred_return_type` is intentionally NOT compared / written:
1933            // it is owned by the priming sweep's serial commit phase
1934            // (`commit_inferred_return_types`) and Pass-1 re-ingest must not
1935            // clobber a previously-inferred value.
1936            if node.active(self)
1937                && node.short_name(self) == storage.short_name
1938                && node.is_pure(self) == storage.is_pure
1939                && node.deprecated(self) == storage.deprecated
1940                && node.return_type(self).as_deref() == storage.return_type.as_deref()
1941                && node.location(self) == storage.location
1942                && *node.params(self) == *storage.params.as_ref()
1943                && *node.template_params(self) == *storage.template_params
1944                && *node.assertions(self) == *storage.assertions
1945                && *node.throws(self) == *storage.throws
1946            {
1947                return node;
1948            }
1949            node.set_active(self).to(true);
1950            node.set_short_name(self).to(storage.short_name.clone());
1951            node.set_params(self).to(storage.params.clone());
1952            node.set_return_type(self).to(storage.return_type.clone());
1953            node.set_template_params(self)
1954                .to(Arc::from(storage.template_params.as_slice()));
1955            node.set_assertions(self)
1956                .to(Arc::from(storage.assertions.as_slice()));
1957            node.set_throws(self)
1958                .to(Arc::from(storage.throws.as_slice()));
1959            node.set_deprecated(self).to(storage.deprecated.clone());
1960            node.set_is_pure(self).to(storage.is_pure);
1961            node.set_location(self).to(storage.location.clone());
1962            node
1963        } else {
1964            let node = FunctionNode::new(
1965                self,
1966                fqn.clone(),
1967                storage.short_name.clone(),
1968                true,
1969                storage.params.clone(),
1970                storage.return_type.clone(),
1971                storage
1972                    .inferred_return_type
1973                    .as_ref()
1974                    .map(|t| Arc::new(t.clone())),
1975                Arc::from(storage.template_params.as_slice()),
1976                Arc::from(storage.assertions.as_slice()),
1977                Arc::from(storage.throws.as_slice()),
1978                storage.deprecated.clone(),
1979                storage.is_pure,
1980                storage.location.clone(),
1981            );
1982            Arc::make_mut(&mut self.function_node_keys_lower)
1983                .insert(fqn.to_ascii_lowercase(), fqn.clone());
1984            Arc::make_mut(&mut self.function_nodes).insert(fqn.clone(), node);
1985            node
1986        }
1987    }
1988
1989    /// Commit a parallel-sweep-collected [`InferredReturnTypes`] buffer
1990    /// into the Salsa db.  **Must be called serially**, after all rayon
1991    /// workers from the priming sweep have dropped their db clones, so
1992    /// that `Storage::cancel_others` sees strong-count==1 inside the
1993    /// setter.  Calling this from inside a `for_each_with` / `map_with`
1994    /// closure will deadlock.
1995    ///
1996    /// Skips writes whose value already matches the current Salsa-tracked
1997    /// value (preserves PR21's fast-skip semantics).  Skips inactive
1998    /// nodes — there's no point committing an inferred return for a node
1999    /// that has been deactivated by a re-analyze.
2000    /// Commit inferred return types collected during the priming sweep.
2001    /// Takes ownership of the function and method inferred type vectors.
2002    pub fn commit_inferred_return_types(
2003        &mut self,
2004        functions: Vec<(Arc<str>, mir_types::Union)>,
2005        methods: Vec<(Arc<str>, Arc<str>, mir_types::Union)>,
2006    ) {
2007        use salsa::Setter as _;
2008        for (fqn, inferred) in functions {
2009            if let Some(&node) = self.function_nodes.get(fqn.as_ref()) {
2010                if !node.active(self) {
2011                    continue;
2012                }
2013                let new = Some(Arc::new(inferred));
2014                if node.inferred_return_type(self) == new {
2015                    continue;
2016                }
2017                node.set_inferred_return_type(self).to(new);
2018            }
2019        }
2020        for (fqcn, name, inferred) in methods {
2021            let name_lower: Arc<str> = if name.chars().all(|c| !c.is_uppercase()) {
2022                name.clone()
2023            } else {
2024                Arc::from(name.to_lowercase().as_str())
2025            };
2026            let node = self
2027                .method_nodes
2028                .get(fqcn.as_ref())
2029                .and_then(|m| m.get(&name_lower))
2030                .copied();
2031            if let Some(node) = node {
2032                if !node.active(self) {
2033                    continue;
2034                }
2035                let new = Some(Arc::new(inferred));
2036                if node.inferred_return_type(self) == new {
2037                    continue;
2038                }
2039                node.set_inferred_return_type(self).to(new);
2040            }
2041        }
2042    }
2043
2044    /// Mark the `FunctionNode` for `fqn` as inactive.
2045    pub fn deactivate_function_node(&mut self, fqn: &str) {
2046        use salsa::Setter as _;
2047        if let Some(&node) = self.function_nodes.get(fqn) {
2048            node.set_active(self).to(false);
2049        }
2050    }
2051
2052    /// Create or update the `MethodNode` for `(storage.fqcn, storage.name.to_lowercase())`.
2053    pub fn upsert_method_node(&mut self, storage: &MethodStorage) -> MethodNode {
2054        use salsa::Setter as _;
2055        let fqcn = &storage.fqcn;
2056        let name_lower: Arc<str> = Arc::from(storage.name.to_lowercase().as_str());
2057        // Copy the existing handle out to release the immutable borrow before
2058        // calling node.set_*(self), which needs &mut self.
2059        let existing = self
2060            .method_nodes
2061            .get(fqcn.as_ref())
2062            .and_then(|m| m.get(&name_lower))
2063            .copied();
2064        if let Some(node) = existing {
2065            // Fast-skip identical re-ingest — see `upsert_class_node` for rationale.
2066            // `inferred_return_type` intentionally not compared / written here;
2067            // ownership is in the priming-sweep commit phase.
2068            if node.active(self)
2069                && node.visibility(self) == storage.visibility
2070                && node.is_static(self) == storage.is_static
2071                && node.is_abstract(self) == storage.is_abstract
2072                && node.is_final(self) == storage.is_final
2073                && node.is_constructor(self) == storage.is_constructor
2074                && node.is_pure(self) == storage.is_pure
2075                && node.is_internal(self) == storage.is_internal
2076                && node.deprecated(self) == storage.deprecated
2077                && node.return_type(self).as_deref() == storage.return_type.as_deref()
2078                && node.location(self) == storage.location
2079                && *node.params(self) == *storage.params.as_ref()
2080                && *node.template_params(self) == *storage.template_params
2081                && *node.assertions(self) == *storage.assertions
2082                && *node.throws(self) == *storage.throws
2083            {
2084                return node;
2085            }
2086            node.set_active(self).to(true);
2087            node.set_params(self).to(storage.params.clone());
2088            node.set_return_type(self).to(storage.return_type.clone());
2089            node.set_template_params(self)
2090                .to(Arc::from(storage.template_params.as_slice()));
2091            node.set_assertions(self)
2092                .to(Arc::from(storage.assertions.as_slice()));
2093            node.set_throws(self)
2094                .to(Arc::from(storage.throws.as_slice()));
2095            node.set_deprecated(self).to(storage.deprecated.clone());
2096            node.set_is_internal(self).to(storage.is_internal);
2097            node.set_visibility(self).to(storage.visibility);
2098            node.set_is_static(self).to(storage.is_static);
2099            node.set_is_abstract(self).to(storage.is_abstract);
2100            node.set_is_final(self).to(storage.is_final);
2101            node.set_is_constructor(self).to(storage.is_constructor);
2102            node.set_is_pure(self).to(storage.is_pure);
2103            node.set_location(self).to(storage.location.clone());
2104            node
2105        } else {
2106            // MethodNode::new takes &mut self; insert after it returns.
2107            let node = MethodNode::new(
2108                self,
2109                fqcn.clone(),
2110                storage.name.clone(),
2111                true,
2112                storage.params.clone(),
2113                storage.return_type.clone(),
2114                storage
2115                    .inferred_return_type
2116                    .as_ref()
2117                    .map(|t| Arc::new(t.clone())),
2118                Arc::from(storage.template_params.as_slice()),
2119                Arc::from(storage.assertions.as_slice()),
2120                Arc::from(storage.throws.as_slice()),
2121                storage.deprecated.clone(),
2122                storage.is_internal,
2123                storage.visibility,
2124                storage.is_static,
2125                storage.is_abstract,
2126                storage.is_final,
2127                storage.is_constructor,
2128                storage.is_pure,
2129                storage.location.clone(),
2130            );
2131            Arc::make_mut(&mut self.method_nodes)
2132                .entry(fqcn.clone())
2133                .or_default()
2134                .insert(name_lower, node);
2135            node
2136        }
2137    }
2138
2139    /// Mark all `MethodNode`s owned by `fqcn` as inactive.
2140    pub fn deactivate_class_methods(&mut self, fqcn: &str) {
2141        use salsa::Setter as _;
2142        let nodes: Vec<MethodNode> = match self.method_nodes.get(fqcn) {
2143            Some(methods) => methods.values().copied().collect(),
2144            None => return,
2145        };
2146        for node in nodes {
2147            node.set_active(self).to(false);
2148        }
2149    }
2150
2151    /// Deactivate `MethodNode`s for `fqcn` whose lowercased name is not in
2152    /// `keep_lower`.  Used by `ingest_stub_slice` to prune stale stub methods
2153    /// when a user file shadows a bundled-stub class with a different method
2154    /// set.  Active-only check preserves PR21's fast-skip — already-inactive
2155    /// nodes don't fire a setter.
2156    pub fn prune_class_methods<T>(&mut self, fqcn: &str, keep_lower: &std::collections::HashSet<T>)
2157    where
2158        T: Eq + std::hash::Hash + std::borrow::Borrow<str>,
2159    {
2160        use salsa::Setter as _;
2161        let candidates: Vec<MethodNode> = self
2162            .method_nodes
2163            .get(fqcn)
2164            .map(|m| {
2165                m.iter()
2166                    .filter(|(k, _)| !keep_lower.contains(k.as_ref()))
2167                    .map(|(_, n)| *n)
2168                    .collect()
2169            })
2170            .unwrap_or_default();
2171        for node in candidates {
2172            if node.active(self) {
2173                node.set_active(self).to(false);
2174            }
2175        }
2176    }
2177
2178    /// Deactivate `PropertyNode`s for `fqcn` whose name is not in `keep`.
2179    pub fn prune_class_properties<T>(&mut self, fqcn: &str, keep: &std::collections::HashSet<T>)
2180    where
2181        T: Eq + std::hash::Hash + std::borrow::Borrow<str>,
2182    {
2183        use salsa::Setter as _;
2184        let candidates: Vec<PropertyNode> = self
2185            .property_nodes
2186            .get(fqcn)
2187            .map(|m| {
2188                m.iter()
2189                    .filter(|(k, _)| !keep.contains(k.as_ref()))
2190                    .map(|(_, n)| *n)
2191                    .collect()
2192            })
2193            .unwrap_or_default();
2194        for node in candidates {
2195            if node.active(self) {
2196                node.set_active(self).to(false);
2197            }
2198        }
2199    }
2200
2201    /// Deactivate `ClassConstantNode`s for `fqcn` whose name is not in `keep`.
2202    pub fn prune_class_constants<T>(&mut self, fqcn: &str, keep: &std::collections::HashSet<T>)
2203    where
2204        T: Eq + std::hash::Hash + std::borrow::Borrow<str>,
2205    {
2206        use salsa::Setter as _;
2207        let candidates: Vec<ClassConstantNode> = self
2208            .class_constant_nodes
2209            .get(fqcn)
2210            .map(|m| {
2211                m.iter()
2212                    .filter(|(k, _)| !keep.contains(k.as_ref()))
2213                    .map(|(_, n)| *n)
2214                    .collect()
2215            })
2216            .unwrap_or_default();
2217        for node in candidates {
2218            if node.active(self) {
2219                node.set_active(self).to(false);
2220            }
2221        }
2222    }
2223
2224    /// Create or update the `PropertyNode` for `(storage.fqcn, storage.name)`.
2225    pub fn upsert_property_node(&mut self, fqcn: &Arc<str>, storage: &PropertyStorage) {
2226        use salsa::Setter as _;
2227        let existing = self
2228            .property_nodes
2229            .get(fqcn.as_ref())
2230            .and_then(|m| m.get(storage.name.as_ref()))
2231            .copied();
2232        if let Some(node) = existing {
2233            // Fast-skip identical re-ingest — see `upsert_class_node` for rationale.
2234            if node.active(self)
2235                && node.visibility(self) == storage.visibility
2236                && node.is_static(self) == storage.is_static
2237                && node.is_readonly(self) == storage.is_readonly
2238                && node.ty(self) == storage.ty
2239                && node.location(self) == storage.location
2240            {
2241                return;
2242            }
2243            node.set_active(self).to(true);
2244            node.set_ty(self).to(storage.ty.clone());
2245            node.set_visibility(self).to(storage.visibility);
2246            node.set_is_static(self).to(storage.is_static);
2247            node.set_is_readonly(self).to(storage.is_readonly);
2248            node.set_location(self).to(storage.location.clone());
2249        } else {
2250            let node = PropertyNode::new(
2251                self,
2252                fqcn.clone(),
2253                storage.name.clone(),
2254                true,
2255                storage.ty.clone(),
2256                storage.visibility,
2257                storage.is_static,
2258                storage.is_readonly,
2259                storage.location.clone(),
2260            );
2261            Arc::make_mut(&mut self.property_nodes)
2262                .entry(fqcn.clone())
2263                .or_default()
2264                .insert(storage.name.clone(), node);
2265        }
2266    }
2267
2268    /// Mark all `PropertyNode`s owned by `fqcn` as inactive.
2269    pub fn deactivate_class_properties(&mut self, fqcn: &str) {
2270        use salsa::Setter as _;
2271        let nodes: Vec<PropertyNode> = match self.property_nodes.get(fqcn) {
2272            Some(props) => props.values().copied().collect(),
2273            None => return,
2274        };
2275        for node in nodes {
2276            node.set_active(self).to(false);
2277        }
2278    }
2279
2280    /// Create or update the `ClassConstantNode` for `(fqcn, storage.name)`.
2281    pub fn upsert_class_constant_node(&mut self, fqcn: &Arc<str>, storage: &ConstantStorage) {
2282        use salsa::Setter as _;
2283        let existing = self
2284            .class_constant_nodes
2285            .get(fqcn.as_ref())
2286            .and_then(|m| m.get(storage.name.as_ref()))
2287            .copied();
2288        if let Some(node) = existing {
2289            // Fast-skip identical re-ingest — see `upsert_class_node` for rationale.
2290            if node.active(self)
2291                && node.visibility(self) == storage.visibility
2292                && node.is_final(self) == storage.is_final
2293                && node.ty(self) == storage.ty
2294                && node.location(self) == storage.location
2295            {
2296                return;
2297            }
2298            node.set_active(self).to(true);
2299            node.set_ty(self).to(storage.ty.clone());
2300            node.set_visibility(self).to(storage.visibility);
2301            node.set_is_final(self).to(storage.is_final);
2302            node.set_location(self).to(storage.location.clone());
2303        } else {
2304            let node = ClassConstantNode::new(
2305                self,
2306                fqcn.clone(),
2307                storage.name.clone(),
2308                true,
2309                storage.ty.clone(),
2310                storage.visibility,
2311                storage.is_final,
2312                storage.location.clone(),
2313            );
2314            Arc::make_mut(&mut self.class_constant_nodes)
2315                .entry(fqcn.clone())
2316                .or_default()
2317                .insert(storage.name.clone(), node);
2318        }
2319    }
2320
2321    /// Create or update the `GlobalConstantNode` for `fqn`.
2322    pub fn upsert_global_constant_node(&mut self, fqn: Arc<str>, ty: Union) -> GlobalConstantNode {
2323        use salsa::Setter as _;
2324        if let Some(&node) = self.global_constant_nodes.get(&fqn) {
2325            // Fast-skip identical re-ingest — see `upsert_class_node` for rationale.
2326            if node.active(self) && node.ty(self) == ty {
2327                return node;
2328            }
2329            node.set_active(self).to(true);
2330            node.set_ty(self).to(ty);
2331            node
2332        } else {
2333            let node = GlobalConstantNode::new(self, fqn.clone(), true, ty);
2334            Arc::make_mut(&mut self.global_constant_nodes).insert(fqn, node);
2335            node
2336        }
2337    }
2338
2339    /// Mark the `GlobalConstantNode` for `fqn` as inactive.
2340    pub fn deactivate_global_constant_node(&mut self, fqn: &str) {
2341        use salsa::Setter as _;
2342        if let Some(&node) = self.global_constant_nodes.get(fqn) {
2343            node.set_active(self).to(false);
2344        }
2345    }
2346
2347    /// Mark all `ClassConstantNode`s owned by `fqcn` as inactive.
2348    pub fn deactivate_class_constants(&mut self, fqcn: &str) {
2349        use salsa::Setter as _;
2350        let nodes: Vec<ClassConstantNode> = match self.class_constant_nodes.get(fqcn) {
2351            Some(consts) => consts.values().copied().collect(),
2352            None => return,
2353        };
2354        for node in nodes {
2355            node.set_active(self).to(false);
2356        }
2357    }
2358}
2359
2360// S4 Step 1: analyze_file accumulators + tracked-query skeleton
2361//
2362// First step toward S4 (issues + reference locations as Salsa accumulators,
2363// `analyze_file` as a tracked query).  This step is purely additive:
2364//
2365//   1. Defines `IssueAccumulator` and `RefLocAccumulator` salsa accumulator
2366//      types — push targets for analyzer-emitted issues and reference-index
2367//      entries during tracked-query evaluation.
2368//   2. Defines `analyze_file` as a tracked-query stub keyed on a
2369//      `(SourceFile, AnalyzeFileInput)` pair.  The stub does NOT perform
2370//      analysis — it accumulates only the parse errors (a strict subset of
2371//      what `collect_file_definitions` already produces, so semantics are
2372//      unchanged).  The full analyzer wiring follows in subsequent S4 PRs.
2373//
2374// Nothing in this module is wired into the batch (`analyze`) or LSP
2375// (`re_analyze_file`) paths yet.  Behavior change: zero.
2376
2377/// Salsa accumulator carrying analyzer-emitted issues.  In the eventual
2378/// S4 design, every site that today calls `IssueBuffer::add` / `Vec::push`
2379/// from inside a tracked query will instead call
2380/// `IssueAccumulator(issue).accumulate(db)`, and `re_analyze_file` will read
2381/// the accumulated issues for the file with
2382/// `analyze_file::accumulated::<IssueAccumulator>(db, file, ...)`.
2383#[salsa::accumulator]
2384#[derive(Clone, Debug)]
2385pub struct IssueAccumulator(pub Issue);
2386
2387/// Reference-index entry as produced during analysis.  Mirrors the tuple
2388/// shape that `Codebase::record_ref` accepts:
2389///
2390/// - `symbol_key`: interner-bound string (`"fn:foo"`, `"cls:Foo"`,
2391///   `"prop:Foo::$bar"`, `"cnst:Foo::BAR"`, `"meth:Foo::bar"` — same keys
2392///   `Codebase::mark_*_referenced_at` use).
2393/// - `file`: the file in which the reference appears.
2394/// - `(line, col_start, col_end)`: span within the file.
2395#[derive(Clone, Debug, PartialEq, Eq)]
2396pub struct RefLoc {
2397    pub symbol_key: Arc<str>,
2398    pub file: Arc<str>,
2399    pub line: u32,
2400    pub col_start: u16,
2401    pub col_end: u16,
2402}
2403
2404/// Salsa accumulator carrying reference-index entries.  In the eventual
2405/// S4 design this replaces the `Codebase::mark_*_referenced_at` side
2406/// effects: instead of mutating the codebase's reference index inside a
2407/// tracked query (which Salsa cannot observe), the analyzer pushes
2408/// `RefLocAccumulator(loc)` and the consumer (LSP / dead-code) reads via
2409/// `analyze_file::accumulated::<RefLocAccumulator>(db, …)`.
2410#[salsa::accumulator]
2411#[derive(Clone, Debug)]
2412pub struct RefLocAccumulator(pub RefLoc);
2413
2414/// Salsa tracked-query input for `analyze_file`.  Carries the analysis
2415/// parameters that aren't already captured by `SourceFile` itself.  Kept
2416/// minimal in this PR; subsequent PRs in the S4 chain will extend it as
2417/// the query body grows to call the full analyzer pipeline.
2418#[salsa::input]
2419pub struct AnalyzeFileInput {
2420    /// Resolved PHP version (`"8.1"`, `"8.2"`, …) used by the analyzer.
2421    /// Mirrors `ProjectAnalyzer::resolved_php_version`.
2422    pub php_version: Arc<str>,
2423}
2424
2425// S4 Step 3: Lazy inferred-type queries
2426//
2427// These tracked queries compute inferred return types on-demand during Pass 2.
2428// When `Pass2Driver` encounters a function/method call, it reads the inferred
2429// type via these queries instead of from a pre-computed buffer.
2430//
2431// This enables two key optimizations:
2432// 1. Single-pass execution: inferred types are computed as needed, not upfront
2433// 2. Incremental caching: if a dependent file doesn't call a function, its
2434//    inferred type is never computed (Salsa skips the query)
2435
2436/// Lazily computes the inferred return type for a function.
2437/// Called on-demand during Pass 2 analysis when we encounter a call to this function.
2438/// Results are cached by Salsa; re-analysis of dependent files that don't call this
2439/// function re-uses the cached inferred type.
2440///
2441/// **Current behavior (S4 PR3):** Reads from the already-committed `inferred_return_type`
2442/// field on `FunctionNode`. Double-pass orchestration (Pass 2a inference + commit) still
2443/// happens in `project.rs::analyze()`.
2444///
2445/// **Future (S4 PR4):** Will compute types on-demand by extracting the function body
2446/// from source and running inference-only Pass 2, eliminating the double-pass.
2447#[salsa::tracked]
2448pub fn inferred_function_return_type(db: &dyn MirDatabase, node: FunctionNode) -> Arc<Union> {
2449    // For now, read the already-committed inferred type from the FunctionNode input.
2450    // This is set via commit_inferred_return_types() after Pass 2a completes.
2451    node.inferred_return_type(db)
2452        .unwrap_or_else(|| Arc::new(Union::mixed()))
2453}
2454
2455/// Lazily computes the inferred return type for a method.
2456///
2457/// **Current behavior (S4 PR3):** Reads from the already-committed `inferred_return_type`
2458/// field on `MethodNode`.
2459///
2460/// **Future (S4 PR4):** Will compute types on-demand by extracting the method body
2461/// from source and running inference-only Pass 2.
2462#[salsa::tracked]
2463pub fn inferred_method_return_type(db: &dyn MirDatabase, node: MethodNode) -> Arc<Union> {
2464    // For now, read the already-committed inferred type from the MethodNode input.
2465    node.inferred_return_type(db)
2466        .unwrap_or_else(|| Arc::new(Union::mixed()))
2467}
2468
2469// Helper: collect analysis results via tracked query accumulators
2470
2471/// Collects all accumulated issues from a set of files analyzed via the
2472/// `analyze_file` tracked query. Used during batch analysis to read issues
2473/// that were emitted during tracked-query evaluation.
2474#[allow(dead_code)]
2475pub(crate) fn collect_accumulated_issues(
2476    db: &dyn MirDatabase,
2477    files: &[(Arc<str>, SourceFile)],
2478    php_version: &str,
2479) -> Vec<Issue> {
2480    let mut all_issues = Vec::new();
2481    let input = AnalyzeFileInput::new(db, Arc::from(php_version));
2482
2483    for (_path, file) in files {
2484        // Call the tracked query to trigger analysis + accumulation
2485        analyze_file(db, *file, input);
2486
2487        // Read back the accumulated issues for this file
2488        let accumulated: Vec<&IssueAccumulator> = analyze_file::accumulated(db, *file, input);
2489        for acc in accumulated {
2490            all_issues.push(acc.0.clone());
2491        }
2492    }
2493
2494    all_issues
2495}
2496
2497/// Tracked-query skeleton for `analyze_file`.
2498///
2499/// **Current behavior (S4 step 2):** parses the file, emits parse-error issues,
2500/// and calls Pass 2 to analyze function/method bodies. Issues and reference
2501/// locations are emitted via `IssueAccumulator` and `RefLocAccumulator`.
2502///
2503/// This is still a hybrid: inferred types come from the prior
2504/// `run_inference_sweep` → `commit_inferred_return_types` in the double-pass
2505/// orchestration. Future S4 PRs will replace that with lazy
2506/// `inferred_return_type(node)` tracked queries.
2507#[salsa::tracked]
2508pub fn analyze_file(db: &dyn MirDatabase, file: SourceFile, input: AnalyzeFileInput) {
2509    use salsa::Accumulator as _;
2510    let path = file.path(db);
2511    let text = file.text(db);
2512
2513    let arena = bumpalo::Bump::new();
2514    let parsed = php_rs_parser::parse(&arena, &text);
2515
2516    // Emit parse errors
2517    for err in &parsed.errors {
2518        let issue = Issue::new(
2519            mir_issues::IssueKind::ParseError {
2520                message: err.to_string(),
2521            },
2522            mir_issues::Location {
2523                file: path.clone(),
2524                line: 1,
2525                line_end: 1,
2526                col_start: 0,
2527                col_end: 0,
2528            },
2529        );
2530        IssueAccumulator(issue).accumulate(db);
2531    }
2532
2533    // If no parse errors, run full analysis via Pass2Driver
2534    if parsed.errors.is_empty() {
2535        use std::str::FromStr as _;
2536        let php_version =
2537            PhpVersion::from_str(input.php_version(db).as_ref()).unwrap_or(PhpVersion::LATEST);
2538        let driver = Pass2Driver::new(db, php_version);
2539        let (issues, _symbols) = driver.analyze_bodies(
2540            &parsed.program,
2541            path.clone(),
2542            text.as_ref(),
2543            &parsed.source_map,
2544        );
2545
2546        // Emit issues via accumulator
2547        for issue in issues {
2548            IssueAccumulator(issue).accumulate(db);
2549        }
2550
2551        // Emit reference locations via accumulator
2552        let ref_locs = db.extract_file_reference_locations(&path);
2553        for (symbol_key, line, col_start, col_end) in ref_locs {
2554            let ref_loc = RefLoc {
2555                symbol_key,
2556                file: path.clone(),
2557                line,
2558                col_start,
2559                col_end,
2560            };
2561            RefLocAccumulator(ref_loc).accumulate(db);
2562        }
2563    }
2564}
2565
2566// Tests
2567
2568#[cfg(test)]
2569mod tests {
2570    use super::*;
2571    use salsa::Setter as _;
2572
2573    fn upsert_class(
2574        db: &mut MirDb,
2575        fqcn: &str,
2576        parent: Option<Arc<str>>,
2577        extends: Arc<[Arc<str>]>,
2578        is_interface: bool,
2579    ) -> ClassNode {
2580        db.upsert_class_node(ClassNodeFields {
2581            is_interface,
2582            parent,
2583            extends,
2584            ..ClassNodeFields::for_class(Arc::from(fqcn))
2585        })
2586    }
2587
2588    #[test]
2589    fn mirdb_constructs() {
2590        let _db = MirDb::default();
2591    }
2592
2593    #[test]
2594    fn source_file_input_roundtrip() {
2595        let db = MirDb::default();
2596        let file = SourceFile::new(&db, Arc::from("/tmp/test.php"), Arc::from("<?php echo 1;"));
2597        assert_eq!(file.path(&db).as_ref(), "/tmp/test.php");
2598        assert_eq!(file.text(&db).as_ref(), "<?php echo 1;");
2599    }
2600
2601    #[test]
2602    fn collect_file_definitions_basic() {
2603        let db = MirDb::default();
2604        let src = Arc::from("<?php class Foo {}");
2605        let file = SourceFile::new(&db, Arc::from("/tmp/foo.php"), src);
2606        let defs = collect_file_definitions(&db, file);
2607        assert!(defs.issues.is_empty());
2608        assert_eq!(defs.slice.classes.len(), 1);
2609        assert_eq!(defs.slice.classes[0].fqcn.as_ref(), "Foo");
2610    }
2611
2612    #[test]
2613    fn collect_file_definitions_memoized() {
2614        let db = MirDb::default();
2615        let file = SourceFile::new(
2616            &db,
2617            Arc::from("/tmp/memo.php"),
2618            Arc::from("<?php class Bar {}"),
2619        );
2620
2621        let defs1 = collect_file_definitions(&db, file);
2622        let defs2 = collect_file_definitions(&db, file);
2623        assert!(
2624            Arc::ptr_eq(&defs1.slice, &defs2.slice),
2625            "unchanged file must return the memoized result"
2626        );
2627    }
2628
2629    #[test]
2630    fn analyze_file_accumulates_parse_errors() {
2631        let db = MirDb::default();
2632        // Unterminated string literal — guaranteed parser diagnostic.
2633        let file = SourceFile::new(
2634            &db,
2635            Arc::from("/tmp/parse_err.php"),
2636            Arc::from("<?php $x = \"unterminated"),
2637        );
2638        let input = AnalyzeFileInput::new(&db, Arc::from("8.2"));
2639        analyze_file(&db, file, input);
2640        let issues: Vec<&IssueAccumulator> = analyze_file::accumulated(&db, file, input);
2641        assert!(
2642            !issues.is_empty(),
2643            "expected parse error to surface as accumulated IssueAccumulator"
2644        );
2645        assert!(matches!(
2646            issues[0].0.kind,
2647            mir_issues::IssueKind::ParseError { .. }
2648        ));
2649    }
2650
2651    #[test]
2652    fn analyze_file_clean_input_accumulates_nothing() {
2653        let db = MirDb::default();
2654        let file = SourceFile::new(
2655            &db,
2656            Arc::from("/tmp/clean.php"),
2657            Arc::from("<?php class Foo {}"),
2658        );
2659        let input = AnalyzeFileInput::new(&db, Arc::from("8.2"));
2660        analyze_file(&db, file, input);
2661        let issues: Vec<&IssueAccumulator> = analyze_file::accumulated(&db, file, input);
2662        let refs: Vec<&RefLocAccumulator> = analyze_file::accumulated(&db, file, input);
2663        assert!(issues.is_empty());
2664        assert!(refs.is_empty());
2665    }
2666
2667    #[test]
2668    fn analyze_file_calls_pass2_for_undefined_class() {
2669        let mut db = MirDb::default();
2670        // Load stubs so we have a baseline codebase
2671        for slice in crate::stubs::builtin_stub_slices_for_version(crate::PhpVersion::LATEST) {
2672            db.ingest_stub_slice(&slice);
2673        }
2674
2675        let file = SourceFile::new(
2676            &db,
2677            Arc::from("/tmp/test_pass2.php"),
2678            Arc::from("<?php function foo() { new UndefinedClass(); }"),
2679        );
2680        let input = AnalyzeFileInput::new(&db, Arc::from("8.2"));
2681        analyze_file(&db, file, input);
2682        let issues: Vec<&IssueAccumulator> = analyze_file::accumulated(&db, file, input);
2683
2684        assert!(
2685            !issues.is_empty(),
2686            "Pass2Driver should emit UndefinedClass issue"
2687        );
2688        assert!(issues
2689            .iter()
2690            .any(|acc| matches!(acc.0.kind, mir_issues::IssueKind::UndefinedClass { .. })));
2691    }
2692
2693    #[test]
2694    fn inferred_function_return_type_query_defined() {
2695        let mut db = MirDb::default();
2696
2697        // Create a simple function via FunctionStorage
2698        let func_storage = FunctionStorage {
2699            fqn: Arc::from("test_fn"),
2700            short_name: Arc::from("test_fn"),
2701            params: Arc::from([]),
2702            return_type: None,
2703            inferred_return_type: Some(Union::int()),
2704            template_params: Vec::new(),
2705            assertions: Vec::new(),
2706            throws: Vec::new(),
2707            deprecated: None,
2708            is_pure: false,
2709            location: None,
2710        };
2711        let node = db.upsert_function_node(&func_storage);
2712
2713        // Query should return the inferred type
2714        let inferred = inferred_function_return_type(&db, node);
2715        assert_eq!(inferred.as_ref(), &Union::int());
2716    }
2717
2718    #[test]
2719    fn inferred_method_return_type_query_defined() {
2720        let mut db = MirDb::default();
2721
2722        // Create a simple method via MethodStorage
2723        let method_storage = MethodStorage {
2724            fqcn: Arc::from("TestClass"),
2725            name: Arc::from("testMethod"),
2726            params: Arc::from([]),
2727            return_type: None,
2728            inferred_return_type: Some(Union::string()),
2729            template_params: Vec::new(),
2730            assertions: Vec::new(),
2731            throws: Vec::new(),
2732            deprecated: None,
2733            visibility: Visibility::Public,
2734            is_static: false,
2735            is_abstract: false,
2736            is_final: false,
2737            is_constructor: false,
2738            is_pure: false,
2739            is_internal: false,
2740            location: None,
2741        };
2742        let node = db.upsert_method_node(&method_storage);
2743
2744        // Query should return the inferred type
2745        let inferred = inferred_method_return_type(&db, node);
2746        assert_eq!(inferred.as_ref(), &Union::string());
2747    }
2748
2749    #[test]
2750    fn collect_file_definitions_recomputes_on_change() {
2751        let mut db = MirDb::default();
2752        let file = SourceFile::new(
2753            &db,
2754            Arc::from("/tmp/memo2.php"),
2755            Arc::from("<?php class Foo {}"),
2756        );
2757
2758        let defs1 = collect_file_definitions(&db, file);
2759        file.set_text(&mut db)
2760            .to(Arc::from("<?php class Foo {} class Bar {}"));
2761        let defs2 = collect_file_definitions(&db, file);
2762
2763        assert!(
2764            !Arc::ptr_eq(&defs1.slice, &defs2.slice),
2765            "changed file must produce a new result"
2766        );
2767        assert_eq!(defs2.slice.classes.len(), 2);
2768    }
2769
2770    #[test]
2771    fn class_ancestors_empty_for_root_class() {
2772        let mut db = MirDb::default();
2773        let node = upsert_class(&mut db, "Foo", None, Arc::from([]), false);
2774        let ancestors = class_ancestors(&db, node);
2775        assert!(ancestors.0.is_empty(), "root class has no ancestors");
2776    }
2777
2778    #[test]
2779    fn class_ancestors_single_parent() {
2780        let mut db = MirDb::default();
2781        upsert_class(&mut db, "Base", None, Arc::from([]), false);
2782        let child = upsert_class(
2783            &mut db,
2784            "Child",
2785            Some(Arc::from("Base")),
2786            Arc::from([]),
2787            false,
2788        );
2789        let ancestors = class_ancestors(&db, child);
2790        assert_eq!(ancestors.0.len(), 1);
2791        assert_eq!(ancestors.0[0].as_ref(), "Base");
2792    }
2793
2794    #[test]
2795    fn class_ancestors_transitive() {
2796        let mut db = MirDb::default();
2797        upsert_class(&mut db, "GrandParent", None, Arc::from([]), false);
2798        upsert_class(
2799            &mut db,
2800            "Parent",
2801            Some(Arc::from("GrandParent")),
2802            Arc::from([]),
2803            false,
2804        );
2805        let child = upsert_class(
2806            &mut db,
2807            "Child",
2808            Some(Arc::from("Parent")),
2809            Arc::from([]),
2810            false,
2811        );
2812        let ancestors = class_ancestors(&db, child);
2813        assert_eq!(ancestors.0.len(), 2);
2814        assert_eq!(ancestors.0[0].as_ref(), "Parent");
2815        assert_eq!(ancestors.0[1].as_ref(), "GrandParent");
2816    }
2817
2818    #[test]
2819    fn class_ancestors_cycle_returns_empty() {
2820        let mut db = MirDb::default();
2821        // A extends A — not valid PHP, but we must not panic.
2822        let node_a = upsert_class(&mut db, "A", Some(Arc::from("A")), Arc::from([]), false);
2823        let ancestors = class_ancestors(&db, node_a);
2824        // Cycle recovery: empty list (A's ancestors exclude itself).
2825        assert!(ancestors.0.is_empty(), "cycle must yield empty ancestors");
2826    }
2827
2828    #[test]
2829    fn class_ancestors_inactive_node_returns_empty() {
2830        let mut db = MirDb::default();
2831        let node = upsert_class(&mut db, "Foo", None, Arc::from([]), false);
2832        db.deactivate_class_node("Foo");
2833        let ancestors = class_ancestors(&db, node);
2834        assert!(ancestors.0.is_empty(), "inactive node must yield empty");
2835    }
2836
2837    #[test]
2838    fn class_ancestors_recomputes_on_parent_change() {
2839        let mut db = MirDb::default();
2840        upsert_class(&mut db, "Base", None, Arc::from([]), false);
2841        let child = upsert_class(&mut db, "Child", None, Arc::from([]), false);
2842
2843        let before = class_ancestors(&db, child);
2844        assert!(before.0.is_empty());
2845
2846        // Add Base as parent of Child.
2847        child.set_parent(&mut db).to(Some(Arc::from("Base")));
2848
2849        let after = class_ancestors(&db, child);
2850        assert_eq!(after.0.len(), 1);
2851        assert_eq!(after.0[0].as_ref(), "Base");
2852    }
2853
2854    #[test]
2855    fn interface_ancestors_via_extends() {
2856        let mut db = MirDb::default();
2857        upsert_class(&mut db, "Countable", None, Arc::from([]), true);
2858        let child_iface = upsert_class(
2859            &mut db,
2860            "Collection",
2861            None,
2862            Arc::from([Arc::from("Countable")]),
2863            true,
2864        );
2865        let ancestors = class_ancestors(&db, child_iface);
2866        assert_eq!(ancestors.0.len(), 1);
2867        assert_eq!(ancestors.0[0].as_ref(), "Countable");
2868    }
2869
2870    #[test]
2871    fn type_exists_via_db_tracks_active_state() {
2872        let mut db = MirDb::default();
2873        upsert_class(&mut db, "Foo", None, Arc::from([]), false);
2874        assert!(type_exists_via_db(&db, "Foo"));
2875        assert!(!type_exists_via_db(&db, "Bar"));
2876        db.deactivate_class_node("Foo");
2877        assert!(!type_exists_via_db(&db, "Foo"));
2878    }
2879
2880    #[test]
2881    fn clone_preserves_class_node_lookups() {
2882        // PR10a: each parallel batch worker gets its own MirDb clone.
2883        // Verify the clone observes the same registered nodes.
2884        let mut db = MirDb::default();
2885        upsert_class(&mut db, "Foo", None, Arc::from([]), false);
2886        let cloned = db.clone();
2887        assert!(
2888            type_exists_via_db(&cloned, "Foo"),
2889            "clone must observe nodes registered before clone()"
2890        );
2891        assert!(
2892            !type_exists_via_db(&cloned, "Bar"),
2893            "clone must not observe nodes that were never registered"
2894        );
2895        // Clones must also resolve ancestors through the same shared storage.
2896        let foo_node = cloned.lookup_class_node("Foo").expect("registered");
2897        let ancestors = class_ancestors(&cloned, foo_node);
2898        assert!(ancestors.0.is_empty(), "Foo has no ancestors");
2899    }
2900
2901    // -----------------------------------------------------------------
2902    // Helpers for method-related fixtures
2903    // -----------------------------------------------------------------
2904
2905    fn upsert_class_with_traits(
2906        db: &mut MirDb,
2907        fqcn: &str,
2908        parent: Option<Arc<str>>,
2909        traits: &[&str],
2910        is_interface: bool,
2911        is_trait: bool,
2912    ) -> ClassNode {
2913        db.upsert_class_node(ClassNodeFields {
2914            is_interface,
2915            is_trait,
2916            parent,
2917            traits: Arc::from(
2918                traits
2919                    .iter()
2920                    .map(|t| Arc::<str>::from(*t))
2921                    .collect::<Vec<_>>(),
2922            ),
2923            ..ClassNodeFields::for_class(Arc::from(fqcn))
2924        })
2925    }
2926
2927    fn upsert_method(db: &mut MirDb, fqcn: &str, name: &str, is_abstract: bool) -> MethodNode {
2928        let storage = MethodStorage {
2929            name: Arc::from(name),
2930            fqcn: Arc::from(fqcn),
2931            params: Arc::from([].as_slice()),
2932            return_type: None,
2933            inferred_return_type: None,
2934            visibility: Visibility::Public,
2935            is_static: false,
2936            is_abstract,
2937            is_final: false,
2938            is_constructor: name == "__construct",
2939            template_params: vec![],
2940            assertions: vec![],
2941            throws: vec![],
2942            deprecated: None,
2943            is_internal: false,
2944            is_pure: false,
2945            location: None,
2946        };
2947        db.upsert_method_node(&storage)
2948    }
2949
2950    fn upsert_enum(db: &mut MirDb, fqcn: &str, interfaces: &[&str], is_backed: bool) -> ClassNode {
2951        db.upsert_class_node(ClassNodeFields {
2952            interfaces: Arc::from(
2953                interfaces
2954                    .iter()
2955                    .map(|i| Arc::<str>::from(*i))
2956                    .collect::<Vec<_>>(),
2957            ),
2958            is_backed_enum: is_backed,
2959            ..ClassNodeFields::for_enum(Arc::from(fqcn))
2960        })
2961    }
2962
2963    // -----------------------------------------------------------------
2964    // method_exists_via_db
2965    // -----------------------------------------------------------------
2966
2967    #[test]
2968    fn method_exists_via_db_finds_own_method() {
2969        let mut db = MirDb::default();
2970        upsert_class(&mut db, "Foo", None, Arc::from([]), false);
2971        upsert_method(&mut db, "Foo", "bar", false);
2972        assert!(method_exists_via_db(&db, "Foo", "bar"));
2973        assert!(!method_exists_via_db(&db, "Foo", "missing"));
2974    }
2975
2976    #[test]
2977    fn method_exists_via_db_walks_parent() {
2978        let mut db = MirDb::default();
2979        upsert_class(&mut db, "Base", None, Arc::from([]), false);
2980        upsert_method(&mut db, "Base", "inherited", false);
2981        upsert_class(
2982            &mut db,
2983            "Child",
2984            Some(Arc::from("Base")),
2985            Arc::from([]),
2986            false,
2987        );
2988        assert!(method_exists_via_db(&db, "Child", "inherited"));
2989    }
2990
2991    #[test]
2992    fn method_exists_via_db_walks_traits_transitively() {
2993        let mut db = MirDb::default();
2994        upsert_class_with_traits(&mut db, "InnerTrait", None, &[], false, true);
2995        upsert_method(&mut db, "InnerTrait", "deep_trait_method", false);
2996        upsert_class_with_traits(&mut db, "OuterTrait", None, &["InnerTrait"], false, true);
2997        upsert_class_with_traits(&mut db, "Foo", None, &["OuterTrait"], false, false);
2998        assert!(method_exists_via_db(&db, "Foo", "deep_trait_method"));
2999    }
3000
3001    #[test]
3002    fn method_exists_via_db_is_case_insensitive() {
3003        let mut db = MirDb::default();
3004        upsert_class(&mut db, "Foo", None, Arc::from([]), false);
3005        upsert_method(&mut db, "Foo", "doStuff", false);
3006        // Stored with original case; lookup must lowercase internally.
3007        assert!(method_exists_via_db(&db, "Foo", "DoStuff"));
3008        assert!(method_exists_via_db(&db, "Foo", "DOSTUFF"));
3009    }
3010
3011    #[test]
3012    fn method_exists_via_db_unknown_class_returns_false() {
3013        let db = MirDb::default();
3014        assert!(!method_exists_via_db(&db, "Nope", "anything"));
3015    }
3016
3017    #[test]
3018    fn method_exists_via_db_inactive_class_returns_false() {
3019        let mut db = MirDb::default();
3020        upsert_class(&mut db, "Foo", None, Arc::from([]), false);
3021        upsert_method(&mut db, "Foo", "bar", false);
3022        db.deactivate_class_node("Foo");
3023        assert!(!method_exists_via_db(&db, "Foo", "bar"));
3024    }
3025
3026    #[test]
3027    fn method_exists_via_db_finds_abstract_methods() {
3028        // Existence-only: abstracts count.  This is the difference vs.
3029        // method_is_concretely_implemented.
3030        let mut db = MirDb::default();
3031        upsert_class(&mut db, "Foo", None, Arc::from([]), false);
3032        upsert_method(&mut db, "Foo", "abstr", true);
3033        assert!(method_exists_via_db(&db, "Foo", "abstr"));
3034    }
3035
3036    // -----------------------------------------------------------------
3037    // method_is_concretely_implemented
3038    // -----------------------------------------------------------------
3039
3040    #[test]
3041    fn method_is_concretely_implemented_skips_abstract() {
3042        let mut db = MirDb::default();
3043        upsert_class(&mut db, "Foo", None, Arc::from([]), false);
3044        upsert_method(&mut db, "Foo", "abstr", true);
3045        assert!(!method_is_concretely_implemented(&db, "Foo", "abstr"));
3046    }
3047
3048    #[test]
3049    fn method_is_concretely_implemented_finds_concrete_in_trait() {
3050        let mut db = MirDb::default();
3051        upsert_class_with_traits(&mut db, "MyTrait", None, &[], false, true);
3052        upsert_method(&mut db, "MyTrait", "provided", false);
3053        upsert_class_with_traits(&mut db, "Foo", None, &["MyTrait"], false, false);
3054        assert!(method_is_concretely_implemented(&db, "Foo", "provided"));
3055    }
3056
3057    #[test]
3058    fn method_is_concretely_implemented_skips_interface_definitions() {
3059        // Interfaces don't supply implementations, regardless of how
3060        // their methods are stored.
3061        let mut db = MirDb::default();
3062        upsert_class(&mut db, "I", None, Arc::from([]), true);
3063        upsert_method(&mut db, "I", "m", false);
3064        upsert_class(&mut db, "C", None, Arc::from([Arc::from("I")]), false);
3065        // C "implements" I but has no own implementation.
3066        assert!(!method_is_concretely_implemented(&db, "C", "m"));
3067    }
3068
3069    // -----------------------------------------------------------------
3070    // extends_or_implements_via_db
3071    // -----------------------------------------------------------------
3072
3073    #[test]
3074    fn extends_or_implements_via_db_self_match() {
3075        let mut db = MirDb::default();
3076        upsert_class(&mut db, "Foo", None, Arc::from([]), false);
3077        assert!(extends_or_implements_via_db(&db, "Foo", "Foo"));
3078    }
3079
3080    #[test]
3081    fn extends_or_implements_via_db_transitive() {
3082        let mut db = MirDb::default();
3083        upsert_class(&mut db, "Animal", None, Arc::from([]), false);
3084        upsert_class(
3085            &mut db,
3086            "Mammal",
3087            Some(Arc::from("Animal")),
3088            Arc::from([]),
3089            false,
3090        );
3091        upsert_class(
3092            &mut db,
3093            "Dog",
3094            Some(Arc::from("Mammal")),
3095            Arc::from([]),
3096            false,
3097        );
3098        assert!(extends_or_implements_via_db(&db, "Dog", "Animal"));
3099        assert!(extends_or_implements_via_db(&db, "Dog", "Mammal"));
3100        assert!(!extends_or_implements_via_db(&db, "Animal", "Dog"));
3101    }
3102
3103    #[test]
3104    fn extends_or_implements_via_db_unknown_returns_false() {
3105        let db = MirDb::default();
3106        assert!(!extends_or_implements_via_db(&db, "Nope", "Foo"));
3107    }
3108
3109    #[test]
3110    fn extends_or_implements_via_db_unit_enum_implicit() {
3111        let mut db = MirDb::default();
3112        upsert_enum(&mut db, "Status", &[], false);
3113        assert!(extends_or_implements_via_db(&db, "Status", "UnitEnum"));
3114        assert!(extends_or_implements_via_db(&db, "Status", "\\UnitEnum"));
3115        // Pure enum is NOT a BackedEnum.
3116        assert!(!extends_or_implements_via_db(&db, "Status", "BackedEnum"));
3117    }
3118
3119    #[test]
3120    fn extends_or_implements_via_db_backed_enum_implicit() {
3121        let mut db = MirDb::default();
3122        upsert_enum(&mut db, "Status", &[], true);
3123        assert!(extends_or_implements_via_db(&db, "Status", "UnitEnum"));
3124        assert!(extends_or_implements_via_db(&db, "Status", "BackedEnum"));
3125        assert!(extends_or_implements_via_db(&db, "Status", "\\BackedEnum"));
3126    }
3127
3128    #[test]
3129    fn extends_or_implements_via_db_enum_declared_interface() {
3130        let mut db = MirDb::default();
3131        upsert_class(&mut db, "Stringable", None, Arc::from([]), true);
3132        upsert_enum(&mut db, "Status", &["Stringable"], false);
3133        assert!(extends_or_implements_via_db(&db, "Status", "Stringable"));
3134    }
3135
3136    // -----------------------------------------------------------------
3137    // has_unknown_ancestor_via_db
3138    // -----------------------------------------------------------------
3139
3140    #[test]
3141    fn has_unknown_ancestor_via_db_clean_chain_returns_false() {
3142        let mut db = MirDb::default();
3143        upsert_class(&mut db, "Base", None, Arc::from([]), false);
3144        upsert_class(
3145            &mut db,
3146            "Child",
3147            Some(Arc::from("Base")),
3148            Arc::from([]),
3149            false,
3150        );
3151        assert!(!has_unknown_ancestor_via_db(&db, "Child"));
3152    }
3153
3154    #[test]
3155    fn has_unknown_ancestor_via_db_missing_parent_returns_true() {
3156        let mut db = MirDb::default();
3157        // Child claims to extend Missing, but Missing isn't registered.
3158        upsert_class(
3159            &mut db,
3160            "Child",
3161            Some(Arc::from("Missing")),
3162            Arc::from([]),
3163            false,
3164        );
3165        assert!(has_unknown_ancestor_via_db(&db, "Child"));
3166    }
3167
3168    #[test]
3169    fn class_template_params_via_db_returns_registered_params() {
3170        use mir_types::Variance;
3171        let mut db = MirDb::default();
3172        let tp = TemplateParam {
3173            name: Arc::from("T"),
3174            bound: None,
3175            defining_entity: Arc::from("Box"),
3176            variance: Variance::Invariant,
3177        };
3178        db.upsert_class_node(ClassNodeFields {
3179            template_params: Arc::from([tp.clone()]),
3180            ..ClassNodeFields::for_class(Arc::from("Box"))
3181        });
3182        let got = class_template_params_via_db(&db, "Box").expect("registered");
3183        assert_eq!(got.len(), 1);
3184        assert_eq!(got[0].name.as_ref(), "T");
3185
3186        assert!(class_template_params_via_db(&db, "Missing").is_none());
3187        db.deactivate_class_node("Box");
3188        assert!(class_template_params_via_db(&db, "Box").is_none());
3189    }
3190
3191    // -----------------------------------------------------------------
3192    // lookup_method_in_chain
3193    // -----------------------------------------------------------------
3194
3195    fn upsert_class_with_mixins(
3196        db: &mut MirDb,
3197        fqcn: &str,
3198        parent: Option<Arc<str>>,
3199        mixins: &[&str],
3200    ) -> ClassNode {
3201        db.upsert_class_node(ClassNodeFields {
3202            parent,
3203            mixins: Arc::from(
3204                mixins
3205                    .iter()
3206                    .map(|m| Arc::<str>::from(*m))
3207                    .collect::<Vec<_>>(),
3208            ),
3209            ..ClassNodeFields::for_class(Arc::from(fqcn))
3210        })
3211    }
3212
3213    #[test]
3214    fn lookup_method_in_chain_finds_own_then_ancestor() {
3215        let mut db = MirDb::default();
3216        upsert_class(&mut db, "Base", None, Arc::from([]), false);
3217        upsert_method(&mut db, "Base", "shared", false);
3218        upsert_class(
3219            &mut db,
3220            "Child",
3221            Some(Arc::from("Base")),
3222            Arc::from([]),
3223            false,
3224        );
3225        upsert_method(&mut db, "Child", "shared", false);
3226        // Own wins over ancestor.
3227        let found = lookup_method_in_chain(&db, "Child", "shared").expect("own");
3228        assert_eq!(found.fqcn(&db).as_ref(), "Child");
3229        // Inherited-only resolves to ancestor.
3230        upsert_method(&mut db, "Base", "only_in_base", false);
3231        let found = lookup_method_in_chain(&db, "Child", "only_in_base").expect("ancestor");
3232        assert_eq!(found.fqcn(&db).as_ref(), "Base");
3233    }
3234
3235    #[test]
3236    fn lookup_method_in_chain_walks_trait_of_traits() {
3237        let mut db = MirDb::default();
3238        upsert_class_with_traits(&mut db, "InnerTrait", None, &[], false, true);
3239        upsert_method(&mut db, "InnerTrait", "deep", false);
3240        upsert_class_with_traits(&mut db, "OuterTrait", None, &["InnerTrait"], false, true);
3241        upsert_class_with_traits(&mut db, "Foo", None, &["OuterTrait"], false, false);
3242        let found = lookup_method_in_chain(&db, "Foo", "deep").expect("transitive trait");
3243        assert_eq!(found.fqcn(&db).as_ref(), "InnerTrait");
3244    }
3245
3246    #[test]
3247    fn lookup_method_in_chain_walks_mixins() {
3248        let mut db = MirDb::default();
3249        upsert_class(&mut db, "MixinTarget", None, Arc::from([]), false);
3250        upsert_method(&mut db, "MixinTarget", "magic", false);
3251        upsert_class_with_mixins(&mut db, "Host", None, &["MixinTarget"]);
3252        let found = lookup_method_in_chain(&db, "Host", "magic").expect("via @mixin");
3253        assert_eq!(found.fqcn(&db).as_ref(), "MixinTarget");
3254    }
3255
3256    #[test]
3257    fn lookup_method_in_chain_mixin_cycle_does_not_hang() {
3258        let mut db = MirDb::default();
3259        // A → B → A (mutual @mixin); neither defines the method.
3260        upsert_class_with_mixins(&mut db, "A", None, &["B"]);
3261        upsert_class_with_mixins(&mut db, "B", None, &["A"]);
3262        assert!(lookup_method_in_chain(&db, "A", "missing").is_none());
3263    }
3264
3265    #[test]
3266    fn lookup_method_in_chain_is_case_insensitive() {
3267        let mut db = MirDb::default();
3268        upsert_class(&mut db, "Foo", None, Arc::from([]), false);
3269        upsert_method(&mut db, "Foo", "doStuff", false);
3270        assert!(lookup_method_in_chain(&db, "Foo", "DOSTUFF").is_some());
3271        assert!(lookup_method_in_chain(&db, "Foo", "dostuff").is_some());
3272    }
3273
3274    #[test]
3275    fn lookup_method_in_chain_unknown_returns_none() {
3276        let db = MirDb::default();
3277        assert!(lookup_method_in_chain(&db, "Nope", "anything").is_none());
3278    }
3279
3280    // -----------------------------------------------------------------
3281    // lookup_property_in_chain
3282    // -----------------------------------------------------------------
3283
3284    fn upsert_property(db: &mut MirDb, fqcn: &str, name: &str, is_readonly: bool) -> PropertyNode {
3285        let storage = PropertyStorage {
3286            name: Arc::from(name),
3287            ty: None,
3288            inferred_ty: None,
3289            visibility: Visibility::Public,
3290            is_static: false,
3291            is_readonly,
3292            default: None,
3293            location: None,
3294        };
3295        let owner = Arc::<str>::from(fqcn);
3296        db.upsert_property_node(&owner, &storage);
3297        db.lookup_property_node(fqcn, name).expect("registered")
3298    }
3299
3300    #[test]
3301    fn lookup_property_in_chain_own_then_ancestor() {
3302        let mut db = MirDb::default();
3303        upsert_class(&mut db, "Base", None, Arc::from([]), false);
3304        upsert_property(&mut db, "Base", "x", false);
3305        upsert_class(
3306            &mut db,
3307            "Child",
3308            Some(Arc::from("Base")),
3309            Arc::from([]),
3310            false,
3311        );
3312        // Inherited resolves to Base.
3313        let found = lookup_property_in_chain(&db, "Child", "x").expect("ancestor");
3314        assert_eq!(found.fqcn(&db).as_ref(), "Base");
3315        // Own override wins.
3316        upsert_property(&mut db, "Child", "x", true);
3317        let found = lookup_property_in_chain(&db, "Child", "x").expect("own");
3318        assert_eq!(found.fqcn(&db).as_ref(), "Child");
3319        assert!(found.is_readonly(&db));
3320    }
3321
3322    #[test]
3323    fn lookup_property_in_chain_walks_mixins() {
3324        let mut db = MirDb::default();
3325        upsert_class(&mut db, "MixinTarget", None, Arc::from([]), false);
3326        upsert_property(&mut db, "MixinTarget", "exposed", false);
3327        upsert_class_with_mixins(&mut db, "Host", None, &["MixinTarget"]);
3328        let found = lookup_property_in_chain(&db, "Host", "exposed").expect("via @mixin");
3329        assert_eq!(found.fqcn(&db).as_ref(), "MixinTarget");
3330    }
3331
3332    #[test]
3333    fn lookup_property_in_chain_mixin_cycle_does_not_hang() {
3334        let mut db = MirDb::default();
3335        upsert_class_with_mixins(&mut db, "A", None, &["B"]);
3336        upsert_class_with_mixins(&mut db, "B", None, &["A"]);
3337        assert!(lookup_property_in_chain(&db, "A", "missing").is_none());
3338    }
3339
3340    #[test]
3341    fn lookup_property_in_chain_is_case_sensitive() {
3342        let mut db = MirDb::default();
3343        upsert_class(&mut db, "Foo", None, Arc::from([]), false);
3344        upsert_property(&mut db, "Foo", "myProp", false);
3345        assert!(lookup_property_in_chain(&db, "Foo", "myProp").is_some());
3346        // Property names are case-sensitive in PHP.
3347        assert!(lookup_property_in_chain(&db, "Foo", "MyProp").is_none());
3348    }
3349
3350    #[test]
3351    fn lookup_property_in_chain_inactive_returns_none() {
3352        let mut db = MirDb::default();
3353        upsert_class(&mut db, "Foo", None, Arc::from([]), false);
3354        upsert_property(&mut db, "Foo", "x", false);
3355        db.deactivate_class_node("Foo");
3356        assert!(lookup_property_in_chain(&db, "Foo", "x").is_none());
3357    }
3358
3359    // -----------------------------------------------------------------
3360    // class_constant_exists_in_chain
3361    // -----------------------------------------------------------------
3362
3363    fn upsert_constant(db: &mut MirDb, fqcn: &str, name: &str) {
3364        let storage = ConstantStorage {
3365            name: Arc::from(name),
3366            ty: mir_types::Union::mixed(),
3367            visibility: None,
3368            is_final: false,
3369            location: None,
3370        };
3371        let owner = Arc::<str>::from(fqcn);
3372        db.upsert_class_constant_node(&owner, &storage);
3373    }
3374
3375    #[test]
3376    fn class_constant_exists_in_chain_finds_own() {
3377        let mut db = MirDb::default();
3378        upsert_class(&mut db, "Foo", None, Arc::from([]), false);
3379        upsert_constant(&mut db, "Foo", "MAX");
3380        assert!(class_constant_exists_in_chain(&db, "Foo", "MAX"));
3381        assert!(!class_constant_exists_in_chain(&db, "Foo", "MIN"));
3382    }
3383
3384    #[test]
3385    fn class_constant_exists_in_chain_walks_parent() {
3386        let mut db = MirDb::default();
3387        upsert_class(&mut db, "Base", None, Arc::from([]), false);
3388        upsert_constant(&mut db, "Base", "VERSION");
3389        upsert_class(
3390            &mut db,
3391            "Child",
3392            Some(Arc::from("Base")),
3393            Arc::from([]),
3394            false,
3395        );
3396        assert!(class_constant_exists_in_chain(&db, "Child", "VERSION"));
3397    }
3398
3399    #[test]
3400    fn class_constant_exists_in_chain_walks_interface() {
3401        let mut db = MirDb::default();
3402        upsert_class(&mut db, "I", None, Arc::from([]), true);
3403        upsert_constant(&mut db, "I", "TYPE");
3404        // A class that implements I — interfaces go in the `interfaces`
3405        // slot, not the `extends` slot which is interface-only.
3406        db.upsert_class_node(ClassNodeFields {
3407            interfaces: Arc::from([Arc::from("I")]),
3408            ..ClassNodeFields::for_class(Arc::from("Impl"))
3409        });
3410        assert!(class_constant_exists_in_chain(&db, "Impl", "TYPE"));
3411    }
3412
3413    #[test]
3414    fn class_constant_exists_in_chain_walks_direct_trait() {
3415        let mut db = MirDb::default();
3416        upsert_class_with_traits(&mut db, "T", None, &[], false, true);
3417        upsert_constant(&mut db, "T", "FROM_TRAIT");
3418        upsert_class_with_traits(&mut db, "Foo", None, &["T"], false, false);
3419        assert!(class_constant_exists_in_chain(&db, "Foo", "FROM_TRAIT"));
3420    }
3421
3422    #[test]
3423    fn class_constant_exists_in_chain_unknown_class_returns_false() {
3424        let db = MirDb::default();
3425        assert!(!class_constant_exists_in_chain(&db, "Nope", "ANY"));
3426    }
3427
3428    #[test]
3429    fn class_constant_exists_in_chain_inactive_returns_false() {
3430        let mut db = MirDb::default();
3431        upsert_class(&mut db, "Foo", None, Arc::from([]), false);
3432        upsert_constant(&mut db, "Foo", "X");
3433        db.deactivate_class_node("Foo");
3434        db.deactivate_class_constants("Foo");
3435        assert!(!class_constant_exists_in_chain(&db, "Foo", "X"));
3436    }
3437
3438    /// Validates the S3-deadlock premise.  After `for_each_with` returns,
3439    /// all worker clones must drop so that a subsequent setter on the
3440    /// canonical db (strong-count==1) does not block on
3441    /// `Storage::cancel_others`.  Wrapped in a join-with-timeout so a
3442    /// regression hangs for at most 30s instead of forever.
3443    #[test]
3444    fn parallel_reads_then_serial_write_does_not_deadlock() {
3445        use rayon::prelude::*;
3446        use std::sync::mpsc;
3447        use std::time::Duration;
3448
3449        let (tx, rx) = mpsc::channel::<()>();
3450        std::thread::spawn(move || {
3451            let mut db = MirDb::default();
3452            let storage = mir_codebase::storage::FunctionStorage {
3453                fqn: Arc::from("foo"),
3454                short_name: Arc::from("foo"),
3455                params: Arc::from([].as_slice()),
3456                return_type: None,
3457                inferred_return_type: None,
3458                template_params: vec![],
3459                assertions: vec![],
3460                throws: vec![],
3461                deprecated: None,
3462                is_pure: false,
3463                location: None,
3464            };
3465            let node = db.upsert_function_node(&storage);
3466
3467            // Parallel sweep with cloned dbs; each worker reads via &dyn MirDatabase.
3468            let db_for_sweep = db.clone();
3469            (0..256u32)
3470                .into_par_iter()
3471                .for_each_with(db_for_sweep, |db, _| {
3472                    let _ = node.return_type(&*db as &dyn MirDatabase);
3473                });
3474
3475            // Sweep is done — clones owned by `for_each_with` are dropped.
3476            // If any worker-thread retains thread-local Salsa state pointing
3477            // at a clone, this setter will hang in `Storage::cancel_others`.
3478            node.set_return_type(&mut db)
3479                .to(Some(Arc::new(Union::mixed())));
3480            assert_eq!(node.return_type(&db), Some(Arc::new(Union::mixed())));
3481            tx.send(()).unwrap();
3482        });
3483
3484        match rx.recv_timeout(Duration::from_secs(30)) {
3485            Ok(()) => {}
3486            Err(_) => {
3487                panic!("S3 deadlock repro: setter after for_each_with did not return within 30s")
3488            }
3489        }
3490    }
3491
3492    /// Pins the actual root cause of the original S3 deadlock: a sibling
3493    /// `MirDb` clone (e.g. the `class_db` used by `ClassAnalyzer` in
3494    /// `project.rs`) being alive when a setter runs blocks
3495    /// `Storage::cancel_others` indefinitely.  Dropping the sibling before
3496    /// the setter unblocks it.
3497    ///
3498    /// This is the regression guard for `commit_inferred_return_types`: if
3499    /// a future refactor hoists a clone past the commit point, this test
3500    /// fails (either the "while sibling alive, setter is blocked" half
3501    /// or the "after drop, setter completes" half).
3502    #[test]
3503    fn sibling_clone_blocks_setter_until_dropped() {
3504        use std::sync::mpsc;
3505        use std::time::Duration;
3506
3507        let mut db = MirDb::default();
3508        let storage = mir_codebase::storage::FunctionStorage {
3509            fqn: Arc::from("foo"),
3510            short_name: Arc::from("foo"),
3511            params: Arc::from([].as_slice()),
3512            return_type: None,
3513            inferred_return_type: None,
3514            template_params: vec![],
3515            assertions: vec![],
3516            throws: vec![],
3517            deprecated: None,
3518            is_pure: false,
3519            location: None,
3520        };
3521        let node = db.upsert_function_node(&storage);
3522
3523        let sibling = db.clone();
3524
3525        // Move the writer into a worker thread so we can probe its progress
3526        // without blocking the test.  Channel signals when the setter returns.
3527        let (tx, rx) = mpsc::channel::<()>();
3528        let writer = std::thread::spawn(move || {
3529            node.set_return_type(&mut db)
3530                .to(Some(Arc::new(Union::mixed())));
3531            tx.send(()).unwrap();
3532        });
3533
3534        // While the sibling clone is alive the setter must NOT make progress —
3535        // strong-count > 1 forces `cancel_others` to wait.
3536        match rx.recv_timeout(Duration::from_millis(500)) {
3537            Err(mpsc::RecvTimeoutError::Timeout) => { /* expected */ }
3538            Ok(()) => panic!(
3539                "setter completed while sibling clone was alive — strong-count==1 \
3540                 invariant of `cancel_others` is broken; commit_inferred_return_types \
3541                 cannot rely on tight-scoping clones"
3542            ),
3543            Err(e) => panic!("unexpected channel error: {e:?}"),
3544        }
3545
3546        // Drop the sibling.  Strong-count drops to 1 and the setter unblocks.
3547        drop(sibling);
3548
3549        match rx.recv_timeout(Duration::from_secs(5)) {
3550            Ok(()) => {}
3551            Err(_) => panic!("setter did not complete within 5s after sibling clone dropped"),
3552        }
3553        writer.join().expect("writer thread panicked");
3554    }
3555}