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