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